toeverything/AFFiNE · error · Error
Too many concurrent writings
Error message
Too many concurrent writings
What it means
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.
Source
Thrown at packages/backend/server/src/core/doc/adapters/workspace.ts:362
});
}
return !!updatedSnapshot;
} catch (e) {
metrics.doc.counter('snapshot_upsert_failed').add(1);
this.logger.error('Failed to upsert snapshot', e);
throw new FailedToUpsertSnapshot();
}
}
protected override async lockDocForUpdate(
workspaceId: string,
docId: string
) {
const lock = await this.mutex.acquire(`doc:update:${workspaceId}:${docId}`);
if (!lock) {
throw new Error('Too many concurrent writings');
}
return lock;
}
protected async lastDocHistory(workspaceId: string, id: string) {
return this.models.history.getLatest(workspaceId, id);
}
}
View on GitHub (pinned to 26c515e050)
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.
Example fix
// before
const lock = await this.mutex.acquire(`doc:update:${workspaceId}:${docId}`);
if (!lock) {
throw new Error('Too many concurrent writings');
}
// after — retry with jitter, then degrade to a typed conflict error
let lock = await this.mutex.acquire(`doc:update:${workspaceId}:${docId}`);
for (let attempt = 0; !lock && attempt < 3; attempt++) {
await sleep((1 << attempt) * 50 + Math.random() * 30);
lock = await this.mutex.acquire(`doc:update:${workspaceId}:${docId}`);
}
if (!lock) {
throw new DocUpdateConflict(`doc ${docId} is busy, retry later`);
} Defensive patterns
Strategy: retry
Validate before calling
// Before issuing an update, probe lock availability cheaply (best-effort).
// True prevention is rate-limiting writers per docId.
async function canLikelyAcquire(mutex, ws, doc) {
// non-blocking probe: acquire+release immediately
const probe = await mutex.acquire(`doc:update:${ws}:${doc}`);
if (probe) { await probe.release?.(); return true; }
return false;
} Type guard
function isLockBusyError(e: unknown): boolean {
return e instanceof Error && e.message === 'Too many concurrent writings';
} Try / catch
try {
await docService.updateDoc(ws, doc, md);
} catch (e) {
if (isLockBusyError(e)) {
await sleep(backoffMs(attempt)); // exponential + jitter
continue;
}
throw e;
} Prevention
- 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.
When it happens
Trigger: 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.
Common situations: 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.
Related errors
- Invalid database index: ${db}, must be between 0 and 11
- invalid_app_config_input
- reply_not_found
- Too many concurrent writings
- internal_server_error
AI-assisted analysis of toeverything/AFFiNE@26c515e050 (2026-08-12).
Data as JSON: /api/errors/6199493c152b1e05.
Report an issue: GitHub.