@anionex/dsh-turn-rewind 0.1.0 → 0.1.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/docs/LARGE_REPOSITORY_CHECKPOINT_DESIGN.md +340 -0
- package/lib/client.js +3 -1
- package/lib/client.js.map +1 -1
- package/lib/deadline.js +13 -0
- package/lib/engine.js +482 -118
- package/lib/git-checkpoint.js +739 -0
- package/lib/git.js +303 -23
- package/lib/index.js +1 -1
- package/lib/path-utils.js +9 -0
- package/lib/rewind-host.js +119 -29
- package/lib/snapshot.js +113 -13
- package/lib/store.js +1281 -106
- package/lib/types/deadline.d.ts +6 -0
- package/lib/types/engine.d.ts +27 -0
- package/lib/types/git-checkpoint.d.ts +46 -0
- package/lib/types/git.d.ts +31 -0
- package/lib/types/path-utils.d.ts +2 -0
- package/lib/types/rewind-host.d.ts +3 -1
- package/lib/types/snapshot.d.ts +6 -1
- package/lib/types/store.d.ts +70 -6
- package/lib/types/types.d.ts +44 -1
- package/lib/types.js +2 -0
- package/lib/validate.js +68 -9
- package/package.json +3 -1
- package/scripts/benchmark-git-checkpoint.mjs +149 -0
- package/scripts/wrap-client.mjs +18 -0
- package/src/client/index.tsx +3 -0
- package/src/deadline.ts +18 -0
- package/src/engine.ts +542 -124
- package/src/git-checkpoint.ts +853 -0
- package/src/git.ts +345 -23
- package/src/index.ts +1 -1
- package/src/path-utils.ts +10 -0
- package/src/rewind-host.ts +134 -30
- package/src/snapshot.ts +122 -12
- package/src/store.ts +1403 -91
- package/src/types.ts +48 -1
- package/src/validate.ts +67 -7
package/lib/snapshot.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
-
import {
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { lstat, open, readlink } from 'node:fs/promises';
|
|
3
4
|
import { ChangeLedgerError } from './errors.js';
|
|
4
5
|
import { discoverRepository, sameRepositoryFence } from './git.js';
|
|
5
6
|
import { isNodeError, resolveWorkspacePath } from './path-utils.js';
|
|
7
|
+
const COMPARISON_READ_BUDGET_BYTES = 128 * 1024 * 1024;
|
|
6
8
|
/** Capture the current tracked and non-ignored Git working tree. */
|
|
7
9
|
export async function captureTree(options) {
|
|
8
10
|
throwIfAborted(options.signal);
|
|
@@ -11,12 +13,19 @@ export async function captureTree(options) {
|
|
|
11
13
|
throw new ChangeLedgerError('TOO_MANY_FILES', `workspace has ${source.paths.length} eligible paths; configured maximum is ${options.config.maxFiles}`);
|
|
12
14
|
}
|
|
13
15
|
const entries = Object.create(null);
|
|
16
|
+
const gitObjectFormat = options.gitObjectFormat;
|
|
17
|
+
const gitCapture = gitObjectFormat === undefined
|
|
18
|
+
? undefined
|
|
19
|
+
: {
|
|
20
|
+
objectFormat: gitObjectFormat,
|
|
21
|
+
entries: Object.create(null),
|
|
22
|
+
};
|
|
14
23
|
let totalBytes = 0;
|
|
15
|
-
|
|
24
|
+
const capturePath = async (path) => {
|
|
16
25
|
throwIfAborted(options.signal);
|
|
17
26
|
const entry = await captureEntry(source.state.root, path, options.config.maxFileBytes, options.signal);
|
|
18
27
|
if (entry === undefined)
|
|
19
|
-
|
|
28
|
+
return;
|
|
20
29
|
if (entry.kind === 'file') {
|
|
21
30
|
totalBytes += entry.content.length;
|
|
22
31
|
if (totalBytes > options.config.maxSnapshotBytes) {
|
|
@@ -26,13 +35,50 @@ export async function captureTree(options) {
|
|
|
26
35
|
await options.store.putBlob(source.state.root, entry.snapshot.blob, entry.content);
|
|
27
36
|
}
|
|
28
37
|
entries[path] = entry.snapshot;
|
|
29
|
-
|
|
38
|
+
if (gitCapture !== undefined) {
|
|
39
|
+
gitCapture.entries[path] = gitFileSnapshot(entry.snapshot, entry.content, gitCapture.objectFormat);
|
|
40
|
+
}
|
|
41
|
+
return;
|
|
30
42
|
}
|
|
31
43
|
entries[path] = entry.snapshot;
|
|
44
|
+
if (gitCapture !== undefined)
|
|
45
|
+
gitCapture.entries[path] = entry.snapshot;
|
|
46
|
+
};
|
|
47
|
+
if (gitCapture === undefined) {
|
|
48
|
+
for (const path of source.paths)
|
|
49
|
+
await capturePath(path);
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
let next = 0;
|
|
53
|
+
let failed = false;
|
|
54
|
+
let firstError = new ChangeLedgerError('WORKSPACE_CHANGED_DURING_CAPTURE', 'parallel comparison capture failed');
|
|
55
|
+
const concurrency = Math.min(32, source.paths.length, Math.max(1, Math.floor(COMPARISON_READ_BUDGET_BYTES / options.config.maxFileBytes)));
|
|
56
|
+
const workers = Array.from({ length: concurrency }, async () => {
|
|
57
|
+
while (!failed && next < source.paths.length) {
|
|
58
|
+
const index = next;
|
|
59
|
+
next += 1;
|
|
60
|
+
const path = source.paths[index];
|
|
61
|
+
if (path === undefined)
|
|
62
|
+
continue;
|
|
63
|
+
try {
|
|
64
|
+
await capturePath(path);
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
if (!failed) {
|
|
68
|
+
failed = true;
|
|
69
|
+
firstError = error;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
await Promise.all(workers);
|
|
75
|
+
if (failed)
|
|
76
|
+
throw firstError;
|
|
32
77
|
}
|
|
33
78
|
return {
|
|
34
79
|
source,
|
|
35
80
|
entries,
|
|
81
|
+
...(gitCapture === undefined ? {} : { gitEntries: gitCapture.entries }),
|
|
36
82
|
treeHash: hashTree(entries),
|
|
37
83
|
fileCount: Object.keys(entries).length,
|
|
38
84
|
totalBytes,
|
|
@@ -48,6 +94,7 @@ export async function captureStableTree(options) {
|
|
|
48
94
|
const first = await captureTree({
|
|
49
95
|
cwd: options.cwd,
|
|
50
96
|
config: options.config,
|
|
97
|
+
...(options.gitObjectFormat === undefined ? {} : { gitObjectFormat: options.gitObjectFormat }),
|
|
51
98
|
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
52
99
|
});
|
|
53
100
|
const second = await captureTree(options);
|
|
@@ -61,12 +108,14 @@ export async function captureStableTree(options) {
|
|
|
61
108
|
throw new ChangeLedgerError('WORKSPACE_CHANGED_DURING_CAPTURE', 'workspace did not remain stable across repeated full-tree captures');
|
|
62
109
|
}
|
|
63
110
|
/** Compute stable path-level differences between two captured trees. */
|
|
64
|
-
export function diffTrees(before, after) {
|
|
111
|
+
export function diffTrees(before, after, comparisonBefore = before, comparisonAfter = after) {
|
|
65
112
|
const paths = [...new Set([...Object.keys(before), ...Object.keys(after)])].sort(comparePaths);
|
|
66
113
|
const changes = [];
|
|
67
114
|
for (const path of paths) {
|
|
68
115
|
const left = before[path];
|
|
69
116
|
const right = after[path];
|
|
117
|
+
const comparisonLeft = comparisonBefore[path];
|
|
118
|
+
const comparisonRight = comparisonAfter[path];
|
|
70
119
|
if (left === undefined && right !== undefined) {
|
|
71
120
|
changes.push({ path, kind: 'added', after: right });
|
|
72
121
|
continue;
|
|
@@ -75,17 +124,27 @@ export function diffTrees(before, after) {
|
|
|
75
124
|
changes.push({ path, kind: 'deleted', before: left });
|
|
76
125
|
continue;
|
|
77
126
|
}
|
|
78
|
-
if (left === undefined || right === undefined || entriesEqual(
|
|
127
|
+
if (left === undefined || right === undefined || entriesEqual(comparisonLeft, comparisonRight))
|
|
79
128
|
continue;
|
|
80
129
|
if (left.kind !== right.kind) {
|
|
81
130
|
changes.push({ path, kind: 'type-changed', before: left, after: right });
|
|
82
131
|
continue;
|
|
83
132
|
}
|
|
84
|
-
if (left.kind === 'file'
|
|
133
|
+
if (left.kind === 'file'
|
|
134
|
+
&& right.kind === 'file'
|
|
135
|
+
&& comparisonLeft?.kind === 'file'
|
|
136
|
+
&& comparisonRight?.kind === 'file'
|
|
137
|
+
&& comparisonLeft.blob === comparisonRight.blob
|
|
138
|
+
&& left.mode !== right.mode) {
|
|
85
139
|
changes.push({ path, kind: 'mode-changed', before: left, after: right });
|
|
86
140
|
continue;
|
|
87
141
|
}
|
|
88
|
-
if (left.kind === 'symlink'
|
|
142
|
+
if (left.kind === 'symlink'
|
|
143
|
+
&& right.kind === 'symlink'
|
|
144
|
+
&& comparisonLeft?.kind === 'symlink'
|
|
145
|
+
&& comparisonRight?.kind === 'symlink'
|
|
146
|
+
&& comparisonLeft.target === comparisonRight.target
|
|
147
|
+
&& left.mode !== right.mode) {
|
|
89
148
|
changes.push({ path, kind: 'mode-changed', before: left, after: right });
|
|
90
149
|
continue;
|
|
91
150
|
}
|
|
@@ -122,6 +181,18 @@ export function hashTree(entries) {
|
|
|
122
181
|
}
|
|
123
182
|
return hash.digest('hex');
|
|
124
183
|
}
|
|
184
|
+
/** Byte-verify one workspace-relative path without following a final symlink. */
|
|
185
|
+
export async function captureSnapshotEntry(root, path, maxFileBytes, signal, gitObjectFormat) {
|
|
186
|
+
const entry = await captureEntry(root, path, maxFileBytes, signal);
|
|
187
|
+
if (entry === undefined || entry.kind === 'symlink' || gitObjectFormat === undefined)
|
|
188
|
+
return entry?.snapshot;
|
|
189
|
+
return gitFileSnapshot(entry.snapshot, entry.content, gitObjectFormat);
|
|
190
|
+
}
|
|
191
|
+
function gitFileSnapshot(snapshot, content, objectFormat) {
|
|
192
|
+
const header = Buffer.from(`blob ${String(content.length)}\0`);
|
|
193
|
+
const blob = createHash(objectFormat).update(header).update(content).digest('hex');
|
|
194
|
+
return { ...snapshot, blob, provider: 'git' };
|
|
195
|
+
}
|
|
125
196
|
async function captureEntry(root, path, maxFileBytes, signal) {
|
|
126
197
|
const target = resolveWorkspacePath(root, path);
|
|
127
198
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
@@ -149,11 +220,29 @@ async function captureEntry(root, path, maxFileBytes, signal) {
|
|
|
149
220
|
if (before.size > BigInt(maxFileBytes)) {
|
|
150
221
|
throw new ChangeLedgerError('FILE_TOO_LARGE', `${JSON.stringify(path)} is ${before.size.toString()} bytes; configured per-file maximum is ${maxFileBytes}`);
|
|
151
222
|
}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
223
|
+
let handle;
|
|
224
|
+
try {
|
|
225
|
+
handle = await open(target, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
226
|
+
}
|
|
227
|
+
catch (error) {
|
|
228
|
+
if (isNodeError(error, 'ELOOP'))
|
|
229
|
+
continue;
|
|
230
|
+
throw error;
|
|
231
|
+
}
|
|
232
|
+
let content;
|
|
233
|
+
try {
|
|
234
|
+
const opened = await handle.stat({ bigint: true });
|
|
235
|
+
if (!opened.isFile() || !sameStat(before, opened))
|
|
236
|
+
continue;
|
|
237
|
+
content = await readBoundedFile(handle, Number(opened.size));
|
|
238
|
+
throwIfAborted(signal);
|
|
239
|
+
const after = await handle.stat({ bigint: true });
|
|
240
|
+
if (!sameStat(opened, after) || BigInt(content.length) !== after.size)
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
finally {
|
|
244
|
+
await handle.close();
|
|
245
|
+
}
|
|
157
246
|
const blob = createHash('sha256').update(content).digest('hex');
|
|
158
247
|
return {
|
|
159
248
|
kind: 'file',
|
|
@@ -163,6 +252,17 @@ async function captureEntry(root, path, maxFileBytes, signal) {
|
|
|
163
252
|
}
|
|
164
253
|
throw new ChangeLedgerError('WORKSPACE_CHANGED_DURING_CAPTURE', `path changed repeatedly while being captured: ${JSON.stringify(path)}`);
|
|
165
254
|
}
|
|
255
|
+
async function readBoundedFile(handle, expectedSize) {
|
|
256
|
+
const buffer = Buffer.allocUnsafe(expectedSize + 1);
|
|
257
|
+
let offset = 0;
|
|
258
|
+
while (offset < buffer.length) {
|
|
259
|
+
const { bytesRead } = await handle.read(buffer, offset, buffer.length - offset, offset);
|
|
260
|
+
if (bytesRead === 0)
|
|
261
|
+
break;
|
|
262
|
+
offset += bytesRead;
|
|
263
|
+
}
|
|
264
|
+
return buffer.subarray(0, offset);
|
|
265
|
+
}
|
|
166
266
|
function sameStat(left, right) {
|
|
167
267
|
return left.dev === right.dev
|
|
168
268
|
&& left.ino === right.ino
|