Skip to content

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 locationCalendar JSON 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 its location_calendar table.
  • /api/qstash/sync-billion-vehicle β€” a vehicle’s static data and its base address changed. Billion refreshes vehicle_geo_cache and the base layer (is_base = true, unbounded range) of the vehicle’s location_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. vehicleId and externalId are encrypted with the shared BILLION_SECRET. Billion decrypts them on receipt (see app/api/qstash/calendar/parsers.ts on the Billion side). The body’s top-level secret field (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.coordinates on the TRA row. placeId, addressLabel, countrySlug, citySlug are also pulled from the same JSON snapshot.
  • Date semantics differ across the boundary. TRA stores inclusive [dateFrom, dateTo]. Billion stores half-open [dateFrom, dateTo) (DATERANGE with '[)'). The Billion ingestion layer converts on receipt β€” TRA does not need to (TRA sends raw YYYY-MM-DD strings 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 to MAX_BATCH_SIZE = 100.

Trigger points on the TRA side

VehicleLocationCalendarService (services/tenant/vehicle-location-calendar/VehicleLocationCalendarService.ts):

Service actionEvent 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_calendar table is layered. Each vehicle has one base layer (is_base = true, unbounded (-infinity, infinity) range, priority 0) 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-vehicle from vehicle.base coordinates. Event layers are upserted by /api/qstash/calendar from each TRA calendar row.
  • Resolution on Billion is β€œhighest priority wins, then narrowest range wins, then newest created_at wins, then lowest id wins”, encoded directly in the search SQL via DISTINCT ON (vehicle_id) … ORDER BY …. The first two rules are intentionally identical to TRA’s resolveWinningEntry; the third tiebreaker differs (TRA uses updatedAt, Billion uses created_at) because each side picks the freshest field it actually tracks β€” TRA mutates rows in place, so updatedAt is the meaningful recency signal; Billion ingests events as immutable upserts and treats created_at as 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

TopicDesign doc v3What shipped
Billion storageEmbedded JSON array on the Fauna vehicle documentDedicated Postgres + PostGIS table on Billion’s Neon instance (location_calendar, plus vehicle_geo_cache)
TransportDirect 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 syncEmbedded 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 wireInclusive '[]'TRA sends inclusive YYYY-MM-DD strings; Billion stores as half-open '[)'. The conversion is Billion-side
Distance APIPublic /api/public/calculate-distance on TRA; Billion stops using the delivery-price endpointsNot 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 safetySync sends an empty array; Billion’s resolver returns vehicle.baseSame 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 priority is 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 the is_base column + 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.js and 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.