fix: stop re-searching Google Maps on every nearby-search request - #448
Merged
Conversation
Production logged "the number of results from redis is 0" on nearly every request, each followed by a full Google Maps fan-out. Two defects compounded. The newer categories were never wired like Visit. EncodeNearbySearchRedisKey appended a price segment for Eatery only, so Shopping/Lodging/Wellness got one price-agnostic bucket — but Get/SetMapsLastSearchTime special-cased only Visit when dropping that segment, leaving those three with a price-scoped marker over a price-agnostic bucket. Worse, the marker was keyed on country:admin1:city while the bucket it guards is a geo index read from the caller's exact coordinates. Trip planning searches from a city centroid so the two roughly agreed; the merchant endpoint, the only caller of the new categories, searches from the user's precise location. A suburban user read "Shopping in Los Angeles is fresh" against a 498-member bucket with nothing inside 16km. And a zero-result read could never be suppressed: the gate required len(savedPlaces) > 0, so the marker was consulted only alongside a non-empty result. Every such request fell through to Google, re-stamped the marker, and repeated on the next request — forever rather than once. Fixes, in the order they matter: - One price-agnostic bucket per category, mirroring placeIDs:visit. The eatery price split fragmented the index for nothing: Google omits price_level for most places (so they collapsed into level0) and only accepts a price filter at level >= 3, so searches for levels 0-2 issued identical requests yet each read back a fifth of the data. Callers already filter on price after the read. - The marker is scoped to the external SEARCH, keyed on an ~8km location cell matching ColdStartSearchRadius: <cell>:<category>, plus :pricey<N> for eatery levels 3-4, the only levels where Google applies a real price filter. Levels 0-2 share one field, removing two of every three eatery fan-outs. Visit needing no price segment now falls out of the rule instead of being an exception the newer categories were never added to. - A fresh marker is honoured at zero results, with a 24h window instead of 14 days so a newly built-out area is retried within a day. The marker stamp moves after a successful search, or a failed one would silence retries for that day. - Place Details, the dominant cost of a cold search at one call per place, is no longer re-bought for places already stored. The placesToUpdate hook was stubbed with a "placeholder" comment; it is now populated from a pipelined cache lookup, filtered before the DetailsLimit cap so the budget goes to places we lack, and gated on LastUpdatedAt so records cannot freeze. restoreCachedDetails fills gaps only, since the write path is a blind upsert and a skipped place would otherwise overwrite the record it was skipped for. - Read amplification: one pipelined fetch instead of a GET per member, a GEORADIUS COUNT bound, and no more req.Radius write-back. The union/merge-sort path and AllPriceLevels are deleted along with the price split. - removePlace ZREMmed "placeIDs:eatery:"+priceLevel — missing the "level" prefix writes used — and never touched the three newer buckets, so it deleted records and orphaned their geo members. It now iterates POI.AllPlaceCategories through the shared encoder. GetPlaceCountByCategory had the same bug, which is why /stats/places always reported zero eateries. GET /v1/migrate/union-eatery-buckets merges the legacy keys, dry-run by default. AGGREGATE MIN is mandatory: a GEO member's score IS its geohash, and the SUM that redis.ZStore defaults to would relocate any place present in two buckets. Run it BEFORE deploying — it is additive and invisible to the running code, whereas deploying first points every eatery read at a key that does not exist yet. Verified against local dev data: 111 members merged, 0 of 111 coordinates drifted, reads returning 49 places at every price level where they previously returned 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WHNC45T5vRTKuEmifJANCG
tim-eternos
self-requested a review
July 30, 2026 21:47
The runbook said to run the migration before deploying, but the migration endpoint is part of the code being deployed — it does not exist until the deploy that introduces it, so it cannot be the pre-deploy step. Production returns the 404 page for /v1/migrate/union-eatery-buckets today, which is how this surfaced. The pre-deploy step is a plain redis-cli ZUNIONSTORE; no application code is involved. The endpoint's value is dry-run reporting and post-deploy verification. Steps now spell out the six-key form (the target is included so the command is idempotent), the coordinate spot-check, and the rollback. Also corrects an overstatement: the marker re-key forces one cold search per occupied cell per category regardless of migration order. Running the union first protects the existing eatery members from being unreadable in the interim, which is a smaller and more accurate claim. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WHNC45T5vRTKuEmifJANCG
placeIDs:eatery already existed in production with 7,113 members — a pre-2023 artefact from before 20c1eb7 introduced the price split, when eateries were written to that un-suffixed name. Nothing has read it since, so this change making it the live read key would have silently revived three-year-old data. 4,634 of those members exist in no level* bucket, and a 300-sample found 286 of 287 parsed records carry no Types field at all, so neither ReclassifyForCategory nor the purge migration can audit them — both keep no-Types records by design. Spot checks included a horse racing track filed as an eatery. The pre-deploy union therefore takes five source keys and overwrites the target, yielding exactly the 8,500 members already served. The six-key form is for post-deploy re-runs only, where an overwrite would drop members the new write path has written. Since five-key overwrites, the runbook now takes a COPY backup first. Also documents the 34 members found in two price buckets: AGGREGATE MIN picks one of two real positions, which can differ from the place_details coordinates by tens of metres. Verified 33 exact and one 82 m difference already present in the sources — pre-existing inconsistency, not migration damage. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WHNC45T5vRTKuEmifJANCG
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Production logged
the number of results from redis is 0on nearly every request, each followed by a full Google Maps fan-out. The cache wasn't warming — it was being bypassed. Two defects compounded.The newer categories were never wired like Visit.
EncodeNearbySearchRedisKeyappended a price segment for Eatery only, so Shopping/Lodging/Wellness got one price-agnostic bucket — butGet/SetMapsLastSearchTimespecial-cased onlyPlaceCategoryVisitwhen dropping that segment. Those three ended up with a price-scoped marker over a price-agnostic bucket. Visit had neither segment, Eatery had both; the newer categories were the only ones where the two disagreed.Worse, the marker was keyed on
country:admin1:citywhile the bucket it guards is a geo index read from the caller's exact coordinates. Trip planning searches from a city centroid, so the two roughly agreed. The merchant endpoint — the only caller of the new categories — searches from the user's precise location (planner.go:1402-1405). A suburban user read "Shopping in Los Angeles is fresh" against a 498-member bucket with nothing inside 16 km. These categories are 30–120× sparser than the old ones (Shopping 498 / Wellness 319 / Lodging 123 vs Visit 14,674 / Eatery 8,589), so they hit it constantly.A zero-result read could never be suppressed. The gate required
len(savedPlaces) > 0, so the marker was only ever consulted alongside a non-empty result. Every such request fell through to Google, re-stamped the marker, and repeated on the very next request — forever rather than once.Separately, the eatery price split cost three identical Google searches per area: Google omits
price_levelfor most places (so they collapsed intolevel0) and only accepts a price filter at level ≥ 3, so levels 0/1/2 issued identical requests yet each read back a fifth of the data — whilesolver.go:527was already filtering on price after the read.Changes
placeIDs:visit.EncodeNearbySearchRedisKey(cat)drops thelevelparameter rather than ignoring it, so the compiler walked every call site.ColdStartSearchRadius:<cell>:<category>, plus:pricey<N>for eatery levels 3–4 — the only levels wherePriceyEaterymakes Google apply a real price filter at 4× radius. Levels 0–2 share one field. Visit needing no price segment now falls out of the rule instead of being an exception. Brand markers get the same treatment; they had the identical bug.searchErrreturn — required, since a failed search would otherwise silence retries for a day.placesToUpdatehook was stubbed with a literal// placeholder for filtering places that do no need updates; it's now populated from a pipelined cache lookup, filtered before theDetailsLimitcap so the budget goes to places we lack, and gated onLastUpdatedAt(90 d) so records can't freeze.restoreCachedDetailsfills gaps only — the write path is a blind upsert, so a skipped place would otherwise overwrite the record it was skipped for.GETper member (reusing the existinggetPlacesPipelined), aGEORADIUS COUNTbound, no morereq.Radiuswrite-back. The union/merge-sort path andAllPriceLevelsare deleted with the price split.removePlaceZREMmed"placeIDs:eatery:"+priceLevel— missing thelevelprefix writes used — and never touched the three newer buckets, so it deleted records and orphaned their geo members (~328 recorded in the runbook). It now iteratesPOI.AllPlaceCategoriesthrough the shared encoder.GetPlaceCountByCategoryhad the same bug, which is why/stats/placesalways reported zero eateries.Migration — run BEFORE deploying
GET /v1/migrate/union-eatery-buckets, dry-run unlessapply=true. Runbook:docs/migrations/collapse-eatery-buckets.md.It's additive and invisible to the running code; deploying first would point every eatery read at a key that doesn't exist yet.
AGGREGATE MINis mandatory — a GEO member's score is its 52-bit geohash, and theSUMthatredis.ZStore.Aggregatedefaults to would add the scores of any place present in two buckets and relocate it into the ocean.Marker fields also re-key, so expect one cold search per occupied cell per category after deploy. Geo buckets are untouched, so reads return the full member set immediately.
Verification
gofmt -l .clean,go vet ./...clean,go test ./... -count=1passing.Also driven against local dev data that happened to be in the exact pre-migration state:
expected_after=111→target_after=111, exact matchNew tests cover the invariant that broke (bucket key identical across all price levels), marker collapse and pricey separation, cell sharing at ~2 km and splitting at ~20 km, the freshness gate including the empty-result case,
AGGREGATE MINpreserving coordinates, migration re-runnability,removePlaceclearing every bucket, and the Details skip (zero calls when all cached, refresh when stale, nil lookup unchanged, no record degradation, budget spent on unstored).Notes for review
CreatePlacebackfills every blank weekday, so hours are never empty and the check would always pass; and the Nearby response carries its ownFormattedAddress.URLhas exactly one source — a Details result — so it's the whole list (detailsSourcedFields).PoiSearcher.mapsClientis the concrete*MapsClient. The existingSearchClientinterface is the natural seam — left as follow-up rather than faked.redis_client.gohot-spins whenreq.Radius == 0(0 * 2 == 0, andMinNumResults > 0means the break never fires). Reachable unauthenticated viagetOptimalPlan, which writes a 400 but never returns. I touched that loop forCOUNTand pipelining but deliberately left the termination bug alone.Hash/tokenHashnaming —NewPATsetsHash: tokenand keys onpat_hash:<token>, and validation looks up the rawAuthorizationvalue. Worth its own issue.🤖 Generated with Claude Code
https://claude.ai/code/session_01WHNC45T5vRTKuEmifJANCG