@filen/sync 0.2.1 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.node-version +1 -1
- package/dist/ignorer.d.ts +6 -0
- package/dist/ignorer.js +43 -24
- package/dist/ignorer.js.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/deltas.d.ts +58 -2
- package/dist/lib/deltas.js +693 -108
- package/dist/lib/deltas.js.map +1 -1
- package/dist/lib/environment.d.ts +47 -0
- package/dist/lib/environment.js +71 -0
- package/dist/lib/environment.js.map +1 -0
- package/dist/lib/filesystems/dirTree.d.ts +70 -0
- package/dist/lib/filesystems/dirTree.js +157 -0
- package/dist/lib/filesystems/dirTree.js.map +1 -0
- package/dist/lib/filesystems/local.d.ts +18 -8
- package/dist/lib/filesystems/local.js +166 -160
- package/dist/lib/filesystems/local.js.map +1 -1
- package/dist/lib/filesystems/remote.d.ts +12 -5
- package/dist/lib/filesystems/remote.js +226 -172
- package/dist/lib/filesystems/remote.js.map +1 -1
- package/dist/lib/ipc.js +1 -2
- package/dist/lib/ipc.js.map +1 -1
- package/dist/lib/lock.js +19 -12
- package/dist/lib/lock.js.map +1 -1
- package/dist/lib/logger.js +9 -7
- package/dist/lib/logger.js.map +1 -1
- package/dist/lib/state.js +159 -63
- package/dist/lib/state.js.map +1 -1
- package/dist/lib/sync.d.ts +18 -0
- package/dist/lib/sync.js +165 -96
- package/dist/lib/sync.js.map +1 -1
- package/dist/lib/tasks.d.ts +7 -8
- package/dist/lib/tasks.js +38 -45
- package/dist/lib/tasks.js.map +1 -1
- package/dist/semaphore.d.ts +1 -0
- package/dist/semaphore.js +22 -5
- package/dist/semaphore.js.map +1 -1
- package/dist/utils.js +51 -35
- package/dist/utils.js.map +1 -1
- package/eslint.config.mjs +36 -0
- package/package.json +19 -15
- package/tests/bench/collapse.bench.ts +114 -0
- package/tests/bench/cycle.bench.ts +111 -0
- package/tests/bench/deltas.bench.ts +151 -0
- package/tests/bench/harness/fake-sync.ts +32 -0
- package/tests/bench/harness/measure.ts +276 -0
- package/tests/bench/harness/scale-world.ts +160 -0
- package/tests/bench/harness/trees.ts +275 -0
- package/tests/bench/local-scan.bench.ts +74 -0
- package/tests/bench/longrun.bench.ts +130 -0
- package/tests/bench/profile-incremental.ts +90 -0
- package/tests/bench/remote-build.bench.ts +104 -0
- package/tests/bench/render.ts +14 -0
- package/tests/bench/semaphore.bench.ts +79 -0
- package/tests/bench/state.bench.ts +85 -0
- package/tests/bench/tasks-dispatch.bench.ts +156 -0
- package/tests/conformance/virtual-fs.test.ts +213 -0
- package/tests/e2e/backup.e2e.test.ts +130 -0
- package/tests/e2e/confirm.e2e.test.ts +191 -0
- package/tests/e2e/conflict.e2e.test.ts +261 -0
- package/tests/e2e/edge.e2e.test.ts +339 -0
- package/tests/e2e/harness/account.ts +104 -0
- package/tests/e2e/harness/assert.ts +127 -0
- package/tests/e2e/harness/drive.ts +88 -0
- package/tests/e2e/harness/mutations.ts +249 -0
- package/tests/e2e/harness/world.ts +222 -0
- package/tests/e2e/ignore.e2e.test.ts +123 -0
- package/tests/e2e/lifecycle.e2e.test.ts +290 -0
- package/tests/e2e/modes.e2e.test.ts +215 -0
- package/tests/e2e/platform.e2e.test.ts +157 -0
- package/tests/e2e/property.e2e.test.ts +163 -0
- package/tests/e2e/races.e2e.test.ts +90 -0
- package/tests/e2e/regressions.e2e.test.ts +212 -0
- package/tests/e2e/resilience.e2e.test.ts +231 -0
- package/tests/e2e/special.e2e.test.ts +185 -0
- package/tests/e2e/state.e2e.test.ts +229 -0
- package/tests/e2e/sync.e2e.test.ts +222 -0
- package/tests/fakes/fake-cloud.test.ts +267 -0
- package/tests/fakes/fake-cloud.ts +1094 -0
- package/tests/fakes/virtual-fs.ts +354 -0
- package/tests/harness/known-bug.ts +17 -0
- package/tests/harness/mutations.ts +65 -0
- package/tests/harness/runner.ts +141 -0
- package/tests/harness/snapshot.ts +113 -0
- package/tests/harness/world.ts +187 -0
- package/tests/scenarios/a-baseline.test.ts +107 -0
- package/tests/scenarios/aa-races.test.ts +258 -0
- package/tests/scenarios/ab-mode-property.test.ts +189 -0
- package/tests/scenarios/ac-platform.test.ts +320 -0
- package/tests/scenarios/ad-unicode-normalization.test.ts +67 -0
- package/tests/scenarios/b-additions.test.ts +160 -0
- package/tests/scenarios/c-modifications.test.ts +194 -0
- package/tests/scenarios/d-deletions.test.ts +259 -0
- package/tests/scenarios/e-rename-move.test.ts +288 -0
- package/tests/scenarios/f-ignore-filter.test.ts +346 -0
- package/tests/scenarios/g-large-deletion.test.ts +277 -0
- package/tests/scenarios/h-resilience.test.ts +167 -0
- package/tests/scenarios/i-lifecycle.test.ts +353 -0
- package/tests/scenarios/j-state-cache.test.ts +264 -0
- package/tests/scenarios/k-scale.test.ts +202 -0
- package/tests/scenarios/l-property.test.ts +145 -0
- package/tests/scenarios/m-golden.test.ts +452 -0
- package/tests/scenarios/o-task-errors.test.ts +497 -0
- package/tests/scenarios/p-remote-originated.test.ts +306 -0
- package/tests/scenarios/q-cycle-lifecycle.test.ts +234 -0
- package/tests/scenarios/r-rename-stress.test.ts +208 -0
- package/tests/scenarios/s-upgrade-transition.test.ts +171 -0
- package/tests/scenarios/t-type-change.test.ts +144 -0
- package/tests/scenarios/u-mode-local-to-cloud.test.ts +347 -0
- package/tests/scenarios/v-mode-local-backup.test.ts +201 -0
- package/tests/scenarios/w-mode-cloud-to-local.test.ts +304 -0
- package/tests/scenarios/x-mode-cloud-backup.test.ts +201 -0
- package/tests/scenarios/y-conflict-matrix.test.ts +292 -0
- package/tests/scenarios/z-cross-ops.test.ts +285 -0
- package/tests/scenarios/zb-dir-rename-cross.test.ts +296 -0
- package/tests/scenarios/zc-crash-recovery.test.ts +189 -0
- package/tests/scenarios/zd-inode-reuse.test.ts +118 -0
- package/tests/scenarios/ze-move-into-new-dir.test.ts +130 -0
- package/tests/scenarios/zf-remote-change-unchanged-local.test.ts +81 -0
- package/tests/scenarios/zg-edit-during-scan.test.ts +68 -0
- package/tests/scenarios/zh-dir-delete-vs-child.test.ts +104 -0
- package/tests/scenarios/zi-smoke-test-outage.test.ts +78 -0
- package/tests/scenarios/zj-trash-cleanup.test.ts +133 -0
- package/tests/scenarios/zk-ignore-asymmetry.test.ts +150 -0
- package/tests/scenarios/zl-mode-atomicity.test.ts +104 -0
- package/tests/scenarios/zm-scan-concurrency.test.ts +78 -0
- package/tests/scenarios/zn-delta-ordering.test.ts +130 -0
- package/tests/scenarios/zo-download-temp-cleanup.test.ts +65 -0
- package/tests/unit/collapse-deltas.test.ts +276 -0
- package/tests/unit/dir-tree.test.ts +159 -0
- package/tests/unit/icloud.test.ts +115 -0
- package/tests/unit/ignorer-cache-regression.test.ts +70 -0
- package/tests/unit/ignorer.test.ts +63 -0
- package/tests/unit/ipc-lock.test.ts +438 -0
- package/tests/unit/lock.test.ts +135 -0
- package/tests/unit/n-unit.test.ts +632 -0
- package/tests/unit/remote-tree-unordered-regression.test.ts +101 -0
- package/tests/unit/semaphore-regression.test.ts +140 -0
- package/tests/unit/state-refencode-regression.test.ts +224 -0
- package/tests/unit/state.test.ts +809 -0
- package/tests/unit/tasks-dispatch-order-regression.test.ts +53 -0
- package/tests/unit/worker-api.test.ts +379 -0
- package/tsconfig.json +10 -1
- package/tsconfig.test.json +12 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/vitest.bench.config.ts +32 -0
- package/vitest.config.ts +27 -0
- package/vitest.e2e.config.ts +68 -0
- package/.eslintrc +0 -16
- package/jest.config.js +0 -5
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { createWorld, type World, LOCAL_ROOT } from "../../harness/world"
|
|
2
|
+
import { type SyncMode } from "../../../src/types"
|
|
3
|
+
import { type VfsSpec } from "../../fakes/virtual-fs"
|
|
4
|
+
import { type CloudSpec } from "../../fakes/fake-cloud"
|
|
5
|
+
import { type SyncDirTreeFetcher, type DirTreeResponse } from "../../../src/lib/filesystems/dirTree"
|
|
6
|
+
import { type Scene } from "./trees"
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Build a memfs {@link VfsSpec} (keys under the local root) from a scene. Files get tiny deterministic
|
|
10
|
+
* content; directories are explicit nulls so empty dirs still materialise.
|
|
11
|
+
*/
|
|
12
|
+
export function sceneToVfsSpec(scene: Scene, contentByte = "x"): VfsSpec {
|
|
13
|
+
const spec: VfsSpec = {}
|
|
14
|
+
|
|
15
|
+
for (const node of scene) {
|
|
16
|
+
if (node.type === "directory") {
|
|
17
|
+
spec[`${LOCAL_ROOT}${node.path}`] = null
|
|
18
|
+
} else {
|
|
19
|
+
spec[`${LOCAL_ROOT}${node.path}`] = contentByte.repeat(node.size)
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return spec
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Build a fake-cloud {@link CloudSpec} (keys are sync-root-absolute paths) from a scene. */
|
|
27
|
+
export function sceneToCloudSpec(scene: Scene, contentByte = "x"): CloudSpec {
|
|
28
|
+
const spec: CloudSpec = {}
|
|
29
|
+
|
|
30
|
+
for (const node of scene) {
|
|
31
|
+
if (node.type === "directory") {
|
|
32
|
+
spec[node.path] = null
|
|
33
|
+
} else {
|
|
34
|
+
spec[node.path] = contentByte.repeat(node.size)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return spec
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Create a wired in-memory world at scale and neutralise the background trash-cleanup interval so it
|
|
43
|
+
* cannot fire mid-benchmark. Runs under REAL timers (the bench measures wall time) — safe because the
|
|
44
|
+
* methods we benchmark (getDirectoryTree, runCycle with the debounce/timestamp pre-set) never await a
|
|
45
|
+
* scheduled timer in the uncontended in-memory path.
|
|
46
|
+
*/
|
|
47
|
+
export async function makeScaleWorld(options: {
|
|
48
|
+
mode: SyncMode
|
|
49
|
+
initialLocal?: VfsSpec
|
|
50
|
+
initialRemote?: CloudSpec
|
|
51
|
+
}): Promise<World> {
|
|
52
|
+
const world = await createWorld(options)
|
|
53
|
+
|
|
54
|
+
if (world.sync.cleanupLocalTrashInterval) {
|
|
55
|
+
clearInterval(world.sync.cleanupLocalTrashInterval)
|
|
56
|
+
|
|
57
|
+
world.sync.cleanupLocalTrashInterval = undefined
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return world
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Reset the local directory-tree cache so the next getDirectoryTree() does a full rescan. */
|
|
64
|
+
export function forceLocalRescan(world: World): void {
|
|
65
|
+
world.sync.localFileSystem.lastDirectoryChangeTimestamp = Date.now()
|
|
66
|
+
world.sync.localFileSystem.getDirectoryTreeCache = {
|
|
67
|
+
timestamp: 0,
|
|
68
|
+
tree: {},
|
|
69
|
+
inodes: {},
|
|
70
|
+
ignored: [],
|
|
71
|
+
errors: [],
|
|
72
|
+
size: 0
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function basename(path: string): string {
|
|
77
|
+
const i = path.lastIndexOf("/")
|
|
78
|
+
|
|
79
|
+
return i === -1 ? path : path.slice(i + 1)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function parentPath(path: string): string {
|
|
83
|
+
const i = path.lastIndexOf("/")
|
|
84
|
+
|
|
85
|
+
return i <= 0 ? "" : path.slice(0, i)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Synthesize the exact `/v3/dir/tree` response (files/folders tuple arrays with JSON metadata — the fake
|
|
90
|
+
* cloud's decrypt is JSON.parse) directly from a scene, so the remote-build benchmark can feed it into
|
|
91
|
+
* the ENGINE's tree-build loop WITHOUT going through the fake cloud's O(N²) `buildFullTree`. Ordered
|
|
92
|
+
* parents-before-children (root first) by default — matching the order the engine's loop relies on.
|
|
93
|
+
* `shuffle` produces an out-of-order response to exercise the order-independence target T2.
|
|
94
|
+
*/
|
|
95
|
+
export function sceneToDirTreeResponse(scene: Scene, rootUUID: string, options?: { shuffle?: () => void; rootName?: string }): DirTreeResponse {
|
|
96
|
+
const uuidByPath = new Map<string, string>()
|
|
97
|
+
|
|
98
|
+
for (const node of scene) {
|
|
99
|
+
if (node.type === "directory") {
|
|
100
|
+
uuidByPath.set(node.path, node.uuid)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const parentUUIDOf = (path: string): string => {
|
|
105
|
+
const parent = parentPath(path)
|
|
106
|
+
|
|
107
|
+
return parent.length === 0 ? rootUUID : uuidByPath.get(parent) ?? rootUUID
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const folders: unknown[] = [[rootUUID, JSON.stringify({ name: options?.rootName ?? "Sync" }), "base"]]
|
|
111
|
+
const files: unknown[] = []
|
|
112
|
+
|
|
113
|
+
for (const node of scene) {
|
|
114
|
+
if (node.type === "directory") {
|
|
115
|
+
folders.push([node.uuid, JSON.stringify({ name: basename(node.path) }), parentUUIDOf(node.path)])
|
|
116
|
+
} else {
|
|
117
|
+
files.push([
|
|
118
|
+
node.uuid,
|
|
119
|
+
"filen-1",
|
|
120
|
+
"de-1",
|
|
121
|
+
1,
|
|
122
|
+
parentUUIDOf(node.path),
|
|
123
|
+
JSON.stringify({
|
|
124
|
+
name: basename(node.path),
|
|
125
|
+
size: node.size,
|
|
126
|
+
mime: "application/octet-stream",
|
|
127
|
+
key: "k",
|
|
128
|
+
lastModified: node.mtime,
|
|
129
|
+
creation: node.creation,
|
|
130
|
+
hash: undefined
|
|
131
|
+
}),
|
|
132
|
+
2,
|
|
133
|
+
node.mtime
|
|
134
|
+
])
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return { files, folders, raw: "" } as unknown as DirTreeResponse
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Replace a world's remote tree fetcher with one that returns a fixed, pre-built response — so the timed
|
|
143
|
+
* `getDirectoryTree` measures ONLY the engine's build loop, not the fake cloud's response construction.
|
|
144
|
+
*/
|
|
145
|
+
export function injectDirTreeResponse(world: World, response: DirTreeResponse): void {
|
|
146
|
+
const fetcher: SyncDirTreeFetcher = async () => response
|
|
147
|
+
|
|
148
|
+
world.sync.environment.fetchDirTree = fetcher
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Reset the remote directory-tree cache so the next getDirectoryTree() rebuilds fully. */
|
|
152
|
+
export function forceRemoteRebuild(world: World): void {
|
|
153
|
+
world.sync.remoteFileSystem.getDirectoryTreeCache = {
|
|
154
|
+
timestamp: 0,
|
|
155
|
+
tree: {},
|
|
156
|
+
uuids: {},
|
|
157
|
+
ignored: [],
|
|
158
|
+
size: 0
|
|
159
|
+
}
|
|
160
|
+
}
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import { type LocalTree, type LocalItem } from "../../../src/lib/filesystems/local"
|
|
2
|
+
import { type RemoteTree, type RemoteItem } from "../../../src/lib/filesystems/remote"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A scene is an ordered (parents-before-children) list of nodes WITH stable identity (inode + uuid),
|
|
6
|
+
* from which a matching {@link LocalTree} and {@link RemoteTree} can be built. Identity is what lets a
|
|
7
|
+
* "current" scene derived from a "previous" one be recognised by the delta engine as a rename/modify
|
|
8
|
+
* (same inode/uuid, changed path/size) rather than an unrelated add+delete.
|
|
9
|
+
*
|
|
10
|
+
* Local identity (inode) and remote identity (uuid) are independent per node, exactly as the engine
|
|
11
|
+
* treats them — there is no cross-requirement between a path's inode and its uuid.
|
|
12
|
+
*/
|
|
13
|
+
export type GenNode = {
|
|
14
|
+
path: string
|
|
15
|
+
type: "file" | "directory"
|
|
16
|
+
inode: number
|
|
17
|
+
uuid: string
|
|
18
|
+
size: number
|
|
19
|
+
mtime: number
|
|
20
|
+
creation: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type Scene = GenNode[]
|
|
24
|
+
|
|
25
|
+
const BASE_MTIME = new Date("2024-06-01T00:00:00.000Z").getTime()
|
|
26
|
+
|
|
27
|
+
let inodeCounter = 1
|
|
28
|
+
let uuidCounter = 1
|
|
29
|
+
|
|
30
|
+
function nextInode(): number {
|
|
31
|
+
return inodeCounter++
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function nextUUID(): string {
|
|
35
|
+
// Deterministic, cheap, uuid-shaped enough for the engine (it only uses uuids as map keys / identity).
|
|
36
|
+
const n = uuidCounter++
|
|
37
|
+
|
|
38
|
+
return `uuid-${n.toString(16).padStart(12, "0")}`
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Reset the identity counters so independent benchmarks start from a clean, deterministic space. */
|
|
42
|
+
export function resetIdentity(): void {
|
|
43
|
+
inodeCounter = 1
|
|
44
|
+
uuidCounter = 1
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function fileNode(path: string, size = 64): GenNode {
|
|
48
|
+
return { path, type: "file", inode: nextInode(), uuid: nextUUID(), size, mtime: BASE_MTIME, creation: BASE_MTIME }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function dirNode(path: string): GenNode {
|
|
52
|
+
return { path, type: "directory", inode: nextInode(), uuid: nextUUID(), size: 0, mtime: BASE_MTIME, creation: BASE_MTIME }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ---- shape generators (return ordered, parents-first scenes) -------------------------------------
|
|
56
|
+
|
|
57
|
+
/** All `fileCount` files in the sync root (worst case for one flat directory). */
|
|
58
|
+
export function genFlatScene(fileCount: number): Scene {
|
|
59
|
+
const scene: Scene = []
|
|
60
|
+
|
|
61
|
+
for (let i = 0; i < fileCount; i++) {
|
|
62
|
+
scene.push(fileNode(`/file-${i}.txt`))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return scene
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** `dirCount` directories each holding `filesPerDir` files (a wide, shallow tree). */
|
|
69
|
+
export function genWideScene(dirCount: number, filesPerDir: number): Scene {
|
|
70
|
+
const scene: Scene = []
|
|
71
|
+
|
|
72
|
+
for (let d = 0; d < dirCount; d++) {
|
|
73
|
+
const dirPath = `/dir-${d}`
|
|
74
|
+
|
|
75
|
+
scene.push(dirNode(dirPath))
|
|
76
|
+
|
|
77
|
+
for (let f = 0; f < filesPerDir; f++) {
|
|
78
|
+
scene.push(fileNode(`${dirPath}/file-${f}.txt`))
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return scene
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** A single chain `/d0/d1/.../d{depth-1}/leaf.txt` (deep nesting). */
|
|
86
|
+
export function genDeepScene(depth: number): Scene {
|
|
87
|
+
const scene: Scene = []
|
|
88
|
+
let prefix = ""
|
|
89
|
+
|
|
90
|
+
for (let level = 0; level < depth; level++) {
|
|
91
|
+
prefix += `/d${level}`
|
|
92
|
+
|
|
93
|
+
scene.push(dirNode(prefix))
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
scene.push(fileNode(`${prefix}/leaf.txt`))
|
|
97
|
+
|
|
98
|
+
return scene
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* A balanced tree: each directory has `fanout` subdirectories down to `depth`, and every directory
|
|
103
|
+
* holds `filesPerDir` files. Realistic mix of files and directories. Ordered parents-first (BFS).
|
|
104
|
+
*/
|
|
105
|
+
export function genBalancedScene(options: { fanout: number; depth: number; filesPerDir: number }): Scene {
|
|
106
|
+
const { fanout, depth, filesPerDir } = options
|
|
107
|
+
const scene: Scene = []
|
|
108
|
+
const queue: { path: string; level: number }[] = [{ path: "", level: 0 }]
|
|
109
|
+
|
|
110
|
+
while (queue.length > 0) {
|
|
111
|
+
const { path, level } = queue.shift()!
|
|
112
|
+
|
|
113
|
+
for (let f = 0; f < filesPerDir; f++) {
|
|
114
|
+
scene.push(fileNode(`${path}/file-${f}.txt`))
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (level < depth) {
|
|
118
|
+
for (let c = 0; c < fanout; c++) {
|
|
119
|
+
const childPath = `${path}/dir-${level}-${c}`
|
|
120
|
+
|
|
121
|
+
scene.push(dirNode(childPath))
|
|
122
|
+
queue.push({ path: childPath, level: level + 1 })
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Drop a possible leading "/file..." with empty path → ensure all paths start with "/".
|
|
128
|
+
return scene
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Generate a wide scene that targets roughly `targetNodes` total nodes. */
|
|
132
|
+
export function genSceneOfSize(targetNodes: number, filesPerDir = 100): Scene {
|
|
133
|
+
const dirCount = Math.max(1, Math.round(targetNodes / (filesPerDir + 1)))
|
|
134
|
+
|
|
135
|
+
return genWideScene(dirCount, filesPerDir)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ---- scene → engine tree builders ----------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
export function buildLocalTree(scene: Scene): LocalTree {
|
|
141
|
+
const tree: Record<string, LocalItem> = {}
|
|
142
|
+
const inodes: Record<number, LocalItem> = {}
|
|
143
|
+
|
|
144
|
+
for (const node of scene) {
|
|
145
|
+
const item: LocalItem = {
|
|
146
|
+
lastModified: node.mtime,
|
|
147
|
+
type: node.type,
|
|
148
|
+
path: node.path,
|
|
149
|
+
size: node.size,
|
|
150
|
+
creation: node.creation,
|
|
151
|
+
inode: node.inode
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
tree[node.path] = item
|
|
155
|
+
inodes[node.inode] = item
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return { tree, inodes, size: scene.length }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function buildRemoteTree(scene: Scene): RemoteTree {
|
|
162
|
+
const tree: Record<string, RemoteItem> = {}
|
|
163
|
+
const uuids: Record<string, RemoteItem> = {}
|
|
164
|
+
|
|
165
|
+
for (const node of scene) {
|
|
166
|
+
const item: RemoteItem =
|
|
167
|
+
node.type === "directory"
|
|
168
|
+
? { type: "directory", uuid: node.uuid, name: basename(node.path), size: 0, path: node.path }
|
|
169
|
+
: {
|
|
170
|
+
type: "file",
|
|
171
|
+
uuid: node.uuid,
|
|
172
|
+
name: basename(node.path),
|
|
173
|
+
size: node.size,
|
|
174
|
+
mime: "application/octet-stream",
|
|
175
|
+
lastModified: node.mtime,
|
|
176
|
+
version: 2,
|
|
177
|
+
chunks: 1,
|
|
178
|
+
key: "k",
|
|
179
|
+
bucket: "filen-1",
|
|
180
|
+
region: "de-1",
|
|
181
|
+
creation: node.creation,
|
|
182
|
+
path: node.path
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
tree[node.path] = item
|
|
186
|
+
uuids[node.uuid] = item
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return { tree, uuids, size: scene.length }
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function basename(path: string): string {
|
|
193
|
+
const i = path.lastIndexOf("/")
|
|
194
|
+
|
|
195
|
+
return i === -1 ? path : path.slice(i + 1)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ---- mutators (return a NEW scene = the "current" side; never mutate the input) -------------------
|
|
199
|
+
|
|
200
|
+
export function cloneScene(scene: Scene): Scene {
|
|
201
|
+
return scene.map(n => ({ ...n }))
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Append `k` brand-new files (fresh identity) under `parent` (default sync root). */
|
|
205
|
+
export function addFiles(scene: Scene, k: number, parent = ""): Scene {
|
|
206
|
+
const next = cloneScene(scene)
|
|
207
|
+
|
|
208
|
+
for (let i = 0; i < k; i++) {
|
|
209
|
+
next.push(fileNode(`${parent}/added-${i}-${nextInode()}.txt`))
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return next
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Remove the last `k` FILE nodes (keeps directories so the tree stays valid). */
|
|
216
|
+
export function deleteFiles(scene: Scene, k: number): Scene {
|
|
217
|
+
const next = cloneScene(scene)
|
|
218
|
+
let removed = 0
|
|
219
|
+
|
|
220
|
+
for (let i = next.length - 1; i >= 0 && removed < k; i--) {
|
|
221
|
+
if (next[i]!.type === "file") {
|
|
222
|
+
next.splice(i, 1)
|
|
223
|
+
|
|
224
|
+
removed++
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return next
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Modify the first `k` files. `side: "local"` keeps inode (a real local edit), `side: "remote"` mints a
|
|
233
|
+
* new uuid (a real remote re-upload). Both bump size + mtime so the change is detected against the base.
|
|
234
|
+
*/
|
|
235
|
+
export function modifyFiles(scene: Scene, k: number, side: "local" | "remote"): Scene {
|
|
236
|
+
const next = cloneScene(scene)
|
|
237
|
+
let changed = 0
|
|
238
|
+
|
|
239
|
+
for (const node of next) {
|
|
240
|
+
if (changed >= k) {
|
|
241
|
+
break
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (node.type === "file") {
|
|
245
|
+
node.size += 1000
|
|
246
|
+
node.mtime += 60_000
|
|
247
|
+
|
|
248
|
+
if (side === "remote") {
|
|
249
|
+
node.uuid = nextUUID()
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
changed++
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return next
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Rename a top-level directory (and rebase every descendant) while preserving identity, so the delta
|
|
261
|
+
* engine sees a single directory rename carrying the subtree.
|
|
262
|
+
*/
|
|
263
|
+
export function renameTopDir(scene: Scene, fromPath: string, toPath: string): Scene {
|
|
264
|
+
const next = cloneScene(scene)
|
|
265
|
+
|
|
266
|
+
for (const node of next) {
|
|
267
|
+
if (node.path === fromPath) {
|
|
268
|
+
node.path = toPath
|
|
269
|
+
} else if (node.path.startsWith(`${fromPath}/`)) {
|
|
270
|
+
node.path = toPath + node.path.slice(fromPath.length)
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return next
|
|
275
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { bench } from "./harness/measure"
|
|
3
|
+
import { makeScaleWorld, sceneToVfsSpec, forceLocalRescan } from "./harness/scale-world"
|
|
4
|
+
import { genWideScene, genBalancedScene, resetIdentity, type Scene } from "./harness/trees"
|
|
5
|
+
import { type World } from "../harness/world"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Local tree-scan benchmark (targets T5, T10). `localFileSystem.getDirectoryTree` runs whenever the local
|
|
9
|
+
* side changed: fast-glob walks the whole tree (materialising an N-string array), then per entry does
|
|
10
|
+
* lstat + access(R_OK) (two fs ops) and builds the LocalItem maps. memfs backs the fs so we measure the
|
|
11
|
+
* walk + per-entry CPU + allocation, not real disk latency (the relative cost of fast-glob vs the loop
|
|
12
|
+
* vs the double-stat is what transfers to real disk).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
async function buildWorld(scene: Scene): Promise<World> {
|
|
16
|
+
return await makeScaleWorld({ mode: "twoWay", initialLocal: sceneToVfsSpec(scene) })
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("localFileSystem.getDirectoryTree", () => {
|
|
20
|
+
it("wide tree size sweep", async () => {
|
|
21
|
+
for (const nodes of [10_000, 50_000, 100_000]) {
|
|
22
|
+
resetIdentity()
|
|
23
|
+
|
|
24
|
+
const scene = genWideScene(Math.max(1, Math.round(nodes / 101)), 100)
|
|
25
|
+
const world = await buildWorld(scene)
|
|
26
|
+
|
|
27
|
+
// Correctness: the scan finds exactly the scene's nodes.
|
|
28
|
+
forceLocalRescan(world)
|
|
29
|
+
|
|
30
|
+
const first = await world.sync.localFileSystem.getDirectoryTree()
|
|
31
|
+
|
|
32
|
+
expect(first.result.size).toBe(scene.length)
|
|
33
|
+
|
|
34
|
+
await bench({
|
|
35
|
+
group: "localFileSystem.getDirectoryTree / wide",
|
|
36
|
+
name: `${nodes} nodes`,
|
|
37
|
+
n: scene.length,
|
|
38
|
+
iterations: 4,
|
|
39
|
+
setup: () => {
|
|
40
|
+
forceLocalRescan(world)
|
|
41
|
+
|
|
42
|
+
return world
|
|
43
|
+
},
|
|
44
|
+
run: w => w.sync.localFileSystem.getDirectoryTree()
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it("balanced (directory-heavy) tree", async () => {
|
|
50
|
+
resetIdentity()
|
|
51
|
+
|
|
52
|
+
const scene = genBalancedScene({ fanout: 4, depth: 6, filesPerDir: 8 })
|
|
53
|
+
const world = await buildWorld(scene)
|
|
54
|
+
|
|
55
|
+
forceLocalRescan(world)
|
|
56
|
+
|
|
57
|
+
const first = await world.sync.localFileSystem.getDirectoryTree()
|
|
58
|
+
|
|
59
|
+
expect(first.result.size).toBe(scene.length)
|
|
60
|
+
|
|
61
|
+
await bench({
|
|
62
|
+
group: "localFileSystem.getDirectoryTree / balanced",
|
|
63
|
+
name: `fanout4 depth6 (${scene.length} nodes)`,
|
|
64
|
+
n: scene.length,
|
|
65
|
+
iterations: 4,
|
|
66
|
+
setup: () => {
|
|
67
|
+
forceLocalRescan(world)
|
|
68
|
+
|
|
69
|
+
return world
|
|
70
|
+
},
|
|
71
|
+
run: w => w.sync.localFileSystem.getDirectoryTree()
|
|
72
|
+
})
|
|
73
|
+
})
|
|
74
|
+
})
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { recordCustom } from "./harness/measure"
|
|
3
|
+
import { makeScaleWorld, sceneToVfsSpec, forceLocalRescan } from "./harness/scale-world"
|
|
4
|
+
import { genWideScene, resetIdentity } from "./harness/trees"
|
|
5
|
+
import { SYNC_INTERVAL } from "../../src/constants"
|
|
6
|
+
import { LOCAL_ROOT, type World } from "../harness/world"
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Long-running benchmark — simulates many "fake hours" of a desktop sync (hundreds of cycles, a small
|
|
10
|
+
* change each cycle) and watches for two failure modes a long-lived process must not have:
|
|
11
|
+
* 1. memory leak — heapUsed trending up cycle over cycle (accumulating signals, caches, hashes…).
|
|
12
|
+
* 2. perf degradation — later cycles getting slower than earlier ones (growing structures, GC churn).
|
|
13
|
+
* Reports the heap slope (KB/cycle) and the late-vs-early cycle-time ratio.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
function ageDebounce(world: World): void {
|
|
17
|
+
world.sync.localFileSystem.lastDirectoryChangeTimestamp = Date.now() - SYNC_INTERVAL - 1_000
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function gc(): void {
|
|
21
|
+
globalThis.gc?.()
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Least-squares slope of y over x (MB per cycle here). */
|
|
25
|
+
function slope(xs: number[], ys: number[]): number {
|
|
26
|
+
const n = xs.length
|
|
27
|
+
const meanX = xs.reduce((a, b) => a + b, 0) / n
|
|
28
|
+
const meanY = ys.reduce((a, b) => a + b, 0) / n
|
|
29
|
+
let num = 0
|
|
30
|
+
let den = 0
|
|
31
|
+
|
|
32
|
+
for (let i = 0; i < n; i++) {
|
|
33
|
+
num += (xs[i]! - meanX) * (ys[i]! - meanY)
|
|
34
|
+
den += (xs[i]! - meanX) ** 2
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return den === 0 ? 0 : num / den
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe("long-running sync (leak + degradation)", () => {
|
|
41
|
+
it("hundreds of incremental cycles stay flat in memory and time", async () => {
|
|
42
|
+
const CYCLES = 300
|
|
43
|
+
const SAMPLE_EVERY = 25
|
|
44
|
+
|
|
45
|
+
resetIdentity()
|
|
46
|
+
|
|
47
|
+
const scene = genWideScene(50, 100) // ~5,050 nodes
|
|
48
|
+
const world = await makeScaleWorld({ mode: "twoWay", initialLocal: sceneToVfsSpec(scene) })
|
|
49
|
+
|
|
50
|
+
// Settle the initial sync.
|
|
51
|
+
for (let i = 0; i < 3; i++) {
|
|
52
|
+
ageDebounce(world)
|
|
53
|
+
|
|
54
|
+
await world.sync.runCycle()
|
|
55
|
+
|
|
56
|
+
world.messages.length = 0
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const targetPath = `${LOCAL_ROOT}/dir-0/file-0.txt`
|
|
60
|
+
const cycleTimes: number[] = []
|
|
61
|
+
const sampleCycles: number[] = []
|
|
62
|
+
const sampleHeapMB: number[] = []
|
|
63
|
+
|
|
64
|
+
for (let c = 0; c < CYCLES; c++) {
|
|
65
|
+
await world.vfs.fs.writeFile(targetPath, "y".repeat(64 + (c % 500)), { encoding: "utf-8" })
|
|
66
|
+
|
|
67
|
+
forceLocalRescan(world)
|
|
68
|
+
ageDebounce(world)
|
|
69
|
+
|
|
70
|
+
const t0 = performance.now()
|
|
71
|
+
|
|
72
|
+
await world.sync.runCycle()
|
|
73
|
+
|
|
74
|
+
cycleTimes.push(performance.now() - t0)
|
|
75
|
+
|
|
76
|
+
// Clear the harness message log so we measure ENGINE memory, not the test's growing array.
|
|
77
|
+
world.messages.length = 0
|
|
78
|
+
|
|
79
|
+
if (c % SAMPLE_EVERY === 0) {
|
|
80
|
+
gc()
|
|
81
|
+
|
|
82
|
+
sampleCycles.push(c)
|
|
83
|
+
sampleHeapMB.push(process.memoryUsage().heapUsed / (1024 * 1024))
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
gc()
|
|
88
|
+
|
|
89
|
+
sampleCycles.push(CYCLES)
|
|
90
|
+
sampleHeapMB.push(process.memoryUsage().heapUsed / (1024 * 1024))
|
|
91
|
+
|
|
92
|
+
const heapSlopeMBPerCycle = slope(sampleCycles, sampleHeapMB)
|
|
93
|
+
const heapSlopeKBPerCycle = heapSlopeMBPerCycle * 1024
|
|
94
|
+
|
|
95
|
+
const half = Math.floor(cycleTimes.length / 2)
|
|
96
|
+
const earlyAvg = cycleTimes.slice(0, half).reduce((a, b) => a + b, 0) / half
|
|
97
|
+
const lateAvg = cycleTimes.slice(half).reduce((a, b) => a + b, 0) / (cycleTimes.length - half)
|
|
98
|
+
const degradationRatio = lateAvg / earlyAvg
|
|
99
|
+
|
|
100
|
+
recordCustom({
|
|
101
|
+
group: "long-run / leak + degradation",
|
|
102
|
+
name: `${CYCLES} incremental cycles, ${scene.length} nodes`,
|
|
103
|
+
n: CYCLES,
|
|
104
|
+
msMean: cycleTimes.reduce((a, b) => a + b, 0) / cycleTimes.length,
|
|
105
|
+
msMin: Math.min(...cycleTimes),
|
|
106
|
+
heapRetainedMB: sampleHeapMB[sampleHeapMB.length - 1]!,
|
|
107
|
+
extra: {
|
|
108
|
+
heapStartMB: Number(sampleHeapMB[0]!.toFixed(1)),
|
|
109
|
+
heapEndMB: Number(sampleHeapMB[sampleHeapMB.length - 1]!.toFixed(1)),
|
|
110
|
+
heapSlopeKBPerCycle: Number(heapSlopeKBPerCycle.toFixed(2)),
|
|
111
|
+
earlyCycleMs: Number(earlyAvg.toFixed(2)),
|
|
112
|
+
lateCycleMs: Number(lateAvg.toFixed(2)),
|
|
113
|
+
degradationRatio: Number(degradationRatio.toFixed(3))
|
|
114
|
+
}
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
process.stdout.write(
|
|
118
|
+
`[longrun] heap ${sampleHeapMB[0]!.toFixed(1)}→${sampleHeapMB[sampleHeapMB.length - 1]!.toFixed(
|
|
119
|
+
1
|
|
120
|
+
)}MB (slope ${heapSlopeKBPerCycle.toFixed(2)} KB/cycle) | cycle early ${earlyAvg.toFixed(2)}ms late ${lateAvg.toFixed(
|
|
121
|
+
2
|
|
122
|
+
)}ms (x${degradationRatio.toFixed(3)})\n`
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
// Leak guard: a flat engine should not trend up more than a few KB/cycle (well under a MB over 300).
|
|
126
|
+
expect(heapSlopeKBPerCycle).toBeLessThan(200)
|
|
127
|
+
// Degradation guard: late cycles within 2x of early ones (generous — absorbs gc noise).
|
|
128
|
+
expect(degradationRatio).toBeLessThan(2)
|
|
129
|
+
})
|
|
130
|
+
})
|