建立无状态 worker reconcile 边界

This commit is contained in:
2026-09-20 18:11:57 +00:00
parent 3f336310af
commit 22dc3fef63
5 changed files with 247 additions and 3 deletions
+129
View File
@@ -0,0 +1,129 @@
// Package taskworker reconciles assigned Gitea tasks against an executor backend.
package taskworker
import (
"context"
"errors"
"strconv"
"git.ddupan.top/panxiao81/gitea-dynamic-runner/internal/taskidentity"
"git.ddupan.top/panxiao81/gitea-dynamic-runner/internal/taskscheduler"
)
// Phase is observed from Kubernetes or OpenSandbox, never stored by the worker.
type Phase string
const (
PhasePending Phase = "pending"
PhaseRunning Phase = "running"
PhaseSucceeded Phase = "succeeded"
PhaseFailed Phase = "failed"
)
// Executor is the backend resource discovered by assignment ID.
type Executor struct {
Name string
IdentityTarget string
Phase Phase
}
// Metadata is persisted on the backend resource. Labels remain query-safe;
// annotations retain complete identity context.
type Metadata struct {
Labels map[string]string
Annotations map[string]string
}
// Backend is implemented by the native Pod and OpenSandbox adapters.
// Every method must be idempotent.
type Backend interface {
Find(context.Context, string) (*Executor, error)
Create(context.Context, taskscheduler.Assignment, Metadata) (*Executor, error)
BindIdentity(context.Context, *Executor, taskidentity.Identity) error
Delete(context.Context, *Executor) error
}
// TaskState uses Gitea as the authority for whether an assigned task has
// already reached a terminal state.
type TaskState interface {
Terminal(context.Context, int64) (bool, error)
Report(context.Context, int64, Phase) error
}
// Worker has no correctness-critical in-memory state. Handle may be called
// again for the same assignment after any operation.
type Worker struct {
Backend Backend
Tasks TaskState
}
// Handle performs one reconciliation. Done means the queue message may be
// acknowledged. A false result should remain pending and be reconciled again.
func (w Worker) Handle(ctx context.Context, assignment taskscheduler.Assignment) (done bool, err error) {
if w.Backend == nil || w.Tasks == nil {
return false, errors.New("backend and Gitea task state are required")
}
if assignment.ID == "" || assignment.Task == nil {
return false, errors.New("valid assignment is required")
}
terminal, err := w.Tasks.Terminal(ctx, assignment.Task.GetId())
if err != nil {
return false, err
}
executor, err := w.Backend.Find(ctx, assignment.ID)
if err != nil {
return false, err
}
if terminal {
if executor != nil {
if err := w.Backend.Delete(ctx, executor); err != nil {
return false, err
}
}
return true, nil
}
if executor == nil {
executor, err = w.Backend.Create(ctx, assignment, BackendMetadata(assignment))
if err != nil {
return false, err
}
}
if executor.IdentityTarget == "" {
return false, nil
}
if err := w.Backend.BindIdentity(ctx, executor, assignment.Identity); err != nil {
return false, err
}
switch executor.Phase {
case PhaseSucceeded, PhaseFailed:
// Reporting first closes the delete-before-ack crash window: after a
// restart Gitea prevents this assignment from executing a second time.
if err := w.Tasks.Report(ctx, assignment.Task.GetId(), executor.Phase); err != nil {
return false, err
}
if err := w.Backend.Delete(ctx, executor); err != nil {
return false, err
}
return true, nil
default:
return false, nil
}
}
// BackendMetadata is the shared metadata contract for Pods and OpenSandbox.
func BackendMetadata(assignment taskscheduler.Assignment) Metadata {
return Metadata{
Labels: map[string]string{
"ci.ddupan.top/assignment-id": assignment.ID,
"ci.ddupan.top/task-id": strconv.FormatInt(assignment.Task.GetId(), 10),
},
Annotations: map[string]string{
"ci.ddupan.top/repository": assignment.Identity.Repository,
"ci.ddupan.top/job-key": assignment.Identity.Task,
"ci.ddupan.top/spiffe-id": assignment.Identity.SPIFFEID,
},
}
}
+102
View File
@@ -0,0 +1,102 @@
package taskworker
import (
"context"
"testing"
runnerv1 "gitea.dev/actionslib/runner/v1"
"git.ddupan.top/panxiao81/gitea-dynamic-runner/internal/taskidentity"
"git.ddupan.top/panxiao81/gitea-dynamic-runner/internal/taskscheduler"
)
type fakeBackend struct {
executor *Executor
created int
bound int
deleted int
}
func (b *fakeBackend) Find(context.Context, string) (*Executor, error) { return b.executor, nil }
func (b *fakeBackend) Create(_ context.Context, _ taskscheduler.Assignment, _ Metadata) (*Executor, error) {
b.created++
b.executor = &Executor{Name: "executor", IdentityTarget: "pod-uid", Phase: PhaseRunning}
return b.executor, nil
}
func (b *fakeBackend) BindIdentity(context.Context, *Executor, taskidentity.Identity) error {
b.bound++
return nil
}
func (b *fakeBackend) Delete(context.Context, *Executor) error {
b.deleted++
b.executor = nil
return nil
}
type fakeTasks struct {
terminal bool
reported []Phase
}
func (t *fakeTasks) Terminal(context.Context, int64) (bool, error) { return t.terminal, nil }
func (t *fakeTasks) Report(_ context.Context, _ int64, phase Phase) error {
t.reported = append(t.reported, phase)
t.terminal = true
return nil
}
func assignment() taskscheduler.Assignment {
return taskscheduler.Assignment{
ID: "gitea-task-42",
Task: &runnerv1.Task{Id: 42},
Identity: taskidentity.Identity{
Repository: "owner/repo",
Task: "publish",
SPIFFEID: "spiffe://ddupan.top/ci/owner/repo/publish",
},
}
}
func TestHandleRecoversExistingExecutorWithoutCreatingAnother(t *testing.T) {
backend := &fakeBackend{executor: &Executor{Name: "existing", IdentityTarget: "uid", Phase: PhaseRunning}}
worker := Worker{Backend: backend, Tasks: &fakeTasks{}}
done, err := worker.Handle(context.Background(), assignment())
if err != nil || done {
t.Fatalf("Handle() = (%v, %v), want pending", done, err)
}
if backend.created != 0 || backend.bound != 1 {
t.Fatalf("created=%d bound=%d", backend.created, backend.bound)
}
}
func TestHandleReportsBeforeCleanupAndBecomesRecoverable(t *testing.T) {
backend := &fakeBackend{executor: &Executor{Name: "finished", IdentityTarget: "uid", Phase: PhaseSucceeded}}
tasks := &fakeTasks{}
worker := Worker{Backend: backend, Tasks: tasks}
done, err := worker.Handle(context.Background(), assignment())
if err != nil || !done {
t.Fatalf("Handle() = (%v, %v), want done", done, err)
}
if len(tasks.reported) != 1 || backend.deleted != 1 {
t.Fatalf("reported=%v deleted=%d", tasks.reported, backend.deleted)
}
// Simulate redelivery after deletion but before the queue ACK. Gitea's
// terminal state prevents a duplicate executor from being created.
done, err = worker.Handle(context.Background(), assignment())
if err != nil || !done || backend.created != 0 {
t.Fatalf("recovery = (%v, %v), created=%d", done, err, backend.created)
}
}
func TestBackendMetadataContainsRecoveryKeys(t *testing.T) {
metadata := BackendMetadata(assignment())
if metadata.Labels["ci.ddupan.top/assignment-id"] != "gitea-task-42" {
t.Fatalf("labels = %#v", metadata.Labels)
}
if metadata.Annotations["ci.ddupan.top/spiffe-id"] != "spiffe://ddupan.top/ci/owner/repo/publish" {
t.Fatalf("annotations = %#v", metadata.Annotations)
}
}