stampeded 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/schema/schema.sql ADDED
@@ -0,0 +1,1049 @@
1
+ CREATE TABLE stampeded_workers (
2
+ id uuid PRIMARY KEY DEFAULT uuidv7(),
3
+ hostname text NOT NULL,
4
+ pid integer NOT NULL,
5
+ queues text[] NOT NULL DEFAULT '{}',
6
+ metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
7
+ started_at timestamptz NOT NULL DEFAULT now(),
8
+ last_heartbeat timestamptz NOT NULL DEFAULT now()
9
+ );
10
+
11
+ CREATE INDEX stampeded_workers_heartbeat_idx ON stampeded_workers (last_heartbeat);
12
+
13
+ CREATE TABLE stampeded_jobs (
14
+ id uuid NOT NULL DEFAULT uuidv7(),
15
+ queue text NOT NULL,
16
+ tenant_id text,
17
+
18
+ task text NOT NULL,
19
+
20
+ args jsonb NOT NULL DEFAULT '{}'::jsonb
21
+ CHECK (pg_column_size(args) <= 1048576),
22
+
23
+ -- One runtime's context for the job, namespaced by runtime: ActiveJob keeps
24
+ -- its job_class, job_id, locale and timezone under "activejob". Every
25
+ -- worker reads `args`; only the runtime that wrote a key here reads it.
26
+ meta jsonb NOT NULL DEFAULT '{}'::jsonb
27
+ CHECK (pg_column_size(meta) <= 1048576),
28
+
29
+ state text NOT NULL DEFAULT 'available',
30
+ priority smallint NOT NULL DEFAULT 0,
31
+
32
+ destination text,
33
+
34
+ scheduled_at timestamptz NOT NULL DEFAULT now(),
35
+
36
+ expire_seconds integer NOT NULL DEFAULT 3600 CHECK (expire_seconds > 0),
37
+
38
+ heartbeat_at timestamptz,
39
+
40
+ attempt smallint NOT NULL DEFAULT 0,
41
+
42
+ max_attempts smallint NOT NULL DEFAULT 25 CHECK (max_attempts >= 1),
43
+
44
+ attempted_by text[] NOT NULL DEFAULT '{}',
45
+
46
+ interruptions smallint NOT NULL DEFAULT 0,
47
+ max_interruptions smallint NOT NULL DEFAULT 1,
48
+
49
+ deferrals smallint NOT NULL DEFAULT 0,
50
+ max_deferrals smallint NOT NULL DEFAULT 100,
51
+
52
+ checkpoint jsonb CHECK (checkpoint IS NULL OR pg_column_size(checkpoint) <= 65536),
53
+ checkpoint_at timestamptz,
54
+
55
+ checkpoint_seq integer NOT NULL DEFAULT 0,
56
+
57
+ backoff_base_ms smallint NOT NULL DEFAULT 1000 CHECK (backoff_base_ms > 0),
58
+ backoff_max_ms integer NOT NULL DEFAULT 3600000 CHECK (backoff_max_ms > 0),
59
+
60
+ claimed_by uuid,
61
+ claimed_at timestamptz,
62
+
63
+ dedup_scope text,
64
+
65
+ dedup_period text CHECK (dedup_period IN
66
+ ('minute','hour','day','week','month')),
67
+
68
+ dedup_key bytea GENERATED ALWAYS AS (
69
+ sha256((
70
+ dedup_scope || coalesce(
71
+ ':' || extract(
72
+ epoch FROM (date_trunc(dedup_period, inserted_at, 'UTC') - to_timestamp(0))
73
+ )::bigint::text,
74
+ ''
75
+ )
76
+ )::bytea)
77
+ ) STORED,
78
+
79
+ failure_kind text,
80
+
81
+ last_error text CHECK (length(last_error) <= 4000),
82
+
83
+ errors jsonb NOT NULL DEFAULT '[]'::jsonb,
84
+
85
+ error_fingerprint bytea,
86
+
87
+ dead_letter_queue text,
88
+
89
+ source_id uuid,
90
+ source_queue text,
91
+ source_attempts smallint,
92
+
93
+ inserted_at timestamptz NOT NULL DEFAULT now(),
94
+ finished_at timestamptz,
95
+
96
+ PRIMARY KEY (queue, id),
97
+
98
+ CONSTRAINT stampeded_jobs_state_valid CHECK (
99
+ state IN ('available','claimed','executing','retryable','completed','discarded','cancelled')
100
+ ),
101
+
102
+ CONSTRAINT stampeded_jobs_failure_kind_valid CHECK (
103
+ failure_kind IS NULL OR failure_kind IN (
104
+ 'raised',
105
+ 'timed_out',
106
+ 'heartbeat_lost',
107
+ 'worker_exited',
108
+ 'worker_missing',
109
+ 'breaker_open',
110
+ 'throttled',
111
+ 'cancelled',
112
+ 'unrecoverable'
113
+ )
114
+ ),
115
+ CONSTRAINT stampeded_jobs_attempt_sane CHECK (attempt >= 0 AND attempt <= max_attempts),
116
+ CONSTRAINT stampeded_jobs_interruptions_sane CHECK (interruptions >= 0),
117
+ CONSTRAINT stampeded_jobs_deferrals_sane CHECK (deferrals >= 0),
118
+
119
+ CONSTRAINT stampeded_jobs_dlq_not_self CHECK (
120
+ dead_letter_queue IS NULL OR dead_letter_queue <> queue
121
+ ),
122
+
123
+ -- The job's named arguments, one object: what a struct in any language
124
+ -- decodes. Positional arguments have no name to decode them by.
125
+ CONSTRAINT stampeded_jobs_args_object CHECK (jsonb_typeof(args) = 'object'),
126
+ CONSTRAINT stampeded_jobs_meta_object CHECK (jsonb_typeof(meta) = 'object')
127
+ )
128
+ PARTITION BY LIST (queue);
129
+
130
+ CREATE TABLE stampeded_jobs_default PARTITION OF stampeded_jobs DEFAULT;
131
+
132
+ CREATE INDEX stampeded_jobs_claim_idx
133
+ ON stampeded_jobs (queue, priority, scheduled_at)
134
+ WHERE state IN ('available', 'retryable');
135
+
136
+ CREATE UNIQUE INDEX stampeded_jobs_dedup_idx
137
+ ON stampeded_jobs (queue, dedup_key)
138
+ WHERE dedup_key IS NOT NULL;
139
+
140
+ CREATE INDEX stampeded_jobs_claimed_idx
141
+ ON stampeded_jobs (claimed_by)
142
+ WHERE state IN ('claimed','executing');
143
+
144
+ CREATE INDEX stampeded_jobs_tenant_idx
145
+ ON stampeded_jobs (tenant_id, state)
146
+ WHERE tenant_id IS NOT NULL;
147
+
148
+ CREATE INDEX stampeded_jobs_finished_idx
149
+ ON stampeded_jobs (finished_at)
150
+ WHERE finished_at IS NOT NULL;
151
+
152
+ CREATE INDEX stampeded_jobs_fingerprint_idx
153
+ ON stampeded_jobs (error_fingerprint)
154
+ WHERE error_fingerprint IS NOT NULL;
155
+
156
+ CREATE INDEX stampeded_jobs_destination_idx
157
+ ON stampeded_jobs (destination, state)
158
+ WHERE destination IS NOT NULL;
159
+
160
+ CREATE INDEX stampeded_jobs_expiry_idx
161
+ ON stampeded_jobs (claimed_at)
162
+ WHERE state = 'executing';
163
+
164
+ ALTER TABLE stampeded_jobs_default SET (
165
+ autovacuum_vacuum_scale_factor = 0.02,
166
+ autovacuum_analyze_scale_factor = 0.02,
167
+ autovacuum_vacuum_cost_limit = 2000,
168
+ fillfactor = 85
169
+ );
170
+
171
+ CREATE FUNCTION stampeded_notify_insert() RETURNS trigger
172
+ LANGUAGE plpgsql AS $$
173
+ BEGIN
174
+ PERFORM pg_notify(current_schema() || '.stampeded_insert', queue)
175
+ FROM (SELECT DISTINCT queue FROM inserted
176
+ WHERE state = 'available' AND scheduled_at <= now()) q;
177
+ RETURN NULL;
178
+ END;
179
+ $$;
180
+
181
+ CREATE TRIGGER stampeded_notify_insert_trg
182
+ AFTER INSERT ON stampeded_jobs
183
+ REFERENCING NEW TABLE AS inserted
184
+ FOR EACH STATEMENT EXECUTE FUNCTION stampeded_notify_insert();
185
+
186
+ CREATE TRIGGER stampeded_notify_insert_trg
187
+ AFTER INSERT ON stampeded_jobs_default
188
+ REFERENCING NEW TABLE AS inserted
189
+ FOR EACH STATEMENT EXECUTE FUNCTION stampeded_notify_insert();
190
+
191
+ CREATE TABLE stampeded_queue_counts (
192
+ queue text NOT NULL,
193
+ state text NOT NULL,
194
+
195
+ n bigint NOT NULL DEFAULT 0,
196
+ PRIMARY KEY (queue, state)
197
+ );
198
+
199
+ CREATE FUNCTION stampeded_recount_queue(p_queue text) RETURNS void
200
+ LANGUAGE plpgsql AS $$
201
+ BEGIN
202
+ DELETE FROM stampeded_queue_counts WHERE queue = p_queue;
203
+ INSERT INTO stampeded_queue_counts (queue, state, n)
204
+ SELECT queue, state, count(*)
205
+ FROM stampeded_jobs
206
+ WHERE queue = p_queue
207
+ GROUP BY queue, state;
208
+ END;
209
+ $$;
210
+
211
+ CREATE FUNCTION stampeded_count_delta() RETURNS trigger
212
+ LANGUAGE plpgsql AS $$
213
+ BEGIN
214
+ IF TG_OP = 'INSERT' THEN
215
+ INSERT INTO stampeded_queue_counts (queue, state, n)
216
+ SELECT queue, state, count(*) FROM added GROUP BY queue, state
217
+ -- Deterministic lock order, same reason as the UPDATE branch below.
218
+ ORDER BY queue, state
219
+ ON CONFLICT (queue, state) DO UPDATE SET n = stampeded_queue_counts.n + excluded.n;
220
+
221
+ ELSIF TG_OP = 'DELETE' THEN
222
+ INSERT INTO stampeded_queue_counts (queue, state, n)
223
+ SELECT queue, state, -count(*) FROM removed GROUP BY queue, state
224
+ -- Deterministic lock order, same reason as the UPDATE branch below.
225
+ ORDER BY queue, state
226
+ ON CONFLICT (queue, state) DO UPDATE SET n = stampeded_queue_counts.n + excluded.n;
227
+
228
+ ELSE
229
+ -- Both directions netted before they touch the table, so a 10k-row
230
+ -- state change is one UPSERT per (queue, state) rather than 10k. The
231
+ -- HAVING drops rows whose state did not change, which is most of them:
232
+ -- a heartbeat UPDATE moves no job between states and should cost
233
+ -- nothing here.
234
+ WITH delta AS (
235
+ SELECT queue, state, -count(*)::bigint AS d FROM removed GROUP BY queue, state
236
+ UNION ALL
237
+ SELECT queue, state, count(*)::bigint FROM added GROUP BY queue, state
238
+ ),
239
+ net AS (
240
+ SELECT queue, state, sum(d) AS d FROM delta GROUP BY queue, state HAVING sum(d) <> 0
241
+ )
242
+ INSERT INTO stampeded_queue_counts (queue, state, n)
243
+ SELECT queue, state, d FROM net
244
+ -- ORDER BY IS LOAD-BEARING: it prevents a deadlock.
245
+ --
246
+ -- A transition statement touches several (queue, state) rows at once --
247
+ -- one decrement, one increment -- and without an order, concurrent
248
+ -- transactions lock them in whatever order the CTE happened to emit.
249
+ -- Worker A locks (default, executing) then (default, completed) while
250
+ -- worker B does the reverse, and Postgres kills one of them with
251
+ -- 40P01.
252
+ --
253
+ -- Reproduced: four workers completing 200 jobs concurrently deadlocked
254
+ -- inside stampeded_complete every run. In production that is a worker
255
+ -- failing to report a finished job, which then sits until the reaper
256
+ -- notices -- a silent stall under exactly the load this design is for.
257
+ ORDER BY queue, state
258
+ ON CONFLICT (queue, state) DO UPDATE SET n = stampeded_queue_counts.n + excluded.n;
259
+ END IF;
260
+
261
+ RETURN NULL;
262
+ END;
263
+ $$;
264
+
265
+ CREATE FUNCTION stampeded_count_truncate() RETURNS trigger
266
+ LANGUAGE plpgsql AS $$
267
+ BEGIN
268
+ DELETE FROM stampeded_queue_counts;
269
+ INSERT INTO stampeded_queue_counts (queue, state, n)
270
+ SELECT queue, state, count(*) FROM stampeded_jobs GROUP BY queue, state;
271
+ RETURN NULL;
272
+ END;
273
+ $$;
274
+
275
+ CREATE FUNCTION stampeded_attach_counters(p_table text) RETURNS void
276
+ LANGUAGE plpgsql AS $$
277
+ BEGIN
278
+ EXECUTE format(
279
+ 'CREATE TRIGGER stampeded_count_ins_trg AFTER INSERT ON %I'
280
+ ' REFERENCING NEW TABLE AS added'
281
+ ' FOR EACH STATEMENT EXECUTE FUNCTION stampeded_count_delta()', p_table);
282
+ EXECUTE format(
283
+ 'CREATE TRIGGER stampeded_count_upd_trg AFTER UPDATE ON %I'
284
+ ' REFERENCING NEW TABLE AS added OLD TABLE AS removed'
285
+ ' FOR EACH STATEMENT EXECUTE FUNCTION stampeded_count_delta()', p_table);
286
+ EXECUTE format(
287
+ 'CREATE TRIGGER stampeded_count_del_trg AFTER DELETE ON %I'
288
+ ' REFERENCING OLD TABLE AS removed'
289
+ ' FOR EACH STATEMENT EXECUTE FUNCTION stampeded_count_delta()', p_table);
290
+ EXECUTE format(
291
+ 'CREATE TRIGGER stampeded_count_trunc_trg AFTER TRUNCATE ON %I'
292
+ ' FOR EACH STATEMENT EXECUTE FUNCTION stampeded_count_truncate()', p_table);
293
+ END;
294
+ $$;
295
+
296
+ DO $$ BEGIN
297
+ PERFORM stampeded_attach_counters('stampeded_jobs');
298
+ PERFORM stampeded_attach_counters('stampeded_jobs_default');
299
+ END $$;
300
+
301
+ -- Per-queue durability.
302
+ --
303
+ -- A partition may be UNLOGGED while its parent is permanent, so one queue can
304
+ -- trade crash-safety for throughput while its neighbours do not: a metrics or
305
+ -- cache-warming queue can be unlogged, a payments queue cannot. An UNLOGGED
306
+ -- table is TRUNCATED after an unclean shutdown -- every job in that queue is
307
+ -- gone, not merely delayed.
308
+ --
309
+ -- MEASURED, and the obvious version of this does nothing. One worker, 8
310
+ -- threads, against a real 8.7 GB database with synchronous_commit ON:
311
+ --
312
+ -- logged queue 792 jobs/sec
313
+ -- UNLOGGED queue, logged counters 734 jobs/sec <-- no gain
314
+ -- UNLOGGED queue, UNLOGGED counters 4,565 jobs/sec <-- 5.8x
315
+ --
316
+ -- The bottleneck is the COMMIT, not the table. Completing a job also updates
317
+ -- stampeded_queue_counts through the counter trigger, so while that table is
318
+ -- permanent every commit still flushes WAL and every job still pays an fsync.
319
+ -- Unlogging the jobs and leaving the counters is the version that looks right
320
+ -- and buys nothing.
321
+ --
322
+ -- So an unlogged queue is only fast if the counters are unlogged too, which is
323
+ -- defensible because they are a derived cache: stampeded_recount_queue()
324
+ -- rebuilds them from the jobs table. That is a GLOBAL decision, though -- the
325
+ -- counters table is shared -- so it is left to the operator rather than
326
+ -- implied by creating one unlogged queue:
327
+ --
328
+ -- SELECT stampeded_create_queue('metrics', p_unlogged => true);
329
+ -- ALTER TABLE stampeded_queue_counts SET UNLOGGED; -- deliberate, global
330
+ CREATE FUNCTION stampeded_create_queue(p_queue text, p_unlogged boolean DEFAULT false)
331
+ RETURNS void
332
+ LANGUAGE plpgsql AS $$
333
+ DECLARE
334
+ part_name text := 'stampeded_jobs_' || regexp_replace(p_queue, '[^a-zA-Z0-9_]', '_', 'g');
335
+ BEGIN
336
+ IF to_regclass(part_name) IS NOT NULL THEN
337
+ RETURN; -- idempotent: provisioning an existing queue is a no-op
338
+ END IF;
339
+
340
+ EXECUTE format(
341
+ 'CREATE %s TABLE %I PARTITION OF stampeded_jobs FOR VALUES IN (%L)',
342
+ CASE WHEN p_unlogged THEN 'UNLOGGED' ELSE '' END, part_name, p_queue);
343
+
344
+ -- Not inherited from the parent. See the note on the parent table.
345
+ EXECUTE format(
346
+ 'ALTER TABLE %I SET ('
347
+ ' fillfactor = 85,'
348
+ ' autovacuum_vacuum_scale_factor = 0.02,'
349
+ ' autovacuum_analyze_scale_factor = 0.02,'
350
+ ' autovacuum_vacuum_cost_limit = 2000)',
351
+ part_name);
352
+
353
+ -- Direct inserts into a partition bypass a parent-only trigger.
354
+ EXECUTE format(
355
+ 'CREATE TRIGGER stampeded_notify_insert_trg'
356
+ ' AFTER INSERT ON %I'
357
+ ' REFERENCING NEW TABLE AS inserted'
358
+ ' FOR EACH STATEMENT EXECUTE FUNCTION stampeded_notify_insert()',
359
+ part_name);
360
+
361
+ -- Counters are per-partition for the same reason the notify trigger is.
362
+ PERFORM stampeded_attach_counters(part_name);
363
+ END;
364
+ $$;
365
+
366
+ CREATE FUNCTION stampeded_claim(
367
+ p_queue text,
368
+ p_worker_id uuid,
369
+ p_limit integer DEFAULT 1,
370
+
371
+ p_now timestamptz DEFAULT NULL
372
+ ) RETURNS SETOF stampeded_jobs
373
+ LANGUAGE sql AS $$
374
+ WITH candidate AS MATERIALIZED (
375
+ SELECT id
376
+ FROM stampeded_jobs
377
+ WHERE queue = p_queue
378
+ -- 'retryable' is claimable once its backoff has elapsed. That can be
379
+ -- handled by a claim predicate, a separate promotion pass, or both.
380
+ -- The predicate is chosen here for having no moving parts -- a
381
+ -- promotion pass is another process that can fall behind or die.
382
+ --
383
+ -- Omitting 'retryable' here is not a missing feature, it is a silent
384
+ -- poison-job bug: the reaper parks a crashed job in 'retryable', a
385
+ -- claim that only reads 'available' never picks it up again, the
386
+ -- attempt budget never walks down, and max_attempts is never
387
+ -- reached. Found by test, not by reading.
388
+ AND state IN ('available','retryable')
389
+ AND scheduled_at <= coalesce(p_now, now())
390
+ AND attempt < max_attempts
391
+ -- Must match stampeded_jobs_claim_idx EXACTLY. `id` is not a
392
+ -- tiebreaker here: adding one reintroduces a sort unless it is also
393
+ -- added to the index, and in the index it costs 8.5x the size by
394
+ -- defeating btree deduplication. See the claim index note in NOTES.md.
395
+ ORDER BY priority, scheduled_at
396
+ LIMIT p_limit
397
+ FOR UPDATE SKIP LOCKED
398
+ )
399
+ UPDATE stampeded_jobs j
400
+ SET state = 'executing',
401
+ attempt = j.attempt + 1,
402
+ -- Seed the job heartbeat at claim so stampeded_expire has a baseline
403
+ -- for a job that never calls stampeded_touch. Left NULL it would fall
404
+ -- back to claimed_at, which is the same thing -- but only by accident,
405
+ -- and the expiry predicate should not depend on that coincidence.
406
+ heartbeat_at = coalesce(p_now, now()),
407
+ claimed_by = p_worker_id,
408
+ claimed_at = coalesce(p_now, now()),
409
+ -- Bounded ring buffer. Unbounded attempt history is a slow leak on
410
+ -- heavily-retried jobs; keep the last 10.
411
+ attempted_by = array_append(
412
+ CASE WHEN coalesce(array_length(j.attempted_by, 1), 0) >= 10
413
+ THEN j.attempted_by[coalesce(array_length(j.attempted_by,1),0) - 8 :]
414
+ ELSE j.attempted_by
415
+ END,
416
+ p_worker_id::text)
417
+ FROM candidate c
418
+ WHERE j.queue = p_queue
419
+ AND j.id = c.id
420
+ RETURNING j.*;
421
+ $$;
422
+
423
+ CREATE FUNCTION stampeded_complete(
424
+ p_queue text,
425
+ p_id uuid,
426
+ p_worker_id uuid,
427
+ p_attempt smallint
428
+ ) RETURNS TABLE (was text, now_state text, owned boolean)
429
+ LANGUAGE sql AS $$
430
+ UPDATE stampeded_jobs
431
+ SET state = 'completed',
432
+ finished_at = now(),
433
+ dedup_scope = NULL,
434
+ -- Dead weight once the job is done, on the one table that already
435
+ -- has the bloat problem. Consistent with the fire-and-forget
436
+ -- decision: we do not keep results either.
437
+ checkpoint = NULL,
438
+ claimed_by = NULL
439
+ WHERE queue = p_queue
440
+ AND id = p_id
441
+ AND claimed_by = p_worker_id -- refuse to complete a job we no longer own
442
+ AND attempt = p_attempt -- ...and refuse a PREVIOUS attempt's result
443
+ RETURNING OLD.state, NEW.state, true;
444
+ $$;
445
+
446
+ CREATE FUNCTION stampeded_fingerprint(p_error text) RETURNS bytea
447
+ LANGUAGE sql IMMUTABLE AS $$
448
+ SELECT sha256(convert_to(
449
+ regexp_replace(
450
+ regexp_replace(
451
+ regexp_replace(
452
+ lower(coalesce(p_error, '')),
453
+ '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}', 'U', 'g'),
454
+ '0x[0-9a-f]+|\m[0-9a-f]{16,}\M', 'X', 'g'),
455
+ '[0-9]+', 'N', 'g'),
456
+ 'UTF8')::bytea);
457
+ $$;
458
+
459
+ CREATE FUNCTION stampeded_fail(
460
+ p_queue text,
461
+ p_id uuid,
462
+ p_worker_id uuid,
463
+ p_attempt smallint,
464
+ p_kind text,
465
+ p_error text,
466
+
467
+ p_retry_delay_ms bigint DEFAULT NULL,
468
+
469
+ p_exit_status integer DEFAULT NULL
470
+ ) RETURNS TABLE (was text, now_state text, dead_lettered boolean)
471
+ LANGUAGE plpgsql AS $$
472
+ DECLARE
473
+ j stampeded_jobs%ROWTYPE;
474
+ v_was text;
475
+ v_state text;
476
+ v_interrupt boolean;
477
+ v_refund boolean;
478
+ v_terminal boolean;
479
+ v_dlq boolean := false;
480
+ BEGIN
481
+ SELECT * INTO j FROM stampeded_jobs
482
+ WHERE queue = p_queue AND id = p_id AND claimed_by = p_worker_id
483
+ AND attempt = p_attempt;
484
+ IF NOT FOUND THEN
485
+ -- Not ours: the reaper already reclaimed it and somebody else owns the
486
+ -- outcome. Writing a failure over another worker's result would be
487
+ -- worse than doing nothing.
488
+ RETURN;
489
+ END IF;
490
+
491
+ v_was := j.state;
492
+
493
+ -- Did the worker die, rather than the job fail?
494
+ v_interrupt := p_kind IN ('heartbeat_lost', 'worker_exited', 'worker_missing');
495
+ -- Did WE refuse to run it?
496
+ -- Both are US declining to run the job: one because the downstream is
497
+ -- sick, one because the tenant is over its share. Kept as separate kinds
498
+ -- because the operator response differs -- "the API is down" and "this
499
+ -- customer is flooding us" want different people woken up.
500
+ v_refund := p_kind IN ('breaker_open', 'throttled');
501
+
502
+ -- The correctness rule: a failure caused by our own backpressure must not
503
+ -- count against the job's retry budget. The attempt was spent at claim, so
504
+ -- declining to run the job has to give it back -- otherwise a downstream
505
+ -- outage silently burns every job's budget and a fleet-wide breaker trip
506
+ -- discards the entire backlog.
507
+ v_terminal := CASE
508
+ -- A deferral is terminal only when the job has been waiting on a
509
+ -- dependency for so long that the dependency is evidently not coming
510
+ -- back. Everything else about the refund still applies: the attempt is
511
+ -- returned, because none of this was the job's doing.
512
+ WHEN v_refund THEN j.deferrals + 1 > j.max_deferrals
513
+ WHEN p_kind = 'unrecoverable' THEN true
514
+ WHEN v_interrupt THEN j.interruptions + 1 > j.max_interruptions
515
+ ELSE j.attempt >= j.max_attempts
516
+ END;
517
+
518
+ v_state := CASE WHEN v_terminal THEN 'discarded' ELSE 'retryable' END;
519
+
520
+ UPDATE stampeded_jobs
521
+ SET state = v_state,
522
+ failure_kind = p_kind,
523
+ attempt = CASE WHEN v_refund THEN greatest(attempt - 1, 0) ELSE attempt END,
524
+ interruptions = CASE WHEN v_interrupt THEN interruptions + 1 ELSE interruptions END,
525
+ deferrals = CASE WHEN v_refund THEN deferrals + 1 ELSE deferrals END,
526
+ -- Truncated in SQL, not in client code: it is the only bound that
527
+ -- survives a polyglot worker.
528
+ last_error = left(p_error, 4000),
529
+ error_fingerprint = stampeded_fingerprint(p_error),
530
+ -- Ring buffer, newest last, capped at 10. Bounding attempted_by
531
+ -- alone would be half a fix, and the half that actually leaks,
532
+ -- since error text dwarfs a worker id.
533
+ errors = (
534
+ SELECT coalesce(jsonb_agg(e), '[]'::jsonb) FROM (
535
+ SELECT e FROM jsonb_array_elements(
536
+ errors || jsonb_build_object(
537
+ 'at', now(),
538
+ 'attempt', j.attempt,
539
+ 'kind', p_kind,
540
+ 'message', left(p_error, 1000),
541
+ 'destination', j.destination,
542
+ 'exit_status', p_exit_status
543
+ )
544
+ ) e OFFSET greatest(jsonb_array_length(errors) + 1 - 10, 0)
545
+ ) t
546
+ ),
547
+ scheduled_at = CASE
548
+ WHEN p_retry_delay_ms IS NULL THEN scheduled_at
549
+ ELSE now() + make_interval(secs => p_retry_delay_ms / 1000.0)
550
+ END,
551
+ claimed_by = NULL,
552
+ claimed_at = NULL,
553
+ heartbeat_at = NULL,
554
+ -- Release the dedup slot only on a terminal outcome; a retryable job
555
+ -- keeps its slot so a duplicate cannot be enqueued while it waits.
556
+ dedup_scope = CASE WHEN v_terminal THEN NULL ELSE dedup_scope END,
557
+ finished_at = CASE WHEN v_terminal THEN now() ELSE NULL END
558
+ WHERE queue = p_queue AND id = p_id AND claimed_by = p_worker_id
559
+ AND attempt = p_attempt;
560
+
561
+ -- Dead letter, with provenance. The copy carries where it came from and how
562
+ -- hard it tried, because a DLQ row with no history is an alert nobody can
563
+ -- action. Its own dead_letter_queue is left NULL: a dead letter that can
564
+ -- itself dead-letter is a loop.
565
+ IF v_terminal AND j.dead_letter_queue IS NOT NULL THEN
566
+ INSERT INTO stampeded_jobs
567
+ (queue, task, args, meta, tenant_id, priority, destination,
568
+ source_id, source_queue, source_attempts, max_attempts)
569
+ VALUES
570
+ (j.dead_letter_queue, j.task, j.args, j.meta, j.tenant_id, j.priority, j.destination,
571
+ j.id, j.queue, j.attempt, j.max_attempts);
572
+ v_dlq := true;
573
+ END IF;
574
+
575
+ RETURN QUERY SELECT v_was, v_state, v_dlq;
576
+ END;
577
+ $$;
578
+
579
+ CREATE FUNCTION stampeded_release(
580
+ p_queue text,
581
+ p_id uuid,
582
+ p_worker_id uuid,
583
+ p_attempt smallint
584
+ ) RETURNS TABLE (was text, now_state text, owned boolean)
585
+ LANGUAGE sql AS $$
586
+ -- Undoes the claim and records nothing: a job its worker never started has not failed.
587
+ UPDATE stampeded_jobs
588
+ SET state = CASE WHEN failure_kind IS NULL THEN 'available' ELSE 'retryable' END,
589
+ attempt = greatest(attempt - 1, 0),
590
+ claimed_by = NULL,
591
+ claimed_at = NULL,
592
+ heartbeat_at = NULL
593
+ WHERE queue = p_queue
594
+ AND id = p_id
595
+ AND claimed_by = p_worker_id
596
+ AND attempt = p_attempt
597
+ AND state = 'executing'
598
+ RETURNING OLD.state, NEW.state, true;
599
+ $$;
600
+
601
+ CREATE FUNCTION stampeded_touch(p_queue text, p_id uuid, p_worker_id uuid, p_attempt smallint)
602
+ RETURNS boolean
603
+ LANGUAGE sql AS $$
604
+ UPDATE stampeded_jobs SET heartbeat_at = now()
605
+ WHERE queue = p_queue AND id = p_id
606
+ AND claimed_by = p_worker_id AND state = 'executing'
607
+ AND attempt = p_attempt
608
+ RETURNING true;
609
+ $$;
610
+
611
+ CREATE FUNCTION stampeded_checkpoint(
612
+ p_queue text,
613
+ p_id uuid,
614
+ p_worker_id uuid,
615
+ p_attempt smallint,
616
+ p_state jsonb
617
+ ) RETURNS integer
618
+ LANGUAGE plpgsql AS $$
619
+ DECLARE
620
+ -- An eighth of the 64 KB cap: enough runway that a job growing steadily
621
+ -- has thousands of writes left when it is told.
622
+ c_warn_bytes constant int := 8192;
623
+ v_seq integer;
624
+ v_old_size integer;
625
+ -- length(::text), NOT pg_column_size.
626
+ --
627
+ -- pg_column_size reports the STORED size, which for a stored jsonb is
628
+ -- TOAST-compressed and for a parameter is not -- so comparing the two
629
+ -- compares compressed against uncompressed. A stable 20 KB checkpoint of
630
+ -- repetitive data compresses to a few hundred bytes on disk and then looks
631
+ -- like it grew 70x on every single write, warning forever about a job
632
+ -- doing nothing wrong. Found by testing the large-but-stable case, which
633
+ -- is the one that distinguishes this heuristic from the size cap.
634
+ --
635
+ -- The text length is the logical size on both sides.
636
+ v_new_size integer := length(p_state::text);
637
+ BEGIN
638
+ UPDATE stampeded_jobs
639
+ SET checkpoint = p_state,
640
+ checkpoint_at = now(),
641
+ checkpoint_seq = checkpoint_seq + 1,
642
+ heartbeat_at = now(),
643
+ interruptions = 0
644
+ WHERE queue = p_queue
645
+ AND id = p_id
646
+ AND claimed_by = p_worker_id
647
+ AND attempt = p_attempt
648
+ AND state = 'executing'
649
+ RETURNING checkpoint_seq, length(OLD.checkpoint::text)
650
+ INTO v_seq, v_old_size;
651
+
652
+ IF v_seq IS NOT NULL
653
+ AND v_new_size > c_warn_bytes
654
+ AND v_old_size IS NOT NULL
655
+ AND v_new_size > v_old_size
656
+ THEN
657
+ RAISE WARNING
658
+ 'checkpoint for job % is % bytes and growing (was %); the 64 KB cap '
659
+ 'will discard this job if it continues. Store a cursor ("I have done '
660
+ 'N") rather than the set of what was done.',
661
+ p_id, v_new_size, v_old_size;
662
+ END IF;
663
+
664
+ RETURN v_seq;
665
+ END;
666
+ $$;
667
+
668
+ CREATE FUNCTION stampeded_expire(p_limit integer DEFAULT 100)
669
+ RETURNS TABLE (out_id uuid, out_queue text, out_worker uuid)
670
+ LANGUAGE plpgsql AS $$
671
+ DECLARE
672
+ r record;
673
+ BEGIN
674
+ FOR r IN
675
+ SELECT id, queue, claimed_by, attempt, expire_seconds
676
+ FROM stampeded_jobs
677
+ WHERE state = 'executing'
678
+ -- From the last sign of life, not from claim: a job that is touching
679
+ -- is working, however long it has been running.
680
+ AND coalesce(heartbeat_at, claimed_at) < now() - make_interval(secs => expire_seconds)
681
+ LIMIT p_limit
682
+ FOR UPDATE SKIP LOCKED
683
+ LOOP
684
+ PERFORM stampeded_fail(
685
+ r.queue, r.id, r.claimed_by, r.attempt, 'timed_out',
686
+ format('exceeded expire_seconds (%s)', r.expire_seconds), NULL, NULL);
687
+ out_id := r.id; out_queue := r.queue; out_worker := r.claimed_by;
688
+ RETURN NEXT;
689
+ END LOOP;
690
+ END;
691
+ $$;
692
+
693
+ CREATE FUNCTION stampeded_reap(
694
+ p_dead_after interval DEFAULT interval '5 minutes'
695
+ ) RETURNS TABLE (id uuid, queue text, worker_id uuid)
696
+ LANGUAGE sql AS $$
697
+ WITH dead AS MATERIALIZED (
698
+ SELECT w.id FROM stampeded_workers w
699
+ WHERE w.last_heartbeat < now() - p_dead_after
700
+ )
701
+ UPDATE stampeded_jobs j
702
+ -- TWO budgets now, and the tighter one usually wins. `attempt` was spent
703
+ -- at claim (the poison brake); `interruptions` is the brake specific to
704
+ -- jobs that KILL their worker, defaulting to one free one. A job that
705
+ -- segfaults every worker it touches hits max_interruptions after two
706
+ -- claims instead of walking 25 workers down first.
707
+ SET state = CASE WHEN j.interruptions + 1 > j.max_interruptions
708
+ OR j.attempt >= j.max_attempts
709
+ THEN 'discarded' ELSE 'retryable' END,
710
+ -- Named distinctly from a raise: conflating the two is what makes
711
+ -- "why did this run twice" unanswerable.
712
+ failure_kind = 'worker_missing',
713
+ interruptions = j.interruptions + 1,
714
+ last_error = 'worker stopped heartbeating; job reclaimed',
715
+ error_fingerprint = stampeded_fingerprint('worker stopped heartbeating; job reclaimed'),
716
+ claimed_by = NULL,
717
+ claimed_at = NULL,
718
+ heartbeat_at = NULL,
719
+ finished_at = CASE WHEN j.interruptions + 1 > j.max_interruptions
720
+ OR j.attempt >= j.max_attempts THEN now() END,
721
+ dedup_scope = CASE WHEN j.interruptions + 1 > j.max_interruptions
722
+ OR j.attempt >= j.max_attempts
723
+ THEN NULL ELSE j.dedup_scope END
724
+ FROM dead d
725
+ WHERE j.claimed_by = d.id
726
+ AND j.state IN ('claimed','executing')
727
+ RETURNING j.id, j.queue, d.id;
728
+ $$;
729
+
730
+ CREATE FUNCTION stampeded_prune(
731
+ p_older_than interval DEFAULT interval '1 hour',
732
+ p_limit integer DEFAULT 10000,
733
+ p_failed_older_than interval DEFAULT interval '7 days'
734
+ ) RETURNS integer
735
+ LANGUAGE sql AS $$
736
+ WITH doomed AS MATERIALIZED (
737
+ SELECT queue, id FROM stampeded_jobs
738
+ WHERE finished_at IS NOT NULL
739
+ AND CASE state
740
+ WHEN 'completed' THEN finished_at < now() - p_older_than
741
+ WHEN 'discarded' THEN finished_at < now() - p_failed_older_than
742
+ WHEN 'cancelled' THEN finished_at < now() - p_failed_older_than
743
+ ELSE false
744
+ END
745
+ ORDER BY finished_at
746
+ LIMIT p_limit
747
+ FOR UPDATE SKIP LOCKED
748
+ ),
749
+ -- COUNTED, not `RETURNING 1`.
750
+ --
751
+ -- A SQL function declared RETURNS integer yields the first column of the
752
+ -- FIRST ROW, so `DELETE ... RETURNING 1` returns 1 whether it deleted one
753
+ -- row or ten thousand (and NULL for none). The caller loops until the
754
+ -- returned count is less than the batch size -- with a constant 1 that
755
+ -- condition is always true, so the loop always exited after one batch and
756
+ -- a large backlog was pruned p_limit rows per TICK instead of per
757
+ -- iteration. The pruner looked correct and quietly did a fraction of the
758
+ -- work.
759
+ deleted AS (
760
+ DELETE FROM stampeded_jobs j
761
+ USING doomed d
762
+ WHERE j.queue = d.queue AND j.id = d.id
763
+ RETURNING 1
764
+ )
765
+ SELECT count(*)::int FROM deleted;
766
+ $$;
767
+
768
+ CREATE FUNCTION stampeded_vacuum_health()
769
+ RETURNS TABLE (
770
+ relname text,
771
+ live_tuples bigint,
772
+ dead_tuples bigint,
773
+ dead_ratio double precision,
774
+ autovac_threshold double precision,
775
+ needs_vacuum boolean,
776
+ last_autovacuum timestamptz
777
+ )
778
+ LANGUAGE sql AS $$
779
+ SELECT
780
+ c.relname::text,
781
+ s.n_live_tup,
782
+ s.n_dead_tup,
783
+ CASE WHEN s.n_live_tup > 0
784
+ THEN s.n_dead_tup::float8 / s.n_live_tup
785
+ ELSE 0 END,
786
+ -- The formula autovacuum itself uses, with this table's own settings
787
+ -- where they are set and the cluster defaults where they are not.
788
+ threshold.base + threshold.factor * s.n_live_tup,
789
+ s.n_dead_tup > threshold.base + threshold.factor * s.n_live_tup,
790
+ s.last_autovacuum
791
+ FROM pg_stat_user_tables s
792
+ JOIN pg_class c ON c.oid = s.relid
793
+ CROSS JOIN LATERAL (
794
+ SELECT
795
+ coalesce(
796
+ (SELECT option_value::float8 FROM pg_options_to_table(c.reloptions)
797
+ WHERE option_name = 'autovacuum_vacuum_threshold'),
798
+ current_setting('autovacuum_vacuum_threshold')::float8) AS base,
799
+ coalesce(
800
+ (SELECT option_value::float8 FROM pg_options_to_table(c.reloptions)
801
+ WHERE option_name = 'autovacuum_vacuum_scale_factor'),
802
+ current_setting('autovacuum_vacuum_scale_factor')::float8) AS factor
803
+ ) threshold
804
+ WHERE c.relname LIKE 'stampeded_jobs%'
805
+ -- SCOPED TO THIS SCHEMA. pg_stat_user_tables spans the whole database,
806
+ -- so without this a second Stampeded install -- or, here, a few hundred
807
+ -- leftover test schemas -- reports as this one's partitions. Found by
808
+ -- running it and getting 200 rows for a 2-partition install.
809
+ AND s.schemaname = current_schema()
810
+ ORDER BY s.n_dead_tup DESC;
811
+ $$;
812
+
813
+ CREATE FUNCTION stampeded_index_density()
814
+ RETURNS TABLE (
815
+ indexname text,
816
+ pages bigint,
817
+ entries bigint,
818
+ per_page double precision,
819
+ sparse boolean
820
+ )
821
+ LANGUAGE sql AS $$
822
+ SELECT
823
+ c.relname::text,
824
+ c.relpages::bigint,
825
+ c.reltuples::bigint,
826
+ CASE WHEN c.relpages > 0 THEN c.reltuples / c.relpages ELSE 0 END,
827
+ c.relpages >= 128
828
+ AND CASE WHEN c.relpages > 0 THEN c.reltuples / c.relpages ELSE 0 END < 5
829
+ FROM pg_class c
830
+ JOIN pg_index i ON i.indexrelid = c.oid
831
+ JOIN pg_class t ON t.oid = i.indrelid
832
+ WHERE c.relkind = 'i'
833
+ AND t.relname LIKE 'stampeded_%'
834
+ -- Skip REINDEX CONCURRENTLY's leftovers rather than trying to rebuild
835
+ -- them: _ccnew/_ccold are transient artefacts and retrying one is how a
836
+ -- reindexer gets stuck in a loop.
837
+ AND c.relname NOT LIKE '%\_ccnew%'
838
+ AND c.relname NOT LIKE '%\_ccold%'
839
+ -- SCOPED TO THIS SCHEMA. pg_class spans the whole database, so without
840
+ -- this a second Stampeded install's indexes -- or, when this was first
841
+ -- run, a few hundred leftover test schemas -- are reported as ours.
842
+ -- 1167 indexes for a two-partition install is how it was noticed.
843
+ AND t.relnamespace = current_schema()::regnamespace
844
+ ORDER BY 4;
845
+ $$;
846
+
847
+ CREATE FUNCTION stampeded_heartbeat(p_worker_id uuid) RETURNS void
848
+ LANGUAGE sql AS $$
849
+ UPDATE stampeded_workers SET last_heartbeat = now() WHERE id = p_worker_id;
850
+ $$;
851
+
852
+ CREATE FUNCTION stampeded_partition_health()
853
+ RETURNS TABLE (
854
+ partition text,
855
+ queue text,
856
+ has_notify boolean,
857
+ has_counts boolean
858
+ )
859
+ LANGUAGE sql STABLE AS $$
860
+ SELECT c.relname::text,
861
+ -- The bound is `FOR VALUES IN ('name')`; pull the queue name back
862
+ -- out rather than assuming the table is named for its queue.
863
+ --
864
+ -- stampeded_jobs_default is the DEFAULT partition, not a partition
865
+ -- for a queue called "default" -- it catches every queue that has
866
+ -- none of its own. Labelling it "default" would be wrong in the
867
+ -- confusing direction: it would read as one queue among many when
868
+ -- it is the fallback for all of them.
869
+ coalesce(
870
+ substring(pg_get_expr(c.relpartbound, c.oid) from $re$FOR VALUES IN \('(.*)'\)$re$),
871
+ CASE WHEN pg_get_expr(c.relpartbound, c.oid) = 'DEFAULT'
872
+ THEN '(catch-all for queues with no partition)'
873
+ ELSE c.relname::text END
874
+ ),
875
+ EXISTS (SELECT 1 FROM pg_trigger t
876
+ WHERE t.tgrelid = c.oid AND NOT t.tgisinternal
877
+ AND t.tgname = 'stampeded_notify_insert_trg'),
878
+ EXISTS (SELECT 1 FROM pg_trigger t
879
+ WHERE t.tgrelid = c.oid AND NOT t.tgisinternal
880
+ AND t.tgname = 'stampeded_count_ins_trg')
881
+ FROM pg_class c
882
+ JOIN pg_inherits i ON i.inhrelid = c.oid
883
+ JOIN pg_class p ON p.oid = i.inhparent
884
+ JOIN pg_namespace n ON n.oid = c.relnamespace
885
+ WHERE p.relname = 'stampeded_jobs'
886
+ AND n.nspname = current_schema()
887
+ ORDER BY 1;
888
+ $$;
889
+
890
+ CREATE TABLE stampeded_schedules (
891
+ key text PRIMARY KEY,
892
+
893
+ queue text NOT NULL,
894
+ task text NOT NULL,
895
+ args jsonb NOT NULL DEFAULT '{}'::jsonb,
896
+ CONSTRAINT stampeded_schedules_args_object CHECK (jsonb_typeof(args) = 'object'),
897
+ meta jsonb NOT NULL DEFAULT '{}'::jsonb,
898
+ CONSTRAINT stampeded_schedules_meta_object CHECK (jsonb_typeof(meta) = 'object'),
899
+ tenant_id text,
900
+ priority smallint NOT NULL DEFAULT 0,
901
+
902
+ interval_secs bigint CHECK (interval_secs > 0),
903
+ cron_expr text,
904
+ CONSTRAINT stampeded_schedules_one_rule CHECK (
905
+ (interval_secs IS NULL) <> (cron_expr IS NULL)
906
+ ),
907
+
908
+ next_run_at timestamptz NOT NULL DEFAULT now(),
909
+ last_run_at timestamptz,
910
+
911
+ dst_policy text NOT NULL DEFAULT 'instant'
912
+ CHECK (dst_policy IN ('instant', 'wall_clock')),
913
+ timezone text NOT NULL DEFAULT 'UTC',
914
+
915
+ missed_policy text NOT NULL DEFAULT 'once'
916
+ CHECK (missed_policy IN ('once', 'skip')),
917
+
918
+ paused boolean NOT NULL DEFAULT false,
919
+ created_at timestamptz NOT NULL DEFAULT now(),
920
+ updated_at timestamptz NOT NULL DEFAULT now()
921
+ );
922
+
923
+ CREATE INDEX stampeded_schedules_due_idx
924
+ ON stampeded_schedules (next_run_at)
925
+ WHERE NOT paused;
926
+
927
+ CREATE TABLE stampeded_cron_occurrences (
928
+ id bigserial PRIMARY KEY,
929
+ cron_key text NOT NULL REFERENCES stampeded_schedules(key) ON DELETE CASCADE,
930
+
931
+ occurrence_token text NOT NULL,
932
+
933
+ job_id uuid,
934
+ enqueued_at timestamptz NOT NULL DEFAULT now(),
935
+
936
+ UNIQUE (cron_key, occurrence_token)
937
+ );
938
+
939
+ CREATE INDEX stampeded_cron_occurrences_enqueued_idx
940
+ ON stampeded_cron_occurrences (enqueued_at);
941
+
942
+ CREATE FUNCTION stampeded_claim_due_schedules(p_limit integer DEFAULT 100)
943
+ RETURNS SETOF stampeded_schedules
944
+ LANGUAGE plpgsql AS $$
945
+ BEGIN
946
+ RETURN QUERY
947
+ SELECT * FROM stampeded_schedules
948
+ WHERE NOT paused AND next_run_at <= now()
949
+ ORDER BY next_run_at
950
+ LIMIT p_limit
951
+ FOR UPDATE SKIP LOCKED;
952
+ END;
953
+ $$;
954
+
955
+ CREATE FUNCTION stampeded_fire_schedule(p_key text, p_next_run_at timestamptz)
956
+ RETURNS uuid
957
+ LANGUAGE plpgsql AS $$
958
+ DECLARE
959
+ s stampeded_schedules%ROWTYPE;
960
+ v_token text;
961
+ v_job uuid;
962
+ -- Separate from v_job: the ledger's id is bigserial, the job's is uuid.
963
+ -- Reusing one variable for both is a runtime cast error, not a compile one.
964
+ v_occ bigint;
965
+ BEGIN
966
+ SELECT * INTO s FROM stampeded_schedules WHERE key = p_key;
967
+ IF NOT FOUND THEN
968
+ RETURN NULL;
969
+ END IF;
970
+
971
+ v_token := CASE s.dst_policy
972
+ WHEN 'wall_clock' THEN
973
+ to_char(s.next_run_at AT TIME ZONE s.timezone, 'YYYY-MM-DD HH24:MI')
974
+ ELSE
975
+ extract(epoch FROM s.next_run_at)::bigint::text
976
+ END;
977
+
978
+ INSERT INTO stampeded_cron_occurrences (cron_key, occurrence_token)
979
+ VALUES (s.key, v_token)
980
+ ON CONFLICT (cron_key, occurrence_token) DO NOTHING
981
+ RETURNING id INTO v_occ;
982
+
983
+ IF v_occ IS NULL THEN
984
+ UPDATE stampeded_schedules
985
+ SET next_run_at = p_next_run_at, updated_at = now()
986
+ WHERE key = s.key;
987
+ RETURN NULL;
988
+ END IF;
989
+
990
+ INSERT INTO stampeded_jobs (queue, task, args, meta, tenant_id, priority)
991
+ VALUES (s.queue, s.task, s.args, s.meta, s.tenant_id, s.priority)
992
+ RETURNING id INTO v_job;
993
+
994
+ UPDATE stampeded_cron_occurrences
995
+ SET job_id = v_job
996
+ WHERE cron_key = s.key AND occurrence_token = v_token;
997
+
998
+ UPDATE stampeded_schedules
999
+ SET next_run_at = p_next_run_at, last_run_at = now(), updated_at = now()
1000
+ WHERE key = s.key;
1001
+
1002
+ RETURN v_job;
1003
+ END;
1004
+ $$;
1005
+
1006
+ CREATE FUNCTION stampeded_prune_occurrences(p_older_than interval DEFAULT interval '30 days')
1007
+ RETURNS integer
1008
+ LANGUAGE sql AS $$
1009
+ WITH doomed AS (
1010
+ SELECT id FROM stampeded_cron_occurrences
1011
+ WHERE enqueued_at < now() - p_older_than
1012
+ LIMIT 10000
1013
+ )
1014
+ -- Counted, for the same reason as stampeded_prune: RETURNING 1 through a
1015
+ -- RETURNS integer function is a constant, not a count.
1016
+ , deleted AS (
1017
+ DELETE FROM stampeded_cron_occurrences o USING doomed d
1018
+ WHERE o.id = d.id
1019
+ RETURNING 1
1020
+ )
1021
+ SELECT count(*)::int FROM deleted;
1022
+ $$;
1023
+
1024
+ -- Per-queue policy, and the daemon planner's verdict on it.
1025
+ --
1026
+ -- Operators write the policy columns. The planner evaluates one behavior tree
1027
+ -- for every row each tick and writes the verdict columns; workers read them
1028
+ -- when choosing which queues to claim from. A queue with no row is steady, and
1029
+ -- so is a verdict older than the reader's freshness window: a dead daemon must
1030
+ -- degrade to plain round robin, not freeze the last verdicts it wrote.
1031
+ --
1032
+ -- No index beyond the key, so the planner's updates stay HOT on a table that
1033
+ -- is rewritten every few seconds.
1034
+ CREATE TABLE stampeded_queue_policies (
1035
+ queue text PRIMARY KEY,
1036
+ -- Claimed plus executing across the fleet. Soft: workers read the verdict,
1037
+ -- so two can claim in the same planner interval.
1038
+ max_running integer CHECK (max_running IS NULL OR max_running > 0),
1039
+ -- How long the next job in claim order may wait before the queue is rushed.
1040
+ max_wait_secs integer CHECK (max_wait_secs IS NULL OR max_wait_secs > 0),
1041
+
1042
+ verdict text NOT NULL DEFAULT 'steady'
1043
+ CHECK (verdict IN ('steady', 'rush', 'hold')),
1044
+ reason text,
1045
+ decided_at timestamptz,
1046
+
1047
+ created_at timestamptz NOT NULL DEFAULT now(),
1048
+ updated_at timestamptz NOT NULL DEFAULT now()
1049
+ );