flezha 0.1.0-x86_64-linux
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +73 -0
- data/README.md +57 -0
- data/THIRD_PARTY_NOTICES.md +2888 -0
- data/ext/flz_core_ffi/Cargo.toml +29 -0
- data/ext/flz_core_ffi/extconf.rb +43 -0
- data/ext/flz_core_ffi/src/lib.rs +7 -0
- data/lib/flezha/3.3/flz_core_ffi.so +0 -0
- data/lib/flezha/3.4/flz_core_ffi.so +0 -0
- data/lib/flezha/builder.rb +504 -0
- data/lib/flezha/codec.rb +217 -0
- data/lib/flezha/control.rb +90 -0
- data/lib/flezha/ctx.rb +389 -0
- data/lib/flezha/declarative.rb +86 -0
- data/lib/flezha/dispatch.rb +395 -0
- data/lib/flezha/errors.rb +58 -0
- data/lib/flezha/executor.rb +329 -0
- data/lib/flezha/graph.rb +135 -0
- data/lib/flezha/identity.rb +36 -0
- data/lib/flezha/local.rb +146 -0
- data/lib/flezha/logger.rb +56 -0
- data/lib/flezha/native.rb +112 -0
- data/lib/flezha/relay.rb +132 -0
- data/lib/flezha/response.rb +27 -0
- data/lib/flezha/route_latest.rb +54 -0
- data/lib/flezha/runner.rb +289 -0
- data/lib/flezha/state.rb +66 -0
- data/lib/flezha/tasklet.rb +288 -0
- data/lib/flezha/testing.rb +269 -0
- data/lib/flezha/version.rb +10 -0
- data/lib/flezha.rb +42 -0
- metadata +182 -0
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "etc"
|
|
4
|
+
|
|
5
|
+
require_relative "dispatch"
|
|
6
|
+
require_relative "logger"
|
|
7
|
+
|
|
8
|
+
module Flezha
|
|
9
|
+
# The concurrency host: what runs `poll -> dispatch -> complete`, and on what.
|
|
10
|
+
#
|
|
11
|
+
# Two members in v1, and the seam between them may never observe or affect
|
|
12
|
+
# per-cell ordering. Exclusivity is the core's handout guarantee — one
|
|
13
|
+
# outstanding activation per Cell — and an activation is completed only
|
|
14
|
+
# after its dispatch has returned, so ordering holds for any executor that
|
|
15
|
+
# does not complete early. That invariant is this seam's entire contract.
|
|
16
|
+
module Executor
|
|
17
|
+
# What `pool_supervise` answers: spawn a base worker, stop, spawn a grown
|
|
18
|
+
# one; anything else is nothing to do this wait.
|
|
19
|
+
POOL_SPAWN_BASE = 1
|
|
20
|
+
POOL_STOP = 2
|
|
21
|
+
POOL_SPAWN_GROWN = 3
|
|
22
|
+
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
# One fetch loop, and the one place the three answers `poll` can give are
|
|
26
|
+
# interpreted.
|
|
27
|
+
#
|
|
28
|
+
# Both executors fetch, and the deferred Ractor one will too — so what a
|
|
29
|
+
# timeout, an activation and a shutdown each mean lives here rather than in
|
|
30
|
+
# one correct copy per executor. The block is yielded each activation. The
|
|
31
|
+
# loop ends when `stopping` answers true, when the core says it is shutting
|
|
32
|
+
# down, and after `retire_ms` of timed-out polls when that is positive: the
|
|
33
|
+
# governor's retire time, for a grown worker.
|
|
34
|
+
def fetch(core, poll_ms, stopping, retire_ms = 0)
|
|
35
|
+
idle = 0
|
|
36
|
+
until stopping.call
|
|
37
|
+
activation = core.poll(poll_ms)
|
|
38
|
+
return :shutting_down if activation == :shutting_down
|
|
39
|
+
|
|
40
|
+
if activation.nil?
|
|
41
|
+
idle += poll_ms
|
|
42
|
+
return :retired if retire_ms.positive? && idle >= retire_ms
|
|
43
|
+
else
|
|
44
|
+
idle = 0
|
|
45
|
+
yield activation
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
:stopped
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# When a drain of `seconds` must be finished by.
|
|
52
|
+
def deadline(seconds) = Process.clock_gettime(Process::CLOCK_MONOTONIC) + seconds
|
|
53
|
+
|
|
54
|
+
# How long is left of it, never negative — `Thread#join(0)` polls rather
|
|
55
|
+
# than blocking, which is what a passed deadline should do.
|
|
56
|
+
def remaining(deadline)
|
|
57
|
+
[deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0].max
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Say what the drain did not finish. Silence here is the failure mode the
|
|
61
|
+
# bound exists to avoid reintroducing: a runner that leaves without
|
|
62
|
+
# reporting what it abandoned looks identical to one that drained.
|
|
63
|
+
def report_undrained(count, seconds)
|
|
64
|
+
return if count.zero?
|
|
65
|
+
|
|
66
|
+
Flezha.log.warn(
|
|
67
|
+
"#{count} activation(s) were still running after the #{seconds}s drain; the " \
|
|
68
|
+
"runner is exiting anyway. The core re-hands unanswered work on the next bind — find " \
|
|
69
|
+
"the handler that does not return."
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# What both executors share: the core's pool governor sizes the pool over
|
|
74
|
+
# this machine's processor count, and its supervisor's answers are what
|
|
75
|
+
# this host spawns. A host only brings the threads.
|
|
76
|
+
module Pooled
|
|
77
|
+
attr_reader :base, :ceiling
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def open_pool(core)
|
|
82
|
+
@core = core
|
|
83
|
+
@base, @ceiling, @poll_ms, @retire_ms, drain_ms = core.pool_open(Etc.nprocessors)
|
|
84
|
+
@drain_s = drain_ms / 1000.0
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# The base workers into the seats the governor reserved, then its supervisor.
|
|
88
|
+
def start_pool
|
|
89
|
+
@base.times { start_worker(false) }
|
|
90
|
+
@supervisor = Thread.new { supervise }
|
|
91
|
+
@supervisor.name = "flezha-supervisor"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# One governor answer per wait, at most a second apart, and the core's
|
|
95
|
+
# notices written on each, so an idle runner's notices still reach its log.
|
|
96
|
+
def supervise
|
|
97
|
+
until @stopping
|
|
98
|
+
answer = @core.pool_supervise
|
|
99
|
+
Flezha.log_notices(@core.take_notices)
|
|
100
|
+
break if answer == POOL_STOP || @stopping
|
|
101
|
+
|
|
102
|
+
start_worker(answer == POOL_SPAWN_GROWN) if [POOL_SPAWN_BASE, POOL_SPAWN_GROWN].include?(answer)
|
|
103
|
+
end
|
|
104
|
+
rescue StandardError => e
|
|
105
|
+
Flezha.log.error("the pool supervisor ended on #{e.class}: #{e.message}")
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# A seat the governor reserved; a thread that cannot start gives it back.
|
|
109
|
+
def start_worker(grown)
|
|
110
|
+
spawn_member(grown)
|
|
111
|
+
rescue ThreadError => e
|
|
112
|
+
@core.pool_left
|
|
113
|
+
Flezha.log.warn("could not start a worker thread: #{e.message}")
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# A worker that dispatches on itself: the thread executor's every worker,
|
|
117
|
+
# and a grown seat under either, because a parked completion needs a
|
|
118
|
+
# thread and a stalled scheduler cannot supply one.
|
|
119
|
+
def run_worker(grown)
|
|
120
|
+
Executor.fetch(@core, @poll_ms, -> { @stopping }, grown ? @retire_ms : 0) do |activation|
|
|
121
|
+
Dispatch.dispatch(activation, @core, @deps)
|
|
122
|
+
end
|
|
123
|
+
rescue StandardError => e
|
|
124
|
+
# A worker that dies silently is a pool that shrinks with no report;
|
|
125
|
+
# the governor replaces it, but only this line says why it left.
|
|
126
|
+
Flezha.log.error("a worker thread ended on #{e.class}: #{e.message}")
|
|
127
|
+
ensure
|
|
128
|
+
@core.pool_left
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# The default executor: a base pool of Ruby threads each looping
|
|
134
|
+
# `poll -> dispatch -> complete` on itself, plus the core pool governor's
|
|
135
|
+
# supervisor, whose answers grow the pool under starvation and restore it
|
|
136
|
+
# when a worker left.
|
|
137
|
+
#
|
|
138
|
+
# This is the Rust host's model rather than Java's hand-off, and the reason
|
|
139
|
+
# is the reason behind both shipped hosts: Java hands off because a parked
|
|
140
|
+
# virtual thread costs a stack and nothing else — which is also exactly why
|
|
141
|
+
# Java's fetchers never wait on user code. An MRI thread is a
|
|
142
|
+
# real OS thread with a real stack, so hand-off would buy nothing and cost a
|
|
143
|
+
# thread per activation up to the core's in-flight cap.
|
|
144
|
+
#
|
|
145
|
+
# Concurrency is bounded by the core's `FLEZHA_MAX_INFLIGHT_ACTIVATIONS`,
|
|
146
|
+
# never by the pool size. The pool is a servicing detail.
|
|
147
|
+
class ThreadExecutor
|
|
148
|
+
include Executor::Pooled
|
|
149
|
+
|
|
150
|
+
def initialize(core, deps)
|
|
151
|
+
open_pool(core)
|
|
152
|
+
@deps = deps
|
|
153
|
+
@workers = []
|
|
154
|
+
@supervisor = nil
|
|
155
|
+
@lock = Mutex.new
|
|
156
|
+
@stopping = false
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def name = "threads"
|
|
160
|
+
|
|
161
|
+
# How many workers are alive now. Reported in the startup line and read by
|
|
162
|
+
# the pool-growth tests.
|
|
163
|
+
def size = @lock.synchronize { @workers.count(&:alive?) }
|
|
164
|
+
|
|
165
|
+
def start
|
|
166
|
+
start_pool
|
|
167
|
+
self
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Stop fetching without the core's word. A local run ends while the
|
|
171
|
+
# process-wide poll core keeps running — there is no controller session to
|
|
172
|
+
# shut down, and shutting the core would stop every later run in the
|
|
173
|
+
# process — so the flag is raised first and the workers leave on their next
|
|
174
|
+
# poll timeout instead of on the drain deadline.
|
|
175
|
+
def stop_fetching
|
|
176
|
+
@stopping = true
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Stop fetching and drain. The core says when: a worker leaves on the poll
|
|
180
|
+
# that reports the core shutting down, which the core reports only once
|
|
181
|
+
# the shutdown sequence has shipped every Cell's state through these
|
|
182
|
+
# same workers; a worker inside a handler finishes it, completes it, and
|
|
183
|
+
# only then observes it. Bounded by the governor's drain bound — a handler
|
|
184
|
+
# that never returns must not keep the runner alive forever — and the flag
|
|
185
|
+
# is raised after the join so a worker spawned late still leaves.
|
|
186
|
+
def stop
|
|
187
|
+
deadline = Executor.deadline(@drain_s)
|
|
188
|
+
@lock.synchronize { @workers.dup }.each { |worker| worker.join(Executor.remaining(deadline)) }
|
|
189
|
+
@stopping = true
|
|
190
|
+
@supervisor&.join(Executor.remaining(deadline))
|
|
191
|
+
undrained = @lock.synchronize { @workers.count(&:alive?) }
|
|
192
|
+
Executor.report_undrained(undrained, @drain_s)
|
|
193
|
+
@lock.synchronize { @workers.clear }
|
|
194
|
+
Flezha.log_notices(@core.take_notices)
|
|
195
|
+
undrained
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
private
|
|
199
|
+
|
|
200
|
+
def spawn_member(grown)
|
|
201
|
+
@lock.synchronize do
|
|
202
|
+
@workers.select!(&:alive?)
|
|
203
|
+
worker = Thread.new { run_worker(grown) }
|
|
204
|
+
worker.name = "flezha-worker-#{@workers.size}"
|
|
205
|
+
@workers << worker
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# The opt-in executor for I/O-heavy handlers: real-thread fetchers hand
|
|
211
|
+
# activations to fibers through a `Thread::Queue`, which is scheduler-aware,
|
|
212
|
+
# so a handler's I/O yields instead of blocking a thread.
|
|
213
|
+
#
|
|
214
|
+
# The fetcher stays a real thread under this executor too. `poll` blocks in
|
|
215
|
+
# native code, and `docs/internal/sdk/poll_core.md` is explicit that fetching on a
|
|
216
|
+
# runtime's shared I/O pool starves everything else that pool serves.
|
|
217
|
+
#
|
|
218
|
+
# Completion happens on the fiber that dispatched, not on the fetcher: the
|
|
219
|
+
# ABI accepts a completion from any thread, and the ordering guarantee is the
|
|
220
|
+
# core's handout, not this executor's.
|
|
221
|
+
class FiberExecutor
|
|
222
|
+
include Executor::Pooled
|
|
223
|
+
|
|
224
|
+
# The queue depth between the fetchers and the fibers. Bounded so a fetcher
|
|
225
|
+
# cannot run arbitrarily far ahead of the handlers; the core's in-flight
|
|
226
|
+
# cap bounds the total either way.
|
|
227
|
+
QUEUE_DEPTH = 64
|
|
228
|
+
|
|
229
|
+
def initialize(core, deps, scheduler: nil)
|
|
230
|
+
open_pool(core)
|
|
231
|
+
@deps = deps
|
|
232
|
+
@scheduler = scheduler
|
|
233
|
+
@queue = Thread::SizedQueue.new(QUEUE_DEPTH)
|
|
234
|
+
@threads = []
|
|
235
|
+
@stopping = false
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def name = "fibers"
|
|
239
|
+
def size = @threads.count(&:alive?)
|
|
240
|
+
|
|
241
|
+
def start
|
|
242
|
+
@dispatcher = Thread.new { run_dispatcher }
|
|
243
|
+
@dispatcher.name = "flezha-fibers"
|
|
244
|
+
start_pool
|
|
245
|
+
self
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Stop fetching without the core's word; see the thread executor's.
|
|
249
|
+
def stop_fetching
|
|
250
|
+
@stopping = true
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# The fetchers leave on the core's word, as the thread executor's workers
|
|
254
|
+
# do; the flag is raised after their join.
|
|
255
|
+
def stop
|
|
256
|
+
deadline = Executor.deadline(@drain_s)
|
|
257
|
+
@threads.dup.each { |thread| thread.join(Executor.remaining(deadline)) }
|
|
258
|
+
@stopping = true
|
|
259
|
+
@supervisor&.join(Executor.remaining(deadline))
|
|
260
|
+
# Closing the queue is what ends the dispatcher: a scheduler-aware pop
|
|
261
|
+
# answers nil on a closed queue rather than parking forever.
|
|
262
|
+
@queue.close
|
|
263
|
+
# Bounded for the reason the thread executor's join is: closing a
|
|
264
|
+
# scheduler runs its outstanding fibers to completion, and a fiber that
|
|
265
|
+
# never returns would hold the drain open forever.
|
|
266
|
+
@dispatcher&.join(Executor.remaining(deadline))
|
|
267
|
+
undrained = [@dispatcher, *@threads].count { |thread| thread&.alive? }
|
|
268
|
+
Executor.report_undrained(undrained, @drain_s)
|
|
269
|
+
Flezha.log_notices(@core.take_notices)
|
|
270
|
+
undrained
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
private
|
|
274
|
+
|
|
275
|
+
# A fetcher for a base seat; a grown seat is a worker that dispatches on
|
|
276
|
+
# itself, since a scheduler stalled in a parked completion cannot rescue itself.
|
|
277
|
+
def spawn_member(grown)
|
|
278
|
+
@threads.select!(&:alive?)
|
|
279
|
+
thread = Thread.new { grown ? run_worker(true) : run_fetcher }
|
|
280
|
+
thread.name = "flezha-fetcher-#{@threads.size}"
|
|
281
|
+
@threads << thread
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def run_fetcher
|
|
285
|
+
Executor.fetch(@core, @poll_ms, -> { @stopping }) { |activation| @queue.push(activation) }
|
|
286
|
+
rescue ClosedQueueError
|
|
287
|
+
nil
|
|
288
|
+
rescue StandardError => e
|
|
289
|
+
Flezha.log.error("a fetcher thread ended on #{e.class}: #{e.message}")
|
|
290
|
+
ensure
|
|
291
|
+
@core.pool_left
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# The pop runs in a scheduled fiber, so a quiet queue yields to the event
|
|
295
|
+
# loop and a handler fiber is resumed as soon as its I/O is ready. That loop
|
|
296
|
+
# is the scheduler's close, which Ruby runs when this thread ends and which
|
|
297
|
+
# runs every fiber to completion, the pop's included: a drain here means
|
|
298
|
+
# what it means under the thread executor. Closing the scheduler by hand as
|
|
299
|
+
# well would be a second close, which the scheduler refuses.
|
|
300
|
+
def run_dispatcher
|
|
301
|
+
Fiber.set_scheduler(build_scheduler)
|
|
302
|
+
Fiber.schedule { pop_and_dispatch }
|
|
303
|
+
rescue StandardError => e
|
|
304
|
+
Flezha.log.error("the fiber dispatcher ended on #{e.class}: #{e.message}")
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def pop_and_dispatch
|
|
308
|
+
while (activation = @queue.pop)
|
|
309
|
+
Fiber.schedule { Dispatch.dispatch(activation, @core, @deps) }
|
|
310
|
+
end
|
|
311
|
+
rescue StandardError => e
|
|
312
|
+
Flezha.log.error("the fiber dispatcher ended on #{e.class}: #{e.message}")
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def build_scheduler
|
|
316
|
+
return @scheduler.call if @scheduler
|
|
317
|
+
|
|
318
|
+
begin
|
|
319
|
+
require "async"
|
|
320
|
+
rescue LoadError
|
|
321
|
+
raise Error.new("runtime.executor_unavailable",
|
|
322
|
+
executor: "fibers",
|
|
323
|
+
reason: "it needs a Fiber::Scheduler; add the `async` gem, or pass `scheduler:` " \
|
|
324
|
+
"a callable answering one")
|
|
325
|
+
end
|
|
326
|
+
Async::Scheduler.new
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
end
|
data/lib/flezha/graph.rb
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
require_relative "builder"
|
|
6
|
+
require_relative "errors"
|
|
7
|
+
|
|
8
|
+
module Flezha
|
|
9
|
+
# The `HostClassDecl` the core is handed, and the verdict it answers.
|
|
10
|
+
#
|
|
11
|
+
# The topology is judged in ONE place, `flz_core::host::register::check`:
|
|
12
|
+
# this module records what the author declared, submits it, and the core's
|
|
13
|
+
# refusal reaches the author as the `Flezha::Error` it raises.
|
|
14
|
+
module Graph
|
|
15
|
+
# The Ruby SDK's byte in the core's `sdk_lang` allocation table.
|
|
16
|
+
SDK_LANG_RUBY = 5
|
|
17
|
+
|
|
18
|
+
# This SDK's own released version, stamped into every durable record it
|
|
19
|
+
# writes; the core packs it. `Flezha::VERSION` is the single literal
|
|
20
|
+
# `lib/flezha/version.rb` holds and the gemspec already reads, so the gem
|
|
21
|
+
# cannot ship a version its records disagree with.
|
|
22
|
+
SDK_VERSION = Flezha::VERSION
|
|
23
|
+
|
|
24
|
+
# MessagePack, shared with every SDK whose codec is MessagePack.
|
|
25
|
+
FORMAT_ID_MSGPACK = 5
|
|
26
|
+
|
|
27
|
+
module_function
|
|
28
|
+
|
|
29
|
+
# Judge one declaration and answer what the core derived from it: the
|
|
30
|
+
# channel names, kinds, consumers, output names and acknowledgement
|
|
31
|
+
# granularity it read off the record.
|
|
32
|
+
#
|
|
33
|
+
# Judge, register and declare one class, in the one order that works.
|
|
34
|
+
#
|
|
35
|
+
# `validate` writes the derived channel names and consumer edges back into
|
|
36
|
+
# the spec, so a caller that skipped it would register a spec whose edges
|
|
37
|
+
# route nowhere. Both the runner and local mode reach the core through
|
|
38
|
+
# here, so there is one such order rather than two that agree.
|
|
39
|
+
def declare(spec, core, registry)
|
|
40
|
+
validate(spec, core)
|
|
41
|
+
registry.add(spec)
|
|
42
|
+
core.declare_class(JSON.generate(to_host_class_decl(spec)))
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def validate(spec, core)
|
|
46
|
+
derived = submit(spec, core)
|
|
47
|
+
bind_edges(spec, derived)
|
|
48
|
+
derived
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The record, over the ABI, and the verdict back. A refusal raises the
|
|
52
|
+
# core's `Flezha::Error`: its code is the first issue's, and
|
|
53
|
+
# `details["issues"]` lists every issue as `{code, message, details}`.
|
|
54
|
+
def submit(spec, core)
|
|
55
|
+
JSON.parse(core.check_class(JSON.generate(to_host_class_decl(spec))))
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# The consumers the CORE derived, each carrying the closures of the chain
|
|
59
|
+
# that produced it: the builder held them aside per chain, and the derived
|
|
60
|
+
# edge names the chain. Which edge survives a re-declaration is therefore
|
|
61
|
+
# the core's answer, not a rule this SDK repeats.
|
|
62
|
+
def bind_edges(spec, derived)
|
|
63
|
+
derived["channels"].each do |channel|
|
|
64
|
+
target = spec.channels[channel["index"]]
|
|
65
|
+
target.consumers = channel["consumers"].map { |edge| target.chain_edges[edge["chain"]] }
|
|
66
|
+
target.derived_kind = channel["kind"]
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Drop the keys whose value is nil, so the JSON omits them.
|
|
71
|
+
def compact(hash) = hash.reject { |_key, value| value.nil? }
|
|
72
|
+
|
|
73
|
+
# The channel as the record carries it: the kind the author declared and
|
|
74
|
+
# the sequences declared off its handle. The consumers, the plugin kind and
|
|
75
|
+
# the output name are the core's to DERIVE from those chains.
|
|
76
|
+
def channel_json(channel)
|
|
77
|
+
compact(
|
|
78
|
+
"name" => channel.name,
|
|
79
|
+
"kind" => channel.kind.to_s,
|
|
80
|
+
"producer" => channel.producer,
|
|
81
|
+
"chains" => channel.chains,
|
|
82
|
+
"retry_max" => channel.retry_max,
|
|
83
|
+
"retry_backoff_ms" => channel.retry_backoff_ms,
|
|
84
|
+
"overflow" => channel.overflow.to_s,
|
|
85
|
+
"keyed" => channel.keyed,
|
|
86
|
+
"codec" => channel.codec,
|
|
87
|
+
"schema" => channel.schema,
|
|
88
|
+
"description" => channel.description
|
|
89
|
+
)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def tasklet_json(node)
|
|
93
|
+
definition = node.definition
|
|
94
|
+
compact(
|
|
95
|
+
"name" => node.name,
|
|
96
|
+
"cell_keys" => definition.cell_keys || [],
|
|
97
|
+
"idle_ttl_ms" => definition.idle_ttl_ms,
|
|
98
|
+
"max_cells" => definition.max_cells,
|
|
99
|
+
"cells_for_overridden" => definition.cells_for?,
|
|
100
|
+
"compensate_overridden" => definition.compensate?,
|
|
101
|
+
"clock" => definition.clock.to_s,
|
|
102
|
+
"mailbox_capacity" => definition.mailbox_capacity,
|
|
103
|
+
"max_timers_per_cell" => definition.max_timers_per_cell,
|
|
104
|
+
# A tasklet with no state journals nothing, so a logical clock over
|
|
105
|
+
# it never advances. Ruby has no per-tasklet Change type; having a
|
|
106
|
+
# state IS the same statement.
|
|
107
|
+
"change_is_nochange" => !definition.stateful?,
|
|
108
|
+
"schema_version" => definition.schema_version || 1,
|
|
109
|
+
"min_readable_schema_version" => definition.min_readable_schema_version,
|
|
110
|
+
"migrates" => definition.migrate_state? || definition.migrate_change?
|
|
111
|
+
)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# The declaration the core deserializes. The settings tree goes as the
|
|
115
|
+
# author wrote it: the core judges it and refuses a misplaced setting by
|
|
116
|
+
# name. Every key is pinned by
|
|
117
|
+
# `test/fixtures/host_class_decl_keys.txt`, which this gem's own test
|
|
118
|
+
# reads: a key the core learns that Ruby cannot spell fails that test.
|
|
119
|
+
def to_host_class_decl(spec)
|
|
120
|
+
compact(
|
|
121
|
+
"factory_id" => spec.factory_id,
|
|
122
|
+
"description" => spec.description,
|
|
123
|
+
"max_duration_ms" => spec.max_duration_ms,
|
|
124
|
+
"run" => spec.run,
|
|
125
|
+
"durable" => spec.durable,
|
|
126
|
+
"sdk_lang" => SDK_LANG_RUBY,
|
|
127
|
+
"sdk_version" => SDK_VERSION,
|
|
128
|
+
"change_format_id" => FORMAT_ID_MSGPACK,
|
|
129
|
+
"compact_after_bytes" => spec.tasklets.filter_map { |node| node.definition.compact_after_bytes }.min,
|
|
130
|
+
"channels" => spec.channels.map { |channel| channel_json(channel) },
|
|
131
|
+
"tasklets" => spec.tasklets.map { |node| tasklet_json(node) }
|
|
132
|
+
)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Flezha
|
|
4
|
+
# The replay-stable identifier of a cell.
|
|
5
|
+
#
|
|
6
|
+
# The algorithm is fixed by the SDK contract rather than by a library, so
|
|
7
|
+
# every SDK derives the same value from the same cell: FNV-1a over
|
|
8
|
+
# `identity | change seq | per-invocation ordinal`, where the identity is
|
|
9
|
+
# the bytes the core hands with the cell's first activation.
|
|
10
|
+
module Identity
|
|
11
|
+
FNV128_OFFSET_BASIS = 0x6c62272e07bb014262b821756295c58d
|
|
12
|
+
FNV128_PRIME = 0x0000000001000000000000000000013b
|
|
13
|
+
MASK128 = (1 << 128) - 1
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def fnv1a128(seed, bytes)
|
|
18
|
+
hash = seed
|
|
19
|
+
bytes.each_byte do |byte|
|
|
20
|
+
hash = (hash ^ byte) & MASK128
|
|
21
|
+
hash = (hash * FNV128_PRIME) & MASK128
|
|
22
|
+
end
|
|
23
|
+
hash
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Thirty-two lowercase hex characters. Repeated calls in one invocation
|
|
27
|
+
# differ; the n-th call of a replayed invocation equals the n-th call of the
|
|
28
|
+
# original, which is what makes it the replacement for a random UUID.
|
|
29
|
+
def stable_id(identity, change_seq, ordinal)
|
|
30
|
+
hash = fnv1a128(FNV128_OFFSET_BASIS, identity)
|
|
31
|
+
hash = fnv1a128(hash, [change_seq & 0xffffffffffffffff].pack("Q>"))
|
|
32
|
+
hash = fnv1a128(hash, [ordinal & 0xffffffff].pack("N"))
|
|
33
|
+
format("%032x", hash)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
data/lib/flezha/local.rb
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Flezha
|
|
4
|
+
# One value a graph released on a declared output. `metadata` is what a
|
|
5
|
+
# deployed run would have put on the wire, `flz.*` stamps included.
|
|
6
|
+
LocalOutput = Struct.new(:channel, :payload, :metadata, keyword_init: true)
|
|
7
|
+
|
|
8
|
+
# Running a declared relay graph in this process, with no controller.
|
|
9
|
+
#
|
|
10
|
+
# `LocalRelay.start` takes the relay class you deploy and runs the
|
|
11
|
+
# `channels` graph it already declares — no local-mode member on the class,
|
|
12
|
+
# no second declaration. What runs is the deployed machinery: entry decode,
|
|
13
|
+
# edge transforms and keying, one Cell per resolved key with its own
|
|
14
|
+
# mailbox, the retry ladder, and inter-tasklet delivery performed by the
|
|
15
|
+
# runtime.
|
|
16
|
+
#
|
|
17
|
+
# run = Flezha::LocalRelay.start(WordCount)
|
|
18
|
+
# run.on_output("totals") { |output| puts output.payload }
|
|
19
|
+
# run.feed("lines", "the quick brown fox")
|
|
20
|
+
# run.run_until_idle(5_000)
|
|
21
|
+
# run.close
|
|
22
|
+
#
|
|
23
|
+
# State is held in memory and nothing is durable. Durability, plugins,
|
|
24
|
+
# placement and migration, the dead-letter queue, observability export,
|
|
25
|
+
# authz and multi-runner scale all need a controller; the run says so through
|
|
26
|
+
# the SDK's logger every time it starts. The "Local mode" section of
|
|
27
|
+
# https://github.com/flezha/flezha/blob/main/docs/public/topics/core/running_and_testing.md#local-mode
|
|
28
|
+
# walks through it.
|
|
29
|
+
class LocalRelay
|
|
30
|
+
# Bounded so a stopped run's drain thread leaves promptly.
|
|
31
|
+
OUTPUT_POLL_MS = 100
|
|
32
|
+
|
|
33
|
+
# Register +klass+'s declared graph and start it locally. Raises naming
|
|
34
|
+
# the capability when the declaration needs a controller.
|
|
35
|
+
def self.start(klass)
|
|
36
|
+
core = Core.create
|
|
37
|
+
begin
|
|
38
|
+
spec = klass.spec
|
|
39
|
+
registry = Registry.new
|
|
40
|
+
Graph.declare(spec, core, registry)
|
|
41
|
+
# Not `start`, which is the one call that dials a controller.
|
|
42
|
+
core.local_start(spec.factory_id, "#{spec.factory_id}-local")
|
|
43
|
+
deps = DispatchDeps.new(registry: registry, bridge: core)
|
|
44
|
+
executor = ThreadExecutor.new(core, deps)
|
|
45
|
+
executor.start
|
|
46
|
+
rescue Exception # rubocop:disable Lint/RescueException
|
|
47
|
+
core.free
|
|
48
|
+
raise
|
|
49
|
+
end
|
|
50
|
+
new(core, executor)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def initialize(core, executor)
|
|
54
|
+
@core = core
|
|
55
|
+
@executor = executor
|
|
56
|
+
@sinks = {}
|
|
57
|
+
@closed = false
|
|
58
|
+
@drain = Thread.new { drain_outputs }
|
|
59
|
+
@drain.name = "flz-local-output"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Deliver every value released on the output +channel+ to the block, in
|
|
63
|
+
# release order. An output channel takes the producing tasklet's name
|
|
64
|
+
# unless the declaration named it.
|
|
65
|
+
def on_output(channel, &callback)
|
|
66
|
+
# Registered with the core first: a channel the declaration does not
|
|
67
|
+
# carry is refused there, naming it, rather than silently accepted.
|
|
68
|
+
@core.local_on_sink(channel)
|
|
69
|
+
@sinks[channel] = callback
|
|
70
|
+
self
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Feed one value's wire bytes to a declared entry channel.
|
|
74
|
+
def feed(channel, payload)
|
|
75
|
+
@core.local_feed(channel, payload.to_s)
|
|
76
|
+
self
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Block until the graph has nothing left to do. A budget that elapses
|
|
80
|
+
# first is reported, never passed off as done.
|
|
81
|
+
def run_until_idle(budget_ms)
|
|
82
|
+
@core.local_run_until_idle(budget_ms)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# End the run and release its Cells. Idempotent.
|
|
86
|
+
def close
|
|
87
|
+
return if @closed
|
|
88
|
+
|
|
89
|
+
@closed = true
|
|
90
|
+
begin
|
|
91
|
+
# `join` re-raises whatever the drain thread died of, so the teardown
|
|
92
|
+
# below is an `ensure`: a failed delivery must not strand the context.
|
|
93
|
+
@drain.join(OUTPUT_POLL_MS / 1000.0 * 20)
|
|
94
|
+
# Stop the run first, then take what it released and the drain thread
|
|
95
|
+
# had not reached: a value can be queued for up to one poll interval,
|
|
96
|
+
# and ending the run while it sits there is how an author loses output
|
|
97
|
+
# the graph did produce. Stopping drops the sender, so this ends at the
|
|
98
|
+
# first empty read rather than waiting.
|
|
99
|
+
@core.local_stop
|
|
100
|
+
while (output = @core.local_next_output(0))
|
|
101
|
+
deliver(output)
|
|
102
|
+
end
|
|
103
|
+
ensure
|
|
104
|
+
# Not `Core#shutdown`: that stops the process-wide poll core, and a
|
|
105
|
+
# process may start another local run after this one. The workers
|
|
106
|
+
# leave on the flag instead, which is why it is raised before the
|
|
107
|
+
# drain rather than after it.
|
|
108
|
+
@executor.stop_fetching
|
|
109
|
+
undrained = @executor.stop
|
|
110
|
+
# As the runner does: a worker the bounded drain gave up on may yet
|
|
111
|
+
# call the core, and freeing the context under it is a use-after-free.
|
|
112
|
+
@core.free if undrained.zero?
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
private
|
|
117
|
+
|
|
118
|
+
# One released value per pass, handed to whoever registered its channel.
|
|
119
|
+
def drain_outputs
|
|
120
|
+
until @closed
|
|
121
|
+
begin
|
|
122
|
+
output = @core.local_next_output(OUTPUT_POLL_MS)
|
|
123
|
+
rescue StandardError
|
|
124
|
+
# The run ended under us; the close path owns the teardown.
|
|
125
|
+
return
|
|
126
|
+
end
|
|
127
|
+
deliver(output) if output
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def deliver(output)
|
|
132
|
+
unless output[:target] == :plugin
|
|
133
|
+
# The core queues what an output released and nothing else; an edge
|
|
134
|
+
# output is an in-process mailbox push that never leaves.
|
|
135
|
+
raise "local mode released a #{output[:target]} output"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
channel = output[:plugin_channel]
|
|
139
|
+
callback = @sinks[channel]
|
|
140
|
+
return if callback.nil?
|
|
141
|
+
|
|
142
|
+
callback.call(LocalOutput.new(channel: channel, payload: output[:payload],
|
|
143
|
+
metadata: (output[:metadata] || []).to_h))
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|