pgbus 0.15.1 → 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: c419d130102e92e1bd355df6e98211e44eeead76298f9ce58bd6c285b1de24a9
4
- data.tar.gz: dab0eefe9c7a23aee676b928521841adffb6d38f084993c6bb3ab56f9ade207f
3
+ metadata.gz: 13ec61547a6821edab1a7aa96e794d522cc6fdcd38dd354bf601c9c760a2fded
4
+ data.tar.gz: b79ef51aa82c336ba98e3df071d40e9de5467bec196fb53edaf8a4fa5507327d
5
5
  SHA512:
6
- metadata.gz: d8cedff08ffed91f3b9a9c1b9df1b2147790ec9bf7e5117b22a4af19b865b239df4883d6708153ec2efc9de5207e007cb4d2cfd06440ac3b4bd25e63d1eea369
7
- data.tar.gz: fcb2ec48f7daf53dc46d5c388cb3b4e1acd99a568e74e92253736efb8ba4bc52fd9704d287fbaafcea893d063676b3f68ceac3d82b79fc3906ef4d7bbc43dcd3
6
+ metadata.gz: 8647f5cff936fae72bc95ea49c793fc68e514c29c2955d88b668dd6e5905a95418f7efdb8082e77048a584b9a0c86aa2cea42bbcd1f0777df349606ddbd4db08
7
+ data.tar.gz: ee10d3f15f0e03a8a46d359c5aaf19f3329ed628dd4ae72d98fe00aec4b9689ee43005b73884b62e997fb3305c0b2c5ec2ce9a747646e558a1ade1cbeca27f0e
data/CHANGELOG.md CHANGED
@@ -2,6 +2,9 @@
2
2
 
3
3
  ### Fixed
4
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.
5
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.
6
9
 
7
10
  ### Added
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
@@ -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.1"
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.1
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: []