@filen/sync 0.2.1 → 0.3.1
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,111 @@
|
|
|
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, resetIdentity } from "./harness/trees"
|
|
5
|
+
import { SYNC_INTERVAL } from "../../src/constants"
|
|
6
|
+
import { LOCAL_ROOT, type World } from "../harness/world"
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* End-to-end cycle benchmark — ties the whole pipeline together (scan + deltas + tasks + state.save)
|
|
10
|
+
* through a real in-memory world. The headline real-world cost is the INCREMENTAL cycle: on a large
|
|
11
|
+
* synced tree, changing ONE file still forces a full-tree rescan + full delta pass + full state.save,
|
|
12
|
+
* all O(N). This is what a desktop user pays on every small edit inside a big folder.
|
|
13
|
+
*
|
|
14
|
+
* Real timers; the debounce is bypassed by ageing lastDirectoryChangeTimestamp, and the uncontended
|
|
15
|
+
* in-memory lock acquires instantly, so a cycle never awaits a scheduled timer.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
function ageDebounce(world: World): void {
|
|
19
|
+
world.sync.localFileSystem.lastDirectoryChangeTimestamp = Date.now() - SYNC_INTERVAL - 1_000
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function runCycle(world: World): Promise<void> {
|
|
23
|
+
ageDebounce(world)
|
|
24
|
+
|
|
25
|
+
await world.sync.runCycle()
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function syncToConvergence(world: World): Promise<void> {
|
|
29
|
+
// A few cycles settle an initial sync (uploads, then a no-change confirmation).
|
|
30
|
+
for (let i = 0; i < 3; i++) {
|
|
31
|
+
await runCycle(world)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe("full runCycle", () => {
|
|
36
|
+
it("initial sync (upload everything)", async () => {
|
|
37
|
+
for (const nodes of [2_000, 10_000]) {
|
|
38
|
+
resetIdentity()
|
|
39
|
+
|
|
40
|
+
const scene = genWideScene(Math.max(1, Math.round(nodes / 101)), 100)
|
|
41
|
+
|
|
42
|
+
await bench({
|
|
43
|
+
group: "runCycle / initial sync (twoWay)",
|
|
44
|
+
name: `${scene.length} nodes`,
|
|
45
|
+
n: scene.length,
|
|
46
|
+
iterations: 3,
|
|
47
|
+
// Fresh world each iteration: initial sync mutates cloud + state irreversibly.
|
|
48
|
+
setup: () => makeScaleWorld({ mode: "twoWay", initialLocal: sceneToVfsSpec(scene) }),
|
|
49
|
+
run: async world => {
|
|
50
|
+
await runCycle(world)
|
|
51
|
+
|
|
52
|
+
return world.sync.remoteFileSystem.getDirectoryTreeCache.size
|
|
53
|
+
}
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it("no-change cycle (steady state — engine fixed overhead, network excluded)", async () => {
|
|
59
|
+
resetIdentity()
|
|
60
|
+
|
|
61
|
+
const scene = genWideScene(50, 100)
|
|
62
|
+
const world = await makeScaleWorld({ mode: "twoWay", initialLocal: sceneToVfsSpec(scene) })
|
|
63
|
+
|
|
64
|
+
await syncToConvergence(world)
|
|
65
|
+
|
|
66
|
+
await bench({
|
|
67
|
+
group: "runCycle / no-change steady state",
|
|
68
|
+
name: `${scene.length} nodes synced`,
|
|
69
|
+
n: scene.length,
|
|
70
|
+
iterations: 10,
|
|
71
|
+
setup: () => world,
|
|
72
|
+
run: w => w.sync.runCycle()
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it("incremental: ONE file changed in a large synced tree (the real per-edit cost)", async () => {
|
|
77
|
+
// Baseline capped (initial sync uses the O(N²) transfers semaphore); bumped after the fix.
|
|
78
|
+
for (const nodes of [10_000, 20_000]) {
|
|
79
|
+
resetIdentity()
|
|
80
|
+
|
|
81
|
+
const scene = genWideScene(Math.max(1, Math.round(nodes / 101)), 100)
|
|
82
|
+
const world = await makeScaleWorld({ mode: "twoWay", initialLocal: sceneToVfsSpec(scene) })
|
|
83
|
+
|
|
84
|
+
await syncToConvergence(world)
|
|
85
|
+
|
|
86
|
+
const targetPath = `${LOCAL_ROOT}/dir-0/file-0.txt`
|
|
87
|
+
let counter = 0
|
|
88
|
+
|
|
89
|
+
await bench({
|
|
90
|
+
group: "runCycle / incremental 1-file change (twoWay)",
|
|
91
|
+
name: `1 of ${scene.length} nodes`,
|
|
92
|
+
n: scene.length,
|
|
93
|
+
iterations: 5,
|
|
94
|
+
setup: async () => {
|
|
95
|
+
// A growing payload guarantees a size change → detected as a real modify each iteration.
|
|
96
|
+
counter++
|
|
97
|
+
|
|
98
|
+
await world.vfs.fs.writeFile(targetPath, "y".repeat(64 + counter), { encoding: "utf-8" })
|
|
99
|
+
|
|
100
|
+
forceLocalRescan(world)
|
|
101
|
+
|
|
102
|
+
return world
|
|
103
|
+
},
|
|
104
|
+
run: w => runCycle(w)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
// Sanity: still converged (the one change propagated, nothing else churned).
|
|
108
|
+
expect(world.sync.remoteFileSystem.getDirectoryTreeCache.size).toBe(scene.length)
|
|
109
|
+
}
|
|
110
|
+
})
|
|
111
|
+
})
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { bench } from "./harness/measure"
|
|
3
|
+
import { makeDeltas } from "./harness/fake-sync"
|
|
4
|
+
import {
|
|
5
|
+
genWideScene,
|
|
6
|
+
buildLocalTree,
|
|
7
|
+
buildRemoteTree,
|
|
8
|
+
addFiles,
|
|
9
|
+
deleteFiles,
|
|
10
|
+
modifyFiles,
|
|
11
|
+
renameTopDir,
|
|
12
|
+
resetIdentity,
|
|
13
|
+
type Scene
|
|
14
|
+
} from "./harness/trees"
|
|
15
|
+
import { type SyncMode } from "../../src/types"
|
|
16
|
+
import { type LocalTree } from "../../src/lib/filesystems/local"
|
|
17
|
+
import { type RemoteTree } from "../../src/lib/filesystems/remote"
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Delta-engine benchmarks — the CPU hot path. `deltas.process` runs every cycle that has changes and is
|
|
21
|
+
* O(N) over the tree with ~20 passes. We measure it as pure in-memory CPU (synthetic trees, fake Sync),
|
|
22
|
+
* across the change scenarios the real suite exercises (add / delete / modify / rename / mixed / no-op)
|
|
23
|
+
* and across all 5 sync modes. Trees are prebuilt once per scenario (process() never mutates its inputs)
|
|
24
|
+
* so only the algorithm is timed.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const FILES_PER_DIR = 100
|
|
28
|
+
|
|
29
|
+
function sceneOf(nodes: number): Scene {
|
|
30
|
+
resetIdentity()
|
|
31
|
+
|
|
32
|
+
return genWideScene(Math.max(1, Math.round(nodes / (FILES_PER_DIR + 1))), FILES_PER_DIR)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type Trees = {
|
|
36
|
+
prevLocal: LocalTree
|
|
37
|
+
prevRemote: RemoteTree
|
|
38
|
+
curLocal: LocalTree
|
|
39
|
+
curRemote: RemoteTree
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function runProcess(mode: SyncMode, t: Trees): Promise<number> {
|
|
43
|
+
const deltas = makeDeltas(mode)
|
|
44
|
+
const result = await deltas.process({
|
|
45
|
+
currentLocalTree: t.curLocal,
|
|
46
|
+
currentRemoteTree: t.curRemote,
|
|
47
|
+
previousLocalTree: t.prevLocal,
|
|
48
|
+
previousRemoteTree: t.prevRemote,
|
|
49
|
+
currentLocalTreeErrors: [],
|
|
50
|
+
currentLocalTreeIgnored: []
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
return result.deltas.length
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe("deltas.process — CPU hot path", () => {
|
|
57
|
+
it("size sweep: bulk local add (twoWay) — the common incremental case", async () => {
|
|
58
|
+
for (const nodes of [10_000, 50_000, 200_000, 500_000]) {
|
|
59
|
+
const base = sceneOf(nodes)
|
|
60
|
+
const prevLocal = buildLocalTree(base)
|
|
61
|
+
const prevRemote = buildRemoteTree(base)
|
|
62
|
+
const addCount = Math.max(1, Math.round(nodes * 0.01))
|
|
63
|
+
const curLocal = buildLocalTree(addFiles(base, addCount))
|
|
64
|
+
const t: Trees = { prevLocal, prevRemote, curLocal, curRemote: prevRemote }
|
|
65
|
+
|
|
66
|
+
// Correctness sanity: exactly `addCount` uploadFile deltas (plus nothing else).
|
|
67
|
+
const count = await runProcess("twoWay", t)
|
|
68
|
+
|
|
69
|
+
expect(count).toBe(addCount)
|
|
70
|
+
|
|
71
|
+
await bench({
|
|
72
|
+
group: "deltas.process / size sweep (twoWay add 1%)",
|
|
73
|
+
name: `add ${addCount} into ${nodes} nodes`,
|
|
74
|
+
n: nodes,
|
|
75
|
+
iterations: nodes >= 200_000 ? 3 : 6,
|
|
76
|
+
setup: () => t,
|
|
77
|
+
run: tr => runProcess("twoWay", tr),
|
|
78
|
+
extra: () => ({ deltas: addCount })
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it("scenario matrix at 200k nodes (twoWay)", async () => {
|
|
84
|
+
const nodes = 200_000
|
|
85
|
+
const base = sceneOf(nodes)
|
|
86
|
+
const prevLocal = buildLocalTree(base)
|
|
87
|
+
const prevRemote = buildRemoteTree(base)
|
|
88
|
+
const k = 2_000
|
|
89
|
+
|
|
90
|
+
const scenarios: { name: string; build: () => Trees }[] = [
|
|
91
|
+
{
|
|
92
|
+
name: "no-op (huge tree, 0 deltas)",
|
|
93
|
+
build: () => ({ prevLocal, prevRemote, curLocal: prevLocal, curRemote: prevRemote })
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
name: "local add 1%",
|
|
97
|
+
build: () => ({ prevLocal, prevRemote, curLocal: buildLocalTree(addFiles(base, k)), curRemote: prevRemote })
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
name: "local delete 1%",
|
|
101
|
+
build: () => ({ prevLocal, prevRemote, curLocal: buildLocalTree(deleteFiles(base, k)), curRemote: prevRemote })
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
name: "local modify 1%",
|
|
105
|
+
build: () => ({ prevLocal, prevRemote, curLocal: buildLocalTree(modifyFiles(base, k, "local")), curRemote: prevRemote })
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
name: "remote modify 1%",
|
|
109
|
+
build: () => ({ prevLocal, prevRemote, curLocal: prevLocal, curRemote: buildRemoteTree(modifyFiles(base, k, "remote")) })
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
name: "rename top dir (subtree collapse)",
|
|
113
|
+
build: () => ({ prevLocal, prevRemote, curLocal: buildLocalTree(renameTopDir(base, "/dir-0", "/dir-renamed")), curRemote: prevRemote })
|
|
114
|
+
}
|
|
115
|
+
]
|
|
116
|
+
|
|
117
|
+
for (const scenario of scenarios) {
|
|
118
|
+
const t = scenario.build()
|
|
119
|
+
|
|
120
|
+
await bench({
|
|
121
|
+
group: "deltas.process / scenario matrix @200k (twoWay)",
|
|
122
|
+
name: scenario.name,
|
|
123
|
+
n: nodes,
|
|
124
|
+
iterations: 4,
|
|
125
|
+
setup: () => t,
|
|
126
|
+
run: tr => runProcess("twoWay", tr)
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it("all 5 modes: local add 1% @100k", async () => {
|
|
132
|
+
const nodes = 100_000
|
|
133
|
+
const base = sceneOf(nodes)
|
|
134
|
+
const prevLocal = buildLocalTree(base)
|
|
135
|
+
const prevRemote = buildRemoteTree(base)
|
|
136
|
+
const curLocal = buildLocalTree(addFiles(base, 1_000))
|
|
137
|
+
const t: Trees = { prevLocal, prevRemote, curLocal, curRemote: prevRemote }
|
|
138
|
+
const modes: SyncMode[] = ["twoWay", "localToCloud", "localBackup", "cloudToLocal", "cloudBackup"]
|
|
139
|
+
|
|
140
|
+
for (const mode of modes) {
|
|
141
|
+
await bench({
|
|
142
|
+
group: "deltas.process / all modes (local add 1% @100k)",
|
|
143
|
+
name: mode,
|
|
144
|
+
n: nodes,
|
|
145
|
+
iterations: 5,
|
|
146
|
+
setup: () => t,
|
|
147
|
+
run: tr => runProcess(mode, tr)
|
|
148
|
+
})
|
|
149
|
+
}
|
|
150
|
+
})
|
|
151
|
+
})
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type Sync from "../../../src/lib/sync"
|
|
2
|
+
import Deltas from "../../../src/lib/deltas"
|
|
3
|
+
import { type SyncMode } from "../../../src/types"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The minimal slice of `Sync` that `Deltas.process` actually reads: mode, removed, the cached upload
|
|
7
|
+
* hashes, a `createFileHash` (only hit on a same-size-mtime-touch modify), the ignorer, and the
|
|
8
|
+
* dot-file flag. Building this directly avoids constructing a whole memfs world + fake cloud + timers
|
|
9
|
+
* for what is a pure in-memory CPU benchmark, so tree construction (the thing we are NOT measuring) is
|
|
10
|
+
* the only setup cost.
|
|
11
|
+
*/
|
|
12
|
+
export function makeDeltaSync(
|
|
13
|
+
mode: SyncMode,
|
|
14
|
+
overrides?: { localFileHashes?: Record<string, string>; createFileHash?: () => Promise<string>; ignores?: (path: string) => boolean }
|
|
15
|
+
): Sync {
|
|
16
|
+
return {
|
|
17
|
+
mode,
|
|
18
|
+
removed: false,
|
|
19
|
+
excludeDotFiles: false,
|
|
20
|
+
localFileHashes: overrides?.localFileHashes ?? {},
|
|
21
|
+
localFileSystem: {
|
|
22
|
+
createFileHash: overrides?.createFileHash ?? (async (): Promise<string> => "benchhash")
|
|
23
|
+
},
|
|
24
|
+
ignorer: {
|
|
25
|
+
ignores: overrides?.ignores ?? ((): boolean => false)
|
|
26
|
+
}
|
|
27
|
+
} as unknown as Sync
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function makeDeltas(mode: SyncMode, overrides?: Parameters<typeof makeDeltaSync>[1]): Deltas {
|
|
31
|
+
return new Deltas(makeDeltaSync(mode, overrides))
|
|
32
|
+
}
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import fs from "fs"
|
|
2
|
+
import os from "os"
|
|
3
|
+
import pathModule from "path"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A single benchmark measurement. Captures the three axes the goal cares about — wall time, CPU time,
|
|
7
|
+
* and memory — plus the input scale so results are comparable across sizes.
|
|
8
|
+
*/
|
|
9
|
+
export type BenchResult = {
|
|
10
|
+
group: string
|
|
11
|
+
name: string
|
|
12
|
+
/** Input scale (e.g. node count) so ms/op-per-node can be derived. */
|
|
13
|
+
n: number
|
|
14
|
+
iterations: number
|
|
15
|
+
/** Wall-clock ms per iteration (mean / min). */
|
|
16
|
+
msMean: number
|
|
17
|
+
msMin: number
|
|
18
|
+
/** CPU ms per iteration (user+system), via process.cpuUsage. */
|
|
19
|
+
cpuMsMean: number
|
|
20
|
+
/** Heap still alive AFTER the run but BEFORE gc (retained result + transient not-yet-collected). MB. */
|
|
21
|
+
heapLiveMB: number
|
|
22
|
+
/** Heap retained AFTER gc (the result the run keeps referenced). MB. */
|
|
23
|
+
heapRetainedMB: number
|
|
24
|
+
/** Peak RSS growth observed by sampling during the timed loop, relative to pre-run RSS. MB. */
|
|
25
|
+
peakRssMB: number
|
|
26
|
+
/** ns per node (msMean / n * 1e6) — the headline efficiency number. */
|
|
27
|
+
nsPerNode: number
|
|
28
|
+
extra?: Record<string, number | string>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const results: BenchResult[] = []
|
|
32
|
+
|
|
33
|
+
function mean(xs: number[]): number {
|
|
34
|
+
return xs.length === 0 ? 0 : xs.reduce((a, b) => a + b, 0) / xs.length
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function min(xs: number[]): number {
|
|
38
|
+
return xs.length === 0 ? 0 : Math.min(...xs)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function gc(): void {
|
|
42
|
+
globalThis.gc?.()
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const MB = 1024 * 1024
|
|
46
|
+
|
|
47
|
+
function fmt(n: number, digits = 2): string {
|
|
48
|
+
if (!Number.isFinite(n)) {
|
|
49
|
+
return "—"
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return n.toLocaleString("en-US", { minimumFractionDigits: digits, maximumFractionDigits: digits })
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Run one benchmark. `setup` builds a fresh input per iteration (NOT timed — so tree construction never
|
|
57
|
+
* pollutes the measurement); `run` is the timed body. Time + CPU are averaged over `iterations`; peak RSS
|
|
58
|
+
* is sampled across the whole timed loop; retained/live heap is measured on a single dedicated run so the
|
|
59
|
+
* accumulated allocations of the time loop don't inflate it.
|
|
60
|
+
*/
|
|
61
|
+
export async function bench<T>(options: {
|
|
62
|
+
group: string
|
|
63
|
+
name: string
|
|
64
|
+
n: number
|
|
65
|
+
iterations?: number
|
|
66
|
+
warmup?: number
|
|
67
|
+
setup: () => T | Promise<T>
|
|
68
|
+
run: (input: T) => unknown | Promise<unknown>
|
|
69
|
+
/** Optional extra columns derived from one input (e.g. delta count). */
|
|
70
|
+
extra?: (input: T) => Record<string, number | string>
|
|
71
|
+
}): Promise<BenchResult> {
|
|
72
|
+
const iterations = options.iterations ?? 5
|
|
73
|
+
const warmup = options.warmup ?? 1
|
|
74
|
+
|
|
75
|
+
for (let i = 0; i < warmup; i++) {
|
|
76
|
+
const input = await options.setup()
|
|
77
|
+
|
|
78
|
+
await options.run(input)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
gc()
|
|
82
|
+
|
|
83
|
+
const times: number[] = []
|
|
84
|
+
let cpuTotalMs = 0
|
|
85
|
+
let peakRss = 0
|
|
86
|
+
const rssBase = process.memoryUsage().rss
|
|
87
|
+
const sampler = setInterval(() => {
|
|
88
|
+
const rss = process.memoryUsage().rss - rssBase
|
|
89
|
+
|
|
90
|
+
if (rss > peakRss) {
|
|
91
|
+
peakRss = rss
|
|
92
|
+
}
|
|
93
|
+
}, 2)
|
|
94
|
+
|
|
95
|
+
// Keep the sampler from holding the event loop open / keeping the process alive.
|
|
96
|
+
if (typeof sampler === "object" && sampler && "unref" in sampler) {
|
|
97
|
+
;(sampler as { unref: () => void }).unref()
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
let extra: Record<string, number | string> | undefined
|
|
101
|
+
|
|
102
|
+
for (let i = 0; i < iterations; i++) {
|
|
103
|
+
const input = await options.setup()
|
|
104
|
+
|
|
105
|
+
if (i === 0 && options.extra) {
|
|
106
|
+
extra = options.extra(input)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const cpu0 = process.cpuUsage()
|
|
110
|
+
const t0 = performance.now()
|
|
111
|
+
|
|
112
|
+
await options.run(input)
|
|
113
|
+
|
|
114
|
+
const elapsed = performance.now() - t0
|
|
115
|
+
const cpu = process.cpuUsage(cpu0)
|
|
116
|
+
|
|
117
|
+
times.push(elapsed)
|
|
118
|
+
cpuTotalMs += (cpu.user + cpu.system) / 1000
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
clearInterval(sampler)
|
|
122
|
+
|
|
123
|
+
// Dedicated retained-memory run.
|
|
124
|
+
gc()
|
|
125
|
+
|
|
126
|
+
const heap0 = process.memoryUsage().heapUsed
|
|
127
|
+
const memInput = await options.setup()
|
|
128
|
+
const out = await options.run(memInput)
|
|
129
|
+
const heapLive = process.memoryUsage().heapUsed - heap0
|
|
130
|
+
|
|
131
|
+
gc()
|
|
132
|
+
|
|
133
|
+
const heapRetained = process.memoryUsage().heapUsed - heap0
|
|
134
|
+
|
|
135
|
+
// Hold a reference so the optimizer / gc can't drop the result before we read retained heap.
|
|
136
|
+
void out
|
|
137
|
+
void memInput
|
|
138
|
+
|
|
139
|
+
const msMean = mean(times)
|
|
140
|
+
const result: BenchResult = {
|
|
141
|
+
group: options.group,
|
|
142
|
+
name: options.name,
|
|
143
|
+
n: options.n,
|
|
144
|
+
iterations,
|
|
145
|
+
msMean,
|
|
146
|
+
msMin: min(times),
|
|
147
|
+
cpuMsMean: cpuTotalMs / iterations,
|
|
148
|
+
heapLiveMB: heapLive / MB,
|
|
149
|
+
heapRetainedMB: heapRetained / MB,
|
|
150
|
+
peakRssMB: peakRss / MB,
|
|
151
|
+
nsPerNode: options.n > 0 ? (msMean / options.n) * 1e6 : 0,
|
|
152
|
+
...(extra ? { extra } : {})
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
results.push(result)
|
|
156
|
+
appendResult(result)
|
|
157
|
+
|
|
158
|
+
return result
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** JSONL path that EVERY bench process appends to (cleared once at the start of a run by the runner). */
|
|
162
|
+
function jsonlPath(): string {
|
|
163
|
+
return process.env["BENCH_JSONL"] ?? pathModule.join(process.cwd(), "docs/perf/benchmarks/_results.jsonl")
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Persist + print one result immediately. Incremental append (not an exit-time flush) so partial runs
|
|
168
|
+
* still produce data and results survive across the separate worker processes vitest spawns per file —
|
|
169
|
+
* vitest workers do not reliably fire `process.on("exit")`.
|
|
170
|
+
*/
|
|
171
|
+
function appendResult(r: BenchResult): void {
|
|
172
|
+
const extraStr = r.extra
|
|
173
|
+
? " | " +
|
|
174
|
+
Object.entries(r.extra)
|
|
175
|
+
.map(([k, v]) => `${k}=${typeof v === "number" ? fmt(v, 0) : v}`)
|
|
176
|
+
.join(" ")
|
|
177
|
+
: ""
|
|
178
|
+
|
|
179
|
+
// process.stdout.write bypasses vitest's console grouping so the line is always visible in the run log.
|
|
180
|
+
process.stdout.write(
|
|
181
|
+
`[bench] ${r.group} :: ${r.name} | n=${fmt(r.n, 0)} | ${fmt(r.msMean)}ms (min ${fmt(r.msMin)}) | cpu ${fmt(
|
|
182
|
+
r.cpuMsMean
|
|
183
|
+
)}ms | ns/node ${fmt(r.nsPerNode, 1)} | heapRet ${fmt(r.heapRetainedMB)}MB live ${fmt(r.heapLiveMB)}MB | peakRSS ${fmt(
|
|
184
|
+
r.peakRssMB
|
|
185
|
+
)}MB${extraStr}\n`
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
try {
|
|
189
|
+
const path = jsonlPath()
|
|
190
|
+
|
|
191
|
+
fs.mkdirSync(pathModule.dirname(path), { recursive: true })
|
|
192
|
+
fs.appendFileSync(path, JSON.stringify(r) + "\n", "utf-8")
|
|
193
|
+
} catch {
|
|
194
|
+
// Best-effort persistence; the stdout line above is the fallback record.
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function getResults(): BenchResult[] {
|
|
199
|
+
return results
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Record a fully-computed result (for benchmarks like long-run leak detection that measure their own
|
|
204
|
+
* bespoke metrics rather than a single timed `run`). Missing numeric fields default to 0.
|
|
205
|
+
*/
|
|
206
|
+
export function recordCustom(
|
|
207
|
+
partial: { group: string; name: string; n: number } & Partial<Omit<BenchResult, "group" | "name" | "n">>
|
|
208
|
+
): void {
|
|
209
|
+
const result: BenchResult = {
|
|
210
|
+
iterations: 1,
|
|
211
|
+
msMean: 0,
|
|
212
|
+
msMin: 0,
|
|
213
|
+
cpuMsMean: 0,
|
|
214
|
+
heapLiveMB: 0,
|
|
215
|
+
heapRetainedMB: 0,
|
|
216
|
+
peakRssMB: 0,
|
|
217
|
+
nsPerNode: 0,
|
|
218
|
+
...partial
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
results.push(result)
|
|
222
|
+
appendResult(result)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Render a JSONL results file (produced incrementally by {@link bench}) into a grouped markdown report.
|
|
227
|
+
* Run as a standalone step after the vitest bench run (see docs/perf/02-benchmarks.md).
|
|
228
|
+
*/
|
|
229
|
+
export function renderReport(inJsonl: string, outMd: string, label: string): void {
|
|
230
|
+
if (!fs.existsSync(inJsonl)) {
|
|
231
|
+
throw new Error(`No results file at ${inJsonl}`)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const rows: BenchResult[] = fs
|
|
235
|
+
.readFileSync(inJsonl, "utf-8")
|
|
236
|
+
.split("\n")
|
|
237
|
+
.filter(line => line.trim().length > 0)
|
|
238
|
+
.map(line => JSON.parse(line) as BenchResult)
|
|
239
|
+
|
|
240
|
+
const lines: string[] = []
|
|
241
|
+
|
|
242
|
+
lines.push(`# Benchmark results — ${label}`)
|
|
243
|
+
lines.push("")
|
|
244
|
+
lines.push(`Generated: ${new Date().toISOString()}`)
|
|
245
|
+
lines.push(`Node: ${process.version} | platform: ${process.platform} | cpus: ${os.cpus().length}`)
|
|
246
|
+
lines.push("")
|
|
247
|
+
|
|
248
|
+
for (const group of [...new Set(rows.map(r => r.group))]) {
|
|
249
|
+
lines.push(`## ${group}`)
|
|
250
|
+
lines.push("")
|
|
251
|
+
lines.push("| scenario | n | ms mean | ms min | cpu ms | ns/node | heap ret MB | heap live MB | peak RSS MB | extra |")
|
|
252
|
+
lines.push("|---|---:|---:|---:|---:|---:|---:|---:|---:|---|")
|
|
253
|
+
|
|
254
|
+
for (const r of rows.filter(x => x.group === group)) {
|
|
255
|
+
const extraStr = r.extra
|
|
256
|
+
? Object.entries(r.extra)
|
|
257
|
+
.map(([k, v]) => `${k}=${typeof v === "number" ? fmt(v as number, 0) : v}`)
|
|
258
|
+
.join(", ")
|
|
259
|
+
: ""
|
|
260
|
+
|
|
261
|
+
lines.push(
|
|
262
|
+
`| ${r.name} | ${fmt(r.n, 0)} | ${fmt(r.msMean)} | ${fmt(r.msMin)} | ${fmt(r.cpuMsMean)} | ${fmt(
|
|
263
|
+
r.nsPerNode,
|
|
264
|
+
1
|
|
265
|
+
)} | ${fmt(r.heapRetainedMB)} | ${fmt(r.heapLiveMB)} | ${fmt(r.peakRssMB)} | ${extraStr} |`
|
|
266
|
+
)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
lines.push("")
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
fs.mkdirSync(pathModule.dirname(outMd), { recursive: true })
|
|
273
|
+
fs.writeFileSync(outMd, lines.join("\n"), "utf-8")
|
|
274
|
+
|
|
275
|
+
process.stdout.write(`\n[bench] rendered ${rows.length} results -> ${outMd}\n`)
|
|
276
|
+
}
|