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
515 lines
21 KiB
C++
515 lines
21 KiB
C++
#include "vdm/segment/budget.hpp"
|
|
|
|
#include <atomic>
|
|
#include <chrono>
|
|
#include <condition_variable>
|
|
#include <functional>
|
|
#include <mutex>
|
|
#include <optional>
|
|
#include <thread>
|
|
#include <vector>
|
|
|
|
#include "vtest.hpp"
|
|
|
|
using namespace vdm;
|
|
using namespace vdm::segment;
|
|
using EB = SegmentBudget::EngineBudget;
|
|
|
|
namespace {
|
|
|
|
TaskId tid(std::uint64_t v) {
|
|
return TaskId{v};
|
|
}
|
|
|
|
// A test task that reacts to slot targets the way stage 8's download_task will: start
|
|
// workers up to the target, release them when the target drops. Purely bookkeeping.
|
|
//
|
|
// Production's real equivalent (register_task()'s on_target lambda, download_task.cpp)
|
|
// never calls back in synchronously -- it posts through host.schedule() (engine.cpp), so
|
|
// apply_slot_target() never runs on the same call stack as whatever budget call triggered
|
|
// it. wake_one_waiter_locked()'s wakeups, though, mean on_target() here *can* now be
|
|
// reentered on the same thread (e.g. this task's own release_slot() call, deep in the grow
|
|
// loop below, can cascade into waking a *different* task whose own release, in turn,
|
|
// cascades back to this one) -- a plain std::mutex would self-deadlock on that, the way
|
|
// FakeTask::mu almost did until this was written. A recursive_mutex plus coalescing the
|
|
// reentrant call's target into `pending` (processed by the outer call's loop once its
|
|
// current pass finishes) keeps this a faithful, deterministic stand-in without actually
|
|
// needing real threads: no callback ever runs nested inside a still-running instance of
|
|
// itself, and a burst of reentrant retargeting converges to the latest value instead of
|
|
// each one doing its own redundant grow/shrink pass.
|
|
struct FakeTask {
|
|
SegmentBudget *budget = nullptr;
|
|
TaskId id{};
|
|
std::recursive_mutex mu;
|
|
std::uint32_t workers = 0;
|
|
std::uint32_t target = 0;
|
|
bool running = false;
|
|
std::optional<std::uint32_t> pending;
|
|
|
|
FakeTask() = default;
|
|
FakeTask(SegmentBudget *b, TaskId i) : budget(b), id(i) {}
|
|
|
|
void on_target(std::uint32_t t) {
|
|
std::lock_guard lk(mu);
|
|
if (running) {
|
|
pending = t; // reentrant call on this thread -- the running instance picks it up
|
|
return;
|
|
}
|
|
running = true;
|
|
std::uint32_t want = t;
|
|
for (;;) {
|
|
target = want;
|
|
while (workers < target) {
|
|
if (!budget->confirm_slot(id))
|
|
break;
|
|
++workers;
|
|
}
|
|
// over target -> yield the excess immediately (a real task waits for a
|
|
// boundary)
|
|
while (workers > target) {
|
|
budget->release_slot(id);
|
|
--workers;
|
|
}
|
|
if (!pending)
|
|
break;
|
|
want = *pending;
|
|
pending.reset();
|
|
}
|
|
running = false;
|
|
}
|
|
std::uint32_t held() {
|
|
std::lock_guard lk(mu);
|
|
return workers;
|
|
}
|
|
};
|
|
|
|
// Mirrors Engine's real timer thread (engine.cpp: timer_loop) and how register_task()'s
|
|
// on_target lambda actually reaches a task (download_task.cpp: host.schedule(), never a
|
|
// direct call). One dedicated thread drains queued closures one at a time -- the
|
|
// serialization that makes production immune to the cross-task deadlock risk FakeTask's
|
|
// synchronous, whatever-thread-triggered-it delivery has under heavy *concurrent* driving
|
|
// (budget_concurrent_confirm_release_stays_consistent, below, is the one test that
|
|
// actually exercises this: multiple threads calling set_want() for *different* tasks at
|
|
// once, each able to cascade into a wake for another task's callback -- two such cascades
|
|
// landing on two different FakeTask mutexes in opposite orders on two different threads is
|
|
// a real AB-BA deadlock a recursive_mutex alone doesn't prevent, since that only guards a
|
|
// single thread against re-entering itself). Every other test in this file drives the
|
|
// budget from one thread at a time, where that risk can't arise, so plain FakeTask is
|
|
// still the simpler, sufficient double there.
|
|
class TestTimer {
|
|
public:
|
|
TestTimer() {
|
|
worker_ = std::jthread([this](std::stop_token st) { run(st); });
|
|
}
|
|
~TestTimer() {
|
|
worker_.request_stop();
|
|
cv_.notify_all();
|
|
}
|
|
void post(std::function<void()> fn) {
|
|
{
|
|
std::lock_guard lk(mu_);
|
|
queue_.push_back(std::move(fn));
|
|
}
|
|
cv_.notify_one();
|
|
}
|
|
// Blocks until the queue is empty and nothing is mid-run -- what a test needs before
|
|
// asserting on state this timer's closures mutate.
|
|
void drain() {
|
|
std::unique_lock lk(mu_);
|
|
cv_done_.wait(lk, [&] { return queue_.empty() && !running_; });
|
|
}
|
|
|
|
private:
|
|
void run(std::stop_token st) {
|
|
std::unique_lock lk(mu_);
|
|
while (true) {
|
|
cv_.wait(lk, st, [&] { return !queue_.empty(); });
|
|
if (st.stop_requested())
|
|
return;
|
|
auto fn = std::move(queue_.front());
|
|
queue_.erase(queue_.begin());
|
|
running_ = true;
|
|
lk.unlock();
|
|
fn();
|
|
lk.lock();
|
|
running_ = false;
|
|
if (queue_.empty())
|
|
cv_done_.notify_all();
|
|
}
|
|
}
|
|
std::mutex mu_;
|
|
std::condition_variable_any cv_; // _any: wait() below takes a stop_token predicate
|
|
std::condition_variable cv_done_;
|
|
std::vector<std::function<void()>> queue_;
|
|
bool running_ = false;
|
|
std::jthread worker_;
|
|
};
|
|
|
|
// Same grow/shrink logic as FakeTask, but on_target() only ever posts through a TestTimer
|
|
// instead of running synchronously -- see the comment above TestTimer for why that's the
|
|
// faithful model under concurrent driving.
|
|
struct AsyncFakeTask {
|
|
SegmentBudget *budget = nullptr;
|
|
TestTimer *timer = nullptr;
|
|
TaskId id{};
|
|
std::mutex mu; // only the timer thread ever touches workers/target -- plain suffices
|
|
std::uint32_t workers = 0;
|
|
std::uint32_t target = 0;
|
|
|
|
void on_target(std::uint32_t t) {
|
|
timer->post([this, t] {
|
|
std::lock_guard lk(mu);
|
|
target = t;
|
|
while (workers < target) {
|
|
if (!budget->confirm_slot(id))
|
|
break;
|
|
++workers;
|
|
}
|
|
while (workers > target) {
|
|
budget->release_slot(id);
|
|
--workers;
|
|
}
|
|
});
|
|
}
|
|
std::uint32_t held() {
|
|
std::lock_guard lk(mu);
|
|
return workers;
|
|
}
|
|
};
|
|
|
|
} // namespace
|
|
|
|
VT_TEST(budget_single_task_grows_to_cap) {
|
|
SegmentBudget b({.max_active_segments = 32});
|
|
FakeTask t{&b, tid(1)};
|
|
b.register_task(tid(1), {.host = "h", .per_task_cap = 8, .resumable = true},
|
|
[&](std::uint32_t n) { t.on_target(n); });
|
|
b.set_want(tid(1), 8);
|
|
VT_CHECK_EQ(t.held(), 8u);
|
|
VT_CHECK_EQ(b.segments_active(tid(1)), 8u);
|
|
VT_CHECK_EQ(b.budget().active, 8u);
|
|
VT_CHECK_EQ(b.budget().tasks_starved, 0u);
|
|
}
|
|
|
|
VT_TEST(budget_min_one_before_seconds) {
|
|
// Budget of 3, two tasks each wanting 8. min-1 first: each gets 1, then the higher-
|
|
// priority one grows to 2.
|
|
SegmentBudget b({.max_active_segments = 3});
|
|
FakeTask a{&b, tid(1)}, c{&b, tid(2)};
|
|
b.register_task(tid(1), {.host = "h1", .per_task_cap = 8, .resumable = true},
|
|
[&](std::uint32_t n) { a.on_target(n); });
|
|
b.register_task(tid(2), {.host = "h2", .per_task_cap = 8, .resumable = true},
|
|
[&](std::uint32_t n) { c.on_target(n); });
|
|
std::vector<TaskId> order = {tid(1), tid(2)};
|
|
b.set_task_order(order);
|
|
b.set_want(tid(1), 8);
|
|
b.set_want(tid(2), 8);
|
|
|
|
VT_CHECK(a.held() >= 1); // guarantee
|
|
VT_CHECK(c.held() >= 1); // guarantee — the load-bearing property
|
|
VT_CHECK_EQ(a.held() + c.held(), 3u);
|
|
VT_CHECK_EQ(a.held(), 2u); // higher priority took the growth slot
|
|
}
|
|
|
|
VT_TEST(budget_new_high_priority_task_gets_min_one_via_yield) {
|
|
SegmentBudget b({.max_active_segments = 4});
|
|
FakeTask a{&b, tid(1)};
|
|
b.register_task(tid(1), {.host = "h", .per_task_cap = 8, .resumable = true},
|
|
[&](std::uint32_t n) { a.on_target(n); });
|
|
b.set_task_order(std::vector<TaskId>{tid(1)});
|
|
b.set_want(tid(1), 8);
|
|
VT_CHECK_EQ(a.held(), 4u); // hogging the whole budget
|
|
|
|
// a second, higher-priority task arrives
|
|
FakeTask c{&b, tid(2)};
|
|
b.register_task(tid(2), {.host = "h2", .per_task_cap = 8, .resumable = true},
|
|
[&](std::uint32_t n) { c.on_target(n); });
|
|
b.set_task_order(std::vector<TaskId>{tid(2), tid(1)});
|
|
b.set_want(tid(2), 8);
|
|
|
|
// a yields so c gets at least its guaranteed slot; the surplus is shared round-robin.
|
|
VT_CHECK(c.held() >= 1); // min-1 — the load-bearing guarantee
|
|
VT_CHECK(a.held() >= 1); // a keeps its own min-1
|
|
VT_CHECK(a.held() < 4); // a really did yield at least one
|
|
VT_CHECK_EQ(a.held() + c.held(), 4u);
|
|
VT_CHECK_EQ(b.budget().active, 4u);
|
|
VT_CHECK_EQ(b.budget().tasks_starved, 0u);
|
|
}
|
|
|
|
VT_TEST(budget_host_cap_clamps_effective_target) {
|
|
SegmentBudget b({.max_active_segments = 32});
|
|
FakeTask t{&b, tid(1)};
|
|
b.set_host_segment_cap("slowcdn", 4);
|
|
b.register_task(tid(1), {.host = "slowcdn", .per_task_cap = 16, .resumable = true},
|
|
[&](std::uint32_t n) { t.on_target(n); });
|
|
b.set_want(tid(1), 16);
|
|
VT_CHECK_EQ(t.held(), 4u); // clamped by the host cap, not per_task_cap
|
|
|
|
b.set_host_segment_cap("slowcdn", 0); // clear
|
|
VT_CHECK_EQ(t.held(), 16u);
|
|
}
|
|
|
|
VT_TEST(budget_non_resumable_task_capped_at_one) {
|
|
SegmentBudget b({.max_active_segments = 32});
|
|
FakeTask t{&b, tid(1)};
|
|
b.register_task(tid(1), {.host = "h", .per_task_cap = 8, .resumable = false},
|
|
[&](std::uint32_t n) { t.on_target(n); });
|
|
b.set_want(tid(1), 8);
|
|
VT_CHECK_EQ(t.held(), 1u);
|
|
}
|
|
|
|
VT_TEST(budget_live_lower_sheds_via_yield_lowest_priority_first) {
|
|
SegmentBudget b({.max_active_segments = 24});
|
|
FakeTask a{&b, tid(1)}, c{&b, tid(2)}, d{&b, tid(3)};
|
|
for (auto *ft : {&a, &c, &d})
|
|
b.register_task(ft->id, {.host = "h", .per_task_cap = 8, .resumable = true},
|
|
[ft](std::uint32_t n) { ft->on_target(n); });
|
|
b.set_task_order(std::vector<TaskId>{tid(1), tid(2), tid(3)});
|
|
for (auto id : {tid(1), tid(2), tid(3)})
|
|
b.set_want(id, 8);
|
|
VT_CHECK_EQ(a.held() + c.held() + d.held(), 24u); // 8 + 8 + 8
|
|
|
|
b.set_max_active_segments(10); // live cut
|
|
VT_CHECK_EQ(a.held() + c.held() + d.held(), 10u);
|
|
VT_CHECK(a.held() >= c.held() && c.held() >= d.held()); // priority order preserved
|
|
VT_CHECK(a.held() >= 1 && c.held() >= 1 && d.held() >= 1); // min-1 still honoured
|
|
}
|
|
|
|
VT_TEST(budget_live_lower_below_task_count_starves_the_tail) {
|
|
SegmentBudget b({.max_active_segments = 6});
|
|
std::vector<FakeTask> ts(4);
|
|
for (std::uint32_t i = 0; i < 4; ++i) {
|
|
ts[i].budget = &b;
|
|
ts[i].id = tid(i + 1);
|
|
}
|
|
for (auto &ft : ts)
|
|
b.register_task(ft.id, {.host = "h", .per_task_cap = 4, .resumable = true},
|
|
[&ft](std::uint32_t n) { ft.on_target(n); });
|
|
b.set_task_order(std::vector<TaskId>{tid(1), tid(2), tid(3), tid(4)});
|
|
for (auto &ft : ts)
|
|
b.set_want(ft.id, 4);
|
|
VT_CHECK_EQ(b.budget().tasks_starved, 0u);
|
|
|
|
b.set_max_active_segments(3); // below the running-task count
|
|
VT_CHECK_EQ(ts[0].held(), 1u);
|
|
VT_CHECK_EQ(ts[3].held(), 0u); // lowest priority shed to zero
|
|
VT_CHECK_EQ(b.budget().tasks_starved, 1u);
|
|
VT_REQUIRE(b.starved_tasks().size() == 1);
|
|
VT_CHECK_EQ(b.starved_tasks()[0], tid(4));
|
|
VT_CHECK(b.starved_since(tid(4)).has_value());
|
|
VT_CHECK(!b.starved_since(tid(1)).has_value());
|
|
}
|
|
|
|
VT_TEST(budget_deregister_frees_slots_to_starved) {
|
|
SegmentBudget b({.max_active_segments = 4});
|
|
FakeTask a{&b, tid(1)}, c{&b, tid(2)};
|
|
b.register_task(tid(1), {.host = "h", .per_task_cap = 8, .resumable = true},
|
|
[&](std::uint32_t n) { a.on_target(n); });
|
|
b.set_task_order(std::vector<TaskId>{tid(1)});
|
|
b.set_want(tid(1), 8);
|
|
VT_CHECK_EQ(a.held(), 4u);
|
|
|
|
b.register_task(tid(2), {.host = "h", .per_task_cap = 8, .resumable = true},
|
|
[&](std::uint32_t n) { c.on_target(n); });
|
|
b.set_task_order(std::vector<TaskId>{tid(1), tid(2)});
|
|
b.set_want(tid(2), 8);
|
|
VT_CHECK(c.held() >= 1); // min-1 from a's yield
|
|
|
|
b.deregister_task(tid(1));
|
|
VT_CHECK_EQ(c.held(), 4u); // c grows into the whole freed budget
|
|
VT_CHECK_EQ(b.budget().active, 4u);
|
|
}
|
|
|
|
VT_TEST(budget_on_changed_fires_on_starved_edge) {
|
|
SegmentBudget b({.max_active_segments = 1, .notify_period = std::chrono::milliseconds{40}});
|
|
std::mutex m;
|
|
std::vector<EB> seen;
|
|
b.on_budget_changed([&](EB e) {
|
|
std::lock_guard lk(m);
|
|
seen.push_back(e);
|
|
});
|
|
|
|
FakeTask a{&b, tid(1)}, c{&b, tid(2)};
|
|
b.register_task(tid(1), {.host = "h", .per_task_cap = 4, .resumable = true},
|
|
[&](std::uint32_t n) { a.on_target(n); });
|
|
b.register_task(tid(2), {.host = "h", .per_task_cap = 4, .resumable = true},
|
|
[&](std::uint32_t n) { c.on_target(n); });
|
|
b.set_task_order(std::vector<TaskId>{tid(1), tid(2)});
|
|
b.set_want(tid(1), 4);
|
|
b.set_want(tid(2), 4); // budget is 1 -> tid(2) is starved: 0 -> nonzero edge
|
|
|
|
// the edge fire is synchronous on the triggering call
|
|
bool saw_starved = false;
|
|
{
|
|
std::lock_guard lk(m);
|
|
for (auto &e : seen)
|
|
if (e.tasks_starved > 0)
|
|
saw_starved = true;
|
|
}
|
|
VT_CHECK(saw_starved);
|
|
|
|
b.deregister_task(tid(1)); // frees the slot -> tid(2) no longer starved: edge back
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(120));
|
|
bool saw_unstarved_after = false;
|
|
{
|
|
std::lock_guard lk(m);
|
|
VT_CHECK(!seen.empty());
|
|
saw_unstarved_after = seen.back().tasks_starved == 0;
|
|
}
|
|
VT_CHECK(saw_unstarved_after);
|
|
}
|
|
|
|
VT_TEST(budget_concurrent_confirm_release_stays_consistent) {
|
|
SegmentBudget b({.max_active_segments = 16});
|
|
constexpr int kTasks = 6;
|
|
TestTimer timer;
|
|
std::vector<std::unique_ptr<AsyncFakeTask>> ts;
|
|
for (int i = 0; i < kTasks; ++i) {
|
|
ts.push_back(std::make_unique<AsyncFakeTask>());
|
|
ts.back()->budget = &b;
|
|
ts.back()->timer = &timer;
|
|
ts.back()->id = tid(i + 1);
|
|
AsyncFakeTask *ft = ts.back().get();
|
|
b.register_task(ft->id, {.host = "h", .per_task_cap = 6, .resumable = true},
|
|
[ft](std::uint32_t n) { ft->on_target(n); });
|
|
}
|
|
std::vector<std::jthread> drivers;
|
|
for (int i = 0; i < kTasks; ++i) {
|
|
drivers.emplace_back([&, id = tid(i + 1)] {
|
|
for (int r = 0; r < 4000; ++r)
|
|
b.set_want(id, (r % 7));
|
|
});
|
|
}
|
|
drivers.clear(); // join
|
|
for (auto &ft : ts)
|
|
b.set_want(ft->id, 0);
|
|
timer.drain(); // let every queued on_target actually run before asserting
|
|
|
|
// With everyone wanting nothing, the budget must be fully released.
|
|
VT_CHECK_EQ(b.budget().active, 0u);
|
|
std::uint32_t sum = 0;
|
|
for (auto &ft : ts)
|
|
sum += b.segments_active(ft->id);
|
|
VT_CHECK_EQ(sum, 0u);
|
|
}
|
|
|
|
// --- wait-list wakeup: tools/bench heap-profile / load found a real task time out
|
|
// waiting on a slot its own target already said it should have (core/docs/m7-baseline.md,
|
|
// docs/adr/0012). Root cause: confirm_slot()'s engine-wide cap check (needed so active_
|
|
// never exceeds max_active_segments -- a real over-admission bug, not just this liveness
|
|
// gap) can deny a task whose target is already correct, when a *different* task is
|
|
// legitimately still holding more than its own just-lowered target (yield is deferred to
|
|
// a segment boundary, ADR 0011 A1). Nothing in the plain target-changed callback
|
|
// mechanism ever revisits a task whose target didn't change -- it was already right. ---
|
|
|
|
namespace {
|
|
|
|
// Unlike FakeTask above, on_target() here only enqueues -- it never calls back into the
|
|
// budget synchronously. This matches production exactly: register_task()'s on_target
|
|
// lambda (download_task.cpp) posts through host.schedule() (engine.cpp), so
|
|
// apply_slot_target() never runs on the same call stack as whatever budget call triggered
|
|
// it. The test drives delivery explicitly (deliver_one()) instead of a background thread
|
|
// so the race this test exists to force -- confirm_slot() denied before the task that's
|
|
// over its target has processed its own shrink -- is deterministic, not a timing gamble.
|
|
struct QueuedTask {
|
|
SegmentBudget *budget = nullptr;
|
|
TaskId id{};
|
|
std::uint32_t workers = 0;
|
|
std::uint32_t target = 0;
|
|
std::vector<std::uint32_t> pending;
|
|
|
|
QueuedTask(SegmentBudget *b, TaskId i) : budget(b), id(i) {}
|
|
|
|
void on_target(std::uint32_t n) { pending.push_back(n); }
|
|
|
|
// Delivers the oldest queued target, applying it the way a real task's
|
|
// apply_slot_target()/fill_slots_locked() would: try to grow to it (confirm_slot()
|
|
// may deny), or shed down to it. Returns false (nothing to deliver) if the queue was
|
|
// empty -- the condition VT_REQUIRE checks to prove a wakeup was actually queued.
|
|
bool deliver_one() {
|
|
if (pending.empty())
|
|
return false;
|
|
target = pending.front();
|
|
pending.erase(pending.begin());
|
|
while (workers < target) {
|
|
if (!budget->confirm_slot(id))
|
|
break;
|
|
++workers;
|
|
}
|
|
while (workers > target) {
|
|
budget->release_slot(id);
|
|
--workers;
|
|
}
|
|
return true;
|
|
}
|
|
};
|
|
|
|
} // namespace
|
|
|
|
VT_TEST(budget_release_wakes_a_denied_waiter) {
|
|
// Sanity baseline: max_active=1, A (higher priority) holds it, B wants one too and is
|
|
// fairly denied -- its target stays 0 while A outranks it and still wants its slot.
|
|
// Once A stops wanting one, B's target rises and B is woken via the plain
|
|
// target-changed path -- no wait-list needed for this simple case. The harder case
|
|
// below is what actually needs it.
|
|
SegmentBudget b({.max_active_segments = 1});
|
|
QueuedTask A{&b, tid(1)}, B{&b, tid(2)};
|
|
b.register_task(A.id, {.host = "h", .per_task_cap = 1, .resumable = true},
|
|
[&](std::uint32_t n) { A.on_target(n); });
|
|
b.set_task_order(std::vector<TaskId>{A.id, B.id});
|
|
b.set_want(A.id, 1);
|
|
VT_REQUIRE(A.deliver_one());
|
|
VT_CHECK_EQ(A.workers, 1u);
|
|
|
|
// B registers and wants one too, but with A (higher priority) already holding the
|
|
// only slot and still wanting it, B's fairly computed target stays 0 -- unchanged
|
|
// from its just-registered value, so no callback is queued for it yet.
|
|
b.register_task(B.id, {.host = "h", .per_task_cap = 1, .resumable = true},
|
|
[&](std::uint32_t n) { B.on_target(n); });
|
|
b.set_want(B.id, 1);
|
|
VT_CHECK(!B.deliver_one());
|
|
VT_CHECK_EQ(B.workers, 0u);
|
|
|
|
b.set_want(A.id, 0); // A is done wanting a slot
|
|
VT_REQUIRE(A.deliver_one()); // A's target dropped to 0 -- sheds its held slot
|
|
VT_CHECK_EQ(A.workers, 0u);
|
|
VT_REQUIRE(B.deliver_one()); // B's target rose to 1 -- the plain target-changed path
|
|
VT_CHECK_EQ(B.workers, 1u); // B took the freed slot
|
|
}
|
|
|
|
VT_TEST(budget_wait_list_wakes_a_task_whose_target_never_changed) {
|
|
// The real gap. max_active=2. Y alone, holds both (target=2). X arrives wanting 1:
|
|
// this recompute correctly drops Y's target to 1 (giving X its guaranteed slot) and
|
|
// raises X's target to 1 -- both real target changes, both queued. Deliver X's
|
|
// *first*: X's target says grow, but Y still physically holds 2 (hasn't processed
|
|
// its own shrink yet) -- confirm_slot() must deny X here (active_ == max_active_),
|
|
// which is the correctness fix (over-admission is the real RSS bug). Then Y
|
|
// processes its shrink and actually releases. X's target never changes again -- it
|
|
// was already correctly 1 -- so nothing in the plain mechanism ever revisits X.
|
|
SegmentBudget b({.max_active_segments = 2});
|
|
QueuedTask Y{&b, tid(1)}, X{&b, tid(2)};
|
|
b.register_task(Y.id, {.host = "h", .per_task_cap = 2, .resumable = true},
|
|
[&](std::uint32_t n) { Y.on_target(n); });
|
|
b.set_want(Y.id, 2);
|
|
VT_REQUIRE(Y.deliver_one());
|
|
VT_CHECK_EQ(Y.workers, 2u);
|
|
|
|
b.register_task(X.id, {.host = "h", .per_task_cap = 1, .resumable = true},
|
|
[&](std::uint32_t n) { X.on_target(n); });
|
|
b.set_task_order(std::vector<TaskId>{Y.id, X.id});
|
|
b.set_want(X.id, 1);
|
|
|
|
VT_REQUIRE(X.deliver_one());
|
|
VT_CHECK_EQ(X.workers, 0u); // denied: active_ == max_active_, even though X's target is 1
|
|
|
|
VT_REQUIRE(Y.deliver_one());
|
|
VT_CHECK_EQ(Y.workers, 1u); // Y actually releases its excess now
|
|
|
|
// The bug: without a wait-list, X.pending is empty here -- nothing was ever queued
|
|
// for it, because X's target never changed again. X would wait forever despite its
|
|
// target correctly saying it should hold a slot.
|
|
VT_REQUIRE(X.deliver_one());
|
|
VT_CHECK_EQ(X.workers, 1u);
|
|
VT_CHECK_EQ(b.budget().active, 2u);
|
|
}
|