Stage 8 of the CORE build order: the bodies behind the DownloadSpec / callback API reviewed in core/docs/engine-api-m1.md. Wires probe -> segment workers -> WriteBuffer -> SparseFile -> .veloxpart.meta -> retry/backoff -> SegmentBudget -> RateLimiter -> callbacks into one event-driven machine. - Engine (src/engine.cpp): owns HttpClient, Prober, SegmentBudget, RateLimiter and one timer jthread (min-heap of scheduled fns). start() builds a task and returns a DownloadHandle; ~Impl quiesces every task before joining the timer so no callback fires during teardown. - DownloadTaskState (src/task/download_task.cpp): one `mu` task lock; a shared_mutex over the worker map for the curl write path; callbacks collected under `mu` and fired after release via a separate deferred queue; weak_from_this() in every async hop. State machine over the CORE-owned EngineState subset, auto-pause on 401/407 and on a 200 where 206 was expected, validated resume via If-Range. - digest (src/task/digest.cpp): OpenSSL EVP hash_file() for the optional post-download checksum; links OpenSSL::Crypto PRIVATE. - Segmenter::release_segment(): hand a paused segment back to the pool unassigned so resume's assign_slot() picks it up instead of splitting a still-"assigned" range and orphaning its front half. - DownloadHandle now names the real control block (vdm::task:: DownloadTaskState, defined only in the engine TU) via a namespace-scope fwd decl and a public-but-effectively-engine-only ctor, replacing the nested State/friend pair. Every public signature is unchanged; DAEMON (vdm-79) confirmed sched/ names only the public API. Fixes found while building the end-to-end suite (tests/task/engine_test.cpp, 9 cases against tools/testserver, green under ASan/UBSan and TSan): - a dropped connection lost its unflushed WriteBuffer tail while advance() had already counted those bytes as done -> a retry resumed past an unwritten hole. Flush on the failure path. - when the byte counters hit total while other workers were still live, teardown dropped their buffered tails. Now: cancel them and let each worker's own seg_finished drain it (the `assembling` state), last one starts verification -- no cross-thread buffer access. - seg_head() let a 401 with credentials present abort before libcurl's resend; now it proceeds once and acts on the final status. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01HPPSGhiArbvQgwC2DNiURS
179 lines
8.1 KiB
C++
179 lines
8.1 KiB
C++
// vdm/segment/segmenter.hpp — per-download range management and dynamic segment stealing.
|
|
//
|
|
// docs/04 §3. Owns the split of [0, total_size) for one download: the initial layout, a
|
|
// split when a slot is granted (`assign_slot`), a *steal* when a worker finishes and may
|
|
// keep its slot (`on_complete` — take the second half of the largest remaining range),
|
|
// and a re-split of an orphaned range when a segment fails 3x on the same host with a
|
|
// mirror available (`on_failed` -> requeue).
|
|
//
|
|
// Thread model: one mutex — the segmenter's — *is* "the task lock" (docs/04 §3: the steal
|
|
// is "atomic under the task lock"). Every method takes it. `advance()` and the per-worker
|
|
// accessors are called once per buffer flush (a few Hz per segment), not from the curl
|
|
// write callback, so a lock there is free; the no-lock/no-alloc rule is about that
|
|
// callback and its ring buffer. Segment fields stay std::atomic so the store type is
|
|
// trivially relocatable and reads never tear. The store is a std::deque so a steal's
|
|
// push_back never moves an existing record.
|
|
//
|
|
// This header compiles standalone.
|
|
|
|
#ifndef VDM_SEGMENT_SEGMENTER_HPP
|
|
#define VDM_SEGMENT_SEGMENTER_HPP
|
|
|
|
#include <atomic>
|
|
#include <cstdint>
|
|
#include <deque>
|
|
#include <mutex>
|
|
#include <optional>
|
|
#include <vector>
|
|
|
|
namespace vdm::segment {
|
|
|
|
inline constexpr std::uint64_t kDefaultMinSegmentBytes = 1u << 20; // 1 MiB (docs/04 §3)
|
|
inline constexpr std::uint32_t kDefaultSegments = 8;
|
|
inline constexpr std::uint32_t kMaxSegments = 32;
|
|
|
|
enum class SegState : std::uint8_t {
|
|
idle, // range assigned, no worker connected yet
|
|
connecting,
|
|
downloading,
|
|
stalled, // low-speed; still holds its slot
|
|
complete,
|
|
failed, // gave up (orphaned; range requeued or lost to a steal)
|
|
};
|
|
|
|
// A flat, copyable view of one segment record. Ranges are absolute byte offsets,
|
|
// **inclusive** on both ends (contract Segment.endByte / ADR 0010).
|
|
struct SegmentView {
|
|
std::uint32_t index = 0;
|
|
std::uint64_t start = 0;
|
|
std::uint64_t end = 0;
|
|
std::uint64_t completed = 0;
|
|
SegState state = SegState::idle;
|
|
std::uint32_t consecutive_failures = 0;
|
|
|
|
[[nodiscard]] std::uint64_t length() const noexcept {
|
|
return end >= start ? end - start + 1 : 0;
|
|
}
|
|
[[nodiscard]] std::uint64_t remaining() const noexcept {
|
|
return length() - (completed < length() ? completed : length());
|
|
}
|
|
bool operator==(const SegmentView &) const = default;
|
|
};
|
|
|
|
// One resumed range, as read back from .veloxpart.meta (kept independent of meta/ so this
|
|
// header stands alone).
|
|
struct ResumedRange {
|
|
std::uint64_t start = 0;
|
|
std::uint64_t end = 0;
|
|
std::uint64_t completed = 0;
|
|
};
|
|
|
|
enum class FailAction {
|
|
retry, // same range, backoff (owned by the task/state machine)
|
|
requeue, // 3x connection failure + a mirror exists: orphan the remaining range and
|
|
// re-split it; the segment's slot is released
|
|
};
|
|
|
|
class Segmenter {
|
|
public:
|
|
// total_size 0 => unknown (chunked): forces a single segment. resumable == false also
|
|
// forces a single segment (docs/04 §3: "Non-resumable servers -> exactly 1 segment").
|
|
Segmenter(std::uint64_t total_size, std::uint32_t requested_segments, bool resumable,
|
|
std::uint64_t min_segment_bytes = kDefaultMinSegmentBytes);
|
|
|
|
// Resume: rebuild from a persisted segment table. Ranges must tile [0, total_size)
|
|
// with no gap or overlap; a malformed table falls back to a single segment.
|
|
Segmenter(std::uint64_t total_size, std::uint32_t requested_segments,
|
|
const std::vector<ResumedRange> &resumed, bool resumable,
|
|
std::uint64_t min_segment_bytes = kDefaultMinSegmentBytes);
|
|
|
|
Segmenter(const Segmenter &) = delete;
|
|
Segmenter &operator=(const Segmenter &) = delete;
|
|
|
|
// The count this download would use with an unlimited budget. 1 when non-resumable /
|
|
// unknown size; otherwise min(requested, floor(total / min_segment_bytes), 32).
|
|
[[nodiscard]] std::uint32_t target_segment_count() const noexcept { return target_count_; }
|
|
[[nodiscard]] std::uint64_t total_size() const noexcept { return total_size_; }
|
|
|
|
// Give a worker something to download. Called when the budget grants a slot. Prefers
|
|
// an orphaned range from a requeue; otherwise splits the largest remaining range in
|
|
// half and hands back the tail. Returns nullopt when the target count is already met
|
|
// or nothing splits to >= min_segment_bytes. `state` of the returned segment is
|
|
// `connecting`.
|
|
[[nodiscard]] std::optional<std::uint32_t> assign_slot();
|
|
|
|
// A worker finished its range. If `may_steal` is false the slot is being yielded —
|
|
// returns nullopt and the caller releases the slot to the budget. If true and a
|
|
// remaining range splits to >= min_segment_bytes, steals its second half: returns a
|
|
// NEW segment index for the same worker to continue on (slot-neutral). Otherwise
|
|
// nullopt (nothing to steal -> release).
|
|
[[nodiscard]] std::optional<std::uint32_t> on_complete(std::uint32_t idx, bool may_steal);
|
|
|
|
// A worker's segment errored. `connection_error` distinguishes a transport failure
|
|
// (reset/timeout/refused) from an HTTP/content one. Returns requeue only on the 3rd
|
|
// consecutive connection error when `has_mirror`; on requeue the remaining range is
|
|
// orphaned for assign_slot() to re-split and the segment is marked failed.
|
|
FailAction on_failed(std::uint32_t idx, bool connection_error, bool has_mirror);
|
|
|
|
// A successful (re)connection resets the consecutive-failure counter.
|
|
void note_connected(std::uint32_t idx);
|
|
|
|
// Progress from the write path. Lock-free. `bytes` is the absolute completed count
|
|
// within the segment; clamped to the segment length.
|
|
void advance(std::uint32_t idx, std::uint64_t bytes) noexcept;
|
|
|
|
// Per-segment fields for a worker. A worker reads `segment_end` before every write so
|
|
// a concurrent steal that shrank its range stops it cleanly. `segment_start` never
|
|
// changes for a given index.
|
|
[[nodiscard]] std::uint64_t segment_start(std::uint32_t idx) const noexcept;
|
|
[[nodiscard]] std::uint64_t segment_end(std::uint32_t idx) const noexcept;
|
|
[[nodiscard]] std::uint64_t segment_completed(std::uint32_t idx) const noexcept;
|
|
[[nodiscard]] SegState segment_state(std::uint32_t idx) const noexcept;
|
|
void set_segment_state(std::uint32_t idx, SegState s) noexcept;
|
|
|
|
// Hand a segment back to the pool without touching its `completed`: it becomes an
|
|
// unassigned idle range that the next assign_slot() picks up (used on pause).
|
|
void release_segment(std::uint32_t idx) noexcept;
|
|
|
|
// Sum of bytes done across every segment (active + already complete). Locks.
|
|
[[nodiscard]] std::uint64_t downloaded() const;
|
|
[[nodiscard]] bool all_complete() const;
|
|
|
|
// Every segment record, for TaskDetail projection and the .veloxpart.meta writer.
|
|
[[nodiscard]] std::vector<SegmentView> snapshot() const;
|
|
|
|
private:
|
|
struct Seg {
|
|
std::uint32_t index;
|
|
std::uint64_t start;
|
|
std::atomic<std::uint64_t> end;
|
|
std::atomic<std::uint64_t> completed{0};
|
|
std::atomic<SegState> state{SegState::idle};
|
|
std::uint32_t consecutive_failures = 0;
|
|
bool assigned = false; // a worker holds this segment right now
|
|
|
|
Seg(std::uint32_t i, std::uint64_t s, std::uint64_t e) : index(i), start(s), end(e) {}
|
|
};
|
|
|
|
void compute_target(std::uint32_t requested);
|
|
std::uint32_t add_seg_locked(std::uint64_t start, std::uint64_t end, std::uint64_t completed,
|
|
SegState state, bool assigned);
|
|
std::uint32_t split_largest_remaining_locked(); // returns new index, or UINT32_MAX
|
|
[[nodiscard]] std::uint64_t remaining_of_locked(const Seg &s) const noexcept;
|
|
[[nodiscard]] std::uint32_t assigned_count_locked() const noexcept;
|
|
|
|
mutable std::mutex mu_;
|
|
std::deque<Seg> segs_;
|
|
std::vector<ResumedRange> orphans_; // requeued ranges awaiting re-split
|
|
|
|
std::uint64_t total_size_;
|
|
std::uint64_t min_seg_;
|
|
bool resumable_;
|
|
std::uint32_t target_count_ = 1;
|
|
std::uint32_t next_index_ = 0;
|
|
};
|
|
|
|
} // namespace vdm::segment
|
|
|
|
#endif // VDM_SEGMENT_SEGMENTER_HPP
|