@mulingai-npm/redis 3.42.0 → 3.43.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -36,6 +36,18 @@ export declare class MulingstreamSpeakerManager {
36
36
  removeSpeakerBySocketId(socketId: string): Promise<boolean>;
37
37
  removeSpeakersByUserId(userId: string): Promise<number>;
38
38
  removeSpeakersByRoomId(roomId: string): Promise<number>;
39
+ /**
40
+ * Remove a speaker by its own id, without needing a live socket mapping.
41
+ *
42
+ * This is the removal that always works, and it is the one the cleanup
43
+ * sweeps must use. Removing by socket depends on `socket:<id>:speaker`
44
+ * still pointing at the record, and after a reconnect self-heal it does
45
+ * not: see updateSocketId. A ghost with a broken mapping is exactly the
46
+ * record a sweep is trying to delete, so resolving it through the mapping
47
+ * fails on precisely the case that matters and the ghost survives every
48
+ * sweep for the full 24 hour TTL.
49
+ */
50
+ removeSpeakerBySpeakerId(speakerId: string): Promise<boolean>;
39
51
  private removeSpeakerById;
40
52
  getSpeakerBySpeakerId(speakerId: string): Promise<MulingstreamSpeakerData | null>;
41
53
  getSpeakerBySocketId(socketId: string): Promise<MulingstreamSpeakerData | null>;
@@ -54,6 +66,22 @@ export declare class MulingstreamSpeakerManager {
54
66
  /**
55
67
  * Update socketId for a speaker (called by heartbeat self-heal when reconnect creates a new socket).
56
68
  * Mirrors the listener-side self-heal pattern — see MULINGSTREAM_RELIABILITY_ROADMAP.md §0.2.
69
+ *
70
+ * ─── THE MAPPING MOVES WITH THE FIELD, OR THE RECORD BECOMES A GHOST ───
71
+ *
72
+ * This used to rewrite the field alone, and that one omission is where
73
+ * every ghost speaker came from. `socket:<id>:speaker` is how a socket is
74
+ * resolved back to its speaker, and it is how disconnect, leave and both
75
+ * cleanup sweeps find the record they mean to delete. Leave it pointing at
76
+ * the OLD socket and the record becomes unreachable from the socket that
77
+ * actually owns it: the speaker's own disconnect finds nothing to remove,
78
+ * so the record survives its socket and lives out the 24 hour TTL.
79
+ *
80
+ * Room 500032 held four of them on 2026-08-27, one per Go Live that day,
81
+ * and three had a socketId field that did not match the socket in their own
82
+ * key: the signature of exactly this. They are not harmless. Ghosts inflate
83
+ * every "is anyone still in this room" count, and cleaning one up is what
84
+ * told a live congregation the speaker had left.
57
85
  */
58
86
  updateSocketId(speakerId: string, newSocketId: string): Promise<boolean>;
59
87
  updateTargetLanguages(socketId: string, languages: string[]): Promise<boolean>;
@@ -129,6 +129,20 @@ class MulingstreamSpeakerManager {
129
129
  deleted += 1;
130
130
  return deleted;
131
131
  }
132
+ /**
133
+ * Remove a speaker by its own id, without needing a live socket mapping.
134
+ *
135
+ * This is the removal that always works, and it is the one the cleanup
136
+ * sweeps must use. Removing by socket depends on `socket:<id>:speaker`
137
+ * still pointing at the record, and after a reconnect self-heal it does
138
+ * not: see updateSocketId. A ghost with a broken mapping is exactly the
139
+ * record a sweep is trying to delete, so resolving it through the mapping
140
+ * fails on precisely the case that matters and the ghost survives every
141
+ * sweep for the full 24 hour TTL.
142
+ */
143
+ async removeSpeakerBySpeakerId(speakerId) {
144
+ return this.removeSpeakerById(speakerId);
145
+ }
132
146
  async removeSpeakerById(speakerId) {
133
147
  const key = this.buildKey(speakerId);
134
148
  const data = await this.redisClient.hgetall(key);
@@ -136,6 +150,21 @@ class MulingstreamSpeakerManager {
136
150
  await this.cleanIndexes(speakerId);
137
151
  return false;
138
152
  }
153
+ /*
154
+ * Drop the mapping for the socket this record CURRENTLY holds, before
155
+ * the hash goes.
156
+ *
157
+ * cleanIndexes can only delete the socket baked into the speakerId,
158
+ * which is the socket the record was created with. A record that has
159
+ * been through a reconnect self-heal holds a different one in its
160
+ * field, and nothing else will ever delete that key: it would sit
161
+ * pointing at a deleted speaker until its TTL, and getSpeakerBySocketId
162
+ * would resolve a socket to a record that no longer exists.
163
+ */
164
+ const currentSocketId = data.socketId;
165
+ if (currentSocketId) {
166
+ await this.redisClient.del(`socket:${currentSocketId}:speaker`);
167
+ }
139
168
  await this.redisClient.del(key);
140
169
  await this.cleanIndexes(speakerId);
141
170
  return true;
@@ -231,12 +260,37 @@ class MulingstreamSpeakerManager {
231
260
  /**
232
261
  * Update socketId for a speaker (called by heartbeat self-heal when reconnect creates a new socket).
233
262
  * Mirrors the listener-side self-heal pattern — see MULINGSTREAM_RELIABILITY_ROADMAP.md §0.2.
263
+ *
264
+ * ─── THE MAPPING MOVES WITH THE FIELD, OR THE RECORD BECOMES A GHOST ───
265
+ *
266
+ * This used to rewrite the field alone, and that one omission is where
267
+ * every ghost speaker came from. `socket:<id>:speaker` is how a socket is
268
+ * resolved back to its speaker, and it is how disconnect, leave and both
269
+ * cleanup sweeps find the record they mean to delete. Leave it pointing at
270
+ * the OLD socket and the record becomes unreachable from the socket that
271
+ * actually owns it: the speaker's own disconnect finds nothing to remove,
272
+ * so the record survives its socket and lives out the 24 hour TTL.
273
+ *
274
+ * Room 500032 held four of them on 2026-08-27, one per Go Live that day,
275
+ * and three had a socketId field that did not match the socket in their own
276
+ * key: the signature of exactly this. They are not harmless. Ghosts inflate
277
+ * every "is anyone still in this room" count, and cleaning one up is what
278
+ * told a live congregation the speaker had left.
234
279
  */
235
280
  async updateSocketId(speakerId, newSocketId) {
236
281
  const speaker = await this.getSpeakerBySpeakerId(speakerId);
237
282
  if (speaker === null)
238
283
  return false;
284
+ const previousSocketId = speaker.socketId;
239
285
  await this.redisClient.hset(this.buildKey(speakerId), { socketId: newSocketId });
286
+ // Old mapping first: if the process dies between the two, an absent
287
+ // mapping is recoverable (the next heartbeat re-heals it) while a
288
+ // mapping pointing at the wrong record is not.
289
+ if (previousSocketId && previousSocketId !== newSocketId) {
290
+ await this.redisClient.del(`socket:${previousSocketId}:speaker`);
291
+ }
292
+ await this.redisClient.set(`socket:${newSocketId}:speaker`, speakerId);
293
+ await this.redisClient.expire(`socket:${newSocketId}:speaker`, EXPIRATION);
240
294
  return true;
241
295
  }
242
296
  async updateTargetLanguages(socketId, languages) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mulingai-npm/redis",
3
- "version": "3.42.0",
3
+ "version": "3.43.1",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "repository": {