mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-23 23:02:17 +10:00
225 lines
9.9 KiB
Markdown
225 lines
9.9 KiB
Markdown
# Plan 005: Deduplicate public-resume view-count writes
|
||
|
||
> **Executor instructions**: Follow step by step. Run every verification and
|
||
> confirm before moving on. Honor "STOP conditions". When done, update the
|
||
> status row in `plans/README.md`.
|
||
>
|
||
> **Drift check (run first)**:
|
||
> `git diff --stat 73daf22b2..HEAD -- packages/api/src/features/resume/service.ts packages/api/src/features/resume/access-policy.ts`
|
||
> If either changed since this plan, compare "Current state" excerpts against
|
||
> live code; on mismatch, STOP.
|
||
|
||
## Status
|
||
|
||
- **Priority**: P2
|
||
- **Effort**: M
|
||
- **Risk**: MED
|
||
- **Depends on**: 001 recommended (its Step 5 test characterizes
|
||
`statistics.increment`, giving you a safety net that this plan changes the
|
||
*caller*, not the write body).
|
||
- **Category**: perf
|
||
- **Planned at**: commit `73daf22b2`, 2026-07-08
|
||
|
||
## Why this matters
|
||
|
||
Every public-resume view triggers a database write. `getBySlug`
|
||
(`packages/api/src/features/resume/service.ts:475`) calls
|
||
`statistics.increment(...)` whenever `shouldCountForStatistics` is true, and
|
||
`increment` (lines 199–237) runs a transaction with **two** `INSERT ... ON
|
||
CONFLICT DO UPDATE` statements (`resumeStatistics` + `resumeStatisticsDaily`).
|
||
This fires on every non-owner load — bots, crawlers, and refreshes included.
|
||
TanStack Query's 60s `staleTime` only suppresses within-session refetches; it
|
||
does nothing for distinct sessions or server-side crawlers. A popular public
|
||
resume therefore drives continuous write traffic to two tables for what is
|
||
functionally the same view counted many times.
|
||
|
||
This plan adds a short-window per-viewer dedup so a burst of views from the
|
||
same client within a window counts once, cutting write volume without changing
|
||
what the counters mean to users (roughly "unique-ish views per window").
|
||
|
||
## Current state
|
||
|
||
- `packages/api/src/features/resume/service.ts:475` — `getBySlug` receives
|
||
`input.requestHeaders: Headers`, resolves the resume, and at lines 505–507:
|
||
|
||
```ts
|
||
if (shouldCountForStatistics(resume, viewer)) {
|
||
await resumeService.statistics.increment({ id: resume.id, views: true });
|
||
}
|
||
```
|
||
|
||
- `packages/api/src/features/resume/access-policy.ts:76` —
|
||
`shouldCountForStatistics(resume, viewer)` decides *whether* a view counts
|
||
(e.g. skip the owner). This plan adds an orthogonal *"have we already counted
|
||
this viewer recently?"* gate; it does **not** change `shouldCountForStatistics`.
|
||
|
||
- `increment` (`service.ts:199-237`) — the two-table transactional write. **Do
|
||
not change its body**; this plan changes only whether it is called.
|
||
|
||
- **Reusable client-identity helper**: `packages/utils/src/rate-limit.ts`
|
||
exports `TRUSTED_IP_HEADERS` (the ordered list of proxy IP headers the app
|
||
trusts). The rate-limit middleware already derives a client key from these
|
||
headers the same way. Reuse `TRUSTED_IP_HEADERS` to read the viewer IP from
|
||
`input.requestHeaders`; fall back to a `user-agent`-based key when no trusted
|
||
IP header is present (mirror the middleware's fallback so behavior is
|
||
consistent).
|
||
|
||
- **Environment**: `REDIS_URL` exists in `packages/env/src/server.ts` and
|
||
`turbo.json` globalEnv, but the app runs as a single Node process by default
|
||
and the existing rate limiter uses an **in-memory** store
|
||
(`@orpc/experimental-ratelimit/memory`). Match that: an in-memory TTL cache
|
||
is the right default here.
|
||
|
||
## Commands you will need
|
||
|
||
| Purpose | Command | Expected |
|
||
|-----------|--------------------------------------------------------------------|-----------|
|
||
| Typecheck | `pnpm --filter @reactive-resume/api typecheck` | exit 0 |
|
||
| Test | `pnpm --filter @reactive-resume/api test -- resume` | all pass |
|
||
| All api | `pnpm --filter @reactive-resume/api test` | all pass |
|
||
|
||
(Do NOT run `pnpm check`.)
|
||
|
||
## Scope
|
||
|
||
**In scope**:
|
||
- A new small helper module, e.g.
|
||
`packages/api/src/features/resume/view-dedup.ts` (create) — an in-memory
|
||
TTL set keyed by `${resumeId}:${clientKey}` with a `shouldCountView(...)`
|
||
function that returns `true` at most once per key per window.
|
||
- `packages/api/src/features/resume/view-dedup.test.ts` (create)
|
||
- `packages/api/src/features/resume/service.ts` — gate the `increment` call
|
||
(lines 505–507) on the new helper.
|
||
- `plans/README.md` (status row)
|
||
|
||
**Out of scope**:
|
||
- `increment`'s write body and the DB schema — unchanged.
|
||
- `shouldCountForStatistics` in `access-policy.ts` — unchanged.
|
||
- Any Redis / distributed-cache implementation — an in-memory window is the
|
||
agreed default; a distributed store is a documented future upgrade, not this
|
||
plan.
|
||
- Download counting (`downloads: true`) — this plan is about view writes only;
|
||
do not alter download increments.
|
||
|
||
## Git workflow
|
||
|
||
- Branch: `advisor/005-stats-view-dedup`
|
||
- Commit: `perf(api): dedup public-resume view increments within a short window`
|
||
- Do NOT push or open a PR unless instructed.
|
||
|
||
## Steps
|
||
|
||
### Step 1: Write the dedup helper
|
||
|
||
Create `view-dedup.ts` exporting a pure-ish, testable function. Suggested
|
||
shape (keep it small — this is not a cache library):
|
||
|
||
```ts
|
||
// ponytail: in-memory per-process dedup window. Single-instance is the default
|
||
// deploy; for multi-instance, swap the Map for a Redis SETNX+EXPIRE keyed the
|
||
// same way (REDIS_URL already exists in env). Upgrade only if you scale out.
|
||
const WINDOW_MS = 60 * 60 * 1000; // 1 hour
|
||
const seen = new Map<string, number>(); // key -> expiry timestamp
|
||
|
||
export function shouldCountView(key: string, now: number): boolean {
|
||
const expiry = seen.get(key);
|
||
if (expiry !== undefined && expiry > now) return false;
|
||
seen.set(key, now + WINDOW_MS);
|
||
return true;
|
||
}
|
||
|
||
export function clientKeyFromHeaders(headers: Headers): string { /* uses TRUSTED_IP_HEADERS, UA fallback */ }
|
||
```
|
||
|
||
Take `now` as a parameter (don't call `Date.now()` inside the predicate) so the
|
||
test can drive the clock deterministically. Add a lightweight size guard: if
|
||
`seen.size` exceeds a cap (e.g. 50_000), prune entries whose expiry has passed
|
||
before inserting, so the Map can't grow unbounded. Reuse `TRUSTED_IP_HEADERS`
|
||
from `@reactive-resume/utils/rate-limit` inside `clientKeyFromHeaders`.
|
||
|
||
**Verify**: `pnpm --filter @reactive-resume/api typecheck` → exit 0.
|
||
|
||
### Step 2: Test the helper
|
||
|
||
Create `view-dedup.test.ts`:
|
||
- `shouldCountView(key, t)` returns `true` the first time, `false` for the same
|
||
key within the window, and `true` again once `now` is past the window.
|
||
- Two different keys are independent.
|
||
- `clientKeyFromHeaders` derives distinct keys for distinct trusted-IP headers
|
||
and a stable key for the UA fallback when no IP header is present.
|
||
|
||
**Verify**: `pnpm --filter @reactive-resume/api test -- view-dedup` → all pass.
|
||
|
||
### Step 3: Gate the increment call
|
||
|
||
In `service.ts` `getBySlug`, wrap the existing count so it fires only when both
|
||
the policy allows it AND the viewer hasn't been counted this window:
|
||
|
||
```ts
|
||
if (shouldCountForStatistics(resume, viewer)) {
|
||
const key = `${resume.id}:${clientKeyFromHeaders(input.requestHeaders)}`;
|
||
if (shouldCountView(key, Date.now())) {
|
||
await resumeService.statistics.increment({ id: resume.id, views: true });
|
||
}
|
||
}
|
||
```
|
||
|
||
Do not change anything else in `getBySlug`.
|
||
|
||
**Verify**: `pnpm --filter @reactive-resume/api typecheck` → exit 0.
|
||
|
||
### Step 4: Confirm no regression in existing resume tests
|
||
|
||
**Verify**: `pnpm --filter @reactive-resume/api test -- resume` → all pass. In
|
||
particular, if Plan 001's `statistics.increment` characterization test exists,
|
||
it should still pass (this plan didn't touch `increment`'s body). If a
|
||
`getBySlug` test asserts increment-on-view, it may now need the test to pass a
|
||
fresh header/clock so the first view still counts — update it to reflect the
|
||
dedup (first view counts, immediate repeat does not).
|
||
|
||
## Test plan
|
||
|
||
- `view-dedup.test.ts`: window behavior + key derivation (Step 2 cases).
|
||
- If an existing `getBySlug` test asserts view counting, extend it: first call
|
||
with a given client key increments; an immediate second call with the same
|
||
key does not; a call with a different key does.
|
||
- Verification: `pnpm --filter @reactive-resume/api test` → all pass.
|
||
|
||
## Done criteria
|
||
|
||
Machine-checkable. ALL must hold:
|
||
|
||
- [ ] `packages/api/src/features/resume/view-dedup.ts` and its `.test.ts` exist
|
||
- [ ] `pnpm --filter @reactive-resume/api test -- view-dedup` passes with ≥ 4 cases
|
||
- [ ] `grep -n "shouldCountView" packages/api/src/features/resume/service.ts` shows the gate around the increment call
|
||
- [ ] `pnpm --filter @reactive-resume/api typecheck` exits 0
|
||
- [ ] `pnpm --filter @reactive-resume/api test` exits 0
|
||
- [ ] `git status --porcelain` lists only in-scope files + `plans/README.md`
|
||
- [ ] `plans/README.md` status row for 005 updated
|
||
|
||
## STOP conditions
|
||
|
||
Stop and report if:
|
||
|
||
- `getBySlug` no longer calls `statistics.increment` at lines ~505–507, or its
|
||
signature no longer exposes `requestHeaders` — the code drifted.
|
||
- You cannot derive a client key from `input.requestHeaders` because headers
|
||
aren't actually populated at this layer in practice (check a real request
|
||
path / existing rate-limit middleware usage) — report it; a dedup keyed on an
|
||
empty header is worthless.
|
||
- You find that view counting must remain exact (product decision that every
|
||
raw hit counts) — the dedup changes counter semantics slightly; if unsure,
|
||
stop and confirm before shipping.
|
||
|
||
## Maintenance notes
|
||
|
||
- **Semantics change**: counters become "unique-ish views per 1h window per
|
||
client" rather than "raw hits". Document this near the helper. If the product
|
||
wants raw hit counts back, this gate is the single place to remove.
|
||
- **Multi-instance ceiling**: the in-memory Map is per-process. If the app is
|
||
ever horizontally scaled, each instance dedups independently (still a large
|
||
reduction, but not global). The ponytail comment names Redis as the upgrade
|
||
path; do it only if scale-out happens.
|
||
- Reviewer should confirm `Date.now()` is only called at the call site (Step 3),
|
||
not inside the predicate, so the helper stays testable.
|