pgbus 0.16.7 → 0.17.0

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