From 3d01766f55e6d9963ffc79a50f6cfe1f76faadc4 Mon Sep 17 00:00:00 2001 From: DarkSky <25152247+darkskygit@users.noreply.github.com> Date: Sun, 22 Feb 2026 02:13:51 +0800 Subject: [PATCH] fix: history may duplicate on concurrency (#14487) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #### PR Dependency Tree * **PR #14487** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) ## Summary by CodeRabbit * **Bug Fixes** * Enhanced history record creation to prevent duplicate entries in concurrent scenarios. * **Tests** * Added validation for idempotent history record creation. --- .../server/src/core/doc/adapters/workspace.ts | 26 +++++++----------- .../src/models/__tests__/history.spec.ts | 21 +++++++++++++++ packages/backend/server/src/models/history.ts | 27 +++++++++++++------ 3 files changed, 50 insertions(+), 24 deletions(-) diff --git a/packages/backend/server/src/core/doc/adapters/workspace.ts b/packages/backend/server/src/core/doc/adapters/workspace.ts index d69f39e522993..6e8ce11af957b 100644 --- a/packages/backend/server/src/core/doc/adapters/workspace.ts +++ b/packages/backend/server/src/core/doc/adapters/workspace.ts @@ -276,22 +276,16 @@ export class PgWorkspaceDocStorageAdapter extends DocStorageAdapter { return false; } - try { - await this.models.history.create( - { - spaceId: snapshot.spaceId, - docId: snapshot.docId, - timestamp: snapshot.timestamp, - blob: Buffer.from(snapshot.bin), - editorId: snapshot.editor, - }, - historyMaxAge - ); - } catch (e) { - // safe to ignore - // only happens when duplicated history record created in multi processes - this.logger.error('Failed to create history record', e); - } + await this.models.history.create( + { + spaceId: snapshot.spaceId, + docId: snapshot.docId, + timestamp: snapshot.timestamp, + blob: Buffer.from(snapshot.bin), + editorId: snapshot.editor, + }, + historyMaxAge + ); metrics.doc .counter('history_created_counter', { diff --git a/packages/backend/server/src/models/__tests__/history.spec.ts b/packages/backend/server/src/models/__tests__/history.spec.ts index 2a26c1ec4d55a..856fba8663844 100644 --- a/packages/backend/server/src/models/__tests__/history.spec.ts +++ b/packages/backend/server/src/models/__tests__/history.spec.ts @@ -74,6 +74,27 @@ test('should create a history record', async t => { }); }); +test('should not fail on duplicated history record', async t => { + const snapshot = { + spaceId: workspace.id, + docId: randomUUID(), + blob: Uint8Array.from([1, 2, 3]), + timestamp: Date.now(), + editorId: user.id, + }; + + const created1 = await t.context.history.create(snapshot, 1000); + const created2 = await t.context.history.create(snapshot, 1000); + t.deepEqual(created1.timestamp, snapshot.timestamp); + t.deepEqual(created2.timestamp, snapshot.timestamp); + + const histories = await t.context.history.findMany( + snapshot.spaceId, + snapshot.docId + ); + t.is(histories.length, 1); +}); + test('should return null when history timestamp not match', async t => { const snapshot = { spaceId: workspace.id, diff --git a/packages/backend/server/src/models/history.ts b/packages/backend/server/src/models/history.ts index 7155e6b1409ba..bb8086f3d8b61 100644 --- a/packages/backend/server/src/models/history.ts +++ b/packages/backend/server/src/models/history.ts @@ -33,22 +33,33 @@ export class HistoryModel extends BaseModel { * Create a doc history with a max age. */ async create(snapshot: Doc, maxAge: number): Promise { - const row = await this.db.snapshotHistory.create({ - select: { - timestamp: true, - createdByUser: { select: publicUserSelect }, + const timestamp = new Date(snapshot.timestamp); + const expiredAt = new Date(Date.now() + maxAge); + + // This method may be called concurrently by multiple processes for the same + // (workspaceId, docId, timestamp). Using upsert avoids duplicate key errors + // that would otherwise abort the surrounding transaction. + const row = await this.db.snapshotHistory.upsert({ + where: { + workspaceId_id_timestamp: { + workspaceId: snapshot.spaceId, + id: snapshot.docId, + timestamp, + }, }, - data: { + select: { timestamp: true, createdByUser: { select: publicUserSelect } }, + create: { workspaceId: snapshot.spaceId, id: snapshot.docId, - timestamp: new Date(snapshot.timestamp), + timestamp, blob: snapshot.blob, createdBy: snapshot.editorId, - expiredAt: new Date(Date.now() + maxAge), + expiredAt, }, + update: { expiredAt }, }); this.logger.debug( - `Created history ${row.timestamp} for ${snapshot.docId} in ${snapshot.spaceId}` + `Upserted history ${row.timestamp} for ${snapshot.docId} in ${snapshot.spaceId}` ); return { timestamp: row.timestamp.getTime(),