pgbus 0.21.4 → 0.23.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.
Files changed (82) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +67 -0
  3. data/README.md +37 -15
  4. data/Rakefile +15 -2
  5. data/app/assets/javascripts/pgbus/stream_source_element.js +17 -3
  6. data/app/assets/javascripts/pgbus/stream_source_element.min.js +4 -4
  7. data/app/assets/javascripts/pgbus/stream_source_element.min.js.map +1 -1
  8. data/app/controllers/pgbus/dead_letter_controller.rb +3 -1
  9. data/app/controllers/pgbus/jobs_controller.rb +2 -1
  10. data/app/models/pgbus/blocked_execution.rb +3 -2
  11. data/app/models/pgbus/queue_state.rb +6 -0
  12. data/app/models/pgbus/uniqueness_key.rb +11 -0
  13. data/config/locales/da.yml +1 -0
  14. data/config/locales/de.yml +1 -0
  15. data/config/locales/en.yml +1 -0
  16. data/config/locales/es.yml +1 -0
  17. data/config/locales/fi.yml +1 -0
  18. data/config/locales/fr.yml +1 -0
  19. data/config/locales/it.yml +1 -0
  20. data/config/locales/ja.yml +1 -0
  21. data/config/locales/nb.yml +1 -0
  22. data/config/locales/nl.yml +1 -0
  23. data/config/locales/pt.yml +1 -0
  24. data/config/locales/sv.yml +1 -0
  25. data/lib/generators/pgbus/fold_queue_name_case_generator.rb +57 -0
  26. data/lib/generators/pgbus/templates/fold_queue_name_case.rb.erb +12 -0
  27. data/lib/generators/pgbus/templates/tune_autovacuum.rb.erb +2 -2
  28. data/lib/generators/pgbus/templates/tune_fillfactor.rb.erb +1 -1
  29. data/lib/pgbus/active_job/adapter.rb +3 -2
  30. data/lib/pgbus/active_job/executor.rb +197 -46
  31. data/lib/pgbus/active_job/quiet_logs.rb +57 -0
  32. data/lib/pgbus/autovacuum_tuning.rb +2 -2
  33. data/lib/pgbus/batch/sweep.rb +2 -0
  34. data/lib/pgbus/batch.rb +4 -1
  35. data/lib/pgbus/cli/dlq.rb +2 -1
  36. data/lib/pgbus/client/async_commit.rb +149 -0
  37. data/lib/pgbus/client/native_statements.rb +432 -0
  38. data/lib/pgbus/client.rb +156 -20
  39. data/lib/pgbus/concurrency/blocked_execution.rb +10 -1
  40. data/lib/pgbus/configuration.rb +158 -6
  41. data/lib/pgbus/dead_letter_header.rb +3 -0
  42. data/lib/pgbus/doctor.rb +55 -2
  43. data/lib/pgbus/event_bus/handler.rb +2 -0
  44. data/lib/pgbus/failed_event_recorder.rb +5 -2
  45. data/lib/pgbus/generators/migration_detector.rb +29 -2
  46. data/lib/pgbus/instrumentation.rb +16 -1
  47. data/lib/pgbus/metrics/subscriber.rb +27 -1
  48. data/lib/pgbus/process/completer.rb +189 -0
  49. data/lib/pgbus/process/consumer.rb +15 -1
  50. data/lib/pgbus/process/worker.rb +57 -4
  51. data/lib/pgbus/queue_name_fold.rb +145 -0
  52. data/lib/pgbus/queue_name_validator.rb +20 -2
  53. data/lib/pgbus/recurring/schedule.rb +22 -13
  54. data/lib/pgbus/serializer.rb +1 -0
  55. data/lib/pgbus/stat_buffer.rb +67 -26
  56. data/lib/pgbus/streams/cursor.rb +17 -6
  57. data/lib/pgbus/streams/envelope.rb +16 -0
  58. data/lib/pgbus/streams/key.rb +1 -1
  59. data/lib/pgbus/table_maintenance.rb +1 -1
  60. data/lib/pgbus/uniqueness.rb +43 -16
  61. data/lib/pgbus/version.rb +1 -1
  62. data/lib/pgbus/visibility_heartbeat.rb +16 -12
  63. data/lib/pgbus/web/data_source/dead_letter.rb +14 -5
  64. data/lib/pgbus/web/data_source/resend.rb +185 -0
  65. data/lib/pgbus/web/data_source/resend_slot.rb +139 -0
  66. data/lib/pgbus/web/data_source.rb +55 -37
  67. data/lib/pgbus/web/dead_letter_reason.rb +6 -0
  68. data/lib/pgbus/web/streamer/connection.rb +36 -29
  69. data/lib/pgbus/web/streamer/falcon_connection.rb +29 -25
  70. data/lib/pgbus/web/streamer/heartbeat.rb +14 -3
  71. data/lib/pgbus/web/streamer/instance.rb +8 -5
  72. data/lib/pgbus/web/streamer/listener.rb +239 -62
  73. data/lib/pgbus/web/streamer/outbound_pump.rb +6 -4
  74. data/lib/pgbus/web/streamer/registry.rb +55 -29
  75. data/lib/pgbus/web/streamer/stream_event_dispatcher.rb +73 -12
  76. data/lib/tasks/pgbus_queues.rake +27 -0
  77. data/sig/pgbus/active_job/adapter.rbs +1 -1
  78. data/sig/pgbus/active_job/quiet_logs.rbs +11 -0
  79. data/sig/pgbus/client.rbs +7 -5
  80. data/sig/pgbus/configuration.rbs +12 -0
  81. data/sig/pgbus/process/completer.rbs +26 -0
  82. metadata +14 -3
@@ -0,0 +1,432 @@
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
+ # The send statements bind the delay as $3::integer. is_a?(Integer) also
303
+ # takes a whole-second ActiveSupport::Duration, which binds as its seconds;
304
+ # a fractional delay or a Time goes through pgmq-ruby.
305
+ def exec_produce(name, message, headers:, delay:)
306
+ statements = delay.is_a?(Integer) && native_statements_for(name)
307
+ return @pgmq.produce(name, message, headers: headers, delay: delay) unless statements
308
+
309
+ run_native(statements[:send], [message, headers, delay])[0]["msg_id"]
310
+ end
311
+
312
+ def exec_produce_batch(name, messages, headers:, delay:)
313
+ statements = messages.any? && (headers.nil? || headers.length == messages.length) &&
314
+ delay.is_a?(Integer) && native_statements_for(name)
315
+ return @pgmq.produce_batch(name, messages, headers: headers, delay: delay) unless statements
316
+
317
+ encoder = PG::TextEncoder::Array.new
318
+ params = [encoder.encode(messages), headers && encoder.encode(headers), delay]
319
+ run_native(statements[:send_batch], params).map { |row| row["msg_id"] }
320
+ end
321
+
322
+ def exec_read_batch(name, vt:, qty:)
323
+ statements = native_statements_for(name)
324
+ return @pgmq.read_batch(name, vt: vt, qty: qty) unless statements
325
+
326
+ literal = qty.is_a?(Integer) && qty.positive? && statements[:reads][qty]
327
+ result = literal ? run_native(literal, [vt]) : run_native(statements[:read], [qty, vt])
328
+ result.map { |row| PGMQ::Message.new(row) }
329
+ end
330
+
331
+ # async: commit with synchronous_commit off (AsyncCommit, issue #619);
332
+ # true only for a job's completion under completion_synchronous_commit
333
+ # = false.
334
+ def exec_archive(name, msg_id, async: false)
335
+ statements = native_statements_for(name)
336
+ return @pgmq.archive(name, msg_id) unless statements
337
+
338
+ run_native(statements[:archive], [msg_id], rows: 1, async: async).ntuples.positive?
339
+ end
340
+
341
+ def exec_archive_batch(name, msg_ids, async: false)
342
+ statements = msg_ids.any? && native_statements_for(name)
343
+ return @pgmq.archive_batch(name, msg_ids) unless statements
344
+
345
+ params = [PG::TextEncoder::Array.new.encode(msg_ids)]
346
+ run_native(statements[:archive_batch], params, rows: msg_ids, async: async).map { |row| row["msg_id"] }
347
+ end
348
+
349
+ def exec_delete(name, msg_id, async: false)
350
+ statements = native_statements_for(name)
351
+ return @pgmq.delete(name, msg_id) unless statements
352
+
353
+ run_native(statements[:delete], [msg_id], rows: 1, async: async).ntuples.positive?
354
+ end
355
+
356
+ def exec_delete_batch(name, msg_ids, async: false)
357
+ statements = msg_ids.any? && native_statements_for(name)
358
+ return @pgmq.delete_batch(name, msg_ids) unless statements
359
+
360
+ params = [PG::TextEncoder::Array.new.encode(msg_ids)]
361
+ run_native(statements[:delete_batch], params, rows: msg_ids, async: async).map { |row| row["msg_id"] }
362
+ end
363
+
364
+ # rows: the rows an archive or delete returns when every id was still
365
+ # there. pgmq-ruby's with_connection re-runs its block after a lost
366
+ # connection ("server closed the connection"). Usually the socket died
367
+ # idle in the pool and nothing ran, but it can follow a commit whose
368
+ # reply was lost: the re-run then finds those ids gone, and the executor
369
+ # would take its own completed job for a duplicate and skip its signals.
370
+ # So a re-run that comes back short raises instead; the executor's
371
+ # retry-once then reads the gone message as :ambiguous and claims it.
372
+ # A re-run that finds every id is certain: the first run never committed.
373
+ # rows is 1 or the batch's ids (counted once each, only on a re-run). An
374
+ # id another worker had already taken also makes a re-run come back
375
+ # short; every waiter of that batch then claims :ambiguous, the side the
376
+ # executor already errs on.
377
+ def run_native(statement, params, rows: nil, async: false)
378
+ attempt = 0
379
+ lost = nil
380
+ @pgmq.with_connection do |conn|
381
+ attempt += 1
382
+ result = exec_native(conn, statement, params, async)
383
+ if rows && attempt > 1 && result.ntuples < (rows.is_a?(Array) ? rows.uniq.size : rows)
384
+ raise PGMQ::Errors::ConnectionError, "#{COMPLETION_NOT_RESENT}: #{lost}"
385
+ end
386
+
387
+ result
388
+ rescue PG::Error => e
389
+ lost = e.message
390
+ raise
391
+ end
392
+ end
393
+
394
+ # async (a job's completion under completion_synchronous_commit = false)
395
+ # goes through AsyncCommit, unless it runs inside a caller's transaction.
396
+ def exec_native(conn, statement, params, async)
397
+ return conn.exec_params(statement.sql, params) if inside_caller_transaction?(conn)
398
+
399
+ unprepared = @native_unprepared || NativeStatements.unprepared?
400
+ return exec_async_commit(conn, statement, params, !unprepared) if async
401
+ return conn.exec_params(statement.sql, params) if unprepared
402
+
403
+ begin
404
+ result = conn.exec_prepared(statement.name, params)
405
+ rescue PG::InvalidSqlStatementName
406
+ return prepare_and_exec(conn, statement, params)
407
+ end
408
+ NativeStatements.note_executed
409
+ result
410
+ end
411
+
412
+ def prepare_and_exec(conn, statement, params)
413
+ pid = conn.backend_pid
414
+ NativeStatements.note_missing(pid, statement.name)
415
+ begin
416
+ conn.prepare(statement.name, statement.sql)
417
+ rescue PG::DuplicatePstatement
418
+ # Prepared on this session between our two calls; run it.
419
+ end
420
+ NativeStatements.note_prepared(pid, statement.name)
421
+ begin
422
+ conn.exec_prepared(statement.name, params)
423
+ rescue PG::InvalidSqlStatementName
424
+ # Gone again right after its own prepare: the pooler ran the two calls
425
+ # on different server connections.
426
+ NativeStatements.unprepare!("a statement disappeared right after it was prepared")
427
+ conn.exec_params(statement.sql, params)
428
+ end
429
+ end
430
+ end
431
+ end
432
+ end