@averyyy/pi-server 0.82.1-piclient.2 → 0.82.1-piclient.4
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/CHANGELOG.md +11 -0
- package/README.md +25 -0
- package/bin/update.js +156 -23
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/pi-server-protocol.d.ts +16 -0
- package/dist/pi-server-protocol.d.ts.map +1 -1
- package/dist/pi-server-protocol.js +30 -1
- package/dist/pi-server-protocol.js.map +1 -1
- package/dist/receive-upload.d.ts +5 -0
- package/dist/receive-upload.d.ts.map +1 -1
- package/dist/receive-upload.js +88 -6
- package/dist/receive-upload.js.map +1 -1
- package/dist/request-chunks.d.ts +33 -0
- package/dist/request-chunks.d.ts.map +1 -1
- package/dist/request-chunks.js +540 -48
- package/dist/request-chunks.js.map +1 -1
- package/dist/server.d.ts +41 -3
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +3088 -366
- package/dist/server.js.map +1 -1
- package/dist/session-persistence.d.ts +39 -1
- package/dist/session-persistence.d.ts.map +1 -1
- package/dist/session-persistence.js +1132 -116
- package/dist/session-persistence.js.map +1 -1
- package/dist/session-store.d.ts +112 -6
- package/dist/session-store.d.ts.map +1 -1
- package/dist/session-store.js +695 -120
- package/dist/session-store.js.map +1 -1
- package/dist/stream-run-persistence.d.ts +163 -0
- package/dist/stream-run-persistence.d.ts.map +1 -0
- package/dist/stream-run-persistence.js +1824 -0
- package/dist/stream-run-persistence.js.map +1 -0
- package/package.json +3 -3
package/dist/request-chunks.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { Buffer } from "node:buffer";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
|
+
import { TextDecoder } from "node:util";
|
|
3
4
|
export const CHUNK_ENDPOINT = "/api/request/chunk";
|
|
5
|
+
export const REQUEST_CHUNK_SEGMENT_ENCODING = "base64-segment-v1";
|
|
6
|
+
const LEGACY_REQUEST_CHUNK_ENCODING = "legacy-base64-stream";
|
|
7
|
+
const UTF8_DECODER = new TextDecoder("utf-8", { fatal: true });
|
|
4
8
|
const ALLOWED_TARGETS = new Set([
|
|
5
9
|
"/api/session/init",
|
|
6
10
|
"/api/session/update",
|
|
@@ -10,38 +14,183 @@ const ALLOWED_TARGETS = new Set([
|
|
|
10
14
|
"/api/session/tree/append",
|
|
11
15
|
"/api/session/tree/switch",
|
|
12
16
|
"/api/session/drop-last-assistant-error",
|
|
17
|
+
"/api/session/run/cancel",
|
|
13
18
|
"/api/session/compact",
|
|
19
|
+
"/api/session/compact/cancel",
|
|
14
20
|
"/api/stream",
|
|
15
21
|
"/api/receive",
|
|
16
22
|
]);
|
|
17
23
|
const pendingRequests = new Map();
|
|
18
24
|
const completedRequests = new Map();
|
|
25
|
+
const completedRequestTombstones = new Map();
|
|
19
26
|
let pendingRequestBytes = 0;
|
|
27
|
+
let pendingRequestMetadataBytes = 0;
|
|
28
|
+
let pendingRequestTombstoneReservationBytes = 0;
|
|
29
|
+
let completedRequestBytes = 0;
|
|
30
|
+
let completedRequestTombstoneBytes = 0;
|
|
31
|
+
let cleanupTimer;
|
|
20
32
|
export const REQUEST_CHUNK_PENDING_TTL_MS = 5 * 60 * 1000;
|
|
21
|
-
|
|
33
|
+
// Chunk transport base64-encodes the JSON body, whose file payloads may already
|
|
34
|
+
// be base64. Keep enough bounded headroom for the 64 MiB decoded receive limit.
|
|
35
|
+
export const REQUEST_CHUNK_MAX_PENDING_BYTES = 256 * 1024 * 1024;
|
|
36
|
+
export const REQUEST_CHUNK_MAX_TOTAL_CHUNKS = 4096;
|
|
37
|
+
export const REQUEST_CHUNK_MAX_REQUEST_ID_CHARS = 128;
|
|
38
|
+
export const REQUEST_CHUNK_MAX_PENDING_COUNT = 128;
|
|
39
|
+
export const REQUEST_CHUNK_MAX_PENDING_METADATA_BYTES = 32 * 1024 * 1024;
|
|
22
40
|
export const REQUEST_CHUNK_COMPLETED_TTL_MS = 60 * 1000;
|
|
41
|
+
export const REQUEST_CHUNK_MAX_COMPLETED_COUNT = 128;
|
|
42
|
+
export const REQUEST_CHUNK_MAX_COMPLETED_BYTES = 256 * 1024 * 1024;
|
|
43
|
+
export const REQUEST_CHUNK_TOMBSTONE_TTL_MS = 6 * 60 * 60 * 1000;
|
|
44
|
+
export const REQUEST_CHUNK_MAX_TOMBSTONE_COUNT = 8192;
|
|
45
|
+
export const REQUEST_CHUNK_MAX_TOMBSTONE_BYTES = 4 * 1024 * 1024;
|
|
46
|
+
const REQUEST_CHUNK_PENDING_BASE_METADATA_BYTES = 256;
|
|
47
|
+
const REQUEST_CHUNK_PENDING_PER_CHUNK_METADATA_BYTES = 192;
|
|
23
48
|
function sha256(value) {
|
|
24
49
|
return createHash("sha256").update(value).digest("hex");
|
|
25
50
|
}
|
|
51
|
+
function base64Value(characterCode) {
|
|
52
|
+
if (characterCode >= 65 && characterCode <= 90)
|
|
53
|
+
return characterCode - 65;
|
|
54
|
+
if (characterCode >= 97 && characterCode <= 122)
|
|
55
|
+
return characterCode - 71;
|
|
56
|
+
if (characterCode >= 48 && characterCode <= 57)
|
|
57
|
+
return characterCode + 4;
|
|
58
|
+
if (characterCode === 43)
|
|
59
|
+
return 62;
|
|
60
|
+
if (characterCode === 47)
|
|
61
|
+
return 63;
|
|
62
|
+
return -1;
|
|
63
|
+
}
|
|
64
|
+
function getCanonicalBase64DecodedBytes(value, label) {
|
|
65
|
+
if (value.length % 4 !== 0) {
|
|
66
|
+
throw new Error(`${label} must be canonical padded base64`);
|
|
67
|
+
}
|
|
68
|
+
let padding = 0;
|
|
69
|
+
if (value.endsWith("==")) {
|
|
70
|
+
padding = 2;
|
|
71
|
+
}
|
|
72
|
+
else if (value.endsWith("=")) {
|
|
73
|
+
padding = 1;
|
|
74
|
+
}
|
|
75
|
+
const dataLength = value.length - padding;
|
|
76
|
+
for (let index = 0; index < dataLength; index++) {
|
|
77
|
+
if (base64Value(value.charCodeAt(index)) < 0) {
|
|
78
|
+
throw new Error(`${label} must be canonical padded base64`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
for (let index = dataLength; index < value.length; index++) {
|
|
82
|
+
if (value.charCodeAt(index) !== 61) {
|
|
83
|
+
throw new Error(`${label} must be canonical padded base64`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if ((padding === 0 && dataLength % 4 !== 0) ||
|
|
87
|
+
(padding === 1 && (dataLength % 4 !== 3 || (base64Value(value.charCodeAt(dataLength - 1)) & 0b11) !== 0)) ||
|
|
88
|
+
(padding === 2 && (dataLength % 4 !== 2 || (base64Value(value.charCodeAt(dataLength - 1)) & 0b1111) !== 0))) {
|
|
89
|
+
throw new Error(`${label} must be canonical padded base64`);
|
|
90
|
+
}
|
|
91
|
+
return (value.length / 4) * 3 - padding;
|
|
92
|
+
}
|
|
93
|
+
function decodeCanonicalBase64(value, label) {
|
|
94
|
+
getCanonicalBase64DecodedBytes(value, label);
|
|
95
|
+
return Buffer.from(value, "base64");
|
|
96
|
+
}
|
|
97
|
+
function decodeRequestBody(rawBody) {
|
|
98
|
+
try {
|
|
99
|
+
return UTF8_DECODER.decode(rawBody);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
throw new Error("Decoded request body must be valid UTF-8");
|
|
103
|
+
}
|
|
104
|
+
}
|
|
26
105
|
function assertValidChunk(body) {
|
|
27
106
|
if (!body.requestId)
|
|
28
107
|
throw new Error("requestId is required");
|
|
108
|
+
if (typeof body.requestId !== "string" || body.requestId.length > REQUEST_CHUNK_MAX_REQUEST_ID_CHARS) {
|
|
109
|
+
throw new Error(`requestId must not exceed ${REQUEST_CHUNK_MAX_REQUEST_ID_CHARS} characters`);
|
|
110
|
+
}
|
|
29
111
|
if (!ALLOWED_TARGETS.has(body.target))
|
|
30
112
|
throw new Error(`Unsupported chunk target: ${body.target}`);
|
|
31
|
-
if (!Number.isInteger(body.totalChunks) ||
|
|
32
|
-
|
|
113
|
+
if (!Number.isInteger(body.totalChunks) ||
|
|
114
|
+
body.totalChunks <= 0 ||
|
|
115
|
+
body.totalChunks > REQUEST_CHUNK_MAX_TOTAL_CHUNKS) {
|
|
116
|
+
throw new Error(`totalChunks must be an integer from 1 to ${REQUEST_CHUNK_MAX_TOTAL_CHUNKS}`);
|
|
33
117
|
}
|
|
34
118
|
if (!Number.isInteger(body.chunkIndex) || body.chunkIndex < 0 || body.chunkIndex >= body.totalChunks) {
|
|
35
119
|
throw new Error("chunkIndex must be an integer within the chunk range");
|
|
36
120
|
}
|
|
37
121
|
if (typeof body.chunk !== "string")
|
|
38
122
|
throw new Error("chunk must be a string");
|
|
39
|
-
if (
|
|
40
|
-
throw new Error("
|
|
123
|
+
if (body.chunk.length === 0)
|
|
124
|
+
throw new Error("chunk must not be empty");
|
|
125
|
+
if (typeof body.sha256 !== "string" || !/^[a-f0-9]{64}$/.test(body.sha256)) {
|
|
126
|
+
throw new Error("sha256 must be a canonical lowercase 64-character hex string");
|
|
41
127
|
}
|
|
42
128
|
if (sha256(body.chunk) !== body.sha256) {
|
|
43
129
|
throw new Error(`Chunk checksum mismatch: ${body.chunkIndex}`);
|
|
44
130
|
}
|
|
131
|
+
if (body.encoding === undefined) {
|
|
132
|
+
if (body.rawTotalBytes !== undefined || body.rawChunkBytes !== undefined) {
|
|
133
|
+
throw new Error("raw chunk layout metadata requires base64-segment-v1 encoding");
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
encoding: LEGACY_REQUEST_CHUNK_ENCODING,
|
|
137
|
+
layout: "stored",
|
|
138
|
+
chunk: {
|
|
139
|
+
encoding: LEGACY_REQUEST_CHUNK_ENCODING,
|
|
140
|
+
encoded: body.chunk,
|
|
141
|
+
sha256: body.sha256,
|
|
142
|
+
retainedBytes: Buffer.byteLength(body.chunk, "utf-8"),
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
if (body.encoding !== REQUEST_CHUNK_SEGMENT_ENCODING) {
|
|
147
|
+
throw new Error(`Unsupported chunk encoding: ${String(body.encoding)}`);
|
|
148
|
+
}
|
|
149
|
+
const decodedBytes = getCanonicalBase64DecodedBytes(body.chunk, `Chunk ${body.chunkIndex}`);
|
|
150
|
+
if (body.rawTotalBytes === undefined && body.rawChunkBytes === undefined) {
|
|
151
|
+
const decoded = Buffer.from(body.chunk, "base64");
|
|
152
|
+
return {
|
|
153
|
+
encoding: REQUEST_CHUNK_SEGMENT_ENCODING,
|
|
154
|
+
layout: "stored",
|
|
155
|
+
chunk: {
|
|
156
|
+
encoding: REQUEST_CHUNK_SEGMENT_ENCODING,
|
|
157
|
+
decoded,
|
|
158
|
+
sha256: body.sha256,
|
|
159
|
+
retainedBytes: decoded.byteLength,
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
if (body.rawTotalBytes === undefined || body.rawChunkBytes === undefined) {
|
|
164
|
+
throw new Error("rawTotalBytes and rawChunkBytes must be supplied together");
|
|
165
|
+
}
|
|
166
|
+
if (!Number.isSafeInteger(body.rawTotalBytes) ||
|
|
167
|
+
body.rawTotalBytes <= 0 ||
|
|
168
|
+
body.rawTotalBytes > REQUEST_CHUNK_MAX_PENDING_BYTES) {
|
|
169
|
+
throw new Error(`rawTotalBytes must be an integer from 1 to ${REQUEST_CHUNK_MAX_PENDING_BYTES}`);
|
|
170
|
+
}
|
|
171
|
+
if (!Number.isSafeInteger(body.rawChunkBytes) ||
|
|
172
|
+
body.rawChunkBytes <= 0 ||
|
|
173
|
+
body.rawChunkBytes > body.rawTotalBytes) {
|
|
174
|
+
throw new Error("rawChunkBytes must be an integer within the raw request body");
|
|
175
|
+
}
|
|
176
|
+
if (Math.ceil(body.rawTotalBytes / body.rawChunkBytes) !== body.totalChunks) {
|
|
177
|
+
throw new Error("totalChunks does not match the raw chunk layout");
|
|
178
|
+
}
|
|
179
|
+
const offset = body.chunkIndex * body.rawChunkBytes;
|
|
180
|
+
const expectedBytes = Math.min(body.rawChunkBytes, body.rawTotalBytes - offset);
|
|
181
|
+
if (expectedBytes <= 0 || decodedBytes !== expectedBytes) {
|
|
182
|
+
throw new Error(`Chunk ${body.chunkIndex} byte length does not match the raw chunk layout`);
|
|
183
|
+
}
|
|
184
|
+
return {
|
|
185
|
+
encoding: REQUEST_CHUNK_SEGMENT_ENCODING,
|
|
186
|
+
layout: "offset",
|
|
187
|
+
decodedBytes,
|
|
188
|
+
rawTotalBytes: body.rawTotalBytes,
|
|
189
|
+
rawChunkBytes: body.rawChunkBytes,
|
|
190
|
+
offset,
|
|
191
|
+
sha256: body.sha256,
|
|
192
|
+
encoded: body.chunk,
|
|
193
|
+
};
|
|
45
194
|
}
|
|
46
195
|
function makeAck(body) {
|
|
47
196
|
return {
|
|
@@ -56,24 +205,96 @@ function makeAck(body) {
|
|
|
56
205
|
}
|
|
57
206
|
function deletePendingRequest(requestId, pending) {
|
|
58
207
|
pendingRequests.delete(requestId);
|
|
59
|
-
pendingRequestBytes -= pending.
|
|
208
|
+
pendingRequestBytes -= pending.allocatedBytes;
|
|
209
|
+
pendingRequestMetadataBytes -= pending.metadataBytes;
|
|
210
|
+
pendingRequestTombstoneReservationBytes -= pending.tombstoneBytes;
|
|
211
|
+
}
|
|
212
|
+
function deleteCompletedRequest(requestId, completed) {
|
|
213
|
+
completedRequests.delete(requestId);
|
|
214
|
+
completedRequestBytes -= completed.retainedBytes;
|
|
60
215
|
}
|
|
61
|
-
function
|
|
216
|
+
function deleteCompletedRequestTombstone(requestId, tombstone) {
|
|
217
|
+
completedRequestTombstones.delete(requestId);
|
|
218
|
+
completedRequestTombstoneBytes -= tombstone.retainedBytes;
|
|
219
|
+
}
|
|
220
|
+
function cleanupExpiredRequests(nowMs) {
|
|
62
221
|
for (const [requestId, pending] of pendingRequests) {
|
|
63
|
-
if (nowMs
|
|
222
|
+
if (nowMs >= pending.expiresAtMs) {
|
|
64
223
|
deletePendingRequest(requestId, pending);
|
|
65
224
|
}
|
|
66
225
|
}
|
|
67
226
|
for (const [requestId, completed] of completedRequests) {
|
|
68
|
-
if (nowMs
|
|
69
|
-
|
|
227
|
+
if (nowMs >= completed.expiresAtMs) {
|
|
228
|
+
deleteCompletedRequest(requestId, completed);
|
|
70
229
|
}
|
|
71
230
|
}
|
|
231
|
+
for (const [requestId, tombstone] of completedRequestTombstones) {
|
|
232
|
+
if (nowMs >= tombstone.expiresAtMs) {
|
|
233
|
+
deleteCompletedRequestTombstone(requestId, tombstone);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
function scheduleCleanupTimer(nowMs = Date.now()) {
|
|
238
|
+
if (cleanupTimer !== undefined) {
|
|
239
|
+
clearTimeout(cleanupTimer);
|
|
240
|
+
cleanupTimer = undefined;
|
|
241
|
+
}
|
|
242
|
+
let nextExpiryMs = Number.POSITIVE_INFINITY;
|
|
243
|
+
for (const pending of pendingRequests.values()) {
|
|
244
|
+
nextExpiryMs = Math.min(nextExpiryMs, pending.expiresAtMs);
|
|
245
|
+
}
|
|
246
|
+
for (const completed of completedRequests.values()) {
|
|
247
|
+
nextExpiryMs = Math.min(nextExpiryMs, completed.expiresAtMs);
|
|
248
|
+
}
|
|
249
|
+
for (const tombstone of completedRequestTombstones.values()) {
|
|
250
|
+
nextExpiryMs = Math.min(nextExpiryMs, tombstone.expiresAtMs);
|
|
251
|
+
}
|
|
252
|
+
if (!Number.isFinite(nextExpiryMs))
|
|
253
|
+
return;
|
|
254
|
+
cleanupTimer = setTimeout(() => {
|
|
255
|
+
cleanupTimer = undefined;
|
|
256
|
+
cleanupExpiredRequests(Date.now());
|
|
257
|
+
scheduleCleanupTimer();
|
|
258
|
+
}, Math.max(0, nextExpiryMs - nowMs));
|
|
259
|
+
cleanupTimer.unref();
|
|
260
|
+
}
|
|
261
|
+
function assertPositiveLimit(value, label) {
|
|
262
|
+
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
263
|
+
throw new Error(`${label} must be a positive safe integer`);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
function calculatePendingMetadataBytes(body) {
|
|
267
|
+
return (REQUEST_CHUNK_PENDING_BASE_METADATA_BYTES +
|
|
268
|
+
body.totalChunks * REQUEST_CHUNK_PENDING_PER_CHUNK_METADATA_BYTES +
|
|
269
|
+
Buffer.byteLength(body.requestId, "utf-8") +
|
|
270
|
+
Buffer.byteLength(body.target, "utf-8") +
|
|
271
|
+
Buffer.byteLength(body.encoding ?? LEGACY_REQUEST_CHUNK_ENCODING, "utf-8"));
|
|
272
|
+
}
|
|
273
|
+
function calculateTombstoneBytes(body) {
|
|
274
|
+
return (192 +
|
|
275
|
+
Buffer.byteLength(body.requestId, "utf-8") +
|
|
276
|
+
Buffer.byteLength(body.target, "utf-8") +
|
|
277
|
+
Buffer.byteLength(body.encoding ?? LEGACY_REQUEST_CHUNK_ENCODING, "utf-8"));
|
|
72
278
|
}
|
|
73
|
-
function
|
|
279
|
+
function cleanupForPendingCapacity(extraBytes, extraMetadataBytes, extraCount, maxPendingBytes, maxPendingMetadataBytes, maxPendingCount, protectedRequestId, protectedBytesAfterAdmission, protectedMetadataBytesAfterAdmission) {
|
|
280
|
+
assertPositiveLimit(maxPendingBytes, "maxPendingBytes");
|
|
281
|
+
assertPositiveLimit(maxPendingMetadataBytes, "maxPendingMetadataBytes");
|
|
282
|
+
assertPositiveLimit(maxPendingCount, "maxPendingCount");
|
|
283
|
+
if (protectedBytesAfterAdmission > maxPendingBytes) {
|
|
284
|
+
throw new Error("Request chunk pending bytes limit exceeded");
|
|
285
|
+
}
|
|
286
|
+
if (protectedMetadataBytesAfterAdmission > maxPendingMetadataBytes) {
|
|
287
|
+
throw new Error("Request chunk pending metadata limit exceeded");
|
|
288
|
+
}
|
|
289
|
+
if (extraCount > maxPendingCount) {
|
|
290
|
+
throw new Error("Request chunk pending count limit exceeded");
|
|
291
|
+
}
|
|
74
292
|
for (const [requestId, pending] of pendingRequests) {
|
|
75
|
-
if (pendingRequestBytes + extraBytes <= maxPendingBytes
|
|
293
|
+
if (pendingRequestBytes + extraBytes <= maxPendingBytes &&
|
|
294
|
+
pendingRequestMetadataBytes + extraMetadataBytes <= maxPendingMetadataBytes &&
|
|
295
|
+
pendingRequests.size + extraCount <= maxPendingCount) {
|
|
76
296
|
return;
|
|
297
|
+
}
|
|
77
298
|
if (requestId !== protectedRequestId) {
|
|
78
299
|
deletePendingRequest(requestId, pending);
|
|
79
300
|
}
|
|
@@ -81,88 +302,359 @@ function cleanupForPendingBytes(extraBytes, maxPendingBytes, protectedRequestId)
|
|
|
81
302
|
if (pendingRequestBytes + extraBytes > maxPendingBytes) {
|
|
82
303
|
throw new Error("Request chunk pending bytes limit exceeded");
|
|
83
304
|
}
|
|
305
|
+
if (pendingRequestMetadataBytes + extraMetadataBytes > maxPendingMetadataBytes) {
|
|
306
|
+
throw new Error("Request chunk pending metadata limit exceeded");
|
|
307
|
+
}
|
|
308
|
+
if (pendingRequests.size + extraCount > maxPendingCount) {
|
|
309
|
+
throw new Error("Request chunk pending count limit exceeded");
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
function cleanupForTombstoneReservation(extraCount, extraBytes, maxTombstoneCount, maxTombstoneBytes, protectedRequestId) {
|
|
313
|
+
assertPositiveLimit(maxTombstoneCount, "maxTombstoneCount");
|
|
314
|
+
assertPositiveLimit(maxTombstoneBytes, "maxTombstoneBytes");
|
|
315
|
+
if (extraCount > maxTombstoneCount || extraBytes > maxTombstoneBytes) {
|
|
316
|
+
throw new Error("Request chunk completed tombstone limit exceeded");
|
|
317
|
+
}
|
|
318
|
+
for (const [requestId, pending] of pendingRequests) {
|
|
319
|
+
if (completedRequestTombstones.size + pendingRequests.size + extraCount <= maxTombstoneCount &&
|
|
320
|
+
completedRequestTombstoneBytes + pendingRequestTombstoneReservationBytes + extraBytes <= maxTombstoneBytes) {
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
if (requestId !== protectedRequestId)
|
|
324
|
+
deletePendingRequest(requestId, pending);
|
|
325
|
+
}
|
|
326
|
+
if (completedRequestTombstones.size + pendingRequests.size + extraCount > maxTombstoneCount ||
|
|
327
|
+
completedRequestTombstoneBytes + pendingRequestTombstoneReservationBytes + extraBytes > maxTombstoneBytes) {
|
|
328
|
+
throw new Error("Request chunk completed tombstone limit exceeded");
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
function cacheCompletedRequest(requestId, completed, maxCompletedCount, maxCompletedBytes) {
|
|
332
|
+
if (maxCompletedCount <= 0 || completed.retainedBytes > maxCompletedBytes)
|
|
333
|
+
return;
|
|
334
|
+
for (const [completedRequestId, existing] of completedRequests) {
|
|
335
|
+
if (completedRequests.size < maxCompletedCount &&
|
|
336
|
+
completedRequestBytes + completed.retainedBytes <= maxCompletedBytes) {
|
|
337
|
+
break;
|
|
338
|
+
}
|
|
339
|
+
deleteCompletedRequest(completedRequestId, existing);
|
|
340
|
+
}
|
|
341
|
+
if (completedRequests.size >= maxCompletedCount ||
|
|
342
|
+
completedRequestBytes + completed.retainedBytes > maxCompletedBytes) {
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
completedRequests.set(requestId, completed);
|
|
346
|
+
completedRequestBytes += completed.retainedBytes;
|
|
347
|
+
}
|
|
348
|
+
function cacheCompletedRequestTombstone(requestId, tombstone, maxTombstoneCount, maxTombstoneBytes) {
|
|
349
|
+
assertPositiveLimit(maxTombstoneCount, "maxTombstoneCount");
|
|
350
|
+
assertPositiveLimit(maxTombstoneBytes, "maxTombstoneBytes");
|
|
351
|
+
if (completedRequestTombstones.size >= maxTombstoneCount ||
|
|
352
|
+
completedRequestTombstoneBytes + tombstone.retainedBytes > maxTombstoneBytes) {
|
|
353
|
+
throw new Error("Request chunk completed tombstone limit exceeded");
|
|
354
|
+
}
|
|
355
|
+
completedRequestTombstones.set(requestId, tombstone);
|
|
356
|
+
completedRequestTombstoneBytes += tombstone.retainedBytes;
|
|
357
|
+
}
|
|
358
|
+
function requestChunksEqual(left, right) {
|
|
359
|
+
if (left.encoding !== right.encoding || left.sha256 !== right.sha256)
|
|
360
|
+
return false;
|
|
361
|
+
if (left.encoding === LEGACY_REQUEST_CHUNK_ENCODING && right.encoding === LEGACY_REQUEST_CHUNK_ENCODING) {
|
|
362
|
+
return left.encoded === right.encoded;
|
|
363
|
+
}
|
|
364
|
+
if (left.encoding === REQUEST_CHUNK_SEGMENT_ENCODING && right.encoding === REQUEST_CHUNK_SEGMENT_ENCODING) {
|
|
365
|
+
return left.decoded.equals(right.decoded);
|
|
366
|
+
}
|
|
367
|
+
return false;
|
|
368
|
+
}
|
|
369
|
+
function requestLayoutMatches(request, validated) {
|
|
370
|
+
const requestUsesOffsetLayout = "rawTotalBytes" in request && request.rawTotalBytes !== undefined && request.rawChunkBytes !== undefined;
|
|
371
|
+
if (validated.layout === "offset") {
|
|
372
|
+
return (request.encoding === validated.encoding &&
|
|
373
|
+
requestUsesOffsetLayout &&
|
|
374
|
+
request.rawTotalBytes === validated.rawTotalBytes &&
|
|
375
|
+
request.rawChunkBytes === validated.rawChunkBytes);
|
|
376
|
+
}
|
|
377
|
+
return request.encoding === validated.encoding && !requestUsesOffsetLayout;
|
|
378
|
+
}
|
|
379
|
+
function assembleBodyJson(pending) {
|
|
380
|
+
if (pending.layout === "offset") {
|
|
381
|
+
if (pending.receivedBytes !== pending.rawTotalBytes) {
|
|
382
|
+
throw new Error("Decoded request chunk byte count did not match the raw chunk layout");
|
|
383
|
+
}
|
|
384
|
+
const rawBody = pending.rawBody;
|
|
385
|
+
pending.rawBody = Buffer.alloc(0);
|
|
386
|
+
return decodeRequestBody(rawBody);
|
|
387
|
+
}
|
|
388
|
+
if (pending.encoding === LEGACY_REQUEST_CHUNK_ENCODING) {
|
|
389
|
+
const encodedChunks = [];
|
|
390
|
+
for (let index = 0; index < pending.totalChunks; index++) {
|
|
391
|
+
const chunk = pending.chunks.get(index);
|
|
392
|
+
if (!chunk || chunk.encoding !== LEGACY_REQUEST_CHUNK_ENCODING) {
|
|
393
|
+
throw new Error(`Missing legacy chunk index: ${index}`);
|
|
394
|
+
}
|
|
395
|
+
encodedChunks.push(chunk.encoded);
|
|
396
|
+
pending.chunks.delete(index);
|
|
397
|
+
}
|
|
398
|
+
return decodeRequestBody(decodeCanonicalBase64(encodedChunks.join(""), "Legacy request body"));
|
|
399
|
+
}
|
|
400
|
+
const rawBody = Buffer.allocUnsafe(pending.receivedBytes);
|
|
401
|
+
let offset = 0;
|
|
402
|
+
for (let index = 0; index < pending.totalChunks; index++) {
|
|
403
|
+
const chunk = pending.chunks.get(index);
|
|
404
|
+
if (!chunk || chunk.encoding !== REQUEST_CHUNK_SEGMENT_ENCODING) {
|
|
405
|
+
throw new Error(`Missing ${REQUEST_CHUNK_SEGMENT_ENCODING} chunk index: ${index}`);
|
|
406
|
+
}
|
|
407
|
+
chunk.decoded.copy(rawBody, offset);
|
|
408
|
+
offset += chunk.decoded.byteLength;
|
|
409
|
+
pending.chunks.delete(index);
|
|
410
|
+
}
|
|
411
|
+
if (offset !== rawBody.byteLength) {
|
|
412
|
+
throw new Error("Decoded request chunk byte count did not match pending accounting");
|
|
413
|
+
}
|
|
414
|
+
return decodeRequestBody(rawBody);
|
|
84
415
|
}
|
|
85
416
|
export function receiveRequestChunk(body, options = {}) {
|
|
86
|
-
assertValidChunk(body);
|
|
417
|
+
const validated = assertValidChunk(body);
|
|
87
418
|
const nowMs = options.nowMs ?? Date.now();
|
|
88
|
-
|
|
419
|
+
const pendingTtlMs = options.pendingTtlMs ?? REQUEST_CHUNK_PENDING_TTL_MS;
|
|
420
|
+
const completedTtlMs = options.completedTtlMs ?? REQUEST_CHUNK_COMPLETED_TTL_MS;
|
|
421
|
+
const tombstoneTtlMs = options.tombstoneTtlMs ?? REQUEST_CHUNK_TOMBSTONE_TTL_MS;
|
|
422
|
+
assertPositiveLimit(pendingTtlMs, "pendingTtlMs");
|
|
423
|
+
assertPositiveLimit(completedTtlMs, "completedTtlMs");
|
|
424
|
+
assertPositiveLimit(tombstoneTtlMs, "tombstoneTtlMs");
|
|
425
|
+
cleanupExpiredRequests(nowMs);
|
|
89
426
|
const completed = completedRequests.get(body.requestId);
|
|
90
427
|
if (completed) {
|
|
91
|
-
if (completed.target !== body.target ||
|
|
428
|
+
if (completed.target !== body.target ||
|
|
429
|
+
completed.totalChunks !== body.totalChunks ||
|
|
430
|
+
!requestLayoutMatches(completed, validated)) {
|
|
92
431
|
throw new Error("Chunk metadata does not match the completed request");
|
|
93
432
|
}
|
|
94
|
-
const
|
|
95
|
-
if (
|
|
433
|
+
const existingHash = completed.chunkHashes[body.chunkIndex];
|
|
434
|
+
if (existingHash !== body.sha256) {
|
|
96
435
|
throw new Error(`Duplicate chunk index does not match: ${body.chunkIndex}`);
|
|
97
436
|
}
|
|
98
437
|
if (body.chunkIndex === completed.completedChunkIndex) {
|
|
99
438
|
return {
|
|
100
439
|
complete: true,
|
|
440
|
+
requestId: body.requestId,
|
|
441
|
+
replayed: true,
|
|
101
442
|
target: completed.target,
|
|
102
443
|
bodyJson: completed.bodyJson,
|
|
103
444
|
};
|
|
104
445
|
}
|
|
105
446
|
return makeAck(body);
|
|
106
447
|
}
|
|
448
|
+
const tombstone = completedRequestTombstones.get(body.requestId);
|
|
449
|
+
if (tombstone) {
|
|
450
|
+
if (tombstone.target !== body.target ||
|
|
451
|
+
tombstone.totalChunks !== body.totalChunks ||
|
|
452
|
+
!requestLayoutMatches(tombstone, validated)) {
|
|
453
|
+
throw new Error("Chunk metadata does not match the completed request tombstone");
|
|
454
|
+
}
|
|
455
|
+
throw new Error("Chunk request already completed; target response is no longer replayable");
|
|
456
|
+
}
|
|
107
457
|
let pending = pendingRequests.get(body.requestId);
|
|
108
458
|
if (pending) {
|
|
109
|
-
if (pending.target !== body.target ||
|
|
459
|
+
if (pending.target !== body.target ||
|
|
460
|
+
pending.totalChunks !== body.totalChunks ||
|
|
461
|
+
!requestLayoutMatches(pending, validated)) {
|
|
110
462
|
throw new Error("Chunk metadata does not match the pending request");
|
|
111
463
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
if (
|
|
464
|
+
if (pending.layout === "offset" && validated.layout === "offset") {
|
|
465
|
+
const existingHash = pending.chunkHashes[body.chunkIndex];
|
|
466
|
+
if (existingHash !== undefined) {
|
|
467
|
+
if (existingHash !== body.sha256) {
|
|
468
|
+
throw new Error(`Duplicate chunk index does not match: ${body.chunkIndex}`);
|
|
469
|
+
}
|
|
470
|
+
pending.expiresAtMs = nowMs + pendingTtlMs;
|
|
471
|
+
scheduleCleanupTimer(nowMs);
|
|
472
|
+
return makeAck(body);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
else if (pending.layout === "stored" && validated.layout === "stored") {
|
|
476
|
+
const existing = pending.chunks.get(body.chunkIndex);
|
|
477
|
+
if (existing && !requestChunksEqual(existing, validated.chunk)) {
|
|
115
478
|
throw new Error(`Duplicate chunk index does not match: ${body.chunkIndex}`);
|
|
116
479
|
}
|
|
117
|
-
|
|
118
|
-
|
|
480
|
+
if (existing) {
|
|
481
|
+
pending.expiresAtMs = nowMs + pendingTtlMs;
|
|
482
|
+
scheduleCleanupTimer(nowMs);
|
|
483
|
+
return makeAck(body);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
else {
|
|
487
|
+
throw new Error("Chunk layout does not match the pending request");
|
|
119
488
|
}
|
|
120
489
|
}
|
|
121
|
-
const
|
|
122
|
-
|
|
490
|
+
const maxPendingBytes = options.maxPendingBytes ?? REQUEST_CHUNK_MAX_PENDING_BYTES;
|
|
491
|
+
const maxPendingMetadataBytes = options.maxPendingMetadataBytes ?? REQUEST_CHUNK_MAX_PENDING_METADATA_BYTES;
|
|
492
|
+
const maxPendingCount = options.maxPendingCount ?? REQUEST_CHUNK_MAX_PENDING_COUNT;
|
|
493
|
+
const maxTombstoneCount = options.maxTombstoneCount ?? REQUEST_CHUNK_MAX_TOMBSTONE_COUNT;
|
|
494
|
+
const maxTombstoneBytes = options.maxTombstoneBytes ?? REQUEST_CHUNK_MAX_TOMBSTONE_BYTES;
|
|
495
|
+
const metadataBytes = pending?.metadataBytes ?? calculatePendingMetadataBytes(body);
|
|
496
|
+
const tombstoneBytes = pending?.tombstoneBytes ?? calculateTombstoneBytes(body);
|
|
497
|
+
const chunkBytes = pending?.layout === "offset" || validated.layout === "offset" ? 0 : validated.chunk.retainedBytes;
|
|
498
|
+
const allocatedBytes = pending?.allocatedBytes ?? (validated.layout === "offset" ? validated.rawTotalBytes : 0);
|
|
499
|
+
const protectedBytesAfterAdmission = allocatedBytes + chunkBytes;
|
|
500
|
+
cleanupForPendingCapacity(pending ? chunkBytes : allocatedBytes + chunkBytes, pending ? 0 : metadataBytes, pending ? 0 : 1, maxPendingBytes, maxPendingMetadataBytes, maxPendingCount, body.requestId, protectedBytesAfterAdmission, metadataBytes);
|
|
501
|
+
cleanupForTombstoneReservation(pending ? 0 : 1, pending ? 0 : tombstoneBytes, maxTombstoneCount, maxTombstoneBytes, body.requestId);
|
|
123
502
|
if (!pending) {
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
503
|
+
if (validated.layout === "offset") {
|
|
504
|
+
pending = {
|
|
505
|
+
target: body.target,
|
|
506
|
+
totalChunks: body.totalChunks,
|
|
507
|
+
encoding: validated.encoding,
|
|
508
|
+
layout: "offset",
|
|
509
|
+
rawTotalBytes: validated.rawTotalBytes,
|
|
510
|
+
rawChunkBytes: validated.rawChunkBytes,
|
|
511
|
+
rawBody: Buffer.allocUnsafe(validated.rawTotalBytes),
|
|
512
|
+
receivedChunks: new Uint8Array(body.totalChunks),
|
|
513
|
+
chunkHashes: new Array(body.totalChunks),
|
|
514
|
+
receivedChunkCount: 0,
|
|
515
|
+
receivedBytes: 0,
|
|
516
|
+
allocatedBytes: validated.rawTotalBytes,
|
|
517
|
+
metadataBytes,
|
|
518
|
+
tombstoneBytes,
|
|
519
|
+
expiresAtMs: nowMs + pendingTtlMs,
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
else {
|
|
523
|
+
pending = {
|
|
524
|
+
target: body.target,
|
|
525
|
+
totalChunks: body.totalChunks,
|
|
526
|
+
encoding: validated.encoding,
|
|
527
|
+
layout: "stored",
|
|
528
|
+
chunks: new Map(),
|
|
529
|
+
receivedBytes: 0,
|
|
530
|
+
allocatedBytes: 0,
|
|
531
|
+
metadataBytes,
|
|
532
|
+
tombstoneBytes,
|
|
533
|
+
expiresAtMs: nowMs + pendingTtlMs,
|
|
534
|
+
};
|
|
535
|
+
}
|
|
131
536
|
pendingRequests.set(body.requestId, pending);
|
|
537
|
+
pendingRequestBytes += pending.allocatedBytes;
|
|
538
|
+
pendingRequestMetadataBytes += pending.metadataBytes;
|
|
539
|
+
pendingRequestTombstoneReservationBytes += pending.tombstoneBytes;
|
|
540
|
+
}
|
|
541
|
+
if (pending.layout === "offset" && validated.layout === "offset") {
|
|
542
|
+
const written = pending.rawBody.write(validated.encoded, validated.offset, validated.decodedBytes, "base64");
|
|
543
|
+
if (written !== validated.decodedBytes) {
|
|
544
|
+
deletePendingRequest(body.requestId, pending);
|
|
545
|
+
throw new Error(`Chunk ${body.chunkIndex} base64 decode did not fill its raw chunk range`);
|
|
546
|
+
}
|
|
547
|
+
pending.receivedChunks[body.chunkIndex] = 1;
|
|
548
|
+
pending.chunkHashes[body.chunkIndex] = validated.sha256;
|
|
549
|
+
pending.receivedChunkCount++;
|
|
550
|
+
pending.receivedBytes += validated.decodedBytes;
|
|
132
551
|
}
|
|
133
|
-
pending.
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
552
|
+
else if (pending.layout === "stored" && validated.layout === "stored") {
|
|
553
|
+
pending.chunks.set(body.chunkIndex, validated.chunk);
|
|
554
|
+
pending.receivedBytes += validated.chunk.retainedBytes;
|
|
555
|
+
pending.allocatedBytes += validated.chunk.retainedBytes;
|
|
556
|
+
pendingRequestBytes += validated.chunk.retainedBytes;
|
|
557
|
+
}
|
|
558
|
+
else {
|
|
559
|
+
deletePendingRequest(body.requestId, pending);
|
|
560
|
+
throw new Error("Chunk layout changed while admitting the request");
|
|
561
|
+
}
|
|
562
|
+
pending.expiresAtMs = nowMs + pendingTtlMs;
|
|
563
|
+
scheduleCleanupTimer(nowMs);
|
|
564
|
+
const receivedChunkCount = pending.layout === "offset" ? pending.receivedChunkCount : pending.chunks.size;
|
|
565
|
+
if (receivedChunkCount !== pending.totalChunks) {
|
|
138
566
|
return makeAck(body);
|
|
139
567
|
}
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
568
|
+
const chunkHashes = [];
|
|
569
|
+
if (pending.layout === "offset") {
|
|
570
|
+
for (let index = 0; index < pending.totalChunks; index++) {
|
|
571
|
+
const chunkHash = pending.chunkHashes[index];
|
|
572
|
+
if (pending.receivedChunks[index] !== 1 || chunkHash === undefined) {
|
|
573
|
+
throw new Error(`Missing ${REQUEST_CHUNK_SEGMENT_ENCODING} chunk index: ${index}`);
|
|
574
|
+
}
|
|
575
|
+
chunkHashes.push(chunkHash);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
else {
|
|
579
|
+
for (let index = 0; index < pending.totalChunks; index++) {
|
|
580
|
+
const chunk = pending.chunks.get(index);
|
|
581
|
+
if (!chunk)
|
|
582
|
+
throw new Error(`Missing chunk index: ${index}`);
|
|
583
|
+
chunkHashes.push(chunk.sha256);
|
|
584
|
+
}
|
|
146
585
|
}
|
|
147
586
|
deletePendingRequest(body.requestId, pending);
|
|
148
|
-
const bodyJson =
|
|
149
|
-
|
|
587
|
+
const bodyJson = assembleBodyJson(pending);
|
|
588
|
+
const retainedBytes = Buffer.byteLength(bodyJson, "utf-8") +
|
|
589
|
+
Buffer.byteLength(pending.target, "utf-8") +
|
|
590
|
+
Buffer.byteLength(pending.encoding, "utf-8") +
|
|
591
|
+
Buffer.byteLength(body.requestId, "utf-8") +
|
|
592
|
+
chunkHashes.length * 64;
|
|
593
|
+
const completedMetadata = {
|
|
150
594
|
target: pending.target,
|
|
151
595
|
totalChunks: pending.totalChunks,
|
|
596
|
+
encoding: pending.encoding,
|
|
597
|
+
...(pending.layout === "offset"
|
|
598
|
+
? { rawTotalBytes: pending.rawTotalBytes, rawChunkBytes: pending.rawChunkBytes }
|
|
599
|
+
: {}),
|
|
600
|
+
};
|
|
601
|
+
cacheCompletedRequestTombstone(body.requestId, {
|
|
602
|
+
...completedMetadata,
|
|
603
|
+
retainedBytes: tombstoneBytes,
|
|
604
|
+
expiresAtMs: nowMs + tombstoneTtlMs,
|
|
605
|
+
}, maxTombstoneCount, maxTombstoneBytes);
|
|
606
|
+
cacheCompletedRequest(body.requestId, {
|
|
607
|
+
...completedMetadata,
|
|
152
608
|
bodyJson,
|
|
153
609
|
completedChunkIndex: body.chunkIndex,
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
610
|
+
chunkHashes,
|
|
611
|
+
retainedBytes,
|
|
612
|
+
expiresAtMs: nowMs + completedTtlMs,
|
|
613
|
+
}, options.maxCompletedCount ?? REQUEST_CHUNK_MAX_COMPLETED_COUNT, options.maxCompletedBytes ?? REQUEST_CHUNK_MAX_COMPLETED_BYTES);
|
|
614
|
+
scheduleCleanupTimer(nowMs);
|
|
157
615
|
return {
|
|
158
616
|
complete: true,
|
|
617
|
+
requestId: body.requestId,
|
|
618
|
+
replayed: false,
|
|
159
619
|
target: pending.target,
|
|
160
620
|
bodyJson,
|
|
161
621
|
};
|
|
162
622
|
}
|
|
623
|
+
export function getRequestChunkCacheStats() {
|
|
624
|
+
let pendingPreallocatedBuffers = 0;
|
|
625
|
+
let pendingStoredChunks = 0;
|
|
626
|
+
for (const pending of pendingRequests.values()) {
|
|
627
|
+
if (pending.layout === "offset") {
|
|
628
|
+
pendingPreallocatedBuffers++;
|
|
629
|
+
}
|
|
630
|
+
else {
|
|
631
|
+
pendingStoredChunks += pending.chunks.size;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
return {
|
|
635
|
+
pendingCount: pendingRequests.size,
|
|
636
|
+
pendingBytes: pendingRequestBytes,
|
|
637
|
+
pendingMetadataBytes: pendingRequestMetadataBytes,
|
|
638
|
+
pendingPreallocatedBuffers,
|
|
639
|
+
pendingStoredChunks,
|
|
640
|
+
completedCount: completedRequests.size,
|
|
641
|
+
completedBytes: completedRequestBytes,
|
|
642
|
+
tombstoneCount: completedRequestTombstones.size,
|
|
643
|
+
tombstoneBytes: completedRequestTombstoneBytes,
|
|
644
|
+
};
|
|
645
|
+
}
|
|
163
646
|
export function clearAllRequestChunks() {
|
|
647
|
+
if (cleanupTimer !== undefined) {
|
|
648
|
+
clearTimeout(cleanupTimer);
|
|
649
|
+
cleanupTimer = undefined;
|
|
650
|
+
}
|
|
164
651
|
pendingRequests.clear();
|
|
165
652
|
completedRequests.clear();
|
|
653
|
+
completedRequestTombstones.clear();
|
|
166
654
|
pendingRequestBytes = 0;
|
|
655
|
+
pendingRequestMetadataBytes = 0;
|
|
656
|
+
pendingRequestTombstoneReservationBytes = 0;
|
|
657
|
+
completedRequestBytes = 0;
|
|
658
|
+
completedRequestTombstoneBytes = 0;
|
|
167
659
|
}
|
|
168
660
|
//# sourceMappingURL=request-chunks.js.map
|