fix(sync): prevent concurrent uploads causing transaction rollback

Root cause: On Android, LockService was skipping locks entirely, allowing
ImmediateUploadService and main sync to upload the same operations
concurrently. This caused PostgreSQL serialization failures under
REPEATABLE_READ isolation.

Fixes:
1. LockService: Use fallback mutex on Electron/Android instead of no
   locking. These are single-instance but still need in-process locking
   for concurrent code paths.

2. Server: Add detection for serialization failures (Prisma P2034,
   PostgreSQL 40001/40P01). These are logged as warnings with clearer
   error messages for retry.

Combined with the previous commit that treats INTERNAL_ERROR as
transient (not permanent rejection), these errors will now be
automatically retried on next sync.
This commit is contained in:
Johannes Millan 2025-12-24 16:43:03 +01:00
parent bbe9b48097
commit 8f277adc60
2 changed files with 31 additions and 10 deletions

View file

@ -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,
}));
}

View file

@ -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<string, Promise<void>>();
async request(lockName: string, callback: () => Promise<void>): Promise<void> {
// 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) {