pgbus 0.9.8 → 0.9.9

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 (36) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +329 -0
  3. data/README.md +336 -25
  4. data/lib/generators/pgbus/{add_job_locks_generator.rb → add_uniqueness_keys_generator.rb} +5 -5
  5. data/lib/pgbus/active_job/adapter.rb +1 -1
  6. data/lib/pgbus/config_loader.rb +29 -2
  7. data/lib/pgbus/configuration.rb +202 -73
  8. data/lib/pgbus/doctor.rb +28 -3
  9. data/lib/pgbus/execution_pools/async_pool.rb +2 -2
  10. data/lib/pgbus/generators/config_converter.rb +7 -5
  11. data/lib/pgbus/generators/migration_detector.rb +20 -16
  12. data/lib/pgbus/instrumentation.rb +2 -1
  13. data/lib/pgbus/integrations/appsignal/subscriber.rb +17 -2
  14. data/lib/pgbus/metrics/subscriber.rb +26 -2
  15. data/lib/pgbus/metrics.rb +3 -2
  16. data/lib/pgbus/pgmq_schema/pgmq_v1.11.1.sql +2126 -0
  17. data/lib/pgbus/pgmq_schema.rb +7 -2
  18. data/lib/pgbus/process/consumer.rb +141 -12
  19. data/lib/pgbus/process/supervisor.rb +33 -10
  20. data/lib/pgbus/process/worker.rb +6 -16
  21. data/lib/pgbus/recurring/schedule.rb +1 -2
  22. data/lib/pgbus/serializer.rb +4 -4
  23. data/lib/pgbus/streams/broadcastable_override.rb +0 -8
  24. data/lib/pgbus/streams/signed_name.rb +2 -2
  25. data/lib/pgbus/uniqueness.rb +11 -12
  26. data/lib/pgbus/version.rb +1 -1
  27. data/lib/pgbus/web/data_source.rb +20 -4
  28. data/lib/pgbus/web/payload_filter.rb +3 -3
  29. data/lib/pgbus/web/streamer/instance.rb +6 -4
  30. data/lib/pgbus/web/streamer/listener.rb +21 -36
  31. data/lib/pgbus.rb +46 -6
  32. metadata +4 -6
  33. data/app/models/pgbus/job_lock.rb +0 -98
  34. data/lib/generators/pgbus/templates/add_job_locks.rb.erb +0 -21
  35. data/lib/rubocop/cop/pgbus/no_ruby_timeout.rb +0 -42
  36. data/lib/rubocop/pgbus.rb +0 -5
@@ -0,0 +1,2126 @@
1
+ ------------------------------------------------------------
2
+ -- Schema, tables, records, privileges, indexes, etc
3
+ ------------------------------------------------------------
4
+ -- When installed as an extension, we don't need to create the `pgmq` schema
5
+ -- because it is automatically created by postgres due to being declared in
6
+ -- the extension control file
7
+ DO
8
+ $$
9
+ BEGIN
10
+ IF (SELECT NOT EXISTS( SELECT 1 FROM pg_extension WHERE extname = 'pgmq')) THEN
11
+ CREATE SCHEMA IF NOT EXISTS pgmq;
12
+ END IF;
13
+ END
14
+ $$;
15
+
16
+ -- Table where queues and metadata about them is stored
17
+ CREATE TABLE IF NOT EXISTS pgmq.meta (
18
+ queue_name VARCHAR UNIQUE NOT NULL,
19
+ is_partitioned BOOLEAN NOT NULL,
20
+ is_unlogged BOOLEAN NOT NULL,
21
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL
22
+ );
23
+
24
+ -- Grant permission to pg_monitor to all tables and sequences
25
+ -- These grants are intentionally placed here (after creating `pgmq.meta` but before creating other tables). This
26
+ -- allows the `pg_dump` output for a fresh installation to match the output for an installation that followed the
27
+ -- upgrade path.
28
+ GRANT USAGE ON SCHEMA pgmq TO pg_monitor;
29
+ GRANT SELECT ON ALL TABLES IN SCHEMA pgmq TO pg_monitor;
30
+ GRANT SELECT ON ALL SEQUENCES IN SCHEMA pgmq TO pg_monitor;
31
+ ALTER DEFAULT PRIVILEGES IN SCHEMA pgmq GRANT SELECT ON TABLES TO pg_monitor;
32
+ ALTER DEFAULT PRIVILEGES IN SCHEMA pgmq GRANT SELECT ON SEQUENCES TO pg_monitor;
33
+
34
+ -- Table to track notification throttling for queues
35
+ CREATE UNLOGGED TABLE IF NOT EXISTS pgmq.notify_insert_throttle (
36
+ queue_name VARCHAR UNIQUE NOT NULL -- Queue name (without 'q_' prefix)
37
+ CONSTRAINT notify_insert_throttle_meta_queue_name_fk
38
+ REFERENCES pgmq.meta (queue_name)
39
+ ON DELETE CASCADE,
40
+ throttle_interval_ms INTEGER NOT NULL DEFAULT 0, -- Min milliseconds between notifications (0 = no throttling)
41
+ last_notified_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT to_timestamp(0) -- Timestamp of last sent notification
42
+ );
43
+
44
+ CREATE INDEX IF NOT EXISTS idx_notify_throttle_active
45
+ ON pgmq.notify_insert_throttle (queue_name, last_notified_at)
46
+ WHERE throttle_interval_ms > 0;
47
+
48
+ CREATE TABLE IF NOT EXISTS pgmq.topic_bindings
49
+ (
50
+ pattern text NOT NULL, -- Wildcard pattern for routing key matching (* = one segment, # = zero or more segments)
51
+ queue_name text NOT NULL -- Name of the queue that receives messages when pattern matches
52
+ CONSTRAINT topic_bindings_meta_queue_name_fk
53
+ REFERENCES pgmq.meta (queue_name)
54
+ ON DELETE CASCADE,
55
+ bound_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL, -- Timestamp when the binding was created
56
+ compiled_regex text GENERATED ALWAYS AS (
57
+ -- Pre-compile the pattern to regex for faster matching
58
+ -- This avoids runtime compilation on every send_topic call
59
+ '^' ||
60
+ replace(
61
+ replace(
62
+ regexp_replace(pattern, '([.+?{}()|\[\]\\^$])', '\\\1', 'g'),
63
+ '*', '[^.]+'
64
+ ),
65
+ '#', '.*'
66
+ ) || '$'
67
+ ) STORED, -- Computed column: stores the compiled regex pattern
68
+ CONSTRAINT topic_bindings_unique_pattern_queue UNIQUE (pattern, queue_name)
69
+ );
70
+
71
+ -- Create covering index for better performance when scanning patterns
72
+ -- Includes queue_name and compiled_regex to allow index-only scans (no table access needed)
73
+ CREATE INDEX IF NOT EXISTS idx_topic_bindings_covering ON pgmq.topic_bindings (pattern) INCLUDE (queue_name, compiled_regex);
74
+
75
+ -- Allow the following `pgmq` tables to be dumped by `pg_dump` when pgmq is installed as an extension
76
+ DO
77
+ $$
78
+ BEGIN
79
+ IF EXISTS(SELECT 1 FROM pg_extension WHERE extname = 'pgmq') THEN
80
+ PERFORM pg_catalog.pg_extension_config_dump('pgmq.meta', '');
81
+ PERFORM pg_catalog.pg_extension_config_dump('pgmq.notify_insert_throttle', '');
82
+ PERFORM pg_catalog.pg_extension_config_dump('pgmq.topic_bindings', '');
83
+ END IF;
84
+ END
85
+ $$;
86
+
87
+ -- This type has the shape of a message in a queue, and is often returned by
88
+ -- pgmq functions that return messages
89
+ CREATE TYPE pgmq.message_record AS (
90
+ msg_id BIGINT,
91
+ read_ct INTEGER,
92
+ enqueued_at TIMESTAMP WITH TIME ZONE,
93
+ last_read_at TIMESTAMP WITH TIME ZONE,
94
+ vt TIMESTAMP WITH TIME ZONE,
95
+ message JSONB,
96
+ headers JSONB
97
+ );
98
+
99
+ CREATE TYPE pgmq.queue_record AS (
100
+ queue_name VARCHAR,
101
+ is_partitioned BOOLEAN,
102
+ is_unlogged BOOLEAN,
103
+ created_at TIMESTAMP WITH TIME ZONE
104
+ );
105
+
106
+ ------------------------------------------------------------
107
+ -- Functions
108
+ ------------------------------------------------------------
109
+
110
+ -- prevents race conditions during queue creation by acquiring a transaction-level advisory lock
111
+ -- uses a transaction advisory lock maintain the lock until transaction commit
112
+ -- a race condition would still exist if lock was released before commit
113
+ CREATE FUNCTION pgmq.acquire_queue_lock(queue_name TEXT)
114
+ RETURNS void AS $$
115
+ BEGIN
116
+ PERFORM pg_advisory_xact_lock(hashtext('pgmq.queue_' || queue_name));
117
+ END;
118
+ $$ LANGUAGE plpgsql;
119
+
120
+ -- read_grouped_round_robin
121
+ -- reads messages while preserving FIFO within groups and interleaving across groups (layered round-robin)
122
+ CREATE FUNCTION pgmq.read_grouped_rr(
123
+ queue_name TEXT,
124
+ vt INTEGER,
125
+ qty INTEGER
126
+ )
127
+ RETURNS SETOF pgmq.message_record AS $$
128
+ DECLARE
129
+ sql TEXT;
130
+ qtable TEXT := pgmq.format_table_name(queue_name, 'q');
131
+ BEGIN
132
+ sql := FORMAT(
133
+ $QUERY$
134
+ WITH fifo_groups AS (
135
+ -- Determine the absolute head (oldest) message id per FIFO group, regardless of visibility
136
+ SELECT
137
+ COALESCE(headers->>'x-pgmq-group', '_default_fifo_group') AS fifo_key,
138
+ MIN(msg_id) AS head_msg_id
139
+ FROM pgmq.%1$I
140
+ GROUP BY COALESCE(headers->>'x-pgmq-group', '_default_fifo_group')
141
+ ),
142
+ eligible_groups AS (
143
+ -- Only groups whose head message is currently visible
144
+ -- Acquire a transaction-level advisory lock per group to prevent concurrent selection
145
+ SELECT
146
+ g.fifo_key,
147
+ g.head_msg_id,
148
+ ROW_NUMBER() OVER (ORDER BY g.head_msg_id) AS group_priority
149
+ FROM fifo_groups g
150
+ JOIN pgmq.%2$I h ON h.msg_id = g.head_msg_id
151
+ WHERE h.vt <= clock_timestamp()
152
+ AND pg_try_advisory_xact_lock(pg_catalog.hashtextextended(g.fifo_key, 0))
153
+ ),
154
+ available_messages AS (
155
+ -- All currently visible messages starting at the head for each eligible group
156
+ SELECT
157
+ m.msg_id,
158
+ eg.group_priority,
159
+ ROW_NUMBER() OVER (
160
+ PARTITION BY eg.fifo_key
161
+ ORDER BY m.msg_id
162
+ ) AS msg_rank_in_group
163
+ FROM pgmq.%3$I m
164
+ JOIN eligible_groups eg
165
+ ON COALESCE(m.headers->>'x-pgmq-group', '_default_fifo_group') = eg.fifo_key
166
+ WHERE m.vt <= clock_timestamp()
167
+ AND m.msg_id >= eg.head_msg_id
168
+ ),
169
+ ordered_messages AS (
170
+ -- Layered round-robin: take rank 1 of all groups by group_priority, then rank 2, etc.
171
+ -- Assign selection order before locking
172
+ SELECT msg_id, ROW_NUMBER() OVER (ORDER BY msg_rank_in_group, group_priority) as selection_order
173
+ FROM available_messages
174
+ ),
175
+ selected_messages AS (
176
+ -- Lock the messages in the correct order, preserving selection_order
177
+ SELECT om.msg_id, om.selection_order
178
+ FROM ordered_messages om
179
+ JOIN pgmq.%4$I m ON m.msg_id = om.msg_id
180
+ WHERE om.selection_order <= $1
181
+ ORDER BY om.selection_order
182
+ FOR UPDATE OF m SKIP LOCKED
183
+ ),
184
+ updated_messages AS (
185
+ UPDATE pgmq.%5$I m
186
+ SET
187
+ vt = clock_timestamp() + %6$L,
188
+ read_ct = read_ct + 1,
189
+ last_read_at = clock_timestamp()
190
+ FROM selected_messages sm
191
+ WHERE m.msg_id = sm.msg_id
192
+ AND m.vt <= clock_timestamp() -- final guard to avoid duplicate reads under races
193
+ RETURNING m.msg_id, m.read_ct, m.enqueued_at, m.last_read_at, m.vt, m.message, m.headers, sm.selection_order
194
+ )
195
+ SELECT msg_id, read_ct, enqueued_at, last_read_at, vt, message, headers
196
+ FROM updated_messages
197
+ ORDER BY selection_order;
198
+ $QUERY$,
199
+ qtable, qtable, qtable, qtable, qtable, make_interval(secs => vt)
200
+ );
201
+ RETURN QUERY EXECUTE sql USING qty;
202
+ END;
203
+ $$ LANGUAGE plpgsql;
204
+
205
+ -- read_grouped_rr_with_poll
206
+ -- reads messages using round-robin layering across groups, with polling support
207
+ CREATE FUNCTION pgmq.read_grouped_rr_with_poll(
208
+ queue_name TEXT,
209
+ vt INTEGER,
210
+ qty INTEGER,
211
+ max_poll_seconds INTEGER DEFAULT 5,
212
+ poll_interval_ms INTEGER DEFAULT 100
213
+ )
214
+ RETURNS SETOF pgmq.message_record AS $$
215
+ DECLARE
216
+ r pgmq.message_record;
217
+ stop_at TIMESTAMP;
218
+ BEGIN
219
+ stop_at := clock_timestamp() + make_interval(secs => max_poll_seconds);
220
+ LOOP
221
+ IF (SELECT clock_timestamp() >= stop_at) THEN
222
+ RETURN;
223
+ END IF;
224
+
225
+ FOR r IN
226
+ SELECT * FROM pgmq.read_grouped_rr(queue_name, vt, qty)
227
+ LOOP
228
+ RETURN NEXT r;
229
+ END LOOP;
230
+ IF FOUND THEN
231
+ RETURN;
232
+ ELSE
233
+ PERFORM pg_sleep(poll_interval_ms::numeric / 1000);
234
+ END IF;
235
+ END LOOP;
236
+ END;
237
+ $$ LANGUAGE plpgsql;
238
+
239
+ -- read_grouped_head: read the head of N different FIFO groups in a single operation.
240
+ -- This supports horizontal scaling by processing groups in parallel while ensuring message ordering is preserved per group.
241
+ CREATE FUNCTION pgmq.read_grouped_head(
242
+ queue_name TEXT,
243
+ vt INTEGER,
244
+ qty INTEGER
245
+ )
246
+ RETURNS SETOF pgmq.message_record AS $$
247
+ DECLARE
248
+ sql TEXT;
249
+ qtable TEXT := pgmq.format_table_name(queue_name, 'q');
250
+ BEGIN
251
+ sql := FORMAT(
252
+ $QUERY$
253
+ WITH fifo_groups AS (
254
+ -- Determine the absolute head (oldest) message id per FIFO group, regardless of visibility
255
+ SELECT
256
+ COALESCE(headers->>'x-pgmq-group', '_default_fifo_group') AS fifo_key,
257
+ MIN(msg_id) AS head_msg_id
258
+ FROM pgmq.%1$I
259
+ GROUP BY COALESCE(headers->>'x-pgmq-group', '_default_fifo_group')
260
+ ),
261
+ selected_messages AS (
262
+ -- Take at most 1 message per group
263
+ SELECT g.head_msg_id msg_id
264
+ FROM fifo_groups g
265
+ JOIN pgmq.%1$I q ON q.msg_id = g.head_msg_id
266
+ WHERE q.vt <= clock_timestamp()
267
+ ORDER BY q.msg_id
268
+ LIMIT $1
269
+ FOR UPDATE SKIP LOCKED
270
+ )
271
+ UPDATE pgmq.%1$I m
272
+ SET
273
+ vt = clock_timestamp() + %2$L,
274
+ read_ct = read_ct + 1,
275
+ last_read_at = clock_timestamp()
276
+ FROM selected_messages sm
277
+ WHERE m.msg_id = sm.msg_id
278
+ RETURNING m.msg_id, m.read_ct, m.enqueued_at, m.last_read_at, m.vt, m.message, m.headers;
279
+ $QUERY$,
280
+ qtable, make_interval(secs => vt)
281
+ );
282
+ RETURN QUERY EXECUTE sql USING qty;
283
+ END;
284
+ $$ LANGUAGE plpgsql;
285
+
286
+ -- a helper to format table names and check for invalid characters
287
+ CREATE FUNCTION pgmq.format_table_name(queue_name text, prefix text)
288
+ RETURNS TEXT AS $$
289
+ BEGIN
290
+ IF queue_name ~ '\$|;|--|'''
291
+ THEN
292
+ RAISE EXCEPTION 'queue name contains invalid characters: $, ;, --, or \''';
293
+ END IF;
294
+ RETURN lower(prefix || '_' || queue_name);
295
+ END;
296
+ $$ LANGUAGE plpgsql;
297
+
298
+ -- read
299
+ -- reads a number of messages from a queue, setting a visibility timeout on them
300
+ CREATE FUNCTION pgmq.read(
301
+ queue_name TEXT,
302
+ vt INTEGER,
303
+ qty INTEGER,
304
+ conditional JSONB DEFAULT '{}'
305
+ )
306
+ RETURNS SETOF pgmq.message_record AS $$
307
+ DECLARE
308
+ sql TEXT;
309
+ qtable TEXT := pgmq.format_table_name(queue_name, 'q');
310
+ BEGIN
311
+ sql := FORMAT(
312
+ $QUERY$
313
+ WITH cte AS
314
+ (
315
+ SELECT msg_id
316
+ FROM pgmq.%I
317
+ WHERE vt <= clock_timestamp() AND CASE
318
+ WHEN %L != '{}'::jsonb THEN (message @> %2$L)::integer
319
+ ELSE 1
320
+ END = 1
321
+ ORDER BY msg_id ASC
322
+ LIMIT $1
323
+ FOR UPDATE SKIP LOCKED
324
+ )
325
+ UPDATE pgmq.%I m
326
+ SET
327
+ last_read_at = clock_timestamp(),
328
+ vt = clock_timestamp() + %L,
329
+ read_ct = read_ct + 1
330
+ FROM cte
331
+ WHERE m.msg_id = cte.msg_id
332
+ RETURNING m.msg_id, m.read_ct, m.enqueued_at, m.last_read_at, m.vt, m.message, m.headers;
333
+ $QUERY$,
334
+ qtable, conditional, qtable, make_interval(secs => vt)
335
+ );
336
+ RETURN QUERY EXECUTE sql USING qty;
337
+ END;
338
+ $$ LANGUAGE plpgsql;
339
+
340
+ -- read_grouped
341
+ -- reads messages with AWS SQS FIFO-style batch retrieval behavior
342
+ -- attempts to return as many messages as possible from the same message group
343
+ CREATE FUNCTION pgmq.read_grouped(
344
+ queue_name TEXT,
345
+ vt INTEGER,
346
+ qty INTEGER
347
+ )
348
+ RETURNS SETOF pgmq.message_record AS $$
349
+ DECLARE
350
+ sql TEXT;
351
+ qtable TEXT := pgmq.format_table_name(queue_name, 'q');
352
+ BEGIN
353
+ sql := FORMAT(
354
+ $QUERY$
355
+ WITH fifo_groups AS (
356
+ -- Find the minimum msg_id for each FIFO group that's ready to be processed
357
+ SELECT
358
+ COALESCE(headers->>'x-pgmq-group', '_default_fifo_group') as fifo_key,
359
+ MIN(msg_id) as min_msg_id
360
+ FROM pgmq.%I
361
+ WHERE vt <= clock_timestamp()
362
+ GROUP BY COALESCE(headers->>'x-pgmq-group', '_default_fifo_group')
363
+ ),
364
+ locked_groups AS (
365
+ -- Lock the first available message in each FIFO group
366
+ SELECT
367
+ m.msg_id,
368
+ fg.fifo_key
369
+ FROM pgmq.%I m
370
+ INNER JOIN fifo_groups fg ON
371
+ COALESCE(m.headers->>'x-pgmq-group', '_default_fifo_group') = fg.fifo_key
372
+ AND m.msg_id = fg.min_msg_id
373
+ WHERE m.vt <= clock_timestamp()
374
+ ORDER BY m.msg_id ASC
375
+ FOR UPDATE SKIP LOCKED
376
+ ),
377
+ group_priorities AS (
378
+ -- Assign priority to groups based on their oldest message
379
+ SELECT
380
+ fifo_key,
381
+ msg_id as min_msg_id,
382
+ ROW_NUMBER() OVER (ORDER BY msg_id) as group_priority
383
+ FROM locked_groups
384
+ ),
385
+ filtered_groups as (
386
+ SELECT * FROM group_priorities gp
387
+ WHERE NOT EXISTS (
388
+ -- Ensure no earlier message in this group is currently being processed
389
+ SELECT 1
390
+ FROM pgmq.%I m2
391
+ WHERE COALESCE(m2.headers->>'x-pgmq-group', '_default_fifo_group') = gp.fifo_key
392
+ AND m2.vt > clock_timestamp()
393
+ AND m2.msg_id < gp.min_msg_id
394
+ )
395
+ ),
396
+ available_messages as (
397
+ SELECT gp.fifo_key, t.msg_id,gp.group_priority,
398
+ ROW_NUMBER() OVER (PARTITION BY gp.fifo_key ORDER BY t.msg_id) as msg_rank_in_group
399
+ FROM filtered_groups gp
400
+ CROSS JOIN LATERAL (
401
+ SELECT *
402
+ FROM pgmq.%I t
403
+ WHERE COALESCE(t.headers->>'x-pgmq-group', '_default_fifo_group') = gp.fifo_key
404
+ AND t.vt <= clock_timestamp()
405
+ ORDER BY msg_id
406
+ LIMIT $1 -- tip to limit query impact, we know we need at most qty in each group
407
+ ) t
408
+ ORDER BY gp.group_priority
409
+ ),
410
+ batch_selection AS (
411
+ -- Select messages to fill batch, prioritizing earliest group
412
+ SELECT
413
+ msg_id,
414
+ ROW_NUMBER() OVER (ORDER BY group_priority, msg_rank_in_group) as overall_rank
415
+ FROM available_messages
416
+ ),
417
+ selected_messages AS (
418
+ -- Limit to requested quantity
419
+ SELECT msg_id
420
+ FROM batch_selection
421
+ WHERE overall_rank <= $1
422
+ ORDER BY msg_id
423
+ FOR UPDATE SKIP LOCKED
424
+ )
425
+ UPDATE pgmq.%I m
426
+ SET
427
+ vt = clock_timestamp() + %L,
428
+ read_ct = read_ct + 1,
429
+ last_read_at = clock_timestamp()
430
+ FROM selected_messages sm
431
+ WHERE m.msg_id = sm.msg_id
432
+ RETURNING m.msg_id, m.read_ct, m.enqueued_at, m.last_read_at, m.vt, m.message, m.headers;
433
+ $QUERY$,
434
+ qtable, qtable, qtable, qtable, qtable, make_interval(secs => vt)
435
+ );
436
+ RETURN QUERY EXECUTE sql USING qty;
437
+ END;
438
+ $$ LANGUAGE plpgsql;
439
+
440
+ -- read_grouped_with_poll
441
+ -- reads messages with AWS SQS FIFO-style batch retrieval behavior, with polling support
442
+ CREATE FUNCTION pgmq.read_grouped_with_poll(
443
+ queue_name TEXT,
444
+ vt INTEGER,
445
+ qty INTEGER,
446
+ max_poll_seconds INTEGER DEFAULT 5,
447
+ poll_interval_ms INTEGER DEFAULT 100
448
+ )
449
+ RETURNS SETOF pgmq.message_record AS $$
450
+ DECLARE
451
+ r pgmq.message_record;
452
+ stop_at TIMESTAMP;
453
+ BEGIN
454
+ stop_at := clock_timestamp() + make_interval(secs => max_poll_seconds);
455
+ LOOP
456
+ IF (SELECT clock_timestamp() >= stop_at) THEN
457
+ RETURN;
458
+ END IF;
459
+
460
+ FOR r IN
461
+ SELECT * FROM pgmq.read_grouped(queue_name, vt, qty)
462
+ LOOP
463
+ RETURN NEXT r;
464
+ END LOOP;
465
+ IF FOUND THEN
466
+ RETURN;
467
+ ELSE
468
+ PERFORM pg_sleep(poll_interval_ms::numeric / 1000);
469
+ END IF;
470
+ END LOOP;
471
+ END;
472
+ $$ LANGUAGE plpgsql;
473
+
474
+ ---- read_with_poll
475
+ ---- reads a number of messages from a queue, setting a visibility timeout on them
476
+ CREATE FUNCTION pgmq.read_with_poll(
477
+ queue_name TEXT,
478
+ vt INTEGER,
479
+ qty INTEGER,
480
+ max_poll_seconds INTEGER DEFAULT 5,
481
+ poll_interval_ms INTEGER DEFAULT 100,
482
+ conditional JSONB DEFAULT '{}'
483
+ )
484
+ RETURNS SETOF pgmq.message_record AS $$
485
+ DECLARE
486
+ r pgmq.message_record;
487
+ stop_at TIMESTAMP;
488
+ sql TEXT;
489
+ qtable TEXT := pgmq.format_table_name(queue_name, 'q');
490
+ BEGIN
491
+ stop_at := clock_timestamp() + make_interval(secs => max_poll_seconds);
492
+ LOOP
493
+ IF (SELECT clock_timestamp() >= stop_at) THEN
494
+ RETURN;
495
+ END IF;
496
+
497
+ sql := FORMAT(
498
+ $QUERY$
499
+ WITH cte AS
500
+ (
501
+ SELECT msg_id
502
+ FROM pgmq.%I
503
+ WHERE vt <= clock_timestamp() AND CASE
504
+ WHEN %L != '{}'::jsonb THEN (message @> %2$L)::integer
505
+ ELSE 1
506
+ END = 1
507
+ ORDER BY msg_id ASC
508
+ LIMIT $1
509
+ FOR UPDATE SKIP LOCKED
510
+ )
511
+ UPDATE pgmq.%I m
512
+ SET
513
+ last_read_at = clock_timestamp(),
514
+ vt = clock_timestamp() + %L,
515
+ read_ct = read_ct + 1
516
+ FROM cte
517
+ WHERE m.msg_id = cte.msg_id
518
+ RETURNING m.msg_id, m.read_ct, m.enqueued_at, m.last_read_at, m.vt, m.message, m.headers;
519
+ $QUERY$,
520
+ qtable, conditional, qtable, make_interval(secs => vt)
521
+ );
522
+
523
+ FOR r IN
524
+ EXECUTE sql USING qty
525
+ LOOP
526
+ RETURN NEXT r;
527
+ END LOOP;
528
+ IF FOUND THEN
529
+ RETURN;
530
+ ELSE
531
+ PERFORM pg_sleep(poll_interval_ms::numeric / 1000);
532
+ END IF;
533
+ END LOOP;
534
+ END;
535
+ $$ LANGUAGE plpgsql;
536
+
537
+ ---- archive
538
+ ---- removes a message from the queue, and sends it to the archive, where its
539
+ ---- saved permanently.
540
+ CREATE FUNCTION pgmq.archive(
541
+ queue_name TEXT,
542
+ msg_id BIGINT
543
+ )
544
+ RETURNS BOOLEAN AS $$
545
+ DECLARE
546
+ sql TEXT;
547
+ result BIGINT;
548
+ qtable TEXT := pgmq.format_table_name(queue_name, 'q');
549
+ atable TEXT := pgmq.format_table_name(queue_name, 'a');
550
+ BEGIN
551
+ sql := FORMAT(
552
+ $QUERY$
553
+ WITH archived AS (
554
+ DELETE FROM pgmq.%I
555
+ WHERE msg_id = $1
556
+ RETURNING msg_id, vt, read_ct, enqueued_at, last_read_at, message, headers
557
+ )
558
+ INSERT INTO pgmq.%I (msg_id, vt, read_ct, enqueued_at, last_read_at, message, headers)
559
+ SELECT msg_id, vt, read_ct, enqueued_at, last_read_at, message, headers
560
+ FROM archived
561
+ RETURNING msg_id;
562
+ $QUERY$,
563
+ qtable, atable
564
+ );
565
+ EXECUTE sql USING msg_id INTO result;
566
+ RETURN NOT (result IS NULL);
567
+ END;
568
+ $$ LANGUAGE plpgsql;
569
+
570
+ ---- archive
571
+ ---- removes an array of message ids from the queue, and sends it to the archive,
572
+ ---- where these messages will be saved permanently.
573
+ CREATE FUNCTION pgmq.archive(
574
+ queue_name TEXT,
575
+ msg_ids BIGINT[]
576
+ )
577
+ RETURNS SETOF BIGINT AS $$
578
+ DECLARE
579
+ sql TEXT;
580
+ qtable TEXT := pgmq.format_table_name(queue_name, 'q');
581
+ atable TEXT := pgmq.format_table_name(queue_name, 'a');
582
+ BEGIN
583
+ sql := FORMAT(
584
+ $QUERY$
585
+ WITH archived AS (
586
+ DELETE FROM pgmq.%I
587
+ WHERE msg_id = ANY($1)
588
+ RETURNING msg_id, vt, read_ct, enqueued_at, last_read_at, message, headers
589
+ )
590
+ INSERT INTO pgmq.%I (msg_id, vt, read_ct, enqueued_at, last_read_at, message, headers)
591
+ SELECT msg_id, vt, read_ct, enqueued_at, last_read_at, message, headers
592
+ FROM archived
593
+ RETURNING msg_id;
594
+ $QUERY$,
595
+ qtable, atable
596
+ );
597
+ RETURN QUERY EXECUTE sql USING msg_ids;
598
+ END;
599
+ $$ LANGUAGE plpgsql;
600
+
601
+ ---- delete
602
+ ---- deletes a message id from the queue permanently
603
+ CREATE FUNCTION pgmq.delete(
604
+ queue_name TEXT,
605
+ msg_id BIGINT
606
+ )
607
+ RETURNS BOOLEAN AS $$
608
+ DECLARE
609
+ sql TEXT;
610
+ result BIGINT;
611
+ qtable TEXT := pgmq.format_table_name(queue_name, 'q');
612
+ BEGIN
613
+ sql := FORMAT(
614
+ $QUERY$
615
+ DELETE FROM pgmq.%I
616
+ WHERE msg_id = $1
617
+ RETURNING msg_id
618
+ $QUERY$,
619
+ qtable
620
+ );
621
+ EXECUTE sql USING msg_id INTO result;
622
+ RETURN NOT (result IS NULL);
623
+ END;
624
+ $$ LANGUAGE plpgsql;
625
+
626
+ ---- delete
627
+ ---- deletes an array of message ids from the queue permanently
628
+ CREATE FUNCTION pgmq.delete(
629
+ queue_name TEXT,
630
+ msg_ids BIGINT[]
631
+ )
632
+ RETURNS SETOF BIGINT AS $$
633
+ DECLARE
634
+ sql TEXT;
635
+ qtable TEXT := pgmq.format_table_name(queue_name, 'q');
636
+ BEGIN
637
+ sql := FORMAT(
638
+ $QUERY$
639
+ DELETE FROM pgmq.%I
640
+ WHERE msg_id = ANY($1)
641
+ RETURNING msg_id
642
+ $QUERY$,
643
+ qtable
644
+ );
645
+ RETURN QUERY EXECUTE sql USING msg_ids;
646
+ END;
647
+ $$ LANGUAGE plpgsql;
648
+
649
+ -- send: actual implementation
650
+ CREATE FUNCTION pgmq.send(
651
+ queue_name TEXT,
652
+ msg JSONB,
653
+ headers JSONB,
654
+ delay TIMESTAMP WITH TIME ZONE
655
+ ) RETURNS SETOF BIGINT AS $$
656
+ DECLARE
657
+ sql TEXT;
658
+ qtable TEXT := pgmq.format_table_name(queue_name, 'q');
659
+ BEGIN
660
+ sql := FORMAT(
661
+ $QUERY$
662
+ INSERT INTO pgmq.%I (vt, message, headers)
663
+ VALUES ($2, $1, $3)
664
+ RETURNING msg_id;
665
+ $QUERY$,
666
+ qtable
667
+ );
668
+ RETURN QUERY EXECUTE sql USING msg, delay, headers;
669
+ END;
670
+ $$ LANGUAGE plpgsql;
671
+
672
+ -- send: 2 args, no delay or headers
673
+ CREATE FUNCTION pgmq.send(
674
+ queue_name TEXT,
675
+ msg JSONB
676
+ ) RETURNS SETOF BIGINT AS $$
677
+ SELECT * FROM pgmq.send(queue_name, msg, NULL, clock_timestamp());
678
+ $$ LANGUAGE sql;
679
+
680
+ -- send: 3 args with headers
681
+ CREATE FUNCTION pgmq.send(
682
+ queue_name TEXT,
683
+ msg JSONB,
684
+ headers JSONB
685
+ ) RETURNS SETOF BIGINT AS $$
686
+ SELECT * FROM pgmq.send(queue_name, msg, headers, clock_timestamp());
687
+ $$ LANGUAGE sql;
688
+
689
+ -- send: 3 args with integer delay
690
+ CREATE FUNCTION pgmq.send(
691
+ queue_name TEXT,
692
+ msg JSONB,
693
+ delay INTEGER
694
+ ) RETURNS SETOF BIGINT AS $$
695
+ SELECT * FROM pgmq.send(queue_name, msg, NULL, clock_timestamp() + make_interval(secs => delay));
696
+ $$ LANGUAGE sql;
697
+
698
+ -- send: 3 args with timestamp
699
+ CREATE FUNCTION pgmq.send(
700
+ queue_name TEXT,
701
+ msg JSONB,
702
+ delay TIMESTAMP WITH TIME ZONE
703
+ ) RETURNS SETOF BIGINT AS $$
704
+ SELECT * FROM pgmq.send(queue_name, msg, NULL, delay);
705
+ $$ LANGUAGE sql;
706
+
707
+ -- send: 4 args with integer delay
708
+ CREATE FUNCTION pgmq.send(
709
+ queue_name TEXT,
710
+ msg JSONB,
711
+ headers JSONB,
712
+ delay INTEGER
713
+ ) RETURNS SETOF BIGINT AS $$
714
+ SELECT * FROM pgmq.send(queue_name, msg, headers, clock_timestamp() + make_interval(secs => delay));
715
+ $$ LANGUAGE sql;
716
+
717
+ -- _validate_batch_params: Private function to validate batch parameters
718
+ CREATE FUNCTION pgmq._validate_batch_params(
719
+ msgs JSONB[],
720
+ headers JSONB[]
721
+ ) RETURNS void AS $$
722
+ BEGIN
723
+ -- Validate that msgs is not NULL or empty
724
+ IF msgs IS NULL OR array_length(msgs, 1) IS NULL THEN
725
+ RAISE EXCEPTION 'msgs cannot be NULL or empty';
726
+ END IF;
727
+
728
+ -- Validate that headers array length matches msgs array length if headers is provided
729
+ -- Note: array_length returns NULL for empty arrays, so we use COALESCE to treat empty arrays as length 0
730
+ IF headers IS NOT NULL AND COALESCE(array_length(headers, 1), 0) != COALESCE(array_length(msgs, 1), 0) THEN
731
+ RAISE EXCEPTION 'headers array length (%) must match msgs array length (%)',
732
+ COALESCE(array_length(headers, 1), 0), COALESCE(array_length(msgs, 1), 0);
733
+ END IF;
734
+ END;
735
+ $$ LANGUAGE plpgsql;
736
+
737
+ -- _send_batch: Private function that performs the actual batch insert without validation
738
+ CREATE FUNCTION pgmq._send_batch(
739
+ queue_name TEXT,
740
+ msgs JSONB[],
741
+ headers JSONB[],
742
+ delay TIMESTAMP WITH TIME ZONE
743
+ ) RETURNS SETOF BIGINT AS $$
744
+ DECLARE
745
+ sql TEXT;
746
+ qtable TEXT := pgmq.format_table_name(queue_name, 'q');
747
+ BEGIN
748
+ sql := FORMAT(
749
+ $QUERY$
750
+ INSERT INTO pgmq.%I (vt, message, headers)
751
+ SELECT $2, unnest($1), unnest(coalesce($3, ARRAY[]::jsonb[]))
752
+ RETURNING msg_id;
753
+ $QUERY$,
754
+ qtable
755
+ );
756
+ RETURN QUERY EXECUTE sql USING msgs, delay, headers;
757
+ END;
758
+ $$ LANGUAGE plpgsql;
759
+
760
+ -- send_batch: Public function with validation
761
+ CREATE FUNCTION pgmq.send_batch(
762
+ queue_name TEXT,
763
+ msgs JSONB[],
764
+ headers JSONB[],
765
+ delay TIMESTAMP WITH TIME ZONE
766
+ ) RETURNS SETOF BIGINT AS $$
767
+ BEGIN
768
+ PERFORM pgmq._validate_batch_params(msgs, headers);
769
+ RETURN QUERY SELECT * FROM pgmq._send_batch(queue_name, msgs, headers, delay);
770
+ END;
771
+ $$ LANGUAGE plpgsql;
772
+
773
+ -- send batch: 2 args
774
+ CREATE FUNCTION pgmq.send_batch(
775
+ queue_name TEXT,
776
+ msgs JSONB[]
777
+ ) RETURNS SETOF BIGINT AS $$
778
+ SELECT * FROM pgmq.send_batch(queue_name, msgs, NULL, clock_timestamp());
779
+ $$ LANGUAGE sql;
780
+
781
+ -- send batch: 3 args with headers
782
+ CREATE FUNCTION pgmq.send_batch(
783
+ queue_name TEXT,
784
+ msgs JSONB[],
785
+ headers JSONB[]
786
+ ) RETURNS SETOF BIGINT AS $$
787
+ SELECT * FROM pgmq.send_batch(queue_name, msgs, headers, clock_timestamp());
788
+ $$ LANGUAGE sql;
789
+
790
+ -- send batch: 3 args with integer delay
791
+ CREATE FUNCTION pgmq.send_batch(
792
+ queue_name TEXT,
793
+ msgs JSONB[],
794
+ delay INTEGER
795
+ ) RETURNS SETOF BIGINT AS $$
796
+ SELECT * FROM pgmq.send_batch(queue_name, msgs, NULL, clock_timestamp() + make_interval(secs => delay));
797
+ $$ LANGUAGE sql;
798
+
799
+ -- send batch: 3 args with timestamp
800
+ CREATE FUNCTION pgmq.send_batch(
801
+ queue_name TEXT,
802
+ msgs JSONB[],
803
+ delay TIMESTAMP WITH TIME ZONE
804
+ ) RETURNS SETOF BIGINT AS $$
805
+ SELECT * FROM pgmq.send_batch(queue_name, msgs, NULL, delay);
806
+ $$ LANGUAGE sql;
807
+
808
+ -- send_batch: 4 args with integer delay
809
+ CREATE FUNCTION pgmq.send_batch(
810
+ queue_name TEXT,
811
+ msgs JSONB[],
812
+ headers JSONB[],
813
+ delay INTEGER
814
+ ) RETURNS SETOF BIGINT AS $$
815
+ SELECT * FROM pgmq.send_batch(queue_name, msgs, headers, clock_timestamp() + make_interval(secs => delay));
816
+ $$ LANGUAGE sql;
817
+
818
+ -- returned by pgmq.metrics() and pgmq.metrics_all
819
+ CREATE TYPE pgmq.metrics_result AS (
820
+ queue_name text,
821
+ queue_length bigint,
822
+ newest_msg_age_sec int,
823
+ oldest_msg_age_sec int,
824
+ total_messages bigint,
825
+ scrape_time timestamp with time zone,
826
+ queue_visible_length bigint
827
+ );
828
+
829
+ -- get metrics for a single queue
830
+ CREATE FUNCTION pgmq.metrics(queue_name TEXT)
831
+ RETURNS pgmq.metrics_result AS $$
832
+ DECLARE
833
+ result_row pgmq.metrics_result;
834
+ query TEXT;
835
+ qtable TEXT := pgmq.format_table_name(queue_name, 'q');
836
+ BEGIN
837
+ query := FORMAT(
838
+ $QUERY$
839
+ WITH q_summary AS (
840
+ SELECT
841
+ count(*) as queue_length,
842
+ count(CASE WHEN vt <= NOW() THEN 1 END) as queue_visible_length,
843
+ EXTRACT(epoch FROM (NOW() - max(enqueued_at)))::int as newest_msg_age_sec,
844
+ EXTRACT(epoch FROM (NOW() - min(enqueued_at)))::int as oldest_msg_age_sec,
845
+ NOW() as scrape_time
846
+ FROM pgmq.%I
847
+ ),
848
+ all_metrics AS (
849
+ SELECT CASE
850
+ WHEN is_called THEN last_value ELSE 0
851
+ END as total_messages
852
+ FROM pgmq.%I
853
+ )
854
+ SELECT
855
+ %L as queue_name,
856
+ q_summary.queue_length,
857
+ q_summary.newest_msg_age_sec,
858
+ q_summary.oldest_msg_age_sec,
859
+ all_metrics.total_messages,
860
+ q_summary.scrape_time,
861
+ q_summary.queue_visible_length
862
+ FROM q_summary, all_metrics
863
+ $QUERY$,
864
+ qtable, qtable || '_msg_id_seq', queue_name
865
+ );
866
+ EXECUTE query INTO result_row;
867
+ RETURN result_row;
868
+ END;
869
+ $$ LANGUAGE plpgsql;
870
+
871
+ -- get metrics for all queues
872
+ CREATE FUNCTION pgmq."metrics_all"()
873
+ RETURNS SETOF pgmq.metrics_result AS $$
874
+ DECLARE
875
+ row_name RECORD;
876
+ result_row pgmq.metrics_result;
877
+ BEGIN
878
+ FOR row_name IN SELECT queue_name FROM pgmq.meta LOOP
879
+ result_row := pgmq.metrics(row_name.queue_name);
880
+ RETURN NEXT result_row;
881
+ END LOOP;
882
+ END;
883
+ $$ LANGUAGE plpgsql;
884
+
885
+ -- list queues
886
+ CREATE FUNCTION pgmq."list_queues"()
887
+ RETURNS SETOF pgmq.queue_record AS $$
888
+ BEGIN
889
+ RETURN QUERY SELECT * FROM pgmq.meta;
890
+ END
891
+ $$ LANGUAGE plpgsql;
892
+
893
+ -- purge queue, deleting all entries in it.
894
+ CREATE OR REPLACE FUNCTION pgmq."purge_queue"(queue_name TEXT)
895
+ RETURNS BIGINT AS $$
896
+ DECLARE
897
+ deleted_count INTEGER;
898
+ qtable TEXT := pgmq.format_table_name(queue_name, 'q');
899
+ BEGIN
900
+ -- Get the row count before truncating
901
+ EXECUTE format('SELECT count(*) FROM pgmq.%I', qtable) INTO deleted_count;
902
+
903
+ -- Use TRUNCATE for better performance on large tables
904
+ EXECUTE format('TRUNCATE TABLE pgmq.%I', qtable);
905
+
906
+ -- Return the number of purged rows
907
+ RETURN deleted_count;
908
+ END
909
+ $$ LANGUAGE plpgsql;
910
+
911
+ -- unassign archive, so it can be kept when a queue is deleted
912
+ CREATE FUNCTION pgmq."detach_archive"(queue_name TEXT)
913
+ RETURNS VOID AS $$
914
+ DECLARE
915
+ atable TEXT := pgmq.format_table_name(queue_name, 'a');
916
+ BEGIN
917
+ RAISE WARNING 'detach_archive(queue_name) is deprecated and is a no-op. It will be removed in PGMQ v2.0. Archive tables are no longer member objects.';
918
+ END
919
+ $$ LANGUAGE plpgsql;
920
+
921
+ -- pop: implementation
922
+ CREATE FUNCTION pgmq.pop(queue_name TEXT, qty INTEGER DEFAULT 1)
923
+ RETURNS SETOF pgmq.message_record AS $$
924
+ DECLARE
925
+ sql TEXT;
926
+ result pgmq.message_record;
927
+ qtable TEXT := pgmq.format_table_name(queue_name, 'q');
928
+ BEGIN
929
+ sql := FORMAT(
930
+ $QUERY$
931
+ WITH cte AS
932
+ (
933
+ SELECT msg_id
934
+ FROM pgmq.%I
935
+ WHERE vt <= clock_timestamp()
936
+ ORDER BY msg_id ASC
937
+ LIMIT $1
938
+ FOR UPDATE SKIP LOCKED
939
+ )
940
+ DELETE from pgmq.%I
941
+ WHERE msg_id IN (select msg_id from cte)
942
+ RETURNING msg_id, read_ct, enqueued_at, last_read_at, vt, message, headers;
943
+ $QUERY$,
944
+ qtable, qtable
945
+ );
946
+ RETURN QUERY EXECUTE sql USING qty;
947
+ END;
948
+ $$ LANGUAGE plpgsql;
949
+
950
+ -- Sets timestamp vt of a message, returns it
951
+ CREATE FUNCTION pgmq.set_vt(queue_name TEXT, msg_id BIGINT, vt TIMESTAMP WITH TIME ZONE)
952
+ RETURNS SETOF pgmq.message_record AS $$
953
+ DECLARE
954
+ sql TEXT;
955
+ result pgmq.message_record;
956
+ qtable TEXT := pgmq.format_table_name(queue_name, 'q');
957
+ BEGIN
958
+ sql := FORMAT(
959
+ $QUERY$
960
+ UPDATE pgmq.%I
961
+ SET vt = $1
962
+ WHERE msg_id = $2
963
+ RETURNING msg_id, read_ct, enqueued_at, last_read_at, vt, message, headers;
964
+ $QUERY$,
965
+ qtable
966
+ );
967
+ RETURN QUERY EXECUTE sql USING vt, msg_id;
968
+ END;
969
+ $$ LANGUAGE plpgsql;
970
+
971
+ -- Sets integer vt of a message, returns it
972
+ CREATE FUNCTION pgmq.set_vt(queue_name TEXT, msg_id BIGINT, vt INTEGER)
973
+ RETURNS SETOF pgmq.message_record AS $$
974
+ SELECT * FROM pgmq.set_vt(queue_name, msg_id, clock_timestamp() + make_interval(secs => vt));
975
+ $$ LANGUAGE sql;
976
+
977
+ -- Sets timestamp vt of multiple messages, returns them
978
+ CREATE FUNCTION pgmq.set_vt(
979
+ queue_name TEXT,
980
+ msg_ids BIGINT[],
981
+ vt TIMESTAMP WITH TIME ZONE
982
+ )
983
+ RETURNS SETOF pgmq.message_record AS $$
984
+ DECLARE
985
+ sql TEXT;
986
+ qtable TEXT := pgmq.format_table_name(queue_name, 'q');
987
+ BEGIN
988
+ sql := FORMAT(
989
+ $QUERY$
990
+ UPDATE pgmq.%I
991
+ SET vt = $1
992
+ WHERE msg_id = ANY($2)
993
+ RETURNING msg_id, read_ct, enqueued_at, last_read_at, vt, message, headers;
994
+ $QUERY$,
995
+ qtable
996
+ );
997
+ RETURN QUERY EXECUTE sql USING vt, msg_ids;
998
+ END;
999
+ $$ LANGUAGE plpgsql;
1000
+
1001
+ -- Sets integer vt of multiple messages, returns them
1002
+ CREATE FUNCTION pgmq.set_vt(
1003
+ queue_name TEXT,
1004
+ msg_ids BIGINT[],
1005
+ vt INTEGER
1006
+ )
1007
+ RETURNS SETOF pgmq.message_record AS $$
1008
+ SELECT * FROM pgmq.set_vt(queue_name, msg_ids, clock_timestamp() + make_interval(secs => vt));
1009
+ $$ LANGUAGE sql;
1010
+
1011
+ CREATE FUNCTION pgmq._get_pg_partman_schema()
1012
+ RETURNS TEXT AS $$
1013
+ SELECT
1014
+ extnamespace::regnamespace::text
1015
+ FROM
1016
+ pg_extension
1017
+ WHERE
1018
+ extname = 'pg_partman';
1019
+ $$ LANGUAGE SQL;
1020
+
1021
+ CREATE FUNCTION pgmq.drop_queue(queue_name TEXT, partitioned BOOLEAN)
1022
+ RETURNS BOOLEAN AS $$
1023
+ DECLARE
1024
+ qtable TEXT := pgmq.format_table_name(queue_name, 'q');
1025
+ fq_qtable TEXT := 'pgmq.' || qtable;
1026
+ atable TEXT := pgmq.format_table_name(queue_name, 'a');
1027
+ fq_atable TEXT := 'pgmq.' || atable;
1028
+ BEGIN
1029
+ RAISE WARNING 'drop_queue(queue_name, partitioned) is deprecated and will be removed in PGMQ v2.0. Use drop_queue(queue_name) instead';
1030
+
1031
+ PERFORM pgmq.drop_queue(queue_name);
1032
+
1033
+ RETURN TRUE;
1034
+ END;
1035
+ $$ LANGUAGE plpgsql;
1036
+
1037
+ CREATE FUNCTION pgmq.drop_queue(queue_name TEXT)
1038
+ RETURNS BOOLEAN AS $$
1039
+ DECLARE
1040
+ qtable TEXT := pgmq.format_table_name(queue_name, 'q');
1041
+ qtable_seq TEXT := qtable || '_msg_id_seq';
1042
+ fq_qtable TEXT := 'pgmq.' || qtable;
1043
+ atable TEXT := pgmq.format_table_name(queue_name, 'a');
1044
+ fq_atable TEXT := 'pgmq.' || atable;
1045
+ partitioned BOOLEAN;
1046
+ BEGIN
1047
+ PERFORM pgmq.acquire_queue_lock(queue_name);
1048
+ EXECUTE FORMAT(
1049
+ $QUERY$
1050
+ SELECT is_partitioned FROM pgmq.meta WHERE queue_name = %L
1051
+ $QUERY$,
1052
+ queue_name
1053
+ ) INTO partitioned;
1054
+
1055
+ -- check if the queue exists
1056
+ IF NOT EXISTS (
1057
+ SELECT 1
1058
+ FROM information_schema.tables
1059
+ WHERE table_name = qtable and table_schema = 'pgmq'
1060
+ ) THEN
1061
+ RAISE NOTICE 'pgmq queue `%` does not exist', queue_name;
1062
+ RETURN FALSE;
1063
+ END IF;
1064
+
1065
+ EXECUTE FORMAT(
1066
+ $QUERY$
1067
+ DROP TABLE IF EXISTS pgmq.%I
1068
+ $QUERY$,
1069
+ qtable
1070
+ );
1071
+
1072
+ EXECUTE FORMAT(
1073
+ $QUERY$
1074
+ DROP TABLE IF EXISTS pgmq.%I
1075
+ $QUERY$,
1076
+ atable
1077
+ );
1078
+
1079
+ IF EXISTS (
1080
+ SELECT 1
1081
+ FROM information_schema.tables
1082
+ WHERE table_name = 'meta' and table_schema = 'pgmq'
1083
+ ) THEN
1084
+ EXECUTE FORMAT(
1085
+ $QUERY$
1086
+ DELETE FROM pgmq.meta WHERE queue_name = %L
1087
+ $QUERY$,
1088
+ queue_name
1089
+ );
1090
+ END IF;
1091
+
1092
+ IF partitioned THEN
1093
+ EXECUTE FORMAT(
1094
+ $QUERY$
1095
+ DELETE FROM %I.part_config where parent_table in (%L, %L)
1096
+ $QUERY$,
1097
+ pgmq._get_pg_partman_schema(), fq_qtable, fq_atable
1098
+ );
1099
+ END IF;
1100
+
1101
+ RETURN TRUE;
1102
+ END;
1103
+ $$ LANGUAGE plpgsql;
1104
+
1105
+ CREATE FUNCTION pgmq.validate_queue_name(queue_name TEXT)
1106
+ RETURNS void AS $$
1107
+ BEGIN
1108
+ IF length(queue_name) > 47 THEN
1109
+ -- complete table identifier must be <= 63
1110
+ -- https://www.postgresql.org/docs/17/sql-syntax-lexical.html#SQL-SYNTAX-IDENTIFIERS
1111
+ -- e.g. template_pgmq_q_my_queue is an identifier for my_queue when partitioned
1112
+ -- template_pgmq_q_ (16) + <a max length queue name> (47) = 63
1113
+ RAISE EXCEPTION 'queue name is too long, maximum length is 47 characters';
1114
+ END IF;
1115
+ END;
1116
+ $$ LANGUAGE plpgsql;
1117
+
1118
+ CREATE FUNCTION pgmq._belongs_to_pgmq(table_name TEXT)
1119
+ RETURNS BOOLEAN AS $$
1120
+ DECLARE
1121
+ sql TEXT;
1122
+ result BOOLEAN;
1123
+ BEGIN
1124
+ SELECT EXISTS (
1125
+ SELECT 1
1126
+ FROM pg_depend
1127
+ WHERE refobjid = (SELECT oid FROM pg_extension WHERE extname = 'pgmq')
1128
+ AND objid = (
1129
+ SELECT oid
1130
+ FROM pg_class
1131
+ WHERE relname = table_name
1132
+ )
1133
+ ) INTO result;
1134
+ RETURN result;
1135
+ END;
1136
+ $$ LANGUAGE plpgsql;
1137
+
1138
+ CREATE FUNCTION pgmq.create_non_partitioned(queue_name TEXT)
1139
+ RETURNS void AS $$
1140
+ DECLARE
1141
+ qtable TEXT := pgmq.format_table_name(queue_name, 'q');
1142
+ qtable_seq TEXT := qtable || '_msg_id_seq';
1143
+ atable TEXT := pgmq.format_table_name(queue_name, 'a');
1144
+ BEGIN
1145
+ PERFORM pgmq.validate_queue_name(queue_name);
1146
+ PERFORM pgmq.acquire_queue_lock(queue_name);
1147
+
1148
+ EXECUTE FORMAT(
1149
+ $QUERY$
1150
+ CREATE TABLE IF NOT EXISTS pgmq.%I (
1151
+ msg_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
1152
+ read_ct INT DEFAULT 0 NOT NULL,
1153
+ enqueued_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL,
1154
+ last_read_at TIMESTAMP WITH TIME ZONE,
1155
+ vt TIMESTAMP WITH TIME ZONE NOT NULL,
1156
+ message JSONB,
1157
+ headers JSONB
1158
+ )
1159
+ $QUERY$,
1160
+ qtable
1161
+ );
1162
+
1163
+ EXECUTE FORMAT(
1164
+ $QUERY$
1165
+ CREATE TABLE IF NOT EXISTS pgmq.%I (
1166
+ msg_id BIGINT PRIMARY KEY,
1167
+ read_ct INT DEFAULT 0 NOT NULL,
1168
+ enqueued_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL,
1169
+ last_read_at TIMESTAMP WITH TIME ZONE,
1170
+ archived_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL,
1171
+ vt TIMESTAMP WITH TIME ZONE NOT NULL,
1172
+ message JSONB,
1173
+ headers JSONB
1174
+ );
1175
+ $QUERY$,
1176
+ atable
1177
+ );
1178
+
1179
+ EXECUTE FORMAT(
1180
+ $QUERY$
1181
+ CREATE INDEX IF NOT EXISTS %I ON pgmq.%I (vt ASC);
1182
+ $QUERY$,
1183
+ qtable || '_vt_idx', qtable
1184
+ );
1185
+
1186
+ EXECUTE FORMAT(
1187
+ $QUERY$
1188
+ CREATE INDEX IF NOT EXISTS %I ON pgmq.%I (archived_at);
1189
+ $QUERY$,
1190
+ 'archived_at_idx_' || queue_name, atable
1191
+ );
1192
+
1193
+ EXECUTE FORMAT(
1194
+ $QUERY$
1195
+ INSERT INTO pgmq.meta (queue_name, is_partitioned, is_unlogged)
1196
+ VALUES (%L, false, false)
1197
+ ON CONFLICT
1198
+ DO NOTHING;
1199
+ $QUERY$,
1200
+ queue_name
1201
+ );
1202
+
1203
+ END;
1204
+ $$ LANGUAGE plpgsql;
1205
+
1206
+ CREATE FUNCTION pgmq.create_unlogged(queue_name TEXT)
1207
+ RETURNS void AS $$
1208
+ DECLARE
1209
+ qtable TEXT := pgmq.format_table_name(queue_name, 'q');
1210
+ qtable_seq TEXT := qtable || '_msg_id_seq';
1211
+ atable TEXT := pgmq.format_table_name(queue_name, 'a');
1212
+ BEGIN
1213
+ PERFORM pgmq.validate_queue_name(queue_name);
1214
+ PERFORM pgmq.acquire_queue_lock(queue_name);
1215
+
1216
+ EXECUTE FORMAT(
1217
+ $QUERY$
1218
+ CREATE UNLOGGED TABLE IF NOT EXISTS pgmq.%I (
1219
+ msg_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
1220
+ read_ct INT DEFAULT 0 NOT NULL,
1221
+ enqueued_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL,
1222
+ last_read_at TIMESTAMP WITH TIME ZONE,
1223
+ vt TIMESTAMP WITH TIME ZONE NOT NULL,
1224
+ message JSONB,
1225
+ headers JSONB
1226
+ )
1227
+ $QUERY$,
1228
+ qtable
1229
+ );
1230
+
1231
+ EXECUTE FORMAT(
1232
+ $QUERY$
1233
+ CREATE TABLE IF NOT EXISTS pgmq.%I (
1234
+ msg_id BIGINT PRIMARY KEY,
1235
+ read_ct INT DEFAULT 0 NOT NULL,
1236
+ enqueued_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL,
1237
+ last_read_at TIMESTAMP WITH TIME ZONE,
1238
+ archived_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL,
1239
+ vt TIMESTAMP WITH TIME ZONE NOT NULL,
1240
+ message JSONB,
1241
+ headers JSONB
1242
+ );
1243
+ $QUERY$,
1244
+ atable
1245
+ );
1246
+
1247
+ EXECUTE FORMAT(
1248
+ $QUERY$
1249
+ CREATE INDEX IF NOT EXISTS %I ON pgmq.%I (vt ASC);
1250
+ $QUERY$,
1251
+ qtable || '_vt_idx', qtable
1252
+ );
1253
+
1254
+ EXECUTE FORMAT(
1255
+ $QUERY$
1256
+ CREATE INDEX IF NOT EXISTS %I ON pgmq.%I (archived_at);
1257
+ $QUERY$,
1258
+ 'archived_at_idx_' || queue_name, atable
1259
+ );
1260
+
1261
+ EXECUTE FORMAT(
1262
+ $QUERY$
1263
+ INSERT INTO pgmq.meta (queue_name, is_partitioned, is_unlogged)
1264
+ VALUES (%L, false, true)
1265
+ ON CONFLICT
1266
+ DO NOTHING;
1267
+ $QUERY$,
1268
+ queue_name
1269
+ );
1270
+ END;
1271
+ $$ LANGUAGE plpgsql;
1272
+
1273
+ CREATE FUNCTION pgmq._get_partition_col(partition_interval TEXT)
1274
+ RETURNS TEXT AS $$
1275
+ DECLARE
1276
+ num INTEGER;
1277
+ BEGIN
1278
+ BEGIN
1279
+ num := partition_interval::INTEGER;
1280
+ RETURN 'msg_id';
1281
+ EXCEPTION
1282
+ WHEN others THEN
1283
+ RETURN 'enqueued_at';
1284
+ END;
1285
+ END;
1286
+ $$ LANGUAGE plpgsql;
1287
+
1288
+ CREATE FUNCTION pgmq._extension_exists(extension_name TEXT)
1289
+ RETURNS BOOLEAN
1290
+ LANGUAGE SQL
1291
+ AS $$
1292
+ SELECT EXISTS (
1293
+ SELECT 1
1294
+ FROM pg_extension
1295
+ WHERE extname = extension_name
1296
+ )
1297
+ $$;
1298
+
1299
+ CREATE FUNCTION pgmq._ensure_pg_partman_installed()
1300
+ RETURNS void AS $$
1301
+ BEGIN
1302
+ IF NOT pgmq._extension_exists('pg_partman') THEN
1303
+ RAISE EXCEPTION 'pg_partman is required for partitioned queues';
1304
+ END IF;
1305
+ END;
1306
+ $$ LANGUAGE plpgsql;
1307
+
1308
+ CREATE FUNCTION pgmq._get_pg_partman_major_version()
1309
+ RETURNS INT
1310
+ LANGUAGE SQL
1311
+ AS $$
1312
+ SELECT split_part(extversion, '.', 1)::INT
1313
+ FROM pg_extension
1314
+ WHERE extname = 'pg_partman'
1315
+ $$;
1316
+
1317
+ CREATE FUNCTION pgmq.create_partitioned(
1318
+ queue_name TEXT,
1319
+ partition_interval TEXT DEFAULT '10000',
1320
+ retention_interval TEXT DEFAULT '100000'
1321
+ )
1322
+ RETURNS void AS $$
1323
+ DECLARE
1324
+ partition_col TEXT;
1325
+ a_partition_col TEXT;
1326
+ qtable TEXT := pgmq.format_table_name(queue_name, 'q');
1327
+ qtable_seq TEXT := qtable || '_msg_id_seq';
1328
+ atable TEXT := pgmq.format_table_name(queue_name, 'a');
1329
+ fq_qtable TEXT := 'pgmq.' || qtable;
1330
+ fq_atable TEXT := 'pgmq.' || atable;
1331
+ BEGIN
1332
+ PERFORM pgmq.validate_queue_name(queue_name);
1333
+ PERFORM pgmq.acquire_queue_lock(queue_name);
1334
+ PERFORM pgmq._ensure_pg_partman_installed();
1335
+ SELECT pgmq._get_partition_col(partition_interval) INTO partition_col;
1336
+
1337
+ EXECUTE FORMAT(
1338
+ $QUERY$
1339
+ CREATE TABLE IF NOT EXISTS pgmq.%I (
1340
+ msg_id BIGINT GENERATED ALWAYS AS IDENTITY,
1341
+ read_ct INT DEFAULT 0 NOT NULL,
1342
+ enqueued_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL,
1343
+ last_read_at TIMESTAMP WITH TIME ZONE,
1344
+ vt TIMESTAMP WITH TIME ZONE NOT NULL,
1345
+ message JSONB,
1346
+ headers JSONB
1347
+ ) PARTITION BY RANGE (%I)
1348
+ $QUERY$,
1349
+ qtable, partition_col
1350
+ );
1351
+
1352
+ -- https://github.com/pgpartman/pg_partman/blob/master/doc/pg_partman.md
1353
+ -- p_parent_table - the existing parent table. MUST be schema qualified, even if in public schema.
1354
+ EXECUTE FORMAT(
1355
+ $QUERY$
1356
+ SELECT %I.create_parent(
1357
+ p_parent_table := %L,
1358
+ p_control := %L,
1359
+ p_interval := %L,
1360
+ p_type := case
1361
+ when pgmq._get_pg_partman_major_version() = 5 then 'range'
1362
+ else 'native'
1363
+ end
1364
+ )
1365
+ $QUERY$,
1366
+ pgmq._get_pg_partman_schema(),
1367
+ fq_qtable,
1368
+ partition_col,
1369
+ partition_interval
1370
+ );
1371
+
1372
+ EXECUTE FORMAT(
1373
+ $QUERY$
1374
+ CREATE INDEX IF NOT EXISTS %I ON pgmq.%I (%I);
1375
+ $QUERY$,
1376
+ qtable || '_part_idx', qtable, partition_col
1377
+ );
1378
+
1379
+ EXECUTE FORMAT(
1380
+ $QUERY$
1381
+ UPDATE %I.part_config
1382
+ SET
1383
+ retention = %L,
1384
+ retention_keep_table = false,
1385
+ retention_keep_index = true,
1386
+ automatic_maintenance = 'on'
1387
+ WHERE parent_table = %L;
1388
+ $QUERY$,
1389
+ pgmq._get_pg_partman_schema(),
1390
+ retention_interval,
1391
+ 'pgmq.' || qtable
1392
+ );
1393
+
1394
+ EXECUTE FORMAT(
1395
+ $QUERY$
1396
+ INSERT INTO pgmq.meta (queue_name, is_partitioned, is_unlogged)
1397
+ VALUES (%L, true, false)
1398
+ ON CONFLICT
1399
+ DO NOTHING;
1400
+ $QUERY$,
1401
+ queue_name
1402
+ );
1403
+
1404
+ IF partition_col = 'enqueued_at' THEN
1405
+ a_partition_col := 'archived_at';
1406
+ ELSE
1407
+ a_partition_col := partition_col;
1408
+ END IF;
1409
+
1410
+ EXECUTE FORMAT(
1411
+ $QUERY$
1412
+ CREATE TABLE IF NOT EXISTS pgmq.%I (
1413
+ msg_id BIGINT NOT NULL,
1414
+ read_ct INT DEFAULT 0 NOT NULL,
1415
+ enqueued_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL,
1416
+ last_read_at TIMESTAMP WITH TIME ZONE,
1417
+ archived_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL,
1418
+ vt TIMESTAMP WITH TIME ZONE NOT NULL,
1419
+ message JSONB,
1420
+ headers JSONB
1421
+ ) PARTITION BY RANGE (%I);
1422
+ $QUERY$,
1423
+ atable, a_partition_col
1424
+ );
1425
+
1426
+ -- https://github.com/pgpartman/pg_partman/blob/master/doc/pg_partman.md
1427
+ -- p_parent_table - the existing parent table. MUST be schema qualified, even if in public schema.
1428
+ EXECUTE FORMAT(
1429
+ $QUERY$
1430
+ SELECT %I.create_parent(
1431
+ p_parent_table := %L,
1432
+ p_control := %L,
1433
+ p_interval := %L,
1434
+ p_type := case
1435
+ when pgmq._get_pg_partman_major_version() = 5 then 'range'
1436
+ else 'native'
1437
+ end
1438
+ )
1439
+ $QUERY$,
1440
+ pgmq._get_pg_partman_schema(),
1441
+ fq_atable,
1442
+ a_partition_col,
1443
+ partition_interval
1444
+ );
1445
+
1446
+ EXECUTE FORMAT(
1447
+ $QUERY$
1448
+ UPDATE %I.part_config
1449
+ SET
1450
+ retention = %L,
1451
+ retention_keep_table = false,
1452
+ retention_keep_index = true,
1453
+ automatic_maintenance = 'on'
1454
+ WHERE parent_table = %L;
1455
+ $QUERY$,
1456
+ pgmq._get_pg_partman_schema(),
1457
+ retention_interval,
1458
+ 'pgmq.' || atable
1459
+ );
1460
+
1461
+ EXECUTE FORMAT(
1462
+ $QUERY$
1463
+ CREATE INDEX IF NOT EXISTS %I ON pgmq.%I (archived_at);
1464
+ $QUERY$,
1465
+ 'archived_at_idx_' || queue_name, atable
1466
+ );
1467
+
1468
+ END;
1469
+ $$ LANGUAGE plpgsql;
1470
+
1471
+ CREATE FUNCTION pgmq.create(queue_name TEXT)
1472
+ RETURNS void AS $$
1473
+ BEGIN
1474
+ PERFORM pgmq.create_non_partitioned(queue_name);
1475
+ END;
1476
+ $$ LANGUAGE plpgsql;
1477
+
1478
+ -- _create_fifo_index_if_not_exists
1479
+ -- internal function to create GIN index on headers for better FIFO performance
1480
+ CREATE OR REPLACE FUNCTION pgmq._create_fifo_index_if_not_exists(queue_name TEXT)
1481
+ RETURNS void AS $$
1482
+ DECLARE
1483
+ qtable TEXT := pgmq.format_table_name(queue_name, 'q');
1484
+ index_name TEXT := qtable || '_fifo_idx';
1485
+ BEGIN
1486
+ -- Create GIN index on headers for efficient FIFO key lookups
1487
+ EXECUTE FORMAT(
1488
+ $QUERY$
1489
+ CREATE INDEX IF NOT EXISTS %I ON pgmq.%I USING GIN (headers);
1490
+ $QUERY$,
1491
+ index_name, qtable
1492
+ );
1493
+ END;
1494
+ $$ LANGUAGE plpgsql;
1495
+
1496
+ -- create_fifo_index
1497
+ -- creates a GIN index on the headers column to improve FIFO read performance
1498
+ CREATE FUNCTION pgmq.create_fifo_index(queue_name TEXT)
1499
+ RETURNS void AS $$
1500
+ BEGIN
1501
+ PERFORM pgmq._create_fifo_index_if_not_exists(queue_name);
1502
+ END;
1503
+ $$ LANGUAGE plpgsql;
1504
+
1505
+ -- create_fifo_indexes_all
1506
+ -- creates FIFO indexes on all existing queues
1507
+ CREATE FUNCTION pgmq.create_fifo_indexes_all()
1508
+ RETURNS void AS $$
1509
+ DECLARE
1510
+ queue_record RECORD;
1511
+ BEGIN
1512
+ FOR queue_record IN SELECT queue_name FROM pgmq.meta LOOP
1513
+ PERFORM pgmq.create_fifo_index(queue_record.queue_name);
1514
+ END LOOP;
1515
+ END;
1516
+ $$ LANGUAGE plpgsql;
1517
+
1518
+ CREATE OR REPLACE FUNCTION pgmq.convert_archive_partitioned(
1519
+ table_name TEXT,
1520
+ partition_interval TEXT DEFAULT '10000',
1521
+ retention_interval TEXT DEFAULT '100000',
1522
+ leading_partition INT DEFAULT 10
1523
+ )
1524
+ RETURNS void AS $$
1525
+ DECLARE
1526
+ a_table_name TEXT := pgmq.format_table_name(table_name, 'a');
1527
+ a_table_name_old TEXT := pgmq.format_table_name(table_name, 'a') || '_old';
1528
+ qualified_a_table_name TEXT := format('pgmq.%I', a_table_name);
1529
+ partition_col TEXT;
1530
+ a_partition_col TEXT;
1531
+ BEGIN
1532
+
1533
+ PERFORM c.relkind
1534
+ FROM pg_class c
1535
+ JOIN pg_namespace n ON n.oid = c.relnamespace
1536
+ WHERE c.relname = a_table_name
1537
+ AND c.relkind = 'p';
1538
+
1539
+ IF FOUND THEN
1540
+ RAISE NOTICE 'Table %s is already partitioned', a_table_name;
1541
+ RETURN;
1542
+ END IF;
1543
+
1544
+ PERFORM c.relkind
1545
+ FROM pg_class c
1546
+ JOIN pg_namespace n ON n.oid = c.relnamespace
1547
+ WHERE c.relname = a_table_name
1548
+ AND c.relkind = 'r';
1549
+
1550
+ IF NOT FOUND THEN
1551
+ RAISE NOTICE 'Table %s does not exists', a_table_name;
1552
+ RETURN;
1553
+ END IF;
1554
+
1555
+ SELECT pgmq._get_partition_col(partition_interval) INTO partition_col;
1556
+
1557
+ -- For archive tables, use archived_at for time-based partitioning
1558
+ IF partition_col = 'enqueued_at' THEN
1559
+ a_partition_col := 'archived_at';
1560
+ ELSE
1561
+ a_partition_col := partition_col;
1562
+ END IF;
1563
+
1564
+ EXECUTE 'ALTER TABLE ' || qualified_a_table_name || ' RENAME TO ' || a_table_name_old;
1565
+
1566
+ -- When partitioning by time (archived_at), we need to exclude constraints and indexes
1567
+ -- because the existing PRIMARY KEY on msg_id alone is incompatible with partitioning by archived_at.
1568
+ -- When partitioning by msg_id, we can keep all constraints including PRIMARY KEY.
1569
+ IF a_partition_col = 'archived_at' THEN
1570
+ EXECUTE format( 'CREATE TABLE pgmq.%I (LIKE pgmq.%I including defaults including generated including storage including comments) PARTITION BY RANGE (%I)', a_table_name, a_table_name_old, a_partition_col );
1571
+ ELSE
1572
+ EXECUTE format( 'CREATE TABLE pgmq.%I (LIKE pgmq.%I including all) PARTITION BY RANGE (%I)', a_table_name, a_table_name_old, a_partition_col );
1573
+ END IF;
1574
+
1575
+ EXECUTE 'ALTER INDEX pgmq.archived_at_idx_' || table_name || ' RENAME TO archived_at_idx_' || table_name || '_old';
1576
+ EXECUTE 'CREATE INDEX archived_at_idx_'|| table_name || ' ON ' || qualified_a_table_name ||'(archived_at)';
1577
+
1578
+ -- https://github.com/pgpartman/pg_partman/blob/master/doc/pg_partman.md
1579
+ -- p_parent_table - the existing parent table. MUST be schema qualified, even if in public schema.
1580
+ EXECUTE FORMAT(
1581
+ $QUERY$
1582
+ SELECT %I.create_parent(
1583
+ p_parent_table := %L,
1584
+ p_control := %L,
1585
+ p_interval := %L,
1586
+ p_type := case
1587
+ when pgmq._get_pg_partman_major_version() = 5 then 'range'
1588
+ else 'native'
1589
+ end
1590
+ )
1591
+ $QUERY$,
1592
+ pgmq._get_pg_partman_schema(),
1593
+ qualified_a_table_name,
1594
+ a_partition_col,
1595
+ partition_interval
1596
+ );
1597
+
1598
+ EXECUTE FORMAT(
1599
+ $QUERY$
1600
+ UPDATE %I.part_config
1601
+ SET
1602
+ retention = %L,
1603
+ retention_keep_table = false,
1604
+ retention_keep_index = false,
1605
+ infinite_time_partitions = true
1606
+ WHERE
1607
+ parent_table = %L;
1608
+ $QUERY$,
1609
+ pgmq._get_pg_partman_schema(),
1610
+ retention_interval,
1611
+ qualified_a_table_name
1612
+ );
1613
+ END;
1614
+ $$ LANGUAGE plpgsql;
1615
+
1616
+ CREATE OR REPLACE FUNCTION pgmq.notify_queue_listeners()
1617
+ RETURNS TRIGGER AS $$
1618
+ DECLARE
1619
+ queue_name_extracted TEXT; -- Queue name extracted from trigger table name
1620
+ updated_count INTEGER; -- Number of rows updated (0 or 1)
1621
+ BEGIN
1622
+ queue_name_extracted := substring(TG_TABLE_NAME from 3);
1623
+
1624
+ UPDATE pgmq.notify_insert_throttle
1625
+ SET last_notified_at = clock_timestamp()
1626
+ WHERE queue_name = queue_name_extracted
1627
+ AND (
1628
+ throttle_interval_ms = 0 -- No throttling configured
1629
+ OR clock_timestamp() - last_notified_at >=
1630
+ (throttle_interval_ms * INTERVAL '1 millisecond') -- Throttle interval has elapsed
1631
+ );
1632
+
1633
+ -- Check how many rows were updated (will be 0 or 1)
1634
+ GET DIAGNOSTICS updated_count = ROW_COUNT;
1635
+
1636
+ IF updated_count > 0 THEN
1637
+ PERFORM PG_NOTIFY('pgmq.' || TG_TABLE_NAME || '.' || TG_OP, NULL);
1638
+ END IF;
1639
+
1640
+ RETURN NEW;
1641
+ END;
1642
+ $$ LANGUAGE plpgsql;
1643
+
1644
+ CREATE OR REPLACE FUNCTION pgmq.enable_notify_insert(queue_name TEXT, throttle_interval_ms INTEGER DEFAULT 250)
1645
+ RETURNS void AS $$
1646
+ DECLARE
1647
+ qtable TEXT := pgmq.format_table_name(queue_name, 'q');
1648
+ v_queue_name TEXT := queue_name;
1649
+ v_throttle_interval_ms INTEGER := throttle_interval_ms;
1650
+ BEGIN
1651
+ -- Validate that throttle_interval_ms is non-negative
1652
+ IF v_throttle_interval_ms < 0 THEN
1653
+ RAISE EXCEPTION 'throttle_interval_ms must be non-negative';
1654
+ END IF;
1655
+
1656
+ -- Validate that the queue table exists
1657
+ IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'pgmq' AND table_name = qtable) THEN
1658
+ RAISE EXCEPTION 'Queue "%" does not exist. Create it first using pgmq.create()', v_queue_name;
1659
+ END IF;
1660
+
1661
+ PERFORM pgmq.disable_notify_insert(v_queue_name);
1662
+
1663
+ INSERT INTO pgmq.notify_insert_throttle (queue_name, throttle_interval_ms)
1664
+ VALUES (v_queue_name, v_throttle_interval_ms)
1665
+ ON CONFLICT ON CONSTRAINT notify_insert_throttle_queue_name_key DO UPDATE
1666
+ SET throttle_interval_ms = EXCLUDED.throttle_interval_ms,
1667
+ last_notified_at = to_timestamp(0);
1668
+
1669
+ EXECUTE FORMAT(
1670
+ $QUERY$
1671
+ CREATE CONSTRAINT TRIGGER trigger_notify_queue_insert_listeners
1672
+ AFTER INSERT ON pgmq.%I
1673
+ DEFERRABLE FOR EACH ROW
1674
+ EXECUTE PROCEDURE pgmq.notify_queue_listeners()
1675
+ $QUERY$,
1676
+ qtable
1677
+ );
1678
+ END;
1679
+ $$ LANGUAGE plpgsql;
1680
+
1681
+ CREATE OR REPLACE FUNCTION pgmq.disable_notify_insert(queue_name TEXT)
1682
+ RETURNS void AS $$
1683
+ DECLARE
1684
+ qtable TEXT := pgmq.format_table_name(queue_name, 'q');
1685
+ v_queue_name TEXT := queue_name;
1686
+ BEGIN
1687
+ EXECUTE FORMAT(
1688
+ $QUERY$
1689
+ DROP TRIGGER IF EXISTS trigger_notify_queue_insert_listeners ON pgmq.%I;
1690
+ $QUERY$,
1691
+ qtable
1692
+ );
1693
+
1694
+ DELETE FROM pgmq.notify_insert_throttle nit WHERE nit.queue_name = v_queue_name;
1695
+ END;
1696
+ $$ LANGUAGE plpgsql;
1697
+
1698
+ CREATE OR REPLACE FUNCTION pgmq.list_notify_insert_throttles()
1699
+ RETURNS TABLE
1700
+ (
1701
+ queue_name text,
1702
+ throttle_interval_ms integer,
1703
+ last_notified_at TIMESTAMP WITH TIME ZONE
1704
+ )
1705
+ LANGUAGE sql
1706
+ STABLE
1707
+ AS
1708
+ $$
1709
+ SELECT queue_name, throttle_interval_ms, last_notified_at
1710
+ FROM pgmq.notify_insert_throttle
1711
+ ORDER BY queue_name;
1712
+ $$;
1713
+
1714
+ CREATE OR REPLACE FUNCTION pgmq.update_notify_insert(queue_name text, throttle_interval_ms integer)
1715
+ RETURNS void
1716
+ LANGUAGE plpgsql
1717
+ AS
1718
+ $$
1719
+ BEGIN
1720
+ IF throttle_interval_ms < 0 THEN
1721
+ RAISE EXCEPTION 'throttle_interval_ms must be non-negative, got: %', throttle_interval_ms;
1722
+ END IF;
1723
+
1724
+ IF NOT EXISTS (SELECT 1 FROM pgmq.meta WHERE meta.queue_name = update_notify_insert.queue_name) THEN
1725
+ RAISE EXCEPTION 'Queue "%" does not exist. Create the queue first using pgmq.create()', queue_name;
1726
+ END IF;
1727
+
1728
+ IF NOT EXISTS (SELECT 1 FROM pgmq.notify_insert_throttle WHERE notify_insert_throttle.queue_name = update_notify_insert.queue_name) THEN
1729
+ RAISE EXCEPTION 'Queue "%" does not have notify_insert enabled. Enable it first using pgmq.enable_notify_insert()', queue_name;
1730
+ END IF;
1731
+
1732
+ UPDATE pgmq.notify_insert_throttle
1733
+ SET throttle_interval_ms = update_notify_insert.throttle_interval_ms,
1734
+ last_notified_at = to_timestamp(0)
1735
+ WHERE notify_insert_throttle.queue_name = update_notify_insert.queue_name;
1736
+ END;
1737
+ $$;
1738
+
1739
+ CREATE OR REPLACE FUNCTION pgmq.validate_routing_key(routing_key text)
1740
+ RETURNS boolean
1741
+ LANGUAGE plpgsql
1742
+ IMMUTABLE
1743
+ AS
1744
+ $$
1745
+ BEGIN
1746
+ -- Valid routing key examples:
1747
+ -- "logs.error"
1748
+ -- "app.user-service.auth"
1749
+ -- "system_events.db.connection_failed"
1750
+ --
1751
+ -- Invalid routing key examples:
1752
+ -- "" - empty
1753
+ -- ".logs.error" - starts with dot
1754
+ -- "logs.error." - ends with dot
1755
+ -- "logs..error" - consecutive dots
1756
+ -- "logs.error!" - invalid character
1757
+ -- "logs error" - space not allowed
1758
+ -- "logs.*" - wildcards not allowed in routing keys
1759
+
1760
+ IF routing_key IS NULL OR routing_key = '' THEN
1761
+ RAISE EXCEPTION 'routing_key cannot be NULL or empty';
1762
+ END IF;
1763
+
1764
+ IF length(routing_key) > 255 THEN
1765
+ RAISE EXCEPTION 'routing_key length cannot exceed 255 characters, got % characters', length(routing_key);
1766
+ END IF;
1767
+
1768
+ IF routing_key !~ '^[a-zA-Z0-9._-]+$' THEN
1769
+ RAISE EXCEPTION 'routing_key contains invalid characters. Only alphanumeric, dots, hyphens, and underscores are allowed. Got: %', routing_key;
1770
+ END IF;
1771
+
1772
+ IF routing_key ~ '^\.' THEN
1773
+ RAISE EXCEPTION 'routing_key cannot start with a dot. Got: %', routing_key;
1774
+ END IF;
1775
+
1776
+ IF routing_key ~ '\.$' THEN
1777
+ RAISE EXCEPTION 'routing_key cannot end with a dot. Got: %', routing_key;
1778
+ END IF;
1779
+
1780
+ IF routing_key ~ '\.\.' THEN
1781
+ RAISE EXCEPTION 'routing_key cannot contain consecutive dots. Got: %', routing_key;
1782
+ END IF;
1783
+
1784
+ RETURN true;
1785
+ END;
1786
+ $$;
1787
+
1788
+ CREATE OR REPLACE FUNCTION pgmq.validate_topic_pattern(pattern text)
1789
+ RETURNS boolean
1790
+ LANGUAGE plpgsql
1791
+ IMMUTABLE
1792
+ AS
1793
+ $$
1794
+ BEGIN
1795
+ -- Valid pattern examples:
1796
+ -- "logs.*" - matches one segment after logs. (e.g., logs.error, logs.info)
1797
+ -- "logs.#" - matches one or more segments after logs. (e.g., logs.error, logs.api.error)
1798
+ -- "*.error" - matches one segment before .error (e.g., app.error, db.error)
1799
+ -- "#.error" - matches one or more segments before .error (e.g., app.error, x.y.error)
1800
+ -- "app.*.#" - mixed wildcards (one segment then one or more)
1801
+ -- "#" - catch-all pattern, matches any routing key
1802
+ --
1803
+ -- Invalid pattern examples:
1804
+ -- ".logs.*" - starts with dot
1805
+ -- "logs.*." - ends with dot
1806
+ -- "logs..error" - consecutive dots
1807
+ -- "logs.**" - consecutive stars
1808
+ -- "logs.##" - consecutive hashes
1809
+ -- "logs.*#" - adjacent wildcards
1810
+ -- "logs.error!" - invalid character
1811
+
1812
+ IF pattern IS NULL OR pattern = '' THEN
1813
+ RAISE EXCEPTION 'pattern cannot be NULL or empty';
1814
+ END IF;
1815
+
1816
+ IF length(pattern) > 255 THEN
1817
+ RAISE EXCEPTION 'pattern length cannot exceed 255 characters, got % characters', length(pattern);
1818
+ END IF;
1819
+
1820
+ IF pattern !~ '^[a-zA-Z0-9._\-*#]+$' THEN
1821
+ RAISE EXCEPTION 'pattern contains invalid characters. Only alphanumeric, dots, hyphens, underscores, *, and # are allowed. Got: %', pattern;
1822
+ END IF;
1823
+
1824
+ IF pattern ~ '^\.' THEN
1825
+ RAISE EXCEPTION 'pattern cannot start with a dot. Got: %', pattern;
1826
+ END IF;
1827
+
1828
+ IF pattern ~ '\.$' THEN
1829
+ RAISE EXCEPTION 'pattern cannot end with a dot. Got: %', pattern;
1830
+ END IF;
1831
+
1832
+ IF pattern ~ '\.\.' THEN
1833
+ RAISE EXCEPTION 'pattern cannot contain consecutive dots. Got: %', pattern;
1834
+ END IF;
1835
+
1836
+ IF pattern ~ '\*\*' THEN
1837
+ RAISE EXCEPTION 'pattern cannot contain consecutive stars (**). Use # for multi-segment matching. Got: %', pattern;
1838
+ END IF;
1839
+
1840
+ IF pattern ~ '##' THEN
1841
+ RAISE EXCEPTION 'pattern cannot contain consecutive hashes (##). A single # already matches zero or more segments. Got: %', pattern;
1842
+ END IF;
1843
+
1844
+ IF pattern ~ '\*#' OR pattern ~ '#\*' THEN
1845
+ RAISE EXCEPTION 'pattern cannot contain adjacent wildcards (*# or #*). Separate wildcards with dots. Got: %', pattern;
1846
+ END IF;
1847
+
1848
+ RETURN true;
1849
+ END;
1850
+ $$;
1851
+
1852
+ CREATE OR REPLACE FUNCTION pgmq.bind_topic(pattern text, queue_name text)
1853
+ RETURNS void
1854
+ LANGUAGE plpgsql
1855
+ AS
1856
+ $$
1857
+ BEGIN
1858
+ PERFORM pgmq.validate_topic_pattern(pattern);
1859
+ IF queue_name IS NULL OR queue_name = '' THEN
1860
+ RAISE EXCEPTION 'queue_name cannot be NULL or empty';
1861
+ END IF;
1862
+
1863
+ IF NOT EXISTS (SELECT 1 FROM pgmq.meta WHERE meta.queue_name = bind_topic.queue_name) THEN
1864
+ RAISE EXCEPTION 'Queue "%" does not exist. Create the queue first using pgmq.create()', queue_name;
1865
+ END IF;
1866
+
1867
+ INSERT INTO pgmq.topic_bindings (pattern, queue_name)
1868
+ VALUES (pattern, queue_name)
1869
+ ON CONFLICT ON CONSTRAINT topic_bindings_unique_pattern_queue DO NOTHING;
1870
+ END;
1871
+ $$;
1872
+
1873
+ CREATE OR REPLACE FUNCTION pgmq.unbind_topic(pattern text, queue_name text)
1874
+ RETURNS boolean
1875
+ LANGUAGE plpgsql
1876
+ AS
1877
+ $$
1878
+ DECLARE
1879
+ rows_deleted integer;
1880
+ BEGIN
1881
+ IF pattern IS NULL OR pattern = '' THEN
1882
+ RAISE EXCEPTION 'pattern cannot be NULL or empty';
1883
+ END IF;
1884
+
1885
+ IF queue_name IS NULL OR queue_name = '' THEN
1886
+ RAISE EXCEPTION 'queue_name cannot be NULL or empty';
1887
+ END IF;
1888
+
1889
+ DELETE
1890
+ FROM pgmq.topic_bindings
1891
+ WHERE topic_bindings.pattern = unbind_topic.pattern
1892
+ AND topic_bindings.queue_name = unbind_topic.queue_name;
1893
+
1894
+ GET DIAGNOSTICS rows_deleted = ROW_COUNT;
1895
+
1896
+ IF rows_deleted > 0 THEN
1897
+ RETURN true;
1898
+ ELSE
1899
+ RETURN false;
1900
+ END IF;
1901
+ END;
1902
+ $$;
1903
+
1904
+ CREATE OR REPLACE FUNCTION pgmq.test_routing(routing_key text)
1905
+ RETURNS TABLE
1906
+ (
1907
+ pattern text,
1908
+ queue_name text,
1909
+ compiled_regex text
1910
+ )
1911
+ LANGUAGE plpgsql
1912
+ STABLE
1913
+ AS
1914
+ $$
1915
+ BEGIN
1916
+ PERFORM pgmq.validate_routing_key(routing_key);
1917
+ RETURN QUERY
1918
+ SELECT b.pattern,
1919
+ b.queue_name,
1920
+ b.compiled_regex
1921
+ FROM pgmq.topic_bindings b
1922
+ WHERE routing_key ~ b.compiled_regex
1923
+ ORDER BY b.pattern;
1924
+ END;
1925
+ $$;
1926
+
1927
+ CREATE OR REPLACE FUNCTION pgmq.send_topic(routing_key text, msg jsonb, headers jsonb, delay integer)
1928
+ RETURNS integer
1929
+ LANGUAGE plpgsql
1930
+ VOLATILE
1931
+ AS
1932
+ $$
1933
+ DECLARE
1934
+ b RECORD;
1935
+ matched_count integer := 0;
1936
+ BEGIN
1937
+ PERFORM pgmq.validate_routing_key(routing_key);
1938
+
1939
+ IF msg IS NULL THEN
1940
+ RAISE EXCEPTION 'msg cannot be NULL';
1941
+ END IF;
1942
+
1943
+ IF delay < 0 THEN
1944
+ RAISE EXCEPTION 'delay cannot be negative, got: %', delay;
1945
+ END IF;
1946
+
1947
+ -- Filter matching patterns in SQL for better performance (uses index)
1948
+ -- Any failure will rollback the entire transaction
1949
+ FOR b IN
1950
+ SELECT DISTINCT tb.queue_name
1951
+ FROM pgmq.topic_bindings tb
1952
+ WHERE routing_key ~ tb.compiled_regex
1953
+ ORDER BY tb.queue_name -- Deterministic ordering, deduplicated by queue_name
1954
+ LOOP
1955
+ PERFORM pgmq.send(b.queue_name, msg, headers, delay);
1956
+ matched_count := matched_count + 1;
1957
+ END LOOP;
1958
+
1959
+ RETURN matched_count;
1960
+ END;
1961
+ $$;
1962
+
1963
+ CREATE OR REPLACE FUNCTION pgmq.send_topic(routing_key text, msg jsonb)
1964
+ RETURNS integer
1965
+ LANGUAGE plpgsql
1966
+ VOLATILE
1967
+ AS
1968
+ $$
1969
+ BEGIN
1970
+ RETURN pgmq.send_topic(routing_key, msg, NULL, 0);
1971
+ END;
1972
+ $$;
1973
+
1974
+ CREATE OR REPLACE FUNCTION pgmq.send_topic(routing_key text, msg jsonb, delay integer)
1975
+ RETURNS integer
1976
+ LANGUAGE plpgsql
1977
+ VOLATILE
1978
+ AS
1979
+ $$
1980
+ BEGIN
1981
+ RETURN pgmq.send_topic(routing_key, msg, NULL, delay);
1982
+ END;
1983
+ $$;
1984
+
1985
+ CREATE OR REPLACE FUNCTION pgmq.list_topic_bindings()
1986
+ RETURNS TABLE
1987
+ (
1988
+ pattern text,
1989
+ queue_name text,
1990
+ bound_at TIMESTAMP WITH TIME ZONE,
1991
+ compiled_regex text
1992
+ )
1993
+ LANGUAGE sql
1994
+ STABLE
1995
+ AS
1996
+ $$
1997
+ SELECT pattern, queue_name, bound_at, compiled_regex
1998
+ FROM pgmq.topic_bindings
1999
+ ORDER BY bound_at DESC, pattern, queue_name;
2000
+ $$;
2001
+
2002
+ CREATE OR REPLACE FUNCTION pgmq.list_topic_bindings(queue_name text)
2003
+ RETURNS TABLE
2004
+ (
2005
+ pattern text,
2006
+ queue_name text,
2007
+ bound_at TIMESTAMP WITH TIME ZONE,
2008
+ compiled_regex text
2009
+ )
2010
+ LANGUAGE sql
2011
+ STABLE
2012
+ AS
2013
+ $$
2014
+ SELECT pattern, tb.queue_name, bound_at, compiled_regex
2015
+ FROM pgmq.topic_bindings tb
2016
+ WHERE tb.queue_name = list_topic_bindings.queue_name
2017
+ ORDER BY bound_at DESC, pattern;
2018
+ $$;
2019
+
2020
+ -- send_batch_topic: Base implementation with TIMESTAMP WITH TIME ZONE delay
2021
+ CREATE OR REPLACE FUNCTION pgmq.send_batch_topic(
2022
+ routing_key text,
2023
+ msgs jsonb[],
2024
+ headers jsonb[],
2025
+ delay TIMESTAMP WITH TIME ZONE
2026
+ )
2027
+ RETURNS TABLE(queue_name text, msg_id bigint)
2028
+ LANGUAGE plpgsql
2029
+ VOLATILE
2030
+ AS
2031
+ $$
2032
+ DECLARE
2033
+ b RECORD;
2034
+ BEGIN
2035
+ PERFORM pgmq.validate_routing_key(routing_key);
2036
+
2037
+ -- Validate batch parameters once (not per queue)
2038
+ PERFORM pgmq._validate_batch_params(msgs, headers);
2039
+
2040
+ -- Filter matching patterns in SQL for better performance (uses index)
2041
+ -- Any failure will rollback the entire transaction
2042
+ FOR b IN
2043
+ SELECT DISTINCT tb.queue_name
2044
+ FROM pgmq.topic_bindings tb
2045
+ WHERE routing_key ~ tb.compiled_regex
2046
+ ORDER BY tb.queue_name -- Deterministic ordering, deduplicated by queue_name
2047
+ LOOP
2048
+ -- Use private _send_batch to avoid redundant validation
2049
+ RETURN QUERY
2050
+ SELECT b.queue_name, batch_result.msg_id
2051
+ FROM pgmq._send_batch(b.queue_name, msgs, headers, delay) AS batch_result(msg_id);
2052
+ END LOOP;
2053
+
2054
+ RETURN;
2055
+ END;
2056
+ $$;
2057
+
2058
+ -- send_batch_topic: 2 args (routing_key, msgs)
2059
+ CREATE OR REPLACE FUNCTION pgmq.send_batch_topic(
2060
+ routing_key text,
2061
+ msgs jsonb[]
2062
+ )
2063
+ RETURNS TABLE(queue_name text, msg_id bigint)
2064
+ LANGUAGE sql
2065
+ VOLATILE
2066
+ AS
2067
+ $$
2068
+ SELECT * FROM pgmq.send_batch_topic(routing_key, msgs, NULL, clock_timestamp());
2069
+ $$;
2070
+
2071
+ -- send_batch_topic: 3 args with headers
2072
+ CREATE OR REPLACE FUNCTION pgmq.send_batch_topic(
2073
+ routing_key text,
2074
+ msgs jsonb[],
2075
+ headers jsonb[]
2076
+ )
2077
+ RETURNS TABLE(queue_name text, msg_id bigint)
2078
+ LANGUAGE sql
2079
+ VOLATILE
2080
+ AS
2081
+ $$
2082
+ SELECT * FROM pgmq.send_batch_topic(routing_key, msgs, headers, clock_timestamp());
2083
+ $$;
2084
+
2085
+ -- send_batch_topic: 3 args with integer delay
2086
+ CREATE OR REPLACE FUNCTION pgmq.send_batch_topic(
2087
+ routing_key text,
2088
+ msgs jsonb[],
2089
+ delay integer
2090
+ )
2091
+ RETURNS TABLE(queue_name text, msg_id bigint)
2092
+ LANGUAGE sql
2093
+ VOLATILE
2094
+ AS
2095
+ $$
2096
+ SELECT * FROM pgmq.send_batch_topic(routing_key, msgs, NULL, clock_timestamp() + make_interval(secs => delay));
2097
+ $$;
2098
+
2099
+ -- send_batch_topic: 3 args with timestamp delay
2100
+ CREATE OR REPLACE FUNCTION pgmq.send_batch_topic(
2101
+ routing_key text,
2102
+ msgs jsonb[],
2103
+ delay TIMESTAMP WITH TIME ZONE
2104
+ )
2105
+ RETURNS TABLE(queue_name text, msg_id bigint)
2106
+ LANGUAGE sql
2107
+ VOLATILE
2108
+ AS
2109
+ $$
2110
+ SELECT * FROM pgmq.send_batch_topic(routing_key, msgs, NULL, delay);
2111
+ $$;
2112
+
2113
+ -- send_batch_topic: 4 args with integer delay
2114
+ CREATE OR REPLACE FUNCTION pgmq.send_batch_topic(
2115
+ routing_key text,
2116
+ msgs jsonb[],
2117
+ headers jsonb[],
2118
+ delay integer
2119
+ )
2120
+ RETURNS TABLE(queue_name text, msg_id bigint)
2121
+ LANGUAGE sql
2122
+ VOLATILE
2123
+ AS
2124
+ $$
2125
+ SELECT * FROM pgmq.send_batch_topic(routing_key, msgs, headers, clock_timestamp() + make_interval(secs => delay));
2126
+ $$;