@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,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase profiler for ONE incremental cycle (1 file changed in a large synced tree) — tells us exactly
|
|
3
|
+
* where the per-edit cost goes before we optimize. Not a *.bench.ts, so the bench suite ignores it.
|
|
4
|
+
*
|
|
5
|
+
* NODE_OPTIONS='--expose-gc' tsx tests/bench/profile-incremental.ts [nodes]
|
|
6
|
+
*/
|
|
7
|
+
import { makeScaleWorld, sceneToVfsSpec, forceLocalRescan } from "./harness/scale-world"
|
|
8
|
+
import { genWideScene, resetIdentity } from "./harness/trees"
|
|
9
|
+
import { SYNC_INTERVAL } from "../../src/constants"
|
|
10
|
+
import { LOCAL_ROOT } from "../harness/world"
|
|
11
|
+
|
|
12
|
+
const nodes = Number(process.argv[2] ?? 20_000)
|
|
13
|
+
|
|
14
|
+
function wrap<T extends object, K extends keyof T>(obj: T, key: K, label: string, acc: Record<string, number>): void {
|
|
15
|
+
const original = obj[key] as unknown as (...args: unknown[]) => unknown
|
|
16
|
+
|
|
17
|
+
obj[key] = (async (...args: unknown[]) => {
|
|
18
|
+
const t0 = performance.now()
|
|
19
|
+
const result = await original.apply(obj, args)
|
|
20
|
+
|
|
21
|
+
acc[label] = (acc[label] ?? 0) + (performance.now() - t0)
|
|
22
|
+
|
|
23
|
+
return result
|
|
24
|
+
}) as unknown as T[K]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function main(): Promise<void> {
|
|
28
|
+
resetIdentity()
|
|
29
|
+
|
|
30
|
+
const scene = genWideScene(Math.max(1, Math.round(nodes / 101)), 100)
|
|
31
|
+
const world = await makeScaleWorld({ mode: "twoWay", initialLocal: sceneToVfsSpec(scene) })
|
|
32
|
+
|
|
33
|
+
// Settle the initial sync.
|
|
34
|
+
for (let i = 0; i < 3; i++) {
|
|
35
|
+
world.sync.localFileSystem.lastDirectoryChangeTimestamp = Date.now() - SYNC_INTERVAL - 1_000
|
|
36
|
+
|
|
37
|
+
await world.sync.runCycle()
|
|
38
|
+
|
|
39
|
+
world.messages.length = 0
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const acc: Record<string, number> = {}
|
|
43
|
+
|
|
44
|
+
wrap(world.sync.ignorer, "initialize", "ignorer.initialize", acc)
|
|
45
|
+
wrap(world.sync.localFileSystem, "getDirectoryTree", "local.getDirectoryTree", acc)
|
|
46
|
+
wrap(world.sync.remoteFileSystem, "getDirectoryTree", "remote.getDirectoryTree", acc)
|
|
47
|
+
wrap(world.sync.deltas, "process", "deltas.process", acc)
|
|
48
|
+
wrap(world.sync.tasks, "process", "tasks.process", acc)
|
|
49
|
+
wrap(world.sync.state, "save", "state.save", acc)
|
|
50
|
+
wrap(world.sync.lock, "acquire", "lock.acquire", acc)
|
|
51
|
+
wrap(world.sync.lock, "release", "lock.release", acc)
|
|
52
|
+
|
|
53
|
+
// One measured incremental cycle: change a single file, force a rescan, run.
|
|
54
|
+
await world.vfs.fs.writeFile(`${LOCAL_ROOT}/dir-0/file-0.txt`, "y".repeat(99), { encoding: "utf-8" })
|
|
55
|
+
|
|
56
|
+
forceLocalRescan(world)
|
|
57
|
+
|
|
58
|
+
world.sync.localFileSystem.lastDirectoryChangeTimestamp = Date.now() - SYNC_INTERVAL - 1_000
|
|
59
|
+
|
|
60
|
+
const t0 = performance.now()
|
|
61
|
+
|
|
62
|
+
await world.sync.runCycle()
|
|
63
|
+
|
|
64
|
+
const total = performance.now() - t0
|
|
65
|
+
|
|
66
|
+
process.stdout.write(`\nIncremental cycle profile — ${scene.length} nodes, 1 file changed:\n`)
|
|
67
|
+
|
|
68
|
+
const sorted = Object.entries(acc).sort((a, b) => b[1] - a[1])
|
|
69
|
+
let accounted = 0
|
|
70
|
+
|
|
71
|
+
for (const [label, ms] of sorted) {
|
|
72
|
+
accounted += ms
|
|
73
|
+
process.stdout.write(` ${label.padEnd(26)} ${ms.toFixed(1).padStart(8)} ms (${((ms / total) * 100).toFixed(1)}%)\n`)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
process.stdout.write(` ${"[unaccounted/other]".padEnd(26)} ${(total - accounted).toFixed(1).padStart(8)} ms\n`)
|
|
77
|
+
process.stdout.write(` ${"TOTAL".padEnd(26)} ${total.toFixed(1).padStart(8)} ms\n`)
|
|
78
|
+
|
|
79
|
+
if (world.sync.cleanupLocalTrashInterval) {
|
|
80
|
+
clearInterval(world.sync.cleanupLocalTrashInterval)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
main().then(
|
|
85
|
+
() => process.exit(0),
|
|
86
|
+
e => {
|
|
87
|
+
console.error(e)
|
|
88
|
+
process.exit(1)
|
|
89
|
+
}
|
|
90
|
+
)
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { bench } from "./harness/measure"
|
|
3
|
+
import { makeScaleWorld, sceneToDirTreeResponse, injectDirTreeResponse, forceRemoteRebuild } from "./harness/scale-world"
|
|
4
|
+
import { genWideScene, genBalancedScene, resetIdentity, type Scene } from "./harness/trees"
|
|
5
|
+
import { type World } from "../harness/world"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Remote tree-build benchmark (target T2). `remoteFileSystem.getDirectoryTree` decrypts every folder
|
|
9
|
+
* (SEQUENTIALLY, to build parent paths) and every file (eagerly mapping N promises through a 1024-wide
|
|
10
|
+
* semaphore), then builds the tree+uuids maps.
|
|
11
|
+
*
|
|
12
|
+
* We feed the engine a PRE-BUILT dir-tree response (synthesised from a scene, JSON metadata) so the
|
|
13
|
+
* timed work is ONLY the engine's build loop — NOT the fake cloud's `buildFullTree`, which is O(N²)
|
|
14
|
+
* (test infra: it scans all nodes per directory) and would dominate + mask the engine cost. The fake
|
|
15
|
+
* cloud's decrypt (JSON.parse) is what runs per item, isolating the engine's per-node CPU + the
|
|
16
|
+
* eager-promise/closure memory T2 targets (real SDK crypto is a separate, SDK-owned cost).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
async function worldFor(scene: Scene): Promise<World> {
|
|
20
|
+
// Empty remote — we inject the response directly; the world only supplies sdk.crypto + environment.
|
|
21
|
+
const world = await makeScaleWorld({ mode: "twoWay" })
|
|
22
|
+
|
|
23
|
+
injectDirTreeResponse(world, sceneToDirTreeResponse(scene, world.cloud.controls.rootUUID))
|
|
24
|
+
|
|
25
|
+
return world
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe("remoteFileSystem.getDirectoryTree (engine build loop, isolated)", () => {
|
|
29
|
+
it("wide tree size sweep", async () => {
|
|
30
|
+
// Post-fix: linear (finding 001 + 004), so the sweep goes to 500k where the baseline capped at
|
|
31
|
+
// 200k (which took ~22s under the old O(N²) build).
|
|
32
|
+
for (const nodes of [10_000, 50_000, 200_000, 500_000]) {
|
|
33
|
+
resetIdentity()
|
|
34
|
+
|
|
35
|
+
const scene = genWideScene(Math.max(1, Math.round(nodes / 101)), 100)
|
|
36
|
+
const world = await worldFor(scene)
|
|
37
|
+
|
|
38
|
+
const first = await world.sync.remoteFileSystem.getDirectoryTree(true)
|
|
39
|
+
|
|
40
|
+
expect(first.result.size).toBe(scene.length)
|
|
41
|
+
|
|
42
|
+
await bench({
|
|
43
|
+
group: "remoteFileSystem.getDirectoryTree / wide",
|
|
44
|
+
name: `${nodes} nodes`,
|
|
45
|
+
n: scene.length,
|
|
46
|
+
iterations: nodes >= 200_000 ? 3 : 5,
|
|
47
|
+
setup: () => {
|
|
48
|
+
forceRemoteRebuild(world)
|
|
49
|
+
|
|
50
|
+
return world
|
|
51
|
+
},
|
|
52
|
+
run: w => w.sync.remoteFileSystem.getDirectoryTree(true)
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it("directory-heavy tree (sequential folder-decrypt path)", async () => {
|
|
58
|
+
resetIdentity()
|
|
59
|
+
|
|
60
|
+
const scene = genWideScene(10_000, 2)
|
|
61
|
+
const world = await worldFor(scene)
|
|
62
|
+
|
|
63
|
+
const first = await world.sync.remoteFileSystem.getDirectoryTree(true)
|
|
64
|
+
|
|
65
|
+
expect(first.result.size).toBe(scene.length)
|
|
66
|
+
|
|
67
|
+
await bench({
|
|
68
|
+
group: "remoteFileSystem.getDirectoryTree / directory-heavy",
|
|
69
|
+
name: `50k dirs x2 files (${scene.length} nodes)`,
|
|
70
|
+
n: scene.length,
|
|
71
|
+
iterations: 4,
|
|
72
|
+
setup: () => {
|
|
73
|
+
forceRemoteRebuild(world)
|
|
74
|
+
|
|
75
|
+
return world
|
|
76
|
+
},
|
|
77
|
+
run: w => w.sync.remoteFileSystem.getDirectoryTree(true)
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it("balanced tree", async () => {
|
|
82
|
+
resetIdentity()
|
|
83
|
+
|
|
84
|
+
const scene = genBalancedScene({ fanout: 4, depth: 5, filesPerDir: 8 })
|
|
85
|
+
const world = await worldFor(scene)
|
|
86
|
+
|
|
87
|
+
const first = await world.sync.remoteFileSystem.getDirectoryTree(true)
|
|
88
|
+
|
|
89
|
+
expect(first.result.size).toBe(scene.length)
|
|
90
|
+
|
|
91
|
+
await bench({
|
|
92
|
+
group: "remoteFileSystem.getDirectoryTree / balanced",
|
|
93
|
+
name: `fanout4 depth5 (${scene.length} nodes)`,
|
|
94
|
+
n: scene.length,
|
|
95
|
+
iterations: 4,
|
|
96
|
+
setup: () => {
|
|
97
|
+
forceRemoteRebuild(world)
|
|
98
|
+
|
|
99
|
+
return world
|
|
100
|
+
},
|
|
101
|
+
run: w => w.sync.remoteFileSystem.getDirectoryTree(true)
|
|
102
|
+
})
|
|
103
|
+
})
|
|
104
|
+
})
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import pathModule from "path"
|
|
2
|
+
import { renderReport } from "./harness/measure"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Standalone report renderer: reads the JSONL the bench run appended and writes a grouped markdown
|
|
6
|
+
* table. Decoupled from the vitest lifecycle (vitest workers don't reliably fire exit hooks).
|
|
7
|
+
*
|
|
8
|
+
* tsx tests/bench/render.ts [label] [inJsonl] [outMd]
|
|
9
|
+
*/
|
|
10
|
+
const label = process.argv[2] ?? process.env["BENCH_LABEL"] ?? "run"
|
|
11
|
+
const inJsonl = process.argv[3] ?? process.env["BENCH_JSONL"] ?? pathModule.join(process.cwd(), "docs/perf/benchmarks/_results.jsonl")
|
|
12
|
+
const outMd = process.argv[4] ?? process.env["BENCH_OUT"] ?? pathModule.join(process.cwd(), `docs/perf/benchmarks/${label}.md`)
|
|
13
|
+
|
|
14
|
+
renderReport(inJsonl, outMd, label)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { bench } from "./harness/measure"
|
|
3
|
+
import { Semaphore } from "../../src/semaphore"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Semaphore throughput benchmark (finding 001). When N tasks contend on a semaphore of width `maxCount`,
|
|
7
|
+
* N−maxCount of them queue. The dequeue cost per release determines whether bulk fan-out (remote tree
|
|
8
|
+
* build, bulk transfers) is O(N) or O(N²). This drives a realistic fan-out: N tasks each acquire, yield a
|
|
9
|
+
* microtask, then release — exactly the shape of `dir.files.map(async () => { await sem.acquire(); … })`.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
async function drain(maxCount: number, tasks: number): Promise<void> {
|
|
13
|
+
const sem = new Semaphore(maxCount)
|
|
14
|
+
|
|
15
|
+
await Promise.all(
|
|
16
|
+
Array.from({ length: tasks }, async () => {
|
|
17
|
+
await sem.acquire()
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
// Yield once so the semaphore actually queues waiters (mirrors an awaited decrypt / IO step).
|
|
21
|
+
await Promise.resolve()
|
|
22
|
+
} finally {
|
|
23
|
+
sem.release()
|
|
24
|
+
}
|
|
25
|
+
})
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("Semaphore fan-out throughput", () => {
|
|
30
|
+
it("width 1024 (remote listSemaphore shape)", async () => {
|
|
31
|
+
// Post-fix sizes — the O(1) head-index dequeue is linear, so these complete fast where the old
|
|
32
|
+
// O(N²) shift took seconds-to-minutes. (Baseline 00-baseline.md used 5k–40k to show the quadratic.)
|
|
33
|
+
for (const tasks of [50_000, 200_000, 1_000_000]) {
|
|
34
|
+
await bench({
|
|
35
|
+
group: "Semaphore.drain / width 1024",
|
|
36
|
+
name: `${tasks} tasks`,
|
|
37
|
+
n: tasks,
|
|
38
|
+
iterations: 4,
|
|
39
|
+
setup: () => ({ maxCount: 1024, tasks }),
|
|
40
|
+
run: ({ maxCount, tasks: t }) => drain(maxCount, t)
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it("width 10 (transfers semaphore shape — bulk upload/download)", async () => {
|
|
46
|
+
for (const tasks of [50_000, 200_000, 1_000_000]) {
|
|
47
|
+
await bench({
|
|
48
|
+
group: "Semaphore.drain / width 10",
|
|
49
|
+
name: `${tasks} tasks`,
|
|
50
|
+
n: tasks,
|
|
51
|
+
iterations: 4,
|
|
52
|
+
setup: () => ({ maxCount: 10, tasks }),
|
|
53
|
+
run: ({ maxCount, tasks: t }) => drain(maxCount, t)
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it("FIFO order preserved", async () => {
|
|
59
|
+
// Correctness guard the optimization must keep: waiters resume in acquire order.
|
|
60
|
+
const sem = new Semaphore(1)
|
|
61
|
+
const order: number[] = []
|
|
62
|
+
|
|
63
|
+
await sem.acquire() // hold the only slot
|
|
64
|
+
|
|
65
|
+
const waiters = [0, 1, 2, 3, 4].map(async i => {
|
|
66
|
+
await sem.acquire()
|
|
67
|
+
order.push(i)
|
|
68
|
+
sem.release()
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
// Let them all queue, then release to drain in FIFO order.
|
|
72
|
+
await Promise.resolve()
|
|
73
|
+
sem.release()
|
|
74
|
+
|
|
75
|
+
await Promise.all(waiters)
|
|
76
|
+
|
|
77
|
+
expect(order).toEqual([0, 1, 2, 3, 4])
|
|
78
|
+
})
|
|
79
|
+
})
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { bench } from "./harness/measure"
|
|
3
|
+
import State from "../../src/lib/state"
|
|
4
|
+
import type Sync from "../../src/lib/sync"
|
|
5
|
+
import { createVirtualFS } from "../fakes/virtual-fs"
|
|
6
|
+
import { genWideScene, buildLocalTree, buildRemoteTree, resetIdentity } from "./harness/trees"
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* State persistence benchmark (target T4). Every change-cycle persists 4 line-delimited-JSON files
|
|
10
|
+
* (previousLocalTree, previousLocalINodes, previousRemoteTree, previousRemoteUUIDs) and reloads them on
|
|
11
|
+
* restart. The tree and the inodes/uuids files hold the SAME item objects keyed differently, so each
|
|
12
|
+
* item is serialized TWICE — this measures the real cost and the duplication T4 targets. memfs backs the
|
|
13
|
+
* writes so we measure serialization + stream + parse CPU, not real disk latency.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
function makeState(scene: ReturnType<typeof genWideScene>): { state: State; sync: Sync } {
|
|
17
|
+
const vfs = createVirtualFS({ "/db": null })
|
|
18
|
+
const localTree = buildLocalTree(scene)
|
|
19
|
+
const remoteTree = buildRemoteTree(scene)
|
|
20
|
+
|
|
21
|
+
const sync = {
|
|
22
|
+
dbPath: "/db",
|
|
23
|
+
syncPair: { uuid: "bench-uuid" },
|
|
24
|
+
environment: { fs: vfs.fs, globFs: vfs.globFs },
|
|
25
|
+
localFileHashes: {},
|
|
26
|
+
previousLocalTree: { tree: localTree.tree, inodes: localTree.inodes, size: localTree.size },
|
|
27
|
+
previousRemoteTree: { tree: remoteTree.tree, uuids: remoteTree.uuids, size: remoteTree.size },
|
|
28
|
+
isPreviousSavedTreeStateEmpty: true
|
|
29
|
+
} as unknown as Sync
|
|
30
|
+
|
|
31
|
+
return { state: new State(sync), sync }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function sceneOf(nodes: number): ReturnType<typeof genWideScene> {
|
|
35
|
+
resetIdentity()
|
|
36
|
+
|
|
37
|
+
return genWideScene(Math.max(1, Math.round(nodes / 101)), 100)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe("state persistence", () => {
|
|
41
|
+
it("savePreviousTrees (4 files, tree+inodes duplicated)", async () => {
|
|
42
|
+
for (const nodes of [10_000, 100_000, 300_000]) {
|
|
43
|
+
await bench({
|
|
44
|
+
group: "state.savePreviousTrees",
|
|
45
|
+
name: `${nodes} nodes`,
|
|
46
|
+
n: nodes,
|
|
47
|
+
iterations: 4,
|
|
48
|
+
setup: () => makeState(sceneOf(nodes)),
|
|
49
|
+
run: ({ state }) => state.savePreviousTrees()
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it("loadPreviousTrees (round-trip read of the 4 files)", async () => {
|
|
55
|
+
for (const nodes of [10_000, 100_000, 300_000]) {
|
|
56
|
+
const scene = sceneOf(nodes)
|
|
57
|
+
const expectedSize = scene.length
|
|
58
|
+
|
|
59
|
+
await bench({
|
|
60
|
+
group: "state.loadPreviousTrees",
|
|
61
|
+
name: `${nodes} nodes`,
|
|
62
|
+
n: nodes,
|
|
63
|
+
iterations: 4,
|
|
64
|
+
setup: async () => {
|
|
65
|
+
const made = makeState(scene)
|
|
66
|
+
|
|
67
|
+
await made.state.savePreviousTrees()
|
|
68
|
+
|
|
69
|
+
// Reset the in-memory trees so the load actually re-reads + rebuilds from disk.
|
|
70
|
+
made.sync.previousLocalTree = { tree: {}, inodes: {}, size: 0 }
|
|
71
|
+
made.sync.previousRemoteTree = { tree: {}, uuids: {}, size: 0 }
|
|
72
|
+
|
|
73
|
+
return made
|
|
74
|
+
},
|
|
75
|
+
run: async ({ state, sync }) => {
|
|
76
|
+
await state.loadPreviousTrees()
|
|
77
|
+
|
|
78
|
+
expect(sync.previousLocalTree.size).toBe(expectedSize)
|
|
79
|
+
|
|
80
|
+
return sync.previousLocalTree.size
|
|
81
|
+
}
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
})
|
|
85
|
+
})
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { bench } from "./harness/measure"
|
|
3
|
+
import { type Delta } from "../../src/lib/deltas"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Task-dispatch benchmark (target T1). `tasks.process` dispatches deltas in 12 type passes by scanning
|
|
7
|
+
* the WHOLE deltasSorted array 10 times (`for…if(type!==X)continue`) plus 2 `.filter()` passes for the
|
|
8
|
+
* up/down transfers — 12·O(D) just to order the work. This isolates that dispatch overhead (with a
|
|
9
|
+
* no-op task body) and compares it to a single bucketing pass, so we know whether T1 is worth doing
|
|
10
|
+
* before touching the real executor.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const DISPATCH_ORDER = [
|
|
14
|
+
"renameLocalDirectory",
|
|
15
|
+
"renameLocalFile",
|
|
16
|
+
"renameRemoteDirectory",
|
|
17
|
+
"renameRemoteFile",
|
|
18
|
+
"deleteLocalDirectory",
|
|
19
|
+
"deleteLocalFile",
|
|
20
|
+
"deleteRemoteDirectory",
|
|
21
|
+
"deleteRemoteFile",
|
|
22
|
+
"createLocalDirectory",
|
|
23
|
+
"createRemoteDirectory"
|
|
24
|
+
] as const
|
|
25
|
+
|
|
26
|
+
const noopProcess = async (): Promise<void> => {}
|
|
27
|
+
|
|
28
|
+
/** Current strategy: 10 full filter-scans (sequential) + 2 filter passes for transfers. */
|
|
29
|
+
async function dispatchCurrent(deltas: Delta[]): Promise<void> {
|
|
30
|
+
for (const type of DISPATCH_ORDER) {
|
|
31
|
+
for (const delta of deltas) {
|
|
32
|
+
if (delta.type !== type) {
|
|
33
|
+
continue
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
await noopProcess()
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
await Promise.all(deltas.filter(d => d.type === "uploadFile").map(noopProcess))
|
|
41
|
+
await Promise.all(deltas.filter(d => d.type === "downloadFile").map(noopProcess))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Bucketed strategy: one pass to partition, then drain each bucket in the same order. */
|
|
45
|
+
async function dispatchBucketed(deltas: Delta[]): Promise<void> {
|
|
46
|
+
const buckets = new Map<string, Delta[]>()
|
|
47
|
+
|
|
48
|
+
for (const delta of deltas) {
|
|
49
|
+
let bucket = buckets.get(delta.type)
|
|
50
|
+
|
|
51
|
+
if (!bucket) {
|
|
52
|
+
bucket = []
|
|
53
|
+
|
|
54
|
+
buckets.set(delta.type, bucket)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
bucket.push(delta)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
for (const type of DISPATCH_ORDER) {
|
|
61
|
+
const bucket = buckets.get(type)
|
|
62
|
+
|
|
63
|
+
if (bucket) {
|
|
64
|
+
for (let i = 0; i < bucket.length; i++) {
|
|
65
|
+
await noopProcess()
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
await Promise.all((buckets.get("uploadFile") ?? []).map(noopProcess))
|
|
71
|
+
await Promise.all((buckets.get("downloadFile") ?? []).map(noopProcess))
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function genUploads(d: number): Delta[] {
|
|
75
|
+
const deltas: Delta[] = []
|
|
76
|
+
|
|
77
|
+
for (let i = 0; i < d; i++) {
|
|
78
|
+
deltas.push({ type: "uploadFile", path: `/dir-${i % 1000}/file-${i}.txt`, size: 64 })
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return deltas
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function genMixed(d: number): Delta[] {
|
|
85
|
+
const deltas: Delta[] = []
|
|
86
|
+
|
|
87
|
+
for (let i = 0; i < d; i++) {
|
|
88
|
+
const r = i % 5
|
|
89
|
+
|
|
90
|
+
if (r === 0) {
|
|
91
|
+
deltas.push({ type: "uploadFile", path: `/u-${i}.txt`, size: 64 })
|
|
92
|
+
} else if (r === 1) {
|
|
93
|
+
deltas.push({ type: "downloadFile", path: `/d-${i}.txt`, size: 64 })
|
|
94
|
+
} else if (r === 2) {
|
|
95
|
+
deltas.push({ type: "deleteRemoteFile", path: `/x-${i}.txt` })
|
|
96
|
+
} else if (r === 3) {
|
|
97
|
+
deltas.push({ type: "createRemoteDirectory", path: `/c-${i}` })
|
|
98
|
+
} else {
|
|
99
|
+
deltas.push({ type: "renameRemoteFile", path: `/r-${i}.txt`, from: `/r-${i}.txt`, to: `/r2-${i}.txt` })
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return deltas
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
describe("tasks dispatch — 12-pass vs bucketed (T1)", () => {
|
|
107
|
+
it("all-uploads (bulk add — worst case for the 10 no-op rename/delete passes)", async () => {
|
|
108
|
+
for (const d of [10_000, 100_000, 1_000_000]) {
|
|
109
|
+
const deltas = genUploads(d)
|
|
110
|
+
|
|
111
|
+
await bench({
|
|
112
|
+
group: "tasks dispatch / all uploads",
|
|
113
|
+
name: `current 12-pass — D=${d}`,
|
|
114
|
+
n: d,
|
|
115
|
+
iterations: 5,
|
|
116
|
+
setup: () => deltas,
|
|
117
|
+
run: ds => dispatchCurrent(ds)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
await bench({
|
|
121
|
+
group: "tasks dispatch / all uploads",
|
|
122
|
+
name: `bucketed — D=${d}`,
|
|
123
|
+
n: d,
|
|
124
|
+
iterations: 5,
|
|
125
|
+
setup: () => deltas,
|
|
126
|
+
run: ds => dispatchBucketed(ds)
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it("mixed delta types", async () => {
|
|
132
|
+
for (const d of [100_000, 1_000_000]) {
|
|
133
|
+
const deltas = genMixed(d)
|
|
134
|
+
|
|
135
|
+
expect(deltas.length).toBe(d)
|
|
136
|
+
|
|
137
|
+
await bench({
|
|
138
|
+
group: "tasks dispatch / mixed types",
|
|
139
|
+
name: `current 12-pass — D=${d}`,
|
|
140
|
+
n: d,
|
|
141
|
+
iterations: 5,
|
|
142
|
+
setup: () => deltas,
|
|
143
|
+
run: ds => dispatchCurrent(ds)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
await bench({
|
|
147
|
+
group: "tasks dispatch / mixed types",
|
|
148
|
+
name: `bucketed — D=${d}`,
|
|
149
|
+
n: d,
|
|
150
|
+
iterations: 5,
|
|
151
|
+
setup: () => deltas,
|
|
152
|
+
run: ds => dispatchBucketed(ds)
|
|
153
|
+
})
|
|
154
|
+
}
|
|
155
|
+
})
|
|
156
|
+
})
|