diff --git a/packages/super-sync-server/src/sync/sync.service.ts b/packages/super-sync-server/src/sync/sync.service.ts index d309705fa3..fd4e1b3c88 100644 --- a/packages/super-sync-server/src/sync/sync.service.ts +++ b/packages/super-sync-server/src/sync/sync.service.ts @@ -322,20 +322,39 @@ export class SyncService { } catch (err) { // Transaction failed - all operations were rolled back const errorMessage = (err as Error).message || 'Unknown error'; - Logger.error(`Transaction failed for user ${userId}: ${errorMessage}`); + + // Check if this is a serialization failure (concurrent transaction conflict) + // Prisma uses P2034 for "Transaction failed due to a write conflict or a deadlock" + // PostgreSQL uses 40001 (serialization_failure) and 40P01 (deadlock_detected) + const isSerializationFailure = + (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2034') || + errorMessage.includes('40001') || + errorMessage.includes('40P01') || + errorMessage.toLowerCase().includes('serialization') || + errorMessage.toLowerCase().includes('deadlock'); // Check if this is a timeout error (common for large payloads) const isTimeout = - errorMessage.toLowerCase().includes('timeout') || errorMessage.includes('P2028'); // Prisma transaction timeout error code + errorMessage.toLowerCase().includes('timeout') || errorMessage.includes('P2028'); + + if (isSerializationFailure) { + Logger.warn( + `Transaction serialization failure for user ${userId} - client should retry: ${errorMessage}`, + ); + } else { + Logger.error(`Transaction failed for user ${userId}: ${errorMessage}`); + } // Mark all "successful" results as failed due to transaction rollback - // Include enough detail for client to determine if retry is appropriate + // Use INTERNAL_ERROR for all transient failures - client will retry return ops.map((op) => ({ opId: op.id, accepted: false, - error: isTimeout - ? 'Transaction timeout - server busy, please retry' - : `Transaction rolled back: ${errorMessage}`, + error: isSerializationFailure + ? 'Concurrent transaction conflict - please retry' + : isTimeout + ? 'Transaction timeout - server busy, please retry' + : `Transaction rolled back: ${errorMessage}`, errorCode: SYNC_ERROR_CODES.INTERNAL_ERROR, })); } diff --git a/src/app/core/persistence/operation-log/sync/lock.service.ts b/src/app/core/persistence/operation-log/sync/lock.service.ts index a72267d787..a1844e9957 100644 --- a/src/app/core/persistence/operation-log/sync/lock.service.ts +++ b/src/app/core/persistence/operation-log/sync/lock.service.ts @@ -14,8 +14,8 @@ import { IS_ANDROID_WEB_VIEW } from '../../../../util/is-android-web-view'; * using Promise-based mutual exclusion. This prevents concurrent operations within * the same tab but cannot protect against multi-tab scenarios. * - * Note: Locking is skipped entirely for Electron and Android WebView since they - * are inherently single-instance environments. + * Electron and Android WebView use the fallback mutex since they are single-instance + * but still need in-process locking for concurrent code paths. */ @Injectable({ providedIn: 'root' }) export class LockService { @@ -25,9 +25,11 @@ export class LockService { private _fallbackLocks = new Map>(); async request(lockName: string, callback: () => Promise): Promise { - // Electron and Android WebView are single-instance by design - skip locking + // Electron and Android WebView are single-instance (no multi-tab), but still need + // in-process locking to prevent concurrent code paths (e.g., ImmediateUploadService + // and main sync running simultaneously). Use fallback mutex for these. if (IS_ELECTRON || IS_ANDROID_WEB_VIEW) { - return callback(); + return this._fallbackRequest(lockName, callback); } if (!navigator.locks) {