mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-25 17:03:52 +00:00
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:
parent
bbe9b48097
commit
8f277adc60
2 changed files with 31 additions and 10 deletions
|
|
@ -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,
|
||||
}));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue