|
15 | 15 | import os |
16 | 16 | import subprocess |
17 | 17 | import sys |
| 18 | +import time |
18 | 19 | from collections import Counter |
19 | 20 | from datetime import datetime, timedelta, timezone |
20 | 21 | from pathlib import Path |
@@ -155,6 +156,78 @@ def _fmt(ms: int | None) -> tuple[str, float | None]: |
155 | 156 | print(f'{r["tier"]:>4} {r["shard_dur"]:>10} {r["n"]:>7} {earliest:>16} {latest:>16} {age_str}') |
156 | 157 |
|
157 | 158 |
|
| 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 | + |
158 | 231 | # ─── cascade tick ─────────────────────────────────────────────────── |
159 | 232 |
|
160 | 233 | # Env → worker URL. Dev is used constantly for smoke tests; prod |
|
0 commit comments