chrono_forge 0.9.1 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +56 -1
- data/README.md +390 -46
- 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 +247 -0
- data/docs/superpowers/plans/2026-06-25-chrono_forge-dashboard.md +1748 -0
- data/docs/superpowers/plans/2026-06-25-chrono_forge-dashboard.md.tasks.json +17 -0
- data/docs/superpowers/plans/2026-06-25-composite-retry-policies.md +930 -0
- data/docs/superpowers/plans/2026-06-25-composite-retry-policies.md.tasks.json +54 -0
- data/docs/superpowers/plans/2026-06-25-reserved-kwarg-guard.md +241 -0
- data/docs/superpowers/plans/2026-06-25-reserved-kwarg-guard.md.tasks.json +12 -0
- data/docs/superpowers/plans/2026-06-26-branches-spawn-merge.md +1378 -0
- data/docs/superpowers/plans/2026-06-26-branches-spawn-merge.md.tasks.json +67 -0
- data/docs/superpowers/plans/2026-06-26-deferral-continuation-race-and-catchup.md +709 -0
- data/docs/superpowers/plans/2026-06-26-deferral-continuation-race-and-catchup.md.tasks.json +19 -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-06-03-unified-retry-policy-design.md +226 -0
- data/docs/superpowers/specs/2026-06-25-chrono_forge-dashboard-design.md +190 -0
- data/docs/superpowers/specs/2026-06-25-composite-retry-policies-design.md +228 -0
- data/docs/superpowers/specs/2026-06-25-reserved-kwarg-guard-design.md +169 -0
- data/docs/superpowers/specs/2026-06-25-spawn-merge-branches-design.md +468 -0
- data/docs/superpowers/specs/2026-06-26-dashboard-branch-view-design.md +142 -0
- data/docs/superpowers/specs/2026-06-26-deferral-continuation-race-and-catchup-design.md +265 -0
- data/docs/superpowers/specs/2026-07-01-workflow-definition-dag-design.md +203 -0
- data/lib/chrono_forge/branch_merge_job.rb +275 -0
- data/lib/chrono_forge/branch_probe.rb +70 -0
- data/lib/chrono_forge/cleanup.rb +6 -0
- data/lib/chrono_forge/configuration.rb +25 -0
- data/lib/chrono_forge/definition.rb +37 -0
- data/lib/chrono_forge/definition_analyzer.rb +501 -0
- data/lib/chrono_forge/execution_log.rb +6 -0
- data/lib/chrono_forge/executor/composite_retry_policy.rb +47 -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/branch.rb +185 -0
- data/lib/chrono_forge/executor/methods/continue_if.rb +15 -6
- data/lib/chrono_forge/executor/methods/durably_execute.rb +36 -26
- data/lib/chrono_forge/executor/methods/durably_repeat.rb +148 -39
- data/lib/chrono_forge/executor/methods/merge_branches.rb +84 -0
- data/lib/chrono_forge/executor/methods/wait.rb +2 -4
- data/lib/chrono_forge/executor/methods/wait_until.rb +25 -25
- data/lib/chrono_forge/executor/methods/workflow_states.rb +50 -46
- data/lib/chrono_forge/executor/methods.rb +2 -0
- data/lib/chrono_forge/executor/retry_policy.rb +111 -0
- data/lib/chrono_forge/executor.rb +241 -28
- data/lib/chrono_forge/version.rb +1 -1
- data/lib/chrono_forge/workflow.rb +10 -1
- data/lib/chrono_forge.rb +8 -0
- data/lib/generators/chrono_forge/migration_actions.rb +1 -0
- data/lib/generators/chrono_forge/templates/add_chrono_forge_parent_execution_log.rb +38 -0
- data/lib/tasks/release.rake +212 -0
- metadata +67 -4
- data/lib/chrono_forge/executor/retry_strategy.rb +0 -29
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
# Branches — Concurrent Sub-Workflows (`branch` / `spawn` / `merge_branches`) — Design
|
|
2
|
+
|
|
3
|
+
**Date:** 2026-06-25
|
|
4
|
+
**Status:** Implemented (branch `feat/branches`).
|
|
5
|
+
**Scope:** New public API, additive. Introduces parent/child workflows and a
|
|
6
|
+
fan-out/fan-in primitive built to dispatch **hundreds of thousands** of children per
|
|
7
|
+
branch. One new (generic, reusable) column on `chrono_forge_workflows`; reuses the
|
|
8
|
+
execution-log pattern for coordination. No breaking change to existing single-workflow
|
|
9
|
+
execution. **New dependency floor:** `activejob >= 7.1` (for `perform_all_later`).
|
|
10
|
+
|
|
11
|
+
## Problem
|
|
12
|
+
|
|
13
|
+
ChronoForge workflows are strictly sequential. The only way to fan work out today is to
|
|
14
|
+
hand-enqueue independent workflows and poll for them with `wait_until` — a hand-rolled
|
|
15
|
+
fork/join with no idempotent dispatch and no parent/child visibility.
|
|
16
|
+
|
|
17
|
+
Real workflows need durable, large-scale fan-out: "spawn one sub-workflow per record
|
|
18
|
+
across a 500k-row set, run them in parallel, continue once all are done." It must be
|
|
19
|
+
crash-safe, idempotent under replay, and must not hold the batch in memory or serialize
|
|
20
|
+
on a hot row.
|
|
21
|
+
|
|
22
|
+
## Goal
|
|
23
|
+
|
|
24
|
+
A **branch** is the unit of fan-out — a durable step that wraps the work it spawns and
|
|
25
|
+
ties it together for the join. `spawn`/`spawn_each` exist **only inside a `branch`
|
|
26
|
+
block**. The model mirrors git: you branch, then you merge.
|
|
27
|
+
|
|
28
|
+
- `branch(name, automerge: false, &block)` — opens branch `name` (the durable
|
|
29
|
+
`branch$<name>` log), runs the block to **eagerly dispatch** children, and **seals**
|
|
30
|
+
when the block closes. Returns immediately (does **not** wait) so branches run
|
|
31
|
+
concurrently.
|
|
32
|
+
- `spawn(name, WorkflowClass, **kwargs)` — inside a branch: dispatch a **single** named
|
|
33
|
+
child.
|
|
34
|
+
- `spawn_each(name, source, of:) { |item| [WorkflowClass, kwargs] }` — inside a branch:
|
|
35
|
+
dispatch **one child per item**, streamed like ActiveRecord batch loading; AR items are
|
|
36
|
+
keyed `name_<record.id>` (primary key); plain enumerables are keyed `name_{index}`
|
|
37
|
+
(sequential index).
|
|
38
|
+
- `merge_branches(*names)` — the **separate** join: halt until every named branch is
|
|
39
|
+
sealed **and** all its children have completed.
|
|
40
|
+
|
|
41
|
+
```ruby
|
|
42
|
+
def perform(cycle_id:)
|
|
43
|
+
branch :fulfillment, automerge: true do # the step; seals when the block closes
|
|
44
|
+
spawn :reconcile, ReconcileWorkflow, region: "EU" # single child of :fulfillment
|
|
45
|
+
spawn_each :orders, Order.pending do |order| # bulk, streamed; keys orders_<id>…
|
|
46
|
+
order.priority? ? [PriorityOrderWorkflow, { order_id: order.id }]
|
|
47
|
+
: [OrderWorkflow, { order_id: order.id }]
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
branch :invoicing do # a second, concurrent branch
|
|
52
|
+
spawn_each :invoices, Invoice.unpaid do |inv|
|
|
53
|
+
[InvoiceWorkflow, { invoice_id: inv.id }]
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
do_other_work # both branches already running
|
|
58
|
+
|
|
59
|
+
merge_branches :invoicing # join :invoicing; :fulfillment auto-merges
|
|
60
|
+
durably_execute :finalize
|
|
61
|
+
end
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Decisions (locked during brainstorming)
|
|
65
|
+
|
|
66
|
+
| Decision | Choice |
|
|
67
|
+
|---|---|
|
|
68
|
+
| Keywords | **`branch` / `spawn` / `spawn_each` / `merge_branches`** — git branch/merge metaphor; `spawn` avoids shadowing `Kernel#fork`. |
|
|
69
|
+
| Branch = the step | A branch **is** its `branch$<name>` execution log. `spawn`/`spawn_each` are valid **only inside a `branch` block** (raise otherwise) — spawns don't exist without a branch. |
|
|
70
|
+
| Dispatch timing | **Eager.** Spawns insert + enqueue as the block runs; children start at once. The branch **seals** (log → `completed`) when the block closes. |
|
|
71
|
+
| Join | **Separate `merge_branches`** so branches run concurrently and work can happen in between. Joins one or more named branches at once (`merge_branches :a` for one). |
|
|
72
|
+
| `merge_branch` alias | **Ship a singular `merge_branch(name, **opts)` alias** that delegates to `merge_branches` — reads naturally for the common one-branch case (`merge_branch :a`) without a plural-method/singular-arg mismatch. Decided, not just mentioned. |
|
|
73
|
+
| Automerge | A property **of the branch**: `branch(name, automerge: true)`. When `true`, `branch` eagerly dispatches inside the block and then immediately calls `merge_branches(name)` at the block's close — execution does not continue past the block until the branch's children complete. No explicit `merge_branches` is needed. |
|
|
74
|
+
| Branch tracking | An **in-memory registry** (`@open_branches`), rebuilt each replay pass: `branch` adds, `merge_branches` removes on completion, the completion gate inspects the remainder. Deterministic replay makes it exact — no persisted `merged`/`automerge` flags. |
|
|
75
|
+
| Every branch must be joined | **No detached branches.** Any branch remaining in `@open_branches` at completion (neither `merge_branches`-d nor automerged) **raises `UnmergedBranchError`** (fail-fast on a forgotten join), rather than silently letting children run orphaned. `automerge: true` branches are joined inline at the block close and are absent from `@open_branches` by the time the completion gate runs. |
|
|
76
|
+
| Spawn identity | Spawns are **named** (`spawn :reconcile, …`, `spawn_each :orders, …`). The name anchors the child key and the per-`spawn_each` cursor — stable across code reordering (unlike a positional ordinal). AR items are keyed `name_<record.id>` (primary key); plain enumerable items are keyed `name_{index}` (sequential index). |
|
|
77
|
+
| Bulk source | `spawn_each` **streams** the source — `find_in_batches(batch_size: of, start: cursor)` for AR — never materialising the batch in memory. Scales to millions. |
|
|
78
|
+
| Child class | **Returned from the block** (`[WorkflowClass, kwargs]`); one branch may fan out into mixed workflow types. |
|
|
79
|
+
| Child key | Deterministic: `spawn` → `"#{parent.key}$#{branch}$#{spawn_name}"`; AR `spawn_each` item → `"#{parent.key}$#{branch}$#{spawn_name}_#{record.id}"`; enumerable item → `"#{parent.key}$#{branch}$#{spawn_name}_#{index}"`. Idempotency falls out of the unique-key constraint. |
|
|
80
|
+
| Cursor | Per `spawn_each`, stored in the `branch$<name>` log's `metadata` keyed by **spawn name** as `{ pk: <keyset>, n: <count/index> }`; persisted **once per dispatched chunk** (bundled with that chunk's `insert_all`). |
|
|
81
|
+
| Completion | **Poll**, no counter: a branch is done when sealed and has no incomplete children (`branch_log.spawned_workflows.where.not(state: :completed)` empty — read as an O(CAP) capped count). Zero per-completion contention. |
|
|
82
|
+
| Poll mechanism | A dedicated lightweight `ChronoForge::BranchMergeJob` (plain ActiveJob — no lock/replay/context) does the repeated probing and wakes the parent only at completion. The heavy parent runs just twice per merge (kick off + wake). No separate recovery timer. |
|
|
83
|
+
| Poll cadence | **Adaptive, capped-count.** `pending = incomplete.limit(CAP).count` (**O(CAP)**, never O(N)); next delay `clamp(pending * factor, min_interval, max_interval)` — fast when few remain, slow when many. |
|
|
84
|
+
| Determinism | AR items are keyed by **primary key**, so the stream is stable by construction. `spawn_each` rejects an AR relation carrying an explicit `.order` by checking `order_values.present?` up front (raises `NotExecutableError`). Plain enumerable items are keyed by **sequential index** and must re-enumerate deterministically (documented contract). |
|
|
85
|
+
| Failure semantics | **Option A.** A `stalled`/`failed` child keeps the branch incomplete; the parent stays parked; the user recovers the child (`retry_now`/`retry_later`) and the merge then resolves. No new failure states, no cascade. |
|
|
86
|
+
| Nesting | **Free.** A child is a workflow and may open its own branches; the tree forms via `parent_execution_log_id` (child → branch log → parent workflow). |
|
|
87
|
+
|
|
88
|
+
## Public API surface
|
|
89
|
+
|
|
90
|
+
```ruby
|
|
91
|
+
branch(name, automerge: false) do
|
|
92
|
+
spawn(name, WorkflowClass, **kwargs)
|
|
93
|
+
spawn_each(name, source, of: 1000) { |item| [WorkflowClass, kwargs] }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
merge_branches(*names, min_interval: 5.seconds, max_interval: 5.minutes) # halts until done
|
|
97
|
+
merge_branch(name, **opts) # singular alias for the common one-branch case
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
`spawn`/`spawn_each` raise `NotInBranchError` if called outside a `branch` block. A branch
|
|
101
|
+
opened but neither `merge_branches`-d nor `automerge: true` raises `UnmergedBranchError` at
|
|
102
|
+
workflow completion.
|
|
103
|
+
|
|
104
|
+
## Data model
|
|
105
|
+
|
|
106
|
+
`chrono_forge_workflows` gains **one** nullable column (inline in the install migration;
|
|
107
|
+
a follow-up migration template for existing installs):
|
|
108
|
+
|
|
109
|
+
| Column | Type | Notes |
|
|
110
|
+
|---|---|---|
|
|
111
|
+
| `parent_execution_log_id` | FK → `chrono_forge_execution_logs.id`, nullable, indexed | The execution log that spawned this workflow. For branches it's the `branch$<name>` log. **Deliberately generic** — any future step that spawns sub-workflows reuses it. |
|
|
112
|
+
|
|
113
|
+
The branch a child belongs to *is* its `parent_execution_log_id` (the `branch$<name>`
|
|
114
|
+
log), which is globally unique and encodes both the parent workflow (the log's
|
|
115
|
+
`workflow_id`) and the branch (its `step_name`). No `branch_name`/`parent_workflow_id`
|
|
116
|
+
column is needed.
|
|
117
|
+
|
|
118
|
+
**Merge/automerge state is not persisted.** It's tracked in an in-memory registry rebuilt
|
|
119
|
+
each replay pass from the `branch`/`merge_branches` calls (see Execution flow) — `branch`
|
|
120
|
+
adds, `merge_branches` removes, the completion gate inspects the remainder. Deterministic
|
|
121
|
+
replay makes this exact every pass, so no `merged`/`automerge` columns or metadata flags
|
|
122
|
+
are needed; the branch log holds only dispatch cursors.
|
|
123
|
+
|
|
124
|
+
**Composite index** `(parent_execution_log_id, state)` — makes the merge capped count
|
|
125
|
+
and the dropped-job re-kick index-only and short-circuiting at millions of rows.
|
|
126
|
+
|
|
127
|
+
No new table. The branch is the **`branch$<name>`** execution log (two-segment, like
|
|
128
|
+
`durably_repeat`'s coordination log — preloaded when sealed, never per child):
|
|
129
|
+
|
|
130
|
+
```
|
|
131
|
+
step_name: "branch$fulfillment"
|
|
132
|
+
state: pending (dispatching) | completed (sealed / block closed)
|
|
133
|
+
metadata: { "cursors" => { "orders" => { "pk" => <keyset>, "n" => <count> } } } # keyed by spawn name
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
The **`merge$<names>`** log coordinates a join (`pending` while polling → `completed`).
|
|
137
|
+
|
|
138
|
+
`Workflow belongs_to :parent_execution_log, class_name: "ExecutionLog", optional: true`;
|
|
139
|
+
`ExecutionLog has_many :spawned_workflows, class_name: "Workflow", foreign_key: :parent_execution_log_id`.
|
|
140
|
+
A branch's children are `branch_log.spawned_workflows`; the parent is `branch_log.workflow`.
|
|
141
|
+
|
|
142
|
+
### One bounded log per branch — preload safety
|
|
143
|
+
|
|
144
|
+
The preload (`completed_step_cache`) bulk-loads all `completed` logs except
|
|
145
|
+
`durably_repeat$%$%` (the unbounded three-segment repetition logs). A two-segment
|
|
146
|
+
`branch$<name>` is preloaded when sealed, so a replay past the branch short-circuits.
|
|
147
|
+
**Per-child state is never modelled as sub-segment logs** (`branch$<name>$<child>`):
|
|
148
|
+
those would be pulled into the preload and load millions of rows on every replay.
|
|
149
|
+
Per-child state lives on the child workflow rows; the branch log holds only cursors.
|
|
150
|
+
|
|
151
|
+
## Execution flow
|
|
152
|
+
|
|
153
|
+
### `branch(name, automerge:) { … }` — wrap + eager dispatch + seal
|
|
154
|
+
|
|
155
|
+
Gated by `find_or_create_execution_log!("branch$#{name}")` (the branch log holds only
|
|
156
|
+
dispatch cursors; `automerge`/merge state is in-memory, not seeded here):
|
|
157
|
+
|
|
158
|
+
1. **Sealed** (`completed`, served from `completed_step_cache`) → already fully
|
|
159
|
+
dispatched; **skip the block entirely** (no re-stream) and return. *(This short-circuit
|
|
160
|
+
is the single most important correctness/performance property in the design — the
|
|
161
|
+
expensive source enumeration never re-runs after sealing. It warrants a prominent comment
|
|
162
|
+
directly above the skip path in the implementation.)*
|
|
163
|
+
2. **Pending / new** → set the current-branch context, **yield the block** (named spawns
|
|
164
|
+
dispatch into this branch, advancing their cursors — see below), clear the context,
|
|
165
|
+
mark the `branch$<name>` log `completed` (**sealed**). If `automerge: true`, immediately
|
|
166
|
+
call `merge_branches(name)` — **execution does not continue past the block** until the
|
|
167
|
+
branch's children complete (identical to an explicit `merge_branches` call placed right
|
|
168
|
+
after the block, but guaranteed by the method). Otherwise **return** without halting —
|
|
169
|
+
branches are concurrent; the explicit join is separate.
|
|
170
|
+
|
|
171
|
+
Either way, `branch` **registers the branch in the in-memory registry**
|
|
172
|
+
`@open_branches[name] = { automerge:, log_id: }` — this runs on *every* pass (sealed or
|
|
173
|
+
not), since the `branch` method itself always executes even when its block is skipped.
|
|
174
|
+
|
|
175
|
+
`spawn`/`spawn_each` read the current branch from that context and raise
|
|
176
|
+
`NotInBranchError` if there is none.
|
|
177
|
+
|
|
178
|
+
### `spawn` / `spawn_each` — dispatch within a branch
|
|
179
|
+
|
|
180
|
+
- **`spawn(name, klass, **kwargs)`** → one child, key `"#{parent.key}$#{branch}$#{name}"`,
|
|
181
|
+
`job_class: klass.name`, `parent_execution_log_id: branch_log.id`. Idempotent on the key.
|
|
182
|
+
- **`spawn_each(name, source, of:)`** → stream, resuming from `metadata.cursors[name]`
|
|
183
|
+
(`{ pk:, n: }`); `n` is a running count (AR) or the resume index (enumerable):
|
|
184
|
+
- **AR relation:** rejects `source` if `source.order_values.present?` (raises
|
|
185
|
+
`NotExecutableError` — iteration is by PK and an explicit order conflicts). Resumes via
|
|
186
|
+
`source.find_in_batches(batch_size: of, start: cursor.pk)`. Per batch, for each record: `klass, kw =
|
|
187
|
+
yield(record)`; build child rows (key
|
|
188
|
+
`"#{parent.key}$#{branch}$#{name}_#{record.id}"`, `job_class`, `kwargs`,
|
|
189
|
+
`parent_execution_log_id: branch_log.id`, `state: :idle`); `insert_all(…, unique_by:
|
|
190
|
+
:key)` (on-conflict-ignore); enqueue only those children still `:idle` (dispatch is
|
|
191
|
+
**queue-idempotent** — a crash-resume never re-runs an already-completed/running child);
|
|
192
|
+
advance `metadata.cursors[name]` to `{ pk: batch.last.id, n: n + batch.size }`
|
|
193
|
+
(committed with the inserts).
|
|
194
|
+
- **Enumerable:** resume via `drop(n)`; child key uses `name_#{n}` (sequential index);
|
|
195
|
+
same per-chunk insert/idle-filter/enqueue/advance (`n` only).
|
|
196
|
+
- Enqueue the chunk, **then** advance the cursor — a crash in between re-enqueues only
|
|
197
|
+
that one chunk on resume (idempotent).
|
|
198
|
+
|
|
199
|
+
### `merge_branches(*names)` — separate poll-join
|
|
200
|
+
|
|
201
|
+
Each name is validated up front: `$` is rejected via `validate_step_name_segment!`, and `,`
|
|
202
|
+
(the merge step-name separator) is also rejected — both raise `InvalidStepName`.
|
|
203
|
+
|
|
204
|
+
Gated by `find_or_create_execution_log!("merge$#{names.sort.join(',')}")`:
|
|
205
|
+
|
|
206
|
+
1. **Completed** → return, continue.
|
|
207
|
+
2. For each `name`: require it to be in `@open_branches` (opened earlier this pass) — a name
|
|
208
|
+
that was never opened **raises `UnknownBranchError`** (a `NotExecutableError` subclass,
|
|
209
|
+
so it fail-fasts via the existing rescue without broadening the executor); a not-yet-sealed
|
|
210
|
+
branch means "still dispatching".
|
|
211
|
+
3. **Capped-count probe** per branch:
|
|
212
|
+
`branch_log.spawned_workflows.where.not(state: :completed).limit(CAP).count`
|
|
213
|
+
(`where(parent_execution_log_id: branch_log.id, …)`, index-only, **O(CAP) not O(N)**).
|
|
214
|
+
All `0` → done. Otherwise enqueue a `BranchMergeJob` (which polls + re-kicks dropped
|
|
215
|
+
jobs) and `halt_execution!`.
|
|
216
|
+
4. All branches `0` pending → **delete those names from `@open_branches`** (so the
|
|
217
|
+
completion gate sees them as joined), mark the `merge$…` log `completed`, continue.
|
|
218
|
+
|
|
219
|
+
Completion is **poll-based**, delegated to a dedicated lightweight job so the heavy
|
|
220
|
+
parent isn't replayed per check:
|
|
221
|
+
|
|
222
|
+
- `merge_branches` does **one** immediate check; if not done, enqueues
|
|
223
|
+
`ChronoForge::BranchMergeJob` and `halt_execution!`s (parent → `idle`, lock released).
|
|
224
|
+
The parent runs only **twice** per merge: kick off + completion wake.
|
|
225
|
+
- **`BranchMergeJob`** is a plain ActiveJob — *no* lock, replay, or context. Each run:
|
|
226
|
+
```ruby
|
|
227
|
+
pending = branch_log_ids.sum { |id| incomplete(id).limit(CAP).count } # O(CAP), index-only
|
|
228
|
+
if pending.zero? && all_sealed?(branch_log_ids)
|
|
229
|
+
ParentWorkflow.perform_later(parent_key) # wake the parent once
|
|
230
|
+
else
|
|
231
|
+
rekick_dropped_jobs(branch_log_ids) # idle re-kick lives here
|
|
232
|
+
delay = [[pending * factor, min_interval].max, max_interval].min # adaptive cadence
|
|
233
|
+
self.class.set(wait: delay).perform_later(parent_key, branch_log_ids, min_interval, max_interval)
|
|
234
|
+
end
|
|
235
|
+
```
|
|
236
|
+
- On the wake, the parent replays once; sealed branches short-circuit (no re-stream),
|
|
237
|
+
`merge_branches` re-checks, marks the `merge$<names>` log `completed`, and continues.
|
|
238
|
+
**The parent completes its own merge step** — the poller only *detects* and wakes.
|
|
239
|
+
|
|
240
|
+
`merge_branches` **(re)spawns a poller whenever reached while still pending**, so a manual
|
|
241
|
+
retry of a parked parent self-heals a lost poller (including when the poller was spawned by
|
|
242
|
+
an automerge inline call); a rare double-poller from an external re-trigger is harmless (the
|
|
243
|
+
wake is idempotent).
|
|
244
|
+
|
|
245
|
+
**No separate recovery poll.** The poller is a durable backend-scheduled job — the same
|
|
246
|
+
durability `wait_until`'s reschedule already relies on. A lost poller just parks the
|
|
247
|
+
parent with a pending `merge$…` log, recoverable by retry (Option A). Cost: one tiny job
|
|
248
|
+
per (adaptive) interval plus an **O(CAP)** index-only capped count per branch — no
|
|
249
|
+
counter, no per-child shared write, no hot-row contention at any scale; latency falls
|
|
250
|
+
toward `min_interval` as the branch nears done. Option A falls out — a failed child keeps
|
|
251
|
+
pending > 0, so the parent waits until it is recovered.
|
|
252
|
+
|
|
253
|
+
### Completion gate — every branch must be joined
|
|
254
|
+
|
|
255
|
+
Every branch must be joined — explicitly via `merge_branches` or implicitly via
|
|
256
|
+
`automerge: true`. **There is no detached branch.** `complete_workflow!` (`enforce_branch_joins!`)
|
|
257
|
+
gains a gate **before** it seals the workflow that inspects `@open_branches` — the in-memory
|
|
258
|
+
registry that `branch` populated and `merge_branches` pruned during this pass (rebuilt
|
|
259
|
+
deterministically every replay, so it's exact). The gate does **only** the unmerged-raise
|
|
260
|
+
check:
|
|
261
|
+
|
|
262
|
+
1. **Unmerged check:** any branch remaining in `@open_branches` at completion is a forgotten
|
|
263
|
+
join → **raise `UnmergedBranchError`** naming the branch(es), with the hint *"add
|
|
264
|
+
`merge_branches :x` or `branch(:x, automerge: true)`."* This fails the workflow fast
|
|
265
|
+
rather than letting children run orphaned; the developer fixes the code and retries. The
|
|
266
|
+
check is unconditional (fires even if the branch's children happen to have finished) so
|
|
267
|
+
the contract is deterministic, not timing-dependent.
|
|
268
|
+
|
|
269
|
+
(A branch joined via `merge_branches` was already deleted from `@open_branches` when that
|
|
270
|
+
merge completed, so it's absent here. An `automerge: true` branch is also absent — its join
|
|
271
|
+
ran inline at the `branch` block's close, removing it from `@open_branches` before
|
|
272
|
+
execution ever continued past the block.)
|
|
273
|
+
|
|
274
|
+
## Determinism
|
|
275
|
+
|
|
276
|
+
The cursor is only meaningful if iteration is reproducible across replays:
|
|
277
|
+
|
|
278
|
+
- **AR relation:** children are keyed by **primary key** (`name_<record.id>`), so the
|
|
279
|
+
mapping from record to child key is stable regardless of enumeration order. Iteration is
|
|
280
|
+
driven by **primary-key keyset** (`find_in_batches(start:)`). `spawn_each` rejects a relation
|
|
281
|
+
carrying an explicit `.order(...)` by checking `order_values.present?` up front (raises
|
|
282
|
+
`NotExecutableError`) — relying on `find_in_batches`'s `error_on_ignore` is not
|
|
283
|
+
sufficient because `find_in_batches(start:)` is inclusive and a crash-resume re-yields
|
|
284
|
+
the boundary record; the explicit up-front check catches order conflicts before any
|
|
285
|
+
inserts occur.
|
|
286
|
+
- **Enumerable:** items are keyed `name_{index}` by their **sequential position** in the
|
|
287
|
+
stream, so the source must re-enumerate identically across replays (effectively frozen
|
|
288
|
+
for the brief dispatch window — once the branch seals, replay skips the block, so no
|
|
289
|
+
re-enumeration happens thereafter). Deterministic re-enumeration is a documented,
|
|
290
|
+
unverifiable contract — misuse is still *safe* (`insert_all`-ignore + poll) but could
|
|
291
|
+
dispatch the wrong set.
|
|
292
|
+
|
|
293
|
+
## Idempotency & crash recovery
|
|
294
|
+
|
|
295
|
+
Three layers — **what exists** (DB), **how far dispatch got** (cursor), **step state**
|
|
296
|
+
(the log).
|
|
297
|
+
|
|
298
|
+
- **`find_or_create_execution_log!`** — a sealed `branch$<name>` skips the whole block;
|
|
299
|
+
a completed `merge$…` short-circuits the join.
|
|
300
|
+
- **Deterministic keys + per-spawn cursors = "which children exist."** The branch never
|
|
301
|
+
tracks children individually. Existence is owned by the unique index (`insert_all`-ignore
|
|
302
|
+
is a no-op for rows that exist); dispatch progress is owned by `metadata.cursors[name]`.
|
|
303
|
+
Recovery resumes from the cursor and re-touches **one chunk**, not the whole set.
|
|
304
|
+
*(Dispatch is not bound to the create block: a crash after the log is created must still
|
|
305
|
+
create and enqueue the remaining rows, or the branch would stall forever.)*
|
|
306
|
+
- **`:idle` filter for dropped jobs.** A child dispatched but never run is re-kicked from
|
|
307
|
+
the `BranchMergeJob` poll: re-enqueue branch children with `state: :idle` in an
|
|
308
|
+
incomplete branch. Branch children are pre-inserted with `state: :idle` and never get
|
|
309
|
+
`started_at` set before execution, so `:idle` is the correct "never picked up" signal
|
|
310
|
+
(filtering by `started_at IS NULL` would be unreliable). *Child existence is not enough;
|
|
311
|
+
the merge guarantees every member is actually queued.* (verbatim into the code comment.)
|
|
312
|
+
The re-kick is batch-capped. Safe because re-enqueue is idempotent: `executable?` is
|
|
313
|
+
`idle || running`, so `acquire_lock` raises `NotExecutableError` for a `completed` child
|
|
314
|
+
(its dispatch can never double-fire) and `ConcurrentExecutionError` for a `running` one.
|
|
315
|
+
Children in other states (running, mid-halt, stalled/failed under Option A) are excluded
|
|
316
|
+
by the `:idle` filter.
|
|
317
|
+
|
|
318
|
+
### Recovery walkthrough — 300,000 children, crash at 250,000
|
|
319
|
+
|
|
320
|
+
A `branch :fulfillment` block's `spawn_each :orders` had committed 250k child rows + jobs
|
|
321
|
+
with `metadata.cursors["orders"]` at `{ pk: <250,000th PK>, n: 250_000 }`; it was mid-chunk
|
|
322
|
+
when the process died. The `branch$orders` log is still `pending` (not sealed), so workflow retry replays
|
|
323
|
+
from the top. The block re-runs; `spawn_each` resumes `find_in_batches(start: cursor)` from PK
|
|
324
|
+
250,000 — re-touching ~50k rows, worst-case duplicate enqueue is the single in-flight
|
|
325
|
+
chunk. The 250k already dispatched keep running the whole time. When the source is
|
|
326
|
+
exhausted the block closes and the branch seals; `merge_branches`/automerge then polls to
|
|
327
|
+
completion. Recovery is bounded and idempotent — never a re-fan-out of 300k.
|
|
328
|
+
|
|
329
|
+
## Scale & performance (target: hundreds of thousands per branch)
|
|
330
|
+
|
|
331
|
+
| Operation | Frequency | Cost |
|
|
332
|
+
|---|---|---|
|
|
333
|
+
| `spawn_each` dispatch | once per branch, **streamed** | `⌈N/of⌉` `insert_all` + `perform_all_later`, each advancing the cursor — O(N) total, bounded chunks, **constant memory** |
|
|
334
|
+
| Child run | per child | one own-row state transition — **no shared-row contention** |
|
|
335
|
+
| Merge poll | per adaptive interval | lightweight `BranchMergeJob` running an **O(CAP)** capped count per branch; interval scales `min`↔`max` with pending — the heavy parent is *not* replayed per poll |
|
|
336
|
+
| Crash recovery | once | resumes dispatch from the cursor — re-touches **one chunk** |
|
|
337
|
+
| Replay cost | per resume | independent of how many children finished — no member list, no sibling scan, no counter |
|
|
338
|
+
|
|
339
|
+
What deliberately does **not** exist: an in-memory fork registry (streamed instead), a
|
|
340
|
+
single hot completion counter (adaptive capped-count poll instead), and a member-key blob
|
|
341
|
+
in metadata (just per-spawn cursors). The remaining O(N) work — N rows + N jobs — is
|
|
342
|
+
irreducible for N sub-workflows, done in bounded chunks by one parent job.
|
|
343
|
+
|
|
344
|
+
### `perform_all_later` — verified (activejob 7.1.3.4)
|
|
345
|
+
|
|
346
|
+
- **Mixed job classes: supported, no same-class requirement.** `perform_all_later` groups
|
|
347
|
+
by `queue_adapter` (`enqueuing.rb:18`); the adapter's `enqueue_all` then sub-groups by
|
|
348
|
+
**class then queue** (e.g. core Sidekiq adapter does `group_by(&:class).group_by(&:queue_name)`
|
|
349
|
+
→ one `push_bulk` per group, `sidekiq_adapter.rb:36`). So a `spawn_each` returning mixed
|
|
350
|
+
workflow types is fine — enqueue just batches per distinct (class, queue), never falling
|
|
351
|
+
back to per-job for being heterogeneous.
|
|
352
|
+
- **It bypasses ChronoForge's class-level `perform_later` override** (`__validate_enqueue!`)
|
|
353
|
+
and **ActiveJob enqueue callbacks** — it builds instances and hits the adapter directly.
|
|
354
|
+
So `spawn`/`spawn_each` must **build child job instances and validate them
|
|
355
|
+
(String key, no reserved kwargs) themselves**, then `ActiveJob.perform_all_later(jobs)`.
|
|
356
|
+
Execution is unaffected (the executor's logic is in instance `perform`). This mirrors the
|
|
357
|
+
existing sanctioned `.set(...)`-bypasses-the-override pattern.
|
|
358
|
+
- **Requires activejob ≥ 7.1.** The gemspec currently pins no version — add
|
|
359
|
+
`spec.add_dependency "activejob", ">= 7.1"` (or provide a `perform_later`-loop fallback).
|
|
360
|
+
- **Bulk enqueue is adapter-dependent.** Only adapters implementing `enqueue_all`
|
|
361
|
+
(Sidekiq in core; solid_queue/good_job ship their own) batch the enqueue; Test/Inline/
|
|
362
|
+
Async fall back to per-job `enqueue`. The `insert_all` of child rows is **always** bulk;
|
|
363
|
+
job enqueue batching is best-effort.
|
|
364
|
+
|
|
365
|
+
Other caveats to verify in the plan:
|
|
366
|
+
- `find_in_batches` `start:` semantics (inclusive boundary — the boundary record is re-yielded on
|
|
367
|
+
crash-resume; PK-keyed children dedup via `insert_all`-ignore) and the per-adapter
|
|
368
|
+
bind-param limit for the `of:` chunk size (notably SQLite's `SQLITE_MAX_VARIABLE_NUMBER`).
|
|
369
|
+
- The merge capped count must be index-only on `(parent_execution_log_id, state)`.
|
|
370
|
+
|
|
371
|
+
## Poll-cadence constants (class-configurable defaults)
|
|
372
|
+
|
|
373
|
+
- `CAP` (capped-count limit) — default `5_000`. Bounds each poll's count cost; beyond it,
|
|
374
|
+
pending saturates to `max_interval` (no signal lost).
|
|
375
|
+
- `factor` — maps pending → delay; default tuned so ~100 → ~10s, ~1k → ~1 min.
|
|
376
|
+
- `min_interval` / `max_interval` — clamp; defaults `5.seconds` / `5.minutes`
|
|
377
|
+
(per-`merge_branches` overridable; `automerge` uses the defaults).
|
|
378
|
+
|
|
379
|
+
## Naming & validation
|
|
380
|
+
|
|
381
|
+
- `STEP_NAME_DELIMITER` is `$` (executor.rb). Reserved.
|
|
382
|
+
- The `branch` name, each `spawn`/`spawn_each` name, and each `merge_branches` name pass
|
|
383
|
+
through `validate_step_name_segment!` (no `$`). The merge step name joins sorted branch
|
|
384
|
+
names with `,`; names containing `,` are rejected. (`name_{index}` uses `_`, which is
|
|
385
|
+
unreserved.)
|
|
386
|
+
- Child keys use `$` (`"#{parent.key}$#{branch}$#{spawn_name}"` / `…$#{spawn_name}_#{index}"`).
|
|
387
|
+
Keys are opaque (never parsed), so a `$` already in the parent key is harmless.
|
|
388
|
+
|
|
389
|
+
## Non-goals (v1) — with caveats
|
|
390
|
+
|
|
391
|
+
- **Sharded completion counter / instant wake.** v1 polls (adaptive, but still polls). If
|
|
392
|
+
sub-`min_interval` wake latency is ever needed, a
|
|
393
|
+
`fork_counters(branch_log_id, shard, completed)` table (K rows/branch) is the upgrade —
|
|
394
|
+
fully internal to the branch, **zero API change**.
|
|
395
|
+
- **Parallel dispatch.** One parent job streams the dispatch. At ~1M this is minutes and
|
|
396
|
+
crash-safe via the cursor; recursive dispatcher sub-jobs are a future throughput upgrade.
|
|
397
|
+
- **Result aggregation.** Children communicate via their own `context`, which is
|
|
398
|
+
**workflow-scoped** — a parent can't read a child's context today. `branch_log.spawned_workflows`
|
|
399
|
+
returns the child **records**. Aggregation, when added, needs an explicit cross-workflow
|
|
400
|
+
read API.
|
|
401
|
+
- **`merge_branches` timeout.** Blocks indefinitely (Option A); a `timeout:` can come later.
|
|
402
|
+
- **Dashboard nesting.** Parent/child tree + per-child recovery is a follow-up; the
|
|
403
|
+
`parent_execution_log_id` column makes the tree cheap to walk.
|
|
404
|
+
|
|
405
|
+
## Testing strategy
|
|
406
|
+
|
|
407
|
+
Mirror the existing `ChaoticJob` style (`perform_later` + `perform_all_jobs`; assert on
|
|
408
|
+
workflow `state`, `execution_logs`).
|
|
409
|
+
|
|
410
|
+
- **Happy path:** a `branch` with a `spawn :a` + a `spawn_each :b`; assert child keys
|
|
411
|
+
(`…$a`, `…$b_0`, `…$b_1`, …), `parent_execution_log_id`, the branch seals,
|
|
412
|
+
`merge_branches` resumes, the workflow finishes.
|
|
413
|
+
- **Spawn outside branch raises** `NotInBranchError`.
|
|
414
|
+
- **Concurrency:** two branches dispatched before the merge both make progress before the
|
|
415
|
+
join; work between branch blocks and merge runs while children are in flight.
|
|
416
|
+
- **Eager dispatch:** children begin before `merge_branches` is reached.
|
|
417
|
+
- **Class from body:** a `spawn_each` returning mixed classes creates children with the
|
|
418
|
+
right `job_class` per item (and they bulk-enqueue together).
|
|
419
|
+
- **Determinism guard:** an AR relation with a conflicting `.order(...)` **raises**.
|
|
420
|
+
- **Crash mid-dispatch (cursor resume):** glitch after chunk *k*; assert
|
|
421
|
+
`metadata.cursors[name]` (`{ pk:, n: }` for AR; `{ n: }` for enumerable) persisted,
|
|
422
|
+
dispatch resumes from it (not 0), final child count correct, no duplicate rows, only the
|
|
423
|
+
in-flight chunk re-enqueued. (250k-of-300k.)
|
|
424
|
+
- **Dropped-job re-kick:** a sealed branch with a child whose job was lost (state `:idle`,
|
|
425
|
+
never started); assert the poll re-enqueues exactly that child and then resolves.
|
|
426
|
+
- **Poll job:** assert the parent runs only twice (kick-off + wake) regardless of poll
|
|
427
|
+
count; a manual retry of a parked parent re-spawns the poller.
|
|
428
|
+
- **Adaptive cadence:** assert the count is capped at `CAP` (a branch with ≫CAP incomplete
|
|
429
|
+
issues an O(CAP) count and picks `max_interval`); the delay shrinks toward `min_interval`
|
|
430
|
+
as pending drops.
|
|
431
|
+
- **Automerge:** an `automerge: true` branch blocks execution inline at the block's close
|
|
432
|
+
(not at workflow completion) — assert execution does not continue past the block until
|
|
433
|
+
children finish, and that the `merge$<name>` log exists and is `completed` before the
|
|
434
|
+
next step runs. No explicit `merge_branches` is needed.
|
|
435
|
+
- **Unmerged branch raises:** a branch opened with neither `merge_branches` nor
|
|
436
|
+
`automerge: true` raises `UnmergedBranchError` at the completion gate (unconditional —
|
|
437
|
+
fires even if children already finished), naming the branch.
|
|
438
|
+
- **Option A:** a child `permanently_fail`s → merge parks; recover via `retry_later` →
|
|
439
|
+
merge resolves; assert no progress while parked.
|
|
440
|
+
- **Idempotency / replay:** force replays; assert constant child count, no re-dispatch once
|
|
441
|
+
sealed, replay query count independent of completed-child count (`branch$<name>` preloads
|
|
442
|
+
when sealed; no per-child sub-logs).
|
|
443
|
+
- **Scale:** a branch of hundreds of thousands; assert `insert_all` issues `⌈N/of⌉` inserts
|
|
444
|
+
(not N), constant memory (streamed), contention-free child runs, and one capped probe per
|
|
445
|
+
branch per poll. (Job-enqueue batching is adapter-dependent — under the test adapter it
|
|
446
|
+
falls back to per-job enqueue, so don't assert bulk *enqueue* there.)
|
|
447
|
+
- **Empty branch / empty source:** seals immediately; the merge resolves at once.
|
|
448
|
+
- **Nesting:** a child opens its own branch; assert the tree completes bottom-up.
|
|
449
|
+
|
|
450
|
+
## README notes (when shipping)
|
|
451
|
+
|
|
452
|
+
Surface these prominently in user-facing docs, not just here:
|
|
453
|
+
- **Every branch must be merged or `automerge: true`** — otherwise `UnmergedBranchError`.
|
|
454
|
+
- **The heavy parent is not replayed per poll** — a lightweight `BranchMergeJob` does the
|
|
455
|
+
waiting; the parent runs twice per merge.
|
|
456
|
+
- **AR source must be stable during a branch's dispatch window** — AR items are keyed by
|
|
457
|
+
primary key (`name_<id>`), so inserting rows mid-dispatch is safe but re-use of a PK
|
|
458
|
+
(soft-delete/re-insert) can confuse the cursor. Plain enumerables are keyed by sequential
|
|
459
|
+
index; inserting/removing items mid-dispatch (before a crash-replay seals the branch)
|
|
460
|
+
shifts indices. Once sealed, the block never re-enumerates.
|
|
461
|
+
|
|
462
|
+
## Future work
|
|
463
|
+
|
|
464
|
+
- Sharded-counter table for instant (non-poll) wake.
|
|
465
|
+
- Parallel/recursive dispatcher sub-jobs for dispatch throughput beyond one parent job.
|
|
466
|
+
- Result aggregation via an explicit child-context read API.
|
|
467
|
+
- `merge_branches(..., timeout:)`.
|
|
468
|
+
- Dashboard parent/child tree + per-child recovery actions.
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# Dashboard Branch View — Design
|
|
2
|
+
|
|
3
|
+
**Date:** 2026-06-26
|
|
4
|
+
**Status:** Design only. **BLOCKED on the branches core feature**
|
|
5
|
+
([`2026-06-25-spawn-merge-branches-design.md`](2026-06-25-spawn-merge-branches-design.md),
|
|
6
|
+
itself still Draft). Nothing here can be built or tested until `parent_execution_log_id`,
|
|
7
|
+
`branch`/`spawn`/`merge_branches`, and the `spawned_workflows` association exist in the
|
|
8
|
+
core gem. This spec is written so it can be implemented the day branches ships.
|
|
9
|
+
**Scope:** additive views in the `chrono_forge-dashboard` engine. No core changes.
|
|
10
|
+
|
|
11
|
+
## Problem
|
|
12
|
+
|
|
13
|
+
With fan-out, a parent can park at `merge_branches` (Option A) because **one** child
|
|
14
|
+
among tens or hundreds of thousands is `failed`/`stalled`. Today there is no way to see
|
|
15
|
+
that: the parent looks idle, and the blocking child is a needle in a haystack. The branch
|
|
16
|
+
view exists to answer, in one screen: *which branch is blocking this parent, how many
|
|
17
|
+
children are outstanding, and which specific children are failed/stalled — with a Retry on
|
|
18
|
+
each.* It is what makes Option A (park until recovered) operable in production.
|
|
19
|
+
|
|
20
|
+
## What it consumes from the core (the contract)
|
|
21
|
+
|
|
22
|
+
This view reads only what the branches spec defines. If any of these change, this spec
|
|
23
|
+
changes with it.
|
|
24
|
+
|
|
25
|
+
- **Column** `chrono_forge_workflows.parent_execution_log_id` (FK → `execution_logs.id`,
|
|
26
|
+
nullable) with composite index `(parent_execution_log_id, state)`.
|
|
27
|
+
- **Associations** `Workflow#parent_execution_log` (→ `ExecutionLog`) and
|
|
28
|
+
`ExecutionLog#spawned_workflows` (→ `Workflow`, FK `parent_execution_log_id`).
|
|
29
|
+
- **Branch log:** an execution log with `step_name` `"branch$<name>"`,
|
|
30
|
+
`state` `pending` (dispatching) | `completed` (sealed), and `metadata`
|
|
31
|
+
`{ "automerge" => bool, "merged" => bool, "cursors" => { "<spawn>" => { "pk", "n" } } }`.
|
|
32
|
+
- **Merge log:** `"merge$<names>"`, `pending` while polling → `completed`.
|
|
33
|
+
- A child is a `Workflow`; its parent branch log is `child.parent_execution_log`; the
|
|
34
|
+
parent workflow is `branch_log.workflow`.
|
|
35
|
+
|
|
36
|
+
The reusable `StepNameParser` (already in the engine) gains `branch` / `merge` kinds.
|
|
37
|
+
|
|
38
|
+
## Design
|
|
39
|
+
|
|
40
|
+
### 1. Branches panel on the parent's detail page
|
|
41
|
+
|
|
42
|
+
A new section on `workflows#show`, rendered only when the workflow has any `branch$%`
|
|
43
|
+
logs. One row per branch (`BranchPresenter`), showing **health**:
|
|
44
|
+
|
|
45
|
+
| Field | Source | Notes |
|
|
46
|
+
|---|---|---|
|
|
47
|
+
| name | `StepNameParser.parse(log.step_name).name` | |
|
|
48
|
+
| status | `log.state` | `completed` → **sealed**; `pending` → **dispatching** (still spawning) |
|
|
49
|
+
| join | `metadata.automerge` / `metadata.merged` | "automerge", "merged", or "unmerged" |
|
|
50
|
+
| dispatched | `sum(metadata.cursors[*].n)` + explicit `spawn` count | cheap (from metadata), avoids counting rows |
|
|
51
|
+
| pending | `spawned_workflows.where.not(state: :completed).limit(CAP).count` | **capped, index-only** (O(CAP)); shows `"5000+"` past CAP |
|
|
52
|
+
| blocked | `spawned_workflows.where(state: [:failed, :stalled]).limit(CAP).count` | the actionable number; rendered in rose when > 0 |
|
|
53
|
+
|
|
54
|
+
Each branch row links to its **children view** (below) and, when `blocked > 0`, a direct
|
|
55
|
+
"View blocked" link pre-filtered to failed/stalled.
|
|
56
|
+
|
|
57
|
+
A parent parked on a merge also surfaces its `merge$<names>` log(s) here ("merging
|
|
58
|
+
invoicing — pending"), so the park is legible.
|
|
59
|
+
|
|
60
|
+
### 2. Branch children view (drill-down)
|
|
61
|
+
|
|
62
|
+
A new route + controller, because a branch can hold **hundreds of thousands** of children
|
|
63
|
+
— they are never all rendered.
|
|
64
|
+
|
|
65
|
+
- Route: `GET /workflows/:workflow_id/branches/:branch_log_id` →
|
|
66
|
+
`BranchChildrenController#show` (scoped to the branch log; verifies it belongs to the
|
|
67
|
+
workflow).
|
|
68
|
+
- **Reuses `WorkflowsQuery`** over `branch_log.spawned_workflows` (same state/key filters,
|
|
69
|
+
pagination). **Default filter: `failed` + `stalled` first** — the triage default, so the
|
|
70
|
+
blockers are the landing view rather than page 1 of 500k.
|
|
71
|
+
- Reuses the existing `_workflow_row` partial (children are workflows) plus a per-row
|
|
72
|
+
**Retry** (and the child's own key links to its detail).
|
|
73
|
+
- A capped state-count strip at the top (completed/running/idle/failed/stalled), each an
|
|
74
|
+
O(CAP) index-only count rendered as `"N"` or `"CAP+"`.
|
|
75
|
+
|
|
76
|
+
### 3. Per-child recovery
|
|
77
|
+
|
|
78
|
+
Children are workflows, so recovery reuses the existing `ActionsController`:
|
|
79
|
+
- Per-child **Retry** (`workflow.retry_later`) in each row and on the child detail.
|
|
80
|
+
- A **"Retry all blocked in this branch"** bulk action: iterate
|
|
81
|
+
`branch_log.spawned_workflows.where(state: [:failed, :stalled]).find_each(&:retry_later)`
|
|
82
|
+
(a scoped sibling of the existing bulk-retry). After recovery the parent's merge poll
|
|
83
|
+
resolves on its own (Option A) — the view does not touch the parent.
|
|
84
|
+
|
|
85
|
+
### 4. Child → parent linkage
|
|
86
|
+
|
|
87
|
+
On `workflows#show`, when `@workflow.parent_execution_log_id` is present, render a
|
|
88
|
+
**breadcrumb**: `parent key › branch <name> › this child`, linking to the parent and the
|
|
89
|
+
branch children view. Cheap: one `parent_execution_log` + its `workflow`.
|
|
90
|
+
|
|
91
|
+
### 5. Tree view (nested branches)
|
|
92
|
+
|
|
93
|
+
Branches nest (a child may open its own branches). The parent panel shows **one level**
|
|
94
|
+
(this workflow's branches + per-branch child summary); you navigate down by opening a
|
|
95
|
+
child (whose own detail shows its branches) rather than rendering an unbounded tree on one
|
|
96
|
+
page. The breadcrumb provides the up-path. This keeps every page O(page_size), never
|
|
97
|
+
O(tree).
|
|
98
|
+
|
|
99
|
+
## Components
|
|
100
|
+
|
|
101
|
+
- `app/presenters/.../branch_presenter.rb` — one branch log → health struct (capped
|
|
102
|
+
counts, dispatched-from-cursor, sealed/merged flags).
|
|
103
|
+
- `app/presenters/.../branches_presenter.rb` — a workflow's `branch$%` + `merge$%` logs.
|
|
104
|
+
- `app/controllers/.../branch_children_controller.rb` — `#show`, scoped children list.
|
|
105
|
+
- `app/queries/.../workflows_query.rb` — extend to accept a base scope (so it can run over
|
|
106
|
+
`branch_log.spawned_workflows`, not just `Workflow.all`).
|
|
107
|
+
- `ActionsController#bulk_retry_branch` — scoped bulk retry.
|
|
108
|
+
- Views: `_branches.html.erb` (panel on show), `branch_children/show.html.erb`,
|
|
109
|
+
`_parent_breadcrumb.html.erb`; `StepNameParser` branch/merge kinds.
|
|
110
|
+
- Routes: nested `branches/:branch_log_id` under `workflows`; a member `bulk_retry` on it.
|
|
111
|
+
|
|
112
|
+
## Scale guardrails (non-negotiable)
|
|
113
|
+
|
|
114
|
+
- **Never** `group(:state).count` an unbounded child set on a page load. All counts are
|
|
115
|
+
**capped** (`limit(CAP)`) and index-only on `(parent_execution_log_id, state)`, shown as
|
|
116
|
+
`"CAP+"` past the cap — mirroring the merge probe.
|
|
117
|
+
- **Never** render more than one page of children. Default to the blocked subset.
|
|
118
|
+
- "Dispatched" total comes from `metadata.cursors` (`n`), not a row count.
|
|
119
|
+
- The branches panel issues at most ~2 capped probes per branch (pending, blocked) — bounded
|
|
120
|
+
regardless of child count.
|
|
121
|
+
|
|
122
|
+
## Testing (once branches exists)
|
|
123
|
+
|
|
124
|
+
Seed parent + `branch$<name>` logs + child workflow rows with `parent_execution_log_id`
|
|
125
|
+
(no need to run real fan-out):
|
|
126
|
+
- branches panel: sealed vs dispatching; automerge/merged/unmerged; pending + blocked
|
|
127
|
+
capped counts (incl. a `>CAP` case showing `"CAP+"`); rose styling when blocked > 0.
|
|
128
|
+
- children view: default filter shows only failed/stalled; state filter + pagination work
|
|
129
|
+
over the scoped relation; per-child Retry calls `retry_later`.
|
|
130
|
+
- scoped bulk retry hits only that branch's failed/stalled children.
|
|
131
|
+
- breadcrumb: a child renders a link to its parent + branch; a non-child renders none.
|
|
132
|
+
- merge log surfaced when the parent is parked.
|
|
133
|
+
|
|
134
|
+
## Open questions (confirm on review)
|
|
135
|
+
|
|
136
|
+
1. **Counts beyond CAP** — show `"5000+"` (capped) everywhere, or pay an exact `COUNT` for
|
|
137
|
+
the *blocked* number only (usually small) while capping pending? (Leaning: exact for
|
|
138
|
+
blocked, capped for pending.)
|
|
139
|
+
2. **Children view default** — land on failed/stalled (triage), or all-with-failed-first?
|
|
140
|
+
(Leaning: failed/stalled, with a clear "show all" toggle.)
|
|
141
|
+
3. **Tree depth** — one level per page + breadcrumb (this spec), or a shallow expandable
|
|
142
|
+
tree for small fan-outs? (Leaning: one level; revisit if small-N trees feel clunky.)
|