@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) {
|