Files
vdm/daemon/tests/store_migrations_test.cpp
T
samiandClaude Sonnet 5 de748cc2fc daemon: fix startMode 'later' -32603 and isolate the single-instance lock by runtime dir
Two bugs blocking PROTO's live-veloxd conformance check.

1. tasks.start_mode's CHECK was ('auto','now','queue','manual') — not the
   contract's StartMode enum (['now','later','queue']) at all. 'later', a real
   documented value (the File Info dialog's Download Later button), hit the CHECK
   on every insert and surfaced as an unhandled -32603; 'auto'/'manual' were
   never contract values to begin with.

   Migration 0003 rebuilds tasks (SQLite can't ALTER a CHECK) with the contract's
   values, remapping existing rows by what they actually meant: 'auto' -> 'now'
   (eligible for the scheduler immediately), 'manual' -> 'later' (parked, matching
   StartMode's own "lands the task in paused" description). store_migrations_test
   covers the remap and that 'later' inserts clean while the retired spellings
   are rejected.

   dispatcher.cpp's on_download_add matched: default (absent startMode) is now
   'now' instead of the invented 'auto'; 'later' actually lands the task in
   `paused` (pause_reason 'user') instead of a dead 'manual' -> `new` branch that
   spec.startMode (typed as the 3-value enum) could never even reach.
   TaskRow::start_mode's in-memory default followed suit ('now').

2. main.cpp's single-instance guard bound an abstract socket named
   "velox-daemon-<euid>" — one name per user, system-wide. XDG_RUNTIME_DIR
   isolation never reached it: a leaked test veloxd held the lock for 4h40m and
   locked out every other isolated instance with the same euid (PROTO, EXT, the
   orchestrator), real daemon included.

   Extracted rpc/single_instance.{hpp,cpp} (was a static in main.cpp, untestable)
   and derived the abstract-socket name from a hash of the resolved runtime dir
   path instead of euid alone. The real per-user daemon is still unique (its
   runtime dir is unique to it); isolated instances pointed at their own runtime
   dirs now coexist. main() resolves the runtime dir before acquiring the lock
   (was the other way around). New single_instance_test covers same-dir refusal,
   different-dir coexistence, and release-on-close.

Verified against real veloxd binaries, not just unit tests: startMode: "later"
via a live download.add lands in `paused`; two veloxd with different runtime
dirs run concurrently, two with the same one and the second refuses with the
runtime dir named in the error. Full ctest: 39/39.

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

178 lines
7.3 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());
}
// --- 0003: start_mode is rebuilt to the contract's values, existing rows mapped -
{
auto db = Db::open(":memory:");
CHECK(db.has_value());
if (!db) return;
// Build a real pre-0003 db (schema 1..2) with rows in the old, non-contract
// start_mode spelling, the way an actually-released daemon would have them.
for (const auto& m : embedded_migrations()) {
if (m.version > 2) break;
CHECK(db->exec(m.sql).has_value());
CHECK(db->set_user_version(m.version).has_value());
}
CHECK(db->exec("INSERT INTO tasks(task_id,url,save_dir,created_at,start_mode) "
"VALUES('auto1','http://x','/tmp','2026-09-10T00:00:00Z','auto')")
.has_value());
CHECK(db->exec("INSERT INTO tasks(task_id,url,save_dir,created_at,start_mode) "
"VALUES('man1','http://x','/tmp','2026-09-10T00:00:00Z','manual')")
.has_value());
CHECK(db->exec("INSERT INTO tasks(task_id,url,save_dir,created_at,start_mode) "
"VALUES('q1','http://x','/tmp','2026-09-10T00:00:00Z','queue')")
.has_value());
CHECK(migrate_to_head(*db).has_value());
CHECK_EQ(db->user_version(), head);
auto start_mode_of = [&](const char* id) -> std::string {
auto st = db->prepare("SELECT start_mode FROM tasks WHERE task_id=?1");
if (!st || !st->bind(1, std::string_view(id))) return "";
auto row = st->step();
if (!row || !*row) return "";
return std::string(st->column_text(0));
};
CHECK_EQ(start_mode_of("auto1"), std::string("now"));
CHECK_EQ(start_mode_of("man1"), std::string("later"));
CHECK_EQ(start_mode_of("q1"), std::string("queue")); // passes through unchanged
// The bug this migration closes: 'later' — a real, documented StartMode value —
// used to hit the old CHECK and fail every insert. It's accepted now, and the two
// retired spellings are gone for good.
CHECK(db->exec("INSERT INTO tasks(task_id,url,save_dir,created_at,start_mode) "
"VALUES('later1','http://x','/tmp','2026-09-10T00:00:00Z','later')")
.has_value());
CHECK(db->exec("INSERT INTO tasks(task_id,url,save_dir,created_at,start_mode) "
"VALUES('bad1','http://x','/tmp','2026-09-10T00:00:00Z','auto')")
.has_value() == false);
CHECK(db->exec("INSERT INTO tasks(task_id,url,save_dir,created_at,start_mode) "
"VALUES('bad2','http://x','/tmp','2026-09-10T00:00:00Z','manual')")
.has_value() == false);
}
// --- 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()