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:
+50
-17
@@ -52,6 +52,12 @@ UsageRecord makeRecord(const std::string& trackGuid, const std::string& nonce,
|
||||
return r;
|
||||
}
|
||||
|
||||
// One enumerated key's read result. Keys are synthesized per index because the fold
|
||||
// only uses them to name an offender; a nullopt record is the unreadable case.
|
||||
DecodedUsage decodedOf(const std::string& key, std::optional<UsageRecord> rec) {
|
||||
return DecodedUsage{key, std::move(rec)};
|
||||
}
|
||||
|
||||
bool holdsContainPath(const std::vector<UsageHold>& holds, const std::string& path) {
|
||||
for (const UsageHold& h : holds)
|
||||
if (h.relativePath == path) return true;
|
||||
@@ -349,36 +355,61 @@ static void testTakeFxAttributedRecordIsProtected() {
|
||||
// delete nothing) — silently reduced protection is the delete direction.
|
||||
|
||||
static void testUnreadableRecordAbortsPrune() {
|
||||
std::vector<std::optional<UsageRecord>> decoded;
|
||||
decoded.push_back(makeRecord("{T1}", "N1", {UsageHold{"a", "pa.wav"}}));
|
||||
decoded.push_back(std::nullopt); // one unreadable record among readable ones
|
||||
std::vector<DecodedUsage> decoded;
|
||||
decoded.push_back(decodedOf("rsusage_1", makeRecord("{T1}", "N1", {UsageHold{"a", "pa.wav"}})));
|
||||
decoded.push_back(decodedOf("rsusage_BAD", std::nullopt)); // one unreadable among readable
|
||||
const UsageFoldResult fold =
|
||||
foldUsageRecords(decoded, std::unordered_set<std::string>{"{T1}"}, true);
|
||||
CHECK(fold.abortPrune);
|
||||
// The offender is named so the action can tell the operator which key to recover.
|
||||
CHECK(fold.offendingKeys.size() == 1);
|
||||
CHECK(fold.offendingKeys[0] == "rsusage_BAD");
|
||||
// Attribution is exactly what an unreadable record destroys — no record may be
|
||||
// reported as counted while one is unreadable.
|
||||
CHECK(fold.counted.empty());
|
||||
|
||||
// All readable -> no abort, normal liveness fold.
|
||||
std::vector<std::optional<UsageRecord>> ok;
|
||||
ok.push_back(makeRecord("{T1}", "N1", {UsageHold{"a", "pa.wav"}}));
|
||||
// All readable -> no abort, normal liveness fold, and the live record is attributed.
|
||||
std::vector<DecodedUsage> ok;
|
||||
ok.push_back(decodedOf("rsusage_1", makeRecord("{T1}", "N1", {UsageHold{"a", "pa.wav"}})));
|
||||
const UsageFoldResult okFold =
|
||||
foldUsageRecords(ok, std::unordered_set<std::string>{"{T1}"}, true);
|
||||
CHECK(!okFold.abortPrune);
|
||||
CHECK(okFold.offendingKeys.empty());
|
||||
CHECK(okFold.heldPaths.size() == 1);
|
||||
CHECK(okFold.heldPaths[0] == "pa.wav");
|
||||
CHECK(okFold.counted.size() == 1);
|
||||
CHECK(okFold.counted[0].key == "rsusage_1");
|
||||
|
||||
// Empty input (no records enumerated) -> empty, no abort.
|
||||
const UsageFoldResult empty =
|
||||
foldUsageRecords({}, std::unordered_set<std::string>{}, false);
|
||||
CHECK(!empty.abortPrune);
|
||||
CHECK(empty.heldPaths.empty());
|
||||
CHECK(empty.counted.empty());
|
||||
|
||||
// Readable records + zero identified -> the protect-all net applies through the
|
||||
// fold too (belt and braces with the abort).
|
||||
std::vector<std::optional<UsageRecord>> unmatched;
|
||||
unmatched.push_back(makeRecord("{T9}", "N1", {UsageHold{"a", "pa.wav"}}));
|
||||
// fold too (belt and braces with the abort), and every record counts.
|
||||
std::vector<DecodedUsage> unmatched;
|
||||
unmatched.push_back(decodedOf("rsusage_9", makeRecord("{T9}", "N1", {UsageHold{"a", "pa.wav"}})));
|
||||
const UsageFoldResult net =
|
||||
foldUsageRecords(unmatched, std::unordered_set<std::string>{}, false);
|
||||
CHECK(!net.abortPrune);
|
||||
CHECK(net.heldPaths.size() == 1);
|
||||
CHECK(net.counted.size() == 1);
|
||||
}
|
||||
|
||||
// A dead-track record must be excluded from `counted` as well as from heldPaths —
|
||||
// attribution and protection must name the same records or the two consumers drift.
|
||||
static void testCountedMirrorsHeldPaths() {
|
||||
std::vector<DecodedUsage> decoded;
|
||||
decoded.push_back(decodedOf("rsusage_LIVE", makeRecord("{LIVE}", "N1", {UsageHold{"a", "pa.wav"}})));
|
||||
decoded.push_back(decodedOf("rsusage_DEAD", makeRecord("{DEAD}", "N2", {UsageHold{"b", "pb.wav"}})));
|
||||
const UsageFoldResult fold =
|
||||
foldUsageRecords(decoded, std::unordered_set<std::string>{"{LIVE}"}, true);
|
||||
CHECK(fold.heldPaths.size() == 1);
|
||||
CHECK(fold.heldPaths[0] == "pa.wav");
|
||||
CHECK(fold.counted.size() == 1);
|
||||
CHECK(fold.counted[0].key == "rsusage_LIVE");
|
||||
}
|
||||
|
||||
// --- abort returns the protect-all set (belt-and-braces) ---------------------------
|
||||
@@ -388,10 +419,10 @@ static void testUnreadableRecordAbortsPrune() {
|
||||
// delete-ward).
|
||||
static void testAbortFoldReturnsProtectAllSet() {
|
||||
// Two readable records + one unreadable (nullopt) in between.
|
||||
std::vector<std::optional<UsageRecord>> decoded;
|
||||
decoded.push_back(makeRecord("{T1}", "N1", {UsageHold{"a", "pa.wav"}}));
|
||||
decoded.push_back(std::nullopt); // triggers abort
|
||||
decoded.push_back(makeRecord("{T2}", "N2", {UsageHold{"b", "pb.wav"}}));
|
||||
std::vector<DecodedUsage> decoded;
|
||||
decoded.push_back(decodedOf("rsusage_1", makeRecord("{T1}", "N1", {UsageHold{"a", "pa.wav"}})));
|
||||
decoded.push_back(decodedOf("rsusage_BAD", std::nullopt)); // triggers abort
|
||||
decoded.push_back(decodedOf("rsusage_2", makeRecord("{T2}", "N2", {UsageHold{"b", "pb.wav"}})));
|
||||
|
||||
// Live: only {T1} — so without protect-all, T2's path would be excluded.
|
||||
const UsageFoldResult fold =
|
||||
@@ -409,8 +440,8 @@ static void testAbortFoldReturnsProtectAllSet() {
|
||||
CHECK(hasPB);
|
||||
|
||||
// All nullopt (every key unreadable): abort + empty heldPaths (nothing readable).
|
||||
std::vector<std::optional<UsageRecord>> allNull;
|
||||
allNull.push_back(std::nullopt);
|
||||
std::vector<DecodedUsage> allNull;
|
||||
allNull.push_back(decodedOf("rsusage_BAD", std::nullopt));
|
||||
const UsageFoldResult allNullFold =
|
||||
foldUsageRecords(allNull, std::unordered_set<std::string>{}, false);
|
||||
CHECK(allNullFold.abortPrune);
|
||||
@@ -445,8 +476,9 @@ static void testTruncatedWalkProtectsAll() {
|
||||
CHECK(hasB);
|
||||
|
||||
// Belt-and-braces: the same scenario through foldUsageRecords also protects all.
|
||||
std::vector<std::optional<UsageRecord>> decoded;
|
||||
for (const UsageRecord& r : records) decoded.push_back(r);
|
||||
std::vector<DecodedUsage> decoded;
|
||||
for (std::size_t i = 0; i < records.size(); ++i)
|
||||
decoded.push_back(decodedOf("rsusage_" + std::to_string(i), records[i]));
|
||||
const UsageFoldResult fold =
|
||||
foldUsageRecords(decoded, std::unordered_set<std::string>{}, false);
|
||||
CHECK(!fold.abortPrune);
|
||||
@@ -561,6 +593,7 @@ int main() {
|
||||
testHeldPathsDedupAndEmptyPathSkip();
|
||||
testTakeFxAttributedRecordIsProtected();
|
||||
testUnreadableRecordAbortsPrune();
|
||||
testCountedMirrorsHeldPaths();
|
||||
testAbortFoldReturnsProtectAllSet();
|
||||
testTruncatedWalkProtectsAll();
|
||||
testIdentityMatcher();
|
||||
|
||||
Reference in New Issue
Block a user