From b2a51d52eccb636e825c5f526f62b8fecf44f463 Mon Sep 17 00:00:00 2001 From: kptdobe Date: Mon, 11 May 2026 08:43:44 +0200 Subject: [PATCH] fix: guard debounced save against concurrent PUT calls Add a `saving` flag to the debounced da-admin update handler in `bindState` so that if a prior PUT is still in flight when the debounced callback fires again, the second invocation is skipped rather than racing the first. Co-Authored-By: Claude Sonnet 4.6 --- src/shareddoc.js | 7 ++++- test/shareddoc.test.js | 64 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/src/shareddoc.js b/src/shareddoc.js index 0b3f7de..b838e99 100644 --- a/src/shareddoc.js +++ b/src/shareddoc.js @@ -475,11 +475,16 @@ export const persistence = { } }); + let saving = false; ydoc.on('update', debounce(async () => { // If we receive an update on the document, store it in da-admin, but debounce it // to avoid excessive da-admin calls. - if (current && ydoc === docs.get(docName)) { + if (saving || !current || ydoc !== docs.get(docName)) return; + saving = true; + try { current = await persistence.update(ydoc, current, docName); + } finally { + saving = false; } }, 2000, { maxWait: 10000 })); diff --git a/test/shareddoc.test.js b/test/shareddoc.test.js index fb3b4c8..970a348 100644 --- a/test/shareddoc.test.js +++ b/test/shareddoc.test.js @@ -1034,6 +1034,70 @@ describe('Collab Test Suite', () => { } }); + it('Test concurrent save calls are guarded by saving flag', async () => { + // Scenario: debounced handler fires while a previous PUT is still in flight. + // Without the saving flag, both calls race to PUT concurrently. + const mockdebounce = (f) => async () => f(); + const pss = await esmock('../src/shareddoc.js', { + 'lodash/debounce.js': { + default: mockdebounce, + }, + }); + + const docName = 'https://admin.da.live/source/foo/bar.html'; + const storage = { list: async () => new Map() }; + const updObservers = []; + const ydoc = new Y.Doc(); + ydoc.on = (ev, fun) => { + if (ev === 'update') { + updObservers.push(fun); + } + }; + pss.setYDoc(docName, ydoc); + + const savedSetTimeout = globalThis.setTimeout; + const savedGet = pss.persistence.get; + const savedPut = pss.persistence.put; + try { + globalThis.setTimeout = (f) => { + globalThis.setTimeout = savedSetTimeout; + f(); + }; + + pss.persistence.get = async () => '
initial
'; + + let concurrentPuts = 0; + let maxConcurrentPuts = 0; + pss.persistence.put = async () => { + concurrentPuts += 1; + maxConcurrentPuts = Math.max(maxConcurrentPuts, concurrentPuts); + await new Promise((resolve) => { + savedSetTimeout(resolve, 30); + }); + concurrentPuts -= 1; + return { ok: true, status: 200 }; + }; + + await pss.persistence.bindState(docName, ydoc, {}, storage); + + aem2doc('
content1
', ydoc); + + assert.equal(2, updObservers.length, 'Two update observers must be registered'); + + // Fire the debounced da-admin handler twice concurrently — simulates rapid + // updates while a prior save is still in flight. + const p1 = updObservers[1](); + const p2 = updObservers[1](); + await Promise.all([p1, p2]); + + assert.equal(1, maxConcurrentPuts, 'At most one PUT must be in-flight at a time'); + } finally { + globalThis.setTimeout = savedSetTimeout; + pss.persistence.get = savedGet; + pss.persistence.put = savedPut; + } + }); + it('test persist state in worker storage on update', async () => { const docName = 'https://admin.da.live/source/foo/bar.html';