pgbus 0.21.3 → 0.21.4

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