Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions src/node/db/Pad.ts
Original file line number Diff line number Diff line change
Expand Up @@ -916,6 +916,39 @@ class Pad {
return this.savedRevisions;
}

/**
* Scans `0..head` for revisions that are absent or unusable.
*
* `check()` already trips over these, but only as
* `assert(timestamp != null)` part-way through replaying the history --
* an assertion about a null timestamp, when what the operator needs to
* hear is "revision 600 is missing". This reports the gaps directly so
* callers can say something actionable instead. See #8134.
*
* Cheap relative to check(): it reads one sub-field per revision and
* replays nothing.
*
* @param limit Stop after this many gaps. A pad damaged by a failed
* cleanup can be missing hundreds of revisions and the operator does
* not need them all enumerated.
* @returns Ascending revision numbers with no usable stored record.
*/
async findMissingRevisions(limit = 20): Promise<number[]> {
const missing: number[] = [];
const revs = Stream.range(0, this.getHeadRevisionNumber() + 1)
.map(async (r: number) => [r, await this.getRevisionDate(r)])
.batch(100).buffer(99);
for await (const [r, timestamp] of revs) {
Comment on lines +938 to +941

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

3. Prefetch abort risks rejections 🐞 Bug ☼ Reliability

Pad.findMissingRevisions() uses Stream.batch(100).buffer(99) and then breaks once the
missing-revision limit is hit, which can start extra DB reads beyond what is needed and (per
Stream’s documented abort semantics) can surface unhandled Promise rejections for read-but-unyielded
operations if any prefetched DB read fails after early termination.
Agent Prompt
### Issue description
`Pad.findMissingRevisions()` prefetches revision timestamp reads using `Stream.batch(100).buffer(99)` but may terminate iteration early via `break` once `limit` gaps are found. Because `Stream` explicitly documents that early-aborted iteration will “un-suppress” rejections for read-but-unyielded Promises, any prefetched DB read that rejects after the early break can become an unhandled rejection. It also means the method can issue more DB reads than necessary even when the limit is reached quickly.

### Issue Context
- `Stream.batch()` reads an entire batch before yielding, and both `batch()` and `buffer()` manage suppression/un-suppression of rejections on early abort.
- The method’s limit is meant to stop work once enough gaps are found.

### Fix Focus Areas
- src/node/db/Pad.ts[936-949]
- src/node/utils/Stream.ts[25-53]
- src/node/utils/Stream.ts[57-72]
- src/node/utils/Stream.ts[96-102]

### What to change (one good option)
- Replace the Stream-based pipeline with explicit bounded batching that always awaits/observes all started reads:
  - Loop `r` from 0..head in chunks (e.g., 100).
  - For each chunk, `await Promise.allSettled(chunk.map(getRevisionDate))` (or `Promise.all` if you want to fail fast).
  - Collect missing revisions from the settled results.
  - Stop once `missing.length >= limit`.

This preserves concurrency but guarantees there are no “started but never observed” Promises when the function returns early.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

// A record that exists but carries no meta.timestamp is just as
// unreplayable as one that is absent, and fails check() identically.
if (timestamp == null) {
missing.push(r);
if (missing.length >= limit) break;
}
}
return missing;
}

/**
* Asserts that all pad data is consistent. Throws if inconsistent.
*/
Expand Down
16 changes: 16 additions & 0 deletions src/node/utils/Cleanup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,22 @@ export const deleteRevisions = async (padId: string, keepRevisions: number): Pro
logger.debug('Start cleanup revisions', padId)

let pad = await padManager.getPad(padId);

// Report a damaged history as a damaged history. check() detects it too,
// but only as `assert(timestamp != null)` part-way through replaying the
// revisions, which tells an operator nothing about which record is bad or
// what to do about it. See #8134.
const missing = await pad.findMissingRevisions();
if (missing.length > 0) {
throw new Error(
`Pad ${padId} is missing revision(s) ${missing.join(', ')}` +
`${missing.length >= 20 ? ' (and possibly more)' : ''}. ` +
'Its history cannot be replayed, so revisions cannot be cleaned up. ' +
Comment on lines +56 to +58

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Informational

4. Hardcoded scan limit 🐞 Bug ⚙ Maintainability

Cleanup.deleteRevisions() hard-codes 20 when deciding whether to append “(and possibly more)”,
which is coupled to Pad.findMissingRevisions()’s current default limit and can become inaccurate if
the default or call site limit changes.
Agent Prompt
### Issue description
The cleanup error message uses a hard-coded `20` (`missing.length >= 20`) to decide whether to append “(and possibly more)”, but the scan limit is defined by `Pad.findMissingRevisions(limit = 20)`. If the default changes or cleanup later passes a different limit, the message becomes misleading.

### Issue Context
`deleteRevisions()` currently calls `pad.findMissingRevisions()` with no argument, so it matches today, but this is brittle.

### Fix Focus Areas
- src/node/utils/Cleanup.ts[53-61]
- src/node/db/Pad.ts[936-947]

### What to change
- Define `const limit = 20;` in `deleteRevisions()`, call `pad.findMissingRevisions(limit)`, and use `missing.length >= limit` in the message; or
- Return `{missing, truncated}` from `findMissingRevisions()` and use `truncated` to decide whether to append the suffix.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

"The pad's current text is unaffected. Rebuild the history with a " +
'full compaction (compactPad with no keepRevisions) to make the pad ' +
'cleanable again.');
Comment on lines +53 to +61

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

1. deleterevisions() throws on gaps 📎 Requirement gap ☼ Reliability

deleteRevisions() now throws an Error when missing revisions are detected, which aborts
cleanup/keepRevisions instead of completing via a defined non-failing path. This conflicts with the
requirement that cleanup/keepRevisions must not fail/abort when revision records are missing.
Agent Prompt
## Issue description
`deleteRevisions()` throws when `pad.findMissingRevisions()` finds gaps, causing cleanup/keepRevisions to abort rather than completing via a defined recovery behavior.

## Issue Context
The compliance requirement for #8134 expects cleanup/keepRevisions to handle missing revision records without failing/aborting (e.g., by logging a controlled warning and skipping, or automatically taking a defined recovery path).

## Fix Focus Areas
- src/node/utils/Cleanup.ts[53-61]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

}

await pad.check()

logger.debug('Initial pad is valid')
Expand Down
150 changes: 150 additions & 0 deletions src/tests/backend/specs/cleanupMissingRevisions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
'use strict';

// Cleanup should report a damaged history as a damaged history.
//
// Before this, deleteRevisions() went straight into pad.check(), which
// replays the whole history and dies on `assert(timestamp != null)` --
// an assertion about a null timestamp, when what the operator needs to
// hear is "revision 600 is missing, here is what to do about it". The
// reporter on #8134 had to bisect their database by hand.

const assert = require('assert').strict;
const common = require('../common');
const padManager = require('../../../node/db/PadManager');
const db = require('../../../node/db/DB');
const settings = require('../../../node/utils/Settings');
const {deleteRevisions, deleteAllRevisions} = require('../../../node/utils/Cleanup');

describe(__filename, function () {
let padId: string;
let cleanupEnabledBackup: boolean;

before(async function () {
await common.init();
cleanupEnabledBackup = settings.cleanup.enabled;
settings.cleanup.enabled = true;
});

after(function () { settings.cleanup.enabled = cleanupEnabledBackup; });

beforeEach(async function () {
padId = common.randomString();
assert(!await padManager.doesPadExist(padId));
});

const padWithHoles = async (holes: number[], revs = 12) => {
const pad = await padManager.getPad(padId);
for (let i = 0; i < revs; i++) await pad.appendText(`line ${i}\n`);
for (const h of holes) await db.remove(`pad:${padId}:revs:${h}`, null);
padManager.unloadPad(padId);
return await padManager.getPad(padId);
};

describe('Pad.findMissingRevisions()', function () {
it('returns [] for a healthy pad', async function () {
const pad = await padWithHoles([]);
assert.deepEqual(await pad.findMissingRevisions(), []);
});

it('finds a single gap', async function () {
const pad = await padWithHoles([3]);
assert.deepEqual(await pad.findMissingRevisions(), [3]);
});

it('finds several gaps, in ascending order', async function () {
const pad = await padWithHoles([7, 2, 5]);
assert.deepEqual(await pad.findMissingRevisions(), [2, 5, 7]);
});

it('honours the limit', async function () {
const pad = await padWithHoles([2, 3, 4, 5, 6]);
const found = await pad.findMissingRevisions(2);
assert.equal(found.length, 2);
assert.deepEqual(found, [2, 3]);
});

it('does not report revisions beyond head', async function () {
const pad = await padWithHoles([]);
const head = pad.getHeadRevisionNumber();
await db.remove(`pad:${padId}:revs:${head + 5}`, null); // no-op
assert.deepEqual(await pad.findMissingRevisions(), []);
});
});

describe('deleteRevisions() on a damaged pad', function () {
it('names the missing revision instead of asserting', async function () {
await padWithHoles([3]);
padManager.unloadPad(padId);
const err: any = await deleteRevisions(padId, 2).then(() => null, (e: any) => e);
assert.ok(err != null, 'expected deleteRevisions to throw');
assert.match(err.message, /missing revision\(s\) 3\b/);
assert.match(err.message, new RegExp(padId));
// Not a bare assertion failure any more.
assert.ok(!/timestamp != null/.test(err.message),
`still surfacing the raw assertion:\n${err.message}`);
});

it('tells the operator their text is safe and how to recover',
async function () {
await padWithHoles([3]);
padManager.unloadPad(padId);
const err: any =
await deleteRevisions(padId, 2).then(() => null, (e: any) => e);
assert.match(err.message, /current text is unaffected/i);
assert.match(err.message, /compactPad/);
});

it('lists multiple gaps', async function () {
await padWithHoles([3, 6]);
padManager.unloadPad(padId);
const err: any = await deleteRevisions(padId, 2).then(() => null, (e: any) => e);
assert.match(err.message, /missing revision\(s\) 3, 6/);
});

it('leaves the damaged pad untouched', async function () {
// The whole point of failing before the destructive phase.
const pad = await padWithHoles([3]);
const headBefore = pad.getHeadRevisionNumber();
const textBefore = pad.atext.text;
padManager.unloadPad(padId);

await deleteRevisions(padId, 2).catch(() => {});

padManager.unloadPad(padId);
const after = await padManager.getPad(padId);
assert.equal(after.getHeadRevisionNumber(), headBefore);
assert.equal(after.atext.text, textBefore);
});

it('still cleans up a healthy pad', async function () {
const pad = await padWithHoles([]);
padManager.unloadPad(padId);
assert.equal(await deleteRevisions(padId, 3), true);
padManager.unloadPad(padId);
await (await padManager.getPad(padId)).check();
});
});

describe('full compaction is still allowed', function () {
it('deleteAllRevisions works on a damaged pad', async function () {
// This is the recovery path the error message points at, so it must
// not be gated behind the same check.
const pad = await padWithHoles([3]);
const textBefore = pad.atext.text;
padManager.unloadPad(padId);

await deleteAllRevisions(padId);

padManager.unloadPad(padId);
const after = await padManager.getPad(padId);
// Compared trimmed: on develop, copyPadWithoutHistory still appends a
// newline per copy (issue #8139, fixed by #8140), and this spec is
// deliberately independent of that one. What matters here is that the
// recovery path runs at all on a pad the keep-count path refuses.
assert.equal(after.atext.text.trimEnd(), textBefore.trimEnd(),
'author-written text preserved');
assert.deepEqual(await after.findMissingRevisions(), [],
'history rebuilt without gaps');
});
});
});
85 changes: 85 additions & 0 deletions src/tests/backend/specs/holeProbe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
'use strict';

// Probe: are there hole-forming paths other than appendRevision (#8134)?

const assert = require('assert').strict;
const common = require('../common');
const padManager = require('../../../node/db/PadManager');
const db = require('../../../node/db/DB');
const settings = require('../../../node/utils/Settings');
const {deleteRevisions} = require('../../../node/utils/Cleanup');

const missingRevs = async (padId: string) => {
const rec = await db.get(`pad:${padId}`);
const missing = [];
for (let r = 0; r <= rec.head; r++) {
if (await db.get(`pad:${padId}:revs:${r}`) == null) missing.push(r);
}
return {head: rec.head, missing};
};

describe(__filename, function () {
let backup: boolean;
before(async function () {
await common.init();
backup = settings.cleanup.enabled;
settings.cleanup.enabled = true;
});
after(function () { settings.cleanup.enabled = backup; });

it('MECHANISM 2: a failed write during deleteRevisions leaves holes',
async function () {
const padId = common.randomString();
const pad = await padManager.getPad(padId);
for (let i = 0; i < 12; i++) await pad.appendText(`line ${i}\n`);
const headBefore = pad.getHeadRevisionNumber();

// deleteRevisions removes every revision, then rewrites the kept
// ones. Fail one of the rewrites.
const realSet = db.set;
db.set = async (key: string, value: unknown) => {
if (key === `pad:${padId}:revs:2`) throw new Error('boom');
return await realSet(key, value);
};
let threw = false;
try {
await deleteRevisions(padId, 3);
} catch { threw = true; } finally { db.set = realSet; }

padManager.unloadPad(padId);
const state = await missingRevs(padId);
console.log(` head before=${headBefore}; after: head=${state.head} ` +
`missing=[${state.missing}] threw=${threw}`);
assert.ok(state.missing.length > 0,
'expected deleteRevisions to leave holes');
Comment on lines +51 to +54

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

2. Probe test committed 🐞 Bug ⚙ Maintainability

src/tests/backend/specs/holeProbe.ts is explicitly labeled a “Probe”, prints to stdout, and asserts
that deleteRevisions() leaves revision holes, which makes CI noisy and encodes an undesirable
failure mode as a permanent test expectation.
Agent Prompt
### Issue description
`src/tests/backend/specs/holeProbe.ts` is committed as a normal backend test but is clearly intended as exploratory diagnostics: it uses `console.log()` and asserts that cleanup corruption (holes) occurs. This will add noise to CI output and makes the test suite assert an undesirable state as “expected”.

### Issue Context
This file is new in this PR and not referenced as part of the intended feature-level test coverage (the real regression coverage is in `cleanupMissingRevisions.ts`).

### Fix Focus Areas
- src/tests/backend/specs/holeProbe.ts[1-85]

### What to change
- Preferred: delete `holeProbe.ts` from the normal specs suite.
- Alternative: change it to `describe.skip(...)` (or move it to a non-default/diagnostic test location) and remove `console.log()`.
- If you want to keep coverage, rewrite as a focused regression test that asserts the *desired* behavior (e.g., cleanup is atomic / does not leave holes) rather than asserting that corruption is produced.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

});

it('MECHANISM 3: a stale in-memory pad appends past the rewritten head',
async function () {
const padId = common.randomString();
const pad = await padManager.getPad(padId);
for (let i = 0; i < 12; i++) await pad.appendText(`line ${i}\n`);
const staleHead = pad.getHeadRevisionNumber();

// Fail late, after the pad record has been rewritten to the new head.
const realSet = db.set;
db.set = async (key: string, value: unknown) => {
if (key === `pad:${padId}:revs:3`) throw new Error('boom');
return await realSet(key, value);
};
try { await deleteRevisions(padId, 3); } catch { /* expected */ }
finally { db.set = realSet; }

const recAfter = await db.get(`pad:${padId}`);
console.log(` stale in-memory head=${pad.getHeadRevisionNumber()}, ` +
`persisted head=${recAfter.head}`);

// The caller still holds the old Pad object. One more edit through it:
try { await pad.appendText('later edit\n'); } catch { /* may throw */ }

padManager.unloadPad(padId);
const state = await missingRevs(padId);
console.log(` final: head=${state.head} missing=[${state.missing}]`);
assert.ok(state.missing.length > 0, 'expected holes');
});
});
Loading