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.
@@ -0,0 +1,217 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "msgpack"
5
+ require "set"
6
+
7
+ module Flezha
8
+ # The MessagePack codec for durable `State` and `Change` bodies, and the Ruby
9
+ # type mapping that goes with it. The payload formats a channel names on
10
+ # `input` and `output` are here too, as `CHANNEL_CODECS`.
11
+ #
12
+ # The mapping is specified here rather than inherited from a library default,
13
+ # because the defaults are wrong for a format that has to survive a restart:
14
+ #
15
+ # | Ruby | MessagePack | Why not the default |
16
+ # | --------- | ---------------------- | -------------------------------------- |
17
+ # | `Symbol` | extension 0 | otherwise it decodes back as a String, and `apply_change` reads a different value on replay than it did live |
18
+ # | `Time` | timestamp extension | a string or a number loses the type |
19
+ # | Hash key | symbolized on decode | `{count: 1}` must fold back as `{count: 1}`, not `{"count" => 1}` |
20
+ # | binary `String` | `bin` | so a byte string survives as bytes rather than as invalid UTF-8 |
21
+ #
22
+ # Ruby's integers are arbitrary-precision, so the u64 offsets, seqs and
23
+ # timestamps in the schema need no width policy: the hazard other SDKs carry
24
+ # (a `number` losing precision above 2^53, a `PHP_INT_MAX` ceiling) has no
25
+ # analogue here. Past `2^64 - 1` MessagePack itself has no integer, and it
26
+ # raises rather than narrowing — which is the outcome that matters.
27
+ #
28
+ # `Marshal` appears nowhere on this path, or on the wire path. It embeds
29
+ # class names and instance-variable layout, so renaming a State class breaks
30
+ # the restore of every record written before the rename, and the first anyone
31
+ # would learn of it is a restart.
32
+ module Codec
33
+ # Extension id for a Symbol. Ruby-side only: it rides inside a body whose
34
+ # header already carries `sdk_lang`, so no other SDK ever decodes it.
35
+ EXT_SYMBOL = 0x00
36
+
37
+ FACTORY = MessagePack::Factory.new
38
+ FACTORY.register_type(EXT_SYMBOL, Symbol)
39
+ FACTORY.register_type(
40
+ MessagePack::Timestamp::TYPE, Time,
41
+ packer: MessagePack::Time::Packer, unpacker: MessagePack::Time::Unpacker
42
+ )
43
+ FACTORY.freeze
44
+
45
+ module_function
46
+
47
+ def encode(value)
48
+ FACTORY.dump(value)
49
+ end
50
+
51
+ def decode(bytes)
52
+ return nil if bytes.nil? || bytes.empty?
53
+
54
+ FACTORY.load(bytes.b, symbolize_keys: true)
55
+ end
56
+
57
+ # The payload formats a channel names on `input` and `output`. A payload
58
+ # between two tasklets names none: it stays in the MessagePack above.
59
+ CHANNEL_CODECS = %i[bytes text json msgpack].freeze
60
+
61
+ # How `input` reads a channel's payload: the named codec, or JSON when the
62
+ # channel names none. JSON keys are symbolized, as the MessagePack decode
63
+ # symbolizes them, so a handler reads `value[:a]` under either.
64
+ def channel_decoder(codec, channel)
65
+ case codec
66
+ when :bytes then ->(bytes) { bytes.b }
67
+ when :text then ->(bytes) { utf8_text(bytes, channel) }
68
+ when :msgpack then method(:decode)
69
+ else ->(bytes) { bytes.empty? ? nil : JSON.parse(utf8_text(bytes, channel), symbolize_names: true) }
70
+ end
71
+ end
72
+
73
+ # How `output` writes a channel's payload: the named codec, or
74
+ # `plain_payload` when the channel names none.
75
+ def channel_encoder(codec, channel)
76
+ case codec
77
+ when :bytes then ->(value) { value.is_a?(String) ? value : refuse_payload(channel, codec, value) }
78
+ when :text then ->(value) { value.is_a?(String) ? utf8_text(value, channel) : refuse_payload(channel, codec, value) }
79
+ when :json then ->(value) { json_payload("channel '#{channel.name}'", value) }
80
+ when :msgpack then method(:encode)
81
+ else ->(value) { plain_payload(value, "channel '#{channel.name}'") }
82
+ end
83
+ end
84
+
85
+ # A String as its bytes and anything else as JSON: what an output naming no
86
+ # codec sends, and what a steps completion carries. `owner` is the
87
+ # channel or tasklet a refusal names.
88
+ def plain_payload(value, owner)
89
+ value.is_a?(String) ? value : json_payload(owner, value)
90
+ end
91
+
92
+ # What `plain_payload` wrote, read back: JSON, else UTF-8 text, else the
93
+ # bytes. A String that reads as JSON comes back as that JSON value.
94
+ def plain_value(bytes)
95
+ JSON.parse(bytes)
96
+ rescue JSON::ParserError, EncodingError
97
+ text = bytes.dup.force_encoding(Encoding::UTF_8)
98
+ text.valid_encoding? ? text : bytes.b
99
+ end
100
+
101
+ def utf8_text(bytes, channel)
102
+ text = bytes.dup.force_encoding(Encoding::UTF_8)
103
+ return text if text.valid_encoding?
104
+
105
+ raise EncodingError, "channel `#{channel.name}`: the payload is not valid UTF-8"
106
+ end
107
+
108
+ def json_payload(owner, value)
109
+ JSON.generate(value)
110
+ rescue JSON::GeneratorError => e
111
+ raise Error.new("runtime.value_not_json", owner: owner, type: value.class.to_s, reason: e.message)
112
+ end
113
+
114
+ def refuse_payload(channel, codec, value)
115
+ raise Error.new("runtime.value_wrong_for_codec", channel: channel.name, codec: codec.to_s, type: value.class.to_s)
116
+ end
117
+
118
+ def unrepresentable(type_name, path, reason)
119
+ Error.new("topology.state_unrepresentable", path: "#{type_name}.#{path.empty? ? '<root>' : path}", reason: reason)
120
+ end
121
+
122
+ # A value the codec cannot carry, named by the declaring type and the path
123
+ # to the offending field.
124
+ #
125
+ # Walked once at registration against what `init_state` produces, so an
126
+ # author learns at startup rather than at the first restore that their
127
+ # state does not survive one.
128
+ def assert_representable(type_name, sample)
129
+ walk(type_name, sample, "", {})
130
+ end
131
+
132
+ def walk(type_name, value, path, seen)
133
+ reason = unrepresentable_reason(value)
134
+ raise unrepresentable(type_name, path, reason) if reason
135
+ return unless value.is_a?(Hash) || value.is_a?(Array)
136
+
137
+ if seen.key?(value.object_id)
138
+ raise unrepresentable(type_name, path,
139
+ "the value refers back to itself; MessagePack has no representation for a cycle")
140
+ end
141
+ seen[value.object_id] = true
142
+ if value.is_a?(Array)
143
+ value.each_with_index { |item, index| walk(type_name, item, "#{path}[#{index}]", seen) }
144
+ else
145
+ value.each do |key, item|
146
+ unless key.is_a?(Symbol) || key.is_a?(String)
147
+ raise unrepresentable(type_name, path,
148
+ "a Hash key of #{key.class} decodes as a String; declare Symbol or String keys")
149
+ end
150
+ walk(type_name, item, path.empty? ? key.to_s : "#{path}.#{key}", seen)
151
+ end
152
+ end
153
+ seen.delete(value.object_id)
154
+ end
155
+
156
+ def unrepresentable_reason(value)
157
+ case value
158
+ when nil, true, false, Integer, Float, String, Symbol, Time, Hash, Array then nil
159
+ when Proc, Method
160
+ "a callable has no durable form; hold the data it closes over instead"
161
+ when Set
162
+ "a Set decodes as a Hash, losing its identity; declare an Array, or supply a " \
163
+ "hand-written codec"
164
+ else
165
+ "an instance of #{value.class} decodes as a plain Hash, losing its class; declare a " \
166
+ "Hash or an Array, or supply a hand-written codec"
167
+ end
168
+ end
169
+
170
+ # The JSON form of a state value, for a live read and a migration handover.
171
+ #
172
+ # JSON has no form for three things this codec carries, so each is marked
173
+ # rather than lost: a Symbol would come back a String, a Time a number, and
174
+ # a binary String would be mangled by UTF-8 validation.
175
+ def to_state_json(value)
176
+ JSON.generate(json_safe(value))
177
+ end
178
+
179
+ def from_state_json(bytes)
180
+ return nil if bytes.nil? || bytes.empty?
181
+
182
+ json_revive(JSON.parse(bytes.b.force_encoding(Encoding::UTF_8)))
183
+ end
184
+
185
+ def json_safe(value)
186
+ case value
187
+ when Symbol then { "$sym" => value.to_s }
188
+ when Time then { "$time" => [value.to_i, value.nsec] }
189
+ when String then binary?(value) ? { "$bytes" => [value].pack("m0") } : value
190
+ when Hash then value.to_h { |k, v| [k.to_s, json_safe(v)] }
191
+ when Array then value.map { |item| json_safe(item) }
192
+ else value
193
+ end
194
+ end
195
+
196
+ def json_revive(value)
197
+ case value
198
+ when Hash
199
+ marked = value.size == 1 ? value.keys.first : nil
200
+ case marked
201
+ when "$sym" then return value[marked].to_sym
202
+ when "$bytes" then return value[marked].unpack1("m0").b
203
+ when "$time"
204
+ seconds, nsec = value[marked]
205
+ return Time.at(seconds, nsec, :nanosecond)
206
+ end
207
+ value.to_h { |k, v| [k.to_sym, json_revive(v)] }
208
+ when Array then value.map { |item| json_revive(item) }
209
+ else value
210
+ end
211
+ end
212
+
213
+ def binary?(value)
214
+ value.encoding == Encoding::BINARY && !value.dup.force_encoding(Encoding::UTF_8).valid_encoding?
215
+ end
216
+ end
217
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Flezha
6
+ # What a start answered: the id the relay runs under, the generated one
7
+ # when the start named none, and `:started` or `:already_running`.
8
+ StartResult = Struct.new(:id, :outcome, keyword_init: true)
9
+
10
+ # Administering the namespace this runner's connection proved, from ordinary
11
+ # application code. Sending is the runner's own, `Runner#send_to`.
12
+ #
13
+ # No tasklet has to be running and the application holds no credential of
14
+ # its own: the controller judges every call as the identity the connection
15
+ # already established, so nothing an unattended process holds expires. An
16
+ # user grants a namespace's applications the authority once and revokes
17
+ # it the same way, and a revocation takes effect on the next call:
18
+ #
19
+ # flz role bind runner@team-orders app team-orders
20
+ #
21
+ # Every method here is one line over a single native entry point, because the
22
+ # verb travels as data. Adding a verb costs a method and no native work.
23
+ class Control
24
+ def initialize(core)
25
+ @core = core
26
+ end
27
+
28
+ # Start a relay of `factory_id`, on the specification
29
+ # `flz relay start` takes, answering a `StartResult`.
30
+ #
31
+ # Without an `id` the controller generates one, so a start retried after a
32
+ # lost answer starts a second relay, and a second stream runs until it
33
+ # is stopped. With an `id`, a start of a running one leaves it untouched
34
+ # and answers `:already_running`.
35
+ def start_relay(factory_id, id: nil, init_values: nil, opts: nil)
36
+ answer = call("Relay.start", given(factory_id:, id:, init_values:, opts:))
37
+ StartResult.new(id: answer.fetch("id"), outcome: answer.fetch("outcome").to_sym)
38
+ end
39
+
40
+ # Stop a relay, or the whole partition group it names.
41
+ def stop_relay(relay_id) = call("Relay.stop", relay_id)
42
+
43
+ # Enable the plugin `name` in this namespace, of the provider `provider`
44
+ # (for example `"relay_websocket"`), on its `opts`. `single: true` runs
45
+ # one copy in the whole cluster; `relay_id` with `hooks` binds that
46
+ # relay in the same call.
47
+ def enable_plugin(name, provider, opts: {}, single: nil, relay_id: nil, hooks: nil)
48
+ call("PluginManager.enable", name, given(provider:, opts:, single:, relay_id:, hooks:))
49
+ end
50
+
51
+ # Bind the relay `relay_id` to the enabled plugin `name`, with the
52
+ # optional `hooks` and `channel`.
53
+ def bind_plugin(name, relay_id, hooks: nil, channel: nil)
54
+ call("PluginManager.bind", name, given(relay_id:, hooks:, channel:))
55
+ end
56
+
57
+ # Unbind the relay `relay_id` from the plugin `name`, which stays enabled.
58
+ def unbind_plugin(name, relay_id) = call("PluginManager.unbind", name, { "relay_id" => relay_id })
59
+
60
+ # Disable the plugin `name`, stopping it wherever it runs.
61
+ def disable_plugin(name) = call("PluginManager.disable", name, {})
62
+
63
+ # The relays in this namespace across the cluster: `rows`, one row each
64
+ # (its id, health, runner and node, and a partition group's width and
65
+ # members), and `unreachable`, the controllers that did not answer.
66
+ def list_relays = call("Relay.list")
67
+
68
+ # The plugins enabled in this namespace, one row each: name, provider,
69
+ # single, bound relays and where it runs.
70
+ def list_plugins = call("PluginManager.enabled")
71
+
72
+ # A relay's live state, and the change sequence it was read at. `cell` is
73
+ # `<tasklet>[/<cell_key>]`: a tasklet's Cells, or one of them; nil reads
74
+ # every Cell of the relay.
75
+ def cell_state(relay_id, cell = nil) = call("Relay.cell_state", relay_id, cell)
76
+
77
+ # Any published function, by name. What succeeds follows from the role the
78
+ # namespace is bound to, never from a list kept here. The arguments are the
79
+ # call's own other than the namespace, which the controller fills from the
80
+ # connection.
81
+ def call(verb, *args)
82
+ JSON.parse(@core.admin_call(verb, JSON.generate(args)))
83
+ end
84
+
85
+ private
86
+
87
+ # The named parameters a call was given, leaving out every one it was not.
88
+ def given(**fields) = fields.compact.transform_keys(&:to_s)
89
+ end
90
+ end
data/lib/flezha/ctx.rb ADDED
@@ -0,0 +1,389 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ require_relative "builder"
6
+ require_relative "codec"
7
+ require_relative "errors"
8
+ require_relative "identity"
9
+
10
+ module Flezha
11
+ # The invocation context: every deterministic effect a handler is allowed.
12
+ #
13
+ # The context accumulates one item's effects (recorded changes, outputs,
14
+ # timer operations, the checkpoint flag, a completion request) and the
15
+ # dispatcher turns them into the item effect the core folds. Nothing here
16
+ # reaches the wire on its own; an emit is a value in a list until the
17
+ # completion carries it.
18
+ class Ctx
19
+ # Metadata keys the runtime owns. A handler that sets one is refused.
20
+ RESERVED_PREFIX = "flz."
21
+
22
+ attr_reader :cell_key, :replay
23
+
24
+ def initialize(spec:, node:, outgoing:, registered:, address:, cell_key:, change_seq:,
25
+ now:, replay:, state:, bridge:, token:, offset: nil, item: nil, compensating: false)
26
+ @token = token
27
+ # The index of the work item this invocation runs within its activation,
28
+ # which the core numbers and stamps a part by. A compensation has none.
29
+ @item = item
30
+ # The input-log offset of the item this invocation runs: a spawn carries
31
+ # it so a crash re-run of the same input resolves to the worker the first
32
+ # run spawned.
33
+ @offset = offset
34
+ @compensating = compensating
35
+ @spec = spec
36
+ @node = node
37
+ @outgoing = outgoing
38
+ # Where the identity bytes the core handed for this cell are kept.
39
+ @registered = registered
40
+ @address = address
41
+ @cell_key = cell_key
42
+ @change_seq = change_seq
43
+ @now = now
44
+ @replay = replay
45
+ @state = state
46
+ @bridge = bridge
47
+ @changes = []
48
+ @outputs = []
49
+ @timers = []
50
+ @checkpointed = false
51
+ @completion = nil
52
+ @ordinal = 0
53
+ @identity = nil
54
+ end
55
+
56
+ # The cell's state. Mutated only through `change`: a field assigned
57
+ # directly is live in this process and gone after a restart.
58
+ attr_reader :state
59
+
60
+ # The core-evaluated declared clock for this item. Never ambient time.
61
+ def now = @now
62
+
63
+ # A replay-stable identifier, distinct per invocation within the cell.
64
+ def stable_id
65
+ @identity ||= @registered.identity_of(@address)
66
+ id = Identity.stable_id(@identity, @change_seq, @ordinal)
67
+ @ordinal += 1
68
+ id
69
+ end
70
+
71
+ # Record one declared change: journaled, then folded into the state.
72
+ def change(change)
73
+ definition = @node.definition
74
+ unless definition.apply_change?
75
+ raise Error.new("runtime.change_without_apply_change", tasklet: @node.name)
76
+ end
77
+
78
+ codec = definition.change_codec
79
+ @changes << (codec ? codec.encode(change).b : Codec.encode(change))
80
+ # Fold it live through the same method the restore fold uses, so the live
81
+ # path and the replay path cannot drift. `apply_change` mutates the state
82
+ # in place and its answer is ignored — the same contract Rust's
83
+ # `&mut S` and the other SDKs carry, so a scalar state is declared as a
84
+ # Hash rather than reassigned here.
85
+ definition.fold_change(@state, change)
86
+ end
87
+
88
+ # Emit one output value on the tasklet's declared output.
89
+ def emit(value, key: nil, channel: nil, metadata: nil)
90
+ targets = channel ? @outgoing.select { |c| c.name == channel } : @outgoing
91
+ if targets.empty?
92
+ raise Error.new("runtime.channel_not_produced", tasklet: @node.name, channel: channel) if channel
93
+
94
+ raise Error.new("runtime.no_output_channel", tasklet: @node.name)
95
+ end
96
+
97
+ targets.each { |target| release(target, value, key, metadata) }
98
+ nil
99
+ end
100
+
101
+ # Send `value` as a part: a preview of the output this invocation is
102
+ # producing, which leaves the runner now rather than when the handler
103
+ # returns. It previews the tasklet's own output channel and is encoded as
104
+ # an emit of the same value there would be. The relay's listeners
105
+ # receive it before the output; an output never does, and it is never durable
106
+ # or replayed. Parts are numbered from 0 within each attempt, so a retry
107
+ # starts its preview again.
108
+ #
109
+ # It never waits: a part that cannot leave at once (no connection, a local
110
+ # run, a stream that would block, a frame over the size limit) is dropped,
111
+ # and a replayed input sends none. Raises a `Flezha::Error` coded
112
+ # `part.no_output` when the output channel does not end in an output (it only
113
+ # feeds another tasklet, or ends in `call`), and `part.outside_invocation`
114
+ # through a context whose invocation has returned.
115
+ def part(value)
116
+ channel = @outgoing.first
117
+ # A channel with no plugin end has no encoding; the core refuses its part
118
+ # before reading a byte.
119
+ payload = channel&.sink ? channel.sink_encode.call(value).b : "".b
120
+ # A compensation holds no work item, which the core answers as outside
121
+ # any invocation, whatever the index.
122
+ @bridge.part(@token, @item || 0, payload)
123
+ nil
124
+ end
125
+
126
+ # Register a processing-time timer `ms` from `now`. Answers its time.
127
+ # Inside a `compensate` hook each timer call raises `runtime.timers_unavailable`.
128
+ def timer_after(milliseconds)
129
+ # Anchored on `now`, never an ambient read: that is what makes a replayed
130
+ # invocation register the timestamp the live one did.
131
+ at = @now + Integer(milliseconds)
132
+ timer(kind: "processing_time", scheduled_at: at, register: true)
133
+ at
134
+ end
135
+
136
+ # Register an event-time timer at an absolute time. Answers it.
137
+ def timer_at_event_time(at)
138
+ at = Integer(at)
139
+ timer(kind: "event_time", scheduled_at: at, register: true)
140
+ at
141
+ end
142
+
143
+ # Cancel a registered timer by the value its registration answered.
144
+ def cancel_timer(kind, at)
145
+ timer(kind: kind.to_s, scheduled_at: Integer(at), register: false)
146
+ nil
147
+ end
148
+
149
+ # Close the open checkpoint interval (checkpoint ack-granularity classes).
150
+ def checkpoint
151
+ @checkpointed = true
152
+ nil
153
+ end
154
+
155
+ # End the instance this tasklet runs in, with `result`.
156
+ #
157
+ # The rest of the iteration still runs and commits; the instance then ends
158
+ # instead of waiting for its next input, a flow instance once every Cell is
159
+ # idle. In a `once` steps relay it sets the result the pass ends with.
160
+ # `result` is encoded as an output naming no codec encodes a value: a String as
161
+ # its bytes and anything else as JSON; nil carries an empty result. A later
162
+ # call in the same invocation replaces an earlier one.
163
+ def complete(result = nil)
164
+ @completion = result.nil? ? "".b : Codec.plain_payload(result, "tasklet '#{@node.name}'").b
165
+ nil
166
+ end
167
+
168
+ # Spawn a steps instance of `factory_id` with `payload` as its first
169
+ # message, answering a `SpawnHandle` at once. The payload is encoded as an
170
+ # output naming no codec encodes a value, the rule `complete` applies: a
171
+ # String as its bytes and anything else as JSON, so the instance's `text`
172
+ # or `json` entry channel reads it as it reads a send. With an `id` already
173
+ # running, `if_running: "deliver"` (the default) hands it `payload` instead,
174
+ # and `"error"` resolves to a `Flezha::Error` coded `relay.already_running`. A replayed
175
+ # invocation sends nothing and resolves to `:skipped_on_replay`.
176
+ #
177
+ # Without an `id` the controller generates one and always starts a new
178
+ # instance, so a spawn reissued after a lost or timed-out answer starts a
179
+ # second one, and a replayed invocation answers an empty id. Pass an id to
180
+ # start at most one instance, or to get the id back on replay.
181
+ def spawn_steps(factory_id, payload, id: nil, if_running: nil)
182
+ handle = @bridge.spawn_request(@token, factory_id, id.to_s,
183
+ Codec.plain_payload(payload, "tasklet '#{@node.name}'").b,
184
+ if_running.to_s, @replay, @offset)
185
+ SpawnHandle.new(@bridge, handle)
186
+ end
187
+
188
+ # Run the block, a call to anything outside the instance (a model, a tool,
189
+ # a service), and keep its result: when this invocation runs again (a
190
+ # crash re-run, a replay, a wake, a move), the call answers the kept result
191
+ # and the block does not run.
192
+ #
193
+ # The result is kept, synced on the controller, before this answers. A
194
+ # crash between the block finishing and that point runs it again, so give
195
+ # the outside service `stable_id` as an idempotency key where it takes
196
+ # one. `name` names the call: a non-empty String of at most 255 bytes,
197
+ # counted per invocation, so a call of another name added before this one
198
+ # leaves its kept result where it was. One call runs at a time; a
199
+ # concurrent one is refused.
200
+ #
201
+ # The result is encoded as an output naming no codec encodes a value (a
202
+ # String as its bytes, anything else as JSON) and read back as JSON, else
203
+ # text, else bytes; `codec` (an object answering `encode` and `decode`, as
204
+ # a change codec does) encodes and decodes it instead. The call answers the
205
+ # result as it reads back, on a first run and a re-run alike.
206
+ #
207
+ # An invocation that took no input-log offset (local mode, a relay with no
208
+ # input log) runs the block each time and keeps nothing. The block's own
209
+ # error propagates and nothing is kept. Raises a `Flezha::Error` coded
210
+ # `effect.bad_name`, `effect.nested`, `effect.outside_invocation`,
211
+ # `effect.too_large` or `effect.not_recorded` (the only one retried).
212
+ def effect(name, codec: nil)
213
+ item = @item || 0
214
+ status, kept = effect_refusals { @bridge.effect_begin(@token, item, name) }
215
+ return effect_decode(kept, codec) if status == :kept
216
+
217
+ begin
218
+ value = yield
219
+ encoded = (codec ? codec.encode(value) : Codec.plain_payload(value, "effect '#{name}'")).b
220
+ first = effect_refusals { @bridge.effect_end(@token, item, encoded) } if status == :run
221
+ effect_decode(first || encoded, codec)
222
+ ensure
223
+ # A no-op once the end has kept the result.
224
+ @bridge.effect_release(@token, item)
225
+ end
226
+ end
227
+
228
+ private
229
+
230
+ # The effect record for this item, as the core folds it. The dispatcher
231
+ # and the test harness read it through `send`, as friends of the context.
232
+ def effect_record(consumed_offset)
233
+ {
234
+ verdict: "ok",
235
+ changes: @changes,
236
+ outputs: @outputs,
237
+ consumed_offset: consumed_offset,
238
+ event_time: @now,
239
+ timers: @timers,
240
+ checkpoint: @checkpointed,
241
+ complete: @completion
242
+ }
243
+ end
244
+
245
+ # An `effect.*` refusal is non-retryable, but for `effect.not_recorded`: a
246
+ # call that could not be kept is the one worth running again.
247
+ def effect_refusals
248
+ yield
249
+ rescue Error => e
250
+ e.define_singleton_method(:non_retryable?) { true } if e.code.start_with?("effect.") && e.code != "effect.not_recorded"
251
+ raise
252
+ end
253
+
254
+ def effect_decode(body, codec) = codec ? codec.decode(body) : Codec.plain_value(body)
255
+
256
+ def timer(operation)
257
+ # A rollback is no place to arm a deadline.
258
+ raise Error.new("runtime.timers_unavailable") if @compensating
259
+
260
+ @timers << operation
261
+ end
262
+
263
+ def release(channel, value, key, metadata)
264
+ # In steps a value rides the edge path to its first consumer, and a
265
+ # terminal the core derived inter-tasklet hands it to the core as the
266
+ # pass's handoff (a chained loop's carry); any other releases nothing.
267
+ if @spec.runs_in_steps?
268
+ consumer = channel.consumers.first&.tasklet
269
+ consumer ||= channel.producer if channel.derived_kind == "inter_task"
270
+ @outputs << steps_output(channel, consumer, value) if consumer
271
+ else
272
+ channel.consumers.each do |edge|
273
+ @outputs.concat(edge_outputs(channel, edge, value, key, metadata))
274
+ end
275
+ end
276
+ @outputs << plugin_output(channel, value, key, metadata) if channel.sink
277
+ @outputs.concat(call_outputs(channel, value, metadata)) if channel.call
278
+ end
279
+
280
+ # The requests a call end sends, with the call chain's transforms and key
281
+ # applied here as an edge's are; the core stamps the call onto each.
282
+ def call_outputs(channel, value, metadata)
283
+ call = channel.call
284
+ Flezha.apply_transforms(call.transforms, value).map do |request|
285
+ {
286
+ target: "plugin", channel: nil, consumer: nil, plugin_channel: channel.name,
287
+ key: call.key_by&.call(request), event_time: nil, metadata: meta_list(metadata),
288
+ payload: call.encode.call(request).b
289
+ }
290
+ end
291
+ end
292
+
293
+ def steps_output(channel, consumer, value)
294
+ payload = Codec.encode(value)
295
+ {
296
+ target: "edge", channel: channel.index, consumer: consumer,
297
+ plugin_channel: nil, key: nil, event_time: nil, metadata: [], payload: payload
298
+ }
299
+ end
300
+
301
+ # One consumer's frames, with its own transforms, key and clock: none when
302
+ # a filter dropped the value, one per value when a flat_map fanned it.
303
+ def edge_outputs(channel, edge, value, key, metadata)
304
+ definition = @spec.tasklets[edge.tasklet].definition
305
+ # Producer-side, in declaration order: the core routes on `(edge, key)`
306
+ # and never evaluates a host closure.
307
+ Flezha.apply_transforms(edge.transforms, value).map do |transformed|
308
+ {
309
+ target: "edge",
310
+ channel: channel.index,
311
+ consumer: edge.tasklet,
312
+ plugin_channel: nil,
313
+ key: key || (edge.key_by && edge.key_by.call(transformed)) || @cell_key,
314
+ event_time: definition.event_time? ? definition.event_time_of(transformed) : nil,
315
+ metadata: meta_list(metadata),
316
+ payload: Codec.encode(transformed)
317
+ }
318
+ end
319
+ end
320
+
321
+ # The plugin frame, serialized once from the value as emitted: an edge's
322
+ # transforms belong to that edge, so they never reach the output.
323
+ def plugin_output(channel, value, key, metadata)
324
+ resolved = key || @cell_key
325
+ if channel.keyed && resolved.nil?
326
+ raise Error.new("runtime.key_required_on_keyed_channel", channel: channel.name)
327
+ end
328
+
329
+ pairs = meta_list(metadata)
330
+ # An encoder's string carries whatever encoding it was built in, so it is
331
+ # copied into bytes; `String#b` copies even when the encoding matches.
332
+ payload = channel.sink_encode.call(value).b
333
+ {
334
+ target: "plugin", channel: nil, consumer: nil, plugin_channel: channel.name,
335
+ key: resolved, event_time: nil, metadata: pairs, payload: payload
336
+ }
337
+ end
338
+
339
+ def meta_list(metadata)
340
+ return [] unless metadata
341
+
342
+ metadata.map do |key, value|
343
+ raise Error.new("runtime.reserved_metadata_key", key: key.to_s) if key.to_s.start_with?(RESERVED_PREFIX)
344
+
345
+ [key.to_s, value.to_s]
346
+ end
347
+ end
348
+ end
349
+
350
+ # A spawn that reached an instance: the id it resolved to, the caller's or
351
+ # the generated one, and `:spawned`, `:delivered` or `:skipped_on_replay`.
352
+ SpawnResult = Struct.new(:id, :outcome, keyword_init: true)
353
+
354
+ # The answer to one spawn request, read inside the invocation that made it:
355
+ # a resolved handle answers every read the same until its invocation ends,
356
+ # when the core releases it and logs once a failure no read collected.
357
+ class SpawnHandle
358
+ def initialize(bridge, handle)
359
+ @bridge = bridge
360
+ @handle = handle
361
+ end
362
+
363
+ # Wait for the answer: a `SpawnResult`, or raises the `Flezha::Error`
364
+ # naming why the spawn failed, its code the controller's (such as
365
+ # `relay.already_running`) or the core's (`spawn.timeout`,
366
+ # `spawn.unavailable`).
367
+ #
368
+ # The wait is in the core with the GVL released, as every waiting core
369
+ # call is, so under the fiber executor it holds the dispatch thread, and
370
+ # every other handler fiber with it, until the answer arrives.
371
+ def wait
372
+ Core.load_extension
373
+ read(Native::WAIT_CORE_BOUND)
374
+ end
375
+
376
+ # The answer if it has arrived, without waiting: `nil` while pending, and
377
+ # otherwise what `wait` answers or raises.
378
+ def try_result = read(0)
379
+
380
+ private
381
+
382
+ def read(timeout_ms)
383
+ return unless (json = @bridge.spawn_result(@handle, timeout_ms))
384
+
385
+ answer = JSON.parse(json)
386
+ SpawnResult.new(id: answer["id"], outcome: answer["outcome"].to_sym)
387
+ end
388
+ end
389
+ end