{"record":{"id":"6199493c152b1e05","repo":"toeverything/AFFiNE","slug":"too-many-concurrent-writings-619949","errorCode":null,"errorMessage":"Too many concurrent writings","messagePattern":"Too many concurrent writings","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"packages/backend/server/src/core/doc/adapters/workspace.ts","lineNumber":362,"sourceCode":"        });\n      }\n\n      return !!updatedSnapshot;\n    } catch (e) {\n      metrics.doc.counter('snapshot_upsert_failed').add(1);\n      this.logger.error('Failed to upsert snapshot', e);\n      throw new FailedToUpsertSnapshot();\n    }\n  }\n\n  protected override async lockDocForUpdate(\n    workspaceId: string,\n    docId: string\n  ) {\n    const lock = await this.mutex.acquire(`doc:update:${workspaceId}:${docId}`);\n\n    if (!lock) {\n      throw new Error('Too many concurrent writings');\n    }\n\n    return lock;\n  }\n\n  protected async lastDocHistory(workspaceId: string, id: string) {\n    return this.models.history.getLatest(workspaceId, id);\n  }\n}\n","sourceCodeStart":344,"sourceCodeEnd":372,"githubUrl":"https://github.com/toeverything/AFFiNE/blob/26c515e050211269e911f7d9cfe162a26c83ed98/packages/backend/server/src/core/doc/adapters/workspace.ts#L344-L372","documentation":"Thrown by lockDocForUpdate when the distributed mutex cannot acquire the per-doc update lock (`doc:update:<workspaceId>:<docId>`). The underlying Mutex.acquire already retries (MUTEX_RETRY times with MUTEX_WAIT backoff) and only returns undefined when every attempt fails — i.e. another writer holds the lock for the whole retry window, or the locker backend (Redis) is unreachable. The Error is a plain `new Error('Too many concurrent writings')`, not a typed exception, so callers must match on message text or wrap the call.","triggerScenarios":"Two or more clients (collaborative editors, the sync server, or a rollback/import job) call updateDoc/pushDocUpdates/rollbackDoc on the same docId concurrently and one writer holds the lock longer than MUTEX_RETRY*MUTEX_WAIT. Also fires when the Redis/redlock backend is down or partitioned, because acquire() then returns undefined for every key, not just contended ones.","commonSituations":"A flaky or saturated Redis (the locker backend) making every lock acquisition time out. A long-running migration or batch re-import that updates the same doc in parallel workers. Collaborative editing under heavy load where one client is slow to release. A wedged lock left behind by a crashed worker that never sent DEL/EXPIRE.","solutions":["Check Redis connectivity and health from the server host (redis-cli ping, memory/latency) — a sick Redis makes acquire() return undefined for ALL keys, which is the most common root cause.","Reduce write parallelism against the same docId: serialize updates per doc (queue or single-writer) so the lock is held briefly and released between operations.","Confirm MUTEX_WAIT / MUTEX_RETRY tuning matches your locker's TTL; if the protected work legitimately exceeds the window, raise the wait or shorten the critical section.","If a lock is wedged (crashed holder), verify the locker uses a TTL-based lock and that stale keys are expiring (Redis KEYS doc:update:* / TTL inspection).","Catch at the API boundary and surface a 409/429-style 'doc busy, retry' to the client with backoff rather than a 500."],"exampleFix":"// before\nconst lock = await this.mutex.acquire(`doc:update:${workspaceId}:${docId}`);\nif (!lock) {\n  throw new Error('Too many concurrent writings');\n}\n\n// after — retry with jitter, then degrade to a typed conflict error\nlet lock = await this.mutex.acquire(`doc:update:${workspaceId}:${docId}`);\nfor (let attempt = 0; !lock && attempt < 3; attempt++) {\n  await sleep((1 << attempt) * 50 + Math.random() * 30);\n  lock = await this.mutex.acquire(`doc:update:${workspaceId}:${docId}`);\n}\nif (!lock) {\n  throw new DocUpdateConflict(`doc ${docId} is busy, retry later`);\n}","handlingStrategy":"retry","validationCode":"// Before issuing an update, probe lock availability cheaply (best-effort).\n// True prevention is rate-limiting writers per docId.\nasync function canLikelyAcquire(mutex, ws, doc) {\n  // non-blocking probe: acquire+release immediately\n  const probe = await mutex.acquire(`doc:update:${ws}:${doc}`);\n  if (probe) { await probe.release?.(); return true; }\n  return false;\n}","typeGuard":"function isLockBusyError(e: unknown): boolean {\n  return e instanceof Error && e.message === 'Too many concurrent writings';\n}","tryCatchPattern":"try {\n  await docService.updateDoc(ws, doc, md);\n} catch (e) {\n  if (isLockBusyError(e)) {\n    await sleep(backoffMs(attempt)); // exponential + jitter\n    continue;\n  }\n  throw e;\n}","preventionTips":["Serialize writes per docId at the application layer (one writer queue per doc).","Monitor Redis latency; sick Redis surfaces as this error across all docs.","Keep the locked critical section short — compute the delta outside the lock where possible.","Surface this as 409/429 to clients, not 500, so they retry correctly."],"tags":["concurrency","locking","redis","doc-update","distributed-lock"],"backgroundTag":null,"analyzedSha":"26c515e050211269e911f7d9cfe162a26c83ed98","analyzedAt":"2026-08-12T13:15:16.447Z","schemaVersion":2},"datasetVersion":"2026-08-12T23:17:12.415Z"}