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
@@ -0,0 +1,107 @@
1
+ // Auto-generated by scripts/generateSqlScripts.js — DO NOT EDIT.
2
+ //
3
+ // Inlined at build time from src/postgres/commands/*.sql and
4
+ // src/postgres/migrations/*.sql, which remain the source of truth (shared
5
+ // verbatim with the Python/Elixir/PHP/Rust ports). Regenerate with
6
+ // 'yarn generate:sql:scripts' after editing any .sql file.
7
+ //
8
+ // Inlining removes the runtime filesystem dependency (readFileSync from a
9
+ // __dirname-derived path), which broke every single-file bundler:
10
+ // https://github.com/taskforcesh/bullmq/issues/4603
11
+ /**
12
+ * Runtime query SQL, keyed by command name (filename without the .sql
13
+ * extension), matching the keys used by loadCommandSql().
14
+ */
15
+ export const SQL_COMMANDS = {
16
+ "add_flow.sql": "-- Insert a flow (tree) of jobs atomically. Param: $1 entries (jsonb array,\n-- ordered roots-first). Returns the job ids in input order.\nSELECT id FROM add_flow($1::jsonb) AS t(id);\n",
17
+ "add_job.sql": "-- Insert a single job, routing it to waiting or delayed; returns its id.\nSELECT add_job(\n $1, $2, $3, $4::jsonb, $5::jsonb, $6, $7, $8, $9,\n $10, $11, $12, $13, $14, $15\n) AS id;\n",
18
+ "add_job_scheduler.sql": "-- Register/update a scheduler and enqueue its next iteration; returns the\n-- delayed job id and its delay. Params: $1 queue, $2 scheduler_id,\n-- $3 next_millis, $4 template_data (jsonb), $5 template_opts (jsonb),\n-- $6 opts (jsonb), $7 delayed_opts (jsonb), $8 now, $9 producer_id.\nSELECT job_id, delay\n FROM add_job_scheduler($1, $2, $3, $4, $5, $6, $7, $8, $9);\n",
19
+ "add_jobs_bulk.sql": "-- Fast set-based bulk insert of INDEPENDENT jobs (no parents, no dedup).\n-- Param: $1 queue, $2 entries (jsonb array). Returns the job ids in input order.\nSELECT id FROM add_jobs_bulk($1, $2::jsonb) AS t(id);\n",
20
+ "add_log.sql": "-- Append a log line at the next per-job ordinal; returns its index.\n-- Params: $1 queue, $2 job_id, $3 row.\n-- The unqualified table name resolves in the configured BullMQ schema via\n-- search_path (default schema: bullmq).\nINSERT INTO job_log (queue, job_id, idx, row)\nVALUES (\n $1, $2,\n COALESCE(\n (SELECT MAX(idx) + 1 FROM job_log WHERE queue = $1 AND job_id = $2),\n 0\n ),\n $3\n)\nRETURNING idx;\n",
21
+ "change_delay.sql": "-- Reschedule a delayed job. Params: $1 queue, $2 id, $3 delay, $4 now.\n-- Returns 0 ok, -1 missing, -3 not delayed.\nSELECT change_delay($1, $2, $3, $4) AS code;\n",
22
+ "change_priority.sql": "-- Change a job's priority (and reposition via lifo). Params: $1 queue, $2 id,\n-- $3 priority, $4 lifo. Returns 0 ok, -1 missing.\nSELECT change_priority($1, $2, $3, $4) AS code;\n",
23
+ "clean.sql": "-- Remove jobs of a state older than a timestamp, up to a limit (0 = all);\n-- returns removed ids. Params: $1 queue, $2 type, $3 timestamp, $4 limit.\nSELECT id FROM clean($1, $2, $3, $4) AS t(id);\n",
24
+ "clear_logs.sql": "-- Clear a job's logs, keeping the latest $3 entries (NULL/0 → remove all).\n-- Params: $1 queue, $2 job_id, $3 keepLogs.\nDELETE FROM job_log\n WHERE queue = $1 AND job_id = $2\n AND idx < COALESCE(\n (SELECT MAX(idx) FROM job_log WHERE queue = $1 AND job_id = $2)\n - $3 + 1,\n idx + 1\n );\n",
25
+ "collect_metrics.sql": "-- Record one finished job into the queue/kind metrics (per-minute deltas).\n-- Params: $1 queue, $2 kind ('completed' | 'failed'), $3 maxDataPoints,\n-- $4 finish timestamp (epoch ms).\nSELECT collect_metrics($1, $2, $3, $4);\n",
26
+ "delete_deduplication_key.sql": "-- Unconditionally remove a deduplication key (Queue.removeDeduplicationKey /\n-- the deprecated removeDebounceKey). Params: $1 queue, $2 dedup_id. Returns the\n-- removed key (0 or 1 row).\nDELETE FROM dedup\nWHERE queue = $1 AND dedup_id = $2\nRETURNING dedup_id;\n",
27
+ "drain.sql": "-- Remove waiting (and optionally delayed) jobs. Params: $1 queue, $2 delayed.\nSELECT drain($1, $2);\n",
28
+ "extend_lock.sql": "-- Refresh an active job's lock; returns 1 on success, 0 if the lock was lost.\n-- Params: $1 queue, $2 id, $3 token, $4 lock_ms, $5 now_ms.\nSELECT extend_lock($1, $2, $3, $4, $5) AS n;\n",
29
+ "extend_locks.sql": "-- Refresh multiple active-job locks in one round-trip; returns the ids whose\n-- locks could not be renewed. Params: $1 queue, $2 ids (text[]), $3 tokens\n-- (text[]), $4 lock_ms, $5 now_ms.\nWITH input AS (\n SELECT ids.job_id, toks.token, ids.ord\n FROM unnest($2::text[]) WITH ORDINALITY AS ids(job_id, ord)\n JOIN unnest($3::text[]) WITH ORDINALITY AS toks(token, ord)\n USING (ord)\n),\nupdated AS (\n UPDATE job\n SET locked_until_ms = $5::bigint + $4::bigint\n FROM input\n WHERE queue = $1\n AND id = input.job_id\n AND state = 'active'\n AND lock_token = input.token\n RETURNING id\n)\nSELECT input.job_id AS id\n FROM input\n LEFT JOIN updated\n ON updated.id = input.job_id\n WHERE updated.id IS NULL\n ORDER BY input.ord;\n",
30
+ "get_client_list.sql": "-- Connected BullMQ clients, mirroring Redis `CLIENT LIST` for worker/queue\n-- discovery. Each long-lived worker / QueueEvents listener names its dedicated\n-- connection via `application_name` (the PostgreSQL analogue of\n-- `CLIENT SETNAME`), so listing the named sessions on this database reproduces\n-- the same discovery surface. Pooled query connections leave `application_name`\n-- empty and are excluded. One row per connection; the backend formats them into\n-- `name=<application_name>` lines for the shared client-list parser.\nSELECT application_name\nFROM pg_stat_activity\nWHERE datname = current_database()\n AND application_name <> '';\n",
31
+ "get_counts.sql": "-- Job counts by state for a queue. Param: $1 queue.\n-- \"prioritized\" = waiting with priority > 0; \"waiting\" = waiting with priority 0.\nSELECT\n COUNT(*) FILTER (WHERE state = 'active') AS active,\n COUNT(*) FILTER (WHERE state = 'completed') AS completed,\n COUNT(*) FILTER (WHERE state = 'failed') AS failed,\n COUNT(*) FILTER (WHERE state = 'delayed') AS delayed,\n COUNT(*) FILTER (WHERE state = 'waiting' AND priority = 0) AS waiting,\n COUNT(*) FILTER (WHERE state = 'waiting' AND priority > 0) AS prioritized,\n COUNT(*) FILTER (WHERE state = 'waiting-children') AS \"waiting-children\",\n (SELECT value FROM meta WHERE queue = $1 AND field = 'paused') AS paused\nFROM job\nWHERE queue = $1;\n",
32
+ "get_counts_per_priority.sql": "-- Waiting-job counts per priority. Params: $1 queue, $2 priorities (bigint[]).\n-- Returns one row per requested priority, in the input array order, with the\n-- number of waiting jobs at that priority. Pausing is an O(1) meta flag that\n-- leaves jobs in the 'waiting' state, so the counts are identical whether or\n-- not the queue is paused (mirrors Redis, where pausing does not touch the\n-- wait/prioritized sets).\nSELECT COUNT(j.id) AS cnt\nFROM unnest($2::bigint[]) WITH ORDINALITY AS pr(priority, ord)\nLEFT JOIN job j\n ON j.queue = $1\n AND j.state = 'waiting'\n AND j.priority = pr.priority\nGROUP BY pr.ord\nORDER BY pr.ord;\n",
33
+ "get_deduplication_job_id.sql": "-- The current \"winner\" job id for a deduplication key, or none when absent or\n-- expired. Params: $1 queue, $2 dedup_id, $3 now (ms).\nSELECT job_id\nFROM dedup\nWHERE queue = $1\n AND dedup_id = $2\n AND (expire_at_ms IS NULL OR expire_at_ms > $3);\n",
34
+ "get_dependencies.sql": "-- All of a parent job's child dependencies with their status and stored value.\n-- Params: $1 parent_queue, $2 parent_id. Status maps to the public categories:\n-- processed → processed (value = return value), pending → unprocessed,\n-- ignored → ignored (value = reason), failed → failed.\nSELECT status::text AS status, child_key, value\nFROM job_dependency\nWHERE parent_queue = $1 AND parent_id = $2\nORDER BY child_id;\n",
35
+ "get_dependencies_page.sql": "-- One page of a parent's child dependencies in a single status category.\n-- Params: $1 parent_queue, $2 parent_id, $3 status, $4 offset, $5 count.\nSELECT child_key, value\nFROM job_dependency\nWHERE parent_queue = $1 AND parent_id = $2 AND status = $3::dep_status\nORDER BY child_id\nOFFSET $4 LIMIT $5;\n",
36
+ "get_dependency_counts.sql": "-- Child-dependency counts per status for a parent job.\n-- Params: $1 parent_queue, $2 parent_id. (\"unprocessed\" = pending children.)\nSELECT\n COUNT(*) FILTER (WHERE status = 'processed') AS processed,\n COUNT(*) FILTER (WHERE status = 'pending') AS unprocessed,\n COUNT(*) FILTER (WHERE status = 'ignored') AS ignored,\n COUNT(*) FILTER (WHERE status = 'failed') AS failed\nFROM job_dependency\nWHERE parent_queue = $1 AND parent_id = $2;\n",
37
+ "get_ignored_children_failures.sql": "-- Ignored children of a parent as (child_key → failure reason) pairs. The reason\n-- is stored as a JSON scalar string, so `#>> '{}'` extracts the raw text\n-- (mirrors the Redis `<jobId>:failed` hash). Params: $1 parent_queue, $2 parent_id.\nSELECT child_key, COALESCE(value #>> '{}', value::text) AS reason\nFROM job_dependency\nWHERE parent_queue = $1 AND parent_id = $2 AND status = 'ignored'\nORDER BY child_id;\n",
38
+ "get_job_data.sql": "-- A job's full row. Params: $1 queue, $2 id.\nSELECT * FROM job WHERE queue = $1 AND id = $2;\n",
39
+ "get_job_logs_asc.sql": "-- A page of a job's logs, oldest first.\n-- Params: $1 queue, $2 job_id, $3 offset, $4 limit.\nSELECT row FROM job_log\n WHERE queue = $1 AND job_id = $2\n ORDER BY idx ASC\n OFFSET $3 LIMIT $4;\n",
40
+ "get_job_logs_count.sql": "-- Total number of log lines for a job. Params: $1 queue, $2 job_id.\nSELECT COUNT(*) AS count FROM job_log WHERE queue = $1 AND job_id = $2;\n",
41
+ "get_job_logs_desc.sql": "-- A page of a job's logs, newest first.\n-- Params: $1 queue, $2 job_id, $3 offset, $4 limit.\nSELECT row FROM job_log\n WHERE queue = $1 AND job_id = $2\n ORDER BY idx DESC\n OFFSET $3 LIMIT $4;\n",
42
+ "get_job_scheduler.sql": "-- Fetch a single scheduler's stored metadata and next-run score.\n-- Params: $1 queue, $2 scheduler_id.\nSELECT name, iteration_count, limit_count, start_date_ms, end_date_ms, tz,\n pattern, every_ms, offset_ms, template_data, template_opts, next_run_ms\n FROM scheduler\n WHERE queue = $1 AND scheduler_id = $2;\n",
43
+ "get_job_schedulers_count.sql": "-- Number of registered schedulers in a queue. Param: $1 queue.\nSELECT count(*)::int AS count FROM scheduler WHERE queue = $1;\n",
44
+ "get_job_schedulers_range.sql": "-- Fetch a page of schedulers ordered by next-run time. Params: $1 queue,\n-- $2 asc (boolean), $3 offset, $4 count (NULL = all remaining).\nSELECT scheduler_id, next_run_ms\n FROM scheduler\n WHERE queue = $1\n ORDER BY CASE WHEN $2 THEN next_run_ms END ASC,\n CASE WHEN NOT $2 THEN next_run_ms END DESC,\n scheduler_id\n OFFSET $3\n LIMIT $4;\n",
45
+ "get_metrics.sql": "-- Metrics for a queue/kind: the cumulative meta `count` and the per-minute data\n-- points (newest first), sliced to [start, end] with Redis LRANGE semantics\n-- (index 0 is the newest point; a negative end means \"to the oldest\").\n-- Params: $1 queue, $2 kind ('completed' | 'failed'), $3 start, $4 end.\nSELECT\n COALESCE((SELECT count FROM metrics\n WHERE queue = $1 AND kind = $2), 0)::bigint AS total,\n COALESCE((\n SELECT CASE\n WHEN $4 < 0 THEN data[($3 + 1) : ]\n ELSE data[($3 + 1) : ($4 + 1)]\n END\n FROM metrics WHERE queue = $1 AND kind = $2\n ), ARRAY[]::bigint[]) AS data;\n",
46
+ "get_processed_children_values.sql": "-- Processed children of a parent as (child_key → serialized value) pairs. The\n-- value is returned as JSON text so the caller can JSON.parse it (mirrors the\n-- Redis `<jobId>:processed` hash of stringified values). Params: $1 parent_queue,\n-- $2 parent_id.\nSELECT child_key, value::text AS value\nFROM job_dependency\nWHERE parent_queue = $1 AND parent_id = $2 AND status = 'processed'\nORDER BY child_id;\n",
47
+ "get_queue_meta.sql": "-- The full queue metadata hash. Param: $1 queue.\nSELECT field, value FROM meta WHERE queue = $1;\n",
48
+ "get_queue_meta_field.sql": "-- A single queue metadata field's value. Params: $1 queue, $2 field.\nSELECT value FROM meta WHERE queue = $1 AND field = $2;\n",
49
+ "get_queue_meta_fields.sql": "-- Several queue metadata fields. Params: $1 queue, $2 fields (text[]).\nSELECT field, value FROM meta\n WHERE queue = $1 AND field = ANY($2::text[]);\n",
50
+ "get_range.sql": "-- Job ids in a state, sliced by [start, end]. Params: $1 queue, $2 type,\n-- $3 start, $4 end, $5 asc.\nSELECT id FROM get_range($1, $2, $3, $4, $5) AS t(id);\n",
51
+ "get_rate_limit_ttl.sql": "-- Current rate-limit ttl in ms (Redis getRateLimitTtl semantics). Params:\n-- $1 queue, $2 maxJobs (0 = unspecified → meta `max` / raw window), $3 now_ms.\nSELECT rate_limit_ttl($1, $2, $3) AS ttl;\n",
52
+ "get_state.sql": "-- A job's state and priority. Params: $1 queue, $2 id.\nSELECT state, priority FROM job WHERE queue = $1 AND id = $2;\n",
53
+ "has_queue_meta_field.sql": "-- Whether a queue metadata field exists. Params: $1 queue, $2 field.\nSELECT EXISTS(\n SELECT 1 FROM meta WHERE queue = $1 AND field = $2\n) AS exists;\n",
54
+ "has_waiting_job.sql": "-- Whether the queue has a claimable (waiting, non-paused) job right now. Used\n-- by waitForJob to close the race where a NOTIFY fires before the listener is\n-- established (mirrors the atomicity of Redis's blocking pop). Param: $1 queue.\nSELECT\n EXISTS(\n SELECT 1 FROM job WHERE queue = $1 AND state = 'waiting'\n )\n AND NOT EXISTS(\n SELECT 1 FROM meta\n WHERE queue = $1 AND field = 'paused' AND value = '1'\n ) AS present;\n",
55
+ "is_finished.sql": "-- A job's finished state and result/reason (for waitUntilFinished / isFinished).\n-- Params: $1 queue, $2 id.\nSELECT state, return_value, failed_reason\n FROM job WHERE queue = $1 AND id = $2;\n",
56
+ "is_job_in_state.sql": "-- Whether a job is currently in the given state. Params: $1 queue, $2 id,\n-- $3 state.\nSELECT EXISTS(\n SELECT 1 FROM job\n WHERE queue = $1 AND id = $2 AND state = $3::job_state\n) AS present;\n",
57
+ "is_job_in_wait.sql": "-- Whether a (non-prioritized) waiting job is in the wait or paused list. The\n-- queue has no separate paused list: a waiting job belongs to the paused list\n-- when the queue is paused, and to the wait list otherwise. $3 selects which\n-- list to test (true = paused, false = wait). Params: $1 queue, $2 id, $3 paused.\nSELECT EXISTS(\n SELECT 1 FROM job j\n WHERE j.queue = $1 AND j.id = $2\n AND j.state = 'waiting' AND j.priority = 0\n AND (\n EXISTS(\n SELECT 1 FROM meta m\n WHERE m.queue = $1 AND m.field = 'paused' AND m.value = '1'\n )\n ) = $3\n) AS present;\n",
58
+ "is_job_prioritized.sql": "-- Whether a job is prioritized (waiting with a non-zero priority).\n-- Params: $1 queue, $2 id.\nSELECT EXISTS(\n SELECT 1 FROM job\n WHERE queue = $1 AND id = $2 AND state = 'waiting' AND priority > 0\n) AS present;\n",
59
+ "is_job_scheduler.sql": "-- Whether an id corresponds to a registered scheduler. Params: $1 queue, $2 id.\nSELECT EXISTS (\n SELECT 1 FROM scheduler WHERE queue = $1 AND scheduler_id = $2\n) AS exists;\n",
60
+ "is_maxed.sql": "-- Whether the queue is \"maxed\": it has a global concurrency limit and the\n-- number of active jobs has reached it (mirrors isQueueMaxed). Returns false\n-- when no concurrency limit is configured.\n-- Params: $1 queue.\nSELECT COALESCE(\n (SELECT count(*) FROM job WHERE queue = $1 AND state = 'active')\n >= (SELECT value::integer FROM meta\n WHERE queue = $1 AND field = 'concurrency'),\n false\n) AS maxed;\n",
61
+ "listen_events.sql": "-- Subscribe to the shared event-stream channel. Producers\n-- `pg_notify('bullmq_events', <queue>)`; a consumer filters for its own queue.\nLISTEN bullmq_events;\n",
62
+ "listen_jobs.sql": "-- Subscribe to the shared job-notification channel (fixed name keeps this\n-- portable). Producers `pg_notify('bullmq_jobs', <queue>)`; a worker filters\n-- notifications for its own queue.\nLISTEN bullmq_jobs;\n",
63
+ "move_active_to_wait.sql": "-- Move an active job back to wait (Job.moveToWait / dynamic rate limit).\n-- Params: $1 queue, $2 id, $3 token ('0' bypasses the lock check), $4 now_ms.\n-- Returns the limiter window ms (>=0), or -1 when the job no longer exists.\nSELECT move_active_to_wait($1, $2, $3, $4) AS n;\n",
64
+ "move_stalled_jobs_to_wait.sql": "-- Two-phase stalled-job recovery (mark on one pass, reclaim on the next);\n-- returns the reclaimed ids. Params: $1 queue, $2 max_stalled_count,\n-- $3 now_ms, $4 max_check_time_ms (stalledInterval).\nSELECT id FROM move_stalled_jobs_to_wait($1, $2, $3, $4) AS t(id);\n",
65
+ "move_to_active.sql": "-- Claim the next ready job for a worker (0 or 1 rows), honouring the limiter.\n-- Params: $1 queue, $2 token, $3 lock_ms, $4 now_ms, $5 worker name,\n-- $6 limiter max (worker option, NULL if none), $7 limiter duration ms.\nSELECT * FROM move_to_active($1, $2, $3, $4, $5, $6, $7);\n",
66
+ "move_to_completed.sql": "-- Finish an active job successfully; returns the finished-at timestamp.\n-- Params: $1 queue, $2 id, $3 token, $4 return_value (jsonb), $5 finished_on,\n-- $6 remove_all, $7 keep_age (s), $8 keep_count.\nSELECT move_to_completed($1, $2, $3, $4::jsonb, $5, $6, $7, $8) AS finished_on;\n",
67
+ "move_to_completed_fetch.sql": "-- Finish an active job successfully AND claim the next ready job in one\n-- transaction (one commit) — the fused equivalent of move_to_completed followed\n-- by move_to_active, mirroring Redis's moveToFinished. Returns 0 or 1 job rows.\n-- Params: $1 queue, $2 id, $3 token, $4 return_value (jsonb), $5 finished_on,\n-- $6 remove_all, $7 keep_age (s), $8 keep_count,\n-- $9 lock_ms, $10 now_ms, $11 worker name,\n-- $12 limiter max (worker option, NULL if none), $13 limiter duration ms.\nSELECT * FROM move_to_completed_fetch(\n $1, $2, $3, $4::jsonb, $5, $6, $7, $8, $9, $10, $11, $12, $13);\n",
68
+ "move_to_delayed.sql": "-- Re-queue an active job to the delayed state (retry-with-delay / manual delay).\n-- Params: $1 queue, $2 id, $3 token, $4 process_at, $5 delay,\n-- $6 skip_attempt, $7 failed_reason, $8 stacktrace (jsonb).\nSELECT move_to_delayed($1, $2, $3, $4, $5, $6, $7, $8::jsonb) AS n;\n",
69
+ "move_to_failed.sql": "-- Mark an active job failed; returns the finished-at timestamp.\n-- Params: $1 queue, $2 id, $3 token, $4 failed_reason, $5 stacktrace (jsonb),\n-- $6 finished_on, $7 remove_all, $8 keep_age (s), $9 keep_count.\nSELECT move_to_failed($1, $2, $3, $4, $5::jsonb, $6, $7, $8, $9) AS finished_on;\n",
70
+ "move_to_failed_fetch.sql": "-- Fail (or retry) an active job AND claim the next ready job in one transaction\n-- (one commit) — the fused equivalent of move_to_failed followed by\n-- move_to_active, mirroring Redis's moveToFinished. Returns 0 or 1 job rows.\n-- Params: $1 queue, $2 id, $3 token, $4 failed_reason, $5 stacktrace (jsonb),\n-- $6 finished_on, $7 remove_all, $8 keep_age (s), $9 keep_count,\n-- $10 lock_ms, $11 now_ms, $12 worker name,\n-- $13 limiter max (worker option, NULL if none), $14 limiter duration ms.\nSELECT * FROM move_to_failed_fetch(\n $1, $2, $3, $4, $5::jsonb, $6, $7, $8, $9, $10, $11, $12, $13, $14);\n",
71
+ "move_to_waiting_children.sql": "-- Move an active parent to waiting-children if it has pending children.\n-- Params: $1 queue, $2 id, $3 token. Returns 1 (should wait), 0 (proceed).\nSELECT move_to_waiting_children($1, $2, $3) AS code;\n",
72
+ "next_delay.sql": "-- The timestamp of the next delayed job, or NULL if there are none.\nSELECT next_delay($1) AS next_delay;\n",
73
+ "next_signal.sql": "-- The worker \"no job\" signal: effective rate-limit ttl + next delayed-job time.\n-- Params: $1 queue, $2 limiter max (worker option, NULL if none), $3 now_ms.\nSELECT rate_limit_ttl, next_delay FROM next_signal($1, $2, $3);\n",
74
+ "obliterate.sql": "-- Obliterate a queue: delete up to $2 jobs; returns -1 (not paused), -2 (active\n-- jobs without force), 1 (more to delete), or 0 (done). Params: $1 queue,\n-- $2 count, $3 force.\nSELECT obliterate($1, $2, $3) AS cursor;\n",
75
+ "paginate_dependencies.sql": "-- A parent job's child dependencies of a given status, sliced [offset, limit]\n-- and joined to the child job rows (for fetchJobs). Params: $1 parent_queue,\n-- $2 parent_id, $3 status ('pending' | 'processed'), $4 offset, $5 limit\n-- (NULL = no limit). `total` is the full number of matching dependencies\n-- (COUNT(*) OVER(), computed before the slice). `dep_value` is the stored\n-- result for processed children. The remaining columns are the child\n-- `job` row consumed by rowToJobJson.\nSELECT\n d.child_key,\n d.value AS dep_value,\n COUNT(*) OVER() AS total,\n j.*\nFROM job_dependency d\nLEFT JOIN job j\n ON j.queue = d.child_queue AND j.id = d.child_id\nWHERE d.parent_queue = $1 AND d.parent_id = $2 AND d.status = $3::dep_status\nORDER BY d.child_key\nOFFSET $4\nLIMIT $5;\n",
76
+ "pause.sql": "-- Pause/resume the queue. Params: $1 queue, $2 paused.\nSELECT pause($1, $2);\n",
77
+ "promote.sql": "-- Promote a delayed job to waiting. Params: $1 queue, $2 id.\n-- Returns 0 ok, -1 missing, -3 not delayed.\nSELECT promote($1, $2) AS code;\n",
78
+ "promote_jobs.sql": "-- Move up to `count` delayed jobs to waiting; returns the number moved.\n-- Params: $1 queue, $2 count.\nSELECT promote_jobs($1, $2) AS n;\n",
79
+ "publish_event.sql": "-- Append a custom event to the stream; returns the event id.\n-- Params: $1 queue, $2 event, $3 data (jsonb).\nSELECT publish_event($1, $2, $3::jsonb) AS id;\n",
80
+ "read_events.sql": "-- A page of events newer than a cursor, oldest first.\n-- Params: $1 queue, $2 cursor (exclusive), $3 limit.\nSELECT id, event, data FROM event\n WHERE queue = $1 AND id > $2\n ORDER BY id ASC\n LIMIT $3;\n",
81
+ "read_events_max.sql": "-- The current max event id for a queue (used to resolve the '$' cursor).\n-- Param: $1 queue.\nSELECT COALESCE(MAX(id), 0)::bigint AS max FROM event WHERE queue = $1;\n",
82
+ "remove.sql": "-- Remove a job (and optionally its children). Returns 1 if removed, else 0.\n-- Params: $1 queue, $2 id, $3 remove_children.\nSELECT remove($1, $2, $3) AS n;\n",
83
+ "remove_child_dependency.sql": "-- Break a child's dependency link to its parent. Params: $1 queue, $2 job_id,\n-- $3 parent_key, $4 now. Returns 0 (removed) or 1 (no relationship); raises on\n-- missing job (-1) / missing parent (-5).\nSELECT remove_child_dependency($1, $2, $3, $4) AS n;\n",
84
+ "remove_deduplication_key.sql": "-- Conditionally remove a deduplication key, only when the given job is still\n-- its (live) winner (mirrors removeDeduplicationKeyIfNeededOnRemoval and the\n-- job-instance Job.removeDeduplicationKey, where Redis GET returns nil for an\n-- expired key). Params: $1 queue, $2 dedup_id, $3 job_id, $4 now (ms). Returns\n-- the removed key (0 or 1 row).\nDELETE FROM dedup\nWHERE queue = $1 AND dedup_id = $2 AND job_id = $3\n AND (expire_at_ms IS NULL OR expire_at_ms > $4)\nRETURNING dedup_id;\n",
85
+ "remove_job_scheduler.sql": "-- Remove a scheduler and its still-pending job (emitting `removed` events);\n-- returns 1 if the scheduler existed, 0 otherwise. Params: $1 queue, $2 id.\nSELECT remove_job_scheduler($1, $2) AS removed;\n",
86
+ "remove_queue_meta_fields.sql": "-- Remove queue metadata fields. Params: $1 queue, $2 fields (text[]).\nDELETE FROM meta WHERE queue = $1 AND field = ANY($2::text[]);\n",
87
+ "remove_rate_limit.sql": "-- Clear the limiter window; returns the number of rows removed (0 or 1).\n-- Param: $1 queue.\nWITH d AS (\n DELETE FROM rate_limit WHERE queue = $1 RETURNING 1\n)\nSELECT count(*)::int AS n FROM d;\n",
88
+ "remove_unprocessed_children.sql": "-- Recursively remove a parent's still-pending children. Params: $1 queue,\n-- $2 job_id.\nSELECT remove_unprocessed_children($1, $2);\n",
89
+ "reprocess_job.sql": "-- Re-queue a finished job (failed/completed) back to wait. Params: $1 queue,\n-- $2 id, $3 state, $4 lifo, $5 reset_attempts_made, $6 reset_attempts_started.\n-- Returns 1 ok, -1 missing, -3 not in the expected state.\nSELECT reprocess_job($1, $2, $3, $4, $5, $6) AS code;\n",
90
+ "retry_job.sql": "-- Re-queue an active job back to waiting immediately (retry now).\n-- Params: $1 queue, $2 id, $3 token, $4 lifo, $5 failed_reason, $6 stacktrace (jsonb).\nSELECT retry_job($1, $2, $3, $4, $5, $6::jsonb) AS n;\n",
91
+ "retry_jobs.sql": "-- Move up to `count` finished jobs back to waiting; returns the number moved.\n-- Params: $1 queue, $2 state, $3 count, $4 timestamp.\nSELECT retry_jobs($1, $2, $3, $4) AS n;\n",
92
+ "set_queue_meta.sql": "-- Upsert queue metadata fields. Params: $1 queue, $2 fields (text[]), $3 values (text[]).\nINSERT INTO meta (queue, field, value)\nSELECT $1, f, v FROM unnest($2::text[], $3::text[]) AS t(f, v)\nON CONFLICT (queue, field) DO UPDATE SET value = EXCLUDED.value;\n",
93
+ "set_rate_limit.sql": "-- Force the limiter window (dynamic / manual rate limit).\n-- Params: $1 queue, $2 expire ms, $3 now_ms.\nSELECT set_rate_limit($1, $2, $3);\n",
94
+ "trim_logs.sql": "-- Trim a job's oldest logs. Params: $1 queue, $2 job_id, $3 min_idx_to_keep.\nDELETE FROM job_log\n WHERE queue = $1 AND job_id = $2 AND idx < $3;\n",
95
+ "update_data.sql": "-- Replace a job's data payload. Params: $1 queue, $2 id, $3 data (jsonb).\nUPDATE job SET data = $3::jsonb WHERE queue = $1 AND id = $2\nRETURNING id;\n",
96
+ "update_job_scheduler.sql": "-- Advance an existing scheduler to its next iteration (no template change);\n-- returns the new delayed job id, or NULL if the scheduler is gone.\n-- Params: $1 queue, $2 scheduler_id, $3 next_millis, $4 template_data (jsonb),\n-- $5 delayed_opts (jsonb), $6 now, $7 producer_id.\nSELECT update_job_scheduler_next_millis($1, $2, $3, $4, $5, $6, $7) AS job_id;\n",
97
+ "update_progress.sql": "-- Update a job's progress and emit a 'progress' event. Returns the number of\n-- rows updated (0 = missing job). Params: $1 queue, $2 id, $3 progress (jsonb).\nSELECT update_progress($1, $2, $3::jsonb) AS updated;\n",
98
+ };
99
+ /**
100
+ * Schema migration SQL, keyed by filename (e.g. '0001_schema.sql'), matching
101
+ * the keys used by loadMigrationSql().
102
+ */
103
+ export const SQL_MIGRATIONS = {
104
+ "0001_schema.sql": "-- BullMQ PostgreSQL backend — schema (types, sequences, tables, indexes).\n--\n-- Consolidated initial schema. All BullMQ objects live in a connection-level\n-- schema (namespace); the operation functions live in 0002_functions.sql.\n\n-- BullMQ PostgreSQL backend — initial schema (schema version 1).\n--\n-- This file is the *portable source of truth* for the schema. It uses only\n-- standard SQL / PL-pgSQL so it can be shared verbatim with the future Elixir\n-- and Python ports (which call the very same tables and functions).\n--\n-- All objects are created inside the backend's configured *schema* (the\n-- connection-level namespace, default `bullmq`). The migration runner has\n-- already created the schema and set `search_path` to it, so the unqualified\n-- names below resolve into that schema. Unlike Redis — where a per-queue key\n-- `prefix` namespaces every key — SQL uses the schema as the single namespace\n-- for the whole connection, so there is no per-row/per-queue prefix.\n--\n-- The migration ledger table (`migration`) is bootstrapped by the\n-- migration runner itself, so it is intentionally not created here.\n--\n-- v1 only establishes the foundation that is independent of the job-storage\n-- model: per-queue metadata. Subsequent migrations add the job tables,\n-- indexes and the atomic operation functions.\n\n-- Per-queue metadata. Mirrors the Redis `<prefix>:<queue>:meta` hash: a small\n-- key/value store keyed by (queue, field). Used for the queue version, global\n-- concurrency, global rate limit, paused flag, etc.\nCREATE TABLE IF NOT EXISTS meta (\n queue text NOT NULL,\n field text NOT NULL,\n value text,\n PRIMARY KEY (queue, field)\n);\n\n-- BullMQ PostgreSQL backend — core job schema (schema version 2).\n--\n-- Portable source of truth (standard SQL / PL-pgSQL) shared verbatim with the\n-- future Elixir and Python ports. This migration establishes the *storage\n-- model* (tables + indexes) for every BullMQ feature: FIFO/LIFO, priority,\n-- delayed jobs, concurrency, locks/stalled detection, flows (parent/child\n-- dependencies), deduplication, rate limiting, repeatable job schedulers, the\n-- event stream and metrics. The atomic *operation* functions (addJob,\n-- moveToActive, …) are layered on top in subsequent migrations.\n--\n-- ──────────────────────────────────────────────────────────────────────────\n-- Design overview\n-- ──────────────────────────────────────────────────────────────────────────\n-- * Namespace = the connection's PostgreSQL *schema* (default `bullmq`), not a\n-- per-queue key prefix. In Redis a `prefix` namespaces every key because the\n-- keyspace is shared; in SQL the schema already isolates BullMQ from the\n-- user's other tables, and a different schema (or database) gives a fully\n-- independent BullMQ namespace. So there is no per-row/per-queue prefix — the\n-- `queue` column alone discriminates within the schema.\n--\n-- * Single `job` table keyed by (queue, id). The Redis adapter spreads a\n-- job's lifecycle across many keys (wait list, prioritized zset, delayed zset,\n-- active list, completed/failed zsets, locks, …); here a single `state`\n-- column plus a handful of promoted, indexable columns replaces all of them.\n-- State transitions are plain `UPDATE`s; claiming uses `FOR UPDATE SKIP\n-- LOCKED` so concurrent workers never block each other.\n--\n-- * Time is stored as `bigint` epoch-milliseconds (suffix `_ms`) to match\n-- BullMQ's millisecond API exactly and avoid timezone math on hot paths.\n--\n-- * Partial indexes are state-scoped: each hot path (claim next ready job,\n-- promote due delayed jobs, find stalled active jobs, clean finished jobs)\n-- has a small index that only contains the rows in the relevant state.\n--\n-- * \"paused\" and \"prioritized\" are NOT physical states. Pausing is an O(1)\n-- queue-level flag in `meta`; a prioritized job is simply a waiting job\n-- with `priority > 0`. This avoids the bulk row rewrites Redis performs on\n-- pause and removes the need for a separate prioritized structure — full\n-- `ORDER BY (priority, seq)` does the job.\n-- ──────────────────────────────────────────────────────────────────────────\n\n-- Physical job states. (Pause = meta flag; prioritized = waiting + priority>0.)\nCREATE TYPE job_state AS ENUM (\n 'waiting',\n 'active',\n 'completed',\n 'failed',\n 'delayed',\n 'waiting-children'\n);\n\n-- Status of a parent→child dependency (flows).\nCREATE TYPE dep_status AS ENUM (\n 'pending', -- child not finished yet\n 'processed', -- child completed; `value` holds its return value\n 'ignored', -- child failed but parent was told to ignore it; `value` = reason\n 'failed' -- child failed and the failure is recorded against the parent\n);\n\n-- Global monotonic ordering for FIFO. A single sequence gives a total order\n-- across all queues; FIFO within a queue is preserved because `seq` increases\n-- with insertion. LIFO jobs are stored with a *negative* `seq` (from the same\n-- sequence, negated) so they sort ahead of all FIFO jobs and, among themselves,\n-- most-recently-added first — reproducing \"last in, first out\" with one column.\nCREATE SEQUENCE job_seq;\n\n-- Global monotonic id for the event stream (the Postgres analogue of a Redis\n-- stream entry id). Consumers page forward with `id > cursor`.\nCREATE SEQUENCE event_seq;\n\n-- ──────────────────────────────────────────────────────────────────────────\n-- Jobs\n-- ──────────────────────────────────────────────────────────────────────────\nCREATE TABLE job (\n queue text NOT NULL,\n id text NOT NULL, -- custom id, or numeric counter as text\n seq bigint NOT NULL, -- FIFO order (negative = LIFO); see sequence above\n name text NOT NULL,\n state job_state NOT NULL,\n\n -- Payload / options. BullMQ serializes these as JSON strings; stored as jsonb\n -- (always valid JSON) so they are queryable and compact.\n data jsonb NOT NULL DEFAULT '{}'::jsonb,\n opts jsonb NOT NULL DEFAULT '{}'::jsonb,\n\n -- Promoted, frequently-read scalars (also live implicitly in `opts`, but are\n -- broken out so they can be indexed / updated cheaply).\n priority integer NOT NULL DEFAULT 0, -- 0 = no priority (FIFO); >0 = prioritized\n delay_ms bigint NOT NULL DEFAULT 0,\n max_attempts integer NOT NULL DEFAULT 1,\n attempts_made integer NOT NULL DEFAULT 0,\n attempts_started integer NOT NULL DEFAULT 0,\n\n -- Progress / results.\n progress jsonb, -- number or object\n return_value jsonb, -- BullMQ `returnvalue`\n failed_reason text,\n stacktrace jsonb, -- array of frames\n deferred_failure text,\n processed_by text, -- worker id that processed it\n\n -- Timestamps (epoch ms).\n added_at_ms bigint NOT NULL, -- BullMQ `timestamp`\n process_at_ms bigint, -- when a delayed job becomes ready\n processed_at_ms bigint, -- BullMQ `processedOn`\n finished_at_ms bigint, -- BullMQ `finishedOn`\n\n -- Locking / stalled detection. An active job is \"locked\" until\n -- `locked_until_ms`; once it elapses the job is considered stalled.\n lock_token text,\n locked_until_ms bigint,\n stalled_count integer NOT NULL DEFAULT 0,\n\n -- Deduplication / scheduler linkage.\n dedup_id text, -- BullMQ deduplicationId / debounceId\n scheduler_id text, -- id of the scheduler that produced this job (BullMQ repeatJobKey)\n\n -- Flow (parent/child) linkage. A child stores its parent's coordinates; a\n -- parent tracks how many children are still unfinished.\n parent_queue text,\n parent_id text,\n parent_key text, -- denormalized \"<queue>:<id>\" (Redis-compatible \"<prefix>:<queue>:<id>\")\n pending_deps integer NOT NULL DEFAULT 0,\n -- Two-phase stalled mark/sweep: set on the first stalled pass, reclaimed on\n -- the next (see move_stalled_jobs_to_wait in 0002_functions.sql).\n stalled_marked boolean NOT NULL DEFAULT false,\n\n PRIMARY KEY (queue, id)\n);\n\n-- Claim next ready job: waiting jobs ordered by (priority, seq). Covers both the\n-- \"wait\" (priority = 0) and \"prioritized\" (priority > 0) views, and the FIFO/LIFO\n-- ordering via the signed `seq`. Used with FOR UPDATE SKIP LOCKED.\nCREATE INDEX job_ready_idx\n ON job (queue, priority, seq)\n WHERE state = 'waiting';\n\n-- Promote due delayed jobs (process_at_ms <= now) and find the next wake-up.\nCREATE INDEX job_delayed_idx\n ON job (queue, process_at_ms)\n WHERE state = 'delayed';\n\n-- Active jobs: stalled scan by lock expiry, and O(1)-ish concurrency counting\n-- (COUNT over the partial index for a queue).\nCREATE INDEX job_active_idx\n ON job (queue, locked_until_ms)\n WHERE state = 'active';\n\n-- Finished jobs: range listing and age/count-based retention + cleaning.\nCREATE INDEX job_finished_idx\n ON job (queue, state, finished_at_ms)\n WHERE state IN ('completed', 'failed');\n\n-- Parents blocked on children.\nCREATE INDEX job_waiting_children_idx\n ON job (queue, seq)\n WHERE state = 'waiting-children';\n\n-- Reverse lookup from a child to its parent (e.g. orphan/repair scans).\nCREATE INDEX job_parent_idx\n ON job (parent_queue, parent_id)\n WHERE parent_id IS NOT NULL;\n\n-- Locate the job(s) produced by a given scheduler.\nCREATE INDEX job_scheduler_idx\n ON job (queue, scheduler_id)\n WHERE scheduler_id IS NOT NULL;\n\n-- ──────────────────────────────────────────────────────────────────────────\n-- Job logs (append-only, per-job ordered lines)\n-- ──────────────────────────────────────────────────────────────────────────\nCREATE TABLE job_log (\n queue text NOT NULL,\n job_id text NOT NULL,\n idx bigint NOT NULL, -- per-job ordinal (0-based); range reads + trimming\n row text NOT NULL,\n PRIMARY KEY (queue, job_id, idx),\n FOREIGN KEY (queue, job_id)\n REFERENCES job (queue, id) ON DELETE CASCADE\n);\n\n-- ──────────────────────────────────────────────────────────────────────────\n-- Flow dependencies (parent → child)\n-- ──────────────────────────────────────────────────────────────────────────\n-- Owned by the parent. Reproduces BullMQ's processed/ignored/failed/unprocessed\n-- child sets with a single `status` column, and stores each processed child's\n-- return value (or failure reason) in `value`. `pending_deps` on the parent job\n-- is the count of rows here still in 'pending'.\nCREATE TABLE job_dependency (\n parent_queue text NOT NULL,\n parent_id text NOT NULL,\n child_queue text NOT NULL,\n child_id text NOT NULL,\n child_key text NOT NULL, -- denormalized \"<queue>:<id>\" (Redis-compatible \"<prefix>:<queue>:<id>\")\n status dep_status NOT NULL DEFAULT 'pending',\n value jsonb,\n PRIMARY KEY (parent_queue, parent_id, child_key),\n FOREIGN KEY (parent_queue, parent_id)\n REFERENCES job (queue, id) ON DELETE CASCADE\n);\n\n-- Paginate a parent's children by category (processed / pending / …).\nCREATE INDEX dep_parent_status_idx\n ON job_dependency (parent_queue, parent_id, status);\n\n-- Resolve dependencies from the child side (when a child finishes, or when a\n-- child dependency is explicitly removed).\nCREATE INDEX dep_child_idx\n ON job_dependency (child_queue, child_id);\n\n-- ──────────────────────────────────────────────────────────────────────────\n-- Event stream (QueueEvents)\n-- ──────────────────────────────────────────────────────────────────────────\n-- The Postgres analogue of the Redis events stream. `id` is globally monotonic;\n-- consumers page forward with `id > cursor`. Blocking reads are delivered via\n-- LISTEN/NOTIFY (the operation layer NOTIFYs on insert); trimming deletes the\n-- lowest ids beyond the configured max length.\nCREATE TABLE event (\n queue text NOT NULL,\n id bigint NOT NULL DEFAULT nextval('event_seq'),\n event text NOT NULL,\n data jsonb NOT NULL DEFAULT '{}'::jsonb,\n created_at_ms bigint NOT NULL,\n PRIMARY KEY (queue, id)\n);\n\nCREATE TABLE metrics (\n queue text NOT NULL,\n kind text NOT NULL, -- 'completed' | 'failed'\n count bigint NOT NULL DEFAULT 0, -- cumulative finished jobs\n prev_ts bigint, -- ts of the last data point\n prev_count bigint NOT NULL DEFAULT 0, -- count at the last data point\n data bigint[] NOT NULL DEFAULT '{}',-- per-minute deltas, newest first\n PRIMARY KEY (queue, kind)\n);\n\n-- ──────────────────────────────────────────────────────────────────────────\n-- Rate limiting (token window per queue)\n-- ──────────────────────────────────────────────────────────────────────────\n-- One row per queue holding the current limiter window: `points` counts the\n-- jobs consumed in the window, which ends at `expire_at_ms`. Mirrors the single\n-- Redis `<prefix>:<queue>:limiter` counter key (with its PTTL). The global\n-- limiter is the only mode the open-source backend supports (per-group rate\n-- limiting was removed in BullMQ 3.0).\nCREATE TABLE rate_limit (\n queue text NOT NULL,\n points bigint NOT NULL DEFAULT 0,\n expire_at_ms bigint NOT NULL,\n PRIMARY KEY (queue)\n);\n\n-- ──────────────────────────────────────────────────────────────────────────\n-- Deduplication keys\n-- ──────────────────────────────────────────────────────────────────────────\nCREATE TABLE dedup (\n queue text NOT NULL,\n dedup_id text NOT NULL,\n job_id text NOT NULL,\n expire_at_ms bigint, -- NULL = no expiry; otherwise lazily reclaimed\n PRIMARY KEY (queue, dedup_id)\n);\n\n-- Expired dedup keys are reclaimed lazily by operation functions in\n-- 0002_functions.sql (for example deduplicate_job and\n-- dedup_finalize). This index keeps those lookups/deletes efficient.\nCREATE INDEX dedup_expire_idx\n ON dedup (queue, expire_at_ms)\n WHERE expire_at_ms IS NOT NULL;\n\n-- ──────────────────────────────────────────────────────────────────────────\n-- Job schedulers (repeatable jobs)\n--\n-- A scheduler is a job factory: it stores a template (data/opts) plus a repeat\n-- spec (cron `pattern` or fixed `every` ms) and, on each upsert, produces the\n-- next delayed job `repeat:<schedulerId>:<nextMillis>`. For cron the caller\n-- computes nextMillis (JS cron-parser); for `every` the backend computes it,\n-- honouring `offset_ms` (the phase offset from `start_date`).\n-- ──────────────────────────────────────────────────────────────────────────\nCREATE TABLE scheduler (\n queue text NOT NULL,\n scheduler_id text NOT NULL,\n name text,\n next_run_ms bigint, -- next iteration's due time\n pattern text, -- cron expression (mutually exclusive with every_ms)\n every_ms bigint, -- fixed interval in ms\n tz text, -- timezone for cron evaluation\n start_date_ms bigint,\n end_date_ms bigint,\n limit_count integer, -- max number of iterations (NULL = unlimited)\n iteration_count integer NOT NULL DEFAULT 0,\n template_data jsonb, -- payload template reused for every iteration\n template_opts jsonb, -- options template reused for every iteration\n producer_id text,\n offset_ms bigint, -- 'every' phase offset from start_date\n PRIMARY KEY (queue, scheduler_id)\n);\n\n-- Find schedulers whose next iteration is due, and order schedulers by next run.\nCREATE INDEX scheduler_next_idx\n ON scheduler (queue, next_run_ms);\n\n-- ── keepLastIfActive proto-next storage ──────────────────────────────────\n-- When a job is deduplicated while its winner is *active* and keepLastIfActive\n-- is set, the new job's payload is stashed here (Redis `dn:<id>` hash) and the\n-- dedup key is persisted (no expiry). When the active winner finishes, the\n-- stashed payload is turned into a real job (the new winner). At most one\n-- proto-next exists per id; a later add while active overwrites it.\nCREATE TABLE dedup_next (\n queue text NOT NULL,\n dedup_id text NOT NULL,\n payload jsonb NOT NULL, -- { name, data, opts, jobId }\n PRIMARY KEY (queue, dedup_id)\n);\n",
105
+ "0002_functions.sql": "-- BullMQ PostgreSQL backend — operation functions.\n--\n-- Every stored function implementing a queue operation (add, move-to-active,\n-- finish, flows, scheduler, rate limiting, dedup, metrics, …), in its final\n-- form. Depends on the tables/types created in 0001_schema.sql.\n\n-- ──────────────────────────────────────────────────────────────────────────\n-- Per-queue job-id allocator\n-- ──────────────────────────────────────────────────────────────────────────\n-- BullMQ hands auto-generated job ids from a per-queue counter (Redis: INCR\n-- <prefix>:<queue>:id). Implementing that as a `meta` row bumped with\n-- `ON CONFLICT DO UPDATE` serializes *every* concurrent `add` on that row's\n-- lock (held until commit), so parallel producers cannot make progress — by far\n-- the biggest `add` bottleneck. A dedicated per-queue SEQUENCE hands out ids via\n-- `nextval`, which takes no transaction-scoped lock, so concurrent adds run in\n-- parallel. Ids are still 1, 2, 3, … per queue (sequence starts at 1), matching\n-- Redis; a rolled-back add may leave a gap, exactly as a failed Redis add leaves\n-- its INCR in place. The sequence is created lazily on first use and dropped by\n-- `obliterate`. If we ever need user-facing immutable ids independent from\n-- this allocator, we can add a separate custom-id column/index without regressing\n-- the hot-path insert concurrency.\nCREATE FUNCTION job_id_seq_name(p_queue text) RETURNS text\nLANGUAGE sql IMMUTABLE AS $$\n SELECT 'bullmq_jid_' || md5(p_queue)\n$$;\n\nCREATE FUNCTION next_job_id(p_queue text) RETURNS text\nLANGUAGE plpgsql\nSET search_path FROM CURRENT\nAS $$\nDECLARE\n v_seq text := job_id_seq_name(p_queue);\nBEGIN\n -- Fast path once the sequence exists: a cached catalog lookup + a lock-free\n -- `nextval`. Only the first add(s) for a queue take the creation path; the\n -- advisory lock serializes those racing first-adds so exactly one issues the\n -- DDL (and it is released the moment that add commits).\n IF to_regclass(v_seq) IS NULL THEN\n PERFORM pg_advisory_xact_lock(hashtext('bullmq:jidseq:' || p_queue));\n EXECUTE format('CREATE SEQUENCE IF NOT EXISTS %I', v_seq);\n END IF;\n RETURN nextval(v_seq::regclass)::text;\nEND;\n$$;\n\n-- ── Recreate add_job to run the deduplication decision before inserting.\nCREATE OR REPLACE FUNCTION add_job(\n p_queue text,\n p_id text,\n p_name text,\n p_data jsonb,\n p_opts jsonb,\n p_priority integer,\n p_delay bigint,\n p_timestamp bigint,\n p_max_attempts integer,\n p_parent_queue text,\n p_parent_id text,\n p_parent_key text,\n p_dedup_id text,\n p_scheduler_id text,\n p_lifo boolean\n) RETURNS text\nLANGUAGE plpgsql\nSET search_path FROM CURRENT\nAS $$\nDECLARE\n v_id text := p_id;\n v_seq bigint;\n v_state job_state;\n v_process_at bigint;\n v_inserted boolean;\n v_dedup jsonb;\n v_existing text;\nBEGIN\n -- Serialize all dedup operations for this id (mirrors Redis's single-threaded\n -- atomicity: two READ COMMITTED adds could otherwise both read \"no live key\"\n -- and both insert). Acquired FIRST — before the id-counter INCR below — so the\n -- add and finish paths always take the locks in the same order (advisory then\n -- the id-counter row), which rules out a deadlock with requeue's own INCR.\n -- Transaction-scoped: released when this add commits.\n IF p_dedup_id IS NOT NULL AND p_dedup_id <> '' THEN\n PERFORM pg_advisory_xact_lock(\n hashtext('bullmq:dedup:' || p_queue || ':' || p_dedup_id));\n END IF;\n\n IF v_id IS NULL OR v_id = '' THEN\n v_id := next_job_id(p_queue);\n END IF;\n\n -- Deduplication: if a live key wins, skip the insert and return its id.\n IF p_dedup_id IS NOT NULL AND p_dedup_id <> '' THEN\n v_dedup := COALESCE(p_opts->'deduplication', p_opts->'debounce',\n jsonb_build_object('id', p_dedup_id));\n v_existing := deduplicate_job(p_queue, v_dedup, v_id, p_timestamp,\n p_name, COALESCE(p_data, '{}'::jsonb), COALESCE(p_opts, '{}'::jsonb));\n IF v_existing IS NOT NULL THEN\n RETURN v_existing;\n END IF;\n END IF;\n\n -- Verify the parent exists before inserting (atomic: a failure rolls back).\n IF p_parent_id IS NOT NULL AND p_parent_queue IS NOT NULL THEN\n IF NOT EXISTS (\n SELECT 1 FROM job WHERE queue = p_parent_queue AND id = p_parent_id\n ) THEN\n RAISE EXCEPTION 'bullmq: missing parent %', p_parent_key\n USING ERRCODE = 'BM001', DETAIL = '-5';\n END IF;\n END IF;\n\n v_seq := nextval('job_seq');\n IF p_lifo THEN\n v_seq := -v_seq;\n END IF;\n\n IF p_delay > 0 THEN\n v_state := 'delayed';\n v_process_at := p_timestamp + p_delay;\n ELSE\n v_state := 'waiting';\n v_process_at := NULL;\n END IF;\n\n INSERT INTO job (\n queue, id, seq, name, state,\n data, opts, priority, delay_ms, max_attempts,\n added_at_ms, process_at_ms,\n dedup_id, scheduler_id,\n parent_queue, parent_id, parent_key\n ) VALUES (\n p_queue, v_id, v_seq, p_name, v_state,\n COALESCE(p_data, '{}'::jsonb), COALESCE(p_opts, '{}'::jsonb),\n COALESCE(p_priority, 0), COALESCE(p_delay, 0), COALESCE(p_max_attempts, 1),\n p_timestamp, v_process_at,\n p_dedup_id, p_scheduler_id,\n p_parent_queue, p_parent_id, p_parent_key\n )\n ON CONFLICT (queue, id) DO NOTHING;\n\n GET DIAGNOSTICS v_inserted = ROW_COUNT;\n\n IF v_inserted AND p_parent_id IS NOT NULL AND p_parent_queue IS NOT NULL THEN\n INSERT INTO job_dependency (\n parent_queue, parent_id, child_queue, child_id, child_key, status\n ) VALUES (\n p_parent_queue, p_parent_id, p_queue, v_id,\n p_queue || ':' || v_id, 'pending'\n )\n ON CONFLICT (parent_queue, parent_id, child_key) DO NOTHING;\n\n UPDATE job\n SET pending_deps = pending_deps + 1\n WHERE queue = p_parent_queue AND id = p_parent_id;\n END IF;\n\n -- The job already existed: re-attach it to the new parent (or, with no\n -- parent, just announce the duplicate). -7 = it already has a different one.\n IF NOT v_inserted THEN\n IF handle_duplicated_job(p_queue, v_id, p_parent_queue,\n p_parent_id, p_parent_key, p_timestamp) = -7 THEN\n RAISE EXCEPTION 'bullmq: parent cannot be replaced'\n USING ERRCODE = 'BM001', DETAIL = '-7';\n END IF;\n END IF;\n\n IF v_inserted THEN\n PERFORM pg_notify('bullmq_jobs', p_queue);\n -- Every stored job announces itself (mirrors storeJob.lua's 'added').\n PERFORM publish_event(p_queue, 'added',\n jsonb_build_object('jobId', v_id, 'name', p_name));\n IF v_state = 'delayed' THEN\n PERFORM publish_event(p_queue, 'delayed',\n jsonb_build_object('jobId', v_id, 'delay', v_process_at));\n ELSE\n PERFORM publish_event(p_queue, 'waiting',\n jsonb_build_object('jobId', v_id));\n END IF;\n END IF;\n\n RETURN v_id;\nEND;\n$$;\n\n-- ── move_to_active with limiter enforcement ────────────────────────────────\n-- Adds the limiter: after promoting due delayed jobs, refuse to claim while\n-- rate limited (the caller reads the ttl via next_signal), and consume a\n-- token when a job is claimed. Otherwise identical to the v23 definition.\nCREATE OR REPLACE FUNCTION move_to_active(\n p_queue text,\n p_token text,\n p_lock_ms bigint,\n p_now bigint,\n p_name text,\n p_limiter_max integer,\n p_limiter_duration bigint\n) RETURNS SETOF job\nLANGUAGE plpgsql\nSET search_path FROM CURRENT\nAS $$\nDECLARE\n v_id text;\n v_job job;\n v_meta_max integer;\n v_max integer;\n v_duration bigint;\n v_concurrency integer;\nBEGIN\n -- Promote due delayed jobs first, regardless of pause. Redis promotes delayed\n -- jobs via a delay timer that routes through getTargetQueueList, so while the\n -- queue is paused they land in the paused list and still surface (counted as\n -- paused) even though they are not processed. Running this before the pause\n -- early-return reproduces that: the jobs become 'waiting' (reported as paused)\n -- but the claim/activate below is skipped.\n FOR v_id IN\n WITH promoted AS (\n UPDATE job\n SET state = 'waiting', delay_ms = 0\n WHERE queue = p_queue\n AND state = 'delayed'\n AND process_at_ms <= p_now\n RETURNING id\n )\n SELECT id FROM promoted\n LOOP\n PERFORM publish_event(p_queue, 'waiting',\n jsonb_build_object('jobId', v_id, 'prev', 'delayed'));\n END LOOP;\n\n IF EXISTS (\n SELECT 1 FROM meta\n WHERE queue = p_queue AND field = 'paused' AND value = '1'\n ) THEN\n RETURN;\n END IF;\n\n -- Global concurrency: never run more than meta.concurrency jobs active at\n -- once (mirrors isQueueMaxed). Serialized per queue with a transaction-scoped\n -- advisory lock so concurrent workers cannot all pass the check and overshoot;\n -- it releases at commit, once this claim is reflected in the active count.\n SELECT value::integer INTO v_concurrency\n FROM meta WHERE queue = p_queue AND field = 'concurrency';\n IF v_concurrency IS NOT NULL THEN\n PERFORM pg_advisory_xact_lock(hashtext('bullmq:concurrency:' || p_queue));\n IF (\n SELECT count(*) FROM job\n WHERE queue = p_queue AND state = 'active'\n ) >= v_concurrency THEN\n RETURN;\n END IF;\n END IF;\n\n -- Rate limit: meta `max` wins, else the worker's `limiter.max`.\n SELECT value::integer INTO v_meta_max\n FROM meta WHERE queue = p_queue AND field = 'max';\n v_max := COALESCE(v_meta_max, p_limiter_max);\n\n IF v_max IS NOT NULL THEN\n -- Redis evaluates moveToActive atomically on a single-threaded server, so a\n -- worker's limiter check and its token consumption can never interleave with\n -- another worker's. Postgres runs each worker's call under its own snapshot,\n -- so without serialization several concurrent workers could all read\n -- counter < max, each claim a (distinct) job, and each consume a token —\n -- overshooting the limit. A transaction-scoped advisory lock serializes the\n -- check-and-consume per queue exactly like Redis; under READ COMMITTED the\n -- effective check below then runs on a fresh snapshot that sees the token a\n -- preceding worker just committed. The lock auto-releases at statement end\n -- (this function is always called standalone) and is only taken when a\n -- limiter actually applies, so unlimited queues pay nothing.\n PERFORM pg_advisory_xact_lock(hashtext('bullmq:limiter:' || p_queue));\n IF rate_limit_effective(p_queue, p_limiter_max, p_now) > 0 THEN\n RETURN;\n END IF;\n END IF;\n\n SELECT id INTO v_id\n FROM job\n WHERE queue = p_queue AND state = 'waiting'\n ORDER BY priority, seq\n FOR UPDATE SKIP LOCKED\n LIMIT 1;\n\n IF v_id IS NULL THEN\n RETURN;\n END IF;\n\n -- Consume a token now that a job is actually being claimed. Duration: the\n -- worker's `limiter.duration` wins, else meta `duration`.\n IF v_max IS NOT NULL THEN\n SELECT value::bigint INTO v_duration\n FROM meta WHERE queue = p_queue AND field = 'duration';\n v_duration := COALESCE(p_limiter_duration, v_duration);\n IF v_duration IS NOT NULL THEN\n PERFORM rate_limit_consume(p_queue, v_duration, p_now);\n END IF;\n END IF;\n\n UPDATE job\n SET state = 'active',\n lock_token = p_token,\n locked_until_ms = p_now + p_lock_ms,\n processed_at_ms = p_now,\n processed_by = p_name,\n attempts_started = attempts_started + 1\n WHERE queue = p_queue AND id = v_id\n RETURNING * INTO v_job;\n\n PERFORM publish_event(p_queue, 'active',\n jsonb_build_object('jobId', v_id, 'prev', 'waiting'));\n\n RETURN NEXT v_job;\nEND;\n$$;\n\n-- ──────────────────────────────────────────────────────────────────────────\n-- next_delay: the timestamp of the next delayed job (for worker block timing),\n-- or NULL when there are none.\n-- ──────────────────────────────────────────────────────────────────────────\nCREATE FUNCTION next_delay(p_queue text) RETURNS bigint\nLANGUAGE sql\nSET search_path FROM CURRENT\nAS $$\n SELECT MIN(process_at_ms)\n FROM job\n WHERE queue = p_queue AND state = 'delayed';\n$$;\n\nCREATE OR REPLACE FUNCTION move_to_completed(\n p_queue text,\n p_id text,\n p_token text,\n p_return_value jsonb,\n p_finished_on bigint,\n p_remove_all boolean,\n p_keep_age bigint,\n p_keep_count integer\n) RETURNS bigint\nLANGUAGE plpgsql\nSET search_path FROM CURRENT\nAS $$\nDECLARE\n v_state job_state;\n v_lock text;\n v_pq text;\n v_pid text;\n v_dedup_id text;\n v_remaining integer;\nBEGIN\n -- If this job is a flow child, its parent may be removed concurrently\n -- (`removeOnFail`/`removeOnComplete`), which — via the `ON DELETE CASCADE`\n -- FKs — deletes this job's row and its dependency row while we lock those same\n -- rows below (in the opposite order), deadlocking (SQLSTATE 40P01). Read the\n -- parent first (unlocked, just for the key) and take the per-parent advisory\n -- lock BEFORE any row lock, so a concurrent parent removal and this finish op\n -- serialize. Mirrors Redis's single-threaded atomicity; auto-releases at\n -- commit and is only ever taken on the direct parent (never nested).\n SELECT parent_queue, parent_id INTO v_pq, v_pid\n FROM job WHERE queue = p_queue AND id = p_id;\n IF v_pq IS NOT NULL AND v_pid IS NOT NULL THEN\n PERFORM pg_advisory_xact_lock(\n hashtext('bullmq:parent:' || v_pq || ':' || v_pid));\n END IF;\n\n SELECT state, lock_token, parent_queue, parent_id, dedup_id\n INTO v_state, v_lock, v_pq, v_pid, v_dedup_id\n FROM job WHERE queue = p_queue AND id = p_id FOR UPDATE;\n IF NOT FOUND THEN\n RAISE EXCEPTION 'bullmq: missing job %', p_id USING ERRCODE = 'BM001', DETAIL = '-1';\n END IF;\n IF v_state <> 'active' THEN\n RAISE EXCEPTION 'bullmq: job % not active', p_id USING ERRCODE = 'BM001', DETAIL = '-3';\n END IF;\n IF p_token <> '0' AND v_lock IS DISTINCT FROM p_token THEN\n RAISE EXCEPTION 'bullmq: job % lock mismatch', p_id USING ERRCODE = 'BM001', DETAIL = '-6';\n END IF;\n\n -- A parent cannot complete while it still has unresolved children: pending\n -- children → -4, failed children → -9 (mirrors moveToFinished-14.lua, which\n -- only enforces this on the \"completed\" path).\n IF EXISTS (\n SELECT 1 FROM job_dependency\n WHERE parent_queue = p_queue AND parent_id = p_id AND status = 'pending'\n ) THEN\n RAISE EXCEPTION 'bullmq: job % has pending dependencies', p_id\n USING ERRCODE = 'BM001', DETAIL = '-4';\n END IF;\n IF EXISTS (\n SELECT 1 FROM job_dependency\n WHERE parent_queue = p_queue AND parent_id = p_id AND status = 'failed'\n ) THEN\n RAISE EXCEPTION 'bullmq: job % has failed dependencies', p_id\n USING ERRCODE = 'BM001', DETAIL = '-9';\n END IF;\n\n UPDATE job\n SET state = 'completed',\n return_value = p_return_value,\n finished_at_ms = p_finished_on,\n lock_token = NULL,\n locked_until_ms = NULL,\n attempts_made = attempts_made + 1\n WHERE queue = p_queue AND id = p_id;\n\n PERFORM publish_event(p_queue, 'completed',\n jsonb_build_object('jobId', p_id, 'returnvalue',\n COALESCE(p_return_value, 'null'::jsonb)::text, 'prev', 'active'));\n\n -- Release the parent: mark this child's dependency processed and, if the\n -- parent has no pending deps left and is waiting on children, requeue it.\n -- The per-parent advisory lock taken at the top of this function serializes\n -- these mutations with any sibling finish op and with a concurrent parent\n -- removal, so the decrement cannot deadlock on the parent row.\n IF v_pid IS NOT NULL AND v_pq IS NOT NULL THEN\n UPDATE job_dependency\n SET status = 'processed', value = p_return_value\n WHERE parent_queue = v_pq AND parent_id = v_pid\n AND child_key = p_queue || ':' || p_id;\n\n UPDATE job\n SET pending_deps = GREATEST(pending_deps - 1, 0)\n WHERE queue = v_pq AND id = v_pid\n RETURNING pending_deps INTO v_remaining;\n\n IF v_remaining = 0 THEN\n -- Release the parent (delay-aware: a parent carrying a delay goes to the\n -- delayed set, a prioritized parent keeps its priority).\n PERFORM move_parent_to_wait(v_pq, v_pid, p_finished_on);\n END IF;\n END IF;\n\n PERFORM apply_retention(\n p_queue, p_id, 'completed', p_finished_on, p_remove_all, p_keep_age, p_keep_count\n );\n\n -- Serialize dedup key mutations with concurrent adds for the same id (taken\n -- before requeue's id-counter INCR, matching add_job's advisory→meta order).\n IF v_dedup_id IS NOT NULL THEN\n PERFORM pg_advisory_xact_lock(\n hashtext('bullmq:dedup:' || p_queue || ':' || v_dedup_id));\n END IF;\n -- Clear a no-ttl deduplication key now that its winner has finished.\n PERFORM dedup_finalize(p_queue, v_dedup_id, p_id, p_finished_on);\n -- keepLastIfActive: turn any stashed proto-next into the new winner job.\n PERFORM requeue_dedup_next(p_queue, v_dedup_id, p_finished_on);\n\n -- run, announce the queue is drained. Redis checks the physical wait + active\n -- + prioritized lists; when paused, jobs live in the paused list, so the wait\n -- list is empty and 'drained' still fires. Here that is: no active jobs and\n -- (the queue is paused OR there are no waiting/prioritized jobs).\n IF NOT EXISTS (\n SELECT 1 FROM job WHERE queue = p_queue AND state = 'active'\n )\n AND (\n EXISTS (\n SELECT 1 FROM meta\n WHERE queue = p_queue AND field = 'paused' AND value = '1'\n )\n OR NOT EXISTS (\n SELECT 1 FROM job WHERE queue = p_queue AND state = 'waiting'\n )\n ) THEN\n PERFORM publish_event(p_queue, 'drained', '{}'::jsonb);\n END IF;\n\n RETURN p_finished_on;\nEND;\n$$;\n\n-- BullMQ PostgreSQL backend — retries-exhausted event (schema version 17).\n--\n-- A permanent failure (no retries left) emits a 'retries-exhausted' event in\n-- addition to 'failed'. Recreate move_to_failed (from 0010) to publish it.\nCREATE OR REPLACE FUNCTION move_to_failed(\n p_queue text,\n p_id text,\n p_token text,\n p_failed_reason text,\n p_stacktrace jsonb,\n p_finished_on bigint,\n p_remove_all boolean,\n p_keep_age bigint,\n p_keep_count integer\n) RETURNS bigint\nLANGUAGE plpgsql\nSET search_path FROM CURRENT\nAS $$\nDECLARE\n v_state job_state;\n v_lock text;\n v_dedup_id text;\n v_attempts integer;\nBEGIN\n -- If this job is itself a flow parent, `removeOnFail` (apply_retention\n -- below) DELETEs it and, via the `job_dependency … ON DELETE CASCADE`\n -- FK, its child-dependency rows. A child finishing concurrently locks that\n -- same dependency row and then this job's row — the opposite order — which\n -- deadlocks (SQLSTATE 40P01). Take the per-parent advisory lock (keyed on this\n -- job, the same key a child's finish op uses for its parent) BEFORE the row\n -- lock below, so the two serialize instead of racing. Mirrors Redis's\n -- single-threaded atomicity; auto-releases at commit.\n PERFORM pg_advisory_xact_lock(\n hashtext('bullmq:parent:' || p_queue || ':' || p_id));\n\n SELECT state, lock_token, dedup_id INTO v_state, v_lock, v_dedup_id\n FROM job WHERE queue = p_queue AND id = p_id FOR UPDATE;\n IF NOT FOUND THEN\n RAISE EXCEPTION 'bullmq: missing job %', p_id USING ERRCODE = 'BM001', DETAIL = '-1';\n END IF;\n IF v_state <> 'active' THEN\n RAISE EXCEPTION 'bullmq: job % not active', p_id USING ERRCODE = 'BM001', DETAIL = '-3';\n END IF;\n IF p_token <> '0' AND v_lock IS DISTINCT FROM p_token THEN\n RAISE EXCEPTION 'bullmq: job % lock mismatch', p_id USING ERRCODE = 'BM001', DETAIL = '-6';\n END IF;\n\n UPDATE job\n SET state = 'failed',\n failed_reason = p_failed_reason,\n stacktrace = COALESCE(p_stacktrace, stacktrace),\n finished_at_ms = p_finished_on,\n lock_token = NULL,\n locked_until_ms = NULL,\n deferred_failure = NULL,\n attempts_made = attempts_made + 1\n WHERE queue = p_queue AND id = p_id\n RETURNING attempts_made INTO v_attempts;\n\n PERFORM publish_event(p_queue, 'failed',\n jsonb_build_object('jobId', p_id, 'failedReason', p_failed_reason, 'prev', 'active'));\n\n -- A final failure (reached this function rather than retry/delay) exhausts\n -- the job's attempts.\n PERFORM publish_event(p_queue, 'retries-exhausted',\n jsonb_build_object('jobId', p_id, 'attemptsMade', v_attempts));\n\n -- Propagate the permanent failure to a parent flow job (fpof/cpof/idof/rdof).\n PERFORM handle_child_failure(p_queue, p_id, p_failed_reason, p_finished_on);\n\n PERFORM apply_retention(\n p_queue, p_id, 'failed', p_finished_on, p_remove_all, p_keep_age, p_keep_count\n );\n\n -- Serialize dedup key mutations with concurrent adds for the same id (taken\n -- before requeue's id-counter INCR, matching add_job's advisory→meta order).\n IF v_dedup_id IS NOT NULL THEN\n PERFORM pg_advisory_xact_lock(\n hashtext('bullmq:dedup:' || p_queue || ':' || v_dedup_id));\n END IF;\n -- Clear a no-ttl deduplication key now that its winner has finished.\n PERFORM dedup_finalize(p_queue, v_dedup_id, p_id, p_finished_on);\n -- keepLastIfActive: turn any stashed proto-next into the new winner job.\n PERFORM requeue_dedup_next(p_queue, v_dedup_id, p_finished_on);\n\n -- finishes and nothing is left to run (no active and either paused or no\n -- waiting/prioritized jobs — see move_to_completed).\n IF NOT EXISTS (\n SELECT 1 FROM job WHERE queue = p_queue AND state = 'active'\n )\n AND (\n EXISTS (\n SELECT 1 FROM meta\n WHERE queue = p_queue AND field = 'paused' AND value = '1'\n )\n OR NOT EXISTS (\n SELECT 1 FROM job WHERE queue = p_queue AND state = 'waiting'\n )\n ) THEN\n PERFORM publish_event(p_queue, 'drained', '{}'::jsonb);\n END IF;\n\n RETURN p_finished_on;\nEND;\n$$;\n\n-- ──────────────────────────────────────────────────────────────────────────\n-- extend_lock: refresh an active job's lock if the token still holds it.\n-- Returns 1 on success, 0 if the lock was lost.\n-- ──────────────────────────────────────────────────────────────────────────\nCREATE FUNCTION extend_lock(\n p_queue text,\n p_id text,\n p_token text,\n p_lock_ms bigint,\n p_now bigint\n) RETURNS integer\nLANGUAGE plpgsql\nSET search_path FROM CURRENT\nAS $$\nDECLARE\n v_updated integer;\nBEGIN\n UPDATE job\n SET locked_until_ms = p_now + p_lock_ms\n WHERE queue = p_queue\n AND id = p_id\n AND state = 'active'\n AND lock_token = p_token;\n\n GET DIAGNOSTICS v_updated = ROW_COUNT;\n RETURN v_updated;\nEND;\n$$;\n\nCREATE OR REPLACE FUNCTION move_to_delayed(\n p_queue text,\n p_id text,\n p_token text,\n p_process_at bigint,\n p_delay bigint,\n p_skip_attempt boolean,\n p_failed_reason text,\n p_stacktrace jsonb\n) RETURNS integer\nLANGUAGE plpgsql\nSET search_path FROM CURRENT\nAS $$\nDECLARE\n v_state job_state;\n v_lock text;\nBEGIN\n SELECT state, lock_token INTO v_state, v_lock\n FROM job WHERE queue = p_queue AND id = p_id FOR UPDATE;\n IF NOT FOUND THEN\n RAISE EXCEPTION 'bullmq: missing job %', p_id USING ERRCODE = 'BM001', DETAIL = '-1';\n END IF;\n IF v_state <> 'active' THEN\n RAISE EXCEPTION 'bullmq: job % not active', p_id USING ERRCODE = 'BM001', DETAIL = '-3';\n END IF;\n IF p_token <> '0' AND v_lock IS DISTINCT FROM p_token THEN\n RAISE EXCEPTION 'bullmq: job % lock mismatch', p_id USING ERRCODE = 'BM001', DETAIL = '-6';\n END IF;\n\n UPDATE job\n SET state = 'delayed',\n process_at_ms = p_process_at,\n delay_ms = p_delay,\n failed_reason = COALESCE(p_failed_reason, failed_reason),\n stacktrace = COALESCE(p_stacktrace, stacktrace),\n lock_token = NULL,\n locked_until_ms = NULL,\n attempts_made = attempts_made + (CASE WHEN p_skip_attempt THEN 0 ELSE 1 END)\n WHERE queue = p_queue AND id = p_id;\n\n -- Announce the delay on the event stream (mirrors moveToDelayed's XADD\n -- 'delayed'); QueueEvents consumers rely on this. `delay` carries the\n -- absolute timestamp the job becomes due, matching the Redis payload.\n PERFORM publish_event(p_queue, 'delayed',\n jsonb_build_object('jobId', p_id, 'delay', p_process_at));\n PERFORM pg_notify('bullmq_jobs', p_queue);\n RETURN 1;\nEND;\n$$;\n\n-- BullMQ PostgreSQL backend — retry waiting-event prev fix (schema version 18).\n--\n-- An immediately-retried job transitions active → waiting, so the 'waiting'\n-- event's `prev` is 'active' (not 'failed'). Recreate retry_job accordingly.\nCREATE OR REPLACE FUNCTION retry_job(\n p_queue text,\n p_id text,\n p_token text,\n p_lifo boolean,\n p_failed_reason text,\n p_stacktrace jsonb\n) RETURNS integer\nLANGUAGE plpgsql\nSET search_path FROM CURRENT\nAS $$\nDECLARE\n v_state job_state;\n v_lock text;\n v_seq bigint;\nBEGIN\n SELECT state, lock_token INTO v_state, v_lock\n FROM job WHERE queue = p_queue AND id = p_id FOR UPDATE;\n IF NOT FOUND THEN\n RAISE EXCEPTION 'bullmq: missing job %', p_id USING ERRCODE = 'BM001', DETAIL = '-1';\n END IF;\n IF v_state <> 'active' THEN\n RAISE EXCEPTION 'bullmq: job % not active', p_id USING ERRCODE = 'BM001', DETAIL = '-3';\n END IF;\n IF p_token <> '0' AND v_lock IS DISTINCT FROM p_token THEN\n RAISE EXCEPTION 'bullmq: job % lock mismatch', p_id USING ERRCODE = 'BM001', DETAIL = '-6';\n END IF;\n\n v_seq := nextval('job_seq');\n IF p_lifo THEN\n v_seq := -v_seq;\n END IF;\n\n UPDATE job\n SET state = 'waiting',\n seq = v_seq,\n process_at_ms = NULL,\n failed_reason = COALESCE(p_failed_reason, failed_reason),\n stacktrace = COALESCE(p_stacktrace, stacktrace),\n lock_token = NULL,\n locked_until_ms = NULL,\n attempts_made = attempts_made + 1\n WHERE queue = p_queue AND id = p_id;\n\n PERFORM pg_notify('bullmq_jobs', p_queue);\n PERFORM publish_event(p_queue, 'waiting',\n jsonb_build_object('jobId', p_id, 'prev', 'active'));\n RETURN 1;\nEND;\n$$;\n\n-- BullMQ PostgreSQL backend — retention age boundary fix (schema version 14).\n--\n-- Age-based retention keeps jobs finished *within* the last `keep_age` seconds:\n-- a job exactly on the boundary (finished_at = now - age*1000) is removed, so\n-- the comparison is `<=`, not `<` (matches Redis: keep jobs with score above\n-- the cutoff). Otherwise an N-second window keeps N+1 jobs.\nCREATE OR REPLACE FUNCTION apply_retention(\n p_queue text,\n p_id text,\n p_state job_state,\n p_now bigint,\n p_remove_all boolean,\n p_keep_age bigint,\n p_keep_count integer\n) RETURNS void\nLANGUAGE plpgsql\nSET search_path FROM CURRENT\nAS $$\nDECLARE\n v_pq text;\n v_pid text;\n v_remaining integer;\nBEGIN\n IF p_remove_all THEN\n -- If this job is a still-pending child (e.g. a default-failed child with\n -- removeOnFail), detach it and release its parent when it was the last\n -- pending dependency (mirrors removeParentDependencyKey on removal). A\n -- processed/failed dependency is left untouched so the parent keeps it.\n SELECT parent_queue, parent_id INTO v_pq, v_pid\n FROM job WHERE queue = p_queue AND id = p_id;\n IF v_pq IS NOT NULL AND v_pid IS NOT NULL THEN\n DELETE FROM job_dependency\n WHERE parent_queue = v_pq AND parent_id = v_pid\n AND child_key = p_queue || ':' || p_id\n AND status = 'pending';\n IF FOUND THEN\n UPDATE job SET pending_deps = GREATEST(pending_deps - 1, 0)\n WHERE queue = v_pq AND id = v_pid\n RETURNING pending_deps INTO v_remaining;\n IF v_remaining = 0 THEN\n PERFORM move_parent_to_wait(v_pq, v_pid, p_now);\n END IF;\n END IF;\n END IF;\n\n DELETE FROM job WHERE queue = p_queue AND id = p_id;\n RETURN;\n END IF;\n\n IF p_keep_age IS NOT NULL AND p_keep_age >= 0 THEN\n DELETE FROM job\n WHERE queue = p_queue\n AND state = p_state\n AND finished_at_ms <= p_now - p_keep_age * 1000;\n END IF;\n\n -- A negative keep-count is the \"keep everything\" sentinel (mirrors Redis,\n -- where -1 disables count-based trimming); skip it — a negative SQL LIMIT is\n -- a hard error.\n IF p_keep_count IS NOT NULL AND p_keep_count >= 0 THEN\n DELETE FROM job\n WHERE queue = p_queue\n AND state = p_state\n AND id NOT IN (\n SELECT id FROM job\n WHERE queue = p_queue AND state = p_state\n ORDER BY finished_at_ms DESC, seq DESC\n LIMIT p_keep_count\n );\n END IF;\nEND;\n$$;\n\n-- BullMQ PostgreSQL backend — event stream (schema version 6).\n--\n-- QueueEvents consumes an append-only event stream. In Redis this is an XADD\n-- stream read with a blocking XREAD; here events live in `event` (a\n-- globally-ordered id per insert) and blocking reads use LISTEN/NOTIFY on the\n-- shared `bullmq_events` channel. This migration adds the publish helper and\n-- weaves event emission into the lifecycle operations.\n\n-- ──────────────────────────────────────────────────────────────────────────\n-- publish_event: append an event to the stream, notify consumers, and trim the\n-- stream to the queue's configured max length (`opts.maxLenEvents`, default\n-- 10000). Returns the new event id.\n-- ──────────────────────────────────────────────────────────────────────────\nCREATE FUNCTION publish_event(\n p_queue text,\n p_event text,\n p_data jsonb\n) RETURNS bigint\nLANGUAGE plpgsql\nSET search_path FROM CURRENT\nAS $$\nDECLARE\n v_id bigint;\n v_max integer;\nBEGIN\n INSERT INTO event (queue, event, data, created_at_ms)\n VALUES (\n p_queue, p_event, COALESCE(p_data, '{}'::jsonb),\n (extract(epoch FROM clock_timestamp()) * 1000)::bigint\n )\n RETURNING id INTO v_id;\n\n -- Wake event consumers — but coalesce the wakeup under concurrency. Postgres\n -- serializes *every* transaction that issues NOTIFY on a single database-wide\n -- lock at commit (to deliver notifications in commit order), so a NOTIFY on\n -- each of N concurrent workers' commits forces them single-file through that\n -- lock — the dominant processing bottleneck under load (and it caps throughput\n -- across *all* queues and processes, since the lock is global).\n --\n -- A per-queue, transaction-scoped try-lock collapses that: only one committer\n -- per queue actually issues the NOTIFY at a time; concurrent committers skip\n -- it. Skipping is safe because a consumer reads *all* pending events by cursor\n -- on any wake (see readEvents/waitForEvent), and waitForEvent also polls on a\n -- timeout — so a skipped wakeup only defers to the next committer's NOTIFY,\n -- and as concurrency winds down the final committer is uncontended and always\n -- fires. Under low concurrency (e.g. tests) the lock is always free, so every\n -- event notifies exactly as before.\n IF pg_try_advisory_xact_lock(hashtext('bullmq:evnotify:' || p_queue)) THEN\n PERFORM pg_notify('bullmq_events', p_queue);\n END IF;\n\n SELECT value::integer INTO v_max\n FROM meta\n WHERE queue = p_queue AND field = 'opts.maxLenEvents';\n IF v_max IS NULL THEN\n v_max := 10000;\n END IF;\n\n -- Trim to (approximately) the most recent `v_max` events. `event.id`\n -- comes from a monotonic sequence, so the cutoff is simply `id - v_max` — an\n -- O(number-of-deleted-rows) DELETE.\n --\n -- Trimming is *chunked*, not per-publish: only every 256th event even attempts\n -- it. Redis's `XADD MAXLEN ~` likewise trims a whole macro-node at a time\n -- rather than on every add — trimming eagerly turns a bulk/concurrent insert\n -- into a stream of DELETEs that contend (and previously deadlocked) with the\n -- concurrent INSERTs on `event`. At 1/256 the table stays within\n -- `v_max + 256`, which is well inside the \"approximate\" contract. A\n -- non-blocking, per-queue advisory lock further ensures at most one trimmer\n -- per queue at a time; publishers that miss it simply skip (the next one\n -- covers the same rows).\n IF v_max > 0 AND v_id > v_max AND (v_id % 256) = 0 THEN\n IF pg_try_advisory_xact_lock(hashtext('bullmq:evtrim:' || p_queue)) THEN\n DELETE FROM event\n WHERE queue = p_queue AND id <= v_id - v_max;\n END IF;\n END IF;\n\n RETURN v_id;\nEND;\n$$;\n\nCREATE FUNCTION update_progress(\n p_queue text,\n p_id text,\n p_progress jsonb\n) RETURNS integer\nLANGUAGE plpgsql\nSET search_path FROM CURRENT\nAS $$\nDECLARE\n v_updated integer;\nBEGIN\n UPDATE job SET progress = p_progress\n WHERE queue = p_queue AND id = p_id;\n GET DIAGNOSTICS v_updated = ROW_COUNT;\n\n IF v_updated > 0 THEN\n PERFORM publish_event(p_queue, 'progress',\n jsonb_build_object('jobId', p_id, 'data', p_progress::text));\n END IF;\n\n RETURN v_updated;\nEND;\n$$;\n\n-- BullMQ PostgreSQL backend — admin operations & getters (schema version 7).\n--\n-- pause/resume, drain, remove, bulk retry/promote, and the job-id range getter\n-- used by getJobs. move_to_active is recreated to honor the paused flag.\n\n-- ──────────────────────────────────────────────────────────────────────────\n-- pause: set/clear the queue's paused flag (an O(1) meta flag — no bulk move).\n-- ──────────────────────────────────────────────────────────────────────────\nCREATE FUNCTION pause(p_queue text, p_paused boolean) RETURNS void\nLANGUAGE plpgsql\nSET search_path FROM CURRENT\nAS $$\nBEGIN\n IF p_paused THEN\n INSERT INTO meta (queue, field, value)\n VALUES (p_queue, 'paused', '1')\n ON CONFLICT (queue, field) DO UPDATE SET value = '1';\n ELSE\n -- Resume mirrors Redis `HDEL meta paused`: the field is removed entirely,\n -- so `isPaused` (hasQueueMetaField 'paused') reports false rather than\n -- finding a lingering '0'.\n DELETE FROM meta WHERE queue = p_queue AND field = 'paused';\n\n -- Wake any worker blocked in waitForJob (LISTEN bullmq_jobs): jobs that were\n -- unclaimable only because the queue was paused are now claimable, but no\n -- `add` (which would notify) is involved. Without this the worker sleeps\n -- until its drainDelay times out. The Redis backend wakes the blocking\n -- fetch via a marker on resume; this is the LISTEN/NOTIFY analogue.\n PERFORM pg_notify('bullmq_jobs', p_queue);\n END IF;\n\n PERFORM publish_event(\n p_queue, CASE WHEN p_paused THEN 'paused' ELSE 'resumed' END, '{}'::jsonb\n );\nEND;\n$$;\n\n-- drain: never remove scheduler jobs.\nCREATE OR REPLACE FUNCTION drain(p_queue text, p_delayed boolean)\nRETURNS void\nLANGUAGE plpgsql\nSET search_path FROM CURRENT\nAS $$\nDECLARE\n v_pq text[];\n v_pid text[];\n i integer;\nBEGIN\n SELECT array_agg(parent_queue), array_agg(parent_id)\n INTO v_pq, v_pid\n FROM (\n SELECT DISTINCT parent_queue, parent_id\n FROM job\n WHERE queue = p_queue\n AND parent_id IS NOT NULL\n AND scheduler_id IS NULL\n AND (state = 'waiting' OR (p_delayed AND state = 'delayed'))\n ) s;\n\n DELETE FROM job_dependency d\n USING job j\n WHERE j.queue = p_queue\n AND (j.state = 'waiting' OR (p_delayed AND j.state = 'delayed'))\n AND j.parent_id IS NOT NULL\n AND j.scheduler_id IS NULL\n AND d.parent_queue = j.parent_queue\n AND d.parent_id = j.parent_id\n AND d.child_queue = j.queue\n AND d.child_id = j.id;\n\n DELETE FROM job\n WHERE queue = p_queue\n AND scheduler_id IS NULL\n AND (state = 'waiting' OR (p_delayed AND state = 'delayed'));\n\n IF v_pq IS NOT NULL THEN\n FOR i IN 1 .. array_length(v_pq, 1) LOOP\n UPDATE job p\n SET pending_deps = (\n SELECT count(*) FROM job_dependency d\n WHERE d.parent_queue = v_pq[i]\n AND d.parent_id = v_pid[i]\n AND d.status = 'pending'\n )\n WHERE p.queue = v_pq[i] AND p.id = v_pid[i];\n\n PERFORM 1 FROM job p\n WHERE p.queue = v_pq[i] AND p.id = v_pid[i]\n AND p.state = 'waiting-children'\n AND p.pending_deps = 0;\n\n IF FOUND THEN\n IF v_pq[i] = p_queue THEN\n DELETE FROM job WHERE queue = v_pq[i] AND id = v_pid[i];\n ELSE\n UPDATE job\n SET state = 'waiting', seq = nextval('job_seq')\n WHERE queue = v_pq[i] AND id = v_pid[i];\n PERFORM pg_notify('bullmq_jobs', v_pq[i]);\n PERFORM publish_event(v_pq[i], 'waiting',\n jsonb_build_object('jobId', v_pid[i], 'prev', 'waiting-children'));\n END IF;\n END IF;\n END LOOP;\n END IF;\nEND;\n$$;\n\n-- BullMQ PostgreSQL backend — job-scheduler protection (schema v22).\n--\n-- Jobs produced by a job scheduler (those with a non-NULL `scheduler_id`) must\n-- not be removed directly, nor swept away by `drain`/`clean`; only the\n-- scheduler itself (or its removal) may delete them. This mirrors the Redis\n-- behaviour where `removeJob` refuses scheduler jobs and `drain`/`clean` skip\n-- them.\n\n-- remove: remove a job (and, when requested, its whole flow subtree). Mirrors\n-- removeJob-2.lua + removeJobWithChildren:\n-- * refuses scheduler jobs (ErrorCode -8);\n-- * returns 0 (removes nothing) when the job — or, with removeChildren, any of\n-- its still-pending descendants — is locked by a worker; the caller turns\n-- this into the \"locked by another worker\" error;\n-- * detaches the removed root from its parent's dependency set and promotes\n-- the parent to wait when that clears its last pending dependency;\n-- * recursively deletes the subtree (children linked via parent_queue/\n-- parent_id) and clears each removed job's deduplication key.\nCREATE OR REPLACE FUNCTION remove(\n p_queue text, p_id text, p_remove_children boolean\n) RETURNS integer\nLANGUAGE plpgsql\nSET search_path FROM CURRENT\nAS $$\nDECLARE\n v_found boolean := false;\n v_scheduler text;\n v_pq text;\n v_pid text;\n v_locked boolean;\n v_remaining integer;\n v_deleted integer := 0;\n v_now bigint := (extract(epoch from clock_timestamp()) * 1000)::bigint;\n r RECORD;\nBEGIN\n SELECT true, scheduler_id, parent_queue, parent_id\n INTO v_found, v_scheduler, v_pq, v_pid\n FROM job WHERE queue = p_queue AND id = p_id;\n\n IF NOT COALESCE(v_found, false) THEN\n RETURN 0;\n END IF;\n\n IF v_scheduler IS NOT NULL THEN\n RAISE EXCEPTION 'job belongs to a scheduler'\n USING ERRCODE = 'BM001', DETAIL = '-8';\n END IF;\n\n -- Lock check. A job cannot be removed while it is locked; with removeChildren\n -- the check recurses through still-pending descendants (mirrors isLocked()).\n IF p_remove_children THEN\n WITH RECURSIVE subtree AS (\n SELECT p_queue AS q, p_id AS id\n UNION\n SELECT d.child_queue, d.child_id\n FROM subtree s\n JOIN job_dependency d\n ON d.parent_queue = s.q AND d.parent_id = s.id\n AND d.status = 'pending'\n )\n SELECT EXISTS (\n SELECT 1 FROM subtree s\n JOIN job j ON j.queue = s.q AND j.id = s.id\n WHERE j.lock_token IS NOT NULL AND j.locked_until_ms > v_now\n ) INTO v_locked;\n ELSE\n SELECT lock_token IS NOT NULL AND locked_until_ms > v_now INTO v_locked\n FROM job WHERE queue = p_queue AND id = p_id;\n END IF;\n\n IF v_locked THEN\n RETURN 0;\n END IF;\n\n -- Detach the removed root from its parent's pending dependency set; if that\n -- clears the parent's last pending dependency, promote it to wait. Only\n -- pending links matter (a processed/failed child was already accounted for).\n IF v_pq IS NOT NULL AND v_pid IS NOT NULL THEN\n DELETE FROM job_dependency\n WHERE parent_queue = v_pq AND parent_id = v_pid\n AND child_key = p_queue || ':' || p_id\n AND status = 'pending';\n IF FOUND THEN\n UPDATE job SET pending_deps = GREATEST(pending_deps - 1, 0)\n WHERE queue = v_pq AND id = v_pid\n RETURNING pending_deps INTO v_remaining;\n IF v_remaining = 0 THEN\n PERFORM move_parent_to_wait(v_pq, v_pid, v_now);\n END IF;\n END IF;\n END IF;\n\n IF p_remove_children THEN\n -- Delete the whole subtree (root + descendants via parent_queue/parent_id).\n FOR r IN\n WITH RECURSIVE subtree AS (\n SELECT p_queue AS q, p_id AS id, dedup_id AS dd\n FROM job WHERE queue = p_queue AND id = p_id\n UNION\n SELECT j.queue, j.id, j.dedup_id\n FROM subtree s\n JOIN job j ON j.parent_queue = s.q AND j.parent_id = s.id\n )\n SELECT q, id, dd FROM subtree\n LOOP\n PERFORM dedup_on_removal(r.q, r.id, r.dd);\n DELETE FROM job WHERE queue = r.q AND id = r.id;\n v_deleted := v_deleted + 1;\n END LOOP;\n ELSE\n PERFORM dedup_on_removal(\n p_queue, p_id,\n (SELECT dedup_id FROM job WHERE queue = p_queue AND id = p_id));\n DELETE FROM job WHERE queue = p_queue AND id = p_id;\n GET DIAGNOSTICS v_deleted = ROW_COUNT;\n END IF;\n\n RETURN v_deleted;\nEND;\n$$;\n\n-- ──────────────────────────────────────────────────────────────────────────\n-- retry_jobs: move up to `count` finished jobs of a state back to waiting.\n-- Returns the number moved (the caller loops until it returns 0).\n-- ──────────────────────────────────────────────────────────────────────────\nCREATE FUNCTION retry_jobs(\n p_queue text, p_state text, p_count integer, p_timestamp bigint\n) RETURNS integer\nLANGUAGE plpgsql\nSET search_path FROM CURRENT\nAS $$\nDECLARE\n v_moved integer;\nBEGIN\n WITH batch AS (\n SELECT id FROM job\n WHERE queue = p_queue\n AND state = p_state::job_state\n AND (p_timestamp IS NULL OR finished_at_ms <= p_timestamp)\n ORDER BY finished_at_ms\n LIMIT p_count\n )\n UPDATE job j\n SET state = 'waiting',\n seq = nextval('job_seq'),\n finished_at_ms = NULL,\n processed_at_ms = NULL,\n return_value = NULL,\n failed_reason = NULL,\n stacktrace = NULL,\n attempts_made = 0,\n attempts_started = 0,\n lock_token = NULL,\n locked_until_ms = NULL\n FROM batch\n WHERE j.queue = p_queue AND j.id = batch.id;\n\n GET DIAGNOSTICS v_moved = ROW_COUNT;\n IF v_moved > 0 THEN\n PERFORM pg_notify('bullmq_jobs', p_queue);\n END IF;\n RETURN v_moved;\nEND;\n$$;\n\n-- ──────────────────────────────────────────────────────────────────────────\n-- promote_jobs: move up to `count` delayed jobs to waiting (process now).\n-- Returns the number moved (the caller loops until it returns 0).\n-- ──────────────────────────────────────────────────────────────────────────\nCREATE FUNCTION promote_jobs(p_queue text, p_count integer)\nRETURNS integer\nLANGUAGE plpgsql\nSET search_path FROM CURRENT\nAS $$\nDECLARE\n v_moved integer;\nBEGIN\n WITH batch AS (\n SELECT id FROM job\n WHERE queue = p_queue AND state = 'delayed'\n ORDER BY process_at_ms\n LIMIT p_count\n )\n UPDATE job j\n SET state = 'waiting', process_at_ms = NULL, seq = nextval('job_seq')\n FROM batch\n WHERE j.queue = p_queue AND j.id = batch.id;\n\n GET DIAGNOSTICS v_moved = ROW_COUNT;\n IF v_moved > 0 THEN\n PERFORM pg_notify('bullmq_jobs', p_queue);\n END IF;\n RETURN v_moved;\nEND;\n$$;\n\n-- ──────────────────────────────────────────────────────────────────────────\n-- get_range: the job ids in a given state, sliced by [start, end] (inclusive,\n-- zero-based). List-like states (wait/active/waiting-children) are returned in\n-- ascending seq order (the getter layer reverses them for `asc`); zset-like\n-- states honour the requested direction.\n-- ──────────────────────────────────────────────────────────────────────────\nCREATE FUNCTION get_range(\n p_queue text, p_type text, p_start integer, p_end integer, p_asc boolean\n) RETURNS SETOF text\nLANGUAGE plpgsql\nSET search_path FROM CURRENT\nAS $$\nDECLARE\n v_where text;\n v_order text;\n v_dir text := CASE WHEN p_asc THEN 'ASC' ELSE 'DESC' END;\n v_offset integer := GREATEST(COALESCE(p_start, 0), 0);\n v_limit text;\nBEGIN\n -- 'wait'/'active' are list-backed in Redis (LPUSH ⇒ newest at head), and the\n -- shared queue-getters layer reverses these `lrange` results when asc is\n -- requested. Returning newest-first (DESC) here makes that reversal yield the\n -- FIFO order callers expect (getWaiting/getActive), mirroring Redis LRANGE.\n IF p_type IN ('wait', 'waiting') THEN\n v_where := 'state = ''waiting'' AND priority = 0'; v_order := 'seq'; v_dir := 'DESC';\n ELSIF p_type = 'prioritized' THEN\n v_where := 'state = ''waiting'' AND priority > 0'; v_order := 'priority, seq';\n ELSIF p_type = 'active' THEN\n v_where := 'state = ''active'''; v_order := 'seq'; v_dir := 'DESC';\n ELSIF p_type = 'delayed' THEN\n v_where := 'state = ''delayed'''; v_order := 'process_at_ms';\n ELSIF p_type IN ('completed', 'failed') THEN\n v_where := format('state = %L', p_type); v_order := 'finished_at_ms';\n ELSIF p_type = 'waiting-children' THEN\n v_where := 'state = ''waiting-children'''; v_order := 'seq'; v_dir := 'ASC';\n ELSE\n RETURN; -- paused/repeat/unknown\n END IF;\n\n IF p_end IS NULL OR p_end < 0 THEN\n v_limit := 'ALL';\n ELSE\n IF (p_end - v_offset + 1) <= 0 THEN\n RETURN;\n END IF;\n v_limit := (p_end - v_offset + 1)::text;\n END IF;\n\n RETURN QUERY EXECUTE format(\n 'SELECT id FROM job WHERE queue = %L AND %s ORDER BY %s %s OFFSET %s LIMIT %s',\n p_queue, v_where, v_order, v_dir, v_offset, v_limit\n );\nEND;\n$$;\n\n-- BullMQ PostgreSQL backend — flows (schema version 8).\n--\n-- `add_flow` atomically inserts a whole tree of jobs (possibly spanning\n-- multiple queues) from a single JSONB array of entries, ordered roots-first so\n-- a parent always exists before its children register a dependency. `drain` is\n-- recreated to be flow-aware: when draining a queue's children resolves a\n-- parent's last pending dependency, the parent is removed (same queue) or moved\n-- to wait (different queue), mirroring the Redis `removeParentDependencyKey`.\n\n-- ──────────────────────────────────────────────────────────────────────────\n-- add_flow: insert an ordered (roots-first) array of flow entries atomically.\n-- Returns the resulting job ids in the same order as the input entries.\n--\n-- Performance: the per-row loop below is what makes flows (parents, dependency\n-- links, deduplication, re-parenting) correct, and it already batches the two\n-- lifecycle events every insert emits into a single set-based INSERT after the\n-- loop (a ~1000-job addBulk drops from ~3000 statement executions to ~1000 job\n-- inserts + 1 event insert). That closed most of the gap to Redis on concurrent\n-- bulk. Two further, deliberately-deferred iterations could push it further:\n--\n-- 1. A set-based *job* insert for the common flat case (no parent, no dedup):\n-- one `INSERT … SELECT FROM jsonb_array_elements(...)` with per-row\n-- `nextval` for the id/seq, collapsing the remaining ~1000 job inserts to\n-- one statement. It requires a second code path (gated + falling back to\n-- this loop) and careful handling of the return-order contract and\n-- `ON CONFLICT` on explicit ids — hence deferred until profiling shows the\n-- job inserts (not the event stream) dominate.\n-- 2. An opt-out of the durable event stream (à la Oban's telemetry-only\n-- model) for callers that do not use QueueEvents, removing the event\n-- insert entirely.\n-- ──────────────────────────────────────────────────────────────────────────\nCREATE FUNCTION add_flow(p_entries jsonb) RETURNS SETOF text\nLANGUAGE plpgsql\nSET search_path FROM CURRENT\nAS $$\nDECLARE\n e jsonb;\n v_id text;\n v_seq bigint;\n v_state job_state;\n v_process_at bigint;\n v_queue text;\n v_delay bigint;\n v_timestamp bigint;\n v_lifo boolean;\n v_parent_queue text;\n v_parent_id text;\n v_awc boolean;\n v_dedup_id text;\n v_dedup jsonb;\n v_winner text;\n v_inserted boolean;\n v_code integer;\n -- Lifecycle-event accumulation. Rather than PERFORM publish_event per\n -- job (which turns a 1000-job addBulk into ~2000 extra statement executions —\n -- the dominant cost of a single-connection bulk add), events are collected\n -- here and flushed in ONE set-based INSERT after the loop. Appending to a\n -- plpgsql-local array is amortized O(1) (read-write \"expanded array\"), so this\n -- stays O(n).\n v_ev_queue text[] := '{}';\n v_ev_event text[] := '{}';\n v_ev_data jsonb[] := '{}';\n v_notify_jobs text[] := '{}';\n v_max integer;\nBEGIN\n FOR e IN SELECT value FROM jsonb_array_elements(p_entries)\n LOOP\n v_queue := e ->> 'queue';\n v_id := COALESCE(e ->> 'id', '');\n v_delay := COALESCE((e ->> 'delay')::bigint, 0);\n v_timestamp := COALESCE((e ->> 'timestamp')::bigint, 0);\n v_lifo := COALESCE((e ->> 'lifo')::boolean, false);\n v_parent_queue := e ->> 'parentQueue';\n v_parent_id := e ->> 'parentId';\n v_awc := COALESCE((e ->> 'addToWaitingChildren')::boolean, false);\n v_dedup_id := e ->> 'dedupId';\n\n -- Serialize dedup operations for this id (matches add_job and the finish\n -- paths: advisory lock taken before the id-counter INCR below).\n IF v_dedup_id IS NOT NULL AND v_dedup_id <> '' THEN\n PERFORM pg_advisory_xact_lock(\n hashtext('bullmq:dedup:' || v_queue || ':' || v_dedup_id));\n END IF;\n\n IF v_id IS NULL OR v_id = '' THEN\n v_id := next_job_id(v_queue);\n END IF;\n\n -- Root deduplication: if a live key already won, return its id and skip\n -- inserting this job (and, because it never exists, its descendants).\n IF v_dedup_id IS NOT NULL AND v_dedup_id <> '' THEN\n v_dedup := COALESCE(e -> 'opts' -> 'deduplication',\n e -> 'opts' -> 'debounce',\n jsonb_build_object('id', v_dedup_id));\n v_winner := deduplicate_job(v_queue, v_dedup, v_id, v_timestamp,\n e ->> 'name', COALESCE((e ->> 'data')::jsonb, '{}'::jsonb),\n COALESCE(e -> 'opts', '{}'::jsonb));\n IF v_winner IS NOT NULL THEN\n RETURN NEXT v_winner;\n CONTINUE;\n END IF;\n END IF;\n\n -- An entry whose parent does not exist (a child of a deduplicated root, or\n -- a genuinely missing parent) is not inserted; report -5 as addJob does,\n -- without aborting the rest of the flow.\n IF v_parent_id IS NOT NULL AND v_parent_queue IS NOT NULL\n AND NOT EXISTS (\n SELECT 1 FROM job\n WHERE queue = v_parent_queue AND id = v_parent_id\n ) THEN\n RETURN NEXT '-5';\n CONTINUE;\n END IF;\n\n v_seq := nextval('job_seq');\n IF v_lifo THEN\n v_seq := -v_seq;\n END IF;\n\n IF v_awc THEN\n v_state := 'waiting-children';\n v_process_at := NULL;\n ELSIF v_delay > 0 THEN\n v_state := 'delayed';\n v_process_at := v_timestamp + v_delay;\n ELSE\n v_state := 'waiting';\n v_process_at := NULL;\n END IF;\n\n INSERT INTO job (\n queue, id, seq, name, state,\n data, opts, priority, delay_ms, max_attempts,\n added_at_ms, process_at_ms,\n dedup_id, scheduler_id,\n parent_queue, parent_id, parent_key, pending_deps\n ) VALUES (\n v_queue, v_id, v_seq, e ->> 'name', v_state,\n COALESCE((e ->> 'data')::jsonb, '{}'::jsonb),\n COALESCE(e -> 'opts', '{}'::jsonb),\n COALESCE((e ->> 'priority')::integer, 0), v_delay,\n COALESCE((e ->> 'attempts')::integer, 1),\n v_timestamp, v_process_at,\n e ->> 'dedupId', e ->> 'schedulerId',\n v_parent_queue, v_parent_id, e ->> 'parentKey', 0\n )\n ON CONFLICT (queue, id) DO NOTHING;\n GET DIAGNOSTICS v_inserted = ROW_COUNT;\n\n IF v_parent_id IS NOT NULL AND v_parent_queue IS NOT NULL THEN\n IF v_inserted THEN\n -- New job: register a pending dependency on its parent.\n INSERT INTO job_dependency (\n parent_queue, parent_id, child_queue, child_id, child_key, status\n ) VALUES (\n v_parent_queue, v_parent_id, v_queue, v_id,\n v_queue || ':' || v_id, 'pending'\n )\n ON CONFLICT (parent_queue, parent_id, child_key) DO NOTHING;\n\n UPDATE job\n SET pending_deps = pending_deps + 1\n WHERE queue = v_parent_queue AND id = v_parent_id;\n ELSE\n -- The job already existed: re-attach it to this new parent (mirrors\n -- handleDuplicatedJob). -7 means it already has a different parent.\n v_code := handle_duplicated_job(v_queue, v_id,\n v_parent_queue, v_parent_id, e ->> 'parentKey', v_timestamp);\n IF v_code = -7 THEN\n RETURN NEXT '-7';\n CONTINUE;\n END IF;\n END IF;\n END IF;\n\n -- Accumulate lifecycle events for newly-inserted jobs; they are flushed in\n -- a single set-based INSERT after the loop. Accumulation order (per job:\n -- 'added' then the state event, in job order) is what the flush replays via\n -- WITH ORDINALITY, preserving the stream ordering the per-row publish gave.\n IF v_inserted THEN\n -- Every stored job announces itself (mirrors storeJob.lua's 'added').\n v_ev_queue := array_append(v_ev_queue, v_queue);\n v_ev_event := array_append(v_ev_event, 'added');\n v_ev_data := array_append(v_ev_data,\n jsonb_build_object('jobId', v_id, 'name', e ->> 'name'));\n IF v_state = 'waiting' THEN\n v_ev_queue := array_append(v_ev_queue, v_queue);\n v_ev_event := array_append(v_ev_event, 'waiting');\n v_ev_data := array_append(v_ev_data,\n jsonb_build_object('jobId', v_id));\n v_notify_jobs := array_append(v_notify_jobs, v_queue);\n ELSIF v_state = 'delayed' THEN\n v_ev_queue := array_append(v_ev_queue, v_queue);\n v_ev_event := array_append(v_ev_event, 'delayed');\n v_ev_data := array_append(v_ev_data,\n jsonb_build_object('jobId', v_id, 'delay', v_process_at));\n v_notify_jobs := array_append(v_notify_jobs, v_queue);\n ELSIF v_state = 'waiting-children' THEN\n v_ev_queue := array_append(v_ev_queue, v_queue);\n v_ev_event := array_append(v_ev_event, 'waiting-children');\n v_ev_data := array_append(v_ev_data,\n jsonb_build_object('jobId', v_id));\n END IF;\n END IF;\n\n RETURN NEXT v_id;\n END LOOP;\n\n -- ── Flush accumulated lifecycle events in one statement ──────────────────\n IF array_length(v_ev_queue, 1) > 0 THEN\n -- Single set-based append. The id DEFAULT nextval('event_seq') is\n -- assigned in ordinality order (the ORDER BY pins row-production order), so\n -- the stream keeps each job's 'added' → state-event ordering.\n INSERT INTO event (queue, event, data, created_at_ms)\n SELECT q, ev, dat, (extract(epoch FROM clock_timestamp()) * 1000)::bigint\n FROM unnest(v_ev_queue, v_ev_event, v_ev_data)\n WITH ORDINALITY AS t(q, ev, dat, ord)\n ORDER BY ord;\n\n -- One 'bullmq_events' wakeup per distinct queue, then trim that queue's\n -- stream once (not per publish). A non-blocking advisory lock keeps at most\n -- one trimmer per queue; publishers that miss it skip (the next flush covers\n -- the same rows). Matches publish_event's approximate-MAXLEN trim.\n FOR v_queue IN SELECT DISTINCT q FROM unnest(v_ev_queue) AS q LOOP\n PERFORM pg_notify('bullmq_events', v_queue);\n\n SELECT value::integer INTO v_max\n FROM meta\n WHERE queue = v_queue AND field = 'opts.maxLenEvents';\n IF v_max IS NULL THEN\n v_max := 10000;\n END IF;\n IF v_max > 0\n AND pg_try_advisory_xact_lock(hashtext('bullmq:evtrim:' || v_queue))\n THEN\n DELETE FROM event\n WHERE queue = v_queue\n AND id <= (SELECT max(id) FROM event WHERE queue = v_queue)\n - v_max;\n END IF;\n END LOOP;\n\n -- One 'bullmq_jobs' wakeup per distinct queue that gained a runnable job.\n FOR v_queue IN SELECT DISTINCT q FROM unnest(v_notify_jobs) AS q LOOP\n PERFORM pg_notify('bullmq_jobs', v_queue);\n END LOOP;\n END IF;\nEND;\n$$;\n\n-- BullMQ PostgreSQL backend — single-job mutations (schema version 9).\n--\n-- promote / changeDelay / changePriority validate the job's state and return a\n-- status code (0 = ok, or a negative `ErrorCode` the backend maps to the shared\n-- error message). Keeping the validation in SQL makes each mutation atomic.\n\n-- ──────────────────────────────────────────────────────────────────────────\n-- promote: a delayed job → waiting (process ASAP, keeping its priority).\n-- Returns 0 ok, -1 missing (JobNotExist), -3 not delayed (JobNotInState).\n-- ──────────────────────────────────────────────────────────────────────────\nCREATE FUNCTION promote(p_queue text, p_id text) RETURNS integer\nLANGUAGE plpgsql\nSET search_path FROM CURRENT\nAS $$\nDECLARE\n v_state job_state;\nBEGIN\n SELECT state INTO v_state FROM job WHERE queue = p_queue AND id = p_id;\n IF NOT FOUND THEN\n RETURN -1;\n END IF;\n IF v_state <> 'delayed' THEN\n RETURN -3;\n END IF;\n\n UPDATE job\n SET state = 'waiting',\n process_at_ms = NULL,\n delay_ms = 0,\n seq = nextval('job_seq')\n WHERE queue = p_queue AND id = p_id;\n\n PERFORM pg_notify('bullmq_jobs', p_queue);\n PERFORM publish_event(p_queue, 'waiting',\n jsonb_build_object('jobId', p_id, 'prev', 'delayed'));\n RETURN 0;\nEND;\n$$;\n\n-- ──────────────────────────────────────────────────────────────────────────\n-- change_delay: reschedule a delayed job to fire `p_delay` ms from now.\n-- Returns 0 ok, -1 missing, -3 not delayed.\n-- ──────────────────────────────────────────────────────────────────────────\nCREATE FUNCTION change_delay(\n p_queue text, p_id text, p_delay bigint, p_now bigint\n) RETURNS integer\nLANGUAGE plpgsql\nSET search_path FROM CURRENT\nAS $$\nDECLARE\n v_state job_state;\nBEGIN\n SELECT state INTO v_state FROM job WHERE queue = p_queue AND id = p_id;\n IF NOT FOUND THEN\n RETURN -1;\n END IF;\n IF v_state <> 'delayed' THEN\n RETURN -3;\n END IF;\n\n UPDATE job\n SET delay_ms = p_delay,\n process_at_ms = p_now + p_delay\n WHERE queue = p_queue AND id = p_id;\n\n -- Wake any worker blocked on an older (longer) delay so it recomputes.\n PERFORM pg_notify('bullmq_jobs', p_queue);\n RETURN 0;\nEND;\n$$;\n\n-- ──────────────────────────────────────────────────────────────────────────\n-- change_priority: set a job's priority (and reposition it via lifo) when it is\n-- waiting/prioritized; otherwise just record the new priority. Returns 0 ok,\n-- -1 missing.\n-- ──────────────────────────────────────────────────────────────────────────\nCREATE FUNCTION change_priority(\n p_queue text, p_id text, p_priority integer, p_lifo boolean\n) RETURNS integer\nLANGUAGE plpgsql\nSET search_path FROM CURRENT\nAS $$\nDECLARE\n v_state job_state;\nBEGIN\n SELECT state INTO v_state FROM job WHERE queue = p_queue AND id = p_id;\n IF NOT FOUND THEN\n RETURN -1;\n END IF;\n\n IF v_state = 'waiting' THEN\n -- Reposition: lifo → head (negative seq), otherwise → tail (new seq).\n UPDATE job\n SET priority = p_priority,\n seq = CASE WHEN p_lifo\n THEN -nextval('job_seq')\n ELSE nextval('job_seq') END\n WHERE queue = p_queue AND id = p_id;\n ELSE\n UPDATE job\n SET priority = p_priority\n WHERE queue = p_queue AND id = p_id;\n END IF;\n\n RETURN 0;\nEND;\n$$;\n\n-- BullMQ PostgreSQL backend — rate-limit-aware active→wait move (schema v26).\n--\n-- Recreates move_active_to_wait (used by the dynamic/manual rate limit\n-- and `Job.moveToWait`) to mirror moveJobFromActiveToWait-9.lua:\n-- * A missing job returns -1 (so the caller can raise the canonical\n-- \"Missing key for job …\" error).\n-- * A requeued job keeps FIFO order: priority > 0 jobs go to the *front* of\n-- their priority group (Redis `pushBackJobWithPriority`), priority 0 jobs go\n-- to the tail (Redis `RPUSH`). In the seq model: front = negative seq (sorts\n-- before the positive seqs of same-priority jobs), tail = `nextval`.\n-- * Returns the remaining limiter window in ms (Redis returns the limiter\n-- PTTL), which the worker uses to decide how long to back off.\nCREATE OR REPLACE FUNCTION move_active_to_wait(\n p_queue text, p_id text, p_token text, p_now bigint\n) RETURNS bigint\nLANGUAGE plpgsql\nSET search_path FROM CURRENT\nAS $$\nDECLARE\n v_state job_state;\n v_lock text;\n v_priority integer;\n v_seq bigint;\n v_expire bigint;\nBEGIN\n SELECT state, lock_token, priority INTO v_state, v_lock, v_priority\n FROM job WHERE queue = p_queue AND id = p_id;\n IF NOT FOUND THEN\n RETURN -1;\n END IF;\n\n IF v_state = 'active'\n AND (p_token = '0' OR v_lock IS NOT DISTINCT FROM p_token) THEN\n IF v_priority > 0 THEN\n v_seq := -nextval('job_seq');\n ELSE\n v_seq := nextval('job_seq');\n END IF;\n\n UPDATE job\n SET state = 'waiting',\n seq = v_seq,\n lock_token = NULL,\n locked_until_ms = NULL\n WHERE queue = p_queue AND id = p_id;\n\n PERFORM pg_notify('bullmq_jobs', p_queue);\n PERFORM publish_event(p_queue, 'waiting',\n jsonb_build_object('jobId', p_id, 'prev', 'active'));\n END IF;\n\n -- Remaining limiter window (mirrors Redis returning PTTL of the limiter key).\n SELECT expire_at_ms INTO v_expire\n FROM rate_limit WHERE queue = p_queue;\n IF v_expire IS NOT NULL AND v_expire > p_now THEN\n RETURN v_expire - p_now;\n END IF;\n RETURN 0;\nEND;\n$$;\n\n-- BullMQ PostgreSQL backend — reprocess clears processedOn (schema version 16).\n--\n-- Job.retry() must reset processedOn too: a re-queued job is \"fresh\", so\n-- processed_at_ms is cleared alongside finished/return/failed/stacktrace.\nCREATE OR REPLACE FUNCTION reprocess_job(\n p_queue text,\n p_id text,\n p_state text,\n p_lifo boolean,\n p_reset_made boolean,\n p_reset_started boolean\n) RETURNS integer\nLANGUAGE plpgsql\nSET search_path FROM CURRENT\nAS $$\nDECLARE\n v_state job_state;\n v_seq bigint;\n v_pq text;\n v_pid text;\nBEGIN\n SELECT state, parent_queue, parent_id INTO v_state, v_pq, v_pid\n FROM job WHERE queue = p_queue AND id = p_id FOR UPDATE;\n IF NOT FOUND THEN\n RETURN -1;\n END IF;\n IF v_state <> p_state::job_state THEN\n RETURN -3;\n END IF;\n\n v_seq := nextval('job_seq');\n IF p_lifo THEN\n v_seq := -v_seq;\n END IF;\n\n UPDATE job\n SET state = 'waiting',\n seq = v_seq,\n process_at_ms = NULL,\n processed_at_ms = NULL,\n finished_at_ms = NULL,\n return_value = NULL,\n failed_reason = NULL,\n stacktrace = NULL,\n attempts_made = CASE WHEN p_reset_made THEN 0 ELSE attempts_made END,\n attempts_started =\n CASE WHEN p_reset_started THEN 0 ELSE attempts_started END\n WHERE queue = p_queue AND id = p_id;\n\n -- A retried child must be awaited again by its parent: move its dependency\n -- back to pending and re-count it (mirrors reprocessJob-8.lua re-adding the\n -- job to the parent's :dependencies set).\n IF v_pq IS NOT NULL AND v_pid IS NOT NULL\n AND EXISTS (SELECT 1 FROM job WHERE queue = v_pq AND id = v_pid) THEN\n UPDATE job_dependency\n SET status = 'pending', value = NULL\n WHERE parent_queue = v_pq AND parent_id = v_pid\n AND child_key = p_queue || ':' || p_id\n AND status = (CASE WHEN p_state = 'failed' THEN 'failed'\n ELSE 'processed' END)::dep_status;\n IF FOUND THEN\n UPDATE job SET pending_deps = pending_deps + 1\n WHERE queue = v_pq AND id = v_pid;\n END IF;\n END IF;\n\n PERFORM pg_notify('bullmq_jobs', p_queue);\n PERFORM publish_event(p_queue, 'waiting',\n jsonb_build_object('jobId', p_id, 'prev', p_state));\n RETURN 1;\nEND;\n$$;\n\n-- BullMQ PostgreSQL backend — step jobs / waiting-children (schema version 19).\n--\n-- * move_to_waiting_children: a parent with pending children → waiting-children\n-- (returns 1 = should wait); no pending → 0 = proceed.\n-- * move_to_completed also releases the parent: a completing child marks its\n-- dependency processed and, when the parent has no pending deps left and is\n-- waiting-children, promotes it back to waiting.\nCREATE FUNCTION move_to_waiting_children(\n p_queue text, p_id text, p_token text\n) RETURNS integer\nLANGUAGE plpgsql\nSET search_path FROM CURRENT\nAS $$\nDECLARE\n v_state job_state;\n v_lock text;\n v_pending integer;\nBEGIN\n SELECT state, lock_token, pending_deps INTO v_state, v_lock, v_pending\n FROM job WHERE queue = p_queue AND id = p_id FOR UPDATE;\n IF NOT FOUND THEN\n RAISE EXCEPTION 'bullmq: missing job %', p_id USING ERRCODE = 'BM001', DETAIL = '-1';\n END IF;\n -- A parent with a failed child (failParentOnFailure) can never complete its\n -- children step; surface it as an unrecoverable failure (mirrors the\n -- `:unsuccessful` set check in moveToWaitingChildren-7.lua).\n IF EXISTS (\n SELECT 1 FROM job_dependency\n WHERE parent_queue = p_queue AND parent_id = p_id AND status = 'failed'\n ) THEN\n RAISE EXCEPTION 'bullmq: job % has failed children', p_id\n USING ERRCODE = 'BM001', DETAIL = '-9';\n END IF;\n -- Lock check (mirrors removeLock, taken before the active-state check): a\n -- missing lock is -2, a mismatched one is -6; token '0' skips the check.\n IF p_token <> '0' THEN\n IF v_lock IS NULL THEN\n RAISE EXCEPTION 'bullmq: job % missing lock', p_id\n USING ERRCODE = 'BM001', DETAIL = '-2';\n ELSIF v_lock <> p_token THEN\n RAISE EXCEPTION 'bullmq: job % lock mismatch', p_id\n USING ERRCODE = 'BM001', DETAIL = '-6';\n END IF;\n END IF;\n IF v_state <> 'active' THEN\n RAISE EXCEPTION 'bullmq: job % not active', p_id USING ERRCODE = 'BM001', DETAIL = '-3';\n END IF;\n\n IF v_pending > 0 THEN\n UPDATE job\n SET state = 'waiting-children', lock_token = NULL, locked_until_ms = NULL\n WHERE queue = p_queue AND id = p_id;\n RETURN 1; -- should wait\n END IF;\n\n RETURN 0; -- no pending children: proceed\nEND;\n$$;\n\n-- clean: skip a job scheduler's still-pending jobs, but clean finished ones.\n-- Flow-aware: when a cleaned job is a pending child, its parent's dependency\n-- count is updated and the parent released (removed if it lives in the cleaned\n-- queue, otherwise moved to wait) — mirrors removeJob → removeParentDependencyKey.\nCREATE OR REPLACE FUNCTION clean(\n p_queue text, p_type text, p_ts bigint, p_limit integer\n) RETURNS SETOF text\nLANGUAGE plpgsql\nSET search_path FROM CURRENT\nAS $$\nDECLARE\n v_where text;\n v_scheduler_filter text := 'AND scheduler_id IS NULL';\n v_ids text[];\n v_pq text[];\n v_pid text[];\n i integer;\nBEGIN\n IF p_type IN ('completed', 'failed') THEN\n v_where := format('state = %L AND finished_at_ms <= %s', p_type, p_ts);\n -- Finished jobs are cleaned regardless of scheduler origin (mirrors\n -- cleanJobsInSet-3.lua omitting the repeat key for finished states).\n v_scheduler_filter := '';\n ELSIF p_type = 'delayed' THEN\n v_where := format('state = ''delayed'' AND added_at_ms <= %s', p_ts);\n ELSIF p_type = 'prioritized' THEN\n v_where := format('state = ''waiting'' AND priority > 0 AND added_at_ms <= %s', p_ts);\n ELSIF p_type IN ('wait', 'waiting', 'paused') THEN\n v_where := format('state = ''waiting'' AND priority = 0 AND added_at_ms <= %s', p_ts);\n ELSIF p_type = 'active' THEN\n v_where := format('state = ''active'' AND added_at_ms <= %s', p_ts);\n ELSE\n RETURN;\n END IF;\n\n -- Collect the ids to clean (respecting FIFO order + limit).\n EXECUTE format(\n 'SELECT array_agg(id) FROM (SELECT id FROM job '\n || 'WHERE queue = %L AND %s %s ORDER BY seq %s) s',\n p_queue, v_where, v_scheduler_filter,\n CASE WHEN p_limit > 0 THEN 'LIMIT ' || p_limit ELSE '' END\n ) INTO v_ids;\n\n IF v_ids IS NULL THEN\n RETURN;\n END IF;\n\n -- Distinct parents of the cleaned jobs, for flow-aware release.\n SELECT array_agg(pq), array_agg(pid) INTO v_pq, v_pid FROM (\n SELECT DISTINCT parent_queue AS pq, parent_id AS pid\n FROM job\n WHERE queue = p_queue AND id = ANY(v_ids)\n AND parent_queue IS NOT NULL AND parent_id IS NOT NULL\n ) s;\n\n -- Break the cleaned jobs' dependency links, then delete the jobs.\n DELETE FROM job_dependency\n WHERE child_queue = p_queue AND child_id = ANY(v_ids);\n DELETE FROM job WHERE queue = p_queue AND id = ANY(v_ids);\n\n -- Release affected parents: a parent with no remaining pending dependencies\n -- is removed when it lives in the cleaned queue, else moved to wait.\n IF v_pq IS NOT NULL THEN\n FOR i IN 1 .. array_length(v_pq, 1) LOOP\n UPDATE job p SET pending_deps = (\n SELECT count(*) FROM job_dependency d\n WHERE d.parent_queue = v_pq[i] AND d.parent_id = v_pid[i]\n AND d.status = 'pending'\n ) WHERE p.queue = v_pq[i] AND p.id = v_pid[i];\n\n PERFORM 1 FROM job p\n WHERE p.queue = v_pq[i] AND p.id = v_pid[i]\n AND p.state = 'waiting-children' AND p.pending_deps = 0;\n IF FOUND THEN\n IF v_pq[i] = p_queue THEN\n DELETE FROM job WHERE queue = v_pq[i] AND id = v_pid[i];\n ELSE\n UPDATE job\n SET state = 'waiting', seq = nextval('job_seq')\n WHERE queue = v_pq[i] AND id = v_pid[i];\n PERFORM pg_notify('bullmq_jobs', v_pq[i]);\n PERFORM publish_event(v_pq[i], 'waiting',\n jsonb_build_object('jobId', v_pid[i], 'prev', 'waiting-children'));\n END IF;\n END IF;\n END LOOP;\n END IF;\n\n -- Announce how many jobs were cleaned (mirrors cleanJobsInSet-3.lua's\n -- `cleaned` event; count is a string, as on the Redis stream).\n PERFORM publish_event(p_queue, 'cleaned',\n jsonb_build_object('count', array_length(v_ids, 1)::text));\n\n RETURN QUERY SELECT unnest(v_ids);\nEND;\n$$;\n\n-- Mirror of the Lua getJobSchedulerEveryNextMillis: returns the next due time\n-- for a fixed-interval scheduler and the aligned offset, as a 2-int array.\nCREATE FUNCTION scheduler_every_next_millis(\n p_prev bigint, p_every bigint, p_now bigint, p_offset bigint, p_start bigint\n) RETURNS bigint[]\nLANGUAGE plpgsql\nIMMUTABLE\nAS $$\nDECLARE\n v_next bigint;\n v_offset bigint := p_offset;\nBEGIN\n IF p_prev IS NULL THEN\n IF p_start IS NOT NULL THEN\n v_next := GREATEST(p_start, p_now);\n ELSE\n v_next := p_now;\n IF p_offset IS NOT NULL AND p_offset > 0 THEN\n v_next := (p_now / p_every) * p_every + p_offset;\n IF v_next <= p_now THEN\n v_next := v_next + p_every;\n END IF;\n END IF;\n END IF;\n ELSE\n v_next := p_prev + p_every;\n IF v_next < p_now THEN\n v_next := (p_now / p_every) * p_every + p_every + COALESCE(p_offset, 0);\n END IF;\n END IF;\n\n IF v_offset IS NULL OR v_offset = 0 THEN\n v_offset := v_next - (v_next / p_every) * p_every;\n END IF;\n\n RETURN ARRAY[v_next, v_offset];\nEND;\n$$;\n\n-- Registers/updates a scheduler and enqueues its next delayed job.\n-- Returns (job_id, delay).\nCREATE FUNCTION add_job_scheduler(\n p_queue text,\n p_scheduler_id text,\n p_next_millis bigint,\n p_template_data jsonb,\n p_template_opts jsonb,\n p_opts jsonb,\n p_delayed_opts jsonb,\n p_now bigint,\n p_producer_id text\n) RETURNS TABLE (job_id text, delay bigint)\nLANGUAGE plpgsql\nSET search_path FROM CURRENT\nAS $$\nDECLARE\n v_name text := p_opts ->> 'name';\n v_tz text := p_opts ->> 'tz';\n v_pattern text := p_opts ->> 'pattern';\n v_every bigint := (p_opts ->> 'every')::bigint;\n v_offset bigint := COALESCE((p_opts ->> 'offset')::bigint,\n (p_delayed_opts #>> '{repeat,offset}')::bigint, 0);\n v_offset_provided boolean := (p_opts ->> 'offset') IS NOT NULL\n OR (p_delayed_opts #>> '{repeat,offset}') IS NOT NULL;\n v_start bigint := (p_opts ->> 'startDate')::bigint;\n v_end bigint := (p_opts ->> 'endDate')::bigint;\n v_limit integer := (p_opts ->> 'limit')::integer;\n v_prev bigint;\n v_millis bigint;\n v_prev_every bigint;\n v_existing_offset bigint;\n v_ic integer;\n v_next bigint := p_next_millis;\n v_updated_every boolean := false;\n v_removed boolean := false;\n v_collision boolean := false;\n v_new_offset bigint := v_offset;\n v_em bigint[];\n v_jobid text;\n v_delay bigint;\n v_state job_state;\n v_seq bigint;\nBEGIN\n SELECT next_run_ms, every_ms, iteration_count, offset_ms\n INTO v_prev, v_prev_every, v_ic, v_existing_offset\n FROM scheduler WHERE queue = p_queue AND scheduler_id = p_scheduler_id;\n\n -- `v_prev` is the previous iteration (used for removal); `v_millis` seeds the\n -- `every` formula and is reset to NULL when the interval itself changed.\n v_millis := v_prev;\n IF v_every IS NOT NULL THEN\n IF v_prev IS NOT NULL AND v_prev_every IS DISTINCT FROM v_every THEN\n v_millis := NULL;\n v_updated_every := true;\n END IF;\n v_em := scheduler_every_next_millis(v_millis, v_every, p_now, v_offset, v_start);\n v_next := v_em[1];\n v_new_offset := v_em[2];\n -- Preserve the offset established at scheduler creation. On re-upsert of an\n -- existing scheduler without an explicit offset, keep the stored offset\n -- instead of recomputing it against the (possibly changed) `every`. Mirrors\n -- Redis storeJobScheduler, which writes a freshly-computed offset only on\n -- first creation and otherwise preserves the existing hash field.\n IF NOT v_offset_provided AND v_existing_offset IS NOT NULL THEN\n v_new_offset := v_existing_offset;\n END IF;\n END IF;\n\n -- Remove the previous iteration's (still-pending) job, if any.\n IF v_prev IS NOT NULL THEN\n DELETE FROM job\n WHERE queue = p_queue\n AND id = 'repeat:' || p_scheduler_id || ':' || v_prev\n AND state IN ('delayed', 'waiting');\n IF FOUND THEN\n v_removed := true;\n END IF;\n END IF;\n\n IF v_removed AND v_every IS NOT NULL AND NOT v_updated_every THEN\n v_next := v_prev;\n END IF;\n\n -- Collision: a job with the next id already exists in a non-replaceable state\n -- (e.g. it is active). For `every` we try the following slot; for `pattern`\n -- we fail — unless we just removed the previous job (override replaces it).\n v_jobid := 'repeat:' || p_scheduler_id || ':' || v_next;\n IF EXISTS (SELECT 1 FROM job WHERE queue = p_queue AND id = v_jobid) THEN\n IF v_every IS NOT NULL THEN\n v_next := v_next + v_every;\n v_jobid := 'repeat:' || p_scheduler_id || ':' || v_next;\n IF EXISTS (SELECT 1 FROM job WHERE queue = p_queue AND id = v_jobid) THEN\n RAISE EXCEPTION 'scheduler job slots busy'\n USING ERRCODE = 'BM001', DETAIL = '-11';\n END IF;\n ELSIF NOT v_removed THEN\n RAISE EXCEPTION 'scheduler job id collision'\n USING ERRCODE = 'BM001', DETAIL = '-10';\n ELSE\n v_collision := true;\n END IF;\n END IF;\n\n v_delay := GREATEST(v_next - p_now, 0);\n\n -- Upsert the scheduler row (iteration count preserved, or 1 the first time).\n INSERT INTO scheduler (\n queue, scheduler_id, name, next_run_ms, pattern, every_ms, tz,\n start_date_ms, end_date_ms, limit_count, iteration_count, offset_ms,\n template_data, template_opts, producer_id\n ) VALUES (\n p_queue, p_scheduler_id, v_name, v_next, v_pattern, v_every, v_tz,\n v_start, v_end, v_limit, COALESCE(v_ic, 1), v_new_offset,\n p_template_data, p_template_opts, p_producer_id\n )\n ON CONFLICT (queue, scheduler_id) DO UPDATE SET\n name = EXCLUDED.name,\n next_run_ms = EXCLUDED.next_run_ms,\n pattern = EXCLUDED.pattern,\n every_ms = EXCLUDED.every_ms,\n tz = EXCLUDED.tz,\n start_date_ms = EXCLUDED.start_date_ms,\n end_date_ms = EXCLUDED.end_date_ms,\n limit_count = EXCLUDED.limit_count,\n offset_ms = EXCLUDED.offset_ms,\n template_data = EXCLUDED.template_data,\n template_opts = EXCLUDED.template_opts,\n producer_id = EXCLUDED.producer_id;\n\n -- Create the next delayed (or immediately-ready) job. When `v_collision` we\n -- are replacing an existing (removed-prev) row, so fully reset it.\n v_seq := nextval('job_seq');\n IF v_delay > 0 THEN\n v_state := 'delayed';\n ELSE\n v_state := 'waiting';\n END IF;\n\n INSERT INTO job (\n queue, id, seq, name, state, data, opts, priority, delay_ms, max_attempts,\n added_at_ms, process_at_ms, scheduler_id\n ) VALUES (\n p_queue, v_jobid, v_seq, v_name, v_state,\n COALESCE(p_template_data, '{}'::jsonb),\n COALESCE(p_delayed_opts, '{}'::jsonb)\n || jsonb_build_object('delay', v_delay, 'jobId', v_jobid),\n COALESCE((p_delayed_opts ->> 'priority')::integer, 0), v_delay,\n COALESCE((p_delayed_opts ->> 'attempts')::integer, 1),\n COALESCE((p_delayed_opts ->> 'timestamp')::bigint, p_now),\n CASE WHEN v_delay > 0 THEN v_next ELSE NULL END, p_scheduler_id\n )\n ON CONFLICT (queue, id) DO UPDATE SET\n seq = EXCLUDED.seq,\n name = EXCLUDED.name,\n state = EXCLUDED.state,\n data = EXCLUDED.data,\n opts = EXCLUDED.opts,\n priority = EXCLUDED.priority,\n delay_ms = EXCLUDED.delay_ms,\n max_attempts = EXCLUDED.max_attempts,\n attempts_made = 0,\n attempts_started = 0,\n added_at_ms = EXCLUDED.added_at_ms,\n process_at_ms = EXCLUDED.process_at_ms,\n processed_at_ms = NULL,\n finished_at_ms = NULL,\n return_value = NULL,\n failed_reason = NULL,\n stacktrace = NULL,\n lock_token = NULL,\n locked_until_ms = NULL,\n stalled_count = 0,\n processed_by = NULL,\n scheduler_id = EXCLUDED.scheduler_id;\n\n PERFORM pg_notify('bullmq_jobs', p_queue);\n\n job_id := v_jobid;\n delay := v_delay;\n RETURN NEXT;\nEND;\n$$;\n\n-- Advance an existing scheduler to its next iteration (no template change).\n-- Returns the new job id, or NULL if the scheduler no longer exists.\nCREATE FUNCTION update_job_scheduler_next_millis(\n p_queue text,\n p_scheduler_id text,\n p_next_millis bigint,\n p_template_data jsonb,\n p_delayed_opts jsonb,\n p_now bigint,\n p_producer_id text\n) RETURNS text\nLANGUAGE plpgsql\nSET search_path FROM CURRENT\nAS $$\nDECLARE\n v_name text;\n v_prev bigint;\n v_every bigint;\n v_start bigint;\n v_offset bigint;\n v_data jsonb;\n v_next bigint := p_next_millis;\n v_new_offset bigint;\n v_em bigint[];\n v_jobid text;\n v_current text;\n v_delay bigint;\n v_state job_state;\n v_seq bigint;\nBEGIN\n SELECT name, next_run_ms, every_ms, start_date_ms, offset_ms, template_data\n INTO v_name, v_prev, v_every, v_start, v_offset, v_data\n FROM scheduler WHERE queue = p_queue AND scheduler_id = p_scheduler_id;\n IF NOT FOUND THEN\n RETURN NULL;\n END IF;\n\n IF v_every IS NULL THEN\n v_every := (p_delayed_opts #>> '{repeat,every}')::bigint;\n END IF;\n\n IF v_every IS NOT NULL THEN\n v_offset := COALESCE(v_offset, (p_delayed_opts #>> '{repeat,offset}')::bigint, 0);\n v_em := scheduler_every_next_millis(v_prev, v_every, p_now, v_offset, v_start);\n v_next := v_em[1];\n v_new_offset := v_em[2];\n END IF;\n\n -- Only the producer of the current iteration may advance the scheduler;\n -- this guards against duplicate iterations from stale/concurrent finishes.\n v_current := 'repeat:' || p_scheduler_id || ':' || v_prev;\n IF p_producer_id IS DISTINCT FROM v_current THEN\n RETURN NULL;\n END IF;\n\n v_jobid := 'repeat:' || p_scheduler_id || ':' || v_next;\n\n -- If the next iteration's job already exists, this is a duplicate.\n IF EXISTS (SELECT 1 FROM job WHERE queue = p_queue AND id = v_jobid) THEN\n PERFORM publish_event(p_queue, 'duplicated',\n jsonb_build_object('jobId', v_jobid));\n RETURN NULL;\n END IF;\n\n v_delay := GREATEST(v_next - p_now, 0);\n\n UPDATE scheduler\n SET next_run_ms = v_next,\n iteration_count = iteration_count + 1,\n offset_ms = COALESCE(offset_ms, v_new_offset),\n template_data = CASE\n WHEN p_template_data IS NOT NULL AND p_template_data <> '{}'::jsonb\n THEN p_template_data\n ELSE template_data\n END\n WHERE queue = p_queue AND scheduler_id = p_scheduler_id;\n\n v_seq := nextval('job_seq');\n v_state := CASE WHEN v_delay > 0 THEN 'delayed' ELSE 'waiting' END;\n\n INSERT INTO job (\n queue, id, seq, name, state, data, opts, priority, delay_ms, max_attempts,\n added_at_ms, process_at_ms, scheduler_id\n ) VALUES (\n p_queue, v_jobid, v_seq, v_name, v_state,\n COALESCE(NULLIF(v_data, '{}'::jsonb), p_template_data, '{}'::jsonb),\n COALESCE(p_delayed_opts, '{}'::jsonb)\n || jsonb_build_object('delay', v_delay, 'jobId', v_jobid),\n COALESCE((p_delayed_opts ->> 'priority')::integer, 0), v_delay,\n COALESCE((p_delayed_opts ->> 'attempts')::integer, 1),\n COALESCE((p_delayed_opts ->> 'timestamp')::bigint, p_now),\n CASE WHEN v_delay > 0 THEN v_next ELSE NULL END, p_scheduler_id\n )\n ON CONFLICT (queue, id) DO NOTHING;\n\n PERFORM pg_notify('bullmq_jobs', p_queue);\n\n RETURN v_jobid;\nEND;\n$$;\n\n-- Remove a scheduler and its still-pending job, emitting a `removed` event for\n-- each deleted job. Returns 0 if the scheduler existed (removed), 1 otherwise\n-- (mirrors removeJobScheduler-3.lua: 0 = OK, 1 = missing).\nCREATE FUNCTION remove_job_scheduler(\n p_queue text, p_scheduler_id text\n) RETURNS integer\nLANGUAGE plpgsql\nSET search_path FROM CURRENT\nAS $$\nDECLARE\n v_next bigint;\n v_jobid text;\n v_removed integer;\nBEGIN\n -- Remove only the scheduler's *next* programmed job, and only while it is\n -- still delayed (mirrors removeJobScheduler-3.lua, which ZREMs the id\n -- `repeat:<id>:<next_run_ms>` from the delayed set). A job that has already\n -- been promoted (waiting/active/…) or processed survives — that surviving\n -- job is what later collides with a re-created scheduler to emit\n -- 'duplicated'. Deleting every delayed/waiting job of the scheduler would\n -- wrongly reap the promoted job.\n SELECT next_run_ms INTO v_next\n FROM scheduler\n WHERE queue = p_queue AND scheduler_id = p_scheduler_id;\n\n IF v_next IS NOT NULL THEN\n v_jobid := 'repeat:' || p_scheduler_id || ':' || v_next;\n DELETE FROM job\n WHERE queue = p_queue AND id = v_jobid AND state = 'delayed';\n IF FOUND THEN\n PERFORM publish_event(p_queue, 'removed',\n jsonb_build_object('jobId', v_jobid, 'prev', 'delayed'));\n END IF;\n END IF;\n\n DELETE FROM scheduler\n WHERE queue = p_queue AND scheduler_id = p_scheduler_id;\n GET DIAGNOSTICS v_removed = ROW_COUNT;\n RETURN CASE WHEN v_removed > 0 THEN 0 ELSE 1 END;\nEND;\n$$;\n\n-- Two-phase stalled detection (mirrors moveStalledJobsToWait-9.lua). A single\n-- pass that reclaimed an active job the instant its lock expired was unsafe\n-- under a fast-forwarding clock (repeatable-job tests tick the fake clock by a\n-- month inside the processor): a freshly-claimed job's short lock looks expired\n-- immediately, so the checker would yank the in-flight job back to wait and its\n-- completion would then fail with \"not in the active state\". Instead:\n-- * A `stalled-check` throttle bounds how often the scan runs.\n-- * Jobs are *marked* (`stalled_marked`, the Redis `stalled` SET analogue) on\n-- one pass and only *reclaimed* on the next if still active with an expired\n-- lock — so a job that completes or renews its lock between passes is never\n-- reclaimed.\n-- * Scheduler (\"repeatable\") jobs are recovered but never permanently failed.\nCREATE FUNCTION move_stalled_jobs_to_wait(\n p_queue text,\n p_max_stalled integer,\n p_now bigint,\n p_max_check_time bigint\n) RETURNS SETOF text\nLANGUAGE plpgsql\nSET search_path FROM CURRENT\nAS $$\nDECLARE\n v_last bigint;\n r record;\n v_count integer;\n v_repeatable boolean;\n v_reclaimed boolean := false;\nBEGIN\n -- Throttle: only run once per `max_check_time` window (mirrors the Redis\n -- `stalled-check` key with `PX maxCheckTime`).\n SELECT value::bigint INTO v_last\n FROM meta WHERE queue = p_queue AND field = 'stalled-check';\n IF v_last IS NOT NULL AND p_now < v_last + p_max_check_time THEN\n RETURN;\n END IF;\n INSERT INTO meta (queue, field, value)\n VALUES (p_queue, 'stalled-check', p_now::text)\n ON CONFLICT (queue, field) DO UPDATE SET value = EXCLUDED.value;\n\n -- Phase 1 (sweep): reclaim jobs that were marked on a PREVIOUS pass and are\n -- still active with an expired lock.\n FOR r IN\n SELECT id, scheduler_id, stalled_count\n FROM job\n WHERE queue = p_queue\n AND state = 'active'\n AND stalled_marked\n AND locked_until_ms IS NOT NULL\n AND locked_until_ms < p_now\n FOR UPDATE SKIP LOCKED\n LOOP\n v_count := r.stalled_count + 1;\n\n -- Scheduler jobs are recovered but never permanently failed.\n v_repeatable := r.scheduler_id IS NOT NULL AND EXISTS (\n SELECT 1 FROM scheduler\n WHERE queue = p_queue AND scheduler_id = r.scheduler_id\n );\n\n UPDATE job\n SET state = 'waiting',\n lock_token = NULL,\n locked_until_ms = NULL,\n stalled_marked = false,\n stalled_count = v_count,\n seq = nextval('job_seq'),\n deferred_failure = CASE\n WHEN v_count > p_max_stalled AND NOT v_repeatable\n THEN 'job stalled more than allowable limit'\n ELSE deferred_failure\n END\n WHERE queue = p_queue AND id = r.id;\n\n PERFORM publish_event(p_queue, 'stalled',\n jsonb_build_object('jobId', r.id));\n v_reclaimed := true;\n RETURN NEXT r.id;\n END LOOP;\n\n -- Clear all old marks (mirrors `DEL stalledKey`) …\n UPDATE job SET stalled_marked = false\n WHERE queue = p_queue AND stalled_marked;\n\n -- … then mark every currently-active job for the NEXT pass (mirrors\n -- `SADD stalledKey <active>`). Freshly-claimed jobs are therefore never\n -- reclaimed on the pass that first observes them.\n UPDATE job SET stalled_marked = true\n WHERE queue = p_queue AND state = 'active';\n\n -- Wake a worker for any jobs pushed back to wait.\n IF v_reclaimed THEN\n PERFORM pg_notify('bullmq_jobs', p_queue);\n END IF;\n RETURN;\nEND;\n$$;\n\n-- BullMQ PostgreSQL backend — rate limiting (schema v25).\n--\n-- Mirrors the Redis limiter, which is a single `<prefix>:<queue>:limiter`\n-- counter key with a PTTL: a token is consumed (INCR + PEXPIRE-on-first) each\n-- time a job is moved to active, and `getRateLimitTTL` reports the remaining\n-- window once the counter reaches `max`. Here that key is one row of\n-- `rate_limit` (`points` ⇔ the counter, `expire_at_ms` ⇔ the PTTL\n-- window). The limiter config comes from the queue meta (`max`/`duration`, set\n-- via `Queue.setGlobalRateLimit`) and/or the worker's `limiter` option.\n\n-- ── Public TTL (mirrors getRateLimitTtl-2.lua) ─────────────────────────────\n-- p_max_jobs > 0 → check against that; else fall back to meta `max`; else the\n-- raw remaining window (or -2 when there is none, like Redis PTTL on a missing\n-- key).\nCREATE FUNCTION rate_limit_ttl(\n p_queue text, p_max_jobs integer, p_now bigint\n) RETURNS bigint\nLANGUAGE plpgsql\nSTABLE\nSET search_path FROM CURRENT\nAS $$\nDECLARE\n v_points bigint;\n v_expire bigint;\n v_active boolean;\n v_counter bigint;\n v_pttl bigint;\n v_max integer;\nBEGIN\n SELECT points, expire_at_ms INTO v_points, v_expire\n FROM rate_limit WHERE queue = p_queue;\n v_active := v_expire IS NOT NULL AND v_expire > p_now;\n v_counter := CASE WHEN v_active THEN v_points ELSE 0 END;\n v_pttl := CASE WHEN v_active THEN v_expire - p_now ELSE -2 END;\n\n IF p_max_jobs IS NOT NULL AND p_max_jobs > 0 THEN\n IF p_max_jobs <= v_counter AND v_pttl > 0 THEN\n RETURN v_pttl;\n END IF;\n RETURN 0;\n END IF;\n\n SELECT value::integer INTO v_max\n FROM meta WHERE queue = p_queue AND field = 'max';\n IF v_max IS NOT NULL THEN\n IF v_max <= v_counter AND v_pttl > 0 THEN\n RETURN v_pttl;\n END IF;\n RETURN 0;\n END IF;\n\n RETURN v_pttl;\nEND;\n$$;\n\n-- ── Worker-effective TTL ───────────────────────────────────────────────────\n-- The limit that applies to a fetching worker: meta `max` takes precedence,\n-- falling back to the worker's own `limiter.max`. Returns the remaining window\n-- when the counter has reached that limit, else 0 (and 0 when no limiter at\n-- all is configured).\nCREATE FUNCTION rate_limit_effective(\n p_queue text, p_limiter_max integer, p_now bigint\n) RETURNS bigint\nLANGUAGE plpgsql\nSTABLE\nSET search_path FROM CURRENT\nAS $$\nDECLARE\n v_meta_max integer;\n v_max integer;\n v_points bigint;\n v_expire bigint;\nBEGIN\n SELECT value::integer INTO v_meta_max\n FROM meta WHERE queue = p_queue AND field = 'max';\n v_max := COALESCE(v_meta_max, p_limiter_max);\n IF v_max IS NULL THEN\n RETURN 0;\n END IF;\n\n SELECT points, expire_at_ms INTO v_points, v_expire\n FROM rate_limit WHERE queue = p_queue;\n IF v_expire IS NULL OR v_expire <= p_now THEN\n RETURN 0;\n END IF;\n IF v_max <= v_points THEN\n RETURN v_expire - p_now;\n END IF;\n RETURN 0;\nEND;\n$$;\n\n-- Consume one token (INCR + PEXPIRE-on-first-of-window).\nCREATE FUNCTION rate_limit_consume(\n p_queue text, p_duration bigint, p_now bigint\n) RETURNS void\nLANGUAGE sql\nSET search_path FROM CURRENT\nAS $$\n INSERT INTO rate_limit (queue, points, expire_at_ms)\n VALUES (p_queue, 1, p_now + p_duration)\n ON CONFLICT (queue) DO UPDATE SET\n points = CASE WHEN rate_limit.expire_at_ms <= p_now\n THEN 1 ELSE rate_limit.points + 1 END,\n expire_at_ms = CASE WHEN rate_limit.expire_at_ms <= p_now\n THEN p_now + p_duration\n ELSE rate_limit.expire_at_ms END;\n$$;\n\n-- Force the limiter for `p_expire_ms` (dynamic / manual rate limit). Mirrors\n-- Redis SET limiter = MAX_SAFE_INTEGER PX p_expire_ms.\nCREATE FUNCTION set_rate_limit(\n p_queue text, p_expire_ms bigint, p_now bigint\n) RETURNS void\nLANGUAGE sql\nSET search_path FROM CURRENT\nAS $$\n INSERT INTO rate_limit (queue, points, expire_at_ms)\n VALUES (p_queue, 9007199254740991, p_now + p_expire_ms)\n ON CONFLICT (queue) DO UPDATE SET\n points = 9007199254740991,\n expire_at_ms = p_now + p_expire_ms;\n$$;\n\n-- The \"no job\" signal: the worker-effective rate-limit ttl and the next delayed\n-- job's timestamp, in one round trip.\nCREATE FUNCTION next_signal(\n p_queue text, p_limiter_max integer, p_now bigint\n) RETURNS TABLE (rate_limit_ttl bigint, next_delay bigint)\nLANGUAGE sql\nSTABLE\nSET search_path FROM CURRENT\nAS $$\n SELECT rate_limit_effective(p_queue, p_limiter_max, p_now),\n next_delay(p_queue);\n$$;\n\n-- BullMQ PostgreSQL backend — obliterate (schema v27).\n--\n-- Completely destroys a queue and all of its contents, mirroring\n-- obliterate-2.lua:\n-- * The queue must be paused, else return -1.\n-- * If there are active jobs and `force` is not set, return -2.\n-- * Otherwise delete up to `p_count` jobs; return 1 while more remain (the\n-- caller loops), and once every job is gone delete the remaining\n-- per-queue data and return 0.\n--\n-- `job_log` and `job_dependency` rows cascade from `job`,\n-- so deleting the jobs removes their logs and (parent-side) dependency links.\nCREATE FUNCTION obliterate(\n p_queue text, p_count integer, p_force boolean\n) RETURNS integer\nLANGUAGE plpgsql\nSET search_path FROM CURRENT\nAS $$\nDECLARE\n v_deleted integer;\nBEGIN\n IF NOT EXISTS (\n SELECT 1 FROM meta\n WHERE queue = p_queue AND field = 'paused' AND value = '1'\n ) THEN\n RETURN -1; -- NotPaused\n END IF;\n\n IF NOT p_force AND EXISTS (\n SELECT 1 FROM job WHERE queue = p_queue AND state = 'active'\n ) THEN\n RETURN -2; -- ExistActiveJobs\n END IF;\n\n WITH batch AS (\n SELECT id FROM job WHERE queue = p_queue LIMIT p_count\n )\n DELETE FROM job j\n USING batch\n WHERE j.queue = p_queue AND j.id = batch.id;\n GET DIAGNOSTICS v_deleted = ROW_COUNT;\n\n -- More jobs than the batch budget: tell the caller to come back.\n IF v_deleted >= p_count THEN\n RETURN 1;\n END IF;\n\n -- Every job is gone; remove the rest of the queue's footprint.\n DELETE FROM scheduler WHERE queue = p_queue;\n DELETE FROM rate_limit WHERE queue = p_queue;\n DELETE FROM event WHERE queue = p_queue;\n DELETE FROM metrics WHERE queue = p_queue;\n DELETE FROM dedup WHERE queue = p_queue;\n DELETE FROM dedup_next WHERE queue = p_queue;\n DELETE FROM meta WHERE queue = p_queue;\n -- Drop the per-queue job-id sequence so a re-created queue restarts at 1\n -- (mirrors Redis obliterate deleting the `<queue>:id` counter key).\n EXECUTE format(\n 'DROP SEQUENCE IF EXISTS %I', job_id_seq_name(p_queue));\n RETURN 0;\nEND;\n$$;\n\n-- BullMQ PostgreSQL backend — deduplication / debounce (schema version 28).\n--\n-- Mirrors the Redis deduplicateJob include chain. A job added with a\n-- `deduplication` (or legacy `debounce`) option carries a `de` opts object\n-- `{ id, ttl, extend, replace, keepLastIfActive }`. When a *live* key already\n-- exists for that id the new job is NOT added: the existing \"winner\" job id is\n-- returned and `deduplicated` events are emitted. The key is one\n-- row of `dedup` (`job_id` ⇔ the winner, `expire_at_ms` ⇔ the Redis\n-- PTTL window; NULL = no expiry). The full key lifecycle:\n-- * add — set / check the key (see deduplicate_job),\n-- * finalize — when the winner completes/fails, a no-ttl key is cleared\n-- (dedup_finalize),\n-- * removal — when the winner is removed, its key is cleared\n-- (dedup_on_removal).\n-- NOTE: `keepLastIfActive`'s proto-job storage/requeue is added in a later\n-- migration; here keepLastIfActive only governs the key's expiry (no ttl).\n\n-- ── Finalize: clear a no-ttl key whose winner is finishing (PTTL == -1 path),\n-- and reap an already-expired key (PTTL == 0). ttl keys expire on their own.\nCREATE FUNCTION dedup_finalize(\n p_queue text, p_dedup_id text, p_job_id text, p_now bigint\n) RETURNS void\nLANGUAGE plpgsql\nSET search_path FROM CURRENT\nAS $$\nDECLARE\n v_cur text;\n v_exp bigint;\nBEGIN\n IF p_dedup_id IS NULL OR p_dedup_id = '' THEN\n RETURN;\n END IF;\n SELECT job_id, expire_at_ms INTO v_cur, v_exp\n FROM dedup WHERE queue = p_queue AND dedup_id = p_dedup_id;\n IF NOT FOUND THEN\n RETURN;\n END IF;\n IF v_exp IS NULL THEN\n -- No expiry: only the current winner clears its own key.\n IF v_cur = p_job_id THEN\n DELETE FROM dedup WHERE queue = p_queue AND dedup_id = p_dedup_id;\n END IF;\n ELSIF v_exp <= p_now THEN\n -- Already expired: reap it.\n DELETE FROM dedup WHERE queue = p_queue AND dedup_id = p_dedup_id;\n END IF;\nEND;\n$$;\n\n-- ── Removal: clear the key (and any proto-next data) when its winner job is\n-- removed (mirrors removeDeduplicationKeyIfNeededOnRemoval).\nCREATE FUNCTION dedup_on_removal(\n p_queue text, p_job_id text, p_dedup_id text\n) RETURNS void\nLANGUAGE plpgsql\nSET search_path FROM CURRENT\nAS $$\nBEGIN\n IF p_dedup_id IS NULL OR p_dedup_id = '' THEN\n RETURN;\n END IF;\n DELETE FROM dedup\n WHERE queue = p_queue AND dedup_id = p_dedup_id AND job_id = p_job_id;\nEND;\n$$;\n\n-- Stash the new job as the proto-next IF keepLastIfActive and the current\n-- winner is active. Returns true when stashed. Mirrors storeDeduplicatedNextJob.\nCREATE FUNCTION dedup_store_next(\n p_queue text, p_dedup_id text, p_winner text, p_job_id text,\n p_keeplast boolean, p_name text, p_data jsonb, p_opts jsonb\n) RETURNS boolean\nLANGUAGE plpgsql\nSET search_path FROM CURRENT\nAS $$\nBEGIN\n IF NOT p_keeplast OR p_winner IS NULL THEN\n RETURN false;\n END IF;\n IF NOT EXISTS (\n SELECT 1 FROM job\n WHERE queue = p_queue AND id = p_winner AND state = 'active'\n ) THEN\n RETURN false;\n END IF;\n INSERT INTO dedup_next (queue, dedup_id, payload)\n VALUES (p_queue, p_dedup_id, jsonb_build_object(\n 'name', p_name, 'data', p_data, 'opts', p_opts, 'jobId', p_job_id))\n ON CONFLICT (queue, dedup_id) DO UPDATE SET payload = EXCLUDED.payload;\n -- Persist the dedup key so it outlives the active job's duration.\n UPDATE dedup SET expire_at_ms = NULL\n WHERE queue = p_queue AND dedup_id = p_dedup_id;\n RETURN true;\nEND;\n$$;\n\n-- Turn a stored proto-next into a real job (the new winner) when the active\n-- winner finishes. Mirrors requeueDeduplicatedJob.\nCREATE FUNCTION requeue_dedup_next(\n p_queue text, p_dedup_id text, p_now bigint\n) RETURNS void\nLANGUAGE plpgsql\nSET search_path FROM CURRENT\nAS $$\nDECLARE\n v_payload jsonb;\n v_name text;\n v_data jsonb;\n v_opts jsonb;\n v_job_id text;\n v_de jsonb;\n v_delay bigint;\n v_priority integer;\n v_lifo boolean;\n v_keeplast boolean;\n v_ttl bigint;\n v_seq bigint;\n v_state job_state;\n v_process_at bigint;\nBEGIN\n IF p_dedup_id IS NULL OR p_dedup_id = '' THEN\n RETURN;\n END IF;\n SELECT payload INTO v_payload\n FROM dedup_next WHERE queue = p_queue AND dedup_id = p_dedup_id;\n IF NOT FOUND THEN\n RETURN;\n END IF;\n\n v_name := v_payload->>'name';\n v_data := COALESCE(v_payload->'data', '{}'::jsonb);\n v_opts := COALESCE(v_payload->'opts', '{}'::jsonb);\n v_job_id := v_payload->>'jobId';\n IF v_job_id IS NULL OR v_job_id = '' THEN\n v_job_id := next_job_id(p_queue);\n END IF;\n\n v_de := COALESCE(v_opts->'deduplication', v_opts->'debounce');\n v_delay := COALESCE((v_opts->>'delay')::bigint, 0);\n v_priority := COALESCE((v_opts->>'priority')::integer, 0);\n v_lifo := COALESCE((v_opts->>'lifo')::boolean, false);\n v_keeplast := COALESCE((v_de->>'keepLastIfActive')::boolean, false);\n v_ttl := NULLIF(v_de->>'ttl', '')::bigint;\n\n v_seq := nextval('job_seq');\n IF v_lifo THEN\n v_seq := -v_seq;\n END IF;\n IF v_delay > 0 THEN\n v_state := 'delayed';\n v_process_at := p_now + v_delay;\n ELSE\n v_state := 'waiting';\n v_process_at := NULL;\n END IF;\n\n INSERT INTO job (\n queue, id, seq, name, state, data, opts, priority, delay_ms,\n max_attempts, added_at_ms, process_at_ms, dedup_id\n ) VALUES (\n p_queue, v_job_id, v_seq, v_name, v_state, v_data, v_opts, v_priority,\n v_delay, COALESCE((v_opts->>'attempts')::integer, 1), p_now, v_process_at,\n p_dedup_id\n )\n ON CONFLICT (queue, id) DO NOTHING;\n\n -- New winner key (no expiry while keepLastIfActive, else honour ttl).\n INSERT INTO dedup (queue, dedup_id, job_id, expire_at_ms)\n VALUES (p_queue, p_dedup_id, v_job_id,\n CASE WHEN v_keeplast OR COALESCE(v_ttl, 0) <= 0\n THEN NULL ELSE p_now + v_ttl END)\n ON CONFLICT (queue, dedup_id) DO UPDATE\n SET job_id = EXCLUDED.job_id, expire_at_ms = EXCLUDED.expire_at_ms;\n\n DELETE FROM dedup_next WHERE queue = p_queue AND dedup_id = p_dedup_id;\n\n PERFORM pg_notify('bullmq_jobs', p_queue);\n IF v_state = 'delayed' THEN\n PERFORM publish_event(p_queue, 'delayed',\n jsonb_build_object('jobId', v_job_id, 'delay', v_process_at));\n ELSE\n PERFORM publish_event(p_queue, 'waiting',\n jsonb_build_object('jobId', v_job_id));\n END IF;\nEND;\n$$;\n\n-- ── Core decision (mirrors deduplicateJob / deduplicateJobWithoutReplace).\n-- Returns the existing winner's id when the new job should be deduplicated\n-- (i.e. NOT inserted); returns NULL when the caller should go on to add it.\nCREATE FUNCTION deduplicate_job(\n p_queue text, p_dedup jsonb, p_job_id text, p_now bigint,\n p_name text, p_data jsonb, p_opts jsonb\n) RETURNS text\nLANGUAGE plpgsql\nSET search_path FROM CURRENT\nAS $$\nDECLARE\n v_id text := p_dedup->>'id';\n v_ttl bigint := NULLIF(p_dedup->>'ttl', '')::bigint;\n v_extend boolean := COALESCE((p_dedup->>'extend')::boolean, false);\n v_replace boolean := COALESCE((p_dedup->>'replace')::boolean, false);\n v_keeplast boolean := COALESCE((p_dedup->>'keepLastIfActive')::boolean, false);\n v_cur text;\n v_exp bigint;\n v_state job_state;\nBEGIN\n IF v_id IS NULL OR v_id = '' THEN\n RETURN NULL;\n END IF;\n\n -- The current winner (only if the key is still live).\n SELECT job_id, expire_at_ms INTO v_cur, v_exp\n FROM dedup WHERE queue = p_queue AND dedup_id = v_id;\n IF v_cur IS NULL OR (v_exp IS NOT NULL AND v_exp <= p_now) THEN\n v_cur := NULL;\n END IF;\n\n IF v_replace THEN\n IF v_cur IS NOT NULL THEN\n SELECT state INTO v_state\n FROM job WHERE queue = p_queue AND id = v_cur;\n IF v_state = 'delayed' THEN\n -- Drop the previous delayed job and take its place.\n DELETE FROM job WHERE queue = p_queue AND id = v_cur;\n PERFORM publish_event(p_queue, 'removed',\n jsonb_build_object('jobId', v_cur, 'prev', 'delayed'));\n PERFORM publish_event(p_queue, 'deduplicated',\n jsonb_build_object('jobId', p_job_id, 'deduplicationId', v_id,\n 'deduplicatedJobId', v_cur));\n IF v_keeplast THEN\n UPDATE dedup SET job_id = p_job_id, expire_at_ms = NULL\n WHERE queue = p_queue AND dedup_id = v_id;\n ELSIF NOT v_extend AND COALESCE(v_ttl, 0) > 0 THEN\n -- KEEPTTL: keep the existing window, just swap the winner.\n UPDATE dedup SET job_id = p_job_id\n WHERE queue = p_queue AND dedup_id = v_id;\n ELSE\n UPDATE dedup\n SET job_id = p_job_id,\n expire_at_ms = CASE WHEN COALESCE(v_ttl, 0) > 0\n THEN p_now + v_ttl ELSE NULL END\n WHERE queue = p_queue AND dedup_id = v_id;\n END IF;\n RETURN NULL;\n ELSE\n -- Winner is not a removable delayed job: stash proto-next if it is\n -- active + keepLastIfActive, then deduplicate.\n PERFORM dedup_store_next(p_queue, v_id, v_cur, p_job_id,\n v_keeplast, p_name, p_data, p_opts);\n RETURN v_cur;\n END IF;\n ELSE\n INSERT INTO dedup (queue, dedup_id, job_id, expire_at_ms)\n VALUES (p_queue, v_id, p_job_id,\n CASE WHEN NOT v_keeplast AND COALESCE(v_ttl, 0) > 0\n THEN p_now + v_ttl ELSE NULL END)\n ON CONFLICT (queue, dedup_id) DO UPDATE\n SET job_id = EXCLUDED.job_id, expire_at_ms = EXCLUDED.expire_at_ms;\n RETURN NULL;\n END IF;\n END IF;\n\n -- Without replace.\n IF COALESCE(v_ttl, 0) > 0 AND v_extend THEN\n IF v_cur IS NOT NULL THEN\n -- Stash proto-next if active+keepLast; else extend the window (or\n -- persist when keepLastIfActive). Either way keep the current winner.\n IF NOT dedup_store_next(p_queue, v_id, v_cur, p_job_id,\n v_keeplast, p_name, p_data, p_opts) THEN\n UPDATE dedup\n SET expire_at_ms = CASE WHEN v_keeplast THEN NULL ELSE p_now + v_ttl END\n WHERE queue = p_queue AND dedup_id = v_id;\n END IF;\n PERFORM publish_event(p_queue, 'deduplicated',\n jsonb_build_object('jobId', v_cur, 'deduplicationId', v_id,\n 'deduplicatedJobId', p_job_id));\n RETURN v_cur;\n END IF;\n INSERT INTO dedup (queue, dedup_id, job_id, expire_at_ms)\n VALUES (p_queue, v_id, p_job_id,\n CASE WHEN v_keeplast THEN NULL ELSE p_now + v_ttl END)\n ON CONFLICT (queue, dedup_id) DO UPDATE\n SET job_id = EXCLUDED.job_id, expire_at_ms = EXCLUDED.expire_at_ms;\n RETURN NULL;\n END IF;\n\n -- SET NX semantics (ttl>0 non-extend, or no ttl at all).\n IF v_cur IS NOT NULL THEN\n PERFORM dedup_store_next(p_queue, v_id, v_cur, p_job_id,\n v_keeplast, p_name, p_data, p_opts);\n PERFORM publish_event(p_queue, 'deduplicated',\n jsonb_build_object('jobId', v_cur, 'deduplicationId', v_id,\n 'deduplicatedJobId', p_job_id));\n RETURN v_cur;\n END IF;\n\n INSERT INTO dedup (queue, dedup_id, job_id, expire_at_ms)\n VALUES (p_queue, v_id, p_job_id,\n CASE WHEN NOT v_keeplast AND COALESCE(v_ttl, 0) > 0\n THEN p_now + v_ttl ELSE NULL END)\n ON CONFLICT (queue, dedup_id) DO UPDATE\n SET job_id = EXCLUDED.job_id, expire_at_ms = EXCLUDED.expire_at_ms;\n RETURN NULL;\nEND;\n$$;\n\n-- BullMQ PostgreSQL backend — flow failure propagation (schema version 29).\n--\n-- When a child job fails permanently, its parent must react according to the\n-- child's parent-link options (mirrors moveChildFromDependenciesIfNeeded):\n-- * failParentOnFailure (fpof) → record the child as a failed dependency,\n-- defer a failure onto the parent and (if\n-- it is waiting on children) release it so\n-- a worker picks it up and fails it.\n-- * continueParentOnFailure (cpof) → record the child as an ignored\n-- dependency and release the parent.\n-- * ignoreDependencyOnFailure (idof)→ record the child as ignored and release\n-- the parent once no pending deps remain.\n-- * removeDependencyOnFailure (rdof)→ drop the dependency entirely and release\n-- the parent once no pending deps remain.\n-- The PG backend stores raw JobsOptions, so the long option names are read\n-- straight from the child's `opts`.\n\n-- Release a parent from waiting-children to wait (mirrors moveParentToWait —\n-- priority is preserved by the row's `priority` column; FIFO order via a fresh\n-- seq). Emits the 'waiting' (prev waiting-children) event and wakes a worker.\nCREATE FUNCTION move_parent_to_wait(\n p_parent_queue text, p_parent_id text, p_now bigint\n) RETURNS void\nLANGUAGE plpgsql\nSET search_path FROM CURRENT\nAS $$\nDECLARE\n v_delay bigint;\n v_process_at bigint;\nBEGIN\n SELECT delay_ms INTO v_delay\n FROM job\n WHERE queue = p_parent_queue AND id = p_parent_id\n AND state = 'waiting-children';\n IF NOT FOUND THEN\n RETURN;\n END IF;\n\n IF v_delay > 0 THEN\n -- The parent carries a delay: release it into the delayed set (mirrors the\n -- delay branch of moveParentToWait), scored from the release moment.\n v_process_at := p_now + v_delay;\n UPDATE job\n SET state = 'delayed', process_at_ms = v_process_at,\n seq = nextval('job_seq')\n WHERE queue = p_parent_queue AND id = p_parent_id;\n PERFORM pg_notify('bullmq_jobs', p_parent_queue);\n PERFORM publish_event(p_parent_queue, 'delayed',\n jsonb_build_object('jobId', p_parent_id, 'delay', v_process_at));\n ELSE\n -- No delay: release to wait. Priority is preserved by the row's `priority`\n -- column, so a prioritized parent stays prioritized.\n UPDATE job\n SET state = 'waiting', seq = nextval('job_seq')\n WHERE queue = p_parent_queue AND id = p_parent_id;\n PERFORM pg_notify('bullmq_jobs', p_parent_queue);\n PERFORM publish_event(p_parent_queue, 'waiting',\n jsonb_build_object('jobId', p_parent_id, 'prev', 'waiting-children'));\n END IF;\nEND;\n$$;\n\n-- Propagate a child's permanent failure to its parent.\nCREATE FUNCTION handle_child_failure(\n p_queue text, p_id text, p_reason text, p_now bigint\n) RETURNS void\nLANGUAGE plpgsql\nSET search_path FROM CURRENT\nAS $$\nDECLARE\n v_pq text;\n v_pid text;\n v_opts jsonb;\n v_child_key text;\n v_remaining integer;\nBEGIN\n SELECT parent_queue, parent_id, opts INTO v_pq, v_pid, v_opts\n FROM job WHERE queue = p_queue AND id = p_id;\n IF v_pid IS NULL OR v_pq IS NULL THEN\n RETURN;\n END IF;\n v_child_key := p_queue || ':' || p_id;\n\n -- Serialize this child→parent mutation with any sibling resolving concurrently\n -- (completion or failure). Mirrors the single-threaded atomicity Redis relies\n -- on and prevents the `pending_deps` UPDATE from deadlocking against a\n -- sibling's decrement (SQLSTATE 40P01). Taken on the direct parent only, so it\n -- never nests and auto-releases at commit (see move_to_completed).\n PERFORM pg_advisory_xact_lock(\n hashtext('bullmq:parent:' || v_pq || ':' || v_pid));\n\n IF COALESCE((v_opts->>'failParentOnFailure')::boolean, false) THEN\n UPDATE job_dependency\n SET status = 'failed', value = to_jsonb(p_reason)\n WHERE parent_queue = v_pq AND parent_id = v_pid\n AND child_key = v_child_key AND status = 'pending';\n IF FOUND THEN\n UPDATE job SET pending_deps = GREATEST(pending_deps - 1, 0)\n WHERE queue = v_pq AND id = v_pid;\n -- Defer the failure onto the parent; when a worker activates it the\n -- deferred failure fails it immediately (worker checks job.deferredFailure).\n UPDATE job\n SET deferred_failure = 'child ' || v_child_key || ' failed'\n WHERE queue = v_pq AND id = v_pid;\n PERFORM move_parent_to_wait(v_pq, v_pid, p_now);\n END IF;\n\n ELSIF COALESCE((v_opts->>'continueParentOnFailure')::boolean, false) THEN\n UPDATE job_dependency\n SET status = 'ignored', value = to_jsonb(p_reason)\n WHERE parent_queue = v_pq AND parent_id = v_pid\n AND child_key = v_child_key AND status = 'pending';\n IF FOUND THEN\n UPDATE job SET pending_deps = GREATEST(pending_deps - 1, 0)\n WHERE queue = v_pq AND id = v_pid;\n PERFORM move_parent_to_wait(v_pq, v_pid, p_now);\n END IF;\n\n ELSIF COALESCE((v_opts->>'ignoreDependencyOnFailure')::boolean, false) THEN\n UPDATE job_dependency\n SET status = 'ignored', value = to_jsonb(p_reason)\n WHERE parent_queue = v_pq AND parent_id = v_pid\n AND child_key = v_child_key AND status = 'pending';\n IF FOUND THEN\n UPDATE job SET pending_deps = GREATEST(pending_deps - 1, 0)\n WHERE queue = v_pq AND id = v_pid\n RETURNING pending_deps INTO v_remaining;\n IF v_remaining = 0 THEN\n PERFORM move_parent_to_wait(v_pq, v_pid, p_now);\n END IF;\n END IF;\n\n ELSIF COALESCE((v_opts->>'removeDependencyOnFailure')::boolean, false) THEN\n DELETE FROM job_dependency\n WHERE parent_queue = v_pq AND parent_id = v_pid\n AND child_key = v_child_key AND status = 'pending';\n IF FOUND THEN\n UPDATE job SET pending_deps = GREATEST(pending_deps - 1, 0)\n WHERE queue = v_pq AND id = v_pid\n RETURNING pending_deps INTO v_remaining;\n IF v_remaining = 0 THEN\n PERFORM move_parent_to_wait(v_pq, v_pid, p_now);\n END IF;\n END IF;\n END IF;\nEND;\n$$;\n\n-- ── removeChildDependency: break a child's link to its parent (mirrors\n-- removeChildDependency-1.lua). Returns 0 when the relationship existed and\n-- was removed, 1 when there was no relationship; raises -1 (missing job) /\n-- -5 (missing parent).\nCREATE FUNCTION remove_child_dependency(\n p_queue text, p_id text, p_parent_key text, p_now bigint\n) RETURNS integer\nLANGUAGE plpgsql\nSET search_path FROM CURRENT\nAS $$\nDECLARE\n v_pq text;\n v_pid text;\n v_deleted integer;\n v_remaining integer;\nBEGIN\n SELECT parent_queue, parent_id INTO v_pq, v_pid\n FROM job WHERE queue = p_queue AND id = p_id;\n IF NOT FOUND THEN\n RAISE EXCEPTION 'bullmq: missing job %', p_id\n USING ERRCODE = 'BM001', DETAIL = '-1';\n END IF;\n IF v_pq IS NULL OR v_pid IS NULL THEN\n RETURN 1; -- no relationship\n END IF;\n IF NOT EXISTS (\n SELECT 1 FROM job WHERE queue = v_pq AND id = v_pid\n ) THEN\n RAISE EXCEPTION 'bullmq: missing parent %', p_parent_key\n USING ERRCODE = 'BM001', DETAIL = '-5';\n END IF;\n\n DELETE FROM job_dependency\n WHERE parent_queue = v_pq AND parent_id = v_pid\n AND child_key = p_queue || ':' || p_id;\n GET DIAGNOSTICS v_deleted = ROW_COUNT;\n IF v_deleted = 0 THEN\n RETURN 1;\n END IF;\n\n -- Detach the child and release the parent if it has no more pending deps.\n UPDATE job\n SET parent_queue = NULL, parent_id = NULL, parent_key = NULL\n WHERE queue = p_queue AND id = p_id;\n -- Serialize with concurrent child resolutions of the same parent (see\n -- move_to_completed) to avoid a pending_deps deadlock.\n PERFORM pg_advisory_xact_lock(\n hashtext('bullmq:parent:' || v_pq || ':' || v_pid));\n UPDATE job SET pending_deps = GREATEST(pending_deps - 1, 0)\n WHERE queue = v_pq AND id = v_pid\n RETURNING pending_deps INTO v_remaining;\n IF v_remaining = 0 THEN\n PERFORM move_parent_to_wait(v_pq, v_pid, p_now);\n END IF;\n RETURN 0;\nEND;\n$$;\n\n-- ── removeUnprocessedChildren: recursively remove a parent's still-pending\n-- children (skipping active/locked ones), mirroring removeUnprocessedChildren-2.\n-- Emits 'removed' for each child taken out; cascades clean up dependency rows.\nCREATE FUNCTION remove_unprocessed_children(\n p_queue text, p_id text\n) RETURNS void\nLANGUAGE plpgsql\nSET search_path FROM CURRENT\nAS $$\nDECLARE\n r RECORD;\nBEGIN\n FOR r IN\n WITH RECURSIVE subtree AS (\n SELECT j.queue AS q, j.id AS id\n FROM job_dependency d\n JOIN job j ON j.queue = d.child_queue AND j.id = d.child_id\n WHERE d.parent_queue = p_queue AND d.parent_id = p_id\n AND d.status = 'pending'\n AND j.state NOT IN ('active', 'failed', 'completed')\n AND j.lock_token IS NULL\n UNION\n SELECT j.queue, j.id\n FROM subtree s\n JOIN job_dependency d\n ON d.parent_queue = s.q AND d.parent_id = s.id AND d.status = 'pending'\n JOIN job j ON j.queue = d.child_queue AND j.id = d.child_id\n WHERE j.state NOT IN ('active', 'failed', 'completed')\n AND j.lock_token IS NULL\n )\n SELECT q, id FROM subtree\n LOOP\n -- Decrement the (live) parent's pending counter for direct children.\n UPDATE job p SET pending_deps = GREATEST(pending_deps - 1, 0)\n FROM job_dependency d\n WHERE d.child_queue = r.q AND d.child_id = r.id AND d.status = 'pending'\n AND p.queue = d.parent_queue AND p.id = d.parent_id;\n DELETE FROM job WHERE queue = r.q AND id = r.id;\n PERFORM publish_event(r.q, 'removed',\n jsonb_build_object('jobId', r.id, 'prev', 'waiting'));\n END LOOP;\nEND;\n$$;\n\n-- BullMQ PostgreSQL backend — duplicated flow jobs (schema version 30).\n--\n-- When a flow (or a plain add with a parent) references a job id that already\n-- exists, BullMQ does not insert a second job; instead it re-attaches the\n-- existing job to the new parent (mirrors handleDuplicatedJob +\n-- updateExistingJobsParent):\n-- * if the existing job already has a *different* parent that still exists,\n-- the parent cannot be replaced → ErrorCode -7;\n-- * otherwise the existing job is linked to the new parent. A still-pending\n-- job becomes a pending dependency (incrementing the parent's counter); an\n-- already-completed job is recorded as a processed dependency (carrying its\n-- return value) and, if it clears the parent's last pending dependency, the\n-- parent is released.\nCREATE FUNCTION handle_duplicated_job(\n p_queue text,\n p_id text,\n p_parent_queue text,\n p_parent_id text,\n p_parent_key text,\n p_now bigint\n) RETURNS integer\nLANGUAGE plpgsql\nSET search_path FROM CURRENT\nAS $$\nDECLARE\n v_ex_pq text;\n v_ex_pid text;\n v_state job_state;\n v_rv jsonb;\n v_remaining integer;\n v_added boolean;\nBEGIN\n -- No new parent to attach: still a duplicate add, so announce it.\n IF p_parent_id IS NULL OR p_parent_queue IS NULL THEN\n PERFORM publish_event(p_queue, 'duplicated',\n jsonb_build_object('jobId', p_id));\n RETURN 0;\n END IF;\n\n SELECT parent_queue, parent_id, state, return_value\n INTO v_ex_pq, v_ex_pid, v_state, v_rv\n FROM job WHERE queue = p_queue AND id = p_id;\n\n -- The existing job already belongs to a different (still-existing) parent.\n IF v_ex_pq IS NOT NULL\n AND (v_ex_pq IS DISTINCT FROM p_parent_queue\n OR v_ex_pid IS DISTINCT FROM p_parent_id)\n AND EXISTS (\n SELECT 1 FROM job WHERE queue = v_ex_pq AND id = v_ex_pid\n ) THEN\n RETURN -7;\n END IF;\n\n IF v_state = 'completed' THEN\n -- Already finished: record a processed dependency (no pending increment)\n -- and release the parent if this was its last outstanding dependency.\n INSERT INTO job_dependency (\n parent_queue, parent_id, child_queue, child_id, child_key, status, value\n ) VALUES (\n p_parent_queue, p_parent_id, p_queue, p_id,\n p_queue || ':' || p_id, 'processed', v_rv\n )\n ON CONFLICT (parent_queue, parent_id, child_key)\n DO UPDATE SET status = 'processed', value = v_rv;\n\n SELECT pending_deps INTO v_remaining\n FROM job WHERE queue = p_parent_queue AND id = p_parent_id;\n IF COALESCE(v_remaining, 0) = 0 THEN\n PERFORM move_parent_to_wait(p_parent_queue, p_parent_id, p_now);\n END IF;\n ELSE\n -- Still pending: register a pending dependency and count it on the parent.\n INSERT INTO job_dependency (\n parent_queue, parent_id, child_queue, child_id, child_key, status\n ) VALUES (\n p_parent_queue, p_parent_id, p_queue, p_id,\n p_queue || ':' || p_id, 'pending'\n )\n ON CONFLICT (parent_queue, parent_id, child_key) DO NOTHING;\n GET DIAGNOSTICS v_added = ROW_COUNT;\n IF v_added THEN\n UPDATE job SET pending_deps = pending_deps + 1\n WHERE queue = p_parent_queue AND id = p_parent_id;\n END IF;\n END IF;\n\n -- Point the existing job at its new parent.\n UPDATE job\n SET parent_queue = p_parent_queue,\n parent_id = p_parent_id,\n parent_key = p_parent_key\n WHERE queue = p_queue AND id = p_id;\n\n PERFORM publish_event(p_queue, 'duplicated',\n jsonb_build_object('jobId', p_id));\n\n RETURN 0;\nEND;\n$$;\n\n-- Record one finished job into the queue/kind metrics (mirrors collectMetrics).\n-- `p_max` is the worker's `metrics.maxDataPoints`; `p_ts` the finish timestamp.\nCREATE FUNCTION collect_metrics(\n p_queue text, p_kind text, p_max integer, p_ts bigint\n) RETURNS void\nLANGUAGE plpgsql\nSET search_path FROM CURRENT\nAS $$\nDECLARE\n v_count bigint;\n v_prev_ts bigint;\n v_prev_count bigint;\n v_data bigint[];\n v_n bigint;\n v_delta bigint;\nBEGIN\n -- Increment the cumulative count; v_count is the value BEFORE this job\n -- (matches Lua's `HINCRBY count 1) - 1`).\n INSERT INTO metrics (queue, kind, count, prev_ts, prev_count, data)\n VALUES (p_queue, p_kind, 1, NULL, 0, '{}')\n ON CONFLICT (queue, kind) DO UPDATE SET count = metrics.count + 1\n RETURNING count - 1, prev_ts, prev_count, data\n INTO v_count, v_prev_ts, v_prev_count, v_data;\n\n -- First data point only establishes the baseline.\n IF v_prev_ts IS NULL THEN\n UPDATE metrics SET prev_ts = p_ts, prev_count = 0\n WHERE queue = p_queue AND kind = p_kind;\n RETURN;\n END IF;\n\n -- Number of one-minute buckets elapsed since the last data point, capped.\n v_n := LEAST(p_ts / 60000 - v_prev_ts / 60000, p_max);\n IF v_n > 0 THEN\n v_delta := v_count - v_prev_count;\n -- Prepend N-1 zeros for the skipped (empty) minutes, then the delta for the\n -- minute of the previous data point. Mirrors Redis collectMetrics, which\n -- LPUSHes the delta first and then the zeros, leaving the zeros newest and\n -- the delta oldest within this chunk (data is newest-first).\n IF v_n > 1 THEN\n v_data := array_fill(0::bigint, ARRAY[(v_n - 1)::int])\n || ARRAY[v_delta]\n || v_data;\n ELSE\n v_data := ARRAY[v_delta] || v_data;\n END IF;\n -- Trim to the max number of data points.\n v_data := v_data[1 : p_max];\n UPDATE metrics\n SET data = v_data, prev_count = v_count, prev_ts = p_ts\n WHERE queue = p_queue AND kind = p_kind;\n END IF;\nEND;\n$$;\n\n-- BullMQ PostgreSQL backend — fused finish+fetch (schema version 32).\n--\n-- Redis's `moveToFinished` completes (or fails) the current job AND returns the\n-- next job to process in a single round-trip / atomic script. The PostgreSQL\n-- backend previously did this as two separate calls — `move_to_completed` (or\n-- `move_to_failed`) followed by `move_to_active` — i.e. TWO transactions, hence\n-- two commits and two WAL fsyncs per processed job. Processing is commit-bound\n-- (each commit fsyncs the WAL under synchronous_commit=on), so paying two\n-- commits per job roughly halves throughput versus one.\n--\n-- These wrappers fuse the two into a single function call — one transaction, one\n-- commit — by simply invoking the existing, unchanged finish and claim functions\n-- in sequence. No logic is duplicated: `move_to_completed` /\n-- `move_to_failed` keep all their semantics (parent release, retention,\n-- dedup, events, retries), and `move_to_active` keeps all of its\n-- (delayed promotion, pause/concurrency/limiter checks, FOR UPDATE SKIP LOCKED\n-- claim). The finish runs first; if it raises (BM001 lock/state errors) the\n-- whole transaction rolls back and nothing is claimed — matching the old\n-- behaviour where a failed finish never fetched a next job. The next job is\n-- locked with the same worker token the finish used, exactly as the standalone\n-- `move_to_active` did.\n--\n-- The worker passes `fetchNext`; when false the backend still calls the plain\n-- finish functions, so these wrappers are only used on the hot processing loop.\n\n-- ──────────────────────────────────────────────────────────────────────────\n-- complete + claim next (0 or 1 job rows)\n-- ──────────────────────────────────────────────────────────────────────────\nCREATE FUNCTION move_to_completed_fetch(\n p_queue text,\n p_id text,\n p_token text,\n p_return_value jsonb,\n p_finished_on bigint,\n p_remove_all boolean,\n p_keep_age bigint,\n p_keep_count integer,\n p_lock_ms bigint,\n p_now bigint,\n p_name text,\n p_limiter_max integer,\n p_limiter_duration bigint\n) RETURNS SETOF job\nLANGUAGE plpgsql\nSET search_path FROM CURRENT\nAS $$\nBEGIN\n PERFORM move_to_completed(\n p_queue, p_id, p_token, p_return_value,\n p_finished_on, p_remove_all, p_keep_age, p_keep_count);\n\n RETURN QUERY SELECT * FROM move_to_active(\n p_queue, p_token, p_lock_ms, p_now, p_name, p_limiter_max, p_limiter_duration);\nEND;\n$$;\n\n-- ──────────────────────────────────────────────────────────────────────────\n-- fail (or retry) + claim next (0 or 1 job rows)\n-- ──────────────────────────────────────────────────────────────────────────\nCREATE FUNCTION move_to_failed_fetch(\n p_queue text,\n p_id text,\n p_token text,\n p_failed_reason text,\n p_stacktrace jsonb,\n p_finished_on bigint,\n p_remove_all boolean,\n p_keep_age bigint,\n p_keep_count integer,\n p_lock_ms bigint,\n p_now bigint,\n p_name text,\n p_limiter_max integer,\n p_limiter_duration bigint\n) RETURNS SETOF job\nLANGUAGE plpgsql\nSET search_path FROM CURRENT\nAS $$\nBEGIN\n PERFORM move_to_failed(\n p_queue, p_id, p_token, p_failed_reason, p_stacktrace,\n p_finished_on, p_remove_all, p_keep_age, p_keep_count);\n\n RETURN QUERY SELECT * FROM move_to_active(\n p_queue, p_token, p_lock_ms, p_now, p_name, p_limiter_max, p_limiter_duration);\nEND;\n$$;\n\n-- ──────────────────────────────────────────────────────────────────────────\n-- add_jobs_bulk: fast set-based bulk insert for INDEPENDENT jobs (no parents,\n-- no deduplication). This is the common Queue.add_bulk case. Unlike\n-- add_flow — which loops row-by-row to support flow trees, dedup and\n-- mixed states — this inserts the whole batch with a single set-based INSERT\n-- and emits the lifecycle events in one more set-based INSERT, so it is several\n-- times faster. Returns the job ids in input order. Callers must route flows or\n-- deduplicated jobs to add_flow instead.\nCREATE FUNCTION add_jobs_bulk(p_queue text, p_entries jsonb)\nRETURNS SETOF text\nLANGUAGE plpgsql\nSET search_path FROM CURRENT\nAS $$\nDECLARE\n v_seq_name text := job_id_seq_name(p_queue);\nBEGIN\n -- Ensure the per-queue id sequence exists (mirrors next_job_id).\n IF to_regclass(v_seq_name) IS NULL THEN\n PERFORM pg_advisory_xact_lock(hashtext('bullmq:jidseq:' || p_queue));\n EXECUTE format('CREATE SEQUENCE IF NOT EXISTS %I', v_seq_name);\n END IF;\n\n DROP TABLE IF EXISTS pg_temp._bulk_inserted;\n DROP TABLE IF EXISTS pg_temp._bulk_result;\n\n CREATE TEMP TABLE _bulk_result ON COMMIT DROP AS\n WITH elems AS (\n SELECT value AS j, ord\n FROM jsonb_array_elements(p_entries) WITH ORDINALITY AS e(value, ord)\n ),\n prepared AS (\n SELECT\n ord,\n COALESCE(NULLIF(j->>'id', ''), nextval(v_seq_name::regclass)::text) AS id,\n j->>'name' AS name,\n COALESCE((j->>'data')::jsonb, '{}'::jsonb) AS data,\n COALESCE(j->'opts', '{}'::jsonb) AS opts,\n COALESCE((j->>'priority')::integer, 0) AS priority,\n COALESCE((j->>'delay')::bigint, 0) AS delay,\n COALESCE((j->>'timestamp')::bigint, 0) AS ts,\n COALESCE((j->>'attempts')::integer, 1) AS attempts,\n j->>'schedulerId' AS scheduler_id,\n COALESCE((j->>'lifo')::boolean, false) AS lifo\n FROM elems\n ),\n -- Reserve one global FIFO seq per row and pair the ord-th smallest reserved\n -- value with the ord-th entry, so seq is strictly monotonic in input order\n -- regardless of evaluation order.\n reserved AS (\n SELECT ord, nextval('job_seq') AS s FROM prepared\n ),\n rr AS (SELECT s, row_number() OVER (ORDER BY s) AS rn FROM reserved),\n rp AS (SELECT prepared.*, row_number() OVER (ORDER BY ord) AS rn FROM prepared),\n final AS (\n SELECT rp.ord, rp.id, rp.name, rp.data, rp.opts, rp.priority, rp.delay,\n rp.ts, rp.attempts, rp.scheduler_id,\n CASE WHEN rp.lifo THEN -rr.s ELSE rr.s END AS seq,\n CASE WHEN rp.delay > 0 THEN 'delayed'::job_state\n ELSE 'waiting'::job_state END AS state,\n CASE WHEN rp.delay > 0 THEN rp.ts + rp.delay ELSE NULL END AS process_at\n FROM rp JOIN rr ON rp.rn = rr.rn\n )\n SELECT * FROM final ORDER BY ord;\n\n -- Insert the batch, capturing ONLY the rows actually inserted: ON CONFLICT\n -- skips ids that already exist and in-batch duplicate ids. Events and the\n -- wakeup are driven from this set, so a batch that repeats or reuses an id\n -- does not emit spurious added/waiting/delayed events or a bogus NOTIFY —\n -- mirroring add_flow's per-row `IF v_inserted` gating.\n CREATE TEMP TABLE _bulk_inserted ON COMMIT DROP AS\n WITH ins AS (\n INSERT INTO job (\n queue, id, seq, name, state, data, opts, priority, delay_ms, max_attempts,\n added_at_ms, process_at_ms, scheduler_id, pending_deps\n )\n SELECT p_queue, id, seq, name, state, data, opts, priority, delay, attempts,\n ts, process_at, scheduler_id, 0\n FROM _bulk_result\n ON CONFLICT (queue, id) DO NOTHING\n RETURNING id, seq, name, state, process_at_ms\n )\n SELECT * FROM ins;\n\n -- One set-based event flush ('added' then the state event per inserted job),\n -- ordered by each inserted job's first input ordinality so stream ordering\n -- matches caller payload order even when seq is negated by LIFO.\n INSERT INTO event (queue, event, data, created_at_ms)\n WITH inserted AS (\n SELECT i.id, i.name, i.state, i.process_at_ms, MIN(r.ord) AS ord\n FROM _bulk_inserted i\n JOIN _bulk_result r USING (id)\n GROUP BY i.id, i.name, i.state, i.process_at_ms\n )\n SELECT p_queue, ev, dat, (extract(epoch FROM clock_timestamp()) * 1000)::bigint\n FROM (\n SELECT ord * 2 AS o, 'added' AS ev,\n jsonb_build_object('jobId', id, 'name', name) AS dat\n FROM inserted\n UNION ALL\n SELECT ord * 2 + 1,\n CASE WHEN state = 'waiting' THEN 'waiting' ELSE 'delayed' END,\n CASE WHEN state = 'waiting'\n THEN jsonb_build_object('jobId', id)\n ELSE jsonb_build_object('jobId', id, 'delay', process_at_ms) END\n FROM inserted\n ) q\n ORDER BY o;\n\n IF EXISTS (SELECT 1 FROM _bulk_inserted) THEN\n PERFORM pg_notify('bullmq_jobs', p_queue);\n END IF;\n\n RETURN QUERY SELECT id FROM _bulk_result ORDER BY ord;\nEND;\n$$;\n",
106
+ "0003_dedup_stale_key.sql": "-- BullMQ PostgreSQL backend — deduplication stale-key recovery.\n--\n-- Replaces `deduplicate_job` (created in 0002_functions.sql) to fix two ways a\n-- deduplication key could outlive the job it points at:\n--\n-- 1. Stale key: a key with no expiry is meant to disappear when its winner\n-- finishes, but `clean`, `drain`, `obliterate` (and outages) delete the\n-- job row without clearing the `dedup` row. Every later add then matched\n-- that dead winner and was silently dropped, forever. Mirrors the Redis\n-- `recoverStaleDeduplicationKey` include.\n-- 2. Orphaned proto-next: replacing a *delayed* winner left behind any\n-- `dedup_next` payload stashed while that winner was previously active,\n-- so it got resurrected when the replacement finished.\n--\n-- Only the function body changes — no schema/DDL change — so the signature is\n-- identical and older clients keep working against the updated schema.\n\nCREATE OR REPLACE FUNCTION deduplicate_job(\n p_queue text, p_dedup jsonb, p_job_id text, p_now bigint,\n p_name text, p_data jsonb, p_opts jsonb\n) RETURNS text\nLANGUAGE plpgsql\nSET search_path FROM CURRENT\nAS $$\nDECLARE\n v_id text := p_dedup->>'id';\n v_ttl bigint := NULLIF(p_dedup->>'ttl', '')::bigint;\n v_extend boolean := COALESCE((p_dedup->>'extend')::boolean, false);\n v_replace boolean := COALESCE((p_dedup->>'replace')::boolean, false);\n v_keeplast boolean := COALESCE((p_dedup->>'keepLastIfActive')::boolean, false);\n v_cur text;\n v_exp bigint;\n v_state job_state;\nBEGIN\n IF v_id IS NULL OR v_id = '' THEN\n RETURN NULL;\n END IF;\n\n -- The current winner (only if the key is still live).\n SELECT job_id, expire_at_ms INTO v_cur, v_exp\n FROM dedup WHERE queue = p_queue AND dedup_id = v_id;\n IF v_cur IS NULL OR (v_exp IS NOT NULL AND v_exp <= p_now) THEN\n v_cur := NULL;\n ELSIF v_exp IS NULL AND NOT EXISTS (\n SELECT 1 FROM job WHERE queue = p_queue AND id = v_cur\n ) THEN\n -- Stale key recovery (mirrors recoverStaleDeduplicationKey): a key with no\n -- expiry is meant to disappear when its winner finishes. If the job row is\n -- gone (clean, drain, obliterate, an outage…) the key is stale, so discard\n -- it together with any pending proto-next (which would otherwise be\n -- resurrected) and start a new window with the incoming job.\n DELETE FROM dedup WHERE queue = p_queue AND dedup_id = v_id;\n DELETE FROM dedup_next WHERE queue = p_queue AND dedup_id = v_id;\n v_cur := NULL;\n END IF;\n\n IF v_replace THEN\n IF v_cur IS NOT NULL THEN\n SELECT state INTO v_state\n FROM job WHERE queue = p_queue AND id = v_cur;\n IF v_state = 'delayed' THEN\n -- Drop the previous delayed job and take its place.\n DELETE FROM job WHERE queue = p_queue AND id = v_cur;\n PERFORM publish_event(p_queue, 'removed',\n jsonb_build_object('jobId', v_cur, 'prev', 'delayed'));\n PERFORM publish_event(p_queue, 'deduplicated',\n jsonb_build_object('jobId', p_job_id, 'deduplicationId', v_id,\n 'deduplicatedJobId', v_cur));\n -- Discard any pending proto-next stashed while the replaced job was\n -- active, otherwise it would be resurrected when p_job_id finalizes.\n DELETE FROM dedup_next WHERE queue = p_queue AND dedup_id = v_id;\n IF v_keeplast THEN\n UPDATE dedup SET job_id = p_job_id, expire_at_ms = NULL\n WHERE queue = p_queue AND dedup_id = v_id;\n ELSIF NOT v_extend AND COALESCE(v_ttl, 0) > 0 THEN\n -- KEEPTTL: keep the existing window, just swap the winner.\n UPDATE dedup SET job_id = p_job_id\n WHERE queue = p_queue AND dedup_id = v_id;\n ELSE\n UPDATE dedup\n SET job_id = p_job_id,\n expire_at_ms = CASE WHEN COALESCE(v_ttl, 0) > 0\n THEN p_now + v_ttl ELSE NULL END\n WHERE queue = p_queue AND dedup_id = v_id;\n END IF;\n RETURN NULL;\n ELSE\n -- Winner is not a removable delayed job: stash proto-next if it is\n -- active + keepLastIfActive, then deduplicate.\n PERFORM dedup_store_next(p_queue, v_id, v_cur, p_job_id,\n v_keeplast, p_name, p_data, p_opts);\n RETURN v_cur;\n END IF;\n ELSE\n INSERT INTO dedup (queue, dedup_id, job_id, expire_at_ms)\n VALUES (p_queue, v_id, p_job_id,\n CASE WHEN NOT v_keeplast AND COALESCE(v_ttl, 0) > 0\n THEN p_now + v_ttl ELSE NULL END)\n ON CONFLICT (queue, dedup_id) DO UPDATE\n SET job_id = EXCLUDED.job_id, expire_at_ms = EXCLUDED.expire_at_ms;\n RETURN NULL;\n END IF;\n END IF;\n\n -- Without replace.\n IF COALESCE(v_ttl, 0) > 0 AND v_extend THEN\n IF v_cur IS NOT NULL THEN\n -- Stash proto-next if active+keepLast; else extend the window (or\n -- persist when keepLastIfActive). Either way keep the current winner.\n IF NOT dedup_store_next(p_queue, v_id, v_cur, p_job_id,\n v_keeplast, p_name, p_data, p_opts) THEN\n UPDATE dedup\n SET expire_at_ms = CASE WHEN v_keeplast THEN NULL ELSE p_now + v_ttl END\n WHERE queue = p_queue AND dedup_id = v_id;\n END IF;\n PERFORM publish_event(p_queue, 'deduplicated',\n jsonb_build_object('jobId', v_cur, 'deduplicationId', v_id,\n 'deduplicatedJobId', p_job_id));\n RETURN v_cur;\n END IF;\n INSERT INTO dedup (queue, dedup_id, job_id, expire_at_ms)\n VALUES (p_queue, v_id, p_job_id,\n CASE WHEN v_keeplast THEN NULL ELSE p_now + v_ttl END)\n ON CONFLICT (queue, dedup_id) DO UPDATE\n SET job_id = EXCLUDED.job_id, expire_at_ms = EXCLUDED.expire_at_ms;\n RETURN NULL;\n END IF;\n\n -- SET NX semantics (ttl>0 non-extend, or no ttl at all).\n IF v_cur IS NOT NULL THEN\n PERFORM dedup_store_next(p_queue, v_id, v_cur, p_job_id,\n v_keeplast, p_name, p_data, p_opts);\n PERFORM publish_event(p_queue, 'deduplicated',\n jsonb_build_object('jobId', v_cur, 'deduplicationId', v_id,\n 'deduplicatedJobId', p_job_id));\n RETURN v_cur;\n END IF;\n\n INSERT INTO dedup (queue, dedup_id, job_id, expire_at_ms)\n VALUES (p_queue, v_id, p_job_id,\n CASE WHEN NOT v_keeplast AND COALESCE(v_ttl, 0) > 0\n THEN p_now + v_ttl ELSE NULL END)\n ON CONFLICT (queue, dedup_id) DO UPDATE\n SET job_id = EXCLUDED.job_id, expire_at_ms = EXCLUDED.expire_at_ms;\n RETURN NULL;\nEND;\n$$;\n",
107
+ };
@@ -72,6 +72,25 @@ local function isQueuePausedOrMaxed(queueMetaKey, activeKey)
72
72
  end
73
73
  return false
74
74
  end
75
+ --[[
76
+ Function to move a retried child back to its parent's dependencies.
77
+ This does not move a parent that has already left waiting-children back to that state.
78
+ ]]
79
+ local function moveChildBackToParentDependencies(jobKey, prevState)
80
+ local parentKey = rcall("HGET", jobKey, "parentKey")
81
+ if parentKey and rcall("EXISTS", parentKey) == 1 then
82
+ if prevState == "failed" then
83
+ if rcall("ZREM", parentKey .. ":unsuccessful", jobKey) == 1 or
84
+ rcall("HDEL", parentKey .. ":failed", jobKey) == 1 then
85
+ rcall("SADD", parentKey .. ":dependencies", jobKey)
86
+ end
87
+ else
88
+ if rcall("HDEL", parentKey .. ":processed", jobKey) == 1 then
89
+ rcall("SADD", parentKey .. ":dependencies", jobKey)
90
+ end
91
+ end
92
+ end
93
+ end
75
94
  local metaKey = KEYS[6]
76
95
  local isPausedOrMaxed = isQueuePausedOrMaxed(metaKey, KEYS[7])
77
96
  local jobs = rcall('ZRANGEBYSCORE', KEYS[3], 0, timestamp, 'LIMIT', 0, maxCount)
@@ -80,11 +99,13 @@ if (#jobs > 0) then
80
99
  for i, key in ipairs(jobs) do
81
100
  local jobKey = KEYS[1] .. key
82
101
  rcall("HDEL", jobKey, "finishedOn", "processedOn", "failedReason")
102
+ moveChildBackToParentDependencies(jobKey, ARGV[3])
83
103
  end
84
104
  elseif ARGV[3] == "completed" then
85
105
  for i, key in ipairs(jobs) do
86
106
  local jobKey = KEYS[1] .. key
87
107
  rcall("HDEL", jobKey, "finishedOn", "processedOn", "returnvalue")
108
+ moveChildBackToParentDependencies(jobKey, ARGV[3])
88
109
  end
89
110
  end
90
111
  local maxEvents = getOrSetMaxEvents(metaKey)