feat(coordinator): quorum verification for untrusted (volunteer) results

Replaces the C1 quarantine with real verification. Trusted results (lab token,
verified, or admin worker) are accepted directly as before. An untrusted
worker's result is recorded as one vote per (task, owner) in a new task_results
table; the task only completes once QUORUM_SIZE distinct owners submit the same
result hash, otherwise it returns to the queue for another independent compute.

- migration 0013 task_results (one vote per owner, quorum by result_sha256)
- CompleteTask branches on worker trust; unknown worker defaults trusted (safe:
  completing needs the lease, whose owner is always a known registered worker)
- claim drops the quarantine and excludes chunks the owner already voted on
- domain Task.ReleaseAfterVote; QUORUM_SIZE config (default 2)
- unit tests: trusted direct-complete, untrusted needs-quorum, can-claim

Reducer and job done/total logic untouched — still one completed task per chunk.
This commit is contained in:
Efremenko Arhip
2026-07-26 22:55:51 +03:00
parent dcabfcd0c3
commit 18d58cce84
12 changed files with 356 additions and 60 deletions
@@ -84,6 +84,9 @@ WITH candidate AS (
WHERE status = 'pending'
AND attempt < max_attempts
AND (cardinality($1::text[]) = 0 OR workload = ANY($1))
AND ($5::uuid IS NULL OR NOT EXISTS (
SELECT 1 FROM task_results tr
WHERE tr.task_id = tasks.id AND tr.owner_id = $5))
ORDER BY created_at, chunk_index
FOR UPDATE SKIP LOCKED
LIMIT 1
@@ -108,7 +111,7 @@ func (r *TaskRepo) ClaimNext(ctx context.Context, f usecase.ClaimFilter) (*domai
var task *domain.Task
err := withRetry(ctx, func(ctx context.Context) error {
row := conn(ctx, r.pool).QueryRow(ctx, claimNextSQL, workloads, f.Owner, f.LeaseUntil, f.Now)
row := conn(ctx, r.pool).QueryRow(ctx, claimNextSQL, workloads, f.Owner, f.LeaseUntil, f.Now, f.VoterOwner)
t, err := scanTask(row)
if errors.Is(err, pgx.ErrNoRows) {
task = nil
@@ -0,0 +1,45 @@
package postgres
import (
"context"
"fmt"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
// TaskResultRepo records and tallies quorum votes for untrusted task results.
type TaskResultRepo struct {
pool *pgxpool.Pool
}
func NewTaskResultRepo(pool *pgxpool.Pool) *TaskResultRepo {
return &TaskResultRepo{pool: pool}
}
// RecordVote stores (or replaces) one owner's vote for a task's result.
func (r *TaskResultRepo) RecordVote(ctx context.Context, taskID, ownerID uuid.UUID, sha256 string, artifactID uuid.UUID) error {
const sql = `
INSERT INTO task_results (task_id, owner_id, result_sha256, result_artifact_id)
VALUES ($1, $2, $3, $4)
ON CONFLICT (task_id, owner_id) DO UPDATE
SET result_sha256 = EXCLUDED.result_sha256,
result_artifact_id = EXCLUDED.result_artifact_id,
created_at = now()`
if _, err := conn(ctx, r.pool).Exec(ctx, sql, taskID, ownerID, sha256, artifactID); err != nil {
return fmt.Errorf("record vote: %w", err)
}
return nil
}
// CountAgreeing returns how many distinct owners have voted for the given result
// hash on this task — the size of the agreeing set the quorum is measured
// against.
func (r *TaskResultRepo) CountAgreeing(ctx context.Context, taskID uuid.UUID, sha256 string) (int, error) {
const sql = `SELECT count(DISTINCT owner_id) FROM task_results WHERE task_id = $1 AND result_sha256 = $2`
var n int
if err := conn(ctx, r.pool).QueryRow(ctx, sql, taskID, sha256).Scan(&n); err != nil {
return 0, fmt.Errorf("count agreeing: %w", err)
}
return n, nil
}