flezha 0.1.0-x86_64-linux

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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