Cache postal code → ward lookups instead of re-resolving each one - #64
Cache postal code → ward lookups instead of re-resolving each one#64mikaalnaik wants to merge 1 commit into
Conversation
Every ward lookup was a full two-hop origin round trip. york_factory answers each one `Cache-Control: no-store` and the proxy forwarded that verbatim, so an answer that depends only on the postal code — and changes at most once per boundary import — was recomputed for every submit, including one a visitor had just tried. The proxy now decides caching rather than forwarding it: - Next's data cache on the upstream fetch, so the second visitor to type a code never reaches york_factory. - Cache-Control by outcome: a year for malformed input (a pure function of the string), a day for a resolved ward, an hour for an unknown code (a real one may land in a later import), and no-store for a boundary data outage — a state to retry, not an answer to keep. - One cache key per code: "m4c1s9", "M4C-1S9" and "M4C 1S9" collapse to the same entry. Anything that isn't a full postal code still goes upstream as typed, so york_factory keeps distinguishing "not a postal code" from "not in our data". Client-side, the lookup fires as soon as the field holds a complete postal code, so the answer is usually on screen before the visitor reaches the button, and results are held for the session so correcting a typo back to a code already tried costs nothing. A request-sequence guard keeps a slow earlier reply from overwriting a later one. Measured against the real upstream, a repeat lookup goes from 752ms to 3ms. Verified against a stub that all three spellings of one code produce a single upstream hit, since production york_factory currently returns boundary_data_unavailable for every Toronto code and can't exercise the resolved path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Greptile SummaryThe PR adds normalized, outcome-sensitive caching to the ward-lookup proxy and debounced session caching to the Toronto ward lookup UI.
Confidence Score: 3/5The PR should not merge until cached transient outages remain retryable and cache hits correctly invalidate older in-flight lookups. The new client cache can preserve boundary-data outages for an entire session, and its early-return path allows older requests to replace the result for the postal code currently shown in the input. Files Needing Attention: src/components/elections/WardLookup.tsx
|
| Filename | Overview |
|---|---|
| src/app/api/elections/ward-lookup/route.ts | Adds postal-code canonicalization and layered caching with response headers selected from the upstream outcome. |
| src/components/elections/WardLookup.tsx | Adds debounced automatic lookup and session caching, but transient outage results persist and cached lookups fail to invalidate older requests. |
Sequence Diagram
sequenceDiagram
participant U as Visitor
participant C as WardLookup UI
participant P as Proxy route
participant Y as york_factory
U->>C: Enter complete postal code
C->>C: Check session cache
alt Cache miss
C->>P: GET ward lookup
P->>Y: Normalized lookup with revalidation
Y-->>P: Outcome
P-->>C: Outcome-specific Cache-Control
C->>C: Store response in session cache
else Cache hit
C->>C: Display cached response
end
Prompt To Fix All With AI
### Issue 1
src/components/elections/WardLookup.tsx:72-73
**Transient outages persist in session**
When the API returns `boundary_data_unavailable`, the unconditional cache write stores that transient outage in the module-scoped map, causing every retry for the postal code to keep showing "We can't look that up right now" for the remainder of the session even after the service recovers.
```suggestion
const result: WardLookupResponse = await res.json();
if (result.reason !== "boundary_data_unavailable") {
cache.set(normalize(typed) ?? typed, result);
}
```
### Issue 2
src/components/elections/WardLookup.tsx:57-63
**Cache hits bypass sequence guard**
When an uncached lookup remains in flight and the visitor enters a cached postal code, the cached branch returns without advancing `latest.current`; the earlier request therefore still passes the sequence guard and overwrites the current result with another postal code's ward or an error.
```suggestion
const request = ++latest.current;
const cached = cache.get(normalize(typed) ?? typed);
if (cached) {
setState({ status: "done", result: cached });
return;
}
```
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.Reviews (1): Last reviewed commit: "Cache postal code → ward lookups instead..." | Re-trigger Greptile
| const result: WardLookupResponse = await res.json(); | ||
| cache.set(normalize(typed) ?? typed, result); |
There was a problem hiding this comment.
Transient outages persist in session
When the API returns boundary_data_unavailable, the unconditional cache write stores that transient outage in the module-scoped map, causing every retry for the postal code to keep showing "We can't look that up right now" for the remainder of the session even after the service recovers.
| const result: WardLookupResponse = await res.json(); | |
| cache.set(normalize(typed) ?? typed, result); | |
| const result: WardLookupResponse = await res.json(); | |
| if (result.reason !== "boundary_data_unavailable") { | |
| cache.set(normalize(typed) ?? typed, result); | |
| } |
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/components/elections/WardLookup.tsx
Line: 72-73
Comment:
**Transient outages persist in session**
When the API returns `boundary_data_unavailable`, the unconditional cache write stores that transient outage in the module-scoped map, causing every retry for the postal code to keep showing "We can't look that up right now" for the remainder of the session even after the service recovers.
```suggestion
const result: WardLookupResponse = await res.json();
if (result.reason !== "boundary_data_unavailable") {
cache.set(normalize(typed) ?? typed, result);
}
```
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| const cached = cache.get(normalize(typed) ?? typed); | ||
| if (cached) { | ||
| setState({ status: "done", result: cached }); | ||
| return; | ||
| } | ||
|
|
||
| const request = ++latest.current; |
There was a problem hiding this comment.
Cache hits bypass sequence guard
When an uncached lookup remains in flight and the visitor enters a cached postal code, the cached branch returns without advancing latest.current; the earlier request therefore still passes the sequence guard and overwrites the current result with another postal code's ward or an error.
| const cached = cache.get(normalize(typed) ?? typed); | |
| if (cached) { | |
| setState({ status: "done", result: cached }); | |
| return; | |
| } | |
| const request = ++latest.current; | |
| const request = ++latest.current; | |
| const cached = cache.get(normalize(typed) ?? typed); | |
| if (cached) { | |
| setState({ status: "done", result: cached }); | |
| return; | |
| } |
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/components/elections/WardLookup.tsx
Line: 57-63
Comment:
**Cache hits bypass sequence guard**
When an uncached lookup remains in flight and the visitor enters a cached postal code, the cached branch returns without advancing `latest.current`; the earlier request therefore still passes the sequence guard and overwrites the current result with another postal code's ward or an error.
```suggestion
const request = ++latest.current;
const cached = cache.get(normalize(typed) ?? typed);
if (cached) {
setState({ status: "done", result: cached });
return;
}
```
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.
The ward lookup on
/toronto/votewas slow because nothing was cached anywhere. york_factory answers every lookupCache-Control: no-storeand our proxy forwarded that verbatim — so an answer that depends only on the postal code, and changes at most once per boundary import, was recomputed on every submit. The route's comment claimed the TTL "varies by outcome on purpose," but upstream doesn't do that today.What changed
src/app/api/elections/ward-lookup/route.ts— the proxy now decides caching rather than forwarding it:next: { revalidate }on the upstream fetch, so the second visitor to type a code never reaches york_factoryCache-Controlby outcome — a year for malformed input (a pure function of the string), a day for a resolved ward, an hour for an unknown code (a real one may land in a later import),no-storefor a boundary data outage, which is a state to retry rather than an answer to keepm4c1s9,M4C-1S9andM4C 1S9collapse to the same entry. Anything that isn't a full postal code still goes upstream as typed, so york_factory keeps distinguishing "not a postal code" from "not in our data"src/components/elections/WardLookup.tsx— fires as soon as the field holds a complete postal code (200ms debounce), so the answer is usually on screen before the visitor reaches the button. Results are held for the session, so correcting a typo back to a code already tried costs nothing, and a request-sequence guard keeps a slow earlier reply from overwriting a later one.Testing
upstream_hits=1, a genuinely new code incremented it, andresolvedcarrieds-maxage=86400. The stub was necessary because production york_factory can't currently returnresolved— see belowtsc --noEmitandeslintcleanTwo things reviewers should know
The lookup is currently broken upstream, independently of this PR. Production york_factory returns
boundary_data_unavailablefor every real Toronto postal code, so the box shows "We can't look that up right now." This PR makes it fast; it can't make it answer. The ward boundary geometries need loading in the warehouse.Cloudflare won't honour
s-maxageon an API path without a Cache Rule. The Next data cache layer works regardless, so the win holds either way, but a Cache Rule on/api/elections/ward-lookupwould push these to the edge and make the first lookup fast too.🤖 Generated with Claude Code