@mulingai-npm/redis 3.42.0 → 3.44.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.
- package/dist/data/listener-presence.d.ts +39 -0
- package/dist/data/listener-presence.js +51 -1
- package/dist/managers/mulingstream-listener-manager.d.ts +14 -7
- package/dist/managers/mulingstream-listener-manager.js +46 -13
- package/dist/managers/mulingstream-speaker-manager.d.ts +28 -0
- package/dist/managers/mulingstream-speaker-manager.js +54 -0
- package/package.json +34 -34
|
@@ -78,3 +78,42 @@ export type ListenerPresence = 'present' | 'away' | 'gone';
|
|
|
78
78
|
export declare function listenerPresence(lastHeartbeatMs: number, isAway: boolean, now?: number): ListenerPresence;
|
|
79
79
|
/** Shorthand for the common question: is this listener consuming anything right now. */
|
|
80
80
|
export declare function isListenerPresent(lastHeartbeatMs: number, isAway: boolean, now?: number): boolean;
|
|
81
|
+
/**
|
|
82
|
+
* Is this listener consuming the product right now, for the purposes of deciding
|
|
83
|
+
* what to translate, what to synthesise, and what a pass is being spent on.
|
|
84
|
+
*
|
|
85
|
+
* ─── WHY THIS IS NOT JUST isListenerPresent ─────────────────────────────────
|
|
86
|
+
*
|
|
87
|
+
* A dark screen means "receiving nothing" only if the listener is READING. The
|
|
88
|
+
* whole presence model above was written from the reader's case and then applied
|
|
89
|
+
* to everyone, and for somebody listening to audio it is simply false. Earbuds
|
|
90
|
+
* in, phone in a pocket, screen off is the most ordinary way a congregation uses
|
|
91
|
+
* live audio translation, and it is `visibilityState === 'hidden'` every time.
|
|
92
|
+
*
|
|
93
|
+
* What that cost, found on 2026-08-27 while testing H7: the listener tab went to
|
|
94
|
+
* the background, the client correctly reported PRESENCE_AWAY, and the pipeline
|
|
95
|
+
* stopped synthesising mid-sentence. Chunks 358 and 359 logged "Active listener
|
|
96
|
+
* languages: [NONE]" and were skipped, sitting between chunks that generated
|
|
97
|
+
* normally, with the heartbeat only seventeen seconds old. In a service that is
|
|
98
|
+
* the congregation losing part of the sermon, with nothing on the speaker's
|
|
99
|
+
* screen to say it happened. The same mechanism withheld TRANSLATION earlier the
|
|
100
|
+
* same day, which is why the room kept going quiet for a few chunks at a time.
|
|
101
|
+
*
|
|
102
|
+
* ─── WHAT THIS DOES AND DOES NOT CONCEDE ────────────────────────────────────
|
|
103
|
+
*
|
|
104
|
+
* Only the CLIENT-DECLARED away is overridden, and only for somebody whose audio
|
|
105
|
+
* is actually on. That distinction is the whole safety of it:
|
|
106
|
+
*
|
|
107
|
+
* isAway means the page told us it is hidden. The page is alive, it is still
|
|
108
|
+
* sending heartbeats, and its audio element is still playing. We know what is
|
|
109
|
+
* happening and it is being consumed.
|
|
110
|
+
*
|
|
111
|
+
* A stale heartbeat means we have heard nothing at all for ninety seconds.
|
|
112
|
+
* That is the genuinely unknown case, and it stays not-consuming exactly as
|
|
113
|
+
* before, because a closed tab that never got to send AUDIO_PAUSE must not go
|
|
114
|
+
* on burning speech synthesis for a quarter of an hour.
|
|
115
|
+
*
|
|
116
|
+
* So a hidden reader still costs nothing, which was the original point, and a
|
|
117
|
+
* hidden listener is served, which is the product.
|
|
118
|
+
*/
|
|
119
|
+
export declare function isListenerConsuming(lastHeartbeatMs: number, isAway: boolean, isListening: boolean, now?: number): boolean;
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
* now costs nothing, because nothing expensive waits on it any more.
|
|
44
44
|
*/
|
|
45
45
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
46
|
-
exports.isListenerPresent = exports.listenerPresence = exports.LISTENER_HEARTBEAT_INTERVAL_MS = exports.LISTENER_GONE_MS = exports.LISTENER_PRESENT_MS = void 0;
|
|
46
|
+
exports.isListenerConsuming = exports.isListenerPresent = exports.listenerPresence = exports.LISTENER_HEARTBEAT_INTERVAL_MS = exports.LISTENER_GONE_MS = exports.LISTENER_PRESENT_MS = void 0;
|
|
47
47
|
/**
|
|
48
48
|
* How long a heartbeat stays fresh: three missed beats at the client's 30 second
|
|
49
49
|
* interval, so one dropped beat on a bad connection never marks anybody away.
|
|
@@ -91,3 +91,53 @@ function isListenerPresent(lastHeartbeatMs, isAway, now = Date.now()) {
|
|
|
91
91
|
return listenerPresence(lastHeartbeatMs, isAway, now) === 'present';
|
|
92
92
|
}
|
|
93
93
|
exports.isListenerPresent = isListenerPresent;
|
|
94
|
+
/**
|
|
95
|
+
* Is this listener consuming the product right now, for the purposes of deciding
|
|
96
|
+
* what to translate, what to synthesise, and what a pass is being spent on.
|
|
97
|
+
*
|
|
98
|
+
* ─── WHY THIS IS NOT JUST isListenerPresent ─────────────────────────────────
|
|
99
|
+
*
|
|
100
|
+
* A dark screen means "receiving nothing" only if the listener is READING. The
|
|
101
|
+
* whole presence model above was written from the reader's case and then applied
|
|
102
|
+
* to everyone, and for somebody listening to audio it is simply false. Earbuds
|
|
103
|
+
* in, phone in a pocket, screen off is the most ordinary way a congregation uses
|
|
104
|
+
* live audio translation, and it is `visibilityState === 'hidden'` every time.
|
|
105
|
+
*
|
|
106
|
+
* What that cost, found on 2026-08-27 while testing H7: the listener tab went to
|
|
107
|
+
* the background, the client correctly reported PRESENCE_AWAY, and the pipeline
|
|
108
|
+
* stopped synthesising mid-sentence. Chunks 358 and 359 logged "Active listener
|
|
109
|
+
* languages: [NONE]" and were skipped, sitting between chunks that generated
|
|
110
|
+
* normally, with the heartbeat only seventeen seconds old. In a service that is
|
|
111
|
+
* the congregation losing part of the sermon, with nothing on the speaker's
|
|
112
|
+
* screen to say it happened. The same mechanism withheld TRANSLATION earlier the
|
|
113
|
+
* same day, which is why the room kept going quiet for a few chunks at a time.
|
|
114
|
+
*
|
|
115
|
+
* ─── WHAT THIS DOES AND DOES NOT CONCEDE ────────────────────────────────────
|
|
116
|
+
*
|
|
117
|
+
* Only the CLIENT-DECLARED away is overridden, and only for somebody whose audio
|
|
118
|
+
* is actually on. That distinction is the whole safety of it:
|
|
119
|
+
*
|
|
120
|
+
* isAway means the page told us it is hidden. The page is alive, it is still
|
|
121
|
+
* sending heartbeats, and its audio element is still playing. We know what is
|
|
122
|
+
* happening and it is being consumed.
|
|
123
|
+
*
|
|
124
|
+
* A stale heartbeat means we have heard nothing at all for ninety seconds.
|
|
125
|
+
* That is the genuinely unknown case, and it stays not-consuming exactly as
|
|
126
|
+
* before, because a closed tab that never got to send AUDIO_PAUSE must not go
|
|
127
|
+
* on burning speech synthesis for a quarter of an hour.
|
|
128
|
+
*
|
|
129
|
+
* So a hidden reader still costs nothing, which was the original point, and a
|
|
130
|
+
* hidden listener is served, which is the product.
|
|
131
|
+
*/
|
|
132
|
+
function isListenerConsuming(lastHeartbeatMs, isAway, isListening, now = Date.now()) {
|
|
133
|
+
const age = now - lastHeartbeatMs;
|
|
134
|
+
// Heard nothing recently: unknown, and unknown is not consuming. Covers both
|
|
135
|
+
// 'gone' and the stale-heartbeat half of 'away'.
|
|
136
|
+
if (age > exports.LISTENER_PRESENT_MS)
|
|
137
|
+
return false;
|
|
138
|
+
// Hidden and reading only. Nothing is reaching them, so nothing is made.
|
|
139
|
+
if (isAway && !isListening)
|
|
140
|
+
return false;
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
exports.isListenerConsuming = isListenerConsuming;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { RedisClient } from '../redis-client';
|
|
2
2
|
import { ListenerPresence } from '../data/listener-presence';
|
|
3
|
-
export { LISTENER_PRESENT_MS, LISTENER_GONE_MS, LISTENER_HEARTBEAT_INTERVAL_MS, listenerPresence } from '../data/listener-presence';
|
|
3
|
+
export { LISTENER_PRESENT_MS, LISTENER_GONE_MS, LISTENER_HEARTBEAT_INTERVAL_MS, listenerPresence, isListenerConsuming } from '../data/listener-presence';
|
|
4
4
|
export type { ListenerPresence } from '../data/listener-presence';
|
|
5
5
|
export type ListenerBreakpoint = 'mobile' | 'desktop' | 'display';
|
|
6
6
|
export type MulingstreamListenerData = {
|
|
@@ -97,12 +97,19 @@ export declare class MulingstreamListenerManager {
|
|
|
97
97
|
* whether a language has an audience: a slot is released exactly when the
|
|
98
98
|
* translation for it stops, never one without the other.
|
|
99
99
|
*
|
|
100
|
-
*
|
|
101
|
-
* listener
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
100
|
+
* Consuming rather than merely registered, and that is the deliberate part. A
|
|
101
|
+
* listener we have not heard from for two minutes is not hearing the language,
|
|
102
|
+
* so holding the room's last slot for them denies it to a guest standing in
|
|
103
|
+
* the room who is. If they come back and the slot has gone to somebody else,
|
|
104
|
+
* they choose again from what the room now has; if it is still free they
|
|
105
|
+
* simply take it back.
|
|
106
|
+
*
|
|
107
|
+
* It has to be the SAME test getUniqueLanguagesByRoom uses, which is why this
|
|
108
|
+
* moved to isListenerConsuming with it on 2026-08-27 rather than being left on
|
|
109
|
+
* isListenerPresent. Leaving it would have broken the invariant this comment
|
|
110
|
+
* opens with, in the least visible direction: somebody listening with their
|
|
111
|
+
* phone in a pocket would keep receiving their language while the room quietly
|
|
112
|
+
* handed their slot to somebody else.
|
|
106
113
|
*/
|
|
107
114
|
countListenersOnLanguage(roomId: string, language: string): Promise<number>;
|
|
108
115
|
/**
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.MulingstreamListenerManager = exports.listenerPresence = exports.LISTENER_HEARTBEAT_INTERVAL_MS = exports.LISTENER_GONE_MS = exports.LISTENER_PRESENT_MS = void 0;
|
|
3
|
+
exports.MulingstreamListenerManager = exports.isListenerConsuming = exports.listenerPresence = exports.LISTENER_HEARTBEAT_INTERVAL_MS = exports.LISTENER_GONE_MS = exports.LISTENER_PRESENT_MS = void 0;
|
|
4
4
|
const listener_presence_1 = require("../data/listener-presence");
|
|
5
5
|
var listener_presence_2 = require("../data/listener-presence");
|
|
6
6
|
Object.defineProperty(exports, "LISTENER_PRESENT_MS", { enumerable: true, get: function () { return listener_presence_2.LISTENER_PRESENT_MS; } });
|
|
7
7
|
Object.defineProperty(exports, "LISTENER_GONE_MS", { enumerable: true, get: function () { return listener_presence_2.LISTENER_GONE_MS; } });
|
|
8
8
|
Object.defineProperty(exports, "LISTENER_HEARTBEAT_INTERVAL_MS", { enumerable: true, get: function () { return listener_presence_2.LISTENER_HEARTBEAT_INTERVAL_MS; } });
|
|
9
9
|
Object.defineProperty(exports, "listenerPresence", { enumerable: true, get: function () { return listener_presence_2.listenerPresence; } });
|
|
10
|
+
Object.defineProperty(exports, "isListenerConsuming", { enumerable: true, get: function () { return listener_presence_2.isListenerConsuming; } });
|
|
10
11
|
const EXPIRATION = 24 * 60 * 60; // 24 hours in seconds
|
|
11
12
|
/**
|
|
12
13
|
* Generates a bright RGB color suitable for dark backgrounds.
|
|
@@ -244,9 +245,16 @@ class MulingstreamListenerManager {
|
|
|
244
245
|
* that removed a listener was the cleanup sweep. See data/listener-presence.ts
|
|
245
246
|
* for why that single threshold could not be lowered and had to be split.
|
|
246
247
|
*/
|
|
248
|
+
/*
|
|
249
|
+
* Consuming, not merely present. A listener with audio on and a hidden screen
|
|
250
|
+
* still needs the translation their speech is synthesised from, so excluding
|
|
251
|
+
* them here would withhold the text and leave the TTS below with nothing to
|
|
252
|
+
* say. See isListenerConsuming for why a hidden READER still counts for
|
|
253
|
+
* nothing. Corrected 2026-08-27.
|
|
254
|
+
*/
|
|
247
255
|
async getUniqueLanguagesByRoom(roomId) {
|
|
248
256
|
const now = Date.now();
|
|
249
|
-
const listeners = (await this.getListenersByRoom(roomId)).filter((listener) => (0, listener_presence_1.
|
|
257
|
+
const listeners = (await this.getListenersByRoom(roomId)).filter((listener) => (0, listener_presence_1.isListenerConsuming)(listener.lastHeartbeat, listener.isAway === true, listener.isListening === true, now));
|
|
250
258
|
// Count how many times each language appears.
|
|
251
259
|
const languageCountMap = {};
|
|
252
260
|
for (const listener of listeners) {
|
|
@@ -271,19 +279,27 @@ class MulingstreamListenerManager {
|
|
|
271
279
|
* whether a language has an audience: a slot is released exactly when the
|
|
272
280
|
* translation for it stops, never one without the other.
|
|
273
281
|
*
|
|
274
|
-
*
|
|
275
|
-
* listener
|
|
276
|
-
*
|
|
277
|
-
*
|
|
278
|
-
*
|
|
279
|
-
*
|
|
282
|
+
* Consuming rather than merely registered, and that is the deliberate part. A
|
|
283
|
+
* listener we have not heard from for two minutes is not hearing the language,
|
|
284
|
+
* so holding the room's last slot for them denies it to a guest standing in
|
|
285
|
+
* the room who is. If they come back and the slot has gone to somebody else,
|
|
286
|
+
* they choose again from what the room now has; if it is still free they
|
|
287
|
+
* simply take it back.
|
|
288
|
+
*
|
|
289
|
+
* It has to be the SAME test getUniqueLanguagesByRoom uses, which is why this
|
|
290
|
+
* moved to isListenerConsuming with it on 2026-08-27 rather than being left on
|
|
291
|
+
* isListenerPresent. Leaving it would have broken the invariant this comment
|
|
292
|
+
* opens with, in the least visible direction: somebody listening with their
|
|
293
|
+
* phone in a pocket would keep receiving their language while the room quietly
|
|
294
|
+
* handed their slot to somebody else.
|
|
280
295
|
*/
|
|
281
296
|
async countListenersOnLanguage(roomId, language) {
|
|
282
297
|
if (!roomId || !language)
|
|
283
298
|
return 0;
|
|
284
299
|
const now = Date.now();
|
|
285
300
|
const listeners = await this.getListenersByRoom(roomId);
|
|
286
|
-
return listeners.filter((listener) => listener.language === language &&
|
|
301
|
+
return listeners.filter((listener) => listener.language === language &&
|
|
302
|
+
(0, listener_presence_1.isListenerConsuming)(listener.lastHeartbeat, listener.isAway === true, listener.isListening === true, now)).length;
|
|
287
303
|
}
|
|
288
304
|
/**
|
|
289
305
|
* The client saying its page went into the background, or came back.
|
|
@@ -384,10 +400,18 @@ class MulingstreamListenerManager {
|
|
|
384
400
|
* expensive thing we produce and nobody should pay to generate audio no one
|
|
385
401
|
* is playing. Text is already produced for the room either way.
|
|
386
402
|
*/
|
|
403
|
+
/*
|
|
404
|
+
* This decides whether a pass is being spent, so it has to agree with what we
|
|
405
|
+
* actually served. Since 2026-08-27 a hidden listener with audio on IS served,
|
|
406
|
+
* and counting them is not generosity: they are receiving the product, and a
|
|
407
|
+
* church whose congregation listens with their phones in their pockets would
|
|
408
|
+
* otherwise get every service for free while we paid for all of the speech.
|
|
409
|
+
* A hidden reader still counts for nothing, because nothing reaches them.
|
|
410
|
+
*/
|
|
387
411
|
async getReceivingCount(roomId) {
|
|
388
412
|
const now = Date.now();
|
|
389
413
|
const listeners = await this.getListenersByRoom(roomId);
|
|
390
|
-
return listeners.filter((l) => (0, listener_presence_1.
|
|
414
|
+
return listeners.filter((l) => (0, listener_presence_1.isListenerConsuming)(l.lastHeartbeat, l.isAway === true, l.isListening === true, now)).length;
|
|
391
415
|
}
|
|
392
416
|
// ─── Per-room listener capacity (design B, 2026-07-09) ────────────────────
|
|
393
417
|
// The cap value is the room owner's plan `max_audience`, resolved ONCE by the
|
|
@@ -564,9 +588,18 @@ class MulingstreamListenerManager {
|
|
|
564
588
|
continue;
|
|
565
589
|
if (!listener.isListening)
|
|
566
590
|
continue;
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
591
|
+
/*
|
|
592
|
+
* Speech synthesis is the most expensive thing we do, so this stays a
|
|
593
|
+
* real gate. What changed on 2026-08-27 is what a dark phone means.
|
|
594
|
+
*
|
|
595
|
+
* It used to skip anyone marked away, and a phone in a pocket is
|
|
596
|
+
* marked away the instant its screen goes off, which is precisely how
|
|
597
|
+
* somebody listens. So the one listener whose audio we were certain
|
|
598
|
+
* about was the one we stopped generating for, mid-sermon. The gate
|
|
599
|
+
* now turns on whether they are CONSUMING: audio on and heartbeats
|
|
600
|
+
* still arriving qualifies, a stale heartbeat still does not.
|
|
601
|
+
*/
|
|
602
|
+
if (!(0, listener_presence_1.isListenerConsuming)(listener.lastHeartbeat, listener.isAway === true, true, now))
|
|
570
603
|
continue;
|
|
571
604
|
const lang = listener.language;
|
|
572
605
|
languageCountMap[lang] = (languageCountMap[lang] || 0) + 1;
|
|
@@ -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,34 +1,34 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@mulingai-npm/redis",
|
|
3
|
-
"version": "3.
|
|
4
|
-
"main": "dist/index.js",
|
|
5
|
-
"types": "dist/index.d.ts",
|
|
6
|
-
"repository": {
|
|
7
|
-
"type": "git",
|
|
8
|
-
"url": "https://github.com/mulingai/mulingai-backend.git"
|
|
9
|
-
},
|
|
10
|
-
"publishConfig": {
|
|
11
|
-
"registry": "https://registry.npmjs.org/"
|
|
12
|
-
},
|
|
13
|
-
"private": false,
|
|
14
|
-
"scripts": {
|
|
15
|
-
"dev": "rm -f tsconfig.tsbuildinfo && tsc --watch",
|
|
16
|
-
"build": "rm -f tsconfig.tsbuildinfo && tsc",
|
|
17
|
-
"prepublishOnly": "npm run build"
|
|
18
|
-
},
|
|
19
|
-
"dependencies": {
|
|
20
|
-
"ioredis": "^5.6.0",
|
|
21
|
-
"uuid": "^11.1.0"
|
|
22
|
-
},
|
|
23
|
-
"devDependencies": {
|
|
24
|
-
"concurrently": "^9.1.2",
|
|
25
|
-
"copyfiles": "^2.4.1",
|
|
26
|
-
"nodemon": "^3.1.9",
|
|
27
|
-
"typescript": "^4.9.5"
|
|
28
|
-
},
|
|
29
|
-
"files": [
|
|
30
|
-
"dist",
|
|
31
|
-
"package.json",
|
|
32
|
-
"README.md"
|
|
33
|
-
]
|
|
34
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@mulingai-npm/redis",
|
|
3
|
+
"version": "3.44.1",
|
|
4
|
+
"main": "dist/index.js",
|
|
5
|
+
"types": "dist/index.d.ts",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/mulingai/mulingai-backend.git"
|
|
9
|
+
},
|
|
10
|
+
"publishConfig": {
|
|
11
|
+
"registry": "https://registry.npmjs.org/"
|
|
12
|
+
},
|
|
13
|
+
"private": false,
|
|
14
|
+
"scripts": {
|
|
15
|
+
"dev": "rm -f tsconfig.tsbuildinfo && tsc --watch",
|
|
16
|
+
"build": "rm -f tsconfig.tsbuildinfo && tsc",
|
|
17
|
+
"prepublishOnly": "npm run build"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"ioredis": "^5.6.0",
|
|
21
|
+
"uuid": "^11.1.0"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"concurrently": "^9.1.2",
|
|
25
|
+
"copyfiles": "^2.4.1",
|
|
26
|
+
"nodemon": "^3.1.9",
|
|
27
|
+
"typescript": "^4.9.5"
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"dist",
|
|
31
|
+
"package.json",
|
|
32
|
+
"README.md"
|
|
33
|
+
]
|
|
34
|
+
}
|