@mulingai-npm/redis 3.40.51 → 3.40.54
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.
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How long a listener counts as being in the room, and as being there RIGHT NOW.
|
|
3
|
+
*
|
|
4
|
+
* ─── WHY THIS LIVES IN THE REDIS PACKAGE ────────────────────────────────────
|
|
5
|
+
*
|
|
6
|
+
* Presence is decided from the listener hash in Redis and nowhere else, and
|
|
7
|
+
* every service that asks about it does so through MulingstreamListenerManager:
|
|
8
|
+
* the pipeline asking what to translate, the billing tick asking whether a pass
|
|
9
|
+
* is being spent, the listener service asking whether a language slot is free,
|
|
10
|
+
* the realtime service asking who has gone. Putting the thresholds beside the
|
|
11
|
+
* only code that reads them means there is exactly one definition. The obvious
|
|
12
|
+
* alternative, @mulingai-npm/shared, would have made this the first dependency
|
|
13
|
+
* between two private packages and changed the publish order for everyone, to
|
|
14
|
+
* hold two numbers that nothing outside this file interprets.
|
|
15
|
+
*
|
|
16
|
+
* ─── WHY THERE ARE TWO THRESHOLDS AND NOT ONE ───────────────────────────────
|
|
17
|
+
*
|
|
18
|
+
* There used to be one, at fifteen minutes, and it was answering five questions
|
|
19
|
+
* with completely different tolerances:
|
|
20
|
+
*
|
|
21
|
+
* "translate and synthesise this language?" wrong for seconds = money, per chunk
|
|
22
|
+
* "does this listener hold a language slot?" wrong for seconds = somebody shut out
|
|
23
|
+
* "is this pass being consumed?" wrong for minutes = a pass spent on nobody
|
|
24
|
+
* "how many listeners does the speaker see?" wants to be STEADY, not fast
|
|
25
|
+
* "when did this session end?" does not need answering in real time at all
|
|
26
|
+
*
|
|
27
|
+
* The last one is now answered from the last heartbeat at the moment the session
|
|
28
|
+
* is closed, so it needs no threshold at all. The rest split cleanly in two.
|
|
29
|
+
*
|
|
30
|
+
* **PRESENT** is the fast question. A phone that has been dark for two minutes is
|
|
31
|
+
* receiving nothing, so translating and synthesising for it costs real money for
|
|
32
|
+
* nobody, and holding its language slot denies that slot to a guest who is
|
|
33
|
+
* actually in the room. This has to lapse quickly.
|
|
34
|
+
*
|
|
35
|
+
* **IN THE ROOM** is the slow question. The speaker's listener count must not
|
|
36
|
+
* flicker every time a congregation locks its phones during a song, and one
|
|
37
|
+
* person's attendance must not be recorded as five short sessions, which is
|
|
38
|
+
* exactly what shortening the single threshold would have done to the analytics
|
|
39
|
+
* it was meant to fix.
|
|
40
|
+
*
|
|
41
|
+
* Separating them is what lets the second stay generous. Patience about departure
|
|
42
|
+
* now costs nothing, because nothing expensive waits on it any more.
|
|
43
|
+
*/
|
|
44
|
+
/**
|
|
45
|
+
* How long a heartbeat stays fresh: three missed beats at the client's 30 second
|
|
46
|
+
* interval, so one dropped beat on a bad connection never marks anybody away.
|
|
47
|
+
*
|
|
48
|
+
* A listener past this is AWAY. Still in the room, still shown to the speaker,
|
|
49
|
+
* still holding their session and their identity, but not translated for, not
|
|
50
|
+
* counted toward a pass, and not holding a language slot. They come back the
|
|
51
|
+
* instant a heartbeat arrives, having lost nothing.
|
|
52
|
+
*/
|
|
53
|
+
export declare const LISTENER_PRESENT_MS: number;
|
|
54
|
+
/**
|
|
55
|
+
* How long a listener stays in the room with no heartbeat before being removed
|
|
56
|
+
* and their session closed.
|
|
57
|
+
*
|
|
58
|
+
* Fifteen minutes, unchanged, and now deliberately generous rather than generous
|
|
59
|
+
* by accident. A phone locked through announcements, a worship set or a long
|
|
60
|
+
* prayer must not cost somebody their place. That generosity used to be paid for
|
|
61
|
+
* in translation nobody heard and passes spent on people who had gone home; it is
|
|
62
|
+
* not any more. The only thing this number now controls is how long a returning
|
|
63
|
+
* listener keeps their identity.
|
|
64
|
+
*/
|
|
65
|
+
export declare const LISTENER_GONE_MS: number;
|
|
66
|
+
/** What the client sends. Here so the thresholds above can be read against it. */
|
|
67
|
+
export declare const LISTENER_HEARTBEAT_INTERVAL_MS: number;
|
|
68
|
+
export type ListenerPresence = 'present' | 'away' | 'gone';
|
|
69
|
+
/**
|
|
70
|
+
* Which of the three states a listener is in.
|
|
71
|
+
*
|
|
72
|
+
* `isAway` is the client having said so, and it is what makes a locked screen
|
|
73
|
+
* register at once instead of after three missed beats. It is a separate flag
|
|
74
|
+
* rather than a backdated heartbeat, because the heartbeat is now also the
|
|
75
|
+
* recorded end of the session and has to stay honest about when the listener was
|
|
76
|
+
* genuinely last there.
|
|
77
|
+
*/
|
|
78
|
+
export declare function listenerPresence(lastHeartbeatMs: number, isAway: boolean, now?: number): ListenerPresence;
|
|
79
|
+
/** Shorthand for the common question: is this listener consuming anything right now. */
|
|
80
|
+
export declare function isListenerPresent(lastHeartbeatMs: number, isAway: boolean, now?: number): boolean;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* How long a listener counts as being in the room, and as being there RIGHT NOW.
|
|
4
|
+
*
|
|
5
|
+
* ─── WHY THIS LIVES IN THE REDIS PACKAGE ────────────────────────────────────
|
|
6
|
+
*
|
|
7
|
+
* Presence is decided from the listener hash in Redis and nowhere else, and
|
|
8
|
+
* every service that asks about it does so through MulingstreamListenerManager:
|
|
9
|
+
* the pipeline asking what to translate, the billing tick asking whether a pass
|
|
10
|
+
* is being spent, the listener service asking whether a language slot is free,
|
|
11
|
+
* the realtime service asking who has gone. Putting the thresholds beside the
|
|
12
|
+
* only code that reads them means there is exactly one definition. The obvious
|
|
13
|
+
* alternative, @mulingai-npm/shared, would have made this the first dependency
|
|
14
|
+
* between two private packages and changed the publish order for everyone, to
|
|
15
|
+
* hold two numbers that nothing outside this file interprets.
|
|
16
|
+
*
|
|
17
|
+
* ─── WHY THERE ARE TWO THRESHOLDS AND NOT ONE ───────────────────────────────
|
|
18
|
+
*
|
|
19
|
+
* There used to be one, at fifteen minutes, and it was answering five questions
|
|
20
|
+
* with completely different tolerances:
|
|
21
|
+
*
|
|
22
|
+
* "translate and synthesise this language?" wrong for seconds = money, per chunk
|
|
23
|
+
* "does this listener hold a language slot?" wrong for seconds = somebody shut out
|
|
24
|
+
* "is this pass being consumed?" wrong for minutes = a pass spent on nobody
|
|
25
|
+
* "how many listeners does the speaker see?" wants to be STEADY, not fast
|
|
26
|
+
* "when did this session end?" does not need answering in real time at all
|
|
27
|
+
*
|
|
28
|
+
* The last one is now answered from the last heartbeat at the moment the session
|
|
29
|
+
* is closed, so it needs no threshold at all. The rest split cleanly in two.
|
|
30
|
+
*
|
|
31
|
+
* **PRESENT** is the fast question. A phone that has been dark for two minutes is
|
|
32
|
+
* receiving nothing, so translating and synthesising for it costs real money for
|
|
33
|
+
* nobody, and holding its language slot denies that slot to a guest who is
|
|
34
|
+
* actually in the room. This has to lapse quickly.
|
|
35
|
+
*
|
|
36
|
+
* **IN THE ROOM** is the slow question. The speaker's listener count must not
|
|
37
|
+
* flicker every time a congregation locks its phones during a song, and one
|
|
38
|
+
* person's attendance must not be recorded as five short sessions, which is
|
|
39
|
+
* exactly what shortening the single threshold would have done to the analytics
|
|
40
|
+
* it was meant to fix.
|
|
41
|
+
*
|
|
42
|
+
* Separating them is what lets the second stay generous. Patience about departure
|
|
43
|
+
* now costs nothing, because nothing expensive waits on it any more.
|
|
44
|
+
*/
|
|
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;
|
|
47
|
+
/**
|
|
48
|
+
* How long a heartbeat stays fresh: three missed beats at the client's 30 second
|
|
49
|
+
* interval, so one dropped beat on a bad connection never marks anybody away.
|
|
50
|
+
*
|
|
51
|
+
* A listener past this is AWAY. Still in the room, still shown to the speaker,
|
|
52
|
+
* still holding their session and their identity, but not translated for, not
|
|
53
|
+
* counted toward a pass, and not holding a language slot. They come back the
|
|
54
|
+
* instant a heartbeat arrives, having lost nothing.
|
|
55
|
+
*/
|
|
56
|
+
exports.LISTENER_PRESENT_MS = 90 * 1000;
|
|
57
|
+
/**
|
|
58
|
+
* How long a listener stays in the room with no heartbeat before being removed
|
|
59
|
+
* and their session closed.
|
|
60
|
+
*
|
|
61
|
+
* Fifteen minutes, unchanged, and now deliberately generous rather than generous
|
|
62
|
+
* by accident. A phone locked through announcements, a worship set or a long
|
|
63
|
+
* prayer must not cost somebody their place. That generosity used to be paid for
|
|
64
|
+
* in translation nobody heard and passes spent on people who had gone home; it is
|
|
65
|
+
* not any more. The only thing this number now controls is how long a returning
|
|
66
|
+
* listener keeps their identity.
|
|
67
|
+
*/
|
|
68
|
+
exports.LISTENER_GONE_MS = 15 * 60 * 1000;
|
|
69
|
+
/** What the client sends. Here so the thresholds above can be read against it. */
|
|
70
|
+
exports.LISTENER_HEARTBEAT_INTERVAL_MS = 30 * 1000;
|
|
71
|
+
/**
|
|
72
|
+
* Which of the three states a listener is in.
|
|
73
|
+
*
|
|
74
|
+
* `isAway` is the client having said so, and it is what makes a locked screen
|
|
75
|
+
* register at once instead of after three missed beats. It is a separate flag
|
|
76
|
+
* rather than a backdated heartbeat, because the heartbeat is now also the
|
|
77
|
+
* recorded end of the session and has to stay honest about when the listener was
|
|
78
|
+
* genuinely last there.
|
|
79
|
+
*/
|
|
80
|
+
function listenerPresence(lastHeartbeatMs, isAway, now = Date.now()) {
|
|
81
|
+
const age = now - lastHeartbeatMs;
|
|
82
|
+
if (age > exports.LISTENER_GONE_MS)
|
|
83
|
+
return 'gone';
|
|
84
|
+
if (isAway || age > exports.LISTENER_PRESENT_MS)
|
|
85
|
+
return 'away';
|
|
86
|
+
return 'present';
|
|
87
|
+
}
|
|
88
|
+
exports.listenerPresence = listenerPresence;
|
|
89
|
+
/** Shorthand for the common question: is this listener consuming anything right now. */
|
|
90
|
+
function isListenerPresent(lastHeartbeatMs, isAway, now = Date.now()) {
|
|
91
|
+
return listenerPresence(lastHeartbeatMs, isAway, now) === 'present';
|
|
92
|
+
}
|
|
93
|
+
exports.isListenerPresent = isListenerPresent;
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { RedisClient } from '../redis-client';
|
|
2
|
+
import { ListenerPresence } from '../data/listener-presence';
|
|
3
|
+
export { LISTENER_PRESENT_MS, LISTENER_GONE_MS, LISTENER_HEARTBEAT_INTERVAL_MS, listenerPresence } from '../data/listener-presence';
|
|
4
|
+
export type { ListenerPresence } from '../data/listener-presence';
|
|
2
5
|
export type ListenerBreakpoint = 'mobile' | 'desktop' | 'display';
|
|
3
6
|
export type MulingstreamListenerData = {
|
|
4
7
|
listenerId: string;
|
|
@@ -12,12 +15,54 @@ export type MulingstreamListenerData = {
|
|
|
12
15
|
color?: string;
|
|
13
16
|
isListening?: boolean;
|
|
14
17
|
breakpoint?: ListenerBreakpoint;
|
|
18
|
+
/**
|
|
19
|
+
* The listener told us their page went into the background: screen locked,
|
|
20
|
+
* app switched, tab hidden.
|
|
21
|
+
*
|
|
22
|
+
* Set by the client rather than inferred, because the socket cannot tell the
|
|
23
|
+
* difference between a locked phone and a closed tab, and waiting for missed
|
|
24
|
+
* heartbeats to decide costs ninety seconds of translating for somebody who
|
|
25
|
+
* is demonstrably not looking. Cleared by the next heartbeat, which is what a
|
|
26
|
+
* returning page sends immediately.
|
|
27
|
+
*
|
|
28
|
+
* Being away is not being gone. See data/listener-presence.ts.
|
|
29
|
+
*/
|
|
30
|
+
isAway?: boolean;
|
|
15
31
|
};
|
|
16
32
|
export declare class MulingstreamListenerManager {
|
|
17
33
|
private redisClient;
|
|
18
34
|
constructor(redisClient: RedisClient);
|
|
19
35
|
private parseHashData;
|
|
20
36
|
addListener(listenerData: MulingstreamListenerData): Promise<string>;
|
|
37
|
+
/**
|
|
38
|
+
* Every listener currently known, across every room.
|
|
39
|
+
*
|
|
40
|
+
* WHY THIS USES SCAN AND NOT KEYS
|
|
41
|
+
*
|
|
42
|
+
* It used to be `KEYS 'listener:*'`, with a TODO next to it saying it should
|
|
43
|
+
* not be. KEYS walks the entire keyspace in one go and Redis is single
|
|
44
|
+
* threaded, so for as long as it runs nothing else in the process is served:
|
|
45
|
+
* not a heartbeat, not a chunk being handed to the pipeline, not a listener
|
|
46
|
+
* joining. The cost is invisible on a development database with a few dozen
|
|
47
|
+
* keys and grows with the busiest moment we will ever have, which is precisely
|
|
48
|
+
* when it must not stall. This is called by the cleanup sweep every sixty
|
|
49
|
+
* seconds, for the entire life of the process, so it would have been the most
|
|
50
|
+
* frequently executed blocking command in the system.
|
|
51
|
+
*
|
|
52
|
+
* SCAN answers the same question in cursored batches, yielding between each,
|
|
53
|
+
* so a large keyspace costs more round trips rather than one long stall. The
|
|
54
|
+
* guarantees are weaker, deliberately: a key added or removed mid-scan may or
|
|
55
|
+
* may not appear. That is exactly right for the caller, which is looking for
|
|
56
|
+
* listeners who went silent minutes ago. One arriving during the scan is one
|
|
57
|
+
* for the next sweep, sixty seconds later, and there is nothing to do about a
|
|
58
|
+
* brand new listener anyway.
|
|
59
|
+
*
|
|
60
|
+
* The pattern also has to reject keys that merely start with `listener:`. The
|
|
61
|
+
* legacy `listener:panel-state:*` keys matched the old pattern and were parsed
|
|
62
|
+
* as listener records, producing entries with NaN timestamps. They never
|
|
63
|
+
* caused harm, since NaN fails every comparison, which is exactly how a
|
|
64
|
+
* defect like that survives.
|
|
65
|
+
*/
|
|
21
66
|
getAllListeners(): Promise<MulingstreamListenerData[]>;
|
|
22
67
|
getListenersByRoom(roomId: string): Promise<MulingstreamListenerData[]>;
|
|
23
68
|
getListener(listenerIdOrToken: string): Promise<MulingstreamListenerData | null>;
|
|
@@ -30,7 +75,61 @@ export declare class MulingstreamListenerManager {
|
|
|
30
75
|
*/
|
|
31
76
|
updateBreakpoint(listenerIdOrToken: string, breakpoint: ListenerBreakpoint): Promise<MulingstreamListenerData | null>;
|
|
32
77
|
getTargetSocketIdsByRoomLanguage(roomId: string, language: string): Promise<string[]>;
|
|
78
|
+
/**
|
|
79
|
+
* Which languages this room has someone PRESENT on.
|
|
80
|
+
*
|
|
81
|
+
* Feeds consumersFor, which decides what gets translated at all, so an
|
|
82
|
+
* over-generous answer here is money spent on every chunk for nobody. It
|
|
83
|
+
* therefore counts present listeners only: somebody whose phone has been dark
|
|
84
|
+
* for two minutes is receiving nothing, and translating for them is pure loss.
|
|
85
|
+
*
|
|
86
|
+
* It used to count every listener the room had ever registered, including ones
|
|
87
|
+
* whose heartbeat had been silent for fourteen minutes, because the only thing
|
|
88
|
+
* that removed a listener was the cleanup sweep. See data/listener-presence.ts
|
|
89
|
+
* for why that single threshold could not be lowered and had to be split.
|
|
90
|
+
*/
|
|
33
91
|
getUniqueLanguagesByRoom(roomId: string): Promise<string[]>;
|
|
92
|
+
private guestLanguagesKey;
|
|
93
|
+
/** Record that a guest, not the host, put this language in the room. */
|
|
94
|
+
markGuestLanguage(roomId: string, language: string): Promise<void>;
|
|
95
|
+
/** Forget a guest language, once it has been released from the room. */
|
|
96
|
+
unmarkGuestLanguage(roomId: string, language: string): Promise<void>;
|
|
97
|
+
/** Every language in this room that a guest added. */
|
|
98
|
+
getGuestLanguages(roomId: string): Promise<string[]>;
|
|
99
|
+
/**
|
|
100
|
+
* How many PRESENT listeners in this room are on a language.
|
|
101
|
+
*
|
|
102
|
+
* Decides whether a guest-added language slot can be handed back, and counts
|
|
103
|
+
* the same population `consumersFor` reads, so the two cannot disagree about
|
|
104
|
+
* whether a language has an audience: a slot is released exactly when the
|
|
105
|
+
* translation for it stops, never one without the other.
|
|
106
|
+
*
|
|
107
|
+
* Present rather than merely registered, and that is the deliberate part. A
|
|
108
|
+
* listener whose phone has been dark for two minutes is not hearing the
|
|
109
|
+
* language, so holding the room's last slot for them denies it to a guest
|
|
110
|
+
* standing in the room who is. If they come back and the slot has gone to
|
|
111
|
+
* somebody else, they choose again from what the room now has; if it is still
|
|
112
|
+
* free they simply take it back.
|
|
113
|
+
*/
|
|
114
|
+
countListenersOnLanguage(roomId: string, language: string): Promise<number>;
|
|
115
|
+
/**
|
|
116
|
+
* The client saying its page went into the background, or came back.
|
|
117
|
+
*
|
|
118
|
+
* This is the difference between reacting to a locked screen at once and
|
|
119
|
+
* reacting to it after three missed heartbeats. Both end in the same state;
|
|
120
|
+
* one of them costs ninety seconds of translation and synthesis for a phone
|
|
121
|
+
* nobody is looking at, on every language in the room.
|
|
122
|
+
*
|
|
123
|
+
* Does NOT touch lastHeartbeat. That field is now also the recorded end of the
|
|
124
|
+
* listener's session, so it has to keep meaning "when they were genuinely last
|
|
125
|
+
* there" and nothing else.
|
|
126
|
+
*/
|
|
127
|
+
setAwayState(listenerIdOrToken: string, isAway: boolean): Promise<MulingstreamListenerData | null>;
|
|
128
|
+
/**
|
|
129
|
+
* Present, away or gone, for one listener. For anything that needs to SHOW the
|
|
130
|
+
* distinction rather than act on it.
|
|
131
|
+
*/
|
|
132
|
+
getPresence(listenerIdOrToken: string): Promise<ListenerPresence | null>;
|
|
34
133
|
updateHeartbeat(listenerIdOrToken: string): Promise<MulingstreamListenerData | null>;
|
|
35
134
|
getListenerStats(roomId: string): Promise<{
|
|
36
135
|
totalListeners: number;
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.MulingstreamListenerManager = void 0;
|
|
3
|
+
exports.MulingstreamListenerManager = exports.listenerPresence = exports.LISTENER_HEARTBEAT_INTERVAL_MS = exports.LISTENER_GONE_MS = exports.LISTENER_PRESENT_MS = void 0;
|
|
4
|
+
const listener_presence_1 = require("../data/listener-presence");
|
|
5
|
+
var listener_presence_2 = require("../data/listener-presence");
|
|
6
|
+
Object.defineProperty(exports, "LISTENER_PRESENT_MS", { enumerable: true, get: function () { return listener_presence_2.LISTENER_PRESENT_MS; } });
|
|
7
|
+
Object.defineProperty(exports, "LISTENER_GONE_MS", { enumerable: true, get: function () { return listener_presence_2.LISTENER_GONE_MS; } });
|
|
8
|
+
Object.defineProperty(exports, "LISTENER_HEARTBEAT_INTERVAL_MS", { enumerable: true, get: function () { return listener_presence_2.LISTENER_HEARTBEAT_INTERVAL_MS; } });
|
|
9
|
+
Object.defineProperty(exports, "listenerPresence", { enumerable: true, get: function () { return listener_presence_2.listenerPresence; } });
|
|
4
10
|
const EXPIRATION = 24 * 60 * 60; // 24 hours in seconds
|
|
5
11
|
/**
|
|
6
12
|
* Generates a bright RGB color suitable for dark backgrounds.
|
|
@@ -48,7 +54,8 @@ class MulingstreamListenerManager {
|
|
|
48
54
|
language: data.language || '',
|
|
49
55
|
color: data.color || '',
|
|
50
56
|
isListening: data.isListening === 'true',
|
|
51
|
-
breakpoint: bp === 'mobile' || bp === 'desktop' || bp === 'display' ? bp : undefined
|
|
57
|
+
breakpoint: bp === 'mobile' || bp === 'desktop' || bp === 'display' ? bp : undefined,
|
|
58
|
+
isAway: data.isAway === 'true'
|
|
52
59
|
};
|
|
53
60
|
}
|
|
54
61
|
// Creates a new listener.
|
|
@@ -74,25 +81,60 @@ class MulingstreamListenerManager {
|
|
|
74
81
|
language: (_c = listenerData.language) !== null && _c !== void 0 ? _c : '',
|
|
75
82
|
color,
|
|
76
83
|
isListening: 'false',
|
|
77
|
-
breakpoint: (_d = listenerData.breakpoint) !== null && _d !== void 0 ? _d : ''
|
|
84
|
+
breakpoint: (_d = listenerData.breakpoint) !== null && _d !== void 0 ? _d : '',
|
|
85
|
+
// A listener who just joined is looking at the page by definition.
|
|
86
|
+
isAway: 'false'
|
|
78
87
|
});
|
|
79
88
|
// expire listener
|
|
80
89
|
await this.redisClient.expire(`listener:${listenerId}`, EXPIRATION);
|
|
81
90
|
return listenerId;
|
|
82
91
|
}
|
|
92
|
+
/**
|
|
93
|
+
* Every listener currently known, across every room.
|
|
94
|
+
*
|
|
95
|
+
* WHY THIS USES SCAN AND NOT KEYS
|
|
96
|
+
*
|
|
97
|
+
* It used to be `KEYS 'listener:*'`, with a TODO next to it saying it should
|
|
98
|
+
* not be. KEYS walks the entire keyspace in one go and Redis is single
|
|
99
|
+
* threaded, so for as long as it runs nothing else in the process is served:
|
|
100
|
+
* not a heartbeat, not a chunk being handed to the pipeline, not a listener
|
|
101
|
+
* joining. The cost is invisible on a development database with a few dozen
|
|
102
|
+
* keys and grows with the busiest moment we will ever have, which is precisely
|
|
103
|
+
* when it must not stall. This is called by the cleanup sweep every sixty
|
|
104
|
+
* seconds, for the entire life of the process, so it would have been the most
|
|
105
|
+
* frequently executed blocking command in the system.
|
|
106
|
+
*
|
|
107
|
+
* SCAN answers the same question in cursored batches, yielding between each,
|
|
108
|
+
* so a large keyspace costs more round trips rather than one long stall. The
|
|
109
|
+
* guarantees are weaker, deliberately: a key added or removed mid-scan may or
|
|
110
|
+
* may not appear. That is exactly right for the caller, which is looking for
|
|
111
|
+
* listeners who went silent minutes ago. One arriving during the scan is one
|
|
112
|
+
* for the next sweep, sixty seconds later, and there is nothing to do about a
|
|
113
|
+
* brand new listener anyway.
|
|
114
|
+
*
|
|
115
|
+
* The pattern also has to reject keys that merely start with `listener:`. The
|
|
116
|
+
* legacy `listener:panel-state:*` keys matched the old pattern and were parsed
|
|
117
|
+
* as listener records, producing entries with NaN timestamps. They never
|
|
118
|
+
* caused harm, since NaN fails every comparison, which is exactly how a
|
|
119
|
+
* defect like that survives.
|
|
120
|
+
*/
|
|
83
121
|
async getAllListeners() {
|
|
84
|
-
// get all keys that match 'listener:*'
|
|
85
|
-
// TODO: if we have many keys, consider using SCAN instead of KEYS to avoid performance issues.
|
|
86
|
-
const keys = await this.redisClient.keys('listener:*');
|
|
87
|
-
if (!keys || keys.length === 0) {
|
|
88
|
-
return [];
|
|
89
|
-
}
|
|
90
|
-
// fetch each hash with HGETALL
|
|
91
122
|
const listeners = [];
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
123
|
+
let cursor = 0;
|
|
124
|
+
do {
|
|
125
|
+
const [nextCursor, keys] = await this.redisClient.scan(cursor, 'listener:*', 500);
|
|
126
|
+
cursor = Number(nextCursor);
|
|
127
|
+
for (const key of keys || []) {
|
|
128
|
+
// `listener:{id}` only. Anything with a further colon belongs to
|
|
129
|
+
// some other feature that happens to share the prefix.
|
|
130
|
+
if (key.split(':').length !== 2)
|
|
131
|
+
continue;
|
|
132
|
+
const data = await this.redisClient.hgetall(key);
|
|
133
|
+
if (!data || Object.keys(data).length === 0)
|
|
134
|
+
continue;
|
|
135
|
+
listeners.push(this.parseHashData(data));
|
|
136
|
+
}
|
|
137
|
+
} while (cursor !== 0);
|
|
96
138
|
return listeners;
|
|
97
139
|
}
|
|
98
140
|
async getListenersByRoom(roomId) {
|
|
@@ -189,13 +231,23 @@ class MulingstreamListenerManager {
|
|
|
189
231
|
});
|
|
190
232
|
return filteredListeners.map((listener) => listener.socketId);
|
|
191
233
|
}
|
|
234
|
+
/**
|
|
235
|
+
* Which languages this room has someone PRESENT on.
|
|
236
|
+
*
|
|
237
|
+
* Feeds consumersFor, which decides what gets translated at all, so an
|
|
238
|
+
* over-generous answer here is money spent on every chunk for nobody. It
|
|
239
|
+
* therefore counts present listeners only: somebody whose phone has been dark
|
|
240
|
+
* for two minutes is receiving nothing, and translating for them is pure loss.
|
|
241
|
+
*
|
|
242
|
+
* It used to count every listener the room had ever registered, including ones
|
|
243
|
+
* whose heartbeat had been silent for fourteen minutes, because the only thing
|
|
244
|
+
* that removed a listener was the cleanup sweep. See data/listener-presence.ts
|
|
245
|
+
* for why that single threshold could not be lowered and had to be split.
|
|
246
|
+
*/
|
|
192
247
|
async getUniqueLanguagesByRoom(roomId) {
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
// if I wanted to get only active listeners
|
|
197
|
-
// const listeners = (await this.getListenersByRoom(roomId)).filter(l => l.isActive);
|
|
198
|
-
// 2) Count how many times each language appears.
|
|
248
|
+
const now = Date.now();
|
|
249
|
+
const listeners = (await this.getListenersByRoom(roomId)).filter((listener) => (0, listener_presence_1.isListenerPresent)(listener.lastHeartbeat, listener.isAway === true, now));
|
|
250
|
+
// Count how many times each language appears.
|
|
199
251
|
const languageCountMap = {};
|
|
200
252
|
for (const listener of listeners) {
|
|
201
253
|
// skip blank/unset language
|
|
@@ -211,6 +263,99 @@ class MulingstreamListenerManager {
|
|
|
211
263
|
// 4) Map back to just the language strings in order
|
|
212
264
|
return sortedEntries.map(([language]) => language);
|
|
213
265
|
}
|
|
266
|
+
/*
|
|
267
|
+
* GUEST ADDED LANGUAGES
|
|
268
|
+
*
|
|
269
|
+
* Which of a room's target languages a guest put there, as opposed to the
|
|
270
|
+
* host. The distinction is the whole reason this exists: a guest-added
|
|
271
|
+
* language is released the moment nobody is on it, and the host's own
|
|
272
|
+
* language never is, however empty the room gets. Without the distinction a
|
|
273
|
+
* church that pinned Danish and had every guest wander off would find its room
|
|
274
|
+
* translating nothing.
|
|
275
|
+
*
|
|
276
|
+
* It also stops a single guest eating the room. Changing language seven times
|
|
277
|
+
* would otherwise leave seven languages standing, every one of them translated
|
|
278
|
+
* and synthesised for the rest of the service on a counted pass, and no slot
|
|
279
|
+
* left for the person who actually needed one.
|
|
280
|
+
*
|
|
281
|
+
* A set rather than a column: it is per-session bookkeeping, not a property of
|
|
282
|
+
* the room, and it should disappear on its own if a process dies mid-service.
|
|
283
|
+
* The TTL is the listener expiry for exactly that reason.
|
|
284
|
+
*/
|
|
285
|
+
guestLanguagesKey(roomId) {
|
|
286
|
+
return `room:${roomId}:guest-languages`;
|
|
287
|
+
}
|
|
288
|
+
/** Record that a guest, not the host, put this language in the room. */
|
|
289
|
+
async markGuestLanguage(roomId, language) {
|
|
290
|
+
if (!roomId || !language)
|
|
291
|
+
return;
|
|
292
|
+
const key = this.guestLanguagesKey(roomId);
|
|
293
|
+
await this.redisClient.sadd(key, language);
|
|
294
|
+
await this.redisClient.expire(key, EXPIRATION);
|
|
295
|
+
}
|
|
296
|
+
/** Forget a guest language, once it has been released from the room. */
|
|
297
|
+
async unmarkGuestLanguage(roomId, language) {
|
|
298
|
+
if (!roomId || !language)
|
|
299
|
+
return;
|
|
300
|
+
await this.redisClient.srem(this.guestLanguagesKey(roomId), language);
|
|
301
|
+
}
|
|
302
|
+
/** Every language in this room that a guest added. */
|
|
303
|
+
async getGuestLanguages(roomId) {
|
|
304
|
+
if (!roomId)
|
|
305
|
+
return [];
|
|
306
|
+
return (await this.redisClient.smembers(this.guestLanguagesKey(roomId))) || [];
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* How many PRESENT listeners in this room are on a language.
|
|
310
|
+
*
|
|
311
|
+
* Decides whether a guest-added language slot can be handed back, and counts
|
|
312
|
+
* the same population `consumersFor` reads, so the two cannot disagree about
|
|
313
|
+
* whether a language has an audience: a slot is released exactly when the
|
|
314
|
+
* translation for it stops, never one without the other.
|
|
315
|
+
*
|
|
316
|
+
* Present rather than merely registered, and that is the deliberate part. A
|
|
317
|
+
* listener whose phone has been dark for two minutes is not hearing the
|
|
318
|
+
* language, so holding the room's last slot for them denies it to a guest
|
|
319
|
+
* standing in the room who is. If they come back and the slot has gone to
|
|
320
|
+
* somebody else, they choose again from what the room now has; if it is still
|
|
321
|
+
* free they simply take it back.
|
|
322
|
+
*/
|
|
323
|
+
async countListenersOnLanguage(roomId, language) {
|
|
324
|
+
if (!roomId || !language)
|
|
325
|
+
return 0;
|
|
326
|
+
const now = Date.now();
|
|
327
|
+
const listeners = await this.getListenersByRoom(roomId);
|
|
328
|
+
return listeners.filter((listener) => listener.language === language && (0, listener_presence_1.isListenerPresent)(listener.lastHeartbeat, listener.isAway === true, now)).length;
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* The client saying its page went into the background, or came back.
|
|
332
|
+
*
|
|
333
|
+
* This is the difference between reacting to a locked screen at once and
|
|
334
|
+
* reacting to it after three missed heartbeats. Both end in the same state;
|
|
335
|
+
* one of them costs ninety seconds of translation and synthesis for a phone
|
|
336
|
+
* nobody is looking at, on every language in the room.
|
|
337
|
+
*
|
|
338
|
+
* Does NOT touch lastHeartbeat. That field is now also the recorded end of the
|
|
339
|
+
* listener's session, so it has to keep meaning "when they were genuinely last
|
|
340
|
+
* there" and nothing else.
|
|
341
|
+
*/
|
|
342
|
+
async setAwayState(listenerIdOrToken, isAway) {
|
|
343
|
+
const listener = await this.getListener(listenerIdOrToken);
|
|
344
|
+
if (!listener)
|
|
345
|
+
return null;
|
|
346
|
+
await this.redisClient.hset(`listener:${listener.listenerId}`, { isAway: isAway.toString() });
|
|
347
|
+
return { ...listener, isAway };
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Present, away or gone, for one listener. For anything that needs to SHOW the
|
|
351
|
+
* distinction rather than act on it.
|
|
352
|
+
*/
|
|
353
|
+
async getPresence(listenerIdOrToken) {
|
|
354
|
+
const listener = await this.getListener(listenerIdOrToken);
|
|
355
|
+
if (!listener)
|
|
356
|
+
return null;
|
|
357
|
+
return (0, listener_presence_1.listenerPresence)(listener.lastHeartbeat, listener.isAway === true);
|
|
358
|
+
}
|
|
214
359
|
async updateHeartbeat(listenerIdOrToken) {
|
|
215
360
|
const listener = await this.getListener(listenerIdOrToken);
|
|
216
361
|
if (!listener) {
|
|
@@ -218,9 +363,16 @@ class MulingstreamListenerManager {
|
|
|
218
363
|
return null;
|
|
219
364
|
}
|
|
220
365
|
const now = Date.now();
|
|
221
|
-
// Update the heartbeat timestamp
|
|
366
|
+
// Update the heartbeat timestamp, and clear away in the same write.
|
|
367
|
+
//
|
|
368
|
+
// A heartbeat only arrives from a page that is running, so receiving one is
|
|
369
|
+
// proof the listener is back: an explicit "I have returned" event would be
|
|
370
|
+
// a second way of saying the same thing, and a second way to get it wrong.
|
|
371
|
+
// A returning page sends a heartbeat immediately on visibility, so this is
|
|
372
|
+
// as fast as anything else could be.
|
|
222
373
|
await this.redisClient.hset(`listener:${listener.listenerId}`, {
|
|
223
|
-
lastHeartbeat: now.toString()
|
|
374
|
+
lastHeartbeat: now.toString(),
|
|
375
|
+
isAway: 'false'
|
|
224
376
|
});
|
|
225
377
|
// Reset expiration on activity
|
|
226
378
|
await this.redisClient.expire(`listener:${listener.listenerId}`, EXPIRATION);
|
|
@@ -254,8 +406,15 @@ class MulingstreamListenerManager {
|
|
|
254
406
|
* still count.
|
|
255
407
|
*/
|
|
256
408
|
async getReceivingCount(roomId) {
|
|
409
|
+
const now = Date.now();
|
|
257
410
|
const listeners = await this.getListenersByRoom(roomId);
|
|
258
|
-
return listeners.filter(
|
|
411
|
+
return listeners.filter(
|
|
412
|
+
// Present AND playing audio. Presence is the half that was missing:
|
|
413
|
+
// a listener who closed their phone kept the isListening flag they had
|
|
414
|
+
// when they left, so they went on being counted as receiving until the
|
|
415
|
+
// sweep removed them, and went on pushing a church's pass toward being
|
|
416
|
+
// spent. Nobody ever pauses audio on the way out of a building.
|
|
417
|
+
(l) => (0, listener_presence_1.isListenerPresent)(l.lastHeartbeat, l.isAway === true, now) && l.isListening !== false).length;
|
|
259
418
|
}
|
|
260
419
|
// ─── Per-room listener capacity (design B, 2026-07-09) ────────────────────
|
|
261
420
|
// The cap value is the room owner's plan `max_audience`, resolved ONCE by the
|
|
@@ -424,6 +583,7 @@ class MulingstreamListenerManager {
|
|
|
424
583
|
* Used by pipeline to determine which languages need TTS generation.
|
|
425
584
|
*/
|
|
426
585
|
async getLanguagesWithActiveListeners(roomId) {
|
|
586
|
+
const now = Date.now();
|
|
427
587
|
const listeners = await this.getListenersByRoom(roomId);
|
|
428
588
|
const languageCountMap = {};
|
|
429
589
|
for (const listener of listeners) {
|
|
@@ -431,6 +591,10 @@ class MulingstreamListenerManager {
|
|
|
431
591
|
continue;
|
|
432
592
|
if (!listener.isListening)
|
|
433
593
|
continue;
|
|
594
|
+
// Speech synthesis is the most expensive thing we do. A language whose
|
|
595
|
+
// only listener has a dark phone must not be synthesised.
|
|
596
|
+
if (!(0, listener_presence_1.isListenerPresent)(listener.lastHeartbeat, listener.isAway === true, now))
|
|
597
|
+
continue;
|
|
434
598
|
const lang = listener.language;
|
|
435
599
|
languageCountMap[lang] = (languageCountMap[lang] || 0) + 1;
|
|
436
600
|
}
|
package/package.json
CHANGED
|
@@ -1,34 +1,34 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@mulingai-npm/redis",
|
|
3
|
-
"version": "3.40.
|
|
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.40.54",
|
|
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
|
+
}
|