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 +4 -4
- data/CHANGELOG.md +9 -0
- data/docs/bridge.md +22 -3
- data/julewire-ractor.gemspec +1 -1
- data/lib/julewire/ractor/bridge.rb +12 -0
- data/lib/julewire/ractor/destination.rb +127 -38
- data/lib/julewire/ractor/destination_worker.rb +7 -1
- data/lib/julewire/ractor/fanout.rb +26 -5
- data/lib/julewire/ractor/remote_runtime.rb +4 -0
- data/lib/julewire/ractor/version.rb +1 -1
- data/lib/julewire/ractor.rb +1 -0
- metadata +3 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bdf4b664cc26510a153647b6041ba3d01804efda4b89eed4880194d9e81b82cd
|
|
4
|
+
data.tar.gz: 7660eff913795f731f31d508e2050320abf1842c967fdccb699c1c1620566a89
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
|
data/julewire-ractor.gemspec
CHANGED
|
@@ -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
|
-
|
|
53
|
-
private_constant :COUNTER_KEYS, :QueueSlots, :
|
|
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
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
@
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
126
|
-
|
|
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
|
-
|
|
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
|
|
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
|
data/lib/julewire/ractor.rb
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
25
|
+
version: 1.1.3
|
|
26
26
|
- !ruby/object:Gem::Dependency
|
|
27
27
|
name: zeitwerk
|
|
28
28
|
requirement: !ruby/object:Gem::Requirement
|