Skip to content

Commit c2c6e5c

Browse files
authored
fix(webapp): keep session runs off the legacy realtime streams backend (#4564)
## Summary Runs created for a Session were triggered without a realtime streams version, so they fell through to the `realtimeStreamsVersion` column default of `v1`. A Session's own `.in` / `.out` channels are always `v2`, so any run-scoped `streams.append()` or `streams.pipe()` call made inside a session run wrote to a different backend than the session it belongs to, and stayed there for the life of the run. The API trigger routes were never affected. They call `determineRealtimeStreamsVersion` with the client's `x-trigger-realtime-streams-version` header and always pass an explicit value, so a current SDK asking for v2 gets it. Only the internal callers that build trigger options by hand were leaning on the column default, which no env var can influence because that path never calls the resolver at all. ## The version resolver Fixing the call site exposed a second problem in `determineRealtimeStreamsVersion`. Its two paths disagreed: an explicit `v2` was checked against the S2 configuration first, but when the caller expressed no preference it returned `REALTIME_STREAMS_DEFAULT_VERSION` verbatim with no check. A deployment that set the default to `v2` without configuring S2 therefore stamped runs `v2`, nothing failed at trigger time, and every later read or write against those runs' streams threw `Realtime streams v2 is required for this run but S2 configuration is missing` for the life of the run. Both paths now resolve through one pure function that takes its configuration rather than reading `env`: ```ts const requested = streamVersion ?? config.defaultVersion; if (requested !== "v2") return "v1"; const hasCredentials = Boolean(config.accessToken) || config.skipAccessTokens; return hasCredentials && Boolean(config.basin) ? "v2" : "v1"; ``` ## The basin requirement `resolveStreamBasin` resolves run, session and organization basins ahead of the global setting, so a deployment that provisions a basin per organization can serve v2 with no global basin at all. Gating purely on the global setting would degrade every run there to `v1`. `determineRealtimeStreamsVersion` therefore takes an optional organization basin, and every caller that holds one passes it, including the session path: ```ts basin: organizationBasinName ?? env.REALTIME_STREAMS_S2_BASIN, ``` This is deliberately the resolved basin and not the `REALTIME_STREAMS_PER_ORG_BASINS_ENABLED` flag. The flag says the feature is on, not that a given organization has been provisioned, and provisioning happens out of band. Keying off the flag would stamp `v2` on runs for unprovisioned organizations, recreating the failure this removes. **This widens behaviour for explicit `v2` requests**, which previously required the global basin: a provisioned organization on a per-org deployment now resolves `v2` where it used to get `v1`. That is intentional, and it makes every path agree. ## Scope Only newly created runs change. A run already stamped `v1` keeps that version for its lifetime by design, since readers resolve the backend from the same column and its existing streams have to stay readable. Scheduled runs reach the same column default through `scheduleEngine.server.ts` and are deliberately left alone: that one is a policy question about `REALTIME_STREAMS_DEFAULT_VERSION` rather than an inconsistency inside a single feature. ## Verification A full-stack e2e boots the real webapp plus Postgres, Redis and s2-lite, creates a Session through the public API so the run comes from the real trigger path, appends records the way `streams.append()` does, and asserts three things at once: the version stamped on the run, that the payload is readable from S2, and that no key exists in Redis. It appends at a realistic record size so the route's body cap and S2's per-record cap are both exercised. Reverting the session-path change flips all three observations, so it fails against the old behaviour rather than passing vacuously. Unit tests cover the resolver matrix, including organization-basin-only and credential-only configurations; two of them fail against the previous resolver. Also verified by hand against a local stack: a real `chat.agent` session run writing 8 records of 250KB through `streams.append()` put 2,049,072 bytes into S2 with no Redis key, while the same agent with the session-path change removed put 2,102,360 bytes into Redis and nothing into S2.
1 parent 429c004 commit c2c6e5c

15 files changed

Lines changed: 573 additions & 23 deletions
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: fix
4+
---
5+
6+
Realtime streams written inside a chat session run now use the same backend as the session itself, and runs are no longer created against a backend that cannot serve them.

apps/webapp/app/routes/api.v1.tasks.$taskId.batch.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,8 @@ const { action } = createActionApiRoute(
9797
traceContext,
9898
spanParentAsLink: spanParentAsLink === 1,
9999
realtimeStreamsVersion: determineRealtimeStreamsVersion(
100-
realtimeStreamsVersion ?? undefined
100+
realtimeStreamsVersion ?? undefined,
101+
authentication.environment.organization.streamBasinName
101102
),
102103
});
103104

apps/webapp/app/routes/api.v1.tasks.$taskId.trigger.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,8 @@ const { action, loader } = createActionApiRoute(
144144
spanParentAsLink: spanParentAsLink === 1,
145145
oneTimeUseToken,
146146
realtimeStreamsVersion: determineRealtimeStreamsVersion(
147-
realtimeStreamsVersion ?? undefined
147+
realtimeStreamsVersion ?? undefined,
148+
authentication.environment.organization.streamBasinName
148149
),
149150
triggerSource: isFromWorker
150151
? "sdk"

apps/webapp/app/routes/api.v1.tasks.batch.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,8 @@ const { action, loader } = createActionApiRoute(
116116
spanParentAsLink: spanParentAsLink === 1,
117117
oneTimeUseToken,
118118
realtimeStreamsVersion: determineRealtimeStreamsVersion(
119-
realtimeStreamsVersion ?? undefined
119+
realtimeStreamsVersion ?? undefined,
120+
authentication.environment.organization.streamBasinName
120121
),
121122
triggerSource: isFromWorker ? "sdk" : (sanitizeTriggerSource(triggerSourceHeader) ?? "api"),
122123
triggerAction: "trigger",

apps/webapp/app/routes/api.v2.tasks.batch.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,8 @@ const { action, loader } = createActionApiRoute(
143143
spanParentAsLink: spanParentAsLink === 1,
144144
oneTimeUseToken,
145145
realtimeStreamsVersion: determineRealtimeStreamsVersion(
146-
realtimeStreamsVersion ?? undefined
146+
realtimeStreamsVersion ?? undefined,
147+
authentication.environment.organization.streamBasinName
147148
),
148149
triggerSource: isFromWorker ? "sdk" : (sanitizeTriggerSource(triggerSourceHeader) ?? "api"),
149150
triggerAction: "trigger",

apps/webapp/app/routes/api.v3.batches.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,8 @@ const { action, loader } = createActionApiRoute(
167167
spanParentAsLink: spanParentAsLink === 1,
168168
oneTimeUseToken,
169169
realtimeStreamsVersion: determineRealtimeStreamsVersion(
170-
realtimeStreamsVersion ?? undefined
170+
realtimeStreamsVersion ?? undefined,
171+
authentication.environment.organization.streamBasinName
171172
),
172173
triggerSource: isFromWorker ? "sdk" : (sanitizeTriggerSource(triggerSourceHeader) ?? "api"),
173174
});
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/**
2+
* Pure realtime-streams version resolution. Deliberately free of `env` and of
3+
* any module-scope singletons so it can be tested with injected values, the
4+
* same split as `nativeRealtimeClient` and `nativeRealtimeClientInstance`.
5+
* The env-bound wrapper is `determineRealtimeStreamsVersion` in
6+
* `v1StreamsGlobal.server.ts`.
7+
*/
8+
9+
export type RealtimeStreamsVersionConfig = {
10+
defaultVersion: "v1" | "v2";
11+
/** A basin that will actually resolve at read/write time, or undefined if none will. */
12+
basin?: string;
13+
accessToken?: string;
14+
skipAccessTokens: boolean;
15+
};
16+
17+
/**
18+
* Resolve the streams version to stamp on a run, falling back to the
19+
* deployment default when the caller expresses no preference.
20+
*
21+
* v2 is only ever returned when S2 can actually serve it. A run stamped v2 on a
22+
* deployment without S2 is unusable: `getRealtimeStreamInstance` throws for the
23+
* life of the run, and no read or write against its streams can succeed. v1 is
24+
* a working backend, so an unsatisfiable v2 degrades to it.
25+
*
26+
* The basin must be one that will actually resolve later. Enabling per-org
27+
* basins is not enough on its own: provisioning is out of band, so an
28+
* unprovisioned organization has no basin and a global setting may not exist
29+
* to fall back to.
30+
*/
31+
export function resolveRealtimeStreamsVersion(
32+
streamVersion: string | undefined,
33+
config: RealtimeStreamsVersionConfig
34+
): "v1" | "v2" {
35+
const requested = streamVersion ?? config.defaultVersion;
36+
37+
if (requested !== "v2") {
38+
return "v1";
39+
}
40+
41+
const hasCredentials = Boolean(config.accessToken) || config.skipAccessTokens;
42+
43+
return hasCredentials && Boolean(config.basin) ? "v2" : "v1";
44+
}

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { logger } from "~/services/logger.server";
88
import { CancelTaskRunService } from "~/v3/services/cancelTaskRun.server";
99
import { TriggerTaskService } from "~/v3/services/triggerTask.server";
1010
import { isFinalRunStatus } from "~/v3/taskStatus";
11+
import { determineRealtimeStreamsVersion } from "./v1StreamsGlobal.server";
1112

1213
/**
1314
* Schema for `Session.triggerConfig` (stored as JSONB). The wire-format
@@ -275,6 +276,12 @@ export async function ensureRunForSession(
275276
* Trigger a single run for a session. Builds `TriggerTaskRequestBody`
276277
* by shallow-merging `payloadOverrides` over `config.basePayload` and
277278
* threading `config`'s machine/queue/tags through the trigger options.
279+
*
280+
* A session's own channels are always v2, so the run is stamped to match
281+
* rather than inheriting the `realtimeStreamsVersion` column default. Without
282+
* this, run-scoped `streams.*` calls inside a session run resolve to v1 while
283+
* the session it belongs to is on v2. `determineRealtimeStreamsVersion`
284+
* degrades to v1 where v2 streams are not configured.
278285
*/
279286
async function triggerSessionRun(params: {
280287
session: Pick<Session, "id" | "taskIdentifier">;
@@ -310,6 +317,10 @@ async function triggerSessionRun(params: {
310317
const result = await service.call(session.taskIdentifier, environment, body, {
311318
triggerSource: "session",
312319
triggerAction: "trigger",
320+
realtimeStreamsVersion: determineRealtimeStreamsVersion(
321+
"v2",
322+
environment.organization.streamBasinName
323+
),
313324
});
314325

315326
if (!result) {

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

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ import { singleton } from "~/utils/singleton";
1010
import type { AuthenticatedEnvironment } from "../apiAuth.server";
1111
import { RedisRealtimeStreams } from "./redisRealtimeStreams.server";
1212
import { S2RealtimeStreams } from "./s2realtimeStreams.server";
13+
import {
14+
resolveRealtimeStreamsVersion,
15+
type RealtimeStreamsVersionConfig,
16+
} from "./realtimeStreamsVersion";
1317
import type { StreamIngestor, StreamResponder } from "./types";
1418

1519
function initializeRedisRealtimeStreams() {
@@ -96,20 +100,24 @@ function streamPrefixFor(environment: AuthenticatedEnvironment, basin: string):
96100
return segments.join("/");
97101
}
98102

99-
export function determineRealtimeStreamsVersion(streamVersion?: string): "v1" | "v2" {
100-
if (!streamVersion) {
101-
return env.REALTIME_STREAMS_DEFAULT_VERSION;
102-
}
103-
104-
if (
105-
streamVersion === "v2" &&
106-
env.REALTIME_STREAMS_S2_BASIN &&
107-
(env.REALTIME_STREAMS_S2_ACCESS_TOKEN || env.REALTIME_STREAMS_S2_SKIP_ACCESS_TOKENS === "true")
108-
) {
109-
return "v2";
110-
}
103+
export type { RealtimeStreamsVersionConfig };
111104

112-
return "v1";
105+
/**
106+
* Pass `organizationBasinName` wherever the caller has it. It mirrors the
107+
* organization step of {@link resolveStreamBasin}, and is what lets a
108+
* per-org-basin deployment with no global setting resolve v2 for a
109+
* provisioned organization while an unprovisioned one still degrades to v1.
110+
*/
111+
export function determineRealtimeStreamsVersion(
112+
streamVersion?: string,
113+
organizationBasinName?: string | null
114+
): "v1" | "v2" {
115+
return resolveRealtimeStreamsVersion(streamVersion, {
116+
defaultVersion: env.REALTIME_STREAMS_DEFAULT_VERSION,
117+
basin: organizationBasinName ?? env.REALTIME_STREAMS_S2_BASIN,
118+
accessToken: env.REALTIME_STREAMS_S2_ACCESS_TOKEN,
119+
skipAccessTokens: env.REALTIME_STREAMS_S2_SKIP_ACCESS_TOKENS === "true",
120+
});
113121
}
114122

115123
const s2RealtimeStreamsCache = singleton(

apps/webapp/app/v3/services/replayTaskRun.server.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,8 @@ export class ReplayTaskRunService extends BaseService {
163163
traceparent: `00-${existingTaskRun.traceId}-${existingTaskRun.spanId}-01`,
164164
},
165165
realtimeStreamsVersion: determineRealtimeStreamsVersion(
166-
existingTaskRun.realtimeStreamsVersion
166+
existingTaskRun.realtimeStreamsVersion,
167+
authenticatedEnvironment.organization.streamBasinName
167168
),
168169
triggerSource: overrideOptions.triggerSource ?? "api",
169170
triggerAction: "replay",

0 commit comments

Comments
 (0)