Files
wiki/prds/howtoavoidwriteconflicts.md
Wayne Sutton 97081dc82d 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
2025-12-15 12:52:34 -08:00

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:

  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:

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: 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