tracking: one ledger, one authority — prune protection and replace-vs-add answered from the same records, fail-safe on unreadable state
This commit is contained in:
@@ -10,6 +10,7 @@ add_subdirectory(wire)
|
||||
add_subdirectory(audio)
|
||||
add_subdirectory(model)
|
||||
add_subdirectory(capture)
|
||||
add_subdirectory(tracking)
|
||||
add_subdirectory(reclaim)
|
||||
add_subdirectory(version)
|
||||
add_subdirectory(view)
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
|
||||
Pure (REAPER-free, unit-tested outside the DAW) sample-index models: the single-bank
|
||||
index, the multi-bank registry that wraps it, its JSON codec, the gap-preserving
|
||||
per-bank slot carrier, the owned-file manifest, and the capture-recipe fingerprint.
|
||||
No REAPER types, no filesystem I/O — see root `CLAUDE.md` for the pure-core/shell
|
||||
split this directory sits on.
|
||||
per-bank slot carrier, and the capture-recipe fingerprint. No REAPER types, no
|
||||
filesystem I/O — see root `CLAUDE.md` for the pure-core/shell split this directory
|
||||
sits on.
|
||||
|
||||
## Invariants
|
||||
|
||||
@@ -69,8 +69,7 @@ split this directory sits on.
|
||||
- `bank_model` — `Sample` metadata struct + `BankIndex` (add/remove/query/tier/dedup-by-hash + JSON round-trip). Test it hard — it is the heart.
|
||||
- `bank_book` — multi-bank registry: an ordered set of banks each wrapping a `BankIndex`. **Pool privileges (un-deletable/un-renamable/un-evacuable, never zero banks) enforced in-model.** Owns create/rename/reorder/delete of named banks, active-bank id, and index-only move/copy/remove of a sample between banks. The JSON round-trip lives in the sibling `bank_book_json` TU (Q-W5 split; serialize/deserialize via a private static `nameKey` seam) — one model, one codec, same public surface.
|
||||
- `slot_map` (`core/model`) — the gap-preserving display-position carrier for ONE bank (sample id → slot, ≥0), extracted from `bank_book` (Q-W1): append/remove/reorder (insert-before-and-shift)/`reconcile` against live membership, `resetDense` migration seed, JSON round-trip. Wrapped (not merged) by `bank_book`.
|
||||
- `owned_manifest` — the set of project-relative files the capture path itself created, persisted under the `"owned_files"` ext-state key, so the prune path can distinguish the bank system's own orphans from hand-dropped files.
|
||||
- `provenance` — capture-recipe fingerprint: build/encode/compare a `rsprov1` fingerprint of scope, range, tail, rate/channels, track GUIDs, and FX-chain identity. **A thin reproducibility fingerprint — NOT a serialized chain to restore.**
|
||||
- `provenance` — capture-recipe fingerprint: build/encode/compare a `rsprov1` fingerprint of scope, range, tail, rate/channels, track GUIDs, and FX-chain identity. **A thin reproducibility fingerprint — NOT a serialized chain to restore.** It is the recipe facet of the tracking system whose authority lives in `core/tracking`; the lineage facet (which file derives from which) is the ledger's, not the `Sample`'s.
|
||||
|
||||
## Gotchas
|
||||
|
||||
|
||||
@@ -9,9 +9,6 @@ reasampler_pure_library(bank_book
|
||||
LINK PUBLIC bank_model slot_map PRIVATE json)
|
||||
reasampler_test(bank_book LINK bank_book)
|
||||
|
||||
reasampler_pure_library(owned_manifest SOURCES owned_manifest.cpp LINK PRIVATE json)
|
||||
reasampler_test(owned_manifest LINK owned_manifest)
|
||||
|
||||
reasampler_pure_library(provenance SOURCES provenance.cpp LINK PRIVATE wire)
|
||||
# bank_model: the test proves the recorded recipe survives the Sample-JSON round-trip.
|
||||
reasampler_test(provenance LINK provenance bank_model)
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
#include "core/model/owned_manifest.h"
|
||||
|
||||
#include <cctype>
|
||||
|
||||
#include "core/json/json.h"
|
||||
|
||||
// owned_manifest implementation. JSON shape is a single object with one string
|
||||
// array:
|
||||
//
|
||||
// {"owned":["reasampler_bank/a.wav","reasampler_bank/b.wav"]}
|
||||
|
||||
namespace reasampler::model {
|
||||
|
||||
// -- path invariant (mirror of bank_model's isAbsolutePath) ----------------
|
||||
|
||||
namespace {
|
||||
|
||||
// Any leading '/' or '\' (POSIX root / UNC), or a leading <alpha>: (Windows drive,
|
||||
// incl. drive-relative "C:foo") is absolute — same rejection bank_model applies to
|
||||
// Sample.relativePath; the manifest holds the same kind of path, so the invariant
|
||||
// must match exactly.
|
||||
bool isAbsolutePath(const std::string& p) {
|
||||
if (p.empty()) return false;
|
||||
if (p[0] == '/' || p[0] == '\\') return true;
|
||||
if (p.size() >= 2 && std::isalpha(static_cast<unsigned char>(p[0])) && p[1] == ':')
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// -- mutation / query -------------------------------------------------------
|
||||
|
||||
ManifestAddResult OwnedFileManifest::add(const std::string& relativePath) {
|
||||
if (relativePath.empty()) return ManifestAddResult::RejectedEmptyPath;
|
||||
if (isAbsolutePath(relativePath)) return ManifestAddResult::RejectedAbsolutePath;
|
||||
if (contains(relativePath)) return ManifestAddResult::AlreadyPresent;
|
||||
paths_.push_back(relativePath);
|
||||
return ManifestAddResult::Added;
|
||||
}
|
||||
|
||||
bool OwnedFileManifest::contains(const std::string& relativePath) const {
|
||||
for (const auto& p : paths_)
|
||||
if (p == relativePath) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// -- JSON writer --------------------------------------------------------
|
||||
|
||||
std::string OwnedFileManifest::serialize() const {
|
||||
std::string out = "{\"owned\":[";
|
||||
for (std::size_t i = 0; i < paths_.size(); ++i) {
|
||||
if (i) out += ',';
|
||||
json::writeEscaped(out, paths_[i]);
|
||||
}
|
||||
out += "]}";
|
||||
return out;
|
||||
}
|
||||
|
||||
// JSON parser: string-array-only grammar. Tolerates unknown keys and requires
|
||||
// the "owned" value to be an array of strings.
|
||||
|
||||
namespace {
|
||||
|
||||
bool parseManifest(json::Reader& r, OwnedFileManifest& out) {
|
||||
if (!r.consume('{')) return false;
|
||||
r.skipWs();
|
||||
if (r.consume('}')) return true; // empty object -> empty manifest
|
||||
for (;;) {
|
||||
std::string key;
|
||||
if (!r.parseKey(key)) return false;
|
||||
if (key == "owned") {
|
||||
std::vector<std::string> paths;
|
||||
if (!r.parseStringArray(paths)) return false;
|
||||
for (auto& p : paths) {
|
||||
// Feed through add() so the persisted invariants (dedup, reject
|
||||
// empty/absolute) are re-asserted on load — a hand-edited or corrupt
|
||||
// blob cannot smuggle an absolute or duplicate path into the manifest.
|
||||
out.add(p);
|
||||
}
|
||||
} else {
|
||||
if (!r.skipValue()) return false; // forward-compat: tolerate unknown keys
|
||||
}
|
||||
r.skipWs();
|
||||
if (r.consume(',')) continue;
|
||||
if (r.consume('}')) return true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::optional<OwnedFileManifest> OwnedFileManifest::deserialize(const std::string& blob) {
|
||||
OwnedFileManifest m;
|
||||
json::Reader r(blob);
|
||||
if (!parseManifest(r, m)) return std::nullopt;
|
||||
return m;
|
||||
}
|
||||
|
||||
} // namespace reasampler::model
|
||||
@@ -1,76 +0,0 @@
|
||||
#pragma once
|
||||
// owned_manifest — the set of files the bank system ITSELF created; every file the
|
||||
// capture path writes gets recorded here so prune can tell the system's own orphans
|
||||
// (owned ∩ present − referenced) apart from hand-dropped files. Writes and persists
|
||||
// the manifest only — no prune logic lives here.
|
||||
//
|
||||
// NOT a mirror of the bank index: removing/moving an index entry does NOT remove
|
||||
// the file's manifest record (the manifest tracks files *created*; prune reconciles
|
||||
// manifest-vs-index later). Only the capture add-path adds to it — no remove verb.
|
||||
//
|
||||
// Paths are ALWAYS project-relative (same invariant as Sample.relativePath). add()
|
||||
// rejects an absolute path rather than guess a relativization — the pure model has
|
||||
// no project root, so "normalizing" could point at the wrong file.
|
||||
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace reasampler::model {
|
||||
|
||||
// Outcome of an add(). Mirrors BankModel::AddResult's honesty — the op reports what
|
||||
// happened rather than silently mutating on a bad request.
|
||||
// - Added: the path was new and recorded.
|
||||
// - AlreadyPresent: the path was already in the manifest (dedup no-op).
|
||||
// - RejectedEmptyPath: the path was empty.
|
||||
// - RejectedAbsolutePath: the path was absolute (relative-paths-only invariant).
|
||||
enum class ManifestAddResult {
|
||||
Added,
|
||||
AlreadyPresent,
|
||||
RejectedEmptyPath,
|
||||
RejectedAbsolutePath,
|
||||
};
|
||||
|
||||
// The owned-file manifest: an insertion-ordered, deduplicated set of project-relative
|
||||
// paths the capture path has created. Insertion order is preserved so serialize()
|
||||
// round-trips byte-identically (deterministic ext-state, mirror of the index).
|
||||
class OwnedFileManifest {
|
||||
public:
|
||||
OwnedFileManifest() = default;
|
||||
|
||||
// Record a project-relative path as owned. Rejects an empty or absolute path (no
|
||||
// mutation). A path already present is a dedup no-op (AlreadyPresent), so a repeat
|
||||
// capture of an identical request does not double-record.
|
||||
ManifestAddResult add(const std::string& relativePath);
|
||||
|
||||
// True iff the exact path string is recorded. Prune uses this to attribute a
|
||||
// present file to the bank system. Exact string match — path normalization (if
|
||||
// any) is the caller's concern, consistent across add and query.
|
||||
bool contains(const std::string& relativePath) const;
|
||||
|
||||
// The owned paths in insertion order. Prune unions this with the on-disk file
|
||||
// set; here it is the round-trip + query surface.
|
||||
const std::vector<std::string>& paths() const { return paths_; }
|
||||
|
||||
std::size_t size() const { return paths_.size(); }
|
||||
bool empty() const { return paths_.empty(); }
|
||||
|
||||
bool operator==(const OwnedFileManifest& o) const { return paths_ == o.paths_; }
|
||||
|
||||
// -- Persistence ---------------------------------------------------------
|
||||
|
||||
// Serialize to a JSON string (lossless round-trip): deserialize(serialize(x)) == x.
|
||||
// An empty manifest serializes to a well-formed empty shape (round-trips to empty).
|
||||
std::string serialize() const;
|
||||
|
||||
// Parse a manifest JSON produced by serialize(). std::nullopt on malformed input
|
||||
// (the persist shell warns + falls back to an empty manifest, mirroring the bank /
|
||||
// view malformed handling). An empty/absent stored value is the caller's concern
|
||||
// (an empty string is not valid JSON) — the shell maps absence to a fresh manifest.
|
||||
static std::optional<OwnedFileManifest> deserialize(const std::string& json);
|
||||
|
||||
private:
|
||||
std::vector<std::string> paths_; // insertion order; deduplicated
|
||||
};
|
||||
|
||||
} // namespace reasampler::model
|
||||
@@ -20,9 +20,9 @@ enumeration and the actual deletion live in `shell/persist` (`prune_fs`), not he
|
||||
- Referenced-set is the union across ALL banks, pool included: a file is an orphan
|
||||
iff no bank in the book references it. This is the safety-critical computation —
|
||||
the prune null test is *prune never deletes a file that any index references.*
|
||||
- Orphan attribution is an owned-file manifest (fork R-D): the book tracks the set
|
||||
of files it has created; prune reclaims `(owned ∩ on-disk) − referenced`. This
|
||||
rejects folder-sweep (which would delete hand-dropped files).
|
||||
- Orphan attribution comes from the tracking ledger (`core/tracking`): the book
|
||||
tracks the files it has created; prune reclaims `(owned ∩ on-disk) − referenced`.
|
||||
This rejects folder-sweep (which would delete hand-dropped files).
|
||||
- **Prune null test:** a prune of a folder whose every file is referenced by some
|
||||
bank deletes nothing; a prune deletes exactly the `present − referenced` orphan
|
||||
set and nothing else.
|
||||
@@ -32,7 +32,7 @@ enumeration and the actual deletion live in `shell/persist` (`prune_fs`), not he
|
||||
|
||||
## Modules
|
||||
|
||||
- `prune_reconcile` — pure prune core: `pruneOrphans(present, referenced, owned)` computes `(owned ∩ present) − referenced`; the safety-critical "which files are orphans" decision, filesystem-free and hard-tested before any I/O exists. Gains `mergeReferenced(bankRefs, liveInstanceHeldPaths)` (pS-usage) — unions live instance holds into the prune referenced-set so the pure orphan computation includes them.
|
||||
- `prune_reconcile` — pure prune core: `pruneOrphans(present, referenced, owned)` computes `(owned ∩ present) − referenced`; the safety-critical "which files are orphans" decision, filesystem-free and hard-tested before any I/O exists. `mergeReferenced(bankRefs, heldPaths)` unions live instance holds into the referenced-set. Two of the three inputs (`owned`, and the held half of `referenced`) come from `core/tracking`'s authority, never assembled ad hoc by a shell. `PruneReport` carries the authority's verdict: `blockedByTracking` + `ledgerUnreadable` / `unreadableUsageKeys`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ std::vector<std::string> pruneOrphans(const std::vector<std::string>& present,
|
||||
const std::vector<std::string>& referenced,
|
||||
const std::vector<std::string>& owned) {
|
||||
// Exact-string membership — the model's canonical relative-path comparison
|
||||
// (Sample.relativePath / OwnedFileManifest::contains). std::string hashes/compares
|
||||
// (Sample.relativePath / OriginLedger::contains). std::string hashes/compares
|
||||
// byte-for-byte, so no normalization creeps in.
|
||||
const std::unordered_set<std::string> referencedSet(referenced.begin(),
|
||||
referenced.end());
|
||||
|
||||
@@ -10,20 +10,20 @@
|
||||
// pool included (union across the whole book — see BankBook::
|
||||
// referencedPaths). A file referenced by any bank — including via a
|
||||
// COPY into a second bank — is NEVER an orphan (the prune null test).
|
||||
// * owned — the owned-file manifest: files the bank system itself created. A
|
||||
// present-but-unowned (hand-dropped) file is NEVER reclaimed.
|
||||
// * owned — the tracking ledger's paths: files the bank system itself created.
|
||||
// A present-but-unowned (hand-dropped) file is NEVER reclaimed.
|
||||
//
|
||||
// The three guardrails fall straight out of the set algebra:
|
||||
// * ∩ present — never proposes deleting a file that is not on disk (an owned-
|
||||
// but-absent manifest entry yields no orphan, no error).
|
||||
// but-absent ledger record yields no orphan, no error).
|
||||
// * ∩ owned — never a hand-dropped file (ownership attribution).
|
||||
// * − referenced — never a file any bank references (union safety, prune null test).
|
||||
//
|
||||
// Path representation: EXACT-STRING match everywhere — Sample.relativePath,
|
||||
// OwnedFileManifest::contains, BankModel all use raw std::string equality: no
|
||||
// OriginLedger::contains, BankModel all use raw std::string equality: no
|
||||
// separator normalization, no case-folding, no trailing-slash trimming. Feeding a
|
||||
// consistent spelling across the three inputs is the shell's contract (it enumerates
|
||||
// the folder, unions the book, and reads the manifest against the SAME resolved
|
||||
// the folder, unions the book, and reads the ledger against the SAME resolved
|
||||
// current folder). Diverging from exact match here (e.g. case-insensitive compare)
|
||||
// would be the unsafe direction — it could let one spelling of a referenced file be
|
||||
// treated as an orphan under another.
|
||||
@@ -48,23 +48,22 @@ namespace reasampler::reclaim {
|
||||
// order (deterministic). MAY be truncated for a large set (the
|
||||
// shell's display cap); `count` stays exact regardless.
|
||||
// * truncated — true iff `orphans` holds fewer than `count` entries.
|
||||
// * abortedUnreadableUsage — true iff the scan found a present-but-unreadable
|
||||
// rsusage_* instance-usage record: the orphan computation was NOT
|
||||
// performed (count 0, empty list) and the prune must HALT —
|
||||
// deleting with degraded protection is the data-loss direction.
|
||||
// Set by the scan shell, never by buildPruneReport (which stays a
|
||||
// pure tally).
|
||||
// * offendingUsageKeys — the exact "rsusage_<guid>" key names that triggered the
|
||||
// abort (non-empty iff abortedUnreadableUsage), so the operator
|
||||
// can clear each key via ReaScript:
|
||||
// reaper.SetProjExtState(0, "reasampler", "<key>", "")
|
||||
// * blockedByTracking — true iff the tracking authority could not answer the
|
||||
// protection question: the orphan computation was NOT performed
|
||||
// (count 0, empty list) and the prune must HALT — deleting with
|
||||
// degraded protection is the data-loss direction. Set by the scan
|
||||
// shell, never by buildPruneReport (which stays a pure tally).
|
||||
// * ledgerUnreadable / unreadableUsageKeys — which side blocked, so the action can
|
||||
// tell the operator what to recover. The key names are the exact
|
||||
// "rsusage_<guid>" spellings.
|
||||
struct PruneReport {
|
||||
std::size_t count = 0;
|
||||
std::uint64_t totalBytes = 0;
|
||||
std::vector<std::string> orphans;
|
||||
bool truncated = false;
|
||||
bool abortedUnreadableUsage = false;
|
||||
std::vector<std::string> offendingUsageKeys; // non-empty iff abortedUnreadableUsage
|
||||
bool blockedByTracking = false;
|
||||
bool ledgerUnreadable = false;
|
||||
std::vector<std::string> unreadableUsageKeys;
|
||||
};
|
||||
|
||||
// The outcome of an actual prune DELETION. The shell fills this as it deletes the
|
||||
@@ -91,7 +90,7 @@ struct PruneDeletionResult {
|
||||
//
|
||||
// Returns the subset of `present` that is BOTH owned AND unreferenced, in the order
|
||||
// they appear in `present` (deterministic — mirrors the insertion-order determinism
|
||||
// the index/manifest keep). Duplicate spellings within `present` are de-duplicated
|
||||
// the index/ledger keep). Duplicate spellings within `present` are de-duplicated
|
||||
// in the result (a folder enumeration yields distinct names, but the core does not
|
||||
// rely on that).
|
||||
//
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
# src/core/tracking — the consolidated file-tracking system
|
||||
|
||||
## Scope
|
||||
|
||||
The one system behind every question about a file the tool created: who created it,
|
||||
what it derives from, who is using it now. Pure (REAPER-free, unit-tested outside the
|
||||
DAW). Two safety-critical consumers read it and no others: prune's protected set and
|
||||
the resample's replace-vs-add decision.
|
||||
|
||||
This is the territory's **authority**, not one of three mechanisms. Two codecs feed it
|
||||
from where codecs belong — the persisted ledger's own JSON (here) and the
|
||||
instrument→extension usage wire (`core/wire/sample_usage`) — but every *decision* is
|
||||
made in `tracking_authority`, from one `TrackingState`.
|
||||
|
||||
## Invariants
|
||||
|
||||
**Safety-critical, overriding.** This territory gates the system's only file-deletion
|
||||
authority (prune) and its only capture-replacement act (resample). A tracking error
|
||||
loses a user's audio. Every failure, ambiguity, or unreadable input resolves to the
|
||||
non-destructive side of the question being asked — over-protection (prune skips a
|
||||
reclaimable file, or refuses to run; resample adds instead of replacing) is an
|
||||
accepted residual; under-protection is a data-loss bug.
|
||||
|
||||
**No silent gaps.** A system-created file is tracked from the instant it exists.
|
||||
`ReaSamplerSession::recordCreated` is the only writer, called at the same point the
|
||||
`Sample` is added, and it reads lineage off that `Sample`'s own provenance — so the
|
||||
recipe fingerprint and the lineage record come from one act and cannot disagree.
|
||||
|
||||
**Lineage is never backfilled.** `OriginLedger::record` on a path already present is
|
||||
`AlreadyPresent` and leaves the stored record untouched. A record says what was known
|
||||
at birth or says nothing at all; nothing may later rewrite history from a guess.
|
||||
|
||||
**Never-recorded and unreadable are different absences.** `LedgerStatus` keeps them
|
||||
apart, and only `loadLedger` can tell them apart (an empty stored value is not valid
|
||||
JSON, so the parser alone cannot). `Fresh` — a new project or a bank predating the
|
||||
ledger — blocks nothing and yields definite answers. `Unreadable` blocks every
|
||||
destructive answer AND suppresses the next write, so a corrupt blob survives for
|
||||
recovery instead of being replaced by a ledger missing every earlier file.
|
||||
|
||||
**Consumers cannot disagree.** Both answers come out of one `TrackingState`. The two
|
||||
universes differ deliberately — prune protects bank-referenced paths, ledger-owned
|
||||
paths, and every live hold; the tie query counts only live holds other than the
|
||||
asker's own — but the replace-vs-add universe is a **strict subset** of the
|
||||
prune-protection universe, proven in `test_tracking_authority`.
|
||||
|
||||
**Conservatism is asymmetric by facet.** The recipe fingerprint (`core/model/provenance`)
|
||||
keeps its record-nothing-when-ambiguous stance: an ambiguous parent is no parent. The
|
||||
lineage half has no record-nothing option — replace-vs-add must be computable — so a
|
||||
birth record is written for every system-created file, ambiguous parentage or not
|
||||
(the parent is simply empty).
|
||||
|
||||
## Modules
|
||||
|
||||
- `origin_ledger` — the record family: `OriginRecord` (project-relative path, `OriginKind`,
|
||||
the sample id minted at birth, the parent sample id) and the insertion-ordered,
|
||||
path-keyed, deduplicated `OriginLedger` that holds them, with its JSON codec and the
|
||||
`loadLedger` three-way `Fresh` / `Loaded` / `Unreadable` classification. Persisted
|
||||
under the FOREVER-STABLE `owned_files` ext-state key; the legacy path-only shape
|
||||
(`{"owned":[...]}`) lifts in as `Unknown`-kind records with no lineage.
|
||||
- `tracking_authority` — the one decision surface: `pruneProtection` (the `owned` and
|
||||
held-path inputs prune's set algebra consumes, plus the blocked/blockers verdict)
|
||||
and `tiedUsageExists` (`Yes` / `No` / `Indeterminate`, per capture, excluding the
|
||||
asker's own usage key). Both read one borrowed `TrackingState`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- `OriginKind` values are PERSISTED INTEGERS — never renumber, only append. An
|
||||
unrecognized value degrades to `Unknown` rather than failing the parse: a vocabulary
|
||||
gap must not halt the prune.
|
||||
- A `unioned` usage record can never be excluded as "my own" — it carries more than one
|
||||
incarnation's holds, so attributing it to a single owner could hide a sibling's tie.
|
||||
- The set algebra prune runs on `(owned ∩ present) − referenced` is `core/reclaim`'s,
|
||||
not this directory's; this directory supplies two of its three inputs.
|
||||
- `sample_usage` deliberately stays in `core/wire` — it is a wire format, and its
|
||||
instrument-side writer needs it there. Consolidation is of the *decisions*, not of
|
||||
the codecs.
|
||||
@@ -0,0 +1,10 @@
|
||||
reasampler_pure_library(origin_ledger SOURCES origin_ledger.cpp LINK PRIVATE json)
|
||||
reasampler_test(origin_ledger LINK origin_ledger json)
|
||||
|
||||
# prune_reconcile + bank_book are linked into the authority's test, not the library:
|
||||
# the test proves end-to-end that a tied usage can never reach the orphan set, which
|
||||
# is the "consumers cannot disagree" property stated at the pure layer.
|
||||
reasampler_pure_library(tracking_authority
|
||||
SOURCES tracking_authority.cpp
|
||||
LINK PUBLIC origin_ledger sample_usage)
|
||||
reasampler_test(tracking_authority LINK tracking_authority prune_reconcile bank_book)
|
||||
@@ -0,0 +1,187 @@
|
||||
#include "core/tracking/origin_ledger.h"
|
||||
|
||||
#include <cctype>
|
||||
|
||||
#include "core/json/json.h"
|
||||
|
||||
// Version ladder for the stored blob, under the FOREVER-STABLE "owned_files" key:
|
||||
//
|
||||
// v1 (legacy, path-only) {"owned":["reasampler_bank/a.wav"]}
|
||||
// v2 (current) {"v":2,"records":[{"path":"...","kind":1,
|
||||
// "sample":"id","parent":"pid"}]}
|
||||
//
|
||||
// v1 blobs lift to v2 records with kind Unknown and empty ids — a pre-existing bank
|
||||
// keeps every protection it had (the paths are still owned) and gains no invented
|
||||
// lineage. Both shapes parse; only v2 is written.
|
||||
|
||||
namespace reasampler::tracking {
|
||||
|
||||
namespace {
|
||||
|
||||
// Any leading '/' or '\' (POSIX root / UNC), or a leading <alpha>: (Windows drive,
|
||||
// incl. drive-relative "C:foo"). Must match bank_model's rejection exactly — the
|
||||
// ledger holds the same kind of path as Sample.relativePath.
|
||||
bool isAbsolutePath(const std::string& p) {
|
||||
if (p.empty()) return false;
|
||||
if (p[0] == '/' || p[0] == '\\') return true;
|
||||
if (p.size() >= 2 && std::isalpha(static_cast<unsigned char>(p[0])) && p[1] == ':')
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// An unrecognized persisted integer degrades to Unknown rather than failing the
|
||||
// parse: a newer channel's kind must not make the whole ledger unreadable, which
|
||||
// would halt prune on nothing worse than a vocabulary gap.
|
||||
OriginKind kindFromInt(int v) {
|
||||
switch (v) {
|
||||
case 1: return OriginKind::Capture;
|
||||
case 2: return OriginKind::Ingest;
|
||||
case 3: return OriginKind::Recapture;
|
||||
case 4: return OriginKind::Resample;
|
||||
default: return OriginKind::Unknown;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool OriginRecord::operator==(const OriginRecord& o) const {
|
||||
return relativePath == o.relativePath && kind == o.kind &&
|
||||
sampleId == o.sampleId && parentSampleId == o.parentSampleId;
|
||||
}
|
||||
|
||||
RecordResult OriginLedger::record(const OriginRecord& rec) {
|
||||
if (rec.relativePath.empty()) return RecordResult::RejectedEmptyPath;
|
||||
if (isAbsolutePath(rec.relativePath)) return RecordResult::RejectedAbsolutePath;
|
||||
if (contains(rec.relativePath)) return RecordResult::AlreadyPresent;
|
||||
records_.push_back(rec);
|
||||
return RecordResult::Recorded;
|
||||
}
|
||||
|
||||
const OriginRecord* OriginLedger::find(const std::string& relativePath) const {
|
||||
for (const OriginRecord& r : records_)
|
||||
if (r.relativePath == relativePath) return &r;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
bool OriginLedger::contains(const std::string& relativePath) const {
|
||||
return find(relativePath) != nullptr;
|
||||
}
|
||||
|
||||
std::vector<std::string> OriginLedger::ownedPaths() const {
|
||||
std::vector<std::string> out;
|
||||
out.reserve(records_.size());
|
||||
for (const OriginRecord& r : records_) out.push_back(r.relativePath);
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string OriginLedger::serialize() const {
|
||||
std::string out = "{\"v\":2,\"records\":[";
|
||||
for (std::size_t i = 0; i < records_.size(); ++i) {
|
||||
if (i) out += ',';
|
||||
const OriginRecord& r = records_[i];
|
||||
out += "{\"path\":";
|
||||
json::writeEscaped(out, r.relativePath);
|
||||
out += ",\"kind\":" + json::numToStr(static_cast<int>(r.kind));
|
||||
out += ",\"sample\":";
|
||||
json::writeEscaped(out, r.sampleId);
|
||||
out += ",\"parent\":";
|
||||
json::writeEscaped(out, r.parentSampleId);
|
||||
out += '}';
|
||||
}
|
||||
out += "]}";
|
||||
return out;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
bool parseRecord(json::Reader& r, OriginRecord& out) {
|
||||
if (!r.consume('{')) return false;
|
||||
r.skipWs();
|
||||
if (r.consume('}')) return true;
|
||||
for (;;) {
|
||||
std::string key;
|
||||
if (!r.parseKey(key)) return false;
|
||||
if (key == "path") {
|
||||
if (!r.parseString(out.relativePath)) return false;
|
||||
} else if (key == "kind") {
|
||||
int k = 0;
|
||||
if (!r.parseInt(k)) return false;
|
||||
out.kind = kindFromInt(k);
|
||||
} else if (key == "sample") {
|
||||
if (!r.parseString(out.sampleId)) return false;
|
||||
} else if (key == "parent") {
|
||||
if (!r.parseString(out.parentSampleId)) return false;
|
||||
} else if (!r.skipValue()) {
|
||||
return false;
|
||||
}
|
||||
r.skipWs();
|
||||
if (r.consume(',')) continue;
|
||||
if (r.consume('}')) return true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
bool parseRecordArray(json::Reader& r, OriginLedger& out) {
|
||||
if (!r.consume('[')) return false;
|
||||
r.skipWs();
|
||||
if (r.consume(']')) return true;
|
||||
for (;;) {
|
||||
OriginRecord rec;
|
||||
if (!parseRecord(r, rec)) return false;
|
||||
// Through record() so the persisted invariants (dedup, reject empty/absolute)
|
||||
// are re-asserted on load: a hand-edited or corrupt blob cannot smuggle an
|
||||
// absolute or duplicate path in.
|
||||
out.record(rec);
|
||||
r.skipWs();
|
||||
if (r.consume(',')) continue;
|
||||
if (r.consume(']')) return true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
bool parseLedger(json::Reader& r, OriginLedger& out) {
|
||||
if (!r.consume('{')) return false;
|
||||
r.skipWs();
|
||||
if (r.consume('}')) return true;
|
||||
for (;;) {
|
||||
std::string key;
|
||||
if (!r.parseKey(key)) return false;
|
||||
if (key == "records") {
|
||||
if (!parseRecordArray(r, out)) return false;
|
||||
} else if (key == "owned") {
|
||||
std::vector<std::string> paths;
|
||||
if (!r.parseStringArray(paths)) return false;
|
||||
for (const std::string& p : paths) out.record(OriginRecord{p});
|
||||
} else if (!r.skipValue()) {
|
||||
return false;
|
||||
}
|
||||
r.skipWs();
|
||||
if (r.consume(',')) continue;
|
||||
if (r.consume('}')) return true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::optional<OriginLedger> OriginLedger::deserialize(const std::string& json) {
|
||||
OriginLedger ledger;
|
||||
::reasampler::json::Reader r(json);
|
||||
if (!parseLedger(r, ledger)) return std::nullopt;
|
||||
return ledger;
|
||||
}
|
||||
|
||||
LedgerLoad loadLedger(const std::string& stored) {
|
||||
LedgerLoad load;
|
||||
if (stored.empty()) return load; // absent key -> Fresh, not an error
|
||||
std::optional<OriginLedger> parsed = OriginLedger::deserialize(stored);
|
||||
if (!parsed) {
|
||||
load.status = LedgerStatus::Unreadable;
|
||||
return load;
|
||||
}
|
||||
load.status = LedgerStatus::Loaded;
|
||||
load.ledger = std::move(*parsed);
|
||||
return load;
|
||||
}
|
||||
|
||||
} // namespace reasampler::tracking
|
||||
@@ -0,0 +1,104 @@
|
||||
#pragma once
|
||||
// origin_ledger — the one persisted record family behind file tracking: for every
|
||||
// file the system itself created, what act created it and what it derives from.
|
||||
// Supersedes the path-only owned manifest (same ext-state key, widened shape).
|
||||
//
|
||||
// Lineage is written at birth and never backfilled — record() on a path already
|
||||
// present is a no-op, so a record says what was known when the file appeared or
|
||||
// says nothing at all. Nothing here decides anything; tracking_authority does.
|
||||
|
||||
#include <cstddef>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace reasampler::tracking {
|
||||
|
||||
// The act that created the file. `Unknown` is the never-recorded case — a record
|
||||
// lifted from a legacy path-only manifest, or one whose creator did not know.
|
||||
// PERSISTED AS INTEGERS: never renumber an existing value, only append.
|
||||
enum class OriginKind {
|
||||
Unknown = 0,
|
||||
Capture = 1,
|
||||
Ingest = 2,
|
||||
Recapture = 3, // regenerated in place from its recorded source recipe
|
||||
Resample = 4, // baked from an instrument's own processing chain
|
||||
};
|
||||
|
||||
// One system-created file's birth record. `relativePath` is the key and is ALWAYS
|
||||
// project-relative (the same invariant as Sample.relativePath); an absolute path is
|
||||
// rejected rather than relativized, because the pure model has no project root and
|
||||
// guessing one could point at the wrong file.
|
||||
struct OriginRecord {
|
||||
std::string relativePath;
|
||||
OriginKind kind = OriginKind::Unknown;
|
||||
std::string sampleId; // bank id minted at birth; "" when never recorded
|
||||
std::string parentSampleId; // the capture this derives from; "" = root / none
|
||||
|
||||
bool operator==(const OriginRecord& o) const;
|
||||
bool operator!=(const OriginRecord& o) const { return !(*this == o); }
|
||||
};
|
||||
|
||||
// Outcome of a record(). The op reports what happened rather than silently mutating
|
||||
// on a bad request (mirrors BankModel::AddResult).
|
||||
enum class RecordResult {
|
||||
Recorded,
|
||||
AlreadyPresent, // dedup no-op; the existing record is NOT overwritten
|
||||
RejectedEmptyPath,
|
||||
RejectedAbsolutePath,
|
||||
};
|
||||
|
||||
// Insertion-ordered, path-keyed, deduplicated. Insertion order is preserved so
|
||||
// serialize() round-trips byte-identically (deterministic ext-state).
|
||||
//
|
||||
// NOT a mirror of the bank index: removing or moving an index entry leaves the
|
||||
// record alone. The ledger tracks files *created*; prune reconciles it against the
|
||||
// index later.
|
||||
class OriginLedger {
|
||||
public:
|
||||
OriginLedger() = default;
|
||||
|
||||
// A path already present is AlreadyPresent and leaves the stored record
|
||||
// untouched — the no-backfill rule, enforced here rather than at call sites.
|
||||
RecordResult record(const OriginRecord& rec);
|
||||
|
||||
// nullptr when the path was never recorded. Distinguishing that from an
|
||||
// unreadable ledger is the loader's job (see LedgerStatus).
|
||||
const OriginRecord* find(const std::string& relativePath) const;
|
||||
bool contains(const std::string& relativePath) const;
|
||||
|
||||
// Prune's `owned` input, in insertion order.
|
||||
std::vector<std::string> ownedPaths() const;
|
||||
|
||||
const std::vector<OriginRecord>& records() const { return records_; }
|
||||
std::size_t size() const { return records_.size(); }
|
||||
bool empty() const { return records_.empty(); }
|
||||
|
||||
bool operator==(const OriginLedger& o) const { return records_ == o.records_; }
|
||||
|
||||
// Lossless round-trip: deserialize(serialize(x)) == x. std::nullopt on malformed
|
||||
// input — the caller must treat that as unreadable, never as empty.
|
||||
std::string serialize() const;
|
||||
static std::optional<OriginLedger> deserialize(const std::string& json);
|
||||
|
||||
private:
|
||||
std::vector<OriginRecord> records_;
|
||||
};
|
||||
|
||||
// The three states of a stored ledger, kept apart because never-recorded and
|
||||
// unreadable demand opposite treatment: `Fresh` is a legitimate empty (a new
|
||||
// project, or a bank predating the ledger) and blocks nothing; `Unreadable` is a
|
||||
// present-but-corrupt blob and must block every destructive answer.
|
||||
enum class LedgerStatus { Fresh, Loaded, Unreadable };
|
||||
|
||||
struct LedgerLoad {
|
||||
LedgerStatus status = LedgerStatus::Fresh;
|
||||
OriginLedger ledger; // empty unless status == Loaded
|
||||
};
|
||||
|
||||
// Classifies a raw stored value. An empty string is the absent key (Fresh), not an
|
||||
// error — an empty string is not valid JSON, so the two cases cannot be told apart
|
||||
// by the parser alone.
|
||||
LedgerLoad loadLedger(const std::string& stored);
|
||||
|
||||
} // namespace reasampler::tracking
|
||||
@@ -0,0 +1,43 @@
|
||||
#include "core/tracking/tracking_authority.h"
|
||||
|
||||
namespace reasampler::tracking {
|
||||
|
||||
ProtectionAnswer pruneProtection(const TrackingState& state) {
|
||||
ProtectionAnswer answer;
|
||||
|
||||
// heldPaths is taken unconditionally: on an abort the usage fold returns its
|
||||
// protect-all set, which is the widest (safest) answer available.
|
||||
answer.heldPaths = state.usage.heldPaths;
|
||||
|
||||
if (state.usage.abortPrune) {
|
||||
answer.blocked = true;
|
||||
answer.unreadableUsageKeys = state.usage.offendingKeys;
|
||||
}
|
||||
|
||||
if (state.ledgerStatus == LedgerStatus::Unreadable) {
|
||||
answer.blocked = true;
|
||||
answer.ledgerUnreadable = true;
|
||||
return answer; // ownedPaths left empty -> (owned ∩ present) is empty
|
||||
}
|
||||
|
||||
answer.ownedPaths = state.ledger.ownedPaths();
|
||||
return answer;
|
||||
}
|
||||
|
||||
Answer tiedUsageExists(const TrackingState& state, const std::string& capturePath,
|
||||
const std::string& ownUsageKey) {
|
||||
if (capturePath.empty()) return Answer::Indeterminate;
|
||||
if (state.ledgerStatus == LedgerStatus::Unreadable) return Answer::Indeterminate;
|
||||
if (state.usage.abortPrune) return Answer::Indeterminate;
|
||||
|
||||
for (const wire::CountedUsage& counted : state.usage.counted) {
|
||||
const bool isOwn = !ownUsageKey.empty() && counted.key == ownUsageKey &&
|
||||
!counted.record.unioned;
|
||||
if (isOwn) continue;
|
||||
for (const wire::UsageHold& hold : counted.record.holds)
|
||||
if (hold.relativePath == capturePath) return Answer::Yes;
|
||||
}
|
||||
return Answer::No;
|
||||
}
|
||||
|
||||
} // namespace reasampler::tracking
|
||||
@@ -0,0 +1,65 @@
|
||||
#pragma once
|
||||
// tracking_authority — the ONE place the two safety-critical consumers are answered:
|
||||
// prune's protected set and the resample's replace-vs-add decision. Both read the
|
||||
// same TrackingState, so they cannot drift apart; every unreadable or ambiguous
|
||||
// input resolves to the non-destructive side of its own question.
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "core/tracking/origin_ledger.h"
|
||||
#include "core/wire/sample_usage.h"
|
||||
|
||||
namespace reasampler::tracking {
|
||||
|
||||
// A borrowed view of everything both consumers read, gathered once by the shell.
|
||||
// References, not values: the ledger can hold thousands of records and both answers
|
||||
// are computed from one gather. Never outlives the gather that built it.
|
||||
struct TrackingState {
|
||||
LedgerStatus ledgerStatus;
|
||||
const OriginLedger& ledger;
|
||||
const wire::UsageFoldResult& usage;
|
||||
};
|
||||
|
||||
// Prune's answer. `blocked` means the protected set is unknowable and the prune must
|
||||
// HALT — deleting with degraded protection is the data-loss direction. The two
|
||||
// blocker fields say what to tell the operator; the caller composes the message
|
||||
// (the pure core does not know ext-state key spellings).
|
||||
//
|
||||
// Both path lists stay populated on a block as belt-and-braces: heldPaths carries
|
||||
// the usage fold's protect-all set, and ownedPaths is left EMPTY on an unreadable
|
||||
// ledger, so a caller that ignored `blocked` still computes an empty orphan set.
|
||||
struct ProtectionAnswer {
|
||||
bool blocked = false;
|
||||
bool ledgerUnreadable = false;
|
||||
std::vector<std::string> unreadableUsageKeys;
|
||||
std::vector<std::string> heldPaths; // union into prune's `referenced`
|
||||
std::vector<std::string> ownedPaths; // prune's `owned`
|
||||
};
|
||||
|
||||
ProtectionAnswer pruneProtection(const TrackingState& state);
|
||||
|
||||
// The resample's answer, per capture. `Indeterminate` is not a failure to compute —
|
||||
// it is the recorded verdict that the state could not be read, and the caller must
|
||||
// treat it exactly as `Yes` (never take the replace branch).
|
||||
enum class Answer { No, Yes, Indeterminate };
|
||||
|
||||
// Does a usage tied to `capturePath` exist, other than the asking instance's own?
|
||||
//
|
||||
// * unreadable ledger, or any unreadable usage record -> Indeterminate.
|
||||
// * otherwise Yes iff some counting live record holds the path.
|
||||
//
|
||||
// `ownUsageKey` is the asking instance's own "rsusage_<guid>" key, excluded from the
|
||||
// scan so a bake does not see itself; empty counts every holder. A `unioned` record
|
||||
// is NEVER excluded — it carries more than one incarnation's holds, so attributing
|
||||
// it to a single owner could hide a sibling's tie.
|
||||
//
|
||||
// Never-recorded (no ledger record for the path) is a definite answer, not an
|
||||
// abstention: a pre-existing capture nothing holds answers No.
|
||||
//
|
||||
// The universes cannot disagree: a Yes implies `capturePath` is in the same
|
||||
// pruneProtection(state).heldPaths, which is itself a subset of what prune protects.
|
||||
Answer tiedUsageExists(const TrackingState& state, const std::string& capturePath,
|
||||
const std::string& ownUsageKey);
|
||||
|
||||
} // namespace reasampler::tracking
|
||||
+13
-9
@@ -58,7 +58,10 @@ This directory owns two cross-artifact contracts specifically:
|
||||
delete). The guarantee: a capture held by any live instance can never be deleted; if
|
||||
the prune cannot determine with certainty which captures are held, it aborts
|
||||
entirely (deletes nothing). Over-protection is the accepted residual; under-protection
|
||||
is a data-loss bug.
|
||||
is a data-loss bug. The fold reports `counted` — the live records still attributed to
|
||||
their `rsusage_*` keys — because the flattened path list cannot answer "who holds
|
||||
this"; `counted` is empty whenever `abortPrune` is set, since attribution is exactly
|
||||
what an unreadable record destroys.
|
||||
- **Collision safety (`planUsagePublish`).** A persisted GUID is copyable (FX copy /
|
||||
track duplication). `ownerNonce` — a per-lifetime nonce minted fresh in memory at
|
||||
instance creation, never persisted — proves "exactly this incarnation wrote the key
|
||||
@@ -67,13 +70,14 @@ This directory owns two cross-artifact contracts specifically:
|
||||
Resolution always leans over-protect: same-nonce + not-unioned → clean replace;
|
||||
same-track foreign nonce or unioned → union; cross-track foreign nonce → remint under
|
||||
a fresh key. None of the three directions can under-protect.
|
||||
- **Deferred follow-up (TODO.md, not this dispatch's scope):** `ownerNonce` is not
|
||||
persisted, so after save→reopen an instance cannot recognize its own prior-session
|
||||
usage record — it unions and marks the record `unioned` forever, so prune stops
|
||||
reclaiming captures the instance once held but no longer uses (safe, but the bank
|
||||
folder grows unbounded). Persisting the nonce is deferred because a persisted nonce
|
||||
would be inherited by a Ctrl+D in-place FX duplicate, and a divergent clone must
|
||||
still be detected and protected fail-safe without reintroducing the sibling-drop bug.
|
||||
- **Deferred follow-up (TODO.md, deliberately NOT absorbed by the tracking
|
||||
consolidation):** `ownerNonce` is not persisted, so after save→reopen an instance
|
||||
cannot recognize its own prior-session usage record — it unions and marks the record
|
||||
`unioned` forever, so prune stops reclaiming captures the instance once held but no
|
||||
longer uses (safe, but the bank folder grows unbounded). This is a *completeness*
|
||||
wart, not a safety one; every candidate fix examined so far trades it for a new
|
||||
under-protection window, which the consolidation's own safety mandate forbids. See
|
||||
`docs/TODO.md` for the constraint and the rejected session-epoch candidate.
|
||||
|
||||
## Modules
|
||||
|
||||
@@ -81,7 +85,7 @@ This directory owns two cross-artifact contracts specifically:
|
||||
- `reasampler_uid.h` — SDK-free header owning the FOREVER-FROZEN VST3 class-UID integer macros (stable + beta pairs, `REASAMPLER_PROC_UID_*` / `REASAMPLER_PROC_UID_BETA_*`) and the `REASAMPLER_ACTIVE_UID_*` channel-selector macros. Split out of `reasampler_vst.h` so the pure extension side (`instrument_drop`) can derive the `.vstpreset` class-ID hex string without pulling in the VST3 SDK. Both `reasampler_vst.h` (runtime `FUID`) and `instrument_drop` (preset hex string) source from this single header — the binary identity and the preset-file identity cannot diverge.
|
||||
- `assignment_request` — pure ingest-assign wire: typed request record carrying the drop payload from the `ingest` shell through to the VST3 bridge.
|
||||
- `instrument_drop` — pure FX-drop payload builder: constructs a Steinberg-format `.vstpreset` image (channel-active class ID + the instrument's own component state, capture pre-selected) the shell applies via `TrackFX_SetPreset`; owns the `infoNamesFxHotspot` prefix classifier for `GetThingFromPoint` tokens. All-or-nothing contract — caller rolls back via `TrackFX_Delete` on any failure.
|
||||
- `sample_usage` — instance-usage wire: `UsageRecord`, `planUsagePublish` (fresh/heal/clean-replace/union/remint publish plan), `foldUsageRecords`/`usageHeldPaths` (liveness fold — protect-all when records exist but no instance is live; abort→protect-all on unreadable record), `identityMatches` (ReaSampler 9000 FX identity). REAPER-free, unit-tested. The mirror of `assignment_request` on the instrument→extension direction: the wire format and the two safety-critical decisions (what to write on publish, which records count at prune time) are pure so they are provable without a DAW.
|
||||
- `sample_usage` — instance-usage wire: `UsageRecord`, `planUsagePublish` (fresh/heal/clean-replace/union/remint publish plan), `foldUsageRecords`/`usageHeldPaths` (liveness fold — protect-all when records exist but no instance is live; abort→protect-all on unreadable record; `counted` carries key-attributed live records), `identityMatches` (ReaSampler 9000 FX identity). REAPER-free, unit-tested. The mirror of `assignment_request` on the instrument→extension direction: the wire format and the two safety-critical decisions (what to write on publish, which records count at prune time) are pure so they are provable without a DAW. It lives here because it is a *wire format* with an instrument-side writer; the fold's output is consumed by `core/tracking`'s authority, which owns every consumer-facing decision built on it.
|
||||
|
||||
## Gotchas
|
||||
|
||||
|
||||
@@ -122,6 +122,21 @@ UsagePublishPlan planUsagePublish(const std::optional<std::string>& existing,
|
||||
return plan;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
// The liveness rule, in one place so the path fold and the attribution fold can
|
||||
// never disagree about which records counted. `protectAll` is the caller's
|
||||
// zero-identified net (see usageHeldPaths).
|
||||
bool recordCounts(const UsageRecord& rec,
|
||||
const std::unordered_set<std::string>& liveTrackGuids,
|
||||
bool anyInstanceLive, bool protectAll) {
|
||||
if (protectAll) return true;
|
||||
if (rec.trackGuid.empty()) return anyInstanceLive;
|
||||
return liveTrackGuids.count(rec.trackGuid) != 0;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::vector<std::string> usageHeldPaths(
|
||||
const std::vector<UsageRecord>& records,
|
||||
const std::unordered_set<std::string>& liveTrackGuids,
|
||||
@@ -133,11 +148,7 @@ std::vector<std::string> usageHeldPaths(
|
||||
// paths rather than none (zero-identified must never degrade toward delete).
|
||||
const bool protectAll = !records.empty() && !anyInstanceLive;
|
||||
for (const UsageRecord& rec : records) {
|
||||
const bool live = protectAll ||
|
||||
(rec.trackGuid.empty()
|
||||
? anyInstanceLive
|
||||
: (liveTrackGuids.count(rec.trackGuid) != 0));
|
||||
if (!live) continue;
|
||||
if (!recordCounts(rec, liveTrackGuids, anyInstanceLive, protectAll)) continue;
|
||||
for (const UsageHold& h : rec.holds) {
|
||||
if (h.relativePath.empty()) continue;
|
||||
if (seen.insert(h.relativePath).second) out.push_back(h.relativePath);
|
||||
@@ -147,33 +158,44 @@ std::vector<std::string> usageHeldPaths(
|
||||
}
|
||||
|
||||
UsageFoldResult foldUsageRecords(
|
||||
const std::vector<std::optional<UsageRecord>>& decoded,
|
||||
const std::vector<DecodedUsage>& decoded,
|
||||
const std::unordered_set<std::string>& liveTrackGuids,
|
||||
bool anyInstanceLive) {
|
||||
UsageFoldResult result;
|
||||
for (const DecodedUsage& entry : decoded) {
|
||||
if (entry.record) continue;
|
||||
// Present-but-unreadable record: it may protect anything, so halt.
|
||||
result.abortPrune = true;
|
||||
result.offendingKeys.push_back(entry.key);
|
||||
}
|
||||
if (result.abortPrune) {
|
||||
// Belt-and-braces: return the protect-all set (every readable record's
|
||||
// paths, bypassing the liveness filter) so the fail-safe holds even if a
|
||||
// future caller forgets to check abortPrune first. `counted` stays empty —
|
||||
// attribution is exactly what an unreadable record makes unknowable.
|
||||
std::unordered_set<std::string> seen;
|
||||
for (const DecodedUsage& entry : decoded) {
|
||||
if (!entry.record) continue;
|
||||
for (const UsageHold& h : entry.record->holds) {
|
||||
if (h.relativePath.empty()) continue;
|
||||
if (seen.insert(h.relativePath).second)
|
||||
result.heldPaths.push_back(h.relativePath);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
std::vector<UsageRecord> records;
|
||||
records.reserve(decoded.size());
|
||||
for (const std::optional<UsageRecord>& rec : decoded) {
|
||||
if (!rec) {
|
||||
// Present-but-unreadable record: it may protect anything, so halt.
|
||||
// Belt-and-braces: also return the protect-all set (every readable
|
||||
// record's paths, bypassing the liveness filter) so the fail-safe
|
||||
// holds even if a future caller forgets to check abortPrune first.
|
||||
result.abortPrune = true;
|
||||
std::unordered_set<std::string> seen;
|
||||
for (const std::optional<UsageRecord>& r : decoded) {
|
||||
if (!r) continue;
|
||||
for (const UsageHold& h : r->holds) {
|
||||
if (h.relativePath.empty()) continue;
|
||||
if (seen.insert(h.relativePath).second)
|
||||
result.heldPaths.push_back(h.relativePath);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
records.push_back(*rec);
|
||||
}
|
||||
for (const DecodedUsage& entry : decoded) records.push_back(*entry.record);
|
||||
result.heldPaths = usageHeldPaths(records, liveTrackGuids, anyInstanceLive);
|
||||
|
||||
const bool protectAll = !records.empty() && !anyInstanceLive;
|
||||
for (const DecodedUsage& entry : decoded) {
|
||||
if (!recordCounts(*entry.record, liveTrackGuids, anyInstanceLive, protectAll))
|
||||
continue;
|
||||
result.counted.push_back(CountedUsage{entry.key, *entry.record});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -147,16 +147,31 @@ std::vector<std::string> usageHeldPaths(
|
||||
const std::unordered_set<std::string>& liveTrackGuids,
|
||||
bool anyInstanceLive);
|
||||
|
||||
// The prune-side entry fold over raw read/decode results, one element per
|
||||
// enumerated rsusage_* key: nullopt = present but unreadable/undecodable. ANY
|
||||
// nullopt sets abortPrune (halt, delete nothing); otherwise delegates to
|
||||
// usageHeldPaths (including its protect-all net).
|
||||
// One enumerated rsusage_* key and what came back from it: nullopt = present but
|
||||
// unreadable/undecodable.
|
||||
struct DecodedUsage {
|
||||
std::string key; // "rsusage_<guid>"
|
||||
std::optional<UsageRecord> record;
|
||||
};
|
||||
|
||||
// A record that counted toward heldPaths, still attributed to its key. Lets a
|
||||
// consumer ask "who holds this path" — the flattened path list cannot.
|
||||
struct CountedUsage {
|
||||
std::string key;
|
||||
UsageRecord record;
|
||||
};
|
||||
|
||||
// The prune-side entry fold. ANY unreadable record sets abortPrune (halt, delete
|
||||
// nothing) and names its key; otherwise delegates to usageHeldPaths (including its
|
||||
// protect-all net) and reports which records counted.
|
||||
struct UsageFoldResult {
|
||||
bool abortPrune = false;
|
||||
std::vector<std::string> offendingKeys; // non-empty iff abortPrune
|
||||
std::vector<std::string> heldPaths;
|
||||
std::vector<CountedUsage> counted; // empty on abort
|
||||
};
|
||||
UsageFoldResult foldUsageRecords(
|
||||
const std::vector<std::optional<UsageRecord>>& decoded,
|
||||
const std::vector<DecodedUsage>& decoded,
|
||||
const std::unordered_set<std::string>& liveTrackGuids,
|
||||
bool anyInstanceLive);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user