fix(sync): handle duplicate operation IDs gracefully in appendBatch

When concurrent sync triggers occur (especially on Android), a race condition
between filterNewOps() and appendBatch() can cause ConstraintError due to
duplicate operation IDs in the byId unique index.

Instead of failing sync, catch ConstraintError and return empty array - the
ops were already inserted by a concurrent sync.

Fixes #6213
This commit is contained in:
Johannes Millan 2026-01-27 13:14:40 +01:00
parent ebe72c14df
commit ab6df00a72
2 changed files with 26 additions and 2 deletions

View file

@ -724,6 +724,23 @@ describe('OperationLogStoreService', () => {
expect(seqs).toEqual([]);
});
it('should throw on duplicate operation IDs', async () => {
const ops = [createTestOperation(), createTestOperation()];
// Insert first time - should succeed
const seqs1 = await service.appendBatch(ops, 'remote');
expect(seqs1.length).toBe(2);
// Insert same ops again - should throw
await expectAsync(service.appendBatch(ops, 'remote')).toBeRejectedWithError(
/Duplicate operation detected/,
);
// Original ops should still be in store
const allOps = await service.getOpsAfterSeq(0);
expect(allOps.length).toBe(2);
});
});
describe('getOpById', () => {

View file

@ -24,6 +24,7 @@ import {
OPS_INDEXES,
} from './db-keys.const';
import { runDbUpgrade } from './db-upgrade';
import { Log } from '../../core/log';
/**
* Vector clock entry stored in the vector_clock object store.
@ -274,6 +275,12 @@ export class OperationLogStoreService {
if (e instanceof DOMException && e.name === 'QuotaExceededError') {
throw new StorageQuotaExceededError();
}
// ConstraintError: duplicate operation ID - see issue #6213
if (e instanceof DOMException && e.name === 'ConstraintError') {
throw new Error(
'[OpLogStore] Duplicate operation detected (likely race condition). See #6213.',
);
}
throw e;
}
}
@ -320,7 +327,7 @@ export class OperationLogStoreService {
} catch (e) {
// Fallback for databases created before version 3 index migration
// This handles the case where the bySourceAndStatus index doesn't exist
console.warn(
Log.warn(
'OperationLogStoreService: bySourceAndStatus index not found, using fallback scan',
);
const allOps = await this.db.getAll(STORE_NAMES.OPS);
@ -707,7 +714,7 @@ export class OperationLogStoreService {
);
} catch (e) {
// Fallback for databases created before version 3 index migration
console.warn(
Log.warn(
'OperationLogStoreService: bySourceAndStatus index not found, using fallback scan',
);
const allOps = await this.db.getAll(STORE_NAMES.OPS);