mirror of
https://github.com/waynesutton/markdown-site.git
synced 2026-01-12 04:09:14 +00:00
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user