core: segment/segmenter + segment/budget (stage 6)

vdm/ids.hpp — TaskId, an opaque engine handle (DAEMON keeps the wire
UUID <-> TaskId map; the engine never sees the UUID).

segment/segmenter — per-download range management (docs/04 §3). Initial
lazy split; assign_slot() splits the largest remaining range when the
budget grants a slot; on_complete(may_steal) either *steals* the second
half of the largest remaining range for the same worker (slot-neutral) or
returns nullopt so the caller *yields* the slot (ADR 0011 A1); on_failed()
returns requeue only on the 3rd consecutive connection error with a mirror
present — the remaining range is orphaned and re-split. Non-resumable or
unknown-size => exactly 1 segment; never split below min_segment_bytes
(1 MiB). Resume ctor rebuilds from a persisted table (falls back to a
fresh layout if it doesn't tile [0,total)). One mutex == "the task lock";
segment fields are std::atomic and the store is a std::deque so a steal's
append never moves a worker's record.

segment/budget — the global allocator (ADR 0011). Owns exactly one
ceiling (maxActiveSegments) and min-1-before-seconds fairness: a two-pass
allocation (guarantee pass gives every wanting task 1 slot in DAEMON's
priority order, then a growth pass round-robins the rest up to each
task's effective cap = min(per_task_cap, host cap, 1 if non-resumable)),
recomputed from scratch on every edge so a live set_max_active_segments
cut naturally yields the excess lowest-priority-first, never a
mid-segment kill. DAEMON-facing surface exactly as promised in
daemon/docs/core-requests-m1.md / ADR 0011: budget(), segments_active(),
starved_tasks(), starved_since(), set_max_active_segments (drain),
set_host_segment_cap, set_task_order, on_budget_changed (a jthread
coalesces at <=4 Hz; the tasks_starved 0<->nonzero edge fires
immediately). Callbacks are copied out and run after the lock is
dropped.

Tests: segmenter split/steal/requeue/resume math + a concurrent
steal-and-advance run; budget min-1 under a tight budget, round-robin
growth, host-cap and non-resumable clamps, live-lower shedding
lowest-priority-first, starvation below the task count, starved-edge
notification, and a concurrent set_want hammer. Green under ASan/UBSan;
the steal path and the budget green under TSan.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01HPPSGhiArbvQgwC2DNiURS
This commit is contained in:
2026-09-10 15:14:14 +04:00
co-authored by Claude Sonnet 5
parent 1fabaf805d
commit 5d81b4cdae
9 changed files with 1485 additions and 0 deletions
+38
View File
@@ -0,0 +1,38 @@
// vdm/ids.hpp — opaque engine-internal identifiers.
//
// TaskId is CORE's handle for a download. DAEMON owns the wire UUID and keeps a
// UUID <-> TaskId map; CORE never sees the UUID (layering rule — no wire types in the
// engine). Assigned by CORE when DAEMON registers a task.
//
// This header compiles standalone.
#ifndef VDM_IDS_HPP
#define VDM_IDS_HPP
#include <chrono>
#include <compare>
#include <cstddef>
#include <cstdint>
#include <functional>
namespace vdm {
struct TaskId {
std::uint64_t value = 0;
[[nodiscard]] constexpr bool valid() const noexcept { return value != 0; }
friend constexpr auto operator<=>(const TaskId &, const TaskId &) = default;
};
using SteadyTime = std::chrono::steady_clock::time_point;
} // namespace vdm
template <>
struct std::hash<vdm::TaskId> {
std::size_t operator()(vdm::TaskId id) const noexcept {
return std::hash<std::uint64_t>{}(id.value);
}
};
#endif // VDM_IDS_HPP
+137
View File
@@ -0,0 +1,137 @@
// vdm/segment/budget.hpp — the global segment allocator (ADR 0011).
//
// One instance per engine. It owns exactly one ceiling — `maxActiveSegments`, in segment
// units — and the min-1-before-seconds fairness rule (ADR 0011 §3). DAEMON's scheduler
// counts tasks and never touches this except through the read-outs and setters below;
// CORE's download_task (stage 8) drives the task-facing half.
//
// Fairness (two-pass, recomputed on every edge): a guarantee pass gives every task that
// wants a slot and holds zero exactly one, in DAEMON's priority order; then a growth pass
// round-robins the remainder up to each task's effective cap. A task's target can drop
// below what it holds (a lower-priority task shedding for a higher-priority arrival, or a
// live `set_max_active_segments` cut) — the task then *yields*: it releases a slot at its
// next segment boundary, never mid-segment (ADR 0011 A1). A finishing worker whose target
// still covers it *steals* instead (slot-neutral). That steal-vs-yield choice lives in
// stage 8, driven by comparing this budget's target to the task's live worker count.
//
// This header compiles standalone.
#ifndef VDM_SEGMENT_BUDGET_HPP
#define VDM_SEGMENT_BUDGET_HPP
#include <chrono>
#include <condition_variable>
#include <cstdint>
#include <functional>
#include <mutex>
#include <optional>
#include <span>
#include <string>
#include <thread>
#include <unordered_map>
#include <vector>
#include "vdm/ids.hpp"
namespace vdm::segment {
class SegmentBudget {
public:
struct EngineBudget {
std::uint32_t total = 0; // == maxActiveSegments
std::uint32_t active = 0; // slots held across all tasks
std::uint32_t tasks_starved = 0; // running tasks holding zero slots (ADR 0011 §3.6)
bool operator==(const EngineBudget &) const = default;
};
struct Options {
std::uint32_t max_active_segments = 32;
std::chrono::milliseconds notify_period{250}; // <=4 Hz, per event.task.progress
};
SegmentBudget(); // default Options
explicit SegmentBudget(Options opts);
~SegmentBudget();
SegmentBudget(const SegmentBudget &) = delete;
SegmentBudget &operator=(const SegmentBudget &) = delete;
// ---- task-facing (download_task, stage 8) -------------------------------------------
struct TaskParams {
std::string host; // key for the per-host segment cap
std::uint32_t per_task_cap = 1; // min(spec.segments ?? maxSegmentsPerDownload, 32)
bool resumable = false; // false => effective cap forced to 1
};
// Called with the new absolute slot target for the task. Runs on a budget thread (or
// the caller's, for the edge case) — must not block and must not re-enter the budget
// beyond confirm_slot()/release_slot(). The task starts or yields workers to match.
using SlotTargetFn = std::function<void(std::uint32_t target)>;
void register_task(TaskId id, const TaskParams &params, SlotTargetFn on_target);
void deregister_task(TaskId id); // pause / complete / cancel
// How many slots the task could use right now (0 .. per_task_cap): incomplete
// segments it has range for. 0 while retry_wait / assembling / verifying / paused —
// which is exactly why those states are never counted as starvation.
void set_want(TaskId id, std::uint32_t want);
// A worker actually started on a granted slot. false => the target was cut in the
// race and the worker must not start.
[[nodiscard]] bool confirm_slot(TaskId id);
// A held slot is free: segment complete with no steal, failed, paused, or yielded.
void release_slot(TaskId id);
// ---- DAEMON-facing (sched/) -------------------------------------------------------
void set_max_active_segments(std::uint32_t n); // drain-not-kill (ADR 0011 §2)
void set_host_segment_cap(std::string host, std::uint32_t cap); // 0 clears
void set_task_order(std::span<const TaskId> priority_order); // pushed on change
[[nodiscard]] EngineBudget budget() const;
[[nodiscard]] std::uint32_t segments_active(TaskId id) const;
[[nodiscard]] std::vector<TaskId> starved_tasks() const;
[[nodiscard]] std::optional<SteadyTime> starved_since(TaskId id) const;
void on_budget_changed(std::function<void(EngineBudget)> cb);
private:
struct Task {
std::string host;
std::uint32_t per_task_cap = 1;
bool resumable = false;
std::uint32_t want = 0;
std::uint32_t held = 0;
std::uint32_t target = 0; // last published
SlotTargetFn on_target;
std::optional<SteadyTime> starved_since;
};
// A unit of deferred work: callbacks are copied out here so the public entry points
// can invoke them AFTER dropping mu_.
struct Plan {
std::vector<std::pair<SlotTargetFn, std::uint32_t>> targets;
std::optional<std::pair<std::function<void(EngineBudget)>, EngineBudget>> notify_now;
};
Plan reallocate_locked();
static void run(Plan &p);
[[nodiscard]] std::uint32_t effective_cap_locked(const Task &t) const;
[[nodiscard]] EngineBudget snapshot_locked() const;
void notifier_loop(std::stop_token st);
mutable std::mutex mu_;
std::unordered_map<TaskId, Task> tasks_;
std::vector<TaskId> order_;
std::unordered_map<std::string, std::uint32_t> host_caps_;
std::uint32_t max_active_;
std::uint32_t active_ = 0;
std::function<void(EngineBudget)> on_changed_;
EngineBudget last_notified_;
std::uint32_t last_starved_ = 0;
bool dirty_ = false;
std::chrono::milliseconds notify_period_;
std::condition_variable notify_cv_;
std::jthread notifier_;
};
} // namespace vdm::segment
#endif // VDM_SEGMENT_BUDGET_HPP
+174
View File
@@ -0,0 +1,174 @@
// 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;
// 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