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
@@ -9,7 +9,9 @@ module Pgbus
9
9
  module QueueNameValidator
10
10
  # PostgreSQL's NAMEDATALEN caps identifiers at 63 bytes, but the
11
11
  # effective limit is tighter: pgmq-ruby (our transport gem) rejects
12
- # any queue name with `length >= 48` in `PGMQ::Client#validate_queue_name!`.
12
+ # any queue name with `length >= PGMQ::QueueName::MAX_LENGTH` (48) in
13
+ # `PGMQ::QueueName.validate!`, whose error has said "maximum length of
14
+ # 47 characters" since pgmq-ruby 0.8.2 (48 before).
13
15
  # That leaves an actual usable ceiling of 47 characters for the
14
16
  # fully-prefixed name (`<queue_prefix>_<logical_name>`), which is what
15
17
  # this constant expresses. pgmq-ruby picked 48 to leave headroom for
@@ -26,6 +28,10 @@ module Pgbus
26
28
  # Only alphanumeric characters and underscores are allowed.
27
29
  VALID_QUEUE_NAME_PATTERN = /\A[a-zA-Z0-9_]+\z/
28
30
 
31
+ # A valid name already in lower case: what normalize returns.
32
+ CANONICAL_QUEUE_NAME = /\A[a-z0-9_]+\z/
33
+ private_constant :CANONICAL_QUEUE_NAME
34
+
29
35
  module_function
30
36
 
31
37
  # Validates a queue name for safe SQL identifier use.
@@ -58,14 +64,26 @@ module Pgbus
58
64
  # (`Pgbus.stream([user, :notifications])` → `"user_gid:notifications"`),
59
65
  # so they must map to a safe character rather than be stripped —
60
66
  # otherwise `"a:b"` and `"ab"` would collide on the same queue.
67
+ #
68
+ # The result is lower case (issue #633): PGMQ lower-cases its table
69
+ # names (`pgmq.format_table_name`), so "Orders" and "orders" share
70
+ # `pgmq.q_orders` while `pgmq.meta` records each spelling as its own
71
+ # queue. Spelling every name the way its table is gives one name per
72
+ # table. `validate!` and `sanitize!` keep case: they also run on names
73
+ # read back from `pgmq.meta`, which an install from before #633 may
74
+ # still hold in mixed case (`rails generate pgbus:fold_queue_name_case`).
61
75
  def normalize(name)
62
76
  name = name.to_s
63
- return validate!(name) if VALID_QUEUE_NAME_PATTERN.match?(name)
77
+ # Every enqueue and read names its queue through here: a name already
78
+ # spelled as its table costs one scan and no allocation.
79
+ return name if name.length <= MAX_QUEUE_NAME_LENGTH && CANONICAL_QUEUE_NAME.match?(name)
80
+ return validate!(name).downcase if VALID_QUEUE_NAME_PATTERN.match?(name)
64
81
 
65
82
  normalized = name.gsub(/[-.:]/, "_") # hyphens/dots/colons → underscores
66
83
  .gsub(/[^a-zA-Z0-9_]/, "") # strip remaining invalid chars
67
84
  .gsub(/_+/, "_") # collapse consecutive underscores
68
85
  .gsub(/\A_|_\z/, "") # strip leading/trailing underscores
86
+ normalized.downcase!
69
87
  validate!(normalized)
70
88
  normalized
71
89
  end
@@ -26,7 +26,7 @@ module Pgbus
26
26
  RecurringExecution.record(task.key, run_at) do
27
27
  payload = build_payload(task)
28
28
  headers = build_headers(task, run_at)
29
- payload = inject_uniqueness_metadata(task, payload)
29
+ payload = inject_uniqueness_metadata(task, payload, acquired_key)
30
30
 
31
31
  Pgbus.client.ensure_queue(queue)
32
32
  msg_id = Pgbus.client.send_message(queue, payload, headers: headers)
@@ -138,7 +138,7 @@ module Pgbus
138
138
 
139
139
  config = job_class.pgbus_uniqueness
140
140
  return nil unless config
141
- return nil unless config[:strategy] == :until_executed
141
+ return nil unless Uniqueness::ENQUEUE_LOCK_STRATEGIES.include?(config[:strategy])
142
142
 
143
143
  key = resolve_uniqueness_key(job_class, config, task)
144
144
  return nil unless key
@@ -198,12 +198,15 @@ module Pgbus
198
198
  args = task.arguments || []
199
199
  return default_uniqueness_key(job_class, args) unless key_proc
200
200
 
201
- begin
201
+ key = begin
202
202
  args.empty? ? key_proc.call : key_proc.call(*args)
203
203
  rescue StandardError => e
204
204
  Pgbus.logger.warn { "[Pgbus] Could not resolve uniqueness key for #{task.key}: #{e.message}" }
205
205
  raise
206
206
  end
207
+ # As Uniqueness.resolve_key does for an ordinary enqueue: a record
208
+ # becomes its GlobalID, so both paths key one record the same way.
209
+ key.respond_to?(:to_global_id) ? key.to_global_id.to_s : key
207
210
  end
208
211
 
209
212
  # Class-name default for the scheduler path, args-qualified so distinct
@@ -216,10 +219,17 @@ module Pgbus
216
219
  end
217
220
 
218
221
  # Inject uniqueness metadata into the payload so the executor releases
219
- # the lock after the job completes.
220
- # Only inject for :until_executed strategy — :while_executing locks are
221
- # acquired at execution time by the executor, not by the scheduler.
222
- def inject_uniqueness_metadata(task, payload)
222
+ # the lock: after the job completes (:until_executed) or when it starts
223
+ # (:until_executing, issue #628). For :while_executing the executor
224
+ # takes the lock at execution start, and only when the payload carries
225
+ # the key, so the scheduler takes none but carries key, strategy and
226
+ # on_conflict (issue #629): a run that outlasts its interval is not
227
+ # overlapped by the next. A key proc that raises propagates, so the
228
+ # tick enqueues nothing rather than a run without that protection.
229
+ # The key is the one the row was acquired under when there is one: a
230
+ # key proc resolved again could answer differently, and the executor
231
+ # would release a key nobody holds and orphan the acquired row.
232
+ def inject_uniqueness_metadata(task, payload, acquired_key = nil)
223
233
  return payload unless task.class_name
224
234
 
225
235
  job_class = task.class_name.safe_constantize
@@ -227,15 +237,14 @@ module Pgbus
227
237
 
228
238
  config = job_class.pgbus_uniqueness
229
239
  return payload unless config
230
- return payload unless config[:strategy] == :until_executed
240
+ return payload unless Uniqueness::VALID_STRATEGIES.include?(config[:strategy])
231
241
 
232
- key = resolve_uniqueness_key(job_class, config, task)
242
+ key = acquired_key.is_a?(String) ? acquired_key : resolve_uniqueness_key(job_class, config, task)
233
243
  return payload unless key
234
244
 
235
- payload.merge(
236
- Pgbus::Uniqueness::METADATA_KEY => key,
237
- Pgbus::Uniqueness::STRATEGY_KEY => config[:strategy].to_s
238
- )
245
+ metadata = { Uniqueness::METADATA_KEY => key, Uniqueness::STRATEGY_KEY => config[:strategy].to_s }
246
+ metadata[Uniqueness::ON_CONFLICT_KEY] = config[:on_conflict].to_s if config[:strategy] == :while_executing
247
+ payload.merge(metadata)
239
248
  end
240
249
  end
241
250
  end
@@ -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 Serializer
@@ -7,6 +7,8 @@ module Pgbus
7
7
  class StatBuffer
8
8
  DEFAULT_FLUSH_SIZE = 100
9
9
  DEFAULT_FLUSH_INTERVAL = 5 # seconds
10
+ BASE_COLUMNS = "job_class, queue_name, status, duration_ms"
11
+ ALL_COLUMNS = "#{BASE_COLUMNS}, enqueue_latency_ms, retry_count".freeze
10
12
 
11
13
  attr_reader :flush_size, :flush_interval
12
14
 
@@ -65,41 +67,80 @@ module Pgbus
65
67
 
66
68
  private
67
69
 
70
+ # One multi-row INSERT per flush, built here rather than by
71
+ # JobStat.insert_all: ActiveRecord's per-value attribute check and type
72
+ # cast and its RETURNING "id" cost ~1 ms of Ruby CPU per 100 rows, 12-15 %
73
+ # of a no-op job's on-CPU time (issue #622). The integer columns are
74
+ # written through Integer(); the strings (job_class comes from the
75
+ # message) go through the connection's quoting, once per distinct value.
76
+ #
77
+ # Always attempt all 6 columns. If the latency migration hasn't run, the
78
+ # statement fails, and we fall back to base columns. This avoids relying
79
+ # on the memoized latency_columns? flag which can be stuck at false if
80
+ # evaluated before the connection is ready.
68
81
  def write_to_database(entries)
69
82
  return unless JobStat.table_exists?
70
83
 
71
- # Always attempt all 6 columns. If the latency migration hasn't
72
- # run, the first flush will fail, and we fall back to base columns.
73
- # This avoids relying on the memoized latency_columns? flag which
74
- # can be stuck at false if evaluated before the connection is ready.
75
- rows = entries.map do |e|
76
- { job_class: e[:job_class], queue_name: e[:queue_name],
77
- status: e[:status], duration_ms: e[:duration_ms],
78
- enqueue_latency_ms: e[:enqueue_latency_ms], retry_count: e[:retry_count] }
79
- end
84
+ JobStat.connection_pool.with_connection do |conn|
85
+ sql = insert_sql(conn, entries, latency: true)
86
+ conn.execute(sql, "Pgbus::JobStat Bulk Insert") if sql
87
+ rescue ActiveRecord::StatementInvalid => e
88
+ raise unless missing_latency_column?(e)
80
89
 
81
- JobStat.insert_all(rows)
82
- rescue ActiveRecord::StatementInvalid, ActiveModel::UnknownAttributeError => e
83
- # Column doesn't exist — fall back to base columns only
84
- if e.message.include?("enqueue_latency_ms") || e.message.include?("retry_count")
85
- Pgbus.logger.warn { "[Pgbus] Latency columns missing — recording stats without latency data" } unless @degraded_warned
86
- @degraded_warned = true
87
- base_rows = entries.map do |entry|
88
- { job_class: entry[:job_class], queue_name: entry[:queue_name],
89
- status: entry[:status], duration_ms: entry[:duration_ms] }
90
- end
91
- begin
92
- JobStat.insert_all(base_rows)
93
- rescue StandardError => fallback_error
94
- Pgbus.logger.warn { "[Pgbus] Stat buffer fallback flush failed: #{fallback_error.message}" }
95
- end
96
- else
97
- Pgbus.logger.warn { "[Pgbus] Stat buffer flush failed: #{e.message}" }
90
+ write_base_columns(conn, entries)
98
91
  end
99
92
  rescue StandardError => e
100
93
  Pgbus.logger.warn { "[Pgbus] Stat buffer flush failed: #{e.message}" }
101
94
  end
102
95
 
96
+ # PostgreSQL's undefined_column (42703, PG::UndefinedColumn: the cause
97
+ # ActiveRecord keeps) naming a latency column in its first line. The
98
+ # class, not the wording, says a column is missing (the wording follows
99
+ # the server's lc_messages); only the first line names which, because the
100
+ # "LINE 1:" excerpt of the statement below it shows both latency columns
101
+ # whatever went wrong. pg is loaded once PostgreSQL has raised anything.
102
+ def missing_latency_column?(error)
103
+ return false unless defined?(::PG::UndefinedColumn) && error.cause.is_a?(::PG::UndefinedColumn)
104
+
105
+ primary = error.cause.message.lines.first.to_s
106
+ primary.include?("enqueue_latency_ms") || primary.include?("retry_count")
107
+ end
108
+
109
+ def write_base_columns(conn, entries)
110
+ Pgbus.logger.warn { "[Pgbus] Latency columns missing — recording stats without latency data" } unless @degraded_warned
111
+ @degraded_warned = true
112
+ sql = insert_sql(conn, entries, latency: false)
113
+ conn.execute(sql, "Pgbus::JobStat Bulk Insert") if sql
114
+ rescue StandardError => e
115
+ Pgbus.logger.warn { "[Pgbus] Stat buffer fallback flush failed: #{e.message}" }
116
+ end
117
+
118
+ def insert_sql(conn, entries, latency:)
119
+ quoted = Hash.new { |memo, value| memo[value] = conn.quote(value) }
120
+ columns = latency ? ALL_COLUMNS : BASE_COLUMNS
121
+ values = entries.filter_map { |entry| sql_row(entry, quoted, latency) }
122
+ return if values.empty?
123
+
124
+ "INSERT INTO #{JobStat.quoted_table_name} (#{columns}) VALUES #{values.join(", ")}"
125
+ end
126
+
127
+ # One entry's tuple, or nil when an integer column holds something
128
+ # Integer() refuses: that entry is dropped, the rest of the flush is still
129
+ # written. Logged on the six-column pass only: the base-column retry runs
130
+ # on the same entries, so anything it drops was dropped and logged already.
131
+ def sql_row(entry, quoted, latency)
132
+ row = "(#{quoted[entry[:job_class]]}, #{quoted[entry[:queue_name]]}, #{quoted[entry[:status]]}, #{sql_integer(entry[:duration_ms])}"
133
+ row << ", #{sql_integer(entry[:enqueue_latency_ms])}, #{sql_integer(entry[:retry_count])}" if latency
134
+ row << ")"
135
+ rescue ArgumentError, TypeError => e
136
+ Pgbus.logger.warn { "[Pgbus] Stat buffer dropped a #{entry[:job_class]} stat: #{e.message}" } if latency
137
+ nil
138
+ end
139
+
140
+ def sql_integer(value)
141
+ value.nil? ? "NULL" : Integer(value).to_s
142
+ end
143
+
103
144
  def monotonic_now
104
145
  ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
105
146
  end
@@ -12,6 +12,12 @@ module Pgbus
12
12
  # because EventSource cannot send custom headers on the initial request)
13
13
  #
14
14
  # Returns 0 when no cursor is present (i.e. "deliver everything from now").
15
+ #
16
+ # A negative Last-Event-ID is not a cursor: ephemeral broadcasts carry a
17
+ # negative `id:` (they have no archive position), and a native EventSource
18
+ # that saw one last echoes it on reconnect. It is skipped, so the
19
+ # element's ?since= (its last durable id) decides (issue #621). A negative
20
+ # ?since= is still rejected: the element never sends one.
15
21
  module Cursor
16
22
  # PGMQ msg_id is BIGINT — strictly within signed 64-bit range.
17
23
  MAX_MSG_ID = (2**63) - 1
@@ -34,7 +40,7 @@ module Pgbus
34
40
  end
35
41
 
36
42
  def self.pick(last_event_id, query_since)
37
- return last_event_id if present?(last_event_id)
43
+ return last_event_id if present?(last_event_id) && !ephemeral_id?(last_event_id)
38
44
  return query_since if present?(query_since)
39
45
 
40
46
  nil
@@ -42,15 +48,20 @@ module Pgbus
42
48
 
43
49
  def self.present?(value)
44
50
  return false if value.nil?
45
- # Any Integer is "present" so precedence is decided here, not by
46
- # the sign of the value. validate! rejects negatives separately
47
- # so a caller passing a literal -1 still crashes loud rather than
48
- # silently falling through to query_since.
51
+ # Any Integer is "present"; pick skips a negative last_event_id (an
52
+ # ephemeral frame's id) and validate! rejects a negative ?since=.
49
53
  return true if value.is_a?(Integer)
50
54
 
51
55
  !value.to_s.strip.empty?
52
56
  end
53
57
 
58
+ # An ephemeral frame's id: a negative Integer, or its decimal text.
59
+ def self.ephemeral_id?(value)
60
+ return value.negative? if value.is_a?(Integer)
61
+
62
+ value.to_s.strip.match?(/\A-\d+\z/)
63
+ end
64
+
54
65
  def self.coerce(raw)
55
66
  return raw if raw.is_a?(Integer)
56
67
 
@@ -65,7 +76,7 @@ module Pgbus
65
76
  raise InvalidCursor, "cursor #{value} is out of BIGINT range" if value > MAX_MSG_ID
66
77
  end
67
78
 
68
- private_class_method :pick, :present?, :coerce, :validate!
79
+ private_class_method :pick, :present?, :ephemeral_id?, :coerce, :validate!
69
80
  end
70
81
  end
71
82
  end
@@ -70,6 +70,16 @@ module Pgbus
70
70
  "retry: #{milliseconds}\n\n"
71
71
  end
72
72
 
73
+ # The SSE event name for a frame: the envelope's typed event when
74
+ # present, else the default turbo-stream. Plain ReadAfter::Envelopes
75
+ # (no event field) and StreamEnvelopes with a nil event both fall back
76
+ # to the default (issue #170). Shared by the connections and the
77
+ # dispatcher, which frames each envelope once per wake (issue #621).
78
+ def self.sse_event_for(envelope)
79
+ event = envelope.respond_to?(:event) ? envelope.event : nil
80
+ event && !event.to_s.empty? ? event.to_s : Pgbus::Streams::DEFAULT_SSE_EVENT
81
+ end
82
+
73
83
  def self.http_response_headers
74
84
  RESPONSE_HEADERS
75
85
  end
@@ -82,6 +92,12 @@ module Pgbus
82
92
  # empty strings, so a payload ending in \n round-trips as an empty
83
93
  # final `data:` line (the client's rejoin restores the newline).
84
94
  def self.data_lines(str)
95
+ # Most payloads are one line (a rendered turbo-stream): no split, map
96
+ # or join, one String (issue #621). Two memchr scans beat the regex
97
+ # match? (232 vs 388 ns on a 170-byte line, YJIT) and cost a multi-line
98
+ # payload ~3 % before its split.
99
+ return "data: #{str}\n" unless str.include?("\n") || str.include?("\r")
100
+
85
101
  lines = str.split(DATA_LINE_BREAK, -1)
86
102
  lines = [""] if lines.empty? # "".split → [] — an empty payload still gets its data: line
87
103
  lines.map { |line| "data: #{line}\n" }.join
@@ -9,7 +9,7 @@ module Pgbus
9
9
  # PGMQ queue names are bounded by two ceilings: PostgreSQL's
10
10
  # NAMEDATALEN (63 chars for `pgmq.q_<name>`) and pgmq-ruby's own
11
11
  # stricter runtime check (`length >= 48` in
12
- # `PGMQ::Client#validate_queue_name!`). The effective budget is the
12
+ # `PGMQ::QueueName.validate!`). The effective budget is the
13
13
  # lower of the two, exposed as `QueueNameValidator::MAX_QUEUE_NAME_LENGTH`
14
14
  # (currently 47). Any stream name composed from UUID primary keys
15
15
  # and turbo-rails-style dom ids blows past that budget almost
@@ -37,7 +37,7 @@ module Pgbus
37
37
  q RECORD;
38
38
  BEGIN
39
39
  FOR q IN SELECT queue_name FROM pgmq.meta LOOP
40
- EXECUTE format('ALTER TABLE pgmq.q_%I SET (fillfactor = #{FILLFACTOR})', q.queue_name);
40
+ EXECUTE format('ALTER TABLE pgmq.%I SET (fillfactor = #{FILLFACTOR})', 'q_' || lower(q.queue_name));
41
41
  END LOOP;
42
42
  END $$;
43
43
  SQL
@@ -13,13 +13,15 @@ module Pgbus
13
13
  # 1. Enqueue: INSERT INTO pgbus_uniqueness_keys ON CONFLICT DO NOTHING
14
14
  # (logical queue, msg_id=0). After send_message, bind! writes the
15
15
  # real msg_id. The lock row lives as long as the job is in the queue
16
- # or executing.
16
+ # or executing (:until_executing: only while it is queued — the
17
+ # executor releases it before perform).
17
18
  # 2. Execution: PGMQ's visibility timeout is the execution lock —
18
19
  # no separate claim_for_execution step needed.
19
- # 3. Retry: a retry_job / retry_on re-enqueue passes through the key the
20
- # running attempt holds, and the row is handed to the retry: unbound
21
- # and re-stamped before the retry is sent or parked (unbind!), bound
22
- # to it once sent (bind!).
20
+ # 3. Retry (:until_executed): a retry_job / retry_on re-enqueue passes
21
+ # through the key the running attempt holds, and the row is handed to
22
+ # the retry: unbound and re-stamped before the retry is sent or parked
23
+ # (unbind!), bound to it once sent (bind!). An :until_executing
24
+ # attempt released its row at start, so its retry goes out with none.
23
25
  # 4. Completion/DLQ: the finishing message deletes the row only while it
24
26
  # holds it (release_held_by!), so a row handed to a retry, or a
25
27
  # successor's, stays. The dashboard's discards do the same.
@@ -31,9 +33,20 @@ module Pgbus
31
33
  # :until_executed — Lock acquired at enqueue, held through execution, released on
32
34
  # completion or DLQ. Prevents duplicate enqueue AND duplicate execution.
33
35
  #
36
+ # :until_executing — Lock acquired at enqueue, released when execution starts
37
+ # (a debounce). Further enqueues are dropped while a copy is
38
+ # queued; one that arrives while a copy runs is accepted, so a
39
+ # run starting after it observes the state written before that
40
+ # enqueue. At most one copy is queued at a time.
41
+ # Two runs can overlap; compose with limits_concurrency to: 1,
42
+ # on_conflict: :block for "debounce and no overlap".
43
+ #
34
44
  # :while_executing — Lock acquired at execution start, released on completion.
35
45
  # Allows duplicate enqueue (multiple copies in queue) but only one
36
- # executes at a time.
46
+ # executes at a time. A copy read while another message holds
47
+ # the key follows on_conflict (issue #629): :discard completes
48
+ # it without running; :reject and :log leave it for a
49
+ # backed-off redelivery (:log warns).
37
50
  #
38
51
  # Usage:
39
52
  # class ImportOrderJob < ApplicationJob
@@ -50,12 +63,17 @@ module Pgbus
50
63
 
51
64
  METADATA_KEY = "pgbus_uniqueness_key"
52
65
  STRATEGY_KEY = "pgbus_uniqueness_strategy"
66
+ # The on_conflict policy of a :while_executing job, read by the executor
67
+ # when the lock is held by another message (issue #629).
68
+ ON_CONFLICT_KEY = "pgbus_uniqueness_on_conflict"
53
69
  # Synthetic queue stored before produce when the caller does not know the
54
70
  # real queue yet. Never a live PGMQ queue — the reaper must not probe
55
71
  # pgmq.q_<prefix>_pending for these rows (issue #418).
56
72
  PLACEHOLDER_QUEUE = "pending"
57
73
 
58
- VALID_STRATEGIES = %i[until_executed while_executing].freeze
74
+ VALID_STRATEGIES = %i[until_executed until_executing while_executing].freeze
75
+ # The strategies that take the lock at enqueue, before the message exists.
76
+ ENQUEUE_LOCK_STRATEGIES = %i[until_executed until_executing].freeze
59
77
  VALID_CONFLICTS = %i[reject discard log].freeze
60
78
 
61
79
  # Class-body macro: it runs while the job class loads (at boot, or under the
@@ -129,7 +147,8 @@ module Pgbus
129
147
  end
130
148
 
131
149
  # Guards the class-name default key against the per-record collapse
132
- # footgun (#333). When an :until_executed job was declared with NO explicit
150
+ # footgun (#333). When an enqueue-locked job (:until_executed, or
151
+ # :until_executing, issue #628) was declared with NO explicit
133
152
  # key (so the key is the class name) AND is enqueued WITH arguments, every
134
153
  # distinct argument set would resolve to the same class-name key and
135
154
  # collapse into one per-class singleton — almost never what the caller
@@ -139,11 +158,11 @@ module Pgbus
139
158
  # per-invocation at execution start, not by class-name identity at enqueue).
140
159
  def guard_class_name_default!(active_job, config, args)
141
160
  return if config[:explicit_key]
142
- return unless config[:strategy] == :until_executed
161
+ return unless ENQUEUE_LOCK_STRATEGIES.include?(config[:strategy])
143
162
  return if args.nil? || args.empty?
144
163
 
145
164
  raise ArgumentError,
146
- "#{active_job.class.name} uses ensures_uniqueness strategy: :until_executed with no key: " \
165
+ "#{active_job.class.name} uses ensures_uniqueness strategy: #{config[:strategy].inspect} with no key: " \
147
166
  "but is enqueued with arguments — the default key is the class name, which would collapse " \
148
167
  "every distinct argument set into one per-class singleton. Pass an explicit " \
149
168
  "key: ->(*args) { ... } that includes the arguments. See https://pgbus.dev/docs/upgrading-pgbus"
@@ -156,10 +175,9 @@ module Pgbus
156
175
  key = resolve_key(active_job)
157
176
  return payload_hash unless key
158
177
 
159
- payload_hash.merge(
160
- METADATA_KEY => key,
161
- STRATEGY_KEY => config[:strategy].to_s
162
- )
178
+ metadata = { METADATA_KEY => key, STRATEGY_KEY => config[:strategy].to_s }
179
+ metadata[ON_CONFLICT_KEY] = config[:on_conflict].to_s if config[:strategy] == :while_executing
180
+ payload_hash.merge(metadata)
163
181
  end
164
182
 
165
183
  def extract_key(payload)
@@ -170,19 +188,28 @@ module Pgbus
170
188
  payload&.dig(STRATEGY_KEY)&.to_sym
171
189
  end
172
190
 
191
+ # The :while_executing policy at the skip point. :reject when the payload
192
+ # carries none (enqueued before the key existed) or one this version does not know:
193
+ # that keeps the redelivery such a message had.
194
+ def extract_on_conflict(payload)
195
+ policy = payload&.dig(ON_CONFLICT_KEY)&.to_sym
196
+ VALID_CONFLICTS.include?(policy) ? policy : :reject
197
+ end
198
+
173
199
  def uniqueness_config(active_job)
174
200
  return nil unless active_job.class.respond_to?(:pgbus_uniqueness)
175
201
 
176
202
  active_job.class.pgbus_uniqueness
177
203
  end
178
204
 
179
- # Acquire the uniqueness lock at enqueue time (:until_executed only).
205
+ # Acquire the uniqueness lock at enqueue time (:until_executed and
206
+ # :until_executing).
180
207
  # Uses pg_advisory_xact_lock to serialize concurrent attempts.
181
208
  # Returns :acquired, :locked, or :no_lock.
182
209
  def acquire_enqueue_lock(key, active_job, queue_name: nil, msg_id: nil)
183
210
  config = uniqueness_config(active_job)
184
211
  return :no_lock unless config
185
- return :no_lock unless config[:strategy] == :until_executed
212
+ return :no_lock unless ENQUEUE_LOCK_STRATEGIES.include?(config[:strategy])
186
213
 
187
214
  acquired = if msg_id && queue_name
188
215
  UniquenessKey.acquire!(key, queue_name: lock_queue_name(queue_name), msg_id: msg_id)
data/lib/pgbus/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pgbus
4
- VERSION = "0.21.4"
4
+ VERSION = "0.23.0"
5
5
  end
@@ -33,8 +33,13 @@ module Pgbus
33
33
  # the whole table is bounded by the execution pool's capacity plus the
34
34
  # read-ahead buffer (threads + read_ahead per process, issue #486) — a
35
35
  # handful of entries per process, not one per enqueued job.
36
+ #
37
+ # `key` is the entry's @entries key, [queue_name, msg_id], built once with
38
+ # the entry (#track, #hold): #register, #unregister and #registered? each
39
+ # used to build their own (issue #604). A tenth member stays in the
40
+ # 160-byte slot.
36
41
  Entry = Struct.new(:client, :queue_name, :prefixed, :msg_id, :job_class, :extended_at, :extensions,
37
- :concurrency, :on_beat, keyword_init: true)
42
+ :concurrency, :on_beat, :key, keyword_init: true)
38
43
 
39
44
  # Per-job opt-out, included on ActiveJob::Base by the engine:
40
45
  #
@@ -81,9 +86,10 @@ module Pgbus
81
86
  concurrency: nil, on_beat: nil)
82
87
  return yield unless config.visibility_heartbeat
83
88
 
84
- entry = Entry.new(client: client, queue_name: queue_name, prefixed: prefixed, msg_id: msg_id.to_i,
89
+ msg_id = msg_id.to_i
90
+ entry = Entry.new(client: client, queue_name: queue_name, prefixed: prefixed, msg_id: msg_id,
85
91
  job_class: job_class, extended_at: monotonic_now, extensions: 0,
86
- concurrency: concurrency, on_beat: on_beat)
92
+ concurrency: concurrency, on_beat: on_beat, key: [queue_name, msg_id].freeze)
87
93
  register(entry, config)
88
94
  begin
89
95
  yield
@@ -100,8 +106,10 @@ module Pgbus
100
106
  def hold(client:, queue_name:, msg_id:, prefixed: true, job_class: nil, config: Pgbus.configuration)
101
107
  return unless config.visibility_heartbeat
102
108
 
103
- entry = Entry.new(client: client, queue_name: queue_name, prefixed: prefixed, msg_id: msg_id.to_i,
104
- job_class: job_class, extended_at: monotonic_now, extensions: 0)
109
+ msg_id = msg_id.to_i
110
+ entry = Entry.new(client: client, queue_name: queue_name, prefixed: prefixed, msg_id: msg_id,
111
+ job_class: job_class, extended_at: monotonic_now, extensions: 0,
112
+ key: [queue_name, msg_id].freeze)
105
113
  register(entry, config)
106
114
  entry
107
115
  end
@@ -159,7 +167,7 @@ module Pgbus
159
167
  @running = false
160
168
  @thread = nil
161
169
  end
162
- @entries[key_for(entry)] = entry
170
+ @entries[entry.key] = entry
163
171
  # Start the ticker once per process.
164
172
  unless @running && @thread&.alive?
165
173
  @running = true
@@ -173,7 +181,7 @@ module Pgbus
173
181
  # job's entry (issue #486).
174
182
  def unregister(entry)
175
183
  synchronize do
176
- key = key_for(entry)
184
+ key = entry.key
177
185
  @entries.delete(key) if @entries[key].equal?(entry)
178
186
  end
179
187
  end
@@ -265,11 +273,7 @@ module Pgbus
265
273
  end
266
274
 
267
275
  def registered?(entry)
268
- synchronize { @entries[key_for(entry)].equal?(entry) }
269
- end
270
-
271
- def key_for(entry)
272
- [entry.queue_name, entry.msg_id]
276
+ synchronize { @entries[entry.key].equal?(entry) }
273
277
  end
274
278
 
275
279
  # A literal block, not `(&)`: ThreadSafety/ClassInstanceVariable accepts
@@ -80,12 +80,21 @@ module Pgbus
80
80
  )
81
81
  return false unless row
82
82
 
83
- @client.transaction do |txn|
84
- # A live message must not claim to be dead: strip the block, count the trip.
85
- txn.produce(original_queue, row["message"], headers: DeadLetterHeader.strip_for_retry(row["headers"]))
86
- txn.delete(queue_name, msg_id.to_i)
83
+ payload = row["message"].is_a?(String) ? JSON.parse(row["message"]) : row["message"]
84
+ # The row Discard would release is the one the copy takes over.
85
+ holder = lock_holder(queue_name, row["msg_id"], row["headers"], row["enqueued_at"])
86
+ # A live message must not claim to be dead: strip the block, count the trip.
87
+ headers = DeadLetterHeader.strip_for_retry(row["headers"])
88
+ drop_source = -> { @client.delete_message(queue_name, msg_id.to_i, prefixed: false) }
89
+ outcome = resend_job(payload, lock_queue: lock_queue_for_table(original_queue), holder: holder,
90
+ drop_source: drop_source, headers: JSON.parse(headers)) do
91
+ @client.transaction do |txn|
92
+ sent = txn.produce(original_queue, row["message"], headers: headers)
93
+ txn.delete(queue_name, msg_id.to_i)
94
+ sent
95
+ end
87
96
  end
88
- true
97
+ outcome != false
89
98
  rescue StandardError => e
90
99
  Pgbus.logger.debug { "[Pgbus::Web] Error retrying DLQ message #{msg_id}: #{e.message}" }
91
100
  false