Skip to content

fix(branch): converge merges promptly via drain-ETA cadence and never-started-count rekick#12

Merged
thedumbtechguy merged 30 commits into
mainfrom
fix/branch-poller-rekick-eta
Jul 1, 2026
Merged

fix(branch): converge merges promptly via drain-ETA cadence and never-started-count rekick#12
thedumbtechguy merged 30 commits into
mainfrom
fix/branch-poller-rekick-eta

Conversation

@thedumbtechguy

@thedumbtechguy thedumbtechguy commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

What & why

BranchMergeJob (the poller that drives a fan-out's merge to completion) had two defects, surfaced by the fan-out scale test:

  1. Cadence overshoot — the old count-based cadence polled slowest (max_interval, ~5 min) exactly when a fast-draining backlog was about to finish, so a parent could hang idle for up to 5 min after its last child completed.
  2. Premature / repeated rekick — healthy but deeply-queued children were re-enqueued as if dropped, because the rekick signal keyed off total pending, which a wait_until child resuming would drop without any never-started child actually moving.

The poller holds no lock and never replays the (heavy) parent; each pass is fenced by a per-pass poll_token so a superseded chain stops quietly.

The two fixes

1. ETA-driven cadence (reschedule_delay). Poll at (pending / rate) * ETA_FRACTION, clamped [min_interval, max_interval]. Because each pass re-estimates against the shrinking remainder, cadence converges geometrically: a 500k fan-out polls flat max_interval through the long middle, then tightens to min_interval for the last few thousand — the parent is woken within ~min_interval of the last child, not up to a max_interval late. When nothing completes in an interval the fallback is motion-aware: :running holds the responsive floor (a slow/low-fan-out child is never woken late), :never_started backs off exponentially (a queued-but-unpicked straggler), :none decays to max_interval. Motion probes are computed lazily, only off the drain path.

2. Never-started-count rekick gate. Rekick is gated on the never-started count (idle & started_at nil) delta, not total pending: if that count fell since the last poll, workers are consuming the queue, so deep-queued-but-healthy children are left alone. Only a branch whose never-started count has gone flat has its stale children rekicked, and a touch per rekick debounces it to at most once per REKICK_AFTER. Blocked (failed/stalled) and waiting (idle with started_at set) children are never touched.

Supporting work

  • Poller queue configChronoForge.config.branch_merge_queue (default :default). merge_branches enqueues the poller after dispatching children, so it must not share a large fan-out's queue or it starves behind the backlog (polls once at pending≈0, no ETA, dashboard gauge never renders). Documented as a trap in the scale-test doc.
  • Persisted poll state — ActiveJob can't be queried for a scheduled poller, so each pass stamps its observable state onto the branch-log metadata (last/next_poll_at, interval, pending, never_started, spawned, rate, eta_seconds, polls, rekick counts) under a row lock + token recheck. Purely observational; replay/correctness never read it.
  • Dashboard — live throughput/ETA on in-flight merges (aggregated across a multi-branch merge: summed rate, combined ETA; sub-1/s rates kept visible via round(3)); a branch-detail stats header; universal auto-refresh (the whole <main> is a data-poll-region, so every page — including detail — refreshes in place, preserving filter text/focus/scroll).
  • Exact counts, no new queriespending and never_started render exact straight from the poll metadata the poller already computes; the total spawned is immutable once a branch seals, so it's counted once and cached on the metadata (sticky), then shown exact with zero per-render cost. All fall back to the capped live count until the branch is sealed+polled. The mutable per-state chips stay capped (5000+).
  • Naming — the poller-side never-started count was renamed dispatched → never_started to kill a collision with the dashboard's total column, which was in turn renamed "Dispatched" → "Spawned" (it maps to spawn_eachspawned_workflows).

Validation

  • Engine 264 tests, dashboard 108 tests — all green.
  • Live-driven at 20k / 100k / 500k on Solid Queue + Postgres; 500k merged flawlessly (every child completed, parent converged, constant memory). Screenshots refreshed from a live 100k drive.

🤖 Generated with Claude Code

A wait/wait_until child completing drops total pending without any never-started
child being consumed, which the old pending-delta gate mistook for 'draining' and
used to suppress rekick of a genuinely-dropped child behind staggered waits
(deferring recovery). Gate on the dispatched (idle, started_at nil) count delta
instead — the true 'workers are pulling this branch's queue' signal. Persists a
'dispatched' count in poll metadata; reused for the cadence :dispatched motion.
The poller (BranchMergeJob) is our code, and its queue placement is load-bearing:
on a queue saturated by a fan-out's own children it starves, so convergence lags
and no throughput is recorded. Previously the only lever was monkey-patching
BranchMergeJob.queue_as in the host app — fragile (a dev code-reload can reset it,
which bit us live-driving). Add ChronoForge.configure { |c| c.branch_merge_queue },
read per-enqueue via a queue_as block. Defaults to :default.
… region

Auto-refresh was per-page opt-in, so pages that never wrapped their content
(analytics, repetitions, wait_states, and — until just now — the workflow detail)
silently didn't refresh. There's no reason for the split: the poller preserves
filter text, focus, and scroll across the swap, and the nav/refresh controls live
in <header> outside it. Hoist the region onto <main> so every page refreshes
structurally, and drop the now-redundant per-page markers.
…e throughput/universal auto-refresh; note as-shipped divergences in the plan
@thedumbtechguy thedumbtechguy force-pushed the fix/branch-poller-rekick-eta branch from 5532173 to 7079dfc Compare July 1, 2026 10:24
…tarted'

Kills the name collision: BranchProbe.dispatched / poll metadata "dispatched"
meant never-started (idle & started_at nil), while the dashboard's "Dispatched"
column means the TOTAL children spawned. Renames the poller-side concept
(BranchProbe#never_started(?), the :never_started cadence motion, the
never_started_by_branch vars, and the poll key) to never_started; the dashboard's
total "Dispatched" column is left as-is. Old metadata falls back to the capped
count until re-polled.
It is the total children spawned (spawn_each -> spawned_workflows); "Spawned"
names the source directly and removes the last "dispatched" ambiguity now that
the poller-side never-started count no longer shares the word.
A sealed branch dispatch total is immutable, so the poller counts it exactly ONCE
(first poll after seal) and caches it on the branch-log metadata as \"spawned\";
later polls reuse it. The dashboard then shows the real Spawned total (panel column
+ branch-detail stat) with zero per-render cost — no cap, no COUNT(*) over 500k on
every refresh. Falls back to the capped live count until the branch seals.
@thedumbtechguy thedumbtechguy merged commit 8c6251f into main Jul 1, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant