chrono_forge 0.10.0 → 0.12.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +44 -1
- data/README.md +246 -119
- data/Rakefile +4 -0
- data/cliff.toml +62 -0
- data/docs/design/per-child-commit-overhead.md +213 -0
- data/docs/fanout-scale-test.md +246 -0
- data/docs/superpowers/plans/2026-06-30-poller-rekick-and-eta-cadence.md +205 -0
- data/docs/superpowers/plans/2026-06-30-poller-rekick-and-eta-cadence.md.tasks.json +33 -0
- data/docs/superpowers/plans/2026-07-01-workflow-definition-dag.md +1373 -0
- data/docs/superpowers/plans/2026-07-01-workflow-definition-dag.md.tasks.json +68 -0
- data/docs/superpowers/specs/2026-07-01-workflow-definition-dag-design.md +203 -0
- data/docs/superpowers/specs/2026-07-09-chrono-forge-reaper-design.md +175 -0
- data/lib/chrono_forge/branch_merge_job.rb +158 -21
- data/lib/chrono_forge/branch_probe.rb +44 -0
- data/lib/chrono_forge/configuration.rb +51 -0
- data/lib/chrono_forge/definition.rb +37 -0
- data/lib/chrono_forge/definition_analyzer.rb +501 -0
- data/lib/chrono_forge/executor/context.rb +23 -0
- data/lib/chrono_forge/executor/lock_strategy.rb +10 -3
- data/lib/chrono_forge/executor/methods/continue_if.rb +15 -6
- data/lib/chrono_forge/executor/methods/durably_execute.rb +15 -7
- data/lib/chrono_forge/executor/methods/durably_repeat.rb +30 -14
- data/lib/chrono_forge/executor/methods/merge_branches.rb +5 -4
- data/lib/chrono_forge/executor/methods/workflow_states.rb +35 -47
- data/lib/chrono_forge/executor.rb +35 -10
- data/lib/chrono_forge/version.rb +1 -1
- data/lib/chrono_forge/workflow.rb +43 -0
- data/lib/chrono_forge.rb +8 -0
- data/lib/tasks/release.rake +212 -0
- metadata +29 -2
|
@@ -4,7 +4,22 @@ module ChronoForge
|
|
|
4
4
|
# Lightweight poller that joins one or more branches. NOT a workflow — it holds
|
|
5
5
|
# no lock, does no replay, and carries no context. It exists so the heavy parent
|
|
6
6
|
# workflow is replayed only twice per merge (kick off + completion wake).
|
|
7
|
+
#
|
|
8
|
+
# DEPLOY NOTE — queue placement matters. merge_branches enqueues this poller
|
|
9
|
+
# AFTER dispatching the branch's children, so if it runs on the SAME queue as a
|
|
10
|
+
# large fan-out's children it is starved behind the whole backlog and only gets a
|
|
11
|
+
# worker slot near the end. It then polls once, at pending≈0, with no prior sample
|
|
12
|
+
# (rate 0) and backs off to max_interval — so the parent's convergence lags by up
|
|
13
|
+
# to max_interval and no mid-drain throughput sample is ever recorded. Set
|
|
14
|
+
# ChronoForge.config.branch_merge_queue to a queue NOT saturated by the fan-out's
|
|
15
|
+
# own children so it polls throughout the drain (ETA cadence then converges
|
|
16
|
+
# tightly). See ChronoForge::Configuration and docs/fanout-scale-test.md.
|
|
7
17
|
class BranchMergeJob < ActiveJob::Base
|
|
18
|
+
# Resolved per-enqueue from config (a block, so changing the config takes effect
|
|
19
|
+
# without redefining the job — and it can't be silently reset by a code reload,
|
|
20
|
+
# unlike a queue_as monkey-patch in a to_prepare block).
|
|
21
|
+
queue_as { ChronoForge.config.branch_merge_queue }
|
|
22
|
+
|
|
8
23
|
# The poller is the parent's only wake mechanism, so survive TRANSIENT
|
|
9
24
|
# infrastructure errors (DB connection/timeout/deadlock) with backoff. Any
|
|
10
25
|
# other error — a programming bug, a bad guard — is NOT retried: it propagates
|
|
@@ -16,8 +31,7 @@ module ChronoForge
|
|
|
16
31
|
ActiveRecord::LockWaitTimeout,
|
|
17
32
|
wait: :polynomially_longer, attempts: 25
|
|
18
33
|
|
|
19
|
-
|
|
20
|
-
FACTOR = 0.06 # seconds of delay per pending child
|
|
34
|
+
ETA_FRACTION = 0.5 # poll at this fraction of the projected time-to-drain
|
|
21
35
|
REKICK_AFTER = 5.minutes
|
|
22
36
|
REKICK_BATCH = 200 # bound per-run rekicks; later polls handle the rest
|
|
23
37
|
|
|
@@ -29,44 +43,135 @@ module ChronoForge
|
|
|
29
43
|
# re-enqueue) holds a stale token. It stops quietly — no poll, no wake, no
|
|
30
44
|
# reschedule — leaving only the newest chain to drive the merge. (A nil token
|
|
31
45
|
# is a pre-upgrade job enqueued before fencing existed; it runs unfenced.)
|
|
32
|
-
|
|
46
|
+
logs = ExecutionLog.where(id: branch_log_ids).to_a
|
|
47
|
+
return if superseded?(logs, token)
|
|
33
48
|
|
|
34
49
|
# Per-branch probe (kept as maps so we can persist each branch's own state,
|
|
35
50
|
# not just the merge aggregate). Same query count as a plain sum/all?.
|
|
36
|
-
|
|
51
|
+
# The pending count is UNCAPPED: it feeds the drain signal below (a change in
|
|
52
|
+
# pending since the prior poll), which a CAP would flatten into a false
|
|
53
|
+
# "not draining" for large branches.
|
|
54
|
+
prev_pending_by_branch = logs.to_h { |l| [l.id, l.metadata&.dig("poll", "pending")] }
|
|
55
|
+
pending_by_branch = branch_log_ids.to_h { |id| [id, BranchProbe.incomplete(id).count] }
|
|
37
56
|
sealed_by_branch = branch_log_ids.to_h { |id| [id, BranchProbe.sealed?(id)] }
|
|
38
57
|
pending = pending_by_branch.values.sum
|
|
39
58
|
sealed = sealed_by_branch.values.all?
|
|
40
59
|
|
|
60
|
+
# Total children spawned per branch. Immutable once the branch is SEALED
|
|
61
|
+
# (dispatch done), so we count it exactly ONCE and cache it on the metadata;
|
|
62
|
+
# every later poll (and the dashboard) reuses the cached value, never recounting.
|
|
63
|
+
# Unsealed (mid-spawn, count still climbing) => nil, and the dashboard falls back
|
|
64
|
+
# to its capped live count until the seal freezes the total.
|
|
65
|
+
logs_by_id = logs.index_by(&:id)
|
|
66
|
+
spawned_by_branch = branch_log_ids.to_h do |id|
|
|
67
|
+
cached = logs_by_id[id]&.metadata&.dig("poll", "spawned")
|
|
68
|
+
[id, cached || (sealed_by_branch[id] ? BranchProbe.spawned(id).count : nil)]
|
|
69
|
+
end
|
|
70
|
+
|
|
41
71
|
if sealed && pending.zero?
|
|
42
|
-
record_poll!(pending_by_branch, sealed_by_branch, token, next_poll_at: nil
|
|
72
|
+
record_poll!(pending_by_branch, sealed_by_branch, token, next_poll_at: nil, interval: nil,
|
|
73
|
+
rate_by_branch: {}, never_started_by_branch: {}, spawned_by_branch: spawned_by_branch, rekicked_by_branch: {})
|
|
43
74
|
parent_job_class.constantize.perform_later(parent_key)
|
|
44
75
|
return
|
|
45
76
|
end
|
|
46
77
|
|
|
47
|
-
|
|
78
|
+
# DISPATCHED (never-started) count per branch — the rekick drain signal. A
|
|
79
|
+
# drop since the prior poll means workers are consuming this branch's queue,
|
|
80
|
+
# so a still-queued child is in line; a flat count with stale never-started
|
|
81
|
+
# children is a dropped job to recover. Keyed off this, NOT total pending,
|
|
82
|
+
# which a wait/wait_until child completing would drop without any never-started
|
|
83
|
+
# child moving (masking a genuinely-dropped one behind staggered waits).
|
|
84
|
+
prev_never_started_by_branch = logs.to_h { |l| [l.id, l.metadata&.dig("poll", "never_started")] }
|
|
85
|
+
never_started_by_branch = branch_log_ids.to_h { |id| [id, BranchProbe.never_started(id).count] }
|
|
86
|
+
|
|
87
|
+
rekicked_by_branch = rekick_dropped_jobs(branch_log_ids, never_started_by_branch, prev_never_started_by_branch)
|
|
88
|
+
|
|
89
|
+
# Cadence is driven by ESTIMATED TIME-TO-DRAIN, measured from the prior
|
|
90
|
+
# poll's persisted pending. `motion` (EXISTS probes) is the fallback signal
|
|
91
|
+
# when nothing completed this interval: :running => a live worker is
|
|
92
|
+
# executing a child (hold the floor, it'll finish); :never_started => the only
|
|
93
|
+
# motion is a queued/rekicked-but-unpicked child (back off exponentially,
|
|
94
|
+
# it may never be picked up); :none => blocked/waiting (max backstop).
|
|
95
|
+
# See reschedule_delay. Computed lazily below, only off the drain path.
|
|
96
|
+
prior = logs.map { |l| l.metadata&.dig("poll") }
|
|
97
|
+
# Only trust the AGGREGATE prev_pending when every requested branch log is
|
|
98
|
+
# loaded AND carries a prior sample — otherwise `pending` (over all
|
|
99
|
+
# branch_log_ids) and prev_pending (over loaded logs) would cover different
|
|
100
|
+
# sets and yield a bogus aggregate rate. Missing/partial => no sample =>
|
|
101
|
+
# bootstrap. Per-branch rate below is independently safe (missing => nil => 0).
|
|
102
|
+
complete_prior = logs.size == branch_log_ids.size && prior.all?
|
|
103
|
+
prev_pending = (prior.sum { |p| p["pending"].to_i } if complete_prior)
|
|
104
|
+
prev_polled_at = prior.filter_map { |p| p && p["last_polled_at"] }.map { |s| Time.zone.parse(s) }.min
|
|
105
|
+
elapsed = prev_polled_at && (Time.current - prev_polled_at)
|
|
106
|
+
prev_delay = prior.filter_map { |p| p && p["interval"] }.max
|
|
107
|
+
|
|
108
|
+
# Drain rate = children completed / second since the prior poll — THIS is the
|
|
109
|
+
# throughput surfaced on the dashboard. Per branch for display; aggregated for
|
|
110
|
+
# the ETA. Zero unless the branch actually drained (a no-headway / cold poll).
|
|
111
|
+
# NOTE: the aggregate ETA blurs a heterogeneous multi-branch merge; acceptable
|
|
112
|
+
# (the common case is single-branch; clamp + per-poll re-estimate bound any
|
|
113
|
+
# skew, and only poll timing is affected — the parent is still woken).
|
|
114
|
+
drained = ->(pend, prev) { prev && elapsed && elapsed > 0 && pend < prev }
|
|
115
|
+
rate_by_branch = pending_by_branch.to_h do |id, pend|
|
|
116
|
+
prev = prev_pending_by_branch[id]
|
|
117
|
+
[id, drained.call(pend, prev) ? (prev - pend) / elapsed.to_f : 0.0]
|
|
118
|
+
end
|
|
119
|
+
rate = drained.call(pending, prev_pending) ? (prev_pending - pending) / elapsed.to_f : 0.0
|
|
120
|
+
|
|
121
|
+
# Only needed when the ETA branch won't be taken (rate == 0); computing the
|
|
122
|
+
# EXISTS probes lazily keeps them off the hot drain path. See reschedule_delay.
|
|
123
|
+
motion = if rate > 0 then nil
|
|
124
|
+
elsif branch_log_ids.any? { |id| BranchProbe.running?(id) } then :running
|
|
125
|
+
elsif never_started_by_branch.values.any?(&:positive?) then :never_started
|
|
126
|
+
else :none
|
|
127
|
+
end
|
|
48
128
|
|
|
49
|
-
delay = reschedule_delay(pending, min_interval, max_interval)
|
|
50
|
-
record_poll!(pending_by_branch, sealed_by_branch, token, next_poll_at: delay.seconds.from_now
|
|
129
|
+
delay = reschedule_delay(pending, rate, motion, prev_delay, min_interval, max_interval)
|
|
130
|
+
record_poll!(pending_by_branch, sealed_by_branch, token, next_poll_at: delay.seconds.from_now,
|
|
131
|
+
interval: delay, rate_by_branch: rate_by_branch, never_started_by_branch: never_started_by_branch,
|
|
132
|
+
spawned_by_branch: spawned_by_branch, rekicked_by_branch: rekicked_by_branch)
|
|
51
133
|
self.class.set(wait: delay.seconds)
|
|
52
134
|
.perform_later(parent_key, parent_job_class, branch_log_ids, min_interval, max_interval, token)
|
|
53
135
|
end
|
|
54
136
|
|
|
55
137
|
private
|
|
56
138
|
|
|
57
|
-
# Adaptive poll cadence
|
|
58
|
-
#
|
|
59
|
-
#
|
|
60
|
-
|
|
61
|
-
|
|
139
|
+
# Adaptive poll cadence driven by ESTIMATED TIME-TO-DRAIN, not backlog size.
|
|
140
|
+
# When the branch-set drained since the last poll we project completion from
|
|
141
|
+
# the measured rate and poll at ETA_FRACTION of it, clamped [min, max]. Because
|
|
142
|
+
# each poll re-estimates against the shrinking remainder, cadence converges
|
|
143
|
+
# geometrically and detects the merge within ~min_interval of the last child
|
|
144
|
+
# finishing — where the old count-based cadence polled SLOWEST (max_interval)
|
|
145
|
+
# exactly when a fast-draining backlog was about to complete.
|
|
146
|
+
#
|
|
147
|
+
# No completion observed this interval — fall back on `motion`:
|
|
148
|
+
# :running => a live worker is executing a child; it will finish, so hold
|
|
149
|
+
# the responsive floor (matches prior behaviour and avoids
|
|
150
|
+
# waking the parent late for a slow/low-fan-out child).
|
|
151
|
+
# :never_started => the only motion is a queued/rekicked-but-unpicked child that
|
|
152
|
+
# may never be picked up => exponential backoff from the floor
|
|
153
|
+
# (double prev_delay, capped at max), catching a quick recovery
|
|
154
|
+
# within seconds without spinning on a dead dispatch.
|
|
155
|
+
# :none => nothing can progress (blocked/failed or parked on a wait) =>
|
|
156
|
+
# straight to max_interval, the cheap recovery backstop.
|
|
157
|
+
# min_interval <= max_interval is enforced in merge_branches, so clamp is safe.
|
|
158
|
+
# `rate` is children/s measured by the caller (0 => nothing completed since the
|
|
159
|
+
# prior poll / cold poll).
|
|
160
|
+
def reschedule_delay(pending, rate, motion, prev_delay, min_interval, max_interval)
|
|
161
|
+
return (pending / rate * ETA_FRACTION).clamp(min_interval, max_interval) if rate > 0
|
|
162
|
+
|
|
163
|
+
case motion
|
|
164
|
+
when :running then min_interval
|
|
165
|
+
when :never_started then prev_delay ? (prev_delay * 2).clamp(min_interval, max_interval) : min_interval
|
|
166
|
+
else max_interval
|
|
167
|
+
end
|
|
62
168
|
end
|
|
63
169
|
|
|
64
170
|
# A poller is superseded when its token no longer matches what's stored on the
|
|
65
171
|
# branch logs (a newer merge_branches pass rotated it). A plain read is enough
|
|
66
172
|
# for the early-out; the persisting write in record_poll! re-checks the token
|
|
67
173
|
# under a row lock so it can never clobber the newer chain.
|
|
68
|
-
def superseded?(
|
|
69
|
-
logs = ExecutionLog.where(id: branch_log_ids).to_a
|
|
174
|
+
def superseded?(logs, token)
|
|
70
175
|
logs.empty? || logs.any? { |log| log.metadata&.dig("poll_token") != token }
|
|
71
176
|
end
|
|
72
177
|
|
|
@@ -78,7 +183,7 @@ module ChronoForge
|
|
|
78
183
|
# work still pending is the signal that the poller was dropped). This is purely
|
|
79
184
|
# observational — replay and correctness never read it. It writes a "poll"
|
|
80
185
|
# sub-key, leaving spawn_each's "cursors" metadata untouched.
|
|
81
|
-
def record_poll!(pending_by_branch, sealed_by_branch, token, next_poll_at:)
|
|
186
|
+
def record_poll!(pending_by_branch, sealed_by_branch, token, next_poll_at:, interval:, rate_by_branch:, never_started_by_branch:, spawned_by_branch:, rekicked_by_branch:)
|
|
82
187
|
now = Time.current
|
|
83
188
|
ExecutionLog.where(id: pending_by_branch.keys).find_each do |log|
|
|
84
189
|
# Lock the row so this read-modify-write can't clobber a concurrent token
|
|
@@ -88,12 +193,25 @@ module ChronoForge
|
|
|
88
193
|
log.with_lock do
|
|
89
194
|
meta = log.metadata || {}
|
|
90
195
|
next unless meta["poll_token"] == token
|
|
196
|
+
prev = meta["poll"] || {}
|
|
197
|
+
n = rekicked_by_branch[log.id].to_i
|
|
198
|
+
pend = pending_by_branch[log.id]
|
|
199
|
+
rate = rate_by_branch[log.id].to_f
|
|
91
200
|
meta["poll"] = {
|
|
92
201
|
"last_polled_at" => now.iso8601,
|
|
93
202
|
"next_poll_at" => next_poll_at&.iso8601,
|
|
94
|
-
"
|
|
203
|
+
"interval" => interval,
|
|
204
|
+
"pending" => pend,
|
|
205
|
+
"never_started" => never_started_by_branch[log.id], # never-started count (rekick drain signal)
|
|
206
|
+
"spawned" => prev["spawned"] || spawned_by_branch[log.id], # total spawned; immutable once sealed, so sticky
|
|
95
207
|
"sealed" => sealed_by_branch[log.id],
|
|
96
|
-
"
|
|
208
|
+
"rate" => rate.round(3), # children/s (round(3), not (2), so a
|
|
209
|
+
# very slow but real drain still reads > 0)
|
|
210
|
+
"eta_seconds" => (rate > 0 ? (pend / rate).round : nil),
|
|
211
|
+
"polls" => prev["polls"].to_i + 1,
|
|
212
|
+
"rekicked" => n,
|
|
213
|
+
"rekick_total" => prev["rekick_total"].to_i + n,
|
|
214
|
+
"last_rekick_at" => (n.positive? ? now.iso8601 : prev["last_rekick_at"])
|
|
97
215
|
}
|
|
98
216
|
log.update!(metadata: meta)
|
|
99
217
|
end
|
|
@@ -111,10 +229,22 @@ module ChronoForge
|
|
|
111
229
|
# keep the :idle guard (a running/failed/stalled child must never be
|
|
112
230
|
# re-dispatched). Re-enqueue of an :idle child a worker just grabbed is still
|
|
113
231
|
# safe — the lock guard rejects the duplicate. Capped per run.
|
|
114
|
-
def rekick_dropped_jobs(branch_log_ids)
|
|
115
|
-
|
|
232
|
+
def rekick_dropped_jobs(branch_log_ids, never_started_by_branch, prev_never_started_by_branch)
|
|
233
|
+
cutoff = REKICK_AFTER.ago
|
|
234
|
+
branch_log_ids.to_h do |id|
|
|
235
|
+
# Skip a branch whose NEVER-STARTED count dropped since the last poll:
|
|
236
|
+
# workers are pulling its dispatched children off the queue, so a still-
|
|
237
|
+
# queued child is in line, not dropped. Deliberately NOT total pending —
|
|
238
|
+
# a wait/wait_until child completing would drop pending without any
|
|
239
|
+
# never-started child moving, masking a genuinely-dropped child behind
|
|
240
|
+
# staggered waits. With no prior sample (cold poll) we don't gate — the
|
|
241
|
+
# per-child staleness filter below still spares freshly-dispatched rows.
|
|
242
|
+
prev = prev_never_started_by_branch[id]
|
|
243
|
+
next [id, 0] if prev && never_started_by_branch[id] < prev
|
|
244
|
+
|
|
245
|
+
count = 0
|
|
116
246
|
Workflow.where(parent_execution_log_id: id, state: Workflow.states[:idle], started_at: nil)
|
|
117
|
-
.where("updated_at < ?",
|
|
247
|
+
.where("updated_at < ?", cutoff)
|
|
118
248
|
.limit(REKICK_BATCH)
|
|
119
249
|
.find_each do |child|
|
|
120
250
|
# Intentionally uses the GUARDED perform_later (single-child path),
|
|
@@ -126,12 +256,19 @@ module ChronoForge
|
|
|
126
256
|
# error — dead-letter the poller, orphaning every healthy sibling. Catch
|
|
127
257
|
# per child, log, and let the next poll retry it (it's still idle+stale).
|
|
128
258
|
child.job_klass.perform_later(child.key, **child.kwargs.symbolize_keys)
|
|
259
|
+
# Debounce: bump updated_at so this child isn't re-rekicked until it's
|
|
260
|
+
# been unstarted for another REKICK_AFTER — one redelivery window for a
|
|
261
|
+
# worker to pick it up. Only on a SUCCESSFUL enqueue; a rescued failure
|
|
262
|
+
# leaves it stale so the next poll retries.
|
|
263
|
+
child.touch
|
|
264
|
+
count += 1
|
|
129
265
|
rescue => e
|
|
130
266
|
Rails.logger.error do
|
|
131
267
|
"ChronoForge:BranchMergeJob rekick failed for child #{child.key}: " \
|
|
132
268
|
"#{e.class}: #{e.message}"
|
|
133
269
|
end
|
|
134
270
|
end
|
|
271
|
+
[id, count]
|
|
135
272
|
end
|
|
136
273
|
end
|
|
137
274
|
end
|
|
@@ -19,6 +19,50 @@ module ChronoForge
|
|
|
19
19
|
.where.not(state: Workflow.states[:completed])
|
|
20
20
|
end
|
|
21
21
|
|
|
22
|
+
# Relation of children that can advance on their own — actively running, or
|
|
23
|
+
# dispatched-but-not-yet-started (started_at nil). This drives the adaptive
|
|
24
|
+
# poll cadence. Deliberately EXCLUDES waiting children (idle with started_at
|
|
25
|
+
# SET — parked on a wait/wait_until) and blocked children (failed/stalled —
|
|
26
|
+
# awaiting operator recovery): polling can't make either progress, so they
|
|
27
|
+
# must not pin the cadence at the responsive floor. They still count as
|
|
28
|
+
# +incomplete+ (the branch stays open), they just don't accelerate polling.
|
|
29
|
+
def progressing(branch_log_id)
|
|
30
|
+
base = Workflow.where(parent_execution_log_id: branch_log_id)
|
|
31
|
+
base.where(state: Workflow.states[:running])
|
|
32
|
+
.or(base.where(state: Workflow.states[:idle], started_at: nil))
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# A child of this branch is actively executing — a live worker will complete
|
|
36
|
+
# it, so the poller can hold its responsive floor rather than backing off.
|
|
37
|
+
def running?(branch_log_id)
|
|
38
|
+
Workflow.where(parent_execution_log_id: branch_log_id, state: Workflow.states[:running]).exists?
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Children dispatched but not yet started (idle, started_at nil) — the queue of
|
|
42
|
+
# never-started work for this branch. A DROP in this count between polls means
|
|
43
|
+
# workers are actively pulling it off the queue (so a still-queued child is in
|
|
44
|
+
# line, not dropped); the rekick gate keys off that. Distinct from total pending,
|
|
45
|
+
# which a wait/wait_until child completing would drop without any never-started
|
|
46
|
+
# child moving. (Not to be confused with the dashboard's "Dispatched" column,
|
|
47
|
+
# which is the TOTAL children spawned.)
|
|
48
|
+
def never_started(branch_log_id)
|
|
49
|
+
Workflow.where(parent_execution_log_id: branch_log_id,
|
|
50
|
+
state: Workflow.states[:idle], started_at: nil)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# A child was dispatched but no worker has started it yet. If this is the only
|
|
54
|
+
# motion left, it's a queued/rekicked-but-unpicked straggler (which may never be
|
|
55
|
+
# picked up), NOT active work — so the poller backs off.
|
|
56
|
+
def never_started?(branch_log_id) = never_started(branch_log_id).exists?
|
|
57
|
+
|
|
58
|
+
# All children spawned into this branch (every state) — the dispatch total. Fixed
|
|
59
|
+
# once the branch is sealed, so the poller counts it exactly once and caches it on
|
|
60
|
+
# the branch-log metadata. This is the dashboard's "Spawned" column. Distinct from
|
|
61
|
+
# #never_started, which is only the idle-and-unstarted subset.
|
|
62
|
+
def spawned(branch_log_id)
|
|
63
|
+
Workflow.where(parent_execution_log_id: branch_log_id)
|
|
64
|
+
end
|
|
65
|
+
|
|
22
66
|
def done?(branch_log_id)
|
|
23
67
|
sealed?(branch_log_id) && !incomplete(branch_log_id).exists?
|
|
24
68
|
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ChronoForge
|
|
4
|
+
# Engine-wide configuration. Set via ChronoForge.configure in an initializer.
|
|
5
|
+
class Configuration
|
|
6
|
+
# The queue the branch-merge poller (BranchMergeJob) runs on.
|
|
7
|
+
#
|
|
8
|
+
# This MUST NOT be a queue that a fan-out's own children saturate: merge_branches
|
|
9
|
+
# enqueues the poller AFTER dispatching the branch's children, so on a shared
|
|
10
|
+
# queue it is starved behind the whole backlog and only gets a worker slot near
|
|
11
|
+
# the end — it then polls once, at pending≈0, and backs off, so the parent's
|
|
12
|
+
# convergence lags by up to max_interval and no mid-drain throughput is recorded.
|
|
13
|
+
# Because the poller is OUR code (not the user's job), its placement is a
|
|
14
|
+
# first-class setting rather than something to monkey-patch onto BranchMergeJob.
|
|
15
|
+
#
|
|
16
|
+
# Defaults to :default (fine when fan-outs run on their own queues). For large
|
|
17
|
+
# fan-outs, point this at a dedicated queue with its own worker so the poller
|
|
18
|
+
# runs promptly throughout the drain.
|
|
19
|
+
attr_accessor :branch_merge_queue
|
|
20
|
+
|
|
21
|
+
# How long a single workflow pass may hold its lock before another job is
|
|
22
|
+
# allowed to steal it (LockStrategy.acquire_lock treats a lock older than this
|
|
23
|
+
# as stale). It bounds the assumed maximum duration of one execution pass.
|
|
24
|
+
# Defaults to 10 minutes.
|
|
25
|
+
attr_accessor :max_duration
|
|
26
|
+
|
|
27
|
+
def initialize
|
|
28
|
+
@branch_merge_queue = :default
|
|
29
|
+
@max_duration = 10.minutes
|
|
30
|
+
@reap_stale_after = nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Age past which a workflow still in :running is treated as stranded and
|
|
34
|
+
# re-enqueued by ChronoForge::Workflow.reap_stalled. A workflow reaches this
|
|
35
|
+
# state when its worker is hard-killed (SIGKILL/OOM/eviction) mid-pass, before
|
|
36
|
+
# the executor's `ensure` block could release the lock and publish the resume
|
|
37
|
+
# continuation — so it stays locked in :running with nothing scheduled to wake it.
|
|
38
|
+
#
|
|
39
|
+
# Defaults to 3x max_duration (30 min out of the box), so it always comfortably
|
|
40
|
+
# exceeds the lock-steal threshold: acquire_lock only steals locks older than
|
|
41
|
+
# max_duration, so a shorter reap threshold would just enqueue resume jobs that
|
|
42
|
+
# immediately no-op via ConcurrentExecutionError. Deriving from max_duration keeps
|
|
43
|
+
# that invariant automatic — raise max_duration and the reaper backs off with it.
|
|
44
|
+
# An explicit value (set via the writer) overrides the derived default.
|
|
45
|
+
def reap_stale_after
|
|
46
|
+
@reap_stale_after || max_duration * 3
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
attr_writer :reap_stale_after
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -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
|