@@ -15,8 +15,9 @@ const TICKET_HEARTBEAT_TTL_SECONDS = 30
1515export const HEARTBEAT_REFRESH_INTERVAL_MS = 10_000
1616
1717/**
18- * TTL on the queue list itself. Set on every enqueue. Prevents abandoned queues
19- * (whole workspace went silent) from sticking around forever in Redis.
18+ * TTL on the queue list itself. Set on enqueue and re-extended by the head's heartbeat,
19+ * so a long-waiting head can't let the list expire out from under the waiters behind it.
20+ * Prevents abandoned queues from sticking around forever in Redis.
2021 */
2122const QUEUE_LIST_TTL_SECONDS = 600
2223
@@ -147,8 +148,9 @@ export class HostedKeyQueue {
147148 }
148149
149150 /**
150- * Refresh the ticket's heartbeat. Called periodically by the head while it's
151- * waiting on the bucket so it doesn't get reaped as dead.
151+ * Refresh the ticket's heartbeat so the head isn't reaped as dead while waiting on the
152+ * bucket. Also re-extends the queue list TTL so a wait outliving {@link QUEUE_LIST_TTL_SECONDS}
153+ * doesn't let the list expire and collapse FIFO ordering.
152154 */
153155 async refreshHeartbeat (
154156 provider : string ,
@@ -158,9 +160,13 @@ export class HostedKeyQueue {
158160 const redis = getRedisClient ( )
159161 if ( ! redis ) return
160162
163+ const listKey = queueListKey ( provider , billingActorId )
161164 const hbKey = heartbeatKey ( provider , billingActorId , ticketId )
162165 try {
163- await redis . set ( hbKey , '1' , 'EX' , TICKET_HEARTBEAT_TTL_SECONDS )
166+ const pipeline = redis . multi ( )
167+ pipeline . set ( hbKey , '1' , 'EX' , TICKET_HEARTBEAT_TTL_SECONDS )
168+ pipeline . expire ( listKey , QUEUE_LIST_TTL_SECONDS )
169+ await pipeline . exec ( )
164170 } catch ( error ) {
165171 logger . warn ( `Queue heartbeat refresh failed for ${ hbKey } ` , {
166172 error : toError ( error ) . message ,
0 commit comments