Skip to content

Commit 4cd4135

Browse files
ryan-williamsclaude
andcommitted
ctbk gbfs manifest {status,backfill} + manifest_status/manifest_fill registry ops
Backfill runs server-side: the CLI drives the api worker's registry proxy, which parses each shard's footer via its D1 binding + R2 binding (truthful side of the 2026-07-28 REST split-brain) and fills `rg_manifest` rows synchronously. `status` reports fill coverage per pyramid incl. stale fills (shard re-registered since fill). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 61f6407 commit 4cd4135

3 files changed

Lines changed: 141 additions & 2 deletions

File tree

ctbk/gbfs_cli.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import os
1616
import subprocess
1717
import sys
18+
import time
1819
from collections import Counter
1920
from datetime import datetime, timedelta, timezone
2021
from pathlib import Path
@@ -155,6 +156,78 @@ def _fmt(ms: int | None) -> tuple[str, float | None]:
155156
print(f'{r["tier"]:>4} {r["shard_dur"]:>10} {r["n"]:>7} {earliest:>16} {latest:>16} {age_str}')
156157

157158

159+
# ─── RG manifest (specs/rg-manifest.md) ─────────────────────────────
160+
161+
MANIFEST_PYRAMIDS = ('rides-v5-start', 'rides-v5-end')
162+
163+
164+
def _registry_post(env_name: str, body: dict) -> dict:
165+
"""POST a registry-proxy op to the api worker (Bearer
166+
`CTBK_REGISTRY_SECRET`). All D1 access happens worker-side via the
167+
binding — the truthful side of the 2026-07-28 REST split-brain."""
168+
import urllib.request
169+
secret = os.environ.get('CTBK_REGISTRY_SECRET')
170+
if not secret:
171+
raise click.ClickException('CTBK_REGISTRY_SECRET not set. `source .envrc`.')
172+
req = urllib.request.Request(
173+
f'{API_URLS[env_name]}/api/registry',
174+
data=json.dumps(body).encode(),
175+
headers={
176+
'Authorization': f'Bearer {secret}',
177+
'Content-Type': 'application/json',
178+
'User-Agent': 'ctbk-gbfs-cli/1.0',
179+
},
180+
)
181+
with urllib.request.urlopen(req, timeout=120) as resp:
182+
return json.loads(resp.read())
183+
184+
185+
@gbfs.group('manifest', help='RG manifest: D1 row-group index for parquet pyramid serving (`specs/rg-manifest.md`).')
186+
def gbfs_manifest() -> None:
187+
pass
188+
189+
190+
@gbfs_manifest.command('status', help='Fill coverage per pyramid: registered keys vs completed fills (+ stale fills whose shard was re-registered).')
191+
@option('-e', '--env', 'env_name', type=click.Choice(['dev', 'prod']), default='prod', show_default=True, help='api worker to query (shared D1, but ops must be deployed there).')
192+
@option('-p', '--pyramid', 'pyramids', multiple=True, help=f'Pyramid name (repeatable) [default: {", ".join(MANIFEST_PYRAMIDS)}].')
193+
@option('-v', '--verbose', is_flag=True, help='List unfilled/stale keys.')
194+
def gbfs_manifest_status(env_name: str, pyramids: tuple[str, ...], verbose: bool) -> None:
195+
for pyramid in pyramids or MANIFEST_PYRAMIDS:
196+
s = _registry_post(env_name, {'op': 'manifest_status', 'pyramid': pyramid})
197+
print(f'{pyramid}: {s["filled"]}/{s["registered"]} filled, {len(s["stale"])} stale, {len(s["unfilled"])} unfilled')
198+
if verbose:
199+
for k in s['stale']:
200+
print(f' stale: {k}')
201+
for k in s['unfilled']:
202+
print(f' unfilled: {k}')
203+
204+
205+
@gbfs_manifest.command('backfill', help='Fill manifest rows for unfilled/stale shards (worker parses each footer server-side via `manifest_fill`; sequential, idempotent).')
206+
@option('-e', '--env', 'env_name', type=click.Choice(['dev', 'prod']), default='prod', show_default=True, help='api worker to run fills on.')
207+
@option('-m', '--max', 'max_keys', type=int, default=None, help='Stop after this many fills.')
208+
@option('-n', '--dry-run', is_flag=True, help='List keys that would fill; no writes.')
209+
@option('-p', '--pyramid', 'pyramids', multiple=True, help=f'Pyramid name (repeatable) [default: {", ".join(MANIFEST_PYRAMIDS)}].')
210+
def gbfs_manifest_backfill(env_name: str, max_keys: int | None, dry_run: bool, pyramids: tuple[str, ...]) -> None:
211+
done = 0
212+
for pyramid in pyramids or MANIFEST_PYRAMIDS:
213+
s = _registry_post(env_name, {'op': 'manifest_status', 'pyramid': pyramid})
214+
todo = s['unfilled'] + s['stale']
215+
err(f'{pyramid}: {len(todo)} to fill ({len(s["unfilled"])} unfilled, {len(s["stale"])} stale)')
216+
for key in todo:
217+
if max_keys is not None and done >= max_keys:
218+
err(f'stopping at --max {max_keys}')
219+
return
220+
if dry_run:
221+
print(key)
222+
continue
223+
t0 = time.time()
224+
res = _registry_post(env_name, {'op': 'manifest_fill', 'pyramid': pyramid, 'key': key})
225+
done += 1
226+
err(f' filled {key}: {res["n_rgs"]} RGs in {time.time() - t0:.1f}s')
227+
if not dry_run:
228+
err(f'{done} fills')
229+
230+
158231
# ─── cascade tick ───────────────────────────────────────────────────
159232

160233
# Env → worker URL. Dev is used constantly for smoke tests; prod

gbfs/api/src/index.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -494,7 +494,9 @@ import { computeAndStoreHealthSnapshot, readCachedHealthSnapshot } from './healt
494494
import { runAlerts } from './alerts';
495495
import { DEFAULT_PYRAMID, repairGeneration, serveAvailV3, serveAvailV3Cells } from './avail_geo';
496496
import { serveRidesV1, serveRidesV1Cells, serveRidesV2, serveRidesV2Cells, serveRidesV3, serveRidesV3Cells, serveRidesV5 } from './rides_v1';
497-
import { withR2Retry } from './r2_retry';
497+
import { retryingStorage, withR2Retry } from './r2_retry';
498+
import { r2Storage } from 'pyrmts-cfw';
499+
import { backfillManifestKey, manifestStatus } from './rg_manifest';
498500

499501
/**
500502
* Build an `AsyncBuffer` (hyparquet's slice-based file abstraction) backed by
@@ -1192,7 +1194,7 @@ export default {
11921194
const auth = request.headers.get('Authorization') ?? '';
11931195
if (auth !== `Bearer ${env.REGISTRY_SECRET}`) return errorResponse('unauthorized', 403, env);
11941196
try {
1195-
const body = await request.json<{ op: string; pyramid?: string; rows?: {
1197+
const body = await request.json<{ op: string; pyramid?: string; key?: string; rows?: {
11961198
pyramid: string; tier: string; shard_dur: string;
11971199
period_start: number; period_end: number; key: string; written_at: number;
11981200
}[] }>();
@@ -1226,6 +1228,18 @@ export default {
12261228
console.log(`registry: register n=${rows.length} keys=${rows.map((r) => r.key).join(',')} d1_meta=${JSON.stringify(meta)}`);
12271229
return jsonResponse({ registered: rows.length, entry, d1: meta }, env);
12281230
}
1231+
// RG-manifest ops (`specs/rg-manifest.md`; `ctbk gbfs manifest`).
1232+
if (body.op === 'manifest_status') {
1233+
if (!body.pyramid) return errorResponse('pyramid required', 400, env);
1234+
return jsonResponse(await manifestStatus(env.DB, body.pyramid), env);
1235+
}
1236+
if (body.op === 'manifest_fill') {
1237+
if (!body.pyramid || !body.key) return errorResponse('pyramid + key required', 400, env);
1238+
const t0 = performance.now();
1239+
const storage = retryingStorage(r2Storage(env.R2));
1240+
const res = await backfillManifestKey(env.DB, storage, body.pyramid, body.key);
1241+
return jsonResponse({ ...res, ms: Math.round(performance.now() - t0) }, env);
1242+
}
12291243
return errorResponse(`unknown op ${body.op}`, 400, env);
12301244
} catch (err: any) {
12311245
return errorResponse(err.message ?? 'registry proxy failed', 500, env);

gbfs/api/src/rg_manifest.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,58 @@ async function fallbackFetch(opts: SegmentFetchOpts): Promise<Row[]> {
260260
}
261261
}
262262

263+
/** Registry-proxy backfill (`ctbk gbfs manifest backfill`): parse one
264+
* shard's footer and fill its manifest rows, synchronously. `written_at`
265+
* is read from `pyramid_shards` via the same binding (truthful side of
266+
* the 2026-07-28 split-brain). */
267+
export async function backfillManifestKey(
268+
db: D1Database,
269+
storage: Storage,
270+
pyramid: string,
271+
key: string,
272+
): Promise<{ n_rgs: number; written_at: number }> {
273+
const row = await db.prepare('SELECT written_at FROM pyramid_shards WHERE pyramid = ? AND key = ?')
274+
.bind(pyramid, key).first<{ written_at: number }>();
275+
const writtenAt = row?.written_at ?? 0;
276+
const release = await acquireFooterSlot(30_000);
277+
try {
278+
const head = await storage.head(key);
279+
if (head === null) throw new Error(`backfillManifestKey: object not found: ${key}`);
280+
const file = storageBuffer(storage, key, head.size);
281+
const metadata = await parquetMetadataAsync(file, { initialFetchSize: INITIAL_FETCH_SIZE });
282+
await fillManifestInner(db, pyramid, key, writtenAt, metadata);
283+
return { n_rgs: metadata.row_groups.length, written_at: writtenAt };
284+
} finally {
285+
release();
286+
}
287+
}
288+
289+
/** Fill coverage for a pyramid: registered keys vs completed fills, with
290+
* stale fills (shard re-registered since) called out separately. */
291+
export async function manifestStatus(
292+
db: D1Database,
293+
pyramid: string,
294+
): Promise<{ registered: number; filled: number; stale: string[]; unfilled: string[] }> {
295+
const [shards, fills] = await db.batch([
296+
db.prepare('SELECT key, written_at FROM pyramid_shards WHERE pyramid = ?').bind(pyramid),
297+
db.prepare('SELECT key, shard_written_at FROM rg_manifest_fills WHERE pyramid = ?').bind(pyramid),
298+
]);
299+
const fillMap = new Map(
300+
((fills.results ?? []) as { key: string; shard_written_at: number }[])
301+
.map((r) => [r.key, r.shard_written_at]),
302+
);
303+
const stale: string[] = [];
304+
const unfilled: string[] = [];
305+
let filled = 0;
306+
for (const s of (shards.results ?? []) as { key: string; written_at: number }[]) {
307+
const fillAt = fillMap.get(s.key);
308+
if (fillAt === undefined) unfilled.push(s.key);
309+
else if (fillAt !== s.written_at) stale.push(s.key);
310+
else filled++;
311+
}
312+
return { registered: (shards.results ?? []).length, filled, stale, unfilled };
313+
}
314+
263315
/** In-isolate single-flight: skip duplicate fills for a key already being
264316
* filled here. Cross-isolate races are harmless: fills are idempotent
265317
* (OR REPLACE, identical content for identical (key, written_at)), and

0 commit comments

Comments
 (0)