Skip to content

Sync

Sync merges a copied store into the local store. The local store is the destination and the source of truth; a copy made elsewhere can be pulled in with the CLI:

research-tracker --root ./artifacts sync /path/to/copied/store

or programmatically:

from pathlib import Path

from research_tracker import ExperimentStore

store = ExperimentStore(Path("artifacts"))
store.sync(Path("/path/to/copied/store"), copy_artifacts=True, progress=print)

The source path must be the store directory itself — the one containing schema.json and store.sqlite. A missing path raises FileNotFoundError; mismatched schema versions between the two stores raise SchemaMismatchError.

What sync copies

  1. It merges the five tables, resolving each one by its reconciliation rule.
  2. It copies the files those rows reference, unless copy_artifacts=False (CLI: --skip-copy-artifacts): each run's config and checkpoint, and each artifact's path.
  3. It installs all five merged tables in one SQLite transaction.

Reconciliation rules

Tables are reconciled by primary key, and the rule depends on whether the table is mutable:

  • Runs are mutable. _MUTABLE_TABLES = {"run": "updated_at"}, so the copy with the newest updated_at wins per run_id. A run present only in the source is added; a run present only locally is kept. Re-syncing the same source is therefore idempotent for runs: the second run sees identical updated_at values and the table is unchanged.
  • Evaluations, conditions, metrics, and artifacts are immutable. A record that exists only in the source is added. A record whose ID exists on both sides must be byte-for-byte identical in every column — any difference raises SyncConflictError: Conflicting record for <key>=<id>. Missing values compare only as equal to other missing values, and a value's Python type is part of the comparison, so 1, 1.0, and True stay distinct.

Because immutable records conflict rather than overwrite, sync can never silently rewrite history: a disagreement is an error to resolve by hand.

Idempotency

Syncing the same unchanged source twice is a no-op: run versions tie on updated_at and resolve to the same row, immutable IDs match, and copying is skipped for every file that already exists at the destination (shutil.copy2 runs only for files that are absent). The CLI summary reports N copied, M already present per category.

The quiescent-source rule

Copying a store directory is safe only while the source is quiescent — no process is mid-write, and the store has been flushed. The database uses a rollback journal (journal_mode = DELETE, never WAL), which is exactly what keeps a store portable as a single file, but it is not by itself a snapshot guarantee: copying while a transaction is open can capture a half-applied state. Copying mid-transaction, or with unflushed metrics still in a writer's memory, captures less than the whole store.

In practice:

  1. Stop or pause writers against the source store.
  2. Call flush() there, or let the process exit normally.
  3. Copy the directory.
  4. Sync the copy into the main store.

See limitations for the other edges.

Failure behaviour

Before any file is copied, sync builds every merged table and preflights it: canonical columns, no missing or duplicate primary keys, every stored path relative and contained, and every value encodable for its column. A rejected merge never reaches the install step, and no file is copied, because the merge is validated first.

That is not a full rollback, however. Sync first flushes the destination's own buffered metrics and conditions under the store lock, and those writes are already committed by the time the merge is validated. Files copied before a later step fails are not removed either. A failed sync therefore leaves the local store's previously buffered records written and any already-copied files in place; only the table install is all-or-nothing.

Progress reporting

The CLI prints each copied file plus a Configs/Checkpoints/Artifacts: N copied, M already present summary. Programmatic callers get the same messages by passing progress, a Callable[[str], None]print works.