Skip to content

Commit d950d46

Browse files
committed
Audit candidate catalogs before server-side promotion
1 parent 319940a commit d950d46

3 files changed

Lines changed: 176 additions & 28 deletions

File tree

‎ctbk/flow_routes_city.py‎

Lines changed: 78 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from sqlite3 import connect
1616
from subprocess import Popen, run
1717
from time import monotonic
18-
from typing import Iterable
18+
from typing import Callable, Iterable, Iterator, TypeVar
1919
from urllib.error import HTTPError
2020
from urllib.parse import urlencode
2121
from urllib.request import urlopen
@@ -30,6 +30,8 @@
3030
from .ride_animation import decode_day
3131

3232
PREFIX = 'routes-city/v1'
33+
T = TypeVar('T')
34+
U = TypeVar('U')
3335

3436

3537
def city_population(population: dict, limit: int = 0) -> dict[str, list[dict]]:
@@ -103,25 +105,30 @@ def packed(self, meta: dict) -> dict:
103105
return loads(decompress(blob))
104106

105107

106-
def publish_catalogs(
107-
store: RouteStore,
108-
catalogs: Iterable[tuple[str, dict]],
108+
def bounded_results(
109+
function: Callable[[T], U],
110+
items: Iterable[T],
109111
workers: int = 8,
110-
) -> dict[str, dict]:
111-
"""Bound independent CA uploads, consuming SQLite-backed input on the caller."""
112+
) -> Iterator[U]:
113+
"""Consume input and ordered results on the caller, retaining at most N tasks."""
112114
if workers < 1:
113-
raise ValueError('publication workers must be positive')
114-
published = {}
115-
pending: deque[tuple[str, Future[dict]]] = deque()
115+
raise ValueError('catalog workers must be positive')
116+
pending: deque[Future[U]] = deque()
116117
with ThreadPoolExecutor(max_workers=workers) as pool:
117-
for sid, data in catalogs:
118-
pending.append((sid, pool.submit(store.publish, data)))
118+
for item in items:
119+
pending.append(pool.submit(function, item))
119120
if len(pending) >= workers:
120-
oldest_sid, future = pending.popleft()
121-
published[oldest_sid] = future.result()
122-
for sid, future in pending:
123-
published[sid] = future.result()
124-
return published
121+
yield pending.popleft().result()
122+
for future in pending:
123+
yield future.result()
124+
125+
126+
def publish_catalogs(
127+
publish: Callable[[dict], dict],
128+
catalogs: Iterable[tuple[str, dict]],
129+
workers: int = 8,
130+
) -> dict[str, dict]:
131+
return dict(bounded_results(lambda item: (item[0], publish(item[1])), catalogs, workers))
125132

126133

127134
@flow_routes.command('city')
@@ -250,15 +257,15 @@ def origin_catalogs() -> Iterable[tuple[str, dict]]:
250257
yield sid, {'version': 1, 'pairs': records}
251258

252259
err(f'Publishing {len(origins)} origin catalogs with at most 8 uploads in flight')
253-
encoded_origins = publish_catalogs(store, origin_catalogs())
260+
encoded_origins = publish_catalogs(store.publish, origin_catalogs())
254261

255262
def destination_catalogs() -> Iterable[tuple[str, dict]]:
256263
for sid in sorted(destination_ids):
257264
records = [loads(row[0]) for row in destination_db.execute('SELECT record FROM pairs WHERE destination = ? ORDER BY source', (sid,))]
258265
yield sid, {'version': 1, 'pairs': records}
259266

260267
err(f'Publishing {len(destination_ids)} destination catalogs with at most 8 uploads in flight')
261-
encoded_destinations = publish_catalogs(store, destination_catalogs())
268+
encoded_destinations = publish_catalogs(store.publish, destination_catalogs())
262269
destination_db.close()
263270
system = store.publish({'version': 1, 'context': context, 'graph': graph_meta, 'volumes': sorted(census_volumes.items()), 'dateRange': population['dateRange'], 'selectedRides': sum(counts.values()), 'routedRides': routed_rides})
264271
summary = {
@@ -304,7 +311,8 @@ def validate_catalog_pair(pair: dict, segments: list[dict]) -> None:
304311
@option('-o', '--output', type=ClickPath(path_type=Path), default=Path('tmp/routes-city-audit.json'))
305312
def check_city(prefix: str, output: Path) -> None:
306313
store = RouteStore(prefix, rw=False)
307-
index = loads(store.get('index.json'))
314+
index_blob = store.get('index.json')
315+
index = loads(index_blob)
308316
if index['version'] != 1:
309317
raise ValueError('unsupported city catalog')
310318
census = RouteStore(index['populationPrefix'], rw=False)
@@ -321,8 +329,11 @@ def check_city(prefix: str, output: Path) -> None:
321329
fallbacks, volumes = Counter(), Counter()
322330
if set(index['origins']) != set(population):
323331
raise ValueError('origin catalog disagrees with census population')
324-
for sid, meta in index['origins'].items():
325-
catalog = store.packed(meta)
332+
def read_catalog(item: tuple[str, dict]) -> tuple[str, dict]:
333+
sid, meta = item
334+
return sid, store.packed(meta)
335+
336+
for sid, catalog in bounded_results(read_catalog, index['origins'].items()):
326337
keys = [(p['source'], p['destination']) for p in catalog['pairs']]
327338
wanted = [(sid, p['destination']['id']) for p in population[sid]]
328339
if catalog['version'] != 1 or keys != wanted:
@@ -339,8 +350,7 @@ def check_city(prefix: str, output: Path) -> None:
339350
fallbacks[pair['fallback']] += 1
340351
if set(index['destinations']) != {b for a, b in expected}:
341352
raise ValueError('destination catalog disagrees with census population')
342-
for sid, meta in index['destinations'].items():
343-
catalog = store.packed(meta)
353+
for sid, catalog in bounded_results(read_catalog, index['destinations'].items()):
344354
for pair in catalog['pairs']:
345355
key = pair['source'], pair['destination']
346356
if pair['destination'] != sid or fingerprints.pop(key, None) != md5(object_blob(pair)).hexdigest():
@@ -353,12 +363,56 @@ def check_city(prefix: str, output: Path) -> None:
353363
system = store.packed(index['system'])
354364
if system['context'] != index['context'] or system['graph'] != index['graph'] or system['volumes'] != [list(p) for p in sorted(volumes.items())]:
355365
raise ValueError('census flow volumes disagree with route assignment')
356-
report = actual | {'context': index['context'], 'segments': len(segments), 'bytes': index['bytes'], 'timings': index['timings'], 'resources': index['resources']}
366+
report = actual | {'indexMd5': md5(index_blob).hexdigest(), 'context': index['context'], 'segments': len(segments), 'bytes': index['bytes'], 'timings': index['timings'], 'resources': index['resources']}
357367
output.parent.mkdir(parents=True, exist_ok=True)
358368
output.write_text(dumps(report, indent=2, sort_keys=True) + '\n')
359369
print(dumps(report, indent=2, sort_keys=True))
360370

361371

372+
@flow_routes.command('promote-city')
373+
@option('-a', '--audit', type=ClickPath(path_type=Path, exists=True), default=Path('tmp/routes-city-audit.json'), show_default=True)
374+
@option('-i', '--input-prefix', default='routes-city-candidate/v1', show_default=True)
375+
@option('-k', '--prefix', default=PREFIX, show_default=True)
376+
def promote_city(
377+
audit: Path,
378+
input_prefix: str,
379+
prefix: str,
380+
) -> None:
381+
if input_prefix == prefix:
382+
raise UsageError('promotion source and destination must differ')
383+
source, target = RouteStore(input_prefix, rw=False), RouteStore(prefix)
384+
blob = source.get('index.json')
385+
index, accepted = loads(blob), loads(audit.read_text())
386+
if index['version'] != 1 or index.get('limit') != 0:
387+
raise UsageError('promotion requires an uncapped version-one city catalog')
388+
if accepted.get('indexMd5') != md5(blob).hexdigest() or accepted.get('context') != index['context']:
389+
raise ValueError('candidate index differs from the accepted audit')
390+
objects = {}
391+
for meta in [index['graph'], index['system'], *index['origins'].values(), *index['destinations'].values()]:
392+
key, size = meta['key'], meta['bytes']
393+
if not fullmatch(r'objects/[a-f0-9]{32}\.json\.gz', key) or not isinstance(size, int) or size <= 0:
394+
raise ValueError('invalid indexed CA object')
395+
if key in objects and objects[key] != meta:
396+
raise ValueError('inconsistent indexed CA object metadata')
397+
objects[key] = meta
398+
399+
def copy_object(meta: dict) -> dict:
400+
key, size = meta['key'], meta['bytes']
401+
expected_etag = '"' + Path(key).name.split('.')[0] + '"'
402+
source_key = f'{source.prefix}/{key}'
403+
head = source.r2.head_object(Bucket=source.bucket, Key=source_key)
404+
if head['ContentLength'] != size or head['ETag'] != expected_etag:
405+
raise ValueError('source CA object hash or size disagrees with index')
406+
copied = target.r2.copy_object(Bucket=target.bucket, Key=f'{target.prefix}/{key}', CopySource={'Bucket': source.bucket, 'Key': source_key}, CopySourceIfMatch=expected_etag, MetadataDirective='COPY')
407+
if copied['CopyObjectResult']['ETag'] != expected_etag:
408+
raise ValueError('copied CA object hash disagrees with index')
409+
return meta
410+
411+
publish_catalogs(copy_object, sorted(objects.items()))
412+
target.put('index.json', blob, mutable=True)
413+
print(dumps({'source': input_prefix, 'prefix': prefix, 'context': index['context'], 'indexMd5': accepted['indexMd5'], 'objects': len(objects), 'bytes': sum(meta['bytes'] for meta in objects.values()), 'pairs': index['pairs']}, indent=2, sort_keys=True))
414+
415+
362416
@flow_routes.command('system')
363417
@option('-d', '--date', 'ym', required=True, help='YYYYMM published animation month.')
364418
@option('-i', '--input-prefix', default='rides-anim-candidate/v1', show_default=True)

‎ctbk/tests/test_flow_routes_city.py‎

Lines changed: 94 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from collections import Counter
22
from gzip import decompress
3+
from hashlib import md5
34
from io import BytesIO
45
from json import loads
56
from pathlib import Path
@@ -73,7 +74,7 @@ def generate():
7374
assert parallel.state['generated'] - parallel.state['completed'] < 2
7475
parallel.state['generated'] += 1
7576
yield sid, data
76-
result = publish_catalogs(parallel, generate(), workers=2)
77+
result = publish_catalogs(parallel.publish, generate(), workers=2)
7778
assert list(result) == ['A', 'B', 'C', 'D']
7879
assert result == expected
7980
assert parallel.objects == serial.objects
@@ -85,9 +86,92 @@ class Store:
8586
def publish(self, data):
8687
raise RuntimeError('upload failed')
8788
with pytest.raises(RuntimeError, match='^upload failed$'):
88-
publish_catalogs(Store(), [('A', {'version': 1})])
89-
with pytest.raises(ValueError, match='^publication workers must be positive$'):
90-
publish_catalogs(Store(), [], workers=0)
89+
publish_catalogs(Store().publish, [('A', {'version': 1})])
90+
with pytest.raises(ValueError, match='^catalog workers must be positive$'):
91+
publish_catalogs(Store().publish, [], workers=0)
92+
93+
94+
@pytest.fixture
95+
def promotion(monkeypatch, tmp_path):
96+
import ctbk.flow_routes_city as module
97+
source, target = 'routes-city-candidate/v1', 'routes-city/v1'
98+
objects, metas = {}, []
99+
for data in [{'version': 1, 'segments': []}, {'version': 1, 'pairs': []}, {'version': 1, 'volumes': []}]:
100+
blob = object_blob(data)
101+
key = f'objects/{md5(blob).hexdigest()}.json.gz'
102+
objects[f'{source}/{key}'] = blob
103+
metas.append({'key': key, 'bytes': len(blob)})
104+
index = {'version': 1, 'limit': 0, 'context': 'accepted', 'pairs': 1, 'graph': metas[0], 'origins': {'A': metas[1]}, 'destinations': {'B': metas[1]}, 'system': metas[2]}
105+
blob = module.dumps(index, sort_keys=True).encode()
106+
objects[f'{source}/index.json'] = blob
107+
objects[f'{source}/checkpoints/unindexed.json.gz'] = b'not copied'
108+
objects[f'{target}/index.json'] = b'previous production index'
109+
audit = tmp_path / 'audit.json'
110+
audit.write_text(module.dumps({'indexMd5': md5(blob).hexdigest(), 'context': 'accepted'}))
111+
state = SimpleNamespace(objects=objects, events=[], copy_error=False, lock=Lock(), index=index, source=source, target=target, audit=audit, module=module, metas=metas)
112+
class Client:
113+
def head_object(self, Bucket, Key):
114+
data = objects[Key]
115+
return {'ContentLength': len(data), 'ETag': f'"{md5(data).hexdigest()}"'}
116+
117+
def copy_object(self, **kwargs):
118+
if state.copy_error:
119+
raise RuntimeError('copy failed')
120+
source_key = kwargs['CopySource']['Key']
121+
data = objects[source_key]
122+
assert kwargs == {'Bucket': 'test', 'Key': f'{target}/{source_key.removeprefix(source + "/")}', 'CopySource': {'Bucket': 'test', 'Key': source_key}, 'CopySourceIfMatch': f'"{md5(data).hexdigest()}"', 'MetadataDirective': 'COPY'}
123+
with state.lock:
124+
objects[kwargs['Key']] = data
125+
state.events.append(('copy', kwargs['Key']))
126+
return {'CopyObjectResult': {'ETag': f'"{md5(data).hexdigest()}"'}}
127+
class Store:
128+
def __init__(self, prefix, rw=True):
129+
self.prefix, self.bucket, self.r2 = prefix, 'test', Client()
130+
131+
def get(self, key):
132+
state.events.append(('get', f'{self.prefix}/{key}'))
133+
return objects[f'{self.prefix}/{key}']
134+
135+
def put(self, key, data, mutable=False):
136+
assert mutable is True
137+
state.events.append(('put', f'{self.prefix}/{key}'))
138+
objects[f'{self.prefix}/{key}'] = data
139+
monkeypatch.setattr(module, 'RouteStore', Store)
140+
return state
141+
142+
143+
def test_promotion_copies_only_indexed_ca_objects_and_publishes_index_last(promotion):
144+
p = promotion
145+
p.module.promote_city.callback(audit=p.audit, input_prefix=p.source, prefix=p.target)
146+
assert p.events[0] == ('get', f'{p.source}/index.json')
147+
assert sorted(p.events[1:-1]) == [('copy', f'{p.target}/{meta["key"]}') for meta in sorted(p.metas, key=lambda meta: meta['key'])]
148+
assert p.events[-1] == ('put', f'{p.target}/index.json')
149+
assert {key.removeprefix(p.target + '/'): value for key, value in p.objects.items() if key.startswith(p.target + '/')} == {
150+
'index.json': p.objects[f'{p.source}/index.json'],
151+
**{meta['key']: p.objects[f'{p.source}/{meta["key"]}'] for meta in p.metas},
152+
}
153+
154+
155+
@pytest.mark.parametrize('failure', ['capped', 'index_changed', 'object_changed', 'copy_failed'])
156+
def test_promotion_keeps_previous_index_on_rejection_or_copy_failure(promotion, failure):
157+
p = promotion
158+
exception, message = ValueError, '^candidate index differs from the accepted audit$'
159+
if failure == 'capped':
160+
p.index['limit'] = 10000
161+
p.objects[f'{p.source}/index.json'] = p.module.dumps(p.index).encode()
162+
exception, message = p.module.UsageError, '^promotion requires an uncapped version-one city catalog$'
163+
elif failure == 'index_changed':
164+
p.objects[f'{p.source}/index.json'] += b' '
165+
elif failure == 'object_changed':
166+
p.objects[f'{p.source}/{p.metas[0]["key"]}'] = b'corrupt graph'
167+
message = '^source CA object hash or size disagrees with index$'
168+
else:
169+
p.copy_error = True
170+
exception, message = RuntimeError, '^copy failed$'
171+
with pytest.raises(exception, match=message):
172+
p.module.promote_city.callback(audit=p.audit, input_prefix=p.source, prefix=p.target)
173+
assert p.objects[f'{p.target}/index.json'] == b'previous production index'
174+
assert [event for event in p.events if event[0] == 'put'] == []
91175

92176

93177
def test_city_builder_publishes_sparse_both_way_catalogs_and_exact_trip_coverage(monkeypatch, tmp_path):
@@ -148,6 +232,12 @@ def response(url, timeout):
148232
assert {k: loads((tmp_path / 'audit.json').read_text())[k] for k in ['pairs', 'routedPairs', 'routedRides', 'alternatives', 'fallbacks']} == {
149233
'pairs': 2, 'routedPairs': 1, 'routedRides': 100, 'alternatives': 1, 'fallbacks': {'NoRoute': 1},
150234
}
235+
parallel_report = loads((tmp_path / 'audit.json').read_text())
236+
bounded = module.bounded_results
237+
with monkeypatch.context() as serial:
238+
serial.setattr(module, 'bounded_results', lambda function, items, workers=8: bounded(function, items, workers=1))
239+
module.check_city.callback(prefix='test-city', output=tmp_path / 'serial-audit.json')
240+
assert loads((tmp_path / 'serial-audit.json').read_text()) == parallel_report
151241
# A second run reads the saved origin checkpoints rather than contacting the router.
152242
monkeypatch.setattr(module, 'urlopen', lambda *args, **kwargs: pytest.fail('checkpoint route was recomputed'))
153243
city.callback(input_prefix='routes-census/v1', prefix='test-city', limit=0, output=tmp_path, snapshot='20261012')

‎specs/flow-routes.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,8 +109,12 @@ The city builder streams node annotations in two passes over per-origin compress
109109

110110
CA catalog publication uses at most eight concurrent uploads, consuming origin encoding and SQLite destination reads on the caller thread. Only eight catalog payloads can remain queued; the ordered result map and deterministic compressed objects match serial publication byte-for-byte. Routing/checkpoint computation remains serial. Separate topology/publication timings and stage logs expose the post-routing work.
111111

112+
Build the full rollout under `routes-city-candidate/v1`, then run `check-city -k routes-city-candidate/v1` and `promote-city` in the same remote task. The checker uses eight bounded catalog reads while preserving origin/destination iteration and floating-point aggregation order on the caller thread. Its small report binds acceptance to the exact candidate index MD5. Promotion refuses capped catalogs or a changed index, server-side copies only indexed CA objects with source size/hash checks and conditional copies, and publishes the production `index.json` last. It copies no checkpoints and downloads no catalog bodies to the laptop. Server-side copy and source ETag conditions are supported by R2's [S3 API compatibility table][r2-copy].
113+
112114
The existing 2024 census is a sizing baseline, not a current-population guarantee. Refresh `routes-census.yml` for `202510-202609` before the full build; September remains provisional. Catalogs retain the immutable census object key, so a later census refresh does not invalidate an older build's audit. New or rare pairs outside the chosen population continue to use explicit fallback arcs/straight animation paths. The full JC/Hoboken catalog still overrides the city/sample namespace for its covered pairs.
113115

114116
`ctbk flow-routes system -d YYYYMM` streams the month's immutable animation days, verifies hashes/counts, excludes carry-in and round trips, and assigns actual monthly directed-pair counts to route alternatives. It publishes `flows/v1` with exact encoded/routed/fallback/roundtrip accounting, source object hashes, provisional status, the route context and graph key, and expected signed street volumes. Missing routes are reported as uncovered volume rather than invented street traffic. `.github/workflows/routes-system.yml` runs this remotely for one month with a serialized writer; monthly automatic extension and time-mode interpolation remain follow-up work.
115117

116118
`/stations?m=2506&network=1` shows the opt-in whole-network layer with direction chevrons and opposing lanes when no station is pinned. It loads one monthly volume object and one shared graph, without downloading every origin catalog. The legend reports the actual non-roundtrip routed share and explicitly names an unbuilt month. Selections continue to use lazy origin/destination catalogs; a missing city index keeps today's NJ/sample behavior. Unit tests cover exact directional expected volumes and floors; a complete hermetic two-origin build/audit tests both indexes and checkpoint resume; the monthly fixture proves carry/roundtrip exclusion and uncovered-pair accounting. Browser checks cover network opt-in/reload/view preservation and an unbuilt month. Chrome CIC could not connect because the installed browser runtime references a missing service module; automated Chromium rendering was checked instead, without claiming HCCSx verification.
119+
120+
[r2-copy]: https://developers.cloudflare.com/r2/api/s3/api/

0 commit comments

Comments
 (0)