pgbus 0.21.4 → 0.22.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,420 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Pgbus
6
+ class Client
7
+ # Native prepared statements (issue #605): pgbus-owned static SQL for the
8
+ # seven hot-path queue operations, run as named prepared statements on the
9
+ # pooled connection instead of through pgmq's plpgsql functions.
10
+ #
11
+ # pgmq.read / archive / delete / send / send_batch build their statement
12
+ # with FORMAT(...) and run it with EXECUTE. plpgsql never caches the plan
13
+ # of a dynamic EXECUTE, so Postgres parses and plans the statement on every
14
+ # call. The SQL below is the same statement those functions build
15
+ # (lib/pgbus/pgmq_schema/pgmq_v1.13.1.sql), written once per queue table,
16
+ # so Postgres can keep its plan for the life of the connection.
17
+ #
18
+ # Each queue is checked once per process: its q_ and a_ tables must have
19
+ # every column the statements touch (REQUIRED_COLUMNS). A queue whose table
20
+ # lacks one (a pgmq extension older than `headers`), or
21
+ # `config.native_statements = false`, keeps calling the pgmq functions.
22
+ #
23
+ # Statements are prepared on demand: exec_prepared first; an
24
+ # InvalidSqlStatementName means this session has not prepared it yet, so
25
+ # prepare and run it again. The server is the registry: nothing records
26
+ # which connection holds which statement. Errors are not wrapped here: the
27
+ # call runs inside @pgmq.with_connection, which retries a lost connection
28
+ # once and raises PGMQ::Errors::ConnectionError, exactly as on the function
29
+ # path.
30
+ #
31
+ # Inside a transaction the server has begun (the shared-AR path, an
32
+ # enqueue inside the app's transaction once Rails has sent its BEGIN) a
33
+ # failed exec_prepared would abort the caller's transaction, so there the
34
+ # same SQL runs through exec_params.
35
+ #
36
+ # Connections built from ActiveRecord's config whose database.yml sets
37
+ # `prepared_statements: false` (what a transaction-mode PgBouncer asks
38
+ # of Rails) send the same SQL through exec_params from the start.
39
+ #
40
+ # A transaction-mode pooler without prepared-statement support (PgBouncer
41
+ # before 1.21, or 1.21+ without max_prepared_statements) drops prepared
42
+ # statements between transactions. When that is seen (see
43
+ # NativeStatements.note_missing) the process switches to exec_params of the
44
+ # same static SQL for good and logs it once.
45
+ module NativeStatements
46
+ REQUIRED_COLUMNS = %w[msg_id read_ct enqueued_at last_read_at vt message headers].freeze
47
+
48
+ COLUMNS_SQL = "SELECT table_name, column_name FROM information_schema.columns " \
49
+ "WHERE table_schema = 'pgmq' AND table_name IN ($1, $2)"
50
+
51
+ # Statements found missing on a backend that already had them, in a row,
52
+ # before the process stops preparing.
53
+ LOST_STATEMENT_LIMIT = 3
54
+
55
+ # Raised when an archive or delete re-run after a lost connection comes
56
+ # back short (run_native). It carries the original error's text, which
57
+ # can match a stale-connection pattern, so with_stale_connection_retry
58
+ # checks completion_lost? first and never re-runs it.
59
+ COMPLETION_NOT_RESENT = "Database connection lost while an archive or delete ran, " \
60
+ "and the re-run found ids gone: it may have committed"
61
+
62
+ # Reads of up to this many messages run a statement with the LIMIT
63
+ # written in: with `LIMIT $1` the generic plan assumes a tenth of the
64
+ # table and hash-joins a full scan, so Postgres plans a custom plan on
65
+ # every call; a literal LIMIT gets the cached generic plan. A larger qty
66
+ # runs the `LIMIT $1` statement.
67
+ LITERAL_READ_LIMIT = 64
68
+
69
+ # Entries kept in @prepared_on before it is cleared. Every reconnect
70
+ # brings a new backend pid, so a long-lived process would otherwise grow
71
+ # it for good. Clearing only forgets history: a statement then missing
72
+ # reads as a first prepare, which can delay the switch, never cause it.
73
+ PREPARED_ON_LIMIT = 10_000
74
+
75
+ Statement = Data.define(:name, :sql)
76
+
77
+ # Process-wide, built at load: the pooler is a property of the process's
78
+ # database route, not of one Client instance.
79
+ @lost_in_a_row = Concurrent::AtomicFixnum.new(0)
80
+ @unprepared = Concurrent::AtomicBoolean.new(false)
81
+ # "backend_pid:statement name" for every statement this process has
82
+ # prepared. A statement missing on a backend listed here was dropped
83
+ # under us (a pooler moved the session); one missing on a new backend
84
+ # (a fresh connection, a reconnect) is only a first prepare.
85
+ @prepared_on = Concurrent::Map.new
86
+ # Whether the database.yml prepared_statements: false notice was logged.
87
+ @ar_unprepared_logged = Concurrent::AtomicBoolean.new(false)
88
+
89
+ # rubocop:disable ThreadSafety/ClassInstanceVariable -- the four are
90
+ # Concurrent primitives built once at load and never reassigned; every
91
+ # read and write below goes through their atomic operations.
92
+ class << self
93
+ # The error run_native raises for a completion whose re-run came back
94
+ # short (the client's stale retry and the consumer key on it).
95
+ def completion_lost?(error)
96
+ defined?(PGMQ::Errors::ConnectionError) && error.is_a?(PGMQ::Errors::ConnectionError) &&
97
+ error.message.start_with?(COMPLETION_NOT_RESENT)
98
+ end
99
+
100
+ # True once the process has given up on prepared statements.
101
+ def unprepared?
102
+ @unprepared.true?
103
+ end
104
+
105
+ def note_prepared(backend_pid, name)
106
+ @prepared_on.clear if @prepared_on.size >= PREPARED_ON_LIMIT
107
+ @prepared_on["#{backend_pid}:#{name}"] = true
108
+ end
109
+
110
+ def prepared_count
111
+ @prepared_on.size
112
+ end
113
+
114
+ # A statement this session needed to prepare. Counts toward the switch
115
+ # only when this backend had prepared it before.
116
+ def note_missing(backend_pid, name)
117
+ return unless @prepared_on.key?("#{backend_pid}:#{name}")
118
+
119
+ unprepare!("#{LOST_STATEMENT_LIMIT} prepared statements in a row disappeared from their connection") if
120
+ @lost_in_a_row.increment >= LOST_STATEMENT_LIMIT
121
+ end
122
+
123
+ # Logs, once per process, that database.yml turned preparing off.
124
+ def note_ar_unprepared
125
+ return unless @ar_unprepared_logged.make_true
126
+
127
+ Pgbus.logger.info do
128
+ "[Pgbus::Client] Native statements run unprepared: database.yml sets prepared_statements: false"
129
+ end
130
+ end
131
+
132
+ def note_executed
133
+ @lost_in_a_row.value = 0 unless @lost_in_a_row.value.zero?
134
+ end
135
+
136
+ def unprepare!(reason)
137
+ return unless @unprepared.make_true
138
+
139
+ Pgbus.logger.warn do
140
+ "[Pgbus::Client] Native statements: #{reason}, which is what a transaction-mode pooler " \
141
+ "without prepared-statement support does (PgBouncer < 1.21, or no max_prepared_statements). " \
142
+ "This process now sends the same SQL unprepared. Set max_prepared_statements on the pooler, " \
143
+ "or config.native_statements = false to use the pgmq functions."
144
+ end
145
+ end
146
+
147
+ # Test seam: forget the switch and every recorded statement.
148
+ def reset!
149
+ @lost_in_a_row.value = 0
150
+ @unprepared.make_false
151
+ @prepared_on.clear
152
+ @ar_unprepared_logged.make_false
153
+ end
154
+ # rubocop:enable ThreadSafety/ClassInstanceVariable
155
+
156
+ # The seven statements for one queue, keyed by op. Names are
157
+ # pgbus_<op>_<12 hex of SHA-256(sql)>: 63 bytes is the identifier limit,
158
+ # and hashing the SQL (which names the table) means two pgbus versions
159
+ # behind one pooler never run each other's text under one name.
160
+ #
161
+ # :reads holds the literal-LIMIT reads, indexed by qty (1 to
162
+ # LITERAL_READ_LIMIT); their one parameter is the visibility timeout.
163
+ def statements_for(queue_table, archive_table)
164
+ queue = "pgmq.#{quote_ident(queue_table)}"
165
+ statements = sql_for(queue, "pgmq.#{quote_ident(archive_table)}").to_h { |op, sql| [op, statement(op, sql)] }
166
+ reads = (1..LITERAL_READ_LIMIT).map { |qty| statement(:read, read_sql(queue, qty.to_s, "$1")) }
167
+ statements.merge(reads: [nil, *reads].freeze).freeze
168
+ end
169
+
170
+ private
171
+
172
+ def statement(kind, sql)
173
+ Statement.new(name: "pgbus_#{kind}_#{Digest::SHA256.hexdigest(sql)[0, 12]}", sql: sql.freeze)
174
+ end
175
+
176
+ def read_sql(queue, limit, vt)
177
+ "WITH cte AS (SELECT msg_id FROM #{queue} WHERE vt <= clock_timestamp() " \
178
+ "ORDER BY msg_id ASC LIMIT #{limit} FOR UPDATE SKIP LOCKED) " \
179
+ "UPDATE #{queue} m SET last_read_at = clock_timestamp(), " \
180
+ "vt = clock_timestamp() + make_interval(secs => #{vt}::integer), read_ct = read_ct + 1 " \
181
+ "FROM cte WHERE m.msg_id = cte.msg_id " \
182
+ "RETURNING m.msg_id, m.read_ct, m.enqueued_at, m.last_read_at, m.vt, m.message, m.headers"
183
+ end
184
+
185
+ def quote_ident(name)
186
+ %("#{name.gsub('"', '""')}")
187
+ end
188
+
189
+ # pgmq.read, pgmq.archive (both overloads), pgmq.delete (both),
190
+ # pgmq.send and pgmq._send_batch of pgmq 1.13.1, with the table names
191
+ # filled in. The delay is computed once per statement, as the pgmq
192
+ # wrappers do before calling the implementation.
193
+ def sql_for(queue, archive)
194
+ archived = "RETURNING msg_id, vt, read_ct, enqueued_at, last_read_at, message, headers"
195
+ archive_insert = "INSERT INTO #{archive} (msg_id, vt, read_ct, enqueued_at, last_read_at, message, headers) " \
196
+ "SELECT msg_id, vt, read_ct, enqueued_at, last_read_at, message, headers FROM archived " \
197
+ "RETURNING msg_id"
198
+ {
199
+ read: read_sql(queue, "$1::integer", "$2"),
200
+ archive: "WITH archived AS (DELETE FROM #{queue} WHERE msg_id = $1::bigint #{archived}) #{archive_insert}",
201
+ archive_batch: "WITH archived AS (DELETE FROM #{queue} WHERE msg_id = ANY($1::bigint[]) #{archived}) " \
202
+ "#{archive_insert}",
203
+ delete: "DELETE FROM #{queue} WHERE msg_id = $1::bigint RETURNING msg_id",
204
+ delete_batch: "DELETE FROM #{queue} WHERE msg_id = ANY($1::bigint[]) RETURNING msg_id",
205
+ send: "INSERT INTO #{queue} (vt, message, headers) " \
206
+ "VALUES (clock_timestamp() + make_interval(secs => $3::integer), $1::jsonb, $2::jsonb) " \
207
+ "RETURNING msg_id",
208
+ send_batch: "INSERT INTO #{queue} (vt, message, headers) " \
209
+ "SELECT d.vt, unnest($1::jsonb[]), unnest(coalesce($2::jsonb[], ARRAY[]::jsonb[])) " \
210
+ "FROM (SELECT clock_timestamp() + make_interval(secs => $3::integer) AS vt) d " \
211
+ "RETURNING msg_id"
212
+ }
213
+ end
214
+ end
215
+
216
+ # Whether this queue runs the native statements. Checked on first use
217
+ # (not at queue creation), so a queue created by another process is
218
+ # covered the same way.
219
+ def native_ready?(full_name)
220
+ native_statements_for(full_name) ? true : false
221
+ end
222
+
223
+ private
224
+
225
+ # Checks the queue's q_ and a_ columns and caches the outcome: the
226
+ # statements when every column is there, false when one is missing.
227
+ # A queue table that does not exist yet is not cached, so the queue is
228
+ # checked again once something creates it. Runs on @pgmq.with_connection
229
+ # without taking @pgmq_mutex: every caller already runs inside
230
+ # `synchronized`.
231
+ def detect_native_support!(full_name)
232
+ return false unless config.native_statements
233
+
234
+ queue_table = "q_#{full_name.downcase}"
235
+ archive_table = "a_#{full_name.downcase}"
236
+ columns = native_table_columns(queue_table, archive_table)
237
+ return false unless columns.key?(queue_table)
238
+
239
+ missing = native_missing_columns(columns, queue_table, archive_table)
240
+ value = missing.empty? ? NativeStatements.statements_for(queue_table, archive_table) : false
241
+ previous = @native_statements.put_if_absent(full_name, value)
242
+ return previous unless previous.nil?
243
+
244
+ log_native_unsupported(full_name, missing) unless value
245
+ value
246
+ end
247
+
248
+ # Whether the connections come from ActiveRecord's config with
249
+ # `prepared_statements: false`: the hash pgbus extracts from it, or a
250
+ # connection_params Proc handing out ActiveRecord's own connection. A
251
+ # database_url or a connection_params Hash is pgbus's own route. Read
252
+ # once, in Client#initialize.
253
+ def ar_disables_prepared_statements?
254
+ return false if config.database_url || config.connection_params.is_a?(Hash)
255
+ return false unless defined?(::ActiveRecord::Base)
256
+
257
+ base = config.connects_to ? Pgbus::BusRecord : ::ActiveRecord::Base
258
+ return false unless [false, "false"].include?(base.connection_db_config.configuration_hash[:prepared_statements])
259
+
260
+ NativeStatements.note_ar_unprepared
261
+ true
262
+ rescue StandardError
263
+ # Client#initialize reads config.connection_options first, whose
264
+ # Configuration#extract_ar_connection_hash makes this same
265
+ # connection_db_config call, logs its failure and falls back to the
266
+ # shared connection; there is no setting to read.
267
+ false
268
+ end
269
+
270
+ def native_statements_for(full_name)
271
+ return unless config.native_statements
272
+
273
+ cached = @native_statements[full_name]
274
+ return cached unless cached.nil?
275
+
276
+ detect_native_support!(full_name)
277
+ end
278
+
279
+ def native_table_columns(queue_table, archive_table)
280
+ rows = @pgmq.with_connection { |conn| conn.exec_params(COLUMNS_SQL, [queue_table, archive_table]).to_a }
281
+ rows.each_with_object(Hash.new { |h, k| h[k] = [] }) do |row, by_table|
282
+ by_table[row["table_name"]] << row["column_name"]
283
+ end
284
+ end
285
+
286
+ def native_missing_columns(columns, queue_table, archive_table)
287
+ missing = (REQUIRED_COLUMNS - columns[queue_table]).map { |c| "#{queue_table}.#{c}" }
288
+ missing + (REQUIRED_COLUMNS - columns[archive_table]).map { |c| "#{archive_table}.#{c}" }
289
+ end
290
+
291
+ def log_native_unsupported(full_name, missing)
292
+ Pgbus.logger.info do
293
+ "[Pgbus::Client] Queue #{full_name} uses the pgmq functions, not native statements: " \
294
+ "pgmq.#{missing.join(", pgmq.")} missing (a pgmq install older than the headers column?)"
295
+ end
296
+ end
297
+
298
+ # The seven operations, native when the queue is ready, the pgmq-ruby
299
+ # call otherwise. Return values match pgmq-ruby's: Messages with String
300
+ # fields, String ids, true/false for one archive/delete.
301
+
302
+ def exec_produce(name, message, headers:, delay:)
303
+ statements = delay.is_a?(Numeric) && native_statements_for(name)
304
+ return @pgmq.produce(name, message, headers: headers, delay: delay) unless statements
305
+
306
+ run_native(statements[:send], [message, headers, delay])[0]["msg_id"]
307
+ end
308
+
309
+ def exec_produce_batch(name, messages, headers:, delay:)
310
+ statements = messages.any? && (headers.nil? || headers.length == messages.length) &&
311
+ delay.is_a?(Numeric) && native_statements_for(name)
312
+ return @pgmq.produce_batch(name, messages, headers: headers, delay: delay) unless statements
313
+
314
+ encoder = PG::TextEncoder::Array.new
315
+ params = [encoder.encode(messages), headers && encoder.encode(headers), delay]
316
+ run_native(statements[:send_batch], params).map { |row| row["msg_id"] }
317
+ end
318
+
319
+ def exec_read_batch(name, vt:, qty:)
320
+ statements = native_statements_for(name)
321
+ return @pgmq.read_batch(name, vt: vt, qty: qty) unless statements
322
+
323
+ literal = qty.is_a?(Integer) && qty.positive? && statements[:reads][qty]
324
+ result = literal ? run_native(literal, [vt]) : run_native(statements[:read], [qty, vt])
325
+ result.map { |row| PGMQ::Message.new(row) }
326
+ end
327
+
328
+ def exec_archive(name, msg_id)
329
+ statements = native_statements_for(name)
330
+ return @pgmq.archive(name, msg_id) unless statements
331
+
332
+ run_native(statements[:archive], [msg_id], rows: 1).ntuples.positive?
333
+ end
334
+
335
+ def exec_archive_batch(name, msg_ids)
336
+ statements = msg_ids.any? && native_statements_for(name)
337
+ return @pgmq.archive_batch(name, msg_ids) unless statements
338
+
339
+ run_native(statements[:archive_batch], [PG::TextEncoder::Array.new.encode(msg_ids)], rows: msg_ids).map { |row| row["msg_id"] }
340
+ end
341
+
342
+ def exec_delete(name, msg_id)
343
+ statements = native_statements_for(name)
344
+ return @pgmq.delete(name, msg_id) unless statements
345
+
346
+ run_native(statements[:delete], [msg_id], rows: 1).ntuples.positive?
347
+ end
348
+
349
+ def exec_delete_batch(name, msg_ids)
350
+ statements = msg_ids.any? && native_statements_for(name)
351
+ return @pgmq.delete_batch(name, msg_ids) unless statements
352
+
353
+ run_native(statements[:delete_batch], [PG::TextEncoder::Array.new.encode(msg_ids)], rows: msg_ids).map { |row| row["msg_id"] }
354
+ end
355
+
356
+ # rows: the rows an archive or delete returns when every id was still
357
+ # there. pgmq-ruby's with_connection re-runs its block after a lost
358
+ # connection ("server closed the connection"). Usually the socket died
359
+ # idle in the pool and nothing ran, but it can follow a commit whose
360
+ # reply was lost: the re-run then finds those ids gone, and the executor
361
+ # would take its own completed job for a duplicate and skip its signals.
362
+ # So a re-run that comes back short raises instead; the executor's
363
+ # retry-once then reads the gone message as :ambiguous and claims it.
364
+ # A re-run that finds every id is certain: the first run never committed.
365
+ # rows is 1 or the batch's ids (counted once each, only on a re-run). An
366
+ # id another worker had already taken also makes a re-run come back
367
+ # short; every waiter of that batch then claims :ambiguous, the side the
368
+ # executor already errs on.
369
+ def run_native(statement, params, rows: nil)
370
+ attempt = 0
371
+ lost = nil
372
+ @pgmq.with_connection do |conn|
373
+ attempt += 1
374
+ result = exec_native(conn, statement, params)
375
+ if rows && attempt > 1 && result.ntuples < (rows.is_a?(Array) ? rows.uniq.size : rows)
376
+ raise PGMQ::Errors::ConnectionError, "#{COMPLETION_NOT_RESENT}: #{lost}"
377
+ end
378
+
379
+ result
380
+ rescue PG::Error => e
381
+ lost = e.message
382
+ raise
383
+ end
384
+ end
385
+
386
+ def exec_native(conn, statement, params)
387
+ if @native_unprepared || NativeStatements.unprepared? || inside_caller_transaction?(conn)
388
+ return conn.exec_params(statement.sql, params)
389
+ end
390
+
391
+ begin
392
+ result = conn.exec_prepared(statement.name, params)
393
+ rescue PG::InvalidSqlStatementName
394
+ return prepare_and_exec(conn, statement, params)
395
+ end
396
+ NativeStatements.note_executed
397
+ result
398
+ end
399
+
400
+ def prepare_and_exec(conn, statement, params)
401
+ pid = conn.backend_pid
402
+ NativeStatements.note_missing(pid, statement.name)
403
+ begin
404
+ conn.prepare(statement.name, statement.sql)
405
+ rescue PG::DuplicatePstatement
406
+ # Prepared on this session between our two calls; run it.
407
+ end
408
+ NativeStatements.note_prepared(pid, statement.name)
409
+ begin
410
+ conn.exec_prepared(statement.name, params)
411
+ rescue PG::InvalidSqlStatementName
412
+ # Gone again right after its own prepare: the pooler ran the two calls
413
+ # on different server connections.
414
+ NativeStatements.unprepare!("a statement disappeared right after it was prepared")
415
+ conn.exec_params(statement.sql, params)
416
+ end
417
+ end
418
+ end
419
+ end
420
+ end
data/lib/pgbus/client.rb CHANGED
@@ -17,6 +17,7 @@ module Pgbus
17
17
  include NotifyLockRetry
18
18
  include EnsureStreamQueue
19
19
  include NotifyStream
20
+ include NativeStatements
20
21
 
21
22
  attr_reader :pgmq, :config, :connection_health
22
23
 
@@ -170,6 +171,9 @@ module Pgbus
170
171
 
171
172
  @queues_created = Concurrent::Map.new
172
173
  @stream_indexes_created = Concurrent::Map.new
174
+ # Per physical queue: its native statements, or false (issue #605).
175
+ @native_statements = Concurrent::Map.new
176
+ @native_unprepared = config.native_statements && ar_disables_prepared_statements?
173
177
  # Guards the one-time build of the publisher autoscale trigger (issue #323).
174
178
  # NOT @pgmq_mutex — that is nil on the dedicated path (the only path the
175
179
  # trigger exists on), so it wouldn't serialize concurrent first-publishers.
@@ -356,7 +360,7 @@ module Pgbus
356
360
  Instrumentation.instrument("pgbus.client.send_message", queue: target) do
357
361
  with_stale_connection_retry do
358
362
  ensure_queue(queue_name)
359
- synchronized { @pgmq.produce(target, serialize(payload), headers: headers && serialize(headers), delay: delay) }
363
+ synchronized { exec_produce(target, serialize(payload), headers: headers && serialize(headers), delay: delay) }
360
364
  end
361
365
  end
362
366
  end
@@ -403,7 +407,7 @@ module Pgbus
403
407
  Instrumentation.instrument("pgbus.client.send_batch", queue: target, size: payloads.size) do
404
408
  with_stale_connection_retry do
405
409
  ensure_queue(queue_name)
406
- synchronized { @pgmq.produce_batch(target, serialized, headers: serialized_headers, delay: delay) }
410
+ synchronized { exec_produce_batch(target, serialized, headers: serialized_headers, delay: delay) }
407
411
  end
408
412
  end
409
413
  end
@@ -424,7 +428,7 @@ module Pgbus
424
428
  guarded_read do
425
429
  Instrumentation.instrument("pgbus.client.read_batch", queue: full_name, qty: qty) do
426
430
  with_stale_connection_retry do
427
- synchronized { with_read_timeout { @pgmq.read_batch(full_name, vt: vt || config.visibility_timeout, qty: qty) } }
431
+ synchronized { with_read_timeout { exec_read_batch(full_name, vt: vt || config.visibility_timeout, qty: qty) } }
428
432
  end
429
433
  end
430
434
  end
@@ -550,7 +554,7 @@ module Pgbus
550
554
  def delete_message(queue_name, msg_id, prefixed: true)
551
555
  name = prefixed ? config.queue_name(queue_name) : queue_name
552
556
  with_stale_connection_retry do
553
- synchronized { @pgmq.delete(name, msg_id) }
557
+ synchronized { exec_delete(name, msg_id) }
554
558
  end
555
559
  end
556
560
 
@@ -559,7 +563,7 @@ module Pgbus
559
563
  def archive_message(queue_name, msg_id, prefixed: true)
560
564
  name = prefixed ? config.queue_name(queue_name) : queue_name
561
565
  with_stale_connection_retry do
562
- synchronized { @pgmq.archive(name, msg_id) }
566
+ synchronized { exec_archive(name, msg_id) }
563
567
  end
564
568
  end
565
569
 
@@ -567,7 +571,7 @@ module Pgbus
567
571
  def archive_batch(queue_name, msg_ids, prefixed: true)
568
572
  name = prefixed ? config.queue_name(queue_name) : queue_name
569
573
  with_stale_connection_retry do
570
- synchronized { @pgmq.archive_batch(name, msg_ids) }
574
+ synchronized { exec_archive_batch(name, msg_ids) }
571
575
  end
572
576
  end
573
577
 
@@ -575,7 +579,7 @@ module Pgbus
575
579
  def delete_batch(queue_name, msg_ids, prefixed: true)
576
580
  name = prefixed ? config.queue_name(queue_name) : queue_name
577
581
  with_stale_connection_retry do
578
- synchronized { @pgmq.delete_batch(name, msg_ids) }
582
+ synchronized { exec_delete_batch(name, msg_ids) }
579
583
  end
580
584
  end
581
585
 
@@ -2182,6 +2186,10 @@ module Pgbus
2182
2186
  end
2183
2187
 
2184
2188
  def stale_connection_error?(error)
2189
+ # A completion whose re-run came back short may have committed: never
2190
+ # re-run it again (NativeStatements#run_native).
2191
+ return false if NativeStatements.completion_lost?(error)
2192
+
2185
2193
  # Substring match against any stale-connection pattern. Deliberately a
2186
2194
  # single Regexp rather than `STALE_CONNECTION_PATTERNS.any? { |p| msg.include?(p) }`:
2187
2195
  # Style/ArrayIntersect misfires on that shape and "corrects" it to
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require "time"
4
5
 
5
6
  module Pgbus
6
7
  module Concurrency
@@ -24,6 +24,34 @@ module Pgbus
24
24
  # one. 0 (default) is off. Overridable per capsule / event_consumers entry
25
25
  # (`read_ahead:`); prefetch_limit still caps everything claimed.
26
26
  attr_reader :read_ahead
27
+ # completion_batching (issue #606, opt-in, default false): a worker archives
28
+ # the jobs that finish close together with one archive_batch per flush
29
+ # instead of one pgmq.archive per job (Process::Completer). No timer: a lone
30
+ # job is archived at once, and whatever finishes during a flush goes in the
31
+ # next. completion_batch_size (default 100) caps the ids per statement.
32
+ # Threads only: validate! rejects it next to execution_mode :async, global
33
+ # or per capsule. The crash window widens from one job to one flush: on a
34
+ # hard crash (SIGKILL, OOM) the jobs waiting in the current flush re-run
35
+ # after visibility_timeout, and a flush that committed just before the
36
+ # crash leaves its jobs completed but unsignalled (uniqueness lock, batch,
37
+ # concurrency slot) until the reapers and sweeps heal them. Worker shutdown
38
+ # waits for a flush in flight at most immediate_shutdown_grace (QUIT) or
39
+ # the rest of the 5 s pool residual (TERM); a flush wedged past that is
40
+ # abandoned, and its jobs fail (Completer::Stopped) and re-run after
41
+ # visibility_timeout, or stay unsignalled if it had committed.
42
+ attr_accessor :completion_batching, :completion_batch_size
43
+ # completed_jobs (issue #607): how a successful job's message leaves its
44
+ # queue. :archive (default) is pgmq.archive, DELETE + INSERT into the
45
+ # queue's a_ table, which archive_retention compacts. :delete is
46
+ # pgmq.delete, the DELETE alone: no a_ rows, no a_ index upkeep, nothing for
47
+ # the compaction to trim, and no completed-job history in the archive.
48
+ # Equally reliable: either way the message is gone once the statement
49
+ # commits. Through the completer (completion_batching) it is delete_batch.
50
+ # ActiveJob only: EventBus handler queues keep archiving (event replay
51
+ # reads their archive), and failures and dead letters are unchanged.
52
+ # Batch::Sweep then cannot tell a deleted batch job from one whose message
53
+ # vanished another way (a purged queue): both count as completed, silently.
54
+ attr_reader :completed_jobs
27
55
  # visibility_heartbeat / visibility_heartbeat_interval: while a job runs,
28
56
  # its message's visibility timeout is re-armed every interval seconds
29
57
  # (default: a third of visibility_timeout), so a job that outlives the
@@ -214,6 +242,22 @@ module Pgbus
214
242
  # See issue #332.
215
243
  attr_reader :connection_guc_mode
216
244
 
245
+ # Run Client's read_batch, archive, delete, send and their batch forms as
246
+ # pgbus-owned static SQL in named prepared statements on the pooled
247
+ # connection, instead of pgmq's plpgsql functions, whose dynamic EXECUTE
248
+ # Postgres plans again on every call (issue #605). Default true. A queue
249
+ # whose table lacks a column the statements use (a pgmq older than the
250
+ # `headers` column) stays on the pgmq functions by itself.
251
+ #
252
+ # Behind a transaction-mode PgBouncer, prepared statements need PgBouncer
253
+ # >= 1.21 with `max_prepared_statements` set. Without it, a process that
254
+ # sees its statements disappear switches to sending the same SQL
255
+ # unprepared and logs a warning once, and connections built from a
256
+ # database.yml with `prepared_statements: false` send it unprepared from
257
+ # the start. false uses the pgmq functions everywhere: the full escape
258
+ # hatch.
259
+ attr_reader :native_statements
260
+
217
261
  # Zombie message detection — logs a warning when a message is redelivered
218
262
  # (read_ct > 1) without any prior failure recorded in pgbus_failed_events.
219
263
  attr_accessor :zombie_detection
@@ -320,6 +364,9 @@ module Pgbus
320
364
 
321
365
  @prefetch_limit = nil
322
366
  @read_ahead = 0
367
+ @completion_batching = false
368
+ @completion_batch_size = 100
369
+ @completed_jobs = :archive
323
370
  @execution_mode = :threads
324
371
 
325
372
  @max_jobs_per_worker = nil
@@ -404,6 +451,7 @@ module Pgbus
404
451
  @connects_to = nil
405
452
  @require_primary = false
406
453
  @connection_guc_mode = :options
454
+ @native_statements = true
407
455
 
408
456
  @mcp_write_tools = false
409
457
 
@@ -797,6 +845,23 @@ module Pgbus
797
845
  @connection_guc_mode = mode
798
846
  end
799
847
 
848
+ VALID_COMPLETED_JOBS = %i[archive delete].freeze
849
+
850
+ def completed_jobs=(value)
851
+ mode = value.to_sym if value.is_a?(String) || value.is_a?(Symbol)
852
+ unless VALID_COMPLETED_JOBS.include?(mode)
853
+ raise Pgbus::ConfigurationError, "completed_jobs must be :archive or :delete (got #{value.inspect})"
854
+ end
855
+
856
+ @completed_jobs = mode
857
+ end
858
+
859
+ def native_statements=(value)
860
+ raise Pgbus::ConfigurationError, "native_statements must be true or false" unless [true, false].include?(value)
861
+
862
+ @native_statements = value
863
+ end
864
+
800
865
  VALID_PGMQ_SCHEMA_MODES = %i[auto extension embedded].freeze
801
866
 
802
867
  def pgmq_schema_mode=(mode)
@@ -986,6 +1051,7 @@ module Pgbus
986
1051
  end
987
1052
 
988
1053
  validate_read_ahead!
1054
+ validate_completion_batching!
989
1055
  validate_capsule_processes!
990
1056
 
991
1057
  if priority_levels && !(priority_levels.is_a?(Integer) && priority_levels >= 1 && priority_levels <= 10)
@@ -1756,6 +1822,27 @@ module Pgbus
1756
1822
 
1757
1823
  private
1758
1824
 
1825
+ # The completer's job threads block on their flush, which would stall an
1826
+ # Async reactor, so batching is threads-only (issue #606). Every capsule
1827
+ # resolves its mode through execution_mode_for, as the supervisor does.
1828
+ def validate_completion_batching!
1829
+ unless [true, false].include?(completion_batching)
1830
+ raise Pgbus::ConfigurationError, "completion_batching must be true or false (got #{completion_batching.inspect})"
1831
+ end
1832
+ unless completion_batch_size.is_a?(Integer) && completion_batch_size.positive?
1833
+ raise Pgbus::ConfigurationError,
1834
+ "completion_batch_size must be a positive Integer (got #{completion_batch_size.inspect})"
1835
+ end
1836
+ return unless completion_batching
1837
+
1838
+ modes = [ExecutionPools.normalize_mode(execution_mode)] + Array(workers).map { |w| execution_mode_for(w) }
1839
+ return unless modes.include?(:async)
1840
+
1841
+ raise Pgbus::ConfigurationError,
1842
+ "completion_batching supports execution_mode :threads only; turn it off for a global or capsule " \
1843
+ "execution_mode :async"
1844
+ end
1845
+
1759
1846
  # Shared resolver for a dedicated connection (streamer LISTEN / worker
1760
1847
  # NotifyListener) that can override the base `connection_options` to bypass a
1761
1848
  # pooler. Precedence: a full `url` wins outright; otherwise `host`/`port`
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "time"
4
+
3
5
  module Pgbus
4
6
  module EventBus
5
7
  class Handler