pgbus 0.15.0 → 0.15.2

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c9e24beb40f37672bb2d5b51db5c6ffeb2894e1a2be3711ff6b17cc5c2ed52b7
4
- data.tar.gz: 4fe85e1733dd8257b1f13af40778fbc449146015d707e9d2889b287ad83af78b
3
+ metadata.gz: 13ec61547a6821edab1a7aa96e794d522cc6fdcd38dd354bf601c9c760a2fded
4
+ data.tar.gz: b79ef51aa82c336ba98e3df071d40e9de5467bec196fb53edaf8a4fa5507327d
5
5
  SHA512:
6
- metadata.gz: b8e23d2e38e12c2dd882dcdb5de8c23763b469551c869511718cd9d52a85ccdceb344779ae48ab3a3cfec2f1d3e8373d642f8d97f712c30f98893ed0450abc0d
7
- data.tar.gz: 142d408f3fa304d82593cf9fdf6167592c3bf2cafb64016702b057e374a98c7476e33922d6b05be00e7a71136861a77d9b8267dbd1fabb1dc6ed48c6fd1e15ae
6
+ metadata.gz: 8647f5cff936fae72bc95ea49c793fc68e514c29c2955d88b668dd6e5905a95418f7efdb8082e77048a584b9a0c86aa2cea42bbcd1f0777df349606ddbd4db08
7
+ data.tar.gz: ee10d3f15f0e03a8a46d359c5aaf19f3329ed628dd4ae72d98fe00aec4b9689ee43005b73884b62e997fb3305c0b2c5ec2ce9a747646e558a1ade1cbeca27f0e
data/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ### Fixed
4
+
5
+ - **Worker forks no longer kill the supervisor's shared LISTEN connection (issue #437).** Under `worker_notify_scope = :supervisor` every fork produced one `[Pgbus::NotifyListener] connection error (PG::ConnectionBad: PQconsumeInput() server closed the connection unexpectedly …) — reconnecting` in the supervisor ~2 s after `Worker started`, and a LISTEN gap (polling fallback) until `reconnect!` completed. The child's fork hygiene closed the Ruby `IO` wrapper of the inherited LISTEN socket, but pg builds `socket_io` with `autoclose=false`, so the fd stayed open — and when the child's GC freed the inherited `PG::Connection`, its `PQfinish` sent a libpq Terminate down that fd, i.e. down the **parent's** connection. `NotifyListener#close_inherited_socket!` now repoints the fd at `/dev/null` (`socket_io.reopen(IO::NULL)`, the ActiveRecord `PostgreSQLAdapter#discard!` idiom) so the eventual `PQfinish` is harmless. Regression-covered by a real-fork integration spec that asserts the parent's `pgbus-listen` backend pid is unchanged and no reconnect is logged. Refs #437.
6
+ - **A process whose `pgbus_processes` row is deleted underneath it now re-registers instead of staying invisible forever (issue #438).** `Heartbeat#beat` updated its row by id; when the row was gone — the dispatcher's stale-process reaper after a ≥ 5-minute heartbeat gap, a manual cleanup, another host's clock skew — `update_all` matched 0 rows, raised nothing, and the process (healthy, logging, serving `/readyz`) was absent from `pgbus_processes` until it restarted, so the supervisor's DB loop-tick check silently fell back to the pipe and `ConsumerPriority` could not see it. The beat now treats an affected-row count of 0 as "row is gone": it logs one WARN naming the old id, kind and pid (so whatever deleted the row can be hunted), re-registers through the existing boot path, and lands that beat's `last_heartbeat_at` / metadata on the new row. A mutex plus a stopped flag keep a beat that races `stop` from resurrecting a row `deregister_process` just deleted. Zero extra queries on the happy path. Refs #438.
7
+ - **Supervisor logs a worker recycle as a clean exit, not a crash (issue #438).** A worker or consumer hitting `max_jobs` / `max_memory` / `max_lifetime` exits 0 by design, but the supervisor logged `Child worker pid=N exited unexpectedly (status=0)`, burying real crashes. A clean exit outside shutdown is now INFO `exited cleanly (status=0) — restarting (worker recycle)`; non-zero exits keep the WARN, and a signaled exit reports `signal=N` instead of an empty status so an OOM SIGKILL is distinguishable. Restart policy is unchanged. Refs #438.
8
+ - **`current_attributes` capture skips an unpersisted record instead of raising at enqueue (issue #435).** An attribute holding an Active Record instance with no id — a dev-mode fallback record assigned to `Current`, a form-built model captured before `save`, a destroyed record whose locate is guaranteed to fail — made every `perform_later` in that context raise `Pgbus::CurrentAttributesError`, even though such a record can never round-trip (no id → no GlobalID) and capture is ambient: the enqueuer never opted into persisting that attribute per-call, so its momentary state must not abort the enqueue. `capture` now skips any attribute value that answers `persisted?` falsey (so destroyed-but-id-bearing records are skipped too, not just `new_record?`) with a debug log naming the class, attribute and why; the rest of the class's attributes still persist. The `except:` guidance and the loud `CurrentAttributesError` remain for genuinely unserializable values — objects without the Active Record duck-type (`respond_to?(:persisted?)`) are untouched. Applies to jobs and event-bus publish alike (same capture path). Refs #435.
9
+
3
10
  ### Added
4
11
 
5
12
  - **First-class `Current` support: ActiveSupport::CurrentAttributes persist across enqueue → perform (issue #430).** The executor already reset `CurrentAttributes` around every job (so nothing leaked) but nothing restored it — `Current.tenant` was always nil inside a job. New `config.current_attributes` (`nil` = off; `:auto` = every `ActiveSupport::CurrentAttributes` subclass; an Array of classes/names; or a Hash of class => `{ only: [...] }` / `{ except: [...] }`). The new `Pgbus::ActiveJob::CurrentAttributes` mixin (included on `ActiveJob::Base` by the engine next to `BatchId`) captures the assigned attributes of each persisted class in `serialize` — serialized with `ActiveJob::Arguments`, so records become GlobalIDs and fall under the `allowed_global_id_models` allowlist on the way back — under the job-hash key `pgbus_current`, and restores them by wrapping the **whole** `perform_now` in nested `Current.set`, so `before_perform`, `perform`, `rescue_from`, `retry_on` / `discard_on` blocks and jobs enqueued from inside `perform` all see the context, under the pgbus worker and Rails' `:test` / `:inline` adapters alike. A deserialized job re-serializes the context it was enqueued with, so a `retry_on` re-enqueue keeps the original; concurrency-blocked promotion, dead-letter / dashboard retry and `perform_all_later` carry it by construction. An unserializable attribute raises `Pgbus::CurrentAttributesError` at `perform_later` naming the class, attribute and the `except:` fix — nothing is dropped silently; a class that no longer exists or an attribute no longer defined is skipped with a log line (Sidekiq parity). Per job class: `self.pgbus_persist_current_attributes = false` or a spec override. Unconfigured installs have byte-identical payloads. Dashboard: failed-job and dead-letter detail pages gain a **Context** card (`Pgbus::Web::JobContext`, through `PayloadFilter`). Event bus follows in #431. Refs #430.
data/README.md CHANGED
@@ -674,7 +674,7 @@ end
674
674
 
675
675
  Events get the same hop: `Pgbus.publish` captures `Current` into the event envelope and the consumer restores it around every `handle` — including across the transactional outbox (captured at `Outbox.publish_event`, inside your transaction). Handlers can also read the raw form via `event.context`.
676
676
 
677
- Captured at enqueue via `ActiveJob::Arguments` (records become GlobalIDs, gated by `allowed_global_id_models`), preserved across retries, concurrency-blocked promotion, dead-letter retry and `perform_all_later`; an unserializable attribute raises at `perform_later` with the `except:` fix. The dashboard shows the context on failed-job and dead-letter pages. Details: [Active Job → Current attributes](https://pgbus.zoolutions.llc/docs/active-job).
677
+ Captured at enqueue via `ActiveJob::Arguments` (records become GlobalIDs, gated by `allowed_global_id_models`), preserved across retries, concurrency-blocked promotion, dead-letter retry and `perform_all_later`; an unserializable attribute raises at `perform_later` with the `except:` fix, while an **unpersisted** record (no id — it could never be restored) is skipped with a debug log instead of aborting the enqueue. The dashboard shows the context on failed-job and dead-letter pages. Details: [Active Job → Current attributes](https://pgbus.zoolutions.llc/docs/active-job).
678
678
 
679
679
  ### Consumer priority
680
680
 
data/lib/pgbus/client.rb CHANGED
@@ -1753,11 +1753,10 @@ module Pgbus
1753
1753
  timeout = config.read_timeout
1754
1754
  return mapping_statement_timeout(&block) unless timeout&.positive?
1755
1755
 
1756
- # rubocop:disable Pgbus/NoRubyTimeout -- deliberate last-resort bound; see above
1756
+ # rubocop:disable-next Pgbus/NoRubyTimeout -- deliberate last-resort bound; see above
1757
1757
  Timeout.timeout(timeout + READ_TIMEOUT_SLACK, WedgedReadTimeout) do
1758
1758
  mapping_statement_timeout(&block)
1759
1759
  end
1760
- # rubocop:enable Pgbus/NoRubyTimeout
1761
1760
  rescue WedgedReadTimeout
1762
1761
  reload_pool_after_wedged_timeout
1763
1762
  raise
@@ -8,8 +8,11 @@ module Pgbus
8
8
  # (config.current_attributes: :auto, an explicit list, or per-class
9
9
  # only:/except: filters) serialized through ActiveJob::Arguments — so
10
10
  # GlobalID models, Symbols, Times round-trip like job arguments and fall
11
- # under the allowed_global_id_models allowlist on the way back. `restore`
12
- # nests `klass.set(attrs)` for the duration of a block.
11
+ # under the allowed_global_id_models allowlist on the way back. An
12
+ # attribute holding an unpersisted record is skipped with a debug log
13
+ # (no id → no GlobalID → it could never be restored; see
14
+ # #reject_unpersisted). `restore` nests `klass.set(attrs)` for the
15
+ # duration of a block.
13
16
  #
14
17
  # The ActiveJob side (Pgbus::ActiveJob::CurrentAttributes) calls capture
15
18
  # from `serialize` and restore around `perform_now`, so the hop works under
@@ -34,7 +37,7 @@ module Pgbus
34
37
  captured = {}
35
38
  persisted_specs(config, override: override).each do |spec|
36
39
  klass = resolve_class(spec[:name]) or next
37
- attrs = filter_attrs(klass.attributes, spec)
40
+ attrs = reject_unpersisted(klass, filter_attrs(klass.attributes, spec))
38
41
  next if attrs.empty?
39
42
 
40
43
  captured[klass.name] = serialize_attrs(klass, attrs)
@@ -126,6 +129,27 @@ module Pgbus
126
129
  attrs
127
130
  end
128
131
 
132
+ # An unpersisted record cannot round-trip by definition — no id, no
133
+ # GlobalID — and capture is ambient: the enqueuer never opted into
134
+ # persisting this attribute per-call, so its momentary state (a dev-mode
135
+ # fallback record, a form-built model assigned to Current before save,
136
+ # a destroyed record whose locate is guaranteed to fail) must not abort
137
+ # the enqueue. Skip it like an unassigned attribute. persisted? (not
138
+ # new_record?) so destroyed-but-id-bearing records are skipped too;
139
+ # objects without the Active Record duck-type pass through untouched
140
+ # and still hit serialize_attrs' loud CurrentAttributesError if bad.
141
+ def reject_unpersisted(klass, attrs)
142
+ attrs.reject do |name, value|
143
+ next false unless value.respond_to?(:persisted?) && !value.persisted?
144
+
145
+ Pgbus.logger.debug do
146
+ "[Pgbus] current_attributes: #{klass.name}##{name} holds an unpersisted #{value.class} — " \
147
+ "skipped (no id to serialize; it could never be restored)"
148
+ end
149
+ true
150
+ end
151
+ end
152
+
129
153
  def serialize_attrs(klass, attrs)
130
154
  ::ActiveJob::Arguments.serialize([attrs]).first
131
155
  rescue ::ActiveJob::SerializationError, URI::Error
@@ -95,7 +95,7 @@ module Pgbus
95
95
  "Add `gem \"async\"` to your Gemfile. Original error: #{e.message}"
96
96
  end
97
97
 
98
- # rubocop:disable Lint/RescueException
98
+ # rubocop:disable-next Lint/RescueException
99
99
  def start_reactor
100
100
  Thread.new do
101
101
  Thread.current.name = "pgbus-async-reactor-#{object_id}"
@@ -122,7 +122,6 @@ module Pgbus
122
122
  raise
123
123
  end
124
124
  end
125
- # rubocop:enable Lint/RescueException
126
125
 
127
126
  def wait_for_executions(semaphore)
128
127
  loop do
@@ -18,6 +18,12 @@ module Pgbus
18
18
  @loop_tick_supplier = loop_tick_supplier
19
19
  @metadata_supplier = metadata_supplier
20
20
  @timer = nil
21
+ @stopped = false
22
+ # Guards @process_id between the timer thread (beat) and the main
23
+ # thread (stop): TimerTask#shutdown does not wait for an in-flight
24
+ # beat, and a beat that re-registers after deregister_process ran
25
+ # would leave a zombie row (issue #438).
26
+ @mutex = Mutex.new
21
27
  end
22
28
 
23
29
  def start
@@ -27,18 +33,19 @@ module Pgbus
27
33
  end
28
34
 
29
35
  def stop
36
+ @stopped = true
30
37
  @timer&.shutdown
31
- deregister_process
38
+ @mutex.synchronize { deregister_process }
32
39
  end
33
40
 
34
41
  def beat
35
- return unless @process_id
42
+ return unless @process_id && !@stopped
36
43
 
37
44
  @on_beat&.call
38
45
  updates = { last_heartbeat_at: Time.current }
39
46
  metadata = beat_metadata
40
47
  updates[:metadata] = metadata unless metadata.nil?
41
- ProcessEntry.where(id: @process_id).update_all(updates)
48
+ @mutex.synchronize { write_beat(updates) }
42
49
  rescue StandardError => e
43
50
  Pgbus.logger.warn { "[Pgbus] Heartbeat failed: #{e.message}" }
44
51
  end
@@ -59,6 +66,25 @@ module Pgbus
59
66
  metadata
60
67
  end
61
68
 
69
+ # update_all by id matches 0 rows when the row was deleted underneath a
70
+ # live process (stale-process reaper after a heartbeat gap, manual
71
+ # cleanup, another host's clock skew). Nothing raises, so treat the
72
+ # count as the signal: re-register and log once so the cause of the
73
+ # deletion is discoverable, then land this beat's updates on the new
74
+ # row (issue #438). Skipped after stop so a beat racing deregistration
75
+ # cannot resurrect the row.
76
+ def write_beat(updates)
77
+ return if ProcessEntry.where(id: @process_id).update_all(updates).positive? || @stopped
78
+
79
+ old_id = @process_id
80
+ Pgbus.logger.warn do
81
+ "[Pgbus] Process row id=#{old_id} kind=#{@kind} pid=#{::Process.pid} is gone " \
82
+ "(stale-process reaper, manual cleanup, or clock skew?) — re-registering"
83
+ end
84
+ register_process
85
+ ProcessEntry.where(id: @process_id).update_all(updates) if @process_id != old_id
86
+ end
87
+
62
88
  def register_process
63
89
  record = ProcessEntry.create!(
64
90
  kind: @kind,
@@ -143,13 +143,21 @@ module Pgbus
143
143
  @state_mutex.synchronize { @running }
144
144
  end
145
145
 
146
- # Called ONLY inside a just-forked child (issue #381 hub hygiene): drop
147
- # this process's copy of the LISTEN socket fd WITHOUT PQfinish — #close
146
+ # Called ONLY inside a just-forked child (issues #381 / #437): release
147
+ # this process's copy of the LISTEN socket WITHOUT PQfinish — #close
148
148
  # would send a libpq Terminate over the socket shared with the parent,
149
- # killing the parent's LISTEN session. Closing the IO wrapper just
150
- # closes the child's fd. The listener thread does not exist in the
151
- # child (fork copies only the calling thread), so there is no
152
- # concurrent owner and the single-owner rule (#375) does not apply.
149
+ # killing the parent's LISTEN session.
150
+ #
151
+ # Closing the IO wrapper is not enough: pg builds socket_io with
152
+ # autoclose=false, so IO#close leaves the fd open, and when GC frees the
153
+ # inherited PG::Connection its PQfinish still writes Terminate on that
154
+ # fd — i.e. on the PARENT's connection (issue #437, one reconnect per
155
+ # fork). Repointing the fd at /dev/null (IO#reopen, the ActiveRecord
156
+ # PostgreSQLAdapter#discard! idiom) makes the eventual PQfinish harmless.
157
+ #
158
+ # The listener thread does not exist in the child (fork copies only the
159
+ # calling thread), so there is no concurrent owner and the single-owner
160
+ # rule (#375) does not apply.
153
161
  def close_inherited_socket!
154
162
  conn = @state_mutex.synchronize do
155
163
  c = @conn
@@ -157,10 +165,11 @@ module Pgbus
157
165
  @running = false
158
166
  c
159
167
  end
160
- conn&.socket_io&.close
168
+ conn&.socket_io&.reopen(IO::NULL)
161
169
  rescue StandardError => e
162
- # Best-effort (a lingering fd copy is benign until the parent dies),
163
- # but never silent: the child keeps booting either way.
170
+ # Best-effort but never silent: the child keeps booting either way.
171
+ # If this fails the child's GC-time PQfinish will hit the parent's
172
+ # session, which the parent's reconnect! survives.
164
173
  @logger.warn do
165
174
  "[Pgbus::NotifyListener] inherited socket cleanup failed: #{e.class}: #{e.message}"
166
175
  end
@@ -613,9 +613,7 @@ module Pgbus
613
613
  if @shutting_down
614
614
  Pgbus.logger.info { "[Pgbus] Child #{info[:type]} pid=#{pid} exited (status=#{status.exitstatus})" }
615
615
  else
616
- Pgbus.logger.warn do
617
- "[Pgbus] Child #{info[:type]} pid=#{pid} exited unexpectedly (status=#{status&.exitstatus})"
618
- end
616
+ log_child_exit(info, pid, status)
619
617
  schedule_restart(info, status)
620
618
  end
621
619
  rescue Errno::ECHILD
@@ -623,6 +621,27 @@ module Pgbus
623
621
  end
624
622
  end
625
623
 
624
+ # A clean exit outside shutdown is a worker/consumer recycle (max_jobs,
625
+ # max_memory, max_lifetime) — expected, so INFO. Anything else is a
626
+ # crash: WARN, naming the signal when there is one so an OOM SIGKILL
627
+ # reads differently from an exit code (issue #438).
628
+ def log_child_exit(info, pid, status)
629
+ if status&.success?
630
+ Pgbus.logger.info do
631
+ "[Pgbus] Child #{info[:type]} pid=#{pid} exited cleanly (status=0) — restarting (worker recycle)"
632
+ end
633
+ else
634
+ Pgbus.logger.warn do
635
+ detail = if status && status.exitstatus.nil? && status.signaled?
636
+ "signal=#{status.termsig}"
637
+ else
638
+ "status=#{status&.exitstatus}"
639
+ end
640
+ "[Pgbus] Child #{info[:type]} pid=#{pid} exited unexpectedly (#{detail})"
641
+ end
642
+ end
643
+ end
644
+
626
645
  # Restart policy: a clean exit (worker recycling) or a crash after a
627
646
  # stable run restarts immediately with a fresh crash streak. A crash
628
647
  # within RESTART_STABLE_UPTIME of forking is a crash loop — the child
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.15.0"
4
+ VERSION = "0.15.2"
5
5
  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.15.0
4
+ version: 0.15.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson
@@ -424,13 +424,13 @@ files:
424
424
  - lib/tasks/pgbus_pgmq.rake
425
425
  - lib/tasks/pgbus_queues.rake
426
426
  - lib/tasks/pgbus_streams.rake
427
- homepage: https://github.com/mhenrixon/pgbus
427
+ homepage: https://github.com/zoolutions/pgbus
428
428
  licenses:
429
429
  - MIT
430
430
  metadata:
431
- homepage_uri: https://github.com/mhenrixon/pgbus
432
- source_code_uri: https://github.com/mhenrixon/pgbus/tree/main
433
- changelog_uri: https://github.com/mhenrixon/pgbus/blob/main/CHANGELOG.md
431
+ homepage_uri: https://github.com/zoolutions/pgbus
432
+ source_code_uri: https://github.com/zoolutions/pgbus/tree/main
433
+ changelog_uri: https://github.com/zoolutions/pgbus/blob/main/CHANGELOG.md
434
434
  documentation_uri: https://pgbus.zoolutions.llc
435
435
  rubygems_mfa_required: 'true'
436
436
  rdoc_options: []