|
1 | 1 | import { json } from "@remix-run/server-runtime"; |
2 | | -import { tryCatch } from "@trigger.dev/core/utils"; |
3 | | -import { nanoid } from "nanoid"; |
4 | 2 | import { z } from "zod"; |
5 | 3 | import { logger } from "~/services/logger.server"; |
6 | | -import { S2RealtimeStreams } from "~/services/realtime/s2realtimeStreams.server"; |
7 | | -import { ensureRunForSession } from "~/services/realtime/sessionRunManager.server"; |
8 | 4 | import { |
9 | 5 | canonicalSessionAddressingKey, |
10 | 6 | resolveSessionWithWriterFallback, |
11 | 7 | } from "~/services/realtime/sessions.server"; |
12 | | -import { getRealtimeStreamInstance } from "~/services/realtime/v1StreamsGlobal.server"; |
13 | 8 | import { stripClientIngressFields } from "~/services/realtime/sanitizeSessionInput.server"; |
14 | | -import { |
15 | | - claimSessionStreamPart, |
16 | | - releaseSessionStreamPart, |
17 | | -} from "~/services/sessionStreamWaitpointCache.server"; |
18 | | -import { completeSessionStreamWaitpoints } from "~/services/realtime/sessionChannelAppend.server"; |
| 9 | +import { ensureRunThenAppend } from "~/services/realtime/sessionChannelAppend.server"; |
19 | 10 | import { anyResource, createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server"; |
20 | | -import { ServiceValidationError } from "~/v3/services/common.server"; |
21 | 11 |
|
22 | 12 | const ParamsSchema = z.object({ |
23 | 13 | session: z.string(), |
@@ -108,116 +98,63 @@ const { action, loader } = createActionApiRoute( |
108 | 98 | ); |
109 | 99 | } |
110 | 100 |
|
111 | | - const realtimeStream = getRealtimeStreamInstance(authentication.environment, "v2", { |
| 101 | + const addressingKey = canonicalSessionAddressingKey(session, params.session); |
| 102 | + |
| 103 | + let part = await request.text(); |
| 104 | + if (params.io === "in") { |
| 105 | + part = stripClientIngressFields(part); |
| 106 | + } |
| 107 | + |
| 108 | + // Ensure a live run, then append. A failed ensure is best effort: the |
| 109 | + // record is durable in S2 and the next append retries the ensure, so the |
| 110 | + // caller still gets a 200 (the SSE tail delivers it once a run boots). |
| 111 | + const result = await ensureRunThenAppend({ |
112 | 112 | session, |
| 113 | + environment: authentication.environment, |
| 114 | + addressingKey, |
| 115 | + io: params.io, |
| 116 | + part, |
| 117 | + partId: request.headers.get("X-Part-Id") ?? undefined, |
| 118 | + reason: "continuation", |
113 | 119 | }); |
114 | 120 |
|
115 | | - if (!(realtimeStream instanceof S2RealtimeStreams)) { |
| 121 | + if (!result.ok && result.kind === "unsupported_backend") { |
116 | 122 | return json( |
117 | 123 | { ok: false, error: "Session channels require the S2 realtime backend" }, |
118 | 124 | { status: 501 } |
119 | 125 | ); |
120 | 126 | } |
121 | 127 |
|
122 | | - // Probe + ensure a live run before appending. The append itself is |
123 | | - // run-independent (S2 stream is durable, keyed on the session) but |
124 | | - // the message is useless if no run is alive to consume it. The |
125 | | - // probe is a single Prisma read; ensureRunForSession is no-op when |
126 | | - // currentRunId is alive, so the steady-state cost is one extra |
127 | | - // read in the hot path. |
128 | | - // |
129 | | - // Best-effort: if ensureRunForSession throws (e.g. the trigger |
130 | | - // call fails transiently), still append to S2 — the record is |
131 | | - // durable and the next append will retry the ensure step. Don't |
132 | | - // surface the error to the caller; the SSE tail just won't deliver |
133 | | - // it until a run boots. |
134 | | - const [ensureError, ensureResult] = await tryCatch( |
135 | | - ensureRunForSession({ |
136 | | - session, |
137 | | - environment: authentication.environment, |
138 | | - reason: "continuation", |
139 | | - }) |
140 | | - ); |
141 | | - if (ensureError) { |
142 | | - logger.error("Failed to ensureRunForSession on .in/append", { |
| 128 | + if (!result.ensure.ok) { |
| 129 | + // A close that lands mid-request is an expected race, not a failure. |
| 130 | + const level = result.ensure.sessionClosed ? "warn" : "error"; |
| 131 | + logger[level]("Failed to ensureRunForSession on .in/append", { |
143 | 132 | sessionId: session.id, |
144 | 133 | externalId: session.externalId, |
145 | | - error: ensureError, |
| 134 | + error: result.ensure.error, |
146 | 135 | }); |
147 | 136 | } |
148 | 137 |
|
149 | | - const addressingKey = canonicalSessionAddressingKey(session, params.session); |
150 | | - |
151 | | - let part = await request.text(); |
152 | | - if (params.io === "in") { |
153 | | - part = stripClientIngressFields(part); |
154 | | - } |
155 | | - |
156 | | - const clientPartId = request.headers.get("X-Part-Id"); |
157 | | - const partId = clientPartId ?? nanoid(7); |
158 | | - |
159 | | - // Idempotency on client-supplied part ids: atomically claim the id before |
160 | | - // appending. A concurrent or retried POST that loses the claim skips the |
161 | | - // append (no duplicate record) but still falls through to the drain below, |
162 | | - // so a retry whose first attempt died before waking the waitpoint can still |
163 | | - // recover it. The claim is released on append failure so a genuine retry |
164 | | - // can re-claim and proceed. |
165 | | - const wonClaim = clientPartId |
166 | | - ? await claimSessionStreamPart( |
167 | | - authentication.environment.id, |
168 | | - addressingKey, |
169 | | - params.io, |
170 | | - clientPartId |
171 | | - ) |
172 | | - : true; |
173 | | - |
174 | | - let appendSeq: number | undefined; |
175 | | - if (wonClaim) { |
176 | | - const [appendError, seq] = await tryCatch( |
177 | | - realtimeStream.appendPartToSessionStream(part, partId, addressingKey, params.io) |
178 | | - ); |
179 | | - appendSeq = seq ?? undefined; |
180 | | - |
181 | | - if (appendError) { |
182 | | - if (clientPartId) { |
183 | | - await releaseSessionStreamPart( |
184 | | - authentication.environment.id, |
185 | | - addressingKey, |
186 | | - params.io, |
187 | | - clientPartId |
188 | | - ); |
189 | | - } |
190 | | - if (appendError instanceof ServiceValidationError) { |
191 | | - return json( |
192 | | - { ok: false, error: appendError.message }, |
193 | | - { status: appendError.status ?? 422 } |
194 | | - ); |
195 | | - } |
196 | | - logger.error("Failed to append to session stream", { |
197 | | - sessionId: session.id, |
198 | | - io: params.io, |
199 | | - error: appendError, |
200 | | - }); |
201 | | - return json( |
202 | | - { ok: false, error: "Something went wrong, please try again." }, |
203 | | - { status: 500 } |
204 | | - ); |
| 138 | + if (!result.ok) { |
| 139 | + if (result.kind === "rejected") { |
| 140 | + return json({ ok: false, error: result.message }, { status: result.status }); |
205 | 141 | } |
| 142 | + logger.error("Failed to append to session stream", { |
| 143 | + sessionId: session.id, |
| 144 | + io: params.io, |
| 145 | + error: result.error, |
| 146 | + }); |
| 147 | + return json({ ok: false, error: "Something went wrong, please try again." }, { status: 500 }); |
206 | 148 | } |
207 | 149 |
|
208 | | - await completeSessionStreamWaitpoints( |
209 | | - authentication.environment.id, |
210 | | - addressingKey, |
211 | | - params.io, |
212 | | - part |
213 | | - ); |
214 | | - |
215 | 150 | // `seq` lets the client correlate this send to the turn that consumes it. |
216 | 151 | return json( |
217 | 152 | { |
218 | 153 | ok: true, |
219 | | - seq: appendSeq, |
220 | | - ...(ensureResult?.pendingVersion ? { pendingVersion: true } : {}), |
| 154 | + seq: result.seq, |
| 155 | + ...(result.ensure.ok && result.ensure.result.pendingVersion |
| 156 | + ? { pendingVersion: true } |
| 157 | + : {}), |
221 | 158 | }, |
222 | 159 | { status: 200 } |
223 | 160 | ); |
|
0 commit comments