daemon: D4b — download.pause/resume/start/cancel and queue.start/stop drive the scheduler
download.pause/resume/start/cancel and queue.start/stop were stubs; now they call into the scheduler and take effect immediately, not on the next 1s tick — pausing, resuming or cancelling a live transfer can't wait, and per ADR 0013 §3 the governor never touches a user-owned pause on its own. New rpc::TaskActionPort interface (owned by rpc/, implemented by sched::Scheduler) is what dispatcher.hpp depends on instead of sched/scheduler.hpp directly. Needed because veloxd_sched already links veloxd_rpc (for EventHub); dispatcher.hpp pulling in sched/scheduler.hpp directly would make it a real circular library dependency, breaking anything that links veloxd_rpc alone (cli's tests, as it turned out — hit and fixed during this change). Scheduler::user_pause/user_resume/user_start/user_cancel + pause_queue follow tick()'s existing to_pause pattern: call the engine (async, no synchronous effect) and transition the store eagerly so download.get/list are correct the instant the RPC call returns. Fixed a real bug surfaced while building this: transition() always overwrote pause_reason to NULL when the engine's own delayed pause-ack callback (on_state to paused, no error) arrived after whoever actually initiated the pause had already written the real reason — now it preserves the stored reason when the callback supplies none, instead of clobbering it. Covered by a regression check in sched_scheduler_test. store/queues gets get() and set_state() (was list()-only) for queue.start/stop. Verified against real veloxd + tools/testserver, not just unit tests: pausing a live single-segment throttled transfer freezes downloadedBytes, resume continues it from that point, cancel stops it; a bad taskId comes back in BulkTaskResult.failed with -32010, not a crash; queue.stop(pauseRunning:true) pauses the queue's running task immediately and queue.start resumes admission. Known gap: download.start's contract "a task in 'queued' jumps its queue" (priority bump) is not implemented — admission is still plain FIFO by created_at. Noted in deferrals.md. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01GRDjHGgpYmMoPE2UFbe7pP
This commit is contained in:
@@ -259,6 +259,108 @@ void run() {
|
||||
|
||||
tasks.remove("pr0");
|
||||
}
|
||||
|
||||
// --- user_pause / user_resume: a live task, and one that never started -----------
|
||||
{
|
||||
FakeEnginePort engine;
|
||||
Scheduler sched(*db, engine,
|
||||
Governor(GovernorConfig{.max_concurrent_downloads = 10,
|
||||
.max_active_segments = 32}));
|
||||
CHECK(tasks.insert(task("up0", "queued", "2026-09-10T10:00:00Z")).has_value());
|
||||
CHECK(tasks.insert(task("up1", "paused", "2026-09-10T10:00:00Z")).has_value());
|
||||
CHECK(sched.tick().has_value()); // admits up0; up1 stays paused (governor never
|
||||
// touches a user-owned pause)
|
||||
CHECK_EQ(engine.starts.size(), 1u);
|
||||
const auto live_id = engine.starts[0].id;
|
||||
|
||||
// Pausing a live task calls the engine now and transitions eagerly — not left for
|
||||
// the next tick.
|
||||
auto r = sched.user_pause("up0");
|
||||
CHECK(r.found);
|
||||
CHECK(r.changed);
|
||||
CHECK_EQ(r.state, std::string("paused"));
|
||||
CHECK_EQ(engine.paused.size(), 1u);
|
||||
CHECK_EQ(engine.paused[0].value, live_id.value);
|
||||
CHECK_EQ(task_state(*db, "up0"), std::string("paused"));
|
||||
auto row = tasks.get("up0").value().value();
|
||||
CHECK_EQ(row.pause_reason.value_or(""), std::string("user"));
|
||||
|
||||
// Idempotent: pausing an already-paused task is a no-op, not an error.
|
||||
auto again = sched.user_pause("up0");
|
||||
CHECK(again.found);
|
||||
CHECK(!again.changed);
|
||||
|
||||
// Resuming a task that still holds a live engine handle calls engine.resume() and
|
||||
// goes straight to `connecting`.
|
||||
auto res = sched.user_resume("up0");
|
||||
CHECK(res.found);
|
||||
CHECK(res.changed);
|
||||
CHECK_EQ(res.state, std::string("connecting"));
|
||||
CHECK_EQ(engine.resumed.size(), 1u);
|
||||
CHECK_EQ(engine.resumed[0].value, live_id.value);
|
||||
|
||||
// The engine's own delayed pause-ack (on_state with no error, arriving after the
|
||||
// eager transition already wrote the real reason) must not clobber pause_reason
|
||||
// back to NULL.
|
||||
(void)sched.user_pause("up0");
|
||||
sched.on_engine_state("up0", "downloading", "paused", std::nullopt);
|
||||
auto row2 = tasks.get("up0").value().value();
|
||||
CHECK_EQ(row2.pause_reason.value_or(""), std::string("user"));
|
||||
|
||||
// up1 never started (still parked, no engine handle): resume just re-queues it for
|
||||
// the next tick's normal admission.
|
||||
auto res2 = sched.user_resume("up1");
|
||||
CHECK(res2.found);
|
||||
CHECK(res2.changed);
|
||||
CHECK_EQ(res2.state, std::string("queued"));
|
||||
CHECK(engine.resumed.size() == 1u); // up1 was never mapped; no engine call
|
||||
|
||||
// Not found: a bogus id reports found=false, not a crash.
|
||||
auto missing = sched.user_pause("does-not-exist");
|
||||
CHECK(!missing.found);
|
||||
|
||||
tasks.remove("up0");
|
||||
tasks.remove("up1");
|
||||
}
|
||||
|
||||
// --- user_cancel + pause_queue --------------------------------------------------
|
||||
{
|
||||
CHECK(db->exec("UPDATE queues SET state='running' WHERE queue_id='main'").has_value());
|
||||
FakeEnginePort engine;
|
||||
Scheduler sched(*db, engine,
|
||||
Governor(GovernorConfig{.max_concurrent_downloads = 10,
|
||||
.max_active_segments = 32}));
|
||||
CHECK(tasks.insert(task("uc0", "queued", "2026-09-10T10:00:00Z", "main", 0)).has_value());
|
||||
CHECK(tasks.insert(task("uc1", "queued", "2026-09-10T10:00:01Z", "main", 1)).has_value());
|
||||
CHECK(sched.tick().has_value());
|
||||
CHECK_EQ(engine.starts.size(), 2u);
|
||||
|
||||
auto c = sched.user_cancel("uc0", /*discard_partial=*/true);
|
||||
CHECK(c.found);
|
||||
CHECK(c.changed);
|
||||
CHECK_EQ(c.state, std::string("cancelled"));
|
||||
CHECK_EQ(engine.cancelled.size(), 1u);
|
||||
CHECK(engine.cancelled[0].second); // discard_partial passed through
|
||||
CHECK_EQ(task_state(*db, "uc0"), std::string("cancelled"));
|
||||
|
||||
// Cancelling an already-terminal task is a no-op.
|
||||
auto c2 = sched.user_cancel("uc0", false);
|
||||
CHECK(c2.found);
|
||||
CHECK(!c2.changed);
|
||||
|
||||
// pause_queue pauses every still-running task in the queue (uc0 is terminal, so
|
||||
// only uc1 is affected) and reports pause_reason 'queue_stopped'.
|
||||
const auto paused_ids = sched.pause_queue("main");
|
||||
CHECK_EQ(paused_ids.size(), std::size_t{1});
|
||||
CHECK_EQ(paused_ids[0], std::string("uc1"));
|
||||
CHECK_EQ(task_state(*db, "uc1"), std::string("paused"));
|
||||
auto row = tasks.get("uc1").value().value();
|
||||
CHECK_EQ(row.pause_reason.value_or(""), std::string("queue_stopped"));
|
||||
|
||||
tasks.remove("uc0");
|
||||
tasks.remove("uc1");
|
||||
CHECK(db->exec("UPDATE queues SET state='stopped' WHERE queue_id='main'").has_value());
|
||||
}
|
||||
}
|
||||
|
||||
TEST_MAIN()
|
||||
|
||||
Reference in New Issue
Block a user