Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,5 @@ CLAUDE.md
codebase_audit.md
security_and_production_readiness.md
workout_templates_anatomy.md
MISSION.md
.agents/
39 changes: 39 additions & 0 deletions architecture_review.md
Original file line number Diff line number Diff line change
Expand Up @@ -359,3 +359,42 @@ A gamified pacing mechanic utilizing the existing `PersonalRecord[]` data.
- **Live Target:** When logging an exercise (e.g., Bench Press) or a run, the UI fetches and displays the user's historical PR as the "Ghost to beat".
- **Social Hype:** If the user logs a value that exceeds their ghost, it triggers a confetti/explosion animation locally.
- **Event Integration:** Hooked into the Upstash Redis SSE system to broadcast a special achievement toast to all friends: _"Adrian just shattered their Bench Press record!"_

---

## 07 — Network Loss Resilience (Deep Edge Cases)

A specialized architecture review was conducted to address edge cases around network timeouts, idempotency, and partial failures (e.g., when a request reaches the server and is committed, but the response is lost before reaching the client). The following 6 candidates were identified and slated for implementation:

### 1. Stable Idempotency Keys at the Seam (Strong)
- **Problem:** `WorkoutForm` and `RunForm` generate `crypto.randomUUID()` at the call-site on every submit. A network timeout followed by a user retry generates a *new* key, bypassing the database idempotency index and creating duplicate workouts.
- **Solution:** Allocate the idempotency key in React state (`useState`) when the form mounts. Only reset it upon a successful `201 Created` response.

### 2. Deepen `apiFetch` for Offline Resilience (Strong)
- **Problem:** `apiFetch` is a shallow wrapper around `fetch()`. A `TypeError: Failed to fetch` (offline) is thrown generically. There is no auto-retry mechanism.
- **Solution:** Deepen `apiFetch` to distinguish between `NetworkError` (e.g., offline) and `ServerError`. Add an automatic retry gate that only fires for `NetworkError`s on requests that provide an idempotency key.
- **Status:** ✅ COMPLETED

### 3. Graceful 409 Conflict Recovery (Strong)
- **Problem:** When the server correctly catches an idempotency duplicate (MongoDB error 11000), it throws a `ConflictError` which bubbles to the client as a generic red error message. The user thinks it failed, even though it succeeded.
- **Solution:** Add `findByIdempotencyKey` to the data layer. When `logWorkout` or `logRun` catch a duplicate key, they will fetch the existing entity and return it with a 200 OK (or 409 + body), allowing the frontend to treat it as a seamless success.
- **Status:** ✅ COMPLETED

### 4. Reliable Background Task Delivery (Strong)
- **Problem:** `after(evaluateAchievements(...))` is fire-and-forget. If the Vercel Function is killed mid-execution, the achievement is lost forever with no dead-letter queue.
- **Solution:** Keep `after()` for the fast-path, but add a reliable Vercel Cron sweep (`/api/cron/achievements-sweep`) that periodically re-evaluates locked achievements idempotently to ensure zero data loss.
- **Status:** ✅ COMPLETED

### 5. Atomic Quest Syncing (Strong)
- **Problem:** `syncUserQuests` uses a read-then-insert pattern (TOCTOU). Concurrent requests (e.g., logging a workout while claiming a quest) race to `bulkInsert` the same missing quests, causing an unhandled duplicate key crash.
- **Solution:** Refactor `syncUserQuests` to use MongoDB `bulkWrite` with `updateOne(..., { upsert: true, $setOnInsert })`. This makes the sync operation atomic and safe under high concurrency.
- **Status:** ✅ COMPLETED

---

## Future Features

### 6. Abort In-Flight Requests on Unmount
- **Problem:** `useEntityForm` does not cancel requests if the user navigates away. The delayed response triggers a `setState` on an unmounted component (memory leak) and invalidates caches unexpectedly.
- **Solution:** Thread an `AbortController` through `useEntityForm` and `apiFetch`. Abort the signal during the `useEffect` cleanup phase.
- **Status:** 🔜 FUTURE
23 changes: 23 additions & 0 deletions learning-records/0001-network-resilience-concurrency.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Learning Record: Network Resilience & Concurrency

**Date:** July 2026

## What was learned

We tackled a series of complex, real-world distributed systems problems in the FitLevelUp codebase.

### 1. The Two-General's Problem (Idempotency)
When an API request is sent, there are three points of failure: the request dropping, the server crashing, or the response dropping. If the response drops, the client thinks the request failed and retries, but the server actually processed it.
* **Insight:** Generating an `idempotencyKey` on the client when a form mounts (using `useState`) guarantees that retries are identifiable. The database acts as the ultimate source of truth by enforcing a unique index on this key.

### 2. Graceful Conflict Recovery
When the database catches an idempotency violation (MongoDB error 11000), throwing a generic 500 or 409 error is a bad user experience.
* **Insight:** Since the request is a duplicate, it means the operation actually succeeded previously! We should catch the error, fetch the *existing* document, and return it with a 200 OK. The frontend treats it as a success.

### 3. Time-Of-Check to Time-Of-Use (TOCTOU)
Reading a value from the database and then inserting it a millisecond later is inherently dangerous under high concurrency.
* **Insight:** The database engine is the only layer capable of atomic guarantees. By eliminating the "read" step and using `bulkWrite` with `updateOne({ upsert: true, $setOnInsert })`, we pushed the concurrency control down to MongoDB, completely eliminating the race condition.

### 4. Serverless "Lost Events"
Fire-and-forget background tasks (like Next.js `after()`) are fast for the user but dangerous if the serverless container is killed prematurely.
* **Insight:** Background queues need a reliable safety net. A daily Cron sweep that idempotently re-evaluates missed tasks ensures eventual consistency with zero data loss.
104 changes: 104 additions & 0 deletions lessons/0001-building-network-resilience.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Lesson 1: Bulletproofing APIs</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 800px;
margin: 0 auto;
padding: 2rem;
background-color: #fcfcfc;
}
h1, h2 { color: #111; }
.card {
background: white;
border: 1px solid #eaeaea;
border-radius: 8px;
padding: 1.5rem;
margin: 1.5rem 0;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05);
}
code {
background: #f4f4f4;
padding: 0.2rem 0.4rem;
border-radius: 4px;
font-size: 0.9em;
color: #d63200;
}
pre code {
display: block;
padding: 1rem;
overflow-x: auto;
color: #333;
}
.quiz {
background: #eef2ff;
border: 1px solid #c7d2fe;
border-radius: 8px;
padding: 1.5rem;
margin: 2rem 0;
}
button {
background: #4f46e5;
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
}
button:hover { background: #4338ca; }
</style>
</head>
<body>
<h1>Lesson 1: Building Bulletproof APIs</h1>
<p>Welcome to your first lesson on Network Resilience! We're going to explore the core concepts we just implemented in the FitLevelUp codebase.</p>

<div class="card">
<h2>Concept 1: The Idempotency Key</h2>
<p>Imagine you're buying a TV online. You click "Buy", but your train enters a tunnel and your phone loses signal. You panic and click "Buy" again. Did you just buy two TVs?</p>
<p>An <strong>idempotent</strong> operation is one that produces the same result whether you run it once or a thousand times. To achieve this, the client generates a unique ID (an Idempotency Key) <em>before</em> sending the request.</p>
<pre><code>// In React:
const [key] = useState(() => crypto.randomUUID());</code></pre>
<p>Because it's in <code>useState</code>, the key stays the exact same even if the component re-renders or the network request retries. The database enforces uniqueness on this key.</p>
</div>

<div class="card">
<h2>Concept 2: TOCTOU (Time Of Check To Time Of Use)</h2>
<p>A classic concurrency bug happens when you read a value, make a decision, and then write a value. If two requests run at the exact same millisecond, they both read the old value and overwrite each other.</p>
<p>The solution? Never read. Let the database do the work atomically.</p>
<pre><code>// Bad (TOCTOU Race Condition):
const exists = await db.find(quest);
if (!exists) await db.insert(quest);

// Good (Atomic):
await db.updateOne(quest, { upsert: true, $setOnInsert: quest });</code></pre>
</div>

<div class="card">
<h2>Concept 3: Cron Sweepers (The Safety Net)</h2>
<p>Serverless functions (like Vercel) are fast, but they have a strict timeout. If you use fire-and-forget background tasks (like <code>after()</code>), they might get killed halfway through.</p>
<p>By adding a daily Cron Job that looks at "recently active users" and safely re-evaluates their data, we guarantee that no data is permanently lost due to a server restart.</p>
</div>

<div class="quiz">
<h2>Knowledge Check</h2>
<p>What should the server return if it catches an idempotency duplicate (Error 11000)?</p>
<div id="options">
<button onclick="alert('Incorrect! Throwing an error makes the frontend think the request failed, resulting in a bad UX.')">Throw a 500 Server Error</button>
<button onclick="alert('Incorrect! While technically a conflict, showing a red error to the user for a successful retry is confusing.')">Throw a 409 Conflict Error</button>
<button onclick="alert('Correct! The request actually succeeded previously, so we fetch the existing document and return it as a success!')">Fetch it and return 200 OK</button>
</div>
</div>

<h3>Next Steps</h3>
<p>We've successfully made our API resilient to network drops and concurrent requests. Read more about <a href="https://stripe.com/docs/api/idempotent_requests" target="_blank">Stripe's implementation of Idempotency</a>, which is the industry gold standard.</p>

<p><em>Remember: If you have any questions, you can always ask me!</em></p>
</body>
</html>
Loading
Loading