@mulingai-npm/redis 3.40.27 → 3.40.29

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;
@@ -159,6 +194,13 @@ export declare class MulingstreamChunkManager {
159
194
  * Update SmartTranslate buffering metadata on a chunk (Section 22).
160
195
  * Writes `bufferStatus`, `pendingText`, and `consumedChunkNumbers` so the chunk row
161
196
  * persisted at the end of the pipeline reflects whether it was DEFERRED / USED / EMITTED.
197
+ *
198
+ * IMPORTANT — this is a partial HSET that touches ONLY the buffering fields.
199
+ * It deliberately does NOT use `withChunk()` (which rewrites the whole chunk hash)
200
+ * because handleTtsService can update `tts` concurrently with this call. Using the
201
+ * read-modify-write pattern of withChunk would race with TTS status updates and
202
+ * clobber `tts.<lang>.status = READY` back to INIT — preventing the audio sequencer
203
+ * from emitting the listener feed (audio came through as text-only with audio:"").
162
204
  */
163
205
  setBufferStatus(roomId: string, n: number, opt: {
164
206
  bufferStatus?: 'EMITTED' | 'DEFERRED' | 'USED';
@@ -171,11 +213,13 @@ export declare class MulingstreamChunkManager {
171
213
  */
172
214
  isChunkComplete(roomId: string, n: number): Promise<boolean>;
173
215
  /**
174
- * 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`.
175
218
  */
176
219
  markChunkComplete(roomId: string, n: number): Promise<MulingstreamChunkData | null>;
177
220
  /**
178
- * Mark a chunk as saved to database
221
+ * Mark a chunk as saved to database.
222
+ * Partial HSET — touches only `isSaved`.
179
223
  */
180
224
  markChunkSaved(roomId: string, n: number): Promise<MulingstreamChunkData | null>;
181
225
  /**
@@ -45,7 +45,11 @@ class MulingstreamChunkManager {
45
45
  llmWords: h.llmWords ? this.deserialize(h.llmWords) : undefined,
46
46
  transcriptionSource: h.transcriptionSource || 'stt',
47
47
  routeUsed: h.routeUsed || undefined,
48
- sttHistory: h.sttHistory ? this.deserialize(h.sttHistory) : undefined
48
+ sttHistory: h.sttHistory ? this.deserialize(h.sttHistory) : undefined,
49
+ // SmartTranslate buffering (Section 22)
50
+ bufferStatus: h.bufferStatus || undefined,
51
+ pendingText: h.pendingText || undefined,
52
+ consumedChunkNumbers: h.consumedChunkNumbers ? this.deserialize(h.consumedChunkNumbers) : undefined
49
53
  };
50
54
  }
51
55
  getTimeout() {
@@ -211,151 +215,266 @@ class MulingstreamChunkManager {
211
215
  const raw = await this.redisClient.hgetall(this.chunkHashKey(cid));
212
216
  return raw.chunkId ? this.hashToChunk(raw) : null;
213
217
  }
214
- 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) {
215
227
  const cid = await this.getChunkId(roomId, n);
216
228
  if (!cid)
217
229
  return null;
218
230
  const key = this.chunkHashKey(cid);
219
- const raw = await this.redisClient.hgetall(key);
220
- if (!raw.chunkId)
221
- return null;
222
- const chunk = this.hashToChunk(raw);
223
- await fn(chunk);
224
- const p = this.redisClient.pipeline();
225
- const updateHash = {
226
- finalTranscription: chunk.finalTranscription,
227
- translation: this.serialize(chunk.translation),
228
- tts: this.serialize(chunk.tts),
229
- streamingChunk: this.serialize(chunk.streamingChunk),
230
- isComplete: String(chunk.isComplete),
231
- isSaved: String(chunk.isSaved),
232
- transcriptionSource: chunk.transcriptionSource || 'stt'
233
- };
234
- // SmartTranslate fields (only set if populated)
235
- if (chunk.llmTranscription !== undefined)
236
- updateHash.llmTranscription = chunk.llmTranscription;
237
- if (chunk.llmWords !== undefined)
238
- updateHash.llmWords = this.serialize(chunk.llmWords);
239
- if (chunk.routeUsed !== undefined)
240
- updateHash.routeUsed = chunk.routeUsed;
241
- if (chunk.sttHistory !== undefined)
242
- updateHash.sttHistory = this.serialize(chunk.sttHistory);
243
- p.hset(key, updateHash);
244
- p.expire(key, EXPIRATION);
245
- await p.exec();
246
- return chunk;
247
- }
248
- async updateFinalTranscription(roomId, n, transcription) {
249
- return this.withChunk(roomId, n, (c) => {
250
- c.finalTranscription = transcription;
251
- });
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);
252
236
  }
253
237
  /**
254
238
  * Update chunk with LLM-sanitized transcription from SmartTranslate.
255
239
  * If the sanitized text differs from the original, sets transcriptionSource to 'llm'
256
240
  * and updates finalTranscription to the sanitized version.
257
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).
258
253
  */
259
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);
260
273
  let wasChanged = false;
261
- const chunk = await this.withChunk(roomId, n, (c) => {
262
- const originalText = c.finalTranscription;
263
- c.llmTranscription = opts.llmTranscription;
264
- c.llmWords = opts.llmWords;
265
- c.routeUsed = opts.routeUsed;
266
- // If LLM produced a different transcription, update the active text
267
- if (opts.routeUsed === 'LLM' && opts.llmTranscription !== originalText) {
268
- c.finalTranscription = opts.llmTranscription;
269
- c.transcriptionSource = 'llm';
270
- wasChanged = true;
271
- }
272
- // Append to STT history (keep last 3)
273
- if (opts.sttHistoryEntry) {
274
- if (!c.sttHistory)
275
- c.sttHistory = [];
276
- c.sttHistory.push(opts.sttHistoryEntry);
277
- if (c.sttHistory.length > 3)
278
- c.sttHistory = c.sttHistory.slice(-3);
279
- }
280
- });
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);
281
291
  return { chunk, wasChanged };
282
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
+ */
283
297
  async discardLanguage(roomId, n, lang) {
284
- return this.withChunk(roomId, n, (c) => {
285
- if (c.translation[lang])
286
- c.translation[lang].status = 'DISCARDED';
287
- if (c.tts[lang])
288
- c.tts[lang].status = 'DISCARDED';
289
- });
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);
290
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
+ */
291
323
  async discardLanguages(roomId, n, opt) {
292
- return this.withChunk(roomId, n, (c) => {
293
- var _a, _b;
294
- (_a = opt.translation) === null || _a === void 0 ? void 0 : _a.forEach((l) => {
295
- 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];
296
342
  if (e && e.status === 'INIT')
297
343
  e.status = 'DISCARDED';
298
344
  });
299
- (_b = opt.tts) === null || _b === void 0 ? void 0 : _b.forEach((l) => {
300
- 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];
301
351
  if (e && e.status === 'INIT')
302
352
  e.status = 'DISCARDED';
303
353
  });
304
- });
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);
305
361
  }
362
+ /**
363
+ * Partial HSET — only `translation` is read and rewritten. Concurrent updateTts
364
+ * calls won't be clobbered.
365
+ */
306
366
  async updateTranslation(roomId, n, lang, opt) {
307
- return this.withChunk(roomId, n, (c) => {
308
- const e = c.translation[lang];
309
- if (!e)
310
- return;
311
- if (opt.translation !== undefined)
312
- e.translation = opt.translation;
313
- if (opt.status !== undefined)
314
- e.status = opt.status;
315
- });
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);
316
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
+ */
317
392
  async updateTranslationInBulk(roomId, n, dict, status = 'READY', hintsPerLanguage) {
318
- return this.withChunk(roomId, n, (c) => {
319
- Object.entries(dict).forEach(([l, txt]) => {
320
- if (!c.translation[l])
321
- return;
322
- c.translation[l].translation = txt;
323
- c.translation[l].status = status;
324
- if (hintsPerLanguage && hintsPerLanguage[l]) {
325
- c.translation[l].hints = hintsPerLanguage[l];
326
- }
327
- });
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
+ }
328
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);
329
414
  }
415
+ /**
416
+ * Partial HSET — only `tts` is read and rewritten. Same race-rationale as
417
+ * updateTranslationInBulk: avoids clobbering concurrent translation writes.
418
+ */
330
419
  async updateTts(roomId, n, lang, opt) {
331
- return this.withChunk(roomId, n, (c) => {
332
- const e = c.tts[lang];
333
- if (!e)
334
- return;
335
- if (opt.ttsAudioPath !== undefined)
336
- e.ttsAudioPath = opt.ttsAudioPath;
337
- if (opt.status !== undefined)
338
- e.status = opt.status;
339
- if (opt.isEmitted !== undefined)
340
- e.isEmitted = opt.isEmitted;
341
- if (opt.duration !== undefined)
342
- e.duration = opt.duration;
343
- });
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);
344
443
  }
345
444
  /**
346
445
  * Update SmartTranslate buffering metadata on a chunk (Section 22).
347
446
  * Writes `bufferStatus`, `pendingText`, and `consumedChunkNumbers` so the chunk row
348
447
  * persisted at the end of the pipeline reflects whether it was DEFERRED / USED / EMITTED.
448
+ *
449
+ * IMPORTANT — this is a partial HSET that touches ONLY the buffering fields.
450
+ * It deliberately does NOT use `withChunk()` (which rewrites the whole chunk hash)
451
+ * because handleTtsService can update `tts` concurrently with this call. Using the
452
+ * read-modify-write pattern of withChunk would race with TTS status updates and
453
+ * clobber `tts.<lang>.status = READY` back to INIT — preventing the audio sequencer
454
+ * from emitting the listener feed (audio came through as text-only with audio:"").
349
455
  */
350
456
  async setBufferStatus(roomId, n, opt) {
351
- return this.withChunk(roomId, n, (c) => {
352
- if (opt.bufferStatus !== undefined)
353
- c.bufferStatus = opt.bufferStatus;
354
- if (opt.pendingText !== undefined)
355
- c.pendingText = opt.pendingText;
356
- if (opt.consumedChunkNumbers !== undefined)
357
- c.consumedChunkNumbers = opt.consumedChunkNumbers;
358
- });
457
+ const cid = await this.getChunkId(roomId, n);
458
+ if (!cid)
459
+ return null;
460
+ const key = this.chunkHashKey(cid);
461
+ const update = {};
462
+ if (opt.bufferStatus !== undefined)
463
+ update.bufferStatus = opt.bufferStatus;
464
+ if (opt.pendingText !== undefined)
465
+ update.pendingText = opt.pendingText;
466
+ if (opt.consumedChunkNumbers !== undefined)
467
+ update.consumedChunkNumbers = this.serialize(opt.consumedChunkNumbers);
468
+ if (Object.keys(update).length === 0) {
469
+ // Nothing to write — read-only fetch for callers that still want the chunk back.
470
+ return this.getMulingstreamChunkById(roomId, n);
471
+ }
472
+ const pipe = this.redisClient.pipeline();
473
+ pipe.hset(key, update);
474
+ pipe.expire(key, EXPIRATION);
475
+ await pipe.exec();
476
+ // Return the freshly-read chunk so callers can persist it (e.g. insertChunk to PG).
477
+ return this.getMulingstreamChunkById(roomId, n);
359
478
  }
360
479
  async areTranslationsProcessed(roomId, n) {
361
480
  const c = await this.getMulingstreamChunkById(roomId, n);
@@ -371,20 +490,34 @@ class MulingstreamChunkManager {
371
490
  return Object.values(c.tts).every((t) => t.status !== 'INIT');
372
491
  }
373
492
  /**
374
- * 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`.
375
495
  */
376
496
  async markChunkComplete(roomId, n) {
377
- return this.withChunk(roomId, n, (c) => {
378
- c.isComplete = true;
379
- });
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);
380
506
  }
381
507
  /**
382
- * Mark a chunk as saved to database
508
+ * Mark a chunk as saved to database.
509
+ * Partial HSET — touches only `isSaved`.
383
510
  */
384
511
  async markChunkSaved(roomId, n) {
385
- return this.withChunk(roomId, n, (c) => {
386
- c.isSaved = true;
387
- });
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);
388
521
  }
389
522
  /**
390
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.27",
3
+ "version": "3.40.29",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "repository": {