From 9059c713f6988974fe22a442514e58fbb2b1e5d3 Mon Sep 17 00:00:00 2001 From: ldt1996 Date: Tue, 7 Jul 2026 08:20:15 +0300 Subject: [PATCH 1/4] fix(queue): poll fallback so waitForDrain cannot hang on a queue emptied without a drained emit --- resources/IterableEventQueue.ts | 15 +++++++++++++-- unitTests/resources/IterableEventQueue.test.js | 9 +++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/resources/IterableEventQueue.ts b/resources/IterableEventQueue.ts index ffc24a25ab..fb73369337 100644 --- a/resources/IterableEventQueue.ts +++ b/resources/IterableEventQueue.ts @@ -39,8 +39,19 @@ export class IterableEventQueue extends EventEmitter return new Promise((resolve) => { if (!this.queue || this.queue.length === 0) resolve(true); else { - this.once('drained', () => resolve(true)); - this.currentDrainResolver = resolve; + // The queue can also empty through paths that never emit 'drained' (the on('data') + // attach loop and the resolveNext bypass), so a waiter relying on the event alone + // can hang forever on an already-empty queue. Poll as a fallback wakeup. + const poll = setInterval(() => { + if (!this.queue || this.queue.length === 0) settle(true); + }, 100); + poll.unref?.(); + const settle = (drained: boolean) => { + clearInterval(poll); + resolve(drained); + }; + this.once('drained', () => settle(true)); + this.currentDrainResolver = settle; if (!this.drainCloseListener) { this.drainCloseListener = true; this.on('close', () => { diff --git a/unitTests/resources/IterableEventQueue.test.js b/unitTests/resources/IterableEventQueue.test.js index 9f2efe96b9..d13e9fff60 100644 --- a/unitTests/resources/IterableEventQueue.test.js +++ b/unitTests/resources/IterableEventQueue.test.js @@ -49,4 +49,13 @@ describe('IterableEventQueue', () => { q.off('close', close); assert.equal(q.hasDataListeners, true, "removing a 'close' listener leaves data state intact"); }); + + it('waitForDrain resolves when the on(data) attach loop empties the queue without emitting drained', async () => { + const q = new IterableEventQueue(); + q.send({ n: 1 }); + const drained = q.waitForDrain(); + q.on('data', () => {}); // attach loop drains the buffered queue synchronously, no 'drained' emit + const result = await Promise.race([drained, new Promise((r) => setTimeout(() => r('hung'), 1000))]); + assert.equal(result, true, 'waiter must observe the queue emptying through the attach path'); + }); }); From ca6a578f2360f1ad8f84b5a2f695eed1e6209d7a Mon Sep 17 00:00:00 2001 From: ldt1996 Date: Tue, 7 Jul 2026 08:20:15 +0300 Subject: [PATCH 2/4] feat(table): expose evictionMS beside expirationMS --- resources/Table.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/resources/Table.ts b/resources/Table.ts index d49083cfd1..e8b4deccb3 100644 --- a/resources/Table.ts +++ b/resources/Table.ts @@ -327,6 +327,9 @@ export function makeTable(options) { static get expirationMS() { return expirationMs; } + static get evictionMS() { + return evictionMs; + } static dbisDB = dbisDb; static schemaDefined = schemaDefined; /** From 28078f99794808d06545426238a6b76a4a337237 Mon Sep 17 00:00:00 2001 From: ldt1996 Date: Tue, 7 Jul 2026 08:38:12 +0300 Subject: [PATCH 3/4] fix(queue): remove the drained listener when settle fires via the poll (review) --- resources/IterableEventQueue.ts | 12 +++++++----- unitTests/resources/IterableEventQueue.test.js | 1 + 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/resources/IterableEventQueue.ts b/resources/IterableEventQueue.ts index fb73369337..e2a9f0aec1 100644 --- a/resources/IterableEventQueue.ts +++ b/resources/IterableEventQueue.ts @@ -42,16 +42,18 @@ export class IterableEventQueue extends EventEmitter // The queue can also empty through paths that never emit 'drained' (the on('data') // attach loop and the resolveNext bypass), so a waiter relying on the event alone // can hang forever on an already-empty queue. Poll as a fallback wakeup. - const poll = setInterval(() => { - if (!this.queue || this.queue.length === 0) settle(true); - }, 100); - poll.unref?.(); const settle = (drained: boolean) => { clearInterval(poll); + this.removeListener('drained', onDrained); resolve(drained); }; - this.once('drained', () => settle(true)); + const onDrained = () => settle(true); + this.once('drained', onDrained); this.currentDrainResolver = settle; + const poll = setInterval(() => { + if (!this.queue || this.queue.length === 0) settle(true); + }, 100); + poll.unref?.(); if (!this.drainCloseListener) { this.drainCloseListener = true; this.on('close', () => { diff --git a/unitTests/resources/IterableEventQueue.test.js b/unitTests/resources/IterableEventQueue.test.js index d13e9fff60..fb6e6ecf98 100644 --- a/unitTests/resources/IterableEventQueue.test.js +++ b/unitTests/resources/IterableEventQueue.test.js @@ -57,5 +57,6 @@ describe('IterableEventQueue', () => { q.on('data', () => {}); // attach loop drains the buffered queue synchronously, no 'drained' emit const result = await Promise.race([drained, new Promise((r) => setTimeout(() => r('hung'), 1000))]); assert.equal(result, true, 'waiter must observe the queue emptying through the attach path'); + assert.equal(q.listenerCount('drained'), 0, 'poll-path settle must remove the drained listener'); }); }); From 4af3a4aba4fe4303e3f94a708b82f45eac535ead Mon Sep 17 00:00:00 2001 From: ldt1996 Date: Tue, 7 Jul 2026 08:46:28 +0300 Subject: [PATCH 4/4] chore: format Table.ts with the pinned prettier --- resources/Table.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/resources/Table.ts b/resources/Table.ts index e8b4deccb3..8012b74d2f 100644 --- a/resources/Table.ts +++ b/resources/Table.ts @@ -3369,7 +3369,7 @@ export function makeTable(options) { logger.error?.('Error getting history entry', auditRecord.localTime, error); } } - for (let i = history.length; i > 0; ) { + for (let i = history.length; i > 0;) { send(history[--i]); } // Use the latest record cursor saw (history[0] = most recent due to reverse @@ -3478,7 +3478,7 @@ export function makeTable(options) { } else break; if (count) count--; } while (nextTime > startTime && count !== 0); - for (let i = history.length; i > 0; ) { + for (let i = history.length; i > 0;) { send(history[--i]); } } @@ -3704,12 +3704,10 @@ export function makeTable(options) { ); break; case 'ID': - if ( - !( - typeof value === 'string' || - (value?.length > 0 && value.every?.((value) => typeof value === 'string')) - ) - ) + if (!( + typeof value === 'string' || + (value?.length > 0 && value.every?.((value) => typeof value === 'string')) + )) (validationErrors || (validationErrors = [])).push( `Value ${stringify(value)} in property ${name} must be a string, or an array of strings` );