@davideasden/pi-undo 0.2.4 → 0.2.6
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/README.md +62 -4
- package/package.json +1 -1
- package/src/restore-engine.ts +37 -10
- package/src/snapshot-store.ts +1 -1
package/README.md
CHANGED
|
@@ -15,7 +15,7 @@ Each completed agent run creates a checkpoint that captures both the Pi session
|
|
|
15
15
|
- **External concurrency detection** — file fingerprint and inode checks detect external modification. Conflicting changes are never silently overwritten; the system fails closed or enters `recovery required`.
|
|
16
16
|
- **No Git workflow** — snapshots use a private object database. No `git commit`, `git stash`, `git reset`, branches, or forges are required.
|
|
17
17
|
- **Nested repositories** and **initialized submodules** are handled as independent roots. Their `.git` metadata is never modified.
|
|
18
|
-
- **Performance** — batch WAL operations (up to 1,024 files per batch), scoped safety snapshots, prebuilt durable transaction packs, and
|
|
18
|
+
- **Performance** — batch WAL operations (up to 1,024 files per batch), scoped safety snapshots, prebuilt durable transaction packs, native Rust no-clobber file helper for macOS arm64, indexed WAL record and conflict lookups, and parallel manifest blob reads keep restore fast at scale. Unsupported platforms and failed integrity checks automatically use the TypeScript path.
|
|
19
19
|
|
|
20
20
|
## Requirements
|
|
21
21
|
|
|
@@ -115,12 +115,18 @@ Real-world measurements from a 104-file undo operation before optimizations:
|
|
|
115
115
|
ok files:104 total:8797ms apply:8591ms capture:89ms journal:64ms commit:27ms plan:11ms
|
|
116
116
|
```
|
|
117
117
|
|
|
118
|
-
After batch WAL, scoped safety snapshots, parallel restore I/O,
|
|
118
|
+
After batch WAL, scoped safety snapshots, parallel restore I/O, batch file deletes, and prebuilt durable transaction packs:
|
|
119
119
|
|
|
120
120
|
```text
|
|
121
121
|
ok files:104 total:~1050ms apply:~850ms capture:~90ms journal:~65ms
|
|
122
122
|
```
|
|
123
123
|
|
|
124
|
+
After native Rust helper, durable pack caching, parallel durable finalization, batch validated blob reads, and indexed mutation/path lookups:
|
|
125
|
+
|
|
126
|
+
```text
|
|
127
|
+
ok files:104 total:~850ms apply:~650ms capture:~90ms journal:~65ms plan:~10ms
|
|
128
|
+
```
|
|
129
|
+
|
|
124
130
|
Performance gains come from:
|
|
125
131
|
|
|
126
132
|
- **Batch WAL durability** — write-once, fsync-once per batch of up to 1,024 entries instead of per-file.
|
|
@@ -132,6 +138,11 @@ Performance gains come from:
|
|
|
132
138
|
- **Concurrent request preparation** — blob reads, live-state checks, and fingerprint computation run at 32-wide concurrency.
|
|
133
139
|
- **Batch file deletes** — delete operations share the same WAL batch and directory fsync merging.
|
|
134
140
|
- **Directory fsync merging** — barriers are applied once per unique directory per phase, not once per file.
|
|
141
|
+
- **Native Rust no-clobber helper** — packaged macOS arm64 binary uses platform renameat2 with EEXIST guard for regular-file batches; missing binaries or platform mismatch fall back to the TypeScript path before mutation.
|
|
142
|
+
- **Durable pack caching** — completed agent runs build and cache reverse/forward durable packs; the undo/redo hot path reuses the cached pack with manifest pin, avoiding redundant snapshot reads.
|
|
143
|
+
- **Indexed WAL record lookups** — mutation records are indexed by ordinal for O(1) direct access instead of linear scan.
|
|
144
|
+
- **Indexed path conflict resolution** — `exactExclusions` are stored as a Set for O(1) membership checks instead of O(n) `Array.includes`.
|
|
145
|
+
- **Batch validated manifest blob reads** — durable pack reads coalesce blob fetches into a single batch per manifest with combined Git validation.
|
|
135
146
|
|
|
136
147
|
## How It Works
|
|
137
148
|
|
|
@@ -259,7 +270,7 @@ test/ Unit, integration, recovery, and fault-injection te
|
|
|
259
270
|
### Testing
|
|
260
271
|
|
|
261
272
|
```bash
|
|
262
|
-
npm test # Full test suite (
|
|
273
|
+
npm test # Full test suite (425+ tests)
|
|
263
274
|
npm run test:native # Rust helper and durable-pack recovery tests
|
|
264
275
|
npm run test:watch # Watch mode
|
|
265
276
|
npm run test:integration # Pi runtime and extension integration tests
|
|
@@ -279,7 +290,54 @@ Benchmark tests assert Git call counts and WAL record counts, not wall-clock thr
|
|
|
279
290
|
| 100-file restore (delete) — batch deletes | ≤12 | 600 | ~0.6s |
|
|
280
291
|
| 4,000-file rollback snapshot — batch Git | ≤24 (4 x `mktree`, 4 x `commit-tree`) | — | ~7s |
|
|
281
292
|
|
|
282
|
-
The 104-file standalone apply probe (10 warmup iterations + 10 measured) completes in approximately 3.9s post-optimization,
|
|
293
|
+
The 104-file standalone apply probe (10 warmup iterations + 10 measured) completes in approximately 3.9s post-optimization on the TypeScript path, and approximately 2.8s on the native Rust path.
|
|
294
|
+
|
|
295
|
+
### Performance Optimizations
|
|
296
|
+
|
|
297
|
+
Several hot paths were profiled and corrected from quadratic (n doubled ≈ 4× cost) to near-linear scaling. Each change was verified against the existing test suite (425+ tests) plus fault-injection and adversarial-diff tests. Optimization order was guided by real profile data rather than static code review alone. After this phase, attention shifted to the native Rust helper path, which dominates production workloads.
|
|
298
|
+
|
|
299
|
+
#### Wall-clock comparison (n changed files + n ignored files)
|
|
300
|
+
|
|
301
|
+
**Plan phase** — undo diff derivation and workspace topology check:
|
|
302
|
+
|
|
303
|
+
| n | Before | After | Improvement |
|
|
304
|
+
|---|---|---|---|
|
|
305
|
+
| 1,000 | ~178ms | ~131ms | 1.4× |
|
|
306
|
+
| 2,000 | ~399ms | ~259ms | 1.5× |
|
|
307
|
+
| 5,000 | ~2.5s | ~699ms | ~3.6× |
|
|
308
|
+
|
|
309
|
+
**Bidirectional durable pack prepare** — agent-settled caching for undo/redo:
|
|
310
|
+
|
|
311
|
+
| n | Before (no batch) | After (batched) | Improvement |
|
|
312
|
+
|---|---|---|---|
|
|
313
|
+
| 500 | ~949ms | ~249ms | 3.8× |
|
|
314
|
+
| 1,000 | ~2,940ms | ~386ms | 7.6× |
|
|
315
|
+
| 2,000 | ~10,512ms | ~677ms | 15.5× |
|
|
316
|
+
|
|
317
|
+
**Native apply** — the production hot path:
|
|
318
|
+
|
|
319
|
+
| n | TypeScript fallback | Native Rust helper | Improvement |
|
|
320
|
+
|---|---|---|---|
|
|
321
|
+
| 500 | ~2,572ms | ~253ms | 10.2× |
|
|
322
|
+
| 1,000 | ~5,726ms | ~374ms | 15.3× |
|
|
323
|
+
| 2,000 | ~10,521ms | ~646ms | 16.3× |
|
|
324
|
+
|
|
325
|
+
#### Algorithm microbenchmarks
|
|
326
|
+
|
|
327
|
+
| Scenario | Before | After | Improvement |
|
|
328
|
+
|---|---|---|---|
|
|
329
|
+
| 8,000×8,000 path overlap check | ~1,352ms | ~7ms | **193×** |
|
|
330
|
+
| 4,000-directory ignored prefix scan (plan) | ~544ms | ~161ms | **3.4×** |
|
|
331
|
+
| 5,000×5,000 manifest integrity verify | ~610ms | ~7ms | **87×** |
|
|
332
|
+
|
|
333
|
+
#### Key enablers
|
|
334
|
+
|
|
335
|
+
- **Manifest context batching** (`SnapshotStore.readBlobs`): one manifest read and full revalidation serves all blob fetches per operation instead of one manifest load per blob. 2,000-file pack creation dropped from 10.5s to 796ms.
|
|
336
|
+
- **Blob read batch sizing** (`BLOB_BATCH_MAX_ENTRIES`: 256 → 2,048): 40 `cat-file --batch` processes collapsed to 6 for 5,000 files by letting the 16MiB byte budget drive batch boundaries.
|
|
337
|
+
- **Path overlap indexing** (`pathSetsOverlap` in `path-safety.ts`): sorted binary search plus ancestor enumeration replaces a nested `some()` pattern. 8,000×8,000 path pairs from 1.35s to 7ms.
|
|
338
|
+
- **Ignored-proof prefix index** (`IgnoredProofIndex` in `restore-engine.ts`): lazily sorted binary lower-bound search for directory prefix lookups replaces a full-set spread per candidate. 4,000 directories from 544ms to 161ms.
|
|
339
|
+
- **WAL ordinal indexing** (`loadOrdinal` in `mutation-journal.ts`): direct array access by contiguous ordinal replaces linear `.find()` throughout quarantine and legacy recovery.
|
|
340
|
+
- **Native Rust no-clobber helper** (`native/pi-undo-fs`): platform native hardlink operations with EEXIST guard. Processes in ~0.3ms per file vs ~5.3ms for the TypeScript fallback, with WAL materialized from the durable pack.
|
|
283
341
|
|
|
284
342
|
## License
|
|
285
343
|
|
package/package.json
CHANGED
package/src/restore-engine.ts
CHANGED
|
@@ -169,7 +169,7 @@ export class RestoreEngine {
|
|
|
169
169
|
this.readOwnedPaths(current, canonicalScopePaths),
|
|
170
170
|
this.readOwnedPaths(target, canonicalScopePaths),
|
|
171
171
|
]);
|
|
172
|
-
const
|
|
172
|
+
const targetIgnoredProof = new IgnoredProofIndex(ignoredWorkspacePaths(target));
|
|
173
173
|
const deleteByRoot = new Map<string, string[]>();
|
|
174
174
|
const writeByRoot = new Map<string, string[]>();
|
|
175
175
|
|
|
@@ -183,7 +183,7 @@ export class RestoreEngine {
|
|
|
183
183
|
continue;
|
|
184
184
|
}
|
|
185
185
|
if (targetOwned === undefined) {
|
|
186
|
-
if (
|
|
186
|
+
if (targetIgnoredProof.isProtected(path, owned.entry.kind)) {
|
|
187
187
|
continue;
|
|
188
188
|
}
|
|
189
189
|
appendPath(deleteByRoot, owned.root.relativeRoot, path);
|
|
@@ -1728,15 +1728,42 @@ function ignoredWorkspacePaths(manifest: SnapshotManifest): Set<string> {
|
|
|
1728
1728
|
));
|
|
1729
1729
|
}
|
|
1730
1730
|
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1731
|
+
/**
|
|
1732
|
+
* ignored 证明的前缀索引。
|
|
1733
|
+
*
|
|
1734
|
+
* 目录判定需要回答"是否存在以 `${path}/` 开头的 ignored 路径"。逐次线性扫描
|
|
1735
|
+
* 整个 set 会让"目录数 × ignored 数"变成平方项,因此这里预排序一次,
|
|
1736
|
+
* 之后每次判定用二分下界定位第一个不小于前缀的元素。
|
|
1737
|
+
*
|
|
1738
|
+
* 语义与线性扫描完全一致:只回答存在性,不改变 fail-closed 行为,也不放宽
|
|
1739
|
+
* 任何 ignored 保护。非目录仍走精确 `has()`。
|
|
1740
|
+
*/
|
|
1741
|
+
class IgnoredProofIndex {
|
|
1742
|
+
private readonly exact: ReadonlySet<string>;
|
|
1743
|
+
private sortedPaths: readonly string[] | undefined;
|
|
1744
|
+
|
|
1745
|
+
constructor(paths: ReadonlySet<string>) {
|
|
1746
|
+
this.exact = paths;
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
isProtected(path: string, kind: RestorePath["kind"]): boolean {
|
|
1750
|
+
if (kind !== "directory") {
|
|
1751
|
+
return this.exact.has(path);
|
|
1752
|
+
}
|
|
1753
|
+
if (this.exact.size === 0) return false;
|
|
1754
|
+
// 排序成本只在第一次目录判定时付出,纯文件计划完全不触发。
|
|
1755
|
+
this.sortedPaths ??= [...this.exact].sort();
|
|
1756
|
+
const sorted = this.sortedPaths;
|
|
1757
|
+
const prefix = `${path}/`;
|
|
1758
|
+
let low = 0;
|
|
1759
|
+
let high = sorted.length;
|
|
1760
|
+
while (low < high) {
|
|
1761
|
+
const middle = (low + high) >>> 1;
|
|
1762
|
+
if (sorted[middle]! < prefix) low = middle + 1;
|
|
1763
|
+
else high = middle;
|
|
1764
|
+
}
|
|
1765
|
+
return low < sorted.length && sorted[low]!.startsWith(prefix);
|
|
1738
1766
|
}
|
|
1739
|
-
return [...ignoredPaths].some((ignoredPath) => ignoredPath.startsWith(`${path}/`));
|
|
1740
1767
|
}
|
|
1741
1768
|
|
|
1742
1769
|
function rootBoundaryDirectories(root: string): string[] {
|
package/src/snapshot-store.ts
CHANGED
|
@@ -41,7 +41,7 @@ const INDEX_BATCH_MAX_ENTRIES = 4_096;
|
|
|
41
41
|
const INDEX_BATCH_MAX_BYTES = 8 * 1024 * 1024;
|
|
42
42
|
const BLOB_CACHE_MAX_BYTES = 128 * 1024 * 1024;
|
|
43
43
|
const BLOB_BATCH_MAX_BYTES = 16 * 1024 * 1024;
|
|
44
|
-
const BLOB_BATCH_MAX_ENTRIES = 256;
|
|
44
|
+
const BLOB_BATCH_MAX_ENTRIES = process.platform === "win32" ? 256 : 2_048;
|
|
45
45
|
|
|
46
46
|
interface PinRecord {
|
|
47
47
|
readonly schemaVersion: 1;
|