@mulingai-npm/redis 3.40.28 → 3.40.30

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.
@@ -122,13 +122,27 @@ export declare class MulingstreamChunkManager {
122
122
  }): Promise<MulingstreamChunkData>;
123
123
  private getChunkId;
124
124
  getMulingstreamChunkById(roomId: string, n: number): Promise<MulingstreamChunkData>;
125
- private withChunk;
125
+ /**
126
+ * Partial HSET — touches only `finalTranscription`. See setBufferStatus for the
127
+ * race rationale (whole-chunk read-modify-write would clobber concurrent updates).
128
+ */
126
129
  updateFinalTranscription(roomId: string, n: number, transcription: string): Promise<MulingstreamChunkData | null>;
127
130
  /**
128
131
  * Update chunk with LLM-sanitized transcription from SmartTranslate.
129
132
  * If the sanitized text differs from the original, sets transcriptionSource to 'llm'
130
133
  * and updates finalTranscription to the sanitized version.
131
134
  * Returns the chunk (caller can compare original vs sanitized to decide on TRANSCRIPTION_CORRECTED).
135
+ *
136
+ * IMPORTANT — partial HSET, NOT withChunk(). Reasoning identical to setBufferStatus:
137
+ * handleTtsService runs in parallel and writes `tts.<lang>.status = READY`. The
138
+ * read-modify-write of withChunk would clobber that READY back to INIT (we read
139
+ * tts before TTS callback fires, then write the entire chunk back later). Result:
140
+ * emitChunk's `if (status !== READY) return;` drops the audio publish, listeners
141
+ * see text but never get the audio URL.
142
+ *
143
+ * This bug surfaced 2026-05-01: same root cause as the earlier setBufferStatus
144
+ * race; updateLlmTranscription wasn't fixed at the same time so it kept producing
145
+ * silent audio for any chunk where wasSanitized=true (i.e. every LLM-routed chunk).
132
146
  */
133
147
  updateLlmTranscription(roomId: string, n: number, opts: {
134
148
  llmTranscription: string;
@@ -139,16 +153,37 @@ export declare class MulingstreamChunkManager {
139
153
  chunk: MulingstreamChunkData | null;
140
154
  wasChanged: boolean;
141
155
  }>;
156
+ /**
157
+ * Partial HSET — reads only `translation` and `tts` fields, modifies in place,
158
+ * writes back only those two. See setBufferStatus for the race rationale.
159
+ */
142
160
  discardLanguage(roomId: string, n: number, lang: string): Promise<MulingstreamChunkData | null>;
161
+ /**
162
+ * Partial HSET — same race-safe pattern as discardLanguage. Only writes the
163
+ * fields actually mutated by the caller (translation and/or tts).
164
+ */
143
165
  discardLanguages(roomId: string, n: number, opt: {
144
166
  translation?: string[];
145
167
  tts?: string[];
146
168
  }): Promise<MulingstreamChunkData | null>;
169
+ /**
170
+ * Partial HSET — only `translation` is read and rewritten. Concurrent updateTts
171
+ * calls won't be clobbered.
172
+ */
147
173
  updateTranslation(roomId: string, n: number, lang: string, opt: {
148
174
  translation?: string;
149
175
  status?: StepStatus;
150
176
  }): Promise<MulingstreamChunkData | null>;
177
+ /**
178
+ * Partial HSET — only `translation` is read and rewritten. This is the hot path
179
+ * that races with handleTtsService's updateTts and was the root cause of the
180
+ * "audio LISTENER_FEED never arrives" bug fixed 2026-05-01.
181
+ */
151
182
  updateTranslationInBulk(roomId: string, n: number, dict: Record<string, string>, status?: StepStatus, hintsPerLanguage?: Record<string, TranslationHint[]>): Promise<MulingstreamChunkData | null>;
183
+ /**
184
+ * Partial HSET — only `tts` is read and rewritten. Same race-rationale as
185
+ * updateTranslationInBulk: avoids clobbering concurrent translation writes.
186
+ */
152
187
  updateTts(roomId: string, n: number, lang: string, opt: {
153
188
  ttsAudioPath?: string;
154
189
  status?: StepStatus;
@@ -178,11 +213,13 @@ export declare class MulingstreamChunkManager {
178
213
  */
179
214
  isChunkComplete(roomId: string, n: number): Promise<boolean>;
180
215
  /**
181
- * Mark a chunk as complete (all TTS finished for all target languages)
216
+ * Mark a chunk as complete (all TTS finished for all target languages).
217
+ * Partial HSET — touches only `isComplete`.
182
218
  */
183
219
  markChunkComplete(roomId: string, n: number): Promise<MulingstreamChunkData | null>;
184
220
  /**
185
- * Mark a chunk as saved to database
221
+ * Mark a chunk as saved to database.
222
+ * Partial HSET — touches only `isSaved`.
186
223
  */
187
224
  markChunkSaved(roomId: string, n: number): Promise<MulingstreamChunkData | null>;
188
225
  /**
@@ -215,143 +215,231 @@ class MulingstreamChunkManager {
215
215
  const raw = await this.redisClient.hgetall(this.chunkHashKey(cid));
216
216
  return raw.chunkId ? this.hashToChunk(raw) : null;
217
217
  }
218
- async withChunk(roomId, n, fn) {
218
+ // NOTE — `withChunk` was removed 2026-05-01. Its read-modify-write pattern
219
+ // raced with concurrent updates to other fields and clobbered them on writeback.
220
+ // All mutators now use partial HSETs that read and write only the fields they own.
221
+ // If you need a new mutator, follow the pattern of `updateTts` / `updateTranslationInBulk`.
222
+ /**
223
+ * Partial HSET — touches only `finalTranscription`. See setBufferStatus for the
224
+ * race rationale (whole-chunk read-modify-write would clobber concurrent updates).
225
+ */
226
+ async updateFinalTranscription(roomId, n, transcription) {
219
227
  const cid = await this.getChunkId(roomId, n);
220
228
  if (!cid)
221
229
  return null;
222
230
  const key = this.chunkHashKey(cid);
223
- const raw = await this.redisClient.hgetall(key);
224
- if (!raw.chunkId)
225
- return null;
226
- const chunk = this.hashToChunk(raw);
227
- await fn(chunk);
228
- const p = this.redisClient.pipeline();
229
- const updateHash = {
230
- finalTranscription: chunk.finalTranscription,
231
- translation: this.serialize(chunk.translation),
232
- tts: this.serialize(chunk.tts),
233
- streamingChunk: this.serialize(chunk.streamingChunk),
234
- isComplete: String(chunk.isComplete),
235
- isSaved: String(chunk.isSaved),
236
- transcriptionSource: chunk.transcriptionSource || 'stt'
237
- };
238
- // SmartTranslate fields (only set if populated)
239
- if (chunk.llmTranscription !== undefined)
240
- updateHash.llmTranscription = chunk.llmTranscription;
241
- if (chunk.llmWords !== undefined)
242
- updateHash.llmWords = this.serialize(chunk.llmWords);
243
- if (chunk.routeUsed !== undefined)
244
- updateHash.routeUsed = chunk.routeUsed;
245
- if (chunk.sttHistory !== undefined)
246
- updateHash.sttHistory = this.serialize(chunk.sttHistory);
247
- // SmartTranslate buffering (Section 22)
248
- if (chunk.bufferStatus !== undefined)
249
- updateHash.bufferStatus = chunk.bufferStatus;
250
- if (chunk.pendingText !== undefined)
251
- updateHash.pendingText = chunk.pendingText;
252
- if (chunk.consumedChunkNumbers !== undefined)
253
- updateHash.consumedChunkNumbers = this.serialize(chunk.consumedChunkNumbers);
254
- p.hset(key, updateHash);
255
- p.expire(key, EXPIRATION);
256
- await p.exec();
257
- return chunk;
258
- }
259
- async updateFinalTranscription(roomId, n, transcription) {
260
- return this.withChunk(roomId, n, (c) => {
261
- c.finalTranscription = transcription;
262
- });
231
+ const pipe = this.redisClient.pipeline();
232
+ pipe.hset(key, { finalTranscription: transcription });
233
+ pipe.expire(key, EXPIRATION);
234
+ await pipe.exec();
235
+ return this.getMulingstreamChunkById(roomId, n);
263
236
  }
264
237
  /**
265
238
  * Update chunk with LLM-sanitized transcription from SmartTranslate.
266
239
  * If the sanitized text differs from the original, sets transcriptionSource to 'llm'
267
240
  * and updates finalTranscription to the sanitized version.
268
241
  * Returns the chunk (caller can compare original vs sanitized to decide on TRANSCRIPTION_CORRECTED).
242
+ *
243
+ * IMPORTANT — partial HSET, NOT withChunk(). Reasoning identical to setBufferStatus:
244
+ * handleTtsService runs in parallel and writes `tts.<lang>.status = READY`. The
245
+ * read-modify-write of withChunk would clobber that READY back to INIT (we read
246
+ * tts before TTS callback fires, then write the entire chunk back later). Result:
247
+ * emitChunk's `if (status !== READY) return;` drops the audio publish, listeners
248
+ * see text but never get the audio URL.
249
+ *
250
+ * This bug surfaced 2026-05-01: same root cause as the earlier setBufferStatus
251
+ * race; updateLlmTranscription wasn't fixed at the same time so it kept producing
252
+ * silent audio for any chunk where wasSanitized=true (i.e. every LLM-routed chunk).
269
253
  */
270
254
  async updateLlmTranscription(roomId, n, opts) {
255
+ const cid = await this.getChunkId(roomId, n);
256
+ if (!cid)
257
+ return { chunk: null, wasChanged: false };
258
+ const key = this.chunkHashKey(cid);
259
+ // Read just the fields we need to compute the diff/append, NOT the whole chunk.
260
+ // We don't need tts/translation/streamingChunk here — they're untouched by this op.
261
+ const [originalTextRaw, sttHistoryRaw] = await Promise.all([
262
+ this.redisClient.hget(key, 'finalTranscription'),
263
+ this.redisClient.hget(key, 'sttHistory')
264
+ ]);
265
+ const originalText = originalTextRaw !== null && originalTextRaw !== void 0 ? originalTextRaw : '';
266
+ const existingSttHistory = sttHistoryRaw ? this.deserialize(sttHistoryRaw) : [];
267
+ const update = {
268
+ llmTranscription: opts.llmTranscription,
269
+ routeUsed: opts.routeUsed
270
+ };
271
+ if (opts.llmWords !== undefined)
272
+ update.llmWords = this.serialize(opts.llmWords);
271
273
  let wasChanged = false;
272
- const chunk = await this.withChunk(roomId, n, (c) => {
273
- const originalText = c.finalTranscription;
274
- c.llmTranscription = opts.llmTranscription;
275
- c.llmWords = opts.llmWords;
276
- c.routeUsed = opts.routeUsed;
277
- // If LLM produced a different transcription, update the active text
278
- if (opts.routeUsed === 'LLM' && opts.llmTranscription !== originalText) {
279
- c.finalTranscription = opts.llmTranscription;
280
- c.transcriptionSource = 'llm';
281
- wasChanged = true;
282
- }
283
- // Append to STT history (keep last 3)
284
- if (opts.sttHistoryEntry) {
285
- if (!c.sttHistory)
286
- c.sttHistory = [];
287
- c.sttHistory.push(opts.sttHistoryEntry);
288
- if (c.sttHistory.length > 3)
289
- c.sttHistory = c.sttHistory.slice(-3);
290
- }
291
- });
274
+ if (opts.routeUsed === 'LLM' && opts.llmTranscription !== originalText) {
275
+ update.finalTranscription = opts.llmTranscription;
276
+ update.transcriptionSource = 'llm';
277
+ wasChanged = true;
278
+ }
279
+ if (opts.sttHistoryEntry) {
280
+ const next = [...existingSttHistory, opts.sttHistoryEntry];
281
+ if (next.length > 3)
282
+ next.splice(0, next.length - 3);
283
+ update.sttHistory = this.serialize(next);
284
+ }
285
+ const pipe = this.redisClient.pipeline();
286
+ pipe.hset(key, update);
287
+ pipe.expire(key, EXPIRATION);
288
+ await pipe.exec();
289
+ // Re-read the full chunk so callers can use the up-to-date object.
290
+ const chunk = await this.getMulingstreamChunkById(roomId, n);
292
291
  return { chunk, wasChanged };
293
292
  }
293
+ /**
294
+ * Partial HSET — reads only `translation` and `tts` fields, modifies in place,
295
+ * writes back only those two. See setBufferStatus for the race rationale.
296
+ */
294
297
  async discardLanguage(roomId, n, lang) {
295
- return this.withChunk(roomId, n, (c) => {
296
- if (c.translation[lang])
297
- c.translation[lang].status = 'DISCARDED';
298
- if (c.tts[lang])
299
- c.tts[lang].status = 'DISCARDED';
300
- });
298
+ var _a, _b;
299
+ const cid = await this.getChunkId(roomId, n);
300
+ if (!cid)
301
+ return null;
302
+ const key = this.chunkHashKey(cid);
303
+ const [translationRaw, ttsRaw] = await Promise.all([
304
+ this.redisClient.hget(key, 'translation'),
305
+ this.redisClient.hget(key, 'tts')
306
+ ]);
307
+ const translation = (_a = this.deserialize(translationRaw !== null && translationRaw !== void 0 ? translationRaw : '')) !== null && _a !== void 0 ? _a : {};
308
+ const tts = (_b = this.deserialize(ttsRaw !== null && ttsRaw !== void 0 ? ttsRaw : '')) !== null && _b !== void 0 ? _b : {};
309
+ if (translation[lang])
310
+ translation[lang].status = 'DISCARDED';
311
+ if (tts[lang])
312
+ tts[lang].status = 'DISCARDED';
313
+ const pipe = this.redisClient.pipeline();
314
+ pipe.hset(key, { translation: this.serialize(translation), tts: this.serialize(tts) });
315
+ pipe.expire(key, EXPIRATION);
316
+ await pipe.exec();
317
+ return this.getMulingstreamChunkById(roomId, n);
301
318
  }
319
+ /**
320
+ * Partial HSET — same race-safe pattern as discardLanguage. Only writes the
321
+ * fields actually mutated by the caller (translation and/or tts).
322
+ */
302
323
  async discardLanguages(roomId, n, opt) {
303
- return this.withChunk(roomId, n, (c) => {
304
- var _a, _b;
305
- (_a = opt.translation) === null || _a === void 0 ? void 0 : _a.forEach((l) => {
306
- const e = c.translation[l];
324
+ var _a, _b, _c, _d;
325
+ const cid = await this.getChunkId(roomId, n);
326
+ if (!cid)
327
+ return null;
328
+ const key = this.chunkHashKey(cid);
329
+ const wantTranslation = !!((_a = opt.translation) === null || _a === void 0 ? void 0 : _a.length);
330
+ const wantTts = !!((_b = opt.tts) === null || _b === void 0 ? void 0 : _b.length);
331
+ if (!wantTranslation && !wantTts)
332
+ return this.getMulingstreamChunkById(roomId, n);
333
+ const [translationRaw, ttsRaw] = await Promise.all([
334
+ wantTranslation ? this.redisClient.hget(key, 'translation') : Promise.resolve(null),
335
+ wantTts ? this.redisClient.hget(key, 'tts') : Promise.resolve(null)
336
+ ]);
337
+ const update = {};
338
+ if (wantTranslation) {
339
+ const translation = (_c = this.deserialize(translationRaw !== null && translationRaw !== void 0 ? translationRaw : '')) !== null && _c !== void 0 ? _c : {};
340
+ opt.translation.forEach((l) => {
341
+ const e = translation[l];
307
342
  if (e && e.status === 'INIT')
308
343
  e.status = 'DISCARDED';
309
344
  });
310
- (_b = opt.tts) === null || _b === void 0 ? void 0 : _b.forEach((l) => {
311
- const e = c.tts[l];
345
+ update.translation = this.serialize(translation);
346
+ }
347
+ if (wantTts) {
348
+ const tts = (_d = this.deserialize(ttsRaw !== null && ttsRaw !== void 0 ? ttsRaw : '')) !== null && _d !== void 0 ? _d : {};
349
+ opt.tts.forEach((l) => {
350
+ const e = tts[l];
312
351
  if (e && e.status === 'INIT')
313
352
  e.status = 'DISCARDED';
314
353
  });
315
- });
354
+ update.tts = this.serialize(tts);
355
+ }
356
+ const pipe = this.redisClient.pipeline();
357
+ pipe.hset(key, update);
358
+ pipe.expire(key, EXPIRATION);
359
+ await pipe.exec();
360
+ return this.getMulingstreamChunkById(roomId, n);
316
361
  }
362
+ /**
363
+ * Partial HSET — only `translation` is read and rewritten. Concurrent updateTts
364
+ * calls won't be clobbered.
365
+ */
317
366
  async updateTranslation(roomId, n, lang, opt) {
318
- return this.withChunk(roomId, n, (c) => {
319
- const e = c.translation[lang];
320
- if (!e)
321
- return;
322
- if (opt.translation !== undefined)
323
- e.translation = opt.translation;
324
- if (opt.status !== undefined)
325
- e.status = opt.status;
326
- });
367
+ var _a;
368
+ const cid = await this.getChunkId(roomId, n);
369
+ if (!cid)
370
+ return null;
371
+ const key = this.chunkHashKey(cid);
372
+ const translationRaw = await this.redisClient.hget(key, 'translation');
373
+ const translation = (_a = this.deserialize(translationRaw !== null && translationRaw !== void 0 ? translationRaw : '')) !== null && _a !== void 0 ? _a : {};
374
+ const e = translation[lang];
375
+ if (!e)
376
+ return this.getMulingstreamChunkById(roomId, n);
377
+ if (opt.translation !== undefined)
378
+ e.translation = opt.translation;
379
+ if (opt.status !== undefined)
380
+ e.status = opt.status;
381
+ const pipe = this.redisClient.pipeline();
382
+ pipe.hset(key, { translation: this.serialize(translation) });
383
+ pipe.expire(key, EXPIRATION);
384
+ await pipe.exec();
385
+ return this.getMulingstreamChunkById(roomId, n);
327
386
  }
387
+ /**
388
+ * Partial HSET — only `translation` is read and rewritten. This is the hot path
389
+ * that races with handleTtsService's updateTts and was the root cause of the
390
+ * "audio LISTENER_FEED never arrives" bug fixed 2026-05-01.
391
+ */
328
392
  async updateTranslationInBulk(roomId, n, dict, status = 'READY', hintsPerLanguage) {
329
- return this.withChunk(roomId, n, (c) => {
330
- Object.entries(dict).forEach(([l, txt]) => {
331
- if (!c.translation[l])
332
- return;
333
- c.translation[l].translation = txt;
334
- c.translation[l].status = status;
335
- if (hintsPerLanguage && hintsPerLanguage[l]) {
336
- c.translation[l].hints = hintsPerLanguage[l];
337
- }
338
- });
393
+ var _a;
394
+ const cid = await this.getChunkId(roomId, n);
395
+ if (!cid)
396
+ return null;
397
+ const key = this.chunkHashKey(cid);
398
+ const translationRaw = await this.redisClient.hget(key, 'translation');
399
+ const translation = (_a = this.deserialize(translationRaw !== null && translationRaw !== void 0 ? translationRaw : '')) !== null && _a !== void 0 ? _a : {};
400
+ Object.entries(dict).forEach(([l, txt]) => {
401
+ if (!translation[l])
402
+ return;
403
+ translation[l].translation = txt;
404
+ translation[l].status = status;
405
+ if (hintsPerLanguage && hintsPerLanguage[l]) {
406
+ translation[l].hints = hintsPerLanguage[l];
407
+ }
339
408
  });
409
+ const pipe = this.redisClient.pipeline();
410
+ pipe.hset(key, { translation: this.serialize(translation) });
411
+ pipe.expire(key, EXPIRATION);
412
+ await pipe.exec();
413
+ return this.getMulingstreamChunkById(roomId, n);
340
414
  }
415
+ /**
416
+ * Partial HSET — only `tts` is read and rewritten. Same race-rationale as
417
+ * updateTranslationInBulk: avoids clobbering concurrent translation writes.
418
+ */
341
419
  async updateTts(roomId, n, lang, opt) {
342
- return this.withChunk(roomId, n, (c) => {
343
- const e = c.tts[lang];
344
- if (!e)
345
- return;
346
- if (opt.ttsAudioPath !== undefined)
347
- e.ttsAudioPath = opt.ttsAudioPath;
348
- if (opt.status !== undefined)
349
- e.status = opt.status;
350
- if (opt.isEmitted !== undefined)
351
- e.isEmitted = opt.isEmitted;
352
- if (opt.duration !== undefined)
353
- e.duration = opt.duration;
354
- });
420
+ var _a;
421
+ const cid = await this.getChunkId(roomId, n);
422
+ if (!cid)
423
+ return null;
424
+ 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);
430
+ if (opt.ttsAudioPath !== undefined)
431
+ e.ttsAudioPath = opt.ttsAudioPath;
432
+ if (opt.status !== undefined)
433
+ e.status = opt.status;
434
+ if (opt.isEmitted !== undefined)
435
+ e.isEmitted = opt.isEmitted;
436
+ 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();
442
+ return this.getMulingstreamChunkById(roomId, n);
355
443
  }
356
444
  /**
357
445
  * Update SmartTranslate buffering metadata on a chunk (Section 22).
@@ -402,20 +490,34 @@ class MulingstreamChunkManager {
402
490
  return Object.values(c.tts).every((t) => t.status !== 'INIT');
403
491
  }
404
492
  /**
405
- * Mark a chunk as complete (all TTS finished for all target languages)
493
+ * Mark a chunk as complete (all TTS finished for all target languages).
494
+ * Partial HSET — touches only `isComplete`.
406
495
  */
407
496
  async markChunkComplete(roomId, n) {
408
- return this.withChunk(roomId, n, (c) => {
409
- c.isComplete = true;
410
- });
497
+ const cid = await this.getChunkId(roomId, n);
498
+ if (!cid)
499
+ return null;
500
+ const key = this.chunkHashKey(cid);
501
+ const pipe = this.redisClient.pipeline();
502
+ pipe.hset(key, { isComplete: 'true' });
503
+ pipe.expire(key, EXPIRATION);
504
+ await pipe.exec();
505
+ return this.getMulingstreamChunkById(roomId, n);
411
506
  }
412
507
  /**
413
- * Mark a chunk as saved to database
508
+ * Mark a chunk as saved to database.
509
+ * Partial HSET — touches only `isSaved`.
414
510
  */
415
511
  async markChunkSaved(roomId, n) {
416
- return this.withChunk(roomId, n, (c) => {
417
- c.isSaved = true;
418
- });
512
+ const cid = await this.getChunkId(roomId, n);
513
+ if (!cid)
514
+ return null;
515
+ const key = this.chunkHashKey(cid);
516
+ const pipe = this.redisClient.pipeline();
517
+ pipe.hset(key, { isSaved: 'true' });
518
+ pipe.expire(key, EXPIRATION);
519
+ await pipe.exec();
520
+ return this.getMulingstreamChunkById(roomId, n);
419
521
  }
420
522
  /**
421
523
  * Get all complete but unsaved chunks for a room
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mulingai-npm/redis",
3
- "version": "3.40.28",
3
+ "version": "3.40.30",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "repository": {