Skip to content

Commit fa8da07

Browse files
committed
fix(aggregator): copilot review fixes
Four findings on PR #972; addressing all. 1. `consecutiveFailures` semantic mismatch (jetstream-ingestor.ts + records-do.ts). Docstring claimed "0 means the most recent attempt produced events", but the run loop unconditionally incremented after each connectAndConsume return. So the counter was always ≥ 1 after any disconnect, including ones that successfully streamed events. Fix: increment only when no progress was made; reset to 0 (without increment) when progress was. Also added a regression test that asserts the counter stays 0 across three connect-disconnect cycles that all produced events. 2. `/_admin/start` returned the DO's status body (cursor + failure count). Even an idempotent admin endpoint shouldn't leak operational data to anonymous callers. Fix: route now fires the DO fetch via `ctx.waitUntil` and returns a fixed 204 — caller learns nothing about whether the DO was already running, just woke up, or is mid-startup. The DO's fetch handler still returns the status body (used internally by the cron liveness pump, which doesn't proxy it either). 3. Unhandled rejection in `wrapAtcuteSubscription.close()`. `void inner?.return?.()` suppressed the value but did NOT catch rejections. If the inner iterator's cleanup ever rejects (today it shouldn't, but a future EventIterator change could), workerd would surface an unhandled-promise warning. Fix: chain `.catch(() => {})`. Tests: 14 (was 13; added counter-semantics regression). 0 lint, 0 typecheck.
1 parent 81ae4f4 commit fa8da07

5 files changed

Lines changed: 67 additions & 27 deletions

File tree

apps/aggregator/src/index.ts

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -23,22 +23,27 @@ export { RecordsJetstreamDO } from "./records-do.js";
2323
/**
2424
* Operational bootstrap route. Hitting `/_admin/start` once after deploy
2525
* spins up the Records DO, which opens its outbound WebSocket and starts
26-
* ingesting. The DO's WebSocket keeps it alive thereafter; this route is
27-
* idempotent — calling it on an already-running DO just returns its current
28-
* status. Recommended deploy hook:
26+
* ingesting. The DO's WebSocket keeps it alive thereafter. The route is
27+
* unauthenticated but returns no operational detail — just a fixed 204 —
28+
* so a probing caller learns nothing useful. The action is idempotent on
29+
* an already-running DO. Recommended deploy hook:
2930
*
30-
* wrangler deploy && curl https://api.emdashcms.com/_admin/start
31+
* wrangler deploy && curl -X POST https://api.emdashcms.com/_admin/start
3132
*/
3233
const BOOTSTRAP_PATH = "/_admin/start";
3334

3435
export default {
3536
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
3637
const url = new URL(request.url);
3738
if (url.pathname === BOOTSTRAP_PATH) {
38-
return bootstrapRecordsDo(env);
39+
const id = env.RECORDS_DO.idFromName(RECORDS_DO_NAME);
40+
const stub = env.RECORDS_DO.get(id);
41+
// Fire-and-forget so the response shape doesn't depend on the
42+
// DO's status output. Caller gets the same 204 whether the DO
43+
// was already running, just woke up, or is mid-startup.
44+
ctx.waitUntil(stub.fetch("https://do.internal/bootstrap"));
45+
return new Response(null, { status: 204 });
3946
}
40-
// Suppress unused-arg lint until the XRPC routes land.
41-
void ctx;
4247
return new Response("emdash-aggregator: not yet implemented", {
4348
status: 503,
4449
headers: { "content-type": "text/plain" },
@@ -61,9 +66,3 @@ export default {
6166
ctx.waitUntil(stub.fetch("https://do.internal/liveness"));
6267
},
6368
};
64-
65-
async function bootstrapRecordsDo(env: Env): Promise<Response> {
66-
const id = env.RECORDS_DO.idFromName(RECORDS_DO_NAME);
67-
const stub = env.RECORDS_DO.get(id);
68-
return stub.fetch("https://do.internal/bootstrap");
69-
}

apps/aggregator/src/jetstream-client.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,11 @@ export function wrapAtcuteSubscription<E extends { kind: string }>(
121121
},
122122
close: () => {
123123
fireClosed();
124-
void inner?.return?.();
124+
// `.catch` swallows rejections from the inner iterator's cleanup
125+
// (an EventIterator's `return()` shouldn't reject, but a future
126+
// implementation could). Without this, a rejection here would
127+
// surface as an unhandled-promise warning in workerd.
128+
inner?.return?.()?.catch(() => {});
125129
},
126130
[Symbol.asyncIterator](): AsyncIterator<JetstreamCommitEvent> {
127131
inner ??= sub[Symbol.asyncIterator]();

apps/aggregator/src/jetstream-ingestor.ts

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -135,16 +135,15 @@ export class JetstreamIngestor {
135135
while (!this.stopped) {
136136
try {
137137
await this.connectAndConsume();
138-
// Subscription ended cleanly (Jetstream closed the socket
139-
// without error). Treat as a soft failure for backoff
140-
// purposes — but if we successfully consumed events during
141-
// the connection, reset the counter first so the backoff
142-
// reflects the latest streak, not historical failures.
138+
// Subscription ended cleanly. If we consumed at least one
139+
// event, the connection was healthy — reset the counter and
140+
// reconnect with the floor delay. Otherwise treat as a soft
141+
// failure and grow the backoff.
143142
if (this.madeProgress) this._consecutiveFailures = 0;
144-
this._consecutiveFailures += 1;
143+
else this._consecutiveFailures += 1;
145144
} catch (err) {
146145
if (this.madeProgress) this._consecutiveFailures = 0;
147-
this._consecutiveFailures += 1;
146+
else this._consecutiveFailures += 1;
148147
this.logger.warn?.("jetstream subscription failed", {
149148
error: err instanceof Error ? err.message : String(err),
150149
consecutiveFailures: this._consecutiveFailures,

apps/aggregator/src/records-do.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -45,12 +45,17 @@ export class RecordsJetstreamDO extends DurableObject<Env> {
4545
}
4646

4747
/**
48-
* Status surface used by `/_admin/start` (post-deploy bootstrap) and the
49-
* 5-minute cron liveness pump. Idempotent — calling it on an
50-
* already-running DO just reports the current cursor and consecutive
51-
* failure count, which is the real liveness signal: 0 means the most
52-
* recent connection attempt produced events; a high value indicates
53-
* Jetstream is unreachable or the wantedCollections filter is wrong.
48+
* Status surface for the `/_admin/start` bootstrap and the 5-minute cron
49+
* liveness pump. Idempotent — calling it on an already-running DO just
50+
* reports the current cursor and consecutive-failure count. `0` means
51+
* the most recent connection attempt produced at least one event; a
52+
* non-zero value indicates the latest reconnect cycle hasn't yet
53+
* delivered an event (Jetstream unreachable, wantedCollections
54+
* mismatch, or queue backpressure).
55+
*
56+
* The bootstrap route in the worker doesn't proxy this body — it
57+
* fires-and-forgets the DO fetch and returns 204 — so this surface is
58+
* effectively internal to the DO + cron pump.
5459
*/
5560
override async fetch(_request: Request): Promise<Response> {
5661
return Response.json({

apps/aggregator/test/jetstream-ingestor.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,39 @@ describe("JetstreamIngestor", () => {
256256
await expect(h.runPromise).resolves.toBeUndefined();
257257
});
258258

259+
it("consecutiveFailures stays 0 across disconnect-with-events cycles", async () => {
260+
// Per the documented contract: 0 means the most recent connection
261+
// attempt produced at least one event. A connect → consume → close
262+
// cycle must NOT bump the counter to 1.
263+
const stream = new MockJetstream();
264+
const queue = new InMemoryQueue();
265+
const storage = new MapStorage();
266+
const ingestor = new JetstreamIngestor({
267+
client: new MockJetstreamClient(stream),
268+
queue,
269+
storage,
270+
wantedCollections: [PROFILE_NSID],
271+
backoff: { initialDelayMs: 1, maxDelayMs: 5, multiplier: 2, jitter: 0 },
272+
sleep: () => Promise.resolve(),
273+
});
274+
const runPromise = ingestor.run();
275+
276+
// Three full cycles of: connect → emit → close. After each, the
277+
// counter should still be 0 because each attempt made progress.
278+
for (let i = 0; i < 3; i++) {
279+
stream.emitCommit({ did: TEST_DID, collection: PROFILE_NSID, rkey: `r${i}` });
280+
await waitFor(() => queue.jobs.length === i + 1, `event ${i}`);
281+
stream.closeAll();
282+
// Yield enough microtasks for the run loop to process the close
283+
// and complete its bookkeeping before we inspect.
284+
await new Promise<void>((r) => setTimeout(r, 5));
285+
expect(ingestor.consecutiveFailures).toBe(0);
286+
}
287+
288+
ingestor.stop();
289+
await runPromise;
290+
});
291+
259292
it("resets backoff after a successful event, even across reconnects", async () => {
260293
// Without a reset, a subscription that disconnects → reconnects →
261294
// processes an event → disconnects again would back off as if the

0 commit comments

Comments
 (0)