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,187 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module Process
5
+ # Batched completion (issue #606, `config.completion_batching`): one per
6
+ # worker, it archives the messages of jobs that finish close together with
7
+ # one `archive_batch` per physical queue instead of one `pgmq.archive` each.
8
+ #
9
+ # There is no timer. An idle completer flushes a lone id at once; ids that
10
+ # arrive while a flush is in flight wait for the next one, so under load a
11
+ # batch is "what arrived during the previous round trip".
12
+ # `batch_size` only caps the ids per statement.
13
+ #
14
+ # The job thread waits for its own result (`complete` blocks), so the
15
+ # executor's post-archive sequence runs on it as before. Threads only: a
16
+ # blocking wait would stall an Async reactor (Configuration#validate!).
17
+ #
18
+ # completed_jobs: :delete (the config setting, issue #607) flushes with
19
+ # `delete_batch` instead; the results keep their names, so :archived
20
+ # means "completed" and :already_archived "already gone".
21
+ class Completer
22
+ # Raised on a waiting job thread whose flush can no longer happen: the
23
+ # flusher was stopped past its join budget, or died.
24
+ class Stopped < Pgbus::Error; end
25
+
26
+ # One job thread's wait for its id's result.
27
+ class Waiter
28
+ def initialize
29
+ @mutex = Mutex.new
30
+ @done = ConditionVariable.new
31
+ @state = nil
32
+ @value = nil
33
+ end
34
+
35
+ def resolve(value) = settle(:value, value)
36
+
37
+ def fail(error) = settle(:error, error)
38
+
39
+ def value
40
+ @mutex.synchronize do
41
+ @done.wait(@mutex) until @state
42
+ raise @value if @state == :error
43
+
44
+ @value
45
+ end
46
+ end
47
+
48
+ private
49
+
50
+ # First settle wins: a waiter already served is not failed again by
51
+ # the flusher's last-resort cleanup.
52
+ def settle(state, value)
53
+ @mutex.synchronize do
54
+ next if @state
55
+
56
+ @state = state
57
+ @value = value
58
+ @done.broadcast
59
+ end
60
+ end
61
+ end
62
+
63
+ BATCH_OPS = { archive: :archive_batch, delete: :delete_batch }.freeze
64
+
65
+ def initialize(client:, batch_size:, completed_jobs: :archive)
66
+ @batch_op = BATCH_OPS.fetch(completed_jobs) do
67
+ raise ArgumentError, "completed_jobs must be :archive or :delete (got #{completed_jobs.inspect})"
68
+ end
69
+ @client = client
70
+ @batch_size = batch_size
71
+ @mutex = Mutex.new
72
+ @wake = ConditionVariable.new
73
+ @pending = {}
74
+ @taken = nil
75
+ @stopping = false
76
+ # Owned by this completer: one per worker, built with it. #stop asks
77
+ # it to drain and exit, then joins it (Worker#shutdown, #immediate_shutdown).
78
+ @thread = Thread.new { run } # rubocop:disable ThreadSafety/NewThread
79
+ @thread.name = "pgbus-completer"
80
+ end
81
+
82
+ # Archives msg_id from the physical (already prefixed) queue and returns
83
+ # :archived, or :already_archived when the message was gone (another
84
+ # worker archived it). Raises the flush's exception on this thread.
85
+ def complete(queue, msg_id)
86
+ waiter = @mutex.synchronize do
87
+ next if @stopping
88
+
89
+ entry = Waiter.new
90
+ (@pending[queue] ||= []) << [msg_id, entry]
91
+ @wake.signal
92
+ entry
93
+ end
94
+ return waiter.value if waiter
95
+
96
+ # Stopped: complete on the caller's thread, with the same results.
97
+ @client.public_send(@batch_op, queue, [msg_id], prefixed: false).empty? ? :already_archived : :archived
98
+ end
99
+
100
+ # Flushes whatever is pending, then joins the flusher. Past timeout the
101
+ # flusher is killed and every waiter it had not served raises Stopped.
102
+ # Later `complete` calls archive on their own thread. Idempotent.
103
+ def stop(timeout: nil)
104
+ @mutex.synchronize do
105
+ @stopping = true
106
+ @wake.signal
107
+ end
108
+ return if @thread.join(timeout)
109
+
110
+ @thread.kill
111
+ @thread.join
112
+ end
113
+
114
+ def alive? = @thread.alive?
115
+
116
+ # Ids waiting for the next flush (test and introspection seam).
117
+ def pending_count
118
+ @mutex.synchronize { @pending.sum { |_queue, entries| entries.size } }
119
+ end
120
+
121
+ # True while a flush is running (test and introspection seam).
122
+ def flushing?
123
+ @mutex.synchronize { !@taken.nil? }
124
+ end
125
+
126
+ private
127
+
128
+ def run
129
+ while (batch = take)
130
+ flush(batch)
131
+ @mutex.synchronize { @taken = nil }
132
+ end
133
+ ensure
134
+ fail_unserved
135
+ end
136
+
137
+ # Waits for pending ids and takes all of them; nil once stopping with
138
+ # nothing left.
139
+ def take
140
+ @mutex.synchronize do
141
+ @wake.wait(@mutex) while @pending.empty? && !@stopping
142
+ next if @pending.empty?
143
+
144
+ @taken = @pending
145
+ @pending = {}
146
+ @taken
147
+ end
148
+ end
149
+
150
+ def flush(batch)
151
+ batch.each do |queue, entries|
152
+ entries.each_slice(@batch_size) { |slice| flush_slice(queue, slice) }
153
+ end
154
+ end
155
+
156
+ # pgmq returns one row per archived id (as a String); an id already gone
157
+ # is absent. A duplicated id (the same message held by two threads after
158
+ # a redelivery) is archived once, so only its first waiter gets :archived.
159
+ def flush_slice(queue, slice)
160
+ archived = @client.public_send(@batch_op, queue, slice.map(&:first).uniq, prefixed: false).to_set(&:to_i)
161
+ slice.each { |msg_id, waiter| waiter.resolve(archived.delete?(msg_id) ? :archived : :already_archived) }
162
+ rescue Exception => e # rubocop:disable Lint/RescueException -- delivered to the waiters, never swallowed
163
+ log_flush_failure(queue, slice, e) unless e.is_a?(StandardError)
164
+ slice.map(&:last).each { |waiter| waiter.fail(e) }
165
+ end
166
+
167
+ def log_flush_failure(queue, slice, error)
168
+ Pgbus.logger.error do
169
+ "[Pgbus::Completer] #{@batch_op} on #{queue} (#{slice.size} ids) raised #{error.class}: #{error.message}"
170
+ end
171
+ end
172
+
173
+ # The flusher is exiting: nothing may be left waiting on it.
174
+ def fail_unserved
175
+ leftovers = @mutex.synchronize do
176
+ entries = [@taken, @pending].compact.flat_map(&:values).flatten(1)
177
+ @taken = nil
178
+ @pending = {}
179
+ @stopping = true
180
+ entries
181
+ end
182
+ error = Stopped.new("the completer stopped before archiving this message")
183
+ leftovers.map(&:last).each { |waiter| waiter.fail(error) }
184
+ end
185
+ end
186
+ end
187
+ end
@@ -264,7 +264,7 @@ module Pgbus
264
264
  dispatch(handlers, message, queue_name)
265
265
  end
266
266
 
267
- Pgbus.client.archive_message(queue_name, message.msg_id.to_i)
267
+ archive_handled(queue_name, message)
268
268
  # A redelivery that succeeds leaves no stale failure behind. First
269
269
  # deliveries never had one: no query on the hot path (#484's gate).
270
270
  if message.read_ct.to_i > 1
@@ -367,6 +367,20 @@ module Pgbus
367
367
  # as retrying with its error. No ErrorReporter call: Handler#process!
368
368
  # already instruments pgbus.event_failed, which feeds the reporters.
369
369
  # A handler queue has no priority sub-queues: its one table is the source.
370
+ # The handlers ran. An archive whose reply was lost after it may have
371
+ # committed, and whose re-run found the message gone (issue #607
372
+ # review), leaves nothing to retry: the message is handled.
373
+ def archive_handled(queue_name, message)
374
+ Pgbus.client.archive_message(queue_name, message.msg_id.to_i)
375
+ rescue StandardError => e # pgmq loads lazily: completion_lost? checks the class
376
+ raise unless Client::NativeStatements.completion_lost?(e)
377
+
378
+ Pgbus.logger.warn do
379
+ "[Pgbus] Consumer archive of #{message.msg_id} on #{queue_name} lost its reply and the message is gone: " \
380
+ "counted as handled (#{e.message})"
381
+ end
382
+ end
383
+
370
384
  def record_failed_event(message, queue_name, error)
371
385
  FailedEventRecorder.record!(
372
386
  queue_name: queue_name, msg_id: message.msg_id.to_i, source_queue: config.queue_name(queue_name),
@@ -62,6 +62,12 @@ module Pgbus
62
62
  "fair_share and group_mode are mutually exclusive — this worker has group_mode " \
63
63
  "#{(@group_mode || config.group_mode).inspect} while config.fair_share is set"
64
64
  end
65
+ # The completer blocks a job thread on its flush, which would stall an
66
+ # Async reactor (issue #606); the mode can arrive here without validate!.
67
+ if config.completion_batching && @execution_mode == :async
68
+ raise Pgbus::ConfigurationError,
69
+ "completion_batching supports execution_mode :threads only; this worker runs :async"
70
+ end
65
71
  @single_active_consumer = single_active_consumer
66
72
  @consumer_priority = consumer_priority
67
73
  @lifecycle = Lifecycle.new
@@ -94,7 +100,16 @@ module Pgbus
94
100
  else
95
101
  stat_buffer
96
102
  end
97
- @executor = Pgbus::ActiveJob::Executor.new(stat_buffer: @stat_buffer)
103
+ # One per worker when config.completion_batching (issue #606): archives
104
+ # the jobs that finish close together in one archive_batch, or deletes
105
+ # them with delete_batch under config.completed_jobs = :delete (#607).
106
+ @completer = if config.completion_batching
107
+ Completer.new(client: Pgbus.client, batch_size: config.completion_batch_size,
108
+ completed_jobs: config.completed_jobs)
109
+ end
110
+ # The worker's config, so the executor and the completer read one
111
+ # completed_jobs (issue #607).
112
+ @executor = Pgbus::ActiveJob::Executor.new(config: config, stat_buffer: @stat_buffer, completer: @completer)
98
113
  @wake_signal = wake_signal || WakeSignal.new
99
114
  @pool = ExecutionPools.build(
100
115
  mode: @execution_mode,
@@ -239,6 +254,13 @@ module Pgbus
239
254
  Pgbus.stopping = true
240
255
  @lifecycle.transition_to!(:stopped)
241
256
  @wake_signal.notify!
257
+ # Before the pool: a job thread killed while it waits on a flush would
258
+ # skip its signals while the flush still commits (archived, never
259
+ # signalled). Stopping first resolves every waiter; a job that finishes
260
+ # inside the grace then archives on its own thread. Bounded by the same
261
+ # grace: a flush wedged on a hung Postgres is abandoned, and its jobs
262
+ # fail with Completer::Stopped and re-run after visibility_timeout.
263
+ @completer&.stop(timeout: config.immediate_shutdown_grace)
242
264
  ExecutionPools.terminate(@pool, grace: config.immediate_shutdown_grace)
243
265
  end
244
266
 
@@ -927,7 +949,13 @@ module Pgbus
927
949
  # config.drain_timeout for in-flight jobs. A job still running here has
928
950
  # proven it won't finish; waiting another full window would push the
929
951
  # worker past the supervisor's shutdown_timeout deadline (issue #386).
952
+ residual_deadline = monotonic_now + POOL_TERMINATION_WAIT
930
953
  @pool.wait_for_termination(POOL_TERMINATION_WAIT)
954
+ # After the pool: every waiter is a job thread, so the drain already
955
+ # flushed theirs; this flushes any left and joins the flusher. It shares
956
+ # the pool's residual budget rather than adding its own, so shutdown
957
+ # stays inside the supervisor's drain_timeout + 5 default deadline.
958
+ @completer&.stop(timeout: [residual_deadline - monotonic_now, 0].max)
931
959
  VisibilityHeartbeat.stop
932
960
  @stat_buffer&.stop
933
961
  @queue_lock&.unlock_all
@@ -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
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.22.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
data/sig/pgbus/client.rbs CHANGED
@@ -33,6 +33,7 @@ module Pgbus
33
33
  def message_with_job_id?: (untyped queue_name, job_id: untyped, ?prefixed: untyped) -> bool?
34
34
  def metrics: (?untyped queue_name) -> untyped
35
35
  def move_to_dead_letter: (untyped queue_name, untyped message, ?headers: untyped) -> untyped
36
+ def native_ready?: (String full_name) -> bool
36
37
  def notify_enabled?: (untyped queue_name) -> bool
37
38
  def notify_stream: (untyped stream_name, untyped payload) -> untyped
38
39
  def oldest_claimable_ages: (?untyped queue_name) -> untyped
@@ -30,6 +30,12 @@ module Pgbus
30
30
  def capsule_named: (untyped name) -> untyped
31
31
  def circuit_breaker_enabled: () -> bool
32
32
  def circuit_breaker_enabled=: (untyped) -> untyped
33
+ def completed_jobs: () -> (:archive | :delete)
34
+ def completed_jobs=: (untyped value) -> (:archive | :delete)
35
+ def completion_batch_size: () -> Integer
36
+ def completion_batch_size=: (untyped) -> untyped
37
+ def completion_batching: () -> bool
38
+ def completion_batching=: (untyped) -> untyped
33
39
  def connection_guc_mode: () -> Symbol
34
40
  def connection_guc_mode=: (untyped mode) -> untyped
35
41
  def connection_options: () -> untyped
@@ -114,6 +120,8 @@ module Pgbus
114
120
  def metrics_backend=: (untyped) -> untyped
115
121
  def metrics_enabled: () -> bool
116
122
  def metrics_enabled=: (untyped) -> untyped
123
+ def native_statements: () -> bool
124
+ def native_statements=: (bool value) -> bool
117
125
  def notify_presence_change: (untyped stream_name, untyped change) -> untyped
118
126
  def outbox_batch_size: () -> Integer
119
127
  def outbox_batch_size=: (untyped) -> untyped
@@ -0,0 +1,26 @@
1
+ module Pgbus
2
+ module Process
3
+ # Batched completion (issue #606): one per worker when
4
+ # config.completion_batching is on.
5
+ class Completer
6
+ class Stopped < Pgbus::Error
7
+ end
8
+
9
+ class Waiter
10
+ def initialize: () -> void
11
+ def resolve: (Symbol value) -> void
12
+ def fail: (Exception error) -> void
13
+ def value: () -> Symbol
14
+ end
15
+
16
+ BATCH_OPS: Hash[Symbol, Symbol]
17
+
18
+ def initialize: (client: Pgbus::Client, batch_size: Integer, ?completed_jobs: :archive | :delete) -> void
19
+ def complete: (String queue, Integer msg_id) -> (:archived | :already_archived)
20
+ def stop: (?timeout: Numeric?) -> void
21
+ def alive?: () -> bool
22
+ def pending_count: () -> Integer
23
+ def flushing?: () -> bool
24
+ end
25
+ end
26
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pgbus
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.21.4
4
+ version: 0.22.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson
@@ -356,6 +356,7 @@ files:
356
356
  - lib/pgbus/client/connection_health.rb
357
357
  - lib/pgbus/client/ensure_stream_queue.rb
358
358
  - lib/pgbus/client/fair_read.rb
359
+ - lib/pgbus/client/native_statements.rb
359
360
  - lib/pgbus/client/notify_lock_retry.rb
360
361
  - lib/pgbus/client/notify_stream.rb
361
362
  - lib/pgbus/client/read_after.rb
@@ -463,6 +464,7 @@ files:
463
464
  - lib/pgbus/pgmq_schema/pgmq_v1.13.0.sql
464
465
  - lib/pgbus/pgmq_schema/pgmq_v1.13.1.sql
465
466
  - lib/pgbus/process/claim_buffer.rb
467
+ - lib/pgbus/process/completer.rb
466
468
  - lib/pgbus/process/consumer.rb
467
469
  - lib/pgbus/process/consumer_priority.rb
468
470
  - lib/pgbus/process/dispatcher.rb
@@ -616,6 +618,7 @@ files:
616
618
  - sig/pgbus/client.rbs
617
619
  - sig/pgbus/configuration.rbs
618
620
  - sig/pgbus/configuration/snapshot.rbs
621
+ - sig/pgbus/process/completer.rbs
619
622
  - sig/pgbus/streams.rbs
620
623
  - sig/pgbus/streams/stream.rbs
621
624
  homepage: https://github.com/zoolutions/pgbus