Skip to content

Commit 08fdfc8

Browse files
ericallamTrigger.dev RepoOps
authored andcommitted
fix(webapp): stop idle chat sessions erroring on their next message
Sending a message to a chat session that had been idle for a while could fail. The session's realtime streams are created empty and auto-deleted once they have been empty for the basin's delete-on-empty age, and a message that arrived during the brief window while a stream was being deleted failed with a conflict. The default delete-on-empty age (`REALTIME_STREAMS_BASIN_DELETE_ON_EMPTY_MIN_AGE`) is raised from 1 hour to 30 days, matching the stream retention age, so an idle session's streams are no longer removed out from under it. This age is now also applied when a basin is reconfigured, not only when it is first created. As an extra guard, a stream append that races the deletion is retried with bounded backoff and recreates the stream instead of surfacing an error. Mono-RevId: de345da6508411a22df4179671e7161161d8b26b
1 parent e3336ab commit 08fdfc8

3 files changed

Lines changed: 20 additions & 20 deletions

File tree

apps/webapp/app/env.server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2523,7 +2523,7 @@ const EnvironmentSchema = z
25232523
REALTIME_STREAMS_BASIN_NAME_ENV: z.string().default("dev"),
25242524
REALTIME_STREAMS_BASIN_DEFAULT_RETENTION: durationString().default("30d"),
25252525
REALTIME_STREAMS_BASIN_STORAGE_CLASS: z.enum(["express", "standard"]).default("express"),
2526-
REALTIME_STREAMS_BASIN_DELETE_ON_EMPTY_MIN_AGE: durationString().default("1h"),
2526+
REALTIME_STREAMS_BASIN_DELETE_ON_EMPTY_MIN_AGE: durationString().default("30d"),
25272527
REALTIME_STREAMS_DEFAULT_VERSION: z.enum(["v1", "v2"]).default("v1"),
25282528
WAIT_UNTIL_TIMEOUT_MS: z.coerce.number().int().default(600_000),
25292529

apps/webapp/app/services/realtime/s2realtimeStreams.server.ts

Lines changed: 12 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -609,14 +609,6 @@ export class S2RealtimeStreams implements StreamResponder, StreamIngestor {
609609

610610
// ---------- Internals: S2 REST ----------
611611
private async s2Append(stream: string, body: S2AppendInput): Promise<S2AppendAck> {
612-
// POST /v1/streams/{stream}/records (JSON).
613-
//
614-
// Retries transient failures (network errors and 5xx) up to 3 times with
615-
// exponential backoff. Undici's "fetch failed" errors observed locally
616-
// are pre-connection (DNS/TCP) so the request never reaches S2, making
617-
// retry safe — the alternative is a 500 surfacing to the SDK transport,
618-
// which then retries the whole `/in/append` round-trip and pollutes
619-
// logs. 4xx are not retried (genuine client errors).
620612
const url = `${this.baseUrl}/streams/${encodeURIComponent(stream)}/records`;
621613
const init: RequestInit = {
622614
method: "POST",
@@ -629,8 +621,8 @@ export class S2RealtimeStreams implements StreamResponder, StreamIngestor {
629621
body: JSON.stringify(body),
630622
};
631623

632-
const maxAttempts = 3;
633-
const backoffsMs = [100, 250, 600];
624+
const maxAttempts = 5;
625+
const backoffsMs = [500, 1000, 1500, 2000];
634626
let lastError: unknown;
635627

636628
for (let attempt = 0; attempt < maxAttempts; attempt++) {
@@ -647,15 +639,17 @@ export class S2RealtimeStreams implements StreamResponder, StreamIngestor {
647639
if (res.ok) {
648640
return (await res.json()) as S2AppendAck;
649641
}
650-
await cancelResponseBody(res);
651-
const httpError = new Error(`S2 append failed: ${res.status} ${res.statusText}`);
652-
if (res.status >= 400 && res.status < 500) {
653-
// 4xx — caller-side problem (auth, malformed body, closed stream).
654-
// Retrying won't help.
655-
throw httpError;
642+
if (res.status === 409) {
643+
const text = await res.text().catch(() => "");
644+
lastError = new Error(`S2 append failed: 409 ${res.statusText} ${text}`.trim());
645+
} else {
646+
await cancelResponseBody(res);
647+
const httpError = new Error(`S2 append failed: ${res.status} ${res.statusText}`);
648+
if (res.status >= 400 && res.status < 500) {
649+
throw httpError;
650+
}
651+
lastError = httpError;
656652
}
657-
// 5xx — retryable.
658-
lastError = httpError;
659653
}
660654

661655
const isLastAttempt = attempt === maxAttempts - 1;

apps/webapp/app/services/realtime/streamBasinProvisioner.server.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,11 @@ async function reconfigureBasinForOrg(orgId: string, retention: string): Promise
105105
});
106106
if (!org?.streamBasinName) return;
107107

108-
await s2ReconfigureBasin(org.streamBasinName, { accessToken, retentionPolicy: retention });
108+
await s2ReconfigureBasin(org.streamBasinName, {
109+
accessToken,
110+
retentionPolicy: retention,
111+
deleteOnEmptyMinAge: env.REALTIME_STREAMS_BASIN_DELETE_ON_EMPTY_MIN_AGE,
112+
});
109113

110114
logger.info("[streamBasinProvisioner] reconfigured basin retention", {
111115
orgId,
@@ -219,13 +223,15 @@ async function s2CreateBasin(name: string, opts: CreateBasinOptions): Promise<vo
219223
type ReconfigureBasinOptions = {
220224
accessToken: string;
221225
retentionPolicy: string;
226+
deleteOnEmptyMinAge: string;
222227
};
223228

224229
async function s2ReconfigureBasin(name: string, opts: ReconfigureBasinOptions): Promise<void> {
225230
const url = `${env.REALTIME_STREAMS_S2_ACCOUNT_URL}/basins/${encodeURIComponent(name)}`;
226231
const body = {
227232
default_stream_config: {
228233
retention_policy: { age: parseDuration(opts.retentionPolicy) },
234+
delete_on_empty: { min_age_secs: parseDuration(opts.deleteOnEmptyMinAge) },
229235
},
230236
};
231237

0 commit comments

Comments
 (0)