mirror of
https://github.com/waynesutton/markdown-site.git
synced 2026-01-12 04:09:14 +00:00
- Add 10-second dedup window to heartbeat mutation for idempotency - Add frontend debouncing (5s) with refs to prevent duplicate calls - Track pending heartbeat state to block overlapping async calls - Early return when session already updated with same path Backend (convex/stats.ts): - Add HEARTBEAT_DEDUP_MS constant (10 seconds) - Check if session was recently updated before patching Frontend (src/hooks/usePageTracking.ts): - Add isHeartbeatPending, lastHeartbeatTime, lastHeartbeatPath refs - Wrap sendHeartbeat in useCallback with debounce logic - Import useCallback from React Fixes write conflict retries shown in Convex dashboard for stats:heartbeat
4.1 KiB
4.1 KiB
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:
- Backend: The heartbeat mutation queried for an existing session, then patched or inserted without checking if an update was actually needed
- Frontend: The
usePageTrackinghook 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:
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:
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: AddedHEARTBEAT_DEDUP_MSconstant and early return logicsrc/hooks/usePageTracking.ts: Added debouncing refs anduseCallbackwrapper
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/