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.
Files changed (31) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +44 -1
  3. data/README.md +246 -119
  4. data/Rakefile +4 -0
  5. data/cliff.toml +62 -0
  6. data/docs/design/per-child-commit-overhead.md +213 -0
  7. data/docs/fanout-scale-test.md +246 -0
  8. data/docs/superpowers/plans/2026-06-30-poller-rekick-and-eta-cadence.md +205 -0
  9. data/docs/superpowers/plans/2026-06-30-poller-rekick-and-eta-cadence.md.tasks.json +33 -0
  10. data/docs/superpowers/plans/2026-07-01-workflow-definition-dag.md +1373 -0
  11. data/docs/superpowers/plans/2026-07-01-workflow-definition-dag.md.tasks.json +68 -0
  12. data/docs/superpowers/specs/2026-07-01-workflow-definition-dag-design.md +203 -0
  13. data/docs/superpowers/specs/2026-07-09-chrono-forge-reaper-design.md +175 -0
  14. data/lib/chrono_forge/branch_merge_job.rb +158 -21
  15. data/lib/chrono_forge/branch_probe.rb +44 -0
  16. data/lib/chrono_forge/configuration.rb +51 -0
  17. data/lib/chrono_forge/definition.rb +37 -0
  18. data/lib/chrono_forge/definition_analyzer.rb +501 -0
  19. data/lib/chrono_forge/executor/context.rb +23 -0
  20. data/lib/chrono_forge/executor/lock_strategy.rb +10 -3
  21. data/lib/chrono_forge/executor/methods/continue_if.rb +15 -6
  22. data/lib/chrono_forge/executor/methods/durably_execute.rb +15 -7
  23. data/lib/chrono_forge/executor/methods/durably_repeat.rb +30 -14
  24. data/lib/chrono_forge/executor/methods/merge_branches.rb +5 -4
  25. data/lib/chrono_forge/executor/methods/workflow_states.rb +35 -47
  26. data/lib/chrono_forge/executor.rb +35 -10
  27. data/lib/chrono_forge/version.rb +1 -1
  28. data/lib/chrono_forge/workflow.rb +43 -0
  29. data/lib/chrono_forge.rb +8 -0
  30. data/lib/tasks/release.rake +212 -0
  31. metadata +29 -2
@@ -0,0 +1,213 @@
1
+ # Per-child commit overhead on large branch fan-out
2
+
3
+ Status: **implemented and benchmarked**, targeted for **v0.12** (baseline is the
4
+ released v0.10). All the consolidations under "Implemented" below have shipped to
5
+ the working tree and are validated by matched baseline-vs-consolidation benchmarks
6
+ at 20k/50k/100k — see
7
+ [`fanout-scale-test.md`](../fanout-scale-test.md): **~−50% per-child execution
8
+ time, +20–30% throughput**, flat across scale. The risky `save`+`release` merge was
9
+ deliberately left out. Scope: `chrono_forge` core engine. Independent of the dashboard.
10
+
11
+ ## Problem
12
+
13
+ Branch fan-out throughput tops out at roughly a couple thousand DB commits/sec
14
+ on a single Postgres writer. Each child workflow, even a trivial one that runs a
15
+ single `durably_execute` to completion in one `perform`, produces a string of
16
+ independent transactions. Because every transaction is its own `fsync`, the
17
+ workload is commit-bound (fsync-bound), and the number of children you can drain
18
+ per second is `~(fsync budget) / (commits per child)`.
19
+
20
+ For ~8 primary-DB commits per child plus Solid Queue overhead, ~10 workers land
21
+ around ~2,000 fsync/s — that's the ceiling. Lowering commits-per-child raises the
22
+ ceiling proportionally.
23
+
24
+ ## The actual per-child commit sequence
25
+
26
+ Traced for a trivial child: one `durably_execute`, runs to completion in a single
27
+ `perform`. Each row is one transaction = one commit = one fsync.
28
+
29
+ | # | Where | Write | Source |
30
+ |---|-------|-------|--------|
31
+ | 1 | `setup_workflow!` | `update_column(:started_at)` — **branch children only** (parent pre-inserts the row, so the create block never stamps it) | `executor.rb:221` |
32
+ | 2 | `LockStrategy.acquire_lock` | `transaction { lock!; update_columns(locked_by, locked_at, state: :running) }` | `executor/lock_strategy.rb:10-33` |
33
+ | 3 | `durably_execute` | INSERT execution log (`find_or_create_execution_log!`) | `executor/methods/durably_execute.rb:70` |
34
+ | 4 | `durably_execute` | `update!(attempts, last_executed_at)` — *before* the step | `executor/methods/durably_execute.rb:80` |
35
+ | 5 | `durably_execute` | `update!(state: :completed)` — *after* the step | `executor/methods/durably_execute.rb:89` |
36
+ | 6 | `complete_workflow!` | **three separate commits**: completion-marker INSERT + `workflow.completed!` UPDATE + log `update!` | `executor/methods/workflow_states.rb:50-70` |
37
+ | 7 | `ensure` → `context.save!` | `update_column(:context)` | `executor/context.rb:70`, called at `executor.rb:187` |
38
+ | 8 | `release_lock` | `update_columns(locked_at: nil, locked_by: nil, …)` | `executor/lock_strategy.rb:53` |
39
+ | + | Solid Queue | claim + finish/delete (2 more, in the **queue** DB) | backend |
40
+
41
+ So ~8 primary-DB commits + ~2 queue commits per child. Empirically (SQLite probe
42
+ of a no-step child), step 6 is **three** independent commits, not one — the
43
+ completion-marker INSERT, the workflow→`:completed` UPDATE, and the marker UPDATE
44
+ each commit separately. That made it the single biggest safe win.
45
+
46
+ ## What's safely reducible (≈⅓, realistically ~8 → ~5)
47
+
48
+ > Note: this section is the original sketch. It shipped *differently* — see
49
+ > "Implemented" below. The real target turned out to be `complete_workflow!`'s own
50
+ > three commits (#6), which collapse to one safely; #7+#8 (`context.save!` +
51
+ > `release_lock`) were deliberately left split. Net effect is the same ~⅓.
52
+
53
+ These have no external side effect between them, so collapsing them into one
54
+ transaction cannot cause double-execution of anything observable:
55
+
56
+ - **Collapse the end-of-run trio (#6, #7, #8).** Completion + context save + lock
57
+ release happen back-to-back with nothing external in between. One transaction
58
+ instead of three. Caveat: today `context.save!` and `release_lock` are
59
+ deliberately ordered in an `ensure` so the lock is released (and the
60
+ continuation published) *even if the save raises* — see `executor.rb:178-196`.
61
+ Any consolidation must preserve that "always release the lock, never strand the
62
+ workflow" guarantee. Merging the happy path while keeping the failure path's
63
+ release semantics is the careful part.
64
+ - **Fold #1 into #2.** Stamp `started_at` inside the `acquire_lock` transaction
65
+ instead of as its own `update_column`. Saves one commit on branch children.
66
+
67
+ Net: ~8 → ~5 primary commits ⇒ a proportional throughput bump on a commit-bound
68
+ workload. Real and worth doing, but it's ~40%, **not** an order of magnitude.
69
+
70
+ ## What is NOT reducible (it's the point of the engine)
71
+
72
+ - **The per-step INSERT + "completed" UPDATE (#3, #5) must commit independently
73
+ of other steps.** That committed "completed" marker is exactly what guarantees a
74
+ side-effecting step (charge a card, send a webhook) runs once and is never
75
+ replayed. You cannot batch step-completion commits across steps without risking
76
+ double-execution of external effects. It looks wasteful for a no-op child, but
77
+ the engine cannot assume a step is side-effect-free.
78
+ - **Lock acquire (#2) must commit before doing work** so other workers see it.
79
+ - **Solid Queue's claim/finish** is backend overhead, outside ChronoForge.
80
+
81
+ ## The real lever for massive fan-out is architectural, not commit-tuning
82
+
83
+ To run millions of trivial children you change the shape, not shave commits:
84
+
85
+ - **Bulk / lightweight child mode** for large sets of trivial, idempotent items:
86
+ process N items as one durable unit, or a leaner child path that skips the full
87
+ lock + completion ceremony when a child has no deferral points. Cuts per-item
88
+ commits dramatically, but **trades away per-item isolation and observability** —
89
+ a feature with real trade-offs, not a tweak.
90
+ - **Opt-in "transactional workflow" mode**: whole `perform` in one transaction → 1
91
+ commit. Only safe for genuinely side-effect-free workflows.
92
+ - **Scale the write tier out** (sharded Postgres) if you need raw commit headroom.
93
+
94
+ ## Implemented
95
+
96
+ Two consolidations shipped, both behaviour-preserving and covered by tests:
97
+
98
+ 1. **`complete_workflow!` → one transaction** (`workflow_states.rb`). The marker
99
+ INSERT, the workflow→`:completed` UPDATE, and the marker UPDATE now share a
100
+ single `ActiveRecord::Base.transaction`. 3 commits → 1. The failure path
101
+ re-finds/recreates the marker and records `:failed` outside the rolled-back
102
+ transaction, so completion observability is preserved and a resume simply
103
+ retries completion. Test: `WriteConsolidationTest#test_completion_writes_share_a_single_transaction`.
104
+ 2. **`started_at` folded into `acquire_lock`** (`lock_strategy.rb`). The branch-child
105
+ first-execution `started_at` stamp now rides along the existing lock UPDATE
106
+ (`update_columns`) instead of a standalone `update_column` in `setup_workflow!`.
107
+ 1 commit → 0 (absorbed). The poller's "nil started_at = dropped child" contract
108
+ is preserved — it's still stamped on first pickup, just in the same write.
109
+ Tests: `LockStrategyTest#test_acquire_lock_stamps_started_at_*`.
110
+
111
+ Net for a trivial branch child: roughly 3 fewer commits/fsyncs per child (the
112
+ note's safe ~⅓), with no behaviour change.
113
+
114
+ ### Statement flattening (a different axis: round-trips/CPU, not fsyncs)
115
+
116
+ Within the now-single transactions, two INSERT-then-UPDATE pairs were collapsed to
117
+ a single INSERT. This does not cut commits further — it cuts statements (DB
118
+ round-trips, parse/plan CPU, a little WAL), so it pays most on round-trip- or
119
+ CPU-bound profiles, less on a pure single-writer fsync-bound one.
120
+
121
+ 3. **Completion marker born completed** (`workflow_states.rb`). The marker is
122
+ INSERTed already in `:completed` state (attempts: 1, all timestamps set) instead
123
+ of INSERTed `:started` then UPDATEd. The rare resume-after-failed-completion /
124
+ create-race path still flips an existing marker via UPDATE. Completion is now
125
+ 2 statements (marker INSERT + workflow UPDATE), down from 3. Test:
126
+ `WriteConsolidationTest#test_completion_marker_is_born_completed_in_a_single_insert`.
127
+ 4. **`durably_execute` first attempt recorded in the INSERT** (`durably_execute.rb`).
128
+ A fresh step log is created with `attempts: 1, last_executed_at` baked in, so the
129
+ pre-execution attempt-bump UPDATE is skipped on first run (detected via
130
+ `previously_new_record?`). Retries (existing log) still bump via UPDATE — the
131
+ committed `:completed` write after the side effect is untouched (the once-only
132
+ boundary). Tests: `WriteConsolidationTest#test_first_step_run_records_attempt_in_the_insert`
133
+ and `#test_retry_run_bumps_attempt_with_an_update`.
134
+
135
+ The same two shapes recur across the engine, so the flattening was applied
136
+ everywhere the pattern is safe (a log written twice in one pass, no deferral
137
+ between the writes):
138
+
139
+ 5. **`fail_workflow!` born-completed + one transaction** (`workflow_states.rb`).
140
+ Identical to `complete_workflow!`: the workflow→`:failed` transition and the
141
+ (born-completed) failure marker are batched in one transaction, marker written
142
+ terminal in a single INSERT. Test:
143
+ `WriteConsolidationTest#test_failure_marker_is_born_completed_in_a_single_insert`.
144
+ 6. **`continue_if` first evaluation in the INSERT** (`continue_if.rb`). Same as
145
+ `durably_execute` — a fresh gate bakes `attempts: 1`; only re-evaluations after a
146
+ not-met halt bump via UPDATE. Test:
147
+ `WriteConsolidationTest#test_continue_if_first_run_records_attempt_in_the_insert`.
148
+ 7. **`durably_repeat` coordination + repetition logs** (`durably_repeat.rb`). Both
149
+ bake the first attempt into their INSERT; later passes/resumes bump via UPDATE.
150
+ Test: `WriteConsolidationTest#test_durably_repeat_first_repetition_records_attempt_in_the_insert`.
151
+
152
+ The born-completed sites (#3, #5) share one helper, `create_completed_execution_log!`
153
+ (`executor.rb`): a single race/replay-safe INSERT in `:completed` state, falling
154
+ back to an UPDATE only when the row already exists.
155
+
156
+ Not flattened: **`merge_branches`** and the **`durably_repeat` fast-forward summary**
157
+ create their log in one pass and complete/finalize it in a *later* pass (across a
158
+ halt), or merge metadata into a reused row — the two writes are inherently separate,
159
+ so there's nothing to collapse. **Waits** (`wait`, `wait_until`) and **branch
160
+ coordination** are the same story: they must persist `:started` before halting.
161
+
162
+ `acquire_lock`'s SELECT…FOR UPDATE + UPDATE was left as a SELECT + UPDATE: a
163
+ single conditional UPDATE would flip pessimistic→optimistic locking and complicate
164
+ `ensure_executable!` / the contention-error message for one statement — not worth it.
165
+
166
+ Full suite (236 tests) green; behaviour unchanged.
167
+
168
+ ### Deliberately NOT done: merging `context.save!` + `release_lock`
169
+
170
+ Tempting (it's the remaining adjacent pair), but the split is load-bearing. The
171
+ `ensure` block (`executor.rb:178-196`) guarantees the lock is released — and the
172
+ continuation published — *even if `context.save!` raises*, and it publishes the
173
+ continuation only after a successful release so a zero-delay same-key continuation
174
+ can't lose the acquire race. Wrapping save+release in one transaction would roll
175
+ the release back with a failed save and strand the workflow holding its lock.
176
+ A correct merge needs a fallback (standalone release on rollback) plus careful
177
+ handling of the lost-lock case, and it also changes whether context persists when
178
+ this job has lost the lock. That subtlety isn't worth one commit on a safety-
179
+ critical path — left as-is intentionally.
180
+
181
+ ## Benchmark outcome
182
+
183
+ Measured against matched baseline pairs at 20k/50k/100k (single Postgres, 5×4
184
+ workers; full data in [`fanout-scale-test.md`](../fanout-scale-test.md)):
185
+
186
+ - **Per-child execution time roughly halved** — p50 ~35 ms → ~19 ms (−46%), avg
187
+ −49%, **flat from 20k to 100k**. This is the direct fsync-per-commit saving and it
188
+ is robust and repeatable.
189
+ - **Aggregate throughput +20–30%, not 2×.** Once the child's own commits are
190
+ cheaper, the **Solid Queue claim/finish cycle and the shared single-Postgres
191
+ fsync budget** dominate per-slot time — halving one of several serial commits
192
+ can't double the whole pipeline. The win also *compresses* with N (+30% at 20k →
193
+ +22% at 100k) as that shared ceiling takes a larger share.
194
+ - **Dispatch got faster too** (100k fan-out 64 s → 53 s): lighter child commits free
195
+ fsync headroom for the parent's `spawn_each` inserts.
196
+
197
+ So the measure-first question is answered: the workload **is** commit/fsync-bound,
198
+ and the consolidation is a clear, behaviour-preserving win — but the residual
199
+ ceiling is now the queue backend and the single-DB fsync budget, not engine work.
200
+
201
+ ## Where the remaining headroom is
202
+
203
+ 1. **Split the queue onto its own database/disk.** Solid Queue's claim/finish
204
+ fsyncs currently compete with engine-commit fsyncs for one budget; separating
205
+ them lets the two streams run in parallel. Likely a bigger aggregate lever than
206
+ any further engine shaving.
207
+ 2. **Bulk / lightweight child mode** for large sets of trivial, idempotent items —
208
+ the path to order-of-magnitude gains, with its isolation/observability
209
+ trade-offs made explicit and opt-in (see above).
210
+ 3. **Scale the write tier out** (sharded Postgres) for raw commit headroom.
211
+
212
+ Do not expect a 10× from further commit consolidation — the engine is no longer the
213
+ wall. That comes from (1)/(2) or scaling the write tier.
@@ -0,0 +1,246 @@
1
+ # ChronoForge fan-out scale test
2
+
3
+ _Run 2026-06-28 on a local Mac (11 cores)._
4
+
5
+ Two things were measured here:
6
+
7
+ 1. **Correctness at scale** — does a fan-out converge, lose nothing, and stay in
8
+ constant memory up to **500,000 children**?
9
+ 2. **Throughput** — what is the steady-state ceiling, where is it, and how much
10
+ does an experimental **commit-consolidation** change move it (matched
11
+ baseline-vs-consolidation pairs at 20k / 50k / 100k)?
12
+
13
+ ## What was tested
14
+
15
+ A parent workflow that fans out **N child workflows** via `branch` + `spawn_each`
16
+ (a `Range` source → constant-memory batched `insert_all`, joined inline with
17
+ `automerge`). Each child is one trivial `durably_execute`.
18
+
19
+ ```ruby
20
+ class ScaleFanout < ActiveJob::Base
21
+ prepend ChronoForge::Executor
22
+ def perform(count:)
23
+ branch :fanout, automerge: true do
24
+ spawn_each :child, (1..count) { |i| [ScaleChild, {n: i}] }
25
+ end
26
+ end
27
+ end
28
+ ```
29
+
30
+ All runs were on **Solid Queue + Postgres 13 (Docker)** against a dev DB. Workers
31
+ ran on an **isolated `scale` queue** (`BranchMergeJob`
32
+ re-routed to the same queue so it never competed with real jobs).
33
+
34
+ | Parameter | Value |
35
+ |---|---|
36
+ | Job backend | Solid Queue, isolated `scale` queue |
37
+ | Database | Postgres 13 (Docker), `max_connections` 100, `shared_buffers` 128 MB |
38
+ | Worker concurrency | 5 processes × 4 threads (= 20 slots) |
39
+ | DB connection pool | `DB_POOL=10` per process |
40
+ | Worker polling interval | 0.1 s |
41
+ | Dispatcher | default Solid Queue dispatcher |
42
+ | Child workload | one trivial `durably_execute` (no real I/O) |
43
+
44
+ ---
45
+
46
+ ## Part 1 — Correctness (500k)
47
+
48
+ | N | Wall clock | Throughput | Completed | Outcome |
49
+ |---|-----------|------------|-----------|---------|
50
+ | 20,000 | 88.3s | 226/s | 20,000 / 20,000 | ✓ merged |
51
+ | 50,000 | 215.5s | 232/s | 50,000 / 50,000 | ✓ merged |
52
+ | 100,000 | 443.2s | 226/s | 100,000 / 100,000 | ✓ merged |
53
+ | **500,000** | **2,497s (~41.6 min)** | **200/s** | **500,000 / 500,000** | **✓ merged** |
54
+
55
+ **Flawless at every scale.** Every child completed and every parent converged to
56
+ `completed`. Streaming dispatch held constant memory; `BranchMergeJob` polled to
57
+ convergence; the parent's replay correctly **skipped the sealed branch** (no
58
+ re-dispatch). Zero failures, zero lost children, no memory wall.
59
+
60
+ Throughput held **rock-steady at ~200–230/s from 20k through 500k** — no
61
+ degradation as the tables grew. The path has a **stable steady-state ceiling**.
62
+
63
+ ---
64
+
65
+ ## Part 2 — Benchmark: baseline vs commit-consolidation
66
+
67
+ **Baseline** is (v0.10). **Consolidation** is the patch released in **v0.11** that cuts per-child write cost without
68
+ changing behaviour. It (a) folds `started_at` into the lock-acquire
69
+ transaction, (b) collapses `complete_workflow!`'s three separate commits — marker
70
+ INSERT + `workflow.completed!` UPDATE + marker UPDATE — into one transaction, and
71
+ (c) flattens the INSERT-then-UPDATE pairs in the step / completion / failure /
72
+ `continue_if` / `durably_repeat` paths into single INSERTs (the row is born in its
73
+ terminal/attempted state). It deliberately does **not** merge `context.save!` +
74
+ `release_lock` — that split is load-bearing (the lock must release even if the
75
+ save raises). See `docs/design/per-child-commit-overhead.md` for the full set.
76
+
77
+ Matched pairs, isolated worker (5 procs × 4 threads), `DB_POOL=10`, single
78
+ Postgres, ~170k-row backdrop held constant across all six runs. Throughput is
79
+ measured over the child window: `N / (max(completed_at) − min(created_at))`.
80
+
81
+ ### Throughput
82
+
83
+ | Run | N | Fan-out (dispatch) | Dispatch rate | **Exec throughput** |
84
+ |------|------:|------:|------:|------:|
85
+ | baseline 20k | 20,000 | 9.6s | 2,093/s | **226/s** |
86
+ | cons 20k | 20,000 | 7.8s | 2,561/s | **293/s** (+30%) |
87
+ | baseline 50k | 50,000 | 30.4s | 1,647/s | **232/s** |
88
+ | cons 50k | 50,000 | 17.2s | 2,905/s | **279/s** (+20%) |
89
+ | baseline 100k | 100,000 | 63.7s | 1,570/s | **226/s** |
90
+ | cons 100k | 100,000 | 53.0s | 1,886/s | **275/s** (+22%) |
91
+
92
+ ### Per-child execution time — `completed_at − started_at` (seconds)
93
+
94
+ | Run | avg | p50 | p95 | p99 | max |
95
+ |------|----:|----:|----:|----:|----:|
96
+ | baseline 20k | 0.042 | 0.035 | 0.074 | 0.137 | 0.544 |
97
+ | cons 20k | 0.020 | 0.018 | 0.030 | 0.070 | 0.560 |
98
+ | baseline 50k | 0.041 | 0.035 | 0.065 | 0.138 | 0.933 |
99
+ | cons 50k | 0.022 | 0.019 | 0.035 | 0.074 | 0.812 |
100
+ | baseline 100k | 0.042 | 0.036 | 0.072 | 0.148 | 0.911 |
101
+ | cons 100k | 0.022 | 0.019 | 0.038 | 0.079 | 0.578 |
102
+
103
+ ### Fan-out (spawn) time — child `created_at` span
104
+
105
+ How long the parent's `spawn_each` took to enqueue the whole set
106
+ (`max(created_at) − min(created_at)` over the children):
107
+
108
+ | Children | baseline | consolidation |
109
+ |---:|---:|---:|
110
+ | 20k | 9.6s (2,093/s) | 7.8s (2,561/s) |
111
+ | 50k | 30.4s (1,647/s) | 17.2s (2,905/s) |
112
+ | 100k | 63.7s (1,570/s) | 53.0s (1,886/s) |
113
+
114
+ Spawning 100k children takes ~64s on baseline, ~53s consolidated — roughly
115
+ **0.5–0.6 ms of parent time per child**. The spawn rate **degrades as N grows**
116
+ on baseline (2,093 → 1,570/s) because the parent inserts child rows into the same
117
+ Postgres that's simultaneously draining the early children. Consolidation lightens
118
+ the children's commit load, freeing fsync budget for the parent's inserts, so its
119
+ spawn rate holds up far better.
120
+
121
+ ---
122
+
123
+ ## Throughput analysis — a *flat* ceiling, set by fsync
124
+
125
+ The ceiling is **single-Postgres commit/`fsync` throughput**, not worker count:
126
+
127
+ - Throughput is **flat (~200–230/s) from 20k to 500k** — it neither degrades as
128
+ tables grow nor rises with more work in flight against 20 worker slots.
129
+ - Halving per-child execution time (consolidation, below) lifts aggregate
130
+ throughput only **~25%** — so the bottleneck is shared write infrastructure, not
131
+ per-child work or worker count.
132
+ - **Not** connection-bound (51 / 100 connections used), **not** memory-bound.
133
+
134
+ Each trivial baseline child costs ~**10 primary-DB commits + ~2 Solid Queue
135
+ commits** (the `complete_workflow!` entry below is itself three separate commits):
136
+
137
+ ```
138
+ setup started_at · acquire_lock · step INSERT · step attempt-update ·
139
+ step completed-update · complete_workflow! (×3) · context.save! · release_lock (+ SQ claim/finish)
140
+ ```
141
+
142
+ ≈ 200 children/s × ~10 commits ≈ **~2,000 fsyncs/s** — the wall.
143
+
144
+ **Commit consolidation confirms the diagnosis.** Folding `started_at` into the
145
+ lock-acquire txn, collapsing `complete_workflow!`'s three commits into one, and
146
+ baking each log's first write into its INSERT **halves per-child execution time**
147
+ (p50 ~35 ms → ~19 ms, −46%; avg −49%) and is **flat across 20k→100k**. Yet
148
+ aggregate throughput rises only **+20–30%**, not 2×: once the
149
+ child's own commits are cheaper, the **Solid Queue claim/finish cycle** and the
150
+ shared single-Postgres fsync budget dominate the per-slot time. Halving one of
151
+ several serial commits can't double the whole pipeline.
152
+
153
+ ## Queue wait is backlog math, not a latency regression
154
+
155
+ Per-child **queue wait** (`started_at − created_at`) scales ~linearly with N:
156
+
157
+ | Run | avg | p50 | p95 | max |
158
+ |------|----:|----:|----:|----:|
159
+ | baseline 20k | 40.6 | 42.9 | 75.3 | 78.7 |
160
+ | cons 20k | 30.3 | 29.3 | 57.4 | 60.4 |
161
+ | baseline 50k | 93.7 | 95.6 | 173.0 | 185.1 |
162
+ | cons 50k | 80.7 | 80.3 | 154.4 | 161.9 |
163
+ | baseline 100k | 191.6 | 194.9 | 357.8 | 379.5 |
164
+ | cons 100k | 151.7 | 147.5 | 291.1 | 310.9 |
165
+
166
+ This is **not** a per-job slowdown. The entire set is enqueued in seconds but
167
+ drains at ~225–290/s, so a child's wait is just its **position in the backlog ÷
168
+ throughput**. The last of 100k children waits ~100,000 / ~270 ≈ ~6 min by
169
+ arithmetic, regardless of how fast any individual child runs. Consolidation
170
+ shrinks the wait proportionally (100k: 192s → 152s) because it lifts throughput —
171
+ the lever for queue wait is throughput, not per-child execution time.
172
+
173
+ ## Dashboard at 500k
174
+
175
+ The dashboard's scale-aware design held up live: capped `5000+` counts (no
176
+ `COUNT(*)` over 500k), keyset pagination, blocked-first triage — instant render
177
+ throughout the run.
178
+
179
+ On a branch's detail views the counts the poller already records render **exact**
180
+ straight from the branch-log metadata — no live count: `pending` and
181
+ `never-started` (recomputed each poll) and the total `spawned` (immutable once the
182
+ branch is sealed, so it's counted **once** and cached). Only the mutable per-state
183
+ chips (idle / completed / …) stay capped. So a 500k branch shows its real
184
+ `spawned` / `pending` / `never-started` figures, not `5000+`.
185
+
186
+ ## Poller behavior
187
+
188
+ `BranchMergeJob` cadence is driven by **estimated time-to-drain** (from the prior
189
+ poll's uncapped pending count), not backlog size. For a 500k fan-out draining at
190
+ ~200/s this is flat `max_interval` (5 min) polling through the long middle, then a
191
+ smooth ramp over the final minutes, tightening to `min_interval` (~5s) for the
192
+ last few thousand children — so the parent is woken within ~5s of the last child
193
+ finishing rather than up to a full `max_interval` late. ~15 cheap polls across the
194
+ run, one branch-scoped index count each (`[parent_execution_log_id, state]`); no
195
+ new indexes. When nothing completes in an interval the fallback is motion-aware: a
196
+ child still running holds the responsive floor (so a slow or single-child branch is
197
+ never woken late), a dispatched-but-unpicked straggler backs off exponentially, and
198
+ a fully blocked/waiting branch decays to `max_interval` instead of spinning.
199
+
200
+ Rekick of dropped children is **gated on the never-started count
201
+ delta**: if that count fell since the last poll, workers are consuming the branch's
202
+ queue, so deeply-queued-but-healthy children are left alone. It deliberately does
203
+ NOT use total pending — a `wait_until` child resuming would drop pending without
204
+ any never-started child moving, masking a genuinely-dropped child behind staggered
205
+ waits. Only a branch whose never-started count has gone flat has its stale
206
+ never-started children rekicked, and a `touch` on each rekick debounces it to at
207
+ most once per `REKICK_AFTER`. Rekick counts are stamped on the branch-log metadata
208
+ for the dashboard.
209
+
210
+ ### ⚠️ Poller queue placement (a trap)
211
+
212
+ `merge_branches` enqueues `BranchMergeJob` **after** it dispatches the branch's
213
+ children, so the poller **must not run on the same queue as a large fan-out's
214
+ children**. If it does, it is enqueued behind the entire backlog and starved: it
215
+ gets a worker slot only near the end, polls **once** at `pending≈0` with no prior
216
+ sample (`rate 0`), and backs off to `max_interval`. The consequences are twofold
217
+ and both defeat the point of the ETA cadence:
218
+
219
+ - the parent's convergence **lags by up to `max_interval` (~5 min)** after the last
220
+ child finishes (all children `completed`, parent still `idle`); and
221
+ - the dashboard's **live throughput/ETA never renders** — the poller never took a
222
+ mid-drain sample, so `rate` stays 0.
223
+
224
+ Give the poller a **dedicated, un-starved queue** so it polls throughout the drain
225
+ (then ETA engages and convergence is tight) via the first-class setting:
226
+
227
+ ```ruby
228
+ ChronoForge.configure { |c| c.branch_merge_queue = :chrono_forge_pollers }
229
+ ```
230
+
231
+ (and run a worker on that queue). It defaults to `:default`, which is fine when
232
+ fan-outs run on their own queues. For these runs that queue is `:scale_poller`
233
+ (see `config/scale_queue.yml`). This bit us live-driving 20k/100k — the first pass
234
+ had the poller on `:scale` and every parent hung `idle` for 5 min.
235
+
236
+ ## Environment caveats
237
+
238
+ - Local Docker **Postgres 13**, default `shared_buffers` 128 MB, single disk,
239
+ `max_connections` 100. Absolute numbers are this-laptop-specific; tuned/larger
240
+ production infra changes them.
241
+ - Part 1's 20k–100k rows are the **same clean baseline runs** as Part 2 (matched
242
+ pairs at a fixed ~170k-row backdrop). The **500k** row is the original
243
+ single-growing-table run, so its absolute throughput isn't strictly comparable
244
+ to the others — but it lands in the same ~200/s band.
245
+ - A child here is a **trivial** `durably_execute`; real children doing actual work
246
+ shift the bottleneck away from the engine's own commits.
@@ -0,0 +1,205 @@
1
+ # BranchMergeJob: ETA poll cadence, drain-aware rekick, configurable poller queue
2
+
3
+ _Implementation record. Started from the two defects below; the final shape was
4
+ driven by three review rounds and a live 20k/100k/500k drive, so this documents
5
+ what shipped, not a pre-implementation plan._
6
+
7
+ ## Problem
8
+
9
+ `ChronoForge::BranchMergeJob` is the lightweight poller that joins a fan-out's
10
+ branches: each pass it counts a branch's incomplete children, wakes the parent
11
+ when all are sealed and complete, otherwise re-arms itself. Two defects, plus a
12
+ deployment trap surfaced by the live drive:
13
+
14
+ 1. **Rekick re-enqueued healthy children.** `rekick_dropped_jobs` re-dispatched any
15
+ child that was `idle`, `started_at: nil`, and `updated_at < REKICK_AFTER.ago` —
16
+ which also matches a healthy child merely waiting deep in a draining backlog
17
+ (queue wait exceeds `REKICK_AFTER` at N ≥ 100k). And because `perform_later`
18
+ never touched the row, a rekicked-but-unpicked child stayed stale and was
19
+ **re-rekicked every poll**, piling up duplicates.
20
+
21
+ 2. **Cadence overshoot.** The delay was `(progressing × FACTOR).clamp(min, max)`
22
+ with `progressing` a count capped at `CAP = 5000`, so it saturated to
23
+ `max_interval` while any large backlog existed — polling *slowest* exactly when
24
+ a fast-draining backlog was about to finish. A 20k fan-out drains in ~88s but
25
+ the parent wasn't woken until ~310s; 500k sealed up to 5 min late.
26
+
27
+ 3. **Poller starvation (deploy trap).** `merge_branches` enqueues the poller *after*
28
+ dispatching the branch's children, so on a queue those children saturate it is
29
+ starved behind the whole backlog — it polls once, at pending≈0, and backs off,
30
+ so the parent converges up to `max_interval` late and no throughput is recorded.
31
+ The only lever was monkey-patching `BranchMergeJob.queue_as` in the host app,
32
+ which a dev code-reload can silently reset.
33
+
34
+ ## Solution overview
35
+
36
+ - **Cadence** is driven by **estimated time-to-drain**, measured from the branch's
37
+ own completion rate — so the parent is woken within ~`min_interval` of the last
38
+ child finishing. When an interval sees no completion, the fallback is
39
+ **motion-aware** so a slow-but-healthy child isn't woken late.
40
+ - **Rekick** is gated on the **never-started count delta** — the true
41
+ "workers are pulling this branch's queue" signal — and **debounced** with
42
+ `child.touch`, so healthy deep-queued children are never rekicked and a dropped
43
+ child is redelivered at most once per `REKICK_AFTER`.
44
+ - The poller's **queue is a first-class config** (`branch_merge_queue`), since its
45
+ placement is our concern, not the user's.
46
+ - The measured **rate + ETA are persisted** each poll and surfaced **live on the
47
+ dashboard**, which now **auto-refreshes on every page**.
48
+
49
+ ---
50
+
51
+ ## Cadence — `reschedule_delay(pending, rate, motion, prev_delay, min, max)`
52
+
53
+ `lib/chrono_forge/branch_merge_job.rb`
54
+
55
+ ```ruby
56
+ def reschedule_delay(pending, rate, motion, prev_delay, min_interval, max_interval)
57
+ return (pending / rate * ETA_FRACTION).clamp(min_interval, max_interval) if rate > 0
58
+
59
+ case motion
60
+ when :running then min_interval
61
+ when :never_started then prev_delay ? (prev_delay * 2).clamp(min_interval, max_interval) : min_interval
62
+ else max_interval
63
+ end
64
+ end
65
+ ```
66
+
67
+ - **Draining (`rate > 0`):** poll at `ETA_FRACTION` (0.5) of the projected time-to-
68
+ drain. Because each poll re-estimates against the shrinking remainder, the cadence
69
+ converges geometrically and tightens to `min_interval` at the tail.
70
+ - **No completion this interval → `motion`:**
71
+ - `:running` — a live worker is executing a child; it will finish, so **hold the
72
+ floor** (`min_interval`). This is the anti-regression case: backing off would
73
+ wake the parent late for a slow / single-child branch.
74
+ - `:never_started` — the only motion is a queued/rekicked-but-unpicked child that may
75
+ never be picked up → **exponential backoff** from the floor (double `prev_delay`,
76
+ capped at `max`). Catches a quick recovery in seconds; decays instead of spinning.
77
+ - `:none` — nothing can progress (blocked/failed or parked on a wait) →
78
+ `max_interval` backstop.
79
+
80
+ Inputs, computed in `perform`:
81
+
82
+ - **`pending`** is the **uncapped** incomplete count (`BranchProbe.incomplete(id)
83
+ .count`), served by the existing `[parent_execution_log_id, state]` index — one
84
+ branch-scoped count per poll (~7 for 20k, ~15 for 500k; a background cost). The
85
+ old `CAP` flattened this to a constant `5000`, which is why the ETA couldn't reuse
86
+ it. `CAP` and `FACTOR` are removed; `ETA_FRACTION` added.
87
+ - **`rate`** = `(prev_pending − pending) / elapsed` when the branch drained since its
88
+ prior poll, else `0.0`. Measured per branch (`rate_by_branch`, for the dashboard)
89
+ and aggregated for the ETA. Aggregate `prev_pending` is only trusted when every
90
+ requested branch log is loaded *and* carries a prior sample
91
+ (`logs.size == branch_log_ids.size && prior.all?`), so a partial set can't yield a
92
+ bogus rate.
93
+ - **`motion`** is computed **lazily** — only when `rate == 0`, keeping the EXISTS
94
+ probes off the hot drain path: `:running` if any `BranchProbe.running?`, else
95
+ `:never_started` if any branch has a positive never-started count, else `:none`.
96
+ - **`prev_delay`** comes from the prior poll's persisted `interval`, driving the
97
+ exponential backoff.
98
+
99
+ ## Rekick — `rekick_dropped_jobs(branch_log_ids, never_started_by_branch, prev_never_started_by_branch)`
100
+
101
+ ```ruby
102
+ prev = prev_never_started_by_branch[id]
103
+ next [id, 0] if prev && never_started_by_branch[id] < prev # never-started count fell → workers consuming → in line
104
+ # else: scan idle & started_at IS NULL & updated_at < REKICK_AFTER.ago, limit REKICK_BATCH,
105
+ # guarded perform_later, then child.touch on success (debounce), rescue per child.
106
+ ```
107
+
108
+ - **Gate on the never-started count delta, not total pending.** A
109
+ `wait_until` child resuming drops total `pending` without any never-started child
110
+ being consumed, so a pending-delta gate would mistake that for "draining" and
111
+ defer recovery of a genuinely-dropped child behind staggered waits. The
112
+ `idle & started_at IS NULL` count falling is the real signal that workers are
113
+ pulling this branch's queue (added `BranchProbe.dispatched`, a countable relation).
114
+ - **Cold poll (no prior sample) doesn't gate** — it falls through to the per-child
115
+ staleness filter, which already spares freshly-dispatched children, so a dropped
116
+ child is still recovered on the first poll.
117
+ - **`child.touch` on a successful rekick** bumps `updated_at`, so the child leaves
118
+ the staleness window for one `REKICK_AFTER` — redelivered at most once per window,
119
+ killing the re-rekick pile-up. Only on success; a rescued enqueue failure leaves it
120
+ stale to retry next poll.
121
+ - Best-effort: a per-child rescue keeps one bad child from sinking the whole poll.
122
+
123
+ ## Persisted poll state — `record_poll!`
124
+
125
+ Each pass stamps the branch log's `metadata["poll"]` (under `with_lock` + a token
126
+ recheck, leaving `spawn_each`'s cursors untouched):
127
+
128
+ `last_polled_at`, `next_poll_at`, `interval`, `pending`, `dispatched`, `sealed`,
129
+ `rate` (children/s, `round(3)` so a very slow but real drain still reads > 0),
130
+ `eta_seconds`, `polls`, `rekicked`, `rekick_total`, `last_rekick_at`.
131
+
132
+ `rate`/`eta_seconds` are **free** — already computed for the cadence, no extra query
133
+ — which is what lets the dashboard show live throughput without the aggregate scans
134
+ the scale-aware design avoids.
135
+
136
+ ## Configurable poller queue
137
+
138
+ `lib/chrono_forge/configuration.rb` (the engine's first config object):
139
+
140
+ ```ruby
141
+ ChronoForge.configure { |c| c.branch_merge_queue = :chrono_forge_pollers } # default: :default
142
+ ```
143
+
144
+ `BranchMergeJob` reads it via `queue_as { ChronoForge.config.branch_merge_queue }`
145
+ — resolved **per-enqueue**, so a change takes effect without redefining the job and
146
+ can't be silently reset by a code reload (the fragility the live drive exposed). Keep
147
+ the poller off a queue saturated by a fan-out's own children.
148
+
149
+ ## Dashboard (`chrono_forge-dashboard`)
150
+
151
+ - **Live throughput / ETA on in-flight merges.** `BranchesPresenter::Merge` gains
152
+ `:rate` / `:eta_seconds` and `throughput? = merging? && rate.to_f > 0`; the merges
153
+ list renders `<rate>/s` and `ETA <cf_secs>`, both guarded. **Multi-branch merges
154
+ aggregate** — `merge_throughput` sums per-branch `rate` and recomputes the combined
155
+ ETA (`Σpending / Σrate`), rather than showing one branch's figure.
156
+ - **Auto-refresh on every page.** The poll region is marked once on the layout's
157
+ `<main>` (the nav + refresh/time controls sit in `<header>`, outside the swap), so
158
+ every page — workflow list *and detail*, analytics, waiting, repetitions —
159
+ refreshes in place, preserving filter text, focus, and scroll. It was previously
160
+ per-page opt-in, which had silently left the detail page (where the gauge lives)
161
+ and several others un-refreshing.
162
+
163
+ ## Files changed
164
+
165
+ **`chrono_forge`**
166
+ - `lib/chrono_forge.rb` — `config` / `configure` / `reset_configuration!`.
167
+ - `lib/chrono_forge/configuration.rb` — new; `branch_merge_queue`.
168
+ - `lib/chrono_forge/branch_merge_job.rb` — `queue_as` from config; ETA + motion
169
+ cadence; dispatched-delta rekick + `touch`; uncapped `pending`; `record_poll!`
170
+ fields; `superseded?(logs, …)`; removed `CAP`/`FACTOR`, added `ETA_FRACTION`.
171
+ - `lib/chrono_forge/branch_probe.rb` — `running?`, `dispatched`/`dispatched?`;
172
+ `incomplete` used uncapped (`progressing` retained, unused by the poller).
173
+ - `lib/chrono_forge/executor/methods/merge_branches.rb` — stale cadence comment.
174
+ - `test/branch_merge_job_test.rb`, `test/branch_probe_test.rb` — cadence (motion),
175
+ dispatched-delta rekick incl. the waits-drain-pending regression, debounce,
176
+ throughput persistence, configurable queue.
177
+ - `docs/fanout-scale-test.md`, `README.md` — cadence, rekick, queue config, the
178
+ poller-queue-placement trap.
179
+
180
+ **`chrono_forge-dashboard`**
181
+ - `branches_presenter.rb` — `Merge` rate/eta + `throughput?` + `merge_throughput`
182
+ aggregate.
183
+ - `_branches.html.erb` — throughput/ETA spans (sub-1/s shown to one decimal).
184
+ - `layouts/.../application.html.erb` — `data-poll-region` on `<main>`; removed the
185
+ per-page markers.
186
+ - `test/branches_test.rb`, `README.md` — aggregation test; docs.
187
+
188
+ ## Validation
189
+
190
+ Full engine suite **263** tests green; dashboard **106**. Live-driven on Solid Queue
191
+ + Postgres (poller on a dedicated `:scale_poller` queue):
192
+
193
+ - **20k / 100k** — parents converged; the dashboard showed live `~226/s` + ETA that
194
+ ramped down and vanished on completion.
195
+ - **500k** — **500,000 / 500,000** children completed, parent converged, **11**
196
+ poller passes (ETA cadence held throughout, not a single starved poll), and **200**
197
+ children rekicked + recovered after a mid-drain worker restart — the
198
+ rekick/debounce path exercised at scale.
199
+
200
+ ## Review findings resolved
201
+
202
+ - **#1** — rekick gate moved from total-pending delta to the never-started count
203
+ delta (dropped-child recovery no longer deferred behind resuming waits).
204
+ - **#2** — multi-branch merges aggregate rate/ETA in the presenter.
205
+ - **#3** — `rate` stored `round(3)` so a sub-1/s drain still renders throughput/ETA.