Skip to content

Fix upstream deletes across independent peer revision spaces - #185

Open
github-actions[bot] wants to merge 1 commit into
mainfrom
gardener/fix-peer-tombstones-167
Open

github-actions[bot] wants to merge 1 commit into
mainfrom
gardener/fix-peer-tombstones-167

Conversation

@github-actions

@github-actions github-actions Bot commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Addresses #167. Requested by #167 (comment).

The existing tombstone guard compares a local node revision with the sender's independent revision counter. Four new integration regressions fail against the original implementation: entry and pack pulls after a high-revision host push, and entry and pack pushes into a higher-revision receiver.

  • On pulls, compare the live node's local revision with this backend's local push watermark. Unpushed edits and recreations remain protected, including after watermark reset.
  • On push receivers, use the committed incoming cursor (in the sender's revision space) to distinguish replays from new authoritative deletes. Thread it through both entry and pack receivers; a failed post-commit settle followed by replay preserves a local recreation.
  • Cover asynchronous/synchronous applies, independent backends, watermark resets, and both transports. Existing pull-only deletion fixtures now complete the echo push before expecting remote deletions to win.

Validation

  • Original implementation: all four new cross-space integration regressions fail.
  • Fixed implementation: 220 dofs sync tests and 99 RPC driver/engine tests pass.
  • RPC typecheck (including dofs build), targeted Biome checks, and git diff --check pass.

No real container was required or exercised. Pulls conservatively retain local versions not yet pushed to the selected backend.


Devin Review

Gardener-Operation: op_5e746d3660443aa9d21345f7e9a13aa41dc4605e4a9a3d8b3210451bd0331dd8:bc1b93cc3ac5de32532ffba0acf0db9b7c51d98bbdbe7fb2097be51daad0fade
@changeset-bot

changeset-bot Bot commented Oct 1, 2026

Copy link
Copy Markdown

鈿狅笍 No Changeset found

Latest commit: b7af735

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Devin Review found 3 potential issues.

Devin Review

}
const row = db.one<{ rev: number }>("SELECT rev FROM vfs_nodes WHERE inode = ?", live.inode);
return row !== undefined && row.rev > entry.rev;
return row !== undefined && row.rev > readWatermark(db, "pushRev", options.backend);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

馃敶 Remote deletes resurrect pulled files

If a remote file is deleted before its earlier pull is echoed, tombstoneIsStale retains the pulled copy. The next push recreates the deleted file remotely.

Learn more

A pull applies a remote file through applyChanges, giving its local inode a fresh revision without advancing the push watermark. If the remote deletes that file before an echo push, the new guard mistakes the pulled copy for an unpushed local edit and skips the tombstone. The pull checkpoints past the deletion, and pushBlocks later exports the retained file as a live change. The remote consequently gets its deleted file back.

Example: The remote creates /x; the host pulls it at local rev 1 with pushRev 0. The remote deletes /x before the host's next push. The host ignores the delete, checkpoints it, then pushes /x back.

Recommended fix: Distinguish local authored changes from revisions minted by upstream apply when guarding pulls, or otherwise track the provenance of versions that have not yet been echoed. Ensure a later remote delete wins over an earlier pulled file while locally authored, unpushed edits remain protected.

Devin Review


Was this helpful? React with 馃憤 or 馃憥 to provide feedback.

Comment on lines 601 to +602
const row = db.one<{ rev: number }>("SELECT rev FROM vfs_nodes WHERE inode = ?", live.inode);
return row !== undefined && row.rev > entry.rev;
return row !== undefined && row.rev > readWatermark(db, "pushRev", options.backend);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

馃敶 Directory deletes discard unpushed child edits

When a child changes beneath a previously pushed directory, tombstoneIsStale checks only the directory's old revision. A remote directory delete recursively removes the unpushed child edit.

Learn more

The guard compares the directory inode's revision against pushRev, but changing a child does not update that directory inode. Once the directory passes the guard, rm deletes its entire subtree, including children that were edited locally after the last push. The pull then checkpoints the remote tombstone, so those child changes cannot be sent upstream.

Example: Push /project and /project/draft at rev 2. Edit /project/draft locally at rev 3, without pushing it. A remote delete of /project passes because /project remains at rev 1 or 2 and pushRev is 2, destroying the draft.

Recommended fix: For a directory tombstone, inspect descendant live revisions or track dirty subtrees for the selected backend before calling recursive rm. Protect unpushed descendants without treating a clean directory as dirty.

Devin Review


Was this helpful? React with 馃憤 or 馃憥 to provide feedback.

}
const row = db.one<{ rev: number }>("SELECT rev FROM vfs_nodes WHERE inode = ?", live.inode);
return row !== undefined && row.rev > entry.rev;
return row !== undefined && row.rev > readWatermark(db, "pushRev", options.backend);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

馃敶 Partial pushes lose unshipped same-revision changes

After a partial same-revision push, tombstoneIsStale treats every path at pushRev as shipped. A remote delete can remove a later, unshipped path before its push.

Learn more

A directory rename stamps all moved nodes with one shared revision in stampRenamedSubtree. A bounded push block can checkpoint partway through that revision, recording both its rev and its last path. readWatermark(..., 'pushRev') discards the path component, so the guard treats later, still-unshipped paths at the same revision as clean. An incoming delete can erase one of those pending changes.

Example: Rename a directory with 3,000 files at rev 40 and ship only the first 2,000 paths, recording {rev:40,path:'/new/f1999'}. If the remote deletes /new/f2999 before the next push, the host accepts that deletion even though /new/f2999 was never shipped.

Recommended fix: Use readPushCursor and compare both revision and path for a live entry rather than comparing against the scalar push watermark. Account for paths whose inode revision was stamped by a rename.

Devin Review


Was this helpful? React with 馃憤 or 馃憥 to provide feedback.

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

Labels

allow-pr Allow a PR to remain open.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants