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,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+
5
+ module Flezha
6
+ # The class-level declaration DSL the authoring surface shares.
7
+ #
8
+ # One method reads and writes: `max_cells` answers what was declared,
9
+ # `max_cells 100` declares it. Each declaration registers its name, which
10
+ # is what makes inheritance **total** — a subclass copies exactly the
11
+ # settings that exist, rather than a list someone has to remember to extend
12
+ # when they add one.
13
+ #
14
+ # That inheritance is the point. Without it a subclass silently loses every
15
+ # declaration its parent made: its static key set falls away, its
16
+ # mailbox capacity to the default, its schema version to 1 — and nothing
17
+ # reports the difference until the topology is wrong at runtime.
18
+ module Declarative
19
+ # Every declaration name known to this class, its own and its ancestors'.
20
+ def settings
21
+ own = @settings ||= []
22
+ superclass.respond_to?(:settings) ? superclass.settings | own : own
23
+ end
24
+
25
+ # Register names a hand-written declaration writes, so inheritance covers
26
+ # them too. Used by the declarations that take more than one value, or that
27
+ # write more than one instance variable.
28
+ def declares(*names)
29
+ (@settings ||= []).concat(names)
30
+ end
31
+
32
+ # A declaration read by its bare name and written by naming a value.
33
+ # `one_of` closes the values it takes: any other is refused where the
34
+ # class body writes it.
35
+ def setting(*names, one_of: nil)
36
+ declares(*names)
37
+ names.each do |name|
38
+ singleton_class.define_method(name) do |value = nil|
39
+ return instance_variable_get(:"@#{name}") if value.nil?
40
+
41
+ if one_of && !one_of.include?(value.to_s.to_sym)
42
+ raise Error.new("topology.invalid_mode", factory_id: @factory_id || to_s, knob: name,
43
+ value: value.to_s)
44
+ end
45
+
46
+ instance_variable_set(:"@#{name}", value)
47
+ end
48
+ end
49
+ end
50
+
51
+ # A declaration whose bare mention declares it: `logical_clock`.
52
+ def flag(name)
53
+ declares(name)
54
+ singleton_class.define_method(name) do |value = true|
55
+ instance_variable_set(:"@#{name}", value)
56
+ end
57
+ end
58
+
59
+ # A declaration that takes a block, read by its bare name.
60
+ def block_setting(*names)
61
+ declares(*names)
62
+ names.each do |name|
63
+ singleton_class.define_method(name) do |&block|
64
+ return instance_variable_get(:"@#{name}") unless block
65
+
66
+ instance_variable_set(:"@#{name}", block)
67
+ end
68
+ end
69
+ end
70
+
71
+ def inherited(subclass)
72
+ super
73
+ hand_down(subclass, settings)
74
+ end
75
+
76
+ private
77
+
78
+ # The declarations among +names+ this class wrote, with their values.
79
+ def written(names) = names.to_h { |name| [name, instance_variable_get(:"@#{name}")] }.compact
80
+
81
+ # Copy the named declarations this class wrote onto +other+.
82
+ def hand_down(other, names)
83
+ written(names).each { |name, value| other.instance_variable_set(:"@#{name}", value) }
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,395 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "codec"
4
+ require_relative "ctx"
5
+ require_relative "graph"
6
+ require_relative "logger"
7
+ require_relative "state"
8
+
9
+ module Flezha
10
+ # A declared class, with the lookups dispatch needs on the hot path, and the
11
+ # live state of every cell it owns.
12
+ class RegisteredClass
13
+ attr_reader :spec, :outgoing
14
+
15
+ def initialize(spec)
16
+ @spec = spec
17
+ @outgoing = spec.tasklets.map do |node|
18
+ spec.channels.select { |channel| channel.producer == node.index }
19
+ end
20
+ @cells = {}
21
+ # The identity bytes the core hands with a cell's first activation.
22
+ @identities = {}
23
+ # Cells of one class are touched from every worker thread, and the
24
+ # core's per-cell exclusivity does not extend to this Hash: two
25
+ # different cells of the same class run concurrently by design.
26
+ @lock = Mutex.new
27
+ end
28
+
29
+ # Where the cell an activation names keeps its state. The relay is part
30
+ # of it: one class serves every relay of its factory on this runner,
31
+ # such as each steps instance, and each has cells of its own.
32
+ def self.address(activation, node) = [activation[:relay], node.index, activation[:cell_key]]
33
+
34
+ # The cell's live state, created from the declaration on first touch.
35
+ def state_of(node, address)
36
+ @lock.synchronize do
37
+ @cells.fetch(address) { @cells[address] = node.definition.build_initial_state(address.last) }
38
+ end
39
+ end
40
+
41
+ def set_state(address, state)
42
+ @lock.synchronize { @cells[address] = state }
43
+ end
44
+
45
+ def keep_identity(address, identity) = @lock.synchronize { @identities[address] = identity }
46
+ def identity_of(address) = @lock.synchronize { @identities.fetch(address) }
47
+
48
+ # Drop a cell that left this runner, or every cell of its relay.
49
+ def forget(address, whole_relay)
50
+ @lock.synchronize do
51
+ [@cells, @identities].each do |table|
52
+ if whole_relay
53
+ table.delete_if { |(relay, _, _), _| relay == address.first }
54
+ else
55
+ table.delete(address)
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
61
+
62
+ # The declared classes this runner serves.
63
+ class Registry
64
+ def initialize
65
+ @classes = {}
66
+ end
67
+
68
+ def add(spec)
69
+ if @classes.key?(spec.factory_id)
70
+ raise Error.new("topology.duplicate_factory_id", factory_id: spec.factory_id)
71
+ end
72
+
73
+ @classes[spec.factory_id] = RegisteredClass.new(spec)
74
+ end
75
+
76
+ def remove(factory_id) = @classes.delete(factory_id)
77
+ def get(factory_id) = @classes[factory_id]
78
+ def all = @classes.values
79
+ def empty? = @classes.empty?
80
+ end
81
+
82
+ # Turning one activation into one answer, exactly once.
83
+ #
84
+ # Per-cell exclusivity IS the handout: the core holds a cell's next
85
+ # activation until this one is answered, so an activation that returns
86
+ # unanswered retires that cell for the life of the process. Every path out
87
+ # of `dispatch` therefore ends in a completion or an abandon, through an
88
+ # `ensure` rather than a rule applied at each return site — a raised handler,
89
+ # an encoding failure and a killed thread all reach the same guard.
90
+ module Dispatch
91
+ module_function
92
+
93
+ def failed(token, message, non_retryable: false)
94
+ { token: token, outcome: "failed", error: message, non_retryable: non_retryable }
95
+ end
96
+
97
+ # What a failure says on the wire. `class: message`, in family with what
98
+ # the other SDKs send — this string is stored in a dead-letter entry and
99
+ # read by a user, and a multi-line backtrace there is noise the retry
100
+ # ladder multiplies. The backtrace is logged host-side instead, where it is
101
+ # a diagnostic rather than a record.
102
+ def error_message(error) = "#{error.class}: #{error.is_a?(Error) ? error.detailed_message : error.message}"
103
+
104
+ # A handler declares an error non-retryable by answering `non_retryable?`.
105
+ def non_retryable?(error)
106
+ error.respond_to?(:non_retryable?) && error.non_retryable?
107
+ end
108
+
109
+ # An entry channel's decoder, or the entry edge's transforms over what it
110
+ # decoded, rejected a message. Only the entrance raises this: the verdict
111
+ # it becomes tells the core the message is unreadable rather than that the
112
+ # handler failed, so a steps relay parks it and keeps waiting instead of
113
+ # ending.
114
+ class UnreadableMessage < StandardError
115
+ def initialize(cause)
116
+ super(Dispatch.error_message(cause))
117
+ end
118
+ end
119
+
120
+ # An item's failure as its verdict and the message the wire carries.
121
+ def failure_of(error)
122
+ return ["unreadable", error.message] if error.is_a?(UnreadableMessage)
123
+
124
+ [non_retryable?(error) ? "non_retryable" : "retryable", error_message(error)]
125
+ end
126
+
127
+ # A payload as its channel reads it: the entry channel's decoder, or the
128
+ # class codec for a payload between tasklets. The one decoder for an input
129
+ # item and for a payload at the prepare crossing.
130
+ def decoded(channel, payload)
131
+ channel&.decode ? channel.decode.call(payload) : Codec.decode(payload)
132
+ end
133
+
134
+ # The channel's edge that feeds `node`, or nil when it feeds none.
135
+ def edge_into(channel, node)
136
+ channel&.consumers&.find { |edge| edge.tasklet == node }
137
+ end
138
+
139
+ # Produce the answer for one activation. Raising is not an option here:
140
+ # every failure becomes a result the core can act on, because the
141
+ # alternative is a cell that never receives work again.
142
+ def answer(activation, deps)
143
+ registered = deps.registry.get(activation[:factory])
144
+ unless registered
145
+ return failed(activation[:token],
146
+ "no class registered for factory `#{activation[:factory]}`",
147
+ non_retryable: true)
148
+ end
149
+
150
+ node = registered.spec.tasklets[activation[:tasklet]]
151
+ unless node
152
+ return failed(activation[:token],
153
+ "class `#{activation[:factory]}` has no tasklet at index " \
154
+ "#{activation[:tasklet]}", non_retryable: true)
155
+ end
156
+ if activation[:cell_identity]
157
+ registered.keep_identity(RegisteredClass.address(activation, node), activation[:cell_identity])
158
+ end
159
+
160
+ case activation[:kind]
161
+ when :work then run_work(activation, registered, node, deps)
162
+ when :restore_fold then restore_fold(activation, registered, node)
163
+ when :snapshot_request then snapshot(activation, registered, node)
164
+ when :live_state_read then state_json(activation, registered, node)
165
+ when :apply_state
166
+ registered.set_state(RegisteredClass.address(activation, node),
167
+ Codec.from_state_json(activation[:state_json]))
168
+ { token: activation[:token], outcome: "state_applied" }
169
+ when :key_request then prepare(activation, registered)
170
+ when :compensate_run then compensate(activation, registered, node, deps)
171
+ when :leave
172
+ registered.forget(RegisteredClass.address(activation, node), activation[:whole_relay])
173
+ { token: activation[:token], outcome: "state_applied" }
174
+ else
175
+ failed(activation[:token], "unknown activation kind `#{activation[:kind]}`",
176
+ non_retryable: true)
177
+ end
178
+ rescue StandardError => e
179
+ failed(activation[:token], error_message(e), non_retryable: non_retryable?(e))
180
+ end
181
+
182
+ # Run user code against one cell, and keep the state it produced.
183
+ #
184
+ # Every activation kind that reaches a handler goes through here — an
185
+ # input, a timer firing, a compensation — so the context they receive and
186
+ # the state they leave behind cannot differ between them. The state is
187
+ # written back whether or not the body raised: a change recorded before the
188
+ # raise is already journaled, and dropping it here would make the live
189
+ # cell disagree with its own log.
190
+ def invoke(activation, registered, node, deps, now, replay, offset = nil, item: nil)
191
+ address = RegisteredClass.address(activation, node)
192
+ ctx = Ctx.new(
193
+ spec: registered.spec, node: node, outgoing: registered.outgoing[node.index],
194
+ registered: registered, address: address, cell_key: activation[:cell_key],
195
+ change_seq: activation[:change_seq], now: now, replay: replay,
196
+ state: registered.state_of(node, address), bridge: deps.bridge,
197
+ token: activation[:token], offset: offset, item: item,
198
+ compensating: activation[:kind] == :compensate_run
199
+ )
200
+ begin
201
+ yield(ctx)
202
+ ctx
203
+ ensure
204
+ registered.set_state(address, ctx.state)
205
+ end
206
+ end
207
+
208
+ def run_work(activation, registered, node, deps)
209
+ effects = []
210
+
211
+ Array(activation[:items]).each_with_index do |item, index|
212
+ now = item[:now] || 0
213
+ begin
214
+ ctx = invoke(activation, registered, node, deps, now, item[:replay] || false,
215
+ item[:consumed_offset], item: index) do |c|
216
+ run_item(registered, node, item, now, c)
217
+ end
218
+ effects << ctx.send(:effect_record, item[:consumed_offset])
219
+ rescue StandardError => e
220
+ # The backtrace is a host diagnostic, not a record: the verdict below
221
+ # travels to the dead-letter entry and the controller's log.
222
+ Flezha.log.debug("#{node.name} failed an item: #{e.class}: #{e.message}\n" \
223
+ "#{Array(e.backtrace).first(8).join("\n")}")
224
+ # Items after this one are re-handed by the core, so the batch stops
225
+ # here rather than reporting effects the cell never saw.
226
+ verdict, message = failure_of(e)
227
+ effects << {
228
+ verdict: verdict,
229
+ message: message,
230
+ event_time: now,
231
+ consumed_offset: item[:consumed_offset]
232
+ }
233
+ break
234
+ end
235
+ end
236
+
237
+ { token: activation[:token], outcome: "work", effects: effects }
238
+ end
239
+
240
+ # One item against the handler its kind names, inside an invocation.
241
+ def run_item(registered, node, item, now, ctx)
242
+ definition = node.definition
243
+ if item[:kind] == :timer
244
+ # Registration is a runtime call, so this cannot be refused at
245
+ # declaration time.
246
+ unless definition.on_timer?
247
+ raise Error.new("runtime.timer_without_on_timer", tasklet: node.name)
248
+ end
249
+
250
+ definition.instance.on_timer(
251
+ { kind: item[:timer_kind], scheduled_at: item[:scheduled_at] || now }, ctx
252
+ )
253
+ return nil
254
+ end
255
+
256
+ values =
257
+ if item[:form] == :prepared
258
+ # The prepare crossing decoded a keyed payload and ran this node's
259
+ # edge already; each value comes encoded as a value between tasklets.
260
+ Array(item[:values]).map { |bytes| Codec.decode(bytes) }
261
+ else
262
+ entering(registered.spec.channels[item[:channel] || 0], node, item)
263
+ end
264
+ # A filtered value reaches no handler and a fanned one reaches it once
265
+ # per value. Either way the item is one effect, so its offset advances
266
+ # once and nothing is retried. What `run` answers is not output: a value
267
+ # leaves through `ctx.emit`.
268
+ values.each { |input| definition.instance.run(input, ctx) }
269
+ nil
270
+ end
271
+
272
+ # An input payload as the handler sees it: decoded, then through this
273
+ # node's own edge off the channel when the core marks it untransformed: a
274
+ # flow producer already ran that edge, and running it again would compose
275
+ # the chain with itself. The entrance is where a message the relay cannot
276
+ # read is told apart from a handler that fails on one it can.
277
+ def entering(channel, node, item)
278
+ edge = item[:form] == :untransformed ? edge_into(channel, node.index) : nil
279
+ Flezha.apply_transforms(edge&.transforms || [], decoded(channel, item[:payload] || "".b))
280
+ rescue StandardError => e
281
+ raise unless channel&.kind == :entry
282
+
283
+ raise UnreadableMessage, e
284
+ end
285
+
286
+ def restore_fold(activation, registered, node)
287
+ registered.set_state(
288
+ RegisteredClass.address(activation, node),
289
+ State.fold_restore(
290
+ node.definition,
291
+ { snapshot: activation[:snapshot], changes: activation[:changes] },
292
+ activation[:cell_key], node.name
293
+ )
294
+ )
295
+ { token: activation[:token], outcome: "restored" }
296
+ end
297
+
298
+ def state_json(activation, registered, node)
299
+ state = registered.state_of(node, RegisteredClass.address(activation, node))
300
+ { token: activation[:token], outcome: "state_json", json: Codec.to_state_json(state).b }
301
+ end
302
+
303
+ def snapshot(activation, registered, node)
304
+ codec = node.definition.state_codec
305
+ state = registered.state_of(node, RegisteredClass.address(activation, node))
306
+ { token: activation[:token], outcome: "snapshot",
307
+ state: (codec ? codec.encode(state).b : Codec.encode(state)) }
308
+ end
309
+
310
+ def prepare(activation, registered)
311
+ request = activation[:key_request]
312
+ return failed(activation[:token], "a key request carried no payloads", non_retryable: true) unless request
313
+
314
+ channel = registered.spec.channels[request[:channel]]
315
+ answers = request[:payloads].map do |payload|
316
+ prepared(registered, channel, request, decoded(channel, payload))
317
+ rescue StandardError => e
318
+ # A per-payload refusal enters the input channel's retry ladder; it
319
+ # is not a failure of the crossing, whose other payloads still have
320
+ # answers.
321
+ { error: error_message(e), non_retryable: non_retryable?(e) }
322
+ end
323
+
324
+ { token: activation[:token], outcome: "prepared", prepared: answers }
325
+ end
326
+
327
+ # One decoded payload's answer: every value each keyed consumer's own edge
328
+ # yields, with the Cells it names, and the event time the first timed
329
+ # consumer reads off its first value. A payload its chains filter answers
330
+ # no value, so the core mints no Cell for it.
331
+ def prepared(registered, channel, request, value)
332
+ event_time = nil
333
+ values = (channel&.consumers || []).flat_map do |edge|
334
+ definition = registered.spec.tasklets[edge.tasklet].definition
335
+ keyed = request[:want_keys] && (edge.key_by || definition.cells_for?)
336
+ timed = request[:want_event_time] && event_time.nil? && definition.event_time?
337
+ next [] unless keyed || timed
338
+
339
+ yielded = Flezha.apply_transforms(edge.transforms, value)
340
+ event_time = definition.event_time_of(yielded.first) if timed && !yielded.empty?
341
+ next [] unless keyed
342
+
343
+ yielded.map do |v|
344
+ keys = edge.key_by ? [edge.key_by.call(v).to_s] : definition.cells_for(v).map(&:to_s)
345
+ { consumer: edge.tasklet, keys: keys, bytes: Codec.encode(v) }
346
+ end
347
+ end
348
+ { values: values, event_time: event_time }
349
+ end
350
+
351
+ def compensate(activation, registered, node, deps)
352
+ definition = node.definition
353
+ unless definition.compensate?
354
+ return failed(activation[:token], "#{node.name} declares no compensate hook",
355
+ non_retryable: true)
356
+ end
357
+
358
+ outputs = Array(activation[:outputs]).map { |bytes| Codec.decode(bytes) }
359
+ ctx = invoke(activation, registered, node, deps, 0, false) do |c|
360
+ definition.instance.compensate(outputs, c)
361
+ end
362
+ { token: activation[:token], outcome: "work", effects: [ctx.send(:effect_record, nil)] }
363
+ end
364
+
365
+ # Dispatch one activation and answer it exactly once.
366
+ #
367
+ # The guard is the contract, not politeness. An unanswered activation
368
+ # retires its cell for the life of the process, so a raise from the
369
+ # dispatcher, from encoding the result, or from `complete` itself abandons
370
+ # rather than logging and returning.
371
+ def dispatch(activation, native, deps)
372
+ answered = false
373
+ begin
374
+ native.complete(answer(activation, deps))
375
+ answered = true
376
+ rescue StandardError => e
377
+ Flezha.log.error("activation processing failed: #{error_message(e)}")
378
+ ensure
379
+ unless answered
380
+ begin
381
+ native.abandon(activation[:token])
382
+ rescue StandardError => e
383
+ # The core refuses an unknown token: `complete` had already landed
384
+ # and only the bookkeeping after it raised. Nothing is wedged.
385
+ Flezha.log.warn("abandoning activation #{activation[:token]} failed: " \
386
+ "#{error_message(e)}")
387
+ end
388
+ end
389
+ end
390
+ end
391
+ end
392
+
393
+ # What a dispatch loop needs to answer an activation, over one extension.
394
+ DispatchDeps = Struct.new(:registry, :bridge, keyword_init: true)
395
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Flezha
6
+ # Anything Flezha refuses: the controller, the core, or this SDK itself.
7
+ #
8
+ # `code` (`family.snake`) and `error_class` (`invalid`, `not_found`,
9
+ # `denied`, ...) are what a program branches on; `message` is one sentence
10
+ # for a reader, `details` names what the message is about, and `hint` is the
11
+ # next command to run, when there is one. An uncaught one prints the code
12
+ # after the message.
13
+ #
14
+ # A refusal this SDK makes is built from its code and details, and its
15
+ # message is the catalogue's, rendered by the core:
16
+ #
17
+ # raise Flezha::Error.new("topology.duplicate_factory_id", factory_id: "echo")
18
+ class Error < StandardError
19
+ attr_reader :code, :error_class, :details, :hint
20
+
21
+ # The error a JSON error document `{"error": {...}}` carries. The native
22
+ # extension raises every failure through here.
23
+ def self.from_document(document) = from_object(JSON.parse(document).fetch("error"))
24
+
25
+ # The error a parsed error object (`code`, `class`, `message`, `details`,
26
+ # `hint`) carries, as a controller writes one into a call's outcome.
27
+ def self.from_object(fields)
28
+ allocate.tap { |error| error.send(:fill, fields) }
29
+ end
30
+
31
+ def initialize(code, **details)
32
+ super()
33
+ fill(JSON.parse(Flezha::Core.error_document(code, details)).fetch("error"))
34
+ end
35
+
36
+ def to_s = @message
37
+
38
+ # How an uncaught one prints: the message, then the code.
39
+ def detailed_message(**) = "#{message} (#{code})"
40
+
41
+ private
42
+
43
+ def fill(fields)
44
+ @code = fields.fetch("code")
45
+ @error_class = fields.fetch("class")
46
+ @message = fields.fetch("message")
47
+ @details = fields["details"] || {}
48
+ @hint = fields["hint"]
49
+ end
50
+ end
51
+
52
+ # A handler error the retry ladder must not retry. Raise this (or set
53
+ # `non_retryable` on any error) to park an item immediately. An author's own
54
+ # steering type, not a refusal, so it is no `Flezha::Error`.
55
+ class NonRetryableError < StandardError
56
+ def non_retryable? = true
57
+ end
58
+ end