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:
@@ -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
|
||||
@@ -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 ¶ms, 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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user