feat(coordinator): artifact upload/download endpoints (CTX-05, part 2)
Wire the artifact storage foundation to HTTP.
- PUT /tasks/{id}/artifacts/{filename}: a worker streams a partial result;
the coordinator verifies lease ownership (foreign worker → 409), streams
the bytes to blob storage while hashing, and records the metadata. An
orphaned blob from a failed metadata insert is cleaned up.
- GET /artifacts/{id}/download: streams an artifact back with its content
type, length, and checksum.
- Ownership is read with a new non-locking TaskRepository.Get, so no row lock
is held across a long upload. Identity travels in X-Worker-ID / X-Task-Attempt
headers per the contract; upload/download bypass the short request timeout.
- docker-compose mounts ./data for durable artifact storage; smoke and
requests.http exercise an upload → foreign-409 → download round-trip.
This commit is contained in:
@@ -92,6 +92,18 @@ func (s *FSStore) Open(ctx context.Context, key string) (io.ReadCloser, error) {
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// Delete removes a stored blob. Absence is not an error: cleaning up after a
|
||||
// failed metadata insert must be idempotent.
|
||||
func (s *FSStore) Delete(ctx context.Context, key string) error {
|
||||
if err := checkKey(key); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Remove(filepath.Join(s.dir, key)); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkKey rejects anything that could escape the storage directory. Keys are
|
||||
// coordinator-generated UUIDs, so this is defence in depth, not the only guard.
|
||||
func checkKey(key string) error {
|
||||
|
||||
@@ -120,6 +120,25 @@ func (r *TaskRepo) ClaimNext(ctx context.Context, f usecase.ClaimFilter) (*domai
|
||||
return task, nil
|
||||
}
|
||||
|
||||
// Get reads a task without locking its row.
|
||||
func (r *TaskRepo) Get(ctx context.Context, id uuid.UUID) (*domain.Task, error) {
|
||||
sql, args, err := psql.Select(taskColumns...).
|
||||
From("tasks").
|
||||
Where(sq.Eq{"id": id}).
|
||||
ToSql()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t, err := scanTask(conn(ctx, r.pool).QueryRow(ctx, sql, args...))
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, domain.ErrTaskNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// GetForUpdate reads a task and holds its row lock until the caller's
|
||||
// transaction ends, so read-modify-write use cases cannot interleave.
|
||||
func (r *TaskRepo) GetForUpdate(ctx context.Context, id uuid.UUID) (*domain.Task, error) {
|
||||
|
||||
Reference in New Issue
Block a user