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:
Efremenko Arhip
2026-07-23 14:06:19 +03:00
parent a5945f2d38
commit dbf578c500
9 changed files with 443 additions and 8 deletions
+17
View File
@@ -9,6 +9,7 @@ package usecase
import (
"context"
"errors"
"io"
"time"
"github.com/google/uuid"
@@ -68,6 +69,22 @@ type WorkerRepository interface {
Get(ctx context.Context, id uuid.UUID) (*domain.Worker, error)
}
// ArtifactRepository persists artifact metadata. The bytes live in a BlobStore;
// this keeps only the record that points at them.
type ArtifactRepository interface {
Insert(ctx context.Context, a *domain.Artifact) error
Get(ctx context.Context, id uuid.UUID) (*domain.Artifact, error)
}
// BlobStore holds artifact bytes, addressed by an opaque storage key. It streams
// in both directions so a large shard never has to sit in memory, and reports
// the checksum and size it measured while writing — the coordinator's own
// numbers, not the client's claim.
type BlobStore interface {
Put(ctx context.Context, key string, r io.Reader) (sha256 string, size int64, err error)
Open(ctx context.Context, key string) (io.ReadCloser, error)
}
// TxManager runs a function inside one database transaction. The transaction
// travels in the context, so repositories pick it up without this port ever
// mentioning pgx.