feat: add Slurm state writer - #908
Conversation
Persist immutable run intent and shard manifests with restrictive descriptor-bound storage, locks, digest validation, and atomic publication. Validate monotonic attempt and readiness updates across concurrent writers, and cover crash recovery, unsafe filesystem state, tampering, and fresh-process reloads. Signed-off-by: Nabin Mulepati <nmulepati@nvidia.com>
Bind loaded records back to persisted plan and location invariants, harden lock and file handling against replacement and hardlink races, and avoid duplicate immutable context reads. Add adversarial, concurrency, interruption-recovery, import, and performance regression coverage. Signed-off-by: Nabin Mulepati <nmulepati@nvidia.com>
Greptile SummaryThe PR adds durable, plan-bound Slurm run state with descriptor-bound filesystem operations and separates persistence responsibilities into focused writer, reader, storage, and validation layers.
|
| Filename | Overview |
|---|---|
| packages/data-designer-slurm/src/data_designer/slurm/filesystem.py | Adds shared descriptor-bound directory, lock, and temporary-file primitives with restrictive ownership policies. |
| packages/data-designer-slurm/src/data_designer/slurm/state/filesystem.py | Implements atomic state publication, replacement, consistent reads, and interrupted-publication recovery; the previously reported recovery races are addressed. |
| packages/data-designer-slurm/src/data_designer/slurm/state/storage.py | Defines the physical state layout and maps typed records onto verified filesystem operations. |
| packages/data-designer-slurm/src/data_designer/slurm/state/reader.py | Composes persisted records into target-shard or full-audit snapshots bound to the resolved plan. |
| packages/data-designer-slurm/src/data_designer/slurm/state/store.py | Adds durable initialization and shard-scoped mutation workflows over the reader, storage, and validation collaborators. |
| packages/data-designer-slurm/src/data_designer/slurm/state/plan_validation.py | Extracts persisted-plan compatibility and identity validation from the integration facade. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart LR
Writer[SlurmStateWriter] --> Validator[PersistedPlanStateValidator]
Writer --> Storage[StateStorage]
Reader[StateReader] --> Validator
Reader --> Storage
Storage --> StateFS[State filesystem policy]
StateFS --> SharedFS[Descriptor-bound filesystem primitives]
SharedFS --> Disk[(Compute-visible storage)]
Reviews (10): Last reviewed commit: "fix: align state boundary invariants" | Re-trigger Greptile
Repair a committed immutable record when an interruption leaves its package-owned temporary hard link behind. Add regression coverage for the exact post-publication interruption window. Signed-off-by: Nabin Mulepati <nmulepati@nvidia.com>
Treat a temporary link already removed by a recovery reader as a successful immutable publication. Cover the publisher-reader cleanup race directly. Signed-off-by: Nabin Mulepati <nmulepati@nvidia.com>
Exercise invalid and missing shard, attempt, ordinal, and readiness inputs through the public state-writer API. Signed-off-by: Nabin Mulepati <nmulepati@nvidia.com>
Let descriptor-bound readers retry valid atomic replacements, including replacements that race interrupted-publication cleanup. Continue rejecting unsafe record types and bound the retry loop. Signed-off-by: Nabin Mulepati <nmulepati@nvidia.com>
Separate validated reads and descriptor-bound storage from the public mutation workflows. Close concurrent attempt publication, retry durability, exact-byte reading, and interrupted recovery races with focused regression coverage. Signed-off-by: Nabin Mulepati <nmulepati@nvidia.com>
Keep error translation at public boundaries and move lock-held happy paths into small helpers. No state method now exceeds 30 lines. Signed-off-by: Nabin Mulepati <nmulepati@nvidia.com>
Signed-off-by: Nabin Mulepati <nmulepati@nvidia.com>
| maximum_size=_MAXIMUM_RECORD_SIZE, | ||
| ) | ||
| record = record_type.model_validate_json(content) | ||
| if record.serialize_json() != content: |
There was a problem hiding this comment.
One thing worth deciding now is how persisted state should behave across package upgrades. This parses with the current model and then requires an exact byte-for-byte reserialization, so even adding a defaulted field to a v1 config, plan, or state record makes older state look corrupt. We reproduced that internally by omitting the defaulted array_tasks field from a DataDesignerSlurmConfig: it still validates, but no longer serializes to the original bytes, and the digest checks would fail too. It would be good to make the policy explicit here, either with versioned readers and migrations or a clear unsupported-version check plus compatibility fixtures that should not be casually regenerated.
There was a problem hiding this comment.
Fixed in e253271. Persisted records now inspect schema_version before model parsing and report an explicit unsupported-version error. For schema v1, non-canonical bytes that only parse because of newly added defaults are rejected with the documented no-migration policy instead of being treated as silently compatible. A frozen compatibility fingerprint covers the authored config, resolved plan, and all state golden records, and regressions cover both an unknown version and the omitted-array_tasks case.
|
|
||
| from data_designer.slurm.config import DataDesignerSlurmConfig | ||
| from data_designer.slurm.contracts import AttemptId, Identifier, ShardId | ||
| from data_designer.slurm.integration import IntegrationContractError, PlanStateValidator |
There was a problem hiding this comment.
The dependency direction feels a little fragile here: state imports integration, while integration imports several state.* modules. The leaf imports and lazy writer export keep it working today, but a future eager export or another planning/client import could turn it into an import cycle. Maybe the persisted plan/state checks could live in a small module under state, with integration.py building on that for finalization validation?
There was a problem hiding this comment.
Fixed in e253271. Persisted plan/state checks now live in state/plan_validation.py as PersistedPlanStateValidator. StateReader and SlurmStateWriter depend only on that state-layer validator; integration.PlanStateValidator builds on it for client/finalization checks. A subprocess regression asserts importing state.store does not import data_designer.slurm.integration.
| shards: tuple[ShardManifest, ...], | ||
| ) -> dict[ShardId, tuple[AttemptManifest, ...]]: | ||
| try: | ||
| attempts_by_shard = {shard.shard_id: self._storage.read_attempts(shard.shard_id) for shard in shards} |
There was a problem hiding this comment.
This currently turns a shard-local operation into a full-run scan. Every attempt or readiness change reads and validates attempts for all shards; create and update also hold the run lock while doing it. With a large array, that will serialize the main write path and make unrelated shard state part of each operation. Since the plan already guarantees one array-task index per shard, a shard-local validation path seems sufficient here, leaving the all-run check for status or auditing.
There was a problem hiding this comment.
Fixed in e253271. Attempt creation/update and readiness publication now acquire only the target shard lock, load one persisted shard, and validate only that shard's attempt sequence. The all-run validator remains available for explicit status/audit snapshots. A multi-shard regression corrupts an unrelated attempt directory and confirms target-shard attempt and readiness writes still succeed while direct reads of the corrupt shard fail.
|
|
||
|
|
||
| @contextmanager | ||
| def acquire_file_lock(directory_descriptor: int, name: str, display_path: Path) -> Iterator[None]: |
There was a problem hiding this comment.
We now have two versions of the same descriptor-bound filesystem building blocks, here and in images/filesystem.py, and they have already started to differ in link, permission, and replacement checks. The .state temporary-name convention is also defined separately in storage.py and in the generator here. This is security-sensitive code, so sharing the low-level primitives and a single temporary-name predicate would make future hardening much easier to keep consistent.
There was a problem hiding this comment.
Fixed in e253271. A shared data_designer.slurm.filesystem module now owns private modes, descriptor-bound directory opens, single-link lock acquisition and replacement checks, restrictive temporary-file creation, stable file facts, and the single managed-temporary-name predicate. Both image and state filesystem adapters compose those primitives, while retaining their domain-specific record/publication policies.
| def _write_readiness_with_lock(self, readiness: AttemptReadiness) -> AttemptReadiness: | ||
| with self._storage.acquire_shard_lock(readiness.shard_id): | ||
| run, plan, shards = self._reader.load_context() | ||
| attempts_by_shard = self._reader.load_validated_attempts(run, plan, shards) |
There was a problem hiding this comment.
[P2] This may become pretty expensive once #909 starts running larger Slurm arrays. Every readiness publication calls load_validated_attempts(), which reads and validates every attempt for every shard. Since each allocation publishes readiness several times, N active shards can produce roughly quadratic shared-filesystem work, and corruption in one unrelated shard prevents the others from reporting progress. Could readiness updates validate only the current shard and attempt, leaving run-wide checks for operations that actually need them?
There was a problem hiding this comment.
Fixed in e253271. Readiness publication now loads and validates only its current shard and attempt under that shard's lock; it no longer calls the all-run attempt scan. The same shard-local path is used by attempt create/update. The regression deliberately corrupts another shard and verifies readiness progress for the target shard remains publishable.
Keep attempt and readiness mutations scoped to their target shard, move persisted plan validation into the state layer, and share descriptor-safe filesystem primitives across state and image storage. Make the schema-v1 no-migration policy explicit and freeze compatibility fixtures. Signed-off-by: Nabin Mulepati <nmulepati@nvidia.com>
| _LOCK_DIRECTORY_NAME = ".locks" | ||
| _MAXIMUM_RECORD_SIZE = 16 * 1024 * 1024 | ||
| _ATTEMPT_NAME_PATTERN = re.compile(r"^attempt-[0-9]{4,}$") | ||
| _TEMPORARY_PREFIX = ".state." |
There was a problem hiding this comment.
The shared predicate helps, but the .state temporary-file convention is still defined twice. These prefix and suffix values are duplicated in state/filesystem.py, where the files are created. If they drift, recoverable leftovers become unexpected records and the attempt directory is reported as corrupt. Could state/filesystem.py expose a state-specific predicate or the constants so creation and recovery share the complete convention?
There was a problem hiding this comment.
Fixed in d741a9d. state/filesystem.py now owns the complete state temporary convention through the public package-internal create_state_temporary_file(...) and is_state_temporary_name(...) pair. Creation, interrupted-publication repair, and StateStorage recovery all use that single owner; storage.py no longer duplicates the prefix/suffix. Added test_incomplete_attempt_recognizes_created_state_temporary_file.
| def acquire_file_lock(directory_descriptor: int, name: str, display_path: Path) -> Iterator[None]: | ||
| """Acquire one exclusive advisory lock without reopening its parent path.""" | ||
| descriptor: int | None = None | ||
| try: |
There was a problem hiding this comment.
One small regression from the extraction: because the yield is inside this try, an OSError from the protected registry operation is now reported as cannot lock image registry target. Previously only acquisition failures were translated, while errors from the operation kept their original context. Could we limit this exception mapping to lock acquisition so future failures are not misdiagnosed as lock problems?
There was a problem hiding this comment.
Fixed in d741a9d. The image adapter now maps only failures from entering the shared lock context to ImageRegistryError; the protected operation runs outside that exception-mapping block and retains its original exception and context. Added test_registry_lock_preserves_operation_oserror.
| persisted: ShardManifest, | ||
| ) -> ShardManifest: | ||
| """Validate one persisted shard against its canonical planned shard.""" | ||
| _require(run.run_id == self.plan.run_id, "run manifest identity does not match the resolved plan") |
There was a problem hiding this comment.
The shard-local path looks much better, but it drops one constant-time run/plan check along with the full scan. validate_plan_shards() checks that run.shard_count matches the plan, while validate_plan_shard() does not. I reproduced the latter accepting an inflated count even though load_shards() rejects the same run as corrupt, so mutations can continue against state that status or audit will not load. Could we share that binding check between both paths? It keeps the hot path shard-local.
There was a problem hiding this comment.
Fixed in d741a9d. validate_plan_shards(...) and shard-local validate_plan_shard(...) now share one constant-time run binding check covering run identity, authored config, plan reference, and shard_count. The hot path still reads only its target shard. Added test_shard_mutation_rejects_run_count_drift_without_scanning_other_shards.
Signed-off-by: Nabin Mulepati <nmulepati@nvidia.com>
andreatnvidia
left a comment
There was a problem hiding this comment.
Looks good from my side now. The follow-up closes the remaining state-boundary and filesystem gaps cleanly, with focused coverage for the cases we discussed. Thanks for turning these around so quickly!
Summary
Adds the durable state foundation for Slurm execution so run intent, shards, attempts, and readiness can be reconstructed safely from compute-visible storage. The implementation composes focused writer, reader, storage, plan-validation, and filesystem responsibilities instead of concentrating them in
store.py.Related Issue
Part of #869 (869#1).
Merge Order and Dependencies
feat/slurm-execution, which includes the merged image-publication hardening from fix: harden Slurm image publication #907.Changes
Added
Changed
SlurmStateWriter,StateReader,StateStorage, and focused filesystem/validation collaborators.OSErrorexceptions unchanged.Fixed
run.shard_countversus persisted-plan validation on the shard-local mutation path.Attention Areas
state/store.py— public state-transition API and short mutation workflows.state/reader.py— target-shard and full-audit snapshot composition.state/storage.py— physical state layout and descriptor-bound storage operations.state/plan_validation.py— persisted-plan compatibility and state binding.filesystem.pyandstate/filesystem.py— shared filesystem invariants and state-specific policy.Testing
make check-slurmmake test-slurm— 1,119 passedmake test-slurm-wheel-installChecklist
Description updated with AI