feat(coordinator): scaffold task-queue service in Go
Adds the SciMesh coordinator: a durable task-queue server on PostgreSQL that owns all database access, with workers reaching it over HTTP only. Structured as a modular monolith following Clean Architecture: domain entities and their invariants, no I/O usecase business operations + repository/clock ports transport HTTP handlers, DTOs, auth, error mapping storage PostgreSQL repositories, transactions carried in context infra config, pool, clock, server, lease reaper Dependencies point strictly inward; domain imports nothing from the module. Working: layer wiring, routing, shared-token auth, access logging, request IDs, domain-error to status-code mapping, transactional boundaries, graceful shutdown (HTTP drain -> reaper stop -> pool close), migrations, and a Compose stack starting Postgres -> migrations -> coordinator. The domain is complete and covered by unit tests that need no database: lease ownership, stale attempts, idempotent result replay, retry budgets, and lease expiry. Repository methods are stubs returning ErrNotImplemented (HTTP 501). The SQL for atomic claiming (FOR UPDATE SKIP LOCKED) and for lease expiry is written and ready to wire up. See coordinator/ARCHITECTURE.md for the layer map and a request traced through every layer.
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
package domain
|
||||
|
||||
import "errors"
|
||||
|
||||
// Business-rule violations. They live in the innermost layer because they
|
||||
// describe what the rules are, not how a transport reports them: the HTTP
|
||||
// adapter maps these to status codes, and nothing here knows 409 exists.
|
||||
//
|
||||
// Always compare with errors.Is — outer layers may wrap these with %w.
|
||||
var (
|
||||
ErrJobNotFound = errors.New("job not found")
|
||||
ErrTaskNotFound = errors.New("task not found")
|
||||
ErrLeaseConflict = errors.New("task leased to another worker")
|
||||
ErrStaleAttempt = errors.New("attempt does not match lease")
|
||||
ErrResultConflict = errors.New("different result already recorded")
|
||||
ErrInvalidInput = errors.New("invalid input")
|
||||
ErrTaskNotLeased = errors.New("task is not currently leased")
|
||||
)
|
||||
@@ -0,0 +1,107 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type JobStatus string
|
||||
|
||||
const (
|
||||
JobPending JobStatus = "pending"
|
||||
JobRunning JobStatus = "running"
|
||||
JobCompleted JobStatus = "completed"
|
||||
JobFailed JobStatus = "failed"
|
||||
JobCancelled JobStatus = "cancelled"
|
||||
)
|
||||
|
||||
// Job is one user submission that fans out into one or more tasks.
|
||||
type Job struct {
|
||||
ID uuid.UUID
|
||||
Workload string
|
||||
InputURI string
|
||||
Parameters map[string]any
|
||||
Status JobStatus
|
||||
CreatedAt time.Time
|
||||
CompletedAt *time.Time
|
||||
}
|
||||
|
||||
// ChunkSpec describes one piece a job is split into. Callers build these from
|
||||
// whatever chunking strategy the workload uses; the domain only validates them.
|
||||
type ChunkSpec struct {
|
||||
ChunkIndex int
|
||||
Workload string // empty inherits the job's workload
|
||||
InputURI string
|
||||
InputSHA256 string
|
||||
Parameters map[string]any
|
||||
MaxAttempts int
|
||||
}
|
||||
|
||||
// NewJobWithTasks builds a job together with all of its tasks, validating the
|
||||
// set as a whole. Returning both from one constructor keeps the invariant
|
||||
// visible: a job without tasks, or with duplicate chunk indexes, cannot exist.
|
||||
func NewJobWithTasks(workload, inputURI string, params map[string]any,
|
||||
chunks []ChunkSpec, now time.Time) (*Job, []*Task, error) {
|
||||
|
||||
if workload == "" || inputURI == "" || len(chunks) == 0 {
|
||||
return nil, nil, ErrInvalidInput
|
||||
}
|
||||
|
||||
job := &Job{
|
||||
ID: uuid.New(),
|
||||
Workload: workload,
|
||||
InputURI: inputURI,
|
||||
Parameters: params,
|
||||
Status: JobPending,
|
||||
CreatedAt: now,
|
||||
}
|
||||
|
||||
seen := make(map[int]struct{}, len(chunks))
|
||||
tasks := make([]*Task, 0, len(chunks))
|
||||
for _, c := range chunks {
|
||||
if _, dup := seen[c.ChunkIndex]; dup {
|
||||
return nil, nil, ErrInvalidInput // unique (job_id, chunk_index)
|
||||
}
|
||||
seen[c.ChunkIndex] = struct{}{}
|
||||
|
||||
w := c.Workload
|
||||
if w == "" {
|
||||
w = workload
|
||||
}
|
||||
task, err := NewTask(job.ID, c.ChunkIndex, w, c.InputURI, c.InputSHA256,
|
||||
c.Parameters, c.MaxAttempts, now)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
tasks = append(tasks, task)
|
||||
}
|
||||
return job, tasks, nil
|
||||
}
|
||||
|
||||
// JobProgress is the aggregate view of a job and the state of its tasks.
|
||||
type JobProgress struct {
|
||||
Job Job
|
||||
Total int
|
||||
Pending int
|
||||
Leased int
|
||||
Done int
|
||||
Failed int
|
||||
}
|
||||
|
||||
// DeriveStatus computes what the job's status should be from its task counts,
|
||||
// so the rule lives here rather than in a SQL trigger or a handler.
|
||||
func (p JobProgress) DeriveStatus() JobStatus {
|
||||
switch {
|
||||
case p.Total == 0:
|
||||
return JobPending
|
||||
case p.Done == p.Total:
|
||||
return JobCompleted
|
||||
case p.Failed > 0 && p.Done+p.Failed == p.Total:
|
||||
return JobFailed
|
||||
case p.Leased > 0 || p.Done > 0 || p.Failed > 0:
|
||||
return JobRunning
|
||||
default:
|
||||
return JobPending
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
// Package domain holds SciMesh's entities and the rules that govern them. It
|
||||
// is the innermost layer: it imports nothing from this module and knows nothing
|
||||
// about HTTP, SQL, or configuration. Every state transition a task can undergo
|
||||
// is a method here, so the rules are unit-testable without a database.
|
||||
package domain
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type TaskStatus string
|
||||
|
||||
const (
|
||||
TaskPending TaskStatus = "pending"
|
||||
TaskLeased TaskStatus = "leased"
|
||||
TaskCompleted TaskStatus = "completed"
|
||||
TaskFailed TaskStatus = "failed"
|
||||
TaskCancelled TaskStatus = "cancelled"
|
||||
)
|
||||
|
||||
// ErrCodeLeaseExpired marks tasks failed by the reaper rather than by a worker.
|
||||
const ErrCodeLeaseExpired = "lease_expired"
|
||||
|
||||
// Task is one independently executable chunk of a job.
|
||||
//
|
||||
// Nullable columns are pointers so "no lease" stays distinguishable from
|
||||
// "lease owned by the empty string" — a plain string cannot express both.
|
||||
type Task struct {
|
||||
ID uuid.UUID
|
||||
JobID uuid.UUID
|
||||
ChunkIndex int
|
||||
Workload string
|
||||
InputURI string
|
||||
InputSHA256 string
|
||||
Parameters map[string]any
|
||||
Status TaskStatus
|
||||
Attempt int
|
||||
MaxAttempts int
|
||||
LeaseOwner *string
|
||||
LeaseExpiresAt *time.Time
|
||||
ResultURI *string
|
||||
ResultSHA256 *string
|
||||
Metrics map[string]any
|
||||
ErrorCode *string
|
||||
ErrorMessage *string
|
||||
CreatedAt time.Time
|
||||
StartedAt *time.Time
|
||||
CompletedAt *time.Time
|
||||
Version int
|
||||
}
|
||||
|
||||
// NewTask builds a pending task. maxAttempts <= 0 falls back to the default.
|
||||
func NewTask(jobID uuid.UUID, chunkIndex int, workload, inputURI, inputSHA256 string,
|
||||
params map[string]any, maxAttempts int, now time.Time) (*Task, error) {
|
||||
|
||||
if inputURI == "" {
|
||||
return nil, ErrInvalidInput
|
||||
}
|
||||
if inputSHA256 == "" {
|
||||
return nil, ErrInvalidInput // checksum is mandatory: workers verify inputs
|
||||
}
|
||||
if chunkIndex < 0 {
|
||||
return nil, ErrInvalidInput
|
||||
}
|
||||
if maxAttempts <= 0 {
|
||||
maxAttempts = DefaultMaxAttempts
|
||||
}
|
||||
return &Task{
|
||||
ID: uuid.New(),
|
||||
JobID: jobID,
|
||||
ChunkIndex: chunkIndex,
|
||||
Workload: workload,
|
||||
InputURI: inputURI,
|
||||
InputSHA256: inputSHA256,
|
||||
Parameters: params,
|
||||
Status: TaskPending,
|
||||
Attempt: 0,
|
||||
MaxAttempts: maxAttempts,
|
||||
CreatedAt: now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DefaultMaxAttempts applies when a task does not specify its own ceiling.
|
||||
const DefaultMaxAttempts = 3
|
||||
|
||||
// CanRetry reports whether any attempts remain.
|
||||
func (t *Task) CanRetry() bool { return t.Attempt < t.MaxAttempts }
|
||||
|
||||
// IsLeaseHeldBy reports whether worker currently holds this task at attempt.
|
||||
func (t *Task) IsLeaseHeldBy(worker string, attempt int) bool {
|
||||
return t.LeaseOwner != nil && *t.LeaseOwner == worker && t.Attempt == attempt
|
||||
}
|
||||
|
||||
// AsClaimed projects the task into the trimmed view handed to a worker:
|
||||
// everything needed to execute, nothing it has no business seeing.
|
||||
func (t *Task) AsClaimed() ClaimedTask {
|
||||
ct := ClaimedTask{
|
||||
TaskID: t.ID,
|
||||
JobID: t.JobID,
|
||||
ChunkIndex: t.ChunkIndex,
|
||||
Workload: t.Workload,
|
||||
InputURI: t.InputURI,
|
||||
InputSHA256: t.InputSHA256,
|
||||
Parameters: t.Parameters,
|
||||
Attempt: t.Attempt,
|
||||
}
|
||||
if t.LeaseOwner != nil {
|
||||
ct.LeaseOwner = *t.LeaseOwner
|
||||
}
|
||||
if t.LeaseExpiresAt != nil {
|
||||
ct.LeaseExpiresAt = *t.LeaseExpiresAt
|
||||
}
|
||||
return ct
|
||||
}
|
||||
|
||||
// verifyLease is the guard every worker-driven transition shares: the caller
|
||||
// must own the lease and reference the attempt it was granted.
|
||||
func (t *Task) verifyLease(worker string, attempt int) error {
|
||||
if t.Status != TaskLeased {
|
||||
return ErrTaskNotLeased
|
||||
}
|
||||
if t.LeaseOwner == nil || *t.LeaseOwner != worker {
|
||||
return ErrLeaseConflict
|
||||
}
|
||||
if t.Attempt != attempt {
|
||||
return ErrStaleAttempt
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RenewLease extends the lease of the worker that holds it.
|
||||
func (t *Task) RenewLease(worker string, attempt int, until time.Time) error {
|
||||
if err := t.verifyLease(worker, attempt); err != nil {
|
||||
return err
|
||||
}
|
||||
t.LeaseExpiresAt = &until
|
||||
t.Version++
|
||||
return nil
|
||||
}
|
||||
|
||||
// CompleteWith records a successful result.
|
||||
//
|
||||
// Idempotency comes first deliberately: a worker whose network dropped will
|
||||
// retry the same manifest, and that must succeed rather than trip the lease
|
||||
// check on a task the coordinator already finished. A *different* manifest for
|
||||
// an already-completed task is a genuine conflict.
|
||||
func (t *Task) CompleteWith(resultURI, resultSHA256 string, metrics map[string]any,
|
||||
worker string, attempt int, now time.Time) error {
|
||||
|
||||
if resultURI == "" || resultSHA256 == "" {
|
||||
return ErrInvalidInput
|
||||
}
|
||||
|
||||
if t.Status == TaskCompleted {
|
||||
if t.Attempt == attempt && t.ResultURI != nil && *t.ResultURI == resultURI &&
|
||||
t.ResultSHA256 != nil && *t.ResultSHA256 == resultSHA256 {
|
||||
return nil // same attempt, same manifest — replay of a successful call
|
||||
}
|
||||
return ErrResultConflict
|
||||
}
|
||||
|
||||
if err := t.verifyLease(worker, attempt); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
t.Status = TaskCompleted
|
||||
t.ResultURI = &resultURI
|
||||
t.ResultSHA256 = &resultSHA256
|
||||
t.Metrics = metrics
|
||||
t.CompletedAt = &now
|
||||
t.LeaseOwner = nil
|
||||
t.LeaseExpiresAt = nil
|
||||
t.ErrorCode = nil
|
||||
t.ErrorMessage = nil
|
||||
t.Version++
|
||||
return nil
|
||||
}
|
||||
|
||||
// Fail records a worker-reported failure. A retryable failure with attempts
|
||||
// left returns the task to the queue; otherwise it terminates as failed.
|
||||
func (t *Task) Fail(worker string, attempt int, code, message string, retryable bool, now time.Time) error {
|
||||
if err := t.verifyLease(worker, attempt); err != nil {
|
||||
return err
|
||||
}
|
||||
t.ErrorCode = &code
|
||||
t.ErrorMessage = &message
|
||||
t.LeaseOwner = nil
|
||||
t.LeaseExpiresAt = nil
|
||||
t.Version++
|
||||
|
||||
if retryable && t.CanRetry() {
|
||||
t.Status = TaskPending
|
||||
return nil
|
||||
}
|
||||
t.Status = TaskFailed
|
||||
t.CompletedAt = &now
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExpireLease is applied by the reaper when a lease elapses without a
|
||||
// heartbeat: requeue while attempts remain, otherwise fail terminally.
|
||||
func (t *Task) ExpireLease(now time.Time) {
|
||||
if t.Status != TaskLeased {
|
||||
return
|
||||
}
|
||||
t.LeaseOwner = nil
|
||||
t.LeaseExpiresAt = nil
|
||||
t.Version++
|
||||
|
||||
if t.CanRetry() {
|
||||
t.Status = TaskPending
|
||||
return
|
||||
}
|
||||
code, msg := ErrCodeLeaseExpired, "lease expired after the final attempt"
|
||||
t.ErrorCode = &code
|
||||
t.ErrorMessage = &msg
|
||||
t.Status = TaskFailed
|
||||
t.CompletedAt = &now
|
||||
}
|
||||
|
||||
// ClaimedTask is the worker-facing projection of a leased task.
|
||||
type ClaimedTask struct {
|
||||
TaskID uuid.UUID
|
||||
JobID uuid.UUID
|
||||
ChunkIndex int
|
||||
Workload string
|
||||
InputURI string
|
||||
InputSHA256 string
|
||||
Parameters map[string]any
|
||||
Attempt int
|
||||
LeaseOwner string
|
||||
LeaseExpiresAt time.Time
|
||||
}
|
||||
|
||||
// ResultManifest is a completed task's output, ordered for the stitcher.
|
||||
type ResultManifest struct {
|
||||
TaskID uuid.UUID
|
||||
ChunkIndex int
|
||||
ResultURI string
|
||||
ResultSHA256 string
|
||||
Metrics map[string]any
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
var (
|
||||
testNow = time.Date(2026, 7, 21, 12, 0, 0, 0, time.UTC)
|
||||
testLater = testNow.Add(time.Hour)
|
||||
testWorker = "worker-1"
|
||||
)
|
||||
|
||||
// leasedTask builds a task already leased to testWorker at the given attempt.
|
||||
func leasedTask(attempt, maxAttempts int) *Task {
|
||||
owner := testWorker
|
||||
expires := testLater
|
||||
return &Task{
|
||||
ID: uuid.New(),
|
||||
JobID: uuid.New(),
|
||||
Status: TaskLeased,
|
||||
Attempt: attempt,
|
||||
MaxAttempts: maxAttempts,
|
||||
LeaseOwner: &owner,
|
||||
LeaseExpiresAt: &expires,
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteWithRecordsResult(t *testing.T) {
|
||||
task := leasedTask(1, 3)
|
||||
|
||||
if err := task.CompleteWith("s3://r.csv", "abc", nil, testWorker, 1, testNow); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if task.Status != TaskCompleted {
|
||||
t.Errorf("status = %q, want completed", task.Status)
|
||||
}
|
||||
if task.LeaseOwner != nil || task.LeaseExpiresAt != nil {
|
||||
t.Error("lease must be released on completion")
|
||||
}
|
||||
if task.CompletedAt == nil || !task.CompletedAt.Equal(testNow) {
|
||||
t.Error("completed_at must be stamped")
|
||||
}
|
||||
}
|
||||
|
||||
// A worker whose network dropped retries the same manifest; that must succeed
|
||||
// rather than fail on the lease it has already given up.
|
||||
func TestCompleteWithIsIdempotentForSameManifest(t *testing.T) {
|
||||
task := leasedTask(1, 3)
|
||||
if err := task.CompleteWith("s3://r.csv", "abc", nil, testWorker, 1, testNow); err != nil {
|
||||
t.Fatalf("first call: %v", err)
|
||||
}
|
||||
versionAfterFirst := task.Version
|
||||
|
||||
if err := task.CompleteWith("s3://r.csv", "abc", nil, testWorker, 1, testLater); err != nil {
|
||||
t.Fatalf("replay must be idempotent, got %v", err)
|
||||
}
|
||||
if task.Version != versionAfterFirst {
|
||||
t.Error("replay must not mutate the task")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteWithRejectsDifferentManifest(t *testing.T) {
|
||||
task := leasedTask(1, 3)
|
||||
if err := task.CompleteWith("s3://r.csv", "abc", nil, testWorker, 1, testNow); err != nil {
|
||||
t.Fatalf("first call: %v", err)
|
||||
}
|
||||
|
||||
err := task.CompleteWith("s3://other.csv", "def", nil, testWorker, 1, testLater)
|
||||
if !errors.Is(err, ErrResultConflict) {
|
||||
t.Errorf("err = %v, want ErrResultConflict", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteWithRejectsForeignWorker(t *testing.T) {
|
||||
task := leasedTask(1, 3)
|
||||
|
||||
err := task.CompleteWith("s3://r.csv", "abc", nil, "worker-2", 1, testNow)
|
||||
if !errors.Is(err, ErrLeaseConflict) {
|
||||
t.Errorf("err = %v, want ErrLeaseConflict", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteWithRejectsStaleAttempt(t *testing.T) {
|
||||
task := leasedTask(2, 3) // task is on attempt 2
|
||||
|
||||
err := task.CompleteWith("s3://r.csv", "abc", nil, testWorker, 1, testNow) // worker thinks it is 1
|
||||
if !errors.Is(err, ErrStaleAttempt) {
|
||||
t.Errorf("err = %v, want ErrStaleAttempt", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailRequeuesWhileAttemptsRemain(t *testing.T) {
|
||||
task := leasedTask(1, 3)
|
||||
|
||||
if err := task.Fail(testWorker, 1, "boom", "exploded", true, testNow); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if task.Status != TaskPending {
|
||||
t.Errorf("status = %q, want pending", task.Status)
|
||||
}
|
||||
if task.LeaseOwner != nil {
|
||||
t.Error("lease must be released so another worker can claim it")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailTerminatesOnFinalAttempt(t *testing.T) {
|
||||
task := leasedTask(3, 3) // no attempts left
|
||||
|
||||
if err := task.Fail(testWorker, 3, "boom", "exploded", true, testNow); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if task.Status != TaskFailed {
|
||||
t.Errorf("status = %q, want failed", task.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailIsTerminalWhenNotRetryable(t *testing.T) {
|
||||
task := leasedTask(1, 3) // attempts remain, but the error is fatal
|
||||
|
||||
if err := task.Fail(testWorker, 1, "bad_input", "checksum mismatch", false, testNow); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if task.Status != TaskFailed {
|
||||
t.Errorf("status = %q, want failed", task.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// This is the MVP acceptance criterion: a dead worker must not strand its task.
|
||||
func TestExpireLeaseRequeuesWhileAttemptsRemain(t *testing.T) {
|
||||
task := leasedTask(1, 3)
|
||||
|
||||
task.ExpireLease(testNow)
|
||||
|
||||
if task.Status != TaskPending {
|
||||
t.Errorf("status = %q, want pending", task.Status)
|
||||
}
|
||||
if task.LeaseOwner != nil || task.LeaseExpiresAt != nil {
|
||||
t.Error("expired lease must be cleared")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpireLeaseFailsAfterFinalAttempt(t *testing.T) {
|
||||
task := leasedTask(3, 3)
|
||||
|
||||
task.ExpireLease(testNow)
|
||||
|
||||
if task.Status != TaskFailed {
|
||||
t.Errorf("status = %q, want failed", task.Status)
|
||||
}
|
||||
if task.ErrorCode == nil || *task.ErrorCode != ErrCodeLeaseExpired {
|
||||
t.Error("expected a lease_expired error code")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpireLeaseIgnoresUnleasedTasks(t *testing.T) {
|
||||
task := &Task{Status: TaskCompleted, Attempt: 1, MaxAttempts: 3}
|
||||
|
||||
task.ExpireLease(testNow)
|
||||
|
||||
if task.Status != TaskCompleted {
|
||||
t.Errorf("status = %q, completed tasks must be untouched", task.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenewLeaseExtendsOnlyForHolder(t *testing.T) {
|
||||
task := leasedTask(1, 3)
|
||||
until := testLater.Add(time.Hour)
|
||||
|
||||
if err := task.RenewLease(testWorker, 1, until); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !task.LeaseExpiresAt.Equal(until) {
|
||||
t.Error("lease must be extended")
|
||||
}
|
||||
|
||||
if err := task.RenewLease("worker-2", 1, until); !errors.Is(err, ErrLeaseConflict) {
|
||||
t.Errorf("err = %v, want ErrLeaseConflict", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user