16. Location Calendar β Billion sync
Date: 2026-05-20
Status
Accepted. Shipped in M2 (TOP-4830 spike, TOP-4837 per-entry sync, TOP-4936 full-vehicle sync on first publish).
Context
The Location Calendar only delivers business value once Billion can see it: Billionβs search uses it to filter vehicles by date-aware delivery radius, and Billionβs delivery-price math uses it to compute distance from the right base. Without the sync, the calendar is a TRA-only operator tool.
The original design doc proposed:
- embedding the calendar as a
locationCalendarJSON array on each vehicle document in Billionβs Fauna; - reusing the existing
updateVehicleInBillion()Fauna writer to ship the calendar with every vehicle sync; - exposing a new public distance API on TRA so Billion could stop calling the delivery-price endpoints.
The TOP-4830 spike with the Billion team rejected the embedded-JSON approach. Billion was already migrating its search out of Fauna into a Postgres + PostGIS store (vehicle_geo_cache + location_calendar) with proper geo indexes, so the calendar belonged in the new store rather than re-encoded as JSON in Fauna. The data-shape contract was redesigned around that store.
This ADR documents the contract that resulted, and the divergences from the design doc.
Decision
Transport β QStash events
TRA does not write to Billionβs database directly. It publishes events through Upstash QStash to Billion HTTP endpoints. Billion is the only writer to its own location_calendar table.
TRA service QStash Billion (power-rent-billion)βββββββββββββββββββββββββ βββββ βββββββββββββββββββββββββββpostCalendarEventToBillion ββββββΊ queue ββββββΊ POST /api/qstash/calendar (event-layer upsert/delete)
updateVehicleInBillion ββββββΊ queue ββββββΊ POST /api/qstash/sync-billion-vehicle (base layer + vehicle_geo_cache)Two endpoints, two responsibilities:
/api/qstash/calendarβ per-entry events: a calendar row was created/updated/deleted on TRA. Billion upserts or deletes the corresponding event layer in itslocation_calendartable./api/qstash/sync-billion-vehicleβ a vehicleβs static data and itsbaseaddress changed. Billion refreshesvehicle_geo_cacheand the base layer (is_base = true, unbounded range) of the vehicleβslocation_calendar.
QStash gives us at-least-once delivery, retries with backoff, signature verification, and a dead-letter visibility path β none of which TRA needs to implement.
Per-entry event payload
Built by lib/billion/postCalendarEventToBillion.ts. The wire body is the event spread alongside an encrypted shared secret:
type CalendarRequestBody = CalendarEvent & { secret: string; // encrypt(BILLION_SECRET); checked on top of QStash signature verification};
type CalendarEvent = | { type: 'vehicle.location_event.upserted'; payload: LocationEventUpserted[] } | { type: 'vehicle.location_event.deleted'; payload: LocationEventDeleted[] };
type LocationEventUpserted = { vehicleId: string; // encrypted with BILLION_SECRET externalId: string; // encrypted; this is the TRA calendar entry id priority: number; location: { lat: number; lng: number }; placeId: string | null; addressLabel: string | null; countrySlug: string | null; citySlug: string | null; dateFrom: string; // YYYY-MM-DD dateTo: string | null; // YYYY-MM-DD, null = indefinite on the TRA side};
type LocationEventDeleted = { vehicleId: string; // encrypted externalId: string; // encrypted};Notes on the contract:
- Encryption.
vehicleIdandexternalIdare encrypted with the sharedBILLION_SECRET. Billion decrypts them on receipt (seeapp/api/qstash/calendar/parsers.tson the Billion side). The bodyβs top-levelsecretfield (shown above) carries the same secret encrypted with itself, as a defence-in-depth check on top of QStashβs signature verification. externalId= TRA calendar entry id. Billion stores it on its event layer rows under the same name; thatβs the join key for upserts and deletes.- Coordinates come from
locationData.coordinateson the TRA row.placeId,addressLabel,countrySlug,citySlugare also pulled from the same JSON snapshot. - Date semantics differ across the boundary. TRA stores inclusive
[dateFrom, dateTo]. Billion stores half-open[dateFrom, dateTo)(DATERANGEwith'[)'). The Billion ingestion layer converts on receipt β TRA does not need to (TRA sends rawYYYY-MM-DDstrings as it stores them). - Batching. Both event types take an array. Today TRA fires one entry per event for per-row mutations and a bulk array for
syncActiveEntriesForVehicle. The endpoint accepts up toMAX_BATCH_SIZE = 100.
Trigger points on the TRA side
VehicleLocationCalendarService (services/tenant/vehicle-location-calendar/VehicleLocationCalendarService.ts):
| Service action | Event sent |
|---|---|
create(input) | upserted with one entry |
update(id, input) | upserted with one entry (the updated row) |
delete(id) | deleted with one entry |
syncActiveEntriesForVehicle(vehicleId) | upserted with all entries where dateFrom >= today (up to 1000), batched |
Every per-mutation send is gated on vehicleRepository.isOnMarketplace(vehicleId). Calendars on vehicles that arenβt on Billion donβt generate any traffic.
syncActiveEntriesForVehicle is called when a vehicle is published to Billion for the first time β without it, Billion would only see the calendar entries created after the publish moment. The bulk variant covers the back-catalogue.
The base address (vehicle.base) is not sent through /api/qstash/calendar. It rides along with the existing vehicle sync to /api/qstash/sync-billion-vehicle, where Billion materialises it as the is_base = true row on its location_calendar.
Error handling
All sync calls run as side effects (sideEffects.fire(...)) so a Billion outage never blocks the TRA mutation. postCalendarEventToBillion is wrapped in Try from @power-rent/try-catch/nextjs, with Sentry tags vehicleId, count, eventType for triage. QStash retries handle transient failures; persistent failures land in QStashβs DLQ for manual replay.
If TRA and Billion fall out of sync (a publish missed, a webhook failed past retries), the recovery path is syncActiveEntriesForVehicle, plus a re-publish to push the base layer. There is no scheduled reconciliation job today; if the need surfaces, thatβs the natural place to add one.
What Billion does on receipt (reference, not normative for TRA)
Documented here so a TRA engineer reading this ADR understands the receiving end. Source of truth lives in power-rent-billion.
location_calendartable is layered. Each vehicle has one base layer (is_base = true, unbounded(-infinity, infinity)range, priority0) plus zero or more event layers (is_base = false, bounded[dateFrom, dateTo)range, priority from the event).- The base layer is upserted by
/api/qstash/sync-billion-vehiclefromvehicle.basecoordinates. Event layers are upserted by/api/qstash/calendarfrom each TRA calendar row. - Resolution on Billion is βhighest priority wins, then narrowest range wins, then newest
created_atwins, then lowest id winsβ, encoded directly in the search SQL viaDISTINCT ON (vehicle_id) β¦ ORDER BY β¦. The first two rules are intentionally identical to TRAβsresolveWinningEntry; the third tiebreaker differs (TRA usesupdatedAt, Billion usescreated_at) because each side picks the freshest field it actually tracks β TRA mutates rows in place, soupdatedAtis the meaningful recency signal; Billion ingests events as immutable upserts and treatscreated_atas the receive timestamp. The fourth tiebreaker (lowest id) only exists on Billion and is there purely to make the SQL output deterministic. The two algorithms can diverge for entries that have been updated on TRA without a corresponding event re-fire to Billion; in practice, any TRA mutation re-fires the event, so they stay aligned. - When a vehicle is disabled or deleted on Billion, the QStash sync endpoint cascades: it deletes the vehicleβs calendar rows (event layers + base layer) and the geo-cache row in one transaction.
Divergences from the design doc
| Topic | Design doc v3 | What shipped |
|---|---|---|
| Billion storage | Embedded JSON array on the Fauna vehicle document | Dedicated Postgres + PostGIS table on Billionβs Neon instance (location_calendar, plus vehicle_geo_cache) |
| Transport | Direct Fauna writes from TRA via billionClient inside updateVehicleInBillion() | QStash events to two Billion HTTP endpoints; TRA never touches Billionβs database directly |
| Calendar coupling to vehicle sync | Embedded in updateVehicleInBillion() (cross-store read inside the sync) | Two separate event streams: per-entry calendar events and a vehicle/base sync; they meet on the Billion side |
| Date bounds on the wire | Inclusive '[]' | TRA sends inclusive YYYY-MM-DD strings; Billion stores as half-open '[)'. The conversion is Billion-side |
| Distance API | Public /api/public/calculate-distance on TRA; Billion stops using the delivery-price endpoints | Not built. Billion computes distance with PostGIS (ST_Distance on the indexed location geography) from its own store. TRAβs old delivery-price endpoints remain in place for the moment; they can be deprecated when the migration is closed out, but that is not on the critical path |
| Empty-calendar safety | Sync sends an empty array; Billionβs resolver returns vehicle.base | Same outcome, different mechanism: with no event layers, the base layer is the only candidate and wins |
The design doc remains useful for the why (business problem, Billion search filtering, alternatives considered) but should not be read as the implementation reference. This ADR and ADR-0015 are the implementation references.
Consequences
- Sync is asynchronous. A calendar mutation on TRA is durable immediately, but takes a QStash hop (typically sub-second, can be longer under retry) before it is visible to Billion search. Operators editing the calendar moments before a customer searches will sometimes see a brief inconsistency. This is acceptable for the use case (seasonal planning) and intentionally not papered over with synchronous waits.
- Billion owns its own store. Adding a new field to the calendar entry on the wire requires a coordinated change: TRA enriches the payload, Billion adds a column + parser update. The migration we did to add
priorityis the template. - The two QStash endpoints must stay aligned. The base layer is the responsibility of
/api/qstash/sync-billion-vehicle; event layers are the responsibility of/api/qstash/calendar. Confusing the two will produce duplicates or gaps. The Billion side enforces this via theis_basecolumn + the partial unique indexes (idx_location_calendar_base_unique,idx_location_calendar_external_unique). - The public distance API is deferred indefinitely. If a future consumer outside Billion needs distance with TRA-quality caching, the design doc proposal can be revisited; there is no current driver.
- TRAβs old delivery-price endpoints (
pages/api/public/calculate-delivery-price-for-billion.jsand the multi-vehicle variant) are still in use. Phase 3 of the rollout was to deprecate them once Billion fully migrated. That phase is not closed yet; tracking is in the Location Calendar project.