Skip to content

feat(worker): persist trip plans in the gateway's SQLite storage - #52

Closed
i11v wants to merge 1 commit into
mainfrom
claude/priceless-lederberg-f98f47
Closed

i11v wants to merge 1 commit into
mainfrom
claude/priceless-lederberg-f98f47

Conversation

@i11v

@i11v i11v commented Oct 17, 2026

Copy link
Copy Markdown
Owner

Why

GET /api/trips/{id} was cached only in the gateway DO's memory. After an eviction, every plan the iOS app asks for spends the shared Golemio limiter again (20 calls / 8 s), so a cold first launch takes ~8 s to load ~20 vehicle plans.

What

  • makeOutcomeCache takes an optional persist tier (load/save, both never fail). A memory miss checks it and promotes the entry with its original fetchedAt, so TTL (6 h) and stale-if-error (24 h) carry over cold starts.
  • New TripStore (src/gateway/tripStore.ts): a trip_plans table in the gateway DO's SQLite storage (state.raw.storage.sql). It stores only ok plans (a notFound is keyed by client input and cheap to forget), prunes rows older than 24 h and keeps at most the newest 4096, and decodes rows with the contract Trip schema so a future shape change is treated as a miss. SQL errors are logged and handled as misses.
  • I chose this over warming from the vehicle snapshot because the snapshot has no stops or shape, so filling a plan would still cost one Golemio call each.
  • The HTTP contract is unchanged.

Tests

bun run test (138 passed) and bun run typecheck both pass. The new tests run TripStore on Node's node:sqlite and build a fresh gateway over the same database to stand in for an evicted DO. They check that:

  • a cold gateway serves a previously fetched trip with no upstream call
  • TTL and the stale fallback still apply across cold starts, and expired rows get pruned
  • a notFound is not persisted
  • the row cap holds

I checked that the cold-start tests fail when the store is swapped for TripStore.layerNone. I haven't run this against a deployed DO yet.

🤖 Generated with Claude Code

The trip-plan cache lived only in the gateway Durable Object's memory, so
every eviction made the next burst of plan requests spend the shared
Golemio budget again (~8 s for 20 vehicles on a cold iOS launch).

Plans now also go to a trip_plans table in the DO's own SQLite storage,
behind the in-memory cache: a memory miss is promoted from there with its
original fetch time, so the 6 h TTL and 24 h stale fallback hold across
cold starts. Only found plans are stored; rows past 24 h or beyond the
newest 4096 are pruned. Storage failures degrade to a miss. The vehicle
snapshot carries no stops or shapes, so it can't warm this cache.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

🚀 Preview deployed: https://preview-52.tablo.run
fallback: https://tablo-pr-52.i11v.workers.dev

@i11v

i11v commented Oct 17, 2026

Copy link
Copy Markdown
Owner Author

Superseded by #56: trip plans are now built from PID's GTFS at deploy time and served from static assets, so persisting Golemio trip answers in the gateway DO is no longer needed.

🤖 Claude Code

@i11v i11v closed this Oct 17, 2026
i11v added a commit that referenced this pull request Oct 17, 2026
## Why
Every `GET /api/trips/:tripId` miss spent the one Golemio token's budget
(20 calls / 8 s) through the singleton gateway Durable Object. A cold
iOS launch took ~8 s to load ~20 vehicle plans, and that cost grows with
every user, which matters for the public release. Trip plans are static
timetable data, and Golemio's trip endpoint is just a view of PID's
public GTFS feed.

## What
- **Build:** `build:index` (same download, one unzip) now also writes
every trip's plan to `apps/web/public/data/trips/v1/<route>.json`. There
is one file per route prefix of the trip id: ~900 files, ~160 MB, about
6 s and 2.3 GB of RAM. Output is deterministic, so routes that didn't
change upload nothing.
- **Format:** `TripBucketV1`, `tripBucketKey` and `tripFromBucket` in
`packages/contract/src/trip-bucket.ts`. The builder is in
`apps/worker/src/gtfs/buckets.ts`; it applies the normalization `toTrip`
did (time parsing, ASW ids, rounding).
- **Serve:** the Worker reads the files through `ASSETS`. It keeps up to
3 parsed files and 512 plans per isolate and validates each plan against
`Trip` before serving it. Trips never reach Golemio or the gateway DO.
An id the deployed feed lacks returns 404 `TripNotFound`; an unreadable
or malformed file returns 503 `UpstreamUnavailable`, which isn't cached,
so the next request retries.
- **Removed:** the Golemio trip-plan path (`fetchTrip`, the gateway's
trip cache and `getTrip` RPC, `PidTripResponse`, `toTrip` and the
Golemio trip fixture). Trips have one source of truth. The GTFS helpers
moved to `gtfs/buckets.ts`, and the trip tests now build from GTFS rows.
- **Refresh:** `deploy.yml` also runs daily at 03:30 UTC (after PID
publishes the feed) and on manual dispatch.
- `scripts/lib/csv.ts` now has a lazy `iterCsv` generator; `parseCsv` is
unchanged.
- **Unrelated fix:** `vitest.integration.config.ts` now points at
`apps/*`. The move to `apps/` left it matching no files.
- `docs/adr/0001-trip-plans-from-gtfs.md` explains why I used static
assets over R2, D1, KV or a Worker cron, and `docs/DEPLOY.md` is
updated.

The HTTP contract is unchanged (`contract:check` passes, and
`wire-shape.json` and the iOS fixtures didn't change).
`/api/trips/:id/vehicle` and `/api/vehicles` still go to Golemio through
the gateway.

## Verification
- **Against production (2026-10-17):** all 366 live vehicles' trip ids
were in the feed. Plans built from it matched production `/api/trips`
byte for byte on all 19 trips I compared. The other requests in that
sample got 503 because I had used up production's Golemio budget.
- **Unit tests:** `bun run test` passes (140 tests).
- Bucket tests on GTFS rows carry over every former `toTrip`
expectation: order, normalization, shape rounding and dedupe, time
fallbacks.
- Plan-cache tests cover a hit loading its file once, misses, a failed
load reported as unavailable and retried, and malformed data reported as
unavailable.
- API tests check that trips never reach the gateway, that a miss is a
404 and that an unreadable file is a 503.
- **Integration:** `GOLEMIO_API_TOKEN=dummy bun run test:integration`
passes (4 tests). The new test gets a plan from local workerd with a
dummy Golemio token, so the plan can only have come from the assets.
- `typecheck`, `lint`, `fmt:check`, `build:web` and `verify:pwa` pass.
No trip files end up in the PWA precache.

## Notes
- **#52:** closed. Persisting Golemio plans in the DO is no longer
needed.
- **Behaviour change:** an id missing from the deployed feed is now a
404, where Golemio would have been asked. Every live id I checked was in
the feed, and the daily deploy keeps it current.
- **Size:** deploy assets grow by ~160 MB, and each PR preview uploads
them on its first deploy.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant