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

  1. 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.
  2. 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.
  3. 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.
  4. 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).
  5. 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

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


AI-assisted analysis of toeverything/AFFiNE@26c515e050 (2026-08-12). Data as JSON: /api/errors/6199493c152b1e05. Report an issue: GitHub.