77 *
88 * See `specs/gbfs-health-page.md` for the surfaced shape.
99 */
10+ import { floorToSpan , listExpectedShards , parseDuration , type Pyramid } from 'pyrmts' ;
1011
1112interface 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. */
324347export 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
398487export async function getTripdataHealth ( r2 : HealthR2 ) : Promise < TripdataHealth | null > {
0 commit comments