bullmq 6.3.11 → 6.3.13

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.
Files changed (204) hide show
  1. package/dist/cjs/classes/queue-getters.js +3 -1
  2. package/dist/cjs/classes/queue.js +2 -1
  3. package/dist/cjs/classes/redis-connection.js +1 -1
  4. package/dist/cjs/classes/redis-queue-backend.js +1 -1
  5. package/dist/cjs/commands/includes/moveChildBackToParentDependencies.lua +20 -0
  6. package/dist/cjs/commands/moveJobsToWait-8.lua +3 -0
  7. package/dist/cjs/commands/reprocessJob-7.lua +2 -14
  8. package/dist/cjs/postgres/sql-loader.js +27 -41
  9. package/dist/cjs/postgres/sql-scripts.js +110 -0
  10. package/dist/cjs/scripts/moveJobsToWait-8.js +21 -0
  11. package/dist/cjs/scripts/reprocessJob-7.js +20 -13
  12. package/dist/cjs/tsconfig-cjs.tsbuildinfo +1 -1
  13. package/dist/cjs/version.js +1 -1
  14. package/dist/esm/classes/queue-getters.d.ts +3 -1
  15. package/dist/esm/classes/queue-getters.js +3 -1
  16. package/dist/esm/classes/queue.d.ts +2 -1
  17. package/dist/esm/classes/queue.js +2 -1
  18. package/dist/esm/classes/redis-connection.d.ts +1 -1
  19. package/dist/esm/classes/redis-connection.js +1 -1
  20. package/dist/esm/classes/redis-queue-backend.d.ts +1 -1
  21. package/dist/esm/classes/redis-queue-backend.js +1 -1
  22. package/dist/esm/commands/includes/moveChildBackToParentDependencies.lua +20 -0
  23. package/dist/esm/commands/moveJobsToWait-8.lua +3 -0
  24. package/dist/esm/commands/reprocessJob-7.lua +2 -14
  25. package/dist/esm/postgres/sql-loader.d.ts +6 -1
  26. package/dist/esm/postgres/sql-loader.js +27 -41
  27. package/dist/esm/postgres/sql-scripts.d.ts +10 -0
  28. package/dist/esm/postgres/sql-scripts.js +107 -0
  29. package/dist/esm/scripts/moveJobsToWait-8.js +21 -0
  30. package/dist/esm/scripts/reprocessJob-7.js +20 -13
  31. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  32. package/dist/esm/version.d.ts +1 -1
  33. package/dist/esm/version.js +1 -1
  34. package/package.json +10 -9
  35. package/dist/cjs/postgres/commands/add_flow.sql +0 -3
  36. package/dist/cjs/postgres/commands/add_job.sql +0 -5
  37. package/dist/cjs/postgres/commands/add_job_scheduler.sql +0 -6
  38. package/dist/cjs/postgres/commands/add_jobs_bulk.sql +0 -3
  39. package/dist/cjs/postgres/commands/add_log.sql +0 -14
  40. package/dist/cjs/postgres/commands/change_delay.sql +0 -3
  41. package/dist/cjs/postgres/commands/change_priority.sql +0 -3
  42. package/dist/cjs/postgres/commands/clean.sql +0 -3
  43. package/dist/cjs/postgres/commands/clear_logs.sql +0 -9
  44. package/dist/cjs/postgres/commands/collect_metrics.sql +0 -4
  45. package/dist/cjs/postgres/commands/delete_deduplication_key.sql +0 -6
  46. package/dist/cjs/postgres/commands/drain.sql +0 -2
  47. package/dist/cjs/postgres/commands/extend_lock.sql +0 -3
  48. package/dist/cjs/postgres/commands/extend_locks.sql +0 -25
  49. package/dist/cjs/postgres/commands/get_client_list.sql +0 -11
  50. package/dist/cjs/postgres/commands/get_counts.sql +0 -13
  51. package/dist/cjs/postgres/commands/get_counts_per_priority.sql +0 -14
  52. package/dist/cjs/postgres/commands/get_deduplication_job_id.sql +0 -7
  53. package/dist/cjs/postgres/commands/get_dependencies.sql +0 -8
  54. package/dist/cjs/postgres/commands/get_dependencies_page.sql +0 -7
  55. package/dist/cjs/postgres/commands/get_dependency_counts.sql +0 -9
  56. package/dist/cjs/postgres/commands/get_ignored_children_failures.sql +0 -7
  57. package/dist/cjs/postgres/commands/get_job_data.sql +0 -2
  58. package/dist/cjs/postgres/commands/get_job_logs_asc.sql +0 -6
  59. package/dist/cjs/postgres/commands/get_job_logs_count.sql +0 -2
  60. package/dist/cjs/postgres/commands/get_job_logs_desc.sql +0 -6
  61. package/dist/cjs/postgres/commands/get_job_scheduler.sql +0 -6
  62. package/dist/cjs/postgres/commands/get_job_schedulers_count.sql +0 -2
  63. package/dist/cjs/postgres/commands/get_job_schedulers_range.sql +0 -10
  64. package/dist/cjs/postgres/commands/get_metrics.sql +0 -14
  65. package/dist/cjs/postgres/commands/get_processed_children_values.sql +0 -8
  66. package/dist/cjs/postgres/commands/get_queue_meta.sql +0 -2
  67. package/dist/cjs/postgres/commands/get_queue_meta_field.sql +0 -2
  68. package/dist/cjs/postgres/commands/get_queue_meta_fields.sql +0 -3
  69. package/dist/cjs/postgres/commands/get_range.sql +0 -3
  70. package/dist/cjs/postgres/commands/get_rate_limit_ttl.sql +0 -3
  71. package/dist/cjs/postgres/commands/get_state.sql +0 -2
  72. package/dist/cjs/postgres/commands/has_queue_meta_field.sql +0 -4
  73. package/dist/cjs/postgres/commands/has_waiting_job.sql +0 -11
  74. package/dist/cjs/postgres/commands/is_finished.sql +0 -4
  75. package/dist/cjs/postgres/commands/is_job_in_state.sql +0 -6
  76. package/dist/cjs/postgres/commands/is_job_in_wait.sql +0 -15
  77. package/dist/cjs/postgres/commands/is_job_prioritized.sql +0 -6
  78. package/dist/cjs/postgres/commands/is_job_scheduler.sql +0 -4
  79. package/dist/cjs/postgres/commands/is_maxed.sql +0 -10
  80. package/dist/cjs/postgres/commands/listen_events.sql +0 -3
  81. package/dist/cjs/postgres/commands/listen_jobs.sql +0 -4
  82. package/dist/cjs/postgres/commands/move_active_to_wait.sql +0 -4
  83. package/dist/cjs/postgres/commands/move_stalled_jobs_to_wait.sql +0 -4
  84. package/dist/cjs/postgres/commands/move_to_active.sql +0 -4
  85. package/dist/cjs/postgres/commands/move_to_completed.sql +0 -4
  86. package/dist/cjs/postgres/commands/move_to_completed_fetch.sql +0 -9
  87. package/dist/cjs/postgres/commands/move_to_delayed.sql +0 -4
  88. package/dist/cjs/postgres/commands/move_to_failed.sql +0 -4
  89. package/dist/cjs/postgres/commands/move_to_failed_fetch.sql +0 -9
  90. package/dist/cjs/postgres/commands/move_to_waiting_children.sql +0 -3
  91. package/dist/cjs/postgres/commands/next_delay.sql +0 -2
  92. package/dist/cjs/postgres/commands/next_signal.sql +0 -3
  93. package/dist/cjs/postgres/commands/obliterate.sql +0 -4
  94. package/dist/cjs/postgres/commands/paginate_dependencies.sql +0 -19
  95. package/dist/cjs/postgres/commands/pause.sql +0 -2
  96. package/dist/cjs/postgres/commands/promote.sql +0 -3
  97. package/dist/cjs/postgres/commands/promote_jobs.sql +0 -3
  98. package/dist/cjs/postgres/commands/publish_event.sql +0 -3
  99. package/dist/cjs/postgres/commands/read_events.sql +0 -6
  100. package/dist/cjs/postgres/commands/read_events_max.sql +0 -3
  101. package/dist/cjs/postgres/commands/remove.sql +0 -3
  102. package/dist/cjs/postgres/commands/remove_child_dependency.sql +0 -4
  103. package/dist/cjs/postgres/commands/remove_deduplication_key.sql +0 -9
  104. package/dist/cjs/postgres/commands/remove_job_scheduler.sql +0 -3
  105. package/dist/cjs/postgres/commands/remove_queue_meta_fields.sql +0 -2
  106. package/dist/cjs/postgres/commands/remove_rate_limit.sql +0 -6
  107. package/dist/cjs/postgres/commands/remove_unprocessed_children.sql +0 -3
  108. package/dist/cjs/postgres/commands/reprocess_job.sql +0 -4
  109. package/dist/cjs/postgres/commands/retry_job.sql +0 -3
  110. package/dist/cjs/postgres/commands/retry_jobs.sql +0 -3
  111. package/dist/cjs/postgres/commands/set_queue_meta.sql +0 -4
  112. package/dist/cjs/postgres/commands/set_rate_limit.sql +0 -3
  113. package/dist/cjs/postgres/commands/trim_logs.sql +0 -3
  114. package/dist/cjs/postgres/commands/update_data.sql +0 -3
  115. package/dist/cjs/postgres/commands/update_job_scheduler.sql +0 -5
  116. package/dist/cjs/postgres/commands/update_progress.sql +0 -3
  117. package/dist/cjs/postgres/migrations/0001_schema.sql +0 -348
  118. package/dist/cjs/postgres/migrations/0002_functions.sql +0 -3473
  119. package/dist/cjs/postgres/migrations/0003_dedup_stale_key.sql +0 -147
  120. package/dist/esm/postgres/commands/add_flow.sql +0 -3
  121. package/dist/esm/postgres/commands/add_job.sql +0 -5
  122. package/dist/esm/postgres/commands/add_job_scheduler.sql +0 -6
  123. package/dist/esm/postgres/commands/add_jobs_bulk.sql +0 -3
  124. package/dist/esm/postgres/commands/add_log.sql +0 -14
  125. package/dist/esm/postgres/commands/change_delay.sql +0 -3
  126. package/dist/esm/postgres/commands/change_priority.sql +0 -3
  127. package/dist/esm/postgres/commands/clean.sql +0 -3
  128. package/dist/esm/postgres/commands/clear_logs.sql +0 -9
  129. package/dist/esm/postgres/commands/collect_metrics.sql +0 -4
  130. package/dist/esm/postgres/commands/delete_deduplication_key.sql +0 -6
  131. package/dist/esm/postgres/commands/drain.sql +0 -2
  132. package/dist/esm/postgres/commands/extend_lock.sql +0 -3
  133. package/dist/esm/postgres/commands/extend_locks.sql +0 -25
  134. package/dist/esm/postgres/commands/get_client_list.sql +0 -11
  135. package/dist/esm/postgres/commands/get_counts.sql +0 -13
  136. package/dist/esm/postgres/commands/get_counts_per_priority.sql +0 -14
  137. package/dist/esm/postgres/commands/get_deduplication_job_id.sql +0 -7
  138. package/dist/esm/postgres/commands/get_dependencies.sql +0 -8
  139. package/dist/esm/postgres/commands/get_dependencies_page.sql +0 -7
  140. package/dist/esm/postgres/commands/get_dependency_counts.sql +0 -9
  141. package/dist/esm/postgres/commands/get_ignored_children_failures.sql +0 -7
  142. package/dist/esm/postgres/commands/get_job_data.sql +0 -2
  143. package/dist/esm/postgres/commands/get_job_logs_asc.sql +0 -6
  144. package/dist/esm/postgres/commands/get_job_logs_count.sql +0 -2
  145. package/dist/esm/postgres/commands/get_job_logs_desc.sql +0 -6
  146. package/dist/esm/postgres/commands/get_job_scheduler.sql +0 -6
  147. package/dist/esm/postgres/commands/get_job_schedulers_count.sql +0 -2
  148. package/dist/esm/postgres/commands/get_job_schedulers_range.sql +0 -10
  149. package/dist/esm/postgres/commands/get_metrics.sql +0 -14
  150. package/dist/esm/postgres/commands/get_processed_children_values.sql +0 -8
  151. package/dist/esm/postgres/commands/get_queue_meta.sql +0 -2
  152. package/dist/esm/postgres/commands/get_queue_meta_field.sql +0 -2
  153. package/dist/esm/postgres/commands/get_queue_meta_fields.sql +0 -3
  154. package/dist/esm/postgres/commands/get_range.sql +0 -3
  155. package/dist/esm/postgres/commands/get_rate_limit_ttl.sql +0 -3
  156. package/dist/esm/postgres/commands/get_state.sql +0 -2
  157. package/dist/esm/postgres/commands/has_queue_meta_field.sql +0 -4
  158. package/dist/esm/postgres/commands/has_waiting_job.sql +0 -11
  159. package/dist/esm/postgres/commands/is_finished.sql +0 -4
  160. package/dist/esm/postgres/commands/is_job_in_state.sql +0 -6
  161. package/dist/esm/postgres/commands/is_job_in_wait.sql +0 -15
  162. package/dist/esm/postgres/commands/is_job_prioritized.sql +0 -6
  163. package/dist/esm/postgres/commands/is_job_scheduler.sql +0 -4
  164. package/dist/esm/postgres/commands/is_maxed.sql +0 -10
  165. package/dist/esm/postgres/commands/listen_events.sql +0 -3
  166. package/dist/esm/postgres/commands/listen_jobs.sql +0 -4
  167. package/dist/esm/postgres/commands/move_active_to_wait.sql +0 -4
  168. package/dist/esm/postgres/commands/move_stalled_jobs_to_wait.sql +0 -4
  169. package/dist/esm/postgres/commands/move_to_active.sql +0 -4
  170. package/dist/esm/postgres/commands/move_to_completed.sql +0 -4
  171. package/dist/esm/postgres/commands/move_to_completed_fetch.sql +0 -9
  172. package/dist/esm/postgres/commands/move_to_delayed.sql +0 -4
  173. package/dist/esm/postgres/commands/move_to_failed.sql +0 -4
  174. package/dist/esm/postgres/commands/move_to_failed_fetch.sql +0 -9
  175. package/dist/esm/postgres/commands/move_to_waiting_children.sql +0 -3
  176. package/dist/esm/postgres/commands/next_delay.sql +0 -2
  177. package/dist/esm/postgres/commands/next_signal.sql +0 -3
  178. package/dist/esm/postgres/commands/obliterate.sql +0 -4
  179. package/dist/esm/postgres/commands/paginate_dependencies.sql +0 -19
  180. package/dist/esm/postgres/commands/pause.sql +0 -2
  181. package/dist/esm/postgres/commands/promote.sql +0 -3
  182. package/dist/esm/postgres/commands/promote_jobs.sql +0 -3
  183. package/dist/esm/postgres/commands/publish_event.sql +0 -3
  184. package/dist/esm/postgres/commands/read_events.sql +0 -6
  185. package/dist/esm/postgres/commands/read_events_max.sql +0 -3
  186. package/dist/esm/postgres/commands/remove.sql +0 -3
  187. package/dist/esm/postgres/commands/remove_child_dependency.sql +0 -4
  188. package/dist/esm/postgres/commands/remove_deduplication_key.sql +0 -9
  189. package/dist/esm/postgres/commands/remove_job_scheduler.sql +0 -3
  190. package/dist/esm/postgres/commands/remove_queue_meta_fields.sql +0 -2
  191. package/dist/esm/postgres/commands/remove_rate_limit.sql +0 -6
  192. package/dist/esm/postgres/commands/remove_unprocessed_children.sql +0 -3
  193. package/dist/esm/postgres/commands/reprocess_job.sql +0 -4
  194. package/dist/esm/postgres/commands/retry_job.sql +0 -3
  195. package/dist/esm/postgres/commands/retry_jobs.sql +0 -3
  196. package/dist/esm/postgres/commands/set_queue_meta.sql +0 -4
  197. package/dist/esm/postgres/commands/set_rate_limit.sql +0 -3
  198. package/dist/esm/postgres/commands/trim_logs.sql +0 -3
  199. package/dist/esm/postgres/commands/update_data.sql +0 -3
  200. package/dist/esm/postgres/commands/update_job_scheduler.sql +0 -5
  201. package/dist/esm/postgres/commands/update_progress.sql +0 -3
  202. package/dist/esm/postgres/migrations/0001_schema.sql +0 -348
  203. package/dist/esm/postgres/migrations/0002_functions.sql +0 -3473
  204. package/dist/esm/postgres/migrations/0003_dedup_stale_key.sql +0 -147
@@ -1,3473 +0,0 @@
1
- -- BullMQ PostgreSQL backend — operation functions.
2
- --
3
- -- Every stored function implementing a queue operation (add, move-to-active,
4
- -- finish, flows, scheduler, rate limiting, dedup, metrics, …), in its final
5
- -- form. Depends on the tables/types created in 0001_schema.sql.
6
-
7
- -- ──────────────────────────────────────────────────────────────────────────
8
- -- Per-queue job-id allocator
9
- -- ──────────────────────────────────────────────────────────────────────────
10
- -- BullMQ hands auto-generated job ids from a per-queue counter (Redis: INCR
11
- -- <prefix>:<queue>:id). Implementing that as a `meta` row bumped with
12
- -- `ON CONFLICT DO UPDATE` serializes *every* concurrent `add` on that row's
13
- -- lock (held until commit), so parallel producers cannot make progress — by far
14
- -- the biggest `add` bottleneck. A dedicated per-queue SEQUENCE hands out ids via
15
- -- `nextval`, which takes no transaction-scoped lock, so concurrent adds run in
16
- -- parallel. Ids are still 1, 2, 3, … per queue (sequence starts at 1), matching
17
- -- Redis; a rolled-back add may leave a gap, exactly as a failed Redis add leaves
18
- -- its INCR in place. The sequence is created lazily on first use and dropped by
19
- -- `obliterate`. If we ever need user-facing immutable ids independent from
20
- -- this allocator, we can add a separate custom-id column/index without regressing
21
- -- the hot-path insert concurrency.
22
- CREATE FUNCTION job_id_seq_name(p_queue text) RETURNS text
23
- LANGUAGE sql IMMUTABLE AS $$
24
- SELECT 'bullmq_jid_' || md5(p_queue)
25
- $$;
26
-
27
- CREATE FUNCTION next_job_id(p_queue text) RETURNS text
28
- LANGUAGE plpgsql
29
- SET search_path FROM CURRENT
30
- AS $$
31
- DECLARE
32
- v_seq text := job_id_seq_name(p_queue);
33
- BEGIN
34
- -- Fast path once the sequence exists: a cached catalog lookup + a lock-free
35
- -- `nextval`. Only the first add(s) for a queue take the creation path; the
36
- -- advisory lock serializes those racing first-adds so exactly one issues the
37
- -- DDL (and it is released the moment that add commits).
38
- IF to_regclass(v_seq) IS NULL THEN
39
- PERFORM pg_advisory_xact_lock(hashtext('bullmq:jidseq:' || p_queue));
40
- EXECUTE format('CREATE SEQUENCE IF NOT EXISTS %I', v_seq);
41
- END IF;
42
- RETURN nextval(v_seq::regclass)::text;
43
- END;
44
- $$;
45
-
46
- -- ── Recreate add_job to run the deduplication decision before inserting.
47
- CREATE OR REPLACE FUNCTION add_job(
48
- p_queue text,
49
- p_id text,
50
- p_name text,
51
- p_data jsonb,
52
- p_opts jsonb,
53
- p_priority integer,
54
- p_delay bigint,
55
- p_timestamp bigint,
56
- p_max_attempts integer,
57
- p_parent_queue text,
58
- p_parent_id text,
59
- p_parent_key text,
60
- p_dedup_id text,
61
- p_scheduler_id text,
62
- p_lifo boolean
63
- ) RETURNS text
64
- LANGUAGE plpgsql
65
- SET search_path FROM CURRENT
66
- AS $$
67
- DECLARE
68
- v_id text := p_id;
69
- v_seq bigint;
70
- v_state job_state;
71
- v_process_at bigint;
72
- v_inserted boolean;
73
- v_dedup jsonb;
74
- v_existing text;
75
- BEGIN
76
- -- Serialize all dedup operations for this id (mirrors Redis's single-threaded
77
- -- atomicity: two READ COMMITTED adds could otherwise both read "no live key"
78
- -- and both insert). Acquired FIRST — before the id-counter INCR below — so the
79
- -- add and finish paths always take the locks in the same order (advisory then
80
- -- the id-counter row), which rules out a deadlock with requeue's own INCR.
81
- -- Transaction-scoped: released when this add commits.
82
- IF p_dedup_id IS NOT NULL AND p_dedup_id <> '' THEN
83
- PERFORM pg_advisory_xact_lock(
84
- hashtext('bullmq:dedup:' || p_queue || ':' || p_dedup_id));
85
- END IF;
86
-
87
- IF v_id IS NULL OR v_id = '' THEN
88
- v_id := next_job_id(p_queue);
89
- END IF;
90
-
91
- -- Deduplication: if a live key wins, skip the insert and return its id.
92
- IF p_dedup_id IS NOT NULL AND p_dedup_id <> '' THEN
93
- v_dedup := COALESCE(p_opts->'deduplication', p_opts->'debounce',
94
- jsonb_build_object('id', p_dedup_id));
95
- v_existing := deduplicate_job(p_queue, v_dedup, v_id, p_timestamp,
96
- p_name, COALESCE(p_data, '{}'::jsonb), COALESCE(p_opts, '{}'::jsonb));
97
- IF v_existing IS NOT NULL THEN
98
- RETURN v_existing;
99
- END IF;
100
- END IF;
101
-
102
- -- Verify the parent exists before inserting (atomic: a failure rolls back).
103
- IF p_parent_id IS NOT NULL AND p_parent_queue IS NOT NULL THEN
104
- IF NOT EXISTS (
105
- SELECT 1 FROM job WHERE queue = p_parent_queue AND id = p_parent_id
106
- ) THEN
107
- RAISE EXCEPTION 'bullmq: missing parent %', p_parent_key
108
- USING ERRCODE = 'BM001', DETAIL = '-5';
109
- END IF;
110
- END IF;
111
-
112
- v_seq := nextval('job_seq');
113
- IF p_lifo THEN
114
- v_seq := -v_seq;
115
- END IF;
116
-
117
- IF p_delay > 0 THEN
118
- v_state := 'delayed';
119
- v_process_at := p_timestamp + p_delay;
120
- ELSE
121
- v_state := 'waiting';
122
- v_process_at := NULL;
123
- END IF;
124
-
125
- INSERT INTO job (
126
- queue, id, seq, name, state,
127
- data, opts, priority, delay_ms, max_attempts,
128
- added_at_ms, process_at_ms,
129
- dedup_id, scheduler_id,
130
- parent_queue, parent_id, parent_key
131
- ) VALUES (
132
- p_queue, v_id, v_seq, p_name, v_state,
133
- COALESCE(p_data, '{}'::jsonb), COALESCE(p_opts, '{}'::jsonb),
134
- COALESCE(p_priority, 0), COALESCE(p_delay, 0), COALESCE(p_max_attempts, 1),
135
- p_timestamp, v_process_at,
136
- p_dedup_id, p_scheduler_id,
137
- p_parent_queue, p_parent_id, p_parent_key
138
- )
139
- ON CONFLICT (queue, id) DO NOTHING;
140
-
141
- GET DIAGNOSTICS v_inserted = ROW_COUNT;
142
-
143
- IF v_inserted AND p_parent_id IS NOT NULL AND p_parent_queue IS NOT NULL THEN
144
- INSERT INTO job_dependency (
145
- parent_queue, parent_id, child_queue, child_id, child_key, status
146
- ) VALUES (
147
- p_parent_queue, p_parent_id, p_queue, v_id,
148
- p_queue || ':' || v_id, 'pending'
149
- )
150
- ON CONFLICT (parent_queue, parent_id, child_key) DO NOTHING;
151
-
152
- UPDATE job
153
- SET pending_deps = pending_deps + 1
154
- WHERE queue = p_parent_queue AND id = p_parent_id;
155
- END IF;
156
-
157
- -- The job already existed: re-attach it to the new parent (or, with no
158
- -- parent, just announce the duplicate). -7 = it already has a different one.
159
- IF NOT v_inserted THEN
160
- IF handle_duplicated_job(p_queue, v_id, p_parent_queue,
161
- p_parent_id, p_parent_key, p_timestamp) = -7 THEN
162
- RAISE EXCEPTION 'bullmq: parent cannot be replaced'
163
- USING ERRCODE = 'BM001', DETAIL = '-7';
164
- END IF;
165
- END IF;
166
-
167
- IF v_inserted THEN
168
- PERFORM pg_notify('bullmq_jobs', p_queue);
169
- -- Every stored job announces itself (mirrors storeJob.lua's 'added').
170
- PERFORM publish_event(p_queue, 'added',
171
- jsonb_build_object('jobId', v_id, 'name', p_name));
172
- IF v_state = 'delayed' THEN
173
- PERFORM publish_event(p_queue, 'delayed',
174
- jsonb_build_object('jobId', v_id, 'delay', v_process_at));
175
- ELSE
176
- PERFORM publish_event(p_queue, 'waiting',
177
- jsonb_build_object('jobId', v_id));
178
- END IF;
179
- END IF;
180
-
181
- RETURN v_id;
182
- END;
183
- $$;
184
-
185
- -- ── move_to_active with limiter enforcement ────────────────────────────────
186
- -- Adds the limiter: after promoting due delayed jobs, refuse to claim while
187
- -- rate limited (the caller reads the ttl via next_signal), and consume a
188
- -- token when a job is claimed. Otherwise identical to the v23 definition.
189
- CREATE OR REPLACE FUNCTION move_to_active(
190
- p_queue text,
191
- p_token text,
192
- p_lock_ms bigint,
193
- p_now bigint,
194
- p_name text,
195
- p_limiter_max integer,
196
- p_limiter_duration bigint
197
- ) RETURNS SETOF job
198
- LANGUAGE plpgsql
199
- SET search_path FROM CURRENT
200
- AS $$
201
- DECLARE
202
- v_id text;
203
- v_job job;
204
- v_meta_max integer;
205
- v_max integer;
206
- v_duration bigint;
207
- v_concurrency integer;
208
- BEGIN
209
- -- Promote due delayed jobs first, regardless of pause. Redis promotes delayed
210
- -- jobs via a delay timer that routes through getTargetQueueList, so while the
211
- -- queue is paused they land in the paused list and still surface (counted as
212
- -- paused) even though they are not processed. Running this before the pause
213
- -- early-return reproduces that: the jobs become 'waiting' (reported as paused)
214
- -- but the claim/activate below is skipped.
215
- FOR v_id IN
216
- WITH promoted AS (
217
- UPDATE job
218
- SET state = 'waiting', delay_ms = 0
219
- WHERE queue = p_queue
220
- AND state = 'delayed'
221
- AND process_at_ms <= p_now
222
- RETURNING id
223
- )
224
- SELECT id FROM promoted
225
- LOOP
226
- PERFORM publish_event(p_queue, 'waiting',
227
- jsonb_build_object('jobId', v_id, 'prev', 'delayed'));
228
- END LOOP;
229
-
230
- IF EXISTS (
231
- SELECT 1 FROM meta
232
- WHERE queue = p_queue AND field = 'paused' AND value = '1'
233
- ) THEN
234
- RETURN;
235
- END IF;
236
-
237
- -- Global concurrency: never run more than meta.concurrency jobs active at
238
- -- once (mirrors isQueueMaxed). Serialized per queue with a transaction-scoped
239
- -- advisory lock so concurrent workers cannot all pass the check and overshoot;
240
- -- it releases at commit, once this claim is reflected in the active count.
241
- SELECT value::integer INTO v_concurrency
242
- FROM meta WHERE queue = p_queue AND field = 'concurrency';
243
- IF v_concurrency IS NOT NULL THEN
244
- PERFORM pg_advisory_xact_lock(hashtext('bullmq:concurrency:' || p_queue));
245
- IF (
246
- SELECT count(*) FROM job
247
- WHERE queue = p_queue AND state = 'active'
248
- ) >= v_concurrency THEN
249
- RETURN;
250
- END IF;
251
- END IF;
252
-
253
- -- Rate limit: meta `max` wins, else the worker's `limiter.max`.
254
- SELECT value::integer INTO v_meta_max
255
- FROM meta WHERE queue = p_queue AND field = 'max';
256
- v_max := COALESCE(v_meta_max, p_limiter_max);
257
-
258
- IF v_max IS NOT NULL THEN
259
- -- Redis evaluates moveToActive atomically on a single-threaded server, so a
260
- -- worker's limiter check and its token consumption can never interleave with
261
- -- another worker's. Postgres runs each worker's call under its own snapshot,
262
- -- so without serialization several concurrent workers could all read
263
- -- counter < max, each claim a (distinct) job, and each consume a token —
264
- -- overshooting the limit. A transaction-scoped advisory lock serializes the
265
- -- check-and-consume per queue exactly like Redis; under READ COMMITTED the
266
- -- effective check below then runs on a fresh snapshot that sees the token a
267
- -- preceding worker just committed. The lock auto-releases at statement end
268
- -- (this function is always called standalone) and is only taken when a
269
- -- limiter actually applies, so unlimited queues pay nothing.
270
- PERFORM pg_advisory_xact_lock(hashtext('bullmq:limiter:' || p_queue));
271
- IF rate_limit_effective(p_queue, p_limiter_max, p_now) > 0 THEN
272
- RETURN;
273
- END IF;
274
- END IF;
275
-
276
- SELECT id INTO v_id
277
- FROM job
278
- WHERE queue = p_queue AND state = 'waiting'
279
- ORDER BY priority, seq
280
- FOR UPDATE SKIP LOCKED
281
- LIMIT 1;
282
-
283
- IF v_id IS NULL THEN
284
- RETURN;
285
- END IF;
286
-
287
- -- Consume a token now that a job is actually being claimed. Duration: the
288
- -- worker's `limiter.duration` wins, else meta `duration`.
289
- IF v_max IS NOT NULL THEN
290
- SELECT value::bigint INTO v_duration
291
- FROM meta WHERE queue = p_queue AND field = 'duration';
292
- v_duration := COALESCE(p_limiter_duration, v_duration);
293
- IF v_duration IS NOT NULL THEN
294
- PERFORM rate_limit_consume(p_queue, v_duration, p_now);
295
- END IF;
296
- END IF;
297
-
298
- UPDATE job
299
- SET state = 'active',
300
- lock_token = p_token,
301
- locked_until_ms = p_now + p_lock_ms,
302
- processed_at_ms = p_now,
303
- processed_by = p_name,
304
- attempts_started = attempts_started + 1
305
- WHERE queue = p_queue AND id = v_id
306
- RETURNING * INTO v_job;
307
-
308
- PERFORM publish_event(p_queue, 'active',
309
- jsonb_build_object('jobId', v_id, 'prev', 'waiting'));
310
-
311
- RETURN NEXT v_job;
312
- END;
313
- $$;
314
-
315
- -- ──────────────────────────────────────────────────────────────────────────
316
- -- next_delay: the timestamp of the next delayed job (for worker block timing),
317
- -- or NULL when there are none.
318
- -- ──────────────────────────────────────────────────────────────────────────
319
- CREATE FUNCTION next_delay(p_queue text) RETURNS bigint
320
- LANGUAGE sql
321
- SET search_path FROM CURRENT
322
- AS $$
323
- SELECT MIN(process_at_ms)
324
- FROM job
325
- WHERE queue = p_queue AND state = 'delayed';
326
- $$;
327
-
328
- CREATE OR REPLACE FUNCTION move_to_completed(
329
- p_queue text,
330
- p_id text,
331
- p_token text,
332
- p_return_value jsonb,
333
- p_finished_on bigint,
334
- p_remove_all boolean,
335
- p_keep_age bigint,
336
- p_keep_count integer
337
- ) RETURNS bigint
338
- LANGUAGE plpgsql
339
- SET search_path FROM CURRENT
340
- AS $$
341
- DECLARE
342
- v_state job_state;
343
- v_lock text;
344
- v_pq text;
345
- v_pid text;
346
- v_dedup_id text;
347
- v_remaining integer;
348
- BEGIN
349
- -- If this job is a flow child, its parent may be removed concurrently
350
- -- (`removeOnFail`/`removeOnComplete`), which — via the `ON DELETE CASCADE`
351
- -- FKs — deletes this job's row and its dependency row while we lock those same
352
- -- rows below (in the opposite order), deadlocking (SQLSTATE 40P01). Read the
353
- -- parent first (unlocked, just for the key) and take the per-parent advisory
354
- -- lock BEFORE any row lock, so a concurrent parent removal and this finish op
355
- -- serialize. Mirrors Redis's single-threaded atomicity; auto-releases at
356
- -- commit and is only ever taken on the direct parent (never nested).
357
- SELECT parent_queue, parent_id INTO v_pq, v_pid
358
- FROM job WHERE queue = p_queue AND id = p_id;
359
- IF v_pq IS NOT NULL AND v_pid IS NOT NULL THEN
360
- PERFORM pg_advisory_xact_lock(
361
- hashtext('bullmq:parent:' || v_pq || ':' || v_pid));
362
- END IF;
363
-
364
- SELECT state, lock_token, parent_queue, parent_id, dedup_id
365
- INTO v_state, v_lock, v_pq, v_pid, v_dedup_id
366
- FROM job WHERE queue = p_queue AND id = p_id FOR UPDATE;
367
- IF NOT FOUND THEN
368
- RAISE EXCEPTION 'bullmq: missing job %', p_id USING ERRCODE = 'BM001', DETAIL = '-1';
369
- END IF;
370
- IF v_state <> 'active' THEN
371
- RAISE EXCEPTION 'bullmq: job % not active', p_id USING ERRCODE = 'BM001', DETAIL = '-3';
372
- END IF;
373
- IF p_token <> '0' AND v_lock IS DISTINCT FROM p_token THEN
374
- RAISE EXCEPTION 'bullmq: job % lock mismatch', p_id USING ERRCODE = 'BM001', DETAIL = '-6';
375
- END IF;
376
-
377
- -- A parent cannot complete while it still has unresolved children: pending
378
- -- children → -4, failed children → -9 (mirrors moveToFinished-14.lua, which
379
- -- only enforces this on the "completed" path).
380
- IF EXISTS (
381
- SELECT 1 FROM job_dependency
382
- WHERE parent_queue = p_queue AND parent_id = p_id AND status = 'pending'
383
- ) THEN
384
- RAISE EXCEPTION 'bullmq: job % has pending dependencies', p_id
385
- USING ERRCODE = 'BM001', DETAIL = '-4';
386
- END IF;
387
- IF EXISTS (
388
- SELECT 1 FROM job_dependency
389
- WHERE parent_queue = p_queue AND parent_id = p_id AND status = 'failed'
390
- ) THEN
391
- RAISE EXCEPTION 'bullmq: job % has failed dependencies', p_id
392
- USING ERRCODE = 'BM001', DETAIL = '-9';
393
- END IF;
394
-
395
- UPDATE job
396
- SET state = 'completed',
397
- return_value = p_return_value,
398
- finished_at_ms = p_finished_on,
399
- lock_token = NULL,
400
- locked_until_ms = NULL,
401
- attempts_made = attempts_made + 1
402
- WHERE queue = p_queue AND id = p_id;
403
-
404
- PERFORM publish_event(p_queue, 'completed',
405
- jsonb_build_object('jobId', p_id, 'returnvalue',
406
- COALESCE(p_return_value, 'null'::jsonb)::text, 'prev', 'active'));
407
-
408
- -- Release the parent: mark this child's dependency processed and, if the
409
- -- parent has no pending deps left and is waiting on children, requeue it.
410
- -- The per-parent advisory lock taken at the top of this function serializes
411
- -- these mutations with any sibling finish op and with a concurrent parent
412
- -- removal, so the decrement cannot deadlock on the parent row.
413
- IF v_pid IS NOT NULL AND v_pq IS NOT NULL THEN
414
- UPDATE job_dependency
415
- SET status = 'processed', value = p_return_value
416
- WHERE parent_queue = v_pq AND parent_id = v_pid
417
- AND child_key = p_queue || ':' || p_id;
418
-
419
- UPDATE job
420
- SET pending_deps = GREATEST(pending_deps - 1, 0)
421
- WHERE queue = v_pq AND id = v_pid
422
- RETURNING pending_deps INTO v_remaining;
423
-
424
- IF v_remaining = 0 THEN
425
- -- Release the parent (delay-aware: a parent carrying a delay goes to the
426
- -- delayed set, a prioritized parent keeps its priority).
427
- PERFORM move_parent_to_wait(v_pq, v_pid, p_finished_on);
428
- END IF;
429
- END IF;
430
-
431
- PERFORM apply_retention(
432
- p_queue, p_id, 'completed', p_finished_on, p_remove_all, p_keep_age, p_keep_count
433
- );
434
-
435
- -- Serialize dedup key mutations with concurrent adds for the same id (taken
436
- -- before requeue's id-counter INCR, matching add_job's advisory→meta order).
437
- IF v_dedup_id IS NOT NULL THEN
438
- PERFORM pg_advisory_xact_lock(
439
- hashtext('bullmq:dedup:' || p_queue || ':' || v_dedup_id));
440
- END IF;
441
- -- Clear a no-ttl deduplication key now that its winner has finished.
442
- PERFORM dedup_finalize(p_queue, v_dedup_id, p_id, p_finished_on);
443
- -- keepLastIfActive: turn any stashed proto-next into the new winner job.
444
- PERFORM requeue_dedup_next(p_queue, v_dedup_id, p_finished_on);
445
-
446
- -- run, announce the queue is drained. Redis checks the physical wait + active
447
- -- + prioritized lists; when paused, jobs live in the paused list, so the wait
448
- -- list is empty and 'drained' still fires. Here that is: no active jobs and
449
- -- (the queue is paused OR there are no waiting/prioritized jobs).
450
- IF NOT EXISTS (
451
- SELECT 1 FROM job WHERE queue = p_queue AND state = 'active'
452
- )
453
- AND (
454
- EXISTS (
455
- SELECT 1 FROM meta
456
- WHERE queue = p_queue AND field = 'paused' AND value = '1'
457
- )
458
- OR NOT EXISTS (
459
- SELECT 1 FROM job WHERE queue = p_queue AND state = 'waiting'
460
- )
461
- ) THEN
462
- PERFORM publish_event(p_queue, 'drained', '{}'::jsonb);
463
- END IF;
464
-
465
- RETURN p_finished_on;
466
- END;
467
- $$;
468
-
469
- -- BullMQ PostgreSQL backend — retries-exhausted event (schema version 17).
470
- --
471
- -- A permanent failure (no retries left) emits a 'retries-exhausted' event in
472
- -- addition to 'failed'. Recreate move_to_failed (from 0010) to publish it.
473
- CREATE OR REPLACE FUNCTION move_to_failed(
474
- p_queue text,
475
- p_id text,
476
- p_token text,
477
- p_failed_reason text,
478
- p_stacktrace jsonb,
479
- p_finished_on bigint,
480
- p_remove_all boolean,
481
- p_keep_age bigint,
482
- p_keep_count integer
483
- ) RETURNS bigint
484
- LANGUAGE plpgsql
485
- SET search_path FROM CURRENT
486
- AS $$
487
- DECLARE
488
- v_state job_state;
489
- v_lock text;
490
- v_dedup_id text;
491
- v_attempts integer;
492
- BEGIN
493
- -- If this job is itself a flow parent, `removeOnFail` (apply_retention
494
- -- below) DELETEs it and, via the `job_dependency … ON DELETE CASCADE`
495
- -- FK, its child-dependency rows. A child finishing concurrently locks that
496
- -- same dependency row and then this job's row — the opposite order — which
497
- -- deadlocks (SQLSTATE 40P01). Take the per-parent advisory lock (keyed on this
498
- -- job, the same key a child's finish op uses for its parent) BEFORE the row
499
- -- lock below, so the two serialize instead of racing. Mirrors Redis's
500
- -- single-threaded atomicity; auto-releases at commit.
501
- PERFORM pg_advisory_xact_lock(
502
- hashtext('bullmq:parent:' || p_queue || ':' || p_id));
503
-
504
- SELECT state, lock_token, dedup_id INTO v_state, v_lock, v_dedup_id
505
- FROM job WHERE queue = p_queue AND id = p_id FOR UPDATE;
506
- IF NOT FOUND THEN
507
- RAISE EXCEPTION 'bullmq: missing job %', p_id USING ERRCODE = 'BM001', DETAIL = '-1';
508
- END IF;
509
- IF v_state <> 'active' THEN
510
- RAISE EXCEPTION 'bullmq: job % not active', p_id USING ERRCODE = 'BM001', DETAIL = '-3';
511
- END IF;
512
- IF p_token <> '0' AND v_lock IS DISTINCT FROM p_token THEN
513
- RAISE EXCEPTION 'bullmq: job % lock mismatch', p_id USING ERRCODE = 'BM001', DETAIL = '-6';
514
- END IF;
515
-
516
- UPDATE job
517
- SET state = 'failed',
518
- failed_reason = p_failed_reason,
519
- stacktrace = COALESCE(p_stacktrace, stacktrace),
520
- finished_at_ms = p_finished_on,
521
- lock_token = NULL,
522
- locked_until_ms = NULL,
523
- deferred_failure = NULL,
524
- attempts_made = attempts_made + 1
525
- WHERE queue = p_queue AND id = p_id
526
- RETURNING attempts_made INTO v_attempts;
527
-
528
- PERFORM publish_event(p_queue, 'failed',
529
- jsonb_build_object('jobId', p_id, 'failedReason', p_failed_reason, 'prev', 'active'));
530
-
531
- -- A final failure (reached this function rather than retry/delay) exhausts
532
- -- the job's attempts.
533
- PERFORM publish_event(p_queue, 'retries-exhausted',
534
- jsonb_build_object('jobId', p_id, 'attemptsMade', v_attempts));
535
-
536
- -- Propagate the permanent failure to a parent flow job (fpof/cpof/idof/rdof).
537
- PERFORM handle_child_failure(p_queue, p_id, p_failed_reason, p_finished_on);
538
-
539
- PERFORM apply_retention(
540
- p_queue, p_id, 'failed', p_finished_on, p_remove_all, p_keep_age, p_keep_count
541
- );
542
-
543
- -- Serialize dedup key mutations with concurrent adds for the same id (taken
544
- -- before requeue's id-counter INCR, matching add_job's advisory→meta order).
545
- IF v_dedup_id IS NOT NULL THEN
546
- PERFORM pg_advisory_xact_lock(
547
- hashtext('bullmq:dedup:' || p_queue || ':' || v_dedup_id));
548
- END IF;
549
- -- Clear a no-ttl deduplication key now that its winner has finished.
550
- PERFORM dedup_finalize(p_queue, v_dedup_id, p_id, p_finished_on);
551
- -- keepLastIfActive: turn any stashed proto-next into the new winner job.
552
- PERFORM requeue_dedup_next(p_queue, v_dedup_id, p_finished_on);
553
-
554
- -- finishes and nothing is left to run (no active and either paused or no
555
- -- waiting/prioritized jobs — see move_to_completed).
556
- IF NOT EXISTS (
557
- SELECT 1 FROM job WHERE queue = p_queue AND state = 'active'
558
- )
559
- AND (
560
- EXISTS (
561
- SELECT 1 FROM meta
562
- WHERE queue = p_queue AND field = 'paused' AND value = '1'
563
- )
564
- OR NOT EXISTS (
565
- SELECT 1 FROM job WHERE queue = p_queue AND state = 'waiting'
566
- )
567
- ) THEN
568
- PERFORM publish_event(p_queue, 'drained', '{}'::jsonb);
569
- END IF;
570
-
571
- RETURN p_finished_on;
572
- END;
573
- $$;
574
-
575
- -- ──────────────────────────────────────────────────────────────────────────
576
- -- extend_lock: refresh an active job's lock if the token still holds it.
577
- -- Returns 1 on success, 0 if the lock was lost.
578
- -- ──────────────────────────────────────────────────────────────────────────
579
- CREATE FUNCTION extend_lock(
580
- p_queue text,
581
- p_id text,
582
- p_token text,
583
- p_lock_ms bigint,
584
- p_now bigint
585
- ) RETURNS integer
586
- LANGUAGE plpgsql
587
- SET search_path FROM CURRENT
588
- AS $$
589
- DECLARE
590
- v_updated integer;
591
- BEGIN
592
- UPDATE job
593
- SET locked_until_ms = p_now + p_lock_ms
594
- WHERE queue = p_queue
595
- AND id = p_id
596
- AND state = 'active'
597
- AND lock_token = p_token;
598
-
599
- GET DIAGNOSTICS v_updated = ROW_COUNT;
600
- RETURN v_updated;
601
- END;
602
- $$;
603
-
604
- CREATE OR REPLACE FUNCTION move_to_delayed(
605
- p_queue text,
606
- p_id text,
607
- p_token text,
608
- p_process_at bigint,
609
- p_delay bigint,
610
- p_skip_attempt boolean,
611
- p_failed_reason text,
612
- p_stacktrace jsonb
613
- ) RETURNS integer
614
- LANGUAGE plpgsql
615
- SET search_path FROM CURRENT
616
- AS $$
617
- DECLARE
618
- v_state job_state;
619
- v_lock text;
620
- BEGIN
621
- SELECT state, lock_token INTO v_state, v_lock
622
- FROM job WHERE queue = p_queue AND id = p_id FOR UPDATE;
623
- IF NOT FOUND THEN
624
- RAISE EXCEPTION 'bullmq: missing job %', p_id USING ERRCODE = 'BM001', DETAIL = '-1';
625
- END IF;
626
- IF v_state <> 'active' THEN
627
- RAISE EXCEPTION 'bullmq: job % not active', p_id USING ERRCODE = 'BM001', DETAIL = '-3';
628
- END IF;
629
- IF p_token <> '0' AND v_lock IS DISTINCT FROM p_token THEN
630
- RAISE EXCEPTION 'bullmq: job % lock mismatch', p_id USING ERRCODE = 'BM001', DETAIL = '-6';
631
- END IF;
632
-
633
- UPDATE job
634
- SET state = 'delayed',
635
- process_at_ms = p_process_at,
636
- delay_ms = p_delay,
637
- failed_reason = COALESCE(p_failed_reason, failed_reason),
638
- stacktrace = COALESCE(p_stacktrace, stacktrace),
639
- lock_token = NULL,
640
- locked_until_ms = NULL,
641
- attempts_made = attempts_made + (CASE WHEN p_skip_attempt THEN 0 ELSE 1 END)
642
- WHERE queue = p_queue AND id = p_id;
643
-
644
- -- Announce the delay on the event stream (mirrors moveToDelayed's XADD
645
- -- 'delayed'); QueueEvents consumers rely on this. `delay` carries the
646
- -- absolute timestamp the job becomes due, matching the Redis payload.
647
- PERFORM publish_event(p_queue, 'delayed',
648
- jsonb_build_object('jobId', p_id, 'delay', p_process_at));
649
- PERFORM pg_notify('bullmq_jobs', p_queue);
650
- RETURN 1;
651
- END;
652
- $$;
653
-
654
- -- BullMQ PostgreSQL backend — retry waiting-event prev fix (schema version 18).
655
- --
656
- -- An immediately-retried job transitions active → waiting, so the 'waiting'
657
- -- event's `prev` is 'active' (not 'failed'). Recreate retry_job accordingly.
658
- CREATE OR REPLACE FUNCTION retry_job(
659
- p_queue text,
660
- p_id text,
661
- p_token text,
662
- p_lifo boolean,
663
- p_failed_reason text,
664
- p_stacktrace jsonb
665
- ) RETURNS integer
666
- LANGUAGE plpgsql
667
- SET search_path FROM CURRENT
668
- AS $$
669
- DECLARE
670
- v_state job_state;
671
- v_lock text;
672
- v_seq bigint;
673
- BEGIN
674
- SELECT state, lock_token INTO v_state, v_lock
675
- FROM job WHERE queue = p_queue AND id = p_id FOR UPDATE;
676
- IF NOT FOUND THEN
677
- RAISE EXCEPTION 'bullmq: missing job %', p_id USING ERRCODE = 'BM001', DETAIL = '-1';
678
- END IF;
679
- IF v_state <> 'active' THEN
680
- RAISE EXCEPTION 'bullmq: job % not active', p_id USING ERRCODE = 'BM001', DETAIL = '-3';
681
- END IF;
682
- IF p_token <> '0' AND v_lock IS DISTINCT FROM p_token THEN
683
- RAISE EXCEPTION 'bullmq: job % lock mismatch', p_id USING ERRCODE = 'BM001', DETAIL = '-6';
684
- END IF;
685
-
686
- v_seq := nextval('job_seq');
687
- IF p_lifo THEN
688
- v_seq := -v_seq;
689
- END IF;
690
-
691
- UPDATE job
692
- SET state = 'waiting',
693
- seq = v_seq,
694
- process_at_ms = NULL,
695
- failed_reason = COALESCE(p_failed_reason, failed_reason),
696
- stacktrace = COALESCE(p_stacktrace, stacktrace),
697
- lock_token = NULL,
698
- locked_until_ms = NULL,
699
- attempts_made = attempts_made + 1
700
- WHERE queue = p_queue AND id = p_id;
701
-
702
- PERFORM pg_notify('bullmq_jobs', p_queue);
703
- PERFORM publish_event(p_queue, 'waiting',
704
- jsonb_build_object('jobId', p_id, 'prev', 'active'));
705
- RETURN 1;
706
- END;
707
- $$;
708
-
709
- -- BullMQ PostgreSQL backend — retention age boundary fix (schema version 14).
710
- --
711
- -- Age-based retention keeps jobs finished *within* the last `keep_age` seconds:
712
- -- a job exactly on the boundary (finished_at = now - age*1000) is removed, so
713
- -- the comparison is `<=`, not `<` (matches Redis: keep jobs with score above
714
- -- the cutoff). Otherwise an N-second window keeps N+1 jobs.
715
- CREATE OR REPLACE FUNCTION apply_retention(
716
- p_queue text,
717
- p_id text,
718
- p_state job_state,
719
- p_now bigint,
720
- p_remove_all boolean,
721
- p_keep_age bigint,
722
- p_keep_count integer
723
- ) RETURNS void
724
- LANGUAGE plpgsql
725
- SET search_path FROM CURRENT
726
- AS $$
727
- DECLARE
728
- v_pq text;
729
- v_pid text;
730
- v_remaining integer;
731
- BEGIN
732
- IF p_remove_all THEN
733
- -- If this job is a still-pending child (e.g. a default-failed child with
734
- -- removeOnFail), detach it and release its parent when it was the last
735
- -- pending dependency (mirrors removeParentDependencyKey on removal). A
736
- -- processed/failed dependency is left untouched so the parent keeps it.
737
- SELECT parent_queue, parent_id INTO v_pq, v_pid
738
- FROM job WHERE queue = p_queue AND id = p_id;
739
- IF v_pq IS NOT NULL AND v_pid IS NOT NULL THEN
740
- DELETE FROM job_dependency
741
- WHERE parent_queue = v_pq AND parent_id = v_pid
742
- AND child_key = p_queue || ':' || p_id
743
- AND status = 'pending';
744
- IF FOUND THEN
745
- UPDATE job SET pending_deps = GREATEST(pending_deps - 1, 0)
746
- WHERE queue = v_pq AND id = v_pid
747
- RETURNING pending_deps INTO v_remaining;
748
- IF v_remaining = 0 THEN
749
- PERFORM move_parent_to_wait(v_pq, v_pid, p_now);
750
- END IF;
751
- END IF;
752
- END IF;
753
-
754
- DELETE FROM job WHERE queue = p_queue AND id = p_id;
755
- RETURN;
756
- END IF;
757
-
758
- IF p_keep_age IS NOT NULL AND p_keep_age >= 0 THEN
759
- DELETE FROM job
760
- WHERE queue = p_queue
761
- AND state = p_state
762
- AND finished_at_ms <= p_now - p_keep_age * 1000;
763
- END IF;
764
-
765
- -- A negative keep-count is the "keep everything" sentinel (mirrors Redis,
766
- -- where -1 disables count-based trimming); skip it — a negative SQL LIMIT is
767
- -- a hard error.
768
- IF p_keep_count IS NOT NULL AND p_keep_count >= 0 THEN
769
- DELETE FROM job
770
- WHERE queue = p_queue
771
- AND state = p_state
772
- AND id NOT IN (
773
- SELECT id FROM job
774
- WHERE queue = p_queue AND state = p_state
775
- ORDER BY finished_at_ms DESC, seq DESC
776
- LIMIT p_keep_count
777
- );
778
- END IF;
779
- END;
780
- $$;
781
-
782
- -- BullMQ PostgreSQL backend — event stream (schema version 6).
783
- --
784
- -- QueueEvents consumes an append-only event stream. In Redis this is an XADD
785
- -- stream read with a blocking XREAD; here events live in `event` (a
786
- -- globally-ordered id per insert) and blocking reads use LISTEN/NOTIFY on the
787
- -- shared `bullmq_events` channel. This migration adds the publish helper and
788
- -- weaves event emission into the lifecycle operations.
789
-
790
- -- ──────────────────────────────────────────────────────────────────────────
791
- -- publish_event: append an event to the stream, notify consumers, and trim the
792
- -- stream to the queue's configured max length (`opts.maxLenEvents`, default
793
- -- 10000). Returns the new event id.
794
- -- ──────────────────────────────────────────────────────────────────────────
795
- CREATE FUNCTION publish_event(
796
- p_queue text,
797
- p_event text,
798
- p_data jsonb
799
- ) RETURNS bigint
800
- LANGUAGE plpgsql
801
- SET search_path FROM CURRENT
802
- AS $$
803
- DECLARE
804
- v_id bigint;
805
- v_max integer;
806
- BEGIN
807
- INSERT INTO event (queue, event, data, created_at_ms)
808
- VALUES (
809
- p_queue, p_event, COALESCE(p_data, '{}'::jsonb),
810
- (extract(epoch FROM clock_timestamp()) * 1000)::bigint
811
- )
812
- RETURNING id INTO v_id;
813
-
814
- -- Wake event consumers — but coalesce the wakeup under concurrency. Postgres
815
- -- serializes *every* transaction that issues NOTIFY on a single database-wide
816
- -- lock at commit (to deliver notifications in commit order), so a NOTIFY on
817
- -- each of N concurrent workers' commits forces them single-file through that
818
- -- lock — the dominant processing bottleneck under load (and it caps throughput
819
- -- across *all* queues and processes, since the lock is global).
820
- --
821
- -- A per-queue, transaction-scoped try-lock collapses that: only one committer
822
- -- per queue actually issues the NOTIFY at a time; concurrent committers skip
823
- -- it. Skipping is safe because a consumer reads *all* pending events by cursor
824
- -- on any wake (see readEvents/waitForEvent), and waitForEvent also polls on a
825
- -- timeout — so a skipped wakeup only defers to the next committer's NOTIFY,
826
- -- and as concurrency winds down the final committer is uncontended and always
827
- -- fires. Under low concurrency (e.g. tests) the lock is always free, so every
828
- -- event notifies exactly as before.
829
- IF pg_try_advisory_xact_lock(hashtext('bullmq:evnotify:' || p_queue)) THEN
830
- PERFORM pg_notify('bullmq_events', p_queue);
831
- END IF;
832
-
833
- SELECT value::integer INTO v_max
834
- FROM meta
835
- WHERE queue = p_queue AND field = 'opts.maxLenEvents';
836
- IF v_max IS NULL THEN
837
- v_max := 10000;
838
- END IF;
839
-
840
- -- Trim to (approximately) the most recent `v_max` events. `event.id`
841
- -- comes from a monotonic sequence, so the cutoff is simply `id - v_max` — an
842
- -- O(number-of-deleted-rows) DELETE.
843
- --
844
- -- Trimming is *chunked*, not per-publish: only every 256th event even attempts
845
- -- it. Redis's `XADD MAXLEN ~` likewise trims a whole macro-node at a time
846
- -- rather than on every add — trimming eagerly turns a bulk/concurrent insert
847
- -- into a stream of DELETEs that contend (and previously deadlocked) with the
848
- -- concurrent INSERTs on `event`. At 1/256 the table stays within
849
- -- `v_max + 256`, which is well inside the "approximate" contract. A
850
- -- non-blocking, per-queue advisory lock further ensures at most one trimmer
851
- -- per queue at a time; publishers that miss it simply skip (the next one
852
- -- covers the same rows).
853
- IF v_max > 0 AND v_id > v_max AND (v_id % 256) = 0 THEN
854
- IF pg_try_advisory_xact_lock(hashtext('bullmq:evtrim:' || p_queue)) THEN
855
- DELETE FROM event
856
- WHERE queue = p_queue AND id <= v_id - v_max;
857
- END IF;
858
- END IF;
859
-
860
- RETURN v_id;
861
- END;
862
- $$;
863
-
864
- CREATE FUNCTION update_progress(
865
- p_queue text,
866
- p_id text,
867
- p_progress jsonb
868
- ) RETURNS integer
869
- LANGUAGE plpgsql
870
- SET search_path FROM CURRENT
871
- AS $$
872
- DECLARE
873
- v_updated integer;
874
- BEGIN
875
- UPDATE job SET progress = p_progress
876
- WHERE queue = p_queue AND id = p_id;
877
- GET DIAGNOSTICS v_updated = ROW_COUNT;
878
-
879
- IF v_updated > 0 THEN
880
- PERFORM publish_event(p_queue, 'progress',
881
- jsonb_build_object('jobId', p_id, 'data', p_progress::text));
882
- END IF;
883
-
884
- RETURN v_updated;
885
- END;
886
- $$;
887
-
888
- -- BullMQ PostgreSQL backend — admin operations & getters (schema version 7).
889
- --
890
- -- pause/resume, drain, remove, bulk retry/promote, and the job-id range getter
891
- -- used by getJobs. move_to_active is recreated to honor the paused flag.
892
-
893
- -- ──────────────────────────────────────────────────────────────────────────
894
- -- pause: set/clear the queue's paused flag (an O(1) meta flag — no bulk move).
895
- -- ──────────────────────────────────────────────────────────────────────────
896
- CREATE FUNCTION pause(p_queue text, p_paused boolean) RETURNS void
897
- LANGUAGE plpgsql
898
- SET search_path FROM CURRENT
899
- AS $$
900
- BEGIN
901
- IF p_paused THEN
902
- INSERT INTO meta (queue, field, value)
903
- VALUES (p_queue, 'paused', '1')
904
- ON CONFLICT (queue, field) DO UPDATE SET value = '1';
905
- ELSE
906
- -- Resume mirrors Redis `HDEL meta paused`: the field is removed entirely,
907
- -- so `isPaused` (hasQueueMetaField 'paused') reports false rather than
908
- -- finding a lingering '0'.
909
- DELETE FROM meta WHERE queue = p_queue AND field = 'paused';
910
-
911
- -- Wake any worker blocked in waitForJob (LISTEN bullmq_jobs): jobs that were
912
- -- unclaimable only because the queue was paused are now claimable, but no
913
- -- `add` (which would notify) is involved. Without this the worker sleeps
914
- -- until its drainDelay times out. The Redis backend wakes the blocking
915
- -- fetch via a marker on resume; this is the LISTEN/NOTIFY analogue.
916
- PERFORM pg_notify('bullmq_jobs', p_queue);
917
- END IF;
918
-
919
- PERFORM publish_event(
920
- p_queue, CASE WHEN p_paused THEN 'paused' ELSE 'resumed' END, '{}'::jsonb
921
- );
922
- END;
923
- $$;
924
-
925
- -- drain: never remove scheduler jobs.
926
- CREATE OR REPLACE FUNCTION drain(p_queue text, p_delayed boolean)
927
- RETURNS void
928
- LANGUAGE plpgsql
929
- SET search_path FROM CURRENT
930
- AS $$
931
- DECLARE
932
- v_pq text[];
933
- v_pid text[];
934
- i integer;
935
- BEGIN
936
- SELECT array_agg(parent_queue), array_agg(parent_id)
937
- INTO v_pq, v_pid
938
- FROM (
939
- SELECT DISTINCT parent_queue, parent_id
940
- FROM job
941
- WHERE queue = p_queue
942
- AND parent_id IS NOT NULL
943
- AND scheduler_id IS NULL
944
- AND (state = 'waiting' OR (p_delayed AND state = 'delayed'))
945
- ) s;
946
-
947
- DELETE FROM job_dependency d
948
- USING job j
949
- WHERE j.queue = p_queue
950
- AND (j.state = 'waiting' OR (p_delayed AND j.state = 'delayed'))
951
- AND j.parent_id IS NOT NULL
952
- AND j.scheduler_id IS NULL
953
- AND d.parent_queue = j.parent_queue
954
- AND d.parent_id = j.parent_id
955
- AND d.child_queue = j.queue
956
- AND d.child_id = j.id;
957
-
958
- DELETE FROM job
959
- WHERE queue = p_queue
960
- AND scheduler_id IS NULL
961
- AND (state = 'waiting' OR (p_delayed AND state = 'delayed'));
962
-
963
- IF v_pq IS NOT NULL THEN
964
- FOR i IN 1 .. array_length(v_pq, 1) LOOP
965
- UPDATE job p
966
- SET pending_deps = (
967
- SELECT count(*) FROM job_dependency d
968
- WHERE d.parent_queue = v_pq[i]
969
- AND d.parent_id = v_pid[i]
970
- AND d.status = 'pending'
971
- )
972
- WHERE p.queue = v_pq[i] AND p.id = v_pid[i];
973
-
974
- PERFORM 1 FROM job p
975
- WHERE p.queue = v_pq[i] AND p.id = v_pid[i]
976
- AND p.state = 'waiting-children'
977
- AND p.pending_deps = 0;
978
-
979
- IF FOUND THEN
980
- IF v_pq[i] = p_queue THEN
981
- DELETE FROM job WHERE queue = v_pq[i] AND id = v_pid[i];
982
- ELSE
983
- UPDATE job
984
- SET state = 'waiting', seq = nextval('job_seq')
985
- WHERE queue = v_pq[i] AND id = v_pid[i];
986
- PERFORM pg_notify('bullmq_jobs', v_pq[i]);
987
- PERFORM publish_event(v_pq[i], 'waiting',
988
- jsonb_build_object('jobId', v_pid[i], 'prev', 'waiting-children'));
989
- END IF;
990
- END IF;
991
- END LOOP;
992
- END IF;
993
- END;
994
- $$;
995
-
996
- -- BullMQ PostgreSQL backend — job-scheduler protection (schema v22).
997
- --
998
- -- Jobs produced by a job scheduler (those with a non-NULL `scheduler_id`) must
999
- -- not be removed directly, nor swept away by `drain`/`clean`; only the
1000
- -- scheduler itself (or its removal) may delete them. This mirrors the Redis
1001
- -- behaviour where `removeJob` refuses scheduler jobs and `drain`/`clean` skip
1002
- -- them.
1003
-
1004
- -- remove: remove a job (and, when requested, its whole flow subtree). Mirrors
1005
- -- removeJob-2.lua + removeJobWithChildren:
1006
- -- * refuses scheduler jobs (ErrorCode -8);
1007
- -- * returns 0 (removes nothing) when the job — or, with removeChildren, any of
1008
- -- its still-pending descendants — is locked by a worker; the caller turns
1009
- -- this into the "locked by another worker" error;
1010
- -- * detaches the removed root from its parent's dependency set and promotes
1011
- -- the parent to wait when that clears its last pending dependency;
1012
- -- * recursively deletes the subtree (children linked via parent_queue/
1013
- -- parent_id) and clears each removed job's deduplication key.
1014
- CREATE OR REPLACE FUNCTION remove(
1015
- p_queue text, p_id text, p_remove_children boolean
1016
- ) RETURNS integer
1017
- LANGUAGE plpgsql
1018
- SET search_path FROM CURRENT
1019
- AS $$
1020
- DECLARE
1021
- v_found boolean := false;
1022
- v_scheduler text;
1023
- v_pq text;
1024
- v_pid text;
1025
- v_locked boolean;
1026
- v_remaining integer;
1027
- v_deleted integer := 0;
1028
- v_now bigint := (extract(epoch from clock_timestamp()) * 1000)::bigint;
1029
- r RECORD;
1030
- BEGIN
1031
- SELECT true, scheduler_id, parent_queue, parent_id
1032
- INTO v_found, v_scheduler, v_pq, v_pid
1033
- FROM job WHERE queue = p_queue AND id = p_id;
1034
-
1035
- IF NOT COALESCE(v_found, false) THEN
1036
- RETURN 0;
1037
- END IF;
1038
-
1039
- IF v_scheduler IS NOT NULL THEN
1040
- RAISE EXCEPTION 'job belongs to a scheduler'
1041
- USING ERRCODE = 'BM001', DETAIL = '-8';
1042
- END IF;
1043
-
1044
- -- Lock check. A job cannot be removed while it is locked; with removeChildren
1045
- -- the check recurses through still-pending descendants (mirrors isLocked()).
1046
- IF p_remove_children THEN
1047
- WITH RECURSIVE subtree AS (
1048
- SELECT p_queue AS q, p_id AS id
1049
- UNION
1050
- SELECT d.child_queue, d.child_id
1051
- FROM subtree s
1052
- JOIN job_dependency d
1053
- ON d.parent_queue = s.q AND d.parent_id = s.id
1054
- AND d.status = 'pending'
1055
- )
1056
- SELECT EXISTS (
1057
- SELECT 1 FROM subtree s
1058
- JOIN job j ON j.queue = s.q AND j.id = s.id
1059
- WHERE j.lock_token IS NOT NULL AND j.locked_until_ms > v_now
1060
- ) INTO v_locked;
1061
- ELSE
1062
- SELECT lock_token IS NOT NULL AND locked_until_ms > v_now INTO v_locked
1063
- FROM job WHERE queue = p_queue AND id = p_id;
1064
- END IF;
1065
-
1066
- IF v_locked THEN
1067
- RETURN 0;
1068
- END IF;
1069
-
1070
- -- Detach the removed root from its parent's pending dependency set; if that
1071
- -- clears the parent's last pending dependency, promote it to wait. Only
1072
- -- pending links matter (a processed/failed child was already accounted for).
1073
- IF v_pq IS NOT NULL AND v_pid IS NOT NULL THEN
1074
- DELETE FROM job_dependency
1075
- WHERE parent_queue = v_pq AND parent_id = v_pid
1076
- AND child_key = p_queue || ':' || p_id
1077
- AND status = 'pending';
1078
- IF FOUND THEN
1079
- UPDATE job SET pending_deps = GREATEST(pending_deps - 1, 0)
1080
- WHERE queue = v_pq AND id = v_pid
1081
- RETURNING pending_deps INTO v_remaining;
1082
- IF v_remaining = 0 THEN
1083
- PERFORM move_parent_to_wait(v_pq, v_pid, v_now);
1084
- END IF;
1085
- END IF;
1086
- END IF;
1087
-
1088
- IF p_remove_children THEN
1089
- -- Delete the whole subtree (root + descendants via parent_queue/parent_id).
1090
- FOR r IN
1091
- WITH RECURSIVE subtree AS (
1092
- SELECT p_queue AS q, p_id AS id, dedup_id AS dd
1093
- FROM job WHERE queue = p_queue AND id = p_id
1094
- UNION
1095
- SELECT j.queue, j.id, j.dedup_id
1096
- FROM subtree s
1097
- JOIN job j ON j.parent_queue = s.q AND j.parent_id = s.id
1098
- )
1099
- SELECT q, id, dd FROM subtree
1100
- LOOP
1101
- PERFORM dedup_on_removal(r.q, r.id, r.dd);
1102
- DELETE FROM job WHERE queue = r.q AND id = r.id;
1103
- v_deleted := v_deleted + 1;
1104
- END LOOP;
1105
- ELSE
1106
- PERFORM dedup_on_removal(
1107
- p_queue, p_id,
1108
- (SELECT dedup_id FROM job WHERE queue = p_queue AND id = p_id));
1109
- DELETE FROM job WHERE queue = p_queue AND id = p_id;
1110
- GET DIAGNOSTICS v_deleted = ROW_COUNT;
1111
- END IF;
1112
-
1113
- RETURN v_deleted;
1114
- END;
1115
- $$;
1116
-
1117
- -- ──────────────────────────────────────────────────────────────────────────
1118
- -- retry_jobs: move up to `count` finished jobs of a state back to waiting.
1119
- -- Returns the number moved (the caller loops until it returns 0).
1120
- -- ──────────────────────────────────────────────────────────────────────────
1121
- CREATE FUNCTION retry_jobs(
1122
- p_queue text, p_state text, p_count integer, p_timestamp bigint
1123
- ) RETURNS integer
1124
- LANGUAGE plpgsql
1125
- SET search_path FROM CURRENT
1126
- AS $$
1127
- DECLARE
1128
- v_moved integer;
1129
- BEGIN
1130
- WITH batch AS (
1131
- SELECT id FROM job
1132
- WHERE queue = p_queue
1133
- AND state = p_state::job_state
1134
- AND (p_timestamp IS NULL OR finished_at_ms <= p_timestamp)
1135
- ORDER BY finished_at_ms
1136
- LIMIT p_count
1137
- )
1138
- UPDATE job j
1139
- SET state = 'waiting',
1140
- seq = nextval('job_seq'),
1141
- finished_at_ms = NULL,
1142
- processed_at_ms = NULL,
1143
- return_value = NULL,
1144
- failed_reason = NULL,
1145
- stacktrace = NULL,
1146
- attempts_made = 0,
1147
- attempts_started = 0,
1148
- lock_token = NULL,
1149
- locked_until_ms = NULL
1150
- FROM batch
1151
- WHERE j.queue = p_queue AND j.id = batch.id;
1152
-
1153
- GET DIAGNOSTICS v_moved = ROW_COUNT;
1154
- IF v_moved > 0 THEN
1155
- PERFORM pg_notify('bullmq_jobs', p_queue);
1156
- END IF;
1157
- RETURN v_moved;
1158
- END;
1159
- $$;
1160
-
1161
- -- ──────────────────────────────────────────────────────────────────────────
1162
- -- promote_jobs: move up to `count` delayed jobs to waiting (process now).
1163
- -- Returns the number moved (the caller loops until it returns 0).
1164
- -- ──────────────────────────────────────────────────────────────────────────
1165
- CREATE FUNCTION promote_jobs(p_queue text, p_count integer)
1166
- RETURNS integer
1167
- LANGUAGE plpgsql
1168
- SET search_path FROM CURRENT
1169
- AS $$
1170
- DECLARE
1171
- v_moved integer;
1172
- BEGIN
1173
- WITH batch AS (
1174
- SELECT id FROM job
1175
- WHERE queue = p_queue AND state = 'delayed'
1176
- ORDER BY process_at_ms
1177
- LIMIT p_count
1178
- )
1179
- UPDATE job j
1180
- SET state = 'waiting', process_at_ms = NULL, seq = nextval('job_seq')
1181
- FROM batch
1182
- WHERE j.queue = p_queue AND j.id = batch.id;
1183
-
1184
- GET DIAGNOSTICS v_moved = ROW_COUNT;
1185
- IF v_moved > 0 THEN
1186
- PERFORM pg_notify('bullmq_jobs', p_queue);
1187
- END IF;
1188
- RETURN v_moved;
1189
- END;
1190
- $$;
1191
-
1192
- -- ──────────────────────────────────────────────────────────────────────────
1193
- -- get_range: the job ids in a given state, sliced by [start, end] (inclusive,
1194
- -- zero-based). List-like states (wait/active/waiting-children) are returned in
1195
- -- ascending seq order (the getter layer reverses them for `asc`); zset-like
1196
- -- states honour the requested direction.
1197
- -- ──────────────────────────────────────────────────────────────────────────
1198
- CREATE FUNCTION get_range(
1199
- p_queue text, p_type text, p_start integer, p_end integer, p_asc boolean
1200
- ) RETURNS SETOF text
1201
- LANGUAGE plpgsql
1202
- SET search_path FROM CURRENT
1203
- AS $$
1204
- DECLARE
1205
- v_where text;
1206
- v_order text;
1207
- v_dir text := CASE WHEN p_asc THEN 'ASC' ELSE 'DESC' END;
1208
- v_offset integer := GREATEST(COALESCE(p_start, 0), 0);
1209
- v_limit text;
1210
- BEGIN
1211
- -- 'wait'/'active' are list-backed in Redis (LPUSH ⇒ newest at head), and the
1212
- -- shared queue-getters layer reverses these `lrange` results when asc is
1213
- -- requested. Returning newest-first (DESC) here makes that reversal yield the
1214
- -- FIFO order callers expect (getWaiting/getActive), mirroring Redis LRANGE.
1215
- IF p_type IN ('wait', 'waiting') THEN
1216
- v_where := 'state = ''waiting'' AND priority = 0'; v_order := 'seq'; v_dir := 'DESC';
1217
- ELSIF p_type = 'prioritized' THEN
1218
- v_where := 'state = ''waiting'' AND priority > 0'; v_order := 'priority, seq';
1219
- ELSIF p_type = 'active' THEN
1220
- v_where := 'state = ''active'''; v_order := 'seq'; v_dir := 'DESC';
1221
- ELSIF p_type = 'delayed' THEN
1222
- v_where := 'state = ''delayed'''; v_order := 'process_at_ms';
1223
- ELSIF p_type IN ('completed', 'failed') THEN
1224
- v_where := format('state = %L', p_type); v_order := 'finished_at_ms';
1225
- ELSIF p_type = 'waiting-children' THEN
1226
- v_where := 'state = ''waiting-children'''; v_order := 'seq'; v_dir := 'ASC';
1227
- ELSE
1228
- RETURN; -- paused/repeat/unknown
1229
- END IF;
1230
-
1231
- IF p_end IS NULL OR p_end < 0 THEN
1232
- v_limit := 'ALL';
1233
- ELSE
1234
- IF (p_end - v_offset + 1) <= 0 THEN
1235
- RETURN;
1236
- END IF;
1237
- v_limit := (p_end - v_offset + 1)::text;
1238
- END IF;
1239
-
1240
- RETURN QUERY EXECUTE format(
1241
- 'SELECT id FROM job WHERE queue = %L AND %s ORDER BY %s %s OFFSET %s LIMIT %s',
1242
- p_queue, v_where, v_order, v_dir, v_offset, v_limit
1243
- );
1244
- END;
1245
- $$;
1246
-
1247
- -- BullMQ PostgreSQL backend — flows (schema version 8).
1248
- --
1249
- -- `add_flow` atomically inserts a whole tree of jobs (possibly spanning
1250
- -- multiple queues) from a single JSONB array of entries, ordered roots-first so
1251
- -- a parent always exists before its children register a dependency. `drain` is
1252
- -- recreated to be flow-aware: when draining a queue's children resolves a
1253
- -- parent's last pending dependency, the parent is removed (same queue) or moved
1254
- -- to wait (different queue), mirroring the Redis `removeParentDependencyKey`.
1255
-
1256
- -- ──────────────────────────────────────────────────────────────────────────
1257
- -- add_flow: insert an ordered (roots-first) array of flow entries atomically.
1258
- -- Returns the resulting job ids in the same order as the input entries.
1259
- --
1260
- -- Performance: the per-row loop below is what makes flows (parents, dependency
1261
- -- links, deduplication, re-parenting) correct, and it already batches the two
1262
- -- lifecycle events every insert emits into a single set-based INSERT after the
1263
- -- loop (a ~1000-job addBulk drops from ~3000 statement executions to ~1000 job
1264
- -- inserts + 1 event insert). That closed most of the gap to Redis on concurrent
1265
- -- bulk. Two further, deliberately-deferred iterations could push it further:
1266
- --
1267
- -- 1. A set-based *job* insert for the common flat case (no parent, no dedup):
1268
- -- one `INSERT … SELECT FROM jsonb_array_elements(...)` with per-row
1269
- -- `nextval` for the id/seq, collapsing the remaining ~1000 job inserts to
1270
- -- one statement. It requires a second code path (gated + falling back to
1271
- -- this loop) and careful handling of the return-order contract and
1272
- -- `ON CONFLICT` on explicit ids — hence deferred until profiling shows the
1273
- -- job inserts (not the event stream) dominate.
1274
- -- 2. An opt-out of the durable event stream (à la Oban's telemetry-only
1275
- -- model) for callers that do not use QueueEvents, removing the event
1276
- -- insert entirely.
1277
- -- ──────────────────────────────────────────────────────────────────────────
1278
- CREATE FUNCTION add_flow(p_entries jsonb) RETURNS SETOF text
1279
- LANGUAGE plpgsql
1280
- SET search_path FROM CURRENT
1281
- AS $$
1282
- DECLARE
1283
- e jsonb;
1284
- v_id text;
1285
- v_seq bigint;
1286
- v_state job_state;
1287
- v_process_at bigint;
1288
- v_queue text;
1289
- v_delay bigint;
1290
- v_timestamp bigint;
1291
- v_lifo boolean;
1292
- v_parent_queue text;
1293
- v_parent_id text;
1294
- v_awc boolean;
1295
- v_dedup_id text;
1296
- v_dedup jsonb;
1297
- v_winner text;
1298
- v_inserted boolean;
1299
- v_code integer;
1300
- -- Lifecycle-event accumulation. Rather than PERFORM publish_event per
1301
- -- job (which turns a 1000-job addBulk into ~2000 extra statement executions —
1302
- -- the dominant cost of a single-connection bulk add), events are collected
1303
- -- here and flushed in ONE set-based INSERT after the loop. Appending to a
1304
- -- plpgsql-local array is amortized O(1) (read-write "expanded array"), so this
1305
- -- stays O(n).
1306
- v_ev_queue text[] := '{}';
1307
- v_ev_event text[] := '{}';
1308
- v_ev_data jsonb[] := '{}';
1309
- v_notify_jobs text[] := '{}';
1310
- v_max integer;
1311
- BEGIN
1312
- FOR e IN SELECT value FROM jsonb_array_elements(p_entries)
1313
- LOOP
1314
- v_queue := e ->> 'queue';
1315
- v_id := COALESCE(e ->> 'id', '');
1316
- v_delay := COALESCE((e ->> 'delay')::bigint, 0);
1317
- v_timestamp := COALESCE((e ->> 'timestamp')::bigint, 0);
1318
- v_lifo := COALESCE((e ->> 'lifo')::boolean, false);
1319
- v_parent_queue := e ->> 'parentQueue';
1320
- v_parent_id := e ->> 'parentId';
1321
- v_awc := COALESCE((e ->> 'addToWaitingChildren')::boolean, false);
1322
- v_dedup_id := e ->> 'dedupId';
1323
-
1324
- -- Serialize dedup operations for this id (matches add_job and the finish
1325
- -- paths: advisory lock taken before the id-counter INCR below).
1326
- IF v_dedup_id IS NOT NULL AND v_dedup_id <> '' THEN
1327
- PERFORM pg_advisory_xact_lock(
1328
- hashtext('bullmq:dedup:' || v_queue || ':' || v_dedup_id));
1329
- END IF;
1330
-
1331
- IF v_id IS NULL OR v_id = '' THEN
1332
- v_id := next_job_id(v_queue);
1333
- END IF;
1334
-
1335
- -- Root deduplication: if a live key already won, return its id and skip
1336
- -- inserting this job (and, because it never exists, its descendants).
1337
- IF v_dedup_id IS NOT NULL AND v_dedup_id <> '' THEN
1338
- v_dedup := COALESCE(e -> 'opts' -> 'deduplication',
1339
- e -> 'opts' -> 'debounce',
1340
- jsonb_build_object('id', v_dedup_id));
1341
- v_winner := deduplicate_job(v_queue, v_dedup, v_id, v_timestamp,
1342
- e ->> 'name', COALESCE((e ->> 'data')::jsonb, '{}'::jsonb),
1343
- COALESCE(e -> 'opts', '{}'::jsonb));
1344
- IF v_winner IS NOT NULL THEN
1345
- RETURN NEXT v_winner;
1346
- CONTINUE;
1347
- END IF;
1348
- END IF;
1349
-
1350
- -- An entry whose parent does not exist (a child of a deduplicated root, or
1351
- -- a genuinely missing parent) is not inserted; report -5 as addJob does,
1352
- -- without aborting the rest of the flow.
1353
- IF v_parent_id IS NOT NULL AND v_parent_queue IS NOT NULL
1354
- AND NOT EXISTS (
1355
- SELECT 1 FROM job
1356
- WHERE queue = v_parent_queue AND id = v_parent_id
1357
- ) THEN
1358
- RETURN NEXT '-5';
1359
- CONTINUE;
1360
- END IF;
1361
-
1362
- v_seq := nextval('job_seq');
1363
- IF v_lifo THEN
1364
- v_seq := -v_seq;
1365
- END IF;
1366
-
1367
- IF v_awc THEN
1368
- v_state := 'waiting-children';
1369
- v_process_at := NULL;
1370
- ELSIF v_delay > 0 THEN
1371
- v_state := 'delayed';
1372
- v_process_at := v_timestamp + v_delay;
1373
- ELSE
1374
- v_state := 'waiting';
1375
- v_process_at := NULL;
1376
- END IF;
1377
-
1378
- INSERT INTO job (
1379
- queue, id, seq, name, state,
1380
- data, opts, priority, delay_ms, max_attempts,
1381
- added_at_ms, process_at_ms,
1382
- dedup_id, scheduler_id,
1383
- parent_queue, parent_id, parent_key, pending_deps
1384
- ) VALUES (
1385
- v_queue, v_id, v_seq, e ->> 'name', v_state,
1386
- COALESCE((e ->> 'data')::jsonb, '{}'::jsonb),
1387
- COALESCE(e -> 'opts', '{}'::jsonb),
1388
- COALESCE((e ->> 'priority')::integer, 0), v_delay,
1389
- COALESCE((e ->> 'attempts')::integer, 1),
1390
- v_timestamp, v_process_at,
1391
- e ->> 'dedupId', e ->> 'schedulerId',
1392
- v_parent_queue, v_parent_id, e ->> 'parentKey', 0
1393
- )
1394
- ON CONFLICT (queue, id) DO NOTHING;
1395
- GET DIAGNOSTICS v_inserted = ROW_COUNT;
1396
-
1397
- IF v_parent_id IS NOT NULL AND v_parent_queue IS NOT NULL THEN
1398
- IF v_inserted THEN
1399
- -- New job: register a pending dependency on its parent.
1400
- INSERT INTO job_dependency (
1401
- parent_queue, parent_id, child_queue, child_id, child_key, status
1402
- ) VALUES (
1403
- v_parent_queue, v_parent_id, v_queue, v_id,
1404
- v_queue || ':' || v_id, 'pending'
1405
- )
1406
- ON CONFLICT (parent_queue, parent_id, child_key) DO NOTHING;
1407
-
1408
- UPDATE job
1409
- SET pending_deps = pending_deps + 1
1410
- WHERE queue = v_parent_queue AND id = v_parent_id;
1411
- ELSE
1412
- -- The job already existed: re-attach it to this new parent (mirrors
1413
- -- handleDuplicatedJob). -7 means it already has a different parent.
1414
- v_code := handle_duplicated_job(v_queue, v_id,
1415
- v_parent_queue, v_parent_id, e ->> 'parentKey', v_timestamp);
1416
- IF v_code = -7 THEN
1417
- RETURN NEXT '-7';
1418
- CONTINUE;
1419
- END IF;
1420
- END IF;
1421
- END IF;
1422
-
1423
- -- Accumulate lifecycle events for newly-inserted jobs; they are flushed in
1424
- -- a single set-based INSERT after the loop. Accumulation order (per job:
1425
- -- 'added' then the state event, in job order) is what the flush replays via
1426
- -- WITH ORDINALITY, preserving the stream ordering the per-row publish gave.
1427
- IF v_inserted THEN
1428
- -- Every stored job announces itself (mirrors storeJob.lua's 'added').
1429
- v_ev_queue := array_append(v_ev_queue, v_queue);
1430
- v_ev_event := array_append(v_ev_event, 'added');
1431
- v_ev_data := array_append(v_ev_data,
1432
- jsonb_build_object('jobId', v_id, 'name', e ->> 'name'));
1433
- IF v_state = 'waiting' THEN
1434
- v_ev_queue := array_append(v_ev_queue, v_queue);
1435
- v_ev_event := array_append(v_ev_event, 'waiting');
1436
- v_ev_data := array_append(v_ev_data,
1437
- jsonb_build_object('jobId', v_id));
1438
- v_notify_jobs := array_append(v_notify_jobs, v_queue);
1439
- ELSIF v_state = 'delayed' THEN
1440
- v_ev_queue := array_append(v_ev_queue, v_queue);
1441
- v_ev_event := array_append(v_ev_event, 'delayed');
1442
- v_ev_data := array_append(v_ev_data,
1443
- jsonb_build_object('jobId', v_id, 'delay', v_process_at));
1444
- v_notify_jobs := array_append(v_notify_jobs, v_queue);
1445
- ELSIF v_state = 'waiting-children' THEN
1446
- v_ev_queue := array_append(v_ev_queue, v_queue);
1447
- v_ev_event := array_append(v_ev_event, 'waiting-children');
1448
- v_ev_data := array_append(v_ev_data,
1449
- jsonb_build_object('jobId', v_id));
1450
- END IF;
1451
- END IF;
1452
-
1453
- RETURN NEXT v_id;
1454
- END LOOP;
1455
-
1456
- -- ── Flush accumulated lifecycle events in one statement ──────────────────
1457
- IF array_length(v_ev_queue, 1) > 0 THEN
1458
- -- Single set-based append. The id DEFAULT nextval('event_seq') is
1459
- -- assigned in ordinality order (the ORDER BY pins row-production order), so
1460
- -- the stream keeps each job's 'added' → state-event ordering.
1461
- INSERT INTO event (queue, event, data, created_at_ms)
1462
- SELECT q, ev, dat, (extract(epoch FROM clock_timestamp()) * 1000)::bigint
1463
- FROM unnest(v_ev_queue, v_ev_event, v_ev_data)
1464
- WITH ORDINALITY AS t(q, ev, dat, ord)
1465
- ORDER BY ord;
1466
-
1467
- -- One 'bullmq_events' wakeup per distinct queue, then trim that queue's
1468
- -- stream once (not per publish). A non-blocking advisory lock keeps at most
1469
- -- one trimmer per queue; publishers that miss it skip (the next flush covers
1470
- -- the same rows). Matches publish_event's approximate-MAXLEN trim.
1471
- FOR v_queue IN SELECT DISTINCT q FROM unnest(v_ev_queue) AS q LOOP
1472
- PERFORM pg_notify('bullmq_events', v_queue);
1473
-
1474
- SELECT value::integer INTO v_max
1475
- FROM meta
1476
- WHERE queue = v_queue AND field = 'opts.maxLenEvents';
1477
- IF v_max IS NULL THEN
1478
- v_max := 10000;
1479
- END IF;
1480
- IF v_max > 0
1481
- AND pg_try_advisory_xact_lock(hashtext('bullmq:evtrim:' || v_queue))
1482
- THEN
1483
- DELETE FROM event
1484
- WHERE queue = v_queue
1485
- AND id <= (SELECT max(id) FROM event WHERE queue = v_queue)
1486
- - v_max;
1487
- END IF;
1488
- END LOOP;
1489
-
1490
- -- One 'bullmq_jobs' wakeup per distinct queue that gained a runnable job.
1491
- FOR v_queue IN SELECT DISTINCT q FROM unnest(v_notify_jobs) AS q LOOP
1492
- PERFORM pg_notify('bullmq_jobs', v_queue);
1493
- END LOOP;
1494
- END IF;
1495
- END;
1496
- $$;
1497
-
1498
- -- BullMQ PostgreSQL backend — single-job mutations (schema version 9).
1499
- --
1500
- -- promote / changeDelay / changePriority validate the job's state and return a
1501
- -- status code (0 = ok, or a negative `ErrorCode` the backend maps to the shared
1502
- -- error message). Keeping the validation in SQL makes each mutation atomic.
1503
-
1504
- -- ──────────────────────────────────────────────────────────────────────────
1505
- -- promote: a delayed job → waiting (process ASAP, keeping its priority).
1506
- -- Returns 0 ok, -1 missing (JobNotExist), -3 not delayed (JobNotInState).
1507
- -- ──────────────────────────────────────────────────────────────────────────
1508
- CREATE FUNCTION promote(p_queue text, p_id text) RETURNS integer
1509
- LANGUAGE plpgsql
1510
- SET search_path FROM CURRENT
1511
- AS $$
1512
- DECLARE
1513
- v_state job_state;
1514
- BEGIN
1515
- SELECT state INTO v_state FROM job WHERE queue = p_queue AND id = p_id;
1516
- IF NOT FOUND THEN
1517
- RETURN -1;
1518
- END IF;
1519
- IF v_state <> 'delayed' THEN
1520
- RETURN -3;
1521
- END IF;
1522
-
1523
- UPDATE job
1524
- SET state = 'waiting',
1525
- process_at_ms = NULL,
1526
- delay_ms = 0,
1527
- seq = nextval('job_seq')
1528
- WHERE queue = p_queue AND id = p_id;
1529
-
1530
- PERFORM pg_notify('bullmq_jobs', p_queue);
1531
- PERFORM publish_event(p_queue, 'waiting',
1532
- jsonb_build_object('jobId', p_id, 'prev', 'delayed'));
1533
- RETURN 0;
1534
- END;
1535
- $$;
1536
-
1537
- -- ──────────────────────────────────────────────────────────────────────────
1538
- -- change_delay: reschedule a delayed job to fire `p_delay` ms from now.
1539
- -- Returns 0 ok, -1 missing, -3 not delayed.
1540
- -- ──────────────────────────────────────────────────────────────────────────
1541
- CREATE FUNCTION change_delay(
1542
- p_queue text, p_id text, p_delay bigint, p_now bigint
1543
- ) RETURNS integer
1544
- LANGUAGE plpgsql
1545
- SET search_path FROM CURRENT
1546
- AS $$
1547
- DECLARE
1548
- v_state job_state;
1549
- BEGIN
1550
- SELECT state INTO v_state FROM job WHERE queue = p_queue AND id = p_id;
1551
- IF NOT FOUND THEN
1552
- RETURN -1;
1553
- END IF;
1554
- IF v_state <> 'delayed' THEN
1555
- RETURN -3;
1556
- END IF;
1557
-
1558
- UPDATE job
1559
- SET delay_ms = p_delay,
1560
- process_at_ms = p_now + p_delay
1561
- WHERE queue = p_queue AND id = p_id;
1562
-
1563
- -- Wake any worker blocked on an older (longer) delay so it recomputes.
1564
- PERFORM pg_notify('bullmq_jobs', p_queue);
1565
- RETURN 0;
1566
- END;
1567
- $$;
1568
-
1569
- -- ──────────────────────────────────────────────────────────────────────────
1570
- -- change_priority: set a job's priority (and reposition it via lifo) when it is
1571
- -- waiting/prioritized; otherwise just record the new priority. Returns 0 ok,
1572
- -- -1 missing.
1573
- -- ──────────────────────────────────────────────────────────────────────────
1574
- CREATE FUNCTION change_priority(
1575
- p_queue text, p_id text, p_priority integer, p_lifo boolean
1576
- ) RETURNS integer
1577
- LANGUAGE plpgsql
1578
- SET search_path FROM CURRENT
1579
- AS $$
1580
- DECLARE
1581
- v_state job_state;
1582
- BEGIN
1583
- SELECT state INTO v_state FROM job WHERE queue = p_queue AND id = p_id;
1584
- IF NOT FOUND THEN
1585
- RETURN -1;
1586
- END IF;
1587
-
1588
- IF v_state = 'waiting' THEN
1589
- -- Reposition: lifo → head (negative seq), otherwise → tail (new seq).
1590
- UPDATE job
1591
- SET priority = p_priority,
1592
- seq = CASE WHEN p_lifo
1593
- THEN -nextval('job_seq')
1594
- ELSE nextval('job_seq') END
1595
- WHERE queue = p_queue AND id = p_id;
1596
- ELSE
1597
- UPDATE job
1598
- SET priority = p_priority
1599
- WHERE queue = p_queue AND id = p_id;
1600
- END IF;
1601
-
1602
- RETURN 0;
1603
- END;
1604
- $$;
1605
-
1606
- -- BullMQ PostgreSQL backend — rate-limit-aware active→wait move (schema v26).
1607
- --
1608
- -- Recreates move_active_to_wait (used by the dynamic/manual rate limit
1609
- -- and `Job.moveToWait`) to mirror moveJobFromActiveToWait-9.lua:
1610
- -- * A missing job returns -1 (so the caller can raise the canonical
1611
- -- "Missing key for job …" error).
1612
- -- * A requeued job keeps FIFO order: priority > 0 jobs go to the *front* of
1613
- -- their priority group (Redis `pushBackJobWithPriority`), priority 0 jobs go
1614
- -- to the tail (Redis `RPUSH`). In the seq model: front = negative seq (sorts
1615
- -- before the positive seqs of same-priority jobs), tail = `nextval`.
1616
- -- * Returns the remaining limiter window in ms (Redis returns the limiter
1617
- -- PTTL), which the worker uses to decide how long to back off.
1618
- CREATE OR REPLACE FUNCTION move_active_to_wait(
1619
- p_queue text, p_id text, p_token text, p_now bigint
1620
- ) RETURNS bigint
1621
- LANGUAGE plpgsql
1622
- SET search_path FROM CURRENT
1623
- AS $$
1624
- DECLARE
1625
- v_state job_state;
1626
- v_lock text;
1627
- v_priority integer;
1628
- v_seq bigint;
1629
- v_expire bigint;
1630
- BEGIN
1631
- SELECT state, lock_token, priority INTO v_state, v_lock, v_priority
1632
- FROM job WHERE queue = p_queue AND id = p_id;
1633
- IF NOT FOUND THEN
1634
- RETURN -1;
1635
- END IF;
1636
-
1637
- IF v_state = 'active'
1638
- AND (p_token = '0' OR v_lock IS NOT DISTINCT FROM p_token) THEN
1639
- IF v_priority > 0 THEN
1640
- v_seq := -nextval('job_seq');
1641
- ELSE
1642
- v_seq := nextval('job_seq');
1643
- END IF;
1644
-
1645
- UPDATE job
1646
- SET state = 'waiting',
1647
- seq = v_seq,
1648
- lock_token = NULL,
1649
- locked_until_ms = NULL
1650
- WHERE queue = p_queue AND id = p_id;
1651
-
1652
- PERFORM pg_notify('bullmq_jobs', p_queue);
1653
- PERFORM publish_event(p_queue, 'waiting',
1654
- jsonb_build_object('jobId', p_id, 'prev', 'active'));
1655
- END IF;
1656
-
1657
- -- Remaining limiter window (mirrors Redis returning PTTL of the limiter key).
1658
- SELECT expire_at_ms INTO v_expire
1659
- FROM rate_limit WHERE queue = p_queue;
1660
- IF v_expire IS NOT NULL AND v_expire > p_now THEN
1661
- RETURN v_expire - p_now;
1662
- END IF;
1663
- RETURN 0;
1664
- END;
1665
- $$;
1666
-
1667
- -- BullMQ PostgreSQL backend — reprocess clears processedOn (schema version 16).
1668
- --
1669
- -- Job.retry() must reset processedOn too: a re-queued job is "fresh", so
1670
- -- processed_at_ms is cleared alongside finished/return/failed/stacktrace.
1671
- CREATE OR REPLACE FUNCTION reprocess_job(
1672
- p_queue text,
1673
- p_id text,
1674
- p_state text,
1675
- p_lifo boolean,
1676
- p_reset_made boolean,
1677
- p_reset_started boolean
1678
- ) RETURNS integer
1679
- LANGUAGE plpgsql
1680
- SET search_path FROM CURRENT
1681
- AS $$
1682
- DECLARE
1683
- v_state job_state;
1684
- v_seq bigint;
1685
- v_pq text;
1686
- v_pid text;
1687
- BEGIN
1688
- SELECT state, parent_queue, parent_id INTO v_state, v_pq, v_pid
1689
- FROM job WHERE queue = p_queue AND id = p_id FOR UPDATE;
1690
- IF NOT FOUND THEN
1691
- RETURN -1;
1692
- END IF;
1693
- IF v_state <> p_state::job_state THEN
1694
- RETURN -3;
1695
- END IF;
1696
-
1697
- v_seq := nextval('job_seq');
1698
- IF p_lifo THEN
1699
- v_seq := -v_seq;
1700
- END IF;
1701
-
1702
- UPDATE job
1703
- SET state = 'waiting',
1704
- seq = v_seq,
1705
- process_at_ms = NULL,
1706
- processed_at_ms = NULL,
1707
- finished_at_ms = NULL,
1708
- return_value = NULL,
1709
- failed_reason = NULL,
1710
- stacktrace = NULL,
1711
- attempts_made = CASE WHEN p_reset_made THEN 0 ELSE attempts_made END,
1712
- attempts_started =
1713
- CASE WHEN p_reset_started THEN 0 ELSE attempts_started END
1714
- WHERE queue = p_queue AND id = p_id;
1715
-
1716
- -- A retried child must be awaited again by its parent: move its dependency
1717
- -- back to pending and re-count it (mirrors reprocessJob-8.lua re-adding the
1718
- -- job to the parent's :dependencies set).
1719
- IF v_pq IS NOT NULL AND v_pid IS NOT NULL
1720
- AND EXISTS (SELECT 1 FROM job WHERE queue = v_pq AND id = v_pid) THEN
1721
- UPDATE job_dependency
1722
- SET status = 'pending', value = NULL
1723
- WHERE parent_queue = v_pq AND parent_id = v_pid
1724
- AND child_key = p_queue || ':' || p_id
1725
- AND status = (CASE WHEN p_state = 'failed' THEN 'failed'
1726
- ELSE 'processed' END)::dep_status;
1727
- IF FOUND THEN
1728
- UPDATE job SET pending_deps = pending_deps + 1
1729
- WHERE queue = v_pq AND id = v_pid;
1730
- END IF;
1731
- END IF;
1732
-
1733
- PERFORM pg_notify('bullmq_jobs', p_queue);
1734
- PERFORM publish_event(p_queue, 'waiting',
1735
- jsonb_build_object('jobId', p_id, 'prev', p_state));
1736
- RETURN 1;
1737
- END;
1738
- $$;
1739
-
1740
- -- BullMQ PostgreSQL backend — step jobs / waiting-children (schema version 19).
1741
- --
1742
- -- * move_to_waiting_children: a parent with pending children → waiting-children
1743
- -- (returns 1 = should wait); no pending → 0 = proceed.
1744
- -- * move_to_completed also releases the parent: a completing child marks its
1745
- -- dependency processed and, when the parent has no pending deps left and is
1746
- -- waiting-children, promotes it back to waiting.
1747
- CREATE FUNCTION move_to_waiting_children(
1748
- p_queue text, p_id text, p_token text
1749
- ) RETURNS integer
1750
- LANGUAGE plpgsql
1751
- SET search_path FROM CURRENT
1752
- AS $$
1753
- DECLARE
1754
- v_state job_state;
1755
- v_lock text;
1756
- v_pending integer;
1757
- BEGIN
1758
- SELECT state, lock_token, pending_deps INTO v_state, v_lock, v_pending
1759
- FROM job WHERE queue = p_queue AND id = p_id FOR UPDATE;
1760
- IF NOT FOUND THEN
1761
- RAISE EXCEPTION 'bullmq: missing job %', p_id USING ERRCODE = 'BM001', DETAIL = '-1';
1762
- END IF;
1763
- -- A parent with a failed child (failParentOnFailure) can never complete its
1764
- -- children step; surface it as an unrecoverable failure (mirrors the
1765
- -- `:unsuccessful` set check in moveToWaitingChildren-7.lua).
1766
- IF EXISTS (
1767
- SELECT 1 FROM job_dependency
1768
- WHERE parent_queue = p_queue AND parent_id = p_id AND status = 'failed'
1769
- ) THEN
1770
- RAISE EXCEPTION 'bullmq: job % has failed children', p_id
1771
- USING ERRCODE = 'BM001', DETAIL = '-9';
1772
- END IF;
1773
- -- Lock check (mirrors removeLock, taken before the active-state check): a
1774
- -- missing lock is -2, a mismatched one is -6; token '0' skips the check.
1775
- IF p_token <> '0' THEN
1776
- IF v_lock IS NULL THEN
1777
- RAISE EXCEPTION 'bullmq: job % missing lock', p_id
1778
- USING ERRCODE = 'BM001', DETAIL = '-2';
1779
- ELSIF v_lock <> p_token THEN
1780
- RAISE EXCEPTION 'bullmq: job % lock mismatch', p_id
1781
- USING ERRCODE = 'BM001', DETAIL = '-6';
1782
- END IF;
1783
- END IF;
1784
- IF v_state <> 'active' THEN
1785
- RAISE EXCEPTION 'bullmq: job % not active', p_id USING ERRCODE = 'BM001', DETAIL = '-3';
1786
- END IF;
1787
-
1788
- IF v_pending > 0 THEN
1789
- UPDATE job
1790
- SET state = 'waiting-children', lock_token = NULL, locked_until_ms = NULL
1791
- WHERE queue = p_queue AND id = p_id;
1792
- RETURN 1; -- should wait
1793
- END IF;
1794
-
1795
- RETURN 0; -- no pending children: proceed
1796
- END;
1797
- $$;
1798
-
1799
- -- clean: skip a job scheduler's still-pending jobs, but clean finished ones.
1800
- -- Flow-aware: when a cleaned job is a pending child, its parent's dependency
1801
- -- count is updated and the parent released (removed if it lives in the cleaned
1802
- -- queue, otherwise moved to wait) — mirrors removeJob → removeParentDependencyKey.
1803
- CREATE OR REPLACE FUNCTION clean(
1804
- p_queue text, p_type text, p_ts bigint, p_limit integer
1805
- ) RETURNS SETOF text
1806
- LANGUAGE plpgsql
1807
- SET search_path FROM CURRENT
1808
- AS $$
1809
- DECLARE
1810
- v_where text;
1811
- v_scheduler_filter text := 'AND scheduler_id IS NULL';
1812
- v_ids text[];
1813
- v_pq text[];
1814
- v_pid text[];
1815
- i integer;
1816
- BEGIN
1817
- IF p_type IN ('completed', 'failed') THEN
1818
- v_where := format('state = %L AND finished_at_ms <= %s', p_type, p_ts);
1819
- -- Finished jobs are cleaned regardless of scheduler origin (mirrors
1820
- -- cleanJobsInSet-3.lua omitting the repeat key for finished states).
1821
- v_scheduler_filter := '';
1822
- ELSIF p_type = 'delayed' THEN
1823
- v_where := format('state = ''delayed'' AND added_at_ms <= %s', p_ts);
1824
- ELSIF p_type = 'prioritized' THEN
1825
- v_where := format('state = ''waiting'' AND priority > 0 AND added_at_ms <= %s', p_ts);
1826
- ELSIF p_type IN ('wait', 'waiting', 'paused') THEN
1827
- v_where := format('state = ''waiting'' AND priority = 0 AND added_at_ms <= %s', p_ts);
1828
- ELSIF p_type = 'active' THEN
1829
- v_where := format('state = ''active'' AND added_at_ms <= %s', p_ts);
1830
- ELSE
1831
- RETURN;
1832
- END IF;
1833
-
1834
- -- Collect the ids to clean (respecting FIFO order + limit).
1835
- EXECUTE format(
1836
- 'SELECT array_agg(id) FROM (SELECT id FROM job '
1837
- || 'WHERE queue = %L AND %s %s ORDER BY seq %s) s',
1838
- p_queue, v_where, v_scheduler_filter,
1839
- CASE WHEN p_limit > 0 THEN 'LIMIT ' || p_limit ELSE '' END
1840
- ) INTO v_ids;
1841
-
1842
- IF v_ids IS NULL THEN
1843
- RETURN;
1844
- END IF;
1845
-
1846
- -- Distinct parents of the cleaned jobs, for flow-aware release.
1847
- SELECT array_agg(pq), array_agg(pid) INTO v_pq, v_pid FROM (
1848
- SELECT DISTINCT parent_queue AS pq, parent_id AS pid
1849
- FROM job
1850
- WHERE queue = p_queue AND id = ANY(v_ids)
1851
- AND parent_queue IS NOT NULL AND parent_id IS NOT NULL
1852
- ) s;
1853
-
1854
- -- Break the cleaned jobs' dependency links, then delete the jobs.
1855
- DELETE FROM job_dependency
1856
- WHERE child_queue = p_queue AND child_id = ANY(v_ids);
1857
- DELETE FROM job WHERE queue = p_queue AND id = ANY(v_ids);
1858
-
1859
- -- Release affected parents: a parent with no remaining pending dependencies
1860
- -- is removed when it lives in the cleaned queue, else moved to wait.
1861
- IF v_pq IS NOT NULL THEN
1862
- FOR i IN 1 .. array_length(v_pq, 1) LOOP
1863
- UPDATE job p SET pending_deps = (
1864
- SELECT count(*) FROM job_dependency d
1865
- WHERE d.parent_queue = v_pq[i] AND d.parent_id = v_pid[i]
1866
- AND d.status = 'pending'
1867
- ) WHERE p.queue = v_pq[i] AND p.id = v_pid[i];
1868
-
1869
- PERFORM 1 FROM job p
1870
- WHERE p.queue = v_pq[i] AND p.id = v_pid[i]
1871
- AND p.state = 'waiting-children' AND p.pending_deps = 0;
1872
- IF FOUND THEN
1873
- IF v_pq[i] = p_queue THEN
1874
- DELETE FROM job WHERE queue = v_pq[i] AND id = v_pid[i];
1875
- ELSE
1876
- UPDATE job
1877
- SET state = 'waiting', seq = nextval('job_seq')
1878
- WHERE queue = v_pq[i] AND id = v_pid[i];
1879
- PERFORM pg_notify('bullmq_jobs', v_pq[i]);
1880
- PERFORM publish_event(v_pq[i], 'waiting',
1881
- jsonb_build_object('jobId', v_pid[i], 'prev', 'waiting-children'));
1882
- END IF;
1883
- END IF;
1884
- END LOOP;
1885
- END IF;
1886
-
1887
- -- Announce how many jobs were cleaned (mirrors cleanJobsInSet-3.lua's
1888
- -- `cleaned` event; count is a string, as on the Redis stream).
1889
- PERFORM publish_event(p_queue, 'cleaned',
1890
- jsonb_build_object('count', array_length(v_ids, 1)::text));
1891
-
1892
- RETURN QUERY SELECT unnest(v_ids);
1893
- END;
1894
- $$;
1895
-
1896
- -- Mirror of the Lua getJobSchedulerEveryNextMillis: returns the next due time
1897
- -- for a fixed-interval scheduler and the aligned offset, as a 2-int array.
1898
- CREATE FUNCTION scheduler_every_next_millis(
1899
- p_prev bigint, p_every bigint, p_now bigint, p_offset bigint, p_start bigint
1900
- ) RETURNS bigint[]
1901
- LANGUAGE plpgsql
1902
- IMMUTABLE
1903
- AS $$
1904
- DECLARE
1905
- v_next bigint;
1906
- v_offset bigint := p_offset;
1907
- BEGIN
1908
- IF p_prev IS NULL THEN
1909
- IF p_start IS NOT NULL THEN
1910
- v_next := GREATEST(p_start, p_now);
1911
- ELSE
1912
- v_next := p_now;
1913
- IF p_offset IS NOT NULL AND p_offset > 0 THEN
1914
- v_next := (p_now / p_every) * p_every + p_offset;
1915
- IF v_next <= p_now THEN
1916
- v_next := v_next + p_every;
1917
- END IF;
1918
- END IF;
1919
- END IF;
1920
- ELSE
1921
- v_next := p_prev + p_every;
1922
- IF v_next < p_now THEN
1923
- v_next := (p_now / p_every) * p_every + p_every + COALESCE(p_offset, 0);
1924
- END IF;
1925
- END IF;
1926
-
1927
- IF v_offset IS NULL OR v_offset = 0 THEN
1928
- v_offset := v_next - (v_next / p_every) * p_every;
1929
- END IF;
1930
-
1931
- RETURN ARRAY[v_next, v_offset];
1932
- END;
1933
- $$;
1934
-
1935
- -- Registers/updates a scheduler and enqueues its next delayed job.
1936
- -- Returns (job_id, delay).
1937
- CREATE FUNCTION add_job_scheduler(
1938
- p_queue text,
1939
- p_scheduler_id text,
1940
- p_next_millis bigint,
1941
- p_template_data jsonb,
1942
- p_template_opts jsonb,
1943
- p_opts jsonb,
1944
- p_delayed_opts jsonb,
1945
- p_now bigint,
1946
- p_producer_id text
1947
- ) RETURNS TABLE (job_id text, delay bigint)
1948
- LANGUAGE plpgsql
1949
- SET search_path FROM CURRENT
1950
- AS $$
1951
- DECLARE
1952
- v_name text := p_opts ->> 'name';
1953
- v_tz text := p_opts ->> 'tz';
1954
- v_pattern text := p_opts ->> 'pattern';
1955
- v_every bigint := (p_opts ->> 'every')::bigint;
1956
- v_offset bigint := COALESCE((p_opts ->> 'offset')::bigint,
1957
- (p_delayed_opts #>> '{repeat,offset}')::bigint, 0);
1958
- v_offset_provided boolean := (p_opts ->> 'offset') IS NOT NULL
1959
- OR (p_delayed_opts #>> '{repeat,offset}') IS NOT NULL;
1960
- v_start bigint := (p_opts ->> 'startDate')::bigint;
1961
- v_end bigint := (p_opts ->> 'endDate')::bigint;
1962
- v_limit integer := (p_opts ->> 'limit')::integer;
1963
- v_prev bigint;
1964
- v_millis bigint;
1965
- v_prev_every bigint;
1966
- v_existing_offset bigint;
1967
- v_ic integer;
1968
- v_next bigint := p_next_millis;
1969
- v_updated_every boolean := false;
1970
- v_removed boolean := false;
1971
- v_collision boolean := false;
1972
- v_new_offset bigint := v_offset;
1973
- v_em bigint[];
1974
- v_jobid text;
1975
- v_delay bigint;
1976
- v_state job_state;
1977
- v_seq bigint;
1978
- BEGIN
1979
- SELECT next_run_ms, every_ms, iteration_count, offset_ms
1980
- INTO v_prev, v_prev_every, v_ic, v_existing_offset
1981
- FROM scheduler WHERE queue = p_queue AND scheduler_id = p_scheduler_id;
1982
-
1983
- -- `v_prev` is the previous iteration (used for removal); `v_millis` seeds the
1984
- -- `every` formula and is reset to NULL when the interval itself changed.
1985
- v_millis := v_prev;
1986
- IF v_every IS NOT NULL THEN
1987
- IF v_prev IS NOT NULL AND v_prev_every IS DISTINCT FROM v_every THEN
1988
- v_millis := NULL;
1989
- v_updated_every := true;
1990
- END IF;
1991
- v_em := scheduler_every_next_millis(v_millis, v_every, p_now, v_offset, v_start);
1992
- v_next := v_em[1];
1993
- v_new_offset := v_em[2];
1994
- -- Preserve the offset established at scheduler creation. On re-upsert of an
1995
- -- existing scheduler without an explicit offset, keep the stored offset
1996
- -- instead of recomputing it against the (possibly changed) `every`. Mirrors
1997
- -- Redis storeJobScheduler, which writes a freshly-computed offset only on
1998
- -- first creation and otherwise preserves the existing hash field.
1999
- IF NOT v_offset_provided AND v_existing_offset IS NOT NULL THEN
2000
- v_new_offset := v_existing_offset;
2001
- END IF;
2002
- END IF;
2003
-
2004
- -- Remove the previous iteration's (still-pending) job, if any.
2005
- IF v_prev IS NOT NULL THEN
2006
- DELETE FROM job
2007
- WHERE queue = p_queue
2008
- AND id = 'repeat:' || p_scheduler_id || ':' || v_prev
2009
- AND state IN ('delayed', 'waiting');
2010
- IF FOUND THEN
2011
- v_removed := true;
2012
- END IF;
2013
- END IF;
2014
-
2015
- IF v_removed AND v_every IS NOT NULL AND NOT v_updated_every THEN
2016
- v_next := v_prev;
2017
- END IF;
2018
-
2019
- -- Collision: a job with the next id already exists in a non-replaceable state
2020
- -- (e.g. it is active). For `every` we try the following slot; for `pattern`
2021
- -- we fail — unless we just removed the previous job (override replaces it).
2022
- v_jobid := 'repeat:' || p_scheduler_id || ':' || v_next;
2023
- IF EXISTS (SELECT 1 FROM job WHERE queue = p_queue AND id = v_jobid) THEN
2024
- IF v_every IS NOT NULL THEN
2025
- v_next := v_next + v_every;
2026
- v_jobid := 'repeat:' || p_scheduler_id || ':' || v_next;
2027
- IF EXISTS (SELECT 1 FROM job WHERE queue = p_queue AND id = v_jobid) THEN
2028
- RAISE EXCEPTION 'scheduler job slots busy'
2029
- USING ERRCODE = 'BM001', DETAIL = '-11';
2030
- END IF;
2031
- ELSIF NOT v_removed THEN
2032
- RAISE EXCEPTION 'scheduler job id collision'
2033
- USING ERRCODE = 'BM001', DETAIL = '-10';
2034
- ELSE
2035
- v_collision := true;
2036
- END IF;
2037
- END IF;
2038
-
2039
- v_delay := GREATEST(v_next - p_now, 0);
2040
-
2041
- -- Upsert the scheduler row (iteration count preserved, or 1 the first time).
2042
- INSERT INTO scheduler (
2043
- queue, scheduler_id, name, next_run_ms, pattern, every_ms, tz,
2044
- start_date_ms, end_date_ms, limit_count, iteration_count, offset_ms,
2045
- template_data, template_opts, producer_id
2046
- ) VALUES (
2047
- p_queue, p_scheduler_id, v_name, v_next, v_pattern, v_every, v_tz,
2048
- v_start, v_end, v_limit, COALESCE(v_ic, 1), v_new_offset,
2049
- p_template_data, p_template_opts, p_producer_id
2050
- )
2051
- ON CONFLICT (queue, scheduler_id) DO UPDATE SET
2052
- name = EXCLUDED.name,
2053
- next_run_ms = EXCLUDED.next_run_ms,
2054
- pattern = EXCLUDED.pattern,
2055
- every_ms = EXCLUDED.every_ms,
2056
- tz = EXCLUDED.tz,
2057
- start_date_ms = EXCLUDED.start_date_ms,
2058
- end_date_ms = EXCLUDED.end_date_ms,
2059
- limit_count = EXCLUDED.limit_count,
2060
- offset_ms = EXCLUDED.offset_ms,
2061
- template_data = EXCLUDED.template_data,
2062
- template_opts = EXCLUDED.template_opts,
2063
- producer_id = EXCLUDED.producer_id;
2064
-
2065
- -- Create the next delayed (or immediately-ready) job. When `v_collision` we
2066
- -- are replacing an existing (removed-prev) row, so fully reset it.
2067
- v_seq := nextval('job_seq');
2068
- IF v_delay > 0 THEN
2069
- v_state := 'delayed';
2070
- ELSE
2071
- v_state := 'waiting';
2072
- END IF;
2073
-
2074
- INSERT INTO job (
2075
- queue, id, seq, name, state, data, opts, priority, delay_ms, max_attempts,
2076
- added_at_ms, process_at_ms, scheduler_id
2077
- ) VALUES (
2078
- p_queue, v_jobid, v_seq, v_name, v_state,
2079
- COALESCE(p_template_data, '{}'::jsonb),
2080
- COALESCE(p_delayed_opts, '{}'::jsonb)
2081
- || jsonb_build_object('delay', v_delay, 'jobId', v_jobid),
2082
- COALESCE((p_delayed_opts ->> 'priority')::integer, 0), v_delay,
2083
- COALESCE((p_delayed_opts ->> 'attempts')::integer, 1),
2084
- COALESCE((p_delayed_opts ->> 'timestamp')::bigint, p_now),
2085
- CASE WHEN v_delay > 0 THEN v_next ELSE NULL END, p_scheduler_id
2086
- )
2087
- ON CONFLICT (queue, id) DO UPDATE SET
2088
- seq = EXCLUDED.seq,
2089
- name = EXCLUDED.name,
2090
- state = EXCLUDED.state,
2091
- data = EXCLUDED.data,
2092
- opts = EXCLUDED.opts,
2093
- priority = EXCLUDED.priority,
2094
- delay_ms = EXCLUDED.delay_ms,
2095
- max_attempts = EXCLUDED.max_attempts,
2096
- attempts_made = 0,
2097
- attempts_started = 0,
2098
- added_at_ms = EXCLUDED.added_at_ms,
2099
- process_at_ms = EXCLUDED.process_at_ms,
2100
- processed_at_ms = NULL,
2101
- finished_at_ms = NULL,
2102
- return_value = NULL,
2103
- failed_reason = NULL,
2104
- stacktrace = NULL,
2105
- lock_token = NULL,
2106
- locked_until_ms = NULL,
2107
- stalled_count = 0,
2108
- processed_by = NULL,
2109
- scheduler_id = EXCLUDED.scheduler_id;
2110
-
2111
- PERFORM pg_notify('bullmq_jobs', p_queue);
2112
-
2113
- job_id := v_jobid;
2114
- delay := v_delay;
2115
- RETURN NEXT;
2116
- END;
2117
- $$;
2118
-
2119
- -- Advance an existing scheduler to its next iteration (no template change).
2120
- -- Returns the new job id, or NULL if the scheduler no longer exists.
2121
- CREATE FUNCTION update_job_scheduler_next_millis(
2122
- p_queue text,
2123
- p_scheduler_id text,
2124
- p_next_millis bigint,
2125
- p_template_data jsonb,
2126
- p_delayed_opts jsonb,
2127
- p_now bigint,
2128
- p_producer_id text
2129
- ) RETURNS text
2130
- LANGUAGE plpgsql
2131
- SET search_path FROM CURRENT
2132
- AS $$
2133
- DECLARE
2134
- v_name text;
2135
- v_prev bigint;
2136
- v_every bigint;
2137
- v_start bigint;
2138
- v_offset bigint;
2139
- v_data jsonb;
2140
- v_next bigint := p_next_millis;
2141
- v_new_offset bigint;
2142
- v_em bigint[];
2143
- v_jobid text;
2144
- v_current text;
2145
- v_delay bigint;
2146
- v_state job_state;
2147
- v_seq bigint;
2148
- BEGIN
2149
- SELECT name, next_run_ms, every_ms, start_date_ms, offset_ms, template_data
2150
- INTO v_name, v_prev, v_every, v_start, v_offset, v_data
2151
- FROM scheduler WHERE queue = p_queue AND scheduler_id = p_scheduler_id;
2152
- IF NOT FOUND THEN
2153
- RETURN NULL;
2154
- END IF;
2155
-
2156
- IF v_every IS NULL THEN
2157
- v_every := (p_delayed_opts #>> '{repeat,every}')::bigint;
2158
- END IF;
2159
-
2160
- IF v_every IS NOT NULL THEN
2161
- v_offset := COALESCE(v_offset, (p_delayed_opts #>> '{repeat,offset}')::bigint, 0);
2162
- v_em := scheduler_every_next_millis(v_prev, v_every, p_now, v_offset, v_start);
2163
- v_next := v_em[1];
2164
- v_new_offset := v_em[2];
2165
- END IF;
2166
-
2167
- -- Only the producer of the current iteration may advance the scheduler;
2168
- -- this guards against duplicate iterations from stale/concurrent finishes.
2169
- v_current := 'repeat:' || p_scheduler_id || ':' || v_prev;
2170
- IF p_producer_id IS DISTINCT FROM v_current THEN
2171
- RETURN NULL;
2172
- END IF;
2173
-
2174
- v_jobid := 'repeat:' || p_scheduler_id || ':' || v_next;
2175
-
2176
- -- If the next iteration's job already exists, this is a duplicate.
2177
- IF EXISTS (SELECT 1 FROM job WHERE queue = p_queue AND id = v_jobid) THEN
2178
- PERFORM publish_event(p_queue, 'duplicated',
2179
- jsonb_build_object('jobId', v_jobid));
2180
- RETURN NULL;
2181
- END IF;
2182
-
2183
- v_delay := GREATEST(v_next - p_now, 0);
2184
-
2185
- UPDATE scheduler
2186
- SET next_run_ms = v_next,
2187
- iteration_count = iteration_count + 1,
2188
- offset_ms = COALESCE(offset_ms, v_new_offset),
2189
- template_data = CASE
2190
- WHEN p_template_data IS NOT NULL AND p_template_data <> '{}'::jsonb
2191
- THEN p_template_data
2192
- ELSE template_data
2193
- END
2194
- WHERE queue = p_queue AND scheduler_id = p_scheduler_id;
2195
-
2196
- v_seq := nextval('job_seq');
2197
- v_state := CASE WHEN v_delay > 0 THEN 'delayed' ELSE 'waiting' END;
2198
-
2199
- INSERT INTO job (
2200
- queue, id, seq, name, state, data, opts, priority, delay_ms, max_attempts,
2201
- added_at_ms, process_at_ms, scheduler_id
2202
- ) VALUES (
2203
- p_queue, v_jobid, v_seq, v_name, v_state,
2204
- COALESCE(NULLIF(v_data, '{}'::jsonb), p_template_data, '{}'::jsonb),
2205
- COALESCE(p_delayed_opts, '{}'::jsonb)
2206
- || jsonb_build_object('delay', v_delay, 'jobId', v_jobid),
2207
- COALESCE((p_delayed_opts ->> 'priority')::integer, 0), v_delay,
2208
- COALESCE((p_delayed_opts ->> 'attempts')::integer, 1),
2209
- COALESCE((p_delayed_opts ->> 'timestamp')::bigint, p_now),
2210
- CASE WHEN v_delay > 0 THEN v_next ELSE NULL END, p_scheduler_id
2211
- )
2212
- ON CONFLICT (queue, id) DO NOTHING;
2213
-
2214
- PERFORM pg_notify('bullmq_jobs', p_queue);
2215
-
2216
- RETURN v_jobid;
2217
- END;
2218
- $$;
2219
-
2220
- -- Remove a scheduler and its still-pending job, emitting a `removed` event for
2221
- -- each deleted job. Returns 0 if the scheduler existed (removed), 1 otherwise
2222
- -- (mirrors removeJobScheduler-3.lua: 0 = OK, 1 = missing).
2223
- CREATE FUNCTION remove_job_scheduler(
2224
- p_queue text, p_scheduler_id text
2225
- ) RETURNS integer
2226
- LANGUAGE plpgsql
2227
- SET search_path FROM CURRENT
2228
- AS $$
2229
- DECLARE
2230
- v_next bigint;
2231
- v_jobid text;
2232
- v_removed integer;
2233
- BEGIN
2234
- -- Remove only the scheduler's *next* programmed job, and only while it is
2235
- -- still delayed (mirrors removeJobScheduler-3.lua, which ZREMs the id
2236
- -- `repeat:<id>:<next_run_ms>` from the delayed set). A job that has already
2237
- -- been promoted (waiting/active/…) or processed survives — that surviving
2238
- -- job is what later collides with a re-created scheduler to emit
2239
- -- 'duplicated'. Deleting every delayed/waiting job of the scheduler would
2240
- -- wrongly reap the promoted job.
2241
- SELECT next_run_ms INTO v_next
2242
- FROM scheduler
2243
- WHERE queue = p_queue AND scheduler_id = p_scheduler_id;
2244
-
2245
- IF v_next IS NOT NULL THEN
2246
- v_jobid := 'repeat:' || p_scheduler_id || ':' || v_next;
2247
- DELETE FROM job
2248
- WHERE queue = p_queue AND id = v_jobid AND state = 'delayed';
2249
- IF FOUND THEN
2250
- PERFORM publish_event(p_queue, 'removed',
2251
- jsonb_build_object('jobId', v_jobid, 'prev', 'delayed'));
2252
- END IF;
2253
- END IF;
2254
-
2255
- DELETE FROM scheduler
2256
- WHERE queue = p_queue AND scheduler_id = p_scheduler_id;
2257
- GET DIAGNOSTICS v_removed = ROW_COUNT;
2258
- RETURN CASE WHEN v_removed > 0 THEN 0 ELSE 1 END;
2259
- END;
2260
- $$;
2261
-
2262
- -- Two-phase stalled detection (mirrors moveStalledJobsToWait-9.lua). A single
2263
- -- pass that reclaimed an active job the instant its lock expired was unsafe
2264
- -- under a fast-forwarding clock (repeatable-job tests tick the fake clock by a
2265
- -- month inside the processor): a freshly-claimed job's short lock looks expired
2266
- -- immediately, so the checker would yank the in-flight job back to wait and its
2267
- -- completion would then fail with "not in the active state". Instead:
2268
- -- * A `stalled-check` throttle bounds how often the scan runs.
2269
- -- * Jobs are *marked* (`stalled_marked`, the Redis `stalled` SET analogue) on
2270
- -- one pass and only *reclaimed* on the next if still active with an expired
2271
- -- lock — so a job that completes or renews its lock between passes is never
2272
- -- reclaimed.
2273
- -- * Scheduler ("repeatable") jobs are recovered but never permanently failed.
2274
- CREATE FUNCTION move_stalled_jobs_to_wait(
2275
- p_queue text,
2276
- p_max_stalled integer,
2277
- p_now bigint,
2278
- p_max_check_time bigint
2279
- ) RETURNS SETOF text
2280
- LANGUAGE plpgsql
2281
- SET search_path FROM CURRENT
2282
- AS $$
2283
- DECLARE
2284
- v_last bigint;
2285
- r record;
2286
- v_count integer;
2287
- v_repeatable boolean;
2288
- v_reclaimed boolean := false;
2289
- BEGIN
2290
- -- Throttle: only run once per `max_check_time` window (mirrors the Redis
2291
- -- `stalled-check` key with `PX maxCheckTime`).
2292
- SELECT value::bigint INTO v_last
2293
- FROM meta WHERE queue = p_queue AND field = 'stalled-check';
2294
- IF v_last IS NOT NULL AND p_now < v_last + p_max_check_time THEN
2295
- RETURN;
2296
- END IF;
2297
- INSERT INTO meta (queue, field, value)
2298
- VALUES (p_queue, 'stalled-check', p_now::text)
2299
- ON CONFLICT (queue, field) DO UPDATE SET value = EXCLUDED.value;
2300
-
2301
- -- Phase 1 (sweep): reclaim jobs that were marked on a PREVIOUS pass and are
2302
- -- still active with an expired lock.
2303
- FOR r IN
2304
- SELECT id, scheduler_id, stalled_count
2305
- FROM job
2306
- WHERE queue = p_queue
2307
- AND state = 'active'
2308
- AND stalled_marked
2309
- AND locked_until_ms IS NOT NULL
2310
- AND locked_until_ms < p_now
2311
- FOR UPDATE SKIP LOCKED
2312
- LOOP
2313
- v_count := r.stalled_count + 1;
2314
-
2315
- -- Scheduler jobs are recovered but never permanently failed.
2316
- v_repeatable := r.scheduler_id IS NOT NULL AND EXISTS (
2317
- SELECT 1 FROM scheduler
2318
- WHERE queue = p_queue AND scheduler_id = r.scheduler_id
2319
- );
2320
-
2321
- UPDATE job
2322
- SET state = 'waiting',
2323
- lock_token = NULL,
2324
- locked_until_ms = NULL,
2325
- stalled_marked = false,
2326
- stalled_count = v_count,
2327
- seq = nextval('job_seq'),
2328
- deferred_failure = CASE
2329
- WHEN v_count > p_max_stalled AND NOT v_repeatable
2330
- THEN 'job stalled more than allowable limit'
2331
- ELSE deferred_failure
2332
- END
2333
- WHERE queue = p_queue AND id = r.id;
2334
-
2335
- PERFORM publish_event(p_queue, 'stalled',
2336
- jsonb_build_object('jobId', r.id));
2337
- v_reclaimed := true;
2338
- RETURN NEXT r.id;
2339
- END LOOP;
2340
-
2341
- -- Clear all old marks (mirrors `DEL stalledKey`) …
2342
- UPDATE job SET stalled_marked = false
2343
- WHERE queue = p_queue AND stalled_marked;
2344
-
2345
- -- … then mark every currently-active job for the NEXT pass (mirrors
2346
- -- `SADD stalledKey <active>`). Freshly-claimed jobs are therefore never
2347
- -- reclaimed on the pass that first observes them.
2348
- UPDATE job SET stalled_marked = true
2349
- WHERE queue = p_queue AND state = 'active';
2350
-
2351
- -- Wake a worker for any jobs pushed back to wait.
2352
- IF v_reclaimed THEN
2353
- PERFORM pg_notify('bullmq_jobs', p_queue);
2354
- END IF;
2355
- RETURN;
2356
- END;
2357
- $$;
2358
-
2359
- -- BullMQ PostgreSQL backend — rate limiting (schema v25).
2360
- --
2361
- -- Mirrors the Redis limiter, which is a single `<prefix>:<queue>:limiter`
2362
- -- counter key with a PTTL: a token is consumed (INCR + PEXPIRE-on-first) each
2363
- -- time a job is moved to active, and `getRateLimitTTL` reports the remaining
2364
- -- window once the counter reaches `max`. Here that key is one row of
2365
- -- `rate_limit` (`points` ⇔ the counter, `expire_at_ms` ⇔ the PTTL
2366
- -- window). The limiter config comes from the queue meta (`max`/`duration`, set
2367
- -- via `Queue.setGlobalRateLimit`) and/or the worker's `limiter` option.
2368
-
2369
- -- ── Public TTL (mirrors getRateLimitTtl-2.lua) ─────────────────────────────
2370
- -- p_max_jobs > 0 → check against that; else fall back to meta `max`; else the
2371
- -- raw remaining window (or -2 when there is none, like Redis PTTL on a missing
2372
- -- key).
2373
- CREATE FUNCTION rate_limit_ttl(
2374
- p_queue text, p_max_jobs integer, p_now bigint
2375
- ) RETURNS bigint
2376
- LANGUAGE plpgsql
2377
- STABLE
2378
- SET search_path FROM CURRENT
2379
- AS $$
2380
- DECLARE
2381
- v_points bigint;
2382
- v_expire bigint;
2383
- v_active boolean;
2384
- v_counter bigint;
2385
- v_pttl bigint;
2386
- v_max integer;
2387
- BEGIN
2388
- SELECT points, expire_at_ms INTO v_points, v_expire
2389
- FROM rate_limit WHERE queue = p_queue;
2390
- v_active := v_expire IS NOT NULL AND v_expire > p_now;
2391
- v_counter := CASE WHEN v_active THEN v_points ELSE 0 END;
2392
- v_pttl := CASE WHEN v_active THEN v_expire - p_now ELSE -2 END;
2393
-
2394
- IF p_max_jobs IS NOT NULL AND p_max_jobs > 0 THEN
2395
- IF p_max_jobs <= v_counter AND v_pttl > 0 THEN
2396
- RETURN v_pttl;
2397
- END IF;
2398
- RETURN 0;
2399
- END IF;
2400
-
2401
- SELECT value::integer INTO v_max
2402
- FROM meta WHERE queue = p_queue AND field = 'max';
2403
- IF v_max IS NOT NULL THEN
2404
- IF v_max <= v_counter AND v_pttl > 0 THEN
2405
- RETURN v_pttl;
2406
- END IF;
2407
- RETURN 0;
2408
- END IF;
2409
-
2410
- RETURN v_pttl;
2411
- END;
2412
- $$;
2413
-
2414
- -- ── Worker-effective TTL ───────────────────────────────────────────────────
2415
- -- The limit that applies to a fetching worker: meta `max` takes precedence,
2416
- -- falling back to the worker's own `limiter.max`. Returns the remaining window
2417
- -- when the counter has reached that limit, else 0 (and 0 when no limiter at
2418
- -- all is configured).
2419
- CREATE FUNCTION rate_limit_effective(
2420
- p_queue text, p_limiter_max integer, p_now bigint
2421
- ) RETURNS bigint
2422
- LANGUAGE plpgsql
2423
- STABLE
2424
- SET search_path FROM CURRENT
2425
- AS $$
2426
- DECLARE
2427
- v_meta_max integer;
2428
- v_max integer;
2429
- v_points bigint;
2430
- v_expire bigint;
2431
- BEGIN
2432
- SELECT value::integer INTO v_meta_max
2433
- FROM meta WHERE queue = p_queue AND field = 'max';
2434
- v_max := COALESCE(v_meta_max, p_limiter_max);
2435
- IF v_max IS NULL THEN
2436
- RETURN 0;
2437
- END IF;
2438
-
2439
- SELECT points, expire_at_ms INTO v_points, v_expire
2440
- FROM rate_limit WHERE queue = p_queue;
2441
- IF v_expire IS NULL OR v_expire <= p_now THEN
2442
- RETURN 0;
2443
- END IF;
2444
- IF v_max <= v_points THEN
2445
- RETURN v_expire - p_now;
2446
- END IF;
2447
- RETURN 0;
2448
- END;
2449
- $$;
2450
-
2451
- -- Consume one token (INCR + PEXPIRE-on-first-of-window).
2452
- CREATE FUNCTION rate_limit_consume(
2453
- p_queue text, p_duration bigint, p_now bigint
2454
- ) RETURNS void
2455
- LANGUAGE sql
2456
- SET search_path FROM CURRENT
2457
- AS $$
2458
- INSERT INTO rate_limit (queue, points, expire_at_ms)
2459
- VALUES (p_queue, 1, p_now + p_duration)
2460
- ON CONFLICT (queue) DO UPDATE SET
2461
- points = CASE WHEN rate_limit.expire_at_ms <= p_now
2462
- THEN 1 ELSE rate_limit.points + 1 END,
2463
- expire_at_ms = CASE WHEN rate_limit.expire_at_ms <= p_now
2464
- THEN p_now + p_duration
2465
- ELSE rate_limit.expire_at_ms END;
2466
- $$;
2467
-
2468
- -- Force the limiter for `p_expire_ms` (dynamic / manual rate limit). Mirrors
2469
- -- Redis SET limiter = MAX_SAFE_INTEGER PX p_expire_ms.
2470
- CREATE FUNCTION set_rate_limit(
2471
- p_queue text, p_expire_ms bigint, p_now bigint
2472
- ) RETURNS void
2473
- LANGUAGE sql
2474
- SET search_path FROM CURRENT
2475
- AS $$
2476
- INSERT INTO rate_limit (queue, points, expire_at_ms)
2477
- VALUES (p_queue, 9007199254740991, p_now + p_expire_ms)
2478
- ON CONFLICT (queue) DO UPDATE SET
2479
- points = 9007199254740991,
2480
- expire_at_ms = p_now + p_expire_ms;
2481
- $$;
2482
-
2483
- -- The "no job" signal: the worker-effective rate-limit ttl and the next delayed
2484
- -- job's timestamp, in one round trip.
2485
- CREATE FUNCTION next_signal(
2486
- p_queue text, p_limiter_max integer, p_now bigint
2487
- ) RETURNS TABLE (rate_limit_ttl bigint, next_delay bigint)
2488
- LANGUAGE sql
2489
- STABLE
2490
- SET search_path FROM CURRENT
2491
- AS $$
2492
- SELECT rate_limit_effective(p_queue, p_limiter_max, p_now),
2493
- next_delay(p_queue);
2494
- $$;
2495
-
2496
- -- BullMQ PostgreSQL backend — obliterate (schema v27).
2497
- --
2498
- -- Completely destroys a queue and all of its contents, mirroring
2499
- -- obliterate-2.lua:
2500
- -- * The queue must be paused, else return -1.
2501
- -- * If there are active jobs and `force` is not set, return -2.
2502
- -- * Otherwise delete up to `p_count` jobs; return 1 while more remain (the
2503
- -- caller loops), and once every job is gone delete the remaining
2504
- -- per-queue data and return 0.
2505
- --
2506
- -- `job_log` and `job_dependency` rows cascade from `job`,
2507
- -- so deleting the jobs removes their logs and (parent-side) dependency links.
2508
- CREATE FUNCTION obliterate(
2509
- p_queue text, p_count integer, p_force boolean
2510
- ) RETURNS integer
2511
- LANGUAGE plpgsql
2512
- SET search_path FROM CURRENT
2513
- AS $$
2514
- DECLARE
2515
- v_deleted integer;
2516
- BEGIN
2517
- IF NOT EXISTS (
2518
- SELECT 1 FROM meta
2519
- WHERE queue = p_queue AND field = 'paused' AND value = '1'
2520
- ) THEN
2521
- RETURN -1; -- NotPaused
2522
- END IF;
2523
-
2524
- IF NOT p_force AND EXISTS (
2525
- SELECT 1 FROM job WHERE queue = p_queue AND state = 'active'
2526
- ) THEN
2527
- RETURN -2; -- ExistActiveJobs
2528
- END IF;
2529
-
2530
- WITH batch AS (
2531
- SELECT id FROM job WHERE queue = p_queue LIMIT p_count
2532
- )
2533
- DELETE FROM job j
2534
- USING batch
2535
- WHERE j.queue = p_queue AND j.id = batch.id;
2536
- GET DIAGNOSTICS v_deleted = ROW_COUNT;
2537
-
2538
- -- More jobs than the batch budget: tell the caller to come back.
2539
- IF v_deleted >= p_count THEN
2540
- RETURN 1;
2541
- END IF;
2542
-
2543
- -- Every job is gone; remove the rest of the queue's footprint.
2544
- DELETE FROM scheduler WHERE queue = p_queue;
2545
- DELETE FROM rate_limit WHERE queue = p_queue;
2546
- DELETE FROM event WHERE queue = p_queue;
2547
- DELETE FROM metrics WHERE queue = p_queue;
2548
- DELETE FROM dedup WHERE queue = p_queue;
2549
- DELETE FROM dedup_next WHERE queue = p_queue;
2550
- DELETE FROM meta WHERE queue = p_queue;
2551
- -- Drop the per-queue job-id sequence so a re-created queue restarts at 1
2552
- -- (mirrors Redis obliterate deleting the `<queue>:id` counter key).
2553
- EXECUTE format(
2554
- 'DROP SEQUENCE IF EXISTS %I', job_id_seq_name(p_queue));
2555
- RETURN 0;
2556
- END;
2557
- $$;
2558
-
2559
- -- BullMQ PostgreSQL backend — deduplication / debounce (schema version 28).
2560
- --
2561
- -- Mirrors the Redis deduplicateJob include chain. A job added with a
2562
- -- `deduplication` (or legacy `debounce`) option carries a `de` opts object
2563
- -- `{ id, ttl, extend, replace, keepLastIfActive }`. When a *live* key already
2564
- -- exists for that id the new job is NOT added: the existing "winner" job id is
2565
- -- returned and `deduplicated` events are emitted. The key is one
2566
- -- row of `dedup` (`job_id` ⇔ the winner, `expire_at_ms` ⇔ the Redis
2567
- -- PTTL window; NULL = no expiry). The full key lifecycle:
2568
- -- * add — set / check the key (see deduplicate_job),
2569
- -- * finalize — when the winner completes/fails, a no-ttl key is cleared
2570
- -- (dedup_finalize),
2571
- -- * removal — when the winner is removed, its key is cleared
2572
- -- (dedup_on_removal).
2573
- -- NOTE: `keepLastIfActive`'s proto-job storage/requeue is added in a later
2574
- -- migration; here keepLastIfActive only governs the key's expiry (no ttl).
2575
-
2576
- -- ── Finalize: clear a no-ttl key whose winner is finishing (PTTL == -1 path),
2577
- -- and reap an already-expired key (PTTL == 0). ttl keys expire on their own.
2578
- CREATE FUNCTION dedup_finalize(
2579
- p_queue text, p_dedup_id text, p_job_id text, p_now bigint
2580
- ) RETURNS void
2581
- LANGUAGE plpgsql
2582
- SET search_path FROM CURRENT
2583
- AS $$
2584
- DECLARE
2585
- v_cur text;
2586
- v_exp bigint;
2587
- BEGIN
2588
- IF p_dedup_id IS NULL OR p_dedup_id = '' THEN
2589
- RETURN;
2590
- END IF;
2591
- SELECT job_id, expire_at_ms INTO v_cur, v_exp
2592
- FROM dedup WHERE queue = p_queue AND dedup_id = p_dedup_id;
2593
- IF NOT FOUND THEN
2594
- RETURN;
2595
- END IF;
2596
- IF v_exp IS NULL THEN
2597
- -- No expiry: only the current winner clears its own key.
2598
- IF v_cur = p_job_id THEN
2599
- DELETE FROM dedup WHERE queue = p_queue AND dedup_id = p_dedup_id;
2600
- END IF;
2601
- ELSIF v_exp <= p_now THEN
2602
- -- Already expired: reap it.
2603
- DELETE FROM dedup WHERE queue = p_queue AND dedup_id = p_dedup_id;
2604
- END IF;
2605
- END;
2606
- $$;
2607
-
2608
- -- ── Removal: clear the key (and any proto-next data) when its winner job is
2609
- -- removed (mirrors removeDeduplicationKeyIfNeededOnRemoval).
2610
- CREATE FUNCTION dedup_on_removal(
2611
- p_queue text, p_job_id text, p_dedup_id text
2612
- ) RETURNS void
2613
- LANGUAGE plpgsql
2614
- SET search_path FROM CURRENT
2615
- AS $$
2616
- BEGIN
2617
- IF p_dedup_id IS NULL OR p_dedup_id = '' THEN
2618
- RETURN;
2619
- END IF;
2620
- DELETE FROM dedup
2621
- WHERE queue = p_queue AND dedup_id = p_dedup_id AND job_id = p_job_id;
2622
- END;
2623
- $$;
2624
-
2625
- -- Stash the new job as the proto-next IF keepLastIfActive and the current
2626
- -- winner is active. Returns true when stashed. Mirrors storeDeduplicatedNextJob.
2627
- CREATE FUNCTION dedup_store_next(
2628
- p_queue text, p_dedup_id text, p_winner text, p_job_id text,
2629
- p_keeplast boolean, p_name text, p_data jsonb, p_opts jsonb
2630
- ) RETURNS boolean
2631
- LANGUAGE plpgsql
2632
- SET search_path FROM CURRENT
2633
- AS $$
2634
- BEGIN
2635
- IF NOT p_keeplast OR p_winner IS NULL THEN
2636
- RETURN false;
2637
- END IF;
2638
- IF NOT EXISTS (
2639
- SELECT 1 FROM job
2640
- WHERE queue = p_queue AND id = p_winner AND state = 'active'
2641
- ) THEN
2642
- RETURN false;
2643
- END IF;
2644
- INSERT INTO dedup_next (queue, dedup_id, payload)
2645
- VALUES (p_queue, p_dedup_id, jsonb_build_object(
2646
- 'name', p_name, 'data', p_data, 'opts', p_opts, 'jobId', p_job_id))
2647
- ON CONFLICT (queue, dedup_id) DO UPDATE SET payload = EXCLUDED.payload;
2648
- -- Persist the dedup key so it outlives the active job's duration.
2649
- UPDATE dedup SET expire_at_ms = NULL
2650
- WHERE queue = p_queue AND dedup_id = p_dedup_id;
2651
- RETURN true;
2652
- END;
2653
- $$;
2654
-
2655
- -- Turn a stored proto-next into a real job (the new winner) when the active
2656
- -- winner finishes. Mirrors requeueDeduplicatedJob.
2657
- CREATE FUNCTION requeue_dedup_next(
2658
- p_queue text, p_dedup_id text, p_now bigint
2659
- ) RETURNS void
2660
- LANGUAGE plpgsql
2661
- SET search_path FROM CURRENT
2662
- AS $$
2663
- DECLARE
2664
- v_payload jsonb;
2665
- v_name text;
2666
- v_data jsonb;
2667
- v_opts jsonb;
2668
- v_job_id text;
2669
- v_de jsonb;
2670
- v_delay bigint;
2671
- v_priority integer;
2672
- v_lifo boolean;
2673
- v_keeplast boolean;
2674
- v_ttl bigint;
2675
- v_seq bigint;
2676
- v_state job_state;
2677
- v_process_at bigint;
2678
- BEGIN
2679
- IF p_dedup_id IS NULL OR p_dedup_id = '' THEN
2680
- RETURN;
2681
- END IF;
2682
- SELECT payload INTO v_payload
2683
- FROM dedup_next WHERE queue = p_queue AND dedup_id = p_dedup_id;
2684
- IF NOT FOUND THEN
2685
- RETURN;
2686
- END IF;
2687
-
2688
- v_name := v_payload->>'name';
2689
- v_data := COALESCE(v_payload->'data', '{}'::jsonb);
2690
- v_opts := COALESCE(v_payload->'opts', '{}'::jsonb);
2691
- v_job_id := v_payload->>'jobId';
2692
- IF v_job_id IS NULL OR v_job_id = '' THEN
2693
- v_job_id := next_job_id(p_queue);
2694
- END IF;
2695
-
2696
- v_de := COALESCE(v_opts->'deduplication', v_opts->'debounce');
2697
- v_delay := COALESCE((v_opts->>'delay')::bigint, 0);
2698
- v_priority := COALESCE((v_opts->>'priority')::integer, 0);
2699
- v_lifo := COALESCE((v_opts->>'lifo')::boolean, false);
2700
- v_keeplast := COALESCE((v_de->>'keepLastIfActive')::boolean, false);
2701
- v_ttl := NULLIF(v_de->>'ttl', '')::bigint;
2702
-
2703
- v_seq := nextval('job_seq');
2704
- IF v_lifo THEN
2705
- v_seq := -v_seq;
2706
- END IF;
2707
- IF v_delay > 0 THEN
2708
- v_state := 'delayed';
2709
- v_process_at := p_now + v_delay;
2710
- ELSE
2711
- v_state := 'waiting';
2712
- v_process_at := NULL;
2713
- END IF;
2714
-
2715
- INSERT INTO job (
2716
- queue, id, seq, name, state, data, opts, priority, delay_ms,
2717
- max_attempts, added_at_ms, process_at_ms, dedup_id
2718
- ) VALUES (
2719
- p_queue, v_job_id, v_seq, v_name, v_state, v_data, v_opts, v_priority,
2720
- v_delay, COALESCE((v_opts->>'attempts')::integer, 1), p_now, v_process_at,
2721
- p_dedup_id
2722
- )
2723
- ON CONFLICT (queue, id) DO NOTHING;
2724
-
2725
- -- New winner key (no expiry while keepLastIfActive, else honour ttl).
2726
- INSERT INTO dedup (queue, dedup_id, job_id, expire_at_ms)
2727
- VALUES (p_queue, p_dedup_id, v_job_id,
2728
- CASE WHEN v_keeplast OR COALESCE(v_ttl, 0) <= 0
2729
- THEN NULL ELSE p_now + v_ttl END)
2730
- ON CONFLICT (queue, dedup_id) DO UPDATE
2731
- SET job_id = EXCLUDED.job_id, expire_at_ms = EXCLUDED.expire_at_ms;
2732
-
2733
- DELETE FROM dedup_next WHERE queue = p_queue AND dedup_id = p_dedup_id;
2734
-
2735
- PERFORM pg_notify('bullmq_jobs', p_queue);
2736
- IF v_state = 'delayed' THEN
2737
- PERFORM publish_event(p_queue, 'delayed',
2738
- jsonb_build_object('jobId', v_job_id, 'delay', v_process_at));
2739
- ELSE
2740
- PERFORM publish_event(p_queue, 'waiting',
2741
- jsonb_build_object('jobId', v_job_id));
2742
- END IF;
2743
- END;
2744
- $$;
2745
-
2746
- -- ── Core decision (mirrors deduplicateJob / deduplicateJobWithoutReplace).
2747
- -- Returns the existing winner's id when the new job should be deduplicated
2748
- -- (i.e. NOT inserted); returns NULL when the caller should go on to add it.
2749
- CREATE FUNCTION deduplicate_job(
2750
- p_queue text, p_dedup jsonb, p_job_id text, p_now bigint,
2751
- p_name text, p_data jsonb, p_opts jsonb
2752
- ) RETURNS text
2753
- LANGUAGE plpgsql
2754
- SET search_path FROM CURRENT
2755
- AS $$
2756
- DECLARE
2757
- v_id text := p_dedup->>'id';
2758
- v_ttl bigint := NULLIF(p_dedup->>'ttl', '')::bigint;
2759
- v_extend boolean := COALESCE((p_dedup->>'extend')::boolean, false);
2760
- v_replace boolean := COALESCE((p_dedup->>'replace')::boolean, false);
2761
- v_keeplast boolean := COALESCE((p_dedup->>'keepLastIfActive')::boolean, false);
2762
- v_cur text;
2763
- v_exp bigint;
2764
- v_state job_state;
2765
- BEGIN
2766
- IF v_id IS NULL OR v_id = '' THEN
2767
- RETURN NULL;
2768
- END IF;
2769
-
2770
- -- The current winner (only if the key is still live).
2771
- SELECT job_id, expire_at_ms INTO v_cur, v_exp
2772
- FROM dedup WHERE queue = p_queue AND dedup_id = v_id;
2773
- IF v_cur IS NULL OR (v_exp IS NOT NULL AND v_exp <= p_now) THEN
2774
- v_cur := NULL;
2775
- END IF;
2776
-
2777
- IF v_replace THEN
2778
- IF v_cur IS NOT NULL THEN
2779
- SELECT state INTO v_state
2780
- FROM job WHERE queue = p_queue AND id = v_cur;
2781
- IF v_state = 'delayed' THEN
2782
- -- Drop the previous delayed job and take its place.
2783
- DELETE FROM job WHERE queue = p_queue AND id = v_cur;
2784
- PERFORM publish_event(p_queue, 'removed',
2785
- jsonb_build_object('jobId', v_cur, 'prev', 'delayed'));
2786
- PERFORM publish_event(p_queue, 'deduplicated',
2787
- jsonb_build_object('jobId', p_job_id, 'deduplicationId', v_id,
2788
- 'deduplicatedJobId', v_cur));
2789
- IF v_keeplast THEN
2790
- UPDATE dedup SET job_id = p_job_id, expire_at_ms = NULL
2791
- WHERE queue = p_queue AND dedup_id = v_id;
2792
- ELSIF NOT v_extend AND COALESCE(v_ttl, 0) > 0 THEN
2793
- -- KEEPTTL: keep the existing window, just swap the winner.
2794
- UPDATE dedup SET job_id = p_job_id
2795
- WHERE queue = p_queue AND dedup_id = v_id;
2796
- ELSE
2797
- UPDATE dedup
2798
- SET job_id = p_job_id,
2799
- expire_at_ms = CASE WHEN COALESCE(v_ttl, 0) > 0
2800
- THEN p_now + v_ttl ELSE NULL END
2801
- WHERE queue = p_queue AND dedup_id = v_id;
2802
- END IF;
2803
- RETURN NULL;
2804
- ELSE
2805
- -- Winner is not a removable delayed job: stash proto-next if it is
2806
- -- active + keepLastIfActive, then deduplicate.
2807
- PERFORM dedup_store_next(p_queue, v_id, v_cur, p_job_id,
2808
- v_keeplast, p_name, p_data, p_opts);
2809
- RETURN v_cur;
2810
- END IF;
2811
- ELSE
2812
- INSERT INTO dedup (queue, dedup_id, job_id, expire_at_ms)
2813
- VALUES (p_queue, v_id, p_job_id,
2814
- CASE WHEN NOT v_keeplast AND COALESCE(v_ttl, 0) > 0
2815
- THEN p_now + v_ttl ELSE NULL END)
2816
- ON CONFLICT (queue, dedup_id) DO UPDATE
2817
- SET job_id = EXCLUDED.job_id, expire_at_ms = EXCLUDED.expire_at_ms;
2818
- RETURN NULL;
2819
- END IF;
2820
- END IF;
2821
-
2822
- -- Without replace.
2823
- IF COALESCE(v_ttl, 0) > 0 AND v_extend THEN
2824
- IF v_cur IS NOT NULL THEN
2825
- -- Stash proto-next if active+keepLast; else extend the window (or
2826
- -- persist when keepLastIfActive). Either way keep the current winner.
2827
- IF NOT dedup_store_next(p_queue, v_id, v_cur, p_job_id,
2828
- v_keeplast, p_name, p_data, p_opts) THEN
2829
- UPDATE dedup
2830
- SET expire_at_ms = CASE WHEN v_keeplast THEN NULL ELSE p_now + v_ttl END
2831
- WHERE queue = p_queue AND dedup_id = v_id;
2832
- END IF;
2833
- PERFORM publish_event(p_queue, 'deduplicated',
2834
- jsonb_build_object('jobId', v_cur, 'deduplicationId', v_id,
2835
- 'deduplicatedJobId', p_job_id));
2836
- RETURN v_cur;
2837
- END IF;
2838
- INSERT INTO dedup (queue, dedup_id, job_id, expire_at_ms)
2839
- VALUES (p_queue, v_id, p_job_id,
2840
- CASE WHEN v_keeplast THEN NULL ELSE p_now + v_ttl END)
2841
- ON CONFLICT (queue, dedup_id) DO UPDATE
2842
- SET job_id = EXCLUDED.job_id, expire_at_ms = EXCLUDED.expire_at_ms;
2843
- RETURN NULL;
2844
- END IF;
2845
-
2846
- -- SET NX semantics (ttl>0 non-extend, or no ttl at all).
2847
- IF v_cur IS NOT NULL THEN
2848
- PERFORM dedup_store_next(p_queue, v_id, v_cur, p_job_id,
2849
- v_keeplast, p_name, p_data, p_opts);
2850
- PERFORM publish_event(p_queue, 'deduplicated',
2851
- jsonb_build_object('jobId', v_cur, 'deduplicationId', v_id,
2852
- 'deduplicatedJobId', p_job_id));
2853
- RETURN v_cur;
2854
- END IF;
2855
-
2856
- INSERT INTO dedup (queue, dedup_id, job_id, expire_at_ms)
2857
- VALUES (p_queue, v_id, p_job_id,
2858
- CASE WHEN NOT v_keeplast AND COALESCE(v_ttl, 0) > 0
2859
- THEN p_now + v_ttl ELSE NULL END)
2860
- ON CONFLICT (queue, dedup_id) DO UPDATE
2861
- SET job_id = EXCLUDED.job_id, expire_at_ms = EXCLUDED.expire_at_ms;
2862
- RETURN NULL;
2863
- END;
2864
- $$;
2865
-
2866
- -- BullMQ PostgreSQL backend — flow failure propagation (schema version 29).
2867
- --
2868
- -- When a child job fails permanently, its parent must react according to the
2869
- -- child's parent-link options (mirrors moveChildFromDependenciesIfNeeded):
2870
- -- * failParentOnFailure (fpof) → record the child as a failed dependency,
2871
- -- defer a failure onto the parent and (if
2872
- -- it is waiting on children) release it so
2873
- -- a worker picks it up and fails it.
2874
- -- * continueParentOnFailure (cpof) → record the child as an ignored
2875
- -- dependency and release the parent.
2876
- -- * ignoreDependencyOnFailure (idof)→ record the child as ignored and release
2877
- -- the parent once no pending deps remain.
2878
- -- * removeDependencyOnFailure (rdof)→ drop the dependency entirely and release
2879
- -- the parent once no pending deps remain.
2880
- -- The PG backend stores raw JobsOptions, so the long option names are read
2881
- -- straight from the child's `opts`.
2882
-
2883
- -- Release a parent from waiting-children to wait (mirrors moveParentToWait —
2884
- -- priority is preserved by the row's `priority` column; FIFO order via a fresh
2885
- -- seq). Emits the 'waiting' (prev waiting-children) event and wakes a worker.
2886
- CREATE FUNCTION move_parent_to_wait(
2887
- p_parent_queue text, p_parent_id text, p_now bigint
2888
- ) RETURNS void
2889
- LANGUAGE plpgsql
2890
- SET search_path FROM CURRENT
2891
- AS $$
2892
- DECLARE
2893
- v_delay bigint;
2894
- v_process_at bigint;
2895
- BEGIN
2896
- SELECT delay_ms INTO v_delay
2897
- FROM job
2898
- WHERE queue = p_parent_queue AND id = p_parent_id
2899
- AND state = 'waiting-children';
2900
- IF NOT FOUND THEN
2901
- RETURN;
2902
- END IF;
2903
-
2904
- IF v_delay > 0 THEN
2905
- -- The parent carries a delay: release it into the delayed set (mirrors the
2906
- -- delay branch of moveParentToWait), scored from the release moment.
2907
- v_process_at := p_now + v_delay;
2908
- UPDATE job
2909
- SET state = 'delayed', process_at_ms = v_process_at,
2910
- seq = nextval('job_seq')
2911
- WHERE queue = p_parent_queue AND id = p_parent_id;
2912
- PERFORM pg_notify('bullmq_jobs', p_parent_queue);
2913
- PERFORM publish_event(p_parent_queue, 'delayed',
2914
- jsonb_build_object('jobId', p_parent_id, 'delay', v_process_at));
2915
- ELSE
2916
- -- No delay: release to wait. Priority is preserved by the row's `priority`
2917
- -- column, so a prioritized parent stays prioritized.
2918
- UPDATE job
2919
- SET state = 'waiting', seq = nextval('job_seq')
2920
- WHERE queue = p_parent_queue AND id = p_parent_id;
2921
- PERFORM pg_notify('bullmq_jobs', p_parent_queue);
2922
- PERFORM publish_event(p_parent_queue, 'waiting',
2923
- jsonb_build_object('jobId', p_parent_id, 'prev', 'waiting-children'));
2924
- END IF;
2925
- END;
2926
- $$;
2927
-
2928
- -- Propagate a child's permanent failure to its parent.
2929
- CREATE FUNCTION handle_child_failure(
2930
- p_queue text, p_id text, p_reason text, p_now bigint
2931
- ) RETURNS void
2932
- LANGUAGE plpgsql
2933
- SET search_path FROM CURRENT
2934
- AS $$
2935
- DECLARE
2936
- v_pq text;
2937
- v_pid text;
2938
- v_opts jsonb;
2939
- v_child_key text;
2940
- v_remaining integer;
2941
- BEGIN
2942
- SELECT parent_queue, parent_id, opts INTO v_pq, v_pid, v_opts
2943
- FROM job WHERE queue = p_queue AND id = p_id;
2944
- IF v_pid IS NULL OR v_pq IS NULL THEN
2945
- RETURN;
2946
- END IF;
2947
- v_child_key := p_queue || ':' || p_id;
2948
-
2949
- -- Serialize this child→parent mutation with any sibling resolving concurrently
2950
- -- (completion or failure). Mirrors the single-threaded atomicity Redis relies
2951
- -- on and prevents the `pending_deps` UPDATE from deadlocking against a
2952
- -- sibling's decrement (SQLSTATE 40P01). Taken on the direct parent only, so it
2953
- -- never nests and auto-releases at commit (see move_to_completed).
2954
- PERFORM pg_advisory_xact_lock(
2955
- hashtext('bullmq:parent:' || v_pq || ':' || v_pid));
2956
-
2957
- IF COALESCE((v_opts->>'failParentOnFailure')::boolean, false) THEN
2958
- UPDATE job_dependency
2959
- SET status = 'failed', value = to_jsonb(p_reason)
2960
- WHERE parent_queue = v_pq AND parent_id = v_pid
2961
- AND child_key = v_child_key AND status = 'pending';
2962
- IF FOUND THEN
2963
- UPDATE job SET pending_deps = GREATEST(pending_deps - 1, 0)
2964
- WHERE queue = v_pq AND id = v_pid;
2965
- -- Defer the failure onto the parent; when a worker activates it the
2966
- -- deferred failure fails it immediately (worker checks job.deferredFailure).
2967
- UPDATE job
2968
- SET deferred_failure = 'child ' || v_child_key || ' failed'
2969
- WHERE queue = v_pq AND id = v_pid;
2970
- PERFORM move_parent_to_wait(v_pq, v_pid, p_now);
2971
- END IF;
2972
-
2973
- ELSIF COALESCE((v_opts->>'continueParentOnFailure')::boolean, false) THEN
2974
- UPDATE job_dependency
2975
- SET status = 'ignored', value = to_jsonb(p_reason)
2976
- WHERE parent_queue = v_pq AND parent_id = v_pid
2977
- AND child_key = v_child_key AND status = 'pending';
2978
- IF FOUND THEN
2979
- UPDATE job SET pending_deps = GREATEST(pending_deps - 1, 0)
2980
- WHERE queue = v_pq AND id = v_pid;
2981
- PERFORM move_parent_to_wait(v_pq, v_pid, p_now);
2982
- END IF;
2983
-
2984
- ELSIF COALESCE((v_opts->>'ignoreDependencyOnFailure')::boolean, false) THEN
2985
- UPDATE job_dependency
2986
- SET status = 'ignored', value = to_jsonb(p_reason)
2987
- WHERE parent_queue = v_pq AND parent_id = v_pid
2988
- AND child_key = v_child_key AND status = 'pending';
2989
- IF FOUND THEN
2990
- UPDATE job SET pending_deps = GREATEST(pending_deps - 1, 0)
2991
- WHERE queue = v_pq AND id = v_pid
2992
- RETURNING pending_deps INTO v_remaining;
2993
- IF v_remaining = 0 THEN
2994
- PERFORM move_parent_to_wait(v_pq, v_pid, p_now);
2995
- END IF;
2996
- END IF;
2997
-
2998
- ELSIF COALESCE((v_opts->>'removeDependencyOnFailure')::boolean, false) THEN
2999
- DELETE FROM job_dependency
3000
- WHERE parent_queue = v_pq AND parent_id = v_pid
3001
- AND child_key = v_child_key AND status = 'pending';
3002
- IF FOUND THEN
3003
- UPDATE job SET pending_deps = GREATEST(pending_deps - 1, 0)
3004
- WHERE queue = v_pq AND id = v_pid
3005
- RETURNING pending_deps INTO v_remaining;
3006
- IF v_remaining = 0 THEN
3007
- PERFORM move_parent_to_wait(v_pq, v_pid, p_now);
3008
- END IF;
3009
- END IF;
3010
- END IF;
3011
- END;
3012
- $$;
3013
-
3014
- -- ── removeChildDependency: break a child's link to its parent (mirrors
3015
- -- removeChildDependency-1.lua). Returns 0 when the relationship existed and
3016
- -- was removed, 1 when there was no relationship; raises -1 (missing job) /
3017
- -- -5 (missing parent).
3018
- CREATE FUNCTION remove_child_dependency(
3019
- p_queue text, p_id text, p_parent_key text, p_now bigint
3020
- ) RETURNS integer
3021
- LANGUAGE plpgsql
3022
- SET search_path FROM CURRENT
3023
- AS $$
3024
- DECLARE
3025
- v_pq text;
3026
- v_pid text;
3027
- v_deleted integer;
3028
- v_remaining integer;
3029
- BEGIN
3030
- SELECT parent_queue, parent_id INTO v_pq, v_pid
3031
- FROM job WHERE queue = p_queue AND id = p_id;
3032
- IF NOT FOUND THEN
3033
- RAISE EXCEPTION 'bullmq: missing job %', p_id
3034
- USING ERRCODE = 'BM001', DETAIL = '-1';
3035
- END IF;
3036
- IF v_pq IS NULL OR v_pid IS NULL THEN
3037
- RETURN 1; -- no relationship
3038
- END IF;
3039
- IF NOT EXISTS (
3040
- SELECT 1 FROM job WHERE queue = v_pq AND id = v_pid
3041
- ) THEN
3042
- RAISE EXCEPTION 'bullmq: missing parent %', p_parent_key
3043
- USING ERRCODE = 'BM001', DETAIL = '-5';
3044
- END IF;
3045
-
3046
- DELETE FROM job_dependency
3047
- WHERE parent_queue = v_pq AND parent_id = v_pid
3048
- AND child_key = p_queue || ':' || p_id;
3049
- GET DIAGNOSTICS v_deleted = ROW_COUNT;
3050
- IF v_deleted = 0 THEN
3051
- RETURN 1;
3052
- END IF;
3053
-
3054
- -- Detach the child and release the parent if it has no more pending deps.
3055
- UPDATE job
3056
- SET parent_queue = NULL, parent_id = NULL, parent_key = NULL
3057
- WHERE queue = p_queue AND id = p_id;
3058
- -- Serialize with concurrent child resolutions of the same parent (see
3059
- -- move_to_completed) to avoid a pending_deps deadlock.
3060
- PERFORM pg_advisory_xact_lock(
3061
- hashtext('bullmq:parent:' || v_pq || ':' || v_pid));
3062
- UPDATE job SET pending_deps = GREATEST(pending_deps - 1, 0)
3063
- WHERE queue = v_pq AND id = v_pid
3064
- RETURNING pending_deps INTO v_remaining;
3065
- IF v_remaining = 0 THEN
3066
- PERFORM move_parent_to_wait(v_pq, v_pid, p_now);
3067
- END IF;
3068
- RETURN 0;
3069
- END;
3070
- $$;
3071
-
3072
- -- ── removeUnprocessedChildren: recursively remove a parent's still-pending
3073
- -- children (skipping active/locked ones), mirroring removeUnprocessedChildren-2.
3074
- -- Emits 'removed' for each child taken out; cascades clean up dependency rows.
3075
- CREATE FUNCTION remove_unprocessed_children(
3076
- p_queue text, p_id text
3077
- ) RETURNS void
3078
- LANGUAGE plpgsql
3079
- SET search_path FROM CURRENT
3080
- AS $$
3081
- DECLARE
3082
- r RECORD;
3083
- BEGIN
3084
- FOR r IN
3085
- WITH RECURSIVE subtree AS (
3086
- SELECT j.queue AS q, j.id AS id
3087
- FROM job_dependency d
3088
- JOIN job j ON j.queue = d.child_queue AND j.id = d.child_id
3089
- WHERE d.parent_queue = p_queue AND d.parent_id = p_id
3090
- AND d.status = 'pending'
3091
- AND j.state NOT IN ('active', 'failed', 'completed')
3092
- AND j.lock_token IS NULL
3093
- UNION
3094
- SELECT j.queue, j.id
3095
- FROM subtree s
3096
- JOIN job_dependency d
3097
- ON d.parent_queue = s.q AND d.parent_id = s.id AND d.status = 'pending'
3098
- JOIN job j ON j.queue = d.child_queue AND j.id = d.child_id
3099
- WHERE j.state NOT IN ('active', 'failed', 'completed')
3100
- AND j.lock_token IS NULL
3101
- )
3102
- SELECT q, id FROM subtree
3103
- LOOP
3104
- -- Decrement the (live) parent's pending counter for direct children.
3105
- UPDATE job p SET pending_deps = GREATEST(pending_deps - 1, 0)
3106
- FROM job_dependency d
3107
- WHERE d.child_queue = r.q AND d.child_id = r.id AND d.status = 'pending'
3108
- AND p.queue = d.parent_queue AND p.id = d.parent_id;
3109
- DELETE FROM job WHERE queue = r.q AND id = r.id;
3110
- PERFORM publish_event(r.q, 'removed',
3111
- jsonb_build_object('jobId', r.id, 'prev', 'waiting'));
3112
- END LOOP;
3113
- END;
3114
- $$;
3115
-
3116
- -- BullMQ PostgreSQL backend — duplicated flow jobs (schema version 30).
3117
- --
3118
- -- When a flow (or a plain add with a parent) references a job id that already
3119
- -- exists, BullMQ does not insert a second job; instead it re-attaches the
3120
- -- existing job to the new parent (mirrors handleDuplicatedJob +
3121
- -- updateExistingJobsParent):
3122
- -- * if the existing job already has a *different* parent that still exists,
3123
- -- the parent cannot be replaced → ErrorCode -7;
3124
- -- * otherwise the existing job is linked to the new parent. A still-pending
3125
- -- job becomes a pending dependency (incrementing the parent's counter); an
3126
- -- already-completed job is recorded as a processed dependency (carrying its
3127
- -- return value) and, if it clears the parent's last pending dependency, the
3128
- -- parent is released.
3129
- CREATE FUNCTION handle_duplicated_job(
3130
- p_queue text,
3131
- p_id text,
3132
- p_parent_queue text,
3133
- p_parent_id text,
3134
- p_parent_key text,
3135
- p_now bigint
3136
- ) RETURNS integer
3137
- LANGUAGE plpgsql
3138
- SET search_path FROM CURRENT
3139
- AS $$
3140
- DECLARE
3141
- v_ex_pq text;
3142
- v_ex_pid text;
3143
- v_state job_state;
3144
- v_rv jsonb;
3145
- v_remaining integer;
3146
- v_added boolean;
3147
- BEGIN
3148
- -- No new parent to attach: still a duplicate add, so announce it.
3149
- IF p_parent_id IS NULL OR p_parent_queue IS NULL THEN
3150
- PERFORM publish_event(p_queue, 'duplicated',
3151
- jsonb_build_object('jobId', p_id));
3152
- RETURN 0;
3153
- END IF;
3154
-
3155
- SELECT parent_queue, parent_id, state, return_value
3156
- INTO v_ex_pq, v_ex_pid, v_state, v_rv
3157
- FROM job WHERE queue = p_queue AND id = p_id;
3158
-
3159
- -- The existing job already belongs to a different (still-existing) parent.
3160
- IF v_ex_pq IS NOT NULL
3161
- AND (v_ex_pq IS DISTINCT FROM p_parent_queue
3162
- OR v_ex_pid IS DISTINCT FROM p_parent_id)
3163
- AND EXISTS (
3164
- SELECT 1 FROM job WHERE queue = v_ex_pq AND id = v_ex_pid
3165
- ) THEN
3166
- RETURN -7;
3167
- END IF;
3168
-
3169
- IF v_state = 'completed' THEN
3170
- -- Already finished: record a processed dependency (no pending increment)
3171
- -- and release the parent if this was its last outstanding dependency.
3172
- INSERT INTO job_dependency (
3173
- parent_queue, parent_id, child_queue, child_id, child_key, status, value
3174
- ) VALUES (
3175
- p_parent_queue, p_parent_id, p_queue, p_id,
3176
- p_queue || ':' || p_id, 'processed', v_rv
3177
- )
3178
- ON CONFLICT (parent_queue, parent_id, child_key)
3179
- DO UPDATE SET status = 'processed', value = v_rv;
3180
-
3181
- SELECT pending_deps INTO v_remaining
3182
- FROM job WHERE queue = p_parent_queue AND id = p_parent_id;
3183
- IF COALESCE(v_remaining, 0) = 0 THEN
3184
- PERFORM move_parent_to_wait(p_parent_queue, p_parent_id, p_now);
3185
- END IF;
3186
- ELSE
3187
- -- Still pending: register a pending dependency and count it on the parent.
3188
- INSERT INTO job_dependency (
3189
- parent_queue, parent_id, child_queue, child_id, child_key, status
3190
- ) VALUES (
3191
- p_parent_queue, p_parent_id, p_queue, p_id,
3192
- p_queue || ':' || p_id, 'pending'
3193
- )
3194
- ON CONFLICT (parent_queue, parent_id, child_key) DO NOTHING;
3195
- GET DIAGNOSTICS v_added = ROW_COUNT;
3196
- IF v_added THEN
3197
- UPDATE job SET pending_deps = pending_deps + 1
3198
- WHERE queue = p_parent_queue AND id = p_parent_id;
3199
- END IF;
3200
- END IF;
3201
-
3202
- -- Point the existing job at its new parent.
3203
- UPDATE job
3204
- SET parent_queue = p_parent_queue,
3205
- parent_id = p_parent_id,
3206
- parent_key = p_parent_key
3207
- WHERE queue = p_queue AND id = p_id;
3208
-
3209
- PERFORM publish_event(p_queue, 'duplicated',
3210
- jsonb_build_object('jobId', p_id));
3211
-
3212
- RETURN 0;
3213
- END;
3214
- $$;
3215
-
3216
- -- Record one finished job into the queue/kind metrics (mirrors collectMetrics).
3217
- -- `p_max` is the worker's `metrics.maxDataPoints`; `p_ts` the finish timestamp.
3218
- CREATE FUNCTION collect_metrics(
3219
- p_queue text, p_kind text, p_max integer, p_ts bigint
3220
- ) RETURNS void
3221
- LANGUAGE plpgsql
3222
- SET search_path FROM CURRENT
3223
- AS $$
3224
- DECLARE
3225
- v_count bigint;
3226
- v_prev_ts bigint;
3227
- v_prev_count bigint;
3228
- v_data bigint[];
3229
- v_n bigint;
3230
- v_delta bigint;
3231
- BEGIN
3232
- -- Increment the cumulative count; v_count is the value BEFORE this job
3233
- -- (matches Lua's `HINCRBY count 1) - 1`).
3234
- INSERT INTO metrics (queue, kind, count, prev_ts, prev_count, data)
3235
- VALUES (p_queue, p_kind, 1, NULL, 0, '{}')
3236
- ON CONFLICT (queue, kind) DO UPDATE SET count = metrics.count + 1
3237
- RETURNING count - 1, prev_ts, prev_count, data
3238
- INTO v_count, v_prev_ts, v_prev_count, v_data;
3239
-
3240
- -- First data point only establishes the baseline.
3241
- IF v_prev_ts IS NULL THEN
3242
- UPDATE metrics SET prev_ts = p_ts, prev_count = 0
3243
- WHERE queue = p_queue AND kind = p_kind;
3244
- RETURN;
3245
- END IF;
3246
-
3247
- -- Number of one-minute buckets elapsed since the last data point, capped.
3248
- v_n := LEAST(p_ts / 60000 - v_prev_ts / 60000, p_max);
3249
- IF v_n > 0 THEN
3250
- v_delta := v_count - v_prev_count;
3251
- -- Prepend N-1 zeros for the skipped (empty) minutes, then the delta for the
3252
- -- minute of the previous data point. Mirrors Redis collectMetrics, which
3253
- -- LPUSHes the delta first and then the zeros, leaving the zeros newest and
3254
- -- the delta oldest within this chunk (data is newest-first).
3255
- IF v_n > 1 THEN
3256
- v_data := array_fill(0::bigint, ARRAY[(v_n - 1)::int])
3257
- || ARRAY[v_delta]
3258
- || v_data;
3259
- ELSE
3260
- v_data := ARRAY[v_delta] || v_data;
3261
- END IF;
3262
- -- Trim to the max number of data points.
3263
- v_data := v_data[1 : p_max];
3264
- UPDATE metrics
3265
- SET data = v_data, prev_count = v_count, prev_ts = p_ts
3266
- WHERE queue = p_queue AND kind = p_kind;
3267
- END IF;
3268
- END;
3269
- $$;
3270
-
3271
- -- BullMQ PostgreSQL backend — fused finish+fetch (schema version 32).
3272
- --
3273
- -- Redis's `moveToFinished` completes (or fails) the current job AND returns the
3274
- -- next job to process in a single round-trip / atomic script. The PostgreSQL
3275
- -- backend previously did this as two separate calls — `move_to_completed` (or
3276
- -- `move_to_failed`) followed by `move_to_active` — i.e. TWO transactions, hence
3277
- -- two commits and two WAL fsyncs per processed job. Processing is commit-bound
3278
- -- (each commit fsyncs the WAL under synchronous_commit=on), so paying two
3279
- -- commits per job roughly halves throughput versus one.
3280
- --
3281
- -- These wrappers fuse the two into a single function call — one transaction, one
3282
- -- commit — by simply invoking the existing, unchanged finish and claim functions
3283
- -- in sequence. No logic is duplicated: `move_to_completed` /
3284
- -- `move_to_failed` keep all their semantics (parent release, retention,
3285
- -- dedup, events, retries), and `move_to_active` keeps all of its
3286
- -- (delayed promotion, pause/concurrency/limiter checks, FOR UPDATE SKIP LOCKED
3287
- -- claim). The finish runs first; if it raises (BM001 lock/state errors) the
3288
- -- whole transaction rolls back and nothing is claimed — matching the old
3289
- -- behaviour where a failed finish never fetched a next job. The next job is
3290
- -- locked with the same worker token the finish used, exactly as the standalone
3291
- -- `move_to_active` did.
3292
- --
3293
- -- The worker passes `fetchNext`; when false the backend still calls the plain
3294
- -- finish functions, so these wrappers are only used on the hot processing loop.
3295
-
3296
- -- ──────────────────────────────────────────────────────────────────────────
3297
- -- complete + claim next (0 or 1 job rows)
3298
- -- ──────────────────────────────────────────────────────────────────────────
3299
- CREATE FUNCTION move_to_completed_fetch(
3300
- p_queue text,
3301
- p_id text,
3302
- p_token text,
3303
- p_return_value jsonb,
3304
- p_finished_on bigint,
3305
- p_remove_all boolean,
3306
- p_keep_age bigint,
3307
- p_keep_count integer,
3308
- p_lock_ms bigint,
3309
- p_now bigint,
3310
- p_name text,
3311
- p_limiter_max integer,
3312
- p_limiter_duration bigint
3313
- ) RETURNS SETOF job
3314
- LANGUAGE plpgsql
3315
- SET search_path FROM CURRENT
3316
- AS $$
3317
- BEGIN
3318
- PERFORM move_to_completed(
3319
- p_queue, p_id, p_token, p_return_value,
3320
- p_finished_on, p_remove_all, p_keep_age, p_keep_count);
3321
-
3322
- RETURN QUERY SELECT * FROM move_to_active(
3323
- p_queue, p_token, p_lock_ms, p_now, p_name, p_limiter_max, p_limiter_duration);
3324
- END;
3325
- $$;
3326
-
3327
- -- ──────────────────────────────────────────────────────────────────────────
3328
- -- fail (or retry) + claim next (0 or 1 job rows)
3329
- -- ──────────────────────────────────────────────────────────────────────────
3330
- CREATE FUNCTION move_to_failed_fetch(
3331
- p_queue text,
3332
- p_id text,
3333
- p_token text,
3334
- p_failed_reason text,
3335
- p_stacktrace jsonb,
3336
- p_finished_on bigint,
3337
- p_remove_all boolean,
3338
- p_keep_age bigint,
3339
- p_keep_count integer,
3340
- p_lock_ms bigint,
3341
- p_now bigint,
3342
- p_name text,
3343
- p_limiter_max integer,
3344
- p_limiter_duration bigint
3345
- ) RETURNS SETOF job
3346
- LANGUAGE plpgsql
3347
- SET search_path FROM CURRENT
3348
- AS $$
3349
- BEGIN
3350
- PERFORM move_to_failed(
3351
- p_queue, p_id, p_token, p_failed_reason, p_stacktrace,
3352
- p_finished_on, p_remove_all, p_keep_age, p_keep_count);
3353
-
3354
- RETURN QUERY SELECT * FROM move_to_active(
3355
- p_queue, p_token, p_lock_ms, p_now, p_name, p_limiter_max, p_limiter_duration);
3356
- END;
3357
- $$;
3358
-
3359
- -- ──────────────────────────────────────────────────────────────────────────
3360
- -- add_jobs_bulk: fast set-based bulk insert for INDEPENDENT jobs (no parents,
3361
- -- no deduplication). This is the common Queue.add_bulk case. Unlike
3362
- -- add_flow — which loops row-by-row to support flow trees, dedup and
3363
- -- mixed states — this inserts the whole batch with a single set-based INSERT
3364
- -- and emits the lifecycle events in one more set-based INSERT, so it is several
3365
- -- times faster. Returns the job ids in input order. Callers must route flows or
3366
- -- deduplicated jobs to add_flow instead.
3367
- CREATE FUNCTION add_jobs_bulk(p_queue text, p_entries jsonb)
3368
- RETURNS SETOF text
3369
- LANGUAGE plpgsql
3370
- SET search_path FROM CURRENT
3371
- AS $$
3372
- DECLARE
3373
- v_seq_name text := job_id_seq_name(p_queue);
3374
- BEGIN
3375
- -- Ensure the per-queue id sequence exists (mirrors next_job_id).
3376
- IF to_regclass(v_seq_name) IS NULL THEN
3377
- PERFORM pg_advisory_xact_lock(hashtext('bullmq:jidseq:' || p_queue));
3378
- EXECUTE format('CREATE SEQUENCE IF NOT EXISTS %I', v_seq_name);
3379
- END IF;
3380
-
3381
- DROP TABLE IF EXISTS pg_temp._bulk_inserted;
3382
- DROP TABLE IF EXISTS pg_temp._bulk_result;
3383
-
3384
- CREATE TEMP TABLE _bulk_result ON COMMIT DROP AS
3385
- WITH elems AS (
3386
- SELECT value AS j, ord
3387
- FROM jsonb_array_elements(p_entries) WITH ORDINALITY AS e(value, ord)
3388
- ),
3389
- prepared AS (
3390
- SELECT
3391
- ord,
3392
- COALESCE(NULLIF(j->>'id', ''), nextval(v_seq_name::regclass)::text) AS id,
3393
- j->>'name' AS name,
3394
- COALESCE((j->>'data')::jsonb, '{}'::jsonb) AS data,
3395
- COALESCE(j->'opts', '{}'::jsonb) AS opts,
3396
- COALESCE((j->>'priority')::integer, 0) AS priority,
3397
- COALESCE((j->>'delay')::bigint, 0) AS delay,
3398
- COALESCE((j->>'timestamp')::bigint, 0) AS ts,
3399
- COALESCE((j->>'attempts')::integer, 1) AS attempts,
3400
- j->>'schedulerId' AS scheduler_id,
3401
- COALESCE((j->>'lifo')::boolean, false) AS lifo
3402
- FROM elems
3403
- ),
3404
- -- Reserve one global FIFO seq per row and pair the ord-th smallest reserved
3405
- -- value with the ord-th entry, so seq is strictly monotonic in input order
3406
- -- regardless of evaluation order.
3407
- reserved AS (
3408
- SELECT ord, nextval('job_seq') AS s FROM prepared
3409
- ),
3410
- rr AS (SELECT s, row_number() OVER (ORDER BY s) AS rn FROM reserved),
3411
- rp AS (SELECT prepared.*, row_number() OVER (ORDER BY ord) AS rn FROM prepared),
3412
- final AS (
3413
- SELECT rp.ord, rp.id, rp.name, rp.data, rp.opts, rp.priority, rp.delay,
3414
- rp.ts, rp.attempts, rp.scheduler_id,
3415
- CASE WHEN rp.lifo THEN -rr.s ELSE rr.s END AS seq,
3416
- CASE WHEN rp.delay > 0 THEN 'delayed'::job_state
3417
- ELSE 'waiting'::job_state END AS state,
3418
- CASE WHEN rp.delay > 0 THEN rp.ts + rp.delay ELSE NULL END AS process_at
3419
- FROM rp JOIN rr ON rp.rn = rr.rn
3420
- )
3421
- SELECT * FROM final ORDER BY ord;
3422
-
3423
- -- Insert the batch, capturing ONLY the rows actually inserted: ON CONFLICT
3424
- -- skips ids that already exist and in-batch duplicate ids. Events and the
3425
- -- wakeup are driven from this set, so a batch that repeats or reuses an id
3426
- -- does not emit spurious added/waiting/delayed events or a bogus NOTIFY —
3427
- -- mirroring add_flow's per-row `IF v_inserted` gating.
3428
- CREATE TEMP TABLE _bulk_inserted ON COMMIT DROP AS
3429
- WITH ins AS (
3430
- INSERT INTO job (
3431
- queue, id, seq, name, state, data, opts, priority, delay_ms, max_attempts,
3432
- added_at_ms, process_at_ms, scheduler_id, pending_deps
3433
- )
3434
- SELECT p_queue, id, seq, name, state, data, opts, priority, delay, attempts,
3435
- ts, process_at, scheduler_id, 0
3436
- FROM _bulk_result
3437
- ON CONFLICT (queue, id) DO NOTHING
3438
- RETURNING id, seq, name, state, process_at_ms
3439
- )
3440
- SELECT * FROM ins;
3441
-
3442
- -- One set-based event flush ('added' then the state event per inserted job),
3443
- -- ordered by each inserted job's first input ordinality so stream ordering
3444
- -- matches caller payload order even when seq is negated by LIFO.
3445
- INSERT INTO event (queue, event, data, created_at_ms)
3446
- WITH inserted AS (
3447
- SELECT i.id, i.name, i.state, i.process_at_ms, MIN(r.ord) AS ord
3448
- FROM _bulk_inserted i
3449
- JOIN _bulk_result r USING (id)
3450
- GROUP BY i.id, i.name, i.state, i.process_at_ms
3451
- )
3452
- SELECT p_queue, ev, dat, (extract(epoch FROM clock_timestamp()) * 1000)::bigint
3453
- FROM (
3454
- SELECT ord * 2 AS o, 'added' AS ev,
3455
- jsonb_build_object('jobId', id, 'name', name) AS dat
3456
- FROM inserted
3457
- UNION ALL
3458
- SELECT ord * 2 + 1,
3459
- CASE WHEN state = 'waiting' THEN 'waiting' ELSE 'delayed' END,
3460
- CASE WHEN state = 'waiting'
3461
- THEN jsonb_build_object('jobId', id)
3462
- ELSE jsonb_build_object('jobId', id, 'delay', process_at_ms) END
3463
- FROM inserted
3464
- ) q
3465
- ORDER BY o;
3466
-
3467
- IF EXISTS (SELECT 1 FROM _bulk_inserted) THEN
3468
- PERFORM pg_notify('bullmq_jobs', p_queue);
3469
- END IF;
3470
-
3471
- RETURN QUERY SELECT id FROM _bulk_result ORDER BY ord;
3472
- END;
3473
- $$;