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¶
- It merges the five tables, resolving each one by its reconciliation rule.
- It copies the files those rows reference, unless
copy_artifacts=False(CLI:--skip-copy-artifacts): each run'sconfigandcheckpoint, and each artifact'spath. - 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 newestupdated_atwins perrun_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 identicalupdated_atvalues 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, so1,1.0, andTruestay 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:
- Stop or pause writers against the source store.
- Call
flush()there, or let the process exit normally. - Copy the directory.
- 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.