Driver search on a per-region GCP Redis index

Each region gets a small Redis index of the drivers who can take a trip now, so one search costs a few Redis commands instead of hundreds.
Design approved. Not built yet. Next: deploy a reconciler stub in preprod Open: 13 checks before and during production

Overview

Redis commands per search
5,960 2.3
Prototype at 20,000 drivers
Data read per search
4,270 KB 29 KB
Prototype at 20,000 drivers
Redis ops per second, worst case
59K 18.1K
Metro Manila on the index. Safety margin 18.5K, plan limit 25K.
Results that differ from today
0
of 100 prototype searches against the legacy search

Same drivers, far fewer reads

Today's Redis search works. It runs at 100% in 12 regions and returns the right drivers in about 21 ms on average. Its cost is reads: it fetches every driver in the radius before it filters, so reads grow with driver density. In Metro Manila at 10%, that pushed Redis to 35–59K ops/s against a 25K plan. The index returns the same drivers from a few reads.

Today
349commands per search

1 GEOSEARCH, then HGETALL driver:{uid} for every driver in the radius, available or not.

Production average, Metro Manila with the legacy flag at 10%.

✓ Nearest 5 drivers

With the index
2.3commands per search

GEOSEARCH … COUNT n, then HMGET T:card for the nearest drivers only.

Prototype average. Offline drivers, drivers on a trip and drivers of other services are not in the set.

✓ Same nearest 5 drivers

online, available, offers this service offline, on a trip, or other service pickup point search radius

The commands one search runs

Todayreads every driver in the radius, then filters Search GEOSEARCHdrivers:geo, no COUNT about 349 HGETALL, one per driver JS filtersonline, free, service Nearest 5about 349 commands With the indexreads only drivers who can take a trip now Search GEOSEARCHT:geo:<svc>, COUNT n 1 HMGET T:card, one page JS filterssame filters Same 5about 2.3 commands next page only if too few pass

What ships

  • A per-region index in GCP Redis. APPLY (redissynclocations) and MOVE (updateLocation) write it. The new reconciler (driverIndexReconciler) repairs it and writes the heartbeat. Every Gen 2 search function reads it.
  • Not changed: the driver and rider apps, the existing Redis keys, the AWS firestore-sync service, and the Gen 1 qr and v1 functions.

How it works

Who writes the index, who reads it

Writers GCP Redis Search Driver apppings, about 1K/s Firestorelocations/{uid} MOVEupdateLocation APPLYredissynclocations reconcilerdriverIndexReconcilerrepair and heartbeat, 60 s AWS firestore-syncwrites positions today Region slot{dsidx:v1:<region>} GEO sets any mctaxi delivery pabili tricycle car hmdelivery taxicab T:carduid → card: v, m, w, p, f T:metareconciledAt: the heartbeatwriterAt · moverAt Existing keys, not changed drivers:geo driver:{uid} location:firestore-writeback:updates the writeback stream Every Gen 2 search onTripCreateV2onTripUpdateV2engine_queue_onUpdateV2qrGen2 · v1Gen2 Firestore searchwhen the index is stale eligible drivers reads positions stale
location ping (MOVE) status change (APPLY) reconciler: repair, positions, heartbeat search read AWS writes, not changed dashed: read only, or fallback

Play one search

read more Index flagon Heartbeatunder 180 s GEOSEARCH HMGET Index resultabout 2.3 commands FallbackFirestore
1
Index flag gcp-redis-driver-search-index
Read for the rider, the service and the region.
off · shadow · on
2
Heartbeat HGET T:meta reconciledAt
Cached per region for 10 s. If it is older than 180 s, missing or unreadable, or the write flag is false, the reason is index_stale.
≈0 commands
3
GEOSEARCH T:geo:<svc> … COUNT n
The page size n starts at 2 × limit + 5. A reply shorter than n means the radius has no more drivers.
1 command
4
HMGET T:card, then the same JS filters
Only cards not read yet. parseDriverHash and matchesFilters run unchanged, then the first limit drivers are kept.
1 command
5
Read more while short
If the page came back full and fewer than limit drivers passed, n grows by at least 1.5× and the search repeats steps 3 and 4. It stops when limit drivers pass or the reply is short.
no cap
The index answers in about 2.3 commands.

Fallback while Firestore is still written

When the index cannot answer, the fallback flag gcp-redis-driver-search-firestore-fallback decides what search returns. Production and staging use always. This table applies in index mode on. In shadow the caller always gets today's result.

Reasonalwayserror_onlynone
Index answeredindexindexindex
index_stale: write flag off, or heartbeat old, missing or unreadableFirestoreFirestoreFirestore
redis_error: GEOSEARCH or HMGET failedFirestoreFirestoreempty
no_candidates: no driver in the radiusFirestoreemptyempty
all_filtered: drivers found, none passedFirestoreemptyempty

A region the reconciler does not know has no heartbeat, so it reads Firestore. The fallback after Firestore is turned off is in Rollout.

COUNT is the page size, not a cap

Each GEOSEARCH asks for one page of n drivers. If too few of them pass the filters, the search reads a bigger page and repeats until it has limit drivers or the radius has no more. The index holds only drivers who can take a trip, so the result is the same as reading every driver in the radius.

The page keeps a typical search small. That matters for GET /nearbyDrivers, whose radius comes from the request with no upper limit.

With COUNT
15 to 37 KB
Without COUNT
≈49 KB

Data per search in Metro Manila, estimated. Without COUNT, one wide GET /nearbyDrivers call reads the whole region, about 2.9 MB. With COUNT it stops after one page when the filters pass.

Key layout and card fields

All keys of one region share one hash tag, so a Lua script can touch several of them on a Redis cluster.

T = {dsidx:v1:<encodeURIComponent(region)>}   region = the d.region value

T:geo:any     GEO   uid → lng,lat   online, status AVAILABLE (0), region R, position known
T:geo:<svc>   GEO   the same set, limited to drivers who offer svc
                    svc: mctaxi delivery pabili tricycle hmdelivery car taxicab (7 sets)
T:card        HASH  uid → JSON { v, m, w, p, f }
T:meta        HASH  reconciledAt, eligible, members, drift, cards   (reconciler)
                    writerAt   (APPLY from redissynclocations)
                    moverAt    (MOVE from updateLocation)

Card fields

vState version, Firestore updateTime in microseconds, 16 digits zero-padded. APPLY rejects an older version. wThe set mask the state wants. 0 means the driver is not eligible. mThe set mask applied. It stays 0 until a position is known. pThe last position: { t, lat, lng, f }, with t the ping time in ms. fThe search fields, string-encoded like driver:{uid}, so the existing parsers read them unchanged.

Superset rule

Set membership encodes only per-driver state: online, status, region and services. Seats, currentregion and required fields stay in the existing JS filters on the card.

An extra member costs a few bytes. A missing member would change results, so every rule leans toward including a driver. A driver who leaves keeps a 43-byte tombstone card, { v, m: 0, w: 0 }, so a late, older write is rejected.

Search and flags

Which flag answers

The index read flag takes precedence over the legacy flag.

onThe index answers. The legacy flag is not read.
shadowThe caller gets today's result: the legacy search in a legacy region, Firestore elsewhere. The index result is compared and logged.
offToday's behavior, unchanged.

Once a region runs the index at 100%, its legacy flag goes off. The legacy search code is deleted after every region is on the index.

Legacy flag in production today

gcp-redis-driver-search decides where each region searches today.

offMetro Manila does not use the legacy Redis search. It searches Firestore.
Ilocos NorteTuguegaraoGeneral SantosZamboanga PeninsulaMetro CebuBacolodPalawanMetro DavaoLeyte ProvinceBaguioIloiloRoxas Cagayan de Oro 80%Butuan-Surigao 80%Bukidnon 80%
on at 100% (12 regions)on at 80% (3 regions)

In staging the legacy flag is on for every region.

Flags

Both new flags go into flagsDefaultFalseMapping in the LaunchDarkly utils.

FlagValuesDefaultContextControls
gcp-redis-driver-search-index (new)off · shadow · onoffuser (rider key and service), regionThe index read path
gcp-redis-driver-index (new)true · falsefalseuser gcp_serverless_v1, regionEvery index write, the stream loop included
gcp-redis-driver-search-firestore-fallbackalways · error_only · nonealwaysuser, regionFirestore fallback. Read only in index mode on.
enable-dr-2780-driver-search-loggingtrue · falsefalseuser, regionThe Driver search source and Driver search shadow log lines
gcp-redis-driver-searchoff · shadow · onoffuser, regionThe legacy Redis search. Read only when the index mode is off or shadow.

The user context gets a service attribute set to K_SERVICE, the function's name. GET /nearbyDrivers and the assignment loops both pass a rider key, so a rule on user.service is what lets the assignment loops move to the index first while qrgen2 and v1gen2 stay off. Each can be rolled back on its own.

How each search function gets the index

Every Gen 2 search function reads the index. Gen 1 runs the same search code but stops at a guard, because it has no Redis URL.

Who searchesCode it runsReads from Trip assignmentengine_queue_onUpdateV2onTripCreateV2onTripUpdateV2 joyride-utils/driver-searchthe shared search module Indexas each region is turned on qrGen2 · v1Gen2GET /nearbyDriversQR booking search Generated copy of Gen 1 codea test keeps it equal tothe shared module Indexturned on last, after the others Gen 1 qr · v1not changed, no deploy GCP_REDIS_URL guardno Redis URL, so mode offbefore any flag is read Firestoreas today

On master, qrGen2 and v1Gen2 run a generated copy of the Gen 1 search file. On new-staging they import joyride-utils/driver-search, so preprod never runs the generated copy. Three checks on the release PR cover that gap. Only the read code is copied. The write code lives only in utils.

How the generated copy stays in step
  • The read code goes into two files with the same text: utils/driver-search/index.js and the Gen 1 file functions/api/utils/driverSearch.util.js. They differ only in three require lines and one guard.
  • The re-slice regenerates helpers/driverSearch.util.js and the extraction.json ledgers with analyze() from scripts/gen2-packages/source-slices.cjs.
  • Release PR check 1: a test that compares index results with the legacy search on a real Redis, run against the Gen 1 file. It must find 0 differences.
  • Release PR check 2: a sync test asserts that the two files stay equal once the require lines and the guard are normalized.
  • Release PR check 3: bash scripts/gen2-packages/linux.sh passes before merge, because no PR pipeline runs that drift check.
  • deploy.sh writes only GCP_REDIS_URL for qrGen2 and v1Gen2. If production Redis runs in cluster mode, it also needs GCP_REDIS_CLUSTER_ENABLED and GCP_REDIS_CLUSTER_NODES. That is checked before these two functions read the index.

Writers and safety

Every write checks gcp-redis-driver-index for its region first. False or unreadable: the write does nothing. No write throws to its caller.

MOVE
updateLocation
Each location ping, after the position is written
T:geoT:cardmoverAt
No card, or w = 0: skipOlder ping: skipInteger ms time, valid positionStamps moverAt every call
APPLY
redissynclocations
Each Firestore locations write
T:geoT:cardwriterAt
Older version: rejectedRegion change: old region tombstoned firstNever eligible: no Redis callOffline: leaves every set
reconciler
driverIndexReconciler
One pass every 60 s, one instance
T:geoT:cardreconciledAt
Firestore against the index, 200 at a timeAdds, fixes, removes, moves regionHeartbeat only on a healthy passWrite flag false: deletes T:meta
Positions from AWS
reconcilePositions
Own interval, POSITION_INTERVAL_MS
driver:{uid}→ MOVE
MOVE only where the hash is newerInterval 0: offStream loop only if AWS entries existStream loop: about 2 s
Region indexT:geo:anyT:geo:<svc>T:cardT:meta
All writer rules

MOVE (updateLocation)

  • Runs after the position is written.
  • Does nothing if the card is missing or w is 0. A late ping cannot bring back an offline driver.
  • Does nothing if the ping is older than the card's. Accepts only an integer ms time and a position that parses.
  • Stamps moverAt on every call, including calls that change nothing.

APPLY (redissynclocations)

  • Runs after each Firestore locations write.
  • Rejects a version older than the card. Re-applies an equal one.
  • On a region change, writes a tombstone in the old region first.
  • A driver not eligible before or after, in the same region, costs no Redis call.
  • An offline driver gets mask 0 and leaves every set.
  • Stamps writerAt.

reconciler (driverIndexReconciler)

  • A worker pool with one instance. One pass, then it sleeps for the rest of 60 s.
  • Compares the eligible drivers in Firestore with the index, 200 at a time. Adds missing drivers and fixes older or different cards.
  • Removes drivers who are in the index but no longer eligible, and moves drivers who changed region.
  • Writes the heartbeat only when the pass is healthy. If a region's write flag goes false, it deletes T:meta.

Positions from AWS

  • AWS firestore-sync already writes positions to GCP Redis but does not call MOVE.
  • reconcilePositions always runs. It lists the index members, reads their positions from driver:{uid}, and runs MOVE where the hash is newer. It runs on its own interval, POSITION_INTERVAL_MS. 0 turns it off.
  • The stream loop runs only if AWS entries exist. A read-only check of the writeback stream decides whether it is built. If built, it reads location:firestore-writeback:updates on its own connection and moves AWS drivers within about 2 s.

Try a missed deploy

The reconciler writes the heartbeat only while every condition holds. Search reads the index only while the heartbeat is fresh.

write flag reconciler APPLY MOVE pass, no errors stream loop, if built heartbeat search IndexT:geo and T:card Firestorereason index_stale
Heartbeat conditions
Index writes on for the region
A repair pass ran
The pass had no errors
writerAt less than 180 s old
moverAt less than 180 s old
Stream loop read in the last 30 s
Heartbeat written every pass
Search reads the index

What happens if a function misses its deploy

A missed deploy never breaks search. Each case ends on today's code or on Firestore.

Not redeployedEffectSearch reads
A joyride-utils search callerRuns the old search code, legacy flag onlysame as today
qrGen2 or v1Gen2Runs the old generated copy, legacy flag onlysame as today
Gen 1 api, qr, v1Never needs a deploy. The guard returns off.Firestore
redissynclocationsNo status writes to the index. writerAt goes stale and the heartbeat stops.Firestore
updateLocationNo MOVE. moverAt goes stale and the heartbeat stops.Firestore
ReconcilerNo heartbeat and no stream loop. Search sees index_stale.Firestore

Same as today means Firestore in Metro Manila and the legacy Redis search in the legacy regions.

Capacity

Redis ops per second with Metro Manila on the index at 100%, against the 25K plan

The first bar is the incident peak, with Metro Manila on the legacy search at 10%. The other bars are estimates from prototype costs and today's rates. Worst case counts every command inside a Lua script as one billed operation.

Show as table
BarBaselineSearchMOVEAPPLYRepair passPositionsTotal
Incident peak, Metro Manila at 10%legacy search reads59K
Typical3.3K2.28K1K0.1K0.15K06.8K
Worst case, positions every 60 s3.3K2.28K11K1.1K0.15K0.07K17.9K
Worst case, positions every 15 s3.3K2.28K11K1.1K0.15K0.29K18.1K

Search is 950 searches per second × 2.3 commands, plus at most 0.08K for searches that read to the end of the radius.

Safety margin rule

New load may use at most 70% of the room the plan has left: 0.7 × (25K − 3.3K) = 15.2K ops/s, so the total stays under 18.5K.

In the worst case the new load uses 14.6K to 14.8K of the 15.2K. That leaves room for about 165 to 260 searches per second from qrGen2 and v1Gen2. Their search rate is not known yet, so they move last, after their own check.

How Redis Cloud bills the commands inside a Lua script is checked in preprod before any production flag moves. In the typical accounting the room left is about 11.7K ops/s.

Other numbers

MOVE cost4 to 11 commands inside the script: 1K ops/s typical, 11K worst Long searchesA search that reads to the end of the radius: about 0.08K ops/s worst, included in Search Network out9.5 to 28 MB/s typical, plus long searches. The compare-only rollout step checks cardsRead. Hot slotThe prototype ran 35.4K ops/s at 0.53 CPU on the busiest slot. Production load is about a third of that. FirestoreAbout 6.2M reads per day for the Metro Manila repair pass, about $3.7 per day

Rollout

Rollback

At any step, one flag change rolls back: set the read flag gcp-redis-driver-search-index to off, or the write flag gcp-redis-driver-index to false. With both new flags off, every deploy changes nothing.

Preprod, from new-staging

  1. R0Reconciler stubNext
  2. S1–S3Deploy, writes on
  3. S4Index stays correct
  4. S5Same drivers
  5. S6Load
  6. S7Index on, QA signs off
Preprod step details
  • R0. Deploy a reconciler stub. It must reach Firestore and Redis and keep its loops running for 10 minutes of reconcile logs.
  • S1 to S3. Deploy every function, qrGen2 and v1Gen2 included, then turn on index writes.
  • S4. Status changes, region changes, offline drivers and positions reach the index. An AWS-shaped stream entry moves a test driver within 2 s. Stopping the stream loop stops the heartbeat within about 40 s.
  • S5. The real-Redis comparison in CI and shadow at 100% show 0 mismatches, qrGen2 and v1Gen2 included.
  • S6. cardsRead per search: p99 at most 500, maximum at most the region's eligible drivers. At most 5 commands per search on a dense test set.
  • S7. QA flows, GET /nearbyDrivers and one QR booking. The Driver search source log shows source: redis with the right user.service.

Production, release branch to master

  1. P0Preflight
  2. P1Deploy, flags off
  3. P2Writes on, Metro Manila
  4. P3Compare only, Metro Manila
  5. P4Index on, Metro Manila
  6. P5Other regions
  7. P6qrGen2, v1Gen2
  8. CleanupDelete legacy code
Production step details
  • P0. Read-only preflight: peak eligible Metro Manila drivers, ZCARD drivers:geo, the status-change rate and Lua billing. The PR must show new load at or below 15.2K, or the rollout stops.
  • P1. Deploy with flags off. Order: reconciler, updateLocation, redissynclocations, onTripCreateV2, onTripUpdateV2, engine_queue_onUpdateV2 (each shard), qrGen2, v1Gen2. Gate: the expected DD_VERSION on each function, the legacy flag unchanged, no {dsidx key in Redis.
  • P2. gcp-redis-driver-index true for Metro Manila. The reconcile log shows low drift and a steady heartbeat.
  • P3. Read flag shadow in Metro Manila, except qrgen2 and v1gen2. Gate over at least 2,000 searches: 0 mismatches, cardsRead p99 at most 500, tail network out at most 5 MB/s.
  • P4. Index on in Metro Manila at 1%, 10%, 50%, then 100%, 30 minutes each, with Redis ops watched at each step.
  • P5. Each other region goes through P2 to P4. When a region reaches 100%, its legacy flag goes off.
  • P6. qrGen2 and v1Gen2. First check the cluster keys, their search rate and their peak instances against the Redis connection limit. v1Gen2 allows up to 1,000 instances at concurrency 1. Then shadow, then on from 1% to 100%.
  • Cleanup. Delete the legacy search code after every region is on the index.

Checks before production

13 open. Each must be answered before the step named after it.

5before merge
4before any production flag
4during rollout
All 13 checks

Before the code merges

  • Does a worker pool reach Firestore and keep its loops running in preprod? Reconciler stub, 10 minutes of logs.
  • Does AWS add Metro Manila entries to the writeback stream, and how many? A read-only stream check. Decides whether the stream loop is built.
  • Does the trigger event carry updateTime? Unit test, then one preprod trigger.
  • Do region names in region_configs equal the driver's region value? Compare the values.
  • Does the repair query need a Firestore composite index? Run it once in preprod.

Before any production flag moves (P0)

  • How does Redis Cloud bill commands inside a Lua script? 1,000 MOVE calls on a quiet preprod database.
  • What time format does AWS write, and what share of Metro Manila pings come from AWS? firestore-sync source, Redis, Datadog.
  • How many Metro Manila drivers are eligible at peak, and how big is drivers:geo?
  • How many status changes per second does Metro Manila have after the skip rule? Cloud Monitoring at peak.

During the rollout

  • Firestore read cost of the repair pass, estimated at 6.2M reads per day. P2.
  • The K_SERVICE value in each search function. From S7, needed before P3.
  • Is production Redis in cluster mode? Before P6.
  • qrGen2 and v1Gen2 search rate and peak instances. Before P6.

What QA tests

Acceptance: with the index on, riders get the same drivers as today.

All 8 tests
  • Same drivers as today. The index and the legacy search return the same drivers for the same search, on a standalone Redis and on a cluster. 0 mismatches.
  • Far drivers are still found. 600 nearby drivers who fail the filters and 3 farther ones who pass return the same 3 drivers as today.
  • Busy areas stay cheap. A dense area with a huge radius stops after one page.
  • Status and region changes. Out-of-order and repeated updates, region changes, w = 0 and bad positions leave the index correct. A storm test checks that no eligible driver goes missing.
  • AWS positions. reconcilePositions moves a driver only when the hash is newer, and interval 0 turns it off. The stream loop moves AWS drivers, skips other entries, keeps the newest entry per driver, and a stalled loop stops the heartbeat.
  • Flag off is today. With the index flag off, search behaves exactly like today's code. With no GCP_REDIS_URL, the index flag is never read.
  • Flag context. The flag context carries service = K_SERVICE.
  • Manual QA in preprod. Standard QA flows, GET /nearbyDrivers and one QR booking with the index on.

Branches

  • release/gcp-redis-index from master, PR to master. feature/gcp-redis-index has PRs to it.
  • feature/gcp-redis-index-staging from new-staging, with the same commits cherry-picked.
  • The Gen 1 driverSearch.util.js change, the re-slice and the sync test go to master only. new-staging has no Gen 1 driverSearch.util.js.

Later: turning off Firestore locations

Only the locations collection stops being written and read. Trips, region_configs and other config stay in Firestore. Three phases after P6 each have their own PR, gate and rollback.

W1

Writers also write Redis

Removes: nothing yet

Gate: no Driver state not direct-written line for 7 days

W2

Redis becomes the source, per region

Removes: the write feed

Gate: writer list complete, clock skew under 500 ms, version source picked

F

Search stops reading Firestore

Removes: the primary read and the fallback read

Gate: auto-accept rate known, atomic claim, recovery plan after a Redis flush

Phase details
W1

Writers also write Redis directly

  • Removes: nothing yet. It proves the list of state writers is complete.
  • A new helper, setDriverState(uid, patch), writes driver:{uid}. Every state writer calls it after its Firestore write. The trigger still feeds the index.
  • The trigger logs Driver state not direct-written when it sees a change the helper did not write.
  • Read fallback: Firestore, as in the first release.
  • Gate to W2: no such log line for 7 days.
W2

Redis becomes the source, one region at a time

  • Removes: the write feed. In a direct region the helper writes the hash and the index, and redissynclocations skips both.
  • The flag gcp-redis-driver-state-source switches a region between trigger and direct. Rollback: set it back to trigger.
  • The reconciler stops reading Firestore. It checks members every 60 s and sweeps drivers:geo every 300 s.
  • Read fallback: Firestore, as in the first release.
  • Gate: the writer list is complete. The Firestore commit time and the Redis clock differ by less than 500 ms. A two-writer test with an injected delay has picked the version source: Redis TIME or a version stamped by the writer.
F

Search stops reading Firestore

  • Removes: the primary read and the fallback read. index_stale no longer forces Firestore, and the geofirestore query builders are deleted.
  • Read fallback: the stale index, else an empty result. See the table below.
  • Gate: the auto-accept search rate is known. Driver assignment has an atomic claim that does not need a Firestore transaction. There is a plan to restore online drivers after a Redis flush. No geofirestore .near( query on locations is left in the Gen 2 functions.

Read fallback in phase F

ReasonResult
Index answeredindex
index_staleindex, logged as stale
redis_error, no_candidates, all_filteredempty

A Datadog monitor watches heartbeat age. A stale index is better than an empty result, because by then the index is the only driver data left.

Never fall back to drivers:geo

That is the path that caused the incident: about 349 HGETALL calls per search. A region-wide fallback to it would repeat the incident.

AWS positions with Firestore off

The AWS writer already writes GCP Redis, and reconcilePositions reads driver:{uid}. Phase F needs no AWS change.