fix: reconcile stale manualBackupBatchJournalCoverage when contiguous earlier journal coverage is rebuilt

- Add hasContiguousJournalCoverageThroughFloor to check if current
  batchJournal contiguously covers back through the old retained floor
- Add reconcileManualBackupBatchJournalCoverage that clears stale
  coverage when actual journals have bridged the gap
- Self-clean during normalizeGraphRuntimeState (load-time) and
  appendBatchJournal (write-time)
- findJournalRecoveryPoint now uses reconciled coverage so stale
  manual backup floors no longer block valid reverse-journal recovery
- Add regression tests for gap-not-yet-bridged and bridged scenarios
This commit is contained in:
Youzini-afk
2026-04-12 16:35:04 +08:00
parent a4a11bb6d2
commit 5a53d010af
2 changed files with 152 additions and 1 deletions

View File

@@ -124,10 +124,88 @@ function getEarliestJournalCoverageStartFloor(journals = []) {
return earliestFloor;
}
function hasContiguousJournalCoverageThroughFloor(journals = [], targetFloor = null) {
const normalizedTargetFloor = Number.isFinite(Number(targetFloor))
? Math.max(0, Math.floor(Number(targetFloor)))
: null;
if (!Number.isFinite(normalizedTargetFloor)) {
return false;
}
const ranges = (Array.isArray(journals) ? journals : [])
.map((journal) => {
const range = Array.isArray(journal?.processedRange)
? journal.processedRange
: [];
const startFloor = Number(range[0]);
const endFloor = Number(range[1]);
if (!Number.isFinite(startFloor) || !Number.isFinite(endFloor)) {
return null;
}
return {
start: Math.max(0, Math.floor(startFloor)),
end: Math.max(0, Math.floor(endFloor)),
};
})
.filter(Boolean)
.sort((left, right) => left.start - right.start || left.end - right.end);
if (ranges.length === 0) {
return false;
}
let coveredUntil = null;
for (const range of ranges) {
if (coveredUntil == null) {
if (range.start > normalizedTargetFloor) {
return false;
}
coveredUntil = range.end;
} else if (range.start > coveredUntil + 1) {
return coveredUntil >= normalizedTargetFloor;
} else {
coveredUntil = Math.max(coveredUntil, range.end);
}
if (coveredUntil >= normalizedTargetFloor) {
return true;
}
}
return false;
}
function reconcileManualBackupBatchJournalCoverage(coverage = null, journals = []) {
const normalizedCoverage = normalizeManualBackupBatchJournalCoverage(coverage);
if (!normalizedCoverage) {
return null;
}
const manualCoverageFloor =
normalizedCoverage.truncated === true &&
Number.isFinite(normalizedCoverage.earliestRetainedFloor)
? normalizedCoverage.earliestRetainedFloor
: null;
const actualCoverageFloor = getEarliestJournalCoverageStartFloor(journals);
if (
normalizedCoverage.truncated === true &&
Number.isFinite(actualCoverageFloor) &&
Number.isFinite(manualCoverageFloor) &&
actualCoverageFloor < manualCoverageFloor &&
hasContiguousJournalCoverageThroughFloor(journals, manualCoverageFloor)
) {
return null;
}
return normalizedCoverage;
}
function getRequiredJournalCoverageStartFloor(graph, journals = []) {
const actualCoverageFloor = getEarliestJournalCoverageStartFloor(journals);
const manualCoverage = normalizeManualBackupBatchJournalCoverage(
const manualCoverage = reconcileManualBackupBatchJournalCoverage(
graph?.historyState?.[MANUAL_BACKUP_BATCH_JOURNAL_COVERAGE_KEY],
journals,
);
const manualCoverageFloor =
manualCoverage?.truncated === true &&
@@ -406,6 +484,11 @@ export function normalizeGraphRuntimeState(graph, chatId = "") {
graph.batchJournal = Array.isArray(graph.batchJournal)
? graph.batchJournal.slice(-BATCH_JOURNAL_LIMIT)
: createDefaultBatchJournal();
historyState[MANUAL_BACKUP_BATCH_JOURNAL_COVERAGE_KEY] =
reconcileManualBackupBatchJournalCoverage(
historyState[MANUAL_BACKUP_BATCH_JOURNAL_COVERAGE_KEY],
graph.batchJournal,
);
graph.maintenanceJournal = Array.isArray(graph.maintenanceJournal)
? graph.maintenanceJournal
.filter((entry) => entry && typeof entry === "object")
@@ -955,6 +1038,11 @@ export function appendBatchJournal(graph, entry) {
if (graph.batchJournal.length > BATCH_JOURNAL_LIMIT) {
graph.batchJournal = graph.batchJournal.slice(-BATCH_JOURNAL_LIMIT);
}
graph.historyState[MANUAL_BACKUP_BATCH_JOURNAL_COVERAGE_KEY] =
reconcileManualBackupBatchJournalCoverage(
graph.historyState?.[MANUAL_BACKUP_BATCH_JOURNAL_COVERAGE_KEY],
graph.batchJournal,
);
}
export function createMaintenanceJournalEntry(

View File

@@ -291,6 +291,69 @@ assert.ok(retainedCoverageRecoveryPoint);
assert.equal(retainedCoverageRecoveryPoint.path, "reverse-journal");
assert.equal(retainedCoverageRecoveryPoint.affectedJournals.length, 3);
const bridgedCoverageGraph = createEmptyGraph();
bridgedCoverageGraph.historyState.chatId = "chat-bridged-history-test";
bridgedCoverageGraph.historyState[MANUAL_BACKUP_BATCH_JOURNAL_COVERAGE_KEY] = {
truncated: true,
earliestRetainedFloor: 4,
retainedCount: 4,
};
bridgedCoverageGraph.batchJournal = [
{ id: "journal-4", journalVersion: 2, processedRange: [4, 4] },
{ id: "journal-5", journalVersion: 2, processedRange: [5, 5] },
];
appendBatchJournal(bridgedCoverageGraph, {
id: "journal-2",
journalVersion: 2,
processedRange: [2, 2],
});
assert.deepEqual(
bridgedCoverageGraph.historyState[MANUAL_BACKUP_BATCH_JOURNAL_COVERAGE_KEY],
{
truncated: true,
earliestRetainedFloor: 4,
retainedCount: 4,
},
);
appendBatchJournal(bridgedCoverageGraph, {
id: "journal-3",
journalVersion: 2,
processedRange: [3, 3],
});
assert.equal(
bridgedCoverageGraph.historyState[MANUAL_BACKUP_BATCH_JOURNAL_COVERAGE_KEY],
null,
);
const recoveredCoverageGraph = createEmptyGraph();
recoveredCoverageGraph.historyState.chatId = "chat-recovered-history-test";
recoveredCoverageGraph.historyState[MANUAL_BACKUP_BATCH_JOURNAL_COVERAGE_KEY] = {
truncated: true,
earliestRetainedFloor: 4,
retainedCount: 4,
};
recoveredCoverageGraph.batchJournal = [
{ id: "journal-2", journalVersion: 2, processedRange: [2, 2] },
{ id: "journal-3", journalVersion: 2, processedRange: [3, 3] },
{ id: "journal-4", journalVersion: 2, processedRange: [4, 4] },
{ id: "journal-5", journalVersion: 2, processedRange: [5, 5] },
];
normalizeGraphRuntimeState(
recoveredCoverageGraph,
recoveredCoverageGraph.historyState.chatId,
);
assert.equal(
recoveredCoverageGraph.historyState[MANUAL_BACKUP_BATCH_JOURNAL_COVERAGE_KEY],
null,
);
const recoveredCoverageRecoveryPoint = findJournalRecoveryPoint(
recoveredCoverageGraph,
2,
);
assert.ok(recoveredCoverageRecoveryPoint);
assert.equal(recoveredCoverageRecoveryPoint.path, "reverse-journal");
assert.equal(recoveredCoverageRecoveryPoint.affectedJournals.length, 4);
rollbackBatch(graph, recoveryPoint.affectedJournals[0]);
assert.equal(graph.nodes.length, 0);
assert.equal(graph.historyState.lastProcessedAssistantFloor, -1);