#include "store/tasks.hpp" #include #include namespace velox::daemon::store { namespace proto = velox::proto; namespace { // Column order shared by get() and list() — the row reader indexes into this. constexpr const char* kCols = "task_id, url, save_dir, filename, state, start_mode, created_at, " "effective_url, category_id, queue_id, description, pause_reason, " "etag, last_modified, content_type, last_try_at, completed_at, " "checksum_algo, checksum_value, " "size_bytes, downloaded_bytes, resumable, " "req_segments, eff_segments, req_buffer_bytes, eff_buffer_bytes, queue_position, " "error_code, error_message, error_http_status, error_retryable, error_attempt, " "error_next_retry_at, speed_bps"; DbResult bind_opt(Stmt& s, int i, const std::optional& v) { return v ? s.bind(i, std::string_view(*v)) : s.bind_null(i); } DbResult bind_opt(Stmt& s, int i, const std::optional& v) { return v ? s.bind(i, *v) : s.bind_null(i); } std::optional col_opt_text(Stmt& s, int i) { if (s.column_is_null(i)) return std::nullopt; return s.column_text(i); } std::optional col_opt_int(Stmt& s, int i) { if (s.column_is_null(i)) return std::nullopt; return s.column_int(i); } TaskRow read_row(Stmt& s) { TaskRow r; r.task_id = s.column_text(0); r.url = s.column_text(1); r.save_dir = s.column_text(2); r.filename = s.column_text(3); r.state = s.column_text(4); r.start_mode = s.column_text(5); r.created_at = s.column_text(6); r.effective_url = col_opt_text(s, 7); r.category_id = col_opt_text(s, 8); r.queue_id = col_opt_text(s, 9); r.description = col_opt_text(s, 10); r.pause_reason = col_opt_text(s, 11); r.etag = col_opt_text(s, 12); r.last_modified = col_opt_text(s, 13); r.content_type = col_opt_text(s, 14); r.last_try_at = col_opt_text(s, 15); r.completed_at = col_opt_text(s, 16); r.checksum_algo = col_opt_text(s, 17); r.checksum_value = col_opt_text(s, 18); r.size_bytes = col_opt_int(s, 19); r.downloaded_bytes = s.column_int(20); r.resumable = s.column_int(21) != 0; r.req_segments = col_opt_int(s, 22); r.eff_segments = s.column_int(23); r.req_buffer_bytes = col_opt_int(s, 24); r.eff_buffer_bytes = col_opt_int(s, 25); r.queue_position = col_opt_int(s, 26); r.error_code = col_opt_text(s, 27); r.error_message = col_opt_text(s, 28); r.error_http_status = col_opt_int(s, 29); if (!s.column_is_null(30)) r.error_retryable = s.column_int(30) != 0; r.error_attempt = col_opt_int(s, 31); r.error_next_retry_at = col_opt_text(s, 32); r.speed_bps = s.column_int(33); return r; } // TaskSort.field -> a whitelisted column. Anything not backed by a stored column (live // speed, eta) sorts by recency instead of erroring. const char* sort_column(proto::TaskSortField f) { switch (f) { case proto::TaskSortField::Filename: return "filename"; case proto::TaskSortField::SizeBytes: return "size_bytes"; case proto::TaskSortField::State: return "state"; case proto::TaskSortField::LastTryAt: return "last_try_at"; case proto::TaskSortField::QueuePosition: return "queue_position"; case proto::TaskSortField::Description: return "description"; case proto::TaskSortField::CreatedAt: case proto::TaskSortField::EtaSeconds: case proto::TaskSortField::SpeedBps: default: return "created_at"; } } } // namespace DbResult Tasks::insert(const TaskRow& r) { auto st = db_.prepare( "INSERT INTO tasks(" "task_id, url, save_dir, filename, state, start_mode, created_at, " "effective_url, category_id, queue_id, description, pause_reason, " "etag, last_modified, content_type, last_try_at, completed_at, " "checksum_algo, checksum_value, size_bytes, downloaded_bytes, resumable, " "req_segments, eff_segments, req_buffer_bytes, eff_buffer_bytes, queue_position, " "error_code, error_message, error_http_status, error_retryable, error_attempt, " "error_next_retry_at, speed_bps) VALUES(" "?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,?18,?19,?20,?21,?22," "?23,?24,?25,?26,?27,?28,?29,?30,?31,?32,?33,?34)"); if (!st) return std::unexpected(st.error()); auto chk = [](DbResult r) { return r.has_value(); }; bool ok = chk(st->bind(1, std::string_view(r.task_id))) && chk(st->bind(2, std::string_view(r.url))) && chk(st->bind(3, std::string_view(r.save_dir))) && chk(st->bind(4, std::string_view(r.filename))) && chk(st->bind(5, std::string_view(r.state))) && chk(st->bind(6, std::string_view(r.start_mode))) && chk(st->bind(7, std::string_view(r.created_at))) && chk(bind_opt(*st, 8, r.effective_url)) && chk(bind_opt(*st, 9, r.category_id)) && chk(bind_opt(*st, 10, r.queue_id)) && chk(bind_opt(*st, 11, r.description)) && chk(bind_opt(*st, 12, r.pause_reason)) && chk(bind_opt(*st, 13, r.etag)) && chk(bind_opt(*st, 14, r.last_modified)) && chk(bind_opt(*st, 15, r.content_type)) && chk(bind_opt(*st, 16, r.last_try_at)) && chk(bind_opt(*st, 17, r.completed_at)) && chk(bind_opt(*st, 18, r.checksum_algo)) && chk(bind_opt(*st, 19, r.checksum_value)) && chk(bind_opt(*st, 20, r.size_bytes)) && chk(st->bind(21, r.downloaded_bytes)) && chk(st->bind(22, static_cast(r.resumable))) && chk(bind_opt(*st, 23, r.req_segments)) && chk(st->bind(24, r.eff_segments)) && chk(bind_opt(*st, 25, r.req_buffer_bytes)) && chk(bind_opt(*st, 26, r.eff_buffer_bytes)) && chk(bind_opt(*st, 27, r.queue_position)) && chk(bind_opt(*st, 28, r.error_code)) && chk(bind_opt(*st, 29, r.error_message)) && chk(bind_opt(*st, 30, r.error_http_status)) && chk(r.error_retryable ? st->bind(31, static_cast(*r.error_retryable)) : st->bind_null(31)) && chk(bind_opt(*st, 32, r.error_attempt)) && chk(bind_opt(*st, 33, r.error_next_retry_at)) && chk(st->bind(34, r.speed_bps)); if (!ok) return std::unexpected(DbError{0, "failed to bind a task column"}); if (auto r2 = st->step(); !r2) return std::unexpected(r2.error()); return {}; } DbResult> Tasks::get(std::string_view task_id) { auto st = db_.prepare(std::string("SELECT ") + kCols + " FROM tasks WHERE task_id = ?1"); if (!st) return std::unexpected(st.error()); if (auto r = st->bind(1, task_id); !r) return std::unexpected(r.error()); auto row = st->step(); if (!row) return std::unexpected(row.error()); if (!*row) return std::optional{}; return std::optional{read_row(*st)}; } DbResult Tasks::list(const std::optional& filter, const std::optional& sort, std::int64_t offset, std::int64_t limit) { std::string where; std::vector params; // bound 1:1 with the '?' placeholders, in order auto clause = [&](std::string c) { where += where.empty() ? " WHERE " : " AND "; where += std::move(c); }; if (filter) { if (filter->states && !filter->states->empty()) { std::string in; for (const auto st : *filter->states) { in += in.empty() ? "" : ","; in += "'"; in += proto::to_string(st); // an enum spelling, never user input in += "'"; } clause("state IN (" + in + ")"); } if (filter->categoryId) { clause("category_id = ?"); params.push_back(*filter->categoryId); } if (filter->queueId) { clause("queue_id = ?"); params.push_back(*filter->queueId); } if (filter->query) { clause("(instr(lower(filename), lower(?)) > 0 OR instr(lower(url), lower(?)) > 0)"); params.push_back(*filter->query); params.push_back(*filter->query); } if (filter->addedAfter) { clause("created_at >= ?"); params.push_back(*filter->addedAfter); } if (filter->addedBefore) { clause("created_at < ?"); params.push_back(*filter->addedBefore); } } std::int64_t total = 0; { auto st = db_.prepare("SELECT count(*) FROM tasks" + where); if (!st) return std::unexpected(st.error()); for (std::size_t i = 0; i < params.size(); ++i) if (auto r = st->bind(static_cast(i + 1), std::string_view(params[i])); !r) return std::unexpected(r.error()); auto row = st->step(); if (!row) return std::unexpected(row.error()); if (*row) total = st->column_int(0); } // Default sort is newest-first; an explicit sort is a whitelisted column + direction. // NULLs sort last in both directions so an unsized / unqueued task never floats up. std::string order = "created_at DESC"; if (sort) { const char* col = sort_column(sort->field); const bool desc = sort->direction == proto::TaskSortDirection::Desc; order = std::string(col) + " IS NULL, " + col + (desc ? " DESC" : " ASC"); } const std::int64_t lim = limit > 0 ? limit : 500; const std::int64_t off = offset > 0 ? offset : 0; Page page; page.total = total; { auto st = db_.prepare(std::string("SELECT ") + kCols + " FROM tasks" + where + " ORDER BY " + order + " LIMIT ? OFFSET ?"); if (!st) return std::unexpected(st.error()); int n = 1; for (const auto& p : params) if (auto r = st->bind(n++, std::string_view(p)); !r) return std::unexpected(r.error()); if (auto r = st->bind(n++, lim); !r) return std::unexpected(r.error()); if (auto r = st->bind(n++, off); !r) return std::unexpected(r.error()); for (;;) { auto row = st->step(); if (!row) return std::unexpected(row.error()); if (!*row) break; page.rows.push_back(read_row(*st)); } } return page; } DbResult Tasks::set_state(std::string_view task_id, std::string_view state, const std::optional& pause_reason) { auto st = db_.prepare( "UPDATE tasks SET state = ?2, pause_reason = ?3 WHERE task_id = ?1"); if (!st) return std::unexpected(st.error()); if (auto r = st->bind(1, task_id); !r) return std::unexpected(r.error()); if (auto r = st->bind(2, state); !r) return std::unexpected(r.error()); const bool paused = state == "paused"; if (auto r = paused && pause_reason ? st->bind(3, std::string_view(*pause_reason)) : st->bind_null(3); !r) return std::unexpected(r.error()); if (auto r = st->step(); !r) return std::unexpected(r.error()); return sqlite3_changes(db_.raw()) > 0; } DbResult Tasks::remove(std::string_view task_id) { auto st = db_.prepare("DELETE FROM tasks WHERE task_id = ?1"); if (!st) return std::unexpected(st.error()); if (auto r = st->bind(1, task_id); !r) return std::unexpected(r.error()); if (auto r = st->step(); !r) return std::unexpected(r.error()); return sqlite3_changes(db_.raw()) > 0; } DbResult Tasks::update_progress(std::string_view task_id, std::int64_t downloaded_bytes, std::int64_t speed_bps, std::int64_t eff_segments, std::int64_t eff_buffer_bytes) { auto st = db_.prepare( "UPDATE tasks SET downloaded_bytes = ?2, speed_bps = ?3, eff_segments = ?4, " "eff_buffer_bytes = ?5 WHERE task_id = ?1"); if (!st) return std::unexpected(st.error()); if (auto r = st->bind(1, task_id); !r) return std::unexpected(r.error()); if (auto r = st->bind(2, downloaded_bytes); !r) return std::unexpected(r.error()); if (auto r = st->bind(3, speed_bps); !r) return std::unexpected(r.error()); if (auto r = st->bind(4, eff_segments); !r) return std::unexpected(r.error()); if (auto r = st->bind(5, eff_buffer_bytes); !r) return std::unexpected(r.error()); if (auto r = st->step(); !r) return std::unexpected(r.error()); return sqlite3_changes(db_.raw()) > 0; } DbResult Tasks::set_probe_result(std::string_view task_id, const ProbeFields& f) { auto st = db_.prepare( "UPDATE tasks SET size_bytes = ?2, resumable = ?3, etag = ?4, last_modified = ?5, " "content_type = ?6, effective_url = ?7 WHERE task_id = ?1"); if (!st) return std::unexpected(st.error()); if (auto r = st->bind(1, task_id); !r) return std::unexpected(r.error()); if (auto r = bind_opt(*st, 2, f.size_bytes); !r) return std::unexpected(r.error()); if (auto r = st->bind(3, static_cast(f.resumable)); !r) return std::unexpected(r.error()); if (auto r = bind_opt(*st, 4, f.etag); !r) return std::unexpected(r.error()); if (auto r = bind_opt(*st, 5, f.last_modified); !r) return std::unexpected(r.error()); if (auto r = bind_opt(*st, 6, f.content_type); !r) return std::unexpected(r.error()); if (auto r = bind_opt(*st, 7, f.effective_url); !r) return std::unexpected(r.error()); if (auto r = st->step(); !r) return std::unexpected(r.error()); return sqlite3_changes(db_.raw()) > 0; } DbResult Tasks::set_final_bytes(std::string_view task_id, std::int64_t bytes) { auto st = db_.prepare( "UPDATE tasks SET downloaded_bytes = ?2, size_bytes = COALESCE(size_bytes, ?2) " "WHERE task_id = ?1"); if (!st) return std::unexpected(st.error()); if (auto r = st->bind(1, task_id); !r) return std::unexpected(r.error()); if (auto r = st->bind(2, bytes); !r) return std::unexpected(r.error()); if (auto r = st->step(); !r) return std::unexpected(r.error()); return sqlite3_changes(db_.raw()) > 0; } DbResult Tasks::count() { auto st = db_.prepare("SELECT count(*) FROM tasks"); if (!st) return std::unexpected(st.error()); auto row = st->step(); if (!row) return std::unexpected(row.error()); return (*row) ? st->column_int(0) : 0; } DbResult Tasks::apply_update(std::string_view task_id, const UpdatePatch& patch) { // One UPDATE per present field: simplest thing that's obviously correct for a // single-row edit with ~9 independent optional fields, and it means a field the // caller didn't touch is never rewritten with its own unchanged value (matters for // no-op-detection callers, though download.update doesn't currently need that). bool touched_any = false; auto run = [&](const char* sql, auto&& binder) -> DbResult { auto st = db_.prepare(sql); if (!st) return std::unexpected(st.error()); if (auto r = st->bind(1, task_id); !r) return std::unexpected(r.error()); if (auto r = binder(*st); !r) return std::unexpected(r.error()); if (auto r = st->step(); !r) return std::unexpected(r.error()); touched_any = true; return {}; }; if (patch.save_dir && patch.filename) { if (auto r = run("UPDATE tasks SET save_dir = ?2, filename = ?3 WHERE task_id = ?1", [&](Stmt& s) { if (auto b = s.bind(2, std::string_view(*patch.save_dir)); !b) return b; return s.bind(3, std::string_view(*patch.filename)); }); !r) return std::unexpected(r.error()); } if (patch.category_id) { if (auto r = run("UPDATE tasks SET category_id = ?2 WHERE task_id = ?1", [&](Stmt& s) { return s.bind(2, std::string_view(*patch.category_id)); }); !r) return std::unexpected(r.error()); } if (patch.queue_id) { if (auto r = run("UPDATE tasks SET queue_id = ?2, queue_position = ?3 WHERE task_id = ?1", [&](Stmt& s) { if (auto b = s.bind(2, std::string_view(*patch.queue_id)); !b) return b; return patch.queue_position ? s.bind(3, *patch.queue_position) : s.bind_null(3); }); !r) return std::unexpected(r.error()); } if (patch.description) { if (auto r = run("UPDATE tasks SET description = ?2 WHERE task_id = ?1", [&](Stmt& s) { return s.bind(2, std::string_view(*patch.description)); }); !r) return std::unexpected(r.error()); } if (patch.req_segments) { if (auto r = run("UPDATE tasks SET req_segments = ?2 WHERE task_id = ?1", [&](Stmt& s) { return s.bind(2, *patch.req_segments); }); !r) return std::unexpected(r.error()); } if (patch.req_buffer_bytes) { if (auto r = run("UPDATE tasks SET req_buffer_bytes = ?2 WHERE task_id = ?1", [&](Stmt& s) { return s.bind(2, *patch.req_buffer_bytes); }); !r) return std::unexpected(r.error()); } if (patch.checksum_algo && patch.checksum_value) { if (auto r = run( "UPDATE tasks SET checksum_algo = ?2, checksum_value = ?3 WHERE task_id = ?1", [&](Stmt& s) { if (auto b = s.bind(2, std::string_view(*patch.checksum_algo)); !b) return b; return s.bind(3, std::string_view(*patch.checksum_value)); }); !r) return std::unexpected(r.error()); } return touched_any; } DbResult Tasks::set_url(std::string_view task_id, std::string_view url) { auto st = db_.prepare("UPDATE tasks SET url = ?2 WHERE task_id = ?1"); if (!st) return std::unexpected(st.error()); if (auto r = st->bind(1, task_id); !r) return std::unexpected(r.error()); if (auto r = st->bind(2, url); !r) return std::unexpected(r.error()); if (auto r = st->step(); !r) return std::unexpected(r.error()); return sqlite3_changes(db_.raw()) > 0; } DbResult Tasks::has_active_duplicate(std::string_view url) { auto st = db_.prepare( "SELECT 1 FROM tasks WHERE url = ?1 " "AND state NOT IN ('complete','failed','cancelled') LIMIT 1"); if (!st) return std::unexpected(st.error()); if (auto b = st->bind(1, url); !b) return std::unexpected(b.error()); auto row = st->step(); if (!row) return std::unexpected(row.error()); return *row; } proto::TaskSummary to_summary(const TaskRow& r) { proto::TaskSummary s; s.taskId = r.task_id; s.filename = r.filename; s.saveDir = r.save_dir; s.url = r.url; s.effectiveUrl = r.effective_url; s.sizeBytes = r.size_bytes; s.downloadedBytes = r.downloaded_bytes; if (auto st = proto::parse_TaskState(r.state)) s.state = *st; s.speedBps = r.speed_bps; s.resumable = r.resumable; // TaskSummary.segments is minimum:1, always -- even a task that has never connected // reports the count it WOULD use (its requested value, or the frozen default), // never the "not started yet" placeholder of 0 that used to leak onto the wire. s.segments = r.eff_segments > 0 ? r.eff_segments : r.req_segments && *r.req_segments > 0 ? *r.req_segments : 8; if (s.segments < 1) s.segments = 1; if (s.segments > 32) s.segments = 32; s.categoryId = r.category_id; s.queueId = r.queue_id; s.queuePosition = r.queue_position; s.description = r.description; s.createdAt = r.created_at; s.lastTryAt = r.last_try_at; s.completedAt = r.completed_at; if (r.error_code) { proto::TaskError e; if (auto c = proto::parse_TaskErrorCode(*r.error_code)) e.code = *c; e.message = r.error_message.value_or(""); e.httpStatus = r.error_http_status; e.retryable = r.error_retryable.value_or(false); e.attempt = r.error_attempt; e.nextRetryAt = r.error_next_retry_at; s.error = std::move(e); } return s; } } // namespace velox::daemon::store