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.
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Julewire
4
+ module Ractor
5
+ class ChildStats
6
+ COUNTER_KEYS = %i[
7
+ messages_dropped
8
+ messages_sent
9
+ requests_failed
10
+ requests_sent
11
+ requests_timed_out
12
+ ].freeze
13
+ private_constant :COUNTER_KEYS
14
+
15
+ def initialize
16
+ @mutex = Mutex.new
17
+ @counters = COUNTER_KEYS.to_h { [it, 0] }
18
+ end
19
+
20
+ def message_sent = increment(:messages_sent)
21
+
22
+ def message_dropped(error)
23
+ record_error(:messages_dropped, error)
24
+ end
25
+
26
+ def request_sent = increment(:requests_sent)
27
+
28
+ def request_failed(error)
29
+ record_error(:requests_failed, error)
30
+ end
31
+
32
+ def request_timed_out = increment(:requests_timed_out)
33
+
34
+ def reset!
35
+ @mutex.synchronize do
36
+ @counters.each_key { @counters[it] = 0 }
37
+ @last_error_class = nil
38
+ end
39
+ nil
40
+ end
41
+
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
49
+ end
50
+
51
+ private
52
+
53
+ def increment(key)
54
+ @mutex.synchronize { @counters[key] += 1 }
55
+ nil
56
+ end
57
+
58
+ def record_error(key, error)
59
+ @mutex.synchronize do
60
+ @counters[key] += 1
61
+ @last_error_class = error.class.name
62
+ end
63
+ nil
64
+ end
65
+ end
66
+
67
+ private_constant :ChildStats
68
+ end
69
+ end
@@ -0,0 +1,289 @@
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
+ class Destination # rubocop:disable Metrics/ClassLength -- Owns parent queue, worker lifecycle, and health.
9
+ COUNTER_KEYS = %i[
10
+ closed_dropped
11
+ queue_full_dropped
12
+ queued
13
+ received
14
+ send_error
15
+ worker_accepted
16
+ worker_dropped
17
+ ].freeze
18
+ DEFAULT_MAX_QUEUE = 1024
19
+ DEFAULT_REQUEST_TIMEOUT = 1
20
+ TIMEOUT_THREAD_NAME = "julewire-ractor-destination-timeout"
21
+ private_constant :COUNTER_KEYS
22
+
23
+ attr_reader :name
24
+
25
+ def initialize( # rubocop:disable Metrics/ParameterLists -- Destination setup mirrors core destination knobs.
26
+ output:,
27
+ name: :ractor,
28
+ formatter: Julewire::RecordFormatter.new,
29
+ encoder: Julewire::JsonEncoder.new,
30
+ max_record_bytes: Core::DEFAULT_MAX_RECORD_BYTES,
31
+ max_queue: DEFAULT_MAX_QUEUE,
32
+ close_output: false,
33
+ request_timeout: DEFAULT_REQUEST_TIMEOUT,
34
+ on_drop: nil,
35
+ on_failure: nil
36
+ )
37
+ @name = Core::Destinations.normalize_name(name)
38
+ @formatter = validate_callable(formatter, name: :formatter)
39
+ @encoder = validate_callable(encoder, name: :encoder)
40
+ Core::Destinations::Sink.validate_writeable!(output)
41
+ Core::Validation.validate_byte_limit!(max_record_bytes, name: :max_record_bytes)
42
+ Core::Validation.validate_non_negative_integer!(max_queue, name: :max_queue)
43
+ Core::Validation.validate_timeout!(request_timeout, name: :request_timeout)
44
+ Core::Validation.validate_callable!(on_drop, name: :on_drop, allow_nil: true)
45
+ Core::Validation.validate_callable!(on_failure, name: :on_failure, allow_nil: true)
46
+ @output = output
47
+ @max_record_bytes = max_record_bytes
48
+ @max_queue = max_queue
49
+ @close_output = close_output
50
+ @request_timeout = request_timeout
51
+ @on_drop = on_drop
52
+ @on_failure = on_failure
53
+ @scheduler = Core::Scheduling::DeadlineScheduler.new(thread_name: TIMEOUT_THREAD_NAME, idle: :exit)
54
+ initialize_tracking
55
+ start_worker
56
+ end
57
+
58
+ def emit(record)
59
+ increment(:received)
60
+ return drop(:closed_dropped, record) if closed?
61
+ return drop(:queue_full_dropped, record) if queue_full?
62
+
63
+ @port.send({ command: :emit, record: record })
64
+ @in_flight.increment
65
+ increment(:queued)
66
+ nil
67
+ rescue StandardError => e
68
+ record_failure(e, phase: :ractor_send)
69
+ drop(:send_error, record)
70
+ end
71
+
72
+ def flush(timeout: nil)
73
+ timeout = @request_timeout if timeout.nil?
74
+ request(:flush, timeout: timeout)
75
+ end
76
+
77
+ def close(timeout: nil)
78
+ timeout = @request_timeout if timeout.nil?
79
+ @closed.set(true)
80
+ result = request(:close, timeout: timeout)
81
+ close_ports
82
+ result
83
+ end
84
+
85
+ def after_fork!
86
+ close_ports
87
+ @scheduler.after_fork!
88
+ initialize_tracking
89
+ start_worker
90
+ self
91
+ rescue StandardError => e
92
+ record_failure(e, phase: :after_fork)
93
+ self
94
+ end
95
+
96
+ def resource_identity = self
97
+
98
+ def health
99
+ worker = request(:health, timeout: @request_timeout)
100
+ worker = @worker_health.get unless worker.is_a?(Hash)
101
+ @worker_health.set(worker) if worker.is_a?(Hash)
102
+
103
+ @health.snapshot(
104
+ in_flight: @in_flight.value,
105
+ max_queue: @max_queue,
106
+ status: status_for(worker),
107
+ worker: worker
108
+ )
109
+ end
110
+
111
+ private
112
+
113
+ def validate_callable(callable, name:)
114
+ Core::Validation.validate_callable!(callable, name: name)
115
+ callable
116
+ end
117
+
118
+ def initialize_tracking
119
+ @closed = Concurrent::AtomicReference.new(false)
120
+ @health = Core::Integration::DestinationHealth.new(counter_keys: COUNTER_KEYS, failure_counter: nil)
121
+ @in_flight = Concurrent::AtomicFixnum.new(0)
122
+ @worker_health = Concurrent::AtomicReference.new
123
+ end
124
+
125
+ def start_worker
126
+ @ack_port = ::Ractor::Port.new
127
+ setup_port = ::Ractor::Port.new
128
+ @worker = spawn_worker(setup_port)
129
+ @port = receive_worker_port(setup_port)
130
+ @ack_thread = start_ack_thread
131
+ rescue StandardError => e
132
+ raise ArgumentError, "ractor destination collaborators must be ractor-copyable or shareable: #{e.message}"
133
+ ensure
134
+ PortLifecycle.close(setup_port) if defined?(setup_port) && setup_port
135
+ end
136
+
137
+ def spawn_worker(setup_port)
138
+ ack_port = @ack_port
139
+ formatter = @formatter
140
+ encoder = @encoder
141
+ output = @output
142
+ max_record_bytes = @max_record_bytes
143
+ close_output = @close_output
144
+
145
+ # :nocov:
146
+ ::Ractor.new(setup_port, ack_port, formatter, encoder, output, max_record_bytes, close_output,
147
+ name: "julewire-ractor-destination") do |worker_port, worker_ack_port, worker_formatter,
148
+ worker_encoder, worker_output, worker_max_record_bytes,
149
+ worker_close_output|
150
+ command_port = ::Ractor::Port.new
151
+ worker_port.send(command_port)
152
+ DestinationWorker.run(
153
+ command_port: command_port,
154
+ ack_port: worker_ack_port,
155
+ formatter: worker_formatter,
156
+ encoder: worker_encoder,
157
+ output: worker_output,
158
+ max_record_bytes: worker_max_record_bytes,
159
+ close_output: worker_close_output
160
+ )
161
+ end
162
+ # :nocov:
163
+ end
164
+
165
+ def receive_worker_port(setup_port)
166
+ selected, value = ::Ractor.select(setup_port, @worker)
167
+ return value if selected.equal?(setup_port) && value.is_a?(::Ractor::Port)
168
+
169
+ raise ArgumentError, "ractor destination worker did not start"
170
+ end
171
+
172
+ def start_ack_thread
173
+ thread = Thread.new do
174
+ loop do
175
+ message = @ack_port.receive
176
+ break if message.is_a?(Hash) && message[:event] == :closed
177
+
178
+ handle_ack(message)
179
+ end
180
+ rescue StandardError => e
181
+ record_failure(e, phase: :ack)
182
+ end
183
+ thread.name = "julewire-ractor-destination-ack"
184
+ thread.report_on_exception = true
185
+ thread
186
+ end
187
+
188
+ def handle_ack(message)
189
+ return unless message.is_a?(Hash) && message[:event] == :ack
190
+
191
+ decrement_in_flight
192
+ case message[:status]
193
+ when :accepted
194
+ increment(:worker_accepted)
195
+ when :dropped
196
+ increment(:worker_dropped)
197
+ end
198
+ end
199
+
200
+ def request(command, timeout:)
201
+ return false if closed? && command != :health && command != :close
202
+
203
+ reply = ::Ractor::Port.new
204
+ @port.send({ command: command, reply: reply })
205
+ wait_for_reply(reply, timeout)
206
+ rescue StandardError => e
207
+ record_failure(e, phase: :request, command: command)
208
+ false
209
+ ensure
210
+ PortLifecycle.close(reply) if reply
211
+ end
212
+
213
+ def wait_for_reply(reply, timeout)
214
+ return reply.receive unless timeout
215
+
216
+ timeout_port = ::Ractor::Port.new
217
+ token = @scheduler.schedule(timeout) do
218
+ timeout_port.send(:timeout)
219
+ rescue StandardError
220
+ nil
221
+ end
222
+ selected, response = ::Ractor.select(reply, timeout_port)
223
+
224
+ selected.equal?(timeout_port) ? false : response
225
+ ensure
226
+ @scheduler.cancel(token) if defined?(token)
227
+ PortLifecycle.close(timeout_port) if defined?(timeout_port) && timeout_port
228
+ end
229
+
230
+ def queue_full?
231
+ @max_queue.positive? && @in_flight.value >= @max_queue
232
+ end
233
+
234
+ def closed? = @closed.get
235
+
236
+ def close_ports
237
+ @port&.send({ command: :close_worker }) unless @port&.closed?
238
+ rescue StandardError
239
+ nil
240
+ ensure
241
+ PortLifecycle.close(@port) if @port
242
+ PortLifecycle.close(@ack_port) if @ack_port
243
+ end
244
+
245
+ def drop(reason, record)
246
+ record_loss(reason, record)
247
+ Core::Diagnostics::CallbackNotifier.call(
248
+ @on_drop,
249
+ reason,
250
+ { destination: name, phase: :ractor_destination, reason: reason }
251
+ )
252
+ nil
253
+ end
254
+
255
+ def record_loss(reason, record)
256
+ metadata = Core::Records::Metadata.call(record)
257
+ @health.record_loss(
258
+ reason: reason,
259
+ event: metadata[:event],
260
+ severity: metadata[:severity],
261
+ source: metadata[:source]
262
+ )
263
+ end
264
+
265
+ def record_failure(error, **metadata)
266
+ @health.record_failure(error, counter: nil, **metadata)
267
+ Core::Diagnostics::CallbackNotifier.call(@on_failure, error, { destination: name }.merge(metadata))
268
+ rescue StandardError
269
+ nil
270
+ end
271
+
272
+ def decrement_in_flight
273
+ @in_flight.decrement if @in_flight.value.positive?
274
+ end
275
+
276
+ def increment(key)
277
+ @health.increment(key)
278
+ end
279
+
280
+ def status_for(worker)
281
+ return :closed if closed?
282
+ return :degraded if @health.degraded?
283
+ return :degraded if worker.is_a?(Hash) && worker[:status] == :degraded
284
+
285
+ :ok
286
+ end
287
+ end
288
+ end
289
+ end
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ # :nocov:
4
+ module Julewire
5
+ module Ractor
6
+ class DestinationWorker
7
+ COUNTER_KEYS = %i[
8
+ encode_error
9
+ formatter_error
10
+ formatted
11
+ output_accepted
12
+ output_error
13
+ output_exception
14
+ output_rejected
15
+ received
16
+ record_too_large
17
+ ].freeze
18
+ private_constant :COUNTER_KEYS
19
+
20
+ class << self
21
+ def run(command_port:, ack_port:, formatter:, encoder:, output:, max_record_bytes:, close_output:)
22
+ new(formatter: formatter, encoder: encoder, output: output, max_record_bytes: max_record_bytes,
23
+ close_output: close_output).run(command_port: command_port, ack_port: ack_port)
24
+ end
25
+ end
26
+
27
+ def initialize(formatter:, encoder:, output:, max_record_bytes:, close_output:)
28
+ @formatter = formatter
29
+ @encoder = encoder
30
+ @output = output
31
+ @max_record_bytes = max_record_bytes
32
+ @close_output = close_output
33
+ @health = Core::Integration::DestinationHealth.new(counter_keys: COUNTER_KEYS, failure_counter: nil)
34
+ @write_step = Core::Destinations::WriteStep.new(
35
+ formatter: @formatter,
36
+ encoder: @encoder,
37
+ output: @output,
38
+ max_record_bytes: @max_record_bytes,
39
+ increment: method(:increment),
40
+ failure: method(:record_write_step_failure),
41
+ loss: method(:record_write_step_loss),
42
+ output_class_name: method(:output_class_name)
43
+ )
44
+ end
45
+
46
+ def run(command_port:, ack_port:)
47
+ @ack_port = ack_port
48
+ loop do
49
+ message = command_port.receive
50
+ break if close_message?(message)
51
+
52
+ break if dispatch(message) == :close
53
+ end
54
+ ensure
55
+ close_output
56
+ ack(:closed)
57
+ end
58
+
59
+ private
60
+
61
+ def close_message?(message)
62
+ message.is_a?(Hash) && message[:command] == :close_worker
63
+ end
64
+
65
+ def dispatch(message)
66
+ return unless message.is_a?(Hash)
67
+
68
+ case message[:command]
69
+ when :emit
70
+ emit(message[:record])
71
+ when :flush
72
+ reply_to(message, call_output_lifecycle(:flush))
73
+ when :close
74
+ reply_to(message, call_output_lifecycle(:close))
75
+ :close
76
+ when :health
77
+ reply_to(message, health)
78
+ end
79
+ rescue StandardError => e
80
+ record_failure(e, phase: :dispatch)
81
+ reply_to(message, false)
82
+ end
83
+
84
+ def emit(record)
85
+ ack(@write_step.call(record) == :accepted ? :accepted : :dropped)
86
+ rescue StandardError => e
87
+ record_failure(e, phase: :emit)
88
+ ack(:dropped)
89
+ end
90
+
91
+ def call_output_lifecycle(method_name)
92
+ return close_lifecycle if method_name == :close
93
+ return true unless @output.respond_to?(method_name)
94
+
95
+ @output.public_send(method_name) != false
96
+ rescue StandardError => e
97
+ record_failure(e, phase: :output_lifecycle, action: method_name)
98
+ false
99
+ end
100
+
101
+ def close_lifecycle
102
+ return true if output_closed?
103
+ return @output.close != false if @close_output && @output.respond_to?(:close)
104
+ return @output.flush != false if @output.respond_to?(:flush)
105
+
106
+ true
107
+ rescue StandardError => e
108
+ record_failure(e, phase: :output_lifecycle, action: :close)
109
+ false
110
+ end
111
+
112
+ def close_output
113
+ return if output_closed?
114
+ return unless @close_output && @output.respond_to?(:close)
115
+
116
+ @output.close
117
+ rescue StandardError => e
118
+ record_failure(e, phase: :output_lifecycle, action: :close)
119
+ end
120
+
121
+ def output_closed?
122
+ @output.respond_to?(:closed?) ? @output.closed? : false
123
+ end
124
+
125
+ def health
126
+ @health.snapshot
127
+ end
128
+
129
+ def increment(key)
130
+ @health.increment(key)
131
+ end
132
+
133
+ def record_failure(error, **metadata)
134
+ @health.record_failure(error, counter: nil, **metadata)
135
+ end
136
+
137
+ def record_loss(reason, **metadata)
138
+ @health.record_loss(reason: reason, counter: nil, **metadata)
139
+ end
140
+
141
+ def record_write_step_failure(error, metadata)
142
+ record_failure(error, **recordless_metadata(metadata))
143
+ end
144
+
145
+ def record_write_step_loss(reason, metadata)
146
+ return if %i[formatter_error encode_error].include?(reason)
147
+
148
+ record_loss(reason, **recordless_metadata(metadata))
149
+ end
150
+
151
+ def output_class_name
152
+ @output.class.name
153
+ end
154
+
155
+ def recordless_metadata(metadata)
156
+ metadata.except(:record)
157
+ end
158
+
159
+ def ack(status)
160
+ @ack_port.send({ event: :ack, status: status })
161
+ rescue StandardError
162
+ nil
163
+ end
164
+
165
+ def reply_to(message, response)
166
+ reply = message[:reply]
167
+ reply.send(response) if reply.is_a?(::Ractor::Port)
168
+ rescue StandardError => e
169
+ record_failure(e, phase: :reply)
170
+ end
171
+ end
172
+
173
+ private_constant :DestinationWorker
174
+ end
175
+ end
176
+ # :nocov:
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Julewire
4
+ module Ractor
5
+ class Fanout
6
+ attr_reader :name
7
+
8
+ def initialize(destinations:, name: :ractor_fanout, on_failure: nil)
9
+ @name = Core::Destinations.normalize_name(name)
10
+ @destinations = Array(destinations).map { normalize_destination(it) }.freeze
11
+ raise ArgumentError, "destinations must not be empty" if @destinations.empty?
12
+
13
+ Core::Validation.validate_callable!(on_failure, name: :on_failure, allow_nil: true)
14
+ @on_failure = on_failure
15
+ @health = Core::Integration::DestinationHealth.new(counter_keys: [], failure_counter: nil)
16
+ end
17
+
18
+ def emit(record)
19
+ @destinations.each { emit_to_destination(it, record) }
20
+ nil
21
+ end
22
+
23
+ def flush(timeout: nil)
24
+ call_lifecycle(:flush, timeout: timeout)
25
+ end
26
+
27
+ def close(timeout: nil)
28
+ call_lifecycle(:close, timeout: timeout)
29
+ end
30
+
31
+ def after_fork!
32
+ @destinations.each do |destination|
33
+ destination.after_fork! if destination.respond_to?(:after_fork!)
34
+ rescue StandardError => e
35
+ record_failure(e, action: :after_fork, destination: destination.name)
36
+ end
37
+ self
38
+ end
39
+
40
+ def resource_identity = self
41
+
42
+ def health
43
+ destinations = @destinations.to_h { [it.name, destination_health(it)] }
44
+ @health.snapshot(
45
+ destinations: destinations,
46
+ status: health_status(destinations)
47
+ )
48
+ end
49
+
50
+ private
51
+
52
+ def normalize_destination(value)
53
+ destination = value.is_a?(Hash) ? Destination.new(**value) : value
54
+ Core::Destinations::Registry.validate!(destination)
55
+ end
56
+
57
+ def emit_to_destination(destination, record)
58
+ destination.emit(record)
59
+ rescue StandardError => e
60
+ record_failure(e, action: :emit, destination: destination.name, record_metadata: Core::Records::Metadata.call(record))
61
+ end
62
+
63
+ def call_lifecycle(method_name, timeout:)
64
+ ok = true
65
+ @destinations.each do |destination|
66
+ ok = false if destination.public_send(method_name, timeout: timeout) == false
67
+ rescue StandardError => e
68
+ record_failure(e, action: method_name, destination: destination.name)
69
+ ok = false
70
+ end
71
+ ok
72
+ end
73
+
74
+ def destination_health(destination)
75
+ destination.health
76
+ rescue StandardError => e
77
+ Core::Diagnostics::FailureSnapshot.build(e, destination: destination.name, phase: :ractor_fanout_health)
78
+ end
79
+
80
+ def health_status(destinations)
81
+ return :degraded if @health.last_failure
82
+ return :degraded if destinations.any? { |_name, health| health[:status] == :degraded || health[:phase] }
83
+
84
+ :ok
85
+ end
86
+
87
+ def record_failure(error, **metadata)
88
+ @health.record_failure(error, counter: nil, phase: :ractor_fanout, **metadata)
89
+ @on_failure&.call(error, **metadata, phase: :ractor_fanout)
90
+ rescue StandardError
91
+ nil
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Julewire
4
+ module Ractor
5
+ module PortLifecycle
6
+ class << self
7
+ def close(port)
8
+ return unless port.respond_to?(:close)
9
+ return if port.respond_to?(:closed?) && port.closed?
10
+
11
+ port.close
12
+ rescue StandardError
13
+ nil
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Julewire
4
+ module Ractor
5
+ module RemotePayload
6
+ MISSING = Object.new.freeze
7
+ private_constant :MISSING
8
+
9
+ class << self
10
+ def extract(payload)
11
+ {
12
+ input: input_value(payload),
13
+ context: hash_value(payload, :context),
14
+ neutral: hash_value(payload, :neutral),
15
+ attributes: hash_value(payload, :attributes),
16
+ carry: hash_value(payload, :carry),
17
+ scope: scope_snapshot(hash_value(payload, :scope))
18
+ }
19
+ end
20
+
21
+ def input_value(payload)
22
+ value = Core::Integration::Values::Read.hash_value(payload, :input, default: MISSING)
23
+ value.equal?(MISSING) ? {} : value
24
+ end
25
+
26
+ def scope_snapshot(scope_payload)
27
+ Core::Execution::ScopeSnapshot.new(
28
+ execution: hash_value(scope_payload, :execution),
29
+ neutral: hash_value(scope_payload, :neutral),
30
+ attributes: hash_value(scope_payload, :attributes),
31
+ carry: hash_value(scope_payload, :carry),
32
+ labels: hash_value(scope_payload, :labels)
33
+ )
34
+ end
35
+
36
+ def hash_value(hash, key)
37
+ value = Core::Integration::Values::Read.hash_value(hash, key)
38
+ value.is_a?(Hash) ? Core::Fields::FieldSet.deep_symbolize_keys(value) : {}
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end