Skip to content

Commit ea1860d

Browse files
Merge pull request #5 from stackshy/hardening/prod-audit-fixes
Production hardening: fix duplicate-run race, scale cliffs, crash/leak/error-path bugs
2 parents 0c866c2 + 6fa36c0 commit ea1860d

23 files changed

Lines changed: 810 additions & 111 deletions

File tree

‎internal/auth/rbac.go‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,10 +81,12 @@ func requiredRole(method string) Role {
8181
}
8282

8383
// openPaths bypass auth entirely: health/observability probes and the static
84-
// dashboard assets (the dashboard's API calls still enforce RBAC).
84+
// dashboard assets (the dashboard's API calls still enforce RBAC). The /app
85+
// match is boundary-anchored so unrelated paths like "/apple" or a traversal
86+
// like "/app/../tasks" don't slip past auth.
8587
func openPath(path string) bool {
8688
return strings.HasPrefix(path, "/.well-known/") ||
87-
strings.HasPrefix(path, "/app") ||
89+
path == "/app" || strings.HasPrefix(path, "/app/") ||
8890
path == "/" || path == "/health" || path == "/metrics" || path == "/favicon.ico"
8991
}
9092

‎internal/coord/lease.go‎

Lines changed: 80 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -60,19 +60,34 @@ func (q *Queue) key(suffix string) string {
6060
return fmt.Sprintf("mara:run:%d:%s", q.runID, suffix)
6161
}
6262

63+
// enqueueChunk bounds how many RPUSH commands go in one pipeline, so publishing
64+
// a large lease set doesn't build one huge Redis transaction.
65+
const enqueueChunk = 1000
66+
6367
// Enqueue publishes all leases for the run and seals it (no more will be added).
6468
// Sealing lets workers distinguish "queue momentarily empty" from "run done".
69+
// Leases are pushed in chunks to keep each Redis round-trip bounded.
6570
func (q *Queue) Enqueue(ctx context.Context, leases []Lease) error {
66-
pipe := q.rdb.TxPipeline()
71+
for start := 0; start < len(leases); start += enqueueChunk {
72+
end := start + enqueueChunk
73+
if end > len(leases) {
74+
end = len(leases)
75+
}
6776

68-
for _, l := range leases {
69-
pipe.RPush(ctx, q.key("pending"), l.encode())
70-
}
77+
pipe := q.rdb.Pipeline()
78+
for _, l := range leases[start:end] {
79+
pipe.RPush(ctx, q.key("pending"), l.encode())
80+
}
7181

72-
pipe.Set(ctx, q.key("sealed"), "1", 0)
82+
if _, err := pipe.Exec(ctx); err != nil {
83+
return fmt.Errorf("enqueue leases: %w", err)
84+
}
85+
}
7386

74-
if _, err := pipe.Exec(ctx); err != nil {
75-
return fmt.Errorf("enqueue leases: %w", err)
87+
// Seal only after every lease is enqueued, so a worker never sees an empty
88+
// pending list as "drained" mid-publish.
89+
if err := q.rdb.Set(ctx, q.key("sealed"), "1", 0).Err(); err != nil {
90+
return fmt.Errorf("seal queue: %w", err)
7691
}
7792

7893
return nil
@@ -119,6 +134,34 @@ func (q *Queue) Complete(ctx context.Context, l Lease) error {
119134
return nil
120135
}
121136

137+
// Requeue returns a lease to pending after the owning worker failed to process
138+
// it (e.g. target unreachable), and bumps the failure counter. Another worker
139+
// will retry it; the monitor uses the failure counter to detect a run that is
140+
// failing with no forward progress.
141+
func (q *Queue) Requeue(ctx context.Context, l Lease) error {
142+
pipe := q.rdb.TxPipeline()
143+
pipe.LRem(ctx, q.key("processing"), 1, l.encode())
144+
pipe.RPush(ctx, q.key("pending"), l.encode())
145+
pipe.ZRem(ctx, q.key("hb"), l.encode())
146+
pipe.Incr(ctx, q.key("fail"))
147+
148+
if _, err := pipe.Exec(ctx); err != nil {
149+
return fmt.Errorf("requeue failed lease: %w", err)
150+
}
151+
152+
return nil
153+
}
154+
155+
// Fails returns the cumulative lease-failure count for the run.
156+
func (q *Queue) Fails(ctx context.Context) (int64, error) {
157+
n, err := q.rdb.Get(ctx, q.key("fail")).Int64()
158+
if err == redis.Nil {
159+
return 0, nil
160+
}
161+
162+
return n, err
163+
}
164+
122165
// Reap requeues leases whose heartbeat is older than the cutoff — the
123166
// signature move of dead-worker recovery. Returns how many were requeued.
124167
func (q *Queue) Reap(ctx context.Context, cutoffMillis int64) (int, error) {
@@ -176,25 +219,50 @@ func (q *Queue) Drained(ctx context.Context) (bool, error) {
176219
func (q *Queue) Cleanup(ctx context.Context) error {
177220
return q.rdb.Del(ctx,
178221
q.key("pending"), q.key("processing"), q.key("hb"),
179-
q.key("sealed"), q.key("done"),
222+
q.key("sealed"), q.key("done"), q.key("fail"),
180223
).Err()
181224
}
182225

183-
// PlanLeases divides [minID, maxID] into leases of the given stride.
226+
// MaxLeases caps how many leases a single run is split into, regardless of how
227+
// large or sparse the key space is. This bounds memory and the Redis enqueue
228+
// fan-out; each lease just covers a wider id range (workers sub-batch within).
229+
const MaxLeases = 100_000
230+
231+
// PlanLeases divides [minID, maxID] into at most MaxLeases leases. It widens the
232+
// stride when the key space is huge or sparse (e.g. snowflake ids up to 10^18)
233+
// and is overflow-safe near math.MaxInt64, so it can never build billions of
234+
// leases or spin forever.
184235
func PlanLeases(minID, maxID, stride int64) []Lease {
236+
if maxID < minID {
237+
return nil
238+
}
239+
185240
if stride <= 0 {
186241
stride = 1
187242
}
188243

189-
var leases []Lease
244+
// Widen the stride so the lease count can't exceed MaxLeases. float64 avoids
245+
// int64 overflow on very wide spans; the hard loop cap below is the backstop.
246+
spanF := float64(maxID) - float64(minID) + 1
247+
if spanF/float64(stride) > float64(MaxLeases) {
248+
stride = int64(spanF/float64(MaxLeases)) + 1
249+
}
250+
251+
leases := make([]Lease, 0, MaxLeases+1)
190252

191-
for lo := minID; lo <= maxID; lo += stride {
253+
for lo := minID; len(leases) <= MaxLeases; {
192254
hi := lo + stride - 1
193-
if hi > maxID {
255+
if hi < lo || hi > maxID { // overflow (wrapped) or past the end
194256
hi = maxID
195257
}
196258

197259
leases = append(leases, Lease{Lo: lo, Hi: hi})
260+
261+
if hi >= maxID {
262+
break // full coverage reached
263+
}
264+
265+
lo = hi + 1
198266
}
199267

200268
return leases

‎internal/coord/lease_test.go‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,43 @@ func TestPlanLeases(t *testing.T) {
2929
}
3030
}
3131

32+
func TestPlanLeasesBounded(t *testing.T) {
33+
// A huge/sparse key space with a tiny stride must NOT produce billions of
34+
// leases or hang — the count is capped and coverage is complete.
35+
got := PlanLeases(1, 1_000_000_000_000, 1000)
36+
if len(got) > MaxLeases+1 {
37+
t.Fatalf("lease count = %d, want <= %d", len(got), MaxLeases+1)
38+
}
39+
40+
if got[0].Lo != 1 {
41+
t.Errorf("first lease Lo = %d, want 1", got[0].Lo)
42+
}
43+
44+
if last := got[len(got)-1]; last.Hi != 1_000_000_000_000 {
45+
t.Errorf("last lease Hi = %d, want full coverage to 10^12", last.Hi)
46+
}
47+
48+
// No gaps or overlaps across the whole plan.
49+
for i := 1; i < len(got); i++ {
50+
if got[i].Lo != got[i-1].Hi+1 {
51+
t.Fatalf("gap/overlap at %d: prev.Hi=%d cur.Lo=%d", i, got[i-1].Hi, got[i].Lo)
52+
}
53+
}
54+
}
55+
56+
func TestPlanLeasesOverflowSafe(t *testing.T) {
57+
// Near math.MaxInt64 the stride addition would overflow; must terminate.
58+
const maxInt64 = int64(9223372036854775807)
59+
got := PlanLeases(maxInt64-10, maxInt64, 4)
60+
if len(got) == 0 || len(got) > MaxLeases+1 {
61+
t.Fatalf("overflow plan len = %d", len(got))
62+
}
63+
64+
if got[len(got)-1].Hi != maxInt64 {
65+
t.Errorf("last Hi = %d, want %d", got[len(got)-1].Hi, maxInt64)
66+
}
67+
}
68+
3269
func TestLeaseRoundTrip(t *testing.T) {
3370
l := Lease{Lo: 42, Hi: 1337}
3471

‎internal/engine/hardening_test.go‎

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
package engine
2+
3+
import (
4+
"context"
5+
"errors"
6+
"sync"
7+
"testing"
8+
9+
"marathon/internal/models"
10+
"marathon/internal/store"
11+
)
12+
13+
// The partial unique index must allow only one active run per task, even under
14+
// a burst of concurrent Create calls (the duplicate-run race).
15+
func TestIntegration_OneActiveRunPerTask(t *testing.T) {
16+
control, _ := openOrSkip(t)
17+
controlSchema(t, control)
18+
19+
task := &models.Task{
20+
Name: "dup-guard", TargetDSN: dsn("MARATHON_TEST_TARGET_DSN", "postgres://demo:demo@localhost:5434/demo?sslmode=disable"),
21+
SourceTable: "items", CursorColumn: "id", BatchSize: 1000,
22+
OperationSQL: "UPDATE items SET applied = applied + 1 WHERE id >= $1 AND id <= $2",
23+
}
24+
newTask(t, control, task)
25+
26+
const racers = 12
27+
var (
28+
wg sync.WaitGroup
29+
mu sync.Mutex
30+
created int
31+
rejected int
32+
)
33+
34+
for range racers {
35+
wg.Add(1)
36+
go func() {
37+
defer wg.Done()
38+
_, err := (store.RunStore{}).Create(context.Background(), control, task.ID)
39+
40+
mu.Lock()
41+
defer mu.Unlock()
42+
switch {
43+
case err == nil:
44+
created++
45+
case errors.Is(err, store.ErrActiveRunExists):
46+
rejected++
47+
default:
48+
t.Errorf("unexpected error: %v", err)
49+
}
50+
}()
51+
}
52+
wg.Wait()
53+
54+
if created != 1 {
55+
t.Errorf("created %d active runs, want exactly 1", created)
56+
}
57+
58+
if rejected != racers-1 {
59+
t.Errorf("rejected %d, want %d", rejected, racers-1)
60+
}
61+
62+
// Confirm the DB agrees: exactly one active run.
63+
var active int
64+
control.QueryRow(`SELECT count(*) FROM runs WHERE task_id=$1 AND state IN ('queued','running','paused')`, task.ID).Scan(&active)
65+
if active != 1 {
66+
t.Errorf("db shows %d active runs, want 1", active)
67+
}
68+
}
69+
70+
// A run's operation is available for a new run only once the previous one is
71+
// terminal — after which Create succeeds again.
72+
func TestIntegration_ActiveRunFreesOnTerminal(t *testing.T) {
73+
control, _ := openOrSkip(t)
74+
controlSchema(t, control)
75+
76+
task := &models.Task{
77+
Name: "free-on-terminal", TargetDSN: dsn("MARATHON_TEST_TARGET_DSN", "postgres://demo:demo@localhost:5434/demo?sslmode=disable"),
78+
SourceTable: "items", CursorColumn: "id", BatchSize: 1000,
79+
OperationSQL: "UPDATE items SET applied = applied + 1 WHERE id >= $1 AND id <= $2",
80+
}
81+
newTask(t, control, task)
82+
83+
runs := store.RunStore{}
84+
ctx := context.Background()
85+
86+
r1, err := runs.Create(ctx, control, task.ID)
87+
if err != nil {
88+
t.Fatal(err)
89+
}
90+
91+
if _, err := runs.Create(ctx, control, task.ID); !errors.Is(err, store.ErrActiveRunExists) {
92+
t.Fatalf("second Create = %v, want ErrActiveRunExists", err)
93+
}
94+
95+
// Finish the first run; a new one may now start.
96+
if err := runs.SetState(ctx, control, r1.ID, models.RunSucceeded, ""); err != nil {
97+
t.Fatal(err)
98+
}
99+
100+
if _, err := runs.Create(ctx, control, task.ID); err != nil {
101+
t.Fatalf("Create after terminal = %v, want success", err)
102+
}
103+
}

‎internal/engine/httpop.go‎

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -86,8 +86,21 @@ func applyHTTPBatch(
8686
}
8787

8888
var cr callbackResponse
89-
if err := json.Unmarshal(payload, &cr); err != nil {
90-
return 0, fmt.Errorf("decode callback response: %w", err)
89+
90+
// A 2xx with a body we can't parse into per-row results is a contract
91+
// violation. We must NOT silently claim all rows succeeded (that would mask
92+
// data never being applied), and we should NOT fail the whole run over one
93+
// bad response. Quarantine the batch's rows so the operator sees it and can
94+
// retry once the callback is fixed. Same for an empty results list.
95+
if err := json.Unmarshal(payload, &cr); err != nil || len(cr.Results) == 0 {
96+
reason := "callback returned no per-row results (expected {\"results\":[{\"row_key\":...,\"ok\":...}]})"
97+
if err != nil {
98+
reason = "callback response was not valid JSON: " + err.Error()
99+
}
100+
101+
quarantineBatch(ctx, quarantine, control, runID, b, reason, log)
102+
103+
return 0, nil
91104
}
92105

93106
var affected, failed int64
@@ -104,18 +117,25 @@ func applyHTTPBatch(
104117
}
105118
}
106119

107-
// Rows the service never mentioned count as applied (fire-and-forget rows).
108-
if len(cr.Results) == 0 {
109-
affected = int64(len(rows))
110-
}
111-
112120
if failed > 0 {
113121
log.Infof("run %d: batch [%v, %v] callback quarantined %d/%d rows", runID, b.Lo, b.Hi, failed, len(rows))
114122
}
115123

116124
return affected, nil
117125
}
118126

127+
// quarantineBatch quarantines every row in a batch with the same reason.
128+
func quarantineBatch(ctx context.Context, quarantine store.QuarantineStore, control store.DB, runID int64, b batch.Bounds, reason string, log store.Logger) {
129+
for _, key := range b.Keys {
130+
enc, _ := batch.Encode(key)
131+
if qErr := quarantine.Add(ctx, control, runID, enc, reason); qErr != nil {
132+
log.Errorf("run %d: quarantine write failed: %v", runID, qErr)
133+
}
134+
}
135+
136+
log.Infof("run %d: batch [%v, %v] callback response unusable, quarantined %d rows: %s", runID, b.Lo, b.Hi, len(b.Keys), reason)
137+
}
138+
119139
// fetchRows loads full rows for the given cursor keys as JSON-ready maps. Each
120140
// row carries a "_row_key" (the encoded cursor value) so the callback service
121141
// can report per-row success/failure back to us.

‎internal/engine/integration_test.go‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,12 @@ func controlSchema(t *testing.T, db *sql.DB) {
104104
t.Fatalf("control alter: %v", err)
105105
}
106106

107+
// Partial unique index: at most one active run per task (matches migration 012).
108+
if _, err := db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS runs_one_active_per_task
109+
ON runs (task_id) WHERE state IN ('queued','running','paused')`); err != nil {
110+
t.Fatalf("control unique index: %v", err)
111+
}
112+
107113
// Clean slate. TRUNCATE ... CASCADE resets identities too.
108114
if _, err := db.Exec(`TRUNCATE run_events, quarantined_rows, checkpoints, runs, tasks, connections RESTART IDENTITY CASCADE`); err != nil {
109115
t.Fatalf("truncate control: %v", err)

0 commit comments

Comments
 (0)