flezha 0.1.0-x86_64-linux
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/LICENSE +73 -0
- data/README.md +57 -0
- data/THIRD_PARTY_NOTICES.md +2888 -0
- data/ext/flz_core_ffi/Cargo.toml +29 -0
- data/ext/flz_core_ffi/extconf.rb +43 -0
- data/ext/flz_core_ffi/src/lib.rs +7 -0
- data/lib/flezha/3.3/flz_core_ffi.so +0 -0
- data/lib/flezha/3.4/flz_core_ffi.so +0 -0
- data/lib/flezha/builder.rb +504 -0
- data/lib/flezha/codec.rb +217 -0
- data/lib/flezha/control.rb +90 -0
- data/lib/flezha/ctx.rb +389 -0
- data/lib/flezha/declarative.rb +86 -0
- data/lib/flezha/dispatch.rb +395 -0
- data/lib/flezha/errors.rb +58 -0
- data/lib/flezha/executor.rb +329 -0
- data/lib/flezha/graph.rb +135 -0
- data/lib/flezha/identity.rb +36 -0
- data/lib/flezha/local.rb +146 -0
- data/lib/flezha/logger.rb +56 -0
- data/lib/flezha/native.rb +112 -0
- data/lib/flezha/relay.rb +132 -0
- data/lib/flezha/response.rb +27 -0
- data/lib/flezha/route_latest.rb +54 -0
- data/lib/flezha/runner.rb +289 -0
- data/lib/flezha/state.rb +66 -0
- data/lib/flezha/tasklet.rb +288 -0
- data/lib/flezha/testing.rb +269 -0
- data/lib/flezha/version.rb +10 -0
- data/lib/flezha.rb +42 -0
- metadata +182 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "errors"
|
|
4
|
+
|
|
5
|
+
module Flezha
|
|
6
|
+
# The class-level declaration DSL the authoring surface shares.
|
|
7
|
+
#
|
|
8
|
+
# One method reads and writes: `max_cells` answers what was declared,
|
|
9
|
+
# `max_cells 100` declares it. Each declaration registers its name, which
|
|
10
|
+
# is what makes inheritance **total** — a subclass copies exactly the
|
|
11
|
+
# settings that exist, rather than a list someone has to remember to extend
|
|
12
|
+
# when they add one.
|
|
13
|
+
#
|
|
14
|
+
# That inheritance is the point. Without it a subclass silently loses every
|
|
15
|
+
# declaration its parent made: its static key set falls away, its
|
|
16
|
+
# mailbox capacity to the default, its schema version to 1 — and nothing
|
|
17
|
+
# reports the difference until the topology is wrong at runtime.
|
|
18
|
+
module Declarative
|
|
19
|
+
# Every declaration name known to this class, its own and its ancestors'.
|
|
20
|
+
def settings
|
|
21
|
+
own = @settings ||= []
|
|
22
|
+
superclass.respond_to?(:settings) ? superclass.settings | own : own
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Register names a hand-written declaration writes, so inheritance covers
|
|
26
|
+
# them too. Used by the declarations that take more than one value, or that
|
|
27
|
+
# write more than one instance variable.
|
|
28
|
+
def declares(*names)
|
|
29
|
+
(@settings ||= []).concat(names)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# A declaration read by its bare name and written by naming a value.
|
|
33
|
+
# `one_of` closes the values it takes: any other is refused where the
|
|
34
|
+
# class body writes it.
|
|
35
|
+
def setting(*names, one_of: nil)
|
|
36
|
+
declares(*names)
|
|
37
|
+
names.each do |name|
|
|
38
|
+
singleton_class.define_method(name) do |value = nil|
|
|
39
|
+
return instance_variable_get(:"@#{name}") if value.nil?
|
|
40
|
+
|
|
41
|
+
if one_of && !one_of.include?(value.to_s.to_sym)
|
|
42
|
+
raise Error.new("topology.invalid_mode", factory_id: @factory_id || to_s, knob: name,
|
|
43
|
+
value: value.to_s)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
instance_variable_set(:"@#{name}", value)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# A declaration whose bare mention declares it: `logical_clock`.
|
|
52
|
+
def flag(name)
|
|
53
|
+
declares(name)
|
|
54
|
+
singleton_class.define_method(name) do |value = true|
|
|
55
|
+
instance_variable_set(:"@#{name}", value)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# A declaration that takes a block, read by its bare name.
|
|
60
|
+
def block_setting(*names)
|
|
61
|
+
declares(*names)
|
|
62
|
+
names.each do |name|
|
|
63
|
+
singleton_class.define_method(name) do |&block|
|
|
64
|
+
return instance_variable_get(:"@#{name}") unless block
|
|
65
|
+
|
|
66
|
+
instance_variable_set(:"@#{name}", block)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def inherited(subclass)
|
|
72
|
+
super
|
|
73
|
+
hand_down(subclass, settings)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
# The declarations among +names+ this class wrote, with their values.
|
|
79
|
+
def written(names) = names.to_h { |name| [name, instance_variable_get(:"@#{name}")] }.compact
|
|
80
|
+
|
|
81
|
+
# Copy the named declarations this class wrote onto +other+.
|
|
82
|
+
def hand_down(other, names)
|
|
83
|
+
written(names).each { |name, value| other.instance_variable_set(:"@#{name}", value) }
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "codec"
|
|
4
|
+
require_relative "ctx"
|
|
5
|
+
require_relative "graph"
|
|
6
|
+
require_relative "logger"
|
|
7
|
+
require_relative "state"
|
|
8
|
+
|
|
9
|
+
module Flezha
|
|
10
|
+
# A declared class, with the lookups dispatch needs on the hot path, and the
|
|
11
|
+
# live state of every cell it owns.
|
|
12
|
+
class RegisteredClass
|
|
13
|
+
attr_reader :spec, :outgoing
|
|
14
|
+
|
|
15
|
+
def initialize(spec)
|
|
16
|
+
@spec = spec
|
|
17
|
+
@outgoing = spec.tasklets.map do |node|
|
|
18
|
+
spec.channels.select { |channel| channel.producer == node.index }
|
|
19
|
+
end
|
|
20
|
+
@cells = {}
|
|
21
|
+
# The identity bytes the core hands with a cell's first activation.
|
|
22
|
+
@identities = {}
|
|
23
|
+
# Cells of one class are touched from every worker thread, and the
|
|
24
|
+
# core's per-cell exclusivity does not extend to this Hash: two
|
|
25
|
+
# different cells of the same class run concurrently by design.
|
|
26
|
+
@lock = Mutex.new
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Where the cell an activation names keeps its state. The relay is part
|
|
30
|
+
# of it: one class serves every relay of its factory on this runner,
|
|
31
|
+
# such as each steps instance, and each has cells of its own.
|
|
32
|
+
def self.address(activation, node) = [activation[:relay], node.index, activation[:cell_key]]
|
|
33
|
+
|
|
34
|
+
# The cell's live state, created from the declaration on first touch.
|
|
35
|
+
def state_of(node, address)
|
|
36
|
+
@lock.synchronize do
|
|
37
|
+
@cells.fetch(address) { @cells[address] = node.definition.build_initial_state(address.last) }
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def set_state(address, state)
|
|
42
|
+
@lock.synchronize { @cells[address] = state }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def keep_identity(address, identity) = @lock.synchronize { @identities[address] = identity }
|
|
46
|
+
def identity_of(address) = @lock.synchronize { @identities.fetch(address) }
|
|
47
|
+
|
|
48
|
+
# Drop a cell that left this runner, or every cell of its relay.
|
|
49
|
+
def forget(address, whole_relay)
|
|
50
|
+
@lock.synchronize do
|
|
51
|
+
[@cells, @identities].each do |table|
|
|
52
|
+
if whole_relay
|
|
53
|
+
table.delete_if { |(relay, _, _), _| relay == address.first }
|
|
54
|
+
else
|
|
55
|
+
table.delete(address)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# The declared classes this runner serves.
|
|
63
|
+
class Registry
|
|
64
|
+
def initialize
|
|
65
|
+
@classes = {}
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def add(spec)
|
|
69
|
+
if @classes.key?(spec.factory_id)
|
|
70
|
+
raise Error.new("topology.duplicate_factory_id", factory_id: spec.factory_id)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
@classes[spec.factory_id] = RegisteredClass.new(spec)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def remove(factory_id) = @classes.delete(factory_id)
|
|
77
|
+
def get(factory_id) = @classes[factory_id]
|
|
78
|
+
def all = @classes.values
|
|
79
|
+
def empty? = @classes.empty?
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Turning one activation into one answer, exactly once.
|
|
83
|
+
#
|
|
84
|
+
# Per-cell exclusivity IS the handout: the core holds a cell's next
|
|
85
|
+
# activation until this one is answered, so an activation that returns
|
|
86
|
+
# unanswered retires that cell for the life of the process. Every path out
|
|
87
|
+
# of `dispatch` therefore ends in a completion or an abandon, through an
|
|
88
|
+
# `ensure` rather than a rule applied at each return site — a raised handler,
|
|
89
|
+
# an encoding failure and a killed thread all reach the same guard.
|
|
90
|
+
module Dispatch
|
|
91
|
+
module_function
|
|
92
|
+
|
|
93
|
+
def failed(token, message, non_retryable: false)
|
|
94
|
+
{ token: token, outcome: "failed", error: message, non_retryable: non_retryable }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# What a failure says on the wire. `class: message`, in family with what
|
|
98
|
+
# the other SDKs send — this string is stored in a dead-letter entry and
|
|
99
|
+
# read by a user, and a multi-line backtrace there is noise the retry
|
|
100
|
+
# ladder multiplies. The backtrace is logged host-side instead, where it is
|
|
101
|
+
# a diagnostic rather than a record.
|
|
102
|
+
def error_message(error) = "#{error.class}: #{error.is_a?(Error) ? error.detailed_message : error.message}"
|
|
103
|
+
|
|
104
|
+
# A handler declares an error non-retryable by answering `non_retryable?`.
|
|
105
|
+
def non_retryable?(error)
|
|
106
|
+
error.respond_to?(:non_retryable?) && error.non_retryable?
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# An entry channel's decoder, or the entry edge's transforms over what it
|
|
110
|
+
# decoded, rejected a message. Only the entrance raises this: the verdict
|
|
111
|
+
# it becomes tells the core the message is unreadable rather than that the
|
|
112
|
+
# handler failed, so a steps relay parks it and keeps waiting instead of
|
|
113
|
+
# ending.
|
|
114
|
+
class UnreadableMessage < StandardError
|
|
115
|
+
def initialize(cause)
|
|
116
|
+
super(Dispatch.error_message(cause))
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# An item's failure as its verdict and the message the wire carries.
|
|
121
|
+
def failure_of(error)
|
|
122
|
+
return ["unreadable", error.message] if error.is_a?(UnreadableMessage)
|
|
123
|
+
|
|
124
|
+
[non_retryable?(error) ? "non_retryable" : "retryable", error_message(error)]
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# A payload as its channel reads it: the entry channel's decoder, or the
|
|
128
|
+
# class codec for a payload between tasklets. The one decoder for an input
|
|
129
|
+
# item and for a payload at the prepare crossing.
|
|
130
|
+
def decoded(channel, payload)
|
|
131
|
+
channel&.decode ? channel.decode.call(payload) : Codec.decode(payload)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# The channel's edge that feeds `node`, or nil when it feeds none.
|
|
135
|
+
def edge_into(channel, node)
|
|
136
|
+
channel&.consumers&.find { |edge| edge.tasklet == node }
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Produce the answer for one activation. Raising is not an option here:
|
|
140
|
+
# every failure becomes a result the core can act on, because the
|
|
141
|
+
# alternative is a cell that never receives work again.
|
|
142
|
+
def answer(activation, deps)
|
|
143
|
+
registered = deps.registry.get(activation[:factory])
|
|
144
|
+
unless registered
|
|
145
|
+
return failed(activation[:token],
|
|
146
|
+
"no class registered for factory `#{activation[:factory]}`",
|
|
147
|
+
non_retryable: true)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
node = registered.spec.tasklets[activation[:tasklet]]
|
|
151
|
+
unless node
|
|
152
|
+
return failed(activation[:token],
|
|
153
|
+
"class `#{activation[:factory]}` has no tasklet at index " \
|
|
154
|
+
"#{activation[:tasklet]}", non_retryable: true)
|
|
155
|
+
end
|
|
156
|
+
if activation[:cell_identity]
|
|
157
|
+
registered.keep_identity(RegisteredClass.address(activation, node), activation[:cell_identity])
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
case activation[:kind]
|
|
161
|
+
when :work then run_work(activation, registered, node, deps)
|
|
162
|
+
when :restore_fold then restore_fold(activation, registered, node)
|
|
163
|
+
when :snapshot_request then snapshot(activation, registered, node)
|
|
164
|
+
when :live_state_read then state_json(activation, registered, node)
|
|
165
|
+
when :apply_state
|
|
166
|
+
registered.set_state(RegisteredClass.address(activation, node),
|
|
167
|
+
Codec.from_state_json(activation[:state_json]))
|
|
168
|
+
{ token: activation[:token], outcome: "state_applied" }
|
|
169
|
+
when :key_request then prepare(activation, registered)
|
|
170
|
+
when :compensate_run then compensate(activation, registered, node, deps)
|
|
171
|
+
when :leave
|
|
172
|
+
registered.forget(RegisteredClass.address(activation, node), activation[:whole_relay])
|
|
173
|
+
{ token: activation[:token], outcome: "state_applied" }
|
|
174
|
+
else
|
|
175
|
+
failed(activation[:token], "unknown activation kind `#{activation[:kind]}`",
|
|
176
|
+
non_retryable: true)
|
|
177
|
+
end
|
|
178
|
+
rescue StandardError => e
|
|
179
|
+
failed(activation[:token], error_message(e), non_retryable: non_retryable?(e))
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Run user code against one cell, and keep the state it produced.
|
|
183
|
+
#
|
|
184
|
+
# Every activation kind that reaches a handler goes through here — an
|
|
185
|
+
# input, a timer firing, a compensation — so the context they receive and
|
|
186
|
+
# the state they leave behind cannot differ between them. The state is
|
|
187
|
+
# written back whether or not the body raised: a change recorded before the
|
|
188
|
+
# raise is already journaled, and dropping it here would make the live
|
|
189
|
+
# cell disagree with its own log.
|
|
190
|
+
def invoke(activation, registered, node, deps, now, replay, offset = nil, item: nil)
|
|
191
|
+
address = RegisteredClass.address(activation, node)
|
|
192
|
+
ctx = Ctx.new(
|
|
193
|
+
spec: registered.spec, node: node, outgoing: registered.outgoing[node.index],
|
|
194
|
+
registered: registered, address: address, cell_key: activation[:cell_key],
|
|
195
|
+
change_seq: activation[:change_seq], now: now, replay: replay,
|
|
196
|
+
state: registered.state_of(node, address), bridge: deps.bridge,
|
|
197
|
+
token: activation[:token], offset: offset, item: item,
|
|
198
|
+
compensating: activation[:kind] == :compensate_run
|
|
199
|
+
)
|
|
200
|
+
begin
|
|
201
|
+
yield(ctx)
|
|
202
|
+
ctx
|
|
203
|
+
ensure
|
|
204
|
+
registered.set_state(address, ctx.state)
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def run_work(activation, registered, node, deps)
|
|
209
|
+
effects = []
|
|
210
|
+
|
|
211
|
+
Array(activation[:items]).each_with_index do |item, index|
|
|
212
|
+
now = item[:now] || 0
|
|
213
|
+
begin
|
|
214
|
+
ctx = invoke(activation, registered, node, deps, now, item[:replay] || false,
|
|
215
|
+
item[:consumed_offset], item: index) do |c|
|
|
216
|
+
run_item(registered, node, item, now, c)
|
|
217
|
+
end
|
|
218
|
+
effects << ctx.send(:effect_record, item[:consumed_offset])
|
|
219
|
+
rescue StandardError => e
|
|
220
|
+
# The backtrace is a host diagnostic, not a record: the verdict below
|
|
221
|
+
# travels to the dead-letter entry and the controller's log.
|
|
222
|
+
Flezha.log.debug("#{node.name} failed an item: #{e.class}: #{e.message}\n" \
|
|
223
|
+
"#{Array(e.backtrace).first(8).join("\n")}")
|
|
224
|
+
# Items after this one are re-handed by the core, so the batch stops
|
|
225
|
+
# here rather than reporting effects the cell never saw.
|
|
226
|
+
verdict, message = failure_of(e)
|
|
227
|
+
effects << {
|
|
228
|
+
verdict: verdict,
|
|
229
|
+
message: message,
|
|
230
|
+
event_time: now,
|
|
231
|
+
consumed_offset: item[:consumed_offset]
|
|
232
|
+
}
|
|
233
|
+
break
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
{ token: activation[:token], outcome: "work", effects: effects }
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# One item against the handler its kind names, inside an invocation.
|
|
241
|
+
def run_item(registered, node, item, now, ctx)
|
|
242
|
+
definition = node.definition
|
|
243
|
+
if item[:kind] == :timer
|
|
244
|
+
# Registration is a runtime call, so this cannot be refused at
|
|
245
|
+
# declaration time.
|
|
246
|
+
unless definition.on_timer?
|
|
247
|
+
raise Error.new("runtime.timer_without_on_timer", tasklet: node.name)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
definition.instance.on_timer(
|
|
251
|
+
{ kind: item[:timer_kind], scheduled_at: item[:scheduled_at] || now }, ctx
|
|
252
|
+
)
|
|
253
|
+
return nil
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
values =
|
|
257
|
+
if item[:form] == :prepared
|
|
258
|
+
# The prepare crossing decoded a keyed payload and ran this node's
|
|
259
|
+
# edge already; each value comes encoded as a value between tasklets.
|
|
260
|
+
Array(item[:values]).map { |bytes| Codec.decode(bytes) }
|
|
261
|
+
else
|
|
262
|
+
entering(registered.spec.channels[item[:channel] || 0], node, item)
|
|
263
|
+
end
|
|
264
|
+
# A filtered value reaches no handler and a fanned one reaches it once
|
|
265
|
+
# per value. Either way the item is one effect, so its offset advances
|
|
266
|
+
# once and nothing is retried. What `run` answers is not output: a value
|
|
267
|
+
# leaves through `ctx.emit`.
|
|
268
|
+
values.each { |input| definition.instance.run(input, ctx) }
|
|
269
|
+
nil
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# An input payload as the handler sees it: decoded, then through this
|
|
273
|
+
# node's own edge off the channel when the core marks it untransformed: a
|
|
274
|
+
# flow producer already ran that edge, and running it again would compose
|
|
275
|
+
# the chain with itself. The entrance is where a message the relay cannot
|
|
276
|
+
# read is told apart from a handler that fails on one it can.
|
|
277
|
+
def entering(channel, node, item)
|
|
278
|
+
edge = item[:form] == :untransformed ? edge_into(channel, node.index) : nil
|
|
279
|
+
Flezha.apply_transforms(edge&.transforms || [], decoded(channel, item[:payload] || "".b))
|
|
280
|
+
rescue StandardError => e
|
|
281
|
+
raise unless channel&.kind == :entry
|
|
282
|
+
|
|
283
|
+
raise UnreadableMessage, e
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def restore_fold(activation, registered, node)
|
|
287
|
+
registered.set_state(
|
|
288
|
+
RegisteredClass.address(activation, node),
|
|
289
|
+
State.fold_restore(
|
|
290
|
+
node.definition,
|
|
291
|
+
{ snapshot: activation[:snapshot], changes: activation[:changes] },
|
|
292
|
+
activation[:cell_key], node.name
|
|
293
|
+
)
|
|
294
|
+
)
|
|
295
|
+
{ token: activation[:token], outcome: "restored" }
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def state_json(activation, registered, node)
|
|
299
|
+
state = registered.state_of(node, RegisteredClass.address(activation, node))
|
|
300
|
+
{ token: activation[:token], outcome: "state_json", json: Codec.to_state_json(state).b }
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def snapshot(activation, registered, node)
|
|
304
|
+
codec = node.definition.state_codec
|
|
305
|
+
state = registered.state_of(node, RegisteredClass.address(activation, node))
|
|
306
|
+
{ token: activation[:token], outcome: "snapshot",
|
|
307
|
+
state: (codec ? codec.encode(state).b : Codec.encode(state)) }
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def prepare(activation, registered)
|
|
311
|
+
request = activation[:key_request]
|
|
312
|
+
return failed(activation[:token], "a key request carried no payloads", non_retryable: true) unless request
|
|
313
|
+
|
|
314
|
+
channel = registered.spec.channels[request[:channel]]
|
|
315
|
+
answers = request[:payloads].map do |payload|
|
|
316
|
+
prepared(registered, channel, request, decoded(channel, payload))
|
|
317
|
+
rescue StandardError => e
|
|
318
|
+
# A per-payload refusal enters the input channel's retry ladder; it
|
|
319
|
+
# is not a failure of the crossing, whose other payloads still have
|
|
320
|
+
# answers.
|
|
321
|
+
{ error: error_message(e), non_retryable: non_retryable?(e) }
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
{ token: activation[:token], outcome: "prepared", prepared: answers }
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
# One decoded payload's answer: every value each keyed consumer's own edge
|
|
328
|
+
# yields, with the Cells it names, and the event time the first timed
|
|
329
|
+
# consumer reads off its first value. A payload its chains filter answers
|
|
330
|
+
# no value, so the core mints no Cell for it.
|
|
331
|
+
def prepared(registered, channel, request, value)
|
|
332
|
+
event_time = nil
|
|
333
|
+
values = (channel&.consumers || []).flat_map do |edge|
|
|
334
|
+
definition = registered.spec.tasklets[edge.tasklet].definition
|
|
335
|
+
keyed = request[:want_keys] && (edge.key_by || definition.cells_for?)
|
|
336
|
+
timed = request[:want_event_time] && event_time.nil? && definition.event_time?
|
|
337
|
+
next [] unless keyed || timed
|
|
338
|
+
|
|
339
|
+
yielded = Flezha.apply_transforms(edge.transforms, value)
|
|
340
|
+
event_time = definition.event_time_of(yielded.first) if timed && !yielded.empty?
|
|
341
|
+
next [] unless keyed
|
|
342
|
+
|
|
343
|
+
yielded.map do |v|
|
|
344
|
+
keys = edge.key_by ? [edge.key_by.call(v).to_s] : definition.cells_for(v).map(&:to_s)
|
|
345
|
+
{ consumer: edge.tasklet, keys: keys, bytes: Codec.encode(v) }
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
{ values: values, event_time: event_time }
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def compensate(activation, registered, node, deps)
|
|
352
|
+
definition = node.definition
|
|
353
|
+
unless definition.compensate?
|
|
354
|
+
return failed(activation[:token], "#{node.name} declares no compensate hook",
|
|
355
|
+
non_retryable: true)
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
outputs = Array(activation[:outputs]).map { |bytes| Codec.decode(bytes) }
|
|
359
|
+
ctx = invoke(activation, registered, node, deps, 0, false) do |c|
|
|
360
|
+
definition.instance.compensate(outputs, c)
|
|
361
|
+
end
|
|
362
|
+
{ token: activation[:token], outcome: "work", effects: [ctx.send(:effect_record, nil)] }
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
# Dispatch one activation and answer it exactly once.
|
|
366
|
+
#
|
|
367
|
+
# The guard is the contract, not politeness. An unanswered activation
|
|
368
|
+
# retires its cell for the life of the process, so a raise from the
|
|
369
|
+
# dispatcher, from encoding the result, or from `complete` itself abandons
|
|
370
|
+
# rather than logging and returning.
|
|
371
|
+
def dispatch(activation, native, deps)
|
|
372
|
+
answered = false
|
|
373
|
+
begin
|
|
374
|
+
native.complete(answer(activation, deps))
|
|
375
|
+
answered = true
|
|
376
|
+
rescue StandardError => e
|
|
377
|
+
Flezha.log.error("activation processing failed: #{error_message(e)}")
|
|
378
|
+
ensure
|
|
379
|
+
unless answered
|
|
380
|
+
begin
|
|
381
|
+
native.abandon(activation[:token])
|
|
382
|
+
rescue StandardError => e
|
|
383
|
+
# The core refuses an unknown token: `complete` had already landed
|
|
384
|
+
# and only the bookkeeping after it raised. Nothing is wedged.
|
|
385
|
+
Flezha.log.warn("abandoning activation #{activation[:token]} failed: " \
|
|
386
|
+
"#{error_message(e)}")
|
|
387
|
+
end
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
end
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
# What a dispatch loop needs to answer an activation, over one extension.
|
|
394
|
+
DispatchDeps = Struct.new(:registry, :bridge, keyword_init: true)
|
|
395
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Flezha
|
|
6
|
+
# Anything Flezha refuses: the controller, the core, or this SDK itself.
|
|
7
|
+
#
|
|
8
|
+
# `code` (`family.snake`) and `error_class` (`invalid`, `not_found`,
|
|
9
|
+
# `denied`, ...) are what a program branches on; `message` is one sentence
|
|
10
|
+
# for a reader, `details` names what the message is about, and `hint` is the
|
|
11
|
+
# next command to run, when there is one. An uncaught one prints the code
|
|
12
|
+
# after the message.
|
|
13
|
+
#
|
|
14
|
+
# A refusal this SDK makes is built from its code and details, and its
|
|
15
|
+
# message is the catalogue's, rendered by the core:
|
|
16
|
+
#
|
|
17
|
+
# raise Flezha::Error.new("topology.duplicate_factory_id", factory_id: "echo")
|
|
18
|
+
class Error < StandardError
|
|
19
|
+
attr_reader :code, :error_class, :details, :hint
|
|
20
|
+
|
|
21
|
+
# The error a JSON error document `{"error": {...}}` carries. The native
|
|
22
|
+
# extension raises every failure through here.
|
|
23
|
+
def self.from_document(document) = from_object(JSON.parse(document).fetch("error"))
|
|
24
|
+
|
|
25
|
+
# The error a parsed error object (`code`, `class`, `message`, `details`,
|
|
26
|
+
# `hint`) carries, as a controller writes one into a call's outcome.
|
|
27
|
+
def self.from_object(fields)
|
|
28
|
+
allocate.tap { |error| error.send(:fill, fields) }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def initialize(code, **details)
|
|
32
|
+
super()
|
|
33
|
+
fill(JSON.parse(Flezha::Core.error_document(code, details)).fetch("error"))
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def to_s = @message
|
|
37
|
+
|
|
38
|
+
# How an uncaught one prints: the message, then the code.
|
|
39
|
+
def detailed_message(**) = "#{message} (#{code})"
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def fill(fields)
|
|
44
|
+
@code = fields.fetch("code")
|
|
45
|
+
@error_class = fields.fetch("class")
|
|
46
|
+
@message = fields.fetch("message")
|
|
47
|
+
@details = fields["details"] || {}
|
|
48
|
+
@hint = fields["hint"]
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# A handler error the retry ladder must not retry. Raise this (or set
|
|
53
|
+
# `non_retryable` on any error) to park an item immediately. An author's own
|
|
54
|
+
# steering type, not a refusal, so it is no `Flezha::Error`.
|
|
55
|
+
class NonRetryableError < StandardError
|
|
56
|
+
def non_retryable? = true
|
|
57
|
+
end
|
|
58
|
+
end
|