Donor Search
The donor search workflow finds eligible donors near a blood request and sends them notifications in waves. It keeps going until enough donors accept or the retry budget runs out.
EventBridge Scheduler triggers the donorSearch lambda one wave at a
time. Locations are indexed on a hexagonal grid using Uber H3 (h3-js).
High-level flow
A donation request is created or updated in DynamoDB.
DynamoDB Stream → EventBridge Pipe sends the change (
PK,SK,h3Res5,h3Res8,status,eventName) to thedonationRequestInitiatorlambda.The initiator writes a
DonorSearchrecord and submits the firstDonorSearchSchedulerAttributespayload to EventBridge Scheduler.The scheduler runs the
donorSearchlambda afterinitialWaveDelaySeconds.The wave loop repeats: the lambda walks one batch of H3 ring cells, notifies any eligible donors, and then either schedules the next wave or stops.
Components
core/services/aws/donorSearch/donationRequestInitiator.tsRuns when the EventBridge Pipe delivers a change. Reads the pipe event into
DonationRequestInitiatorAttributes(centerHex = h3Res8plush3Res5, which is forwarded for consistency with the nearby blood requests feed but unused by the donor search itself) and callsDonorSearchService.initiateDonorSearchRequest. Retries on transient errors with exponential backoff, up todonorSearchMaxInitiatingRetryCountattempts.core/services/aws/donorSearch/donorSearch.tsRuns when EventBridge Scheduler fires, receiving a
DonorSearchSchedulerAttributespayload. Plugs in the AWS adapters (DDB, SQS, H3 query repo) and callsDonorSearchService.searchDonorsto run one wave.core/application/bloodDonationWorkflow/DonorSearchService.tsThe application-layer service. Tracks waves, decides on retries, and skips donors that were already notified. Has no AWS imports.
core/application/bloodDonationWorkflow/H3SearchService.tsBuilds the next batch of H3 ring cells (
buildRingBatch) and looks up donors in those cells from the H3-indexed location GSI (queryDonorsInHex).core/application/utils/h3.tsA thin wrapper around
h3-js:generateH3Cell,getH3CellParent,getH3GridRing,getH3GridDisk,haversineKm,getDistanceBetweenH3Cells.core/application/utils/calculateDonorsToNotify.tsPure helpers that decide how many donors a wave should aim for and how long to wait between retries (see below).
Spatial model
Three H3 resolutions are used (constants in
commons/libs/constants/NoMagicNumbers.ts):
H3_PUBLIC_FEED_RESOLUTION = 5(edge ~8.5 km, area ~252.9 km²) — partition key for the request GSI that backs the public nearby blood requests feed.H3_DONOR_SEARCH_RESOLUTION = 8(edge ~461 m, area ~0.74 km²) — partition key for the location GSI used by the donor search ring walk.H3_FINE_RESOLUTION = 10(edge ~66 m, area ~15,047 m²) — a fine-grained cell stored on each user location row, used to rank donors by distance.
A wave starts at the request’s h3Res8 cell (the centerHex) and
expands outward one ring at a time (getH3GridRing with k = 1,
then k = 2, and so on). It stops when it hits the cell budget
(maxCellsPerExecution), reaches the max search radius
(maxSearchRadiusKm), or finds enough donors.
State persisted in DynamoDB
DonorSearchDTO (see core/services/aws/commons/ddbModels) holds:
status—PENDINGorCOMPLETED.notifiedEligibleDonors— a map of every donor notified across all waves so far, keyed byuserId.createdAt— original creation timestamp, used as part of the composite key.
Per-wave state (currentLevel, remainingCells, retryCount,
remainingDonorsToFind, targetedExecutionTime) is not stored in
DynamoDB. It is passed from one wave to the next on the
DonorSearchSchedulerAttributes payload.
searchDonors per-wave logic
Each scheduled invocation does the following:
Stop if needed. Skip the wave if the donation request is
COMPLETEDorCANCELLEDor the search record was deleted.Work out how many donors to find. Subtract donors who already accepted from the requested bag count. If none are needed, stop. Otherwise compute
totalDonorsToFindwithcalculateTotalDonorsToFind, or carry over the previous wave’sremainingDonorsToFindplus however many donors have ignored the request so far.Build the ring batch.
H3SearchService.buildRingBatchkeeps adding rings aroundcenterHex, starting just pastcurrentLevel, until it reachesmaxCellsPerExecutionor hits the max ring level derived frommaxSearchRadiusKm.Query donors in parallel. Cells are queried in chunks of
parallelQueryConcurrencyagainst the location GSI, filtered by blood group and country. Results are deduplicated and any donors who are the seeker or who were already notified are removed.Notify them.
NotificationService.sendRequestNotificationis called every wave; if any eligible donors were found they are pushed to the notification SQS queue and merged intonotifiedEligibleDonorson the search record.Pick what to do next.
If more donors are still needed and there are unprocessed cells left in the ring batch, schedule the next wave after
searchIntervalSecondswith the updatedcurrentLevelandremainingCells.Otherwise, if no donors were found in this wave and
retryCountis belowmaxRetries, schedule a delayed retry from level 0 withretryCount + 1.Otherwise, mark the search
COMPLETED.
Calculation helpers (calculateDonorsToNotify.ts)
calculateRemainingBagsNeeded(bloodQuantity, donorsFoundCount)Returns
max(0, bloodQuantity - donorsFoundCount). The donor search callsacceptDonationService.getRemainingBagsNeeded, which fetches accepted donors from DynamoDB and then delegates to this helper.calculateTotalDonorsToFind(remainingBagsNeeded, urgencyLevel)Asks for a few extra donors so the request can survive some declines:
2extra forurgentrequests,1forregular. Returns0when no bags are still needed.calculateDelayPeriod(donationDateTime, maxRetries, retryDelaySeconds, acceptanceWindowSeconds)Spaces retries evenly across the time left until donation:
(timeUntilDonation / maxRetries) - retryDelaySeconds, but never shorter thanacceptanceWindowSeconds.
Tunables
These come from DonorSearchConfig (loaded from environment variables
in commons/libs/config/config.ts):
Current values come from iac/terraform/aws/donor_search/local.tf and
are passed in as Lambda environment variables.
Tunable |
Value |
Purpose |
|---|---|---|
|
500 |
Most H3 cells one wave is allowed to walk. |
|
25 |
How many cells one wave queries at the same time. |
|
180 |
Delay between back-to-back waves of the same run. |
|
0 |
Delay before the first wave runs. |
|
300 |
Smallest gap allowed between retry waves. |
|
3 |
How many retries when a wave finds no donors. |
|
3600 |
Smallest gap between retries, so donors have time to respond. |
|
15 |
Hard cap on how far from the request the search may walk, in kilometres. |
|
5 |
Retries allowed on transient errors at init time. |
Time-based tunables are in seconds.
What the numbers mean in practice
The values above lead to the following behaviour:
- Cells per ring (H3 res 8)
Ring
karound the center contains6kcells (andk = 0is the center itself, 1 cell). The disk up to ringkcontains1 + 3k(k + 1)cells.- Rings covered in one wave
With
maxCellsPerExecution = 500, one wave walks the largest disk that still fits in the budget:Disk through ring
12:1 + 3 · 12 · 13 = 469cells ✔Disk through ring
13:1 + 3 · 13 · 14 = 547cells ✘
So wave 1 sweeps rings
0–12. If more donors are still needed, the next back-to-back wave continues from ring13and fits about five more rings (rings13–17add78 + 84 + 90 + 96 + 102 = 450cells), and so on.- Ground radius covered
Adjacent H3 res-8 cells are
edge × √3 ≈ 461 m × 1.732 ≈ 798 mapart center-to-center. So:Wave 1 (ring 12) reaches
≈ 12 × 0.798 km ≈ 9.6 km.Wave 2 (ring 17) reaches
≈ 13.6 km.
- Max search distance
maxSearchRadiusKm = 15is converted to a max ring level by rounding up so the configured radius is fully covered:ceil(15 / 0.798) = 19. After ring 19 the ring walk stops, even if more donors are still needed. Effective max reach:19 × 0.798 km ≈ 15.2 kmfrom the request center (the last ring that crosses the 15 km cap).With the current cell budget, that disk is covered in three back-to-back waves:
Wave 1: rings 0–13 (500 cells processed, 47 leftover in ring 13).
Wave 2: rings 13–18 (500 cells processed, 27 leftover in ring 18).
Wave 3: leftover from ring 18 + ring 19 (141 cells, all processed) and the search ends.
- Parallel query batches per wave
469 cells / 25 = 19parallel batches against the location GSI in wave 1.
Time between waves
Back-to-back waves (donors still needed, cells left): scheduled
searchIntervalSeconds = 180 s(3 min) apart.Retry wave (a wave found zero donors): scheduled
max(calculateDelayPeriod, acceptanceWindowSeconds) = max(computed, 3600 s). The floor is 1 hour.
- Total retry budget
maxRetries = 3retries × 1 hour minimum per retry =3 hoursof retry waiting in the worst case before the search is markedCOMPLETED.
Restart behaviour
If a finished search is reopened — the request is edited back to
PENDING after the search reached COMPLETED — the initiator sees
eventName = MODIFY plus status = PENDING on the EventBridge Pipe
event and schedules a fresh wave from level 0. Any donors already in
notifiedEligibleDonors are kept on the record.