Skip to content

Commit 2fbab77

Browse files
ryan-williamsclaude
andcommitted
/health: min-cover status per tier (replaces rung-count pivot)
Bumps gbfs/api's pyrmts pin c1f1f1b → f96532b — the old pin predates 9d16761 ("gap-discovery: emit minimal cover"), so its \`listExpectedShards\` enumerated every rung × every period (full inventory). The cascade worker was already on the min-cover impl; the api worker was the straggler. \`getPyramidsHealth\` now reports what actually matters per tier: the min-cover of \`[genesis, now)\` — max-rung tiles over closed history + trailing finer-rung "dust" — rather than raw per-rung shard counts (which mostly surfaced stale intermediates that never re-enter the cover): - max rung present/expected + per-dust-rung present/expected - \`firstMissingPeriod\` — oldest cover gap - \`dustAgeSec\` — time since last max-rung boundary - \`staleShardCount\` — indexed shards outside the cover (GC candidates) - per-pyramid \`allComplete\` / \`totalMissing\` / \`totalStale\` \`AVAIL_GENESIS\` exported from \`avail_geo.ts\` (kept in sync with the cascade worker's copy). FE \`PyramidsSection\` rewritten to one row per tier: ✓/✗, max-rung N/M, dust breakdown, dust age, stale count, first-missing timestamp. First render against prod D1 immediately surfaced a real gap: all 10 finest tiers' \`firstMissingPeriod\` = today's midnight UTC — the cascade hasn't produced the 2026-07-09T00:00 boundary shards (debugging separately). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent e8f9edc commit 2fbab77

5 files changed

Lines changed: 313 additions & 145 deletions

File tree

gbfs/api/package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@
1818
"@rdub/file-tree": "https://github.com/runsascoded/file-tree#d3476b0d2cb398197f3a1383f41fe3b0216be39f",
1919
"@rdub/thrds": "https://github.com/runsascoded/thrds#ts-dist",
2020
"hyparquet": "https://github.com/runsascoded/hyparquet#0f355ea5a37dcb9b29a43e33a3d45ce660c80798",
21-
"pyrmts": "https://github.com/runsascoded/pyrmts#c1f1f1bcd5854519c41032d03d9bed5d3d1e76b3&path:/js/packages/pyrmts",
22-
"pyrmts-cfw": "https://github.com/runsascoded/pyrmts#c1f1f1bcd5854519c41032d03d9bed5d3d1e76b3&path:/js/packages/pyrmts-cfw",
23-
"pyrmts-geo": "https://github.com/runsascoded/pyrmts#c1f1f1bcd5854519c41032d03d9bed5d3d1e76b3&path:/js/packages/pyrmts-geo"
21+
"pyrmts": "https://github.com/runsascoded/pyrmts#f96532b4ac35d2934b0c256d91a8dce4ba7744b2&path:/js/packages/pyrmts",
22+
"pyrmts-cfw": "https://github.com/runsascoded/pyrmts#f96532b4ac35d2934b0c256d91a8dce4ba7744b2&path:/js/packages/pyrmts-cfw",
23+
"pyrmts-geo": "https://github.com/runsascoded/pyrmts#f96532b4ac35d2934b0c256d91a8dce4ba7744b2&path:/js/packages/pyrmts-geo"
2424
}
2525
}

gbfs/api/pnpm-lock.yaml

Lines changed: 17 additions & 17 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

gbfs/api/src/avail_geo.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,12 @@ export const TIERS: Tier[] = [
9292
const KEY_TEMPLATE = 'avail-v3/{tier}/{shard}/{period}.parquet';
9393
const RESOLUTIONS = [15, 14, 13, 12, 11, 10];
9494

95+
/** avail-v3 genesis: earliest 5-min-aligned UTC timestamp intersecting any
96+
* materialized shard. Used by the /health min-cover computation. Keep in
97+
* sync with `gbfs/cascade/src/avail3/cascade.ts` — same value must hold on
98+
* both workers so cover math agrees on the closed-history region. */
99+
export const AVAIL_GENESIS = new Date('2026-04-07T01:15:00Z');
100+
95101
function makeBaseProps(bucket: R2Bucket): Omit<GeoPyramid, 'dims'> {
96102
return {
97103
storage: parquetBackend(r2Storage(bucket)),

gbfs/api/src/health.ts

Lines changed: 152 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
*
88
* See `specs/gbfs-health-page.md` for the surfaced shape.
99
*/
10+
import { floorToSpan, listExpectedShards, parseDuration, type Pyramid } from 'pyrmts';
1011

1112
interface R2Object {
1213
key: string;
@@ -57,27 +58,46 @@ export interface CascadeHealth {
5758
expectedCells: Array<{ agg: string; cons: string; deployed: boolean }>;
5859
}
5960

60-
/** Per-(tier, shard_dur) status for a unified-shard-ladder pyramid
61-
* (avail-v3, rides-v3). One row per declared ladder rung. */
62-
export interface PyramidShardStatus {
61+
/** Per-rung slot in a tier's current min-cover of `[genesis, now)`. In
62+
* equilibrium each tier's min-cover is mostly max-rung tiles filling the
63+
* closed-history region `[genesis, floor(now, max_rung))`, plus a small
64+
* "dust" of finer-rung tiles filling `[floor(now, max_rung), now)`. A rung
65+
* entry counts how many shards the min-cover places at that rung and how
66+
* many actually exist in the ShardIndex. */
67+
export interface PyramidCoverRung {
68+
shardDur: string;
69+
role: 'max' | 'dust';
70+
expected: number; // shards the min-cover requires at this rung
71+
present: number; // of those, how many are in D1
72+
}
73+
74+
/** Per-tier min-cover status. `complete` iff every rung's `present === expected`. */
75+
export interface PyramidTierCoverStatus {
6376
tier: string;
64-
shardDur: string; // e.g. '5min', '1d', '4320d'
65-
shardCount: number; // # shards recorded in ShardIndex
66-
earliestPeriodStart: string | null; // ISO ms
67-
latestPeriodEnd: string | null; // ISO ms
68-
watermarkEnd: string | null; // pyramid_watermarks.latest_period_end
77+
bin: string;
78+
maxRung: string;
79+
rungs: PyramidCoverRung[]; // ordered oldest → newest
80+
totalExpected: number;
81+
totalPresent: number;
82+
complete: boolean;
83+
firstMissingPeriod: string | null; // ISO of oldest missing cover shard, if any
84+
lastMaxBoundary: string; // ISO of floor(now, max_rung) — dust head
85+
dustAgeSec: number; // now - lastMaxBoundary, in seconds
86+
staleShardCount: number; // present shards NOT in current min-cover
6987
}
7088

71-
export interface PyramidStatus {
89+
/** Per-pyramid roll-up. `allComplete` iff every tier's min-cover is satisfied. */
90+
export interface PyramidCoverStatus {
7291
name: string; // 'avail' / 'rides' etc.
73-
tiers: Array<{
74-
tier: string;
75-
bin: string;
76-
shards: PyramidShardStatus[]; // one per declared rung
77-
}>;
92+
genesis: string; // ISO — cover computed over [genesis, now)
93+
now: string; // ISO snapshot time
94+
tiers: PyramidTierCoverStatus[];
95+
totalMissing: number; // sum of (expected - present) across tiers
96+
totalStale: number; // sum of staleShardCount across tiers
97+
allComplete: boolean;
7898
}
7999

80-
export type PyramidsHealth = PyramidStatus[];
100+
export type PyramidsHealth = PyramidCoverStatus[];
81101

82102
/** Recency of `s3://tripdata` — the upstream Citi Bike monthly publish.
83103
* Sourced from `tripdata/latest.json` in R2, refreshed every `tripdata.yml`
@@ -313,14 +333,17 @@ interface RawTripdataSummary {
313333
total_zips: number;
314334
}
315335

316-
/** Snapshot per-(tier, shard_dur) status for unified-shard-ladder
317-
* pyramids. Reads D1 directly (`pyramid_shards` for counts/extents,
318-
* `pyramid_watermarks` for watermarks). Tier declarations come from
319-
* `avail_geo.ts`'s `TIERS`; tracks the same ladders the api worker
320-
* serves queries from.
336+
/** Snapshot per-tier min-cover status for unified-shard-ladder pyramids.
337+
* Reads `pyramid_shards` directly (all rows: `tier, shard_dur,
338+
* period_start`), computes the expected min-cover for `[genesis, now)`
339+
* from `avail_geo.ts`'s `TIERS`, and reports:
340+
* - which cover slots are satisfied by present shards
341+
* - the first missing period (if any)
342+
* - `staleShardCount`: present shards NOT in the current min-cover
343+
* (usually old max-rung tiles that got superseded — GC candidates)
321344
*
322-
* D1 column compatibility: pre-P3 the column is `cadence`; post-P3
323-
* it's `shard_dur`. Probe schema once and substitute. */
345+
* D1 column compatibility: pre-P3 the column is `cadence`; post-P3 it's
346+
* `shard_dur`. Probe schema once and substitute. */
324347
export async function getPyramidsHealth(db: D1Database): Promise<PyramidsHealth> {
325348
// Probe which column name the D1 schema uses (P3 transition).
326349
let shardCol = 'shard_dur';
@@ -334,29 +357,22 @@ export async function getPyramidsHealth(db: D1Database): Promise<PyramidsHealth>
334357
}
335358

336359
const PYRAMID = 'avail';
337-
const { TIERS } = await import('./avail_geo');
360+
const { TIERS, AVAIL_GENESIS } = await import('./avail_geo');
338361
const largestPerTier: Record<string, string> = Object.fromEntries(
339362
TIERS.map((t) => [t.name, t.shards[t.shards.length - 1]!])
340363
);
341364

342-
// One query for all shards of this pyramid; one for watermarks.
343-
type ShardRow = { tier: string; sd: string; n: number; earliest: number; latest: number };
344-
type WmRow = { tier: string; sd: string; latest_period_end: number };
345-
const shardSql = `SELECT tier, ${shardCol} AS sd, COUNT(*) AS n, ` +
346-
`MIN(period_start) AS earliest, MAX(period_end) AS latest ` +
347-
`FROM pyramid_shards WHERE pyramid = ? GROUP BY tier, ${shardCol}`;
348-
const wmSql = `SELECT tier, ${shardCol} AS sd, latest_period_end ` +
349-
`FROM pyramid_watermarks WHERE pyramid = ?`;
365+
// All shard rows for this pyramid. ~5-10k rows for avail-v3 today; well
366+
// within a single D1 query.
367+
type ShardRow = { tier: string; sd: string; period_start: number };
368+
const shardSql =
369+
`SELECT tier, ${shardCol} AS sd, period_start ` +
370+
`FROM pyramid_shards WHERE pyramid = ?`;
350371

351372
let shardRows: ShardRow[] = [];
352-
let wmRows: WmRow[] = [];
353373
try {
354-
const [s, w] = await Promise.all([
355-
db.prepare(shardSql).bind(PYRAMID).all<ShardRow>(),
356-
db.prepare(wmSql).bind(PYRAMID).all<WmRow>(),
357-
]);
374+
const s = await db.prepare(shardSql).bind(PYRAMID).all<ShardRow>();
358375
shardRows = s.results ?? [];
359-
wmRows = w.results ?? [];
360376
} catch {
361377
// D1 unavailable — return empty.
362378
return [];
@@ -366,33 +382,106 @@ export async function getPyramidsHealth(db: D1Database): Promise<PyramidsHealth>
366382
const norm = (tier: string, sd: string): string =>
367383
sd === '' ? (largestPerTier[tier] ?? sd) : sd;
368384

369-
const shardsByKey = new Map<string, ShardRow>();
370-
for (const r of shardRows) shardsByKey.set(`${r.tier}@${norm(r.tier, r.sd)}`, r);
371-
const wmByKey = new Map<string, WmRow>();
372-
for (const r of wmRows) wmByKey.set(`${r.tier}@${norm(r.tier, r.sd)}`, r);
373-
374-
const isoMs = (ms: number | null): string | null =>
375-
ms === null || ms === undefined ? null : new Date(ms).toISOString();
376-
377-
const tiers = TIERS.map((t) => ({
378-
tier: t.name,
379-
bin: String(t.bin),
380-
shards: t.shards.map((sd) => {
381-
const key = `${t.name}@${sd}`;
382-
const s = shardsByKey.get(key);
383-
const w = wmByKey.get(key);
384-
return {
385-
tier: t.name,
386-
shardDur: String(sd),
387-
shardCount: s ? s.n : 0,
388-
earliestPeriodStart: s ? isoMs(s.earliest) : null,
389-
latestPeriodEnd: s ? isoMs(s.latest) : null,
390-
watermarkEnd: w ? isoMs(w.latest_period_end) : null,
391-
};
392-
}),
393-
}));
385+
// Index: `tier\x00shardDur\x00periodStartMs` → present.
386+
const presentKey = (tier: string, sd: string, periodStartMs: number) =>
387+
`${tier}\x00${sd}\x00${periodStartMs}`;
388+
const present = new Set<string>();
389+
const presentByTier: Record<string, number> = {};
390+
for (const r of shardRows) {
391+
const sd = norm(r.tier, r.sd);
392+
present.add(presentKey(r.tier, sd, r.period_start));
393+
presentByTier[r.tier] = (presentByTier[r.tier] ?? 0) + 1;
394+
}
395+
396+
// Min-cover of [genesis, now] per tier via pyrmts. `listExpectedShards`
397+
// returns max-rung tiles filling the closed-history region + dust rungs
398+
// for the trailing partial-max window. We infer `role` from the shardDur
399+
// (max iff === tier's largest rung).
400+
const now = new Date();
401+
// pyrmts's Pyramid requires more fields for full query use, but
402+
// listExpectedShards only reads `.tiers` and `.keyTemplate`. Cast the
403+
// stub to shut the compiler up.
404+
const pyramid = {
405+
tiers: TIERS,
406+
keyTemplate: 'avail-v3/{tier}/{shard}/{period}.parquet',
407+
} as unknown as Pyramid;
408+
const expected = listExpectedShards(pyramid, { from: AVAIL_GENESIS, to: now });
409+
const expectedByTier = new Map<string, typeof expected>();
410+
for (const e of expected) {
411+
const list = expectedByTier.get(e.tier) ?? [];
412+
list.push(e);
413+
expectedByTier.set(e.tier, list);
414+
}
415+
416+
const tiers: PyramidTierCoverStatus[] = [];
417+
let totalMissing = 0;
418+
let totalStale = 0;
419+
420+
for (const t of TIERS) {
421+
const maxRung = t.shards[t.shards.length - 1]!;
422+
const maxSpan = parseDuration(maxRung);
423+
const lastMaxBoundary = floorToSpan(now, maxSpan);
424+
const cover = expectedByTier.get(t.name) ?? [];
425+
426+
// Aggregate by rung (preserving order of first appearance in cover).
427+
const rungOrder: string[] = [];
428+
const rungAgg: Record<string, { expected: number; present: number; role: 'max' | 'dust' }> = {};
429+
let firstMissingPeriod: string | null = null;
430+
let coverPresent = 0;
431+
432+
for (const slot of cover) {
433+
const role: 'max' | 'dust' = slot.shardDur === maxRung ? 'max' : 'dust';
434+
if (!(slot.shardDur in rungAgg)) {
435+
rungOrder.push(slot.shardDur);
436+
rungAgg[slot.shardDur] = { expected: 0, present: 0, role };
437+
}
438+
const agg = rungAgg[slot.shardDur]!;
439+
agg.expected += 1;
440+
const key = presentKey(t.name, slot.shardDur, slot.periodStart.getTime());
441+
if (present.has(key)) {
442+
agg.present += 1;
443+
coverPresent += 1;
444+
} else if (firstMissingPeriod === null) {
445+
firstMissingPeriod = slot.periodStart.toISOString();
446+
}
447+
}
448+
449+
const rungs: PyramidCoverRung[] = rungOrder.map((sd) => ({
450+
shardDur: sd,
451+
role: rungAgg[sd]!.role,
452+
expected: rungAgg[sd]!.expected,
453+
present: rungAgg[sd]!.present,
454+
}));
455+
const totalExpected = cover.length;
456+
const staleShardCount = Math.max(0, (presentByTier[t.name] ?? 0) - coverPresent);
457+
458+
totalMissing += (totalExpected - coverPresent);
459+
totalStale += staleShardCount;
460+
461+
tiers.push({
462+
tier: t.name,
463+
bin: String(t.bin),
464+
maxRung,
465+
rungs,
466+
totalExpected,
467+
totalPresent: coverPresent,
468+
complete: coverPresent === totalExpected,
469+
firstMissingPeriod,
470+
lastMaxBoundary: lastMaxBoundary.toISOString(),
471+
dustAgeSec: Math.floor((now.getTime() - lastMaxBoundary.getTime()) / 1000),
472+
staleShardCount,
473+
});
474+
}
394475

395-
return [{ name: PYRAMID, tiers }];
476+
return [{
477+
name: PYRAMID,
478+
genesis: AVAIL_GENESIS.toISOString(),
479+
now: now.toISOString(),
480+
tiers,
481+
totalMissing,
482+
totalStale,
483+
allComplete: totalMissing === 0,
484+
}];
396485
}
397486

398487
export async function getTripdataHealth(r2: HealthR2): Promise<TripdataHealth | null> {

0 commit comments

Comments
 (0)