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';