feat(worker): throttle EventSource reconnects after idle disconnect
Add SSE `retry:` directive to suggest a 2-minute reconnect delay when the worker enters idle sleep and drops silent EventSource streams. This prevents clients from immediately reconnecting and re-waking the container, reducing unnecessary resource usage. Update SSE encoding to support the `retry` field and add tests for correct behavior. Improve idle disconnect logging for better observability.
This commit is contained in:
parent
92e93d4231
commit
a284ee852e
3 changed files with 41 additions and 1 deletions
|
|
@ -3,6 +3,8 @@ export interface SseFrameInput<T = unknown> {
|
||||||
id?: string | number;
|
id?: string | number;
|
||||||
data?: T;
|
data?: T;
|
||||||
comment?: string;
|
comment?: string;
|
||||||
|
/** Reconnection delay (ms) sent to the client EventSource as a `retry:` line. */
|
||||||
|
retry?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function encodeSseFrame<T = unknown>(input: SseFrameInput<T>): string {
|
export function encodeSseFrame<T = unknown>(input: SseFrameInput<T>): string {
|
||||||
|
|
@ -10,6 +12,9 @@ export function encodeSseFrame<T = unknown>(input: SseFrameInput<T>): string {
|
||||||
if (typeof input.comment === 'string') {
|
if (typeof input.comment === 'string') {
|
||||||
lines.push(`: ${input.comment}`);
|
lines.push(`: ${input.comment}`);
|
||||||
}
|
}
|
||||||
|
if (typeof input.retry === 'number' && Number.isFinite(input.retry)) {
|
||||||
|
lines.push(`retry: ${Math.max(0, Math.floor(input.retry))}`);
|
||||||
|
}
|
||||||
if (typeof input.id !== 'undefined') {
|
if (typeof input.id !== 'undefined') {
|
||||||
lines.push(`id: ${String(input.id)}`);
|
lines.push(`id: ${String(input.id)}`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,4 +23,14 @@ describe('sse codec', () => {
|
||||||
expect(parseSseEventId(frame)).toBe(5);
|
expect(parseSseEventId(frame)).toBe(5);
|
||||||
expect(parseSsePayload(frame)).toBe('line1\nline2');
|
expect(parseSsePayload(frame)).toBe('line1\nline2');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('emits a retry directive when provided', () => {
|
||||||
|
const frame = encodeSseFrame({ retry: 120_000 });
|
||||||
|
expect(frame).toContain('retry: 120000');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('omits retry when not finite and floors fractional values', () => {
|
||||||
|
expect(encodeSseFrame({ retry: Number.NaN })).not.toContain('retry:');
|
||||||
|
expect(encodeSseFrame({ retry: 1500.9 })).toContain('retry: 1500');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,11 @@ const COMPUTE_STATE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||||
const LOOP_ERROR_BACKOFF_MS = 500;
|
const LOOP_ERROR_BACKOFF_MS = 500;
|
||||||
const RUNNING_HEARTBEAT_MS = 5000;
|
const RUNNING_HEARTBEAT_MS = 5000;
|
||||||
const OP_EVENTS_KEEPALIVE_MS = 15_000;
|
const OP_EVENTS_KEEPALIVE_MS = 15_000;
|
||||||
|
// Reconnection delay handed to the browser EventSource via the SSE `retry:`
|
||||||
|
// directive. When a silent stream is torn down for idle sleep, this keeps the
|
||||||
|
// client from immediately reconnecting and re-waking the worker; instead it
|
||||||
|
// reconnects on a slow cadence so the container stays asleep most of the time.
|
||||||
|
const OP_EVENTS_RECONNECT_HINT_MS = 120_000;
|
||||||
const DOCUMENT_ID_REGEX = /^[a-f0-9]{64}$/i;
|
const DOCUMENT_ID_REGEX = /^[a-f0-9]{64}$/i;
|
||||||
const SAFE_NAMESPACE_REGEX = /^[a-zA-Z0-9._-]{1,128}$/;
|
const SAFE_NAMESPACE_REGEX = /^[a-zA-Z0-9._-]{1,128}$/;
|
||||||
const WHISPER_MAX_DELIVER = 1;
|
const WHISPER_MAX_DELIVER = 1;
|
||||||
|
|
@ -588,7 +593,11 @@ export async function createComputeWorkerApp(options: CreateComputeWorkerAppOpti
|
||||||
if (idleTimer) return;
|
if (idleTimer) return;
|
||||||
idleTimer = setInterval(() => {
|
idleTimer = setInterval(() => {
|
||||||
if (!session || stopping) return;
|
if (!session || stopping) return;
|
||||||
if (inFlightHttp > 0 || activeSse > 0 || inFlightJobs > 0) return;
|
// Hard work in flight always blocks idle. An open SSE no longer blocks just
|
||||||
|
// by existing — only by delivering events, which refresh lastActivityAt via
|
||||||
|
// markActivity() in the stream's onEvent. So a silent/stuck-op stream lets
|
||||||
|
// the idle window elapse and is torn down by disconnect() below.
|
||||||
|
if (inFlightHttp > 0 || inFlightJobs > 0) return;
|
||||||
if (Date.now() - lastActivityAt < IDLE_DISCONNECT_MS) return;
|
if (Date.now() - lastActivityAt < IDLE_DISCONNECT_MS) return;
|
||||||
void disconnect('idle');
|
void disconnect('idle');
|
||||||
}, IDLE_CHECK_INTERVAL_MS);
|
}, IDLE_CHECK_INTERVAL_MS);
|
||||||
|
|
@ -612,6 +621,15 @@ export async function createComputeWorkerApp(options: CreateComputeWorkerAppOpti
|
||||||
async function disconnect(reason: string): Promise<void> {
|
async function disconnect(reason: string): Promise<void> {
|
||||||
const current = session;
|
const current = session;
|
||||||
if (!current) return;
|
if (!current) return;
|
||||||
|
// Snapshot what's still attached so a dropped connection is visible in logs
|
||||||
|
// (e.g. SSE streams torn down because we went idle while they were open).
|
||||||
|
app.log.info({
|
||||||
|
reason,
|
||||||
|
activeSse,
|
||||||
|
inFlightHttp,
|
||||||
|
inFlightJobs,
|
||||||
|
idleForMs: Date.now() - lastActivityAt,
|
||||||
|
}, 'nats dropping connection');
|
||||||
// Clear synchronously (before any await) so concurrent requests reconnect a
|
// Clear synchronously (before any await) so concurrent requests reconnect a
|
||||||
// fresh session instead of using the connection we're about to close.
|
// fresh session instead of using the connection we're about to close.
|
||||||
session = null;
|
session = null;
|
||||||
|
|
@ -1032,6 +1050,10 @@ export async function createComputeWorkerApp(options: CreateComputeWorkerAppOpti
|
||||||
reply.raw.setHeader('Cache-Control', 'no-cache, no-transform');
|
reply.raw.setHeader('Cache-Control', 'no-cache, no-transform');
|
||||||
reply.raw.setHeader('Connection', 'keep-alive');
|
reply.raw.setHeader('Connection', 'keep-alive');
|
||||||
reply.raw.setHeader('X-Accel-Buffering', 'no');
|
reply.raw.setHeader('X-Accel-Buffering', 'no');
|
||||||
|
// Tell the browser EventSource to back off before reconnecting. If this stream
|
||||||
|
// is torn down because the worker went idle (NATS dropped), we don't want the
|
||||||
|
// client to reconnect immediately and re-wake the container.
|
||||||
|
reply.raw.write(encodeSseFrame({ retry: OP_EVENTS_RECONNECT_HINT_MS }));
|
||||||
|
|
||||||
let closed = false;
|
let closed = false;
|
||||||
let unsubscribe: (() => void) | null = null;
|
let unsubscribe: (() => void) | null = null;
|
||||||
|
|
@ -1095,6 +1117,9 @@ export async function createComputeWorkerApp(options: CreateComputeWorkerAppOpti
|
||||||
if (nextSignature !== signature) {
|
if (nextSignature !== signature) {
|
||||||
current = event.snapshot;
|
current = event.snapshot;
|
||||||
signature = nextSignature;
|
signature = nextSignature;
|
||||||
|
// A real event is progress: refresh the idle window so an actively
|
||||||
|
// streaming op keeps the worker awake. A silent stream does not.
|
||||||
|
markActivity();
|
||||||
writeSnapshot(current, event.eventId);
|
writeSnapshot(current, event.eventId);
|
||||||
}
|
}
|
||||||
if (isTerminalStatus(event.snapshot.status)) {
|
if (isTerminalStatus(event.snapshot.status)) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue