chrono_forge 0.10.0 → 0.11.0

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,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChronoForge
4
+ # Rendering-agnostic graph model produced by DefinitionAnalyzer. Plain value
5
+ # objects so a Definition can be cached/serialized (to_h -> JSON) and consumed
6
+ # by any renderer. No DB, no Prism, no dashboard dependency here.
7
+ class Definition
8
+ # kind: :execute :wait :wait_until :continue_if :branch :merge :repeat :dynamic
9
+ # A node binds to runtime logs by EXACT step_name when known, else by
10
+ # step_name_pattern (a prefix for fan-out/repeat/dynamic).
11
+ Node = Struct.new(
12
+ :id, :kind, :label, :step_name, :step_name_pattern, :guard, :warnings,
13
+ keyword_init: true
14
+ ) do
15
+ def dynamic? = kind == :dynamic || step_name.nil?
16
+
17
+ # Default a missing warnings member to [] here (rather than overriding the
18
+ # struct's generated reader, which triggers a method-redefined warning).
19
+ def to_h = super.merge(warnings: self[:warnings] || [])
20
+ end
21
+
22
+ # kind: :seq :conditional :fanout :join :terminal
23
+ Edge = Struct.new(:from, :to, :kind, :guard, keyword_init: true)
24
+
25
+ attr_reader :nodes, :edges, :warnings
26
+
27
+ def initialize(nodes: [], edges: [], warnings: [])
28
+ @nodes = nodes
29
+ @edges = edges
30
+ @warnings = warnings
31
+ end
32
+
33
+ def to_h
34
+ {nodes: nodes.map(&:to_h), edges: edges.map(&:to_h), warnings: warnings}
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,501 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module ChronoForge
6
+ # Statically analyzes a workflow class's `perform` method (via Prism) into a
7
+ # conservative Definition graph. Reads SOURCE TEXT ONLY — never the DB, never
8
+ # executes workflow code. Unresolvable Ruby becomes a :dynamic node + warning.
9
+ class DefinitionAnalyzer
10
+ # The durable DSL calls we recognize -> node kind.
11
+ DURABLE = {
12
+ durably_execute: :execute, wait: :wait, wait_until: :wait_until,
13
+ continue_if: :continue_if, branch: :branch, merge_branches: :merge,
14
+ merge_branch: :merge, durably_repeat: :repeat
15
+ }.freeze
16
+
17
+ # Which argument carries the step NAME, per the real DSL signatures in
18
+ # executor/methods. `pos` is the 0-based positional index; `kw` = whether a
19
+ # `name:` keyword overrides it. `wait(duration, name)` is the one primitive
20
+ # whose name is the SECOND positional. merge_branches is special-cased in
21
+ # step_name_for (its name joins all positional branch names).
22
+ NAME_ARG = {
23
+ durably_execute: {pos: 0, kw: true},
24
+ wait: {pos: 1, kw: false},
25
+ wait_until: {pos: 0, kw: false},
26
+ continue_if: {pos: 0, kw: true},
27
+ durably_repeat: {pos: 0, kw: true},
28
+ branch: {pos: 0, kw: false}
29
+ }.freeze
30
+
31
+ def self.call(workflow_class) = new(workflow_class).call
32
+
33
+ def initialize(workflow_class)
34
+ @klass = workflow_class
35
+ @nodes = []
36
+ @edges = []
37
+ @warnings = []
38
+ @seq = 0
39
+ end
40
+
41
+ def call
42
+ _file, method_node, defs = locate_perform
43
+ return unavailable unless method_node
44
+
45
+ @defs = defs # name(Symbol) => Prism::DefNode, for same-class helper tracing
46
+ walk(method_node.body, "start") # builds @nodes/@edges as a side effect
47
+ if @nodes.empty?
48
+ @warnings << "no durable steps found in this workflow's perform — it may be " \
49
+ "empty, or drive its steps through code this static analysis can't follow"
50
+ end
51
+ Definition.new(nodes: @nodes, edges: @edges, warnings: @warnings)
52
+ rescue => e
53
+ unavailable("analysis error: #{e.class}: #{e.message}")
54
+ end
55
+
56
+ private
57
+
58
+ # The workflow's OWN perform, not the prepended ChronoForge::Executor#perform.
59
+ # A workflow does `prepend ChronoForge::Executor`, so instance_method(:perform)
60
+ # resolves to the executor's wrapper (owner is a Module). Walk the super chain
61
+ # to the first perform defined on a real Class in the ancestry — the user's.
62
+ def user_perform
63
+ um = @klass.instance_method(:perform)
64
+ um = um.super_method while um && !um.owner.is_a?(Class)
65
+ um
66
+ rescue NameError
67
+ nil
68
+ end
69
+
70
+ # Resolve perform's source file, parse it, and collect the instance-method
71
+ # DefNodes that lexically belong to the SAME class body as the bound perform
72
+ # (for same-class helper tracing). Scoping to the containing class avoids a
73
+ # bare helper call resolving to a same-named method in a DIFFERENT class.
74
+ def locate_perform
75
+ loc = user_perform&.source_location
76
+ return [nil, nil, {}] unless loc && File.readable?(loc.first)
77
+
78
+ file, line = loc
79
+ root = Prism.parse_file(file).value
80
+ klass = innermost_class_containing(root, line)
81
+ return [file, nil, {}] unless klass
82
+
83
+ defs = {}
84
+ perform = nil
85
+ class_method_defs(klass).each do |d|
86
+ defs[d.name] = d
87
+ # A file may hold several workflow classes (each with its own #perform);
88
+ # bind to the one whose `def` starts on this method's source line.
89
+ perform = d if d.name == :perform && d.location.start_line == line
90
+ end
91
+ [file, perform, defs]
92
+ end
93
+
94
+ # The DEEPEST Class/Module node whose line range covers `line` (a class nested
95
+ # in a module returns the inner class, since descent reassigns last-wins).
96
+ def innermost_class_containing(node, line)
97
+ found = nil
98
+ visit = ->(n) do
99
+ return unless n.is_a?(Prism::Node)
100
+ if (n.is_a?(Prism::ClassNode) || n.is_a?(Prism::ModuleNode)) &&
101
+ n.location.start_line <= line && line <= n.location.end_line
102
+ found = n
103
+ end
104
+ n.compact_child_nodes.each { |c| visit.call(c) }
105
+ end
106
+ visit.call(node)
107
+ found
108
+ end
109
+
110
+ # DefNodes directly in this class/module body — NOT inside a deeper nested
111
+ # class/module (those belong to other classes).
112
+ def class_method_defs(klass)
113
+ out = []
114
+ collect = ->(n) do
115
+ return unless n.is_a?(Prism::Node)
116
+ return if n.is_a?(Prism::ClassNode) || n.is_a?(Prism::ModuleNode)
117
+ out << n if n.is_a?(Prism::DefNode)
118
+ n.compact_child_nodes.each { |c| collect.call(c) }
119
+ end
120
+ klass.compact_child_nodes.each { |c| collect.call(c) }
121
+ out
122
+ end
123
+
124
+ # Walk a body node in source order, threading the "previous node id" so we can
125
+ # emit sequential edges. Returns the id of the last node reached (the exit).
126
+ def walk(node, prev)
127
+ return prev if node.nil?
128
+
129
+ statements =
130
+ case node
131
+ when Prism::StatementsNode then node.body
132
+ else [node]
133
+ end
134
+
135
+ statements.each { |stmt| prev = visit(stmt, prev) }
136
+ prev
137
+ end
138
+
139
+ # Visit one statement; return the new "previous" id (unchanged if it emitted
140
+ # nothing). `prev` may be a single node id or a list of ids (the multiple
141
+ # exits of a conditional that rejoin at the next statement).
142
+ def visit(stmt, prev)
143
+ case stmt
144
+ when Prism::IfNode, Prism::UnlessNode
145
+ visit_conditional(stmt, prev)
146
+ when Prism::CaseNode, Prism::CaseMatchNode
147
+ visit_case(stmt, prev)
148
+ when Prism::BeginNode
149
+ visit_begin(stmt, prev)
150
+ when Prism::LocalVariableWriteNode, Prism::InstanceVariableWriteNode,
151
+ Prism::ClassVariableWriteNode, Prism::GlobalVariableWriteNode,
152
+ Prism::ConstantWriteNode, Prism::MultiWriteNode
153
+ # A durable call on an assignment's RHS (result = durably_execute :fetch)
154
+ # must still appear; unwrap to the value expression and visit it.
155
+ visit(stmt.value, prev)
156
+ when Prism::ArrayNode
157
+ # An implicit array (e.g. a multi-assign RHS `a, b = durably_execute(:x), 1`)
158
+ # — visit each element so a durable one is surfaced.
159
+ stmt.elements.reduce(prev) { |p, e| visit(e, p) }
160
+ when Prism::AndNode, Prism::OrNode
161
+ # Short-circuit boolean: the left always runs, the right conditionally.
162
+ # Surface both operands in order so a durable one (ok? || wait_until :warm)
163
+ # isn't dropped.
164
+ visit(stmt.right, visit(stmt.left, prev))
165
+ when Prism::ReturnNode
166
+ # An early `return` exits the run: a :terminal edge to the shared "halt"
167
+ # sink from each live predecessor. When the return sits inside an if/unless
168
+ # (the common `return unless ready?` form), mark_entry_conditional stamps
169
+ # this edge with the guard. This path does NOT continue, so it contributes
170
+ # no exit — return [] so the next statement builds only from the skip path.
171
+ to_list(prev).each { |p| add_edge(p, "halt", :terminal) }
172
+ []
173
+ else
174
+ if (call = durable_call(stmt))
175
+ id = emit_durable(call, prev)
176
+ id = attach_terminal(call, id) if call.name == :continue_if
177
+ id
178
+ elsif (helper = traceable_helper(stmt))
179
+ trace_helper(helper, prev)
180
+ elsif loop_with_durable?(stmt)
181
+ @warnings << "durable step inside a loop (#{stmt.class.name.split("::").last}) — " \
182
+ "count is data-dependent; shown once, not unrolled"
183
+ walk_loop_body(stmt, prev)
184
+ else
185
+ prev
186
+ end
187
+ end
188
+ end
189
+
190
+ # A bare (receiverless/self) call to a same-file method whose body contains a
191
+ # durable call — worth tracing inline. Recursion-guarded via @tracing.
192
+ def traceable_helper(node)
193
+ return nil unless node.is_a?(Prism::CallNode)
194
+ return nil unless node.receiver.nil? || node.receiver.is_a?(Prism::SelfNode)
195
+ dfn = @defs[node.name]
196
+ return nil unless dfn
197
+ return nil if (@tracing ||= []).include?(node.name)
198
+ body_has_durable?(dfn.body) ? dfn : nil
199
+ end
200
+
201
+ def trace_helper(dfn, prev)
202
+ (@tracing ||= []) << dfn.name
203
+ result = walk(dfn.body, prev)
204
+ @tracing.pop
205
+ result
206
+ end
207
+
208
+ def body_has_durable?(node)
209
+ return false unless node.is_a?(Prism::Node)
210
+ if node.is_a?(Prism::CallNode) && DURABLE.key?(node.name) &&
211
+ (node.receiver.nil? || node.receiver.is_a?(Prism::SelfNode))
212
+ return true
213
+ end
214
+ node.compact_child_nodes.any? { |c| body_has_durable?(c) }
215
+ end
216
+
217
+ def loop_with_durable?(node)
218
+ case node
219
+ when Prism::WhileNode, Prism::UntilNode, Prism::ForNode
220
+ body_has_durable?(node)
221
+ when Prism::CallNode
222
+ %i[each times upto downto each_with_index map].include?(node.name) &&
223
+ node.block && body_has_durable?(node.block)
224
+ else
225
+ false
226
+ end
227
+ end
228
+
229
+ # Walk a loop body ONCE so contained steps appear (with the warning), not
230
+ # unrolled. Handles both keyword loops and iterator blocks.
231
+ def walk_loop_body(node, prev)
232
+ body =
233
+ case node
234
+ when Prism::CallNode then node.block.is_a?(Prism::BlockNode) ? node.block.body : nil
235
+ else node.respond_to?(:statements) ? node.statements : nil
236
+ end
237
+ walk(body, prev)
238
+ end
239
+
240
+ # if/unless: walk the body under a guard, then expose BOTH the body exit(s)
241
+ # and the skip path (pre-`if` node, or the else-branch exits) so the next
242
+ # statement is reachable every way. Returns a list of exit ids.
243
+ def visit_conditional(node, prev)
244
+ raw = source_of(node.predicate)
245
+ unless_node = node.is_a?(Prism::UnlessNode)
246
+ # `unless P` runs its body when P is FALSE and its else when P is TRUE.
247
+ body_guard = unless_node ? negate(raw) : raw
248
+ else_guard = unless_node ? raw : negate(raw)
249
+
250
+ before = @edges.size
251
+ body_exit = walk(node.statements, prev)
252
+ mark_entry_conditional(before, prev, body_guard)
253
+
254
+ exits = to_list(body_exit)
255
+ if (sub = branch_else(node))
256
+ before_else = @edges.size
257
+ else_stmts = sub.is_a?(Prism::ElseNode) ? sub.statements : sub
258
+ else_exit = walk(else_stmts, prev)
259
+ mark_entry_conditional(before_else, prev, else_guard)
260
+ exits |= to_list(else_exit)
261
+ else
262
+ exits |= to_list(prev) # no else: skip path is the pre-`if` node
263
+ end
264
+ exits
265
+ end
266
+
267
+ # begin/rescue/else/ensure: walk the main body and every rescue clause so
268
+ # durable calls in either path appear. Each is an alternative path from the
269
+ # same `prev`; their exits rejoin. (ensure always runs, so it follows all.)
270
+ def visit_begin(node, prev)
271
+ exits = to_list(walk(node.statements, prev))
272
+ rescue_clause = node.rescue_clause
273
+ while rescue_clause
274
+ exits |= to_list(walk(rescue_clause.statements, prev))
275
+ rescue_clause = rescue_clause.subsequent
276
+ end
277
+ exits |= to_list(walk(node.else_clause.statements, prev)) if node.else_clause
278
+ exits = to_list(walk(node.ensure_clause.statements, exits)) if node.ensure_clause
279
+ exits
280
+ end
281
+
282
+ def visit_case(node, prev)
283
+ exits = []
284
+ Array(node.conditions).each do |clause|
285
+ before = @edges.size
286
+ exit_id = walk(clause.statements, prev)
287
+ mark_entry_conditional(before, prev, clause_guard(clause))
288
+ exits |= to_list(exit_id)
289
+ end
290
+ exits |=
291
+ if node.else_clause
292
+ to_list(walk(node.else_clause.statements, prev))
293
+ else
294
+ to_list(prev)
295
+ end
296
+ exits
297
+ end
298
+
299
+ # Guard text for one case clause: the "a, b" condition list of a `when`, or the
300
+ # pattern of a case/in `in` clause.
301
+ def clause_guard(clause)
302
+ case clause
303
+ when Prism::InNode then source_of(clause.pattern)
304
+ else Array(clause.conditions).map { |c| source_of(c) }.join(", ")
305
+ end
306
+ end
307
+
308
+ # The else/elsif chain, across prism versions (IfNode uses #subsequent, older
309
+ # #consequent; Unless/Case expose #else_clause). Returns the node or nil.
310
+ def branch_else(node)
311
+ return node.subsequent if node.respond_to?(:subsequent)
312
+ return node.else_clause if node.respond_to?(:else_clause)
313
+ return node.consequent if node.respond_to?(:consequent)
314
+ nil
315
+ end
316
+
317
+ # The edges added since `before` whose `from` is one of the incoming `prev`
318
+ # ids are exactly the conditional's ENTRY edges (internal body edges originate
319
+ # at body nodes, not at `prev`). Relabel them :conditional and COMPOSE the
320
+ # guard with any existing one so an outer conditional wrapping an inner one
321
+ # yields `outer && inner` on every entry edge.
322
+ def mark_entry_conditional(before, prev, guard)
323
+ prevs = to_list(prev)
324
+ @edges[before..].each do |e|
325
+ next unless prevs.include?(e.from)
326
+ # Keep a terminal (early-return / continue_if false) edge dashed; only its
327
+ # guard is composed. Other entry edges become conditional.
328
+ e.kind = :conditional unless e.kind == :terminal
329
+ e.guard = e.guard ? "#{guard} && #{e.guard}" : guard
330
+ end
331
+ end
332
+
333
+ # continue_if's false path halts the workflow: a :terminal edge to the shared
334
+ # synthetic "halt" sink. Like "start", the sink is a virtual endpoint id, not
335
+ # a real node, so it never pollutes the durable-step node list.
336
+ def attach_terminal(_call, id)
337
+ add_edge(id, "halt", :terminal, "condition false")
338
+ id
339
+ end
340
+
341
+ # Best-effort source text of a predicate node (for guard labels). Falls back
342
+ # to the node type when slicing isn't available.
343
+ def source_of(node)
344
+ raw = node.respond_to?(:slice) ? node.slice : node.class.name.split("::").last
345
+ # Collapse internal whitespace/newlines and truncate so guard labels stay
346
+ # short enough to render on a single edge.
347
+ compact = raw.to_s.gsub(/\s+/, " ").strip
348
+ (compact.length > 60) ? "#{compact[0, 59]}…" : compact
349
+ end
350
+
351
+ def negate(guard) = "!(#{guard})"
352
+
353
+ def to_list(value) = value.is_a?(Array) ? value : [value]
354
+
355
+ # A Prism::CallNode whose method is a recognized durable DSL call with no
356
+ # explicit receiver (or `self`). Returns the CallNode or nil.
357
+ def durable_call(node)
358
+ return nil unless node.is_a?(Prism::CallNode)
359
+ return nil unless DURABLE.key?(node.name)
360
+ return nil unless node.receiver.nil? || node.receiver.is_a?(Prism::SelfNode)
361
+ node
362
+ end
363
+
364
+ def emit_durable(call, prev)
365
+ kind = DURABLE.fetch(call.name)
366
+ name, dynamic = resolved_name(call)
367
+ step_name = dynamic ? nil : step_name_for(call.name, name, call)
368
+ # merge_branches only binds when EVERY branch name is literal; if step_name_for
369
+ # couldn't resolve them all it returns nil. Treat that as dynamic (unbindable)
370
+ # rather than a merge node with a nil step_name that silently never matches.
371
+ dynamic ||= step_name.nil?
372
+ node = add_node(
373
+ kind: dynamic ? :dynamic : kind,
374
+ label: ((kind == :merge) ? merge_label(call) : label_for(call.name, name)),
375
+ step_name: step_name,
376
+ step_name_pattern: ("#{prefix_for(call.name)}$" if dynamic),
377
+ warnings: (dynamic ? ["#{call.name}: dynamic name — bound by prefix/ordinal"] : [])
378
+ )
379
+ to_list(prev).each { |p| add_edge(p, node.id, :seq) }
380
+
381
+ if call.name == :branch && call.block
382
+ emit_branch_children(call.block, node)
383
+ (@branches ||= {})[name] = node.id if name # skip dynamic branch names
384
+ elsif kind == :merge
385
+ positional_args(call).each do |arg|
386
+ bname = literal_value(arg)
387
+ next unless bname # a dynamic merge name matches no recorded branch
388
+ src = @branches && @branches[bname]
389
+ add_edge(src, node.id, :join) if src
390
+ end
391
+ end
392
+ node.id
393
+ end
394
+
395
+ # A branch's spawn/spawn_each calls each become one child-group node, reached
396
+ # by a :fanout edge. Children are keyed <wf.key>$<branch>$<name>_* at runtime;
397
+ # we record a prefix pattern for reference, but the dashboard overlay computes
398
+ # fan-out status from child-workflow counts (BranchProbe), not this pattern.
399
+ def emit_branch_children(block, branch_node)
400
+ body = block.is_a?(Prism::BlockNode) ? block.body : nil
401
+ stmts =
402
+ case body
403
+ when Prism::StatementsNode then body.body
404
+ else Array(body)
405
+ end
406
+ stmts.each do |stmt|
407
+ next unless stmt.is_a?(Prism::CallNode) && %i[spawn spawn_each].include?(stmt.name)
408
+ sname = literal_value(positional_args(stmt).first)
409
+ child = add_node(
410
+ kind: :dynamic,
411
+ label: "#{stmt.name} #{sname}".strip,
412
+ step_name: nil,
413
+ step_name_pattern: (sname ? "spawn:#{sname}" : "spawn"),
414
+ warnings: ["fan-out — status is aggregated from child workflows"]
415
+ )
416
+ add_edge(branch_node.id, child.id, :fanout)
417
+ end
418
+ end
419
+
420
+ # Resolve the step NAME literal from a durable call, per NAME_ARG. Returns
421
+ # [name_string_or_nil, dynamic?]. dynamic? is true when the name can't be
422
+ # resolved to a literal statically. (merge_branches resolves via all
423
+ # positionals in step_name_for; its `name` here is just the first branch.)
424
+ def resolved_name(call)
425
+ spec = NAME_ARG.fetch(call.name, {pos: 0, kw: true})
426
+ if spec[:kw] && (override = keyword_literal(call, :name))
427
+ return [override, false]
428
+ end
429
+ lit = literal_value(positional_args(call)[spec[:pos]])
430
+ lit ? [lit, false] : [nil, true]
431
+ end
432
+
433
+ def step_name_for(dsl, name, call)
434
+ case dsl
435
+ when :merge_branches, :merge_branch
436
+ names = positional_args(call).map { |a| literal_value(a) }
437
+ return nil if names.any?(&:nil?)
438
+ "merge$#{names.sort.join(",")}"
439
+ else
440
+ "#{prefix_for(dsl)}$#{name}"
441
+ end
442
+ end
443
+
444
+ def prefix_for(dsl)
445
+ case dsl
446
+ when :merge_branches, :merge_branch then "merge"
447
+ when :branch then "branch"
448
+ when :durably_repeat then "durably_repeat"
449
+ else dsl.to_s
450
+ end
451
+ end
452
+
453
+ def label_for(dsl, name) = name ? "#{dsl} #{name}" : dsl.to_s
454
+
455
+ # A merge node lists ALL its literal branch names, e.g. "merge_branches a, b"
456
+ # (source order). Falls back to the bare DSL name if any name is non-literal.
457
+ def merge_label(call)
458
+ names = positional_args(call).map { |a| literal_value(a) }
459
+ return call.name.to_s if names.empty? || names.any?(&:nil?)
460
+ "#{call.name} #{names.join(", ")}"
461
+ end
462
+
463
+ # ---- Prism literal helpers ----
464
+
465
+ def positional_args(call)
466
+ (call.arguments&.arguments || []).reject { |a| a.is_a?(Prism::KeywordHashNode) }
467
+ end
468
+
469
+ def keyword_literal(call, key)
470
+ hash = (call.arguments&.arguments || []).find { |a| a.is_a?(Prism::KeywordHashNode) }
471
+ return nil unless hash
472
+ assoc = hash.elements.grep(Prism::AssocNode).find do |e|
473
+ e.key.is_a?(Prism::SymbolNode) && e.key.value.to_sym == key
474
+ end
475
+ assoc && literal_value(assoc.value)
476
+ end
477
+
478
+ def literal_value(node)
479
+ case node
480
+ when Prism::SymbolNode then node.value
481
+ when Prism::StringNode then node.unescaped
482
+ end
483
+ end
484
+
485
+ # ---- graph builders ----
486
+
487
+ def add_node(**attrs)
488
+ node = Definition::Node.new(id: "n#{@seq += 1}", **attrs)
489
+ @nodes << node
490
+ node
491
+ end
492
+
493
+ def add_edge(from, to, kind, guard = nil)
494
+ @edges << Definition::Edge.new(from: from, to: to, kind: kind, guard: guard)
495
+ end
496
+
497
+ def unavailable(msg = "perform source is not statically analyzable")
498
+ Definition.new(nodes: [], edges: [], warnings: [msg])
499
+ end
500
+ end
501
+ end
@@ -51,6 +51,29 @@ module ChronoForge
51
51
  set_value(key, value)
52
52
  end
53
53
 
54
+ # Sets multiple values in the context at once from a hash.
55
+ # The merge is atomic: every value is validated before any is written, so
56
+ # a single invalid value raises and leaves the context untouched.
57
+ # Returns self for chaining.
58
+ def merge(hash)
59
+ hash.each_value { |value| validate_value!(value) }
60
+ hash.each { |key, value| set_value(key, value) }
61
+ self
62
+ end
63
+ alias_method :set_multiple, :merge
64
+
65
+ # Like #merge, but only sets keys that don't already exist; present keys
66
+ # are skipped entirely (their values are never validated), matching
67
+ # #set_once semantics. The applied keys are written atomically: an invalid
68
+ # value among the new keys raises and writes nothing. Returns self.
69
+ def merge_once(hash)
70
+ new_pairs = hash.reject { |key, _| key?(key) }
71
+ new_pairs.each_value { |value| validate_value!(value) }
72
+ new_pairs.each { |key, value| set_value(key, value) }
73
+ self
74
+ end
75
+ alias_method :set_multiple_once, :merge_once
76
+
54
77
  # Sets a value in the context only if the key doesn't already exist
55
78
  # Returns true if the value was set, false otherwise
56
79
  def set_once(key, value)
@@ -20,12 +20,19 @@ module ChronoForge
20
20
  "Currently being executed by job(#{workflow.locked_by})"
21
21
  end
22
22
 
23
- # Atomic update of lock status
24
- workflow.update_columns(
23
+ # Atomic update of lock status. Stamp started_at here on first
24
+ # acquisition too: branch children are pre-inserted by their parent
25
+ # without it, and folding the stamp into this UPDATE saves a separate
26
+ # commit (fsync) per child on large fan-outs. started_at then reliably
27
+ # means "has been picked up and run" — the BranchMergeJob rekick poller
28
+ # treats a nil started_at as a never-executed (dropped) child.
29
+ columns = {
25
30
  locked_by: job_id,
26
31
  locked_at: Time.current,
27
32
  state: :running
28
- )
33
+ }
34
+ columns[:started_at] = Time.current if workflow.started_at.nil?
35
+ workflow.update_columns(columns)
29
36
 
30
37
  Rails.logger.debug { "ChronoForge:#{name}(#{workflow.key}) job(#{job_id}) acquired lock." }
31
38
 
@@ -99,9 +99,14 @@ module ChronoForge
99
99
  validate_step_name_segment!(name || condition)
100
100
  step_name = "continue_if$#{name || condition}"
101
101
 
102
- # Find or create execution log
102
+ # Find or create execution log. A fresh gate records its first evaluation
103
+ # in the INSERT itself (attempts: 1, last_executed_at), so there is no
104
+ # separate pre-evaluation UPDATE to follow it.
103
105
  execution_log = find_or_create_execution_log!(step_name) do |log|
104
- log.started_at = Time.current
106
+ now = Time.current
107
+ log.started_at = now
108
+ log.last_executed_at = now
109
+ log.attempts = 1
105
110
  log.metadata = {
106
111
  condition: condition.to_s,
107
112
  name: name
@@ -115,10 +120,14 @@ module ChronoForge
115
120
 
116
121
  # Evaluate condition once
117
122
  begin
118
- execution_log.update!(
119
- attempts: execution_log.attempts + 1,
120
- last_executed_at: Time.current
121
- )
123
+ # Existing logs (re-evaluations after a not-met halt) still need the
124
+ # attempt bump; a freshly-created gate already recorded its first above.
125
+ unless execution_log.previously_new_record?
126
+ execution_log.update!(
127
+ attempts: execution_log.attempts + 1,
128
+ last_executed_at: Time.current
129
+ )
130
+ end
122
131
 
123
132
  condition_met = send(condition)
124
133
  rescue HaltExecutionFlow
@@ -66,9 +66,14 @@ module ChronoForge
66
66
  policy = step_retry_policy(retry_policy)
67
67
  validate_step_name_segment!(name || method)
68
68
  step_name = "durably_execute$#{name || method}"
69
- # Find or create execution log
69
+ # Find or create execution log. On a fresh step the first attempt is
70
+ # recorded in the INSERT itself (attempts: 1, last_executed_at) so there
71
+ # is no separate pre-execution UPDATE to follow it.
70
72
  execution_log = find_or_create_execution_log!(step_name) do |log|
71
- log.started_at = Time.current
73
+ now = Time.current
74
+ log.started_at = now
75
+ log.last_executed_at = now
76
+ log.attempts = 1
72
77
  end
73
78
 
74
79
  # Return if already completed
@@ -76,11 +81,14 @@ module ChronoForge
76
81
 
77
82
  # Execute with error handling
78
83
  begin
79
- # Update execution log with attempt
80
- execution_log.update!(
81
- attempts: execution_log.attempts + 1,
82
- last_executed_at: Time.current
83
- )
84
+ # Existing logs (retries) still need the pre-execution attempt bump;
85
+ # a freshly-created log already recorded its first attempt above.
86
+ unless execution_log.previously_new_record?
87
+ execution_log.update!(
88
+ attempts: execution_log.attempts + 1,
89
+ last_executed_at: Time.current
90
+ )
91
+ end
84
92
 
85
93
  # Execute the method
86
94
  send(method)