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.
Same drivers as today Every Gen 2 search reads the index No AWS change Firestore stays the fallback Rollback is one flag

Overview

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

Why production failed

Today one search reads the record of every driver in drivers:geo inside the radius, and nothing ever removes a driver from that set. The index holds only drivers who can take a trip now, so a search reads a few nearby drivers in about 2 commands.

Today
349reads per search

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

Average in Metro Manila with the legacy flag at 10%. All of them share one 300 ms timeout.

With the index
2commands per search

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

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

online, available, offers this service offline, on a trip, or other service pickup point search radius Dots show the idea, not real counts.

The commands one search runs, today and with the index

Today drivers:geo holds every driver who ever pinged, and the search has no COUNT, so all of them in the radius come back. Search GEOSEARCHdrivers:geo, no COUNT ~349 HGETALL per search With the index The index holds only drivers who can take a trip. COUNT is a page size, and the search reads more pages if needed. Search GEOSEARCH T:geo:<svc>COUNT is the page size HMGET T:cardcards not read yet ≈2.3 commandssame JS filters

What changes, and what stays the same

  • Riders and drivers. Search returns the same drivers as today. Each search does far less Redis work, so the 2026-10-30 overload does not repeat. The driver and rider apps do not change.
  • Backend. Each region gets an index in GCP Redis. The status writer and the location writer also update it, and a new repair worker fixes drift. All six Gen 2 search functions read it. Existing Redis keys, the AWS service and Gen 1 functions do not change.
  • Safety. If the index is unhealthy, search reads Firestore, as it does today.
  • Rollback is one flag. Set gcp-redis-driver-search-index to off and search runs today's code.

Read this if you are…

Words used on this page

Index
A small set of Redis keys per region that holds only drivers who can take a trip now: online, available, with a known position.
Region
The driver's region value, for example Metro Manila. Each region has its own index and its own flag rules.
Gen 2 search functions
The six Cloud Functions that search for drivers: onTripCreateV2, onTripUpdateV2, engine_queue_onUpdateV2, qrGen2 and v1Gen2, which serves v1Application.
Gen 1
The older qr and v1 functions. They have no GCP Redis access, stay on Firestore and do not change.
Heartbeat
A timestamp the reconciler writes when the index is healthy. Search uses the index only while the heartbeat is less than 180 seconds old.
Reconciler
A repair worker that compares the index with Firestore every 60 seconds, fixes differences and writes the heartbeat.
Fallback
What search does when the index cannot answer. Until Firestore locations are turned off, it reads Firestore, as today.
Flag on, shadow, off
The index read flag. on serves index results. shadow serves today's results and compares them with the index. off runs today's code.

How it works

Who writes the index, who reads it

Moving dots show data flow. Grey keys are the existing GCP Redis keys, which do not change. Every writer here writes GCP Redis.

Driver applocation pings, ~1K/s AWS firestore-syncwrites GCP Redis today:driver:{uid}drivers:geoand the writeback stream Firestorelocations/{uid} updateLocation MOVE: updates the positionstamps moverAt redissynclocations APPLY when status changesstamps writerAt driverIndexReconciler repair pass every 60 sreconcilePositions on a timerAWS stream loop, if builtwrites the heartbeat GCP Redis (Redis Cloud) Existing keys, not changed drivers:geo driver:{uid} location:firestore-writeback:updates Region slot{dsidx:v1:Metro%20Manila} any mctaxi delivery pabili tricycle hmdelivery car taxicab GEO sets: T:geo:any plus one per service T:carduid → {v, m, w, p, f}, the search fields T:metareconciledAt, the heartbeatwriterAt · moverAt Six Gen 2 search functions onTripCreateV2 · onTripUpdateV2engine_queue_onUpdateV2qrGen2 · v1Gen2 Firestore searchfallback until phase F GEOSEARCH + HMGET stale
location ping (MOVE) status change (APPLY) repair, positions, heartbeat search read dashed: read only, or fallback

Play one search

1
Index flag gcp-redis-driver-search-index
Read for the rider, the service and the region. off runs today's code. shadow serves today's result and compares. on uses the index and does not read the legacy flag.
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.
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
Pick a scenario to play it.

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.

For developers: 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. The fallback flag is always in production and staging.

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

Six Gen 2 functions search for drivers, and all six read 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 moduleindex off runs today's code Indexrollout steps P2 to P5 qrGen2 · v1Gen2GET /nearbyDriversQR booking search Generated copy of Gen 1 codea test keeps it equal tothe shared module Indexown rollout step P6 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.

For developers: 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 step P6.

Writers and safety

Every write first checks gcp-redis-driver-index for its region. If the flag is false or cannot be read, the write does nothing. No write ever throws an error to its caller.

APPLY: when a driver's status changes

Runs in redissynclocations, 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.

MOVE: when a driver's location updates

Runs in updateLocation, 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.

Reconciler: a repair pass on a fixed interval

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. Nothing changes on the AWS side.
  • 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

Turn parts off. The reconciler writes the heartbeat only if every condition holds, and search reads the index only while the heartbeat is fresh.

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 search path.

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. Hover a segment for its value.

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. Step P3 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 is one flag

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

Prove the repair worker runsStep R0
Deploy a reconciler stub. It must reach Firestore and Redis and keep its loops running for 10 minutes of reconcile logs.
Deploy everything, then turn on index writesSteps S1 to S3
Deploy every function, qrGen2 and v1Gen2 included.
Check the index stays correctStep 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.
Check the index finds the same driversStep S5
The real-Redis comparison in CI and shadow at 100% show 0 mismatches, qrGen2 and v1Gen2 included.
Check the loadStep 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.
Index on, QA signs offStep 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

Read-only preflightStep P0
Get 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.
Deploy with flags offStep P1
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.
Index writes on in Metro ManilaStep P2
gcp-redis-driver-index true for Metro Manila. The reconcile log shows low drift and a steady heartbeat.
Compare only in Metro ManilaStep P3
Read flag shadow, 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.
Index on in Metro ManilaStep P4
1%, 10%, 50%, then 100%, 30 minutes each, with Redis ops watched at each step.
Other regionsStep P5
Each region goes through steps P2 to P4. When a region reaches 100%, its legacy flag goes off.
qrGen2 and v1Gen2Step P6
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%.
Delete the legacy search codeCleanup
After every region is on the index.

Checks before production

These questions are still open. Each must be answered before the step named after it.

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 (step 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. Step P2.
  • The K_SERVICE value in each search function. From step S7, needed before step P3.
  • Is production Redis in cluster mode? Before step P6.
  • qrGen2 and v1Gen2 search rate and peak instances. Before step P6.

What QA tests

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

  • 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, cut from master, with a PR to master.
  • feature/gcp-redis-index, with PRs to the release branch.
  • feature/gcp-redis-index-staging, cut from new-staging, cherry-picking the same commits.
  • The Gen 1 driverSearch.util.js change, the re-slice and the sync test go to master only, because new-staging has no Gen 1 driverSearch.util.js.

Later: turning off Firestore locations

What turning off Firestore locations removes

Only the locations collection stops being written and read. Trips, region_configs and other config stay in Firestore. Three phases after step P6 remove the three ways search uses locations. Each phase has its own PR, gate and rollback.

Firestorelocations/{uid}used by search today Primary readthe query where the legacy flag is off Fallback readruns when Redis fails or finds no driver Write feedtrigger and reconciler copy it into Redis Removed in F Removed in F Removed in W2
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.