@mulingai-npm/redis 3.40.40 → 3.40.43

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.
@@ -181,8 +181,21 @@ export declare class MulingstreamChunkManager {
181
181
  */
182
182
  updateTranslationInBulk(roomId: string, n: number, dict: Record<string, string>, status?: StepStatus, hintsPerLanguage?: Record<string, TranslationHint[]>): Promise<MulingstreamChunkData | null>;
183
183
  /**
184
- * Partial HSET only `tts` is read and rewritten. Same race-rationale as
185
- * updateTranslationInBulk: avoids clobbering concurrent translation writes.
184
+ * Atomic per-language merge into the serialized `tts` hash field.
185
+ *
186
+ * The `tts` field is ONE JSON blob holding every target language's TTS state. A naive
187
+ * hget -> modify -> hset (the previous implementation) races with concurrent updateTts calls
188
+ * for OTHER languages on the SAME chunk: with fast TTS (ElevenLabs Flash ~150ms) all target
189
+ * languages of a chunk finish within a few ms of each other, so two handlers read the same
190
+ * snapshot and the second hset clobbers the first language's freshly-written READY back to its
191
+ * old value (INIT). The sequencer then reads INIT and drops the audio
192
+ * ('[audio-drop] reason=not-ready'), leaving the listener with text but no audio even though
193
+ * TTS succeeded — the faster the provider, the tighter the window and the more clips are lost.
194
+ *
195
+ * Running the read-modify-write inside a Lua script makes it atomic (Redis executes a script to
196
+ * completion single-threaded), so sibling-language writes can no longer interleave. WATCH/MULTI
197
+ * is NOT usable here: RedisClient shares a single ioredis connection across all callers, and
198
+ * concurrent commands on that connection would break optimistic-lock semantics.
186
199
  */
187
200
  updateTts(roomId: string, n: number, lang: string, opt: {
188
201
  ttsAudioPath?: string;
@@ -4,6 +4,27 @@ exports.MulingstreamChunkManager = void 0;
4
4
  const uuid_1 = require("uuid");
5
5
  const EXPIRATION = 12 * 60 * 60;
6
6
  const ROOM_ARRAY_LENGTH = 50;
7
+ /**
8
+ * Atomic merge of a partial update into one language's entry inside the serialized `tts` JSON
9
+ * hash field. Runs entirely inside Redis so concurrent per-language writes to the same chunk can
10
+ * never clobber each other (see MulingstreamChunkManager.updateTts for the race this prevents).
11
+ * KEYS[1] = chunk hash key
12
+ * ARGV[1] = language code ARGV[2] = JSON of the partial patch ARGV[3] = expiry seconds
13
+ * Returns 1 on merge, -1 if the language entry is absent (no-op), 0 if there is no tts field yet.
14
+ */
15
+ const TTS_MERGE_LUA = `
16
+ local raw = redis.call('HGET', KEYS[1], 'tts')
17
+ if not raw or raw == '' then return 0 end
18
+ local ok, tts = pcall(cjson.decode, raw)
19
+ if not ok or type(tts) ~= 'table' then return 0 end
20
+ local entry = tts[ARGV[1]]
21
+ if entry == nil then return -1 end
22
+ local patch = cjson.decode(ARGV[2])
23
+ for k, v in pairs(patch) do entry[k] = v end
24
+ redis.call('HSET', KEYS[1], 'tts', cjson.encode(tts))
25
+ redis.call('EXPIRE', KEYS[1], tonumber(ARGV[3]))
26
+ return 1
27
+ `;
7
28
  class MulingstreamChunkManager {
8
29
  constructor(redisClient) {
9
30
  this.redisClient = redisClient;
@@ -413,32 +434,38 @@ class MulingstreamChunkManager {
413
434
  return this.getMulingstreamChunkById(roomId, n);
414
435
  }
415
436
  /**
416
- * Partial HSET only `tts` is read and rewritten. Same race-rationale as
417
- * updateTranslationInBulk: avoids clobbering concurrent translation writes.
437
+ * Atomic per-language merge into the serialized `tts` hash field.
438
+ *
439
+ * The `tts` field is ONE JSON blob holding every target language's TTS state. A naive
440
+ * hget -> modify -> hset (the previous implementation) races with concurrent updateTts calls
441
+ * for OTHER languages on the SAME chunk: with fast TTS (ElevenLabs Flash ~150ms) all target
442
+ * languages of a chunk finish within a few ms of each other, so two handlers read the same
443
+ * snapshot and the second hset clobbers the first language's freshly-written READY back to its
444
+ * old value (INIT). The sequencer then reads INIT and drops the audio
445
+ * ('[audio-drop] reason=not-ready'), leaving the listener with text but no audio even though
446
+ * TTS succeeded — the faster the provider, the tighter the window and the more clips are lost.
447
+ *
448
+ * Running the read-modify-write inside a Lua script makes it atomic (Redis executes a script to
449
+ * completion single-threaded), so sibling-language writes can no longer interleave. WATCH/MULTI
450
+ * is NOT usable here: RedisClient shares a single ioredis connection across all callers, and
451
+ * concurrent commands on that connection would break optimistic-lock semantics.
418
452
  */
419
453
  async updateTts(roomId, n, lang, opt) {
420
- var _a;
421
454
  const cid = await this.getChunkId(roomId, n);
422
455
  if (!cid)
423
456
  return null;
424
457
  const key = this.chunkHashKey(cid);
425
- const ttsRaw = await this.redisClient.hget(key, 'tts');
426
- const tts = (_a = this.deserialize(ttsRaw !== null && ttsRaw !== void 0 ? ttsRaw : '')) !== null && _a !== void 0 ? _a : {};
427
- const e = tts[lang];
428
- if (!e)
429
- return this.getMulingstreamChunkById(roomId, n);
458
+ // Only the explicitly-provided fields are merged; absent fields are left untouched.
459
+ const patch = {};
430
460
  if (opt.ttsAudioPath !== undefined)
431
- e.ttsAudioPath = opt.ttsAudioPath;
461
+ patch.ttsAudioPath = opt.ttsAudioPath;
432
462
  if (opt.status !== undefined)
433
- e.status = opt.status;
463
+ patch.status = opt.status;
434
464
  if (opt.isEmitted !== undefined)
435
- e.isEmitted = opt.isEmitted;
465
+ patch.isEmitted = opt.isEmitted;
436
466
  if (opt.duration !== undefined)
437
- e.duration = opt.duration;
438
- const pipe = this.redisClient.pipeline();
439
- pipe.hset(key, { tts: this.serialize(tts) });
440
- pipe.expire(key, EXPIRATION);
441
- await pipe.exec();
467
+ patch.duration = opt.duration;
468
+ await this.redisClient.eval(TTS_MERGE_LUA, [key], [lang, JSON.stringify(patch), EXPIRATION]);
442
469
  return this.getMulingstreamChunkById(roomId, n);
443
470
  }
444
471
  /**
@@ -44,6 +44,13 @@ export declare class RedisClient {
44
44
  lrange(key: string, start: number, stop: number): Promise<string[]>;
45
45
  ltrim(key: string, start: number, stop: number): Promise<string>;
46
46
  pipeline(): ReturnType<IORedis['pipeline']>;
47
+ /**
48
+ * Run a Lua script atomically (Redis executes each script to completion single-threaded, so a
49
+ * read-modify-write inside one script cannot be interleaved by another writer). Used for
50
+ * lost-update-safe merges into serialized JSON hash fields — see MulingstreamChunkManager.updateTts.
51
+ * Args are stringified because every Redis command argument is a bulk string; use tonumber() in Lua.
52
+ */
53
+ eval(script: string, keys: string[], args: (string | number)[]): Promise<unknown>;
47
54
  unlink(...keys: string[]): Promise<number>;
48
55
  jsonSet<T>(key: string, path: string, value: T): Promise<string>;
49
56
  jsonGet<T>(key: string, path: string): Promise<T | null>;
@@ -126,6 +126,15 @@ class RedisClient {
126
126
  pipeline() {
127
127
  return this.client.pipeline();
128
128
  }
129
+ /**
130
+ * Run a Lua script atomically (Redis executes each script to completion single-threaded, so a
131
+ * read-modify-write inside one script cannot be interleaved by another writer). Used for
132
+ * lost-update-safe merges into serialized JSON hash fields — see MulingstreamChunkManager.updateTts.
133
+ * Args are stringified because every Redis command argument is a bulk string; use tonumber() in Lua.
134
+ */
135
+ async eval(script, keys, args) {
136
+ return this.client.call('EVAL', script, String(keys.length), ...keys, ...args.map(String));
137
+ }
129
138
  async unlink(...keys) {
130
139
  // unlink exists on Redis ≥4 and is supported by ioredis but not typed in
131
140
  // older versions – we cast to any.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mulingai-npm/redis",
3
- "version": "3.40.40",
3
+ "version": "3.40.43",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "repository": {