@@ -10,9 +10,11 @@ const KEYS = {
1010 workflowMeta : ( wfId : string ) => `workflow:${ wfId } :meta` ,
1111 socketWorkflow : ( socketId : string ) => `socket:${ socketId } :workflow` ,
1212 socketSession : ( socketId : string ) => `socket:${ socketId } :session` ,
13+ socketPresenceWorkflow : ( socketId : string ) => `socket:${ socketId } :presence-workflow` ,
1314} as const
1415
1516const SOCKET_KEY_TTL = 3600
17+ const SOCKET_PRESENCE_WORKFLOW_KEY_TTL = 24 * 60 * 60
1618
1719/**
1820 * Lua script for atomic user removal from room.
@@ -22,20 +24,24 @@ const SOCKET_KEY_TTL = 3600
2224const REMOVE_USER_SCRIPT = `
2325local socketWorkflowKey = KEYS[1]
2426local socketSessionKey = KEYS[2]
27+ local socketPresenceWorkflowKey = KEYS[3]
2528local workflowUsersPrefix = ARGV[1]
2629local workflowMetaPrefix = ARGV[2]
2730local socketId = ARGV[3]
2831
2932local workflowId = redis.call('GET', socketWorkflowKey)
3033if not workflowId then
31- return nil
34+ workflowId = redis.call('GET', socketPresenceWorkflowKey)
35+ if not workflowId then
36+ return nil
37+ end
3238end
3339
3440local workflowUsersKey = workflowUsersPrefix .. workflowId .. ':users'
3541local workflowMetaKey = workflowMetaPrefix .. workflowId .. ':meta'
3642
3743redis.call('HDEL', workflowUsersKey, socketId)
38- redis.call('DEL', socketWorkflowKey, socketSessionKey)
44+ redis.call('DEL', socketWorkflowKey, socketSessionKey, socketPresenceWorkflowKey )
3945
4046local remaining = redis.call('HLEN', workflowUsersKey)
4147if remaining == 0 then
@@ -54,11 +60,13 @@ const UPDATE_ACTIVITY_SCRIPT = `
5460local workflowUsersKey = KEYS[1]
5561local socketWorkflowKey = KEYS[2]
5662local socketSessionKey = KEYS[3]
63+ local socketPresenceWorkflowKey = KEYS[4]
5764local socketId = ARGV[1]
5865local cursorJson = ARGV[2]
5966local selectionJson = ARGV[3]
6067local lastActivity = ARGV[4]
6168local ttl = tonumber(ARGV[5])
69+ local presenceWorkflowTtl = tonumber(ARGV[6])
6270
6371local existingJson = redis.call('HGET', workflowUsersKey, socketId)
6472if not existingJson then
@@ -78,6 +86,7 @@ existing.lastActivity = tonumber(lastActivity)
7886redis.call('HSET', workflowUsersKey, socketId, cjson.encode(existing))
7987redis.call('EXPIRE', socketWorkflowKey, ttl)
8088redis.call('EXPIRE', socketSessionKey, ttl)
89+ redis.call('EXPIRE', socketPresenceWorkflowKey, presenceWorkflowTtl)
8190return 1
8291`
8392
@@ -164,6 +173,8 @@ export class RedisRoomManager implements IRoomManager {
164173 pipeline . hSet ( KEYS . workflowMeta ( workflowId ) , 'lastModified' , Date . now ( ) . toString ( ) )
165174 pipeline . set ( KEYS . socketWorkflow ( socketId ) , workflowId )
166175 pipeline . expire ( KEYS . socketWorkflow ( socketId ) , SOCKET_KEY_TTL )
176+ pipeline . set ( KEYS . socketPresenceWorkflow ( socketId ) , workflowId )
177+ pipeline . expire ( KEYS . socketPresenceWorkflow ( socketId ) , SOCKET_PRESENCE_WORKFLOW_KEY_TTL )
167178 pipeline . hSet ( KEYS . socketSession ( socketId ) , {
168179 userId : presence . userId ,
169180 userName : presence . userName ,
@@ -187,35 +198,55 @@ export class RedisRoomManager implements IRoomManager {
187198 }
188199 }
189200
190- async removeUserFromRoom ( socketId : string , retried = false ) : Promise < string | null > {
201+ async removeUserFromRoom (
202+ socketId : string ,
203+ workflowIdHint ?: string ,
204+ retried = false
205+ ) : Promise < string | null > {
191206 if ( ! this . removeUserScriptSha ) {
192207 logger . error ( 'removeUserFromRoom called before initialize()' )
193208 return null
194209 }
195210
196211 try {
197212 const workflowId = await this . redis . evalSha ( this . removeUserScriptSha , {
198- keys : [ KEYS . socketWorkflow ( socketId ) , KEYS . socketSession ( socketId ) ] ,
213+ keys : [
214+ KEYS . socketWorkflow ( socketId ) ,
215+ KEYS . socketSession ( socketId ) ,
216+ KEYS . socketPresenceWorkflow ( socketId ) ,
217+ ] ,
199218 arguments : [ 'workflow:' , 'workflow:' , socketId ] ,
200219 } )
201220
202- if ( workflowId ) {
221+ if ( typeof workflowId === 'string' && workflowId . length > 0 ) {
203222 logger . debug ( `Removed socket ${ socketId } from workflow ${ workflowId } ` )
223+ return workflowId
224+ }
225+
226+ // Fallback without global SCAN: direct cleanup using workflow hint from socket rooms / join context.
227+ if ( workflowIdHint ) {
228+ return this . removeUserFromWorkflowHint ( socketId , workflowIdHint )
204229 }
205- return workflowId as string | null
230+
231+ return null
206232 } catch ( error ) {
207233 if ( ( error as Error ) . message ?. includes ( 'NOSCRIPT' ) && ! retried ) {
208234 logger . warn ( 'Lua script not found, reloading...' )
209235 this . removeUserScriptSha = await this . redis . scriptLoad ( REMOVE_USER_SCRIPT )
210- return this . removeUserFromRoom ( socketId , true )
236+ return this . removeUserFromRoom ( socketId , workflowIdHint , true )
211237 }
212238 logger . error ( `Failed to remove user from room: ${ socketId } ` , error )
213239 return null
214240 }
215241 }
216242
217243 async getWorkflowIdForSocket ( socketId : string ) : Promise < string | null > {
218- return this . redis . get ( KEYS . socketWorkflow ( socketId ) )
244+ const workflowId = await this . redis . get ( KEYS . socketWorkflow ( socketId ) )
245+ if ( workflowId ) {
246+ return workflowId
247+ }
248+
249+ return this . redis . get ( KEYS . socketPresenceWorkflow ( socketId ) )
219250 }
220251
221252 async getUserSession ( socketId : string ) : Promise < UserSession | null > {
@@ -261,6 +292,52 @@ export class RedisRoomManager implements IRoomManager {
261292 return exists > 0
262293 }
263294
295+ private async removeUserFromWorkflowHint (
296+ socketId : string ,
297+ workflowIdHint : string
298+ ) : Promise < string | null > {
299+ try {
300+ const pipeline = this . redis . multi ( )
301+ pipeline . hDel ( KEYS . workflowUsers ( workflowIdHint ) , socketId )
302+ pipeline . del ( KEYS . socketWorkflow ( socketId ) )
303+ pipeline . del ( KEYS . socketSession ( socketId ) )
304+ pipeline . del ( KEYS . socketPresenceWorkflow ( socketId ) )
305+
306+ const results = await pipeline . exec ( )
307+ if ( results . some ( ( result ) => result instanceof Error ) ) {
308+ logger . error ( 'Pipeline partially failed during hinted fallback cleanup' , {
309+ socketId,
310+ workflowIdHint,
311+ } )
312+ return null
313+ }
314+
315+ const hDelResult = results [ 0 ]
316+ const removedCount =
317+ typeof hDelResult === 'number'
318+ ? hDelResult
319+ : typeof hDelResult === 'string'
320+ ? Number . parseInt ( hDelResult , 10 ) || 0
321+ : 0
322+
323+ if ( removedCount <= 0 ) {
324+ return null
325+ }
326+
327+ await this . redis . hSet (
328+ KEYS . workflowMeta ( workflowIdHint ) ,
329+ 'lastModified' ,
330+ Date . now ( ) . toString ( )
331+ )
332+
333+ logger . warn ( `Removed socket ${ socketId } from workflow ${ workflowIdHint } via hinted fallback` )
334+ return workflowIdHint
335+ } catch ( error ) {
336+ logger . error ( 'Failed hinted fallback cleanup' , { socketId, workflowIdHint, error } )
337+ return null
338+ }
339+ }
340+
264341 async updateUserActivity (
265342 workflowId : string ,
266343 socketId : string ,
@@ -278,13 +355,15 @@ export class RedisRoomManager implements IRoomManager {
278355 KEYS . workflowUsers ( workflowId ) ,
279356 KEYS . socketWorkflow ( socketId ) ,
280357 KEYS . socketSession ( socketId ) ,
358+ KEYS . socketPresenceWorkflow ( socketId ) ,
281359 ] ,
282360 arguments : [
283361 socketId ,
284362 updates . cursor !== undefined ? JSON . stringify ( updates . cursor ) : '' ,
285363 updates . selection !== undefined ? JSON . stringify ( updates . selection ) : '' ,
286364 ( updates . lastActivity ?? Date . now ( ) ) . toString ( ) ,
287365 SOCKET_KEY_TTL . toString ( ) ,
366+ SOCKET_PRESENCE_WORKFLOW_KEY_TTL . toString ( ) ,
288367 ] ,
289368 } )
290369 } catch ( error ) {
@@ -348,7 +427,7 @@ export class RedisRoomManager implements IRoomManager {
348427
349428 // Remove all users from Redis state
350429 for ( const user of users ) {
351- await this . removeUserFromRoom ( user . socketId )
430+ await this . removeUserFromRoom ( user . socketId , workflowId )
352431 }
353432
354433 // Clean up room data
0 commit comments