julewire-ractor 1.0.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 4d166b07720f0d37eb7b80d8bc7c3e5bcc3b00c83c14f2ce991e5931bbf0272b
4
+ data.tar.gz: 89bcff708f1fd8c0a22843ce4c74a8e49d99f39bed8f82f328d0115ba20216e9
5
+ SHA512:
6
+ metadata.gz: 91bd26a0399339393e0c075ae1752180ae058b449af0563b4a044073d62f5506b86dca06b6e649cc50c024a9a289bc0ca2142aea51a703fc349e45e196d0a893
7
+ data.tar.gz: 12dc746f9b15eeadc5c26f3566f83e042317632fee4fbed5281265a0f9ace8ce20ced5e1605d8a425158bcaf71de2b9ee985b8c8977766cbaf6101da128c486b
data/CHANGELOG.md ADDED
@@ -0,0 +1,6 @@
1
+ ## Unreleased
2
+
3
+ ## 1.0.0 - 2026-06-21
4
+
5
+ - Initial release: Ruby 4 ractor bridge, child-runtime forwarding, remote
6
+ summaries, fanout, and ractor destination workers.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Alexander Grebennik
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,62 @@
1
+ # Julewire Ractor
2
+
3
+ `julewire-ractor` is the experimental Ractor bridge for Julewire.
4
+
5
+ It requires Ruby 4.0 or newer. Code inside `Julewire.ractor` can emit through
6
+ the parent runtime while the parent keeps configuration, processors,
7
+ destinations, outputs, and labels authoritative.
8
+
9
+ ## Install
10
+
11
+ ```ruby
12
+ gem "julewire-ractor"
13
+ ```
14
+
15
+ ## Quickstart
16
+
17
+ ```ruby
18
+ Julewire.configure do |config|
19
+ config.destinations.use(:default, output: $stdout)
20
+ end
21
+
22
+ Julewire.enable_experimental_ractor!
23
+
24
+ ractor = Julewire.ractor do
25
+ Julewire.emit(message: "from ractor")
26
+ Julewire.flush
27
+ end
28
+
29
+ ractor.value
30
+ ```
31
+
32
+ This is best-effort logging infrastructure, not durable transport.
33
+
34
+ Child-side send failures are visible from inside the child:
35
+
36
+ ```ruby
37
+ Julewire.ractor do
38
+ Julewire.emit(message: "from ractor")
39
+ Julewire::Ractor.child_stats
40
+ end.value
41
+ ```
42
+
43
+ For CPU-heavy formatting/encoding experiments, the gem can make the normal
44
+ `:default` destination kind ractor-backed. The parent pipeline stays synchronous
45
+ up to the immutable record boundary, then the worker ractor owns formatter,
46
+ encoder, and output:
47
+
48
+ ```ruby
49
+ Julewire::Ractor.enable_default_destination_workers!
50
+
51
+ Julewire.configure do |config|
52
+ config.destinations.use(:default, output: MyRactorCopyableOutput.new)
53
+ end
54
+ ```
55
+
56
+ Use the explicit `:ractor` kind when a second ractor-backed destination should
57
+ sit beside another destination. Formatter, encoder, and output must be
58
+ ractor-copyable or shareable under Ruby's Ractor rules.
59
+
60
+ ## Docs
61
+
62
+ - [Bridge](docs/bridge.md)
data/docs/bridge.md ADDED
@@ -0,0 +1,159 @@
1
+ # Bridge
2
+
3
+ `julewire-ractor` adds two facade methods:
4
+
5
+ - `Julewire.enable_experimental_ractor!`
6
+ - `Julewire.ractor`
7
+
8
+ The opt-in is deliberate. Ractor support is Ruby-version-sensitive, bridge
9
+ emits use an unbounded port queue, and the bridge is outside the stable core
10
+ application API. Core documents the narrow Bridge SPI used by this gem.
11
+
12
+ ## Flow
13
+
14
+ ```text
15
+ child ractor emit
16
+ -> remote runtime serializes input, context, carry, and scope
17
+ -> Ractor::Port message
18
+ -> parent bridge thread
19
+ -> bridge reconstructs a core scope snapshot
20
+ -> parent runtime emit_envelope
21
+ -> core processors, destinations, formatter, encoder, output
22
+ ```
23
+
24
+ The parent runtime owns all policy. The child does not install configuration,
25
+ outputs, processors, labels, or event definitions. Parent static labels still
26
+ apply to records emitted by the child.
27
+
28
+ Values are serialized before crossing the ractor boundary. Parent-side
29
+ processors see log-safe scalar values rather than the child object's original
30
+ identity or class.
31
+
32
+ The serialized envelope is a ractor concern. Core keeps only the narrow
33
+ `emit_envelope` hook that accepts already-extracted input, context, carry, and
34
+ 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.
37
+
38
+ ## Available in Child
39
+
40
+ `Julewire.emit` sends a fire-and-forget message to the parent bridge.
41
+ Child-side emits serialize and cross the ractor port before the parent runtime
42
+ applies its level threshold.
43
+
44
+ Child-side send loss is visible inside the child runtime:
45
+
46
+ ```ruby
47
+ Julewire::Ractor.child_stats
48
+ Julewire::Ractor.reset_child_stats!
49
+ ```
50
+
51
+ These counters cover child-to-parent port sends, lifecycle requests, request
52
+ timeouts, and the last local send error class. Parent bridge health still lives
53
+ at `Julewire::Ractor.health`.
54
+
55
+ Integration code can call `RuntimeLocator.current.emit_without_level` inside
56
+ the child when it has already applied its own level gate.
57
+
58
+ `Julewire.with_execution`, `Julewire.context`, `Julewire.carry`,
59
+ `Julewire.attributes`, and `Julewire.summary` work against the child-local
60
+ context store. Summary records are sent to the parent when execution scopes
61
+ finish.
62
+
63
+ `Julewire.flush` sends a request/reply message to the parent bridge. The default
64
+ remote request timeout is one second. `timeout: nil` remains unbounded.
65
+
66
+ `Julewire.reset!` clears only the child-local context store.
67
+
68
+ ## Parent Only
69
+
70
+ - `Julewire.configure`
71
+ - `Julewire.config`
72
+ - `Julewire.labels`
73
+ - `Julewire.health`
74
+ - `Julewire.close`
75
+
76
+ These belong to the parent runtime.
77
+
78
+ ## Health
79
+
80
+ Bridge health is available through:
81
+
82
+ ```ruby
83
+ Julewire::Ractor.health
84
+ ```
85
+
86
+ It reports bridge thread counts, received message count, and the last bridge
87
+ thread error class. It is diagnostic state, not a delivery guarantee.
88
+
89
+ ## Ractor Destination
90
+
91
+ `julewire-ractor` can make the normal `:default` destination kind ractor-backed:
92
+
93
+ ```ruby
94
+ Julewire::Ractor.enable_default_destination_workers!
95
+
96
+ Julewire.configure do |config|
97
+ config.destinations.use(:default, output: MyRactorCopyableOutput.new)
98
+ end
99
+ ```
100
+
101
+ It also registers `:ractor` for additional ractor-backed destinations.
102
+
103
+ The parent pipeline still normalizes, processes, and freezes records
104
+ synchronously. The destination then sends each immutable record to a worker
105
+ ractor. The worker owns formatter, encoder, byte-limit checks, and output
106
+ writes.
107
+
108
+ The destination has a bounded parent-side in-flight queue. When `max_queue` is
109
+ full, new records are dropped and counted in destination health. `flush` sends a
110
+ request to the worker and waits for all earlier records to finish. `close`
111
+ flushes or closes the worker-owned output and stops the worker.
112
+
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.
117
+
118
+ Formatter, encoder, and output must be ractor-copyable or shareable. Avoid
119
+ singleton-method/proc-backed output objects; plain class instances with
120
+ ractor-safe state are the intended shape.
121
+ Record payload values sent through `Julewire::Ractor::Destination` must also be
122
+ ractor-copyable or shareable. Non-copyable values are dropped at the parent-side
123
+ send boundary and counted as `send_error`.
124
+
125
+ Use `Julewire::Ractor::Fanout` when one core destination should fan out to
126
+ multiple ractor-backed destination workers:
127
+
128
+ ```ruby
129
+ config.destinations.add(
130
+ Julewire::Ractor.fanout(
131
+ destinations: [
132
+ { name: :stdout, output: $stdout },
133
+ { name: :audit, output: audit_io }
134
+ ]
135
+ )
136
+ )
137
+ ```
138
+
139
+ The parent pipeline still processes once. Each fanout child formats, encodes,
140
+ and writes in its own destination worker.
141
+
142
+ ## Raw Ractors
143
+
144
+ Raw `Ractor.new` does not install the bridge. Use `Julewire.ractor` when child
145
+ code needs Julewire facade calls to reach the parent runtime.
146
+
147
+ ## Forking
148
+
149
+ Do not fork a process with live Julewire ractor bridges. After a process fork,
150
+ core calls the ractor integration after-fork hook and clears inherited bridge
151
+ thread health. Create new ractors in the worker process after fork.
152
+
153
+ ## Runtime Promise
154
+
155
+ Inside `Julewire.ractor`, `Julewire.emit` is best-effort fire-and-forget. Use
156
+ `Julewire.flush` when the child wants to ask the parent bridge to drain.
157
+
158
+ The bridge uses Ruby's ractor message-passing model and an unbounded
159
+ `Ractor::Port`. It should not be used as a reliable inter-ractor queue.
@@ -0,0 +1,42 @@
1
+ # Development
2
+
3
+ Install dependencies:
4
+
5
+ ```sh
6
+ bundle install
7
+ ```
8
+
9
+ Run the default checks:
10
+
11
+ ```sh
12
+ bundle exec rake
13
+ ```
14
+
15
+ The default task runs:
16
+
17
+ - Minitest
18
+ - RuboCop
19
+ - Flay
20
+ - Debride
21
+ - Bundler Audit
22
+
23
+ ## Coverage
24
+
25
+ Tests can run with SimpleCov:
26
+
27
+ ```sh
28
+ COVERAGE=true bundle exec rake test
29
+ ```
30
+
31
+ The gem supports Ruby 4.0 and newer. Tests exercise the Ruby 4.0
32
+ `Ractor::Port` bridge directly.
33
+
34
+ ## Lockfile
35
+
36
+ `Gemfile.lock` is committed for reproducible local development and CI. Runtime
37
+ dependencies still belong in the gemspec.
38
+
39
+ ## Packaging Notes
40
+
41
+ The gemspec is the runtime dependency contract. `Gemfile` is for development
42
+ tooling and the local path dependency on `julewire-core`.
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/julewire/ractor/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "julewire-ractor"
7
+ spec.version = Julewire::Ractor::VERSION
8
+ spec.authors = ["Alexander Grebennik"]
9
+ spec.email = ["slbug@users.noreply.github.com", "sl.bug.sl@gmail.com"]
10
+
11
+ spec.summary = "Experimental Ractor bridge for julewire-core."
12
+ spec.description = "Opt-in Ractor bridge that forwards records from worker ractors to a parent Julewire runtime."
13
+ spec.homepage = "https://github.com/slbug/julewire"
14
+ spec.license = "MIT"
15
+ spec.required_ruby_version = ">= 4.0"
16
+ spec.metadata["homepage_uri"] = spec.homepage
17
+ spec.metadata["source_code_uri"] = "https://github.com/slbug/julewire/tree/main/gems/ractor"
18
+ spec.metadata["changelog_uri"] = "https://github.com/slbug/julewire/blob/main/gems/ractor/CHANGELOG.md"
19
+
20
+ spec.metadata["rubygems_mfa_required"] = "true"
21
+
22
+ spec.files = Dir.chdir(__dir__) do
23
+ Dir[
24
+ "CHANGELOG.md",
25
+ "LICENSE.txt",
26
+ "README.md",
27
+ "docs/**/*.md",
28
+ "julewire-ractor.gemspec",
29
+ "lib/**/*.rb"
30
+ ]
31
+ end
32
+ spec.executables = []
33
+ spec.require_paths = ["lib"]
34
+
35
+ spec.add_dependency "julewire-core", ">= 1.0"
36
+ spec.add_dependency "zeitwerk", ">= 2.8.1"
37
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Julewire
4
+ module Ractor
5
+ module Bridge
6
+ module BridgeThread
7
+ THREAD_NAME = "julewire-ractor-bridge"
8
+ MONITOR_MESSAGES = %i[aborted exited].freeze
9
+
10
+ class << self
11
+ def start(port:, monitor_port: nil, &handler)
12
+ Thread.new { run(port: port, monitor_port: monitor_port, handler: handler) }.tap do |thread|
13
+ thread.name = THREAD_NAME
14
+ thread.report_on_exception = true
15
+ end
16
+ end
17
+
18
+ def run(port:, handler:, monitor_port: nil)
19
+ bridge_error = nil
20
+ Stats.bridge_started
21
+ loop do
22
+ message = receive_message(port, monitor_port)
23
+ Stats.message_received
24
+ break if close_message?(message) || monitor_message?(message)
25
+
26
+ handler.call(message)
27
+ rescue StandardError => e
28
+ bridge_error = e
29
+ warn_bridge_stopped(e)
30
+ Julewire::Ractor::PortLifecycle.close(port)
31
+ break
32
+ end
33
+ ensure
34
+ Stats.bridge_stopped(bridge_error)
35
+ end
36
+
37
+ def close_message?(message)
38
+ message.is_a?(Hash) && message[:command] == :close
39
+ end
40
+
41
+ def monitor_message?(message)
42
+ MONITOR_MESSAGES.include?(message)
43
+ end
44
+
45
+ def receive_message(port, monitor_port)
46
+ return port.receive unless monitor_port
47
+
48
+ _selected_port, message = ::Ractor.select(port, monitor_port)
49
+ message
50
+ end
51
+
52
+ def warn_bridge_stopped(error)
53
+ Warning.warn("julewire ractor bridge stopped: #{error.class}\n")
54
+ rescue StandardError
55
+ nil
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Julewire
4
+ module Ractor
5
+ module Bridge
6
+ module RuntimeValidation
7
+ REQUIRED_METHODS = %i[
8
+ emit_envelope
9
+ emit_summary_record
10
+ flush
11
+ ].freeze
12
+
13
+ class << self
14
+ def validate!(runtime)
15
+ missing = REQUIRED_METHODS.reject { runtime.respond_to?(it) }
16
+ return if missing.empty?
17
+
18
+ raise ArgumentError, "Julewire.ractor requires a bridge-compatible runtime " \
19
+ "(missing: #{missing.join(", ")})"
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent/atomic/atomic_fixnum"
4
+ require "concurrent/atomic/atomic_reference"
5
+
6
+ module Julewire
7
+ module Ractor
8
+ module Bridge
9
+ module Stats
10
+ @active_threads = Concurrent::AtomicFixnum.new(0)
11
+ @failure_count = Concurrent::AtomicFixnum.new(0)
12
+ @last_error = Concurrent::AtomicReference.new
13
+ @message_count = Concurrent::AtomicFixnum.new(0)
14
+ @started_threads = Concurrent::AtomicFixnum.new(0)
15
+ @stopped_threads = Concurrent::AtomicFixnum.new(0)
16
+
17
+ class << self
18
+ def bridge_started
19
+ @active_threads.increment
20
+ @started_threads.increment
21
+ end
22
+
23
+ def bridge_stopped(error = nil)
24
+ @active_threads.decrement if @active_threads.value.positive?
25
+ @stopped_threads.increment
26
+ record_failure(error) if error
27
+ end
28
+
29
+ def message_received
30
+ @message_count.increment
31
+ end
32
+
33
+ def message_failed(error)
34
+ record_failure(error)
35
+ end
36
+
37
+ def health
38
+ {
39
+ active_threads: @active_threads.value,
40
+ experimental: true,
41
+ failure_count: @failure_count.value,
42
+ last_error_class: @last_error.get&.fetch(:class),
43
+ messages: @message_count.value,
44
+ started_threads: @started_threads.value,
45
+ stopped_threads: @stopped_threads.value
46
+ }.compact
47
+ end
48
+
49
+ def reset!
50
+ # Active bridge threads are live state; reset history without
51
+ # forcing a running bridge to later decrement a cleared counter.
52
+ @failure_count.value = 0
53
+ @last_error.set(nil)
54
+ @message_count.value = 0
55
+ @started_threads.value = 0
56
+ @stopped_threads.value = 0
57
+ nil
58
+ end
59
+
60
+ def after_fork!
61
+ @active_threads.value = 0
62
+ reset!
63
+ end
64
+
65
+ private
66
+
67
+ def record_failure(error)
68
+ @failure_count.increment
69
+ @last_error.set({ class: error.class.name })
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent/atomic/atomic_reference"
4
+
5
+ module Julewire
6
+ module Ractor
7
+ # @api internal
8
+ # Experimental bridge that forwards ractor records back to a parent runtime.
9
+ module Bridge
10
+ ENABLED = Concurrent::AtomicReference.new(false)
11
+ private_constant :ENABLED
12
+
13
+ class << self
14
+ def opt_in!
15
+ ENABLED.set(true)
16
+ end
17
+
18
+ def enabled? = ENABLED.get
19
+
20
+ def spawn(args:, name:, runtime:, &)
21
+ unless enabled?
22
+ raise Core::Error, "Julewire.ractor is experimental; call Julewire.enable_experimental_ractor! first"
23
+ end
24
+
25
+ RuntimeValidation.validate!(runtime)
26
+
27
+ envelope = Core::Propagation.capture
28
+ body = ::Ractor.shareable_proc(&)
29
+ port = ::Ractor::Port.new
30
+ ractor = spawn_ractor(
31
+ args: args,
32
+ name: name,
33
+ port: port,
34
+ envelope: envelope,
35
+ body: body,
36
+ emit_non_standard_exception_summaries: runtime.config.emit_non_standard_exception_summaries
37
+ )
38
+ start_bridge(port: port, runtime: runtime, ractor: ractor)
39
+ ractor
40
+ end
41
+
42
+ def health = Stats.health
43
+
44
+ def reset!
45
+ ENABLED.set(false)
46
+ Stats.reset!
47
+ end
48
+
49
+ def after_fork! = Stats.after_fork!
50
+
51
+ private
52
+
53
+ def start_bridge(port:, runtime:, ractor: nil)
54
+ monitor_port = monitor_ractor(ractor)
55
+ BridgeThread.start(port: port, monitor_port: monitor_port) { handle_message(runtime, it) }
56
+ end
57
+
58
+ def monitor_ractor(ractor)
59
+ return unless ractor
60
+
61
+ ::Ractor::Port.new.tap { ractor.monitor(it) }
62
+ rescue StandardError
63
+ nil
64
+ end
65
+
66
+ def spawn_ractor(args:, name:, port:, envelope:, body:, emit_non_standard_exception_summaries:)
67
+ # :nocov:
68
+ ::Ractor.new(port, envelope, body, emit_non_standard_exception_summaries, *args, name: name) do
69
+ |bridge_port, captured_envelope, callable, emit_non_standard_summaries, *call_args|
70
+ Julewire::Core::RuntimeLocator.current = Julewire::Ractor::RemoteRuntime.new(
71
+ port: bridge_port, emit_non_standard_exception_summaries: emit_non_standard_summaries
72
+ )
73
+ Julewire::Core::Propagation.restore(captured_envelope) do
74
+ callable.call(*call_args)
75
+ end
76
+ ensure
77
+ # Tell the bridge thread to exit even when the child body raises.
78
+ begin
79
+ bridge_port.send({ command: :close })
80
+ rescue StandardError
81
+ nil
82
+ end
83
+ end
84
+ # :nocov:
85
+ end
86
+
87
+ def handle_message(runtime, message)
88
+ return unless message.is_a?(Hash)
89
+
90
+ response = dispatch(runtime, message)
91
+ reply_to(message, response)
92
+ rescue StandardError => e
93
+ Stats.message_failed(e)
94
+ reply_to(message, nil)
95
+ end
96
+
97
+ def dispatch(runtime, message)
98
+ case message[:command]
99
+ when :emit
100
+ dispatch_emit(runtime, message, enforce_level: true)
101
+ when :emit_without_level
102
+ dispatch_emit(runtime, message, enforce_level: false)
103
+ when :emit_record
104
+ runtime.emit_summary_record(
105
+ RemoteSummaryRecord.new(RemotePayload.hash_value(message, :payload))
106
+ )
107
+ when :flush
108
+ runtime.flush(timeout: message.dig(:payload, :timeout))
109
+ end
110
+ end
111
+
112
+ def dispatch_emit(runtime, message, enforce_level:)
113
+ payload = RemotePayload.hash_value(message, :payload)
114
+ arguments = RemotePayload.extract(payload)
115
+ arguments[:enforce_level] = false unless enforce_level
116
+ runtime.emit_envelope(**arguments)
117
+ end
118
+
119
+ def reply_to(message, response)
120
+ reply = message[:reply]
121
+ reply.send(response) if reply_port?(reply)
122
+ rescue StandardError => e
123
+ Stats.message_failed(e)
124
+ nil
125
+ end
126
+
127
+ def reply_port?(reply)
128
+ reply.is_a?(::Ractor::Port)
129
+ end
130
+ end
131
+ end
132
+ end
133
+ end