amalgm 0.1.240 → 0.1.242
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/lib/shared-realtime-tunnel.js +12 -3
- package/package.json +1 -1
- package/runtime/scripts/amalgm-mcp/server/routes/state.js +13 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-object-tunnel.test.js +90 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-objects.test.js +84 -0
- package/runtime/scripts/amalgm-mcp/workspace/objects.js +71 -1
|
@@ -618,9 +618,18 @@ function createSharedRealtimeTunnel({
|
|
|
618
618
|
}
|
|
619
619
|
|
|
620
620
|
function handleError(frame) {
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
621
|
+
const failedUpload = repoUploadInflight.get(frame.request_id);
|
|
622
|
+
if (failedUpload) {
|
|
623
|
+
void localStateJson('POST', '/state/repo-objects/uploads/fail', {
|
|
624
|
+
resourceId: failedUpload.resourceId,
|
|
625
|
+
sha256: failedUpload.sha256,
|
|
626
|
+
retryable: frame.retryable !== false,
|
|
627
|
+
code: frame.code,
|
|
628
|
+
message: frame.message,
|
|
629
|
+
}).finally(() => {
|
|
630
|
+
repoUploadInflight.delete(frame.request_id);
|
|
631
|
+
scheduleRepoObjects();
|
|
632
|
+
});
|
|
624
633
|
return;
|
|
625
634
|
}
|
|
626
635
|
if (repoDownloadInflight.has(frame.request_id)) {
|
package/package.json
CHANGED
|
@@ -59,6 +59,19 @@ async function handleStateRoutes(ctx) {
|
|
|
59
59
|
));
|
|
60
60
|
return true;
|
|
61
61
|
}
|
|
62
|
+
if (ctx.pathname === '/state/repo-objects/uploads/fail') {
|
|
63
|
+
const body = await ctx.readJsonBody();
|
|
64
|
+
ctx.sendJson(200, require('../../workspace/objects').failUpload(
|
|
65
|
+
body?.resourceId,
|
|
66
|
+
body?.sha256,
|
|
67
|
+
{
|
|
68
|
+
retryable: body?.retryable,
|
|
69
|
+
code: body?.code,
|
|
70
|
+
message: body?.message,
|
|
71
|
+
},
|
|
72
|
+
));
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
62
75
|
if (ctx.pathname === '/state/repo-objects/downloads/claim') {
|
|
63
76
|
ctx.sendJson(200, { download: require('../../workspace/objects').claimDownload() });
|
|
64
77
|
return true;
|
|
@@ -201,6 +201,96 @@ test('repo object chunks retry after the tunnel reconnects', async () => {
|
|
|
201
201
|
}
|
|
202
202
|
});
|
|
203
203
|
|
|
204
|
+
test('a rejected stale object yields the tunnel to the next upload', async () => {
|
|
205
|
+
const staleBytes = Buffer.from('stale denied object');
|
|
206
|
+
const staleSha = crypto.createHash('sha256').update(staleBytes).digest('hex');
|
|
207
|
+
const currentBytes = Buffer.from('current allowed object');
|
|
208
|
+
const currentSha = crypto.createHash('sha256').update(currentBytes).digest('hex');
|
|
209
|
+
const resourceId = 'shr_cp1abcdefghijklmnopqrstuvwx';
|
|
210
|
+
let failed = false;
|
|
211
|
+
const calls = [];
|
|
212
|
+
const server = http.createServer((req, res) => {
|
|
213
|
+
let raw = '';
|
|
214
|
+
req.on('data', (chunk) => { raw += chunk; });
|
|
215
|
+
req.on('end', () => {
|
|
216
|
+
const body = raw ? JSON.parse(raw) : null;
|
|
217
|
+
calls.push({ path: req.url, body });
|
|
218
|
+
res.setHeader('content-type', 'application/json');
|
|
219
|
+
if (req.url === '/state/shared/replicas') {
|
|
220
|
+
res.end('{"replicas":[]}');
|
|
221
|
+
} else if (req.url.startsWith('/state/stream')) {
|
|
222
|
+
res.writeHead(200, { 'content-type': 'text/event-stream' });
|
|
223
|
+
res.write('\n');
|
|
224
|
+
} else if (req.url === '/state/repo-objects/uploads/claim') {
|
|
225
|
+
const bytes = failed ? currentBytes : staleBytes;
|
|
226
|
+
const sha256 = failed ? currentSha : staleSha;
|
|
227
|
+
res.end(JSON.stringify({
|
|
228
|
+
upload: {
|
|
229
|
+
resourceId,
|
|
230
|
+
sha256,
|
|
231
|
+
bytes: bytes.length,
|
|
232
|
+
dataBase64: bytes.toString('base64'),
|
|
233
|
+
},
|
|
234
|
+
}));
|
|
235
|
+
} else if (req.url === '/state/repo-objects/uploads/fail') {
|
|
236
|
+
failed = true;
|
|
237
|
+
res.end('{"changed":true,"terminal":true}');
|
|
238
|
+
} else if (req.url.endsWith('/claim')) {
|
|
239
|
+
res.end('{}');
|
|
240
|
+
} else {
|
|
241
|
+
res.end('{}');
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
await new Promise((resolve) => { server.listen(0, '127.0.0.1', resolve); });
|
|
246
|
+
|
|
247
|
+
const frames = [];
|
|
248
|
+
const tunnel = createSharedRealtimeTunnel({
|
|
249
|
+
send: (frame) => { frames.push(frame); return true; },
|
|
250
|
+
sendLarge: (frame) => { frames.push(frame); return true; },
|
|
251
|
+
localPort: () => server.address().port,
|
|
252
|
+
localHeaders: (headers) => headers,
|
|
253
|
+
warn: () => {},
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
try {
|
|
257
|
+
tunnel.enable(['shared.mutation.v1']);
|
|
258
|
+
await until(
|
|
259
|
+
() => frames.some((frame) => frame.type === 'repo.object.put'),
|
|
260
|
+
'the stale upload',
|
|
261
|
+
);
|
|
262
|
+
const stale = frames.find((frame) => frame.type === 'repo.object.put');
|
|
263
|
+
assert.equal(stale.sha256, staleSha);
|
|
264
|
+
tunnel.handleFrame({
|
|
265
|
+
type: 'shared.error',
|
|
266
|
+
request_id: stale.request_id,
|
|
267
|
+
resource_id: resourceId,
|
|
268
|
+
code: 'shared_resource_read_forbidden',
|
|
269
|
+
message: 'stale rail is no longer readable',
|
|
270
|
+
retryable: false,
|
|
271
|
+
});
|
|
272
|
+
await until(
|
|
273
|
+
() => frames.some((frame) => (
|
|
274
|
+
frame.type === 'repo.object.put' && frame.sha256 === currentSha
|
|
275
|
+
)),
|
|
276
|
+
'the current upload behind the stale one',
|
|
277
|
+
);
|
|
278
|
+
assert.deepEqual(
|
|
279
|
+
calls.find((call) => call.path === '/state/repo-objects/uploads/fail').body,
|
|
280
|
+
{
|
|
281
|
+
resourceId,
|
|
282
|
+
sha256: staleSha,
|
|
283
|
+
retryable: false,
|
|
284
|
+
code: 'shared_resource_read_forbidden',
|
|
285
|
+
message: 'stale rail is no longer readable',
|
|
286
|
+
},
|
|
287
|
+
);
|
|
288
|
+
} finally {
|
|
289
|
+
tunnel.stop();
|
|
290
|
+
server.close();
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
|
|
204
294
|
test('a healthy tunnel sends one request for one durable mutation', async () => {
|
|
205
295
|
const envelope = {
|
|
206
296
|
resourceId: '018f0000-0000-7000-8000-000000000050',
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const test = require('node:test');
|
|
4
4
|
const assert = require('node:assert/strict');
|
|
5
|
+
const crypto = require('crypto');
|
|
5
6
|
const fs = require('fs');
|
|
6
7
|
const os = require('os');
|
|
7
8
|
const path = require('path');
|
|
@@ -29,6 +30,7 @@ const {
|
|
|
29
30
|
acknowledgeUpload,
|
|
30
31
|
claimDownload,
|
|
31
32
|
claimUpload,
|
|
33
|
+
failUpload,
|
|
32
34
|
importFromLocator,
|
|
33
35
|
objectSourceFor,
|
|
34
36
|
prepareBundleImport,
|
|
@@ -242,3 +244,85 @@ test('one malformed download request cannot block valid requests behind it', ()
|
|
|
242
244
|
assert.equal(fs.existsSync(path.join(requests, '000-malformed.json')), false);
|
|
243
245
|
receiveDownload(PROJECT, chunks[0].sha256, content.toString('base64'));
|
|
244
246
|
});
|
|
247
|
+
|
|
248
|
+
test('failed uploads yield the bulk lane to later durable objects', () => {
|
|
249
|
+
for (const content of ['first queued object', 'second queued object', 'third queued object']) {
|
|
250
|
+
assert.equal(
|
|
251
|
+
publishPayload(`fair-${content}`, Buffer.from(content), REPO_KEY).kind,
|
|
252
|
+
'pending',
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const first = claimUpload();
|
|
257
|
+
assert.ok(first);
|
|
258
|
+
const retry = failUpload(first.resourceId, first.sha256, {
|
|
259
|
+
retryable: true,
|
|
260
|
+
code: 'temporary_object_failure',
|
|
261
|
+
message: 'try this object later',
|
|
262
|
+
});
|
|
263
|
+
assert.equal(retry.changed, true);
|
|
264
|
+
assert.equal(retry.terminal, false);
|
|
265
|
+
assert.ok(Date.parse(retry.retryAfter) > Date.now());
|
|
266
|
+
|
|
267
|
+
const second = claimUpload();
|
|
268
|
+
assert.ok(second);
|
|
269
|
+
assert.notEqual(second.sha256, first.sha256);
|
|
270
|
+
const terminal = failUpload(second.resourceId, second.sha256, {
|
|
271
|
+
retryable: false,
|
|
272
|
+
code: 'shared_resource_read_forbidden',
|
|
273
|
+
message: 'the stale rail is no longer readable',
|
|
274
|
+
});
|
|
275
|
+
assert.equal(terminal.changed, true);
|
|
276
|
+
assert.equal(terminal.terminal, true);
|
|
277
|
+
|
|
278
|
+
const third = claimUpload();
|
|
279
|
+
assert.ok(third);
|
|
280
|
+
assert.notEqual(third.sha256, first.sha256);
|
|
281
|
+
assert.notEqual(third.sha256, second.sha256);
|
|
282
|
+
|
|
283
|
+
acknowledgeUpload(first.resourceId, first.sha256);
|
|
284
|
+
acknowledgeUpload(second.resourceId, second.sha256);
|
|
285
|
+
acknowledgeUpload(third.resourceId, third.sha256);
|
|
286
|
+
assert.equal(claimUpload(), null);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test('legacy shared-resource uploads can terminate without starving current projects', () => {
|
|
290
|
+
const legacyResourceId = 'shr_legacyobjectqueue0001';
|
|
291
|
+
const legacyBytes = Buffer.from('stale object from a legacy rail');
|
|
292
|
+
const legacyDigest = crypto.createHash('sha256').update(legacyBytes).digest('hex');
|
|
293
|
+
const objectRoot = path.join(process.env.AMALGM_DIR, 'state', 'repo-objects');
|
|
294
|
+
const manifests = path.join(objectRoot, 'manifests');
|
|
295
|
+
const chunks = path.join(objectRoot, 'chunks');
|
|
296
|
+
fs.writeFileSync(path.join(chunks, `${legacyDigest}.bin`), legacyBytes);
|
|
297
|
+
fs.writeFileSync(path.join(manifests, '000-legacy-shared-resource.json'), JSON.stringify({
|
|
298
|
+
contract: 'git-bundle-chunks@2',
|
|
299
|
+
resourceId: legacyResourceId,
|
|
300
|
+
repoKey: 'legacy-repo',
|
|
301
|
+
oid: '1'.repeat(40),
|
|
302
|
+
ref: `refs/amalgm/exports/${'1'.repeat(40)}`,
|
|
303
|
+
checksum: locatorChecksum([{ sha256: legacyDigest, bytes: legacyBytes.length }]),
|
|
304
|
+
bytes: legacyBytes.length,
|
|
305
|
+
chunks: [{ sha256: legacyDigest, bytes: legacyBytes.length, uploaded: false }],
|
|
306
|
+
createdAt: new Date().toISOString(),
|
|
307
|
+
}));
|
|
308
|
+
|
|
309
|
+
assert.equal(
|
|
310
|
+
publishPayload('current-after-legacy', Buffer.from('current project object'), REPO_KEY).kind,
|
|
311
|
+
'pending',
|
|
312
|
+
);
|
|
313
|
+
const legacy = claimUpload();
|
|
314
|
+
assert.equal(legacy.resourceId, legacyResourceId);
|
|
315
|
+
assert.equal(legacy.sha256, legacyDigest);
|
|
316
|
+
const terminal = failUpload(legacy.resourceId, legacy.sha256, {
|
|
317
|
+
retryable: false,
|
|
318
|
+
code: 'shared_resource_read_forbidden',
|
|
319
|
+
message: 'the legacy rail is no longer readable',
|
|
320
|
+
});
|
|
321
|
+
assert.equal(terminal.changed, true);
|
|
322
|
+
assert.equal(terminal.terminal, true);
|
|
323
|
+
|
|
324
|
+
const current = claimUpload();
|
|
325
|
+
assert.equal(current.resourceId, PROJECT);
|
|
326
|
+
assert.notEqual(current.sha256, legacy.sha256);
|
|
327
|
+
acknowledgeUpload(current.resourceId, current.sha256);
|
|
328
|
+
});
|
|
@@ -13,6 +13,7 @@ const crypto = require('crypto');
|
|
|
13
13
|
const fs = require('fs');
|
|
14
14
|
const path = require('path');
|
|
15
15
|
const { AMALGM_DIR } = require('../config');
|
|
16
|
+
const { isSharedResourceId } = require('../state/cloud-resource-id');
|
|
16
17
|
const { appendStateEvent } = require('../state/events');
|
|
17
18
|
const durable = require('./durable');
|
|
18
19
|
const transition = require('./transition');
|
|
@@ -31,6 +32,8 @@ const PAYLOADS = path.join(ROOT, 'payloads');
|
|
|
31
32
|
const CHUNKS = path.join(ROOT, 'chunks');
|
|
32
33
|
const REQUESTS = path.join(ROOT, 'requests');
|
|
33
34
|
const preparing = new Map();
|
|
35
|
+
const UPLOAD_RETRY_BASE_MS = 1_000;
|
|
36
|
+
const UPLOAD_RETRY_MAX_MS = 60_000;
|
|
34
37
|
|
|
35
38
|
function isProjectId(value) {
|
|
36
39
|
return require('./tree/identity').isUuid(String(value || ''));
|
|
@@ -209,11 +212,20 @@ async function objectSourceFor(worktreeRoot, repoKey, oid) {
|
|
|
209
212
|
|
|
210
213
|
function claimUpload() {
|
|
211
214
|
ensureDirs();
|
|
215
|
+
const now = Date.now();
|
|
212
216
|
for (const directory of [MANIFESTS, PAYLOADS]) {
|
|
213
217
|
for (const name of fs.readdirSync(directory).sort()) {
|
|
214
218
|
const file = path.join(directory, name);
|
|
215
219
|
const manifest = readJson(file);
|
|
216
|
-
const chunk = manifest?.chunks?.find((entry) =>
|
|
220
|
+
const chunk = manifest?.chunks?.find((entry) => (
|
|
221
|
+
!entry.uploaded
|
|
222
|
+
&& !entry.uploadTerminal
|
|
223
|
+
&& (
|
|
224
|
+
!entry.uploadRetryAfter
|
|
225
|
+
|| !Number.isFinite(Date.parse(entry.uploadRetryAfter))
|
|
226
|
+
|| Date.parse(entry.uploadRetryAfter) <= now
|
|
227
|
+
)
|
|
228
|
+
));
|
|
217
229
|
if (!chunk) continue;
|
|
218
230
|
const chunkFile = path.join(CHUNKS, `${chunk.sha256}.bin`);
|
|
219
231
|
const bytes = fs.existsSync(chunkFile) ? fs.readFileSync(chunkFile) : null;
|
|
@@ -252,6 +264,10 @@ function acknowledgeUpload(resourceId, digest) {
|
|
|
252
264
|
for (const chunk of manifest.chunks) {
|
|
253
265
|
if (chunk.sha256 === digest && !chunk.uploaded) {
|
|
254
266
|
chunk.uploaded = true;
|
|
267
|
+
delete chunk.uploadAttempts;
|
|
268
|
+
delete chunk.uploadLastError;
|
|
269
|
+
delete chunk.uploadRetryAfter;
|
|
270
|
+
delete chunk.uploadTerminal;
|
|
255
271
|
touched = true;
|
|
256
272
|
changed = true;
|
|
257
273
|
}
|
|
@@ -272,6 +288,59 @@ function acknowledgeUpload(resourceId, digest) {
|
|
|
272
288
|
return { changed, completed };
|
|
273
289
|
}
|
|
274
290
|
|
|
291
|
+
function failUpload(resourceId, digest, failure = {}) {
|
|
292
|
+
// Upload work is durable across protocol generations. Match the exact
|
|
293
|
+
// identity that was accepted when the work was queued, including legacy
|
|
294
|
+
// `shr_` rails, so stale historical work can reach a terminal state instead
|
|
295
|
+
// of monopolizing every newer code project behind it.
|
|
296
|
+
if (!isSharedResourceId(resourceId) || !/^[0-9a-f]{64}$/.test(String(digest || ''))) {
|
|
297
|
+
return { changed: false, terminal: false };
|
|
298
|
+
}
|
|
299
|
+
ensureDirs();
|
|
300
|
+
const failedAt = new Date().toISOString();
|
|
301
|
+
const retryable = failure?.retryable !== false;
|
|
302
|
+
let changed = false;
|
|
303
|
+
let terminal = false;
|
|
304
|
+
let retryAfter = null;
|
|
305
|
+
let attempts = 0;
|
|
306
|
+
for (const directory of [MANIFESTS, PAYLOADS]) {
|
|
307
|
+
for (const name of fs.readdirSync(directory)) {
|
|
308
|
+
const file = path.join(directory, name);
|
|
309
|
+
const manifest = readJson(file);
|
|
310
|
+
if (manifest?.resourceId !== resourceId || !manifest?.chunks) continue;
|
|
311
|
+
const chunk = manifest.chunks.find((entry) => (
|
|
312
|
+
entry.sha256 === digest && !entry.uploaded
|
|
313
|
+
));
|
|
314
|
+
if (!chunk) continue;
|
|
315
|
+
attempts = Math.max(attempts, Number(chunk.uploadAttempts || 0) + 1);
|
|
316
|
+
chunk.uploadAttempts = attempts;
|
|
317
|
+
chunk.uploadLastError = {
|
|
318
|
+
code: typeof failure?.code === 'string' ? failure.code : 'repo_object_upload_failed',
|
|
319
|
+
message: typeof failure?.message === 'string' ? failure.message : 'repo object upload failed',
|
|
320
|
+
retryable,
|
|
321
|
+
at: failedAt,
|
|
322
|
+
};
|
|
323
|
+
if (retryable) {
|
|
324
|
+
const delayMs = Math.min(
|
|
325
|
+
UPLOAD_RETRY_MAX_MS,
|
|
326
|
+
UPLOAD_RETRY_BASE_MS * (2 ** Math.min(attempts - 1, 16)),
|
|
327
|
+
);
|
|
328
|
+
retryAfter = new Date(Date.now() + delayMs).toISOString();
|
|
329
|
+
chunk.uploadRetryAfter = retryAfter;
|
|
330
|
+
delete chunk.uploadTerminal;
|
|
331
|
+
} else {
|
|
332
|
+
chunk.uploadTerminal = { ...chunk.uploadLastError };
|
|
333
|
+
delete chunk.uploadRetryAfter;
|
|
334
|
+
terminal = true;
|
|
335
|
+
}
|
|
336
|
+
writeJson(file, manifest);
|
|
337
|
+
changed = true;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
if (changed) wake('update', digest);
|
|
341
|
+
return { changed, terminal, retryAfter, attempts };
|
|
342
|
+
}
|
|
343
|
+
|
|
275
344
|
function validatePayloadLocator(locator) {
|
|
276
345
|
if (
|
|
277
346
|
locator?.kind !== 'chunks'
|
|
@@ -456,6 +525,7 @@ module.exports = {
|
|
|
456
525
|
bundleSourceFor,
|
|
457
526
|
claimDownload,
|
|
458
527
|
claimUpload,
|
|
528
|
+
failUpload,
|
|
459
529
|
importFromLocator,
|
|
460
530
|
objectSourceFor,
|
|
461
531
|
prepareBundleImport,
|