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
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Flezha
|
|
4
|
+
# The SDK's diagnostics, through a logger the host supplies.
|
|
5
|
+
#
|
|
6
|
+
# The default writes to standard error at `warn` and above, which is what the
|
|
7
|
+
# other SDKs' defaults amount to: a runner that says nothing about a wedge is
|
|
8
|
+
# the failure mode the diagnostics exist to close, and one that narrates
|
|
9
|
+
# every activation is noise. No observability dependency — a host that wants
|
|
10
|
+
# spans or structured fields supplies its own logger.
|
|
11
|
+
class ConsoleLogger
|
|
12
|
+
ORDER = { debug: 0, info: 1, warn: 2, error: 3 }.freeze
|
|
13
|
+
|
|
14
|
+
def initialize(level = :warn)
|
|
15
|
+
@level = ORDER.fetch(level.to_sym, 2)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
ORDER.each_key do |level|
|
|
19
|
+
define_method(level) { |message| write(level, message) }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def write(level, message)
|
|
25
|
+
return if ORDER[level] < @level
|
|
26
|
+
|
|
27
|
+
$stderr.puts("[flezha] #{message}")
|
|
28
|
+
rescue StandardError
|
|
29
|
+
# A logger must never take the process down. The case that reaches here
|
|
30
|
+
# is a closed standard stream during shutdown — the harness closes the
|
|
31
|
+
# runner's pipes to stop it — where writing raises EPIPE, and an
|
|
32
|
+
# unhandled stream error would turn a clean drain into a crash.
|
|
33
|
+
nil
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Per-Ractor rather than a bare global, for the reason every handle in this
|
|
38
|
+
# SDK is: a Ractor executor must be addable without restructuring, and a
|
|
39
|
+
# shared mutable global is exactly what would have to be unpicked.
|
|
40
|
+
def self.log
|
|
41
|
+
Ractor.current[:flezha_logger] ||= ConsoleLogger.new(ENV.fetch("FLEZHA_LOG_LEVEL", "warn"))
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.log=(logger)
|
|
45
|
+
Ractor.current[:flezha_logger] = logger
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
NOTICE_LEVELS = { "info" => :info, "warn" => :warn }.freeze
|
|
49
|
+
|
|
50
|
+
# Write the core's notices, as `take_notices` answers them, through the SDK's
|
|
51
|
+
# logger; an error, and the local-mode banner (`always`), at `error`, which no
|
|
52
|
+
# level hides.
|
|
53
|
+
def self.log_notices(taken)
|
|
54
|
+
taken.each { |level, _target, text| log.public_send(NOTICE_LEVELS.fetch(level, :error), text) }
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
require_relative "errors"
|
|
6
|
+
require_relative "version"
|
|
7
|
+
|
|
8
|
+
module Flezha
|
|
9
|
+
# The compiled veneer over `flz_core`'s C ABI, and the two rules that hold
|
|
10
|
+
# around it: one context per process, and no core call from a process that
|
|
11
|
+
# did not start it.
|
|
12
|
+
#
|
|
13
|
+
# `Flezha::Native` itself is defined by the extension (`ruby_glue.rs`). This
|
|
14
|
+
# wrapper adds no ABI surface — every method below forwards to exactly one
|
|
15
|
+
# veneer function — it adds the fork guard, which is a host rule rather than
|
|
16
|
+
# a core one.
|
|
17
|
+
class Core
|
|
18
|
+
# An absent extension is an installation defect rather than a refusal, and
|
|
19
|
+
# nothing is left to render a catalogue message with, so it stays a
|
|
20
|
+
# `LoadError`.
|
|
21
|
+
def self.load_extension
|
|
22
|
+
# Ruby derives `Init_flz_core_ffi` from the basename, which is what the
|
|
23
|
+
# veneer exports. A precompiled gem carries one library per Ruby minor,
|
|
24
|
+
# under `lib/flezha/<minor>/`; a source build puts its one in `lib/flezha/`.
|
|
25
|
+
per_minor = "flezha/#{RUBY_VERSION[/\A\d+\.\d+/]}/flz_core_ffi"
|
|
26
|
+
begin
|
|
27
|
+
require per_minor
|
|
28
|
+
rescue LoadError => e
|
|
29
|
+
# Only an absent file falls through; one that is there and will not
|
|
30
|
+
# load is the error to report.
|
|
31
|
+
raise unless e.path == per_minor
|
|
32
|
+
|
|
33
|
+
require "flezha/flz_core_ffi"
|
|
34
|
+
end
|
|
35
|
+
rescue LoadError => e
|
|
36
|
+
raise LoadError,
|
|
37
|
+
"the flezha native extension could not be loaded (#{e.message}). Install a " \
|
|
38
|
+
"precompiled platform gem, or build the source gem with cargo and git."
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# The JSON error document the catalogue renders for `code` and `details`:
|
|
42
|
+
# what `Flezha::Error` is built from, for a refusal this SDK makes itself.
|
|
43
|
+
def self.error_document(code, details)
|
|
44
|
+
load_extension
|
|
45
|
+
Native.error_document(code.to_s, JSON.generate(details))
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Create the process context, negotiating the activation schema version
|
|
49
|
+
# before any controller connection is attempted.
|
|
50
|
+
def self.create
|
|
51
|
+
load_extension
|
|
52
|
+
# The first take starts holding the core's notices for this host's
|
|
53
|
+
# drains; anything an earlier context left is written now.
|
|
54
|
+
Flezha.log_notices(Native.take_notices)
|
|
55
|
+
Native.context_new(ACTIVATION_SCHEMA_VERSION)
|
|
56
|
+
new
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The entry points that take no context and therefore no guard: they read
|
|
60
|
+
# a process-global clock, which a fork does not break. Reachable before
|
|
61
|
+
# `create`, which is what lets a runner answer before it boots.
|
|
62
|
+
def self.context_free
|
|
63
|
+
load_extension
|
|
64
|
+
Native
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def initialize
|
|
68
|
+
# Recorded at construction rather than at `start`, so a fork between the
|
|
69
|
+
# two is caught by the same guard.
|
|
70
|
+
@owner_pid = Process.pid
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# The process that owns the core. A fork after this point does not carry
|
|
74
|
+
# the core's Rust threads — transport, flusher, the runner loop — so the
|
|
75
|
+
# child would connect to nothing and serve nothing, silently.
|
|
76
|
+
def guard!
|
|
77
|
+
return if Process.pid == @owner_pid
|
|
78
|
+
|
|
79
|
+
raise Error.new("runtime.forked_process", owner: @owner_pid, pid: Process.pid)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Every ABI entry point a running host reaches, in the veneer's own
|
|
83
|
+
# spelling.
|
|
84
|
+
#
|
|
85
|
+
# Generated rather than written out fifteen times over: the fork guard then
|
|
86
|
+
# provably applies to all of them — there is no site left to forget it at —
|
|
87
|
+
# and `native_test.rb` asserts this list is exactly what the veneer
|
|
88
|
+
# exports, so an entry point added there and not here is a red build rather
|
|
89
|
+
# than a method that quietly does not exist.
|
|
90
|
+
ENTRY_POINTS = %i[
|
|
91
|
+
declare_class undeclare_class check_class start poll complete abandon shutdown
|
|
92
|
+
pool_open pool_supervise pool_left
|
|
93
|
+
local_start local_on_sink local_feed local_next_output local_run_until_idle local_stop
|
|
94
|
+
watch_stdin_eof spawn_request spawn_result part effect_begin effect_end effect_release
|
|
95
|
+
admin_call send_message relays monotonic_nanos last_error take_notices
|
|
96
|
+
].freeze
|
|
97
|
+
|
|
98
|
+
ENTRY_POINTS.each do |entry|
|
|
99
|
+
define_method(entry) do |*args|
|
|
100
|
+
guard!
|
|
101
|
+
Native.public_send(entry, *args)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# The one entry point not named after its own: `context_free` releases what
|
|
106
|
+
# `context_new` created, and `create` is what made this object.
|
|
107
|
+
def free
|
|
108
|
+
guard!
|
|
109
|
+
Native.context_free
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
data/lib/flezha/relay.rb
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "builder"
|
|
4
|
+
require_relative "declarative"
|
|
5
|
+
require_relative "errors"
|
|
6
|
+
require_relative "tasklet"
|
|
7
|
+
|
|
8
|
+
module Flezha
|
|
9
|
+
# The base an authored relay subclasses. Its class body declares the
|
|
10
|
+
# factory id and one body: a `channels` graph, or, for the single-tasklet
|
|
11
|
+
# short form, that tasklet's `run` instance method, which emits through its
|
|
12
|
+
# `ctx` as a tasklet's does. `spec` resolves the declaration once.
|
|
13
|
+
class Relay
|
|
14
|
+
extend Declarative
|
|
15
|
+
# A short form's tasklet settings, handed to the tasklet it generates.
|
|
16
|
+
extend TaskletSettings
|
|
17
|
+
|
|
18
|
+
# The channels graph. The block receives the builder.
|
|
19
|
+
block_setting :channels
|
|
20
|
+
# The short form's entry channel name, its codec and its optional plugin
|
|
21
|
+
# output. A full form names its channels in `channels` instead.
|
|
22
|
+
SHORT_FORM_KEYS = %i[input codec output].freeze
|
|
23
|
+
setting(*SHORT_FORM_KEYS)
|
|
24
|
+
# The declarations only a short form reads.
|
|
25
|
+
SHORT_FORM_ONLY = (SHORT_FORM_KEYS + TaskletSettings::NAMES).freeze
|
|
26
|
+
# The class settings, the tree's own data: `run :steps`,
|
|
27
|
+
# `run steps: { each: :per_input }`, `durable false`,
|
|
28
|
+
# `durable local: { sleep_after: 2_000 }`. A finished instance keeps
|
|
29
|
+
# nothing unless the level sets `keep_finished` to hours, or to `true` for
|
|
30
|
+
# the controller's window (72 hours by default), so `flz relay info` can
|
|
31
|
+
# still show its Cells and result. Either form takes them; the core reads
|
|
32
|
+
# the tree when the class registers and refuses a misplaced setting by name.
|
|
33
|
+
setting :run, :durable, :max_duration_ms, :description
|
|
34
|
+
# `factory_id` is deliberately NOT inherited: two relays cannot share
|
|
35
|
+
# one, and a subclass silently taking its parent's would be refused by the
|
|
36
|
+
# core at declare time rather than here.
|
|
37
|
+
|
|
38
|
+
class << self
|
|
39
|
+
def factory_id(id = nil)
|
|
40
|
+
return @factory_id if id.nil?
|
|
41
|
+
|
|
42
|
+
@factory_id = id.to_s
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def spec
|
|
46
|
+
@spec ||= build_spec
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# The tasklet a short form generates, or `nil` for a full form. Named
|
|
50
|
+
# after the factory id, verbatim: a tasklet's name is part of every
|
|
51
|
+
# cell's state path, so the full form that replaces this one keeps
|
|
52
|
+
# its cells by naming its tasklet the same.
|
|
53
|
+
def tasklet
|
|
54
|
+
return nil unless short_form?
|
|
55
|
+
|
|
56
|
+
check_declaration!
|
|
57
|
+
@tasklet ||= Class.new(Tasklet).tap do |generated|
|
|
58
|
+
relay = self
|
|
59
|
+
generated.tasklet_name(@factory_id)
|
|
60
|
+
generated.define_singleton_method(:body) { relay }
|
|
61
|
+
hand_down(generated, TaskletSettings::NAMES)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# The refusals a class earns before anything is built, raised by
|
|
66
|
+
# `Runner#register_factory` at the call the author wrote and again when
|
|
67
|
+
# the spec is built.
|
|
68
|
+
def check_declaration!
|
|
69
|
+
# A factory id is written, never derived from the class.
|
|
70
|
+
raise Error.new("topology.missing_factory_id", relay: to_s) unless @factory_id
|
|
71
|
+
|
|
72
|
+
if short_form?
|
|
73
|
+
raise Error.new("topology.short_form_with_topology", factory_id: @factory_id) if channels
|
|
74
|
+
elsif channels
|
|
75
|
+
refuse_written("topology.short_form_field_on_full_form", SHORT_FORM_ONLY)
|
|
76
|
+
else
|
|
77
|
+
raise Error.new("topology.missing_body", declaration: "relay '#{@factory_id}'",
|
|
78
|
+
body: "`channels`, or one tasklet's `run` as the short form")
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
# A short form carries its one tasklet's `run` body instead of a
|
|
85
|
+
# `channels` graph.
|
|
86
|
+
def short_form? = method_defined?(:run)
|
|
87
|
+
|
|
88
|
+
# Refuse the declarations among +names+ this class wrote, naming each.
|
|
89
|
+
def refuse_written(code, names)
|
|
90
|
+
names = written(names).keys
|
|
91
|
+
return if names.empty?
|
|
92
|
+
|
|
93
|
+
raise Error.new(code, factory_id: @factory_id, fields: names.map { |name| "`#{name}`" })
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def build_builder
|
|
97
|
+
check_declaration!
|
|
98
|
+
builder = ChannelBuilder.new(@factory_id)
|
|
99
|
+
short_form? ? wire_short_form(builder) : channels.call(builder)
|
|
100
|
+
builder
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# `input -> tasklet`, and on to the plugin output when one is declared.
|
|
104
|
+
def wire_short_form(builder)
|
|
105
|
+
edge = builder.input(input || "input", codec: codec).into(tasklet)
|
|
106
|
+
edge.output(output) if output
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# The graph and the settings tree as written: the core judges the tree
|
|
110
|
+
# and refuses a misplaced setting by name.
|
|
111
|
+
def build_spec
|
|
112
|
+
# Sealed before the channels are read: filing every declared sequence
|
|
113
|
+
# on its channel is what can still create one.
|
|
114
|
+
builder = build_builder.seal
|
|
115
|
+
ClassSpec.new(
|
|
116
|
+
factory_id: @factory_id, description: description, max_duration_ms: max_duration_ms,
|
|
117
|
+
run: Flezha.tree_json(run), durable: Flezha.tree_json(durable),
|
|
118
|
+
tasklets: builder.tasklets, channels: builder.channels
|
|
119
|
+
)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# A settings tree as its JSON: symbols are strings, `nil` stays absent.
|
|
125
|
+
def self.tree_json(tree)
|
|
126
|
+
case tree
|
|
127
|
+
when Hash then tree.to_h { |name, member| [name.to_s, tree_json(member)] }
|
|
128
|
+
when Symbol then tree.to_s
|
|
129
|
+
else tree
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "errors"
|
|
4
|
+
require_relative "native"
|
|
5
|
+
|
|
6
|
+
module Flezha
|
|
7
|
+
# One outcome of a `call`, as its Response channel delivers it: the `request`
|
|
8
|
+
# the call end sent, decoded back with the call's codec; the answer as
|
|
9
|
+
# `value`, or the `error` the call ended in (a missed deadline is
|
|
10
|
+
# `call.timeout`); `target`, the callee's instance id; and `created`, true
|
|
11
|
+
# when this call started the callee.
|
|
12
|
+
Response = Data.define(:request, :value, :error, :target, :created) do
|
|
13
|
+
def ok? = error.nil?
|
|
14
|
+
|
|
15
|
+
# The Response a controller's envelope carries, read by the core's
|
|
16
|
+
# decoder; a payload that is no envelope raises `call.malformed_response`.
|
|
17
|
+
# `decode` reads the request's and the answer's bytes.
|
|
18
|
+
def self.decode(payload, decode)
|
|
19
|
+
Core.load_extension unless defined?(Native)
|
|
20
|
+
target, created, request, value, error = Native.response_decode(payload)
|
|
21
|
+
new(
|
|
22
|
+
request: decode.call(request), value: error ? nil : decode.call(value),
|
|
23
|
+
error: error && Error.from_document(error), target:, created:
|
|
24
|
+
)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "codec"
|
|
4
|
+
require_relative "tasklet"
|
|
5
|
+
|
|
6
|
+
module Flezha
|
|
7
|
+
# The single-cell tasklet `ChannelBuilder#route_latest` expands to. Its input is
|
|
8
|
+
# what the merge's two mappings make of a value, `{ document: }` or
|
|
9
|
+
# `{ traffic: key }`; the traffic item itself never reaches it.
|
|
10
|
+
#
|
|
11
|
+
# The state is `{ document:, version:, delivered: { key => version } }` and
|
|
12
|
+
# rides the change journal like any tasklet's: `{ retain: document }`
|
|
13
|
+
# replaces the document and advances the version, and
|
|
14
|
+
# `{ delivered: key, version: }` records one key's delivery.
|
|
15
|
+
class RouteLatest < Tasklet
|
|
16
|
+
tasklet_name "route_latest"
|
|
17
|
+
|
|
18
|
+
# Version 0 is "nothing retained", so the first document lands at 1 and
|
|
19
|
+
# every key's absent entry is already behind it.
|
|
20
|
+
def init_state(_cell_key) = { document: nil, version: 0, delivered: {} }
|
|
21
|
+
|
|
22
|
+
def apply_change(state, change)
|
|
23
|
+
if change.key?(:retain)
|
|
24
|
+
state[:document] = change[:retain]
|
|
25
|
+
state[:version] += 1
|
|
26
|
+
else
|
|
27
|
+
# A Symbol, because a Hash key decodes as one: a state restored from a
|
|
28
|
+
# snapshot reads the key the live state wrote.
|
|
29
|
+
state[:delivered][change[:delivered].to_sym] = change[:version]
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def run(input, ctx)
|
|
34
|
+
state = ctx.state
|
|
35
|
+
if input.key?(:document)
|
|
36
|
+
# The document is opaque: equal is the class codec's bytes, so a
|
|
37
|
+
# re-poll of an unchanged document is inert.
|
|
38
|
+
document = input[:document]
|
|
39
|
+
ctx.change({ retain: document }) unless Codec.encode(document) == Codec.encode(state[:document])
|
|
40
|
+
return nil
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
key = input[:traffic]
|
|
44
|
+
version = state[:version]
|
|
45
|
+
return nil if version.zero? || state[:delivered].fetch(key.to_sym, 0) >= version
|
|
46
|
+
|
|
47
|
+
# Emit first: a failed emit rides the channel's retry ladder, and
|
|
48
|
+
# recording the delivery before it would make the retry silent.
|
|
49
|
+
ctx.emit([key, state[:document]])
|
|
50
|
+
ctx.change({ delivered: key, version: version })
|
|
51
|
+
nil
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
require_relative "dispatch"
|
|
6
|
+
require_relative "executor"
|
|
7
|
+
require_relative "graph"
|
|
8
|
+
require_relative "logger"
|
|
9
|
+
require_relative "native"
|
|
10
|
+
require_relative "relay"
|
|
11
|
+
|
|
12
|
+
module Flezha
|
|
13
|
+
# The runner: what an author starts, and everything it owns while running.
|
|
14
|
+
#
|
|
15
|
+
# Classes are registered explicitly, by naming them. There is no autoload, no
|
|
16
|
+
# load-path walk and no package scan: what runs is what the author said runs,
|
|
17
|
+
# and a class that is defined but not registered never reaches the core.
|
|
18
|
+
#
|
|
19
|
+
# ## Startup order
|
|
20
|
+
#
|
|
21
|
+
# declare classes -> warm up -> (fork, if the user forks) -> start
|
|
22
|
+
#
|
|
23
|
+
# `Process.warmup` compacts the heap and promotes the declared object graph
|
|
24
|
+
# to the old generation, which is what makes a subsequent fork
|
|
25
|
+
# copy-on-write-friendly. It runs after the last declaration and before the
|
|
26
|
+
# first `poll`. The core must NOT be started before a fork: it owns native
|
|
27
|
+
# threads that `fork(2)` does not carry, and `Core#guard!` raises naming that
|
|
28
|
+
# rule rather than letting a child serve nothing.
|
|
29
|
+
class Runner
|
|
30
|
+
attr_reader :namespace
|
|
31
|
+
|
|
32
|
+
def initialize(namespace)
|
|
33
|
+
@namespace = namespace
|
|
34
|
+
@classes = []
|
|
35
|
+
@registry = Registry.new
|
|
36
|
+
@core = nil
|
|
37
|
+
@executor = nil
|
|
38
|
+
@executor_kind = (ENV["FLEZHA_RUBY_EXECUTOR"] || "threads").to_sym
|
|
39
|
+
@executor_options = {}
|
|
40
|
+
@yjit = nil
|
|
41
|
+
@stopped = Thread::Queue.new
|
|
42
|
+
@stopping = false
|
|
43
|
+
@closing = Mutex.new
|
|
44
|
+
@teardown = []
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Register a relay class, short form or full. After start the class is
|
|
48
|
+
# declared at once: the executor serves it and the controller learns of it.
|
|
49
|
+
def register_factory(klass)
|
|
50
|
+
# A tasklet is registered by the relay that wires it.
|
|
51
|
+
unless klass.is_a?(Class) && klass < Relay
|
|
52
|
+
raise Error.new("topology.no_relay_exported", module: klass.to_s)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
klass.check_declaration!
|
|
56
|
+
Graph.declare(klass.spec, @core, @registry) if @core
|
|
57
|
+
@classes << klass
|
|
58
|
+
self
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Undo a registration — the inverse of {#register_factory}, taking what it
|
|
62
|
+
# took. After start the core refuses a class with live instances on this
|
|
63
|
+
# runner; otherwise the executor stops serving it and the controller
|
|
64
|
+
# learns of it.
|
|
65
|
+
def unregister_factory(klass)
|
|
66
|
+
factory = klass.respond_to?(:factory_id) && klass.factory_id || klass.to_s
|
|
67
|
+
raise Error.new("runtime.class_not_registered", factory: factory) unless @classes.include?(klass)
|
|
68
|
+
|
|
69
|
+
if @core
|
|
70
|
+
@core.undeclare_class(factory)
|
|
71
|
+
@registry.remove(factory)
|
|
72
|
+
end
|
|
73
|
+
@classes.delete(klass)
|
|
74
|
+
self
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Which executor dispatches: `:threads` (the default) or `:fibers`.
|
|
78
|
+
def executor(kind, **options)
|
|
79
|
+
@executor_kind = kind.to_sym
|
|
80
|
+
@executor_options = options
|
|
81
|
+
self
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def logger(logger)
|
|
85
|
+
Flezha.log = logger
|
|
86
|
+
self
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Administer this namespace from ordinary application code: start and stop
|
|
90
|
+
# relays, enable and disable plugins, read what is running.
|
|
91
|
+
#
|
|
92
|
+
# The controller judges every call as the identity this connection proved,
|
|
93
|
+
# so no credential is held here and nothing expires. A user grants a
|
|
94
|
+
# namespace's applications the authority once, with
|
|
95
|
+
# `flz role bind runner@<namespace> app <namespace>`; until then every call
|
|
96
|
+
# is refused.
|
|
97
|
+
def control = Control.new(@core || Core.context_free)
|
|
98
|
+
|
|
99
|
+
# Send `message` to the entry channel `channel` of `relay_id`, through
|
|
100
|
+
# the controller under the same role check as `control`, answering its
|
|
101
|
+
# verdict: `{"to" => relay, "verdict" => "delivered" | "absorbed" |
|
|
102
|
+
# "suspended"}`. `msg_id:` is stamped as `flz.msg_id`, so a relay whose
|
|
103
|
+
# input log still holds that id absorbs the repeat; `key:` reaches the
|
|
104
|
+
# partition group member whose range owns it.
|
|
105
|
+
#
|
|
106
|
+
# `local: true`: in process, without the controller: the message is on no
|
|
107
|
+
# disk, is lost if the runner stops before handling it, and a relay's
|
|
108
|
+
# input log does not cover it. It answers `nil`.
|
|
109
|
+
#
|
|
110
|
+
# Named `send_to` because `send` is every Ruby object's own.
|
|
111
|
+
def send_to(relay_id, message, channel, msg_id: nil, key: nil, local: false)
|
|
112
|
+
JSON.parse((@core || Core.context_free).send_message(relay_id, channel, message.b, msg_id, key, local))
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Call the entry channel `channel` of `relay_id` with `value`, through the
|
|
116
|
+
# controller under the same role check as `send_to`, answering what the
|
|
117
|
+
# relay answers: a JSON value, a String for text, binary bytes for the
|
|
118
|
+
# binary formats, or `{"accepted" => true}` from a channel no output
|
|
119
|
+
# answers. `value` is encoded as an output naming no codec encodes it.
|
|
120
|
+
def call(relay_id, channel, value)
|
|
121
|
+
payload = [Codec.plain_payload(value, "a call to '#{relay_id}'")].pack("m0")
|
|
122
|
+
args = JSON.generate([relay_id, channel, payload, { "payload" => "base64" }])
|
|
123
|
+
reply = JSON.parse((@core || Core.context_free).admin_call("Relay.call_channel", args))
|
|
124
|
+
return { "accepted" => true } if reply["accepted"]
|
|
125
|
+
|
|
126
|
+
%w[json text].include?(reply["answer_codec"]) ? reply["answer"] : reply["answer"].unpack1("m0")
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# The ids of the relay instances bound on this runner now, read without
|
|
130
|
+
# the controller. Refused with `runtime.runner_not_started` before `boot`.
|
|
131
|
+
def relays = (@core || raise(Error.new("runtime.runner_not_started"))).relays
|
|
132
|
+
|
|
133
|
+
# Override the YJIT decision. `nil` (the default) means "decide", `false`
|
|
134
|
+
# means "leave it off".
|
|
135
|
+
def yjit(enabled)
|
|
136
|
+
@yjit = enabled
|
|
137
|
+
self
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Declare, warm up, connect and start serving — everything up to and
|
|
141
|
+
# including the first poll, and nothing that blocks.
|
|
142
|
+
#
|
|
143
|
+
# Separate from `run` so a host with its own supervision tree can own the
|
|
144
|
+
# waiting, and so the startup ORDER is a thing a test can observe rather
|
|
145
|
+
# than a comment.
|
|
146
|
+
def start(core: nil)
|
|
147
|
+
jit = resolve_yjit
|
|
148
|
+
|
|
149
|
+
# Before anything else: a native library built against a different
|
|
150
|
+
# activation schema is refused here, with no controller connection
|
|
151
|
+
# attempted.
|
|
152
|
+
@core = core || Core.create
|
|
153
|
+
|
|
154
|
+
declare_all
|
|
155
|
+
# After the last declaration and before the first poll.
|
|
156
|
+
Process.warmup
|
|
157
|
+
|
|
158
|
+
# The core's termination handler also runs the handler installed before
|
|
159
|
+
# it. Ruby's default raises in the main thread and ends the process before
|
|
160
|
+
# any state is shipped, so it gives way to the system default, which is
|
|
161
|
+
# never run; a trap the application set is kept.
|
|
162
|
+
%w[TERM INT].each do |signal|
|
|
163
|
+
previous = Signal.trap(signal, "SYSTEM_DEFAULT")
|
|
164
|
+
Signal.trap(signal, previous) unless previous == "DEFAULT"
|
|
165
|
+
end
|
|
166
|
+
begin
|
|
167
|
+
@core.start(@namespace)
|
|
168
|
+
rescue StandardError
|
|
169
|
+
# The core holds what it said while connecting until a take; no
|
|
170
|
+
# supervisor is left to take it.
|
|
171
|
+
Flezha.log_notices(@core.take_notices)
|
|
172
|
+
raise
|
|
173
|
+
end
|
|
174
|
+
observe_shutdown
|
|
175
|
+
|
|
176
|
+
@executor = build_executor
|
|
177
|
+
@executor.start
|
|
178
|
+
|
|
179
|
+
Flezha.log.info(
|
|
180
|
+
"runner up on namespace #{@namespace}: #{@executor.name} executor, " \
|
|
181
|
+
"#{@executor.size} workers, yjit #{jit}"
|
|
182
|
+
)
|
|
183
|
+
self
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Declare, warm up, connect and serve until shutdown: `start`, then
|
|
187
|
+
# `await_termination`.
|
|
188
|
+
def run(core: nil)
|
|
189
|
+
start(core: core)
|
|
190
|
+
await_termination
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Wait for shutdown, then close. A host that called `start` can work
|
|
194
|
+
# between the two, making control calls for one, and still stop the way
|
|
195
|
+
# `run` does.
|
|
196
|
+
def await_termination
|
|
197
|
+
@stopped.pop
|
|
198
|
+
close
|
|
199
|
+
self
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Stop fetching, drain what is in flight, release the core and wake every
|
|
203
|
+
# thread in `await_termination`. Idempotent: the waiter it wakes finds the
|
|
204
|
+
# core already released. Not for a trap handler, since it takes a lock.
|
|
205
|
+
def close
|
|
206
|
+
@closing.synchronize { release_core }
|
|
207
|
+
stop
|
|
208
|
+
nil
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
private
|
|
212
|
+
|
|
213
|
+
# Wakes `await_termination`. Trap-safe: a queue push and no lock.
|
|
214
|
+
def stop
|
|
215
|
+
return if @stopping
|
|
216
|
+
|
|
217
|
+
@stopping = true
|
|
218
|
+
@stopped.push(:stop)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def release_core
|
|
222
|
+
return unless @core
|
|
223
|
+
|
|
224
|
+
Flezha.log.info("draining")
|
|
225
|
+
@core.shutdown
|
|
226
|
+
undrained = @executor ? @executor.stop : 0
|
|
227
|
+
@teardown.each(&:call)
|
|
228
|
+
@teardown.clear
|
|
229
|
+
# The context is released only when nothing is still running against it.
|
|
230
|
+
# A worker the bounded drain gave up on is inside user code and may yet
|
|
231
|
+
# call the core; freeing the context under it would be a use-after-free
|
|
232
|
+
# in native code. The process is on its way out either way, so the
|
|
233
|
+
# kernel reclaims what this skips.
|
|
234
|
+
@core.free if undrained.zero?
|
|
235
|
+
@core = nil
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def declare_all
|
|
239
|
+
# The verdict before the registration: the core judges the record and
|
|
240
|
+
# this SDK renders it, so an author meets a declaration refusal rather
|
|
241
|
+
# than a native failure at the first connection.
|
|
242
|
+
@classes.each { |klass| Graph.declare(klass.spec, @core, @registry) }
|
|
243
|
+
raise Error.new("runtime.no_relays") if @registry.empty?
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def build_executor
|
|
247
|
+
deps = DispatchDeps.new(registry: @registry, bridge: @core)
|
|
248
|
+
case @executor_kind
|
|
249
|
+
when :threads then ThreadExecutor.new(@core, deps, **@executor_options)
|
|
250
|
+
when :fibers then FiberExecutor.new(@core, deps, **@executor_options)
|
|
251
|
+
else
|
|
252
|
+
raise Error.new("usage.unknown_word", noun: "executor", value: @executor_kind.to_s,
|
|
253
|
+
allowed: %w[threads fibers])
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Shutdown is observed three ways, because the harness, a user and the
|
|
258
|
+
# controller each have their own: end of file on standard input, a signal,
|
|
259
|
+
# and the core's own stop — which every worker sees as `:shutting_down`
|
|
260
|
+
# from its next poll.
|
|
261
|
+
#
|
|
262
|
+
# The first two are the core's, on a thread that holds no GVL and no Ruby
|
|
263
|
+
# object: it installs the SIGINT and SIGTERM handler when the runner starts,
|
|
264
|
+
# and the watch below is the same exit by another name. The harness manages
|
|
265
|
+
# a runner as an Erlang Port and stops it by closing the port, which is this
|
|
266
|
+
# process's standard input.
|
|
267
|
+
def observe_shutdown
|
|
268
|
+
@core.watch_stdin_eof unless $stdin.tty?
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# YJIT is on by default because handler bodies are the one thing the GVL
|
|
272
|
+
# bounds, so the JIT's gain lands exactly where the ceiling is. The
|
|
273
|
+
# user's decision wins, and the resulting state is reported in the
|
|
274
|
+
# startup line so a measurement can never be ambiguous about it.
|
|
275
|
+
def resolve_yjit
|
|
276
|
+
return "disabled (runner override)" if @yjit == false
|
|
277
|
+
return "unavailable (this Ruby was built without it)" unless defined?(RubyVM::YJIT)
|
|
278
|
+
return "enabled (already on)" if RubyVM::YJIT.enabled?
|
|
279
|
+
return "disabled (RUBY_YJIT_ENABLE set)" if ENV.key?("RUBY_YJIT_ENABLE")
|
|
280
|
+
return "disabled (FLEZHA_YJIT=0)" if ENV["FLEZHA_YJIT"] == "0"
|
|
281
|
+
|
|
282
|
+
RubyVM::YJIT.enable
|
|
283
|
+
RubyVM::YJIT.enabled? ? "enabled" : "unavailable (enable refused)"
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# Start here: `Flezha.create_runner("default").register_factory(MyRelay).run`.
|
|
288
|
+
def self.create_runner(namespace) = Runner.new(namespace)
|
|
289
|
+
end
|