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
This commit is contained in:
2026-09-11 12:11:19 +04:00
co-authored by Claude Sonnet 5
parent 824fa481bb
commit a967eca669
14 changed files with 448 additions and 60 deletions
+19 -2
View File
@@ -8,6 +8,7 @@
#include "fs/safepath.hpp"
#include "store/categories.hpp"
#include "store/queues.hpp"
#include "store/segments.hpp"
#include "store/settings.hpp"
#include "store/tasks.hpp"
#include "util/time.hpp"
@@ -160,6 +161,13 @@ VeloxDispatcher::on_download_add(const proto::DownloadSpec& spec) {
row.description = spec.description;
row.req_segments = spec.segments;
row.req_buffer_bytes = spec.bufferBytes;
// eff_segments starts at the requested-or-default count, never 0: TaskSummary.segments
// is minimum:1 on the wire even before this task has connected its first segment (a
// task that has never run does not get a free pass to violate its own contract).
row.eff_segments =
spec.segments.value_or(settings.get_int("connection.maxSegmentsPerDownload"));
if (row.eff_segments < 1) row.eff_segments = 1;
if (row.eff_segments > 32) row.eff_segments = 32;
if (spec.checksum) {
row.checksum_algo = std::string(proto::to_string(spec.checksum->algorithm));
row.checksum_value = spec.checksum->value;
@@ -237,8 +245,17 @@ VeloxDispatcher::on_download_get(const proto::DownloadGetParams& params) {
const store::TaskRow& row = **got;
proto::TaskDetail d;
d.summary = store::to_summary(row);
// segmentDetail stays empty until the engine has segmented the task — the schema
// permits that ("empty before the task has been segmented").
// Empty until the engine has segmented the task — the schema permits that ("empty
// before the task has been segmented"); otherwise exactly summary.segments entries,
// kept current by the same progress tick that updates the byte counters.
store::Segments segments(db_);
if (auto segs = segments.list(row.task_id))
d.segmentDetail = std::move(*segs);
else
return std::unexpected(proto::HandlerError{
proto::ErrorCode::InternalError, "download.get: " + segs.error().message});
d.mime = row.content_type;
d.bufferBytes = row.req_buffer_bytes;
d.effectiveBufferBytes = row.eff_buffer_bytes;