julewire-ractor 1.0.0 → 1.1.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4d166b07720f0d37eb7b80d8bc7c3e5bcc3b00c83c14f2ce991e5931bbf0272b
4
- data.tar.gz: 89bcff708f1fd8c0a22843ce4c74a8e49d99f39bed8f82f328d0115ba20216e9
3
+ metadata.gz: a0b295ac53d6eea75e9313e6274e039cf88f0c6b9d990b3152035f7cd193b3cf
4
+ data.tar.gz: d3992f7083c8b612d59d8104dd46d76f58c94db851c7c4a8cfa090de45de8618
5
5
  SHA512:
6
- metadata.gz: 91bd26a0399339393e0c075ae1752180ae058b449af0563b4a044073d62f5506b86dca06b6e649cc50c024a9a289bc0ca2142aea51a703fc349e45e196d0a893
7
- data.tar.gz: 12dc746f9b15eeadc5c26f3566f83e042317632fee4fbed5281265a0f9ace8ce20ced5e1605d8a425158bcaf71de2b9ee985b8c8977766cbaf6101da128c486b
6
+ metadata.gz: 819d50753851ad531bb413f593173b6c4b6cd08fda9f5a1bfdfcd3f72d6c9ef12aebe980cbdde9bed2f9f7234865a39162b3e488b8ef05544f3703403900a65a
7
+ data.tar.gz: 6880dca6536397902cd131d0f9f2243336bde72df17ddbcbd89918f11429795d0eba5924acf58c651a3edcad341718bd2ccb154bf26f2313a85dcdfb3e415008
data/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  ## Unreleased
2
2
 
3
+ ## 1.1.0 - 2026-07-20
4
+
5
+ - Require finite request timeouts and bound destination worker startup and
6
+ shutdown so lifecycle operations cannot wait forever.
7
+ - Harden queue accounting, fanout validation, worker stats, and Ractor-safe
8
+ reply-timeout cleanup.
9
+ - Discard inherited Ractor handles before rebuilding destination workers in a
10
+ forked child process.
11
+ - Normalize application emit input before strict bridge serialization while
12
+ keeping integration-owned emits on a non-normalizing Symbol-key path.
13
+ - Require julewire-core 1.1.0.
14
+
15
+ ## 1.0.1 - 2026-06-25
16
+
17
+ - Reserve ractor destination queue slots before send and roll them back on
18
+ send failure.
19
+ - Count impossible queue-slot over-release events for destination health
20
+ debugging.
21
+ - Require julewire-core 1.0.1.
22
+
3
23
  ## 1.0.0 - 2026-06-21
4
24
 
5
25
  - Initial release: Ruby 4 ractor bridge, child-runtime forwarding, remote
data/docs/bridge.md CHANGED
@@ -29,11 +29,18 @@ Values are serialized before crossing the ractor boundary. Parent-side
29
29
  processors see log-safe scalar values rather than the child object's original
30
30
  identity or class.
31
31
 
32
+ The child public facade canonicalizes application input, including nested
33
+ String keys and unknown top-level payload fields, before strict bridge
34
+ serialization. Integration facade emits use a separate owned path: every key
35
+ must already be a Symbol and unknown record fields are rejected. The serializer
36
+ never decides which contract applies and never normalizes bridge data.
37
+
32
38
  The serialized envelope is a ractor concern. Core keeps only the narrow
33
39
  `emit_envelope` hook that accepts already-extracted input, context, carry, and
34
40
  a scope snapshot. Payload parsing and scope reconstruction stay in this gem.
35
- That hook is parent-runtime SPI; the child `RemoteRuntime` exposes the normal
36
- facade emit methods instead of a detached-envelope API.
41
+ That hook is parent-runtime SPI; the bridge marks ractor wire data as owned, and
42
+ the child `RemoteRuntime` exposes the normal facade emit methods instead of a
43
+ detached-envelope API.
37
44
 
38
45
  ## Available in Child
39
46
 
@@ -105,15 +112,28 @@ synchronously. The destination then sends each immutable record to a worker
105
112
  ractor. The worker owns formatter, encoder, byte-limit checks, and output
106
113
  writes.
107
114
 
115
+ All hashes exchanged between Julewire gems, the parent bridge, and worker
116
+ ractors use Symbol keys at every depth. This is an internal protocol, not an
117
+ input-normalization boundary: String keys and malformed or unknown commands are
118
+ protocol errors. They are raised and recorded at the owning bridge/worker
119
+ lifecycle boundary instead of being converted, ignored, or replaced with empty
120
+ values. String-key normalization remains limited to public application input
121
+ and decoded propagation carriers.
122
+
108
123
  The destination has a bounded parent-side in-flight queue. When `max_queue` is
109
124
  full, new records are dropped and counted in destination health. `flush` sends a
110
125
  request to the worker and waits for all earlier records to finish. `close`
111
126
  flushes or closes the worker-owned output and stops the worker.
112
127
 
113
- Unlike direct core destinations, ractor-backed destinations treat
114
- `flush(timeout: nil)` and `close(timeout: nil)` as the configured request timeout
115
- instead of an unbounded wait. Worker ractors can die or stop replying; the parent
116
- must not park forever while draining diagnostics.
128
+ Unlike direct core destinations, ractor-backed destinations require a finite
129
+ configured request timeout. `flush(timeout: nil)` and `close(timeout: nil)` use
130
+ that configured timeout instead of creating an unbounded wait. Worker ractors
131
+ can die or stop replying; the parent observes worker termination as well as the
132
+ reply and must not park forever while draining diagnostics.
133
+
134
+ Worker startup has a fixed one-second bound, independent of the request timeout.
135
+ A worker that does not hand back its command port raises
136
+ `Julewire::Core::Error` instead of blocking construction.
117
137
 
118
138
  Formatter, encoder, and output must be ractor-copyable or shareable. Avoid
119
139
  singleton-method/proc-backed output objects; plain class instances with
@@ -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.0"
35
+ spec.add_dependency "julewire-core", ">= 1.1.0"
36
36
  spec.add_dependency "zeitwerk", ">= 2.8.1"
37
37
  end
@@ -27,7 +27,7 @@ module Julewire
27
27
  rescue StandardError => e
28
28
  bridge_error = e
29
29
  warn_bridge_stopped(e)
30
- Julewire::Ractor::PortLifecycle.close(port)
30
+ PortLifecycle.close(port)
31
31
  break
32
32
  end
33
33
  ensure
@@ -35,7 +35,7 @@ module Julewire
35
35
  end
36
36
 
37
37
  def close_message?(message)
38
- message.is_a?(Hash) && message[:command] == :close
38
+ message.instance_of?(Hash) && message[:command] == :close
39
39
  end
40
40
 
41
41
  def monitor_message?(message)
@@ -40,6 +40,7 @@ module Julewire
40
40
  experimental: true,
41
41
  failure_count: @failure_count.value,
42
42
  last_error_class: @last_error.get&.fetch(:class),
43
+ last_error_message: @last_error.get&.fetch(:message),
43
44
  messages: @message_count.value,
44
45
  started_threads: @started_threads.value,
45
46
  stopped_threads: @stopped_threads.value
@@ -66,7 +67,7 @@ module Julewire
66
67
 
67
68
  def record_failure(error)
68
69
  @failure_count.increment
69
- @last_error.set({ class: error.class.name })
70
+ @last_error.set({ class: error.class.name, message: error.message })
70
71
  end
71
72
  end
72
73
  end
@@ -17,14 +17,14 @@ module Julewire
17
17
 
18
18
  def enabled? = ENABLED.get
19
19
 
20
- def spawn(args:, name:, runtime:, &)
20
+ def start(args:, name:, runtime:, &)
21
21
  unless enabled?
22
22
  raise Core::Error, "Julewire.ractor is experimental; call Julewire.enable_experimental_ractor! first"
23
23
  end
24
24
 
25
25
  RuntimeValidation.validate!(runtime)
26
26
 
27
- envelope = Core::Propagation.capture
27
+ envelope = Core::Propagation.capture_local
28
28
  body = ::Ractor.shareable_proc(&)
29
29
  port = ::Ractor::Port.new
30
30
  ractor = spawn_ractor(
@@ -58,19 +58,23 @@ module Julewire
58
58
  def monitor_ractor(ractor)
59
59
  return unless ractor
60
60
 
61
- ::Ractor::Port.new.tap { ractor.monitor(it) }
61
+ monitor_port = ::Ractor::Port.new
62
+ return unless monitor_port.instance_of?(::Ractor::Port)
63
+
64
+ ractor.monitor(monitor_port)
65
+ monitor_port
62
66
  rescue StandardError
63
67
  nil
64
68
  end
65
69
 
66
70
  def spawn_ractor(args:, name:, port:, envelope:, body:, emit_non_standard_exception_summaries:)
67
- # :nocov:
71
+ # simplecov:disable
68
72
  ::Ractor.new(port, envelope, body, emit_non_standard_exception_summaries, *args, name: name) do
69
73
  |bridge_port, captured_envelope, callable, emit_non_standard_summaries, *call_args|
70
- Julewire::Core::RuntimeLocator.current = Julewire::Ractor::RemoteRuntime.new(
74
+ Core::RuntimeLocator.current = RemoteRuntime.new(
71
75
  port: bridge_port, emit_non_standard_exception_summaries: emit_non_standard_summaries
72
76
  )
73
- Julewire::Core::Propagation.restore(captured_envelope) do
77
+ Core::Propagation.restore(captured_envelope, owned: true) do
74
78
  callable.call(*call_args)
75
79
  end
76
80
  ensure
@@ -81,12 +85,10 @@ module Julewire
81
85
  nil
82
86
  end
83
87
  end
84
- # :nocov:
88
+ # simplecov:enable
85
89
  end
86
90
 
87
91
  def handle_message(runtime, message)
88
- return unless message.is_a?(Hash)
89
-
90
92
  response = dispatch(runtime, message)
91
93
  reply_to(message, response)
92
94
  rescue StandardError => e
@@ -95,7 +97,9 @@ module Julewire
95
97
  end
96
98
 
97
99
  def dispatch(runtime, message)
98
- case message[:command]
100
+ validate_message!(message)
101
+ command = message.fetch(:command)
102
+ case command
99
103
  when :emit
100
104
  dispatch_emit(runtime, message, enforce_level: true)
101
105
  when :emit_without_level
@@ -105,7 +109,9 @@ module Julewire
105
109
  RemoteSummaryRecord.new(RemotePayload.hash_value(message, :payload))
106
110
  )
107
111
  when :flush
108
- runtime.flush(timeout: message.dig(:payload, :timeout))
112
+ runtime.flush(timeout: message.fetch(:payload).fetch(:timeout))
113
+ else
114
+ raise ArgumentError, "unknown ractor bridge command: #{command.inspect}"
109
115
  end
110
116
  end
111
117
 
@@ -113,12 +119,18 @@ module Julewire
113
119
  payload = RemotePayload.hash_value(message, :payload)
114
120
  arguments = RemotePayload.extract(payload)
115
121
  arguments[:enforce_level] = false unless enforce_level
116
- runtime.emit_envelope(**arguments)
122
+ runtime.emit_envelope(**arguments, owned: true)
117
123
  end
118
124
 
119
125
  def reply_to(message, response)
126
+ return unless message.is_a?(Hash)
127
+
120
128
  reply = message[:reply]
121
- reply.send(response) if reply_port?(reply)
129
+ return unless reply
130
+
131
+ raise TypeError, "ractor bridge reply must be a Ractor::Port" unless reply_port?(reply)
132
+
133
+ reply.send(response)
122
134
  rescue StandardError => e
123
135
  Stats.message_failed(e)
124
136
  nil
@@ -127,6 +139,10 @@ module Julewire
127
139
  def reply_port?(reply)
128
140
  reply.is_a?(::Ractor::Port)
129
141
  end
142
+
143
+ def validate_message!(message)
144
+ Core::Integration::Protocol.validate_symbol_hash(message)
145
+ end
130
146
  end
131
147
  end
132
148
  end
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "concurrent/atomic/atomic_fixnum"
4
+ require "concurrent/atomic/atomic_reference"
5
+
3
6
  module Julewire
4
7
  module Ractor
5
8
  class ChildStats
@@ -13,8 +16,8 @@ module Julewire
13
16
  private_constant :COUNTER_KEYS
14
17
 
15
18
  def initialize
16
- @mutex = Mutex.new
17
- @counters = COUNTER_KEYS.to_h { [it, 0] }
19
+ @counters = COUNTER_KEYS.to_h { [it, Concurrent::AtomicFixnum.new] }
20
+ @last_error_class = Concurrent::AtomicReference.new
18
21
  end
19
22
 
20
23
  def message_sent = increment(:messages_sent)
@@ -32,34 +35,27 @@ module Julewire
32
35
  def request_timed_out = increment(:requests_timed_out)
33
36
 
34
37
  def reset!
35
- @mutex.synchronize do
36
- @counters.each_key { @counters[it] = 0 }
37
- @last_error_class = nil
38
- end
39
- nil
38
+ @counters.each_value { it.value = 0 }
39
+ @last_error_class.set(nil)
40
40
  end
41
41
 
42
42
  def to_h
43
- @mutex.synchronize do
44
- {
45
- counts: @counters.dup.freeze,
46
- last_error_class: @last_error_class
47
- }.compact.freeze
48
- end
43
+ {
44
+ counts: @counters.transform_values(&:value).freeze,
45
+ last_error_class: @last_error_class.get
46
+ }.compact.freeze
49
47
  end
50
48
 
51
49
  private
52
50
 
53
51
  def increment(key)
54
- @mutex.synchronize { @counters[key] += 1 }
52
+ @counters.fetch(key).increment
55
53
  nil
56
54
  end
57
55
 
58
56
  def record_error(key, error)
59
- @mutex.synchronize do
60
- @counters[key] += 1
61
- @last_error_class = error.class.name
62
- end
57
+ @counters.fetch(key).increment
58
+ @last_error_class.set(error.class.name)
63
59
  nil
64
60
  end
65
61
  end