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.
- checksums.yaml +7 -0
- data/LICENSE +28 -0
- data/README.md +206 -0
- data/exe/stampeded +13 -0
- data/lib/active_job/queue_adapters/stampeded_adapter.rb +58 -0
- data/lib/generators/stampeded/install/install_generator.rb +47 -0
- data/lib/generators/stampeded/install/templates/stampeded.rb +13 -0
- data/lib/stampeded/active_job_ext.rb +89 -0
- data/lib/stampeded/active_job_worker.rb +28 -0
- data/lib/stampeded/client.rb +47 -0
- data/lib/stampeded/configuration.rb +41 -0
- data/lib/stampeded/connection.rb +82 -0
- data/lib/stampeded/continuable_ext.rb +40 -0
- data/lib/stampeded/current.rb +19 -0
- data/lib/stampeded/daemon.rb +51 -0
- data/lib/stampeded/envelope.rb +86 -0
- data/lib/stampeded/errors.rb +68 -0
- data/lib/stampeded/job.rb +85 -0
- data/lib/stampeded/job_context.rb +49 -0
- data/lib/stampeded/lifecycle.rb +30 -0
- data/lib/stampeded/native.rb +233 -0
- data/lib/stampeded/queue_policy.rb +39 -0
- data/lib/stampeded/railtie.rb +23 -0
- data/lib/stampeded/schedule.rb +102 -0
- data/lib/stampeded/schema.rb +68 -0
- data/lib/stampeded/steps.rb +94 -0
- data/lib/stampeded/tasks/stampeded.rake +32 -0
- data/lib/stampeded/version.rb +5 -0
- data/lib/stampeded/worker.rb +224 -0
- data/lib/stampeded.rb +72 -0
- data/schema/schema.sql +1049 -0
- metadata +108 -0
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
|
+
);
|