flezha 0.1.0-aarch64-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/state.rb
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "codec"
|
|
4
|
+
|
|
5
|
+
module Flezha
|
|
6
|
+
# Declared state: how it is built and how it is restored.
|
|
7
|
+
#
|
|
8
|
+
# Declared state is mutated only through declared changes, because the change
|
|
9
|
+
# log IS how the state is rebuilt: a field assigned directly is live in this
|
|
10
|
+
# process and gone after a restart, and nothing would report the difference
|
|
11
|
+
# until the restart. Ruby has no cheap deep read-only view — the Node SDK's
|
|
12
|
+
# guard is a `Proxy`, which has no counterpart here that does not cost an
|
|
13
|
+
# allocation per field read — so the rule is documented and `ctx.change` is
|
|
14
|
+
# the path, rather than paid for on every access.
|
|
15
|
+
module State
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
# Decode one durable body, choosing between the three things it could be:
|
|
19
|
+
# a payload from an older schema version, one the tasklet declared its
|
|
20
|
+
# own codec for, or the MessagePack default.
|
|
21
|
+
#
|
|
22
|
+
# One function rather than the same three-way choice written at each of the
|
|
23
|
+
# places a body is decoded — the two halves of a restore fold, on the
|
|
24
|
+
# dispatch path and in the test harness.
|
|
25
|
+
def decode_body(definition, which, body, tasklet)
|
|
26
|
+
from_version = body[:from_version]
|
|
27
|
+
bytes = body[:bytes] || "".b
|
|
28
|
+
if from_version
|
|
29
|
+
migration = :"migrate_#{which}"
|
|
30
|
+
unless definition.declares?(migration)
|
|
31
|
+
raise Error.new("runtime.hook_missing",
|
|
32
|
+
tasklet: tasklet,
|
|
33
|
+
action: "was handed a #{which} record written at schema version #{from_version}",
|
|
34
|
+
hook: migration.to_s)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
return definition.instance.public_send(migration, from_version, bytes)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
codec = which == :state ? definition.state_codec : definition.change_codec
|
|
41
|
+
codec ? codec.decode(bytes) : Codec.decode(bytes)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Rebuild a cell's state: the declared starting value, the snapshot
|
|
45
|
+
# baseline when there is one, then every change folded on top in order.
|
|
46
|
+
#
|
|
47
|
+
# This IS the restore, and both callers are the real thing — the dispatcher
|
|
48
|
+
# answering a restore fold, and the test harness constructing from a
|
|
49
|
+
# payload — which is why the harness proves what a warm restart does rather
|
|
50
|
+
# than a second implementation that agrees with it.
|
|
51
|
+
def fold_restore(definition, payload, cell_key, tasklet)
|
|
52
|
+
state = definition.build_initial_state(cell_key)
|
|
53
|
+
snapshot = payload[:snapshot]
|
|
54
|
+
state = decode_body(definition, :state, snapshot, tasklet) if snapshot
|
|
55
|
+
|
|
56
|
+
Array(payload[:changes]).each do |body|
|
|
57
|
+
unless definition.apply_change?
|
|
58
|
+
raise Error.new("runtime.change_without_apply_change", tasklet: tasklet)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
definition.fold_change(state, decode_body(definition, :change, body, tasklet))
|
|
62
|
+
end
|
|
63
|
+
state
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "codec"
|
|
4
|
+
require_relative "declarative"
|
|
5
|
+
require_relative "errors"
|
|
6
|
+
|
|
7
|
+
module Flezha
|
|
8
|
+
# The codec for an author-declared State or Change class.
|
|
9
|
+
#
|
|
10
|
+
# `to_h` on the way out and `.from_h` on the way back, over MessagePack —
|
|
11
|
+
# never `Marshal`, which embeds the class name and the instance-variable
|
|
12
|
+
# layout, so a rename would break the restore of every record written before
|
|
13
|
+
# it. Encoding the Hash instead is what makes the rename survivable.
|
|
14
|
+
#
|
|
15
|
+
# Built on the same `state_codec` / `change_codec` seam a hand-written codec
|
|
16
|
+
# uses, rather than beside it: the dispatcher and the restore fold already
|
|
17
|
+
# ask that seam, and a second one would be a second answer to one question.
|
|
18
|
+
class TypeCodec
|
|
19
|
+
attr_reader :type
|
|
20
|
+
|
|
21
|
+
def initialize(type, which)
|
|
22
|
+
unless type.method_defined?(:to_h)
|
|
23
|
+
raise Codec.unrepresentable(type, which.to_s, "it declares no `to_h`; a durable #{which} is encoded as the " \
|
|
24
|
+
"Hash it answers")
|
|
25
|
+
end
|
|
26
|
+
unless type.respond_to?(:from_h)
|
|
27
|
+
raise Codec.unrepresentable(type, which.to_s,
|
|
28
|
+
"it declares no `.from_h`; a durable #{which} is rebuilt from the Hash it " \
|
|
29
|
+
"was encoded as")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
@type = type
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def encode(value) = Codec.encode(value.to_h)
|
|
36
|
+
def decode(bytes) = @type.from_h(Codec.decode(bytes))
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# What a declared tasklet resolves to: the scalar declarations, and the one
|
|
40
|
+
# instance whose methods the dispatcher calls.
|
|
41
|
+
#
|
|
42
|
+
# Tasklets hold no state of their own — a cell's state lives in the
|
|
43
|
+
# registry and is handed to each invocation — so one instance per class is
|
|
44
|
+
# enough, and the dispatcher never allocates per activation.
|
|
45
|
+
class Definition
|
|
46
|
+
attr_reader :name, :cell_keys, :idle_ttl_ms, :max_cells, :clock,
|
|
47
|
+
:mailbox_capacity, :max_timers_per_cell, :schema_version, :min_readable_schema_version,
|
|
48
|
+
:compact_after_bytes,
|
|
49
|
+
:state_type, :state_codec, :change_codec, :instance
|
|
50
|
+
|
|
51
|
+
# Read straight off the class rather than through a mirrored option hash:
|
|
52
|
+
# the hash was the declaration written twice, and a field added to one half
|
|
53
|
+
# and not the other is a declaration that silently does not reach the core.
|
|
54
|
+
# Captured here rather than delegated, because the dispatcher reads these
|
|
55
|
+
# on the hot path.
|
|
56
|
+
def initialize(klass)
|
|
57
|
+
@body = klass.body
|
|
58
|
+
@name = klass.tasklet_name
|
|
59
|
+
@cell_keys = klass.cell_keys
|
|
60
|
+
@idle_ttl_ms = klass.idle_ttl_seconds&.*(1000)
|
|
61
|
+
@max_cells = klass.max_cells
|
|
62
|
+
@mailbox_capacity = klass.mailbox_capacity
|
|
63
|
+
@max_timers_per_cell = klass.max_timers_per_cell
|
|
64
|
+
@schema_version = klass.schema_version
|
|
65
|
+
@min_readable_schema_version = klass.min_readable_schema_version
|
|
66
|
+
@compact_after_bytes = klass.compact_after_bytes
|
|
67
|
+
@state_type = klass.state_type
|
|
68
|
+
@state_codec = klass.state_codec
|
|
69
|
+
@change_codec = klass.change_codec
|
|
70
|
+
@instance = @body.new
|
|
71
|
+
@clock = if event_time? then :event_time
|
|
72
|
+
elsif klass.logical_clock? then :logical
|
|
73
|
+
else :wall
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def declares?(method) = @body.method_defined?(method) || @body.private_method_defined?(method)
|
|
78
|
+
|
|
79
|
+
def init_state? = declares?(:init_state)
|
|
80
|
+
def apply_change? = declares?(:apply_change)
|
|
81
|
+
def cells_for? = declares?(:cells_for)
|
|
82
|
+
def event_time? = declares?(:event_time)
|
|
83
|
+
def on_timer? = declares?(:on_timer)
|
|
84
|
+
def compensate? = declares?(:compensate)
|
|
85
|
+
def migrate_state? = declares?(:migrate_state)
|
|
86
|
+
def migrate_change? = declares?(:migrate_change)
|
|
87
|
+
|
|
88
|
+
# Whether a Cell of this tasklet holds a state at all.
|
|
89
|
+
def stateful? = init_state? || !@state_type.nil?
|
|
90
|
+
|
|
91
|
+
def build_initial_state(cell_key)
|
|
92
|
+
if init_state? then @instance.init_state(cell_key)
|
|
93
|
+
elsif @state_type then @state_type.new
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def fold_change(state, change) = @instance.apply_change(state, change)
|
|
98
|
+
def cells_for(input) = Array(@instance.cells_for(input))
|
|
99
|
+
def event_time_of(input) = Integer(@instance.event_time(input))
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# A tasklet's class-level declarations. A tasklet class carries them,
|
|
103
|
+
# and so does a short-form relay class, which hands them to the
|
|
104
|
+
# tasklet it generates.
|
|
105
|
+
module TaskletSettings
|
|
106
|
+
# The scalar declarations. Each is read by its bare name and written by
|
|
107
|
+
# naming a value: `max_cells` answers, `max_cells 100` declares.
|
|
108
|
+
SCALARS = %i[mailbox_capacity schema_version min_readable_schema_version
|
|
109
|
+
state_codec change_codec max_cells max_timers_per_cell
|
|
110
|
+
compact_after_bytes].freeze
|
|
111
|
+
# Written by the declarations below, which check their value or write
|
|
112
|
+
# more than one variable; named so a subclass inherits them.
|
|
113
|
+
WRITTEN = %i[cell_keys idle_ttl_seconds state_type change_type].freeze
|
|
114
|
+
# Declared by a bare mention in the class body.
|
|
115
|
+
FLAGS = %i[logical_clock].freeze
|
|
116
|
+
NAMES = (SCALARS + WRITTEN + FLAGS).freeze
|
|
117
|
+
|
|
118
|
+
# Class-body declarations that no longer exist, with what replaced each.
|
|
119
|
+
RETIRED = {
|
|
120
|
+
dynamic_cells: "a tasklet fed by `key_by` has one Cell per key; write `idle_ttl_seconds` " \
|
|
121
|
+
"and `max_cells` on their own",
|
|
122
|
+
clock: "defining `event_time` makes the clock event time and `logical_clock` makes it " \
|
|
123
|
+
"logical; otherwise it is the wall clock",
|
|
124
|
+
idle_ttl_ms: "write `idle_ttl_seconds`, a whole number of seconds",
|
|
125
|
+
migrate_state: "define `migrate_state(from_version, bytes)` as an instance method",
|
|
126
|
+
migrate_change: "define `migrate_change(from_version, bytes)` as an instance method"
|
|
127
|
+
}.freeze
|
|
128
|
+
|
|
129
|
+
def self.extended(base)
|
|
130
|
+
base.setting(*SCALARS)
|
|
131
|
+
FLAGS.each { |name| base.flag(name) }
|
|
132
|
+
base.declares(*WRITTEN)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
RETIRED.each do |retired, instead|
|
|
136
|
+
define_method(retired) do |*, **|
|
|
137
|
+
raise NoMethodError.new("`#{retired}` is not a tasklet declaration any more: #{instead}",
|
|
138
|
+
retired)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def cell_keys(*keys)
|
|
143
|
+
return @cell_keys if keys.empty?
|
|
144
|
+
|
|
145
|
+
@cell_keys = keys.flatten.map(&:to_s)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# How long a keyed Cell may sit idle before it is evicted, in whole
|
|
149
|
+
# seconds. Unset, a Cell is never evicted for being idle.
|
|
150
|
+
def idle_ttl_seconds(seconds = nil)
|
|
151
|
+
return @idle_ttl_seconds if seconds.nil?
|
|
152
|
+
|
|
153
|
+
unless seconds.is_a?(Integer) && seconds.positive?
|
|
154
|
+
raise Error.new("topology.invalid_setting", declaration: "tasklet '#{self}'", setting: "idle_ttl_seconds",
|
|
155
|
+
value: seconds.inspect,
|
|
156
|
+
expected: "a positive whole number of seconds")
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
@idle_ttl_seconds = seconds
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Whether the class body declared the logical clock.
|
|
163
|
+
def logical_clock? = @logical_clock || false
|
|
164
|
+
|
|
165
|
+
# Declare the class a cell's State is. Its `to_h` is what is encoded
|
|
166
|
+
# and its `.from_h` is what rebuilds it, so a rename of the class does
|
|
167
|
+
# not break the restore of records written before it: the durable form
|
|
168
|
+
# is the Hash, not the class. A tasklet that defines no `init_state`
|
|
169
|
+
# starts each Cell from `type.new`.
|
|
170
|
+
def state_type(type = nil)
|
|
171
|
+
return @state_type if type.nil?
|
|
172
|
+
|
|
173
|
+
@state_type = type
|
|
174
|
+
@state_codec = TypeCodec.new(type, "state")
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Declare the class a recorded Change is. Same contract as `state_type`.
|
|
178
|
+
def change_type(type = nil)
|
|
179
|
+
return @change_type if type.nil?
|
|
180
|
+
|
|
181
|
+
@change_type = type
|
|
182
|
+
@change_codec = TypeCodec.new(type, "change")
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# The base every authored tasklet subclasses.
|
|
187
|
+
#
|
|
188
|
+
# The class body carries the `tasklet_name` and the scalar declarations; the
|
|
189
|
+
# instance methods carry the behaviour. Which optional hooks a tasklet
|
|
190
|
+
# declares is read from the methods it defines — `apply_change` present means
|
|
191
|
+
# it folds changes, `event_time` present means its clock is event time — so a
|
|
192
|
+
# declaration and its implementation cannot disagree. A value leaves only
|
|
193
|
+
# through `ctx.emit`; what `run` answers is not output.
|
|
194
|
+
class Tasklet
|
|
195
|
+
extend Declarative
|
|
196
|
+
extend TaskletSettings
|
|
197
|
+
|
|
198
|
+
# `tasklet_name` is deliberately NOT inherited: a node identity is per
|
|
199
|
+
# class, and a subclass silently taking its parent's would make two
|
|
200
|
+
# tasklets claim one node — which the builder refuses, at the far end of
|
|
201
|
+
# the declaration rather than here.
|
|
202
|
+
|
|
203
|
+
class << self
|
|
204
|
+
# The node identity within its relay, and the name every Cell's
|
|
205
|
+
# saved state is kept under. Written on every concrete tasklet, never
|
|
206
|
+
# derived: a derived name would move the saved state when the class is
|
|
207
|
+
# renamed. Not `name`, which is `Class#name`.
|
|
208
|
+
def tasklet_name(name = nil)
|
|
209
|
+
return @tasklet_name if name.nil?
|
|
210
|
+
|
|
211
|
+
@tasklet_name = name.to_s
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# The class whose instance methods run for this tasklet: the tasklet
|
|
215
|
+
# itself, or the short-form relay whose `run` body it was generated
|
|
216
|
+
# from.
|
|
217
|
+
def body = self
|
|
218
|
+
|
|
219
|
+
# A tasklet has no output: the one-tasklet short form is a relay,
|
|
220
|
+
# and its output is written there.
|
|
221
|
+
def output(*, **)
|
|
222
|
+
raise Error.new("topology.relay_field_on_tasklet",
|
|
223
|
+
tasklet: to_s, fields: ["`output`"],
|
|
224
|
+
instead: "a Flezha::Relay subclass with a `run` body")
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# The resolved declaration. Built once: it instantiates the tasklet,
|
|
228
|
+
# and the dispatcher reads it on the hot path.
|
|
229
|
+
def definition
|
|
230
|
+
@definition ||= build_definition
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
private
|
|
234
|
+
|
|
235
|
+
def build_definition
|
|
236
|
+
check_name
|
|
237
|
+
definition = Definition.new(self)
|
|
238
|
+
check_hooks(definition)
|
|
239
|
+
if @cell_keys&.empty?
|
|
240
|
+
raise Error.new("topology.static_tasklet_without_cell_keys", tasklet: @tasklet_name)
|
|
241
|
+
end
|
|
242
|
+
raise Error.new("topology.two_clocks", tasklet: @tasklet_name) if definition.event_time? && logical_clock?
|
|
243
|
+
|
|
244
|
+
check_state(definition)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def check_name
|
|
248
|
+
return unless @tasklet_name.to_s.empty?
|
|
249
|
+
|
|
250
|
+
raise Error.new("topology.tasklet_name_missing", tasklet: name || "an anonymous tasklet class")
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def check_hooks(definition)
|
|
254
|
+
unless body.method_defined?(:run)
|
|
255
|
+
raise Error.new("topology.missing_body", declaration: "tasklet '#{@tasklet_name}'", body: "`run`")
|
|
256
|
+
end
|
|
257
|
+
return unless definition.declares?(:initial_state)
|
|
258
|
+
|
|
259
|
+
raise Error.new("topology.retired_callback", tasklet: @tasklet_name, callback: "`initial_state`",
|
|
260
|
+
instead: "`init_state(cell_key)`")
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# State without a fold is legitimate: a value seeded at start and read
|
|
264
|
+
# but never mutated has nothing to journal, and `ctx.change` already
|
|
265
|
+
# refuses at the point of use. A fold with no state to fold into is not.
|
|
266
|
+
def check_state(definition)
|
|
267
|
+
if definition.apply_change? && !definition.stateful?
|
|
268
|
+
raise Error.new("topology.change_without_state", tasklet: @tasklet_name)
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# A state that cannot be built or encoded fails at the first Cell or
|
|
272
|
+
# the first restart. Build the starting state now instead.
|
|
273
|
+
if definition.init_state? && !state_codec
|
|
274
|
+
Codec.assert_representable("#{@tasklet_name}.state", definition.build_initial_state(nil))
|
|
275
|
+
elsif !definition.init_state? && state_type
|
|
276
|
+
check_state_type
|
|
277
|
+
end
|
|
278
|
+
definition
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def check_state_type
|
|
282
|
+
state_type.new
|
|
283
|
+
rescue StandardError
|
|
284
|
+
raise Error.new("topology.state_not_constructible", tasklet: @tasklet_name, state: state_type.to_s)
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
end
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
|
|
6
|
+
require_relative "builder"
|
|
7
|
+
require_relative "codec"
|
|
8
|
+
require_relative "dispatch"
|
|
9
|
+
require_relative "state"
|
|
10
|
+
|
|
11
|
+
module Flezha
|
|
12
|
+
# Driving one tasklet without a controller, for the author's own tests.
|
|
13
|
+
#
|
|
14
|
+
# The harness is not a second implementation of the dispatcher: it drives
|
|
15
|
+
# `Dispatch.invoke` — the same function every activation kind that reaches a
|
|
16
|
+
# handler goes through — over a one-tasklet `RegisteredClass`, and
|
|
17
|
+
# `replay_equals` folds through `State.fold_restore`, the function a real
|
|
18
|
+
# restore calls. So what it proves is what the runner does, rather than
|
|
19
|
+
# something that agrees with it.
|
|
20
|
+
module Testing
|
|
21
|
+
# The recorded change log and the live state disagree: the harness's own
|
|
22
|
+
# assertion about the tasklet under test, not a refusal.
|
|
23
|
+
class ReplayMismatch < StandardError
|
|
24
|
+
def initialize(tasklet, live, replayed)
|
|
25
|
+
super(
|
|
26
|
+
"#{tasklet}: replaying the recorded change log against a fresh initial state " \
|
|
27
|
+
"produced #{replayed.inspect}, but the live state is #{live.inspect}. Either a " \
|
|
28
|
+
"handler mutated the state without recording a change, or apply_change is not a " \
|
|
29
|
+
"pure fold — both are lost on restart and reported nowhere until then."
|
|
30
|
+
)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# A replayed input made an outside call its live run kept through
|
|
35
|
+
# `ctx.effect` some other way: its block ran again, or the kept result was
|
|
36
|
+
# never asked for.
|
|
37
|
+
class EffectReplayMismatch < StandardError
|
|
38
|
+
attr_reader :name
|
|
39
|
+
|
|
40
|
+
def initialize(tasklet, name, what)
|
|
41
|
+
@name = name
|
|
42
|
+
super(
|
|
43
|
+
"#{tasklet}: on the replay, effect '#{name}' #{what}. A re-run reads an outside " \
|
|
44
|
+
"call's result only through ctx.effect, with the same name in the same order."
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# The longest effect name, in bytes, as the core refuses it.
|
|
50
|
+
MAX_EFFECT_NAME = 255
|
|
51
|
+
|
|
52
|
+
# One input's effect results, by `[name, ordinal]`, and the invocation's
|
|
53
|
+
# count of each name and the call whose block is running.
|
|
54
|
+
Effects = Struct.new(:kept, :replaying, :ordinals, :running)
|
|
55
|
+
private_constant :Effects
|
|
56
|
+
|
|
57
|
+
# What one invocation produced.
|
|
58
|
+
Outcome = Struct.new(:outputs, :changes, :timers, keyword_init: true)
|
|
59
|
+
|
|
60
|
+
# The core bridge a `Ctx` asks, answered as the core would for one
|
|
61
|
+
# tasklet with no controller behind it.
|
|
62
|
+
class FakeCore
|
|
63
|
+
BASE32 = [*"a".."z", *"2".."7"].freeze
|
|
64
|
+
|
|
65
|
+
# Answers by handle, until the invocation that made them ends, as the
|
|
66
|
+
# core releases them.
|
|
67
|
+
attr_reader :spawns
|
|
68
|
+
|
|
69
|
+
def initialize(tasklet)
|
|
70
|
+
@tasklet = tasklet
|
|
71
|
+
@spawns = {}
|
|
72
|
+
@spawn_count = 0
|
|
73
|
+
# Each live input's effect results, in order, and how many replays ran.
|
|
74
|
+
@kept = []
|
|
75
|
+
@replays = 0
|
|
76
|
+
@effects = Effects.new({}, false, {}, nil)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Serve the next input: a live one keeps its results, the n-th replay
|
|
80
|
+
# reads what the n-th live input kept.
|
|
81
|
+
def begin_input(replay)
|
|
82
|
+
if replay
|
|
83
|
+
@effects = Effects.new(@kept[@replays] || {}, true, {}, nil)
|
|
84
|
+
@replays += 1
|
|
85
|
+
else
|
|
86
|
+
@effects = Effects.new({}, false, {}, nil)
|
|
87
|
+
@kept << @effects.kept
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# A replay must ask for every result its live input kept.
|
|
92
|
+
def finish_input
|
|
93
|
+
unasked = @effects.kept.each_key.find { |name, ordinal| ordinal >= @effects.ordinals.fetch(name, 0) }
|
|
94
|
+
raise EffectReplayMismatch.new(@tasklet, unasked.first, "was never asked for") if @effects.replaying && unasked
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# A harness has no controller, so a spawn resolves at once to what one
|
|
98
|
+
# would have answered: spawned under the given id or a generated-looking
|
|
99
|
+
# one, or skipped on a replay.
|
|
100
|
+
def spawn_request(_token, factory_id, id, _payload, _if_running, replay, _offset)
|
|
101
|
+
id = generated_id(factory_id) if id.empty? && !replay
|
|
102
|
+
outcome = replay ? "skipped_on_replay" : "spawned"
|
|
103
|
+
handle = @spawn_count += 1
|
|
104
|
+
@spawns[handle] = JSON.generate({ "id" => id, "outcome" => outcome })
|
|
105
|
+
handle
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def spawn_result(handle, _timeout_ms)
|
|
109
|
+
@spawns.fetch(handle) { raise Error.new("runtime.spawn_handle_released", handle: handle) }
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# No controller and no listener, so the part, already encoded, goes
|
|
113
|
+
# nowhere.
|
|
114
|
+
def part(_token, _item, _payload) = nil
|
|
115
|
+
|
|
116
|
+
# The core's answer to a begun `ctx.effect`, over this input's results:
|
|
117
|
+
# the kept one, or run the block (on a live input only).
|
|
118
|
+
def effect_begin(_token, _item, name)
|
|
119
|
+
raise Error.new("effect.bad_name", name: name.inspect) if name.empty? || name.bytesize > MAX_EFFECT_NAME
|
|
120
|
+
|
|
121
|
+
effects = @effects
|
|
122
|
+
raise Error.new("effect.nested", name: name) if effects.running
|
|
123
|
+
|
|
124
|
+
ordinal = effects.ordinals.fetch(name, 0)
|
|
125
|
+
effects.ordinals[name] = ordinal + 1
|
|
126
|
+
key = [name, ordinal]
|
|
127
|
+
return [:kept, effects.kept[key]] if effects.kept.key?(key)
|
|
128
|
+
raise EffectReplayMismatch.new(@tasklet, name, "ran its block") if effects.replaying
|
|
129
|
+
|
|
130
|
+
effects.running = key
|
|
131
|
+
[:run, nil]
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Keep a live input's result, as the controller does.
|
|
135
|
+
def effect_end(_token, _item, payload)
|
|
136
|
+
effects = @effects
|
|
137
|
+
raise "an end follows a begin that runs" unless effects.running
|
|
138
|
+
|
|
139
|
+
effects.kept[effects.running] = payload.b
|
|
140
|
+
effects.running = nil
|
|
141
|
+
nil
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def effect_release(_token, _item)
|
|
145
|
+
@effects.running = nil
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
private
|
|
149
|
+
|
|
150
|
+
# The controller's shape: the factory id trimmed so the whole id fits the
|
|
151
|
+
# 32-byte limit, then 16 base32 characters.
|
|
152
|
+
def generated_id(factory_id)
|
|
153
|
+
prefix = factory_id.byteslice(0, 15).scrub("").sub(/[-_]+\z/, "")
|
|
154
|
+
"#{prefix}-#{SecureRandom.alphanumeric(16, chars: BASE32)}"
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
private_constant :FakeCore
|
|
158
|
+
|
|
159
|
+
# A `ctx.effect` keeps its result on a live `feed`; the n-th
|
|
160
|
+
# `feed(..., replay: true)` replays the n-th live input and is served what
|
|
161
|
+
# it kept, and raises `EffectReplayMismatch` when a block runs there or a
|
|
162
|
+
# kept result is never asked for.
|
|
163
|
+
class TaskletTestHarness
|
|
164
|
+
attr_reader :log
|
|
165
|
+
|
|
166
|
+
def initialize(tasklet_class, cell_key: nil, relay: "harness")
|
|
167
|
+
@definition = tasklet_class.definition
|
|
168
|
+
@cell_key = cell_key
|
|
169
|
+
# What every invocation's activation names: the cell this harness drives.
|
|
170
|
+
@target = { relay: relay, cell_key: cell_key }.freeze
|
|
171
|
+
# The real channel builder, not a hand-written spec: one entry channel
|
|
172
|
+
# into the tasklet, and one plugin output so `ctx.emit` has somewhere
|
|
173
|
+
# to go. A tasklet under test emits to be observed, not to be routed,
|
|
174
|
+
# and the output names MessagePack so `outputs` reports exactly the value
|
|
175
|
+
# a handler emitted.
|
|
176
|
+
builder = ChannelBuilder.new("<harness>")
|
|
177
|
+
builder.input("in").into(tasklet_class).output("out", codec: :msgpack)
|
|
178
|
+
@node = builder.tasklets.first
|
|
179
|
+
@spec = ClassSpec.new(factory_id: "<harness>", tasklets: builder.tasklets, channels: builder.channels)
|
|
180
|
+
# The dispatcher's own cell store, so the state this harness reports
|
|
181
|
+
# is the state a runner would hold.
|
|
182
|
+
@registered = RegisteredClass.new(@spec)
|
|
183
|
+
@address = RegisteredClass.address(@target, @node)
|
|
184
|
+
# No core runs here, so the harness names the cell's identity itself.
|
|
185
|
+
@registered.keep_identity(@address, "#{relay}/#{@node.name}/#{cell_key}".b)
|
|
186
|
+
@core = FakeCore.new(@definition.name)
|
|
187
|
+
@deps = DispatchDeps.new(registry: nil, bridge: @core)
|
|
188
|
+
@log = []
|
|
189
|
+
@change_seq = 0
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# The cell's live state, as the dispatcher left it.
|
|
193
|
+
def state = @registered.state_of(@node, @address)
|
|
194
|
+
|
|
195
|
+
# Run the tasklet against one input; `replay` replays the next live
|
|
196
|
+
# input, in order.
|
|
197
|
+
def feed(input, now: 0, replay: false)
|
|
198
|
+
@core.begin_input(replay)
|
|
199
|
+
outcome = run(now, replay) { |ctx| @definition.instance.run(input, ctx) }
|
|
200
|
+
@core.finish_input
|
|
201
|
+
outcome
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Fire a per-cell timer. `now` IS the scheduled time, as it is live.
|
|
205
|
+
def fire_timer(kind, scheduled_at)
|
|
206
|
+
run(scheduled_at, false) do |ctx|
|
|
207
|
+
@definition.instance.on_timer({ kind: kind, scheduled_at: scheduled_at }, ctx)
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Run the declared compensation over recorded forward outputs.
|
|
212
|
+
def compensate(outputs)
|
|
213
|
+
run(0, false) { |ctx| @definition.instance.compensate(outputs, ctx) }
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Rebuild the state the way a restore does, from a snapshot and a change
|
|
217
|
+
# log — the same call the dispatcher answers `restore_fold` with.
|
|
218
|
+
def restore(snapshot: nil, changes: [])
|
|
219
|
+
@registered.set_state(@address, fold(snapshot, changes))
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Fold every recorded change into a fresh initial state and require the
|
|
223
|
+
# answer to equal the live one.
|
|
224
|
+
#
|
|
225
|
+
# In this SDK that catches both failures at once: a handler that mutated
|
|
226
|
+
# the state without recording a change, and an `apply_change` that is not
|
|
227
|
+
# a pure fold. The SDKs whose state is guarded can only catch the second.
|
|
228
|
+
def replay_equals
|
|
229
|
+
replayed = fold(nil, @log)
|
|
230
|
+
raise ReplayMismatch.new(@definition.name, state, replayed) unless replayed == state
|
|
231
|
+
|
|
232
|
+
true
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
private
|
|
236
|
+
|
|
237
|
+
# What the body answers is dropped, as the dispatcher drops it.
|
|
238
|
+
def run(now, replay)
|
|
239
|
+
activation = @target.merge(change_seq: @change_seq)
|
|
240
|
+
ctx = begin
|
|
241
|
+
Dispatch.invoke(activation, @registered, @node, @deps, now, replay) { |c| yield(c) }
|
|
242
|
+
ensure
|
|
243
|
+
@core.spawns.clear
|
|
244
|
+
end
|
|
245
|
+
record = ctx.send(:effect_record, nil)
|
|
246
|
+
@log.concat(record[:changes])
|
|
247
|
+
@change_seq += record[:changes].size
|
|
248
|
+
Outcome.new(
|
|
249
|
+
outputs: record[:outputs].map { |o| Codec.decode(o[:payload]) },
|
|
250
|
+
changes: record[:changes].map { |bytes| decode_change(bytes) },
|
|
251
|
+
timers: record[:timers]
|
|
252
|
+
)
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def fold(snapshot, changes)
|
|
256
|
+
State.fold_restore(
|
|
257
|
+
@definition,
|
|
258
|
+
{ snapshot: snapshot && { bytes: snapshot }, changes: changes.map { |bytes| { bytes: bytes } } },
|
|
259
|
+
@cell_key, @definition.name
|
|
260
|
+
)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def decode_change(bytes)
|
|
264
|
+
codec = @definition.change_codec
|
|
265
|
+
codec ? codec.decode(bytes) : Codec.decode(bytes)
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Flezha
|
|
4
|
+
VERSION = "0.1.0"
|
|
5
|
+
|
|
6
|
+
# The activation schema this gem was built against. Passed at context
|
|
7
|
+
# creation and compared with the native library's, so a skewed pair fails
|
|
8
|
+
# naming both versions before any controller connection is attempted.
|
|
9
|
+
ACTIVATION_SCHEMA_VERSION = 8
|
|
10
|
+
end
|
data/lib/flezha.rb
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The Flezha Ruby SDK: a poll host over `flz_core`.
|
|
4
|
+
#
|
|
5
|
+
# The SDK contains no kernel code. Transport, framing, ordering, per-cell
|
|
6
|
+
# exclusivity, durability, retries, the retry-to-park ladder, timers, credit,
|
|
7
|
+
# routing, the steps driver and reconnect all live in the core; this gem
|
|
8
|
+
# brings the authoring surface, the codec for user types, the threading host
|
|
9
|
+
# and idiomatic errors.
|
|
10
|
+
#
|
|
11
|
+
# class Echo < Flezha::Tasklet
|
|
12
|
+
# tasklet_name "echo"
|
|
13
|
+
# def run(input, ctx) = ctx.emit(input)
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
# class EchoRelay < Flezha::Relay
|
|
17
|
+
# factory_id "echo"
|
|
18
|
+
# channels { |c| c.input("input", codec: :text).into(Echo).output("out") }
|
|
19
|
+
# end
|
|
20
|
+
#
|
|
21
|
+
# Flezha.create_runner("default").register_factory(EchoRelay).run
|
|
22
|
+
|
|
23
|
+
require_relative "flezha/version"
|
|
24
|
+
require_relative "flezha/errors"
|
|
25
|
+
require_relative "flezha/logger"
|
|
26
|
+
require_relative "flezha/identity"
|
|
27
|
+
require_relative "flezha/codec"
|
|
28
|
+
require_relative "flezha/response"
|
|
29
|
+
require_relative "flezha/state"
|
|
30
|
+
require_relative "flezha/builder"
|
|
31
|
+
require_relative "flezha/tasklet"
|
|
32
|
+
require_relative "flezha/route_latest"
|
|
33
|
+
require_relative "flezha/relay"
|
|
34
|
+
require_relative "flezha/graph"
|
|
35
|
+
require_relative "flezha/ctx"
|
|
36
|
+
require_relative "flezha/dispatch"
|
|
37
|
+
require_relative "flezha/native"
|
|
38
|
+
require_relative "flezha/control"
|
|
39
|
+
require_relative "flezha/executor"
|
|
40
|
+
require_relative "flezha/runner"
|
|
41
|
+
require_relative "flezha/local"
|
|
42
|
+
require_relative "flezha/testing"
|