Files
vdm/daemon/tests/store_migrations_test.cpp
T
samiandClaude Sonnet 5 a967eca669 daemon: persist engine progress/probe to the store — segments:0 no longer leaks
Engine numbers never reached the store: download.get after a correctly-finished
download reported sizeBytes: null, downloadedBytes: 0, speedBps: 0, resumable:
false, segments: 0, segmentDetail: [] — segments: 0 breaks the frozen contract
(TaskSummary.segments is minimum:1, required).

- Scheduler::tick() now probes (EnginePort::probe) before every start(), persisting
  sizeBytes/resumable/validators via Tasks::set_probe_result before a byte moves,
  then starts the engine with that ProbeResult as probe_hint.
- Scheduler::persist_progress() (new) writes downloadedBytes/speedBps/segments/
  segmentDetail from the engine's Progress. Called from progress_snapshot() (the
  250ms tick) *and* once more from on_engine_state right before release()/unmap on
  every terminal transition, so a task that finishes between two ticks — the common
  case for anything small or fast — still leaves real numbers instead of the
  pre-persistence defaults.
- TaskSummary.segments is sourced from segments.size() when the task has any
  (matching what actually lands in segmentDetail, per the schema's "exactly
  segments entries"), falling back to the engine's effective_segments (budget
  slots *held*, not necessarily physical range count) only pre-segmentation.
- Tasks::set_final_bytes tops up on_finished's byte count as a last-resort
  backstop.
- store/segments.{cpp,hpp}: read/write access to the segments table behind
  TaskDetail.segmentDetail. Wired into daemon/CMakeLists.txt.
- migrations/0002: speed_bps on tasks and segments; fixes segments.state's CHECK
  to include 'pending' (0001 omitted it, so a pre-connect snapshot could never be
  written).
- store_migrations_test's forward-only loop faked "released version N" by setting
  the user_version pragma alone, with no real schema underneath — never exercised
  until 0002 existed. Fixed to actually build the db through migrations 1..N first.

Verified against real veloxd + tools/testserver (not just unit tests):
download.list/download.get correct immediately after completion and after a
daemon restart, with saveTo.allowedRoots pointed at an isolated dir.

Observed but not fixed (CORE, not this lane, noted in deferrals.md): Progress.
speed_bps reads back 0 for the whole lifetime of a live throttled download in the
same E2E check, despite downloadedBytes visibly advancing. DAEMON passes it
through unmodified; filed rather than worked around.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01GRDjHGgpYmMoPE2UFbe7pP
2026-09-11 12:11:19 +04:00

128 lines
4.6 KiB
C++

// The migrator: fresh DB -> head, idempotent re-run, and forward-only from every released
// user_version (M1 DoD: "a forward-only test from every released schema version").
#include <string>
#include "check.hpp"
#include "store/migrations.hpp"
#include "store/sqlite.hpp"
using namespace velox::daemon::store;
namespace {
std::int64_t head_version() {
std::int64_t v = 0;
for (const auto& m : embedded_migrations()) v = std::max(v, m.version);
return v;
}
bool table_exists(Db& db, const char* name) {
auto st = db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1");
if (!st) return false;
if (!st->bind(1, std::string_view(name))) return false;
auto row = st->step();
return row && *row;
}
std::int64_t count(Db& db, const char* sql) {
auto st = db.prepare(sql);
if (!st) return -1;
auto row = st->step();
if (!row || !*row) return -1;
return st->column_int(0);
}
} // namespace
void run() {
const std::int64_t head = head_version();
CHECK(head >= 1);
// --- fresh in-memory DB migrates cleanly to head --------------------------------
{
auto db = Db::open(":memory:");
CHECK(db.has_value());
if (!db) return;
CHECK_EQ(db->user_version(), 0);
auto out = migrate_to_head(*db);
CHECK(out.has_value());
if (out) {
CHECK_EQ(out->from_version, 0);
CHECK_EQ(out->to_version, head);
CHECK_EQ(static_cast<std::int64_t>(out->applied), head);
}
CHECK_EQ(db->user_version(), head);
for (const char* t : {"settings", "categories", "queues", "tasks", "segments",
"rules", "history", "pairings"}) {
CHECK(table_exists(*db, t));
}
// Seed rows the initial migration inserts.
CHECK_EQ(count(*db, "SELECT count(*) FROM categories WHERE builtin=1"), 6);
CHECK_EQ(count(*db, "SELECT count(*) FROM queues"), 1);
// FK + cascade wired: a segment for a missing task is rejected; deleting a task
// takes its segments with it.
CHECK(db->exec("INSERT INTO tasks(task_id,url,save_dir,created_at) "
"VALUES('t1','http://x','/tmp','2026-09-10T00:00:00Z')")
.has_value());
CHECK(db->exec("INSERT INTO segments(task_id,idx,start_byte,end_byte) "
"VALUES('t1',0,0,99)")
.has_value());
CHECK(!db->exec("INSERT INTO segments(task_id,idx,start_byte,end_byte) "
"VALUES('nope',0,0,99)")
.has_value());
CHECK(db->exec("DELETE FROM tasks WHERE task_id='t1'").has_value());
CHECK_EQ(count(*db, "SELECT count(*) FROM segments"), 0);
// A whole-file zero-length download: one segment, end_byte = -1 (ADR 0010 B3a).
CHECK(db->exec("INSERT INTO tasks(task_id,url,save_dir,created_at,size_bytes) "
"VALUES('z','http://x','/tmp','2026-09-10T00:00:00Z',0)")
.has_value());
CHECK(db->exec("INSERT INTO segments(task_id,idx,start_byte,end_byte) "
"VALUES('z',0,0,-1)")
.has_value());
}
// --- re-running the migrator on an at-head DB is a no-op ------------------------
{
auto db = Db::open(":memory:");
CHECK(db.has_value());
(void)migrate_to_head(*db);
auto again = migrate_to_head(*db);
CHECK(again.has_value());
if (again) {
CHECK_EQ(again->applied, 0);
CHECK_EQ(again->to_version, head);
}
}
// --- forward-only: from every released version [0 .. head-1], reach head --------
// A "released version N" db has the real schema migrations 1..N actually built, not
// just the pragma set to N — faking the pragma alone left `start >= 1` cases running
// a later migration (e.g. 0002's ALTER TABLE tasks / rebuild of segments) against a
// db with no tables at all.
for (std::int64_t start = 0; start < head; ++start) {
auto db = Db::open(":memory:");
CHECK(db.has_value());
if (!db) continue;
for (const auto& m : embedded_migrations()) {
if (m.version > start) break;
CHECK(db->exec(m.sql).has_value());
CHECK(db->set_user_version(m.version).has_value());
}
CHECK_EQ(db->user_version(), start);
auto out = migrate_to_head(*db);
CHECK(out.has_value());
if (out) {
CHECK_EQ(out->from_version, start);
CHECK_EQ(out->to_version, head);
}
CHECK_EQ(db->user_version(), head);
}
}
TEST_MAIN()