@davideasden/pi-undo 0.2.17 → 0.2.20
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/extensions/pi-undo.ts +0 -2
- package/package.json +2 -1
- package/src/durable-pack.ts +7 -2
- package/src/journal.ts +45 -0
- package/src/pi-runtime.ts +1 -0
- package/src/recovery.ts +48 -1
- package/src/restore-engine.ts +490 -77
- package/src/root-discovery.ts +49 -2
- package/src/snapshot-store.ts +95 -1
package/extensions/pi-undo.ts
CHANGED
|
@@ -69,8 +69,6 @@ export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi
|
|
|
69
69
|
next.recovery,
|
|
70
70
|
);
|
|
71
71
|
} else next.reporter.setReady(history.undoCount, history.redoCount);
|
|
72
|
-
// 后台预热快照缓存,把新会话首次冷 capture 移出第一条 prompt 的关键路径。
|
|
73
|
-
next.controller.warmUp();
|
|
74
72
|
} catch (error) {
|
|
75
73
|
if (currentGeneration !== generation) return;
|
|
76
74
|
runtime = undefined;
|
package/package.json
CHANGED
package/src/durable-pack.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { randomBytes } from "node:crypto";
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
2
|
import { copyFile, link, lstat, open, readFile, rename, rm, type FileHandle } from "node:fs/promises";
|
|
3
3
|
import { dirname, join, resolve } from "node:path";
|
|
4
4
|
|
|
@@ -194,7 +194,12 @@ export async function createDurablePack(
|
|
|
194
194
|
await rm(temporary, { force: true }).catch(() => {});
|
|
195
195
|
throw error;
|
|
196
196
|
}
|
|
197
|
-
const
|
|
197
|
+
const digest = createHash("sha256");
|
|
198
|
+
digest.update(MAGIC);
|
|
199
|
+
digest.update(lengthBytes);
|
|
200
|
+
digest.update(headerBytes);
|
|
201
|
+
for (const payload of payloads) digest.update(payload);
|
|
202
|
+
const packChecksum = digest.digest("hex");
|
|
198
203
|
return durablePackFromInput(input.opId, input.planDigest, packPath, packChecksum, entries);
|
|
199
204
|
}
|
|
200
205
|
|
package/src/journal.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { appendFile, lstat, readFile, readdir, rm } from "node:fs/promises";
|
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
|
|
4
4
|
import { fsyncDirectory, fsyncFile, writeContentAddressed, writeJsonAtomic } from "./atomic-fs.ts";
|
|
5
|
+
import { hasDurablePack } from "./durable-pack.ts";
|
|
5
6
|
import {
|
|
6
7
|
assertCursor,
|
|
7
8
|
assertJournalState,
|
|
@@ -13,6 +14,7 @@ import type {
|
|
|
13
14
|
CursorState,
|
|
14
15
|
JournalPhase,
|
|
15
16
|
JournalState,
|
|
17
|
+
MutationRecord,
|
|
16
18
|
OperationDescriptor,
|
|
17
19
|
} from "./model.ts";
|
|
18
20
|
import { MutationJournal } from "./mutation-journal.ts";
|
|
@@ -154,6 +156,31 @@ export class JournalStore {
|
|
|
154
156
|
}
|
|
155
157
|
}
|
|
156
158
|
|
|
159
|
+
/**
|
|
160
|
+
* 判断 transaction 的 mutation 义务是否已全部履行且净效果回到操作前状态:
|
|
161
|
+
* 所有记录都达到 CLEANED,且按路径折叠的 fingerprint 链首尾相接(前向变更
|
|
162
|
+
* 被补偿变更精确抵消);没有任何 mutation 记录且无 durable pack 时视为从未发生。
|
|
163
|
+
* 这是纯只读检查;评估期间 journal 发生变化或任何证据不确定时都返回 false。
|
|
164
|
+
*/
|
|
165
|
+
async isFullyCompensated(pending: PendingJournal): Promise<boolean> {
|
|
166
|
+
try {
|
|
167
|
+
const current = await this.load(pending.descriptor.opId);
|
|
168
|
+
if (!samePendingJournal(current, pending)) return false;
|
|
169
|
+
const mutationJournal = this.mutationJournal(pending.descriptor.opId);
|
|
170
|
+
const records = await mutationJournal.load();
|
|
171
|
+
const compensated = records.length === 0
|
|
172
|
+
? !await hasDurablePack(mutationJournal)
|
|
173
|
+
: fingerprintChainsFoldToIdentity(records);
|
|
174
|
+
if (!compensated) return false;
|
|
175
|
+
const verified = await this.load(pending.descriptor.opId);
|
|
176
|
+
if (!samePendingJournal(verified, current)) return false;
|
|
177
|
+
const reloaded = await mutationJournal.load();
|
|
178
|
+
return reloaded.length === records.length && fingerprintChainsFoldToIdentity(reloaded);
|
|
179
|
+
} catch {
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
157
184
|
async assertLogicalCommitReady(opId: string, allowPendingMutations = false): Promise<void> {
|
|
158
185
|
if (!allowPendingMutations) await this.mutationJournal(opId).assertCleaned();
|
|
159
186
|
const pending = await this.load(opId);
|
|
@@ -414,6 +441,24 @@ function samePendingJournal(left: PendingJournal, right: PendingJournal): boolea
|
|
|
414
441
|
}
|
|
415
442
|
}
|
|
416
443
|
|
|
444
|
+
function fingerprintChainsFoldToIdentity(records: readonly MutationRecord[]): boolean {
|
|
445
|
+
const chains = new Map<string, { source: string; target: string }>();
|
|
446
|
+
for (const record of records) {
|
|
447
|
+
if (record.state !== "CLEANED") return false;
|
|
448
|
+
const chain = chains.get(record.path);
|
|
449
|
+
if (chain === undefined) {
|
|
450
|
+
chains.set(record.path, { source: record.sourceFingerprint, target: record.targetFingerprint });
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
if (record.sourceFingerprint !== chain.target) return false;
|
|
454
|
+
chain.target = record.targetFingerprint;
|
|
455
|
+
}
|
|
456
|
+
for (const chain of chains.values()) {
|
|
457
|
+
if (chain.target !== chain.source) return false;
|
|
458
|
+
}
|
|
459
|
+
return true;
|
|
460
|
+
}
|
|
461
|
+
|
|
417
462
|
function isEmptyArray(value: unknown): value is readonly unknown[] {
|
|
418
463
|
return Array.isArray(value) && value.length === 0;
|
|
419
464
|
}
|
package/src/pi-runtime.ts
CHANGED
|
@@ -65,6 +65,7 @@ export async function createPiUndoRuntime(context: ExtensionContext, pi: Extensi
|
|
|
65
65
|
getLogicalLeafId: () => sessionStateFor(manager).getLogicalLeafId(),
|
|
66
66
|
loadPending: () => journal.loadPending(),
|
|
67
67
|
assessForeignTransaction: (pending) => journal.isInertForeignPrepared(pending),
|
|
68
|
+
assessCompensatedTransaction: (pending) => journal.isFullyCompensated(pending),
|
|
68
69
|
inspectCursor: (pending) => inspectCursorMarkers(pending.descriptor.sessionIdentity.path, pending.descriptor),
|
|
69
70
|
finalizeCursor: (pending, inspection) => finalizeCursorMarker(
|
|
70
71
|
pending.descriptor.sessionIdentity.path,
|
package/src/recovery.ts
CHANGED
|
@@ -11,6 +11,8 @@ export interface JournalRecoveryDependencies {
|
|
|
11
11
|
readonly loadPending: () => Promise<readonly PendingJournal[]>;
|
|
12
12
|
/** 仅允许严格证明无工作区 mutation 的 foreign PREPARED 空事务被忽略。 */
|
|
13
13
|
readonly assessForeignTransaction?: (journal: PendingJournal) => Promise<boolean>;
|
|
14
|
+
/** 判断 transaction 是否已被完全补偿(mutation 全部 CLEANED 且净效果回到操作前状态),可直接 settle。 */
|
|
15
|
+
readonly assessCompensatedTransaction?: (journal: PendingJournal) => Promise<boolean>;
|
|
14
16
|
readonly inspectCursor: (journal: PendingJournal) => Promise<CursorMarkerInspection>;
|
|
15
17
|
readonly finalizeCursor: (journal: PendingJournal, inspection: Extract<CursorMarkerInspection, { kind: "match" }>) => Promise<void>;
|
|
16
18
|
readonly recoverMutations: (
|
|
@@ -67,9 +69,22 @@ export class JournalRecovery {
|
|
|
67
69
|
for (const journal of pending) {
|
|
68
70
|
const identityError = this.identityError(journal);
|
|
69
71
|
if (identityError !== null) {
|
|
70
|
-
if (identityError === "session_identity_mismatch"
|
|
72
|
+
if (identityError === "session_identity_mismatch") {
|
|
73
|
+
if (await this.settleCompensated(journal)) {
|
|
74
|
+
recovered += 1;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (await this.canIgnoreForeignTransaction(journal)) continue;
|
|
78
|
+
}
|
|
71
79
|
return { kind: "locked", reason: identityError, operations: recovered };
|
|
72
80
|
}
|
|
81
|
+
// 同一会话(重启或第二窗口)内,完全补偿的事务同样可直接 settle:
|
|
82
|
+
// 工作区义务已全部履行且无 cursor 提交证据,session leaf 位置对
|
|
83
|
+
// ABORTED 终结不构成安全输入,无需通过 leaf 校验。
|
|
84
|
+
if (await this.settleCompensated(journal)) {
|
|
85
|
+
recovered += 1;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
73
88
|
let inspection: CursorMarkerInspection;
|
|
74
89
|
try {
|
|
75
90
|
inspection = await this.dependencies.inspectCursor(journal);
|
|
@@ -145,6 +160,38 @@ export class JournalRecovery {
|
|
|
145
160
|
}
|
|
146
161
|
}
|
|
147
162
|
|
|
163
|
+
/**
|
|
164
|
+
* 终结完全补偿的 pending transaction(own session 重启/第二窗口与 foreign 会话均适用)。
|
|
165
|
+
* 调用方持有 workspace lock,pending 非终态事务的 owner 操作已确定性结束;
|
|
166
|
+
* mutation 义务全部履行且净效果回到操作前状态时,无需触碰工作区即可 settle。
|
|
167
|
+
* cursor marker 必须缺失——marker 存在意味着操作已提交,与补偿证据矛盾,fail closed。
|
|
168
|
+
*/
|
|
169
|
+
private async settleCompensated(journal: PendingJournal): Promise<boolean> {
|
|
170
|
+
const assess = this.dependencies.assessCompensatedTransaction;
|
|
171
|
+
if (assess === undefined) return false;
|
|
172
|
+
let compensated: boolean;
|
|
173
|
+
try {
|
|
174
|
+
compensated = await assess(journal);
|
|
175
|
+
} catch {
|
|
176
|
+
// 评估失败等同于证据不足,退回原有恢复语义。
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
if (!compensated) return false;
|
|
180
|
+
let inspection: CursorMarkerInspection;
|
|
181
|
+
try {
|
|
182
|
+
inspection = await this.dependencies.inspectCursor(journal);
|
|
183
|
+
} catch {
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
if (inspection.kind !== "absent") return false;
|
|
187
|
+
try {
|
|
188
|
+
await this.dependencies.settle(journal.descriptor.opId, "ABORTED");
|
|
189
|
+
} catch {
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
|
|
148
195
|
private identityError(journal: PendingJournal): string | null {
|
|
149
196
|
if (journal.descriptor.workspaceIdentity !== this.dependencies.workspaceIdentity) {
|
|
150
197
|
return "workspace_identity_mismatch";
|
package/src/restore-engine.ts
CHANGED
|
@@ -4,8 +4,11 @@ import { lstat, mkdir, mkdtemp, readFile, readlink, realpath, rm, rmdir } from "
|
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { dirname, join, resolve } from "node:path";
|
|
6
6
|
|
|
7
|
+
import { writeJsonAtomic } from "./atomic-fs.ts";
|
|
7
8
|
import {
|
|
8
9
|
createDurablePack,
|
|
10
|
+
hasDurablePack,
|
|
11
|
+
loadDurablePack,
|
|
9
12
|
publishCachedDurablePack,
|
|
10
13
|
removeDurablePack,
|
|
11
14
|
type DurableLeafInput,
|
|
@@ -33,11 +36,15 @@ import {
|
|
|
33
36
|
type ReplaceFileRequest,
|
|
34
37
|
} from "./quarantine.ts";
|
|
35
38
|
import { SnapshotStore, SnapshotStoreError } from "./snapshot-store.ts";
|
|
39
|
+
import { WorkspaceLock } from "./workspace-lock.ts";
|
|
36
40
|
|
|
37
41
|
const PREPARED_PLAN_CACHE_LIMIT = 16;
|
|
42
|
+
const DURABLE_CACHE_INDEX_FILE = "index.json";
|
|
43
|
+
const DURABLE_PACK_MEMORY_MAX_BYTES = 64 * 1024 * 1024;
|
|
38
44
|
const RESTORE_FILE_BATCH_MAX_ENTRIES = 1_024;
|
|
39
45
|
const RESTORE_FILE_BATCH_MAX_BYTES = 64 * 1024 * 1024;
|
|
40
46
|
const RESTORE_FILE_PREPARE_CONCURRENCY = 32;
|
|
47
|
+
const RESTORE_FILE_VERIFY_CONCURRENCY = 32;
|
|
41
48
|
|
|
42
49
|
export interface RestorePlan {
|
|
43
50
|
currentManifestId: ManifestId;
|
|
@@ -73,6 +80,8 @@ export interface RestoreEngineOptions {
|
|
|
73
80
|
readonly store: SnapshotStore;
|
|
74
81
|
readonly discovery?: RootDiscovery;
|
|
75
82
|
readonly beforeMutation?: (mutation: RestoreMutation) => void | Promise<void>;
|
|
83
|
+
/** 仅用于测试:覆盖进程内已加载 durable pack 的内存预算。 */
|
|
84
|
+
readonly durablePackCacheMaxBytes?: number;
|
|
76
85
|
}
|
|
77
86
|
|
|
78
87
|
export interface RestoreMutation {
|
|
@@ -94,6 +103,40 @@ interface PreparedRestorePlan {
|
|
|
94
103
|
readonly targetPaths: ReadonlyMap<string, OwnedPath>;
|
|
95
104
|
}
|
|
96
105
|
|
|
106
|
+
interface DurableCacheIndexEntry {
|
|
107
|
+
readonly currentManifestId: ManifestId;
|
|
108
|
+
readonly targetManifestId: ManifestId;
|
|
109
|
+
readonly scopePaths: readonly string[];
|
|
110
|
+
readonly planDigest: string;
|
|
111
|
+
readonly packChecksum: string;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
interface DurableCacheIndex {
|
|
115
|
+
readonly schemaVersion: 1;
|
|
116
|
+
entries: DurableCacheIndexEntry[];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
interface CachedDurablePack {
|
|
120
|
+
readonly currentManifestId: ManifestId;
|
|
121
|
+
readonly targetManifestId: ManifestId;
|
|
122
|
+
readonly path: string;
|
|
123
|
+
readonly packChecksum: string;
|
|
124
|
+
readonly pinReason: string;
|
|
125
|
+
readonly pinsDeferred: boolean;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
interface CachedDurablePair {
|
|
129
|
+
readonly planDigest: string;
|
|
130
|
+
readonly pack: DurablePack;
|
|
131
|
+
readonly bytes: number;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
type DurableIndexDisk =
|
|
135
|
+
| { readonly kind: "ok"; readonly index: DurableCacheIndex }
|
|
136
|
+
| { readonly kind: "missing" }
|
|
137
|
+
| { readonly kind: "corrupt" }
|
|
138
|
+
| { readonly kind: "unavailable" };
|
|
139
|
+
|
|
97
140
|
interface MutationContext {
|
|
98
141
|
readonly phase: RestoreMutation["phase"];
|
|
99
142
|
readonly sourceManifestId: ManifestId;
|
|
@@ -114,17 +157,17 @@ export class RestoreEngine {
|
|
|
114
157
|
private readonly discovery: RootDiscovery;
|
|
115
158
|
private readonly beforeMutation: RestoreEngineOptions["beforeMutation"];
|
|
116
159
|
private readonly preparedPlans = new Map<string, PreparedRestorePlan>();
|
|
117
|
-
private readonly durablePackCache = new Map<string,
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
}
|
|
124
|
-
private readonly
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
160
|
+
private readonly durablePackCache = new Map<string, CachedDurablePack>();
|
|
161
|
+
private readonly durablePackByPair = new Map<string, CachedDurablePair>();
|
|
162
|
+
private readonly durableIndexLock = new WorkspaceLock({
|
|
163
|
+
leaseMs: 10_000,
|
|
164
|
+
retryMs: 25,
|
|
165
|
+
acquireTimeoutMs: 1_000,
|
|
166
|
+
});
|
|
167
|
+
private readonly durablePackCacheMaxBytes: number;
|
|
168
|
+
private durableIndex: DurableCacheIndex | undefined;
|
|
169
|
+
private durableIndexQueue: Promise<void> = Promise.resolve();
|
|
170
|
+
private durablePackMemoryBytes = 0;
|
|
128
171
|
|
|
129
172
|
constructor(options: RestoreEngineOptions) {
|
|
130
173
|
this.requestedWorkspaceRoot = resolve(options.workspaceRoot);
|
|
@@ -132,6 +175,10 @@ export class RestoreEngine {
|
|
|
132
175
|
this.store = options.store;
|
|
133
176
|
this.discovery = options.discovery ?? new RootDiscovery();
|
|
134
177
|
this.beforeMutation = options.beforeMutation;
|
|
178
|
+
this.durablePackCacheMaxBytes = options.durablePackCacheMaxBytes ?? DURABLE_PACK_MEMORY_MAX_BYTES;
|
|
179
|
+
if (!Number.isSafeInteger(this.durablePackCacheMaxBytes) || this.durablePackCacheMaxBytes <= 0) {
|
|
180
|
+
throw new Error("durable pack 内存预算必须是正整数");
|
|
181
|
+
}
|
|
135
182
|
}
|
|
136
183
|
|
|
137
184
|
private async compatibleApplyOptions(): Promise<{
|
|
@@ -225,14 +272,27 @@ export class RestoreEngine {
|
|
|
225
272
|
target: SnapshotManifest,
|
|
226
273
|
scopePaths: readonly string[],
|
|
227
274
|
): Promise<boolean> {
|
|
228
|
-
const
|
|
229
|
-
|
|
275
|
+
const pairKey = durablePairKey(current.manifestId, target.manifestId, scopePaths);
|
|
276
|
+
let cached = this.durablePackByPair.get(pairKey);
|
|
277
|
+
if (cached !== undefined && !this.durablePackCache.has(cached.planDigest)) {
|
|
278
|
+
this.dropResidentDurablePack(pairKey);
|
|
279
|
+
cached = undefined;
|
|
280
|
+
}
|
|
281
|
+
const fromPersistentIndex = cached === undefined;
|
|
282
|
+
if (cached === undefined) {
|
|
283
|
+
cached = await this.readIndexedDurablePack(current.manifestId, target.manifestId, scopePaths);
|
|
284
|
+
}
|
|
285
|
+
if (cached === undefined || cached.pack.opId !== `cache-${cached.planDigest}`) return false;
|
|
230
286
|
const cacheJournal = new MutationJournal(
|
|
231
287
|
join(dirname(cached.pack.storagePath), "mutations.jsonl"),
|
|
232
288
|
`cache-${cached.planDigest}`,
|
|
233
289
|
);
|
|
234
290
|
try {
|
|
235
291
|
if (await this.assertWorkspaceRootIdentity() !== current.workspaceIdentity) return false;
|
|
292
|
+
if (fromPersistentIndex) {
|
|
293
|
+
const expected = await this.plan(current, target, scopePaths);
|
|
294
|
+
if (expected.planDigest !== cached.planDigest) return false;
|
|
295
|
+
}
|
|
236
296
|
const topology = await this.discovery.discover(this.workspaceRoot);
|
|
237
297
|
this.assertCurrentTopology(current, target, topology);
|
|
238
298
|
const native = await createNativeFileBatch({
|
|
@@ -240,7 +300,19 @@ export class RestoreEngine {
|
|
|
240
300
|
planDigest: cached.planDigest,
|
|
241
301
|
journal: cacheJournal,
|
|
242
302
|
});
|
|
243
|
-
|
|
303
|
+
if (native === undefined || !await native.verifySource(cached.pack)) return false;
|
|
304
|
+
if (fromPersistentIndex) {
|
|
305
|
+
await this.rememberDurablePackInMemory(
|
|
306
|
+
cached.planDigest,
|
|
307
|
+
current,
|
|
308
|
+
target,
|
|
309
|
+
scopePaths,
|
|
310
|
+
cached.pack,
|
|
311
|
+
`durable-cache:${cached.planDigest}`,
|
|
312
|
+
false,
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
return true;
|
|
244
316
|
} catch {
|
|
245
317
|
return false;
|
|
246
318
|
}
|
|
@@ -286,7 +358,7 @@ export class RestoreEngine {
|
|
|
286
358
|
throw error;
|
|
287
359
|
}
|
|
288
360
|
await this.rememberDurablePack(
|
|
289
|
-
plan,
|
|
361
|
+
plan.planDigest,
|
|
290
362
|
current,
|
|
291
363
|
target,
|
|
292
364
|
scopePaths,
|
|
@@ -297,41 +369,245 @@ export class RestoreEngine {
|
|
|
297
369
|
}
|
|
298
370
|
|
|
299
371
|
private async rememberDurablePack(
|
|
300
|
-
|
|
372
|
+
planDigest: string,
|
|
301
373
|
current: SnapshotManifest,
|
|
302
374
|
target: SnapshotManifest,
|
|
303
375
|
scopePaths: readonly string[],
|
|
304
376
|
pack: DurablePack,
|
|
305
377
|
pinReason: string,
|
|
306
378
|
): Promise<void> {
|
|
307
|
-
this.
|
|
379
|
+
await this.rememberDurablePackInMemory(planDigest, current, target, scopePaths, pack, pinReason, true);
|
|
380
|
+
await this.enqueueDurableIndex(async () => {
|
|
381
|
+
const lease = await this.acquireDurableIndexLease();
|
|
382
|
+
if (lease === undefined) return;
|
|
383
|
+
try {
|
|
384
|
+
const disk = await this.readDurableIndexFromDisk();
|
|
385
|
+
if (disk.kind === "unavailable") return;
|
|
386
|
+
this.durableIndex = disk.kind === "ok"
|
|
387
|
+
? disk.index
|
|
388
|
+
: { schemaVersion: 1, entries: [] };
|
|
389
|
+
this.upsertDurableIndexEntry({
|
|
390
|
+
currentManifestId: current.manifestId,
|
|
391
|
+
targetManifestId: target.manifestId,
|
|
392
|
+
scopePaths: [...scopePaths],
|
|
393
|
+
planDigest,
|
|
394
|
+
packChecksum: pack.packChecksum,
|
|
395
|
+
});
|
|
396
|
+
await this.evictPersistedDurablePacks(planDigest);
|
|
397
|
+
await this.persistDurableIndex();
|
|
398
|
+
} finally {
|
|
399
|
+
await lease.release().catch(() => {});
|
|
400
|
+
}
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
private async rememberDurablePackInMemory(
|
|
405
|
+
planDigest: string,
|
|
406
|
+
current: SnapshotManifest,
|
|
407
|
+
target: SnapshotManifest,
|
|
408
|
+
scopePaths: readonly string[],
|
|
409
|
+
pack: DurablePack,
|
|
410
|
+
pinReason: string,
|
|
411
|
+
pinsDeferred: boolean,
|
|
412
|
+
): Promise<void> {
|
|
413
|
+
this.durablePackCache.delete(planDigest);
|
|
414
|
+
this.durablePackCache.set(planDigest, {
|
|
308
415
|
currentManifestId: current.manifestId,
|
|
309
416
|
targetManifestId: target.manifestId,
|
|
310
417
|
path: pack.storagePath,
|
|
311
418
|
packChecksum: pack.packChecksum,
|
|
312
419
|
pinReason,
|
|
420
|
+
pinsDeferred,
|
|
313
421
|
});
|
|
314
|
-
this.
|
|
315
|
-
|
|
422
|
+
this.trimDurablePackMetadata();
|
|
423
|
+
await this.rememberResidentDurablePack(
|
|
424
|
+
durablePairKey(current.manifestId, target.manifestId, scopePaths),
|
|
425
|
+
planDigest,
|
|
316
426
|
pack,
|
|
317
|
-
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
private trimDurablePackMetadata(): void {
|
|
318
431
|
while (this.durablePackCache.size > PREPARED_PLAN_CACHE_LIMIT) {
|
|
319
|
-
const oldest = this.durablePackCache.
|
|
320
|
-
| readonly [string, { readonly currentManifestId: ManifestId; readonly targetManifestId: ManifestId; readonly path: string; readonly packChecksum: string; readonly pinReason: string }]
|
|
321
|
-
| undefined;
|
|
432
|
+
const oldest = this.durablePackCache.keys().next().value as string | undefined;
|
|
322
433
|
if (oldest === undefined) break;
|
|
323
|
-
|
|
324
|
-
this.
|
|
325
|
-
|
|
326
|
-
|
|
434
|
+
this.durablePackCache.delete(oldest);
|
|
435
|
+
this.dropResidentDurablePacks(oldest);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
private async rememberResidentDurablePack(
|
|
440
|
+
pairKey: string,
|
|
441
|
+
planDigest: string,
|
|
442
|
+
pack: DurablePack,
|
|
443
|
+
): Promise<void> {
|
|
444
|
+
const bytes = await this.durablePackFileBytes(pack.storagePath);
|
|
445
|
+
const previous = this.durablePackByPair.get(pairKey);
|
|
446
|
+
if (previous !== undefined) {
|
|
447
|
+
this.durablePackMemoryBytes -= previous.bytes;
|
|
448
|
+
this.durablePackByPair.delete(pairKey);
|
|
449
|
+
}
|
|
450
|
+
this.durablePackByPair.set(pairKey, { planDigest, pack, bytes });
|
|
451
|
+
this.durablePackMemoryBytes += bytes;
|
|
452
|
+
while (this.durablePackMemoryBytes > this.durablePackCacheMaxBytes) {
|
|
453
|
+
const oldest = this.durablePackByPair.keys().next().value as string | undefined;
|
|
454
|
+
if (oldest === undefined) break;
|
|
455
|
+
this.dropResidentDurablePack(oldest);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
private dropResidentDurablePack(pairKey: string): void {
|
|
460
|
+
const cached = this.durablePackByPair.get(pairKey);
|
|
461
|
+
if (cached === undefined) return;
|
|
462
|
+
this.durablePackByPair.delete(pairKey);
|
|
463
|
+
this.durablePackMemoryBytes = Math.max(0, this.durablePackMemoryBytes - cached.bytes);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
private dropResidentDurablePacks(planDigest: string): void {
|
|
467
|
+
for (const [pairKey, cached] of this.durablePackByPair) {
|
|
468
|
+
if (cached.planDigest === planDigest) this.dropResidentDurablePack(pairKey);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
private async readIndexedDurablePack(
|
|
473
|
+
currentManifestId: ManifestId,
|
|
474
|
+
targetManifestId: ManifestId,
|
|
475
|
+
scopePaths: readonly string[],
|
|
476
|
+
): Promise<CachedDurablePair | undefined> {
|
|
477
|
+
const pairKey = durablePairKey(currentManifestId, targetManifestId, scopePaths);
|
|
478
|
+
const existing = this.durablePackByPair.get(pairKey);
|
|
479
|
+
if (existing !== undefined && this.durablePackCache.has(existing.planDigest)) return existing;
|
|
480
|
+
if (existing !== undefined) this.dropResidentDurablePack(pairKey);
|
|
481
|
+
const disk = await this.enqueueDurableIndex(async () => {
|
|
482
|
+
const result = await this.readDurableIndexFromDisk();
|
|
483
|
+
if (result.kind === "ok") this.durableIndex = result.index;
|
|
484
|
+
return result;
|
|
485
|
+
});
|
|
486
|
+
const entries = disk.kind === "ok"
|
|
487
|
+
? disk.index.entries
|
|
488
|
+
: disk.kind === "unavailable" ? this.durableIndex?.entries : undefined;
|
|
489
|
+
const entry = entries?.find((candidate) =>
|
|
490
|
+
durablePairKey(candidate.currentManifestId, candidate.targetManifestId, candidate.scopePaths) === pairKey);
|
|
491
|
+
if (entry === undefined || !isDigest(entry.planDigest) || !isDigest(entry.packChecksum)) return undefined;
|
|
492
|
+
try {
|
|
493
|
+
const cacheRoot = await this.store.durableCacheDirectory();
|
|
494
|
+
const journal = new MutationJournal(
|
|
495
|
+
join(cacheRoot, entry.planDigest, "mutations.jsonl"),
|
|
496
|
+
`cache-${entry.planDigest}`,
|
|
497
|
+
);
|
|
498
|
+
if (!await hasDurablePack(journal)) return undefined;
|
|
499
|
+
const pack = await loadDurablePack(journal, entry.planDigest, true);
|
|
500
|
+
if (pack.packChecksum !== entry.packChecksum || pack.planDigest !== entry.planDigest) return undefined;
|
|
501
|
+
return {
|
|
502
|
+
planDigest: entry.planDigest,
|
|
503
|
+
pack,
|
|
504
|
+
bytes: await this.durablePackFileBytes(pack.storagePath),
|
|
505
|
+
};
|
|
506
|
+
} catch {
|
|
507
|
+
return undefined;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
private enqueueDurableIndex<T>(operation: () => Promise<T>): Promise<T> {
|
|
512
|
+
const run = this.durableIndexQueue.then(operation, operation);
|
|
513
|
+
this.durableIndexQueue = run.then(() => undefined, () => undefined);
|
|
514
|
+
return run;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
private async acquireDurableIndexLease(): Promise<{ release(): Promise<void> } | undefined> {
|
|
518
|
+
try {
|
|
519
|
+
return await this.durableIndexLock.acquire(await this.durableIndexLockIdentity());
|
|
520
|
+
} catch {
|
|
521
|
+
return undefined;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
private async durableIndexLockIdentity(): Promise<string> {
|
|
526
|
+
const cacheRoot = await this.store.durableCacheDirectory();
|
|
527
|
+
try {
|
|
528
|
+
return `durable-cache:${await realpath(cacheRoot)}`;
|
|
529
|
+
} catch {
|
|
530
|
+
return `durable-cache:${resolve(cacheRoot)}`;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
private async readDurableIndexFromDisk(): Promise<DurableIndexDisk> {
|
|
535
|
+
try {
|
|
536
|
+
const cacheRoot = await this.store.durableCacheDirectory();
|
|
537
|
+
const raw = await readFile(join(cacheRoot, DURABLE_CACHE_INDEX_FILE), "utf8");
|
|
538
|
+
try {
|
|
539
|
+
return { kind: "ok", index: parseDurableCacheIndex(JSON.parse(raw)) };
|
|
540
|
+
} catch {
|
|
541
|
+
return { kind: "corrupt" };
|
|
327
542
|
}
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
await rm(dirname(entry.path), { recursive: true, force: true }).catch(() => {});
|
|
543
|
+
} catch (error) {
|
|
544
|
+
if (hasErrorCode(error, "ENOENT")) return { kind: "missing" };
|
|
545
|
+
return { kind: "unavailable" };
|
|
332
546
|
}
|
|
333
547
|
}
|
|
334
548
|
|
|
549
|
+
private upsertDurableIndexEntry(entry: DurableCacheIndexEntry): void {
|
|
550
|
+
const index = this.durableIndex ?? { schemaVersion: 1, entries: [] };
|
|
551
|
+
const pairKey = durablePairKey(entry.currentManifestId, entry.targetManifestId, entry.scopePaths);
|
|
552
|
+
index.entries = index.entries.filter((candidate) =>
|
|
553
|
+
candidate.planDigest !== entry.planDigest &&
|
|
554
|
+
durablePairKey(candidate.currentManifestId, candidate.targetManifestId, candidate.scopePaths) !== pairKey);
|
|
555
|
+
index.entries.push(entry);
|
|
556
|
+
this.durableIndex = index;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
private async persistDurableIndex(): Promise<void> {
|
|
560
|
+
if (this.durableIndex === undefined) return;
|
|
561
|
+
try {
|
|
562
|
+
const cacheRoot = await this.store.durableCacheDirectory();
|
|
563
|
+
await writeJsonAtomic(join(cacheRoot, DURABLE_CACHE_INDEX_FILE), {
|
|
564
|
+
schemaVersion: 1,
|
|
565
|
+
entries: this.durableIndex.entries,
|
|
566
|
+
});
|
|
567
|
+
} catch {
|
|
568
|
+
// 索引只是跨会话候选提示;写入失败不得让已经 pin 成功的 pack 准备失败。
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
private async evictPersistedDurablePacks(keepPlanDigest: string): Promise<void> {
|
|
573
|
+
while (this.durableIndex !== undefined && this.durableIndex.entries.length > PREPARED_PLAN_CACHE_LIMIT) {
|
|
574
|
+
const oldest = this.durableIndex.entries.find((entry) => entry.planDigest !== keepPlanDigest);
|
|
575
|
+
if (oldest === undefined) break;
|
|
576
|
+
await this.dropPersistedDurablePack(oldest.planDigest);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
private async dropPersistedDurablePack(planDigest: string): Promise<void> {
|
|
581
|
+
const cached = this.durablePackCache.get(planDigest);
|
|
582
|
+
const indexed = this.durableIndex?.entries.find((entry) => entry.planDigest === planDigest);
|
|
583
|
+
this.durablePackCache.delete(planDigest);
|
|
584
|
+
this.dropResidentDurablePacks(planDigest);
|
|
585
|
+
if (this.durableIndex !== undefined) {
|
|
586
|
+
this.durableIndex.entries = this.durableIndex.entries.filter((entry) => entry.planDigest !== planDigest);
|
|
587
|
+
}
|
|
588
|
+
const pinReason = cached?.pinReason ?? `durable-cache:${planDigest}`;
|
|
589
|
+
const manifests = [...new Set([
|
|
590
|
+
...(cached === undefined ? [] : [cached.currentManifestId, cached.targetManifestId]),
|
|
591
|
+
...(indexed === undefined ? [] : [indexed.currentManifestId, indexed.targetManifestId]),
|
|
592
|
+
])];
|
|
593
|
+
await Promise.all(manifests.map((manifestId) => this.store.unpin(manifestId, pinReason).catch(() => {})));
|
|
594
|
+
try {
|
|
595
|
+
await rm(join(await this.store.durableCacheDirectory(), planDigest), { recursive: true, force: true });
|
|
596
|
+
} catch {
|
|
597
|
+
// 淘汰失败只影响缓存占用,不影响当前 restore 正确性。
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
private async durablePackFileBytes(path: string): Promise<number> {
|
|
602
|
+
try {
|
|
603
|
+
const stats = await lstat(path);
|
|
604
|
+
if (stats.isFile() && !stats.isSymbolicLink() && stats.size > 0) return Number(stats.size);
|
|
605
|
+
} catch {
|
|
606
|
+
// 文件可能在加载后被其他进程淘汰;不能把已加载的 pack 当作零字节常驻。
|
|
607
|
+
}
|
|
608
|
+
return this.durablePackCacheMaxBytes + 1;
|
|
609
|
+
}
|
|
610
|
+
|
|
335
611
|
private rememberPreparedPlan(
|
|
336
612
|
plan: RestorePlan,
|
|
337
613
|
currentPaths: ReadonlyMap<string, OwnedPath>,
|
|
@@ -390,8 +666,9 @@ export class RestoreEngine {
|
|
|
390
666
|
if (!hasValidPlanDigest(plan)) {
|
|
391
667
|
throw new Error("restore plan digest 与语义字段不匹配");
|
|
392
668
|
}
|
|
669
|
+
const cachedDurablePack = this.durablePackCache.get(plan.planDigest);
|
|
393
670
|
const pinsDeferred = effectiveOptions.deferDurability === true &&
|
|
394
|
-
|
|
671
|
+
cachedDurablePack?.pinsDeferred === true;
|
|
395
672
|
const recoveryReason = `restore:${plan.planDigest}`;
|
|
396
673
|
const attemptReason = `${recoveryReason}:attempt:${randomUUID()}`;
|
|
397
674
|
const pinned = [...new Set([plan.currentManifestId, target.manifestId])];
|
|
@@ -519,7 +796,13 @@ export class RestoreEngine {
|
|
|
519
796
|
return { code: "recovery_required", verifiedPaths: 0, totalPaths: currentPaths.size };
|
|
520
797
|
}
|
|
521
798
|
try {
|
|
522
|
-
await this.assertCompleteVisibleSubset(
|
|
799
|
+
await this.assertCompleteVisibleSubset(
|
|
800
|
+
topologyBefore,
|
|
801
|
+
[current, target],
|
|
802
|
+
options.mutationJournal,
|
|
803
|
+
[],
|
|
804
|
+
this.completeCoverageOwnedPaths(plan.scopePaths, [currentPaths, targetPaths]),
|
|
805
|
+
);
|
|
523
806
|
} catch {
|
|
524
807
|
return { code: "restore_failed_safe", verifiedPaths: 0, totalPaths: 0 };
|
|
525
808
|
}
|
|
@@ -588,6 +871,11 @@ export class RestoreEngine {
|
|
|
588
871
|
);
|
|
589
872
|
return result;
|
|
590
873
|
}
|
|
874
|
+
try {
|
|
875
|
+
await this.prefetchCompleteRestoreBlobs(plan, current, target, currentPaths, targetPaths);
|
|
876
|
+
} catch {
|
|
877
|
+
// 预取是性能优化;失败时继续走原有逐文件校验和可恢复 mutation 路径。
|
|
878
|
+
}
|
|
591
879
|
const preflight = await this.verifyKnownState(current, target, currentPaths, targetPaths, plan.scopePaths);
|
|
592
880
|
if (!preflight.ok) {
|
|
593
881
|
return {
|
|
@@ -625,7 +913,13 @@ export class RestoreEngine {
|
|
|
625
913
|
|
|
626
914
|
const topologyAfter = await this.discovery.discover(this.workspaceRoot);
|
|
627
915
|
assertUnchangedTopology(topologyBefore, topologyAfter);
|
|
628
|
-
await this.assertCompleteVisibleSubset(
|
|
916
|
+
await this.assertCompleteVisibleSubset(
|
|
917
|
+
topologyAfter,
|
|
918
|
+
[target],
|
|
919
|
+
options.mutationJournal,
|
|
920
|
+
[],
|
|
921
|
+
this.completeCoverageOwnedPaths(plan.scopePaths, [targetPaths]),
|
|
922
|
+
);
|
|
629
923
|
const verification = await this.verifyTarget(
|
|
630
924
|
target,
|
|
631
925
|
currentPaths,
|
|
@@ -702,6 +996,7 @@ export class RestoreEngine {
|
|
|
702
996
|
? []
|
|
703
997
|
: [artifacts.source, ...(artifacts.target === null ? [] : [artifacts.target])];
|
|
704
998
|
}),
|
|
999
|
+
this.completeCoverageOwnedPaths(plan.scopePaths, [targetPaths]),
|
|
705
1000
|
);
|
|
706
1001
|
const totalPaths = plan.deletePaths.length + plan.writePaths.length;
|
|
707
1002
|
if ((await options.mutationJournal.load()).length !== 0) {
|
|
@@ -887,15 +1182,47 @@ export class RestoreEngine {
|
|
|
887
1182
|
return result;
|
|
888
1183
|
}
|
|
889
1184
|
|
|
1185
|
+
private completeCoverageOwnedPaths(
|
|
1186
|
+
scopePaths: readonly string[] | undefined,
|
|
1187
|
+
ownedPaths: readonly ReadonlyMap<string, OwnedPath>[],
|
|
1188
|
+
): readonly ReadonlyMap<string, OwnedPath>[] | undefined {
|
|
1189
|
+
return scopePaths === undefined ? ownedPaths : undefined;
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
private async prefetchCompleteRestoreBlobs(
|
|
1193
|
+
plan: RestorePlan,
|
|
1194
|
+
current: SnapshotManifest,
|
|
1195
|
+
target: SnapshotManifest,
|
|
1196
|
+
currentPaths: ReadonlyMap<string, OwnedPath>,
|
|
1197
|
+
targetPaths: ReadonlyMap<string, OwnedPath>,
|
|
1198
|
+
): Promise<void> {
|
|
1199
|
+
if (plan.scopePaths !== undefined || !SnapshotStore.supportsValidatedBlobBatch(this.store)) return;
|
|
1200
|
+
const requestsFor = (paths: ReadonlyMap<string, OwnedPath>, extraPaths: readonly string[] = []) => {
|
|
1201
|
+
const requests = [];
|
|
1202
|
+
const requested = new Set([...plan.writePaths, ...extraPaths]);
|
|
1203
|
+
for (const path of requested) {
|
|
1204
|
+
const owned = paths.get(path);
|
|
1205
|
+
if (owned === undefined || owned.entry.kind !== "file" || owned.entry.blobId === null) continue;
|
|
1206
|
+
requests.push({
|
|
1207
|
+
rootPath: owned.root.relativeRoot,
|
|
1208
|
+
blobId: owned.entry.blobId,
|
|
1209
|
+
relativePath: owned.entry.relativePath,
|
|
1210
|
+
});
|
|
1211
|
+
}
|
|
1212
|
+
return requests;
|
|
1213
|
+
};
|
|
1214
|
+
await Promise.all([
|
|
1215
|
+
this.store.prefetchBlobs(current.manifestId, requestsFor(currentPaths, plan.deletePaths)),
|
|
1216
|
+
this.store.prefetchBlobs(target.manifestId, requestsFor(targetPaths)),
|
|
1217
|
+
]);
|
|
1218
|
+
}
|
|
1219
|
+
|
|
890
1220
|
private assertCurrentTopology(
|
|
891
1221
|
current: SnapshotManifest,
|
|
892
1222
|
target: SnapshotManifest,
|
|
893
1223
|
actual: RootTopology,
|
|
894
1224
|
): void {
|
|
895
|
-
if (
|
|
896
|
-
actual.workspaceIdentity !== current.workspaceIdentity ||
|
|
897
|
-
actual.fingerprint !== current.topologyFingerprint
|
|
898
|
-
) {
|
|
1225
|
+
if (!sameTopologyModuloIdentity(actual, current)) {
|
|
899
1226
|
throw new Error("apply 前 workspace topology 与 current manifest 不一致");
|
|
900
1227
|
}
|
|
901
1228
|
const currentRoots = new Map(current.roots.map((root) => [root.relativeRoot, root]));
|
|
@@ -904,14 +1231,6 @@ export class RestoreEngine {
|
|
|
904
1231
|
if (currentRoot !== undefined && targetRoot.state === "active" && currentRoot.state !== "active") {
|
|
905
1232
|
throw new Error(`restore 不能把 inactive root 物化为 active:${targetRoot.relativeRoot}`);
|
|
906
1233
|
}
|
|
907
|
-
if (
|
|
908
|
-
currentRoot !== undefined &&
|
|
909
|
-
targetRoot.state === "active" &&
|
|
910
|
-
(currentRoot.sourceIdentity !== targetRoot.sourceIdentity ||
|
|
911
|
-
currentRoot.privateRepositoryId !== targetRoot.privateRepositoryId)
|
|
912
|
-
) {
|
|
913
|
-
throw new Error(`restore boundary root identity 冲突:${targetRoot.relativeRoot}`);
|
|
914
|
-
}
|
|
915
1234
|
}
|
|
916
1235
|
}
|
|
917
1236
|
|
|
@@ -926,12 +1245,13 @@ export class RestoreEngine {
|
|
|
926
1245
|
const paths = [...new Set([...currentPaths.keys(), ...targetPaths.keys()])]
|
|
927
1246
|
.filter((path) => scope === undefined || scope.has(path))
|
|
928
1247
|
.sort(comparePaths);
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
1248
|
+
const results: Array<boolean | undefined> = new Array(paths.length);
|
|
1249
|
+
let nextIndex = 0;
|
|
1250
|
+
let stop = false;
|
|
1251
|
+
let failure: unknown;
|
|
1252
|
+
let failureIndex: number | undefined;
|
|
1253
|
+
const verifyPath = async (path: string): Promise<boolean> => {
|
|
1254
|
+
if (await this.pathIsShadowedByTarget(target.manifestId, path, targetPaths)) return true;
|
|
935
1255
|
const currentPath = currentPaths.get(path);
|
|
936
1256
|
const targetPath = targetPaths.get(path);
|
|
937
1257
|
const matchesCurrent = currentPath !== undefined &&
|
|
@@ -941,7 +1261,37 @@ export class RestoreEngine {
|
|
|
941
1261
|
const matchesAbsentSide = !matchesCurrent && !matchesTarget &&
|
|
942
1262
|
(currentPath === undefined || targetPath === undefined) &&
|
|
943
1263
|
await this.pathIsAbsent(path);
|
|
944
|
-
|
|
1264
|
+
return matchesCurrent || matchesTarget || matchesAbsentSide;
|
|
1265
|
+
};
|
|
1266
|
+
const worker = async (): Promise<void> => {
|
|
1267
|
+
while (!stop && nextIndex < paths.length) {
|
|
1268
|
+
const index = nextIndex;
|
|
1269
|
+
nextIndex += 1;
|
|
1270
|
+
try {
|
|
1271
|
+
const ok = await verifyPath(paths[index]!);
|
|
1272
|
+
results[index] = ok;
|
|
1273
|
+
if (!ok) stop = true;
|
|
1274
|
+
} catch (error) {
|
|
1275
|
+
if (failureIndex === undefined || index < failureIndex) {
|
|
1276
|
+
failure = error;
|
|
1277
|
+
failureIndex = index;
|
|
1278
|
+
}
|
|
1279
|
+
stop = true;
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
};
|
|
1283
|
+
if (paths.length > 0) {
|
|
1284
|
+
await Promise.all(Array.from(
|
|
1285
|
+
{ length: Math.min(RESTORE_FILE_VERIFY_CONCURRENCY, paths.length) },
|
|
1286
|
+
() => worker(),
|
|
1287
|
+
));
|
|
1288
|
+
}
|
|
1289
|
+
let verifiedPaths = 0;
|
|
1290
|
+
for (let index = 0; index < results.length; index += 1) {
|
|
1291
|
+
const ok = results[index];
|
|
1292
|
+
if (ok === false) return { ok: false, verifiedPaths, totalPaths: paths.length };
|
|
1293
|
+
if (ok === undefined) {
|
|
1294
|
+
if (failureIndex === index && failure !== undefined) throw failure;
|
|
945
1295
|
return { ok: false, verifiedPaths, totalPaths: paths.length };
|
|
946
1296
|
}
|
|
947
1297
|
verifiedPaths += 1;
|
|
@@ -1168,7 +1518,13 @@ export class RestoreEngine {
|
|
|
1168
1518
|
await this.writePlannedPaths(current.manifestId, currentPaths, rollbackPlan.writePaths, context);
|
|
1169
1519
|
const topologyAfter = await this.discovery.discover(this.workspaceRoot);
|
|
1170
1520
|
assertUnchangedTopology(topologyBefore, topologyAfter);
|
|
1171
|
-
await this.assertCompleteVisibleSubset(
|
|
1521
|
+
await this.assertCompleteVisibleSubset(
|
|
1522
|
+
topologyAfter,
|
|
1523
|
+
[current],
|
|
1524
|
+
options.mutationJournal,
|
|
1525
|
+
[],
|
|
1526
|
+
this.completeCoverageOwnedPaths(scopePaths, [currentPaths]),
|
|
1527
|
+
);
|
|
1172
1528
|
const verification = await this.verifyTarget(
|
|
1173
1529
|
current,
|
|
1174
1530
|
targetPaths,
|
|
@@ -1205,7 +1561,13 @@ export class RestoreEngine {
|
|
|
1205
1561
|
try {
|
|
1206
1562
|
const topologyAfter = await this.discovery.discover(this.workspaceRoot);
|
|
1207
1563
|
assertUnchangedTopology(topologyBefore, topologyAfter);
|
|
1208
|
-
await this.assertCompleteVisibleSubset(
|
|
1564
|
+
await this.assertCompleteVisibleSubset(
|
|
1565
|
+
topologyAfter,
|
|
1566
|
+
[current],
|
|
1567
|
+
options.mutationJournal,
|
|
1568
|
+
[],
|
|
1569
|
+
this.completeCoverageOwnedPaths(scopePaths, [currentPaths]),
|
|
1570
|
+
);
|
|
1209
1571
|
const verification = await this.verifyTarget(
|
|
1210
1572
|
current,
|
|
1211
1573
|
targetPaths,
|
|
@@ -1397,16 +1759,17 @@ export class RestoreEngine {
|
|
|
1397
1759
|
allowedManifests: readonly SnapshotManifest[],
|
|
1398
1760
|
mutationJournal?: MutationJournal,
|
|
1399
1761
|
extraExclusions: readonly string[] = [],
|
|
1762
|
+
ownedPaths?: readonly (ReadonlyMap<string, OwnedPath> | undefined)[],
|
|
1400
1763
|
): Promise<void> {
|
|
1401
1764
|
if (allowedManifests.some((manifest) => manifest.coverage !== "complete")) {
|
|
1402
1765
|
return;
|
|
1403
1766
|
}
|
|
1404
1767
|
const allowedPaths = new Set<string>();
|
|
1405
|
-
for (const manifest of allowedManifests) {
|
|
1768
|
+
for (const [index, manifest] of allowedManifests.entries()) {
|
|
1406
1769
|
for (const path of ignoredWorkspacePaths(manifest)) {
|
|
1407
1770
|
allowedPaths.add(path);
|
|
1408
1771
|
}
|
|
1409
|
-
const paths = await this.readOwnedPaths(manifest);
|
|
1772
|
+
const paths = ownedPaths?.[index] ?? await this.readOwnedPaths(manifest);
|
|
1410
1773
|
for (const [path, owned] of paths) {
|
|
1411
1774
|
if (owned.entry.kind !== "directory") {
|
|
1412
1775
|
allowedPaths.add(path);
|
|
@@ -1435,23 +1798,18 @@ export class RestoreEngine {
|
|
|
1435
1798
|
deletePaths: readonly string[],
|
|
1436
1799
|
scopePaths?: readonly string[],
|
|
1437
1800
|
): Promise<{ verifiedPaths: number; totalPaths: number; pathFingerprints: string[] }> {
|
|
1438
|
-
const pathFingerprints: string[] = [];
|
|
1439
1801
|
const scope = scopePaths === undefined ? undefined : new Set(scopePaths);
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
) {
|
|
1452
|
-
continue;
|
|
1453
|
-
}
|
|
1454
|
-
totalPaths += 1;
|
|
1802
|
+
const scopedTargets = [...targetPaths].filter(([path]) => scope === undefined || scope.has(path));
|
|
1803
|
+
const pathFingerprints = await mapConcurrentOrdered(
|
|
1804
|
+
scopedTargets,
|
|
1805
|
+
RESTORE_FILE_VERIFY_CONCURRENCY,
|
|
1806
|
+
([, owned]) => this.verifyEntry(target.manifestId, owned),
|
|
1807
|
+
);
|
|
1808
|
+
const remainingDeletes = deletePaths.filter((path) =>
|
|
1809
|
+
!targetPaths.has(path) &&
|
|
1810
|
+
currentPaths.get(path)?.entry.kind !== "directory" &&
|
|
1811
|
+
!hasNonDirectoryAncestor(path, targetPaths));
|
|
1812
|
+
await mapConcurrentOrdered(remainingDeletes, RESTORE_FILE_VERIFY_CONCURRENCY, async (path) => {
|
|
1455
1813
|
try {
|
|
1456
1814
|
await lstat(this.absolutePath(path));
|
|
1457
1815
|
throw new Error(`目标应删除的路径仍然存在:${path}`);
|
|
@@ -1460,11 +1818,10 @@ export class RestoreEngine {
|
|
|
1460
1818
|
throw error;
|
|
1461
1819
|
}
|
|
1462
1820
|
}
|
|
1463
|
-
|
|
1464
|
-
}
|
|
1821
|
+
});
|
|
1465
1822
|
return {
|
|
1466
|
-
verifiedPaths,
|
|
1467
|
-
totalPaths,
|
|
1823
|
+
verifiedPaths: pathFingerprints.length + remainingDeletes.length,
|
|
1824
|
+
totalPaths: pathFingerprints.length + remainingDeletes.length,
|
|
1468
1825
|
pathFingerprints,
|
|
1469
1826
|
};
|
|
1470
1827
|
}
|
|
@@ -1652,6 +2009,28 @@ function sameEntry(left: RestorePath, right: RestorePath): boolean {
|
|
|
1652
2009
|
left.linkText === right.linkText;
|
|
1653
2010
|
}
|
|
1654
2011
|
|
|
2012
|
+
// sourceIdentity/privateRepositoryId 会随 git remote 配置漂移(例如后来补充 remote origin),
|
|
2013
|
+
// 它们是仓库元数据而非工作区内容;restore 的内容安全由逐文件校验
|
|
2014
|
+
// (verifyKnownState、assertCompleteVisibleSubset、verifyTarget)保证。
|
|
2015
|
+
// 因此这里只比较结构性拓扑字段:root 集合、parentRoot、state 与 gitlinkOid。
|
|
2016
|
+
function sameTopologyModuloIdentity(actual: RootTopology, expected: SnapshotManifest): boolean {
|
|
2017
|
+
if (actual.workspaceIdentity !== expected.workspaceIdentity) return false;
|
|
2018
|
+
const expectedRoots = new Map(expected.roots.map((root) => [root.relativeRoot, root]));
|
|
2019
|
+
if (actual.roots.length !== expectedRoots.size) return false;
|
|
2020
|
+
for (const root of actual.roots) {
|
|
2021
|
+
const expectedRoot = expectedRoots.get(root.relativeRoot);
|
|
2022
|
+
if (
|
|
2023
|
+
expectedRoot === undefined ||
|
|
2024
|
+
expectedRoot.parentRoot !== root.parentRoot ||
|
|
2025
|
+
expectedRoot.state !== root.state ||
|
|
2026
|
+
(expectedRoot.gitlinkOid ?? null) !== (root.gitlinkOid ?? null)
|
|
2027
|
+
) {
|
|
2028
|
+
return false;
|
|
2029
|
+
}
|
|
2030
|
+
}
|
|
2031
|
+
return true;
|
|
2032
|
+
}
|
|
2033
|
+
|
|
1655
2034
|
function assertCompatibleManifests(
|
|
1656
2035
|
current: SnapshotManifest,
|
|
1657
2036
|
target: SnapshotManifest,
|
|
@@ -1903,6 +2282,40 @@ function durablePairKey(
|
|
|
1903
2282
|
return `${currentManifestId}\0${targetManifestId}\0${checksum(canonicalJson([...scopePaths].sort(comparePaths)))}`;
|
|
1904
2283
|
}
|
|
1905
2284
|
|
|
2285
|
+
function isDigest(value: unknown): value is string {
|
|
2286
|
+
return typeof value === "string" && /^[0-9a-f]{64}$/.test(value);
|
|
2287
|
+
}
|
|
2288
|
+
|
|
2289
|
+
function parseDurableCacheIndex(value: unknown): DurableCacheIndex {
|
|
2290
|
+
if (!isRecord(value) || value.schemaVersion !== 1 || !Array.isArray(value.entries)) {
|
|
2291
|
+
throw new Error("durable cache index 无效");
|
|
2292
|
+
}
|
|
2293
|
+
const entries: DurableCacheIndexEntry[] = [];
|
|
2294
|
+
for (const entry of value.entries) {
|
|
2295
|
+
if (
|
|
2296
|
+
!isRecord(entry) ||
|
|
2297
|
+
!isDigest(entry.currentManifestId) ||
|
|
2298
|
+
!isDigest(entry.targetManifestId) ||
|
|
2299
|
+
!isDigest(entry.planDigest) ||
|
|
2300
|
+
!isDigest(entry.packChecksum) ||
|
|
2301
|
+
!Array.isArray(entry.scopePaths) ||
|
|
2302
|
+
entry.scopePaths.some((path) => typeof path !== "string")
|
|
2303
|
+
) continue;
|
|
2304
|
+
entries.push({
|
|
2305
|
+
currentManifestId: entry.currentManifestId as ManifestId,
|
|
2306
|
+
targetManifestId: entry.targetManifestId as ManifestId,
|
|
2307
|
+
scopePaths: entry.scopePaths.filter((path): path is string => typeof path === "string"),
|
|
2308
|
+
planDigest: entry.planDigest,
|
|
2309
|
+
packChecksum: entry.packChecksum,
|
|
2310
|
+
});
|
|
2311
|
+
}
|
|
2312
|
+
return { schemaVersion: 1, entries };
|
|
2313
|
+
}
|
|
2314
|
+
|
|
2315
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
2316
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2317
|
+
}
|
|
2318
|
+
|
|
1906
2319
|
function hasErrorCode(error: unknown, code: string): error is NodeJS.ErrnoException {
|
|
1907
2320
|
return typeof error === "object" && error !== null && "code" in error && error.code === code;
|
|
1908
2321
|
}
|
package/src/root-discovery.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { lstat, readdir, realpath } from "node:fs/promises";
|
|
1
|
+
import { lstat, readdir, readFile, realpath, stat } from "node:fs/promises";
|
|
2
2
|
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
3
3
|
|
|
4
4
|
import { checksum, topologyFingerprint } from "./encoding.ts";
|
|
@@ -6,6 +6,7 @@ import { GitRunner } from "./git-runner.ts";
|
|
|
6
6
|
import type { DiscoveryRoot } from "./model.ts";
|
|
7
7
|
|
|
8
8
|
const DIRECTORY_SCAN_CONCURRENCY = 16;
|
|
9
|
+
const GIT_POINTER_MAX_BYTES = 4096;
|
|
9
10
|
|
|
10
11
|
interface RepositoryInfo {
|
|
11
12
|
readonly absoluteRoot: string;
|
|
@@ -17,6 +18,7 @@ interface RepositoryInfo {
|
|
|
17
18
|
type RepositoryInspection =
|
|
18
19
|
| { readonly kind: "active"; readonly repository: RepositoryInfo }
|
|
19
20
|
| { readonly kind: "broken"; readonly absoluteRoot: string }
|
|
21
|
+
| { readonly kind: "stale"; readonly absoluteRoot: string }
|
|
20
22
|
| { readonly kind: "absent" };
|
|
21
23
|
|
|
22
24
|
interface DiscoveredRoot {
|
|
@@ -68,7 +70,7 @@ export class RootDiscovery {
|
|
|
68
70
|
outerRepository.repository.absoluteRoot,
|
|
69
71
|
this.activeRoot(workspaceIdentity, outerRepository.repository),
|
|
70
72
|
);
|
|
71
|
-
} else if (outerRepository.kind === "broken") {
|
|
73
|
+
} else if (outerRepository.kind === "broken" || outerRepository.kind === "stale") {
|
|
72
74
|
activeRoots.set(outerRepository.absoluteRoot, brokenRoot(workspaceIdentity, outerRepository.absoluteRoot));
|
|
73
75
|
} else {
|
|
74
76
|
activeRoots.set(workspaceIdentity, syntheticRoot(workspaceIdentity));
|
|
@@ -117,6 +119,8 @@ export class RootDiscovery {
|
|
|
117
119
|
);
|
|
118
120
|
} else if (inspection.kind === "broken") {
|
|
119
121
|
activeRoots.set(inspection.absoluteRoot, brokenRoot(workspaceIdentity, inspection.absoluteRoot));
|
|
122
|
+
} else if (inspection.kind === "stale") {
|
|
123
|
+
activeRoots.set(inspection.absoluteRoot, staleWorktreeRoot(workspaceIdentity, inspection.absoluteRoot));
|
|
120
124
|
}
|
|
121
125
|
if (!await isSafeDirectory(candidate.path, workspaceIdentity)) return [];
|
|
122
126
|
}
|
|
@@ -145,6 +149,9 @@ export class RootDiscovery {
|
|
|
145
149
|
if (marker === "invalid") {
|
|
146
150
|
return { kind: "broken", absoluteRoot };
|
|
147
151
|
}
|
|
152
|
+
if (await deadWorktreePointer(absoluteRoot)) {
|
|
153
|
+
return { kind: "stale", absoluteRoot };
|
|
154
|
+
}
|
|
148
155
|
|
|
149
156
|
const details = await this.gitOutput([
|
|
150
157
|
"-C",
|
|
@@ -274,6 +281,46 @@ function brokenRoot(workspaceIdentity: string, absoluteRoot: string): Discovered
|
|
|
274
281
|
};
|
|
275
282
|
}
|
|
276
283
|
|
|
284
|
+
// git worktree 的 .git 文件是 "gitdir: <path>" 指针;项目从其他机器/位置搬移后,
|
|
285
|
+
// 指针常指向本机不存在的 gitdir(例如虚拟机共享目录的绝对路径)。这种可证明失效的
|
|
286
|
+
// 指针不再代表可用仓库,按未初始化根处理:内容不进入快照,也不会被 restore 触碰。
|
|
287
|
+
async function deadWorktreePointer(absoluteRoot: string): Promise<boolean> {
|
|
288
|
+
const pointerPath = join(absoluteRoot, ".git");
|
|
289
|
+
let content: string;
|
|
290
|
+
try {
|
|
291
|
+
const marker = await lstat(pointerPath);
|
|
292
|
+
if (!marker.isFile()) return false;
|
|
293
|
+
content = await readFile(pointerPath, "utf8");
|
|
294
|
+
} catch {
|
|
295
|
+
return false;
|
|
296
|
+
}
|
|
297
|
+
if (content.length > GIT_POINTER_MAX_BYTES) return false;
|
|
298
|
+
const firstLine = content.split("\n", 1)[0] ?? "";
|
|
299
|
+
const match = /^gitdir: (.+)$/.exec(firstLine.trimEnd());
|
|
300
|
+
if (match === null) return false;
|
|
301
|
+
const gitDirectory = isAbsolute(match[1]) ? resolve(match[1]) : resolve(absoluteRoot, match[1]);
|
|
302
|
+
try {
|
|
303
|
+
await stat(gitDirectory);
|
|
304
|
+
return false;
|
|
305
|
+
} catch (error) {
|
|
306
|
+
return hasErrorCode(error, "ENOENT");
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function staleWorktreeRoot(workspaceIdentity: string, absoluteRoot: string): DiscoveredRoot {
|
|
311
|
+
const relativeRoot = workspaceRelativePath(workspaceIdentity, absoluteRoot);
|
|
312
|
+
const sourceIdentity = `stale-worktree:${absoluteRoot}`;
|
|
313
|
+
return {
|
|
314
|
+
absoluteRoot,
|
|
315
|
+
relativeRoot,
|
|
316
|
+
gitBacked: true,
|
|
317
|
+
state: "uninitialized",
|
|
318
|
+
sourceIdentity,
|
|
319
|
+
privateRepositoryId: checksum(sourceIdentity),
|
|
320
|
+
treeId: null,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
277
324
|
function buildRoots(discovered: readonly DiscoveredRoot[]): DiscoveryRoot[] {
|
|
278
325
|
const unique = new Map<string, DiscoveredRoot>();
|
|
279
326
|
for (const root of discovered) {
|
package/src/snapshot-store.ts
CHANGED
|
@@ -210,6 +210,8 @@ export class SnapshotStore {
|
|
|
210
210
|
private readonly blobCache = new Map<string, CachedBlob>();
|
|
211
211
|
private readonly visibleLeafCache = new Map<string, Map<string, CachedVisibleLeaf>>();
|
|
212
212
|
private readonly leafCacheDirectoriesLoaded = new Set<string>();
|
|
213
|
+
private readonly configuredPrivateRepositories = new Set<string>();
|
|
214
|
+
private readonly leafCacheDirtyDirectories = new Set<string>();
|
|
213
215
|
private blobCacheBytes = 0;
|
|
214
216
|
|
|
215
217
|
constructor(options: SnapshotStoreOptions = {}) {
|
|
@@ -741,6 +743,61 @@ export class SnapshotStore {
|
|
|
741
743
|
return (await this.readBlobOperation(id, [{ rootPath, blobId, relativePath }], false))[0]!;
|
|
742
744
|
}
|
|
743
745
|
|
|
746
|
+
/** 按 root 批量预取普通文件 blob;membership 与 manifest 校验仍走只读路径。 */
|
|
747
|
+
async prefetchBlobs(id: ManifestId, requests: readonly SnapshotBlobRequest[]): Promise<void> {
|
|
748
|
+
if (requests.length === 0) return;
|
|
749
|
+
for (const request of requests) {
|
|
750
|
+
relativeSafePath("/", request.rootPath);
|
|
751
|
+
if (!isObjectId(request.blobId)) {
|
|
752
|
+
throw new SnapshotStoreError("object_missing", "blob ID 无效");
|
|
753
|
+
}
|
|
754
|
+
if (request.relativePath === undefined) {
|
|
755
|
+
throw new SnapshotStoreError("object_missing", "blob 预取必须提供 root-relative path");
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
const manifestPath = await this.findManifestPath(id);
|
|
759
|
+
const manifest = await this.loadManifest(id);
|
|
760
|
+
const roots = new Map(manifest.roots.map((root) => [root.relativeRoot, root]));
|
|
761
|
+
const storeDirectory = dirname(dirname(manifestPath));
|
|
762
|
+
const byRoot = new Map<string, SnapshotBlobRequest[]>();
|
|
763
|
+
for (const request of requests) {
|
|
764
|
+
const grouped = byRoot.get(request.rootPath) ?? [];
|
|
765
|
+
grouped.push(request);
|
|
766
|
+
byRoot.set(request.rootPath, grouped);
|
|
767
|
+
}
|
|
768
|
+
try {
|
|
769
|
+
const grouped = new Map<string, Map<string, CapturedTreeEntry>>();
|
|
770
|
+
for (const [rootPath, rootRequests] of byRoot) {
|
|
771
|
+
const root = roots.get(rootPath);
|
|
772
|
+
if (root === undefined) {
|
|
773
|
+
throw new SnapshotStoreError("root_not_found", "manifest 中不存在指定 root");
|
|
774
|
+
}
|
|
775
|
+
if (root.state !== "active" || root.treeId === null) {
|
|
776
|
+
throw new SnapshotStoreError("object_missing", "指定 root 没有可读取的 tree");
|
|
777
|
+
}
|
|
778
|
+
const gitDirectory = this.rootGitDirectory(storeDirectory, root);
|
|
779
|
+
const entries = await this.readTreeEntries(gitDirectory, root.treeId);
|
|
780
|
+
const byPath = new Map(entries.map((entry) => [entry.relativePath, entry]));
|
|
781
|
+
const unique = grouped.get(gitDirectory) ?? new Map<string, CapturedTreeEntry>();
|
|
782
|
+
for (const request of rootRequests) {
|
|
783
|
+
const safeRelativePath = relativeSafePath("/", request.relativePath!);
|
|
784
|
+
const entry = byPath.get(safeRelativePath);
|
|
785
|
+
if (entry === undefined || entry.objectId !== request.blobId) {
|
|
786
|
+
throw new SnapshotStoreError("object_missing", "blob 不属于指定 root tree path");
|
|
787
|
+
}
|
|
788
|
+
unique.set(entry.objectId, entry);
|
|
789
|
+
}
|
|
790
|
+
grouped.set(gitDirectory, unique);
|
|
791
|
+
}
|
|
792
|
+
for (const [gitDirectory, unique] of grouped) {
|
|
793
|
+
await this.preloadBlobBytes(gitDirectory, [...unique.values()]);
|
|
794
|
+
}
|
|
795
|
+
} catch (error) {
|
|
796
|
+
if (error instanceof SnapshotStoreError) throw error;
|
|
797
|
+
throw new SnapshotStoreError("object_missing", "blob 无法预取", { cause: error });
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
744
801
|
private readBlobsValidated(
|
|
745
802
|
id: ManifestId,
|
|
746
803
|
requests: readonly SnapshotBlobRequest[],
|
|
@@ -1155,9 +1212,10 @@ export class SnapshotStore {
|
|
|
1155
1212
|
|
|
1156
1213
|
private rememberVisibleLeaves(update: VisibleLeafCacheUpdate): void {
|
|
1157
1214
|
const { gitDirectory, staged, inclusions } = update;
|
|
1215
|
+
const previous = this.visibleLeafCache.get(gitDirectory);
|
|
1158
1216
|
const cache = inclusions !== null && inclusions.length === 0
|
|
1159
1217
|
? new Map<string, CachedVisibleLeaf>()
|
|
1160
|
-
: new Map(
|
|
1218
|
+
: new Map(previous);
|
|
1161
1219
|
if (inclusions !== null && inclusions.length > 0) {
|
|
1162
1220
|
for (const relativePath of cache.keys()) {
|
|
1163
1221
|
if (inclusions.some((inclusion) => isPathAtOrBelow(inclusion, relativePath))) {
|
|
@@ -1174,6 +1232,9 @@ export class SnapshotStore {
|
|
|
1174
1232
|
}
|
|
1175
1233
|
cache.set(leaf.relativePath, { ...leaf, objectId, verifiedAtNs: staged.verifiedAtNs });
|
|
1176
1234
|
}
|
|
1235
|
+
if (!samePersistedLeafCache(previous, cache)) {
|
|
1236
|
+
this.leafCacheDirtyDirectories.add(storeDirectoryForGitDirectory(gitDirectory));
|
|
1237
|
+
}
|
|
1177
1238
|
this.visibleLeafCache.set(gitDirectory, cache);
|
|
1178
1239
|
}
|
|
1179
1240
|
|
|
@@ -1210,6 +1271,7 @@ export class SnapshotStore {
|
|
|
1210
1271
|
|
|
1211
1272
|
/** 把当前 storeDirectory 范围内的叶子缓存原子写入磁盘(best-effort)。 */
|
|
1212
1273
|
private async persistLeafCache(storeDirectory: string): Promise<void> {
|
|
1274
|
+
if (!this.leafCacheDirtyDirectories.has(storeDirectory)) return;
|
|
1213
1275
|
const prefix = `${storeDirectory}${sep}`;
|
|
1214
1276
|
const entries: Record<string, Record<string, PersistedLeafCacheEntry>> = {};
|
|
1215
1277
|
for (const [gitDirectory, cache] of this.visibleLeafCache) {
|
|
@@ -1235,6 +1297,7 @@ export class SnapshotStore {
|
|
|
1235
1297
|
Buffer.from(JSON.stringify({ schemaVersion: 1, entries }), "utf8"),
|
|
1236
1298
|
0o600,
|
|
1237
1299
|
);
|
|
1300
|
+
this.leafCacheDirtyDirectories.delete(storeDirectory);
|
|
1238
1301
|
} catch {
|
|
1239
1302
|
// 缓存写入是 best-effort:失败只影响下次性能,不影响正确性。
|
|
1240
1303
|
}
|
|
@@ -1428,9 +1491,11 @@ export class SnapshotStore {
|
|
|
1428
1491
|
}
|
|
1429
1492
|
|
|
1430
1493
|
private async configurePrivateRepository(gitDirectory: string): Promise<void> {
|
|
1494
|
+
if (this.configuredPrivateRepositories.has(gitDirectory)) return;
|
|
1431
1495
|
const environment = cleanGitEnvironment();
|
|
1432
1496
|
await this.runGit(["--git-dir", gitDirectory, "config", "gc.auto", "0"], { env: environment });
|
|
1433
1497
|
await this.runGit(["--git-dir", gitDirectory, "config", "maintenance.auto", "false"], { env: environment });
|
|
1498
|
+
this.configuredPrivateRepositories.add(gitDirectory);
|
|
1434
1499
|
}
|
|
1435
1500
|
|
|
1436
1501
|
private async assertNoAlternates(gitDirectory: string): Promise<void> {
|
|
@@ -1982,6 +2047,35 @@ function blobCacheKey(gitDirectory: string, objectId: string): string {
|
|
|
1982
2047
|
return `${gitDirectory}\0${objectId}`;
|
|
1983
2048
|
}
|
|
1984
2049
|
|
|
2050
|
+
function storeDirectoryForGitDirectory(gitDirectory: string): string {
|
|
2051
|
+
return dirname(dirname(dirname(gitDirectory)));
|
|
2052
|
+
}
|
|
2053
|
+
|
|
2054
|
+
function samePersistedLeafCache(
|
|
2055
|
+
left: ReadonlyMap<string, CachedVisibleLeaf> | undefined,
|
|
2056
|
+
right: ReadonlyMap<string, CachedVisibleLeaf>,
|
|
2057
|
+
): boolean {
|
|
2058
|
+
if (left === undefined) return right.size === 0;
|
|
2059
|
+
if (left.size !== right.size) return false;
|
|
2060
|
+
for (const [path, entry] of right) {
|
|
2061
|
+
const existing = left.get(path);
|
|
2062
|
+
const existingTrusted = existing !== undefined &&
|
|
2063
|
+
existing.verifiedAtNs > existing.changedAtNs + RACY_CLEAN_WINDOW_NS;
|
|
2064
|
+
const entryTrusted = entry.verifiedAtNs > entry.changedAtNs + RACY_CLEAN_WINDOW_NS;
|
|
2065
|
+
if (
|
|
2066
|
+
existing === undefined ||
|
|
2067
|
+
existing.kind !== entry.kind ||
|
|
2068
|
+
existing.mode !== entry.mode ||
|
|
2069
|
+
existing.fingerprint !== entry.fingerprint ||
|
|
2070
|
+
existing.cacheable !== entry.cacheable ||
|
|
2071
|
+
existing.objectId !== entry.objectId ||
|
|
2072
|
+
existing.changedAtNs !== entry.changedAtNs ||
|
|
2073
|
+
existingTrusted !== entryTrusted
|
|
2074
|
+
) return false;
|
|
2075
|
+
}
|
|
2076
|
+
return true;
|
|
2077
|
+
}
|
|
2078
|
+
|
|
1985
2079
|
function blobReadBatches(entries: readonly CapturedTreeEntry[]): CapturedTreeEntry[][] {
|
|
1986
2080
|
const result: CapturedTreeEntry[][] = [];
|
|
1987
2081
|
let batch: CapturedTreeEntry[] = [];
|