Skip to content

Fix upstream deletes across independent peer revision spaces - #185

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

github-actions[bot] wants to merge 5 commits 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: c053336

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[bot]

This comment was marked as outdated.

@pkg-pr-new

pkg-pr-new Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@cloudflare/computer@185

commit: f78ff4b

@scuffi scuffi left a comment

Copy link
Copy Markdown
Member

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.

Comment thread packages/dofs/src/sync/apply.ts Outdated
}
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
Member

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.

Comment thread packages/dofs/src/sync/apply.ts Outdated
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
Member

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.

Comment thread packages/dofs/src/sync/apply.ts Outdated
}
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
Member

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.

- Let a remote delete win over a pulled version awaiting its echo push
  by recording revs minted by pull applies (_vfs_upstream_revs).
- Check every node in a deleted directory's subtree, not only the
  directory, so unpushed descendant edits are protected.
- Compare against the full push cursor (rev and path), so same-rev
  paths beyond a partial push are protected.

Gardener-Operation: op_29ef360a754d42b37e0381356c12af6d78734c3a98f528dc18cd8bad69630a27:2fb95d57eb10338caf78fbb6584c0a03f9ecadd85a8ac4c0c72bf4de4f4fa155
@github-actions

github-actions Bot commented Oct 1, 2026

Copy link
Copy Markdown
Contributor Author

I reproduced all three findings (each is reported twice, by Devin and again by hand) with failing tests on f78ff4b. The proposed commit fixes them in tombstoneIsStale (packages/dofs/src/sync/apply.ts).

Remote deletes resurrect pulled files: fixed. Pull applies now record the local revs they mint, per backend, in a new _vfs_upstream_revs table. It is created by the baseline DDL, so no migration is needed, and rows are pruned in writePushCursor. A node still at a pulled rev gives way to a later delete from the same backend. A local edit after the pull still protects it.

  • Tests: replay.test.ts "lets a remote delete win over an unechoed pulled file" and "protects a local edit made after a pull". sync-engine-pack.test.ts "a container delete wins over a pulled file awaiting its echo push" covers entries and pack mode, and it fails without the fix.
  • This replaces the PR body's note that pulls keep local versions until they are pushed.

Directory deletes discard unpushed child edits: fixed. The guard now walks the whole subtree being deleted with a recursive CTE. It protects the delete if any node is past the push cursor and was not minted by a pull.

  • Tests: "protects unpushed descendants from a directory delete" and "still deletes a clean directory tree".

Partial pushes lose unshipped same-rev changes: fixed. The guard compares {rev, path} against readPushCursor, capped at the pushRev watermark so a reset stays conservative.

  • Test: "protects same-rev paths beyond a partial push cursor", which uses a renamed directory.

Every replay.test.ts case runs against both applyChanges and applyChangesSync.

Checks run, all passing:

  • dofs vitest: 680 tests (after building dofs for the schema test)
  • rpc vitest: 126 tests
  • rpc tsc --noEmit and dofs build
  • Biome on the changed files
  • git diff --check

@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 1 new potential issue.

Devin Review

Comment on lines +639 to +641
AND NOT EXISTS (
SELECT 1 FROM _vfs_upstream_revs u WHERE u.backend = ? AND u.rev = n.rev
)`,

@devin-ai-integration devin-ai-integration Bot Oct 1, 2026 •

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 deletion of updated hardlink stays blocked

After a pull updates /x beside an unpushed hardlink /y, rewritesUnpushedHardlink leaves /x's revision unmarked. A later remote deletion of /x is rejected as local, although deleting /x leaves /y intact.

Learn more

Hardlinks share one inode and its revision, but deleting one name does not delete the other. tombstoneIsStale treats every name of that inode as locally changed when the pulled revision lacks upstream provenance. Skipping provenance preserves an unpushed /y against its own remote tombstone, but also blocks a later authoritative deletion of the updated /x.

Example: /x is already pushed, then the host links it locally as /y without pushing /y. A pull updates /x and a later pull deletes /x. The second pull retains /x because the update's revision was not marked as pulled, even though /y remains linked independently.

Recommended fix: Track pending local hardlink names separately from inode-wide pull provenance, and make the pull tombstone guard check whether the deleted name, rather than any alias's shared inode revision, has an unshipped local change. Cover an update followed by deletion of /x while /y remains unpushed in both apply variants.

Devin Review


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

A pulled write to one name of a hardlinked file no longer marks the
shared inode's new rev as upstream while that inode holds an unpushed
local version, so a later remote delete of another name cannot discard
the local link.

Gardener-Operation: op_789a00a9ac036c1a03cabf304c4c0ed4a78f5d010d9a28320a7422d516373113:b1b75f82ac55517fe6e1459d845c0232ecd34eb183e361f5c43e4bdb561d57e4
@github-actions

github-actions Bot commented Oct 1, 2026

Copy link
Copy Markdown
Contributor Author

Unpushed hardlinks lost after remote writes: fixing in the proposed commit. I reproduced it on 4f13766. A pull that rewrites /x restamps the inode it shares with an unpushed link /y, and records that rev as upstream. A later delete of /y then removed the link. The fix adds rewritesUnpushedHardlink in packages/dofs/src/sync/apply.ts. When a pulled file write lands on an inode that has more than one name and still holds an unpushed local version, the new rev is not recorded as upstream. A partially shipped rev counts as unpushed, because the names have different paths. The trade-off is conservative: the shared inode stays protected from later deletes until it is pushed, which is how it behaved before the pull.

  • replay.test.ts "keeps an unpushed hardlink when a pull rewrites its inode" fails without the fix, in both the async and sync variants.
  • "still deletes a pushed hardlink after a pull rewrites its inode" checks that a clean link still gives way to the delete.

The three duplicate threads by scuffi (remote deletes resurrect pulled files, directory deletes discard child edits, partial pushes lose same-rev changes) repeat Devin's round-1 findings. They were fixed in 4f13766, and Devin has resolved its copies. The round-1 tests still pass, so I made no further change for them.

Checks I ran, all passing:

  • dofs vitest: 684 tests, after building dofs
  • rpc vitest: 126 tests
  • rpc typecheck (tsconfig.build.json)
  • Biome on the changed files
  • git diff --check

A bare tsc --noEmit in rpc reports 5 errors in test files. They are the same with and without this change.

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.

1 participant