138 lines
4.4 KiB
Markdown
138 lines
4.4 KiB
Markdown
# OCC Conflict Resolution
|
|
|
|
Use these rules when insights, logs, or dashboard health show OCC (Optimistic
|
|
Concurrency Control) conflicts, mutation retries, or write contention on hot
|
|
tables.
|
|
|
|
## Core Principle
|
|
|
|
Convex uses optimistic concurrency control. When two transactions read or write
|
|
overlapping data, one succeeds and the other retries automatically. High
|
|
contention means wasted work and increased latency.
|
|
|
|
## Symptoms
|
|
|
|
- OCC conflict errors in deployment logs or health page
|
|
- Mutations retrying multiple times before succeeding
|
|
- User-visible latency spikes on write-heavy pages
|
|
- `npx convex insights --details` showing high conflict rates
|
|
|
|
## Common Causes
|
|
|
|
### Hot documents
|
|
|
|
Multiple mutations writing to the same document concurrently. Classic examples:
|
|
a global counter, a shared settings row, or a "last updated" timestamp on a
|
|
parent record.
|
|
|
|
### Broad read sets causing false conflicts
|
|
|
|
A query that scans a large table range creates a broad read set. If any write
|
|
touches that range, the query's transaction conflicts even if the specific
|
|
document the query cared about was not modified.
|
|
|
|
### Fan-out from triggers or cascading writes
|
|
|
|
A single user action triggers multiple mutations that all touch related
|
|
documents. Each mutation competes with the others.
|
|
|
|
Database triggers (e.g. from `convex-helpers`) run inside the same transaction
|
|
as the mutation that caused them. If a trigger does heavy work, reads extra
|
|
tables, or writes to many documents, it extends the transaction's read/write set
|
|
and increases the window for conflicts. Keep trigger logic minimal, or move
|
|
expensive derived work to a scheduled function.
|
|
|
|
### Write-then-read chains
|
|
|
|
A mutation writes a document, then a reactive query re-reads it, then another
|
|
mutation writes it again. Under load, these chains stack up.
|
|
|
|
## Fix Order
|
|
|
|
### 1. Reduce read set size
|
|
|
|
Narrower reads mean fewer false conflicts.
|
|
|
|
```ts
|
|
// Bad: broad scan creates a wide conflict surface
|
|
const allTasks = await ctx.db.query("tasks").collect();
|
|
const mine = allTasks.filter((t) => t.ownerId === userId);
|
|
```
|
|
|
|
```ts
|
|
// Good: indexed query touches only relevant documents
|
|
const mine = await ctx.db
|
|
.query("tasks")
|
|
.withIndex("by_owner", (q) => q.eq("ownerId", userId))
|
|
.collect();
|
|
```
|
|
|
|
### 2. Split hot documents
|
|
|
|
When many writers target the same document, split the contention point.
|
|
|
|
```ts
|
|
// Bad: every vote increments the same counter document
|
|
const counter = await ctx.db.get(pollCounterId);
|
|
await ctx.db.patch(pollCounterId, { count: counter!.count + 1 });
|
|
```
|
|
|
|
```ts
|
|
// Good: shard the counter across multiple documents, aggregate on read
|
|
const shardIndex = Math.floor(Math.random() * SHARD_COUNT);
|
|
const shardId = shardIds[shardIndex];
|
|
const shard = await ctx.db.get(shardId);
|
|
await ctx.db.patch(shardId, { count: shard!.count + 1 });
|
|
```
|
|
|
|
Aggregate the shards in a query or scheduled job when you need the total.
|
|
|
|
### 3. Move non-critical work to scheduled functions
|
|
|
|
If a mutation does primary work plus secondary bookkeeping (analytics,
|
|
non-critical notifications, cache warming), the bookkeeping extends the
|
|
transaction's lifetime and read/write set.
|
|
|
|
```ts
|
|
// Bad: canonical write and derived work happen in the same transaction
|
|
await ctx.db.patch(userId, { name: args.name });
|
|
await ctx.db.insert("userUpdateAnalytics", {
|
|
userId,
|
|
kind: "name_changed",
|
|
name: args.name,
|
|
});
|
|
```
|
|
|
|
```ts
|
|
// Good: keep the primary write small, defer the analytics work
|
|
await ctx.db.patch(userId, { name: args.name });
|
|
await ctx.scheduler.runAfter(0, internal.users.recordNameChangeAnalytics, {
|
|
userId,
|
|
name: args.name,
|
|
});
|
|
```
|
|
|
|
### 4. Combine competing writes
|
|
|
|
If two mutations must update the same document atomically, consider whether they
|
|
can be combined into a single mutation call from the client, reducing round
|
|
trips and conflict windows.
|
|
|
|
Do not introduce artificial locks or queues unless the above steps have been
|
|
tried first.
|
|
|
|
## Related: Invalidation Scope
|
|
|
|
Splitting hot documents also reduces subscription invalidation, not just OCC
|
|
contention. If a document is written frequently and read by many queries, those
|
|
queries re-run on every write even when the fields they care about have not
|
|
changed. See `subscription-cost.md` section 4 ("Isolate frequently-updated
|
|
fields") for that pattern.
|
|
|
|
## Verification
|
|
|
|
1. OCC conflict rate has dropped in insights or dashboard
|
|
2. Mutation latency is lower and more consistent
|
|
3. No data correctness regressions from splitting or scheduling changes
|
|
4. Sibling writers to the same hot documents were fixed consistently
|