11import { Redis } from "ioredis" ;
22import { defaultReconnectOnError } from "@internal/redis" ;
3+ import {
4+ SESSION_STREAM_WAITPOINT_RECORD_CONTENT_TYPE ,
5+ serializeSessionStreamWaitpointRecord ,
6+ } from "@trigger.dev/core/v3" ;
37import { env } from "~/env.server" ;
48import { singleton } from "~/utils/singleton" ;
59import { logger } from "./logger.server" ;
@@ -13,12 +17,35 @@ import { logger } from "./logger.server";
1317// is shared — without it, two environments using the same externalId
1418// would drain each other's waitpoints.
1519const KEY_PREFIX = "ssw:" ;
20+ const FORMAT_KEY_PREFIX = "sswf:" ;
1621const DEFAULT_TTL_MS = 7 * 24 * 60 * 60 * 1000 ; // 7 days
1722
23+ export type SessionStreamWaitpoint = {
24+ id : string ;
25+ responseFormat ?: "record-v1" ;
26+ } ;
27+
28+ export function sessionStreamWaitpointOutput (
29+ waitpoint : SessionStreamWaitpoint ,
30+ data : string ,
31+ seqNum : number | undefined
32+ ) : { value : string ; type : string ; isError : false } {
33+ const hasRecordEnvelope = waitpoint . responseFormat === "record-v1" && seqNum !== undefined ;
34+ return {
35+ value : hasRecordEnvelope ? serializeSessionStreamWaitpointRecord ( data , seqNum ) : data ,
36+ type : hasRecordEnvelope ? SESSION_STREAM_WAITPOINT_RECORD_CONTENT_TYPE : "application/json" ,
37+ isError : false ,
38+ } ;
39+ }
40+
1841function buildKey ( environmentId : string , addressingKey : string , io : "out" | "in" ) : string {
1942 return `${ KEY_PREFIX } ${ environmentId } :${ addressingKey } :${ io } ` ;
2043}
2144
45+ function buildFormatKey ( waitpointId : string ) : string {
46+ return `${ FORMAT_KEY_PREFIX } ${ waitpointId } ` ;
47+ }
48+
2249// Pre-env-scoping key format, drained for one release so waitpoints from the
2350// previous deploy still wake. Removable once this has been live > turn timeout.
2451function buildLegacyKey ( addressingKey : string , io : "out" | "in" ) : string {
@@ -81,13 +108,25 @@ export async function addSessionStreamWaitpoint(
81108 addressingKey : string ,
82109 io : "out" | "in" ,
83110 waitpointId : string ,
84- ttlMs ?: number
111+ ttlMs ?: number ,
112+ responseFormat ?: "record-v1"
85113) : Promise < void > {
86114 if ( ! redis ) return ;
87115
88116 try {
89117 const key = buildKey ( environmentId , addressingKey , io ) ;
90- await redis . eval ( ADD_WAITPOINT_SCRIPT , 1 , key , waitpointId , String ( ttlMs ?? DEFAULT_TTL_MS ) ) ;
118+ const effectiveTtlMs = ttlMs ?? DEFAULT_TTL_MS ;
119+
120+ // Keep the set member as the plain waitpoint id so an older append
121+ // instance can still drain it during a rolling deploy. New instances read
122+ // the optional response format from this separate, TTL-bound key.
123+ if ( responseFormat ) {
124+ await redis . set ( buildFormatKey ( waitpointId ) , responseFormat , "PX" , effectiveTtlMs ) ;
125+ } else {
126+ await redis . del ( buildFormatKey ( waitpointId ) ) ;
127+ }
128+
129+ await redis . eval ( ADD_WAITPOINT_SCRIPT , 1 , key , waitpointId , String ( effectiveTtlMs ) ) ;
91130 } catch ( error ) {
92131 logger . error ( "Failed to set session stream waitpoint cache" , {
93132 environmentId,
@@ -107,7 +146,7 @@ export async function drainSessionStreamWaitpoints(
107146 environmentId : string ,
108147 addressingKey : string ,
109148 io : "out" | "in"
110- ) : Promise < string [ ] > {
149+ ) : Promise < SessionStreamWaitpoint [ ] > {
111150 if ( ! redis ) return [ ] ;
112151
113152 try {
@@ -129,7 +168,34 @@ export async function drainSessionStreamWaitpoints(
129168 if ( err || ! Array . isArray ( members ) ) continue ;
130169 for ( const m of members as string [ ] ) ids . add ( m ) ;
131170 }
132- return [ ...ids ] ;
171+ const waitpointIds = [ ...ids ] ;
172+ if ( waitpointIds . length === 0 ) return [ ] ;
173+
174+ let formatResults : Awaited < ReturnType < typeof pipeline . exec > > | null = null ;
175+ try {
176+ const formatPipeline = redis . multi ( ) ;
177+ for ( const waitpointId of waitpointIds ) {
178+ formatPipeline . get ( buildFormatKey ( waitpointId ) ) ;
179+ formatPipeline . del ( buildFormatKey ( waitpointId ) ) ;
180+ }
181+ formatResults = await formatPipeline . exec ( ) ;
182+ } catch ( error ) {
183+ // The waitpoint ids were already drained. Complete them with raw data
184+ // rather than losing the wake-up because optional metadata was unavailable.
185+ logger . error ( "Failed to read session stream waitpoint response formats" , {
186+ environmentId,
187+ addressingKey,
188+ io,
189+ error,
190+ } ) ;
191+ }
192+
193+ return waitpointIds . map ( ( id , index ) => {
194+ const formatEntry = formatResults ?. [ index * 2 ] ;
195+ const responseFormat =
196+ formatEntry && ! formatEntry [ 0 ] && formatEntry [ 1 ] === "record-v1" ? "record-v1" : undefined ;
197+ return { id, responseFormat } ;
198+ } ) ;
133199 } catch ( error ) {
134200 logger . error ( "Failed to drain session stream waitpoint cache" , {
135201 environmentId,
@@ -240,7 +306,10 @@ export async function removeSessionStreamWaitpoint(
240306
241307 try {
242308 const key = buildKey ( environmentId , addressingKey , io ) ;
243- await redis . srem ( key , waitpointId ) ;
309+ const pipeline = redis . multi ( ) ;
310+ pipeline . srem ( key , waitpointId ) ;
311+ pipeline . del ( buildFormatKey ( waitpointId ) ) ;
312+ await pipeline . exec ( ) ;
244313 } catch ( error ) {
245314 logger . error ( "Failed to remove session stream waitpoint cache entry" , {
246315 environmentId,
0 commit comments