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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user