Skip to content

feat: report a damaged pad history instead of asserting - #8145

Open
JohnMcLear wants to merge 1 commit into
developfrom
fix/cleanup-detect-missing-revisions
Open

feat: report a damaged pad history instead of asserting#8145
JohnMcLear wants to merge 1 commit into
developfrom
fix/cleanup-detect-missing-revisions

Conversation

@JohnMcLear

Copy link
Copy Markdown
Member

Refs #8134. First of two: this makes a damaged pad fail gracefully; the follow-up stops cleanup creating damage.

Problem

deleteRevisions() goes straight into pad.check(), which replays the entire history and dies on:

AssertionError [ERR_ASSERTION]: The expression evaluated to a falsy value:
  assert(timestamp != null)
  at Pad.check (src/node/db/Pad.ts:969:9)
  at async deleteRevisions (src/node/utils/Cleanup.ts:48:3)

That's an assertion about a null timestamp. What the 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.

Change

Pad.findMissingRevisions(limit = 20) scans 0..head for revisions that are absent or carry no meta.timestamp. It reads one sub-field per revision and replays nothing, so it's cheap next to check(). It runs before check() so the clear error wins.

What the admin UI now shows (it already renders err.toString(), so no UI change):

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.

Three things an operator actually wants: which revision, that their content is safe, and the way out.

Full compaction is deliberately not gated on the same check — it doesn't replay history, it rebuilds from the current text, so it's the recovery path the message points at. There's a test asserting it still works on a damaged pad.

Tests

src/tests/backend/specs/cleanupMissingRevisions.ts — 11 cases covering findMissingRevisions (healthy pad, one gap, several gaps in ascending order, the limit, nothing beyond head) and deleteRevisions on a damaged pad (names the revision, no raw assertion text, says the text is safe and names the recovery, lists multiple gaps, leaves the damaged pad untouched, still cleans healthy pads), plus full compaction still working on a damaged pad.

Full backend suite: 1634 passing, 0 failing. tsc --noEmit clean.

Note

The deleteAllRevisions test compares text trimmed rather than byte-exact, because on develop copyPadWithoutHistory still appends a newline per copy (#8139, fixed by #8140). Keeping this PR independent of that one rather than stacking them.

🤖 Generated with Claude Code

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>
@qodo-code-review

Copy link
Copy Markdown

ⓘ Qodo reviews are paused because the subscription is no longer active. Ask your workspace admin to reactivate the subscription to resume reviews. Manage billing

@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Report missing pad revisions during cleanup instead of assertion failure

🐞 Bug fix 🧪 Tests 🕐 20-40 Minutes

Grey Divider

AI Description

• Add a cheap preflight scan to detect missing/unreplayable revisions before history replay.
• Make cleanup fail with an actionable error (missing revs, text safe, recovery via full
 compaction).
• Add backend specs covering gap detection, cleanup behavior on damaged pads, and recovery path.
Diagram

graph TD
  A(["Cleanup.deleteRevisions"]) --> B(["Pad.findMissingRevisions"]) --> C[("DB: pad:{id}:revs:{n}")] --> D{{"Missing/invalid?"}}
  D -->|"yes"| E["Actionable Error"] --> F["Admin UI (err.toString)"]
  D -->|"no"| G(["Pad.check (replay)"])
  H(["Cleanup.deleteAllRevisions"]) --> I["Full compaction"] --> C
  subgraph Legend
    direction LR
    _fn(["Function"]) ~~~ _db[("Database")] ~~~ _dec{{"Decision"}} ~~~ _out["Output"]
  end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Improve Pad.check() error reporting in-place
  • ➕ Single validation mechanism; avoids preflight + replay duplication
  • ➕ Could report the exact revision at the moment of failure
  • ➖ Still requires expensive history replay before producing a useful error
  • ➖ Harder to guarantee operator-facing messaging stays actionable vs. internal assertions
2. Introduce a typed CleanupError with structured fields
  • ➕ Allows UI/CLI to render richer guidance (missing rev list, next steps) without parsing strings
  • ➕ Easier future localization or programmatic handling
  • ➖ Requires plumbing error types through call sites and possibly UI changes
  • ➖ More invasive than needed given current UI already renders err.toString()

Recommendation: The preflight scan is the right trade-off: it is cheap (timestamp reads only), fails before destructive work, and produces operator-actionable guidance without requiring UI changes. Improving Pad.check() messaging is worthwhile long-term, but it would still be slower and risk surfacing internal assertion semantics rather than a clear remediation path.

Files changed (4) +284 / -0

Enhancement (1) +33 / -0
Pad.tsAdd findMissingRevisions() to detect unreplayable revision gaps +33/-0

Add findMissingRevisions() to detect unreplayable revision gaps

• Introduces Pad.findMissingRevisions(limit=20) to scan 0..head for missing revisions or revisions with null timestamps via getRevisionDate(). Uses a batched/streamed iteration to avoid history replay and stops after the configured number of gaps.

src/node/db/Pad.ts

Bug fix (1) +16 / -0
Cleanup.tsFail cleanup with actionable error when pad history is damaged +16/-0

Fail cleanup with actionable error when pad history is damaged

• Calls pad.findMissingRevisions() before pad.check() in deleteRevisions(). If gaps are found, throws an error that names the missing revisions, reassures that current text is unaffected, and points to full compaction as recovery.

src/node/utils/Cleanup.ts

Tests (2) +235 / -0
cleanupMissingRevisions.tsAdd specs for missing revision detection and cleanup failure messaging +150/-0

Add specs for missing revision detection and cleanup failure messaging

• Adds backend tests for Pad.findMissingRevisions() (healthy pad, gaps, ordering, limit, and head boundary). Verifies deleteRevisions() on damaged pads reports missing revs (not raw assertions), leaves data untouched, still cleans healthy pads, and confirms deleteAllRevisions (full compaction) remains usable as recovery.

src/tests/backend/specs/cleanupMissingRevisions.ts

holeProbe.tsAdd probe specs demonstrating hole-forming cleanup failure scenarios +85/-0

Add probe specs demonstrating hole-forming cleanup failure scenarios

• Adds exploratory tests that simulate failures during deleteRevisions (failed DB write and stale in-memory pad edits) to show how revision holes can be created. Uses DB monkeypatching to force partial writes and asserts that gaps exist afterward.

src/tests/backend/specs/holeProbe.ts

});

it('still cleans up a healthy pad', async function () {
const pad = await padWithHoles([]);
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();
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (3) 📘 Rule violations (0) 📎 Requirement gaps (1) 📜 Skill insights (0)

Grey Divider


Remediation recommended

1. deleteRevisions() throws on gaps 📎 Requirement gap ☼ Reliability
Description
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.
Code

src/node/utils/Cleanup.ts[R53-61]

+  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. ' +
+        "The pad's current text is unaffected. Rebuild the history with a " +
+        'full compaction (compactPad with no keepRevisions) to make the pad ' +
+        'cleanable again.');
Evidence
PR Compliance ID 1 requires cleanup/keepRevisions to not fail/abort when revision records are
missing. The added code explicitly throws an exception when gaps are found, preventing
deleteRevisions() from completing.

Cleanup/keepRevisions must not fail when encountering missing revision records
src/node/utils/Cleanup.ts[53-61]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## 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


2. Probe test committed 🐞 Bug ⚙ Maintainability
Description
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.
Code

src/tests/backend/specs/holeProbe.ts[R51-54]

+        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');
Evidence
The new spec calls itself a probe and includes unconditional console logging and assertions that
holes exist, which is diagnostic rather than a stable product behavior contract.

src/tests/backend/specs/holeProbe.ts[3-4]
src/tests/backend/specs/holeProbe.ts[51-55]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### 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


3. Prefetch abort risks rejections 🐞 Bug ☼ Reliability
Description
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.
Code

src/node/db/Pad.ts[R938-941]

+    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) {
Evidence
The new method uses batch/buffer prefetch and breaks on limit. Stream’s own
documentation/implementation states that Promises read but not yielded will have rejection
suppression removed when iteration is aborted early, which can lead to unhandled rejections if any
of those prefetched operations reject later.

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]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### 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



Informational

4. Hardcoded scan limit 🐞 Bug ⚙ Maintainability
Description
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.
Code

src/node/utils/Cleanup.ts[R56-58]

+        `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. ' +
Evidence
The scan helper takes a limit parameter with a default, but the error message independently
encodes 20, creating a silent coupling.

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

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### 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


Grey Divider

Context

Grey Divider

Tip of the day
💡 Did you know, you can turn on the rule miner and Qodo learns your standards from review history

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread src/node/utils/Cleanup.ts
Comment on lines +53 to +61
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. ' +
"The pad's current text is unaffected. Rebuild the history with a " +
'full compaction (compactPad with no keepRevisions) to make the pad ' +
'cleanable again.');

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

Comment on lines +51 to +54
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');

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

Comment thread src/node/db/Pad.ts
Comment on lines +938 to +941
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) {

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

Comment thread src/node/utils/Cleanup.ts
Comment on lines +56 to +58
`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. ' +

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant