Skip to content

feat(live): production — one poller across tasks, a fraction of the bytes, on by default - #168

Merged
NateIsern merged 6 commits into
mainfrom
feat/live-production
Oct 13, 2026
Merged

NateIsern merged 6 commits into
mainfrom
feat/live-production

Conversation

@NateIsern

@NateIsern NateIsern commented Oct 12, 2026 •

Copy link
Copy Markdown
Member

GoWay Live on goway.to

This makes the live layer ready for production. Production still serves nothing until the owner lists LIVE_SOURCES on the task definition, along with the other values below. On current main there is no LIVE_ENABLED: live runs when a source is listed. Design, measurements and the full checklists are in docs/MAP_LIVE.md → Production.

1. One poller across API tasks (packages/backend/src/live/cluster.ts)

The tasks share GoWay's Valkey (REDIS_URL, i.e. /oxy/_shared/REDIS_URL).

  • Demand. Every 2 s each task writes the room keys its sockets watch, with a 10 s TTL. These are public tiles × kinds: never a viewport, nothing per viewer.
  • Leadership. Each source has a lease (SET NX PX 15000, renewed with a compare-and-renew script). Only the holder polls. It publishes each answer (enriched entities, gzip-compressed) on pub/sub, and every other task applies it to its own index and serves its own sockets.
  • Failover. A dead leader's lease expires after 15 s and another task polls at once. A task that starts asks the leaders to republish what they hold (sync). If Valkey is down, every task polls on its own and the outage is logged once.
  • What is stored. Positions only pass through pub/sub and are never stored in Valkey. The only keys are leases and demand, all with TTLs.
  • Tests.
    • cluster.test.ts covers, in memory: three tasks making one upstream request, every task serving the answer, failover after the lease, a late task being synced, local polling while the bus is down, and compact moves.
    • The same file runs against a real Valkey when TEST_VALKEY_URL is set. CI now runs a valkey/valkey:8.0 service for this.
  • Measured with two real backend processes on one Valkey.
    • The lease holder was killed with SIGKILL at 11:24:58, and the lease moved at 11:25:14.
    • The surviving task kept serving New York.
    • While both ran, GET goway:live:lease:* named exactly one holder per source.

2. Bandwidth

  • Per-kind rooms (LIVE_ROOM_ZOOMS): aircraft and ships use z7, transit z11 (~15 km), bike share z13 (~3.7 km). The client asks for bike share only from z15, where it is drawn.

  • Moves. A vehicle that only moved travels as a LiveMove tuple (~80 B) instead of the whole entity (~450 B).

  • Packing. Any payload over 2 KB is sent as gzip bytes and inflated by unpackLivePayload, in arrival order. This is done in the app because Bun's WebSocket server does not negotiate permessage-deflate: socket.io under Node answers permessage-deflate, under Bun nothing.

  • Measured on New York at the same moment, old protocol vs new:

    View First snapshots Deltas
    Whole city 1,225 KB → 25 KB 18.1 → 0.7 KB/s
    Midtown, z15 1,225 KB → 24 KB 18.1 → 0.3 KB/s

    This was at 04:20 local time, ~550 vehicles; daytime has about 3× as many.

3. Settings registry

  • live is now stable, offered everywhere on the web, and on by default. It is safe to be on by default because the backend is the gate: LiveHost asks GET /live/sources first, and a 503 means nothing is drawn and no socket is opened.
  • Which feeds are served, and therefore which licences are in play, is decided by LIVE_SOURCES.
  • The panel starts folded to its pill.
  • The live.maxEntities quality cap still applies.
  • The client uses WebSocket only, so the ALB needs no stickiness.

4. Production config

These are the owner's actions; this PR writes no SSM and changes no AWS.

  1. Run aws ssm put-parameter --name /oxy/goway/LIVE_SOURCE_MTA_BUS_KEY --type SecureString --value <key> once the key exists.
  2. In oxy-infra app-goway.tf:
    • Add this environment variable:
      • LIVE_SOURCES=renfe-cercanias,fgc-geotren,mta-lirr,mta-mnr,mta-subway,amb-bus,aisa-bus,emt-fuenlabrada,digitraffic-ais
    • The user agent and the limits are code (LIVE_POLICY), not configuration.
    • Add these secrets:
      • REDIS_URL ← /oxy/_shared/REDIS_URL
      • LIVE_SOURCE_MTA_BUS_KEY ← /oxy/goway/LIVE_SOURCE_MTA_BUS_KEY
    • Set memory = "2048". A task holding every source's static GTFS measured 592 MB resident and a 902 MB peak, against today's 1,024 MB.
  3. In iam-goway-deploy.tf, give goway_execution ssm:GetParameters on /oxy/_shared/REDIS_URL and /oxy/goway/LIVE_SOURCE_MTA_BUS_KEY. It can read only DATABASE_URL today.
  4. Register a revision derived from the running one, repoint the service once, and confirm that GET https://api.goway.to/api/v1/live/sources answers 200.
  5. Nothing to do for the network: the tasks' security group already reaches Valkey.

5. Licence readiness

Ready now After an action Not yet
Renfe/Rodalies, FGC, LIRR, Metro-North, the subway (estimated), AMB (estimated), AISA, EMT Fuenlabrada, Digitraffic mta-bus: register a Bus Time key at https://register.developer.obanyc.com/ (steps are in the doc). adsb-lol: send the drafted email to the operator and plan a feeder. nyc-bikeshare: Lyft (or counsel) must first answer whether a public map over an open socket counts as "stand-alone" distribution.

Review fixes (Devin)

  • A recovered bus strands followers (fixed). Bun's Redis client reconnects without restoring subscriptions (measured on a Redis restart). The bus now reports isSubscribed(), and a reconnect marks the subscription lost. Before giving up local polling, a task subscribes again on a fresh connection and sends a sync.
  • Live off blocks boot (fixed). With no LIVE_SOURCES listed, no other live setting is read, so a bad REDIS_URL can't stop the backend from booting.
  • Open maps miss newly enabled live (fixed). LiveHost asks again while the backend says 503: after 30 s, 1 min, 2 min, then every 5 min.
  • Bus connections survive shutdown (fixed). stop() now always closes the bus, and releases leases only when Valkey answers.

Each fix has a test that fails without it.

Independent review fixes

  • A Valkey outage of 8 s or more stranded the bus (fixed). Bun 1.4.3's client stays failed after such an outage, even once Valkey is back.
    • A failed call now replaces any dead connection (LiveBus.recover).
    • A subscriber that closed or is not connected now counts as lost.
    • A real-server test stops redis-server for 10 s and checks that there is one leader again and the follower hears it. It fails on the previous code, which left 2 leaders. CI installs redis-server and the test passes there in 21.9 s.
  • Bus calls had no deadline (fixed). Every call is now bounded at a third of the lease (5 s). A call that does not answer counts as the bus being down, and the task polls locally.
  • A malformed shared REDIS_URL stopped the backend booting (fixed). It now logs a warning, never the value, and live runs without a bus. LIVE_VALKEY_URL is removed.
  • Shared demand could reveal a viewer's area (fixed). Only z6 tiles × kinds (~470 km) are stored, for 10 s. The doc says exactly what is stored and why it is not a viewport.

Tests

  • Lint and typecheck pass.
  • Backend: 1,082 tests pass, including the real-Valkey cluster tests against my scratchpad Redis 8.0.
  • Frontend: 1,575 tests pass. SDK: 207 tests plus smoke.
  • check:openapi, check:contracts, test:gates, check:lockfile and validate:agents-md pass.
  • I checked it in a browser with no ?live=1: Midtown drew live vehicles from the new protocol, with packed payloads inflated and the panel starting as its pill.

🤖 Generated with Claude Code

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 4 potential issues.

2 flags not posted on this PR by your GitHub settings — view them in Devin Review. (Configure)

Devin Review

Comment on lines +253 to +261
/** Join the other tasks: listen, then ask the leaders for what they hold. */
async connect(): Promise<void> {
if (!this.bus) return;
try {
await this.bus.subscribe((message) => this.receive(message));
await this.bus.publish({ type: 'sync', from: this.bus.taskId });
} catch (error) {
this.busFailed(error);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Recovered bus strands live followers

When the first connect() fails, a later successful coordinate() stops local polling without restoring the subscription. Followers receive no published positions until their processes restart.

Learn more

The hub subscribes to the shared Valkey channel only in connect(), which runs once from start. If that call fails, busDown enables local polling. Later coordinate can succeed and clear busDown, although the subscriber was never connected. Followers stop polling and never receive leader updates.

Example: Valkey is offline when task B starts, then returns. B's next coordination succeeds, task A gets the lease, and B's existing map viewers receive no vehicles from A.

Recommended fix: Track subscription health separately from command health. Retry connect() on recovery, confirm the subscription and request a fresh sync before disabling local polling; also handle subsequent subscriber disconnects.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +189 to +201
let valkeyUrl: string | null = null;
const rawValkey = emptyAsUndefined(source.LIVE_VALKEY_URL) ?? emptyAsUndefined(source.REDIS_URL);
if (rawValkey !== undefined) {
try {
const url = new URL(String(rawValkey).trim());
if (url.protocol !== 'redis:' && url.protocol !== 'rediss:') throw new Error('protocol');
valkeyUrl = url.toString();
} catch {
// Never echo the value: it may carry a password.
problems.push(' - LIVE_VALKEY_URL / REDIS_URL: must be a redis:// or rediss:// URL');
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Disabled live configuration blocks backend startup

With LIVE_ENABLED off and a malformed REDIS_URL, parseLiveConfig() still throws at server import. The entire backend fails to start, although live polling is disabled.

Learn more

The liveConfig export is evaluated when server bootstrap imports it. The new Redis URL parse runs even when LIVE_ENABLED is unset. A bad optional bus setting now prevents Places, search, and routing from booting, although none of them requires the live service.

Example: Start the backend with LIVE_ENABLED=0 and REDIS_URL=not-a-url. The module throws during import instead of starting with live disabled.

Recommended fix: Validate bus settings only when the live service is enabled and has configured sources, or isolate the live parse so an invalid bus setting disables live without aborting core startup.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +197 to +211
const [available, setAvailable] = useState<'checking' | 'yes' | 'no'>('checking');
useEffect(() => {
let current = true;
void import('@/lib/live/client')
.then(({ liveApiClient }) => liveApiClient.live.sources())
.then(
() => current && setAvailable('yes'),
(error: unknown) =>
// Anything else (offline, a blip) is the socket's to report and retry.
current && setAvailable(error instanceof GoWayUnavailableError ? 'no' : 'yes'),
);
return () => {
current = false;
};
}, []);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Existing maps miss newly enabled live feeds

If live.sources() returns 503 once, LiveHost remains unavailable after the backend enables live. Open maps never reconnect or show vehicles until remounted.

Learn more

Availability is checked once per mount. On a 503, the state becomes no and the socket effect never runs. Backend rollout enables LIVE_ENABLED independently of existing frontend sessions, so those sessions cannot discover the change.

Example: A map stays open while /live/sources changes from 503 to 200 during deployment. The map keeps showing no Live panel and opens no socket until the user reloads it.

Recommended fix: Retry the availability request after a bounded delay while the feature is enabled, cancel retries on unmount, and open the socket when a retry returns 200.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +425 to +432
const bus = this.bus;
if (bus && !this.busDown) {
void Promise.all(
this.states
.filter((state) => state.leader)
.map((state) => bus.release(state.definition.id).catch(() => {})),
).finally(() => void bus.close().catch(() => {}));
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Failed bus connections survive live shutdown

When stop() runs during a bus outage, busDown skips bus.close(). The command and subscriber connections remain open after live has stopped.

Learn more

ValkeyLiveBus owns two Redis clients, and close closes both. An outage sets busDown; the shutdown path then bypasses close altogether. Graceful shutdown may exit soon afterward, but a stopped service held in-process retains these connections and their reconnect activity.

Example: A task loses Valkey, then calls live.stop() during a drain. Both Redis clients remain open until process termination rather than closing with the service.

Recommended fix: Always close the bus during stop, regardless of whether releasing leases is possible; attempt lease release only when the bus is reachable.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

@NateIsern
NateIsern force-pushed the feat/live-production branch from 02061d0 to 2a70549 Compare October 12, 2026 08:48
NateIsern added a commit that referenced this pull request Oct 12, 2026
…ver blocks boot

From the review of #168:

- A task whose subscription never happened (Valkey away at start) or was
  lost with its connection (Bun's client reconnects without restoring
  subscriptions — measured on a Redis restart) now subscribes again on a
  fresh connection, with a sync, before it stops polling locally. The bus
  reports `isSubscribed()`; a reconnect marks it lost.
- With LIVE_ENABLED off, parseLiveConfig reads nothing else and cannot
  throw: no live setting (a shared REDIS_URL, a stale source list) can stop
  the backend from booting.
- An open map asks again whether the backend serves live (30 s, 1, 2, then
  every 5 min), so it finds live when LIVE_ENABLED is turned on.
- Stopping live closes the bus's connections even during an outage.

Each has a test that fails without its fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ytes, on by default

GoWay Live ready for goway.to:

- One poller across API tasks (live/cluster.ts): tasks share demand (room
  keys with a TTL, never a viewport) through Valkey (REDIS_URL, the fleet's
  /oxy/_shared/REDIS_URL); a per-source lease (SET NX PX, compare-and-renew)
  picks the one task that polls; it publishes each answer (gzip) on pub/sub
  and every task applies it and serves its own sockets. A dead leader's lease
  expires in 15 s and another task polls at once; a starting task is synced;
  with Valkey down every task polls locally, and a follower stops polling only
  once it is subscribed again (Bun's client reconnects without restoring
  subscriptions). Positions are never stored in Valkey. Tested in memory and
  against a real Valkey (CI service valkey/valkey:8.0); failover measured with
  two real backend processes.
- Bandwidth: rooms at a zoom per kind (aircraft/ships z7, transit z11, bike
  share z13; LIVE_ROOM_ZOOMS), bike share asked for only from z15, vehicles
  that only moved sent as compact LiveMove tuples, and messages over 2 KB as
  gzip bytes (Bun's WebSocket server does not negotiate permessage-deflate).
  New York at the same moment: first snapshots 1,225 KB → 25 KB, deltas
  18 KB/s → 0.7 KB/s (whole city) and 0.3 KB/s (Midtown).
- The registry offers live everywhere on the web, on by default: the backend
  is the gate (GET /live/sources 503 → nothing drawn, no socket; asked again
  while open), the panel starts as its pill, live.maxEntities caps drawing.
  WebSocket-only transport, so no ALB stickiness is needed.
- docs/MAP_LIVE.md → Production: the cluster, the measurements, the exact
  environment and secrets, the owner's oxy-infra checklist (2 GB tasks), a
  per-source licence checklist, the adsb.lol email and the MTA key steps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@NateIsern
NateIsern force-pushed the feat/live-production branch from b9c3ccc to 95527c5 Compare October 12, 2026 12:11
NateIsern and others added 4 commits October 12, 2026 18:20
# Conflicts:
#	docs/MAP_FEATURES.md
#	packages/frontend/features/explore/ExploreScreen.tsx
…only coarse demand

From the independent review of #168, each with a test that fails without it:

- After an outage of 8 s or more Bun 1.4.3's Redis client stays failed
  (ERR_REDIS_CONNECTION_CLOSED) even once Valkey is back, so every task kept
  polling every source. A failed call now replaces any connection that is no
  longer connected (LiveBus.recover), a subscriber that closed or is not
  connected counts as lost, and a real-server test stops redis-server for
  10 s and asserts one leader again and the follower hearing it.
- Every bus call is bounded at a third of the lease: unanswered, the bus
  counts as down and the task polls on its own, instead of a follower going
  dark for ~10.7 s per command (minutes on a silent partition).
- A malformed shared REDIS_URL logs a warning (never the value) and live runs
  without a bus; it no longer stops the backend booting. LIVE_VALKEY_URL is
  gone: REDIS_URL is the one name.
- Shared demand is z6 tiles × kinds (~470 km), never the rooms a viewer is
  in; MAP_LIVE.md says exactly what is stored, for how long and why it is not
  a viewport.
- CI installs redis-server (kept from starting on the service's port) for the
  outage test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… replace a connecting client

From the second review of #168, each with a test that fails on 9a07838:

- Demand: each task decides from its exact rooms (never shared) which
  sources it wants and shares only their ids — plus z6 regions for the one
  regional feed (aircraft), polled per z6 region anyway. A viewer in
  Philadelphia no longer turns on the MTA's feeds (they shared a z6 tile with
  New York). Only the leader, and a task whose own viewers watch a source,
  read its static GTFS.
- Lease epochs: the lease carries Valkey's clock at the time it was taken;
  every message is stamped with it and followers drop older ones, so a
  publish abandoned at its deadline and delivered late is never applied.
- recover() replaces a client only when a call failed on its connection or
  it connected and then dropped — never one still making its first
  connection.
- Reverts an unrelated .pyc change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ery vehicle

With transit rooms only at z11 (~15 km) and at most 20 rooms per
subscription, transit vanished from any view wider than ~z10.5: all of
Catalonia showed no Rodalies train at all. Rooms now come at a few levels
per kind (transit z7, z9, z11); every entity is indexed in one room per
level, and a subscription takes, per kind, the finest level that covers its
view in at most 20 rooms — a borough in a city, the z7 rooms for a region.
Street views keep their small rooms and bytes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…y stop areas, counter epochs

From the independent review of db334c8; each with a test that fails without it.

- The subscription's answer lists its rooms PER KIND (`tiles: { transit,
  aircraft, ... }`) and the client keeps exactly those (tile, kind) pairs:
  an aircraft z7 tile no longer keeps the transit z7 room of the same tile,
  whose buses froze there after zooming into Midtown.
- Only rail (`LIVE_REGIONAL_TRANSIT_MODES`: rail, subway) is in the regional
  transit rooms; buses and every street-level mode are in z11 rooms alone
  (`liveRoomZoomsOf`, the one source of truth in the contract). A regional
  view of New York: 3.4 KiB of snapshots (77 trains) instead of 100.2 KiB
  (2,344 vehicles). A source of street modes only (`transitModes`) is not
  woken by a regional view.
- Static GTFS is read for local demand only from the rooms that can use
  shapes (the finest transit level; `LIVE_SHAPES_MIN_ZOOM`, now shared).
- Demand is matched against each source's areas, derived from its
  operator's stops (`areas.ts`, `live:areas` → `areas.generated.ts`), and
  its coverage is the box round them: a street view of Toulouse no longer
  wakes Renfe.
- Lease epochs come from a per-source `INCR` counter, not Valkey's clock;
  a follower takes a lower epoch once the newer holder has been silent a
  whole lease, so a failover that lost the counter cannot leave it deaf.

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

Copy link
Copy Markdown
Member Author

4e069de addresses all five review findings. Each fix has a test that fails without it. CI is green.

  1. Stale vehicles after zooming in. live:subscription now lists tiles per kind, and the client keeps exactly those (tile, kind) rooms. Tests: liveStore.test.ts, socket.test.ts, SDK.
  2. Regional transit payload. Only rail (LIVE_REGIONAL_TRANSIT_MODES: rail, subway) is in the z7 and z9 transit rooms. Buses and every other street-level mode are in z11 rooms only, via liveRoomZoomsOf, defined once in the contract. A source carrying only street-level modes (transitModes) is not woken by a regional view. Regional New York (four z9 rooms, real feeds, 21:15 local):
    • Snapshots: 3.4 KiB (77 trains) instead of 100.2 KiB (2,344 vehicles).
    • Deltas: 16.3 KiB over 19 s instead of 34.8 KiB.
  3. Static GTFS demand. It now counts only the rooms where shapes are asked for: the finest transit level, from LIVE_SHAPES_MIN_ZOOM, which is now in the contract.
  4. Coverage. Demand is matched against each source's areas, derived from its operator's own stops by live:areas into areas.generated.ts. Coverage is the box around those areas. A street view of Toulouse no longer wakes Renfe.
  5. Epochs. They come from a per-source INCR counter with no TTL. A follower accepts a lower epoch once the newer holder has been silent for a whole lease. Tested against a real Valkey as well.

#194 and #195 are rebased onto this commit. #195 derives areas for 51 of its 73 feeds.

🤖 Generated with Claude Code

@NateIsern
NateIsern merged commit a078b93 into main Oct 13, 2026
13 checks passed
@NateIsern
NateIsern deleted the feat/live-production branch October 13, 2026 00:29
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