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:
@@ -291,7 +291,16 @@ void Scheduler::transition(const std::string& wire_id, std::string_view to_state
|
||||
// An engine-initiated pause carries an error => 'auto' (ADR 0013 §2), overriding
|
||||
// whatever the caller passed (a scheduler-driven pause never carries an error here).
|
||||
std::optional<std::string> reason = pause_reason;
|
||||
if (to_state == "paused" && err) reason = "auto";
|
||||
if (to_state == "paused" && err) {
|
||||
reason = "auto";
|
||||
} else if (to_state == "paused" && !reason && before && before->has_value()) {
|
||||
// No reason supplied — the common case is the engine's own pause-ack callback
|
||||
// (on_state(_, paused, nullopt)) arriving after whoever actually initiated the
|
||||
// pause (user_pause() or tick()'s to_pause loop) already wrote the real reason
|
||||
// eagerly. Keep what's already stored instead of clobbering it back to NULL:
|
||||
// set_state() always overwrites the column, reason or not.
|
||||
reason = (**before).pause_reason;
|
||||
}
|
||||
(void)tasks.set_state(wire_id, to_state, reason);
|
||||
|
||||
if (err) {
|
||||
@@ -472,4 +481,98 @@ std::vector<Scheduler::ProgressRow> Scheduler::progress_snapshot() {
|
||||
return out;
|
||||
}
|
||||
|
||||
namespace {
|
||||
bool is_terminal_state(const std::string& s) {
|
||||
return s == "complete" || s == "failed" || s == "cancelled";
|
||||
}
|
||||
} // namespace
|
||||
|
||||
rpc::TaskActionPort::Result Scheduler::user_pause(const std::string& wire_id) {
|
||||
store::Tasks tasks(db_);
|
||||
auto got = tasks.get(wire_id);
|
||||
if (!got || !got->has_value()) return {false, false, {}};
|
||||
const store::TaskRow& row = **got;
|
||||
if (is_terminal_state(row.state) || row.state == "paused")
|
||||
return {true, false, row.state};
|
||||
|
||||
if (auto eid = engine_id_of(wire_id)) engine_.pause(*eid);
|
||||
transition(wire_id, "paused", std::string("user"), std::nullopt);
|
||||
return {true, true, "paused"};
|
||||
}
|
||||
|
||||
rpc::TaskActionPort::Result Scheduler::user_resume(const std::string& wire_id) {
|
||||
store::Tasks tasks(db_);
|
||||
auto got = tasks.get(wire_id);
|
||||
if (!got || !got->has_value()) return {false, false, {}};
|
||||
const store::TaskRow& row = **got;
|
||||
if (row.state != "paused") return {true, false, row.state};
|
||||
|
||||
if (auto eid = engine_id_of(wire_id)) {
|
||||
engine_.resume(*eid);
|
||||
transition(wire_id, "connecting", std::nullopt, std::nullopt);
|
||||
return {true, true, "connecting"};
|
||||
}
|
||||
transition(wire_id, "queued", std::nullopt, std::nullopt);
|
||||
return {true, true, "queued"};
|
||||
}
|
||||
|
||||
rpc::TaskActionPort::Result Scheduler::user_start(const std::string& wire_id) {
|
||||
store::Tasks tasks(db_);
|
||||
auto got = tasks.get(wire_id);
|
||||
if (!got || !got->has_value()) return {false, false, {}};
|
||||
const store::TaskRow& row = **got;
|
||||
if (row.state != "paused" && row.state != "new") return {true, false, row.state};
|
||||
|
||||
if (auto eid = engine_id_of(wire_id)) {
|
||||
engine_.resume(*eid);
|
||||
transition(wire_id, "connecting", std::nullopt, std::nullopt);
|
||||
return {true, true, "connecting"};
|
||||
}
|
||||
transition(wire_id, "queued", std::nullopt, std::nullopt);
|
||||
return {true, true, "queued"};
|
||||
}
|
||||
|
||||
rpc::TaskActionPort::Result Scheduler::user_cancel(const std::string& wire_id,
|
||||
bool discard_partial) {
|
||||
store::Tasks tasks(db_);
|
||||
auto got = tasks.get(wire_id);
|
||||
if (!got || !got->has_value()) return {false, false, {}};
|
||||
const store::TaskRow& row = **got;
|
||||
if (is_terminal_state(row.state)) return {true, false, row.state};
|
||||
|
||||
// Same pattern as tick()'s to_pause loop: call the engine (async, no synchronous
|
||||
// effect) and transition the store eagerly so download.get/list are correct the
|
||||
// instant this call returns. The engine's own on_state(_, cancelled, nullopt) +
|
||||
// on_finished arrive later via on_engine_state, which is what actually
|
||||
// release()s/unmaps the handle — never done here.
|
||||
if (auto eid = engine_id_of(wire_id)) engine_.cancel(*eid, discard_partial);
|
||||
transition(wire_id, "cancelled", std::nullopt, std::nullopt);
|
||||
return {true, true, "cancelled"};
|
||||
}
|
||||
|
||||
std::vector<std::string> Scheduler::pause_queue(const std::string& queue_id) {
|
||||
store::Tasks tasks(db_);
|
||||
proto::TaskFilter filter;
|
||||
filter.queueId = queue_id;
|
||||
filter.states = non_terminal_states();
|
||||
// No paging needed: a queue's max_concurrent is <= 32, so "everything non-terminal in
|
||||
// this queue" is never a large page.
|
||||
auto page = tasks.list(filter, std::nullopt, 0, 10000);
|
||||
std::vector<std::string> paused;
|
||||
if (!page) return paused;
|
||||
|
||||
for (const auto& row : page->rows) {
|
||||
if (run_state_of(row.state) != RunState::Running) continue;
|
||||
if (auto eid = engine_id_of(row.task_id)) engine_.pause(*eid);
|
||||
transition(row.task_id, "paused", std::string("queue_stopped"), std::nullopt);
|
||||
paused.push_back(row.task_id);
|
||||
}
|
||||
return paused;
|
||||
}
|
||||
|
||||
void Scheduler::probe_now(const vdm::net::ProbeRequest& req,
|
||||
std::function<void(vdm::Result<vdm::net::ProbeResult>)> done) {
|
||||
engine_.probe(req, std::move(done));
|
||||
}
|
||||
|
||||
} // namespace velox::daemon::sched
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
#include <vector>
|
||||
|
||||
#include "rpc/event_hub.hpp"
|
||||
#include "rpc/task_action_port.hpp"
|
||||
#include "sched/engine_port.hpp"
|
||||
#include "sched/governor.hpp"
|
||||
#include "store/sqlite.hpp"
|
||||
@@ -45,7 +46,12 @@ struct TaskErrorFields {
|
||||
std::optional<std::int64_t> attempt;
|
||||
};
|
||||
|
||||
class Scheduler {
|
||||
// Implements rpc::TaskActionPort directly — sched/ already depends on rpc/ (EventHub), so
|
||||
// this costs nothing new, and it's what lets dispatcher.hpp depend on the port interface
|
||||
// instead of on sched/scheduler.hpp (see rpc/task_action_port.hpp's top comment for why
|
||||
// that matters: it would otherwise make veloxd_rpc <-> veloxd_sched a circular library
|
||||
// dependency).
|
||||
class Scheduler final : public rpc::TaskActionPort {
|
||||
public:
|
||||
// `local_now` returns a fully-populated std::tm in local time; injected so tests can
|
||||
// pin the clock. `post_to_loop` marshals an engine-thread callback onto the loop
|
||||
@@ -98,6 +104,42 @@ public:
|
||||
};
|
||||
std::vector<ProgressRow> progress_snapshot();
|
||||
|
||||
// rpc::TaskActionPort. These apply immediately — never wait for the next tick() —
|
||||
// because pausing, resuming or cancelling a live transfer cannot wait up to 1s for the
|
||||
// timerfd, and the governor will never do any of them on its own for a user-owned
|
||||
// reason (ADR 0013 §3: "never touch a task paused for a reason it does not own").
|
||||
// Idempotent: calling one on a task already in the target (or a terminal) state
|
||||
// reports found=true, changed=false.
|
||||
rpc::TaskActionPort::Result user_pause(const std::string& wire_id) override;
|
||||
// A task still holding a live engine handle (paused mid-flight) is engine_.resume()'d
|
||||
// straight back to `connecting`; one with no handle yet (parked since download.add
|
||||
// with startMode 'later', or never admitted) goes to `queued` for the next tick's
|
||||
// normal admission.
|
||||
rpc::TaskActionPort::Result user_resume(const std::string& wire_id) override;
|
||||
// "Begin or restart the given tasks" (download.start): same effect as user_resume for
|
||||
// a paused/new task. NOTE: the contract's "a task in 'queued' jumps its queue" priority
|
||||
// bump is not implemented — admission is still plain FIFO via the governor's
|
||||
// created_at rank. Flagged in deferrals.md.
|
||||
rpc::TaskActionPort::Result user_start(const std::string& wire_id) override;
|
||||
// download.cancel == cancel(discard_partial=false); download.remove == cancel(true)
|
||||
// plus the store row / file cleanup (that part is still D3).
|
||||
rpc::TaskActionPort::Result user_cancel(const std::string& wire_id,
|
||||
bool discard_partial) override;
|
||||
|
||||
// queue.stop(pauseRunning=true): pause every task in `queue_id` the governor would
|
||||
// currently call Running, right now rather than waiting for the next tick — the same
|
||||
// immediacy reasoning as the user_* actions above, with PauseReason::QueueStopped
|
||||
// instead of User. Returns the wire ids actually paused.
|
||||
std::vector<std::string> pause_queue(const std::string& queue_id) override;
|
||||
|
||||
// download.probe's standalone use (File Info dialog, no task row involved): a thin
|
||||
// passthrough to the engine's own probe pool, outside the segment budget (ADR 0011
|
||||
// §5). Never blocks — `done` arrives on an engine thread like every other EnginePort
|
||||
// callback; the caller (the RPC server layer, not this synchronous dispatcher — see
|
||||
// rpc/dispatcher.hpp's top comment) is responsible for marshalling the reply back.
|
||||
void probe_now(const vdm::net::ProbeRequest& req,
|
||||
std::function<void(vdm::Result<vdm::net::ProbeResult>)> done);
|
||||
|
||||
// Diagnostics / tests.
|
||||
std::optional<std::string> wire_id_of(vdm::TaskId id) const;
|
||||
std::optional<vdm::TaskId> engine_id_of(const std::string& wire_id) const;
|
||||
|
||||
Reference in New Issue
Block a user