tracking: read the ledger's version, not just write it; clear owned on any block; channel-correct prune recovery
This commit is contained in:
@@ -273,8 +273,6 @@ ImportResult importFileIntoActiveBank(const std::string& absoluteSourcePath) {
|
||||
s.createdTimestamp = nowSec;
|
||||
|
||||
const AddResult r = book.activeIndex().add(s);
|
||||
// Record the birth regardless of outcome — the tool WROTE the file, so prune must
|
||||
// attribute it even in the narrow Collapsed race below.
|
||||
g_session->recordCreated(s, tracking::OriginKind::Ingest);
|
||||
|
||||
switch (r) {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "core/version/app_version.h" // extStateNamespace — the printed recovery line must be channel-correct
|
||||
#include "shell/persist/session.h" // ReaSamplerSession — pruneDryRun / pruneOrphanSet / pruneReclaim
|
||||
|
||||
#define REAPERAPI_MINIMAL
|
||||
@@ -27,22 +28,36 @@ void doBankPruneFolder(ReaSamplerSession& session) {
|
||||
// set unknowable, so the prune HALTS outright rather than proceed with degraded
|
||||
// protection. Both blockers can fire at once; report each one that did.
|
||||
if (report.blockedByTracking) {
|
||||
// Every printed recovery line names THIS build's ext-state namespace: a beta
|
||||
// user handed the stable spelling would clear the stable channel's key in
|
||||
// their project and still be blocked.
|
||||
const std::string& ns = version::extStateNamespace();
|
||||
std::string msg =
|
||||
"ReaSampler prune: ABORTED -- the file-tracking state could not be read. "
|
||||
"Nothing was deleted.\n";
|
||||
if (report.ledgerUnreadable) {
|
||||
msg += "The stored file-tracking ledger is malformed. It has been left "
|
||||
"intact rather than overwritten, so it can be repaired or cleared:\n"
|
||||
" reaper.SetProjExtState(0, \"reasampler\", \"owned_files\", \"\")\n"
|
||||
" reaper.SetProjExtState(0, \"" + ns + "\", \"owned_files\", \"\")\n"
|
||||
"Clearing it makes every existing bank file un-reclaimable (they stop "
|
||||
"being attributable to ReaSampler); no file is lost.\n";
|
||||
"being attributable to ReaSampler); no file is lost. Reopen the "
|
||||
"project afterwards -- the block is held for the rest of this "
|
||||
"session, and until then no new capture is persisted to the ledger "
|
||||
"either.\n";
|
||||
}
|
||||
if (report.ledgerFutureVersion) {
|
||||
msg += "The stored file-tracking ledger was written by a NEWER version of "
|
||||
"ReaSampler than this one, so its records cannot be read safely. It "
|
||||
"has been left intact and will NOT be overwritten. Reopen the project "
|
||||
"with that newer version -- do NOT clear this key from here, that "
|
||||
"would discard tracking records this build cannot see.\n";
|
||||
}
|
||||
if (!report.unreadableUsageKeys.empty()) {
|
||||
msg += "One or more instance usage records could not be read or decoded. "
|
||||
"If the owning instance is still loaded it will republish its record "
|
||||
"on the next poll tick, clearing the abort. If the instance no longer "
|
||||
"exists (the key is an orphaned corrupt record), clear it manually:\n"
|
||||
" reaper.SetProjExtState(0, \"reasampler\", \"<key>\", \"\")\n"
|
||||
" reaper.SetProjExtState(0, \"" + ns + "\", \"<key>\", \"\")\n"
|
||||
"Offending key(s):\n";
|
||||
for (const std::string& key : report.unreadableUsageKeys) {
|
||||
msg += " " + key + "\n";
|
||||
|
||||
@@ -455,13 +455,13 @@ void RunRecaptureFromSource(ReaSamplerSession& session)
|
||||
// One batched undo point around the in-place mutation; index-only ext-state,
|
||||
// nothing placed on the timeline.
|
||||
Undo_BeginBlock2(nullptr);
|
||||
// Before the index outcome is even known: the render above already wrote the file,
|
||||
// and makeUniqueTag guarantees a NEW path, so this is a second record carrying the
|
||||
// same sampleId as the original. The superseded file becomes an orphan for prune.
|
||||
session.recordCreated(updated, tracking::OriginKind::Recapture);
|
||||
const bool changed = session.book().updateSampleInPlace(sampleId, updated);
|
||||
if (changed)
|
||||
{
|
||||
// Record the regenerated file's birth; the superseded file becomes an orphan
|
||||
// for prune to reclaim. A re-capture onto the same path is a dedup no-op, so
|
||||
// the original birth record — not this one — stays authoritative.
|
||||
session.recordCreated(updated, tracking::OriginKind::Recapture);
|
||||
// Regenerating the same id's audio is exactly why instances need the generation
|
||||
// bump — they'd otherwise keep playing stale audio until reload. Bumped inside
|
||||
// the undo block so undo rolls back the generation with the rest of the blob.
|
||||
|
||||
@@ -250,9 +250,6 @@ CaptureResult captureAndIndexOne(ReaSamplerSession& session,
|
||||
// AddResult tells a fresh add from a hash-dedup collapse, so the assign path (S8) can
|
||||
// target the sample actually in the bank (the existing entry on a collapse).
|
||||
const model::AddResult addResult = session.bank().add(res.sample);
|
||||
// Record the birth at the same point the Sample is added, regardless of the index
|
||||
// AddResult — even a hash-collapse still WROTE a file the tool owns, and the ledger
|
||||
// dedups a repeat path itself (prune reconciles ledger vs index later).
|
||||
session.recordCreated(res.sample, tracking::OriginKind::Capture);
|
||||
|
||||
// Resolve the LANDED bank-index id into res.sample.id for the S8 assign path: the new
|
||||
|
||||
@@ -31,8 +31,6 @@ void CommitRealtimeResult(ReaSamplerSession& session, const CaptureResult& res)
|
||||
return;
|
||||
}
|
||||
session.bank().add(res.sample);
|
||||
// Record the birth regardless of the index AddResult — even a hash-collapse still
|
||||
// wrote a file the tool owns; the ledger dedups a repeat path itself.
|
||||
session.recordCreated(res.sample, tracking::OriginKind::Capture);
|
||||
// A capture add changes what a live instance could play, so bump the generation
|
||||
// before persisting to refresh instances.
|
||||
@@ -56,14 +54,28 @@ void DriveRealtimeCapture(ReaSamplerSession& session)
|
||||
{
|
||||
RealtimeTickResult r = g_rtBackend.abort(*g_rtCapture);
|
||||
// Log without persisting — we restored into the original project but must
|
||||
// not persist into the now-active foreign one. A Failed abort surfaces
|
||||
// not persist into the now-active foreign one: the ledger and bank we would
|
||||
// have to write belong to the project we just left. A Failed abort surfaces
|
||||
// abort()'s own message, distinguishing a clean tab-switch abort from the
|
||||
// closed-project case (nothing restored because the pointers were already
|
||||
// freed).
|
||||
if (r.status == RealtimeTickStatus::Done)
|
||||
ShowConsoleMsg("ReaSampler realtime capture: project switched mid-record -- "
|
||||
"captured audio restored into the original project; not "
|
||||
"persisted to avoid crossing projects.\n");
|
||||
//
|
||||
// The abort already moved the recorded WAV into the ORIGINAL project's bank
|
||||
// folder, so it survives here untracked — deleting a user's just-recorded
|
||||
// audio is the destructive direction and is not this shell's call, so the
|
||||
// path is NAMED instead and the operator decides (docs/TODO.md).
|
||||
if (r.status == RealtimeTickStatus::Done && r.result.status == CaptureStatus::Ok)
|
||||
ShowConsoleMsg(("ReaSampler realtime capture: project switched mid-record -- "
|
||||
"captured audio restored into the original project; not "
|
||||
"persisted to avoid crossing projects. The recorded file was "
|
||||
"left in the original project's bank folder as '" +
|
||||
r.result.sample.relativePath +
|
||||
"', untracked -- reopen that project and re-import it, or "
|
||||
"delete it by hand.\n").c_str());
|
||||
else if (r.status == RealtimeTickStatus::Done)
|
||||
ShowConsoleMsg(("ReaSampler realtime capture: project switched mid-record -- "
|
||||
"the capture was aborted and produced no usable file: " +
|
||||
r.result.message + "\n").c_str());
|
||||
else
|
||||
ShowConsoleMsg(("ReaSampler realtime capture: project changed mid-record -- " +
|
||||
r.result.message + "\n").c_str());
|
||||
|
||||
@@ -34,17 +34,20 @@ REAPER/filesystem-facing half only, and it gathers rather than decides.
|
||||
record/input chains, containers recursively, take FX), and folds via the pure
|
||||
`sample_usage::foldUsageRecords`. Read-only at prune-scan time: `usage_scan` writes
|
||||
no ext-state.
|
||||
- **A malformed tracking ledger is Unreadable, never "empty".** `ext_state_io` keeps
|
||||
- **A ledger this build cannot read is degraded, never "empty".** `ext_state_io` keeps
|
||||
the `LedgerStatus` alongside the ledger, and `saveToActiveProject` SKIPS the
|
||||
`owned_files` write while it is `Unreadable` — replacing a corrupt blob would
|
||||
destroy the only record of every file created before the corruption, silently
|
||||
turning them into permanently unreclaimable foreign files. Captures made during
|
||||
such a session are recorded in memory but not persisted; they degrade to foreign
|
||||
(untouchable), which is the safe direction.
|
||||
`owned_files` write while `tracking::ledgerDegraded` holds — replacing a blob we
|
||||
could not read would destroy the only record of every file created before it,
|
||||
silently turning them into permanently unreclaimable foreign files. The status is
|
||||
written only by `loadFromProject`, so it is sticky until the project is reloaded:
|
||||
captures made during such a session are recorded in memory but not persisted, and
|
||||
degrade to foreign (untouchable), which is the safe direction.
|
||||
- **`PruneReport` carries `blockedByTracking` + `ledgerUnreadable` /
|
||||
`unreadableUsageKeys`**; dry-run, orphan-set, and reclaim each independently abort
|
||||
(delete nothing) on a block. `BANK_PRUNE_FOLDER` (in `shell/actions`) halts on the
|
||||
flag and prints whichever blockers fired.
|
||||
`ledgerFutureVersion` / `unreadableUsageKeys`**; dry-run, orphan-set, and reclaim
|
||||
each independently abort (delete nothing) on a block. `BANK_PRUNE_FOLDER` (in
|
||||
`shell/actions`) halts on the flag and prints whichever blockers fired, with
|
||||
channel-correct recovery lines — a corrupt blob may be cleared, a newer build's
|
||||
must not be.
|
||||
|
||||
## Modules
|
||||
|
||||
|
||||
@@ -161,11 +161,9 @@ bool ReaSamplerSession::saveToActiveProject() {
|
||||
SetProjExtState(static_cast<ReaProject*>(proj), projExtNamespace(),
|
||||
kProjExtTailKey, tailJson.c_str());
|
||||
|
||||
// Written on every save so the ledger and the bank stay in lockstep on disk —
|
||||
// EXCEPT over a blob we could not read: replacing it would destroy the only
|
||||
// record of every file created before the corruption, silently turning them
|
||||
// into permanently unreclaimable foreign files.
|
||||
if (trackingStatus_ != tracking::LedgerStatus::Unreadable) {
|
||||
// Never write over a blob this build could not read (see this directory's
|
||||
// CLAUDE.md for why the suppression, not a rewrite, is the safe direction).
|
||||
if (!tracking::ledgerDegraded(trackingStatus_)) {
|
||||
const std::string ledgerJson = tracking_.serialize();
|
||||
SetProjExtState(static_cast<ReaProject*>(proj), projExtNamespace(),
|
||||
kProjExtOwnedKey, ledgerJson.c_str());
|
||||
@@ -234,10 +232,8 @@ capture::TailSetting loadTailSetting(ReaProject* proj) {
|
||||
return *loaded;
|
||||
}
|
||||
|
||||
// Absent/empty key -> Fresh (a new project, or a bank predating the ledger).
|
||||
// Malformed -> Unreadable, which halts the prune and suppresses the next write
|
||||
// rather than degrading to an empty ledger that looks like "nothing was ever
|
||||
// created".
|
||||
// Absent/empty key -> Fresh (a new project, or a bank predating the ledger). Anything
|
||||
// this build cannot read is a degraded status, never an empty ledger.
|
||||
tracking::LedgerLoad loadOriginLedger(ReaProject* proj) {
|
||||
if (!proj) return tracking::LedgerLoad{};
|
||||
tracking::LedgerLoad load = tracking::loadLedger(
|
||||
@@ -246,6 +242,11 @@ tracking::LedgerLoad loadOriginLedger(ReaProject* proj) {
|
||||
ShowConsoleMsg("ReaSampler: the stored file-tracking ledger is malformed. Prune "
|
||||
"is halted for this project and the stored value is left intact "
|
||||
"for recovery.\n");
|
||||
} else if (load.status == tracking::LedgerStatus::FutureVersion) {
|
||||
ShowConsoleMsg("ReaSampler: the stored file-tracking ledger was written by a "
|
||||
"NEWER version of ReaSampler. Prune is halted for this project "
|
||||
"and the stored value will not be overwritten -- reopen the "
|
||||
"project with that version.\n");
|
||||
}
|
||||
return load;
|
||||
}
|
||||
|
||||
@@ -72,6 +72,7 @@ struct PruneScan {
|
||||
std::unordered_map<std::string, std::uint64_t> sizeByRel;
|
||||
bool blocked = false;
|
||||
bool ledgerUnreadable = false;
|
||||
bool ledgerFutureVersion = false;
|
||||
std::vector<std::string> unreadableUsageKeys;
|
||||
};
|
||||
|
||||
@@ -134,6 +135,7 @@ PruneScan scanPruneOrphans(const BankBook& book, const tracking::OriginLedger& l
|
||||
// action tell the user what to recover.
|
||||
scan.blocked = true;
|
||||
scan.ledgerUnreadable = protection.ledgerUnreadable;
|
||||
scan.ledgerFutureVersion = protection.ledgerFutureVersion;
|
||||
scan.unreadableUsageKeys = protection.unreadableUsageKeys;
|
||||
return scan;
|
||||
}
|
||||
@@ -211,6 +213,7 @@ reclaim::PruneReport ReaSamplerSession::pruneDryRun() const {
|
||||
// the prune refused to run.
|
||||
report.blockedByTracking = scan.blocked;
|
||||
report.ledgerUnreadable = scan.ledgerUnreadable;
|
||||
report.ledgerFutureVersion = scan.ledgerFutureVersion;
|
||||
report.unreadableUsageKeys = scan.unreadableUsageKeys;
|
||||
return report;
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
#define REAPERAPI_MINIMAL
|
||||
#define REAPERAPI_WANT_MarkProjectDirty
|
||||
#define REAPERAPI_WANT_SetProjExtState
|
||||
#define REAPERAPI_WANT_ShowConsoleMsg
|
||||
#include "reaper_plugin_functions.h"
|
||||
|
||||
namespace reasampler {
|
||||
@@ -56,7 +57,18 @@ void ReaSamplerSession::recordCreated(const model::Sample& sample,
|
||||
// rather than re-deriving keeps one source for the lineage fact. Absent
|
||||
// provenance is a root capture, not a gap.
|
||||
if (sample.provenance) rec.parentSampleId = sample.provenance->parentSampleId;
|
||||
tracking_.record(rec);
|
||||
|
||||
const tracking::RecordResult result = tracking_.record(rec);
|
||||
// A rejection means a file exists that nothing attributes to us — invisible
|
||||
// otherwise, and exactly the gap this ledger exists to close. AlreadyPresent is
|
||||
// the normal dedup outcome, not a gap.
|
||||
if (result == tracking::RecordResult::RejectedEmptyPath ||
|
||||
result == tracking::RecordResult::RejectedAbsolutePath) {
|
||||
ShowConsoleMsg(("ReaSampler: could not record the origin of '" +
|
||||
sample.relativePath +
|
||||
"' -- the path is empty or absolute. The file is NOT tracked and "
|
||||
"prune will treat it as a foreign file.\n").c_str());
|
||||
}
|
||||
}
|
||||
|
||||
bool ReaSamplerSession::consumeLoadSignal() {
|
||||
|
||||
+16
-14
@@ -71,17 +71,20 @@ public:
|
||||
capture::TailSetting& tail() { return tail_; }
|
||||
const capture::TailSetting& tail() const { return tail_; }
|
||||
|
||||
// Birth records for the files the system itself created. Read as a PAIR —
|
||||
// the ledger alone cannot say whether an absent record means never-recorded
|
||||
// or unreadable, and the two demand opposite treatment. This is the reach
|
||||
// any consumer outside persist uses to build a tracking::TrackingState.
|
||||
const tracking::OriginLedger& tracking() const { return tracking_; }
|
||||
tracking::LedgerStatus trackingStatus() const { return trackingStatus_; }
|
||||
|
||||
// Record a system-created file at the moment it exists — the ONLY way a
|
||||
// birth record is written, so lineage can never be backfilled from a later
|
||||
// guess. Lineage is read off the Sample's own provenance, the same act that
|
||||
// stamped it, so the two cannot disagree. A repeat path is a no-op.
|
||||
// Record a system-created file at the moment it exists — the ONLY way a birth
|
||||
// record is written. Lineage is read off the Sample's own provenance, the same
|
||||
// act that stamped it, so the two cannot disagree; where they later diverge the
|
||||
// ledger record is authoritative (core/tracking/CLAUDE.md).
|
||||
//
|
||||
// Call it at EVERY creation site regardless of the bank's AddResult: a
|
||||
// hash-collapse still wrote a file the tool owns, and an unrecorded file is a
|
||||
// permanently unreclaimable foreign file. A rejected record is reported to the
|
||||
// console — a file with no record is the gap this track exists to eliminate.
|
||||
//
|
||||
// A consumer outside persist that needs the ledger must take it WITH its
|
||||
// LedgerStatus (a tracking::TrackingState); no accessor exposes one without the
|
||||
// other, because an absent record and an unreadable ledger demand opposite
|
||||
// treatment.
|
||||
void recordCreated(const model::Sample& sample, tracking::OriginKind kind);
|
||||
|
||||
// The version that last wrote the active project: PreVersioning (no
|
||||
@@ -153,9 +156,8 @@ private:
|
||||
ViewModeModel view_; // reset to default on a project with no stored view_state
|
||||
capture::TailSetting tail_; // reset to default (None / 2s) with no stored tail key
|
||||
tracking::OriginLedger tracking_; // reset to empty/stored on EVERY load path, never inherited
|
||||
// Unreadable is sticky for the project's session: it halts the prune AND
|
||||
// suppresses the ledger write, so a corrupt blob survives for recovery
|
||||
// instead of being silently replaced by a ledger missing every earlier file.
|
||||
// Written only by loadFromProject, so a degraded status is sticky until the
|
||||
// project is reloaded (see this directory's CLAUDE.md).
|
||||
tracking::LedgerStatus trackingStatus_ = tracking::LedgerStatus::Fresh;
|
||||
version::WritingVersion writingVersion_; // recovered per load; PreVersioning default
|
||||
std::int64_t bankGeneration_ = 0; // recovered per load (absent -> 0); monotonic
|
||||
|
||||
Reference in New Issue
Block a user