Ligant's picture
download
raw
9.33 kB
#!/usr/bin/env python3
"""Run the pinned tick, then mirror collected stage JSON into the HF output mount.
This auxiliary entrypoint deliberately lives outside castle_pipeline/: copying
artifacts must not change the code identity of existing runs. GCS remains the
source of truth; the mirror is never used as a checkpoint or submission input.
The mirror threads its per-object round trips (CASTLE_MIRROR_WORKERS, default 16)
because the work is latency-bound, not bandwidth-bound. Only the walk stays
sequential; the copied bytes, the validation and the failure reported do not.
"""
import argparse
from concurrent.futures import ThreadPoolExecutor, as_completed
import hashlib
import json
import os
from pathlib import Path
import re
import subprocess
import sys
import tempfile
from castle_pipeline.batch_cloud import BatchCloud, parse_gs_uri
class MirrorConflict(ValueError):
"""Safe diagnostic fields: no result data or provider error payloads."""
def __init__(self, relative):
super().__init__('Stage mirror already has different content: ' + relative)
self.relative = relative
MIRROR_RELATIVE = re.compile(r'(audio|annotation|review)/[A-Za-z0-9_-]+\.json')
# Latency-bound work with a cgroup-blind default, like the other run knobs: the
# measured scaling was 4.0x at 4 threads and 14.3x at 16 on a 2 vCPU machine, so
# the default stays well inside what a cpu-basic container can drive and every
# run may override it. It is a run parameter, never an identity input.
DEFAULT_MIRROR_WORKERS = 16
MAX_MIRROR_WORKERS = 64
def env_mirror_workers():
"""Mirror concurrency from CASTLE_MIRROR_WORKERS, else the modest default."""
raw = os.environ.get('CASTLE_MIRROR_WORKERS')
if raw is None or not raw.strip():
return DEFAULT_MIRROR_WORKERS
try:
value = int(raw.strip())
except ValueError:
raise ValueError('Environment variable CASTLE_MIRROR_WORKERS must be a positive integer') from None
if not 1 <= value <= MAX_MIRROR_WORKERS:
raise ValueError('Environment variable CASTLE_MIRROR_WORKERS must be in 1..%d' % MAX_MIRROR_WORKERS)
return value
def _mirror_object(blob, prefix, output):
"""Mirror one GCS object. Returns 'copied', 'unchanged', or None if not ours.
Runs on a mirror thread, so it only touches its own destination path. The
validation, hash and conflict rules are unchanged from the sequential walk.
"""
relative = blob.name[len(prefix):]
if not MIRROR_RELATIVE.fullmatch(relative):
return None
target = output / 'results' / relative
expected = (blob.metadata or {}).get('sha256')
existing = hashlib.sha256(target.read_bytes()).hexdigest() if target.is_file() else None
if expected and existing == expected:
return 'unchanged'
if expected and existing is not None:
raise MirrorConflict(relative)
data = blob.download_as_bytes(if_generation_match=int(blob.generation))
actual = hashlib.sha256(data).hexdigest()
if expected and actual != expected:
raise ValueError('GCS stage content hash mismatch: ' + relative)
if not isinstance(json.loads(data), dict):
raise ValueError('Stage result must be a JSON object: ' + relative)
if existing is not None:
if existing != actual:
raise MirrorConflict(relative)
return 'unchanged'
target.parent.mkdir(parents=True, exist_ok=True)
# Publish only complete bytes. This temp file belongs to this invocation.
temporary = None
try:
with tempfile.NamedTemporaryFile(dir=target.parent, prefix='.stage-', suffix='.tmp', delete=False) as handle:
temporary = Path(handle.name)
handle.write(data)
# Catch a destination populated during the download. This is not a
# cross-mount lock: each output prefix must still have one writer.
if target.is_file():
if hashlib.sha256(target.read_bytes()).hexdigest() != actual:
raise MirrorConflict(relative)
return 'unchanged'
os.replace(temporary, target)
finally:
if temporary is not None:
temporary.unlink(missing_ok=True)
return 'copied'
def mirror_results(cloud, gcs_prefix, output_dir, workers=None):
"""Copy collected stage results into the HF output mount, with bounded concurrency.
Mirroring is latency-bound, not bandwidth-bound: the median object is 4.9 KiB
but still costs one or two cross-store round trips, and the walk was strictly
sequential (measured in-container: 10-13 objects/s, 1.76 Mbps, ~2 min for
~1200 objects). A read-only probe of the same object set scaled 14x at 16
threads, so only the per-object work is threaded here. The walk, the counts
and the failure reported stay independent of scheduling.
"""
bucket, run = parse_gs_uri(gcs_prefix)
prefix = run.rstrip('/') + '/results/'
output = Path(output_dir)
total = env_mirror_workers() if workers is None else workers
if isinstance(total, bool) or not isinstance(total, int) or not 1 <= total <= MAX_MIRROR_WORKERS:
raise ValueError('Mirror workers must be an integer in 1..%d' % MAX_MIRROR_WORKERS)
targets = [blob for blob in cloud.storage.list_blobs(bucket, prefix=prefix)
if blob.name.startswith(prefix)]
counts = {'copied': 0, 'unchanged': 0}
failures = []
if targets:
with ThreadPoolExecutor(max_workers=min(total, len(targets)), thread_name_prefix='mirror') as pool:
pending = {pool.submit(_mirror_object, blob, prefix, output): blob.name for blob in targets}
for future in as_completed(pending):
try:
outcome = future.result()
except Exception as error: # re-raised deterministically below
failures.append((pending[future], error))
else:
if outcome is not None:
counts[outcome] += 1
if failures:
# The same object set must always report the same failure, whatever order
# the threads happened to finish in.
failures.sort(key=lambda item: item[0])
raise failures[0][1]
return counts
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('command', choices=['tick'])
parser.add_argument('--project', required=True)
parser.add_argument('--location', default='global')
parser.add_argument('--state-uri', required=True)
parser.add_argument('--output-dir', type=Path, required=True)
parser.add_argument('--scratch-dir', type=Path, required=True)
parser.add_argument('--allow-missing-state', action='store_true')
args = parser.parse_args(argv)
parse_gs_uri(args.state_uri)
cloud = BatchCloud(args.project, args.location)
state, _ = cloud.read_state(args.state_uri)
if state is None:
if not args.allow_missing_state:
raise ValueError('No batch state exists')
print(json.dumps({'event': 'batch_tick_waiting', 'status': 'waiting_for_state',
'state_uri': args.state_uri, 'stop_schedule': False}), flush=True)
return 0
config = state['config']
if config['project'] != args.project or config['location'] != args.location:
raise ValueError('Project/location must match the saved run')
if args.state_uri != config['gcs_prefix'].rstrip('/') + '/state.json':
raise ValueError('State URI must belong to the saved GCS run prefix')
from batch_pipeline import code_hash
if config.get('code_hash') != code_hash():
raise ValueError('Use the pinned code version that initialized this run')
command = [sys.executable, str(Path(__file__).with_name('batch_pipeline.py')), 'tick',
'--project', args.project, '--location', args.location, '--state-uri', args.state_uri,
'--output-dir', str(args.output_dir), '--scratch-dir', str(args.scratch_dir)]
result = subprocess.run(command, check=False)
# Even a failed tick can have persisted valid stage results before failing.
# Replaying this mirror never creates model requests or alters GCS state.
try:
workers = env_mirror_workers()
counts = mirror_results(cloud, config['gcs_prefix'], args.output_dir, workers=workers)
except Exception as error:
details = {'file': error.relative, 'reason': 'different_content'} if isinstance(error, MirrorConflict) else {}
print(json.dumps({'event': 'batch_stage_mirror_failed', 'error_type': type(error).__name__,
'message': 'Stage mirror failed; GCS results are preserved. Inspect and retry.', **details}),
file=sys.stderr, flush=True)
return result.returncode or 1
print(json.dumps({'event': 'batch_stage_mirror', 'workers': workers, **counts}), flush=True)
return result.returncode
if __name__ == '__main__':
try:
raise SystemExit(main())
except KeyboardInterrupt:
raise SystemExit(130)
except Exception as error:
print(json.dumps({'ok': False, 'error_type': type(error).__name__,
'message': 'Batch tick wrapper failed; inspect configuration and persisted state.'}),
file=sys.stderr)
raise SystemExit(1)

Xet Storage Details

Size:
9.33 kB
·
Xet hash:
7765e2e873881c9809ec0b11193a568d6d57c8ef619b9a2e5d14caf855032701

Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.