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