Skip to content

fix(watcher): back off re-forking a hard-failing index worker - #2075

Merged
DeusData merged 5 commits into
DeusData:mainfrom
halindrome:fix/watcher-index-failure-backoff
Sep 21, 2026
Merged

DeusData merged 5 commits into
DeusData:mainfrom
halindrome:fix/watcher-index-failure-backoff

Conversation

@halindrome

Copy link
Copy Markdown
Contributor

Closes #2015

What

poll_project() treats a hard index failure (index_fn returning rc < 0) as a transient event: it emits watcher.index.err and schedules the next poll at the plain interval. A persistent start failure — a poisoned coordination endpoint, an unreadable DB — therefore re-forks an identically-failing worker at the normal cadence for as long as the daemon lives (2,233 workers in 4h43m in the report).

This adds a per-project consecutive-failure counter with a doubling, capped backoff on that path only:

  • index_failures on project_state_t, incremented on rc < 0, reset to 0 by any successful reindex.
  • cbm_watcher_index_backoff_ms(interval_ms, consecutive_failures) — pure function, doubling per consecutive failure up to a shift cap and an absolute ceiling, so a permanently-failing project decays to occasional retries instead of every-poll retries.
  • watcher.index.err now carries rc and consecutive so the streak is visible in the log rather than inferred from line count.
  • cbm_watcher_index_failure_count() accessor so the counter is testable without reaching into the struct.

The watcher guarantee is preserved

Per the maintainer note on #2015 — a failed observation must not be silently committed. It isn't: the rc < 0 arm only logs and lengthens the next poll. It does not touch last_dirty_sig, pending_dirty_sig, or pending_head. The baseline stays uncommitted exactly as #937 intends, so the pending change is still picked up whenever the underlying failure clears; only the retry cadence decays. The busy-skip (rc > 0) path is untouched.

Field evidence

The patched build has been running locally since 2026-09-02. On 2026-09-06 an unrelated daemon-coordination fault made every index worker fail to start — the same permanent-failure class as the original report — across three watched projects.

build watcher.index.err events for a permanent failure
before (unpatched) 2,226
after (this patch) 14 total, across 3 projects; longest streak 6

The underlying fault was not fixed by this change and is not meant to be — the point is that the watcher stopped re-forking within seconds instead of accumulating thousands of doomed workers, and the streak counter in the log made the fault diagnosable at a glance.

Tests

tests/test_watcher.c adds coverage for the backoff arithmetic (monotonic, capped, saturating), the counter's increment/reset contract, and the guarantee that a failing poll leaves the baseline uncommitted.

Notes

Opened as a draft. Three QA rounds were run against this diff before submission (commits 4fc5999a, 61ccd905, 08642202); reports follow as comments.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Y9gerhYqHmbgnCxmqpVKFc

shanemccarron-maker and others added 4 commits September 2, 2026 13:02
A hard index failure (index_fn < 0) left the retry cadence untouched, so a
persistently failing project re-forked an index worker every poll for as
long as the daemon lived. Observed on 0.10.8/macOS: 2233 consecutive failed
workers over 4h43m, ~8/min, every one dying at the same worker-side
coordination seal. Read queries stayed healthy throughout, so nothing
surfaced to the user while no project could be indexed at all.

DeusData#937 deliberately leaves the baseline uncommitted on a failed reindex so the
change is retried rather than lost. That guarantee is kept; what changes is
the cadence. Each consecutive hard failure doubles the delay, capped at five
minutes, taking a permanently failing project from ~480 attempts/hour to
~12 while still recovering on its own once the cause clears. Busy-skip
(rc > 0) and success paths are untouched.

The delay arithmetic is a pure exported helper so it is unit-testable
without a clock. watcher.index.err now carries rc and the consecutive-
failure count, and a distinct watcher.index.sustained_failure names a
project whose failures are clearly not transient.

Note: itoa_buf returns a single shared per-thread buffer, so the two-value
log line uses local buffers rather than two itoa_buf calls.

Refs DeusData#2015

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FbrM52pmB1m2SR1vRRoNnf
Signed-off-by: Shane McCarron <shane.mccarron@corvexconnect.com>
F1 (major) — the failure state machine the backoff feeds had no test
coverage. Deleting the streak reset, or widening the sustained-failure
comparison, left all 7788 tests green: the index_backoff_* tests cover the
delay arithmetic, and nothing covered the counter that selects which delay
applies. A streak that never increments silently degrades the backoff to the
unbounded retry it exists to prevent.

Adds cbm_watcher_index_failure_count(), alongside the existing
cbm_watcher_watch_count() in watcher.h's "Introspection (for testing)"
section, and a test that drives a real git repo through fail/fail/succeed on
the failing_index_callback seam already used by the DeusData#937 test, asserting the
streak goes 0 -> 1 -> 2 -> 0. Verified by mutation: removing the reset turns
it red at the reset assertion while every index_backoff_* test stays green.

The delay-GATING half stays uncovered on purpose. Every integration test
calls cbm_watcher_touch first, which zeroes next_poll_ns, so whether the
scheduler honours the computed deadline is unobservable without an
injectable clock the watcher does not have. That harness is a larger change
than this fix.

F3 (minor) — cbm_watcher_index_backoff_ms was non-monotonic for an interval
above the ceiling: zero failures returned the interval, one failure returned
the smaller ceiling. Unreachable today (POLL_MAX_MS < the ceiling), but the
function is exported, so the clamp is now a floor as well as a cap and
backing off can never schedule sooner than the project's own cadence.

F2 (minor) needed no code change: a busy-skip following an unresolved
failure streak keeps the backed-off delay, which is deliberate — only a
success clears the streak, and treating rc > 0 as recovery would let a
project alternating fail/busy never back off at all. The PR description's
over-strong "busy-skip is behaviourally unchanged" claim is corrected there.

Refs DeusData#2015

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FbrM52pmB1m2SR1vRRoNnf
Signed-off-by: Shane McCarron <shane.mccarron@corvexconnect.com>
Retracts an incorrect justification from the round-1 commit (4fc5999) and
closes the coverage it was used to excuse.

MAJOR — the delay wire-up had no coverage, and the reason given for that was
wrong. Round 1 claimed the gating half was "structurally unobservable without
an injectable clock". It is not. cbm_watcher_touch zeroes next_poll_ns, so a
wire-up that never sets a deadline leaves it at zero and the callback fires on
every poll: simply NOT calling touch between polls observes gating
deterministically, no clock involved. Only the delay's MAGNITUDE needs one.

watcher_index_failure_backoff_gates_repolling_issue2015 covers it — one hard
failure, then five polls without touch asserting no re-fork, then a touch
proving the retry still gets through (so the test cannot pass by observing a
dead watcher). Mutation-verified: deleting the wire-up yields
"failing_index_calls == 6, expected 1".

MINOR — round 1's F1 was half-fixed. Of the two single-token mutations that
finding named, only the streak reset was covered; widening the
sustained-failure "==" to ">=" still passed. watcher_sustained_failure_logs_
once_issue2015 drives 14 consecutive failures through a cbm_log_set_sink and
asserts exactly one emission. Mutation-verified: ">=" yields
"sustained_log_hits == 5, expected 1".

All three mutations F1 named are now demonstrated caught rather than argued
about: reset removal (round 1), wire-up deletion, and the "==" widening.

MINOR — cbm_watcher_index_failure_count reads index_failures under
projects_lock while poll_project writes it lock-free from a state snapshot.
That is the discipline every other per-project field here already follows, so
this documents the visibility contract rather than making one field atomic in
isolation: the accessor is a diagnostic and test seam, not a synchronisation
point, and must not carry scheduling decisions without atomic accessors first.

MINOR — watcher.h still described the pre-F3 backoff contract. It now states
the clamp is a floor as well as a cap, why the floor exists for an exported
function whose reachable inputs cannot currently hit it, and that negative
inputs are treated as zero.

Still uncovered, now stated precisely rather than hand-waved: the delay's
MAGNITUDE. Nothing asserts that ten failures produce a five-minute delay
rather than a five-second one. Gating is proven; the numbers are not, and
that does need a controllable clock.

Refs DeusData#2015

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FbrM52pmB1m2SR1vRRoNnf
Signed-off-by: Shane McCarron <shane.mccarron@corvexconnect.com>
Documentation and claim accuracy only — no behavioural change. Two of the
three findings are corrections to inaccurate statements the round-2 commit
(61ccd90) introduced.

The gating test's framing was overstated, and this time the overstatement was
measured rather than argued. Replacing the backoff call with the pre-change
`ctx->now + interval_ms * US_PER_MS` leaves the watcher suite green at 82/82:
the index_backoff_* tests pass because they exercise the pure function
directly, which the reverted scheduler no longer calls, and the streak tests
pass because the counter is untouched. So no test in this suite fails when
this change's behaviour is removed. The test now records that, with the
figure, instead of claiming it demonstrates the backoff. It is a regression
guard on a deadline being assigned at all — real, but narrower than round 2
said.

Closing that gap needs either a controllable clock or a further accessor
exposing next_poll_ns. Both were judged out of scope: a third exported symbol
purely for testing, on a change already carrying two, is the API widening
earlier rounds flagged. The gap is recorded in the test, the PR description
and the round-3 note rather than papered over.

The memory-visibility comment claimed index_failures follows "the same
discipline every other per-project field in this struct already follows".
That is false: active_git is serialized by projects_lock and registered is an
atomic_bool. Narrowed to the poll-mutated fields, which are now enumerated.
The round-2 decision to document rather than lock still stands — the panel
verified the underlying premise — but it was resting on an overbroad claim.

watcher.h said the delay "doubles up to a fixed ceiling", which skips the
shift cap: the delay plateaus at interval_ms << INDEX_FAIL_SHIFT_MAX, and
that reaches the ceiling only for interval_ms >= 4688 ms. Every interval this
watcher generates is >= POLL_BASE_MS so the ceiling is always reached in
practice, but the function is exported and a caller may pass less.

Refs DeusData#2015

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FbrM52pmB1m2SR1vRRoNnf
Signed-off-by: Shane McCarron <shane.mccarron@corvexconnect.com>
@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown

Thanks for opening this — it has been seen, and it is queued.

This note is automated, but it is not a brush-off: it exists so you know where your PR stands instead of having to guess from silence.

Current review status: working through a backlog. 0.9.1-rc.1 is out, so the release freeze that held reviews is over — but it left a large queue of open pull requests behind it, and we are reading through them oldest-first. The background is in discussion #1144.

What that means for this PR, concretely:

  • It will not be closed for inactivity. No stale bot touches pull requests here.
  • It may still sit a while before a human reads it. That is on us, not on you.
  • Older PRs are read first, so a recent one is not being skipped — it is behind a queue.

Things that will genuinely speed it up whenever review does happen:

  • Keep it rebased on main — the tree is moving quickly right now, and a conflicting branch cannot be reviewed as the diff you intended.
  • Get CI green, or say which failures you believe are pre-existing.
  • Keep the change to one claim. Bundled features and refactors get split before they get merged, which costs you a round trip.
  • Every commit needs a sign-off (git commit -s) — CI enforces DCO.

If this fixes a bug, a reproduction we can run is worth more than a description of the symptom.

Thanks for contributing, and sorry in advance for the wait.

@DeusData

DeusData commented Sep 7, 2026

Copy link
Copy Markdown
Owner

Thank you for this one — the per-project failure counter with a capped doubling backoff on the rc < 0 path only, leaving the dirty-signature baseline alone so #937's at-least-once guarantee survives, is exactly the shape we wanted here, and the 2,233-worker log from #2015 made the case better than any description could.

We are assembling a patch release (v0.10.9) around the install and memory reports, and this belongs in it. Whenever you consider the draft ready, please mark it ready for review and we will take it through the normal review right away. If there is a piece you are still unsure about, say so in the PR and we can work through it together rather than wait.

@halindrome

Copy link
Copy Markdown
Contributor Author

Marked ready for review — thanks for the confirmation on the shape, and glad it lands in v0.10.9.

Three QA rounds have been run against the diff and are already pushed (4fc5999a, 61ccd905, 08642202); the branch is current with main and mergeable.

On the one red check: test / test-windows-guards is failing, and I believe it is pre-existing rather than something this PR introduced. The failure is in tests/windows/test_daemon_stability.py:

RED: cold-storm client 0 failed (racing daemon spawn)

This diff touches only src/watcher/watcher.c, src/watcher/watcher.h, and tests/test_watcher.c — no daemon startup, no client path, nothing Windows-specific. The symptom matches #2057 ("test-windows-guards: test_daemon_stability turns setup failures and timeouts into REGRESSION verdicts on unrelated PRs") exactly. Everything else in the matrix is green, including all three pr-smoke platforms, tsan/msan/lsan, and the full test-diag shard.

Happy to re-run that job if you would like a second data point, or to work through anything else you want changed during review.

@DeusData DeusData added bug Something isn't working stability/performance Server crashes, OOM, hangs, high CPU/memory priority/high Needs near-term maintainer attention; high-impact bug, regression, safety issue, or release blocker. labels Sep 9, 2026
@halindrome

Copy link
Copy Markdown
Contributor Author

Could I ask for a re-run of test / test-windows-guards when you get a moment? I don't have rerun rights on the repo (Must have admin rights to Repository), and it's the only thing keeping ci-ok red.

For context on the run itself: it is from 2026-09-06, but it is against the current head SHA 08642202 — all three QA-round commits were already pushed when the PR was opened, so there is nothing stale about what it tested, only about when. Everything else in the matrix is green.

The failure remains tests/windows/test_daemon_stability.pyRED: cold-storm client 0 failed (racing daemon spawn), matching #2057. This diff touches only src/watcher/watcher.c, src/watcher/watcher.h, and tests/test_watcher.c.

Some field data that arrived since

The backoff wedged-daemon scenario recurred on my machine on 2026-09-11 — every index worker refusing to start for 427 minutes — which gave an unplanned before/after for this patch in a single cbm-daemon.log. The watcher.index.err line only carries rc / consecutive on the patched build, so the two populations are separable:

build watcher.index.err lines projects affected max streak
unpatched 2226 1 unthrottled
patched (this PR) ~560 8 81, sitting at the 5-min INDEX_FAIL_CEILING_MS

watcher.index.sustained_failure fired at consecutive=10 for six projects, which is exactly the intended signal — it made the fault obvious at a glance instead of requiring a line count.

Worth being straight about the limit, though: the backoff bounds the retry rate, not the total. Over a 427-minute wedge the 5-minute ceiling still permits ~85 retries per project, so dead workers accumulate linearly with daemon uptime until the underlying fault is cleared by hand. That is not a gap in this PR — it is the separate root cause, which I've now written up as #2178 with the mechanism traced through version_cohort_active_daemon_presence(). Happy to take that one on separately if the analysis looks right to you.

@DeusData

Copy link
Copy Markdown
Owner

You asked for a rerun on 11 September and got silence for nine days. I am sorry — that is on us, and the field data you posted in the meantime deserved a faster answer than that.

Review: I have no changes to ask for. I read the production diff line by line:

  • cbm_watcher_index_backoff_ms is a pure function with an int64_t intermediate, a shift cap and an absolute ceiling, and the final clamp makes it monotonic for every input rather than only for the ones today's constants can produce — the comment explaining why an unreachable branch is there is exactly the kind of comment worth having.
  • The counter lives on project_state_t, is read under projects_lock, saturates instead of overflowing, and is reset by any successful reindex. Busy-skip and success keep the adaptive cadence; only rc < 0 backs off.
  • It leaves the dirty-signature baseline uncommitted, so Watcher can repeatedly re-index dirty repos and cause large disk write amplification #937's at-least-once guarantee survives: "never lost" no longer also means "retried forever".
  • watcher.index.sustained_failure fires once at the threshold, and rc / consecutive on the warning line are what made your before/after separable in a single log. 2,226 unthrottled lines for one project against ~560 across eight, sitting at the five-minute ceiling, is about as good as field evidence gets.
  • The tests: six on the pure function (doubling, ceiling, monotonicity, degenerate inputs, never-sooner-than-the-interval) and state tests that drive poll_once with a control proving the watcher is merely gated, not dead. No sleeps, no wall-clock assertions — which matters more than usual here, because the watcher suite is where our most expensive flaky-test family lives.

About the red. You read it correctly: test_daemon_stability.pysection_cold_storm is a known nondeterministic guard on our side (the #2057 class), it has reddened docs-only PRs, and nothing in src/watcher/ can reach it. I am not going to rerun that one job, for a reason worth stating: a rerun re-tests the merge commit GitHub computed on 6 September, and main has moved 398 commits since. A green there would say nothing about today's tree. What I will do instead:

  1. main currently has two reds of its own (a linter ratchet and a worker-policy script, both fixed by fix: restore main to green after #1723 — memory-core linter and worker-scope Step 5f #2257, which is in its last CI legs right now). Until that lands, every PR's CI is red for reasons that are not the PR's.
  2. Once it is in, I bring your branch up to date with main — your textual merge is clean — which gives this PR a fresh, full CI run against the current tree.
  3. Before that I build and run the watcher suite on the merge result locally; this week taught us twice that a textually clean merge can still be a wrong one.
  4. Then it merges. This is a contained bug fix with its evidence attached, and it does not need a further decision.

If section_cold_storm reddens the fresh run as well, that is ours to deal with and will not be held against this PR.

Thank you for the fix, for three QA rounds you ran on your own, and for coming back with production numbers rather than a reminder.

@DeusData
DeusData merged commit a7187ad into DeusData:main Sep 21, 2026
39 checks passed
@DeusData

Copy link
Copy Markdown
Owner

Merged as a7187ad — thank you.

Verified on the merge result before landing: watcher suite 83/83 three runs in a row with the three issue2015 tests green each time; with the production change reverted the test binary no longer links, so the tests bind; memory-core linter clean. It was then verified a second time as part of the combined result of the five PRs that landed together tonight (ten suites, no failures). CI on your updated head came back fully green, including the Windows guards job that had been red for reasons unrelated to you.

It ships in the next patch release. The 2,226-versus-560 log comparison you posted is going into the release notes as the reason this matters.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working priority/high Needs near-term maintainer attention; high-impact bug, regression, safety issue, or release blocker. stability/performance Server crashes, OOM, hangs, high CPU/memory

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Watcher re-forks a permanently-failing index worker forever — no backoff on rc < 0 (2233 workers in 4h43m); gap in #937

3 participants