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
data/lib/flezha/codec.rb
ADDED
|
@@ -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
|