feat(coordinator): artifact storage foundation (CTX-05, part 1)
Introduce durable, coordinator-owned artifacts — the model the master plan requires instead of trusting worker-supplied result URIs. - migration 0003: artifacts table + artifact_kind enum, plus nullable input/result_artifact_id columns on jobs and tasks. - domain.Artifact with NewArtifact/SetContent; storage keys derive from a fresh UUID, never from a client filename (no path traversal). - BlobStore and ArtifactRepository ports. - blob.FSStore: filesystem blob storage that streams while hashing (SHA-256), fsyncs, and atomically renames into place — a failed upload leaves neither a committed artifact nor a staging file. Covered by unit tests. - ArtifactRepo (squirrel) and COORDINATOR_STORAGE_DIR config. HTTP upload/download handlers and the switch of result submission to artifact_id come in the next parts.
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type ArtifactKind string
|
||||
|
||||
const (
|
||||
ArtifactInput ArtifactKind = "input"
|
||||
ArtifactShard ArtifactKind = "shard"
|
||||
ArtifactPartialResult ArtifactKind = "partial_result"
|
||||
ArtifactFinalResult ArtifactKind = "final_result"
|
||||
ArtifactLog ArtifactKind = "log"
|
||||
)
|
||||
|
||||
// Artifact is a durable file the coordinator owns, described by its metadata.
|
||||
// The bytes live in blob storage under StorageKey; this struct is what the
|
||||
// database persists and what every other layer reasons about.
|
||||
type Artifact struct {
|
||||
ID uuid.UUID
|
||||
JobID uuid.UUID
|
||||
TaskID *uuid.UUID // nil for a job-level input
|
||||
Kind ArtifactKind
|
||||
Filename string
|
||||
StorageKey string
|
||||
ContentType string
|
||||
SizeBytes int64
|
||||
SHA256 string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// NewArtifact begins an artifact record. Size and checksum are unknown until the
|
||||
// bytes have been streamed to storage, so they are filled in later by SetContent.
|
||||
//
|
||||
// StorageKey is derived from a fresh UUID, never from the client-supplied
|
||||
// filename — that is what stops a "../../etc/passwd" filename from escaping the
|
||||
// storage directory.
|
||||
func NewArtifact(jobID uuid.UUID, taskID *uuid.UUID, kind ArtifactKind,
|
||||
filename, contentType string, now time.Time) (*Artifact, error) {
|
||||
|
||||
if filename == "" || kind == "" {
|
||||
return nil, ErrInvalidInput
|
||||
}
|
||||
if contentType == "" {
|
||||
contentType = "application/octet-stream"
|
||||
}
|
||||
id := uuid.New()
|
||||
return &Artifact{
|
||||
ID: id,
|
||||
JobID: jobID,
|
||||
TaskID: taskID,
|
||||
Kind: kind,
|
||||
Filename: filename,
|
||||
StorageKey: id.String(),
|
||||
ContentType: contentType,
|
||||
CreatedAt: now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SetContent records the size and checksum measured while streaming the bytes
|
||||
// into storage. Both are computed by the coordinator, never trusted from the
|
||||
// client — the whole point of owning the artifact.
|
||||
func (a *Artifact) SetContent(sha256 string, size int64) {
|
||||
a.SHA256 = sha256
|
||||
a.SizeBytes = size
|
||||
}
|
||||
@@ -8,12 +8,13 @@ import "errors"
|
||||
//
|
||||
// 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")
|
||||
ErrWorkerNotFound = errors.New("worker 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")
|
||||
ErrJobNotFound = errors.New("job not found")
|
||||
ErrTaskNotFound = errors.New("task not found")
|
||||
ErrWorkerNotFound = errors.New("worker not found")
|
||||
ErrArtifactNotFound = errors.New("artifact 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")
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user