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,29 @@
|
|
|
1
|
+
# The wrapper crate flezha's native binary is built from when it is built from
|
|
2
|
+
# source outside the core's repository.
|
|
3
|
+
#
|
|
4
|
+
# It carries no code: the veneer is a feature of `flz_core_ffi`, which
|
|
5
|
+
# `sdk-core-abi` makes a requirement rather than a preference. All this
|
|
6
|
+
# manifest does is name the core at a tag, turn on the one feature the gem's native extension
|
|
7
|
+
# needs, and ask for a `cdylib`. Switching to a registry is a change to the
|
|
8
|
+
# dependency line and nothing else.
|
|
9
|
+
[package]
|
|
10
|
+
name = "flezha-ruby-native"
|
|
11
|
+
version = "0.1.0"
|
|
12
|
+
edition = "2021"
|
|
13
|
+
publish = false
|
|
14
|
+
|
|
15
|
+
# Detached from any workspace. In this repository the file sits inside the
|
|
16
|
+
# core's own cargo workspace, and a member of it would inherit a lockfile and
|
|
17
|
+
# a target directory an SDK checkout does not have.
|
|
18
|
+
[workspace]
|
|
19
|
+
|
|
20
|
+
# The artefact keeps the core's own name, so the build scripts that stage it
|
|
21
|
+
# and the loaders that find it read one file name whichever side built it.
|
|
22
|
+
[lib]
|
|
23
|
+
name = "flz_core_ffi"
|
|
24
|
+
crate-type = ["cdylib"]
|
|
25
|
+
|
|
26
|
+
[dependencies]
|
|
27
|
+
# Renamed, because a dependency spelled like this crate's own lib target is
|
|
28
|
+
# ambiguous at the glob below.
|
|
29
|
+
veneer = { package = "flz_core_ffi", version = "0.1.0", git = "https://github.com/flezha/flezha", tag = "v0.1.0", features = ["ruby"] }
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Build the native veneer for the source gem.
|
|
4
|
+
#
|
|
5
|
+
# A precompiled platform gem ships the library and needs no toolchain at all;
|
|
6
|
+
# that is the primary distribution, and this file is the fallback for platforms
|
|
7
|
+
# it does not cover. The fallback builds the wrapper crate beside this file,
|
|
8
|
+
# whose one dependency is the core at a git tag, so it needs cargo and git and
|
|
9
|
+
# says so rather than failing inside cargo.
|
|
10
|
+
|
|
11
|
+
require "mkmf"
|
|
12
|
+
|
|
13
|
+
ext_dir = __dir__
|
|
14
|
+
prebuilt = File.expand_path("../../lib/flezha/flz_core_ffi.#{RbConfig::CONFIG['DLEXT']}", ext_dir)
|
|
15
|
+
|
|
16
|
+
# A checkout whose library is already built, installed in place by Bundler.
|
|
17
|
+
# Only in place: rake-compiler builds in a directory of its own and copies what
|
|
18
|
+
# make produced, so a Makefile that produces nothing would fail it.
|
|
19
|
+
if File.exist?(prebuilt) && File.expand_path(Dir.pwd) == ext_dir
|
|
20
|
+
File.write("Makefile", "all:\n\t@true\ninstall:\n\t@true\nclean:\n\t@true\n")
|
|
21
|
+
exit 0
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
missing = %w[cargo git].reject { |tool| find_executable(tool) }
|
|
25
|
+
unless missing.empty?
|
|
26
|
+
abort <<~MESSAGE
|
|
27
|
+
flezha: building the source gem needs cargo and git (#{missing.join(' and ')} not found):
|
|
28
|
+
it compiles the core from its git tag. Install a Rust toolchain and git, or install
|
|
29
|
+
the precompiled platform gem instead: gem install flezha --platform #{Gem::Platform.local}
|
|
30
|
+
MESSAGE
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
require "rb_sys/mkmf"
|
|
34
|
+
|
|
35
|
+
create_rust_makefile("flezha/flz_core_ffi") do |r|
|
|
36
|
+
# rb-sys runs bindgen over ruby.h, and bindgen needs clang's builtin headers.
|
|
37
|
+
# Without libclang-dev they are absent, and gcc's builtin include directory
|
|
38
|
+
# carries the same ones.
|
|
39
|
+
if ENV["BINDGEN_EXTRA_CLANG_ARGS"].nil? && Dir.glob("/usr/lib/llvm-*/lib/clang/*/include/stdarg.h").empty?
|
|
40
|
+
gcc_include = `gcc -print-file-name=include 2>/dev/null`.strip
|
|
41
|
+
r.env = { "BINDGEN_EXTRA_CLANG_ARGS" => "-I#{gcc_include}" } if File.file?(File.join(gcc_include, "stdarg.h"))
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
//! Nothing of this crate's own: the gem's native extension IS `flz_core_ffi` built with one
|
|
2
|
+
//! feature, and this re-export is what puts its entry points in a `cdylib`'s
|
|
3
|
+
//! export table. A `cdylib` exports no `#[no_mangle]` symbol of a dependency
|
|
4
|
+
//! it never names. Measured, not assumed: without the glob the binary
|
|
5
|
+
//! links and exports nothing at all.
|
|
6
|
+
|
|
7
|
+
pub use ::veneer::*;
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "errors"
|
|
4
|
+
|
|
5
|
+
module Flezha
|
|
6
|
+
# A tasklet as the graph holds it, with its position in the declaration.
|
|
7
|
+
# `output` is the one channel it produces on, created on first wiring.
|
|
8
|
+
TaskNode = Struct.new(:index, :name, :klass, :definition, :output)
|
|
9
|
+
|
|
10
|
+
# One consumer of a channel, with the transforms and key of its own edge.
|
|
11
|
+
ConsumerEdge = Struct.new(:tasklet, :transforms, :key_by, keyword_init: true)
|
|
12
|
+
|
|
13
|
+
# A channel's call end: the transforms and key of the chain that closed on
|
|
14
|
+
# it, applied to a value before it becomes a request, and the request's
|
|
15
|
+
# encoder.
|
|
16
|
+
CallEnd = Struct.new(:transforms, :key_by, :encode, keyword_init: true)
|
|
17
|
+
|
|
18
|
+
# One fluent sequence declared off a channel handle, as the record carries
|
|
19
|
+
# it: `links` in the order the author called them, for the core to judge.
|
|
20
|
+
# Every transform or `key_by` answers a new sequence over the previous,
|
|
21
|
+
# which is what lets a shared prefix branch without the arms colliding;
|
|
22
|
+
# `superseded` marks the prefix a later link built on, so only a tip reaches
|
|
23
|
+
# the record. `edge` is the consumer an `into` sequence closes on, held aside
|
|
24
|
+
# for the core to name back.
|
|
25
|
+
Chain = Struct.new(:source, :links, :superseded, :edge, keyword_init: true)
|
|
26
|
+
|
|
27
|
+
# A channel as the graph holds it. Its position in the list IS its id.
|
|
28
|
+
# `output` is the channel's name once a plugin end, an output or a call, is
|
|
29
|
+
# attached; nil means none, and the core's answer names no output for a call.
|
|
30
|
+
# `sink_encode` is that end's payload encoder and belongs to the
|
|
31
|
+
# plugin frame alone: an inter-tasklet frame is decoded with the class codec,
|
|
32
|
+
# so encoding one with it hands the consuming tasklet bytes it cannot read.
|
|
33
|
+
# `call` is the channel's call end, when a chain closes on one.
|
|
34
|
+
# `codec` is the format the relay's API announces (an entry's input, an
|
|
35
|
+
# output's value), and `schema` and `description` an entry's own.
|
|
36
|
+
#
|
|
37
|
+
# `chain_edges` runs parallel to `chains`: the closures each sequence closed
|
|
38
|
+
# on, at the position the core reads that sequence at. `consumers` and
|
|
39
|
+
# `derived_kind` are filled from the derived declaration — the core says
|
|
40
|
+
# which chains became edges and what kind the channel is.
|
|
41
|
+
ChannelSpec = Struct.new(
|
|
42
|
+
:index, :name, :kind, :derived_kind, :producer, :consumers, :chains, :chain_edges, :overflow,
|
|
43
|
+
:retry_max, :retry_backoff_ms, :keyed, :sink, :decode, :sink_encode, :call,
|
|
44
|
+
:codec, :schema, :description,
|
|
45
|
+
keyword_init: true
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
# Everything the graph knows about one declared class.
|
|
49
|
+
# `run` and `durable` are the tree's JSON as written, `nil` when unwritten.
|
|
50
|
+
ClassSpec = Struct.new(
|
|
51
|
+
:factory_id, :description, :max_duration_ms, :run, :durable,
|
|
52
|
+
:tasklets, :channels,
|
|
53
|
+
keyword_init: true
|
|
54
|
+
) do
|
|
55
|
+
# Whether this class runs in steps, whose producers release their values
|
|
56
|
+
# untransformed.
|
|
57
|
+
#
|
|
58
|
+
# A steps stage releases ONE value per emit and the driver fans it out
|
|
59
|
+
# to every consumer itself, so each consumer edge (its transforms and its
|
|
60
|
+
# key) belongs to the consumer, which applies it on the input the core
|
|
61
|
+
# marks untransformed.
|
|
62
|
+
def runs_in_steps? = ClassSpec.branch(run) == "steps"
|
|
63
|
+
|
|
64
|
+
# The branch a tree setting names: its bare name, or its one key.
|
|
65
|
+
def self.branch(tree)
|
|
66
|
+
return "false" if tree == false
|
|
67
|
+
return tree if tree.is_a?(String)
|
|
68
|
+
|
|
69
|
+
tree.keys.first if tree.is_a?(Hash) && tree.size == 1
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Run an edge's transform chain over one value and answer the values that
|
|
74
|
+
# come out of it. Every link answers the list one value becomes: none when a
|
|
75
|
+
# filter dropped it, one when it was mapped, several when a flat_map fanned
|
|
76
|
+
# it. A later link runs over every value an earlier one yielded, and a value
|
|
77
|
+
# that is itself an Array stays one value.
|
|
78
|
+
#
|
|
79
|
+
# One implementation for the three places a chain runs — an entry payload
|
|
80
|
+
# before its handler, a payload at the prepare crossing, and an emit on its
|
|
81
|
+
# way out — so a filter cannot mean one thing on one path and another
|
|
82
|
+
# elsewhere.
|
|
83
|
+
def self.apply_transforms(transforms, value)
|
|
84
|
+
transforms.reduce([value]) do |values, transform|
|
|
85
|
+
values.flat_map { |current| transform.call(current) }
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# The fluent channel builder a relay's `channels` block receives.
|
|
90
|
+
class ChannelBuilder
|
|
91
|
+
attr_reader :factory_id, :tasklets, :channels
|
|
92
|
+
|
|
93
|
+
def initialize(factory_id)
|
|
94
|
+
@factory_id = factory_id
|
|
95
|
+
@tasklets = []
|
|
96
|
+
@channels = []
|
|
97
|
+
# The links each fluent sequence was declared with, in declaration
|
|
98
|
+
# order. Recorded, never judged: the core reads them and answers.
|
|
99
|
+
@chains = []
|
|
100
|
+
@merge_groups = 0
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Declare a controller-fed entry channel. `decode` turns the wire bytes
|
|
104
|
+
# into the value a handler sees, `codec` names a format from
|
|
105
|
+
# `Codec::CHANNEL_CODECS` instead, and with neither the bytes are JSON.
|
|
106
|
+
# `description` and a `json` channel's JSON Schema `schema` are announced
|
|
107
|
+
# in the relay's API.
|
|
108
|
+
def input(name, decode: nil, codec: nil, description: nil, schema: nil)
|
|
109
|
+
channel = ChannelSpec.new(
|
|
110
|
+
index: @channels.size, name: name.to_s, kind: :entry, consumers: [],
|
|
111
|
+
chains: [], chain_edges: [], overflow: :drop_nothing, keyed: false,
|
|
112
|
+
description: description, schema: schema
|
|
113
|
+
)
|
|
114
|
+
named = channel_codec(channel, codec, decode)
|
|
115
|
+
channel.decode = decode || Codec.channel_decoder(named, channel)
|
|
116
|
+
channel.codec = api_codec(named, decode)
|
|
117
|
+
@channels << channel
|
|
118
|
+
Stream.new(self, [:entry, channel], {})
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# The codec a channel names, refused when it is unknown or named beside
|
|
122
|
+
# the channel's own decoder or encoder; nil when it names none.
|
|
123
|
+
def channel_codec(channel, codec, callable)
|
|
124
|
+
return nil if codec.nil?
|
|
125
|
+
|
|
126
|
+
raise Error.new("topology.codec_with_callable", channel: channel.name) if callable
|
|
127
|
+
|
|
128
|
+
known = Codec::CHANNEL_CODECS.find { |name| name.to_s == codec.to_s }
|
|
129
|
+
return known if known
|
|
130
|
+
|
|
131
|
+
raise Error.new("topology.unknown_codec", channel: channel.name, codec: codec.to_s,
|
|
132
|
+
codecs: Codec::CHANNEL_CODECS.map(&:to_s))
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# The format the relay's API announces for a channel: the codec it names,
|
|
136
|
+
# `custom` for its own decoder or encoder, else JSON.
|
|
137
|
+
def api_codec(named, callable) = callable ? "custom" : (named || :json).to_s
|
|
138
|
+
|
|
139
|
+
# Merge two or more sources into one tasklet: see `Merge`.
|
|
140
|
+
def merge = Merge.new(self)
|
|
141
|
+
|
|
142
|
+
# The id that ties one merge's sources together in the record.
|
|
143
|
+
def next_merge_group
|
|
144
|
+
group = @merge_groups
|
|
145
|
+
@merge_groups += 1
|
|
146
|
+
group
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Turn a relay-wide document stream into per-key deliveries: every key
|
|
150
|
+
# seen on `traffic` receives each distinct document once, as
|
|
151
|
+
# `[key, document]`, on that key's own next traffic item. The block answers
|
|
152
|
+
# a traffic item's key.
|
|
153
|
+
#
|
|
154
|
+
# A key never seen in traffic receives nothing, and a key learns of a new
|
|
155
|
+
# document one traffic item late. Fanning a document out over a remembered
|
|
156
|
+
# key set instead would emit keys this member may no longer own after a
|
|
157
|
+
# rescale; a key its own traffic just reached is one it owns.
|
|
158
|
+
#
|
|
159
|
+
# Sugar over a merge into one packaged `RouteLatest` node named
|
|
160
|
+
# `route_latest`, so a second router in one relay names its own node
|
|
161
|
+
# with `route_latest_named`. Answers the router's output edge.
|
|
162
|
+
def route_latest(documents, traffic, &key) = route_latest_named(documents, traffic, nil, &key)
|
|
163
|
+
|
|
164
|
+
def route_latest_named(documents, traffic, node_name, &key)
|
|
165
|
+
merge
|
|
166
|
+
.from(documents) { |document| { document: document } }
|
|
167
|
+
.from(traffic) { |item| { traffic: key.call(item).to_s } }
|
|
168
|
+
.into(RouteLatest, node_name)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# The node for a tasklet. Two unnamed mentions of one tasklet are one
|
|
172
|
+
# node — the naming convention every SDK mirrors — so a diamond that fans
|
|
173
|
+
# out and back in has one instance, not two.
|
|
174
|
+
def node_for(klass, name = nil)
|
|
175
|
+
definition = klass.definition
|
|
176
|
+
identity = (name || definition.name).to_s
|
|
177
|
+
existing = @tasklets.find { |node| node.name == identity && node.klass == klass }
|
|
178
|
+
return existing if existing
|
|
179
|
+
|
|
180
|
+
if @tasklets.any? { |node| node.name == identity }
|
|
181
|
+
# The node table is keyed by identity and physically cannot hold two,
|
|
182
|
+
# so the record the core would judge is unwritable. The refusal answers
|
|
183
|
+
# under the core's own name all the same.
|
|
184
|
+
raise Error.new("topology.node_identity_collision", name: identity)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
node = TaskNode.new(@tasklets.size, identity, klass, definition, nil)
|
|
188
|
+
@tasklets << node
|
|
189
|
+
node
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# The channel this node produces on, created on first use.
|
|
193
|
+
#
|
|
194
|
+
# Lazily, so a leaf node never carries a phantom channel. Named by the
|
|
195
|
+
# node's identity until a `named(...)` override, and reused by every later
|
|
196
|
+
# call on an edge whose source is this node — which is what makes a fan-out
|
|
197
|
+
# one channel with two consumers rather than two channels claiming one
|
|
198
|
+
# producer.
|
|
199
|
+
def output_channel_for(node)
|
|
200
|
+
return node.output if node.output
|
|
201
|
+
|
|
202
|
+
channel = ChannelSpec.new(
|
|
203
|
+
index: @channels.size, name: node.name, kind: :inter_task,
|
|
204
|
+
producer: node.index, consumers: [], chains: [], chain_edges: [],
|
|
205
|
+
overflow: :drop_nothing, keyed: false
|
|
206
|
+
)
|
|
207
|
+
@channels << channel
|
|
208
|
+
node.output = channel
|
|
209
|
+
channel
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Record one more link on the sequence at `base`, or open one on `source`,
|
|
213
|
+
# and answer the new sequence's id.
|
|
214
|
+
#
|
|
215
|
+
# The links are copied rather than appended in place: a handle held over a
|
|
216
|
+
# branch point extends the prefix twice, and two sequences sharing one
|
|
217
|
+
# array would each see the other's links.
|
|
218
|
+
def link_chain(source, base, link, edge: nil)
|
|
219
|
+
links = base.nil? ? [] : @chains[base].links
|
|
220
|
+
@chains[base].superseded = true unless base.nil?
|
|
221
|
+
@chains << Chain.new(source: source, links: links + [link], superseded: false, edge: edge)
|
|
222
|
+
@chains.size - 1
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# File every sequence on the channel it was declared off, in declaration
|
|
226
|
+
# order — the `chains` the record carries.
|
|
227
|
+
#
|
|
228
|
+
# A channel named only by a sequence no terminator closed is created here,
|
|
229
|
+
# so a dangling chain is submitted rather than dropped on the way to the
|
|
230
|
+
# core. Idempotent: a spec is assembled once, but a builder is reachable.
|
|
231
|
+
def seal
|
|
232
|
+
return self if @sealed
|
|
233
|
+
|
|
234
|
+
@sealed = true
|
|
235
|
+
@chains.each do |chain|
|
|
236
|
+
next if chain.superseded
|
|
237
|
+
|
|
238
|
+
channel = channel_of(chain.source)
|
|
239
|
+
channel.chains << chain.links
|
|
240
|
+
channel.chain_edges << chain.edge
|
|
241
|
+
end
|
|
242
|
+
self
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# The channel an edge's values leave on: the entry's own, or the producing
|
|
246
|
+
# node's, created on first use.
|
|
247
|
+
def channel_of(source)
|
|
248
|
+
source[0] == :entry ? source[1] : output_channel_for(source[1])
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# One edge under construction. Immutable: every fluent call answers a new
|
|
253
|
+
# edge, so a shared prefix can be branched without the arms colliding.
|
|
254
|
+
class Stream
|
|
255
|
+
# The two links a fluent call adds. Frozen literals rather than a hash per
|
|
256
|
+
# call: every `map` on every channel records the same one.
|
|
257
|
+
TRANSFORM = { "link" => "transform" }.freeze
|
|
258
|
+
KEY = { "link" => "key" }.freeze
|
|
259
|
+
|
|
260
|
+
def initialize(builder, from, pending)
|
|
261
|
+
@builder = builder
|
|
262
|
+
@from = from
|
|
263
|
+
@pending = pending
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Override the channel name this edge creates. The default is derived from
|
|
267
|
+
# the producing node's identity.
|
|
268
|
+
def named(name) = with(channel_name: name.to_s)
|
|
269
|
+
|
|
270
|
+
# Transform every value on this edge. Evaluated producer-side.
|
|
271
|
+
def map(&transform) = flat_map { |value| [transform.call(value)] }
|
|
272
|
+
|
|
273
|
+
# Drop the values a predicate rejects, before this edge's key is computed.
|
|
274
|
+
# A dropped value is consumed and reaches no handler.
|
|
275
|
+
def filter(&predicate) = flat_map { |value| predicate.call(value) ? [value] : [] }
|
|
276
|
+
|
|
277
|
+
# Turn every value on this edge into the values the block answers, each
|
|
278
|
+
# keyed and delivered on its own. An empty answer drops the value. Every
|
|
279
|
+
# transform link is one of these: `map` and `filter` are its one-value and
|
|
280
|
+
# one-or-none cases.
|
|
281
|
+
def flat_map(&fan)
|
|
282
|
+
relink(TRANSFORM, transforms: transforms + [->(value) { fan.call(value).to_a }])
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# Route by a computed key: the consumer becomes a dynamic keyed tasklet.
|
|
286
|
+
def key_by(&key) = relink(KEY, key_by: key)
|
|
287
|
+
|
|
288
|
+
def on_full(policy) = with(overflow: policy.to_sym)
|
|
289
|
+
def retry_max(attempts) = with(retry_max: attempts)
|
|
290
|
+
def retry_backoff_ms(milliseconds) = with(retry_backoff_ms: milliseconds)
|
|
291
|
+
|
|
292
|
+
# Stamp `flz.cell` / `flz.seq` / `flz.key` onto this channel's plugin frames.
|
|
293
|
+
def keyed = with(keyed: true)
|
|
294
|
+
|
|
295
|
+
# Fan out: each arm receives this same edge, so `branch` builds exactly the
|
|
296
|
+
# topology the separate `into` statements would.
|
|
297
|
+
#
|
|
298
|
+
# The arity is recorded as a sequence of its own, so the arms extend the
|
|
299
|
+
# prefix this edge already carries rather than the branch's own record.
|
|
300
|
+
def branch(*arms)
|
|
301
|
+
@builder.link_chain(@from, nil, { "link" => "branch", "arms" => arms.size })
|
|
302
|
+
arms.each { |arm| arm.call(self) }
|
|
303
|
+
self
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# Wire this edge into a tasklet.
|
|
307
|
+
def into(klass, node_name = nil)
|
|
308
|
+
node = @builder.node_for(klass, node_name)
|
|
309
|
+
close_on(node, { "link" => "into", "tasklet" => node.index })
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# Close this edge's chain on a resolved node with `link`, and answer the
|
|
313
|
+
# node's output edge. The one body behind `into` and every merge source.
|
|
314
|
+
def close_on(node, link)
|
|
315
|
+
# Creates this edge's channel and applies its pending options, before the
|
|
316
|
+
# link that closes on it is recorded.
|
|
317
|
+
channel
|
|
318
|
+
# The transforms and the key belong to this edge, not to the channel: one
|
|
319
|
+
# channel may feed several tasklets, and a chain declared before one
|
|
320
|
+
# `into` must not reach another. They ride the chain that closes on them,
|
|
321
|
+
# and the core names that chain back on the edge it derived — so whether
|
|
322
|
+
# a re-declaration is the legal fan-in no-op or `EdgeAlreadyWired` is
|
|
323
|
+
# answered there and nowhere here.
|
|
324
|
+
@builder.link_chain(
|
|
325
|
+
@from, @pending[:chain], link,
|
|
326
|
+
edge: ConsumerEdge.new(
|
|
327
|
+
tasklet: node.index, transforms: transforms, key_by: @pending[:key_by]
|
|
328
|
+
)
|
|
329
|
+
)
|
|
330
|
+
Stream.new(@builder, [:tasklet, node], {})
|
|
331
|
+
end
|
|
332
|
+
# `Merge` reaches it through `send`, as a friend of the builder.
|
|
333
|
+
private :close_on
|
|
334
|
+
|
|
335
|
+
# Terminate this edge's channel on the plugin layer, and answer the edge.
|
|
336
|
+
# A name written here IS the channel's name, everywhere one appears;
|
|
337
|
+
# `output` with none leaves it derived as it would be without an output at all.
|
|
338
|
+
#
|
|
339
|
+
# `output(...).into(...)` wires a consumer behind the output, so one emit
|
|
340
|
+
# reaches both. A channel with no consumer behind it is the plugin-bound
|
|
341
|
+
# terminal — terminality is the absence of further wiring, not a different
|
|
342
|
+
# method.
|
|
343
|
+
# The one refusal left here: a channel with no producer has no value to
|
|
344
|
+
# serialize, and the core reads an output as the terminal of the channel it
|
|
345
|
+
# sits on — it cannot see that this one is fed by the controller.
|
|
346
|
+
#
|
|
347
|
+
# `encode` turns a value into the plugin frame's bytes, and `codec` names a
|
|
348
|
+
# format instead. With neither, a String is sent as its bytes and any other
|
|
349
|
+
# value as JSON, so an output needs neither for a string, bytes or a JSON value.
|
|
350
|
+
def output(name = nil, encode: nil, codec: nil) = attach_sink(name&.to_s, encode, codec)
|
|
351
|
+
|
|
352
|
+
# End this edge's chain in a call to another relay, and answer the edge
|
|
353
|
+
# of its Response channel: the entry channel `<channel>.response`, whose
|
|
354
|
+
# values are `Flezha::Response`s. Every value reaching the call, after this
|
|
355
|
+
# chain's transforms and key, is one request. `target` names an instance,
|
|
356
|
+
# or a partition group whose member the key chooses; `factory:` instead
|
|
357
|
+
# names a factory whose instance the key names, started when absent unless
|
|
358
|
+
# `create: false`. `channel:` names the callee's entry channel, and
|
|
359
|
+
# `answer_channel:` the callee's channel whose output answers (its first
|
|
360
|
+
# output for the request when unset). `codec` names the format of both the
|
|
361
|
+
# request and the answer, JSON by default. A call is not an edge, so the
|
|
362
|
+
# Response channel may feed this tasklet. A channel ends in one output or one
|
|
363
|
+
# call.
|
|
364
|
+
def call(target = nil, factory: nil, deadline_ms:, create: true, channel: nil,
|
|
365
|
+
answer_channel: nil, codec: nil)
|
|
366
|
+
raise Error.new("topology.output_on_entry", channel: @from[1].name) unless @from[0] == :tasklet
|
|
367
|
+
|
|
368
|
+
source = self.channel
|
|
369
|
+
named = @builder.channel_codec(source, codec || :json, nil)
|
|
370
|
+
link = call_link(target, factory, deadline_ms, create, channel, answer_channel, source)
|
|
371
|
+
@builder.link_chain(@from, @pending[:chain], link)
|
|
372
|
+
# First call wins, as for an output; a second end is the core's to refuse.
|
|
373
|
+
source.call ||= CallEnd.new(transforms: transforms, key_by: @pending[:key_by],
|
|
374
|
+
encode: Codec.channel_encoder(named, source))
|
|
375
|
+
decode = Codec.channel_decoder(named, source)
|
|
376
|
+
@builder.input("#{source.name}.response", decode: ->(bytes) { Response.decode(bytes, decode) })
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
protected
|
|
380
|
+
|
|
381
|
+
# Attach an output, which leaves as a plugin output; the core refuses a second
|
|
382
|
+
# end on a channel.
|
|
383
|
+
def attach_sink(name, encode, codec)
|
|
384
|
+
unless @from[0] == :tasklet
|
|
385
|
+
raise Error.new("topology.output_on_entry", channel: name || "<derived>")
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
target = channel
|
|
389
|
+
# The name written here IS the channel's name; `output` with none leaves it
|
|
390
|
+
# derived as it would be without an output at all.
|
|
391
|
+
target.name = name unless name.nil?
|
|
392
|
+
named = @builder.channel_codec(target, codec, encode)
|
|
393
|
+
link = { "link" => "sink" }
|
|
394
|
+
link["name"] = name unless name.nil?
|
|
395
|
+
@builder.link_chain(@from, @pending[:chain], link)
|
|
396
|
+
# First call wins on the builder's own side, as the reference does; a
|
|
397
|
+
# second end is what the core refuses the class for.
|
|
398
|
+
if target.sink.nil?
|
|
399
|
+
target.sink = target.name
|
|
400
|
+
target.sink_encode = encode || Codec.channel_encoder(named, target)
|
|
401
|
+
target.codec = @builder.api_codec(named, encode)
|
|
402
|
+
end
|
|
403
|
+
self
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
# The channel this edge's values leave on, with the pending options
|
|
407
|
+
# applied. An entry edge already has one; a tasklet edge's is created on
|
|
408
|
+
# first use and reused after.
|
|
409
|
+
def channel
|
|
410
|
+
if @from[0] == :entry
|
|
411
|
+
target = @from[1]
|
|
412
|
+
else
|
|
413
|
+
target = @builder.output_channel_for(@from[1])
|
|
414
|
+
rename = @pending[:channel_name]
|
|
415
|
+
# Recorded where it is APPLIED, so the core reads the overrides in the
|
|
416
|
+
# order this builder took them and the two cannot disagree about which
|
|
417
|
+
# name a channel ends up with.
|
|
418
|
+
if rename && target.name != rename
|
|
419
|
+
target.name = rename
|
|
420
|
+
@builder.link_chain(@from, nil, { "link" => "named", "name" => rename })
|
|
421
|
+
end
|
|
422
|
+
end
|
|
423
|
+
apply_pending(target)
|
|
424
|
+
target
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
private
|
|
428
|
+
|
|
429
|
+
def transforms = @pending[:transforms] || []
|
|
430
|
+
|
|
431
|
+
# The `call` link as the record carries it; which of `target` and `factory`
|
|
432
|
+
# is named, and whether exactly one is, is the core's to judge.
|
|
433
|
+
def call_link(target, factory, deadline_ms, create, channel, answer_channel, source)
|
|
434
|
+
link = { "link" => "call" }
|
|
435
|
+
link["target"] = target.to_s unless target.nil?
|
|
436
|
+
link["factory"] = factory.to_s unless factory.nil?
|
|
437
|
+
link.merge!("deadline_ms" => deadline_ms, "create" => create)
|
|
438
|
+
link["channel"] = channel.to_s unless channel.nil?
|
|
439
|
+
link["answer_channel"] = answer_channel.to_s unless answer_channel.nil?
|
|
440
|
+
link.merge("response" => "#{source.name}.response")
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
# Applied now, as the reference applies it: an option no terminator follows
|
|
444
|
+
# would otherwise be dropped. This call is why `channel` is protected.
|
|
445
|
+
def with(**next_pending)
|
|
446
|
+
edge = Stream.new(@builder, @from, @pending.merge(next_pending))
|
|
447
|
+
edge.channel
|
|
448
|
+
edge
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
# This edge with one more chain link — the one place a transform's or a
|
|
452
|
+
# key's result edge is built, so every link reaches the record and a chain
|
|
453
|
+
# no `into(...)` closes is submitted rather than silently discarded.
|
|
454
|
+
def relink(link, **next_pending)
|
|
455
|
+
chain = @builder.link_chain(@from, @pending[:chain], link)
|
|
456
|
+
Stream.new(@builder, @from, @pending.merge(next_pending, { chain: chain }))
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
def apply_pending(channel)
|
|
460
|
+
channel.overflow = @pending[:overflow] if @pending[:overflow]
|
|
461
|
+
channel.retry_max = @pending[:retry_max] unless @pending[:retry_max].nil?
|
|
462
|
+
channel.retry_backoff_ms = @pending[:retry_backoff_ms] unless @pending[:retry_backoff_ms].nil?
|
|
463
|
+
channel.keyed = true if @pending[:keyed]
|
|
464
|
+
end
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
# Several sources into one tasklet node, each lifted into the merged value
|
|
468
|
+
# by its own mapping. Recorded, never judged: a merge of one source, or of
|
|
469
|
+
# one source twice, is the core's to refuse.
|
|
470
|
+
class Merge
|
|
471
|
+
def initialize(builder)
|
|
472
|
+
@builder = builder
|
|
473
|
+
@sources = []
|
|
474
|
+
@keys = []
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
# One source and the block that maps its values into the merged value. The
|
|
478
|
+
# mapping is that source's own edge transform, after any the handle
|
|
479
|
+
# already carries, so it runs on the edge the core derives for the source.
|
|
480
|
+
def from(source, &mapping)
|
|
481
|
+
@sources << source.map(&mapping)
|
|
482
|
+
self
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
# Route the merged value by a computed key, evaluated on the mapped value.
|
|
486
|
+
def key_by(&key)
|
|
487
|
+
@keys << key
|
|
488
|
+
self
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
# Close every source on one node under one merge group, and answer the
|
|
492
|
+
# node's output edge.
|
|
493
|
+
def into(klass, node_name = nil)
|
|
494
|
+
node = @builder.node_for(klass, node_name)
|
|
495
|
+
link = { "link" => "merge", "tasklet" => node.index, "group" => @builder.next_merge_group }
|
|
496
|
+
# The key rides each source's chain after its mapping; a second `key_by`
|
|
497
|
+
# is one more link, which the core refuses as it would on one edge.
|
|
498
|
+
@sources.each do |source|
|
|
499
|
+
@keys.reduce(source) { |edge, key| edge.key_by(&key) }.send(:close_on, node, link)
|
|
500
|
+
end
|
|
501
|
+
Stream.new(@builder, [:tasklet, node], {})
|
|
502
|
+
end
|
|
503
|
+
end
|
|
504
|
+
end
|