mirror of
https://github.com/waynesutton/markdown-site.git
synced 2026-01-11 20:08:57 +00:00
145 lines
4.1 KiB
Markdown
145 lines
4.1 KiB
Markdown
|
|
# How to Avoid Write Conflicts in Markdown Blog
|
||
|
|
|
||
|
|
This document explains the write conflict issue that occurred in the `activeSessions` table and how it was resolved.
|
||
|
|
|
||
|
|
## Problem
|
||
|
|
|
||
|
|
The Convex dashboard showed write conflicts in the `stats:heartbeat` mutation affecting the `activeSessions` table. Conflicts were happening every minute with retry counts of 0 and 1, indicating parallel mutations were competing to update the same documents.
|
||
|
|
|
||
|
|
## Root Cause
|
||
|
|
|
||
|
|
The original implementation had two issues:
|
||
|
|
|
||
|
|
1. **Backend**: The heartbeat mutation queried for an existing session, then patched or inserted without checking if an update was actually needed
|
||
|
|
2. **Frontend**: The `usePageTracking` hook could send duplicate heartbeats when:
|
||
|
|
- Path changed (immediate heartbeat + interval overlap)
|
||
|
|
- React StrictMode caused double effect invocations
|
||
|
|
- Multiple tabs were open
|
||
|
|
|
||
|
|
## Solution
|
||
|
|
|
||
|
|
### Backend Changes (convex/stats.ts)
|
||
|
|
|
||
|
|
Added a 10-second dedup window to make the heartbeat mutation idempotent:
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
const HEARTBEAT_DEDUP_MS = 10 * 1000;
|
||
|
|
|
||
|
|
export const heartbeat = mutation({
|
||
|
|
args: {
|
||
|
|
sessionId: v.string(),
|
||
|
|
currentPath: v.string(),
|
||
|
|
},
|
||
|
|
returns: v.null(),
|
||
|
|
handler: async (ctx, args) => {
|
||
|
|
const now = Date.now();
|
||
|
|
|
||
|
|
const existingSession = await ctx.db
|
||
|
|
.query("activeSessions")
|
||
|
|
.withIndex("by_sessionId", (q) => q.eq("sessionId", args.sessionId))
|
||
|
|
.first();
|
||
|
|
|
||
|
|
if (existingSession) {
|
||
|
|
// Early return if same path and recently updated
|
||
|
|
if (
|
||
|
|
existingSession.currentPath === args.currentPath &&
|
||
|
|
now - existingSession.lastSeen < HEARTBEAT_DEDUP_MS
|
||
|
|
) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
await ctx.db.patch(existingSession._id, {
|
||
|
|
currentPath: args.currentPath,
|
||
|
|
lastSeen: now,
|
||
|
|
});
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
await ctx.db.insert("activeSessions", {
|
||
|
|
sessionId: args.sessionId,
|
||
|
|
currentPath: args.currentPath,
|
||
|
|
lastSeen: now,
|
||
|
|
});
|
||
|
|
|
||
|
|
return null;
|
||
|
|
},
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
### Frontend Changes (src/hooks/usePageTracking.ts)
|
||
|
|
|
||
|
|
Added debouncing with refs to prevent duplicate mutation calls:
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
const HEARTBEAT_DEBOUNCE_MS = 5 * 1000;
|
||
|
|
|
||
|
|
// Track heartbeat state to prevent duplicate calls
|
||
|
|
const isHeartbeatPending = useRef(false);
|
||
|
|
const lastHeartbeatTime = useRef(0);
|
||
|
|
const lastHeartbeatPath = useRef<string | null>(null);
|
||
|
|
|
||
|
|
const sendHeartbeat = useCallback(
|
||
|
|
async (path: string) => {
|
||
|
|
const sessionId = sessionIdRef.current;
|
||
|
|
if (!sessionId) return;
|
||
|
|
|
||
|
|
const now = Date.now();
|
||
|
|
|
||
|
|
// Skip if heartbeat is already pending
|
||
|
|
if (isHeartbeatPending.current) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Skip if same path and sent recently
|
||
|
|
if (
|
||
|
|
lastHeartbeatPath.current === path &&
|
||
|
|
now - lastHeartbeatTime.current < HEARTBEAT_DEBOUNCE_MS
|
||
|
|
) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
isHeartbeatPending.current = true;
|
||
|
|
lastHeartbeatTime.current = now;
|
||
|
|
lastHeartbeatPath.current = path;
|
||
|
|
|
||
|
|
try {
|
||
|
|
await heartbeatMutation({ sessionId, currentPath: path });
|
||
|
|
} catch {
|
||
|
|
// Silently fail
|
||
|
|
} finally {
|
||
|
|
isHeartbeatPending.current = false;
|
||
|
|
}
|
||
|
|
},
|
||
|
|
[heartbeatMutation]
|
||
|
|
);
|
||
|
|
```
|
||
|
|
|
||
|
|
## Key Patterns Applied
|
||
|
|
|
||
|
|
| Pattern | Implementation | Purpose |
|
||
|
|
|---------|---------------|---------|
|
||
|
|
| Backend idempotency | 10-second dedup window | Skip updates if recently updated with same data |
|
||
|
|
| Frontend debouncing | 5-second debounce with refs | Prevent rapid duplicate calls |
|
||
|
|
| Pending state tracking | `isHeartbeatPending` ref | Block overlapping async calls |
|
||
|
|
| Indexed queries | `by_sessionId` index | Efficient document lookup |
|
||
|
|
|
||
|
|
## Files Changed
|
||
|
|
|
||
|
|
- `convex/stats.ts`: Added `HEARTBEAT_DEDUP_MS` constant and early return logic
|
||
|
|
- `src/hooks/usePageTracking.ts`: Added debouncing refs and `useCallback` wrapper
|
||
|
|
|
||
|
|
## Monitoring
|
||
|
|
|
||
|
|
Check the Convex dashboard at Health > Insights > Insight Breakdown to verify:
|
||
|
|
|
||
|
|
- Write conflict retries should drop to near zero
|
||
|
|
- Function latency should stabilize
|
||
|
|
- No permanent failures in error logs
|
||
|
|
|
||
|
|
## References
|
||
|
|
|
||
|
|
- Convex Write Conflicts: https://docs.convex.dev/error#1
|
||
|
|
- Optimistic Concurrency Control: https://docs.convex.dev/database/advanced/occ
|
||
|
|
- Convex Best Practices: https://docs.convex.dev/understanding/best-practices/
|
||
|
|
|