Files
vdm/docs/adr/0017-segment-budget-admission-and-wait-list-wakeup.md
T
samiandClaude Sonnet 5 322a20efa5 core: fix SegmentBudget over-admission and add a wait-list wakeup
Root cause of the M7 RSS gap (core/docs/m7-baseline.md: ~70 MB measured
against a 60 MB target): SegmentBudget::confirm_slot() only checked a task's
own held count against its own target -- never the engine-wide active_ sum.
reallocate_locked()'s two-pass fairness allocation does bound
sum(target) <= max_active_ at the moment it computes a plan, but that bound
says nothing about sum(held): a task can be legitimately holding more than
its own just-lowered target for a while (yield is deferred to a segment
boundary, never mid-segment -- ADR 0011 A1), and another task's target can
correctly rise to claim that capacity before the first task has physically
released it. Both confirm_slot() calls could then succeed against their own,
individually-correct targets while sum(held) exceeded max_active_ --
tools/bench heap-profile caught this directly: budget.active reading 56-86
against a total of 32.

confirm_slot() now also checks active_ < max_active_, unconditionally, as a
backstop that doesn't depend on any task's target bookkeeping being in sync
with what every other task holds. That creates a liveness question the
original design never answered: a task denied only by this new check has a
target that's already correct, so it never changes again and
reallocate_locked()'s plain "fire a callback when a task's target changes"
mechanism never revisits it. Task gained a waiting_for_slot flag, set on
exactly this denial; release_slot()/deregister_task() (the only two places
that free real capacity) now hand a freed slot directly to the
highest-priority waiting task via wake_one_waiter_locked(), if
reallocate_locked()'s own plan didn't already produce a callback for anyone.

download_task.cpp's fill_slots_locked() needed a matching fix: a woken
task's stalled segments (SegState::stalled -- backed off mid-retry, its own
release_slot() already called) have no live worker and never surface
through Segmenter::assign_slot(), which only hands out unassigned or fresh
ranges. fill_slots_locked() now restarts any stalled segment with no live
worker directly (bounded by slot_target, same as its assign_slot() loop)
before looking for new work; a segment it doesn't get to keeps its own
scheduled retry_worker() timer as a second chance.

Also fixes a real TSan-caught data race this work surfaced: SegWorker::
speed_bps was written only by its own segment's curl callback and, before
Progress.speed_bps's polled-path fix, only ever read from that same thread
-- safe without synchronization. snapshot_progress() reading it from
whatever thread calls DownloadHandle::progress() broke that invariant
(workers_mu's shared_lock protects the workers map's structure, not an
individual SegWorker's fields). Now std::atomic<double> with relaxed
ordering -- an informational EMA, nothing synchronizes real state on it --
rather than adding a lock to the write side.

core/tests/segment/budget_test.cpp adds two tests reproducing the actual
gap (budget_active_never_exceeds_max_active_segments_under_concurrent_load,
budget_wait_list_wakes_a_task_whose_target_never_changed) plus a sanity
baseline (budget_release_wakes_a_denied_waiter), and introduces AsyncFakeTask
+ TestTimer for the one existing test that drives the budget from multiple
concurrent threads -- mirroring production's real dispatch (register_task()'s
on_target lambda posts through host.schedule(), download_task.cpp, never a
synchronous call) rather than adding reentrancy-guarding machinery to
SegmentBudget itself to compensate for a synchronous test double being
unlike production. See docs/adr/0017 for the full writeup, including what an
earlier version of this fix got wrong chasing a same-thread reentrancy
hazard that doesn't actually exist in production.

core/docs/m7-baseline.md updated: the RSS number now clears the DoD line
(45.41 MiB via heap-profile), root-caused rather than just re-measured.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Q3QrF7rCt21bkAjt9BCDFQ
2026-09-12 10:58:14 +04:00

6.9 KiB
Raw Blame History

17. SegmentBudget over-admission, and a wait-list to wake denied tasks

Status: accepted

Context

core/docs/m7-baseline.md's first pass measured ~70 MB RSS for the M1/M7 DoD's "20 active downloads, max_active_segments=32" scenario, against a 60 MB target docs/adr/0012 estimated would hold with ~4550 MB of margin. tools/bench heap-profile (added to chase this down — no massif/heaptrack in this environment) found the actual cause directly: Engine::segment_budget().budget().active read 5686 against a total of 32. The engine was not over budget by a measurement artifact or an ADR 0012 arithmetic gap; it was genuinely running more concurrent segments — and their 1 MiB ring buffers — than max_active_segments allows.

SegmentBudget::confirm_slot(id) checked only t.held < t.target — a per-task check. reallocate_locked()'s two-pass fairness allocation does correctly bound sum(target) <= max_active_ at the moment it computes a plan, but that bound says nothing about sum(held): a task can be legitimately holding more than its own just-lowered target for a while, because yield is deliberately deferred to a segment boundary, never mid-segment (ADR 0011 A1). When that happens, another task's target can correctly rise to claim the capacity the first task is about to give back, and both confirm_slot() calls can succeed against their own, individually-correct targets while the sum of what's physically held exceeds max_active_. Reproduced deterministically in core/tests/segment/budget_test.cpp (budget_active_never_exceeds_max_active_segments_ under_concurrent_load, budget_wait_list_wakes_a_task_whose_target_never_changed) independent of any real network I/O.

Decision

confirm_slot() also checks active_ < max_active_, unconditionally, as a backstop that doesn't depend on any task's target bookkeeping being perfectly in sync with what every other task currently holds. This is the fix for the invariant itself.

That backstop creates a liveness question the original design never had to answer: a task denied only by this new check has a target that's already correct — it doesn't change again on its own, so reallocate_locked()'s plain "fire a callback when a task's target changes" mechanism never revisits it. Nothing was watching for "this specific task is owed a slot"; Task gained a waiting_for_slot flag, set by confirm_slot() on exactly this denial and cleared on the task's next successful confirm however that retry was triggered. release_slot() and deregister_task() — the only two places that can free real capacity — now call wake_one_waiter_locked(), which hands the newly-freed slot to the highest-priority waiting_for_slot task (other than the one that just released) via a direct retry hint, if reallocate_locked()'s own plan didn't already produce one.

On the download_task.cpp side, a woken task's apply_slot_target()fill_slots_locked() also needed a fix: a segment that had backed off mid-retry (SegState::stalled, its release_slot() already called, a retry_worker() timer already scheduled and possibly already denied once) has no live worker and never surfaces through Segmenter::assign_slot() — it stays assigned, just not running, and assign_slot() only ever hands out unassigned or fresh ranges. fill_slots_locked() now restarts any stalled segment with no live worker directly (bounded by slot_target, same as its assign_slot() loop) before looking for new work; a segment it doesn't get to keeps its own scheduled retry_worker() timer as a second chance, so this is additive, not a replacement for that path.

What this isn't

Production's real on_target callback (register_task()'s lambda, download_task.cpp) never runs synchronously with whatever budget call triggered it — it posts through host.schedule() (engine.cpp's single timer thread), so a task's own mu can never be re-entered on the same call stack, and two tasks' callbacks can never race each other into an AB-BA lock order either (only one ever runs at a time, on one thread). An earlier version of this fix tried to defend against a same-thread reentrancy hazard that, diagnosed correctly, doesn't exist in production at all — it exists only if a test double calls back into the budget synchronously from inside on_target, which none of production does. core/tests/segment/budget_test.cpp's FakeTask does exactly that (by design — it's simple and every other test in the file drives the budget from one thread at a time, where that's harmless); the one test that drives it from multiple concurrent threads (budget_concurrent_confirm_release_stays_consistent) uses a separate AsyncFakeTask that posts through a small TestTimer, mirroring host.schedule() / the engine's timer thread for real, rather than adding synchronization machinery to SegmentBudget itself to paper over a test double being unlike production. wake_one_waiter_locked()'s exclude parameter is the one piece of that defense that's independently justified either way — a task doesn't need to be told to retry a slot it just gave back itself — and is the only piece that stayed.

Consequences

  • The M7 RSS number now clears the DoD line: heap-profile reports 45.41 MiB for the 20-task/8-segment/32-cap scenario (core/docs/m7-baseline.md), and budget.active never exceeds budget.total regardless of contention.
  • docs/adr/0016's TSan-only load-test straggler (a different subsystem — rate::RateLimiter's byte-pacing, not SegmentBudget's segment-admission) is now suspected to have been this same root cause rather than the CURLOPT_LOW_SPEED_TIME guess offered there, since the symptom (a task that simply never resumes) matches exactly. Not reverified at the DoD's full shape under --preset tsan in this change — tools/bench's sanitizer-preset load registration still runs at reduced concurrency. Worth a follow-up run before closing that ADR's postscript.
  • docs/adr/0016's actual subject — rate::RateLimiter::TokenBucket's own peek/commit race, unrelated to SegmentBudget — is untouched by this change and remains open.
  • wake_one_waiter_locked() wakes exactly one task per actual release, by priority order. Under sustained heavy oversubscription (this bench's 20 tasks × 8 segments = 160 wanted against 32 available) a low-priority task can still wait a long time for its fair share — that's the two-pass allocator's own fairness policy working as designed, not a liveness bug: it will get there, just not fast, and every fresh reallocate_locked() call (any task's set_want, registration, or departure) reconsiders everyone from scratch. No test in this change measures how long; if that ever needs a stronger guarantee (bounded wait time, not just eventual service), it's a fairness-policy change, not a wiring fix.

Co-Authored-By: Claude Sonnet 5 [email protected]