@pgpm/jobs 0.4.0 → 0.6.0

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.
@@ -6,6 +6,53 @@ GRANT USAGE ON SCHEMA app_jobs TO administrator;
6
6
  ALTER DEFAULT PRIVILEGES IN SCHEMA app_jobs
7
7
  GRANT EXECUTE ON FUNCTIONS TO administrator;
8
8
 
9
+ CREATE FUNCTION app_jobs.tg_update_timestamps() RETURNS trigger AS $EOFCODE$
10
+ BEGIN
11
+ IF TG_OP = 'INSERT' THEN
12
+ NEW.created_at = NOW();
13
+ NEW.updated_at = NOW();
14
+ ELSIF TG_OP = 'UPDATE' THEN
15
+ NEW.created_at = OLD.created_at;
16
+ NEW.updated_at = greatest (now(), OLD.updated_at + interval '1 millisecond');
17
+ END IF;
18
+ RETURN NEW;
19
+ END;
20
+ $EOFCODE$ LANGUAGE plpgsql;
21
+
22
+ CREATE FUNCTION app_jobs.tg_add_job_with_row_id() RETURNS trigger AS $EOFCODE$
23
+ BEGIN
24
+ IF (TG_OP = 'INSERT' OR TG_OP = 'UPDATE') THEN
25
+ PERFORM
26
+ app_jobs.add_job (tg_argv[0], json_build_object('id', NEW.id));
27
+ RETURN NEW;
28
+ END IF;
29
+ IF (TG_OP = 'DELETE') THEN
30
+ PERFORM
31
+ app_jobs.add_job (tg_argv[0], json_build_object('id', OLD.id));
32
+ RETURN OLD;
33
+ END IF;
34
+ END;
35
+ $EOFCODE$ LANGUAGE plpgsql VOLATILE SECURITY DEFINER;
36
+
37
+ COMMENT ON FUNCTION app_jobs.tg_add_job_with_row_id IS 'Useful shortcut to create a job on insert or update. Pass the task name as the trigger argument, and the record id will automatically be available on the JSON payload.';
38
+
39
+ CREATE FUNCTION app_jobs.tg_add_job_with_row() RETURNS trigger AS $EOFCODE$
40
+ BEGIN
41
+ IF (TG_OP = 'INSERT' OR TG_OP = 'UPDATE') THEN
42
+ PERFORM
43
+ app_jobs.add_job (TG_ARGV[0], to_json(NEW));
44
+ RETURN NEW;
45
+ END IF;
46
+ IF (TG_OP = 'DELETE') THEN
47
+ PERFORM
48
+ app_jobs.add_job (TG_ARGV[0], to_json(OLD));
49
+ RETURN OLD;
50
+ END IF;
51
+ END;
52
+ $EOFCODE$ LANGUAGE plpgsql VOLATILE SECURITY DEFINER;
53
+
54
+ COMMENT ON FUNCTION app_jobs.tg_add_job_with_row IS 'Useful shortcut to create a job on insert or update. Pass the task name as the trigger argument, and the record data will automatically be available on the JSON payload.';
55
+
9
56
  CREATE FUNCTION app_jobs.json_build_object_apply(arguments text[]) RETURNS pg_catalog.json AS $EOFCODE$
10
57
  DECLARE
11
58
  arg text;
@@ -26,19 +73,58 @@ BEGIN
26
73
  END;
27
74
  $EOFCODE$ LANGUAGE plpgsql;
28
75
 
29
- CREATE TABLE app_jobs.jobs (
76
+ CREATE FUNCTION app_jobs.trigger_job_with_fields() RETURNS trigger AS $EOFCODE$
77
+ DECLARE
78
+ arg text;
79
+ fn text;
80
+ i int;
81
+ args text[];
82
+ BEGIN
83
+ FOR i IN
84
+ SELECT
85
+ *
86
+ FROM
87
+ generate_series(1, TG_NARGS) g (i)
88
+ LOOP
89
+ IF (i = 1) THEN
90
+ fn = TG_ARGV[i - 1];
91
+ ELSE
92
+ args = array_append(args, TG_ARGV[i - 1]);
93
+ IF (TG_OP = 'INSERT' OR TG_OP = 'UPDATE') THEN
94
+ EXECUTE format('SELECT ($1).%s::text', TG_ARGV[i - 1])
95
+ USING NEW INTO arg;
96
+ END IF;
97
+ IF (TG_OP = 'DELETE') THEN
98
+ EXECUTE format('SELECT ($1).%s::text', TG_ARGV[i - 1])
99
+ USING OLD INTO arg;
100
+ END IF;
101
+ args = array_append(args, arg);
102
+ END IF;
103
+ END LOOP;
104
+ PERFORM
105
+ app_jobs.add_job (fn, app_jobs.json_build_object_apply (args));
106
+ IF (TG_OP = 'INSERT' OR TG_OP = 'UPDATE') THEN
107
+ RETURN NEW;
108
+ END IF;
109
+ IF (TG_OP = 'DELETE') THEN
110
+ RETURN OLD;
111
+ END IF;
112
+ END;
113
+ $EOFCODE$ LANGUAGE plpgsql VOLATILE SECURITY DEFINER;
114
+
115
+ CREATE TABLE app_jobs.scheduled_jobs (
30
116
  id bigserial PRIMARY KEY,
31
117
  queue_name text DEFAULT public.gen_random_uuid()::text,
32
118
  task_identifier text NOT NULL,
33
119
  payload pg_catalog.json DEFAULT '{}'::json NOT NULL,
34
120
  priority int DEFAULT 0 NOT NULL,
35
- run_at timestamptz DEFAULT now() NOT NULL,
36
- attempts int DEFAULT 0 NOT NULL,
37
121
  max_attempts int DEFAULT 25 NOT NULL,
38
122
  key text,
39
- last_error text,
40
123
  locked_at timestamptz,
41
124
  locked_by text,
125
+ schedule_info pg_catalog.json NOT NULL,
126
+ last_scheduled timestamptz,
127
+ last_scheduled_id bigint,
42
128
  CHECK (length(key) < 513),
43
129
  CHECK (length(task_identifier) < 127),
44
130
  CHECK (max_attempts > 0),
@@ -47,71 +133,39 @@ CREATE TABLE app_jobs.jobs (
47
133
  UNIQUE (key)
48
134
  );
49
135
 
50
- CREATE TABLE app_jobs.job_queues (
51
- queue_name text NOT NULL PRIMARY KEY,
52
- job_count int DEFAULT 0 NOT NULL,
53
- locked_at timestamptz,
54
- locked_by text
55
- );
56
-
57
- CREATE FUNCTION app_jobs.add_job(identifier text, payload pg_catalog.json DEFAULT '{}'::json, job_key text DEFAULT NULL, queue_name text DEFAULT NULL, run_at timestamptz DEFAULT now(), max_attempts int DEFAULT 25, priority int DEFAULT 0) RETURNS app_jobs.jobs AS $EOFCODE$
58
- DECLARE
59
- v_job app_jobs.jobs;
136
+ CREATE FUNCTION app_jobs.do_notify() RETURNS trigger AS $EOFCODE$
60
137
  BEGIN
61
- IF job_key IS NOT NULL THEN
62
- -- Upsert job
63
- INSERT INTO app_jobs.jobs (task_identifier, payload, queue_name, run_at, max_attempts, KEY, priority)
64
- VALUES (identifier, coalesce(payload, '{}'::json), queue_name, coalesce(run_at, now()), coalesce(max_attempts, 25), job_key, coalesce(priority, 0))
65
- ON CONFLICT (KEY)
66
- DO UPDATE SET
67
- task_identifier = excluded.task_identifier, payload = excluded.payload, queue_name = excluded.queue_name, max_attempts = excluded.max_attempts, run_at = excluded.run_at, priority = excluded.priority,
68
- -- always reset error/retry state
69
- attempts = 0, last_error = NULL
70
- WHERE
71
- jobs.locked_at IS NULL
72
- RETURNING
73
- * INTO v_job;
138
+ PERFORM
139
+ pg_notify(TG_ARGV[0], '');
140
+ RETURN NEW;
141
+ END;
142
+ $EOFCODE$ LANGUAGE plpgsql;
74
143
 
75
- -- If upsert succeeded (insert or update), return early
76
-
77
- IF NOT (v_job IS NULL) THEN
78
- RETURN v_job;
79
- END IF;
144
+ CREATE TRIGGER _900_notify_scheduled_job
145
+ AFTER INSERT
146
+ ON app_jobs.scheduled_jobs
147
+ FOR EACH ROW
148
+ EXECUTE PROCEDURE app_jobs.do_notify('scheduled_jobs:insert');
80
149
 
81
- -- Upsert failed -> there must be an existing job that is locked. Remove
82
- -- existing key to allow a new one to be inserted, and prevent any
83
- -- subsequent retries by bumping attempts to the max allowed.
150
+ CREATE INDEX scheduled_jobs_priority_id_idx ON app_jobs.scheduled_jobs (priority, id);
84
151
 
85
- UPDATE
86
- app_jobs.jobs
87
- SET
88
- KEY = NULL,
89
- attempts = jobs.max_attempts
90
- WHERE
91
- KEY = job_key;
92
- END IF;
152
+ CREATE INDEX scheduled_jobs_locked_by_idx ON app_jobs.scheduled_jobs (locked_by);
93
153
 
94
- INSERT INTO app_jobs.jobs (task_identifier, payload, queue_name, run_at, max_attempts, priority)
95
- VALUES (identifier, payload, queue_name, run_at, max_attempts, priority)
96
- RETURNING
97
- * INTO v_job;
98
- RETURN v_job;
99
- END;
100
- $EOFCODE$ LANGUAGE plpgsql VOLATILE SECURITY DEFINER;
154
+ GRANT SELECT, INSERT, UPDATE, DELETE ON app_jobs.scheduled_jobs TO administrator;
101
155
 
102
- CREATE TABLE app_jobs.scheduled_jobs (
156
+ CREATE TABLE app_jobs.jobs (
103
157
  id bigserial PRIMARY KEY,
104
158
  queue_name text DEFAULT public.gen_random_uuid()::text,
105
159
  task_identifier text NOT NULL,
106
160
  payload pg_catalog.json DEFAULT '{}'::json NOT NULL,
107
161
  priority int DEFAULT 0 NOT NULL,
162
+ run_at timestamptz DEFAULT now() NOT NULL,
163
+ attempts int DEFAULT 0 NOT NULL,
108
164
  max_attempts int DEFAULT 25 NOT NULL,
109
165
  key text,
166
+ last_error text,
110
167
  locked_at timestamptz,
111
168
  locked_by text,
112
- schedule_info pg_catalog.json NOT NULL,
113
- last_scheduled timestamptz,
114
- last_scheduled_id bigint,
115
169
  CHECK (length(key) < 513),
116
170
  CHECK (length(task_identifier) < 127),
117
171
  CHECK (max_attempts > 0),
@@ -120,116 +174,267 @@ CREATE TABLE app_jobs.scheduled_jobs (
120
174
  UNIQUE (key)
121
175
  );
122
176
 
123
- CREATE FUNCTION app_jobs.add_scheduled_job(identifier text, payload pg_catalog.json DEFAULT '{}'::json, schedule_info pg_catalog.json DEFAULT '{}'::json, job_key text DEFAULT NULL, queue_name text DEFAULT NULL, max_attempts int DEFAULT 25, priority int DEFAULT 0) RETURNS app_jobs.scheduled_jobs AS $EOFCODE$
124
- DECLARE
125
- v_job app_jobs.scheduled_jobs;
126
- BEGIN
127
- IF job_key IS NOT NULL THEN
177
+ ALTER TABLE app_jobs.jobs
178
+ ADD COLUMN created_at timestamptz;
128
179
 
129
- -- Upsert job
130
- INSERT INTO app_jobs.scheduled_jobs (task_identifier, payload, queue_name, schedule_info, max_attempts, KEY, priority)
131
- VALUES (identifier, coalesce(payload, '{}'::json), queue_name, schedule_info, coalesce(max_attempts, 25), job_key, coalesce(priority, 0))
132
- ON CONFLICT (KEY)
133
- DO UPDATE SET
134
- task_identifier = excluded.task_identifier,
135
- payload = excluded.payload,
136
- queue_name = excluded.queue_name,
137
- max_attempts = excluded.max_attempts,
138
- schedule_info = excluded.schedule_info,
139
- priority = excluded.priority
140
- WHERE
141
- scheduled_jobs.locked_at IS NULL
142
- RETURNING
143
- * INTO v_job;
180
+ ALTER TABLE app_jobs.jobs
181
+ ALTER COLUMN created_at SET DEFAULT now();
144
182
 
145
- -- If upsert succeeded (insert or update), return early
146
-
147
- IF NOT (v_job IS NULL) THEN
148
- RETURN v_job;
149
- END IF;
183
+ ALTER TABLE app_jobs.jobs
184
+ ADD COLUMN updated_at timestamptz;
150
185
 
151
- -- Upsert failed -> there must be an existing scheduled job that is locked. Remove
152
- -- and allow a new one to be inserted
186
+ ALTER TABLE app_jobs.jobs
187
+ ALTER COLUMN updated_at SET DEFAULT now();
153
188
 
154
- DELETE FROM
155
- app_jobs.scheduled_jobs
156
- WHERE
157
- KEY = job_key;
158
- END IF;
189
+ CREATE TRIGGER _100_update_jobs_modtime_tg
190
+ BEFORE INSERT OR UPDATE
191
+ ON app_jobs.jobs
192
+ FOR EACH ROW
193
+ EXECUTE PROCEDURE app_jobs.tg_update_timestamps();
159
194
 
160
- INSERT INTO app_jobs.scheduled_jobs (task_identifier, payload, queue_name, schedule_info, max_attempts, priority)
161
- VALUES (identifier, payload, queue_name, schedule_info, max_attempts, priority)
162
- RETURNING
163
- * INTO v_job;
164
- RETURN v_job;
195
+ CREATE FUNCTION app_jobs.tg_increase_job_queue_count() RETURNS trigger AS $EOFCODE$
196
+ BEGIN
197
+ INSERT INTO app_jobs.job_queues (queue_name, job_count)
198
+ VALUES (NEW.queue_name, 1)
199
+ ON CONFLICT (queue_name)
200
+ DO UPDATE SET
201
+ job_count = job_queues.job_count + 1;
202
+ RETURN NEW;
165
203
  END;
166
- $EOFCODE$ LANGUAGE plpgsql VOLATILE SECURITY DEFINER;
204
+ $EOFCODE$ LANGUAGE plpgsql VOLATILE;
167
205
 
168
- CREATE FUNCTION app_jobs.complete_job(worker_id text, job_id bigint) RETURNS app_jobs.jobs LANGUAGE plpgsql AS $EOFCODE$
206
+ CREATE TRIGGER _500_increase_job_queue_count_on_insert
207
+ AFTER INSERT
208
+ ON app_jobs.jobs
209
+ FOR EACH ROW
210
+ WHEN (new.queue_name IS NOT NULL)
211
+ EXECUTE PROCEDURE app_jobs.tg_increase_job_queue_count();
212
+
213
+ CREATE TRIGGER _500_increase_job_queue_count_on_update
214
+ AFTER UPDATE OF queue_name
215
+ ON app_jobs.jobs
216
+ FOR EACH ROW
217
+ WHEN (new.queue_name IS DISTINCT FROM old.queue_name
218
+ AND new.queue_name IS NOT NULL)
219
+ EXECUTE PROCEDURE app_jobs.tg_increase_job_queue_count();
220
+
221
+ CREATE TRIGGER _900_notify_worker
222
+ AFTER INSERT
223
+ ON app_jobs.jobs
224
+ FOR EACH ROW
225
+ EXECUTE PROCEDURE app_jobs.do_notify('jobs:insert');
226
+
227
+ CREATE FUNCTION app_jobs.tg_decrease_job_queue_count() RETURNS trigger AS $EOFCODE$
169
228
  DECLARE
170
- v_row app_jobs.jobs;
229
+ v_new_job_count int;
171
230
  BEGIN
172
- DELETE FROM app_jobs.jobs
173
- WHERE id = job_id
231
+ UPDATE
232
+ app_jobs.job_queues
233
+ SET
234
+ job_count = job_queues.job_count - 1
235
+ WHERE
236
+ queue_name = OLD.queue_name
174
237
  RETURNING
175
- * INTO v_row;
176
- IF v_row.queue_name IS NOT NULL THEN
177
- UPDATE
178
- app_jobs.job_queues
179
- SET
180
- locked_by = NULL,
181
- locked_at = NULL
238
+ job_count INTO v_new_job_count;
239
+ IF v_new_job_count <= 0 THEN
240
+ DELETE FROM app_jobs.job_queues
241
+ WHERE queue_name = OLD.queue_name
242
+ AND job_count <= 0;
243
+ END IF;
244
+ RETURN OLD;
245
+ END;
246
+ $EOFCODE$ LANGUAGE plpgsql VOLATILE;
247
+
248
+ CREATE TRIGGER decrease_job_queue_count_on_delete
249
+ AFTER DELETE
250
+ ON app_jobs.jobs
251
+ FOR EACH ROW
252
+ WHEN (old.queue_name IS NOT NULL)
253
+ EXECUTE PROCEDURE app_jobs.tg_decrease_job_queue_count();
254
+
255
+ CREATE TRIGGER decrease_job_queue_count_on_update
256
+ AFTER UPDATE OF queue_name
257
+ ON app_jobs.jobs
258
+ FOR EACH ROW
259
+ WHEN (new.queue_name IS DISTINCT FROM old.queue_name
260
+ AND old.queue_name IS NOT NULL)
261
+ EXECUTE PROCEDURE app_jobs.tg_decrease_job_queue_count();
262
+
263
+ CREATE INDEX priority_run_at_id_idx ON app_jobs.jobs (priority, run_at, id);
264
+
265
+ CREATE INDEX jobs_locked_by_idx ON app_jobs.jobs (locked_by);
266
+
267
+ GRANT SELECT, INSERT, UPDATE, DELETE ON app_jobs.jobs TO administrator;
268
+
269
+ CREATE TABLE app_jobs.job_queues (
270
+ queue_name text NOT NULL PRIMARY KEY,
271
+ job_count int DEFAULT 0 NOT NULL,
272
+ locked_at timestamptz,
273
+ locked_by text
274
+ );
275
+
276
+ CREATE INDEX job_queues_locked_by_idx ON app_jobs.job_queues (locked_by);
277
+
278
+ GRANT SELECT, INSERT, UPDATE, DELETE ON app_jobs.job_queues TO administrator;
279
+
280
+ CREATE FUNCTION app_jobs.run_scheduled_job(id bigint, job_expiry interval DEFAULT '1 hours') RETURNS app_jobs.jobs AS $EOFCODE$
281
+ DECLARE
282
+ j app_jobs.jobs;
283
+ last_id bigint;
284
+ lkd_by text;
285
+ BEGIN
286
+ -- check last scheduled
287
+ SELECT
288
+ last_scheduled_id
289
+ FROM
290
+ app_jobs.scheduled_jobs s
291
+ WHERE
292
+ s.id = run_scheduled_job.id INTO last_id;
293
+ -- if it's been scheduled check if it's been run
294
+ IF (last_id IS NOT NULL) THEN
295
+ SELECT
296
+ locked_by
297
+ FROM
298
+ app_jobs.jobs js
182
299
  WHERE
183
- queue_name = v_row.queue_name
184
- AND locked_by = worker_id;
300
+ js.id = last_id
301
+ AND (js.locked_at IS NULL -- never been run
302
+ OR js.locked_at >= (NOW() - job_expiry)
303
+ -- still running within a safe interval
304
+ ) INTO lkd_by;
305
+ IF (FOUND) THEN
306
+ RAISE EXCEPTION 'ALREADY_SCHEDULED';
307
+ END IF;
185
308
  END IF;
186
- RETURN v_row;
309
+ -- insert new job
310
+ INSERT INTO app_jobs.jobs (queue_name, task_identifier, payload, priority, max_attempts, key)
311
+ SELECT
312
+ queue_name,
313
+ task_identifier,
314
+ payload,
315
+ priority,
316
+ max_attempts,
317
+ key
318
+ FROM
319
+ app_jobs.scheduled_jobs s
320
+ WHERE
321
+ s.id = run_scheduled_job.id
322
+ RETURNING
323
+ * INTO j;
324
+ -- update the scheduled job
325
+ UPDATE
326
+ app_jobs.scheduled_jobs s
327
+ SET
328
+ last_scheduled = NOW(),
329
+ last_scheduled_id = j.id
330
+ WHERE
331
+ s.id = run_scheduled_job.id;
332
+ RETURN j;
187
333
  END;
188
- $EOFCODE$;
334
+ $EOFCODE$ LANGUAGE plpgsql VOLATILE;
189
335
 
190
- CREATE FUNCTION app_jobs.complete_jobs(job_ids bigint[]) RETURNS SETOF app_jobs.jobs LANGUAGE sql AS $EOFCODE$
191
- DELETE FROM app_jobs.jobs
192
- WHERE id = ANY (job_ids)
336
+ CREATE FUNCTION app_jobs.reschedule_jobs(job_ids bigint[], run_at timestamptz DEFAULT NULL, priority int DEFAULT NULL, attempts int DEFAULT NULL, max_attempts int DEFAULT NULL) RETURNS SETOF app_jobs.jobs LANGUAGE sql AS $EOFCODE$
337
+ UPDATE
338
+ app_jobs.jobs
339
+ SET
340
+ run_at = coalesce(reschedule_jobs.run_at, jobs.run_at),
341
+ priority = coalesce(reschedule_jobs.priority, jobs.priority),
342
+ attempts = coalesce(reschedule_jobs.attempts, jobs.attempts),
343
+ max_attempts = coalesce(reschedule_jobs.max_attempts, jobs.max_attempts)
344
+ WHERE
345
+ id = ANY (job_ids)
193
346
  AND (locked_by IS NULL
194
347
  OR locked_at < NOW() - interval '4 hours')
195
348
  RETURNING
196
349
  *;
197
350
  $EOFCODE$;
198
351
 
199
- CREATE FUNCTION app_jobs.do_notify() RETURNS trigger AS $EOFCODE$
352
+ CREATE FUNCTION app_jobs.release_scheduled_jobs(worker_id text, ids bigint[] DEFAULT NULL) RETURNS void AS $EOFCODE$
353
+ DECLARE
200
354
  BEGIN
201
- PERFORM
202
- pg_notify(TG_ARGV[0], '');
203
- RETURN NEW;
355
+ -- clear the scheduled job
356
+ UPDATE
357
+ app_jobs.scheduled_jobs s
358
+ SET
359
+ locked_at = NULL,
360
+ locked_by = NULL
361
+ WHERE
362
+ locked_by = worker_id
363
+ AND (ids IS NULL
364
+ OR s.id = ANY (ids));
204
365
  END;
205
- $EOFCODE$ LANGUAGE plpgsql;
366
+ $EOFCODE$ LANGUAGE plpgsql VOLATILE;
206
367
 
207
- CREATE FUNCTION app_jobs.fail_job(worker_id text, job_id bigint, error_message text) RETURNS app_jobs.jobs LANGUAGE plpgsql STRICT AS $EOFCODE$
368
+ CREATE FUNCTION app_jobs.release_jobs(worker_id text) RETURNS void AS $EOFCODE$
208
369
  DECLARE
209
- v_row app_jobs.jobs;
210
370
  BEGIN
371
+ -- clear the job
211
372
  UPDATE
212
373
  app_jobs.jobs
213
374
  SET
214
- last_error = error_message,
215
- run_at = greatest (now(), run_at) + (exp(least (attempts, 10))::text || ' seconds')::interval,
375
+ locked_at = NULL,
216
376
  locked_by = NULL,
217
- locked_at = NULL
377
+ attempts = GREATEST (attempts - 1, 0)
218
378
  WHERE
219
- id = job_id
220
- AND locked_by = worker_id
379
+ locked_by = worker_id;
380
+ -- clear the queue
381
+ UPDATE
382
+ app_jobs.job_queues
383
+ SET
384
+ locked_at = NULL,
385
+ locked_by = NULL
386
+ WHERE
387
+ locked_by = worker_id;
388
+ END;
389
+ $EOFCODE$ LANGUAGE plpgsql VOLATILE;
390
+
391
+ CREATE FUNCTION app_jobs.permanently_fail_jobs(job_ids bigint[], error_message text DEFAULT NULL) RETURNS SETOF app_jobs.jobs LANGUAGE sql AS $EOFCODE$
392
+ UPDATE
393
+ app_jobs.jobs
394
+ SET
395
+ last_error = coalesce(error_message, 'Manually marked as failed'),
396
+ attempts = max_attempts
397
+ WHERE
398
+ id = ANY (job_ids)
399
+ AND (locked_by IS NULL
400
+ OR locked_at < NOW() - interval '4 hours')
221
401
  RETURNING
222
- * INTO v_row;
223
- IF v_row.queue_name IS NOT NULL THEN
224
- UPDATE
225
- app_jobs.job_queues
226
- SET
227
- locked_by = NULL,
228
- locked_at = NULL
229
- WHERE
230
- queue_name = v_row.queue_name
231
- AND locked_by = worker_id;
402
+ *;
403
+ $EOFCODE$;
404
+
405
+ CREATE FUNCTION app_jobs.get_scheduled_job(worker_id text, task_identifiers text[] DEFAULT NULL) RETURNS app_jobs.scheduled_jobs LANGUAGE plpgsql AS $EOFCODE$
406
+ DECLARE
407
+ v_job_id bigint;
408
+ v_row app_jobs.scheduled_jobs;
409
+ BEGIN
410
+ IF worker_id IS NULL THEN
411
+ RAISE exception 'INVALID_WORKER_ID';
412
+ END IF;
413
+ SELECT
414
+ scheduled_jobs.id INTO v_job_id
415
+ FROM
416
+ app_jobs.scheduled_jobs
417
+ WHERE (scheduled_jobs.locked_at IS NULL)
418
+ AND (task_identifiers IS NULL
419
+ OR task_identifier = ANY (task_identifiers))
420
+ ORDER BY
421
+ priority ASC,
422
+ id ASC
423
+ LIMIT 1
424
+ FOR UPDATE
425
+ SKIP LOCKED;
426
+ IF v_job_id IS NULL THEN
427
+ RETURN NULL;
232
428
  END IF;
429
+ UPDATE
430
+ app_jobs.scheduled_jobs
431
+ SET
432
+ locked_by = worker_id,
433
+ locked_at = NOW()
434
+ WHERE
435
+ id = v_job_id
436
+ RETURNING
437
+ * INTO v_row;
233
438
  RETURN v_row;
234
439
  END;
235
440
  $EOFCODE$;
@@ -301,358 +506,153 @@ BEGIN
301
506
  END;
302
507
  $EOFCODE$;
303
508
 
304
- CREATE FUNCTION app_jobs.get_scheduled_job(worker_id text, task_identifiers text[] DEFAULT NULL) RETURNS app_jobs.scheduled_jobs LANGUAGE plpgsql AS $EOFCODE$
509
+ CREATE FUNCTION app_jobs.fail_job(worker_id text, job_id bigint, error_message text) RETURNS app_jobs.jobs LANGUAGE plpgsql STRICT AS $EOFCODE$
305
510
  DECLARE
306
- v_job_id bigint;
307
- v_row app_jobs.scheduled_jobs;
511
+ v_row app_jobs.jobs;
308
512
  BEGIN
309
- IF worker_id IS NULL THEN
310
- RAISE exception 'INVALID_WORKER_ID';
311
- END IF;
312
- SELECT
313
- scheduled_jobs.id INTO v_job_id
314
- FROM
315
- app_jobs.scheduled_jobs
316
- WHERE (scheduled_jobs.locked_at IS NULL)
317
- AND (task_identifiers IS NULL
318
- OR task_identifier = ANY (task_identifiers))
319
- ORDER BY
320
- priority ASC,
321
- id ASC
322
- LIMIT 1
323
- FOR UPDATE
324
- SKIP LOCKED;
325
- IF v_job_id IS NULL THEN
326
- RETURN NULL;
327
- END IF;
328
513
  UPDATE
329
- app_jobs.scheduled_jobs
514
+ app_jobs.jobs
330
515
  SET
331
- locked_by = worker_id,
332
- locked_at = NOW()
516
+ last_error = error_message,
517
+ run_at = greatest (now(), run_at) + (exp(least (attempts, 10))::text || ' seconds')::interval,
518
+ locked_by = NULL,
519
+ locked_at = NULL
333
520
  WHERE
334
- id = v_job_id
521
+ id = job_id
522
+ AND locked_by = worker_id
335
523
  RETURNING
336
524
  * INTO v_row;
337
- RETURN v_row;
338
- END;
339
- $EOFCODE$;
340
-
341
- CREATE FUNCTION app_jobs.permanently_fail_jobs(job_ids bigint[], error_message text DEFAULT NULL) RETURNS SETOF app_jobs.jobs LANGUAGE sql AS $EOFCODE$
342
- UPDATE
343
- app_jobs.jobs
344
- SET
345
- last_error = coalesce(error_message, 'Manually marked as failed'),
346
- attempts = max_attempts
347
- WHERE
348
- id = ANY (job_ids)
349
- AND (locked_by IS NULL
350
- OR locked_at < NOW() - interval '4 hours')
351
- RETURNING
352
- *;
353
- $EOFCODE$;
354
-
355
- CREATE FUNCTION app_jobs.release_jobs(worker_id text) RETURNS void AS $EOFCODE$
356
- DECLARE
357
- BEGIN
358
- -- clear the job
359
- UPDATE
360
- app_jobs.jobs
361
- SET
362
- locked_at = NULL,
363
- locked_by = NULL,
364
- attempts = GREATEST (attempts - 1, 0)
365
- WHERE
366
- locked_by = worker_id;
367
- -- clear the queue
368
- UPDATE
369
- app_jobs.job_queues
370
- SET
371
- locked_at = NULL,
372
- locked_by = NULL
373
- WHERE
374
- locked_by = worker_id;
375
- END;
376
- $EOFCODE$ LANGUAGE plpgsql VOLATILE;
377
-
378
- CREATE FUNCTION app_jobs.release_scheduled_jobs(worker_id text, ids bigint[] DEFAULT NULL) RETURNS void AS $EOFCODE$
379
- DECLARE
380
- BEGIN
381
- -- clear the scheduled job
382
- UPDATE
383
- app_jobs.scheduled_jobs s
384
- SET
385
- locked_at = NULL,
386
- locked_by = NULL
387
- WHERE
388
- locked_by = worker_id
389
- AND (ids IS NULL
390
- OR s.id = ANY (ids));
525
+ IF v_row.queue_name IS NOT NULL THEN
526
+ UPDATE
527
+ app_jobs.job_queues
528
+ SET
529
+ locked_by = NULL,
530
+ locked_at = NULL
531
+ WHERE
532
+ queue_name = v_row.queue_name
533
+ AND locked_by = worker_id;
534
+ END IF;
535
+ RETURN v_row;
391
536
  END;
392
- $EOFCODE$ LANGUAGE plpgsql VOLATILE;
537
+ $EOFCODE$;
393
538
 
394
- CREATE FUNCTION app_jobs.reschedule_jobs(job_ids bigint[], run_at timestamptz DEFAULT NULL, priority int DEFAULT NULL, attempts int DEFAULT NULL, max_attempts int DEFAULT NULL) RETURNS SETOF app_jobs.jobs LANGUAGE sql AS $EOFCODE$
395
- UPDATE
396
- app_jobs.jobs
397
- SET
398
- run_at = coalesce(reschedule_jobs.run_at, jobs.run_at),
399
- priority = coalesce(reschedule_jobs.priority, jobs.priority),
400
- attempts = coalesce(reschedule_jobs.attempts, jobs.attempts),
401
- max_attempts = coalesce(reschedule_jobs.max_attempts, jobs.max_attempts)
402
- WHERE
403
- id = ANY (job_ids)
539
+ CREATE FUNCTION app_jobs.complete_jobs(job_ids bigint[]) RETURNS SETOF app_jobs.jobs LANGUAGE sql AS $EOFCODE$
540
+ DELETE FROM app_jobs.jobs
541
+ WHERE id = ANY (job_ids)
404
542
  AND (locked_by IS NULL
405
543
  OR locked_at < NOW() - interval '4 hours')
406
544
  RETURNING
407
545
  *;
408
546
  $EOFCODE$;
409
547
 
410
- CREATE FUNCTION app_jobs.run_scheduled_job(id bigint, job_expiry interval DEFAULT '1 hours') RETURNS app_jobs.jobs AS $EOFCODE$
548
+ CREATE FUNCTION app_jobs.complete_job(worker_id text, job_id bigint) RETURNS app_jobs.jobs LANGUAGE plpgsql AS $EOFCODE$
411
549
  DECLARE
412
- j app_jobs.jobs;
413
- last_id bigint;
414
- lkd_by text;
550
+ v_row app_jobs.jobs;
415
551
  BEGIN
416
- -- check last scheduled
417
- SELECT
418
- last_scheduled_id
419
- FROM
420
- app_jobs.scheduled_jobs s
421
- WHERE
422
- s.id = run_scheduled_job.id INTO last_id;
423
- -- if it's been scheduled check if it's been run
424
- IF (last_id IS NOT NULL) THEN
425
- SELECT
426
- locked_by
427
- FROM
428
- app_jobs.jobs js
552
+ DELETE FROM app_jobs.jobs
553
+ WHERE id = job_id
554
+ RETURNING
555
+ * INTO v_row;
556
+ IF v_row.queue_name IS NOT NULL THEN
557
+ UPDATE
558
+ app_jobs.job_queues
559
+ SET
560
+ locked_by = NULL,
561
+ locked_at = NULL
429
562
  WHERE
430
- js.id = last_id
431
- AND (js.locked_at IS NULL -- never been run
432
- OR js.locked_at >= (NOW() - job_expiry)
433
- -- still running within a safe interval
434
- ) INTO lkd_by;
435
- IF (FOUND) THEN
436
- RAISE EXCEPTION 'ALREADY_SCHEDULED';
437
- END IF;
563
+ queue_name = v_row.queue_name
564
+ AND locked_by = worker_id;
438
565
  END IF;
439
- -- insert new job
440
- INSERT INTO app_jobs.jobs (queue_name, task_identifier, payload, priority, max_attempts, key)
441
- SELECT
442
- queue_name,
443
- task_identifier,
444
- payload,
445
- priority,
446
- max_attempts,
447
- key
448
- FROM
449
- app_jobs.scheduled_jobs s
450
- WHERE
451
- s.id = run_scheduled_job.id
452
- RETURNING
453
- * INTO j;
454
- -- update the scheduled job
455
- UPDATE
456
- app_jobs.scheduled_jobs s
457
- SET
458
- last_scheduled = NOW(),
459
- last_scheduled_id = j.id
460
- WHERE
461
- s.id = run_scheduled_job.id;
462
- RETURN j;
566
+ RETURN v_row;
463
567
  END;
464
- $EOFCODE$ LANGUAGE plpgsql VOLATILE;
465
-
466
- GRANT SELECT, INSERT, UPDATE, DELETE ON app_jobs.job_queues TO administrator;
467
-
468
- CREATE INDEX job_queues_locked_by_idx ON app_jobs.job_queues (locked_by);
469
-
470
- GRANT SELECT, INSERT, UPDATE, DELETE ON app_jobs.jobs TO administrator;
471
-
472
- CREATE INDEX jobs_locked_by_idx ON app_jobs.jobs (locked_by);
473
-
474
- CREATE INDEX priority_run_at_id_idx ON app_jobs.jobs (priority, run_at, id);
568
+ $EOFCODE$;
475
569
 
476
- CREATE FUNCTION app_jobs.tg_decrease_job_queue_count() RETURNS trigger AS $EOFCODE$
570
+ CREATE FUNCTION app_jobs.add_scheduled_job(identifier text, payload pg_catalog.json DEFAULT '{}'::json, schedule_info pg_catalog.json DEFAULT '{}'::json, job_key text DEFAULT NULL, queue_name text DEFAULT NULL, max_attempts int DEFAULT 25, priority int DEFAULT 0) RETURNS app_jobs.scheduled_jobs AS $EOFCODE$
477
571
  DECLARE
478
- v_new_job_count int;
479
- BEGIN
480
- UPDATE
481
- app_jobs.job_queues
482
- SET
483
- job_count = job_queues.job_count - 1
484
- WHERE
485
- queue_name = OLD.queue_name
486
- RETURNING
487
- job_count INTO v_new_job_count;
488
- IF v_new_job_count <= 0 THEN
489
- DELETE FROM app_jobs.job_queues
490
- WHERE queue_name = OLD.queue_name
491
- AND job_count <= 0;
492
- END IF;
493
- RETURN OLD;
494
- END;
495
- $EOFCODE$ LANGUAGE plpgsql VOLATILE;
496
-
497
- CREATE TRIGGER decrease_job_queue_count_on_delete
498
- AFTER DELETE
499
- ON app_jobs.jobs
500
- FOR EACH ROW
501
- WHEN (old.queue_name IS NOT NULL)
502
- EXECUTE PROCEDURE app_jobs.tg_decrease_job_queue_count();
503
-
504
- CREATE TRIGGER decrease_job_queue_count_on_update
505
- AFTER UPDATE OF queue_name
506
- ON app_jobs.jobs
507
- FOR EACH ROW
508
- WHEN (new.queue_name IS DISTINCT FROM old.queue_name
509
- AND old.queue_name IS NOT NULL)
510
- EXECUTE PROCEDURE app_jobs.tg_decrease_job_queue_count();
511
-
512
- CREATE FUNCTION app_jobs.tg_increase_job_queue_count() RETURNS trigger AS $EOFCODE$
572
+ v_job app_jobs.scheduled_jobs;
513
573
  BEGIN
514
- INSERT INTO app_jobs.job_queues (queue_name, job_count)
515
- VALUES (NEW.queue_name, 1)
516
- ON CONFLICT (queue_name)
517
- DO UPDATE SET
518
- job_count = job_queues.job_count + 1;
519
- RETURN NEW;
520
- END;
521
- $EOFCODE$ LANGUAGE plpgsql VOLATILE;
574
+ IF job_key IS NOT NULL THEN
522
575
 
523
- CREATE TRIGGER _500_increase_job_queue_count_on_insert
524
- AFTER INSERT
525
- ON app_jobs.jobs
526
- FOR EACH ROW
527
- WHEN (new.queue_name IS NOT NULL)
528
- EXECUTE PROCEDURE app_jobs.tg_increase_job_queue_count();
576
+ -- Upsert job
577
+ INSERT INTO app_jobs.scheduled_jobs (task_identifier, payload, queue_name, schedule_info, max_attempts, KEY, priority)
578
+ VALUES (identifier, coalesce(payload, '{}'::json), queue_name, schedule_info, coalesce(max_attempts, 25), job_key, coalesce(priority, 0))
579
+ ON CONFLICT (KEY)
580
+ DO UPDATE SET
581
+ task_identifier = excluded.task_identifier,
582
+ payload = excluded.payload,
583
+ queue_name = excluded.queue_name,
584
+ max_attempts = excluded.max_attempts,
585
+ schedule_info = excluded.schedule_info,
586
+ priority = excluded.priority
587
+ WHERE
588
+ scheduled_jobs.locked_at IS NULL
589
+ RETURNING
590
+ * INTO v_job;
529
591
 
530
- CREATE TRIGGER _500_increase_job_queue_count_on_update
531
- AFTER UPDATE OF queue_name
532
- ON app_jobs.jobs
533
- FOR EACH ROW
534
- WHEN (new.queue_name IS DISTINCT FROM old.queue_name
535
- AND new.queue_name IS NOT NULL)
536
- EXECUTE PROCEDURE app_jobs.tg_increase_job_queue_count();
592
+ -- If upsert succeeded (insert or update), return early
593
+
594
+ IF NOT (v_job IS NULL) THEN
595
+ RETURN v_job;
596
+ END IF;
537
597
 
538
- CREATE TRIGGER _900_notify_worker
539
- AFTER INSERT
540
- ON app_jobs.jobs
541
- FOR EACH ROW
542
- EXECUTE PROCEDURE app_jobs.do_notify('jobs:insert');
598
+ -- Upsert failed -> there must be an existing scheduled job that is locked. Remove
599
+ -- and allow a new one to be inserted
543
600
 
544
- CREATE FUNCTION app_jobs.tg_update_timestamps() RETURNS trigger AS $EOFCODE$
545
- BEGIN
546
- IF TG_OP = 'INSERT' THEN
547
- NEW.created_at = NOW();
548
- NEW.updated_at = NOW();
549
- ELSIF TG_OP = 'UPDATE' THEN
550
- NEW.created_at = OLD.created_at;
551
- NEW.updated_at = greatest (now(), OLD.updated_at + interval '1 millisecond');
601
+ DELETE FROM
602
+ app_jobs.scheduled_jobs
603
+ WHERE
604
+ KEY = job_key;
552
605
  END IF;
553
- RETURN NEW;
554
- END;
555
- $EOFCODE$ LANGUAGE plpgsql;
556
-
557
- ALTER TABLE app_jobs.jobs
558
- ADD COLUMN created_at timestamptz;
559
-
560
- ALTER TABLE app_jobs.jobs
561
- ALTER COLUMN created_at SET DEFAULT now();
562
-
563
- ALTER TABLE app_jobs.jobs
564
- ADD COLUMN updated_at timestamptz;
565
-
566
- ALTER TABLE app_jobs.jobs
567
- ALTER COLUMN updated_at SET DEFAULT now();
568
-
569
- CREATE TRIGGER _100_update_jobs_modtime_tg
570
- BEFORE INSERT OR UPDATE
571
- ON app_jobs.jobs
572
- FOR EACH ROW
573
- EXECUTE PROCEDURE app_jobs.tg_update_timestamps();
574
-
575
- GRANT SELECT, INSERT, UPDATE, DELETE ON app_jobs.scheduled_jobs TO administrator;
576
-
577
- CREATE INDEX scheduled_jobs_locked_by_idx ON app_jobs.scheduled_jobs (locked_by);
578
-
579
- CREATE INDEX scheduled_jobs_priority_id_idx ON app_jobs.scheduled_jobs (priority, id);
580
-
581
- CREATE TRIGGER _900_notify_scheduled_job
582
- AFTER INSERT
583
- ON app_jobs.scheduled_jobs
584
- FOR EACH ROW
585
- EXECUTE PROCEDURE app_jobs.do_notify('scheduled_jobs:insert');
586
606
 
587
- CREATE FUNCTION app_jobs.trigger_job_with_fields() RETURNS trigger AS $EOFCODE$
588
- DECLARE
589
- arg text;
590
- fn text;
591
- i int;
592
- args text[];
593
- BEGIN
594
- FOR i IN
595
- SELECT
596
- *
597
- FROM
598
- generate_series(1, TG_NARGS) g (i)
599
- LOOP
600
- IF (i = 1) THEN
601
- fn = TG_ARGV[i - 1];
602
- ELSE
603
- args = array_append(args, TG_ARGV[i - 1]);
604
- IF (TG_OP = 'INSERT' OR TG_OP = 'UPDATE') THEN
605
- EXECUTE format('SELECT ($1).%s::text', TG_ARGV[i - 1])
606
- USING NEW INTO arg;
607
- END IF;
608
- IF (TG_OP = 'DELETE') THEN
609
- EXECUTE format('SELECT ($1).%s::text', TG_ARGV[i - 1])
610
- USING OLD INTO arg;
611
- END IF;
612
- args = array_append(args, arg);
613
- END IF;
614
- END LOOP;
615
- PERFORM
616
- app_jobs.add_job (fn, app_jobs.json_build_object_apply (args));
617
- IF (TG_OP = 'INSERT' OR TG_OP = 'UPDATE') THEN
618
- RETURN NEW;
619
- END IF;
620
- IF (TG_OP = 'DELETE') THEN
621
- RETURN OLD;
622
- END IF;
607
+ INSERT INTO app_jobs.scheduled_jobs (task_identifier, payload, queue_name, schedule_info, max_attempts, priority)
608
+ VALUES (identifier, payload, queue_name, schedule_info, max_attempts, priority)
609
+ RETURNING
610
+ * INTO v_job;
611
+ RETURN v_job;
623
612
  END;
624
613
  $EOFCODE$ LANGUAGE plpgsql VOLATILE SECURITY DEFINER;
625
614
 
626
- CREATE FUNCTION app_jobs.tg_add_job_with_row_id() RETURNS trigger AS $EOFCODE$
615
+ CREATE FUNCTION app_jobs.add_job(identifier text, payload pg_catalog.json DEFAULT '{}'::json, job_key text DEFAULT NULL, queue_name text DEFAULT NULL, run_at timestamptz DEFAULT now(), max_attempts int DEFAULT 25, priority int DEFAULT 0) RETURNS app_jobs.jobs AS $EOFCODE$
616
+ DECLARE
617
+ v_job app_jobs.jobs;
627
618
  BEGIN
628
- IF (TG_OP = 'INSERT' OR TG_OP = 'UPDATE') THEN
629
- PERFORM
630
- app_jobs.add_job (tg_argv[0], json_build_object('id', NEW.id));
631
- RETURN NEW;
632
- END IF;
633
- IF (TG_OP = 'DELETE') THEN
634
- PERFORM
635
- app_jobs.add_job (tg_argv[0], json_build_object('id', OLD.id));
636
- RETURN OLD;
637
- END IF;
638
- END;
639
- $EOFCODE$ LANGUAGE plpgsql VOLATILE SECURITY DEFINER;
619
+ IF job_key IS NOT NULL THEN
620
+ -- Upsert job
621
+ INSERT INTO app_jobs.jobs (task_identifier, payload, queue_name, run_at, max_attempts, KEY, priority)
622
+ VALUES (identifier, coalesce(payload, '{}'::json), queue_name, coalesce(run_at, now()), coalesce(max_attempts, 25), job_key, coalesce(priority, 0))
623
+ ON CONFLICT (KEY)
624
+ DO UPDATE SET
625
+ task_identifier = excluded.task_identifier, payload = excluded.payload, queue_name = excluded.queue_name, max_attempts = excluded.max_attempts, run_at = excluded.run_at, priority = excluded.priority,
626
+ -- always reset error/retry state
627
+ attempts = 0, last_error = NULL
628
+ WHERE
629
+ jobs.locked_at IS NULL
630
+ RETURNING
631
+ * INTO v_job;
640
632
 
641
- COMMENT ON FUNCTION app_jobs.tg_add_job_with_row_id IS 'Useful shortcut to create a job on insert or update. Pass the task name as the trigger argument, and the record id will automatically be available on the JSON payload.';
633
+ -- If upsert succeeded (insert or update), return early
634
+
635
+ IF NOT (v_job IS NULL) THEN
636
+ RETURN v_job;
637
+ END IF;
642
638
 
643
- CREATE FUNCTION app_jobs.tg_add_job_with_row() RETURNS trigger AS $EOFCODE$
644
- BEGIN
645
- IF (TG_OP = 'INSERT' OR TG_OP = 'UPDATE') THEN
646
- PERFORM
647
- app_jobs.add_job (TG_ARGV[0], to_json(NEW));
648
- RETURN NEW;
649
- END IF;
650
- IF (TG_OP = 'DELETE') THEN
651
- PERFORM
652
- app_jobs.add_job (TG_ARGV[0], to_json(OLD));
653
- RETURN OLD;
639
+ -- Upsert failed -> there must be an existing job that is locked. Remove
640
+ -- existing key to allow a new one to be inserted, and prevent any
641
+ -- subsequent retries by bumping attempts to the max allowed.
642
+
643
+ UPDATE
644
+ app_jobs.jobs
645
+ SET
646
+ KEY = NULL,
647
+ attempts = jobs.max_attempts
648
+ WHERE
649
+ KEY = job_key;
654
650
  END IF;
655
- END;
656
- $EOFCODE$ LANGUAGE plpgsql VOLATILE SECURITY DEFINER;
657
651
 
658
- COMMENT ON FUNCTION app_jobs.tg_add_job_with_row IS 'Useful shortcut to create a job on insert or update. Pass the task name as the trigger argument, and the record data will automatically be available on the JSON payload.';
652
+ INSERT INTO app_jobs.jobs (task_identifier, payload, queue_name, run_at, max_attempts, priority)
653
+ VALUES (identifier, payload, queue_name, run_at, max_attempts, priority)
654
+ RETURNING
655
+ * INTO v_job;
656
+ RETURN v_job;
657
+ END;
658
+ $EOFCODE$ LANGUAGE plpgsql VOLATILE SECURITY DEFINER;