@posthog/agent 2.3.656 → 2.3.658
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/dist/agent.js +1 -1
- package/dist/agent.js.map +1 -1
- package/dist/posthog-api.js +1 -1
- package/dist/posthog-api.js.map +1 -1
- package/dist/server/agent-server.d.ts +4 -0
- package/dist/server/agent-server.js +708 -4
- package/dist/server/agent-server.js.map +1 -1
- package/dist/server/bin.cjs +716 -7
- package/dist/server/bin.cjs.map +1 -1
- package/package.json +3 -3
- package/src/server/agent-server.test.ts +241 -0
- package/src/server/agent-server.ts +55 -3
- package/src/server/bin.ts +12 -0
- package/src/server/event-stream-sender.test.ts +779 -0
- package/src/server/event-stream-sender.ts +693 -0
- package/src/server/streaming-upload.ts +160 -0
- package/src/server/types.ts +2 -0
|
@@ -0,0 +1,779 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { Logger } from "../utils/logger";
|
|
3
|
+
import { TaskRunEventStreamSender } from "./event-stream-sender";
|
|
4
|
+
|
|
5
|
+
const STREAM_COMPLETE_CONTROL_TYPE = "_posthog/stream_complete";
|
|
6
|
+
|
|
7
|
+
async function readRequestBody(init?: RequestInit): Promise<string> {
|
|
8
|
+
const body = init?.body;
|
|
9
|
+
if (!body) {
|
|
10
|
+
return "";
|
|
11
|
+
}
|
|
12
|
+
if (typeof body === "string") {
|
|
13
|
+
return body;
|
|
14
|
+
}
|
|
15
|
+
if (body instanceof Uint8Array) {
|
|
16
|
+
return new TextDecoder().decode(body);
|
|
17
|
+
}
|
|
18
|
+
if (body instanceof ReadableStream) {
|
|
19
|
+
const reader = body.getReader();
|
|
20
|
+
const chunks: Uint8Array[] = [];
|
|
21
|
+
while (true) {
|
|
22
|
+
const { done, value } = await reader.read();
|
|
23
|
+
if (done) {
|
|
24
|
+
break;
|
|
25
|
+
}
|
|
26
|
+
chunks.push(value);
|
|
27
|
+
}
|
|
28
|
+
const length = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
|
29
|
+
const bytes = new Uint8Array(length);
|
|
30
|
+
let offset = 0;
|
|
31
|
+
for (const chunk of chunks) {
|
|
32
|
+
bytes.set(chunk, offset);
|
|
33
|
+
offset += chunk.length;
|
|
34
|
+
}
|
|
35
|
+
return new TextDecoder().decode(bytes);
|
|
36
|
+
}
|
|
37
|
+
return String(body);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function parseLines(body: string): Record<string, unknown>[] {
|
|
41
|
+
const trimmed = body.trim();
|
|
42
|
+
if (!trimmed) {
|
|
43
|
+
return [];
|
|
44
|
+
}
|
|
45
|
+
return trimmed.split("\n").map((line) => JSON.parse(line));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function eventSequences(body: string): number[] {
|
|
49
|
+
return parseLines(body)
|
|
50
|
+
.map((line) => line.seq)
|
|
51
|
+
.filter((seq): seq is number => typeof seq === "number");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function completionSequences(body: string): number[] {
|
|
55
|
+
return parseLines(body)
|
|
56
|
+
.filter((line) => line.type === STREAM_COMPLETE_CONTROL_TYPE)
|
|
57
|
+
.map((line) => line.final_seq)
|
|
58
|
+
.filter((seq): seq is number => typeof seq === "number");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function responseForBody(body: string, lastAcceptedSeq = 0): Response {
|
|
62
|
+
const sequences = eventSequences(body);
|
|
63
|
+
const acceptedSeq = sequences.at(-1) ?? lastAcceptedSeq;
|
|
64
|
+
return new Response(JSON.stringify({ last_accepted_seq: acceptedSeq }), {
|
|
65
|
+
status: 200,
|
|
66
|
+
headers: { "Content-Type": "application/json" },
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
type StreamingRequestInit = RequestInit & { duplex: "half" };
|
|
71
|
+
|
|
72
|
+
function createFetchStreamingUpload({
|
|
73
|
+
url,
|
|
74
|
+
headers,
|
|
75
|
+
abortController,
|
|
76
|
+
}: {
|
|
77
|
+
url: string;
|
|
78
|
+
headers: Record<string, string>;
|
|
79
|
+
abortController: AbortController;
|
|
80
|
+
}) {
|
|
81
|
+
const bodyStream = new TransformStream<Uint8Array, Uint8Array>();
|
|
82
|
+
const writer = bodyStream.writable.getWriter();
|
|
83
|
+
const requestInit: StreamingRequestInit = {
|
|
84
|
+
method: "POST",
|
|
85
|
+
headers,
|
|
86
|
+
body: bodyStream.readable as BodyInit,
|
|
87
|
+
signal: abortController.signal,
|
|
88
|
+
duplex: "half",
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
write(chunk: Uint8Array): Promise<void> {
|
|
93
|
+
return writer.write(chunk);
|
|
94
|
+
},
|
|
95
|
+
close(): Promise<void> {
|
|
96
|
+
return writer.close();
|
|
97
|
+
},
|
|
98
|
+
async abort(): Promise<void> {
|
|
99
|
+
abortController.abort();
|
|
100
|
+
try {
|
|
101
|
+
await writer.abort();
|
|
102
|
+
} catch {
|
|
103
|
+
// The fetch mock may have already closed the body reader.
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
responsePromise: fetch(url, requestInit),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function createSender(
|
|
111
|
+
options: Partial<
|
|
112
|
+
ConstructorParameters<typeof TaskRunEventStreamSender>[0]
|
|
113
|
+
> = {},
|
|
114
|
+
): TaskRunEventStreamSender {
|
|
115
|
+
return new TaskRunEventStreamSender({
|
|
116
|
+
apiUrl: "http://localhost:8000/",
|
|
117
|
+
projectId: 1,
|
|
118
|
+
taskId: "task-1",
|
|
119
|
+
runId: "run-1",
|
|
120
|
+
token: "ingest-token",
|
|
121
|
+
logger: new Logger({ debug: false }),
|
|
122
|
+
createStreamingUpload: createFetchStreamingUpload,
|
|
123
|
+
...options,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function waitFor(
|
|
128
|
+
predicate: () => boolean,
|
|
129
|
+
timeoutMs = 100,
|
|
130
|
+
): Promise<void> {
|
|
131
|
+
const deadlineAtMs = Date.now() + timeoutMs;
|
|
132
|
+
while (!predicate()) {
|
|
133
|
+
if (Date.now() >= deadlineAtMs) {
|
|
134
|
+
throw new Error("Timed out waiting for condition");
|
|
135
|
+
}
|
|
136
|
+
await new Promise((resolve) => setTimeout(resolve, 1));
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
describe("TaskRunEventStreamSender", () => {
|
|
141
|
+
afterEach(() => {
|
|
142
|
+
vi.unstubAllGlobals();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("streams ordered NDJSON events with the run-scoped token", async () => {
|
|
146
|
+
const requestBodies: string[] = [];
|
|
147
|
+
const fetchMock = vi.fn(
|
|
148
|
+
async (_url: string | URL | Request, init?: RequestInit) => {
|
|
149
|
+
const body = await readRequestBody(init);
|
|
150
|
+
requestBodies.push(body);
|
|
151
|
+
return responseForBody(body);
|
|
152
|
+
},
|
|
153
|
+
);
|
|
154
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
155
|
+
|
|
156
|
+
const sender = createSender();
|
|
157
|
+
|
|
158
|
+
sender.enqueue({ type: "notification", notification: { method: "first" } });
|
|
159
|
+
sender.enqueue({
|
|
160
|
+
type: "notification",
|
|
161
|
+
notification: { method: "second" },
|
|
162
|
+
});
|
|
163
|
+
await sender.stop();
|
|
164
|
+
|
|
165
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
166
|
+
expect(fetchMock.mock.calls[1][0]).toBe(
|
|
167
|
+
"http://localhost:8000/api/projects/1/tasks/task-1/runs/run-1/event_stream/",
|
|
168
|
+
);
|
|
169
|
+
expect(fetchMock.mock.calls[1][1]?.headers).toEqual({
|
|
170
|
+
Authorization: "Bearer ingest-token",
|
|
171
|
+
"Content-Type": "application/x-ndjson",
|
|
172
|
+
});
|
|
173
|
+
expect(fetchMock.mock.calls[1][1]?.headers).not.toHaveProperty(
|
|
174
|
+
"X-PostHog-Event-Stream-Complete",
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
expect(parseLines(requestBodies[1])).toEqual([
|
|
178
|
+
{
|
|
179
|
+
seq: 1,
|
|
180
|
+
event: { type: "notification", notification: { method: "first" } },
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
seq: 2,
|
|
184
|
+
event: { type: "notification", notification: { method: "second" } },
|
|
185
|
+
},
|
|
186
|
+
{ type: STREAM_COMPLETE_CONTROL_TYPE, final_seq: 2 },
|
|
187
|
+
]);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("keeps the active ingest request open across scheduled flushes", async () => {
|
|
191
|
+
const requestBodies: string[] = [];
|
|
192
|
+
let activeStreamClosed = false;
|
|
193
|
+
const fetchMock = vi.fn(
|
|
194
|
+
async (_url: string | URL | Request, init?: RequestInit) => {
|
|
195
|
+
if (!init?.body || typeof init.body === "string") {
|
|
196
|
+
const body = await readRequestBody(init);
|
|
197
|
+
requestBodies.push(body);
|
|
198
|
+
return responseForBody(body);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const body = await readRequestBody(init);
|
|
202
|
+
activeStreamClosed = true;
|
|
203
|
+
requestBodies.push(body);
|
|
204
|
+
return responseForBody(body);
|
|
205
|
+
},
|
|
206
|
+
);
|
|
207
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
208
|
+
|
|
209
|
+
const sender = createSender({ flushDelayMs: 0 });
|
|
210
|
+
|
|
211
|
+
sender.enqueue({ type: "notification", notification: { method: "first" } });
|
|
212
|
+
await waitFor(() => fetchMock.mock.calls.length === 2);
|
|
213
|
+
expect(activeStreamClosed).toBe(false);
|
|
214
|
+
|
|
215
|
+
sender.enqueue({
|
|
216
|
+
type: "notification",
|
|
217
|
+
notification: { method: "second" },
|
|
218
|
+
});
|
|
219
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
220
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
221
|
+
|
|
222
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
223
|
+
expect(activeStreamClosed).toBe(false);
|
|
224
|
+
|
|
225
|
+
await sender.stop();
|
|
226
|
+
|
|
227
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
228
|
+
expect(activeStreamClosed).toBe(true);
|
|
229
|
+
expect(parseLines(requestBodies[1])).toEqual([
|
|
230
|
+
{
|
|
231
|
+
seq: 1,
|
|
232
|
+
event: { type: "notification", notification: { method: "first" } },
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
seq: 2,
|
|
236
|
+
event: { type: "notification", notification: { method: "second" } },
|
|
237
|
+
},
|
|
238
|
+
{ type: STREAM_COMPLETE_CONTROL_TYPE, final_seq: 2 },
|
|
239
|
+
]);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("closes an idle active ingest request after the stream window elapses", async () => {
|
|
243
|
+
const requestBodies: string[] = [];
|
|
244
|
+
let activeStreamClosed = false;
|
|
245
|
+
const fetchMock = vi.fn(
|
|
246
|
+
async (_url: string | URL | Request, init?: RequestInit) => {
|
|
247
|
+
if (!init?.body || typeof init.body === "string") {
|
|
248
|
+
return responseForBody(await readRequestBody(init));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const body = await readRequestBody(init);
|
|
252
|
+
activeStreamClosed = true;
|
|
253
|
+
requestBodies.push(body);
|
|
254
|
+
return responseForBody(body);
|
|
255
|
+
},
|
|
256
|
+
);
|
|
257
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
258
|
+
|
|
259
|
+
const sender = createSender({ flushDelayMs: 0, streamWindowMs: 5 });
|
|
260
|
+
|
|
261
|
+
sender.enqueue({ type: "notification", notification: { method: "first" } });
|
|
262
|
+
await waitFor(() => fetchMock.mock.calls.length === 2);
|
|
263
|
+
expect(activeStreamClosed).toBe(false);
|
|
264
|
+
|
|
265
|
+
await waitFor(() => activeStreamClosed, 200);
|
|
266
|
+
expect(eventSequences(requestBodies[0])).toEqual([1]);
|
|
267
|
+
expect(completionSequences(requestBodies[0])).toEqual([]);
|
|
268
|
+
|
|
269
|
+
await sender.stop();
|
|
270
|
+
|
|
271
|
+
expect(eventSequences(requestBodies[1])).toEqual([]);
|
|
272
|
+
expect(completionSequences(requestBodies[1])).toEqual([1]);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("aborts a stuck ingest response after closing the request body", async () => {
|
|
276
|
+
let aborted = false;
|
|
277
|
+
const fetchMock = vi.fn(
|
|
278
|
+
async (_url: string | URL | Request, init?: RequestInit) => {
|
|
279
|
+
if (!init?.body || typeof init.body === "string") {
|
|
280
|
+
return new Response(JSON.stringify({ last_accepted_seq: 0 }), {
|
|
281
|
+
status: 200,
|
|
282
|
+
headers: { "Content-Type": "application/json" },
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
void readRequestBody(init);
|
|
287
|
+
return new Promise<Response>((_resolve, reject) => {
|
|
288
|
+
init.signal?.addEventListener("abort", () => {
|
|
289
|
+
aborted = true;
|
|
290
|
+
reject(new DOMException("aborted", "AbortError"));
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
},
|
|
294
|
+
);
|
|
295
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
296
|
+
|
|
297
|
+
const sender = createSender({
|
|
298
|
+
requestTimeoutMs: 1,
|
|
299
|
+
retryDelayMs: 1,
|
|
300
|
+
stopTimeoutMs: 1,
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
sender.enqueue({ type: "notification", notification: { method: "first" } });
|
|
304
|
+
await sender.stop();
|
|
305
|
+
|
|
306
|
+
expect(fetchMock.mock.calls.length).toBeGreaterThanOrEqual(2);
|
|
307
|
+
expect(aborted).toBe(true);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it("waits for the final ingest response before stop resolves", async () => {
|
|
311
|
+
const ingestRequest: { resolve?: (response: Response) => void } = {};
|
|
312
|
+
let stopped = false;
|
|
313
|
+
const fetchMock = vi.fn(
|
|
314
|
+
async (_url: string | URL | Request, init?: RequestInit) => {
|
|
315
|
+
if (!init?.body || typeof init.body === "string") {
|
|
316
|
+
return new Response(JSON.stringify({ last_accepted_seq: 0 }), {
|
|
317
|
+
status: 200,
|
|
318
|
+
headers: { "Content-Type": "application/json" },
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
void readRequestBody(init);
|
|
323
|
+
return new Promise<Response>((resolve) => {
|
|
324
|
+
ingestRequest.resolve = resolve;
|
|
325
|
+
});
|
|
326
|
+
},
|
|
327
|
+
);
|
|
328
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
329
|
+
|
|
330
|
+
const sender = createSender();
|
|
331
|
+
|
|
332
|
+
sender.enqueue({ type: "notification", notification: { method: "first" } });
|
|
333
|
+
const stopPromise = sender.stop().then(() => {
|
|
334
|
+
stopped = true;
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
338
|
+
expect(stopped).toBe(false);
|
|
339
|
+
|
|
340
|
+
const resolveIngest = ingestRequest.resolve;
|
|
341
|
+
if (!resolveIngest) {
|
|
342
|
+
throw new Error("expected ingest request to be in flight");
|
|
343
|
+
}
|
|
344
|
+
resolveIngest(
|
|
345
|
+
new Response(JSON.stringify({ last_accepted_seq: 1 }), {
|
|
346
|
+
status: 200,
|
|
347
|
+
headers: { "Content-Type": "application/json" },
|
|
348
|
+
}),
|
|
349
|
+
);
|
|
350
|
+
await stopPromise;
|
|
351
|
+
|
|
352
|
+
expect(stopped).toBe(true);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it("streams only a completion control line on shutdown without buffered events", async () => {
|
|
356
|
+
const requestBodies: string[] = [];
|
|
357
|
+
const fetchMock = vi.fn(
|
|
358
|
+
async (_url: string | URL | Request, init?: RequestInit) => {
|
|
359
|
+
const body = await readRequestBody(init);
|
|
360
|
+
requestBodies.push(body);
|
|
361
|
+
return responseForBody(body);
|
|
362
|
+
},
|
|
363
|
+
);
|
|
364
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
365
|
+
|
|
366
|
+
const sender = createSender();
|
|
367
|
+
|
|
368
|
+
await sender.stop();
|
|
369
|
+
|
|
370
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
371
|
+
expect(requestBodies[0]).toBe("");
|
|
372
|
+
expect(parseLines(requestBodies[1])).toEqual([
|
|
373
|
+
{ type: STREAM_COMPLETE_CONTROL_TYPE, final_seq: 0 },
|
|
374
|
+
]);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it.each([
|
|
378
|
+
{
|
|
379
|
+
name: "when the buffer is full",
|
|
380
|
+
senderOptions: { maxBufferedEvents: 1 },
|
|
381
|
+
events: [
|
|
382
|
+
{ type: "notification", notification: { method: "first" } },
|
|
383
|
+
{ type: "notification", notification: { method: "second" } },
|
|
384
|
+
],
|
|
385
|
+
acceptedMethod: "first",
|
|
386
|
+
},
|
|
387
|
+
{
|
|
388
|
+
name: "when an event is oversized",
|
|
389
|
+
senderOptions: { maxEventBytes: 120 },
|
|
390
|
+
events: [
|
|
391
|
+
{
|
|
392
|
+
type: "notification",
|
|
393
|
+
notification: {
|
|
394
|
+
method: "oversized",
|
|
395
|
+
params: { message: "x".repeat(200) },
|
|
396
|
+
},
|
|
397
|
+
},
|
|
398
|
+
{ type: "notification", notification: { method: "small" } },
|
|
399
|
+
],
|
|
400
|
+
acceptedMethod: "small",
|
|
401
|
+
},
|
|
402
|
+
])(
|
|
403
|
+
"drops events before assigning sequence $name",
|
|
404
|
+
async ({ senderOptions, events, acceptedMethod }) => {
|
|
405
|
+
const requestBodies: string[] = [];
|
|
406
|
+
const fetchMock = vi.fn(
|
|
407
|
+
async (_url: string | URL | Request, init?: RequestInit) => {
|
|
408
|
+
const body = await readRequestBody(init);
|
|
409
|
+
requestBodies.push(body);
|
|
410
|
+
return responseForBody(body);
|
|
411
|
+
},
|
|
412
|
+
);
|
|
413
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
414
|
+
|
|
415
|
+
const sender = createSender(senderOptions);
|
|
416
|
+
|
|
417
|
+
for (const event of events) {
|
|
418
|
+
sender.enqueue(event);
|
|
419
|
+
}
|
|
420
|
+
await sender.stop();
|
|
421
|
+
|
|
422
|
+
expect(parseLines(requestBodies[1])).toEqual([
|
|
423
|
+
{
|
|
424
|
+
seq: 1,
|
|
425
|
+
event: {
|
|
426
|
+
type: "notification",
|
|
427
|
+
notification: { method: acceptedMethod },
|
|
428
|
+
},
|
|
429
|
+
},
|
|
430
|
+
{ type: STREAM_COMPLETE_CONTROL_TYPE, final_seq: 1 },
|
|
431
|
+
]);
|
|
432
|
+
},
|
|
433
|
+
);
|
|
434
|
+
|
|
435
|
+
it("accepts an event at the next sequence size boundary", async () => {
|
|
436
|
+
const requestBodies: string[] = [];
|
|
437
|
+
const fetchMock = vi.fn(
|
|
438
|
+
async (_url: string | URL | Request, init?: RequestInit) => {
|
|
439
|
+
const body = await readRequestBody(init);
|
|
440
|
+
requestBodies.push(body);
|
|
441
|
+
return responseForBody(body);
|
|
442
|
+
},
|
|
443
|
+
);
|
|
444
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
445
|
+
|
|
446
|
+
const event = {
|
|
447
|
+
type: "notification",
|
|
448
|
+
notification: { method: "boundary" },
|
|
449
|
+
};
|
|
450
|
+
const maxEventBytes = new TextEncoder().encode(
|
|
451
|
+
JSON.stringify({ seq: 1, event }),
|
|
452
|
+
).length;
|
|
453
|
+
|
|
454
|
+
const sender = createSender({ maxEventBytes });
|
|
455
|
+
|
|
456
|
+
sender.enqueue(event);
|
|
457
|
+
await sender.stop();
|
|
458
|
+
|
|
459
|
+
expect(parseLines(requestBodies[1])).toEqual([
|
|
460
|
+
{
|
|
461
|
+
seq: 1,
|
|
462
|
+
event: {
|
|
463
|
+
type: "notification",
|
|
464
|
+
notification: { method: "boundary" },
|
|
465
|
+
},
|
|
466
|
+
},
|
|
467
|
+
{ type: STREAM_COMPLETE_CONTROL_TYPE, final_seq: 1 },
|
|
468
|
+
]);
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
it("rolls capped streams on stop", async () => {
|
|
472
|
+
const requestBodies: string[] = [];
|
|
473
|
+
const fetchMock = vi.fn(
|
|
474
|
+
async (_url: string | URL | Request, init?: RequestInit) => {
|
|
475
|
+
const body = await readRequestBody(init);
|
|
476
|
+
requestBodies.push(body);
|
|
477
|
+
return responseForBody(body);
|
|
478
|
+
},
|
|
479
|
+
);
|
|
480
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
481
|
+
|
|
482
|
+
const sender = createSender({ maxStreamEvents: 1 });
|
|
483
|
+
|
|
484
|
+
sender.enqueue({ type: "notification", notification: { method: "first" } });
|
|
485
|
+
sender.enqueue({
|
|
486
|
+
type: "notification",
|
|
487
|
+
notification: { method: "second" },
|
|
488
|
+
});
|
|
489
|
+
await sender.stop();
|
|
490
|
+
|
|
491
|
+
expect(requestBodies).toHaveLength(3);
|
|
492
|
+
expect(eventSequences(requestBodies[1])).toEqual([1]);
|
|
493
|
+
expect(completionSequences(requestBodies[1])).toEqual([]);
|
|
494
|
+
expect(eventSequences(requestBodies[2])).toEqual([2]);
|
|
495
|
+
expect(completionSequences(requestBodies[2])).toEqual([2]);
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
it("retries stop drain after a transient ingest failure", async () => {
|
|
499
|
+
const requestBodies: string[] = [];
|
|
500
|
+
const fetchMock = vi.fn(
|
|
501
|
+
async (_url: string | URL | Request, init?: RequestInit) => {
|
|
502
|
+
const body = await readRequestBody(init);
|
|
503
|
+
if (!body) {
|
|
504
|
+
return new Response(JSON.stringify({ last_accepted_seq: 0 }), {
|
|
505
|
+
status: 200,
|
|
506
|
+
headers: { "Content-Type": "application/json" },
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
requestBodies.push(body);
|
|
511
|
+
if (requestBodies.length === 1) {
|
|
512
|
+
return new Response("temporary failure", { status: 503 });
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
return responseForBody(body);
|
|
516
|
+
},
|
|
517
|
+
);
|
|
518
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
519
|
+
|
|
520
|
+
const sender = createSender({
|
|
521
|
+
retryDelayMs: 1,
|
|
522
|
+
stopTimeoutMs: 100,
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
sender.enqueue({ type: "notification", notification: { method: "first" } });
|
|
526
|
+
await sender.stop();
|
|
527
|
+
|
|
528
|
+
expect(requestBodies.map(eventSequences)).toEqual([[1], [1]]);
|
|
529
|
+
expect(completionSequences(requestBodies[1])).toEqual([1]);
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
it("retries when the active stream response rejects before shutdown", async () => {
|
|
533
|
+
const requestBodies: string[] = [];
|
|
534
|
+
let failedStream = false;
|
|
535
|
+
const fetchMock = vi.fn(
|
|
536
|
+
async (_url: string | URL | Request, init?: RequestInit) => {
|
|
537
|
+
if (!init?.body || typeof init.body === "string") {
|
|
538
|
+
return new Response(JSON.stringify({ last_accepted_seq: 0 }), {
|
|
539
|
+
status: 200,
|
|
540
|
+
headers: { "Content-Type": "application/json" },
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
if (!failedStream) {
|
|
545
|
+
failedStream = true;
|
|
546
|
+
throw new TypeError("fetch failed");
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
const body = await readRequestBody(init);
|
|
550
|
+
requestBodies.push(body);
|
|
551
|
+
return responseForBody(body);
|
|
552
|
+
},
|
|
553
|
+
);
|
|
554
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
555
|
+
|
|
556
|
+
const sender = createSender({
|
|
557
|
+
retryDelayMs: 1,
|
|
558
|
+
stopTimeoutMs: 100,
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
sender.enqueue({ type: "notification", notification: { method: "first" } });
|
|
562
|
+
await sender.stop();
|
|
563
|
+
|
|
564
|
+
expect(requestBodies.map(eventSequences)).toEqual([[1]]);
|
|
565
|
+
expect(completionSequences(requestBodies[0])).toEqual([1]);
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
it("stops retrying after the stop deadline", async () => {
|
|
569
|
+
const requestBodies: string[] = [];
|
|
570
|
+
const warnings: string[] = [];
|
|
571
|
+
const fetchMock = vi.fn(
|
|
572
|
+
async (_url: string | URL | Request, init?: RequestInit) => {
|
|
573
|
+
const body = await readRequestBody(init);
|
|
574
|
+
if (!body) {
|
|
575
|
+
return new Response(JSON.stringify({ last_accepted_seq: 0 }), {
|
|
576
|
+
status: 200,
|
|
577
|
+
headers: { "Content-Type": "application/json" },
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
requestBodies.push(body);
|
|
582
|
+
return new Response("temporary failure", { status: 503 });
|
|
583
|
+
},
|
|
584
|
+
);
|
|
585
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
586
|
+
|
|
587
|
+
const sender = createSender({
|
|
588
|
+
logger: new Logger({
|
|
589
|
+
debug: false,
|
|
590
|
+
onLog: (level, _scope, message) => {
|
|
591
|
+
if (level === "warn") {
|
|
592
|
+
warnings.push(message);
|
|
593
|
+
}
|
|
594
|
+
},
|
|
595
|
+
}),
|
|
596
|
+
retryDelayMs: 5,
|
|
597
|
+
stopTimeoutMs: 1,
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
sender.enqueue({ type: "notification", notification: { method: "first" } });
|
|
601
|
+
await sender.stop();
|
|
602
|
+
|
|
603
|
+
expect(requestBodies).toHaveLength(1);
|
|
604
|
+
expect(eventSequences(requestBodies[0])).toEqual([1]);
|
|
605
|
+
expect(warnings).toContain(
|
|
606
|
+
"Task run event ingest stop deadline reached before fully completing transport",
|
|
607
|
+
);
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
it("continues after a payload error acknowledges a valid prefix", async () => {
|
|
611
|
+
const requestBodies: string[] = [];
|
|
612
|
+
const fetchMock = vi.fn(
|
|
613
|
+
async (_url: string | URL | Request, init?: RequestInit) => {
|
|
614
|
+
const body = await readRequestBody(init);
|
|
615
|
+
if (!body) {
|
|
616
|
+
return new Response(JSON.stringify({ last_accepted_seq: 0 }), {
|
|
617
|
+
status: 200,
|
|
618
|
+
headers: { "Content-Type": "application/json" },
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
requestBodies.push(body);
|
|
622
|
+
|
|
623
|
+
if (requestBodies.length === 1) {
|
|
624
|
+
return new Response(
|
|
625
|
+
JSON.stringify({
|
|
626
|
+
error: "Too many events in request",
|
|
627
|
+
last_accepted_seq: 1,
|
|
628
|
+
}),
|
|
629
|
+
{
|
|
630
|
+
status: 413,
|
|
631
|
+
headers: { "Content-Type": "application/json" },
|
|
632
|
+
},
|
|
633
|
+
);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
return responseForBody(body);
|
|
637
|
+
},
|
|
638
|
+
);
|
|
639
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
640
|
+
|
|
641
|
+
const sender = createSender({
|
|
642
|
+
retryDelayMs: 1,
|
|
643
|
+
stopTimeoutMs: 100,
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
sender.enqueue({ type: "notification", notification: { method: "first" } });
|
|
647
|
+
sender.enqueue({
|
|
648
|
+
type: "notification",
|
|
649
|
+
notification: { method: "second" },
|
|
650
|
+
});
|
|
651
|
+
await sender.stop();
|
|
652
|
+
|
|
653
|
+
expect(requestBodies.map(eventSequences)).toEqual([[1, 2], [2]]);
|
|
654
|
+
expect(completionSequences(requestBodies[1])).toEqual([2]);
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
it("starts after the server's last accepted sequence on restart", async () => {
|
|
658
|
+
const requestBodies: string[] = [];
|
|
659
|
+
const fetchMock = vi.fn(
|
|
660
|
+
async (_url: string | URL | Request, init?: RequestInit) => {
|
|
661
|
+
const body = await readRequestBody(init);
|
|
662
|
+
if (!body) {
|
|
663
|
+
return new Response(JSON.stringify({ last_accepted_seq: 42 }), {
|
|
664
|
+
status: 200,
|
|
665
|
+
headers: { "Content-Type": "application/json" },
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
requestBodies.push(body);
|
|
669
|
+
return responseForBody(body);
|
|
670
|
+
},
|
|
671
|
+
);
|
|
672
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
673
|
+
|
|
674
|
+
const sender = createSender();
|
|
675
|
+
|
|
676
|
+
sender.enqueue({
|
|
677
|
+
type: "notification",
|
|
678
|
+
notification: { method: "after-restart" },
|
|
679
|
+
});
|
|
680
|
+
sender.enqueue({ type: "notification", notification: { method: "next" } });
|
|
681
|
+
await sender.stop();
|
|
682
|
+
|
|
683
|
+
expect(eventSequences(requestBodies[0])).toEqual([43, 44]);
|
|
684
|
+
expect(completionSequences(requestBodies[0])).toEqual([44]);
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
it("rebases buffered events after a sequence gap response", async () => {
|
|
688
|
+
const requestBodies: string[] = [];
|
|
689
|
+
const fetchMock = vi.fn(
|
|
690
|
+
async (_url: string | URL | Request, init?: RequestInit) => {
|
|
691
|
+
const body = await readRequestBody(init);
|
|
692
|
+
if (!body) {
|
|
693
|
+
return new Response(JSON.stringify({ last_accepted_seq: 42 }), {
|
|
694
|
+
status: 200,
|
|
695
|
+
headers: { "Content-Type": "application/json" },
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
requestBodies.push(body);
|
|
700
|
+
if (requestBodies.length === 1) {
|
|
701
|
+
return new Response(
|
|
702
|
+
JSON.stringify({
|
|
703
|
+
error: "Expected sequence 1, got 43",
|
|
704
|
+
last_accepted_seq: 0,
|
|
705
|
+
}),
|
|
706
|
+
{
|
|
707
|
+
status: 409,
|
|
708
|
+
headers: { "Content-Type": "application/json" },
|
|
709
|
+
},
|
|
710
|
+
);
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
return responseForBody(body);
|
|
714
|
+
},
|
|
715
|
+
);
|
|
716
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
717
|
+
|
|
718
|
+
const sender = createSender({
|
|
719
|
+
retryDelayMs: 1,
|
|
720
|
+
stopTimeoutMs: 100,
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
sender.enqueue({
|
|
724
|
+
type: "notification",
|
|
725
|
+
notification: { method: "after-expiry" },
|
|
726
|
+
});
|
|
727
|
+
sender.enqueue({ type: "notification", notification: { method: "next" } });
|
|
728
|
+
await sender.stop();
|
|
729
|
+
|
|
730
|
+
expect(requestBodies.map(eventSequences)).toEqual([
|
|
731
|
+
[43, 44],
|
|
732
|
+
[1, 2],
|
|
733
|
+
]);
|
|
734
|
+
expect(completionSequences(requestBodies[1])).toEqual([2]);
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
it("reconnects and replays only events after the server's accepted prefix", async () => {
|
|
738
|
+
const requestBodies: string[] = [];
|
|
739
|
+
let syncCount = 0;
|
|
740
|
+
const fetchMock = vi.fn(
|
|
741
|
+
async (_url: string | URL | Request, init?: RequestInit) => {
|
|
742
|
+
const body = await readRequestBody(init);
|
|
743
|
+
if (!body) {
|
|
744
|
+
syncCount += 1;
|
|
745
|
+
return new Response(
|
|
746
|
+
JSON.stringify({ last_accepted_seq: syncCount === 1 ? 0 : 1 }),
|
|
747
|
+
{
|
|
748
|
+
status: 200,
|
|
749
|
+
headers: { "Content-Type": "application/json" },
|
|
750
|
+
},
|
|
751
|
+
);
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
requestBodies.push(body);
|
|
755
|
+
if (requestBodies.length === 1) {
|
|
756
|
+
throw new Error("connection reset");
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
return responseForBody(body);
|
|
760
|
+
},
|
|
761
|
+
);
|
|
762
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
763
|
+
|
|
764
|
+
const sender = createSender({
|
|
765
|
+
retryDelayMs: 1,
|
|
766
|
+
stopTimeoutMs: 100,
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
sender.enqueue({ type: "notification", notification: { method: "first" } });
|
|
770
|
+
sender.enqueue({
|
|
771
|
+
type: "notification",
|
|
772
|
+
notification: { method: "second" },
|
|
773
|
+
});
|
|
774
|
+
await sender.stop();
|
|
775
|
+
|
|
776
|
+
expect(requestBodies.map(eventSequences)).toEqual([[1, 2], [2]]);
|
|
777
|
+
expect(completionSequences(requestBodies[1])).toEqual([2]);
|
|
778
|
+
});
|
|
779
|
+
});
|