julewire-ractor 1.1.2 → 1.1.3

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: f80086f2967d5e948d9d0ce3cae9e1a55e5257bb1893a655d63aeac7f1909b30
4
- data.tar.gz: 4eec5968bc6b73593b01d7531a8c7fc9550be48227eb23775a6c4b903184601e
3
+ metadata.gz: bdf4b664cc26510a153647b6041ba3d01804efda4b89eed4880194d9e81b82cd
4
+ data.tar.gz: 7660eff913795f731f31d508e2050320abf1842c967fdccb699c1c1620566a89
5
5
  SHA512:
6
- metadata.gz: 9077d90838210803b36a82e4b7ab73dbba33af36f6313051242d8b8c9f2ac67d26b5dc3002139b66830699fda74f75a1e7eaa9cd68a46f84fca26b283681f990
7
- data.tar.gz: 73a4ade8715104b2f740db61fb9aac3943c3f6ddfef1bfe64f46883e1b46a1a42272c789bff7254e4f5a3d5aa89889fdcb90267772c97c8ff95f2a85ebb18218
6
+ metadata.gz: b12284718b7f14a5c91cc735028ab5db62cb6e1c2ee4be8f399f17f56f99ad4e963ab22ce83199521bb5b4b8b157422d49e5d7247c5a30d64a90869e66617e17
7
+ data.tar.gz: c7d6266890e110b97110b9245ba06bcbc3dbe583360a2ce4cb130bc8e17c2a880152bb0929a9d73cb826d969d99d14aea3ccf3a6b282adb132d59d5c7f004cc5
data/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  ## Unreleased
2
2
 
3
+ ## 1.1.3 - 2026-08-09
4
+
5
+ - Quiesce destination workers before process forks and reject forks with live
6
+ Ractors instead of rebuilding unsafe inherited VM state.
7
+ - Serialize emit, close, and fork transitions; preserve terminal closure and
8
+ keep interrupted worker teardown retryable.
9
+ - Require julewire-core 1.1.3.
10
+ - Refresh development tooling and compatibility locksets.
11
+
3
12
  ## 1.1.2 - 2026-08-02
4
13
 
5
14
  - Refresh development and compatibility dependency locksets.
data/docs/bridge.md CHANGED
@@ -79,6 +79,8 @@ remote request timeout is one second. `timeout: nil` remains unbounded.
79
79
  - `Julewire.labels`
80
80
  - `Julewire.health`
81
81
  - `Julewire.close`
82
+ - `Julewire.before_fork!`
83
+ - `Julewire.after_fork!`
82
84
 
83
85
  These belong to the parent runtime.
84
86
 
@@ -166,9 +168,26 @@ code needs Julewire facade calls to reach the parent runtime.
166
168
 
167
169
  ## Forking
168
170
 
169
- Do not fork a process with live Julewire ractor bridges. After a process fork,
170
- core calls the ractor integration after-fork hook and clears inherited bridge
171
- thread health. Create new ractors in the worker process after fork.
171
+ CRuby cannot safely continue Ractor execution in a child forked while another
172
+ Ractor is alive. Rebuilding inherited handles in the child is therefore too
173
+ late.
174
+
175
+ Stop application work, then call `Julewire.before_fork!` before the actual
176
+ `Process.fork`. It flushes and stops Ractor destination workers without closing
177
+ their outputs. It rejects the fork while a `Julewire.ractor` bridge or any other
178
+ non-main Ractor remains active. Call `Julewire.after_fork!` in the child to start
179
+ fresh destination workers, and in the parent too when it must resume logging
180
+ between forks.
181
+
182
+ ```ruby
183
+ before_fork { Julewire.before_fork! }
184
+ on_worker_boot { Julewire.after_fork! }
185
+ ```
186
+
187
+ The application must prevent concurrent emits and new Ractor creation between
188
+ the pre-fork call and `Process.fork`; Julewire cannot make an external fork
189
+ atomic. Calling `after_fork!` in an unprepared child raises
190
+ `Julewire::UnsafeForkError` without touching inherited Ractor handles.
172
191
 
173
192
  ## Runtime Promise
174
193
 
@@ -32,6 +32,6 @@ Gem::Specification.new do |spec|
32
32
  spec.executables = []
33
33
  spec.require_paths = ["lib"]
34
34
 
35
- spec.add_dependency "julewire-core", ">= 1.1.0"
35
+ spec.add_dependency "julewire-core", ">= 1.1.3"
36
36
  spec.add_dependency "zeitwerk", ">= 2.8.1"
37
37
  end
@@ -48,6 +48,18 @@ module Julewire
48
48
 
49
49
  def after_fork! = Stats.after_fork!
50
50
 
51
+ def before_fork!
52
+ active_bridges = health.fetch(:active_threads)
53
+ if active_bridges.positive?
54
+ raise UnsafeForkError,
55
+ "cannot fork while #{active_bridges} Julewire ractor bridge thread(s) are active"
56
+ end
57
+
58
+ return if ::Ractor.count.eql?(1)
59
+
60
+ raise UnsafeForkError, "cannot fork while non-main Ractors are active"
61
+ end
62
+
51
63
  private
52
64
 
53
65
  def start_bridge(port:, runtime:, ractor: nil)
@@ -49,8 +49,8 @@ module Julewire
49
49
  ].freeze
50
50
  DEFAULT_MAX_QUEUE = 1024
51
51
  DEFAULT_REQUEST_TIMEOUT = 1
52
- WORKER_STOP_MESSAGE = { command: :close_worker }.freeze
53
- private_constant :COUNTER_KEYS, :QueueSlots, :WORKER_STOP_MESSAGE
52
+ WORKER_QUIESCE_MESSAGE = { command: :quiesce_worker }.freeze
53
+ private_constant :COUNTER_KEYS, :QueueSlots, :WORKER_QUIESCE_MESSAGE
54
54
 
55
55
  attr_reader :name
56
56
 
@@ -84,25 +84,23 @@ module Julewire
84
84
  @request_timeout = request_timeout
85
85
  @on_drop = on_drop
86
86
  @on_failure = on_failure
87
- initialize_tracking
87
+ @fork_lifecycle_mutex = Mutex.new
88
+ initialize_tracking(closed: false)
88
89
  start_worker
89
90
  end
90
91
 
91
92
  def emit(record)
92
93
  increment(:received)
93
- return drop(:closed_dropped, record) if closed?
94
- return drop(:queue_full_dropped, record) unless @queue_slots.reserve
95
-
96
- begin
97
- degradation_marker = @health.degradation_marker
98
- @port.send({ command: :emit, degradation_marker: degradation_marker, record: record })
99
- increment(:queued)
100
- rescue StandardError => e
101
- release_slot
102
- record_failure(e, phase: :ractor_send)
94
+ outcome = enqueue(record)
95
+ case outcome
96
+ when :closed
97
+ drop(:closed_dropped, record)
98
+ when :queue_full
99
+ drop(:queue_full_dropped, record)
100
+ when StandardError
101
+ record_failure(outcome, phase: :ractor_send)
103
102
  drop(:send_error, record)
104
103
  end
105
- nil
106
104
  end
107
105
 
108
106
  def flush(timeout: nil)
@@ -111,20 +109,61 @@ module Julewire
111
109
 
112
110
  def close(timeout: nil)
113
111
  timeout = lifecycle_timeout(timeout)
114
- @closed.set(true)
115
- result = request(:close, timeout: timeout, allow_closed: true)
116
- close_ports(timeout: timeout)
117
- result
112
+ @fork_lifecycle_mutex.synchronize do
113
+ @closed.set(true)
114
+ prepared_for_fork = @prepared_for_fork
115
+ @prepared_for_fork = false
116
+ unless @worker
117
+ @owned_output_close_pending ||= prepared_for_fork && @close_output
118
+ return true unless @owned_output_close_pending
119
+
120
+ begin
121
+ start_worker
122
+ rescue StandardError => e
123
+ record_failure(e, phase: :worker_start)
124
+ return false
125
+ end
126
+ end
127
+
128
+ result = request(:close, timeout: timeout, allow_closed: true)
129
+ begin
130
+ close_ports(timeout: timeout)
131
+ @owned_output_close_pending = false if result
132
+ result
133
+ rescue Core::Error => e
134
+ record_failure(e, phase: :worker_stop)
135
+ false
136
+ end
137
+ end
118
138
  end
119
139
 
120
- def after_fork!
121
- if (@process_id - Process.pid).zero? && !close_ports(timeout: @request_timeout)
122
- raise Core::Error, "ractor destination worker did not stop within #{@request_timeout} seconds"
140
+ def before_fork!(timeout: nil)
141
+ timeout = lifecycle_timeout(timeout)
142
+ @fork_lifecycle_mutex.synchronize do
143
+ validate_before_fork_process!
144
+ deadline = Core::Scheduling::Deadline.for(timeout)
145
+ remaining_timeout = -> { Core::Scheduling::Deadline.remaining(deadline) }
146
+ if closed?
147
+ close_ports(timeout: remaining_timeout.call) if @worker
148
+ return self
149
+ end
150
+
151
+ @closed.set(true)
152
+ flush_before_fork!(remaining_timeout.call)
153
+ stop_before_fork!(remaining_timeout.call)
123
154
  end
155
+ self
156
+ rescue StandardError => e
157
+ record_failure(e, phase: :before_fork)
158
+ raise
159
+ end
124
160
 
125
- initialize_tracking
126
- start_worker
161
+ def after_fork!
162
+ @fork_lifecycle_mutex.synchronize { resume_after_fork! }
127
163
  self
164
+ rescue UnsafeForkError => e
165
+ record_failure(e, phase: :after_fork)
166
+ raise
128
167
  rescue StandardError => e
129
168
  record_failure(e, phase: :after_fork)
130
169
  self
@@ -153,15 +192,75 @@ module Julewire
153
192
 
154
193
  private
155
194
 
195
+ def enqueue(record)
196
+ @fork_lifecycle_mutex.synchronize do
197
+ return :closed if closed?
198
+ return :queue_full unless @queue_slots.reserve
199
+
200
+ begin
201
+ @port.send(
202
+ { command: :emit, degradation_marker: @health.degradation_marker, record: record }
203
+ )
204
+ increment(:queued)
205
+ rescue StandardError => e
206
+ release_slot
207
+ e
208
+ end
209
+ end
210
+ end
211
+
212
+ def validate_before_fork_process!
213
+ return if @process_id == Process.pid
214
+ return unless @worker
215
+
216
+ raise UnsafeForkError,
217
+ "ractor destination was inherited without Julewire.before_fork! in the parent process"
218
+ end
219
+
220
+ def validate_after_fork_process!
221
+ return if @process_id == Process.pid
222
+ return unless @worker
223
+
224
+ raise UnsafeForkError,
225
+ "ractor destination was inherited without Julewire.before_fork! in the parent process"
226
+ end
227
+
228
+ def flush_before_fork!(timeout)
229
+ return if request(:flush, timeout: timeout, allow_closed: true)
230
+
231
+ @closed.set(false)
232
+ raise Core::Error, "ractor destination could not flush before fork"
233
+ end
234
+
235
+ def stop_before_fork!(timeout)
236
+ # Mark preparation before teardown so every failure remains recoverable
237
+ # through the paired after-fork rollback.
238
+ @prepared_for_fork = true
239
+ @port.send(WORKER_QUIESCE_MESSAGE)
240
+ close_ports(timeout: timeout)
241
+ end
242
+
243
+ def resume_after_fork!
244
+ validate_after_fork_process!
245
+ return unless @prepared_for_fork
246
+
247
+ # Quiesce is terminal and ordered after the completed flush. Replace
248
+ # any retained handles before starting the recovery worker.
249
+ initialize_tracking(closed: true)
250
+ start_worker
251
+ @closed.set(false)
252
+ @prepared_for_fork = false
253
+ end
254
+
156
255
  def validate_callable(callable, name:)
157
256
  Core::Validation.validate_callable!(callable, name: name)
158
257
  callable
159
258
  end
160
259
 
161
- def initialize_tracking
260
+ def initialize_tracking(closed:)
162
261
  @process_id = Process.pid
163
262
  @scheduler = ReplyTimeoutScheduler.new(timeout_value: false)
164
- @closed = Concurrent::AtomicReference.new
263
+ @closed = Concurrent::AtomicReference.new(closed)
165
264
  @health = Core::Integration::DestinationHealth.new(counter_keys: COUNTER_KEYS, failure_counter: nil)
166
265
  @queue_slots = QueueSlots.new(max_queue: @max_queue)
167
266
  @worker_health = Concurrent::AtomicReference.new
@@ -270,30 +369,20 @@ module Julewire
270
369
  def closed? = @closed.get
271
370
 
272
371
  def close_ports(timeout:)
273
- worker_stopped = true
274
372
  begin
275
- if @worker
276
- begin
277
- @port.send(WORKER_STOP_MESSAGE)
278
- rescue ::Ractor::ClosedError
279
- # A stopped worker no longer accepts commands. Still collect it
280
- # below so an abnormal exit remains observable.
281
- end
282
- worker_stopped = wait_for_worker(timeout)
283
- end
373
+ worker_stopped = wait_for_worker(timeout)
284
374
  rescue ::Ractor::RemoteError => e
285
375
  record_failure(e, phase: :worker_stop)
286
376
  worker_stopped = true
287
377
  ensure
288
- @ack_thread&.kill
289
- @ack_thread&.join
290
378
  PortLifecycle.close(@ack_port)
379
+ @ack_thread&.kill&.join
291
380
  @port = @worker = nil if worker_stopped
292
381
  @ack_port = nil
293
382
  @ack_thread = nil
294
383
  end
295
384
 
296
- worker_stopped
385
+ raise Core::Error, "ractor destination worker did not stop within #{timeout} seconds" unless worker_stopped
297
386
  end
298
387
 
299
388
  def wait_for_worker(timeout)
@@ -44,15 +44,21 @@ module Julewire
44
44
 
45
45
  def run(command_port:, ack_port:)
46
46
  @ack_port = ack_port
47
+ close_owned_output = true
47
48
  loop do
48
49
  message = command_port.receive
49
50
  current_command = command(message)
50
51
  break if current_command == :close_worker
51
52
 
53
+ if current_command == :quiesce_worker
54
+ close_owned_output = false
55
+ break
56
+ end
57
+
52
58
  break if dispatch(message, current_command) == :close
53
59
  end
54
60
  ensure
55
- close_output
61
+ close_output if close_owned_output
56
62
  end
57
63
 
58
64
  private
@@ -34,15 +34,28 @@ module Julewire
34
34
 
35
35
  def after_fork!
36
36
  @health.recover_if_successful do
37
- @destinations.each do |destination|
38
- destination.after_fork! if destination.respond_to?(:after_fork!)
39
- rescue StandardError => e
40
- record_failure(e, action: :after_fork, destination: destination.name)
41
- end
37
+ @destinations.each { call_destination_after_fork(it) }
42
38
  end
43
39
  self
44
40
  end
45
41
 
42
+ def before_fork!(timeout: nil)
43
+ prepared = []
44
+ Core::Validation.validate_timeout!(timeout, name: :timeout)
45
+ deadline = Core::Scheduling::Deadline.for(timeout)
46
+ @destinations.each do |destination|
47
+ next unless destination.respond_to?(:before_fork!)
48
+
49
+ prepared << destination
50
+ result = destination.before_fork!(timeout: Core::Scheduling::Deadline.remaining(deadline))
51
+ raise Core::Error, "destination #{destination.name} rejected before_fork" if result == false
52
+ end
53
+ self
54
+ rescue StandardError
55
+ prepared.reverse_each { call_destination_after_fork(it) }
56
+ raise
57
+ end
58
+
46
59
  def resource_identity = self
47
60
 
48
61
  def health
@@ -55,6 +68,14 @@ module Julewire
55
68
 
56
69
  private
57
70
 
71
+ def call_destination_after_fork(destination)
72
+ destination.after_fork! if destination.respond_to?(:after_fork!)
73
+ rescue Core::UnsafeForkError
74
+ raise
75
+ rescue StandardError => e
76
+ record_failure(e, action: :after_fork, destination: destination.name)
77
+ end
78
+
58
79
  def normalize_destination(value)
59
80
  destination = value.is_a?(Hash) ? Destination.new(**value) : value
60
81
  Core::Destinations::Registry.validate!(destination)
@@ -76,6 +76,10 @@ module Julewire
76
76
  raise Core::Error, "Julewire.after_fork! is not available inside Julewire.ractor"
77
77
  end
78
78
 
79
+ def before_fork!(**)
80
+ raise Core::Error, "Julewire.before_fork! is not available inside Julewire.ractor"
81
+ end
82
+
79
83
  def health
80
84
  raise Core::Error, "Julewire.health is not available inside Julewire.ractor"
81
85
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Julewire
4
4
  module Ractor
5
- VERSION = "1.1.2"
5
+ VERSION = "1.1.3"
6
6
  end
7
7
  end
@@ -38,6 +38,7 @@ module Julewire
38
38
  loader = Zeitwerk::Loader.for_gem_extension(self)
39
39
  loader.setup
40
40
  Core::Destinations.register(:ractor) { |name:, **options| Ractor::Destination.new(name: name, **options) }
41
+ Core::Integration::Lifecycle.register_before_fork(:ractor, component: :bridge) { Ractor::Bridge.before_fork! }
41
42
  Core::Integration::Lifecycle.register_after_fork(:ractor, component: :bridge) { Ractor::Bridge.after_fork! }
42
43
 
43
44
  class << self
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: julewire-ractor
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.2
4
+ version: 1.1.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alexander Grebennik
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: 1.1.0
18
+ version: 1.1.3
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
- version: 1.1.0
25
+ version: 1.1.3
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: zeitwerk
28
28
  requirement: !ruby/object:Gem::Requirement