fix(stats): resolve write conflicts in activeSessions table

- 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
This commit is contained in:
Wayne Sutton
2025-12-15 12:52:34 -08:00
parent b280cb4605
commit 97081dc82d
5 changed files with 641 additions and 29 deletions

View File

@@ -624,6 +624,138 @@ Some write conflicts are expected and acceptable:
In these cases, ensure your mutations are idempotent and let Convex's retry mechanism handle the conflicts. Add user-facing retry logic if needed.
## App-Specific Patterns: Markdown Blog
This app uses specific patterns to avoid write conflicts in the `activeSessions` table for real-time visitor tracking.
### Heartbeat Mutation with Dedup Window
The `stats:heartbeat` mutation uses a 10-second dedup window to prevent rapid updates:
```typescript
// convex/stats.ts
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();
// Find existing session by sessionId using index
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 (idempotent)
if (
existingSession.currentPath === args.currentPath &&
now - existingSession.lastSeen < HEARTBEAT_DEDUP_MS
) {
return null;
}
// Patch directly with new data
await ctx.db.patch(existingSession._id, {
currentPath: args.currentPath,
lastSeen: now,
});
return null;
}
// Create new session only if none exists
await ctx.db.insert("activeSessions", {
sessionId: args.sessionId,
currentPath: args.currentPath,
lastSeen: now,
});
return null;
},
});
```
### Frontend Debouncing with Refs
The `usePageTracking` hook uses refs to prevent duplicate heartbeat calls:
```typescript
// src/hooks/usePageTracking.ts
const HEARTBEAT_DEBOUNCE_MS = 5 * 1000;
export function usePageTracking(): void {
const heartbeatMutation = useMutation(api.stats.heartbeat);
// 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 (debounce)
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],
);
// Send heartbeat on interval and path change
useEffect(() => {
const path = location.pathname;
sendHeartbeat(path);
const intervalId = setInterval(() => {
sendHeartbeat(path);
}, HEARTBEAT_INTERVAL_MS);
return () => clearInterval(intervalId);
}, [location.pathname, sendHeartbeat]);
}
```
### Key Patterns Used
1. **Backend idempotency**: Early return when session was updated within 10 seconds with same path
2. **Frontend debouncing**: 5-second debounce window using refs
3. **Pending state tracking**: `isHeartbeatPending` ref prevents overlapping calls
4. **Indexed queries**: Uses `by_sessionId` index for efficient lookups
5. **Event records pattern**: Page views use `pageViews` table instead of counters
## Summary
**Key Takeaway:** The less you read before writing, the fewer conflicts you'll have. Design your mutations to write directly when possible, and structure your data model to avoid concurrent writes to the same documents.
@@ -632,6 +764,8 @@ In these cases, ensure your mutations are idempotent and let Convex's retry mech
1. Patch directly without reading first (most common fix)
2. Use indexed queries to minimize read scope
3. Debounce rapid user inputs (300-500ms)
3. Debounce rapid user inputs (300-500ms for typing, 5s for heartbeats)
4. Use event records for high-frequency counters
5. Monitor your dashboard for conflict patterns
5. Add backend dedup windows for idempotency (10s for heartbeats)
6. Use refs to track pending mutations and prevent duplicates
7. Monitor your dashboard for conflict patterns