@haathie/pgmb 0.2.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,256 @@
1
+ SET search_path = pgmb, pg_catalog;
2
+
3
+ -- Find all existing queues via the "pgmb.queues" table,
4
+ -- and drop the default on the "id" column
5
+ DO $$
6
+ DECLARE
7
+ queue_schema VARCHAR(64);
8
+ BEGIN
9
+ -- get all queue schemas
10
+ -- drop the default on the "id" column for each queue
11
+ FOR queue_schema IN (SELECT schema_name FROM pgmb.queues) LOOP
12
+ EXECUTE 'ALTER TABLE '
13
+ || quote_ident(queue_schema)
14
+ || '.live_messages ALTER COLUMN id DROP DEFAULT';
15
+ END LOOP;
16
+ END $$;
17
+
18
+ DROP FUNCTION create_message_id(timestamp with time zone,bigint);
19
+
20
+ DROP FUNCTION create_random_bigint(additive double precision);
21
+
22
+ DROP FUNCTION extract_date_from_message_id(message_id VARCHAR(64));
23
+
24
+ DROP FUNCTION get_queue_metrics(queue_name VARCHAR(64));
25
+
26
+ DROP FUNCTION get_all_queue_metrics();
27
+
28
+ CREATE OR REPLACE FUNCTION create_queue_table(queue_name VARCHAR(64), schema_name VARCHAR(64), queue_type pgmb.queue_type) RETURNS VOID AS $$
29
+ BEGIN
30
+ -- create the live_messages table
31
+ EXECUTE 'CREATE TABLE ' || quote_ident(schema_name) || '.live_messages (
32
+ id VARCHAR(22) PRIMARY KEY,
33
+ message BYTEA NOT NULL,
34
+ headers JSONB NOT NULL DEFAULT ''{}''::JSONB,
35
+ created_at TIMESTAMPTZ DEFAULT NOW()
36
+ )';
37
+ IF queue_type = 'unlogged' THEN
38
+ EXECUTE 'ALTER TABLE ' || quote_ident(schema_name)
39
+ || '.live_messages SET UNLOGGED';
40
+ END IF;
41
+ END;
42
+ $$ LANGUAGE plpgsql;
43
+
44
+ CREATE OR REPLACE FUNCTION create_random_bigint() RETURNS BIGINT AS $$
45
+ BEGIN
46
+ -- the message ID allows for 7 hex-bytes of randomness,
47
+ -- i.e. 28 bits of randomness. Thus, the max we allow is 2^28/2
48
+ -- i.e. 0xffffff8, which allows for batch inserts to increment the
49
+ -- randomness for up to another 2^28/2 messages (more than enough)
50
+ RETURN (random() * 0xffffff8)::BIGINT;
51
+ END
52
+ $$ LANGUAGE plpgsql VOLATILE PARALLEL SAFE;
53
+
54
+ CREATE OR REPLACE FUNCTION create_message_id(dt timestamptz = clock_timestamp(), rand bigint = pgmb.create_random_bigint()) RETURNS VARCHAR(22) AS $$
55
+ BEGIN
56
+ -- create a unique message ID, 16 chars of hex-date
57
+ -- some additional bytes of randomness
58
+ -- ensure the string is always, at most 32 bytes
59
+ RETURN substr(
60
+ 'pm'
61
+ || substr(lpad(to_hex((extract(epoch from dt) * 1000000)::bigint), 13, '0'), 1, 13)
62
+ || lpad(to_hex(rand), 7, '0'),
63
+ 1,
64
+ 22
65
+ );
66
+ END
67
+ $$ LANGUAGE plpgsql VOLATILE PARALLEL SAFE;
68
+
69
+ CREATE OR REPLACE FUNCTION get_max_message_id(dt timestamptz = clock_timestamp()) RETURNS VARCHAR(22) AS $$
70
+ BEGIN
71
+ RETURN pgmb.create_message_id(
72
+ dt,
73
+ rand := 999999999999 -- max randomness
74
+ );
75
+ END
76
+ $$ LANGUAGE plpgsql VOLATILE PARALLEL SAFE;
77
+
78
+ CREATE OR REPLACE FUNCTION extract_date_from_message_id(message_id VARCHAR(22)) RETURNS TIMESTAMPTZ AS $$
79
+ BEGIN
80
+ -- convert it to a timestamp
81
+ RETURN to_timestamp(('0x' || substr(message_id, 3, 13))::numeric / 1000000);
82
+ END
83
+ $$ LANGUAGE plpgsql IMMUTABLE PARALLEL SAFE;
84
+
85
+ CREATE OR REPLACE FUNCTION send(queue_name VARCHAR(64), messages pgmb.enqueue_msg[]) RETURNS SETOF VARCHAR(22) AS $$
86
+ DECLARE
87
+ -- we'll have a starting random number, and each successive message ID's
88
+ -- random component will be this number + the ordinality of the message.
89
+ start_rand constant BIGINT = pgmb.create_random_bigint();
90
+ BEGIN
91
+ -- create the ID for each message, and then send to the internal _send fn
92
+ RETURN QUERY
93
+ WITH msg_records AS (
94
+ SELECT (
95
+ pgmb.create_message_id(
96
+ COALESCE(m.consume_at, clock_timestamp()),
97
+ start_rand + m.ordinality
98
+ ),
99
+ m.message,
100
+ m.headers
101
+ )::pgmb.msg_record AS record
102
+ FROM unnest(messages) WITH ORDINALITY AS m
103
+ )
104
+ SELECT pgmb._send(queue_name, ARRAY_AGG(m.record)::pgmb.msg_record[])
105
+ FROM msg_records m;
106
+ END
107
+ $$ LANGUAGE plpgsql;
108
+
109
+ CREATE OR REPLACE FUNCTION _send(queue_name VARCHAR(64), messages pgmb.msg_record[]) RETURNS SETOF VARCHAR(22) AS $$
110
+ DECLARE
111
+ -- check if the queue already exists
112
+ schema_name VARCHAR(64);
113
+ default_headers JSONB;
114
+ BEGIN
115
+ -- each queue would have its own channel to listen on, so a consumer can
116
+ -- listen to a specific queue. This'll be used to notify the consumer when
117
+ -- new messages are added to the queue.
118
+ PERFORM pg_notify(
119
+ 'chn_' || queue_name,
120
+ ('{"count":' || array_length(messages, 1)::varchar || '}')::varchar
121
+ );
122
+
123
+ -- get schema name and default headers
124
+ SELECT q.schema_name, q.default_headers FROM pgmb.queues q
125
+ WHERE q.name = queue_name INTO schema_name, default_headers;
126
+ -- Insert the message into the queue and return all message IDs. We use the
127
+ -- ordinality of the array to ensure that each message is inserted in the same
128
+ -- order as it was sent. This is important for the consumer to process the
129
+ -- messages in the same order as they were sent.
130
+ RETURN QUERY
131
+ EXECUTE 'INSERT INTO '
132
+ || quote_ident(schema_name)
133
+ || '.live_messages (id, message, headers)
134
+ SELECT
135
+ id,
136
+ message,
137
+ COALESCE($1, ''{}''::JSONB) || COALESCE(headers, ''{}''::JSONB)
138
+ FROM unnest($2)
139
+ RETURNING id' USING default_headers, messages;
140
+ END
141
+ $$ LANGUAGE plpgsql;
142
+
143
+ CREATE OR REPLACE FUNCTION read_from_queue(queue_name VARCHAR(64), limit_count INTEGER = 1) RETURNS SETOF pgmb.msg_record AS $$
144
+ DECLARE
145
+ schema_name VARCHAR(64);
146
+ BEGIN
147
+ -- get schema name
148
+ SELECT q.schema_name FROM pgmb.queues q
149
+ WHERE q.name = queue_name INTO schema_name;
150
+ -- read the messages from the queue
151
+ RETURN QUERY EXECUTE 'SELECT id, message, headers
152
+ FROM ' || quote_ident(schema_name) || '.live_messages
153
+ WHERE id <= pgmb.get_max_message_id()
154
+ ORDER BY id ASC
155
+ FOR UPDATE SKIP LOCKED
156
+ LIMIT $1'
157
+ USING limit_count;
158
+ END
159
+ $$ LANGUAGE plpgsql;
160
+
161
+ CREATE OR REPLACE FUNCTION publish(messages pgmb.publish_msg[]) RETURNS SETOF VARCHAR(22) AS $$
162
+ DECLARE
163
+ start_rand constant BIGINT = pgmb.create_random_bigint();
164
+ BEGIN
165
+ -- Create message IDs for each message, then we'll send them to the individual
166
+ -- queues. The ID will be the same for all queues, but the headers may vary
167
+ -- across queues.
168
+ RETURN QUERY
169
+ WITH msg_records AS (
170
+ SELECT
171
+ pgmb.create_message_id(
172
+ COALESCE(consume_at, clock_timestamp()),
173
+ start_rand + ordinality
174
+ ) AS id,
175
+ message,
176
+ JSONB_SET(
177
+ COALESCE(headers, '{}'::JSONB),
178
+ '{exchange}',
179
+ TO_JSONB(exchange)
180
+ ) as headers,
181
+ exchange,
182
+ ordinality
183
+ FROM unnest(messages) WITH ORDINALITY
184
+ ),
185
+ sends AS (
186
+ SELECT
187
+ pgmb._send(
188
+ q.queue_name,
189
+ ARRAY_AGG((m.id, m.message, m.headers)::pgmb.msg_record)
190
+ ) as id
191
+ FROM msg_records m,
192
+ LATERAL (
193
+ SELECT DISTINCT name, unnest(queues) AS queue_name
194
+ FROM pgmb.exchanges e
195
+ WHERE e.name = m.exchange
196
+ ) q
197
+ GROUP BY q.queue_name
198
+ )
199
+ -- we'll select an aggregate of "sends", to ensure that each "send" call
200
+ -- is executed. If this is not done, PG may optimize the query
201
+ -- and not execute the "sends" CTE at all, resulting in no messages being sent.
202
+ -- So, this aggregate call ensures PG does not optimize it away.
203
+ SELECT
204
+ CASE WHEN count(*) FILTER (WHERE sends.id IS NOT NULL) > 0 THEN m.id END
205
+ FROM msg_records m
206
+ LEFT JOIN sends ON sends.id = m.id
207
+ GROUP BY m.id, m.ordinality
208
+ ORDER BY m.ordinality;
209
+ END
210
+ $$ LANGUAGE plpgsql;
211
+
212
+ CREATE OR REPLACE FUNCTION get_queue_metrics(queue_name VARCHAR(64), approximate BOOLEAN = FALSE) RETURNS SETOF pgmb.metrics_result AS $$
213
+ DECLARE
214
+ schema_name VARCHAR(64);
215
+ BEGIN
216
+ -- get schema name
217
+ SELECT q.schema_name FROM pgmb.queues q
218
+ WHERE q.name = queue_name INTO schema_name;
219
+ -- get the metrics of the queue
220
+ RETURN QUERY EXECUTE 'SELECT
221
+ ''' || queue_name || '''::varchar(64) AS queue_name,
222
+ ' ||
223
+ (CASE WHEN approximate THEN
224
+ 'COALESCE(pgmb.get_approximate_count(' || quote_literal(schema_name || '.live_messages') || '), 0) AS total_length,'
225
+ || '0 AS consumable_length,'
226
+ ELSE
227
+ 'count(*)::int AS total_length,'
228
+ || '(count(*) FILTER (WHERE id <= pgmb.get_max_message_id()))::int AS consumable_length,'
229
+ END) || '
230
+ (clock_timestamp() - pgmb.extract_date_from_message_id(max(id))) AS newest_msg_age_sec,
231
+ (clock_timestamp() - pgmb.extract_date_from_message_id(min(id))) AS oldest_msg_age_sec
232
+ FROM ' || quote_ident(schema_name) || '.live_messages';
233
+ END
234
+ $$ LANGUAGE plpgsql;
235
+
236
+ CREATE OR REPLACE FUNCTION get_all_queue_metrics(approximate BOOLEAN = FALSE) RETURNS SETOF pgmb.metrics_result AS $$
237
+ BEGIN
238
+ RETURN QUERY
239
+ SELECT m.*
240
+ FROM pgmb.queues q, pgmb.get_queue_metrics(q.name, approximate) m
241
+ ORDER BY q.name ASC;
242
+ END
243
+ $$ LANGUAGE plpgsql;
244
+
245
+ CREATE OR REPLACE FUNCTION get_approximate_count(table_name regclass) RETURNS INTEGER AS $$
246
+ SELECT (
247
+ CASE WHEN c.reltuples < 0 THEN NULL -- never vacuumed
248
+ WHEN c.relpages = 0 THEN float8 '0' -- empty table
249
+ ELSE c.reltuples / c.relpages END
250
+ * (pg_catalog.pg_relation_size(c.oid)
251
+ / pg_catalog.current_setting('block_size')::int)
252
+ )::bigint
253
+ FROM pg_catalog.pg_class c
254
+ WHERE c.oid = table_name
255
+ LIMIT 1;
256
+ $$ LANGUAGE sql;
@@ -0,0 +1,95 @@
1
+ CREATE OR REPLACE FUNCTION pgmb.ack_msgs(
2
+ queue_name VARCHAR(64),
3
+ success BOOLEAN,
4
+ ids VARCHAR(22)[]
5
+ )
6
+ RETURNS VOID AS $$
7
+ DECLARE
8
+ schema_name VARCHAR(64);
9
+ ack_setting pgmb.queue_ack_setting;
10
+ query_str TEXT;
11
+ deleted_msg_count int;
12
+ BEGIN
13
+ -- get schema name and ack setting
14
+ SELECT q.schema_name, q.ack_setting
15
+ FROM pgmb.queues q
16
+ WHERE q.name = queue_name
17
+ INTO schema_name, ack_setting;
18
+
19
+ -- we'll construct a single CTE query that'll delete messages,
20
+ -- requeue them if needed, and archive them if ack_setting is 'archive'.
21
+ query_str := 'WITH deleted_msgs AS (
22
+ DELETE FROM ' || quote_ident(schema_name) || '.live_messages
23
+ WHERE id = ANY($1)
24
+ RETURNING id, message, headers
25
+ )';
26
+
27
+ -- re-insert messages that can be retried
28
+ IF NOT success THEN
29
+ query_str := query_str || ',
30
+ requeued AS (
31
+ INSERT INTO '
32
+ || quote_ident(schema_name)
33
+ || '.live_messages (id, message, headers)
34
+ SELECT
35
+ pgmb.create_message_id(
36
+ clock_timestamp() + (interval ''1 second'') * (t.headers->''retriesLeftS''->0)::int,
37
+ rn
38
+ ),
39
+ t.message,
40
+ t.headers
41
+ -- set retriesLeftS to the next retry
42
+ || jsonb_build_object(''retriesLeftS'', (t.headers->''retriesLeftS'') #- ''{0}'')
43
+ -- set the originalMessageId
44
+ -- to the original message ID if it exists
45
+ || jsonb_build_object(
46
+ ''originalMessageId'', COALESCE(t.headers->''originalMessageId'', to_jsonb(t.id))
47
+ )
48
+ -- set the tries
49
+ || jsonb_build_object(
50
+ ''tries'',
51
+ CASE
52
+ WHEN jsonb_typeof(t.headers->''tries'') = ''number'' THEN
53
+ to_jsonb((t.headers->>''tries'')::INTEGER + 1)
54
+ ELSE
55
+ to_jsonb(1)
56
+ END
57
+ )
58
+ FROM (select *, row_number() over () AS rn FROM deleted_msgs) t
59
+ WHERE jsonb_typeof(t.headers -> ''retriesLeftS'' -> 0) = ''number''
60
+ RETURNING id
61
+ ),
62
+ requeued_notify AS (
63
+ SELECT pg_notify(
64
+ ''chn_' || queue_name || ''',
65
+ ''{"count":'' || (select count(*) from requeued)::varchar || ''}''
66
+ )
67
+ )
68
+ ';
69
+ END IF;
70
+
71
+ IF ack_setting = 'archive' THEN
72
+ -- Delete the messages from live_messages and insert them into
73
+ -- consumed_messages in one operation,
74
+ -- if the queue's ack_setting is set to 'archive'
75
+ query_str := query_str || ',
76
+ archived_records AS (
77
+ INSERT INTO ' || quote_ident(schema_name) || '.consumed_messages
78
+ (id, message, headers, success)
79
+ SELECT t.id, t.message, t.headers, $2::boolean
80
+ FROM deleted_msgs t
81
+ )';
82
+ END IF;
83
+
84
+ query_str := query_str || '
85
+ SELECT COUNT(*) FROM deleted_msgs';
86
+
87
+ EXECUTE query_str USING ids, success INTO deleted_msg_count;
88
+
89
+ -- Raise exception if no rows were affected
90
+ IF deleted_msg_count != array_length(ids, 1) THEN
91
+ RAISE EXCEPTION 'Only removed % out of % expected message(s).',
92
+ deleted_msg_count, array_length(ids, 1);
93
+ END IF;
94
+ END
95
+ $$ LANGUAGE plpgsql;