feat(migrate): wandb → Pluto historical data migration#131
Conversation
… disable_system_metrics Groundwork for pluto.migrate (wandb importer): explicit historical timestamps thread through to the sync layer (wire format already carried them), console lines can be replayed with original times, and the importing host's system metrics can be suppressed so they don't pollute migrated runs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Long-schema PartWriter with size-based part rotation, part readers, atomic-JSON state helpers, export sentinels, and the load-phase LoadedCache. wandb+pyarrow become the optional 'migrate' extra. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Full-fidelity export via wandb.Api: scan_history metrics with original step/timestamp, media/histogram rows, events-stream system metrics (renamed system.* -> sys/*), console lines from output.log (parsing per-line timestamps when present), artifacts with a size cap, and atomic per-run staging with sentinel-based resume. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Per-step metric replay with original timestamps, run createdAt via settings.compat, media/table/histogram conversion, console + artifact replay, sync-queue backpressure, finish-code mapping, external-id dedup, loaded-cache resume, and dry-run. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Thin argparse layer over WandbExporter/PlutoLoader; heavy deps import inside handlers so the base CLI works without the migrate extra. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Loads a hand-staged export into the dev/staging environment via PlutoLoader and verifies through pluto.query that historical metric timestamps, tags, and (once the server fix deploys) run createdAt round-trip. Gated on PLUTO_STAGING_API_KEY; URLs default to the pluto-*-dev.trainy.ai channel and are overridable via env. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- perf-mode log() now carries timestamp through the queue (was silently dropped when sync is disabled) - collision on externalId resumes and re-replays instead of permanently marking a half-loaded run as done (typed RunExistsError in op.py) - metric replay batches groups through one SQLite transaction (Op._log_metrics_batch / SyncProcessManager.enqueue_metrics_batch) - disable_system_metrics now reaches the sync subprocess (sys/pluto.* health metrics no longer stamp migrated runs with current time) and suppresses host systemMetadata on run creation - backpressure wait is bounded (stall_timeout) with guarded polling - staged system metrics keep source-native names; loader owns the sys/ translation; console lines are no longer rewritten when they carry their own timestamps - 'all' loads staged runs even when some exports failed; 'all --dry-run' is rejected instead of silently exporting; --artifact-max-size-mb 0 means a zero cap, not unlimited Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request introduces a robust two-phase migration pipeline (pluto.migrate) to export historical experiment data from Weights & Biases to local parquet files and load them into Pluto while preserving original timestamps. The changes include new CLI commands, exporter and loader modules, resume bookkeeping, and support for historical timestamps and batch logging in the core Op class. The code review identified critical path traversal vulnerabilities when handling externally-sourced identifiers (such as run IDs, file names, and artifact names) from the wandb API and staged files. Additionally, several bugs and robustness issues were highlighted, including a missing defaultdict import in pluto/op.py, potential crashes from malformed JSON or non-string inputs, uncleaned temporary directories on export failure, and platform-dependent file opening without explicit UTF-8 encoding.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| new_metric_names: List[str] = [] | ||
| new_file_meta: Dict[str, List[str]] = defaultdict(list) |
There was a problem hiding this comment.
The defaultdict class is used here but it is not imported in this file, which will raise a NameError when _log_metrics_batch is called. Import defaultdict from collections locally or at the top of the file to prevent this crash.
| new_metric_names: List[str] = [] | |
| new_file_meta: Dict[str, List[str]] = defaultdict(list) | |
| from collections import defaultdict | |
| new_metric_names: List[str] = [] | |
| new_file_meta: Dict[str, List[str]] = defaultdict(list) |
| if self.before_ms is not None and created_ms > self.before_ms: | ||
| continue | ||
|
|
||
| run_dir = runs_root / run.id |
There was a problem hiding this comment.
The run.id is an externally-sourced identifier from the wandb API. Using it directly to construct run_dir can lead to path traversal vulnerabilities if a malicious run ID contains .. or path separators. Sanitize it using Path(run.id).name and check for unsafe values like . or .. before constructing the path.
| run_dir = runs_root / run.id | |
| safe_run_id = Path(run.id).name | |
| if not safe_run_id or safe_run_id in ('.', '..'): | |
| logger.warning(f'{tag}: unsafe run ID {run.id!r}, skipping') | |
| continue | |
| run_dir = runs_root / safe_run_id |
References
- Sanitize externally-sourced identifiers before using them to construct file paths to prevent path traversal vulnerabilities. Use
pathlib.Path(identifier).nameoros.path.basename(identifier)and check for unsafe values like.or...
| for f in run.files(): | ||
| if not self.include_files and f.name != 'output.log': | ||
| continue | ||
| try: | ||
| f.download(root=str(files_dir), exist_ok=True) |
There was a problem hiding this comment.
The f.name is an externally-sourced file path from the wandb API. To prevent path traversal vulnerabilities, sanitize it using Path(f.name).name and check for unsafe values like . or .. before downloading.
for f in run.files():
if not self.include_files and f.name != 'output.log':
continue
safe_name = Path(f.name).name
if not safe_name or safe_name in ('.', '..') or safe_name != f.name:
logger.warning(f'{tag}: unsafe file name {f.name!r}, skipping')
continue
try:
f.download(root=str(files_dir), exist_ok=True)References
- Sanitize externally-sourced identifiers before using them to construct file paths to prevent path traversal vulnerabilities. Use
pathlib.Path(identifier).nameoros.path.basename(identifier)and check for unsafe values like.or...
| f'({size} bytes > cap {self.artifact_max_bytes})' | ||
| ) | ||
| continue | ||
| dest = tmp_dir / 'artifacts' / artifact.name |
There was a problem hiding this comment.
The artifact.name is an externally-sourced identifier from the wandb API. To prevent path traversal vulnerabilities, sanitize it using Path(artifact.name).name and check for unsafe values like . or .. before constructing the download destination path.
safe_name = Path(artifact.name).name
if not safe_name or safe_name in ('.', '..'):
logger.warning(f'{tag}: unsafe artifact name {artifact.name!r}, skipping')
continue
dest = tmp_dir / 'artifacts' / safe_nameReferences
- Sanitize externally-sourced identifiers before using them to construct file paths to prevent path traversal vulnerabilities. Use
pathlib.Path(identifier).nameoros.path.basename(identifier)and check for unsafe values like.or...
| path = run_dir / (row['file_value'] or '') | ||
| if not row['file_value'] or not path.exists(): |
There was a problem hiding this comment.
The row['file_value'] is read from the staged parquet files, which could be manipulated or contain unsafe paths. To prevent path traversal vulnerabilities, sanitize it using Path(file_value).name and check for unsafe values like . or .. before checking for its existence or loading it.
| path = run_dir / (row['file_value'] or '') | |
| if not row['file_value'] or not path.exists(): | |
| file_value = row['file_value'] or '' | |
| safe_file_value = Path(file_value).name | |
| if not safe_file_value or safe_file_value in ('.', '..') or safe_file_value != file_value: | |
| logger.warning(f'{tag}: unsafe file path {file_value!r}, skipping') | |
| return | |
| path = run_dir / safe_file_value | |
| if not path.exists(): |
References
- Sanitize externally-sourced identifiers before using them to construct file paths to prevent path traversal vulnerabilities. Use
pathlib.Path(identifier).nameoros.path.basename(identifier)and check for unsafe values like.or...
| def parse_iso_ms(value: Optional[str]) -> Optional[int]: | ||
| """Parse an ISO-8601 string to epoch milliseconds (UTC assumed if naive).""" | ||
| if not value: | ||
| return None |
There was a problem hiding this comment.
The parse_iso_ms function assumes value is always a string. If the wandb API returns a non-string type (such as an integer or a datetime object), calling .replace will raise an AttributeError and crash the export. Add a defensive check to ensure value is a string.
| def parse_iso_ms(value: Optional[str]) -> Optional[int]: | |
| """Parse an ISO-8601 string to epoch milliseconds (UTC assumed if naive).""" | |
| if not value: | |
| return None | |
| def parse_iso_ms(value: Optional[str]) -> Optional[int]: | |
| """Parse an ISO-8601 string to epoch milliseconds (UTC assumed if naive).""" | |
| if not isinstance(value, str): | |
| return None | |
| try: |
| def _export_run(self, run: Any, run_dir: Path) -> None: | ||
| tmp_dir = run_dir.with_name(run_dir.name + '.tmp') | ||
| if tmp_dir.exists(): | ||
| shutil.rmtree(tmp_dir) # leftovers from an interrupted export | ||
| tmp_dir.mkdir(parents=True) |
There was a problem hiding this comment.
If an exception occurs during _export_run (e.g., during history scanning or file downloading), the partial .tmp directory is left on disk. Wrap the export logic in a try-except block to clean up tmp_dir on failure so orphaned temporary directories do not accumulate.
def _export_run(self, run: Any, run_dir: Path) -> None:
tmp_dir = run_dir.with_name(run_dir.name + '.tmp')
if tmp_dir.exists():
shutil.rmtree(tmp_dir) # leftovers from an interrupted export
tmp_dir.mkdir(parents=True)
try:
created_ms = parse_iso_ms(getattr(run, 'created_at', None))
with PartWriter(tmp_dir) as writer:
self._write_run_json(run, tmp_dir, created_ms)
self._export_history(run, writer)
if self.include_system:
self._export_system_metrics(run, writer)
files_dir = tmp_dir / 'files'
if self.include_files or self.include_console:
self._download_files(run, files_dir)
if self.include_console:
self._export_console(run, writer, files_dir, created_ms)
if self.include_artifacts:
self._export_artifacts(run, writer, tmp_dir)
mark_run_exported(tmp_dir, {'rows': writer.rows_written})
if run_dir.exists():
shutil.rmtree(run_dir)
os.rename(tmp_dir, run_dir)
except Exception:
if tmp_dir.exists():
shutil.rmtree(tmp_dir)
raise| return | ||
| base = self._row_base(run) | ||
| fallback_ms = created_ms or 0 | ||
| with open(output_log, errors='replace') as f: |
There was a problem hiding this comment.
Opening files without specifying an explicit encoding is platform-dependent and can lead to UnicodeDecodeError or corrupt characters on platforms where the default encoding is not UTF-8 (such as Windows). Specify encoding='utf-8' explicitly.
| with open(output_log, errors='replace') as f: | |
| with open(output_log, encoding='utf-8', errors='replace') as f: |
| def write_json_atomic(path: Union[str, Path], obj: Any) -> None: | ||
| path = Path(path) | ||
| tmp = path.with_name(path.name + '.tmp') | ||
| with open(tmp, 'w') as f: |
There was a problem hiding this comment.
Opening files without specifying an explicit encoding is platform-dependent and can lead to UnicodeDecodeError or corrupt characters on platforms where the default encoding is not UTF-8 (such as Windows). Specify encoding='utf-8' explicitly.
| with open(tmp, 'w') as f: | |
| with open(tmp, 'w', encoding='utf-8') as f: |
|
|
||
|
|
||
| def read_json(path: Union[str, Path]) -> Any: | ||
| with open(path) as f: |
There was a problem hiding this comment.
Opening files without specifying an explicit encoding is platform-dependent and can lead to UnicodeDecodeError or corrupt characters on platforms where the default encoding is not UTF-8 (such as Windows). Specify encoding='utf-8' explicitly.
| with open(path) as f: | |
| with open(path, encoding='utf-8') as f: |
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
CI status: all code-quality gates are green (mypy, format, API-docs check, CodeQL, prod + staging contract tests, k8s-multinode). The remaining pytest-smoke failures are an environmental prod flake, not this PR: intermittent 🤖 Generated with Claude Code |
…ed token login() posts the token to /api/slug as best-effort validation. When that single POST hit a transient network error, the except branch overwrote settings._auth with the '_key' sentinel even when the token came from PLUTO_API_KEY/keyring, so every subsequent request sent 'Bearer _key' and got 401 "Invalid API key" for the run's lifetime. This was the source of the intermittent CI pytest-smoke failures (random victim test per run, all retries failing together). Keep the sentinel only when no token was provided (interactive flow). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Correction on the flake diagnosis above: the root cause was client-side, not server-side — and it's now fixed in this PR (
Regression-tested in 🤖 Generated with Claude Code |
Summary
Two-phase migration tool for importing legacy wandb data into Pluto with full timestamp fidelity — built for the first customer coming off the wandb shim who wants their historical runs transferred.
pluto migrate wandb export— stages complete runs from the wandb cloud API on disk (parquet long-schema parts +run.jsonmanifest + media/artifact files): fullscan_historymetrics, media, tables/histograms, events-stream system metrics, console logs, artifacts (size-cappable). Atomic per-run staging with sentinel-based resume.pluto migrate wandb load— replays staged runs through the public client API with original wall-clock timestamps,wandb::{entity}/{project}/{run_id}external-id dedup, crash-healing resume (typedRunExistsError→ resume + re-replay), batched metric enqueue, bounded backpressure, and--dry-run.pluto migrate wandb all— both phases; loads whatever staged even if some exports failed.Client groundwork
op.log(..., timestamp=)— epoch-seconds override threaded through the sync store (wire format already carried per-point time); works in sync, legacy, and perf-queue modes.Op._log_console/Op._log_metrics_batch— batched backfill helpers.settings.disable_system_metrics— keeps the migration host's hardware/health metrics (sys/*,sys/pluto.*, runsystemMetadata) out of imported runs, including in the sync subprocess.migrateextra (pip install 'pluto-ml[migrate]'): wandb + pyarrow, lazily imported so the base CLI/package are unaffected.Server dependency
Run
createdAt/updatedAtbackfill needs the companion server PR (Trainy-ai/server-private branchfeat/run-createdat-backfill); metric/file/console point timestamps already round-trip against today's prod ingest. Until it deploys, imported runs show import-day creation dates only.Test plan
tests/test_migrate_staging_e2e.py: live round-trip against the dev channel, gated onPLUTO_STAGING_API_KEY.🤖 Generated with Claude Code
Note
Medium Risk
Touches core logging/sync paths and performs bulk server writes with idempotency edge cases (re-replay may duplicate media); scope is large but guarded by extensive tests and optional install.
Overview
Introduces
pluto migrate wandb export|load|allfor importing historical Weights & Biases runs into Pluto. Export pulls runs from the wandb API into a local staging layout (run.json, rotated parquet parts, downloaded files) with per-run atomic writes and sentinel-based resume. Load replays staged data throughpluto.initusing stable external ids (wandb::{entity}/{project}/{run_id}), original per-point timestamps,loaded_runs.jsonskip/resume, and sync-queue backpressure. Themigrateoptional extra (wandb,pyarrow) is lazy-loaded so the base CLI stays light.The public client gains backfill-oriented behavior:
op.log(..., timestamp=)for wall-clock replay;Op._log_metrics_batchand_log_console; typedRunExistsErrorfor crash-healing resume;settings.disable_system_metricsso migration hosts do not pollute imported runs (monitor, run-create metadata, sync health uploads); andenqueue_metrics_batchon the sync path for faster replay.Packaging adds the
migrateextra inpyproject.toml/ lockfile; coverage includes unit tests across schema, exporter, loader, CLI, and an optional staging e2e gated onPLUTO_STAGING_API_KEY. RuncreatedAt/updatedAtbackfill still depends on a companion server deploy; metric/console timestamps are expected to round-trip today.Reviewed by Cursor Bugbot for commit 641460f. Configure here.