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,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"