Skip to content

Commit cb1d8a0

Browse files
committed
dofs: Track provider renames in sync
Move provider renames through a filesystem primitive so local rename behavior and sync output share one implementation. A rename stamps the moved inode subtree with one revision and records tombstones for the old paths, letting the existing change stream represent moves without a new wire opcode. Directory renames are O(subtree) in database writes and wire entries. That cost is explicit in the protocol docs. The apply path also resolves type conflicts by replacing the local node tree with the upstream entry, and rm now unlinks final symlinks without following them to their targets.
1 parent 45e7b91 commit cb1d8a0

12 files changed

Lines changed: 1192 additions & 146 deletions

File tree

docs/02_sync_protocol.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,28 @@ A typical `exec()` round-trip:
8383
step 1 is "this single change", steps 3–6 are skipped. `workspace.push()`
8484
runs step 1 on demand; `workspace.pull()` runs steps 4–6.
8585

86+
Renames are local inode moves, but the sync wire has no rename opcode.
87+
The wire stays final-state based — live entries plus tombstones — so
88+
apply remains idempotent and does not need operation-order replay. The
89+
cost is that a directory rename stamps every moved inode with one new
90+
revision and records tombstones for the old paths in one synchronous
91+
transaction. Large directory renames are therefore O(subtree) in local
92+
writes and wire entries, with no separate cap beyond the caller's own
93+
workload. Parent directory mtimes are not changed by rename, which
94+
differs from POSIX `rename(2)` but keeps parent directory metadata out
95+
of content sync.
96+
97+
Directory entries carry mode and mtime. New directories are created
98+
with the incoming mtime, but idempotence for an existing directory is
99+
mode-only. That keeps mtime drift on matching directories from
100+
becoming sync traffic.
101+
102+
When an upstream file, directory, or symlink lands where the receiver
103+
has a different node type, the receiver removes the local node tree and
104+
applies the upstream entry. This is last-writer-wins conflict handling:
105+
it converges the tree, but local-only children under the conflicting
106+
path are discarded without separate tombstones.
107+
86108
### Chunking
87109

88110
Files are split at a fixed `CHUNK_SIZE` (512 KiB). Chunk boundaries are

docs/03_filesystem_schema.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,10 @@ ultimately hits one of these tables. All tables are prefixed with
1414
collide with application-owned tables in the same DO storage.
1515

1616
Paths are resolved through an inode-style indirection (`vfs_dirents`
17-
`vfs_nodes`), so renames are O(1) regardless of subtree size and
18-
hardlinks fall out for free.
17+
`vfs_nodes`), so the local namespace move in a rename is O(1) and
18+
hardlinks fall out for free. Directory rename sync still walks the
19+
moved subtree because the wire represents the move as live entries at
20+
the new paths plus tombstones at the old paths.
1921

2022
## Tables
2123

packages/dofs/src/fs/rename.ts

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
import { createWorkspaceError } from "../errors.js";
2+
import { canonicalizePath } from "../path.js";
3+
import { incrementRev } from "../rev.js";
4+
import type { Database } from "../storage.js";
5+
import { recordDelete } from "../sync/changes.js";
6+
import { pathOf } from "../sync/paths.js";
7+
import { assertNotReadOnly } from "./mount-guard.js";
8+
import { resolveInode } from "./resolve.js";
9+
10+
interface DirChild {
11+
name: string;
12+
child_inode: number;
13+
type: NodeType;
14+
}
15+
16+
interface SubtreeEntry {
17+
path: string;
18+
inode: number;
19+
type: NodeType;
20+
}
21+
22+
type NodeType = "file" | "dir" | "symlink";
23+
24+
export function rename(db: Database, oldPath: string, newPath: string): void {
25+
const { path: oldCanonical } = canonicalizePath(oldPath);
26+
const { parts: newParts, path: newCanonical } = canonicalizePath(newPath);
27+
28+
if (oldCanonical === "/") {
29+
throw createWorkspaceError("EINVAL", "cannot rename root", oldCanonical);
30+
}
31+
if (newParts.length === 0) {
32+
throw createWorkspaceError("EINVAL", "cannot rename onto root", newCanonical);
33+
}
34+
35+
assertNotReadOnly(db, oldCanonical);
36+
assertNotReadOnly(db, newCanonical);
37+
38+
db.transactionSync(() => {
39+
const source = resolveInode(db, oldCanonical, { followSymlinks: false });
40+
if (source === null) {
41+
throw createWorkspaceError("ENOENT", `no such path: ${oldCanonical}`, oldCanonical);
42+
}
43+
44+
// Resolve the source's real parent dirent. The parent path is
45+
// resolved with symlinks followed so a request through a symlinked
46+
// directory lands on the real container; the inode is then
47+
// identified by (parent_inode, name) rather than by child_inode so
48+
// a hardlinked source touches only the requested name.
49+
const { parts: oldParts } = canonicalizePath(oldCanonical);
50+
const oldName = oldParts[oldParts.length - 1];
51+
const oldParentPath = oldParts.length === 1 ? "/" : `/${oldParts.slice(0, -1).join("/")}`;
52+
const oldParent = resolveInode(db, oldParentPath);
53+
if (oldParent === null || oldParent.type !== "dir") {
54+
throw createWorkspaceError("ENOENT", `no such path: ${oldCanonical}`, oldCanonical);
55+
}
56+
const oldParentReal = pathOf(db, oldParent.inode);
57+
if (oldParentReal === null) {
58+
throw createWorkspaceError("ENOENT", `no such path: ${oldCanonical}`, oldCanonical);
59+
}
60+
const oldRealPath = oldParentReal === "/" ? `/${oldName}` : `${oldParentReal}/${oldName}`;
61+
62+
if (oldCanonical === newCanonical) return;
63+
if (source.type === "dir" && newCanonical.startsWith(`${oldRealPath}/`)) {
64+
throw createWorkspaceError(
65+
"EINVAL",
66+
`cannot rename a directory into itself: ${oldRealPath}`,
67+
newCanonical,
68+
);
69+
}
70+
71+
const newName = newParts[newParts.length - 1];
72+
const newParentPath = newParts.length === 1 ? "/" : `/${newParts.slice(0, -1).join("/")}`;
73+
const newParent = resolveInode(db, newParentPath);
74+
if (newParent === null || newParent.type !== "dir") {
75+
throw createWorkspaceError(
76+
"ENOENT",
77+
`parent directory missing: ${newCanonical}`,
78+
newCanonical,
79+
);
80+
}
81+
82+
// A rename whose source and destination resolve to the very same
83+
// dirent (same real parent and name, e.g. through a symlinked path)
84+
// is a true no-op: leave the tree and the change stream untouched.
85+
// This is distinct from renaming one hardlink onto another, where
86+
// the names differ and the source link must still be removed.
87+
if (oldParent.inode === newParent.inode && oldName === newName) return;
88+
89+
const existing = db.one<{ child_inode: number; type: "file" | "dir" | "symlink" }>(
90+
`SELECT d.child_inode AS child_inode, n.type AS type
91+
FROM vfs_dirents d
92+
JOIN vfs_nodes n ON n.inode = d.child_inode
93+
WHERE d.parent_inode = ? AND d.name = ?`,
94+
newParent.inode,
95+
newName,
96+
);
97+
98+
const oldEntries =
99+
source.type === "dir"
100+
? collectSubtree(db, source.inode, oldRealPath)
101+
: [{ path: oldRealPath, inode: source.inode, type: source.type }];
102+
if (source.type === "dir") {
103+
assertDestinationParentOutsideSource(oldEntries, newParent.inode, oldRealPath, newCanonical);
104+
}
105+
106+
if (existing !== undefined) {
107+
assertCompatibleOverwrite(source.type, existing.type, newCanonical);
108+
if (existing.type === "dir") {
109+
const childCount = db.scalar<number>(
110+
"SELECT COUNT(*) FROM vfs_dirents WHERE parent_inode = ?",
111+
existing.child_inode,
112+
);
113+
if ((childCount ?? 0) > 0) {
114+
throw createWorkspaceError("ENOTEMPTY", `not empty: ${newCanonical}`, newCanonical);
115+
}
116+
}
117+
// Displace only the destination name. The displaced inode may
118+
// carry other hardlinks (or be the source inode itself), so reap
119+
// its chunks and node row only once the final link disappears.
120+
// Order matters: displace before unlinking the source so a
121+
// hardlink-onto-hardlink rename never momentarily drops to zero
122+
// links and reaps the inode it is about to re-point.
123+
db.run(
124+
"DELETE FROM vfs_dirents WHERE parent_inode = ? AND name = ?",
125+
newParent.inode,
126+
newName,
127+
);
128+
const remaining = db.scalar<number>(
129+
"SELECT COUNT(*) FROM vfs_dirents WHERE child_inode = ?",
130+
existing.child_inode,
131+
);
132+
if ((remaining ?? 0) === 0) {
133+
db.run("DELETE FROM vfs_chunks WHERE inode = ?", existing.child_inode);
134+
db.run("DELETE FROM vfs_nodes WHERE inode = ?", existing.child_inode);
135+
}
136+
}
137+
138+
// Unlink only the source name; a hardlinked source keeps its other
139+
// names alive.
140+
db.run("DELETE FROM vfs_dirents WHERE parent_inode = ? AND name = ?", oldParent.inode, oldName);
141+
db.run(
142+
"INSERT INTO vfs_dirents (parent_inode, name, child_inode) VALUES (?, ?, ?)",
143+
newParent.inode,
144+
newName,
145+
source.inode,
146+
);
147+
148+
const rev = incrementRev(db);
149+
// Rename is represented on the wire as old-path tombstones plus
150+
// live entries for the moved inode subtree, so stamp only that
151+
// subtree with the shared rev. Parent directory mtimes are left
152+
// unchanged on purpose; this diverges from POSIX rename(2), but
153+
// avoids treating the old and new parents as content changes.
154+
for (const entry of oldEntries) {
155+
db.run("UPDATE vfs_nodes SET rev = ? WHERE inode = ?", rev, entry.inode);
156+
recordDelete(db, rev, entry.path);
157+
}
158+
});
159+
}
160+
161+
function assertCompatibleOverwrite(
162+
sourceType: NodeType,
163+
existingType: NodeType,
164+
path: string,
165+
): void {
166+
if (sourceType === "dir" && existingType === "dir") return;
167+
if (existingType === "dir") {
168+
throw createWorkspaceError("EISDIR", `cannot overwrite directory: ${path}`, path);
169+
}
170+
if (sourceType === "dir") {
171+
throw createWorkspaceError("ENOTDIR", `cannot overwrite non-directory: ${path}`, path);
172+
}
173+
}
174+
175+
function assertDestinationParentOutsideSource(
176+
oldEntries: SubtreeEntry[],
177+
newParentInode: number,
178+
oldCanonical: string,
179+
newCanonical: string,
180+
): void {
181+
if (!oldEntries.some((entry) => entry.inode === newParentInode)) return;
182+
183+
throw createWorkspaceError(
184+
"EINVAL",
185+
`cannot rename a directory into itself: ${oldCanonical}`,
186+
newCanonical,
187+
);
188+
}
189+
190+
function collectSubtree(db: Database, rootInode: number, rootPath: string): SubtreeEntry[] {
191+
const entries: SubtreeEntry[] = [{ path: rootPath, inode: rootInode, type: "dir" }];
192+
for (let idx = 0; idx < entries.length; idx++) {
193+
const entry = entries[idx];
194+
if (entry.type !== "dir") continue;
195+
const children = db.all<DirChild>(
196+
`SELECT d.name AS name, d.child_inode AS child_inode, n.type AS type
197+
FROM vfs_dirents d
198+
JOIN vfs_nodes n ON n.inode = d.child_inode
199+
WHERE d.parent_inode = ?
200+
ORDER BY d.name`,
201+
entry.inode,
202+
);
203+
for (const child of children) {
204+
const childPath = entry.path === "/" ? `/${child.name}` : `${entry.path}/${child.name}`;
205+
entries.push({
206+
path: childPath,
207+
inode: child.child_inode,
208+
type: child.type,
209+
});
210+
}
211+
}
212+
return entries;
213+
}

packages/dofs/src/fs/rm.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
33
import type { Database } from "../storage.js";
44
import { mkdir } from "./mkdir.js";
55
import { readdir } from "./readdir.js";
6+
import { readFile } from "./readFile.js";
67
import { resolveInode } from "./resolve.js";
78
import { rm } from "./rm.js";
89
import { symlink } from "./symlink.js";
@@ -41,6 +42,23 @@ describe("rm", () => {
4142
});
4243
});
4344

45+
it("records tombstones at the resolved path through intermediate symlinks", async () => {
46+
await withDB(async (db) => {
47+
mkdir(db, "/real", {}, () => 0);
48+
await writeFile(db, "/real/file.txt", "content", {}, () => 0);
49+
symlink(db, "/real", "/link", () => 0);
50+
51+
rm(db, "/link/file.txt", {});
52+
53+
expect(listChanges(db)).toContainEqual(
54+
expect.objectContaining({ path: "/real/file.txt", op: "delete" }),
55+
);
56+
expect(listChanges(db)).not.toContainEqual(
57+
expect.objectContaining({ path: "/link/file.txt", op: "delete" }),
58+
);
59+
});
60+
});
61+
4462
it("bumps rev once per call", async () => {
4563
await withDB(async (db) => {
4664
await writeFile(db, "/a.txt", "hi", {}, () => 0);
@@ -86,6 +104,16 @@ describe("rm", () => {
86104
});
87105
});
88106

107+
it("removes a dangling symlink", async () => {
108+
await withDB((db) => {
109+
symlink(db, "/missing", "/dangling", () => 0);
110+
111+
rm(db, "/dangling", {});
112+
113+
expect(resolveInode(db, "/dangling", { followSymlinks: false })).toBeNull();
114+
});
115+
});
116+
89117
it("rejects ENOENT for a missing path", async () => {
90118
await withDB((db) => {
91119
expect(() => rm(db, "/missing", {})).toThrowError(
@@ -143,6 +171,19 @@ describe("rm", () => {
143171
});
144172
});
145173

174+
it("recursive removes a symlink to a directory without deleting its target", async () => {
175+
await withDB(async (db) => {
176+
mkdir(db, "/target/sub", { recursive: true }, () => 0);
177+
await writeFile(db, "/target/sub/file.txt", "content", {}, () => 0);
178+
symlink(db, "/target", "/link", () => 0);
179+
180+
rm(db, "/link", { recursive: true });
181+
182+
expect(resolveInode(db, "/link", { followSymlinks: false })).toBeNull();
183+
expect(await readFile(db, "/target/sub/file.txt", "utf8")).toBe("content");
184+
});
185+
});
186+
146187
it("recursive records one tombstone per removed path", async () => {
147188
await withDB(async (db) => {
148189
mkdir(db, "/d", {}, () => 0);
@@ -156,6 +197,25 @@ describe("rm", () => {
156197
});
157198
});
158199

200+
it("recursive records resolved subtree tombstones through intermediate symlinks", async () => {
201+
await withDB(async (db) => {
202+
mkdir(db, "/real/dir", { recursive: true }, () => 0);
203+
await writeFile(db, "/real/dir/a", "x", {}, () => 0);
204+
await writeFile(db, "/real/dir/b", "y", {}, () => 0);
205+
symlink(db, "/real", "/link", () => 0);
206+
207+
rm(db, "/link/dir", { recursive: true });
208+
209+
const paths = listChanges(db)
210+
.map((r) => r.path)
211+
.sort();
212+
expect(paths).toEqual(expect.arrayContaining(["/real/dir", "/real/dir/a", "/real/dir/b"]));
213+
expect(paths).not.toEqual(
214+
expect.arrayContaining(["/link/dir", "/link/dir/a", "/link/dir/b"]),
215+
);
216+
});
217+
});
218+
159219
it("recursive still bumps rev only once for the whole tree", async () => {
160220
await withDB(async (db) => {
161221
mkdir(db, "/d", {}, () => 0);

0 commit comments

Comments
 (0)