amalgm 0.1.238 → 0.1.239
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.
|
@@ -17,7 +17,6 @@ const CLAIM_LIMIT = 16;
|
|
|
17
17
|
const CLAIM_LEASE_MS = 30_000;
|
|
18
18
|
const RETRY_MS = 1_000;
|
|
19
19
|
const REAUTHORIZE_MS = 30_000;
|
|
20
|
-
const REPO_OBJECT_REQUEST_TIMEOUT_MS = 15_000;
|
|
21
20
|
|
|
22
21
|
function createSharedRealtimeTunnel({
|
|
23
22
|
send,
|
|
@@ -25,7 +24,6 @@ function createSharedRealtimeTunnel({
|
|
|
25
24
|
localPort,
|
|
26
25
|
localHeaders,
|
|
27
26
|
warn,
|
|
28
|
-
repoObjectRequestTimeoutMs = REPO_OBJECT_REQUEST_TIMEOUT_MS,
|
|
29
27
|
}) {
|
|
30
28
|
const mutationInflight = new Map();
|
|
31
29
|
const promotionInflight = new Map();
|
|
@@ -144,6 +142,10 @@ function createSharedRealtimeTunnel({
|
|
|
144
142
|
attachmentRetryTimer.unref?.();
|
|
145
143
|
}
|
|
146
144
|
|
|
145
|
+
function alreadyInflight(inflight, matches) {
|
|
146
|
+
return [...inflight.values()].some(matches);
|
|
147
|
+
}
|
|
148
|
+
|
|
147
149
|
async function drainMutations() {
|
|
148
150
|
if (!enabled || mutationDrain) return;
|
|
149
151
|
mutationDrain = (async () => {
|
|
@@ -154,6 +156,10 @@ function createSharedRealtimeTunnel({
|
|
|
154
156
|
for (const claim of Array.isArray(result?.mutations) ? result.mutations : []) {
|
|
155
157
|
const envelope = claim?.envelope;
|
|
156
158
|
if (!envelope?.resourceId || !envelope?.mutationId) continue;
|
|
159
|
+
if (alreadyInflight(mutationInflight, (entry) => (
|
|
160
|
+
entry.resourceId === envelope.resourceId
|
|
161
|
+
&& entry.mutationId === envelope.mutationId
|
|
162
|
+
))) continue;
|
|
157
163
|
const requestId = `shared_${crypto.randomBytes(12).toString('hex')}`;
|
|
158
164
|
if (!sendLarge({ type: 'shared.mutation.submit', request_id: requestId, envelope })) {
|
|
159
165
|
await failMutationClaim(claim, 'shared tunnel backpressure: mutation was not queued');
|
|
@@ -201,6 +207,9 @@ function createSharedRealtimeTunnel({
|
|
|
201
207
|
for (const claim of Array.isArray(result?.promotions) ? result.promotions : []) {
|
|
202
208
|
const promotion = claim?.frame;
|
|
203
209
|
if (!promotion?.promotionId || !promotion?.resourceId) continue;
|
|
210
|
+
if (alreadyInflight(promotionInflight, (entry) => (
|
|
211
|
+
entry.promotionId === promotion.promotionId
|
|
212
|
+
))) continue;
|
|
204
213
|
const requestId = `promote_${crypto.randomBytes(12).toString('hex')}`;
|
|
205
214
|
const queued = sendLarge({
|
|
206
215
|
type: 'shared.resource.promote',
|
|
@@ -247,6 +256,9 @@ function createSharedRealtimeTunnel({
|
|
|
247
256
|
});
|
|
248
257
|
for (const attachment of Array.isArray(result?.attachments) ? result.attachments : []) {
|
|
249
258
|
if (!attachment?.attachmentId || !attachment?.resourceId) continue;
|
|
259
|
+
if (alreadyInflight(attachmentInflight, (entry) => (
|
|
260
|
+
entry.attachmentId === attachment.attachmentId
|
|
261
|
+
))) continue;
|
|
250
262
|
const requestId = `attach_${crypto.randomBytes(12).toString('hex')}`;
|
|
251
263
|
if (!send({
|
|
252
264
|
type: 'shared.resource.attach',
|
|
@@ -279,17 +291,10 @@ function createSharedRealtimeTunnel({
|
|
|
279
291
|
repoObjectTimer.unref?.();
|
|
280
292
|
}
|
|
281
293
|
|
|
282
|
-
function expireRepoObjectRequests(now = Date.now()) {
|
|
283
|
-
for (const inflight of [repoUploadInflight, repoDownloadInflight]) {
|
|
284
|
-
for (const [requestId, object] of inflight) {
|
|
285
|
-
if (now - object.sentAt >= repoObjectRequestTimeoutMs) inflight.delete(requestId);
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
|
|
290
294
|
async function drainRepoObjects() {
|
|
291
295
|
if (!enabled) return;
|
|
292
|
-
|
|
296
|
+
// One ordered WebSocket carries one request until its reply. A disconnect
|
|
297
|
+
// clears this map and the durable local queue resends after reconnect.
|
|
293
298
|
if (repoUploadInflight.size === 0) {
|
|
294
299
|
const result = await localStateJson('POST', '/state/repo-objects/uploads/claim', {});
|
|
295
300
|
const upload = result?.upload;
|
|
@@ -306,7 +311,6 @@ function createSharedRealtimeTunnel({
|
|
|
306
311
|
repoUploadInflight.set(requestId, {
|
|
307
312
|
resourceId: upload.resourceId,
|
|
308
313
|
sha256: upload.sha256,
|
|
309
|
-
sentAt: Date.now(),
|
|
310
314
|
});
|
|
311
315
|
}
|
|
312
316
|
}
|
|
@@ -325,7 +329,6 @@ function createSharedRealtimeTunnel({
|
|
|
325
329
|
repoDownloadInflight.set(requestId, {
|
|
326
330
|
resourceId: download.resourceId,
|
|
327
331
|
sha256: download.sha256,
|
|
328
|
-
sentAt: Date.now(),
|
|
329
332
|
});
|
|
330
333
|
}
|
|
331
334
|
}
|
package/package.json
CHANGED
|
@@ -123,7 +123,7 @@ test('repo object chunks travel through the shared tunnel one at a time', async
|
|
|
123
123
|
}
|
|
124
124
|
});
|
|
125
125
|
|
|
126
|
-
test('repo object chunks retry
|
|
126
|
+
test('repo object chunks retry after the tunnel reconnects', async () => {
|
|
127
127
|
const uploadBytes = Buffer.from('retry this content-addressed chunk');
|
|
128
128
|
const uploadSha = crypto.createHash('sha256').update(uploadBytes).digest('hex');
|
|
129
129
|
const resourceId = 'shr_cp1abcdefghijklmnopqrstuvwx';
|
|
@@ -165,14 +165,25 @@ test('repo object chunks retry when an acknowledgement is lost', async () => {
|
|
|
165
165
|
localPort: () => server.address().port,
|
|
166
166
|
localHeaders: (headers) => headers,
|
|
167
167
|
warn: () => {},
|
|
168
|
-
repoObjectRequestTimeoutMs: 25,
|
|
169
168
|
});
|
|
170
169
|
|
|
171
170
|
try {
|
|
171
|
+
tunnel.enable(['shared.mutation.v1']);
|
|
172
|
+
await until(
|
|
173
|
+
() => frames.filter((frame) => frame.type === 'repo.object.put').length === 1,
|
|
174
|
+
'the original upload',
|
|
175
|
+
);
|
|
176
|
+
await new Promise((resolve) => { setTimeout(resolve, 50); });
|
|
177
|
+
assert.equal(
|
|
178
|
+
frames.filter((frame) => frame.type === 'repo.object.put').length,
|
|
179
|
+
1,
|
|
180
|
+
'a healthy ordered tunnel never duplicates an in-flight upload',
|
|
181
|
+
);
|
|
182
|
+
tunnel.stop();
|
|
172
183
|
tunnel.enable(['shared.mutation.v1']);
|
|
173
184
|
await until(
|
|
174
185
|
() => frames.filter((frame) => frame.type === 'repo.object.put').length >= 2,
|
|
175
|
-
'the
|
|
186
|
+
'the upload after reconnect',
|
|
176
187
|
);
|
|
177
188
|
const puts = frames.filter((frame) => frame.type === 'repo.object.put');
|
|
178
189
|
assert.notEqual(puts[0].request_id, puts[1].request_id);
|
|
@@ -189,3 +200,66 @@ test('repo object chunks retry when an acknowledgement is lost', async () => {
|
|
|
189
200
|
server.close();
|
|
190
201
|
}
|
|
191
202
|
});
|
|
203
|
+
|
|
204
|
+
test('a healthy tunnel sends one request for one durable mutation', async () => {
|
|
205
|
+
const envelope = {
|
|
206
|
+
resourceId: 'shr_cp1abcdefghijklmnopqrstuvwx',
|
|
207
|
+
authorityEpoch: 1,
|
|
208
|
+
mutationId: 'mut_one_ordered_request',
|
|
209
|
+
deviceId: 'machine-a',
|
|
210
|
+
baseVersion: 0,
|
|
211
|
+
contract: 'code-project-stream@1',
|
|
212
|
+
schemaVersion: 1,
|
|
213
|
+
operationKind: 'code.publish',
|
|
214
|
+
operation: { kind: 'code.publish', record: { value: 'one change' } },
|
|
215
|
+
};
|
|
216
|
+
let wakeResponse = null;
|
|
217
|
+
const server = http.createServer((req, res) => {
|
|
218
|
+
req.resume();
|
|
219
|
+
req.on('end', () => {
|
|
220
|
+
res.setHeader('content-type', 'application/json');
|
|
221
|
+
if (req.url === '/state/shared/replicas') {
|
|
222
|
+
res.end('{"replicas":[]}');
|
|
223
|
+
} else if (req.url.startsWith('/state/stream')) {
|
|
224
|
+
res.writeHead(200, { 'content-type': 'text/event-stream' });
|
|
225
|
+
res.write('\n');
|
|
226
|
+
wakeResponse = res;
|
|
227
|
+
} else if (req.url === '/state/mutations/claim') {
|
|
228
|
+
res.end(JSON.stringify({ mutations: [{ envelope }] }));
|
|
229
|
+
} else if (req.url.endsWith('/claim')) {
|
|
230
|
+
res.end('{}');
|
|
231
|
+
} else {
|
|
232
|
+
res.end('{}');
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
await new Promise((resolve) => { server.listen(0, '127.0.0.1', resolve); });
|
|
237
|
+
|
|
238
|
+
const frames = [];
|
|
239
|
+
const tunnel = createSharedRealtimeTunnel({
|
|
240
|
+
send: (frame) => { frames.push(frame); return true; },
|
|
241
|
+
sendLarge: (frame) => { frames.push(frame); return true; },
|
|
242
|
+
localPort: () => server.address().port,
|
|
243
|
+
localHeaders: (headers) => headers,
|
|
244
|
+
warn: () => {},
|
|
245
|
+
});
|
|
246
|
+
const submits = () => frames.filter((frame) => frame.type === 'shared.mutation.submit');
|
|
247
|
+
|
|
248
|
+
try {
|
|
249
|
+
tunnel.enable(['shared.mutation.v1']);
|
|
250
|
+
await until(() => submits().length === 1 && wakeResponse, 'the original mutation request');
|
|
251
|
+
for (let index = 0; index < 3; index += 1) {
|
|
252
|
+
wakeResponse.write('event: state\ndata: {"sharedResourceId":"shr_cp1abcdefghijklmnopqrstuvwx"}\n\n');
|
|
253
|
+
await new Promise((resolve) => { setTimeout(resolve, 20); });
|
|
254
|
+
}
|
|
255
|
+
assert.equal(submits().length, 1, 'wake signals never duplicate an in-flight mutation');
|
|
256
|
+
|
|
257
|
+
tunnel.stop();
|
|
258
|
+
tunnel.enable(['shared.mutation.v1']);
|
|
259
|
+
await until(() => submits().length === 2, 'the mutation after reconnect');
|
|
260
|
+
assert.notEqual(submits()[0].request_id, submits()[1].request_id);
|
|
261
|
+
} finally {
|
|
262
|
+
tunnel.stop();
|
|
263
|
+
server.close();
|
|
264
|
+
}
|
|
265
|
+
});
|