@forgeax/engine-ddc 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/LICENSE +202 -0
- package/README.md +136 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/__tests__/concurrency.integration.test.d.ts +2 -0
- package/dist/__tests__/concurrency.integration.test.d.ts.map +1 -0
- package/dist/__tests__/consumer-path.integration.test.d.ts +2 -0
- package/dist/__tests__/consumer-path.integration.test.d.ts.map +1 -0
- package/dist/__tests__/crash-recovery.integration.test.d.ts +2 -0
- package/dist/__tests__/crash-recovery.integration.test.d.ts.map +1 -0
- package/dist/__tests__/entry-store.integration.test.d.ts +2 -0
- package/dist/__tests__/entry-store.integration.test.d.ts.map +1 -0
- package/dist/__tests__/errors.unit.test.d.ts +2 -0
- package/dist/__tests__/errors.unit.test.d.ts.map +1 -0
- package/dist/__tests__/key.unit.test.d.ts +2 -0
- package/dist/__tests__/key.unit.test.d.ts.map +1 -0
- package/dist/__tests__/layout.unit.test.d.ts +2 -0
- package/dist/__tests__/layout.unit.test.d.ts.map +1 -0
- package/dist/__tests__/lifecycle.unit.test.d.ts +2 -0
- package/dist/__tests__/lifecycle.unit.test.d.ts.map +1 -0
- package/dist/__tests__/multiprocess-gc.integration.test.d.ts +2 -0
- package/dist/__tests__/multiprocess-gc.integration.test.d.ts.map +1 -0
- package/dist/__tests__/multiprocess-lifecycle.integration.test.d.ts +2 -0
- package/dist/__tests__/multiprocess-lifecycle.integration.test.d.ts.map +1 -0
- package/dist/__tests__/multiprocess-worker.d.ts +2 -0
- package/dist/__tests__/multiprocess-worker.d.ts.map +1 -0
- package/dist/__tests__/status-root-kind-owner.test-d.d.ts +2 -0
- package/dist/__tests__/status-root-kind-owner.test-d.d.ts.map +1 -0
- package/dist/__tests__/status.unit.test.d.ts +2 -0
- package/dist/__tests__/status.unit.test.d.ts.map +1 -0
- package/dist/entry-store.d.ts +52 -0
- package/dist/entry-store.d.ts.map +1 -0
- package/dist/entry-store.mjs +339 -0
- package/dist/entry-store.mjs.map +1 -0
- package/dist/errors.d.ts +58 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.mjs +109 -0
- package/dist/errors.mjs.map +1 -0
- package/dist/gc.d.ts +13 -0
- package/dist/gc.d.ts.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +896 -0
- package/dist/index.mjs.map +1 -0
- package/dist/key.d.ts +13 -0
- package/dist/key.d.ts.map +1 -0
- package/dist/key.mjs +38 -0
- package/dist/key.mjs.map +1 -0
- package/dist/layout.d.ts +51 -0
- package/dist/layout.d.ts.map +1 -0
- package/dist/layout.mjs +68 -0
- package/dist/layout.mjs.map +1 -0
- package/dist/lifecycle.d.ts +55 -0
- package/dist/lifecycle.d.ts.map +1 -0
- package/dist/runtime-scope.d.ts +10 -0
- package/dist/runtime-scope.d.ts.map +1 -0
- package/dist/status.d.ts +39 -0
- package/dist/status.d.ts.map +1 -0
- package/dist/status.mjs +20 -0
- package/dist/status.mjs.map +1 -0
- package/package.json +98 -0
- package/src/__tests__/concurrency.integration.test.ts +53 -0
- package/src/__tests__/consumer-path.integration.test.ts +61 -0
- package/src/__tests__/crash-recovery.integration.test.ts +84 -0
- package/src/__tests__/entry-store.integration.test.ts +83 -0
- package/src/__tests__/errors.unit.test.ts +54 -0
- package/src/__tests__/key.unit.test.ts +49 -0
- package/src/__tests__/layout.unit.test.ts +50 -0
- package/src/__tests__/lifecycle.unit.test.ts +258 -0
- package/src/__tests__/multiprocess-gc.integration.test.ts +36 -0
- package/src/__tests__/multiprocess-lifecycle.integration.test.ts +109 -0
- package/src/__tests__/multiprocess-worker.ts +41 -0
- package/src/__tests__/status-root-kind-owner.test-d.ts +42 -0
- package/src/__tests__/status.unit.test.ts +56 -0
- package/src/entry-store.ts +307 -0
- package/src/errors.ts +164 -0
- package/src/gc.ts +34 -0
- package/src/index.ts +65 -0
- package/src/key.ts +46 -0
- package/src/layout.ts +132 -0
- package/src/lifecycle.ts +549 -0
- package/src/runtime-scope.ts +87 -0
- package/src/status.ts +58 -0
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
5
|
+
import { DdcEntryStore, ddcOutputDigest } from '../entry-store.js';
|
|
6
|
+
import { DdcStoreError } from '../errors.js';
|
|
7
|
+
import { DdcLifecycle } from '../lifecycle.js';
|
|
8
|
+
|
|
9
|
+
const GUID = '019e3969-1d48-7c3b-ac24-6d68f457065f';
|
|
10
|
+
const KEY_A = 'a'.repeat(64);
|
|
11
|
+
const KEY_B = 'b'.repeat(64);
|
|
12
|
+
const KEY_C = 'c'.repeat(64);
|
|
13
|
+
|
|
14
|
+
async function snapshotFiles(root: string): Promise<Record<string, string>> {
|
|
15
|
+
const files: Record<string, string> = {};
|
|
16
|
+
async function visit(directory: string): Promise<void> {
|
|
17
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
18
|
+
const path = join(directory, entry.name);
|
|
19
|
+
if (entry.isDirectory()) {
|
|
20
|
+
await visit(path);
|
|
21
|
+
} else {
|
|
22
|
+
files[path.slice(root.length + 1)] = Buffer.from(await readFile(path)).toString('base64');
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
await visit(root);
|
|
27
|
+
return files;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function expectHeadConflict(
|
|
31
|
+
operation: () => Promise<unknown>,
|
|
32
|
+
actual: 'syntax-invalid' | 'schema-invalid',
|
|
33
|
+
): Promise<void> {
|
|
34
|
+
let error: unknown;
|
|
35
|
+
try {
|
|
36
|
+
await operation();
|
|
37
|
+
} catch (caught) {
|
|
38
|
+
error = caught;
|
|
39
|
+
}
|
|
40
|
+
expect(error).toBeInstanceOf(DdcStoreError);
|
|
41
|
+
expect(error).toMatchObject({
|
|
42
|
+
code: 'ddc-head-conflict',
|
|
43
|
+
expected: 'a valid DDC head record',
|
|
44
|
+
actual,
|
|
45
|
+
detail: expect.any(String),
|
|
46
|
+
hint: 'inspect the current head and retry with a fresh revision',
|
|
47
|
+
owner: 'engine-ddc',
|
|
48
|
+
rootKind: 'project-ddc',
|
|
49
|
+
recoveryActions: [
|
|
50
|
+
{ kind: 'inspect', executable: true },
|
|
51
|
+
{ kind: 'retry', executable: true },
|
|
52
|
+
],
|
|
53
|
+
});
|
|
54
|
+
const structured = error as DdcStoreError;
|
|
55
|
+
expect(structured.detail.length).toBeLessThanOrEqual(96);
|
|
56
|
+
expect(JSON.stringify(structured)).not.toContain('/heads/');
|
|
57
|
+
expect(JSON.stringify(structured)).not.toContain('SyntaxError');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function writeEntry(root: string, key: string): Promise<void> {
|
|
61
|
+
const base = {
|
|
62
|
+
key,
|
|
63
|
+
guid: GUID,
|
|
64
|
+
payload: { key },
|
|
65
|
+
refs: [],
|
|
66
|
+
artifacts: {},
|
|
67
|
+
receipt: {
|
|
68
|
+
guid: GUID,
|
|
69
|
+
key,
|
|
70
|
+
producer: 'test',
|
|
71
|
+
inputFingerprint: key,
|
|
72
|
+
outputDigest: '',
|
|
73
|
+
},
|
|
74
|
+
} as const;
|
|
75
|
+
await new DdcEntryStore(root).write({
|
|
76
|
+
...base,
|
|
77
|
+
receipt: { ...base.receipt, outputDigest: ddcOutputDigest(base) },
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
describe('DDC lifecycle head', () => {
|
|
82
|
+
const roots: string[] = [];
|
|
83
|
+
|
|
84
|
+
afterEach(async () => {
|
|
85
|
+
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('moves missing to cooking and current only after validated commit', async () => {
|
|
89
|
+
const root = await mkdtemp(join(tmpdir(), 'forgeax-ddc-lifecycle-'));
|
|
90
|
+
roots.push(root);
|
|
91
|
+
const lifecycle = new DdcLifecycle(root);
|
|
92
|
+
|
|
93
|
+
await expect(lifecycle.inspect(GUID, KEY_A)).resolves.toMatchObject({ state: 'missing' });
|
|
94
|
+
const lease = await lifecycle.begin(GUID, KEY_A);
|
|
95
|
+
await expect(lifecycle.inspect(GUID, KEY_A)).resolves.toMatchObject({ state: 'cooking' });
|
|
96
|
+
await writeEntry(root, KEY_A);
|
|
97
|
+
await expect(lifecycle.commit(lease, KEY_A)).resolves.toEqual({
|
|
98
|
+
result: 'current',
|
|
99
|
+
key: KEY_A,
|
|
100
|
+
});
|
|
101
|
+
await expect(lifecycle.inspect(GUID, KEY_A)).resolves.toMatchObject({
|
|
102
|
+
state: 'current',
|
|
103
|
+
currentKey: KEY_A,
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('retains last-known-good when recook fails', async () => {
|
|
108
|
+
const root = await mkdtemp(join(tmpdir(), 'forgeax-ddc-lifecycle-'));
|
|
109
|
+
roots.push(root);
|
|
110
|
+
const lifecycle = new DdcLifecycle(root);
|
|
111
|
+
const first = await lifecycle.begin(GUID, KEY_A);
|
|
112
|
+
await writeEntry(root, KEY_A);
|
|
113
|
+
await lifecycle.commit(first, KEY_A);
|
|
114
|
+
const second = await lifecycle.begin(GUID, KEY_B);
|
|
115
|
+
await lifecycle.fail(second, { code: 'producer-failed', detail: 'invalid source' });
|
|
116
|
+
|
|
117
|
+
await expect(lifecycle.inspect(GUID, KEY_B)).resolves.toMatchObject({
|
|
118
|
+
state: 'failed',
|
|
119
|
+
lastKnownGoodKey: KEY_A,
|
|
120
|
+
currentKey: KEY_A,
|
|
121
|
+
failure: { code: 'producer-failed' },
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('does not promote an old result after the desired key changes', async () => {
|
|
126
|
+
const root = await mkdtemp(join(tmpdir(), 'forgeax-ddc-lifecycle-'));
|
|
127
|
+
roots.push(root);
|
|
128
|
+
const lifecycle = new DdcLifecycle(root);
|
|
129
|
+
const first = await lifecycle.begin(GUID, KEY_A);
|
|
130
|
+
await writeEntry(root, KEY_A);
|
|
131
|
+
await lifecycle.commit(first, KEY_A);
|
|
132
|
+
const second = await lifecycle.begin(GUID, KEY_B);
|
|
133
|
+
|
|
134
|
+
await expect(lifecycle.inspect(GUID, KEY_B)).resolves.toMatchObject({
|
|
135
|
+
state: 'cooking',
|
|
136
|
+
lastKnownGoodKey: KEY_A,
|
|
137
|
+
});
|
|
138
|
+
await expect(lifecycle.commit(second, KEY_A)).resolves.toEqual({
|
|
139
|
+
result: 'stale',
|
|
140
|
+
key: KEY_A,
|
|
141
|
+
});
|
|
142
|
+
await expect(lifecycle.inspect(GUID, KEY_B)).resolves.toMatchObject({
|
|
143
|
+
state: 'stale',
|
|
144
|
+
lastKnownGoodKey: KEY_A,
|
|
145
|
+
currentKey: KEY_A,
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('keeps a first cook failure failed without inventing a current or LKG', async () => {
|
|
150
|
+
const root = await mkdtemp(join(tmpdir(), 'forgeax-ddc-lifecycle-'));
|
|
151
|
+
roots.push(root);
|
|
152
|
+
const lifecycle = new DdcLifecycle(root);
|
|
153
|
+
const lease = await lifecycle.begin(GUID, KEY_A);
|
|
154
|
+
await lifecycle.fail(lease, { code: 'producer-failed', detail: 'missing input' });
|
|
155
|
+
|
|
156
|
+
await expect(lifecycle.inspect(GUID, KEY_A)).resolves.toMatchObject({
|
|
157
|
+
state: 'failed',
|
|
158
|
+
currentKey: undefined,
|
|
159
|
+
lastKnownGoodKey: undefined,
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('reports a corrupt current entry as failed with its identity preserved', async () => {
|
|
164
|
+
const root = await mkdtemp(join(tmpdir(), 'forgeax-ddc-lifecycle-'));
|
|
165
|
+
roots.push(root);
|
|
166
|
+
const lifecycle = new DdcLifecycle(root);
|
|
167
|
+
const store = new DdcEntryStore(root);
|
|
168
|
+
const firstLease = await lifecycle.begin(GUID, KEY_A);
|
|
169
|
+
await writeEntry(root, KEY_A);
|
|
170
|
+
await expect(lifecycle.commit(firstLease, KEY_A)).resolves.toEqual({
|
|
171
|
+
result: 'current',
|
|
172
|
+
key: KEY_A,
|
|
173
|
+
});
|
|
174
|
+
const lease = await lifecycle.begin(GUID, KEY_B);
|
|
175
|
+
await writeEntry(root, KEY_B);
|
|
176
|
+
await expect(lifecycle.commit(lease, KEY_B)).resolves.toEqual({
|
|
177
|
+
result: 'current',
|
|
178
|
+
key: KEY_B,
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
const receiptPath = join(root, 'entries', KEY_B, 'receipt.json');
|
|
182
|
+
const receipt = JSON.parse(await readFile(receiptPath, 'utf8')) as { outputDigest: string };
|
|
183
|
+
await writeFile(
|
|
184
|
+
receiptPath,
|
|
185
|
+
JSON.stringify({ ...receipt, outputDigest: `sha256:${'0'.repeat(64)}` }),
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
await expect(store.readChecked(KEY_B)).resolves.toMatchObject({
|
|
189
|
+
ok: false,
|
|
190
|
+
error: { code: 'ddc-entry-invalid' },
|
|
191
|
+
});
|
|
192
|
+
await expect(lifecycle.inspect(GUID, KEY_B)).resolves.toMatchObject({
|
|
193
|
+
state: 'failed',
|
|
194
|
+
currentKey: KEY_B,
|
|
195
|
+
lastKnownGoodKey: KEY_A,
|
|
196
|
+
failure: { code: 'ddc-entry-invalid' },
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it.each([
|
|
201
|
+
{ label: 'syntax-invalid', bytes: Buffer.from('{'), actual: 'syntax-invalid' as const },
|
|
202
|
+
{
|
|
203
|
+
label: 'schema-invalid',
|
|
204
|
+
bytes: Buffer.from(JSON.stringify({ guid: GUID, desiredKey: KEY_B, revision: 'bad' })),
|
|
205
|
+
actual: 'schema-invalid' as const,
|
|
206
|
+
},
|
|
207
|
+
])('refuses an existing $label head without mutation and recovers on same-lifecycle repair', async ({
|
|
208
|
+
bytes,
|
|
209
|
+
actual,
|
|
210
|
+
}) => {
|
|
211
|
+
const root = await mkdtemp(join(tmpdir(), 'forgeax-ddc-lifecycle-'));
|
|
212
|
+
roots.push(root);
|
|
213
|
+
const lifecycle = new DdcLifecycle(root);
|
|
214
|
+
const store = new DdcEntryStore(root);
|
|
215
|
+
const firstLease = await lifecycle.begin(GUID, KEY_A);
|
|
216
|
+
await writeEntry(root, KEY_A);
|
|
217
|
+
await expect(lifecycle.commit(firstLease, KEY_A)).resolves.toMatchObject({
|
|
218
|
+
result: 'current',
|
|
219
|
+
key: KEY_A,
|
|
220
|
+
});
|
|
221
|
+
const secondLease = await lifecycle.begin(GUID, KEY_B);
|
|
222
|
+
await writeEntry(root, KEY_B);
|
|
223
|
+
await expect(lifecycle.commit(secondLease, KEY_B)).resolves.toMatchObject({
|
|
224
|
+
result: 'current',
|
|
225
|
+
key: KEY_B,
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
const headPath = join(root, 'heads', `${encodeURIComponent(GUID)}.json`);
|
|
229
|
+
const validHeadBytes = await readFile(headPath);
|
|
230
|
+
const prior = await lifecycle.inspect(GUID, KEY_B);
|
|
231
|
+
expect(prior).toMatchObject({
|
|
232
|
+
state: 'current',
|
|
233
|
+
currentKey: KEY_B,
|
|
234
|
+
lastKnownGoodKey: KEY_A,
|
|
235
|
+
});
|
|
236
|
+
const entryBytes = await snapshotFiles(join(root, 'entries'));
|
|
237
|
+
await writeFile(headPath, bytes);
|
|
238
|
+
const malformedFiles = await snapshotFiles(root);
|
|
239
|
+
|
|
240
|
+
await expectHeadConflict(() => lifecycle.inspect(GUID, KEY_B), actual);
|
|
241
|
+
await expect(snapshotFiles(root)).resolves.toEqual(malformedFiles);
|
|
242
|
+
await expect(store.read(KEY_A)).resolves.toMatchObject({ key: KEY_A, guid: GUID });
|
|
243
|
+
await expect(store.read(KEY_B)).resolves.toMatchObject({ key: KEY_B, guid: GUID });
|
|
244
|
+
|
|
245
|
+
await expectHeadConflict(() => lifecycle.begin(GUID, KEY_C), actual);
|
|
246
|
+
await expect(snapshotFiles(root)).resolves.toEqual(malformedFiles);
|
|
247
|
+
await expect(snapshotFiles(join(root, 'entries'))).resolves.toEqual(entryBytes);
|
|
248
|
+
|
|
249
|
+
await writeFile(headPath, validHeadBytes);
|
|
250
|
+
await expect(lifecycle.inspect(GUID, KEY_B)).resolves.toEqual(prior);
|
|
251
|
+
await expect(lifecycle.inspect(GUID, KEY_B)).resolves.toEqual(prior);
|
|
252
|
+
|
|
253
|
+
const nextLease = await lifecycle.begin(GUID, KEY_C);
|
|
254
|
+
expect(nextLease.expectedRevision).toBe(prior.revision);
|
|
255
|
+
expect(nextLease.generation).toBe((prior.generation ?? 0) + 1);
|
|
256
|
+
await lifecycle.close(nextLease);
|
|
257
|
+
});
|
|
258
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
5
|
+
import { collectDdcGarbage } from '../gc.js';
|
|
6
|
+
import { createRuntimeScope } from '../runtime-scope.js';
|
|
7
|
+
|
|
8
|
+
describe('DDC multiprocess GC and scope protection', () => {
|
|
9
|
+
const roots: string[] = [];
|
|
10
|
+
|
|
11
|
+
afterEach(async () => {
|
|
12
|
+
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('keeps current, LKG and active lease objects reachable and scopes collision-free', async () => {
|
|
16
|
+
const root = await mkdtemp(join(tmpdir(), 'forgeax-ddc-multiprocess-gc-'));
|
|
17
|
+
roots.push(root);
|
|
18
|
+
await mkdir(join(root, 'entries', 'orphan'), { recursive: true });
|
|
19
|
+
await writeFile(join(root, 'entries', 'orphan', 'marker'), 'orphan');
|
|
20
|
+
|
|
21
|
+
const first = await createRuntimeScope(root, 'a/b');
|
|
22
|
+
const sibling = await createRuntimeScope(root, 'a_b');
|
|
23
|
+
const result = await collectDdcGarbage(root, {
|
|
24
|
+
currentKeys: ['current-key'],
|
|
25
|
+
lastKnownGoodKeys: ['lkg-key'],
|
|
26
|
+
activeLeaseKeys: ['lease-key'],
|
|
27
|
+
scopeIds: [first.scopeId, sibling.scopeId],
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
expect(first.scopeHash).not.toBe(sibling.scopeHash);
|
|
31
|
+
expect(result.deleted).not.toContain('current-key');
|
|
32
|
+
expect(result.deleted).not.toContain('lkg-key');
|
|
33
|
+
expect(result.deleted).not.toContain('lease-key');
|
|
34
|
+
expect(result.deleted).toContain('orphan');
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { dirname, join, resolve } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
6
|
+
import { spawnDdcWorker } from './multiprocess-worker.js';
|
|
7
|
+
|
|
8
|
+
const sourceRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
9
|
+
const entryModule = join(sourceRoot, 'entry-store.ts');
|
|
10
|
+
const lifecycleModule = join(sourceRoot, 'lifecycle.ts');
|
|
11
|
+
const guid = '019e3969-1d48-7c3b-ac24-6d68f457065f';
|
|
12
|
+
const key = 'a'.repeat(64);
|
|
13
|
+
|
|
14
|
+
function runWorker(
|
|
15
|
+
root: string,
|
|
16
|
+
source: string,
|
|
17
|
+
args: readonly string[] = [],
|
|
18
|
+
): Promise<Record<string, unknown>> {
|
|
19
|
+
return new Promise((resolveWorker, reject) => {
|
|
20
|
+
const child = spawnDdcWorker(source, [lifecycleModule, root, guid, key, ...args]);
|
|
21
|
+
let output = '';
|
|
22
|
+
let error = '';
|
|
23
|
+
child.stdout.on('data', (chunk: Buffer) => (output += chunk.toString()));
|
|
24
|
+
child.stderr.on('data', (chunk: Buffer) => (error += chunk.toString()));
|
|
25
|
+
const timer = setTimeout(() => {
|
|
26
|
+
child.kill('SIGKILL');
|
|
27
|
+
reject(new Error(`worker timed out: ${error}`));
|
|
28
|
+
}, 5000);
|
|
29
|
+
child.on('error', reject);
|
|
30
|
+
child.on('exit', (code) => {
|
|
31
|
+
clearTimeout(timer);
|
|
32
|
+
if (code !== 0) {
|
|
33
|
+
reject(new Error(`worker failed (${code}): ${error}`));
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
resolveWorker(JSON.parse(output) as Record<string, unknown>);
|
|
38
|
+
} catch (parseError) {
|
|
39
|
+
reject(new Error(`worker output was not JSON: ${output}; ${String(parseError)}`));
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe('DDC multiprocess lifecycle', () => {
|
|
46
|
+
const roots: string[] = [];
|
|
47
|
+
|
|
48
|
+
afterEach(async () => {
|
|
49
|
+
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('allocates unique persistent generations across two independent processes', async () => {
|
|
53
|
+
const root = await mkdtemp(join(tmpdir(), 'forgeax-ddc-multiprocess-generation-'));
|
|
54
|
+
roots.push(root);
|
|
55
|
+
const script = `
|
|
56
|
+
const { DdcLifecycle } = await load(process.argv[1]);
|
|
57
|
+
const lease = await new DdcLifecycle(process.argv[2]).begin(process.argv[3], process.argv[4]);
|
|
58
|
+
console.log(JSON.stringify({ attempt: lease.attempt, generation: lease.generation }));
|
|
59
|
+
`;
|
|
60
|
+
|
|
61
|
+
const [first, second] = await Promise.all([runWorker(root, script), runWorker(root, script)]);
|
|
62
|
+
expect(first.generation).toEqual(expect.any(Number));
|
|
63
|
+
expect(second.generation).toEqual(expect.any(Number));
|
|
64
|
+
expect(first.generation).not.toBe(second.generation);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('does not reuse a generation after a process restart', async () => {
|
|
68
|
+
const root = await mkdtemp(join(tmpdir(), 'forgeax-ddc-multiprocess-restart-'));
|
|
69
|
+
roots.push(root);
|
|
70
|
+
const script = `
|
|
71
|
+
const { DdcLifecycle } = await load(process.argv[1]);
|
|
72
|
+
const lease = await new DdcLifecycle(process.argv[2]).begin(process.argv[3], process.argv[4]);
|
|
73
|
+
console.log(JSON.stringify({ generation: lease.generation }));
|
|
74
|
+
`;
|
|
75
|
+
const first = await runWorker(root, script);
|
|
76
|
+
const second = await runWorker(root, script);
|
|
77
|
+
|
|
78
|
+
expect(second.generation).toBeGreaterThan(first.generation as number);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('rejects the stale writer instead of silently overwriting current', async () => {
|
|
82
|
+
const root = await mkdtemp(join(tmpdir(), 'forgeax-ddc-multiprocess-head-'));
|
|
83
|
+
roots.push(root);
|
|
84
|
+
const script = `
|
|
85
|
+
const { DdcEntryStore, ddcOutputDigest } = await load(process.argv[5]);
|
|
86
|
+
const { DdcLifecycle } = await load(process.argv[1]);
|
|
87
|
+
const root = process.argv[2];
|
|
88
|
+
const guid = process.argv[3];
|
|
89
|
+
const key = process.argv[4];
|
|
90
|
+
const payload = process.argv[6];
|
|
91
|
+
const entry = { key, guid, payload: { payload }, refs: [], artifacts: {}, receipt: { guid, key, producer: 'worker', inputFingerprint: payload, outputDigest: '' } };
|
|
92
|
+
const store = new DdcEntryStore(root);
|
|
93
|
+
const lifecycle = new DdcLifecycle(root);
|
|
94
|
+
const lease = await lifecycle.begin(guid, key);
|
|
95
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
96
|
+
await store.write({ ...entry, receipt: { ...entry.receipt, outputDigest: ddcOutputDigest(entry) } });
|
|
97
|
+
const committed = await lifecycle.commit(lease, key);
|
|
98
|
+
console.log(JSON.stringify({ result: committed.result, revision: committed.revision, generation: lease.generation }));
|
|
99
|
+
`;
|
|
100
|
+
const [first, second] = await Promise.all([
|
|
101
|
+
runWorker(root, script, [entryModule, 'first']),
|
|
102
|
+
runWorker(root, script, [entryModule, 'second']),
|
|
103
|
+
]);
|
|
104
|
+
|
|
105
|
+
expect([first.result, second.result].filter((value) => value === 'current')).toHaveLength(1);
|
|
106
|
+
expect([first.result, second.result]).toContain('stale');
|
|
107
|
+
expect(first.revision ?? second.revision).toEqual(expect.any(Number));
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
const bunWorkerArgvSentinel = '__forgeax_ddc_worker__';
|
|
4
|
+
const bunBootstrap = `
|
|
5
|
+
// Bun 1.2 exposes its virtual -e entry as process.argv[1].
|
|
6
|
+
if (process.argv[1]?.endsWith('[eval]')) {
|
|
7
|
+
process.argv.splice(1, 1);
|
|
8
|
+
}
|
|
9
|
+
if (process.argv[1] === '${bunWorkerArgvSentinel}') {
|
|
10
|
+
process.argv.splice(1, 1);
|
|
11
|
+
}
|
|
12
|
+
const load = (specifier) => import(specifier);
|
|
13
|
+
`;
|
|
14
|
+
const nodeBootstrap = `
|
|
15
|
+
import jitiPackage from 'jiti';
|
|
16
|
+
const createJiti = jitiPackage.createJiti ?? jitiPackage.default ?? jitiPackage;
|
|
17
|
+
const jiti = createJiti(import.meta.url, { interopDefault: false });
|
|
18
|
+
const load = (specifier) => jiti.import(specifier);
|
|
19
|
+
`;
|
|
20
|
+
|
|
21
|
+
function resolveBunExecutable(): string | undefined {
|
|
22
|
+
const configuredRuntime = process.env.FORGEAX_DDC_WORKER_RUNTIME;
|
|
23
|
+
if (configuredRuntime === 'node') return undefined;
|
|
24
|
+
if (configuredRuntime && configuredRuntime !== 'bun') return configuredRuntime;
|
|
25
|
+
try {
|
|
26
|
+
execFileSync('bun', ['--version'], { stdio: 'ignore' });
|
|
27
|
+
return 'bun';
|
|
28
|
+
} catch {
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function spawnDdcWorker(source: string, args: string[]) {
|
|
34
|
+
const bunExecutable = resolveBunExecutable();
|
|
35
|
+
const runtime = bunExecutable ? bunBootstrap : nodeBootstrap;
|
|
36
|
+
const command = bunExecutable ?? process.execPath;
|
|
37
|
+
const commandArgs = bunExecutable
|
|
38
|
+
? ['-e', `${runtime}\n${source}`, bunWorkerArgvSentinel, ...args]
|
|
39
|
+
: ['--input-type=module', '-e', `${runtime}\n${source}`, ...args];
|
|
40
|
+
return spawn(command, commandArgs, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
41
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { describe, expect, expectTypeOf, it } from 'vitest';
|
|
5
|
+
import type { DdcErrorRootKind } from '../errors.js';
|
|
6
|
+
import type { BrowserDdcStatusLayer, DdcStatusLayer, DdcStatusRootKind } from '../index.js';
|
|
7
|
+
|
|
8
|
+
type ExpectedRootKinds = 'build-cache' | 'project-ddc' | 'runtime';
|
|
9
|
+
type LayerRootKind = DdcStatusLayer['rootKind'];
|
|
10
|
+
type BrowserLayerRootKind = BrowserDdcStatusLayer['rootKind'];
|
|
11
|
+
|
|
12
|
+
const statusSource = readFileSync(new URL('../status.ts', import.meta.url), 'utf8');
|
|
13
|
+
|
|
14
|
+
describe('DDC status root kind owner', () => {
|
|
15
|
+
it('keeps the exact root membership and derives status from the error owner', () => {
|
|
16
|
+
expectTypeOf<DdcErrorRootKind>().toEqualTypeOf<ExpectedRootKinds>();
|
|
17
|
+
expectTypeOf<ExpectedRootKinds>().toEqualTypeOf<DdcErrorRootKind>();
|
|
18
|
+
expectTypeOf<DdcStatusRootKind>().toEqualTypeOf<DdcErrorRootKind>();
|
|
19
|
+
expectTypeOf<DdcErrorRootKind>().toEqualTypeOf<DdcStatusRootKind>();
|
|
20
|
+
|
|
21
|
+
const acceptsRootKind = (kind: DdcStatusRootKind): DdcStatusRootKind => kind;
|
|
22
|
+
acceptsRootKind('build-cache');
|
|
23
|
+
acceptsRootKind('project-ddc');
|
|
24
|
+
acceptsRootKind('runtime');
|
|
25
|
+
// @ts-expect-error unknown root kinds remain outside the closed owner union.
|
|
26
|
+
acceptsRootKind('root-kind-not-real');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('preserves the layer/root distinction and browser projection', () => {
|
|
30
|
+
expectTypeOf<DdcStatusLayer['kind']>().toEqualTypeOf<'build' | 'project' | 'runtime'>();
|
|
31
|
+
expectTypeOf<LayerRootKind>().toEqualTypeOf<DdcErrorRootKind>();
|
|
32
|
+
expectTypeOf<BrowserLayerRootKind>().toEqualTypeOf<DdcErrorRootKind>();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('keeps the owner declaration as the single status root vocabulary', () => {
|
|
36
|
+
expect(statusSource).toContain("import type { DdcErrorRootKind } from './errors.js';");
|
|
37
|
+
expect(statusSource).toContain('export type DdcStatusRootKind = DdcErrorRootKind;');
|
|
38
|
+
expect(statusSource).not.toContain(
|
|
39
|
+
"export type DdcStatusRootKind = 'build-cache' | 'project-ddc' | 'runtime';",
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
DDC_STATUS_SCHEMA,
|
|
4
|
+
type DdcStatus,
|
|
5
|
+
projectDdcStatusForBrowser,
|
|
6
|
+
serializeDdcStatus,
|
|
7
|
+
} from '../status.js';
|
|
8
|
+
|
|
9
|
+
describe('DDC versioned status envelope', () => {
|
|
10
|
+
const status: DdcStatus = {
|
|
11
|
+
schemaVersion: DDC_STATUS_SCHEMA,
|
|
12
|
+
health: 'blocked',
|
|
13
|
+
gameDir: '/Users/test/game',
|
|
14
|
+
projectDdcRoot: '/Users/test/game/.forgeax/ddc/v2',
|
|
15
|
+
layers: [
|
|
16
|
+
{
|
|
17
|
+
kind: 'project',
|
|
18
|
+
owner: 'engine-ddc',
|
|
19
|
+
rootKind: 'project-ddc',
|
|
20
|
+
scopeId: 'editor/main',
|
|
21
|
+
current: 7,
|
|
22
|
+
lastKnownGood: 6,
|
|
23
|
+
protection: ['current', 'lease:editor-a'],
|
|
24
|
+
actions: [
|
|
25
|
+
{
|
|
26
|
+
kind: 'prune',
|
|
27
|
+
executable: true,
|
|
28
|
+
exactTarget: '/Users/test/game/.forgeax/ddc/v2/generations/4',
|
|
29
|
+
},
|
|
30
|
+
],
|
|
31
|
+
},
|
|
32
|
+
],
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
it('serializes the public schema with layer, owner, current, LKG, protection and actions', () => {
|
|
36
|
+
const serialized = JSON.parse(serializeDdcStatus(status)) as DdcStatus;
|
|
37
|
+
|
|
38
|
+
expect(serialized).toEqual(status);
|
|
39
|
+
expect(serialized.schemaVersion).toBe('forgeax-ddc-status/v2');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('redacts every host path from browser projection while preserving recovery data', () => {
|
|
43
|
+
const browser = projectDdcStatusForBrowser(status);
|
|
44
|
+
const text = JSON.stringify(browser);
|
|
45
|
+
|
|
46
|
+
expect(browser).not.toHaveProperty('gameDir');
|
|
47
|
+
expect(browser).not.toHaveProperty('projectDdcRoot');
|
|
48
|
+
expect(text).not.toContain('/Users/test');
|
|
49
|
+
expect(browser.layers[0]).toMatchObject({
|
|
50
|
+
current: 7,
|
|
51
|
+
lastKnownGood: 6,
|
|
52
|
+
protection: ['current', 'lease:editor-a'],
|
|
53
|
+
actions: [{ kind: 'prune', executable: true }],
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
});
|