Skip to content

Commit d9a6c9e

Browse files
JohnMcLearclaude
andcommitted
feat: report a damaged pad history instead of asserting
deleteRevisions() went straight into pad.check(), which replays the whole history and dies on AssertionError: The expression evaluated to a falsy value: assert(timestamp != null) That is an assertion about a null timestamp, when what an operator needs to hear is which revision is missing and what to do about it. The reporter on #8134 had to bisect their database by hand to find it. Add Pad.findMissingRevisions(), a cheap scan of 0..head for revisions that are absent or carry no meta.timestamp -- it reads one sub-field per revision and replays nothing -- and run it before check() so the clear error wins. Cleanup now fails with: Pad eu-demopad is missing revision(s) 6. Its history cannot be replayed, so revisions cannot be cleaned up. The pad's current text is unaffected. Rebuild the history with a full compaction (compactPad with no keepRevisions) to make the pad cleanable again. The admin UI already renders err.toString(), so this reaches the operator with no UI change. Full compaction is deliberately NOT gated on the same check: it does not replay history, it rebuilds from the current text, so it is the recovery path the message points at. Refs #8134 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 7ffd6b8 commit d9a6c9e

4 files changed

Lines changed: 284 additions & 0 deletions

File tree

‎src/node/db/Pad.ts‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -916,6 +916,39 @@ class Pad {
916916
return this.savedRevisions;
917917
}
918918

919+
/**
920+
* Scans `0..head` for revisions that are absent or unusable.
921+
*
922+
* `check()` already trips over these, but only as
923+
* `assert(timestamp != null)` part-way through replaying the history --
924+
* an assertion about a null timestamp, when what the operator needs to
925+
* hear is "revision 600 is missing". This reports the gaps directly so
926+
* callers can say something actionable instead. See #8134.
927+
*
928+
* Cheap relative to check(): it reads one sub-field per revision and
929+
* replays nothing.
930+
*
931+
* @param limit Stop after this many gaps. A pad damaged by a failed
932+
* cleanup can be missing hundreds of revisions and the operator does
933+
* not need them all enumerated.
934+
* @returns Ascending revision numbers with no usable stored record.
935+
*/
936+
async findMissingRevisions(limit = 20): Promise<number[]> {
937+
const missing: number[] = [];
938+
const revs = Stream.range(0, this.getHeadRevisionNumber() + 1)
939+
.map(async (r: number) => [r, await this.getRevisionDate(r)])
940+
.batch(100).buffer(99);
941+
for await (const [r, timestamp] of revs) {
942+
// A record that exists but carries no meta.timestamp is just as
943+
// unreplayable as one that is absent, and fails check() identically.
944+
if (timestamp == null) {
945+
missing.push(r);
946+
if (missing.length >= limit) break;
947+
}
948+
}
949+
return missing;
950+
}
951+
919952
/**
920953
* Asserts that all pad data is consistent. Throws if inconsistent.
921954
*/

‎src/node/utils/Cleanup.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,22 @@ export const deleteRevisions = async (padId: string, keepRevisions: number): Pro
4545
logger.debug('Start cleanup revisions', padId)
4646

4747
let pad = await padManager.getPad(padId);
48+
49+
// Report a damaged history as a damaged history. check() detects it too,
50+
// but only as `assert(timestamp != null)` part-way through replaying the
51+
// revisions, which tells an operator nothing about which record is bad or
52+
// what to do about it. See #8134.
53+
const missing = await pad.findMissingRevisions();
54+
if (missing.length > 0) {
55+
throw new Error(
56+
`Pad ${padId} is missing revision(s) ${missing.join(', ')}` +
57+
`${missing.length >= 20 ? ' (and possibly more)' : ''}. ` +
58+
'Its history cannot be replayed, so revisions cannot be cleaned up. ' +
59+
"The pad's current text is unaffected. Rebuild the history with a " +
60+
'full compaction (compactPad with no keepRevisions) to make the pad ' +
61+
'cleanable again.');
62+
}
63+
4864
await pad.check()
4965

5066
logger.debug('Initial pad is valid')
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
'use strict';
2+
3+
// Cleanup should report a damaged history as a damaged history.
4+
//
5+
// Before this, deleteRevisions() went straight into pad.check(), which
6+
// replays the whole history and dies on `assert(timestamp != null)` --
7+
// an assertion about a null timestamp, when what the operator needs to
8+
// hear is "revision 600 is missing, here is what to do about it". The
9+
// reporter on #8134 had to bisect their database by hand.
10+
11+
const assert = require('assert').strict;
12+
const common = require('../common');
13+
const padManager = require('../../../node/db/PadManager');
14+
const db = require('../../../node/db/DB');
15+
const settings = require('../../../node/utils/Settings');
16+
const {deleteRevisions, deleteAllRevisions} = require('../../../node/utils/Cleanup');
17+
18+
describe(__filename, function () {
19+
let padId: string;
20+
let cleanupEnabledBackup: boolean;
21+
22+
before(async function () {
23+
await common.init();
24+
cleanupEnabledBackup = settings.cleanup.enabled;
25+
settings.cleanup.enabled = true;
26+
});
27+
28+
after(function () { settings.cleanup.enabled = cleanupEnabledBackup; });
29+
30+
beforeEach(async function () {
31+
padId = common.randomString();
32+
assert(!await padManager.doesPadExist(padId));
33+
});
34+
35+
const padWithHoles = async (holes: number[], revs = 12) => {
36+
const pad = await padManager.getPad(padId);
37+
for (let i = 0; i < revs; i++) await pad.appendText(`line ${i}\n`);
38+
for (const h of holes) await db.remove(`pad:${padId}:revs:${h}`, null);
39+
padManager.unloadPad(padId);
40+
return await padManager.getPad(padId);
41+
};
42+
43+
describe('Pad.findMissingRevisions()', function () {
44+
it('returns [] for a healthy pad', async function () {
45+
const pad = await padWithHoles([]);
46+
assert.deepEqual(await pad.findMissingRevisions(), []);
47+
});
48+
49+
it('finds a single gap', async function () {
50+
const pad = await padWithHoles([3]);
51+
assert.deepEqual(await pad.findMissingRevisions(), [3]);
52+
});
53+
54+
it('finds several gaps, in ascending order', async function () {
55+
const pad = await padWithHoles([7, 2, 5]);
56+
assert.deepEqual(await pad.findMissingRevisions(), [2, 5, 7]);
57+
});
58+
59+
it('honours the limit', async function () {
60+
const pad = await padWithHoles([2, 3, 4, 5, 6]);
61+
const found = await pad.findMissingRevisions(2);
62+
assert.equal(found.length, 2);
63+
assert.deepEqual(found, [2, 3]);
64+
});
65+
66+
it('does not report revisions beyond head', async function () {
67+
const pad = await padWithHoles([]);
68+
const head = pad.getHeadRevisionNumber();
69+
await db.remove(`pad:${padId}:revs:${head + 5}`, null); // no-op
70+
assert.deepEqual(await pad.findMissingRevisions(), []);
71+
});
72+
});
73+
74+
describe('deleteRevisions() on a damaged pad', function () {
75+
it('names the missing revision instead of asserting', async function () {
76+
await padWithHoles([3]);
77+
padManager.unloadPad(padId);
78+
const err: any = await deleteRevisions(padId, 2).then(() => null, (e: any) => e);
79+
assert.ok(err != null, 'expected deleteRevisions to throw');
80+
assert.match(err.message, /missing revision\(s\) 3\b/);
81+
assert.match(err.message, new RegExp(padId));
82+
// Not a bare assertion failure any more.
83+
assert.ok(!/timestamp != null/.test(err.message),
84+
`still surfacing the raw assertion:\n${err.message}`);
85+
});
86+
87+
it('tells the operator their text is safe and how to recover',
88+
async function () {
89+
await padWithHoles([3]);
90+
padManager.unloadPad(padId);
91+
const err: any =
92+
await deleteRevisions(padId, 2).then(() => null, (e: any) => e);
93+
assert.match(err.message, /current text is unaffected/i);
94+
assert.match(err.message, /compactPad/);
95+
});
96+
97+
it('lists multiple gaps', async function () {
98+
await padWithHoles([3, 6]);
99+
padManager.unloadPad(padId);
100+
const err: any = await deleteRevisions(padId, 2).then(() => null, (e: any) => e);
101+
assert.match(err.message, /missing revision\(s\) 3, 6/);
102+
});
103+
104+
it('leaves the damaged pad untouched', async function () {
105+
// The whole point of failing before the destructive phase.
106+
const pad = await padWithHoles([3]);
107+
const headBefore = pad.getHeadRevisionNumber();
108+
const textBefore = pad.atext.text;
109+
padManager.unloadPad(padId);
110+
111+
await deleteRevisions(padId, 2).catch(() => {});
112+
113+
padManager.unloadPad(padId);
114+
const after = await padManager.getPad(padId);
115+
assert.equal(after.getHeadRevisionNumber(), headBefore);
116+
assert.equal(after.atext.text, textBefore);
117+
});
118+
119+
it('still cleans up a healthy pad', async function () {
120+
const pad = await padWithHoles([]);
121+
padManager.unloadPad(padId);
122+
assert.equal(await deleteRevisions(padId, 3), true);
123+
padManager.unloadPad(padId);
124+
await (await padManager.getPad(padId)).check();
125+
});
126+
});
127+
128+
describe('full compaction is still allowed', function () {
129+
it('deleteAllRevisions works on a damaged pad', async function () {
130+
// This is the recovery path the error message points at, so it must
131+
// not be gated behind the same check.
132+
const pad = await padWithHoles([3]);
133+
const textBefore = pad.atext.text;
134+
padManager.unloadPad(padId);
135+
136+
await deleteAllRevisions(padId);
137+
138+
padManager.unloadPad(padId);
139+
const after = await padManager.getPad(padId);
140+
// Compared trimmed: on develop, copyPadWithoutHistory still appends a
141+
// newline per copy (issue #8139, fixed by #8140), and this spec is
142+
// deliberately independent of that one. What matters here is that the
143+
// recovery path runs at all on a pad the keep-count path refuses.
144+
assert.equal(after.atext.text.trimEnd(), textBefore.trimEnd(),
145+
'author-written text preserved');
146+
assert.deepEqual(await after.findMissingRevisions(), [],
147+
'history rebuilt without gaps');
148+
});
149+
});
150+
});
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
'use strict';
2+
3+
// Probe: are there hole-forming paths other than appendRevision (#8134)?
4+
5+
const assert = require('assert').strict;
6+
const common = require('../common');
7+
const padManager = require('../../../node/db/PadManager');
8+
const db = require('../../../node/db/DB');
9+
const settings = require('../../../node/utils/Settings');
10+
const {deleteRevisions} = require('../../../node/utils/Cleanup');
11+
12+
const missingRevs = async (padId: string) => {
13+
const rec = await db.get(`pad:${padId}`);
14+
const missing = [];
15+
for (let r = 0; r <= rec.head; r++) {
16+
if (await db.get(`pad:${padId}:revs:${r}`) == null) missing.push(r);
17+
}
18+
return {head: rec.head, missing};
19+
};
20+
21+
describe(__filename, function () {
22+
let backup: boolean;
23+
before(async function () {
24+
await common.init();
25+
backup = settings.cleanup.enabled;
26+
settings.cleanup.enabled = true;
27+
});
28+
after(function () { settings.cleanup.enabled = backup; });
29+
30+
it('MECHANISM 2: a failed write during deleteRevisions leaves holes',
31+
async function () {
32+
const padId = common.randomString();
33+
const pad = await padManager.getPad(padId);
34+
for (let i = 0; i < 12; i++) await pad.appendText(`line ${i}\n`);
35+
const headBefore = pad.getHeadRevisionNumber();
36+
37+
// deleteRevisions removes every revision, then rewrites the kept
38+
// ones. Fail one of the rewrites.
39+
const realSet = db.set;
40+
db.set = async (key: string, value: unknown) => {
41+
if (key === `pad:${padId}:revs:2`) throw new Error('boom');
42+
return await realSet(key, value);
43+
};
44+
let threw = false;
45+
try {
46+
await deleteRevisions(padId, 3);
47+
} catch { threw = true; } finally { db.set = realSet; }
48+
49+
padManager.unloadPad(padId);
50+
const state = await missingRevs(padId);
51+
console.log(` head before=${headBefore}; after: head=${state.head} ` +
52+
`missing=[${state.missing}] threw=${threw}`);
53+
assert.ok(state.missing.length > 0,
54+
'expected deleteRevisions to leave holes');
55+
});
56+
57+
it('MECHANISM 3: a stale in-memory pad appends past the rewritten head',
58+
async function () {
59+
const padId = common.randomString();
60+
const pad = await padManager.getPad(padId);
61+
for (let i = 0; i < 12; i++) await pad.appendText(`line ${i}\n`);
62+
const staleHead = pad.getHeadRevisionNumber();
63+
64+
// Fail late, after the pad record has been rewritten to the new head.
65+
const realSet = db.set;
66+
db.set = async (key: string, value: unknown) => {
67+
if (key === `pad:${padId}:revs:3`) throw new Error('boom');
68+
return await realSet(key, value);
69+
};
70+
try { await deleteRevisions(padId, 3); } catch { /* expected */ }
71+
finally { db.set = realSet; }
72+
73+
const recAfter = await db.get(`pad:${padId}`);
74+
console.log(` stale in-memory head=${pad.getHeadRevisionNumber()}, ` +
75+
`persisted head=${recAfter.head}`);
76+
77+
// The caller still holds the old Pad object. One more edit through it:
78+
try { await pad.appendText('later edit\n'); } catch { /* may throw */ }
79+
80+
padManager.unloadPad(padId);
81+
const state = await missingRevs(padId);
82+
console.log(` final: head=${state.head} missing=[${state.missing}]`);
83+
assert.ok(state.missing.length > 0, 'expected holes');
84+
});
85+
});

0 commit comments

Comments
 (0)