Skip to content

Commit e211162

Browse files
ryan-williamsclaude
andcommitted
smg-v1 stand-up: worker reconcile, engine sweep/adopt, capped + self-healing daily fill
Stand-up of the SMG pyramid (`specs/avail-smg-pyramid.md`, done 2026-09-07 from `e`): 152 source days backfilled, image `ctbk-engine:f5fefbe1` (job-def rev 12), Batch `-f` build → 224 shards registered in D1, cover [2026-04-07, 2026-09-06). Two engine-side surprises, both worked around here and documented in the spec: - The engine's default `mem_budget` (70% of the cgroup limit) read the Fargate *host* (46 GB in a 32 GiB container) → OOM at 96/307 windows. Every `engine submit` now passes `-b` (pyrmts spec filed). - The Batch base image (pyrmts `ed50cdb`) predates open-period classification, so an uncapped fill writes 0-row trailing shards over not-yet-existing days and then fails the strict source check. New `ctbk gbfs engine sweep CUTOFF` deletes such shards from R2 + D1 + manifest; new `ctbk gbfs engine adopt` records shards a dead job flushed but never wrote to the manifest; `engine manifest -H N` dumps raw records. Daily cadence (`gbfs-compact.yml`): a `ctbk gbfs smg backfill -C -k -t <day>` self-heal step (no-op when nothing is missing) precedes the fill, and the fill is capped at `<day+1>T00:00` (`-r`) with `-b 20g -w 1h`. `RECONCILE_PYRAMIDS` in the api worker now includes `smg-v1`, so the daily fill's new shards self-register. Spec also records the heartbeat era (2026-05-03): before it `no_poll` ≡ "unpolled or stale". Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GgLAzeRJeNeyizG4XHWRBK
1 parent f5fefbe commit e211162

4 files changed

Lines changed: 217 additions & 15 deletions

File tree

.github/workflows/gbfs-compact.yml

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ jobs:
4646
echo "trigger_mo1=$([ "$NEXT_MONTH" = "01" ] && [ "$DOM" = "01" ] && echo true || echo false)" >> "$GITHUB_OUTPUT"
4747
echo "ym=$(echo $DATE | cut -c 1-7)" >> "$GITHUB_OUTPUT"
4848
echo "year=$(echo $DATE | cut -c 1-4)" >> "$GITHUB_OUTPUT"
49+
# Pyramid fills are capped at the end of DATE: the last day whose source exists.
50+
echo "range_to=$(date -u -d "$DATE +1 day" +%Y-%m-%dT00:00)" >> "$GITHUB_OUTPUT"
4951
5052
- name: Compact WAL → parquet
5153
run: python3 gbfs/compact-r2.py all ${{ steps.date.outputs.date }}
@@ -60,12 +62,31 @@ jobs:
6062
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
6163
run: ctbk gbfs empty build -C -V ${{ steps.date.outputs.date }}
6264

63-
# SMG pyramid (`specs/avail-smg-pyramid.md`): `empty build` above also wrote the
64-
# day's `gbfs/smg/<day>.parquet`; a declarative Batch gap-fill (`-f`) builds only
65-
# the rungs that new day completes, into the REAL prefix (`-p smg-v1` — the
66-
# default would be the `-engine-check` scratch prefix). Own AWS env: the
67-
# job-level `AWS_*` above point at R2, and Batch is real AWS (us-east-1).
68-
# `-W` tails the job so a failed fill fails this step.
65+
# SMG source (`specs/avail-smg-pyramid.md`): `empty build` above also wrote the
66+
# day's `gbfs/smg/<day>.parquet`. This self-heals any day that has a status
67+
# parquet but no SMG parquet (a missed run, or days compacted before the SMG
68+
# step existed) — a no-op when nothing is missing — so the fill below never
69+
# sees a real source hole.
70+
- name: Build any missing gbfs/smg day parquets
71+
env:
72+
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
73+
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
74+
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
75+
run: ctbk gbfs smg backfill -C -k -t ${{ steps.date.outputs.date }}
76+
77+
# SMG pyramid: a declarative Batch gap-fill (`-f`) builds only the rungs the new
78+
# day completes, into the REAL prefix (`-p smg-v1` — the default would be the
79+
# `-engine-check` scratch prefix). Own AWS env: the job-level `AWS_*` above
80+
# point at R2, and Batch is real AWS (us-east-1). `-W` tails the job so a
81+
# failed fill fails this step.
82+
# `-r /<range_to>` CAPS the range at the end of the day just built: the Batch
83+
# base image (pyrmts ed50cdb) predates open-period classification, so an
84+
# uncapped fill would (a) fail the strict missing-source check on today's
85+
# not-yet-existing parquet and (b) first write 0-row shards over it, which
86+
# every later fill then treats as built (`ctbk gbfs engine sweep` undoes that).
87+
# `-b 20g`: the engine's default budget (70% of the cgroup limit) reads the
88+
# Fargate HOST here (46 GB in a 32 GiB container → OOM on the first full
89+
# build); an explicit budget under the container size keeps a catch-up safe.
6990
- name: Fill smg-v1 pyramid (Batch)
7091
env:
7192
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
@@ -76,7 +97,7 @@ jobs:
7697
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
7798
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
7899
CTBK_REGISTRY_SECRET: ${{ secrets.CTBK_REGISTRY_SECRET }}
79-
run: ctbk gbfs engine submit -C smg-v1 -p smg-v1 -x ctbk_engine_src:smg_daily -f -W
100+
run: ctbk gbfs engine submit -C smg-v1 -p smg-v1 -x ctbk_engine_src:smg_daily -f -W -b 20g -w 1h -r /${{ steps.date.outputs.range_to }}
80101

81102
- name: Build /h1 avail-agg for the day
82103
run: |

ctbk/gbfs_cli.py

Lines changed: 163 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from __future__ import annotations
1313

1414
import json
15+
import re
1516
import os
1617
import subprocess
1718
import sys
@@ -1594,10 +1595,15 @@ def gbfs_engine_register(
15941595

15951596
@gbfs_engine.command('manifest', help='Summarize a build manifest (local path or `s3://` URL): shard counts per (tier, rung), period span; optionally diff key sets vs another manifest.')
15961597
@option('-d', '--diff', 'other', default=None, help='Second manifest; report key-set differences.')
1598+
@option('-H', '--head', 'head_n', type=int, default=0, help='Print the first N raw records (JSONL) instead of the summary.')
15971599
@argument('path', metavar='PATH')
1598-
def gbfs_engine_manifest(other: str | None, path: str) -> None:
1600+
def gbfs_engine_manifest(other: str | None, head_n: int, path: str) -> None:
15991601
load = _load_manifest
16001602
recs = load(path)
1603+
if head_n:
1604+
for r in recs[:head_n]:
1605+
print(json.dumps(r, separators=(',', ':')))
1606+
return
16011607
by_rung = Counter((r['tier'], r['shard_dur']) for r in recs)
16021608
starts = [r['period_start'] for r in recs]
16031609
ends = [r['period_end'] for r in recs]
@@ -1616,6 +1622,162 @@ def gbfs_engine_manifest(other: str | None, path: str) -> None:
16161622
sys.exit(1)
16171623

16181624

1625+
def _period_from_key(prefix: str, key: str) -> tuple[str, str, datetime, datetime]:
1626+
"""`(tier, shard_dur, period_start, period_end)` for a `{prefix}/{tier}/{shard}/{label}.parquet` key.
1627+
Labels are the engine's period labels (`2026-09-07`, `2026-09-07T03-10`, …)."""
1628+
from pyrmts import shard_periods_covering
1629+
rel = key[len(prefix) + 1:]
1630+
if not rel.endswith('.parquet'):
1631+
raise click.ClickException(f'not a shard key: {key}')
1632+
tier, shard, label = rel[:-len('.parquet')].split('/')
1633+
iso = re.sub(r'T(\d\d)-(\d\d)', r'T\1:\2', label)
1634+
start = datetime.fromisoformat(iso).replace(tzinfo=timezone.utc)
1635+
p = shard_periods_covering(start, start + timedelta(milliseconds=1), shard)[0]
1636+
if p.start != start:
1637+
raise click.ClickException(f'{key}: label {label!r} is not {shard}-aligned (period starts {p.start.isoformat()})')
1638+
return tier, shard, p.start, p.end
1639+
1640+
1641+
@gbfs_engine.command('sweep', help='Delete shards whose period extends past CUTOFF (UTC ISO) from all three stores — R2, the D1 `pyramid_shards` registry, and the prefix\'s manifest. Run after an UNCAPPED `engine submit -f` on an engine without open-period classification (base image < pyrmts 72f2552): the fill\'s expected cover reaches `now`, so shards spanning source days that don\'t exist yet build with 0 rows for those days (or partial rows, for a shard straddling the last real day) and read as "built" to every later fill. Then re-fill CAPPED: `engine submit -f -r /CUTOFF`. Candidates = manifest records ∪ R2 listing (keys missing from the manifest are reported).')
1642+
@option('-C', '--config', 'config_name', default='smg-v1', show_default=True, help='Pyramid config basename (registry name and prefix default to it).')
1643+
@option('-m', '--manifest', 'manifest_name', default='manifest.jsonl', show_default=True, help='Manifest object name under the prefix.')
1644+
@option('-n', '--dry-run', is_flag=True, help='List affected shards; no deletes.')
1645+
@option('-p', '--prefix', default=None, help='R2 key prefix [default: <config>].')
1646+
@option('-P', '--pyramid', 'pyramid_name', default=None, help='Registry pyramid name [default: <config>].')
1647+
@argument('cutoff', metavar='CUTOFF')
1648+
def gbfs_engine_sweep(
1649+
config_name: str,
1650+
manifest_name: str,
1651+
dry_run: bool,
1652+
prefix: str | None,
1653+
pyramid_name: str | None,
1654+
cutoff: str,
1655+
) -> None:
1656+
from ctbk.pyramid_cascade.d1_http import d1_query
1657+
prefix = prefix or config_name
1658+
pyramid_name = pyramid_name or config_name
1659+
cut = datetime.fromisoformat(cutoff)
1660+
cut = cut.replace(tzinfo=timezone.utc) if cut.tzinfo is None else cut.astimezone(timezone.utc)
1661+
cut_ms = int(cut.timestamp()) * 1000
1662+
client, bucket = _r2_client()
1663+
mkey = f'{prefix}/{manifest_name}'
1664+
recs = _load_manifest(f's3://{bucket}/{mkey}')
1665+
by_key = {r['key']: r for r in recs}
1666+
paginator = client.get_paginator('list_objects_v2')
1667+
r2_keys = [
1668+
o['Key']
1669+
for page in paginator.paginate(Bucket=bucket, Prefix=f'{prefix}/')
1670+
for o in page.get('Contents') or []
1671+
if o['Key'].endswith('.parquet')
1672+
]
1673+
unlisted = sorted(set(r2_keys) - set(by_key))
1674+
if unlisted:
1675+
err(f'sweep: {len(unlisted)} R2 shard(s) not in {mkey}: ' + ', '.join(unlisted[:5]) + (', …' if len(unlisted) > 5 else ''))
1676+
victims: list[tuple[str, str, str, int, int]] = [] # (key, tier, shard_dur, period_start_ms, period_end_ms)
1677+
for key in sorted(set(r2_keys) | set(by_key)):
1678+
r = by_key.get(key)
1679+
if r is not None:
1680+
tier, shard, p0, p1 = r['tier'], r['shard_dur'], r['period_start'], r['period_end']
1681+
else:
1682+
tier, shard, s, e = _period_from_key(prefix, key)
1683+
p0, p1 = int(s.timestamp()) * 1000, int(e.timestamp()) * 1000
1684+
if p1 > cut_ms:
1685+
victims.append((key, tier, shard, p0, p1))
1686+
if not victims:
1687+
err(f'sweep: no shards under {prefix}/ extend past {cut.isoformat()}')
1688+
return
1689+
err(f'sweep: {len(victims)} shard(s) under {prefix}/ extend past {cut.isoformat()}:')
1690+
fmt = lambda ms: datetime.fromtimestamp(ms / 1000, tz=timezone.utc).strftime('%Y-%m-%dT%H:%M')
1691+
for key, _t, _s, p0, p1 in victims:
1692+
err(f' {key} [{fmt(p0)}, {fmt(p1)})')
1693+
if dry_run:
1694+
return
1695+
keys = [v[0] for v in victims]
1696+
for i in range(0, len(keys), 1000):
1697+
client.delete_objects(Bucket=bucket, Delete={'Objects': [{'Key': k} for k in keys[i:i + 1000]], 'Quiet': True}) # type: ignore[attr-defined]
1698+
err(f' deleted {len(keys)} R2 object(s)')
1699+
for i in range(0, len(keys), 50):
1700+
chunk = keys[i:i + 50]
1701+
d1_query(f"DELETE FROM pyramid_shards WHERE pyramid = ? AND key IN ({', '.join('?' * len(chunk))})", [pyramid_name, *chunk])
1702+
err(f' dropped {len(keys)} registry row(s) (pyramid={pyramid_name}, where present)')
1703+
dropped = set(keys)
1704+
keep = [r for r in recs if r['key'] not in dropped]
1705+
if len(keep) != len(recs):
1706+
out = '\n'.join(json.dumps(r, separators=(',', ':')) for r in keep) + '\n'
1707+
client.put_object(Bucket=bucket, Key=mkey, Body=out.encode(), ContentType='application/jsonl') # type: ignore[attr-defined]
1708+
err(f' {mkey}: {len(recs)}{len(keep)} records')
1709+
1710+
1711+
@gbfs_engine.command('adopt', help='Adopt R2 shards that a build flushed but never recorded (the job died after the PUT — OOM, Spot reclaim): append them to the prefix\'s manifest (HEAD for bytes, streamed md5) and register them in D1. Default KEYS = every `.parquet` under the prefix missing from the manifest (what `sweep` reports as "not in manifest").')
1712+
@option('-C', '--config', 'config_name', default='smg-v1', show_default=True, help='Pyramid config basename (registry name and prefix default to it).')
1713+
@option('-m', '--manifest', 'manifest_name', default='manifest.jsonl', show_default=True, help='Manifest object name under the prefix.')
1714+
@option('-n', '--dry-run', is_flag=True, help='List the shards that would be adopted; no writes.')
1715+
@option('-p', '--prefix', default=None, help='R2 key prefix [default: <config>].')
1716+
@option('-P', '--pyramid', 'pyramid_name', default=None, help='Registry pyramid name [default: <config>].')
1717+
@argument('keys', metavar='KEYS', nargs=-1)
1718+
def gbfs_engine_adopt(
1719+
config_name: str,
1720+
manifest_name: str,
1721+
dry_run: bool,
1722+
prefix: str | None,
1723+
pyramid_name: str | None,
1724+
keys: tuple[str, ...],
1725+
) -> None:
1726+
from hashlib import md5
1727+
from ctbk.pyramid_cascade.d1_http import d1_query
1728+
prefix = prefix or config_name
1729+
pyramid_name = pyramid_name or config_name
1730+
client, bucket = _r2_client()
1731+
mkey = f'{prefix}/{manifest_name}'
1732+
recs = _load_manifest(f's3://{bucket}/{mkey}')
1733+
listed = {r['key'] for r in recs}
1734+
if not keys:
1735+
paginator = client.get_paginator('list_objects_v2')
1736+
keys = tuple(sorted(
1737+
o['Key']
1738+
for page in paginator.paginate(Bucket=bucket, Prefix=f'{prefix}/')
1739+
for o in page.get('Contents') or []
1740+
if o['Key'].endswith('.parquet') and o['Key'] not in listed
1741+
))
1742+
else:
1743+
dup = [k for k in keys if k in listed]
1744+
if dup:
1745+
raise click.ClickException(f'already in {mkey}: {dup}')
1746+
if not keys:
1747+
err(f'adopt: every shard under {prefix}/ is already in {mkey}')
1748+
return
1749+
err(f'adopt: {len(keys)} shard(s) → {mkey} + pyramid_shards({pyramid_name}):')
1750+
for k in keys:
1751+
err(f' {k}')
1752+
if dry_run:
1753+
return
1754+
new: list[dict] = []
1755+
for k in keys:
1756+
tier, shard, s, e = _period_from_key(prefix, k)
1757+
obj = client.get_object(Bucket=bucket, Key=k) # type: ignore[attr-defined]
1758+
h = md5()
1759+
n = 0
1760+
for chunk in obj['Body'].iter_chunks(8 << 20):
1761+
h.update(chunk)
1762+
n += len(chunk)
1763+
new.append({
1764+
'pyramid': pyramid_name, 'tier': tier, 'shard_dur': shard,
1765+
'period_start': int(s.timestamp()) * 1000, 'period_end': int(e.timestamp()) * 1000,
1766+
'key': k, 'written_at': int(obj['LastModified'].timestamp() * 1000),
1767+
'md5': h.hexdigest(), 'bytes': n,
1768+
})
1769+
err(f' {k}: {n:,} B md5={h.hexdigest()}')
1770+
out = '\n'.join(json.dumps(r, separators=(',', ':')) for r in recs + new) + '\n'
1771+
client.put_object(Bucket=bucket, Key=mkey, Body=out.encode(), ContentType='application/jsonl') # type: ignore[attr-defined]
1772+
err(f' {mkey}: {len(recs)}{len(recs) + len(new)} records')
1773+
cols = '(pyramid, tier, shard_dur, period_start, period_end, key, written_at)'
1774+
params: list = []
1775+
for r in new:
1776+
params += [r['pyramid'], r['tier'], r['shard_dur'], r['period_start'], r['period_end'], r['key'], r['written_at']]
1777+
d1_query(f"INSERT OR REPLACE INTO pyramid_shards {cols} VALUES {', '.join(['(?, ?, ?, ?, ?, ?, ?)'] * len(new))}", params)
1778+
err(f' registered {len(new)}')
1779+
1780+
16191781
@gbfs_r2.command('pq', help='Inspect a parquet object on R2: schema + row count; optionally head rows and row-group metadata (range reads, no full download).')
16201782
@option('-m', '--metadata', 'show_meta', is_flag=True, help='Row-group count/sizes and file metadata.')
16211783
@option('-n', '--head', 'head_n', type=int, default=0, help='Print the first N rows.')

gbfs/api/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1122,6 +1122,9 @@ const RECONCILE_PYRAMIDS: { name: string; prefix: string; rides?: boolean }[] =
11221122
{ name: 'avail', prefix: 'avail-v3/' },
11231123
{ name: 'avail-v5', prefix: 'avail-v5/' },
11241124
{ name: 'avail-v6', prefix: 'avail-v6/' },
1125+
// Same ladder + genesis as avail-v6 (`configs/pyramids/smg-v1.yaml`); the
1126+
// daily Batch `-f` fill writes shards but doesn't register them.
1127+
{ name: 'smg-v1', prefix: 'smg-v1/' },
11251128
{ name: 'rides-v5-start', prefix: 'rides-v5/start/', rides: true },
11261129
{ name: 'rides-v5-end', prefix: 'rides-v5/end/', rides: true },
11271130
];

0 commit comments

Comments
 (0)