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:
2026-07-30 19:44:11 -04:00
parent 7bd911d58b
commit 7f70d94228
40 changed files with 1546 additions and 633 deletions
+44 -37
View File
@@ -33,10 +33,11 @@
#include "shell/persist/persist_internal.h"
#include "shell/persist/session.h"
#include "shell/persist/usage_scan.h" // liveInstanceHeldPaths — instance holds join `referenced`
#include "shell/persist/usage_scan.h" // scanInstanceUsage — one of the authority's two inputs
#include "core/capture/capture_paths.h" // resolveBankFile / bankRelativeForName / kBankSubfolder
#include "core/reclaim/prune_reconcile.h" // the pure orphan decision + report tallies
#include "core/capture/capture_paths.h" // resolveBankFile / bankRelativeForName / kBankSubfolder
#include "core/reclaim/prune_reconcile.h" // the pure orphan decision + report tallies
#include "core/tracking/tracking_authority.h" // the one protection answer
namespace reasampler {
@@ -62,21 +63,22 @@ constexpr std::size_t kPruneListDisplayCap = 64;
// active/saved project, no project dir, or no folder yet.
// * orphans — the full orphan set, untruncated. The pure core decides.
// * sizeByRel — per-orphan on-disk byte size (0 when it could not be stat'd).
// * abortedUnreadableUsage — true iff a present rsusage_* record could not
// be read/decoded: `orphans` is left EMPTY, the prune must
// halt rather than proceed with degraded protection.
// * blocked — true iff the tracking authority could not answer: `orphans`
// is left EMPTY, the prune must halt rather than proceed with
// degraded protection.
struct PruneScan {
std::string bankDirAbs;
std::vector<std::string> orphans;
std::unordered_map<std::string, std::uint64_t> sizeByRel;
bool abortedUnreadableUsage = false;
std::vector<std::string> offendingUsageKeys; // non-empty iff abortedUnreadableUsage
bool blocked = false;
bool ledgerUnreadable = false;
std::vector<std::string> unreadableUsageKeys;
};
// Non-throwing: readActiveProject + resolveBankFile are pure/string; every filesystem
// call below uses an error_code form so no std::filesystem_error crosses REAPER's C ABI.
PruneScan scanPruneOrphans(const BankBook& book,
const model::OwnedFileManifest& owned) {
PruneScan scanPruneOrphans(const BankBook& book, const tracking::OriginLedger& ledger,
tracking::LedgerStatus ledgerStatus) {
PruneScan scan;
std::string rppPath;
@@ -97,7 +99,7 @@ PruneScan scanPruneOrphans(const BankBook& book,
// Enumerate into project-relative paths spelled the SAME way the capture
// path spells them, so the pure core's exact-string match lines up with
// referencedPaths() and the manifest. Non-recursive: the bank folder is
// referencedPaths() and the ledger. Non-recursive: the bank folder is
// flat. Manual iterator form (it.increment(ec)) keeps the loop
// non-throwing on a mid-iteration failure.
std::vector<std::string> present;
@@ -115,28 +117,30 @@ PruneScan scanPruneOrphans(const BankBook& book,
scan.sizeByRel[rel] = sz_ec ? 0 : static_cast<std::uint64_t>(sz);
}
// The decision lives in the pure core — read-only inputs from the book and
// manifest. referencedPaths() unions across the whole book; the referenced
// set additionally unions every LIVE ReaSampler 9000 instance's held
// captures (usage_scan + sample_usage decide liveness) — a capture any
// live instance holds can never be an orphan, even if its bank entry was
// deleted while the instance kept its ref. liveInstanceHeldPaths is
// read-only; this shell only enumerates, resolves, and stats.
// The decision lives in the pure core; the tracking authority supplies both of
// its tracking-derived inputs so the prune and the resample can never disagree
// about what is protected. referencedPaths() unions across the whole book; the
// authority's heldPaths adds every live instance's captures on top — a capture
// any live instance holds can never be an orphan, even if its bank entry was
// deleted while the instance kept its ref. Read-only throughout: this shell
// only enumerates, resolves, and stats.
scan.bankDirAbs = bankDir;
const UsageScanResult usage = liveInstanceHeldPaths(proj);
if (usage.abortPrune) {
// FAIL-SAFE ABORT: a present rsusage_* record could not be read/decoded,
// so the protected set is unknowable. Compute NO orphans — every
// downstream consumer then deletes nothing. The key names let the
// action tell the user which keys to recover.
scan.abortedUnreadableUsage = true;
scan.offendingUsageKeys = usage.offendingKeys;
const wire::UsageFoldResult usage = scanInstanceUsage(proj);
const tracking::TrackingState state{ledgerStatus, ledger, usage};
const tracking::ProtectionAnswer protection = tracking::pruneProtection(state);
if (protection.blocked) {
// FAIL-SAFE ABORT: the protected set is unknowable. Compute NO orphans —
// every downstream consumer then deletes nothing. The blockers let the
// action tell the user what to recover.
scan.blocked = true;
scan.ledgerUnreadable = protection.ledgerUnreadable;
scan.unreadableUsageKeys = protection.unreadableUsageKeys;
return scan;
}
scan.orphans = reclaim::pruneOrphans(
present,
reclaim::mergeReferenced(book.referencedPaths(), usage.heldPaths),
owned.paths());
reclaim::mergeReferenced(book.referencedPaths(), protection.heldPaths),
protection.ownedPaths);
return scan;
}
@@ -199,19 +203,22 @@ bool deleteOrphanFile(const std::string& absPath, bool& outUsedTrash,
} // namespace
reclaim::PruneReport ReaSamplerSession::pruneDryRun() const {
const PruneScan scan = scanPruneOrphans(book_, owned_);
const PruneScan scan = scanPruneOrphans(book_, tracking_, trackingStatus_);
reclaim::PruneReport report =
reclaim::buildPruneReport(scan.orphans, scan.sizeByRel, kPruneListDisplayCap);
// Surface the unreadable-usage abort so the action halts with an explicit
// message instead of reporting "no orphaned files" — the count IS zero,
// but the user must know the prune refused to run.
report.abortedUnreadableUsage = scan.abortedUnreadableUsage;
report.offendingUsageKeys = scan.offendingUsageKeys;
// Surface the block so the action halts with an explicit message instead of
// reporting "no orphaned files" — the count IS zero, but the user must know
// the prune refused to run.
report.blockedByTracking = scan.blocked;
report.ledgerUnreadable = scan.ledgerUnreadable;
report.unreadableUsageKeys = scan.unreadableUsageKeys;
return report;
}
std::vector<std::string> ReaSamplerSession::pruneOrphanSet() const {
return scanPruneOrphans(book_, owned_).orphans; // full set, untruncated
// Full set, untruncated; empty on a block, so a caller that skipped the report
// still confirms nothing.
return scanPruneOrphans(book_, tracking_, trackingStatus_).orphans;
}
reclaim::PruneDeletionResult ReaSamplerSession::pruneReclaim(
@@ -222,10 +229,10 @@ reclaim::PruneDeletionResult ReaSamplerSession::pruneReclaim(
// targets exactly `confirmed ∩ freshOrphans`, so a file that vanished or
// became referenced between confirm and delete is skipped, and a newly-
// appeared orphan not in `confirmed` is never swept. If this fresh scan
// hits an unreadable usage record it aborts with an EMPTY orphan set, so
// hits unreadable tracking state it aborts with an EMPTY orphan set, so
// the plan below intersects to empty and nothing is deleted — the
// fail-safe holds even in the confirm-to-delete window.
const PruneScan scan = scanPruneOrphans(book_, owned_);
const PruneScan scan = scanPruneOrphans(book_, tracking_, trackingStatus_);
if (scan.bankDirAbs.empty()) return result; // no project / no folder -> nothing
const std::vector<std::string> plan =