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 +7 -0
- data/CHANGELOG.md +6 -0
- data/LICENSE.txt +21 -0
- data/README.md +62 -0
- data/docs/bridge.md +159 -0
- data/docs/development.md +42 -0
- data/julewire-ractor.gemspec +37 -0
- data/lib/julewire/ractor/bridge/bridge_thread.rb +61 -0
- data/lib/julewire/ractor/bridge/runtime_validation.rb +25 -0
- data/lib/julewire/ractor/bridge/stats.rb +75 -0
- data/lib/julewire/ractor/bridge.rb +133 -0
- data/lib/julewire/ractor/child_stats.rb +69 -0
- data/lib/julewire/ractor/destination.rb +289 -0
- data/lib/julewire/ractor/destination_worker.rb +176 -0
- data/lib/julewire/ractor/fanout.rb +95 -0
- data/lib/julewire/ractor/port_lifecycle.rb +18 -0
- data/lib/julewire/ractor/remote_payload.rb +43 -0
- data/lib/julewire/ractor/remote_runtime.rb +187 -0
- data/lib/julewire/ractor/remote_summary_record.rb +15 -0
- data/lib/julewire/ractor/reply_timeout_scheduler.rb +39 -0
- data/lib/julewire/ractor/version.rb +7 -0
- data/lib/julewire/ractor.rb +59 -0
- data/lib/julewire-ractor.rb +3 -0
- metadata +96 -0
|
@@ -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
|