exlab_wizard.cache.sync_state_writer#

Orchestrator-only writer for sync_state.json.

Operator-free per-file NAS sync design (2026-05-21). The orchestrator writes one sync_state.json per run pending NAS sync, under <run>/.exlab-wizard/sync_state.json. It is a freely-mutable current-state map: per-file records are overwritten in place as a file is synced, re-modified, and re-synced, and cleared_at is stamped once when the run’s staging copy is cleaned up.

Disk-side guarantees follow the §4.4.5 CacheWriter contract:

  • msgspec.json for typed encode/decode (schema validation in one pass).

  • filelock.FileLock advisory exclusive lock around every read-mutate-write cycle so concurrent updates never lose a record.

  • Atomic write via tempfile + fsync + os.replace (atomic_write_bytes()).

The run-level SYNCING / SYNCED / CLEARED rollup is derived on read by SyncStateWriter.rollup_state() – it is never persisted, because it can oscillate (a SYNCED run whose file is modified again returns to SYNCING).

Classes

SyncStateWriter()

Writer for sync_state.json.

class exlab_wizard.cache.sync_state_writer.SyncStateWriter[source]#

Bases: object

Writer for sync_state.json. Orchestrator-mode only.

All public methods are async to match the §4.4.5 CacheWriter contract; the blocking lock + I/O work is dispatched through asyncio.to_thread so the FastAPI event loop is never blocked.

async mark_cleared(run_path)[source]#

Stamp cleared_at with the current UTC time.

Called once the run’s staging copy has been cleaned up; this flips the derived rollup to CLEARED.

Parameters:

run_path (Path)

Return type:

SyncStateJson

mark_cleared_sync(run_path)[source]#

Blocking variant of mark_cleared() for synchronous callers.

Used by the synchronous clear_run_dir() operator-clear path so an operator “Clear” stamps cleared_at exactly like the automatic cleanup reaper does. No-op-safe when sync_state.json is absent – a record is created carrying only cleared_at.

Parameters:

run_path (Path)

Return type:

SyncStateJson

async read(run_path)[source]#

Read and decode the run’s sync_state.json.

Returns a fresh empty SyncStateJson when the file does not exist yet – a run with no sync activity simply has no record. Raises SchemaMajorMismatchError (§11.9.2) when the on-disk file carries a different schema major than SYNC_STATE_JSON_VERSION.

Parameters:

run_path (Path)

Return type:

SyncStateJson

read_sync(run_path)[source]#

Blocking variant of read() for synchronous callers.

read() dispatches the blocking lock + decode through asyncio.to_thread. This variant runs the lock + decode inline; synchronous read-side code – notably exlab_wizard.orchestrator.staging_query.list_staged_runs() and exlab_wizard.orchestrator.staging_clear.clear_run_dir() – calls it directly. Some of those call sites run inside a @ui.page handler (i.e. on the event loop); the lock + a single small JSON decode is brief enough not to matter there, and the read-side query is itself synchronous, so there is no to_thread hop to make. Returns a fresh empty SyncStateJson when the file is absent.

Parameters:

run_path (Path)

Return type:

SyncStateJson

static rollup_state(state)[source]#

Derive the run-level RunSyncState rollup from state.

Pure function – no I/O, no mutation. The rollup is computed on read rather than persisted because the SYNCING/SYNCED distinction oscillates as files are re-modified.

  • CLEAREDcleared_at is set (takes precedence even if some files are unverified, e.g. keep_local files left on disk).

  • SYNCEDfiles is non-empty and every record has a non-null verified_at.

  • SYNCING – otherwise (no files tracked yet, or at least one file still unverified).

Parameters:

state (SyncStateJson)

Return type:

RunSyncState

async set_keep_local(run_path, rel_path, value)[source]#

Toggle a file’s keep_local flag, creating the record if absent.

Parameters:
Return type:

SyncStateJson

async upsert_file(run_path, rel_path, *, synced_signature=None, verified_at=None, verified_sha256=None)[source]#

Create or update one file record under an exclusive lock.

The record for rel_path (a run-relative POSIX path) is created if absent, otherwise updated in place. keep_local is preserved by this method (toggle it via set_keep_local()).

The three remaining kwargs have asymmetric None handling by design:

  • synced_signature and verified_at are always overwritten – even with None. The poller’s re-modified-file flow relies on this: it calls upsert_file(... synced_signature=None, verified_at=None) to drop the verify marks on a file whose local bytes have changed, which flips the run-level rollup from SYNCED back to SYNCING.

  • verified_sha256 is preserved when None is passed – the audit-trail digest survives re-verify passes that carry no fresh local SHA (notably the operator-triggered force_verify path).

Parameters:
Return type:

SyncStateJson