pivot 0.2.2__tar.gz → 0.2.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {pivot-0.2.2 → pivot-0.2.4}/PKG-INFO +1 -1
- {pivot-0.2.2 → pivot-0.2.4}/pyproject.toml +1 -1
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/cli/checkout.py +20 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/engine/engine.py +40 -21
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/executor/commit.py +1 -1
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/executor/core.py +1 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/executor/worker.py +21 -8
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/fingerprint.py +12 -4
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/remote/storage.py +57 -7
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/storage/AGENTS.md +10 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/storage/cache.py +362 -262
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/storage/restore.py +12 -14
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/storage/store.py +22 -3
- {pivot-0.2.2 → pivot-0.2.4}/LICENSE +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/README.md +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/__init__.py +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/ast_utils.py +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/cli/AGENTS.md +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/cli/CLAUDE.md +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/cli/__init__.py +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/cli/_run_common.py +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/cli/check_ignore.py +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/cli/commit.py +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/cli/completion.py +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/cli/config.py +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/cli/console.py +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/cli/dag.py +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/cli/data.py +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/cli/decorators.py +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/cli/doctor.py +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/cli/errors.py +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/cli/fingerprint.py +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/cli/helpers.py +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/cli/history.py +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/cli/import_cmd.py +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/cli/init.py +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/cli/list.py +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/cli/metrics.py +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/cli/params.py +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/cli/plots.py +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/cli/remote.py +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/cli/repro.py +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/cli/run.py +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/cli/status.py +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/cli/targets.py +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/cli/track.py +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/cli/update.py +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/cli/verify.py +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/compose.py +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/config/__init__.py +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/config/io.py +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/config/models.py +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/dag/__init__.py +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/dag/render.py +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/decorators.py +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/discovery.py +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/engine/__init__.py +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/engine/agent_rpc.py +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/engine/graph.py +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/engine/scheduler.py +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/engine/sinks.py +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/engine/sources.py +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/engine/types.py +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/engine/watch.py +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/engine/worker_pool.py +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/exceptions.py +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/executor/AGENTS.md +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/executor/CLAUDE.md +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/executor/__init__.py +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/explain.py +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/git.py +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/ignore.py +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/import_artifact.py +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/loaders.py +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/metrics.py +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/names.py +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/outputs.py +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/parameters.py +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/path_policy.py +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/project.py +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/py.typed +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/registry.py +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/remote/AGENTS.md +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/remote/__init__.py +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/remote/config.py +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/remote/git_archive.py +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/remote/github.py +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/remote/sync.py +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/run_history.py +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/show/__init__.py +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/show/common.py +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/show/data.py +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/show/metrics.py +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/show/params.py +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/show/plots.py +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/skip.py +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/stage_def.py +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/status.py +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/storage/CLAUDE.md +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/storage/__init__.py +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/storage/artifact_lock.py +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/storage/lock.py +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/storage/presentation.py +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/storage/state.py +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/storage/track.py +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/types.py +0 -0
- {pivot-0.2.2 → pivot-0.2.4}/src/pivot/yaml_config.py +0 -0
|
@@ -89,6 +89,9 @@ def _restore_path_sync(
|
|
|
89
89
|
) -> tuple[RestoreResult, str]:
|
|
90
90
|
"""Restore a file or directory from cache (sync version).
|
|
91
91
|
|
|
92
|
+
Holds the restore lock for path across the existence check and the restore, so a
|
|
93
|
+
concurrent checkout never compares against, or deletes, a half-restored output.
|
|
94
|
+
|
|
92
95
|
Returns:
|
|
93
96
|
Tuple of (result, path_name) for the caller to handle output.
|
|
94
97
|
|
|
@@ -97,6 +100,22 @@ def _restore_path_sync(
|
|
|
97
100
|
"already exists" without --force). Cache misses return ("missing", name)
|
|
98
101
|
instead of raising.
|
|
99
102
|
"""
|
|
103
|
+
with cache.restore_lock(path, state_dir) as lock_token:
|
|
104
|
+
return _restore_path_locked(
|
|
105
|
+
path, output_hash, cache_dir, checkout_modes, behavior, state_dir, lock_token
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _restore_path_locked(
|
|
110
|
+
path: pathlib.Path,
|
|
111
|
+
output_hash: HashInfo,
|
|
112
|
+
cache_dir: pathlib.Path,
|
|
113
|
+
checkout_modes: list[cache.CheckoutMode],
|
|
114
|
+
behavior: CheckoutBehavior,
|
|
115
|
+
state_dir: pathlib.Path | None,
|
|
116
|
+
lock_token: str | None,
|
|
117
|
+
) -> tuple[RestoreResult, str]:
|
|
118
|
+
"""Body of _restore_path_sync; the caller holds the restore lock for path."""
|
|
100
119
|
keep_ignored = False
|
|
101
120
|
if path.exists():
|
|
102
121
|
match behavior:
|
|
@@ -146,6 +165,7 @@ def _restore_path_sync(
|
|
|
146
165
|
cache_dir,
|
|
147
166
|
checkout_modes=checkout_modes,
|
|
148
167
|
state_dir=state_dir,
|
|
168
|
+
lock_token=lock_token,
|
|
149
169
|
keep_ignored=keep_ignored,
|
|
150
170
|
)
|
|
151
171
|
if not success:
|
|
@@ -1306,26 +1306,6 @@ class Engine:
|
|
|
1306
1306
|
run_id=run_id,
|
|
1307
1307
|
)
|
|
1308
1308
|
|
|
1309
|
-
# Check cancellation
|
|
1310
|
-
if self._cancel_event.is_set():
|
|
1311
|
-
cancelled = self._scheduler.apply_cancel()
|
|
1312
|
-
for name, old_state in cancelled:
|
|
1313
|
-
await self.emit(
|
|
1314
|
-
StageStateChanged(
|
|
1315
|
-
type="stage_state_changed",
|
|
1316
|
-
stage=name,
|
|
1317
|
-
state=StageExecutionState.COMPLETED,
|
|
1318
|
-
previous_state=old_state,
|
|
1319
|
-
)
|
|
1320
|
-
)
|
|
1321
|
-
await self._emit_terminal_stage(
|
|
1322
|
-
name,
|
|
1323
|
-
StageStatus.CANCELLED,
|
|
1324
|
-
"cancelled",
|
|
1325
|
-
results,
|
|
1326
|
-
run_id=run_id,
|
|
1327
|
-
)
|
|
1328
|
-
|
|
1329
1309
|
# Start more stages if slots available
|
|
1330
1310
|
if not self._scheduler.stop_starting_new:
|
|
1331
1311
|
await self._start_ready_stages(
|
|
@@ -1389,6 +1369,9 @@ class Engine:
|
|
|
1389
1369
|
"Sentinel send failed (will rely on shutdown_event): %s", exc
|
|
1390
1370
|
)
|
|
1391
1371
|
|
|
1372
|
+
if self._cancel_event.is_set():
|
|
1373
|
+
await self._finalize_cancelled_stages(results, run_id)
|
|
1374
|
+
|
|
1392
1375
|
finally:
|
|
1393
1376
|
# Close StateDBs first (lightweight), then shut down worker pool.
|
|
1394
1377
|
with anyio.CancelScope(shield=True):
|
|
@@ -1682,6 +1665,19 @@ class Engine:
|
|
|
1682
1665
|
skipped = False
|
|
1683
1666
|
if skipped:
|
|
1684
1667
|
continue
|
|
1668
|
+
# A watch event may cancel this run while its coordinator skip check awaited.
|
|
1669
|
+
# Do not submit a stage to the pool after cancellation stopped admissions.
|
|
1670
|
+
if self._cancel_event.is_set():
|
|
1671
|
+
self._scheduler.release_resources(stage_name)
|
|
1672
|
+
await self._set_stage_state(stage_name, StageExecutionState.COMPLETED)
|
|
1673
|
+
await self._emit_terminal_stage(
|
|
1674
|
+
stage_name,
|
|
1675
|
+
StageStatus.CANCELLED,
|
|
1676
|
+
"cancelled",
|
|
1677
|
+
results,
|
|
1678
|
+
run_id=run_id,
|
|
1679
|
+
)
|
|
1680
|
+
continue
|
|
1685
1681
|
|
|
1686
1682
|
# Stage must run — lazily create pool on first dispatch
|
|
1687
1683
|
started += 1
|
|
@@ -1839,7 +1835,7 @@ class Engine:
|
|
|
1839
1835
|
stage_info["outs"],
|
|
1840
1836
|
lock_data["output_hashes"],
|
|
1841
1837
|
state_db,
|
|
1842
|
-
state_dir=
|
|
1838
|
+
state_dir=state_dir,
|
|
1843
1839
|
file_hash_entries=restored_entries,
|
|
1844
1840
|
)
|
|
1845
1841
|
if restored and restored_entries:
|
|
@@ -2162,6 +2158,29 @@ class Engine:
|
|
|
2162
2158
|
|
|
2163
2159
|
return duration_ms
|
|
2164
2160
|
|
|
2161
|
+
async def _finalize_cancelled_stages(
|
|
2162
|
+
self,
|
|
2163
|
+
results: dict[str, executor_core.ExecutionSummary],
|
|
2164
|
+
run_id: str,
|
|
2165
|
+
) -> None:
|
|
2166
|
+
"""Mark every ready or pending stage cancelled exactly once."""
|
|
2167
|
+
for stage_name, old_state in self._scheduler.apply_cancel():
|
|
2168
|
+
await self.emit(
|
|
2169
|
+
StageStateChanged(
|
|
2170
|
+
type="stage_state_changed",
|
|
2171
|
+
stage=stage_name,
|
|
2172
|
+
state=StageExecutionState.COMPLETED,
|
|
2173
|
+
previous_state=old_state,
|
|
2174
|
+
)
|
|
2175
|
+
)
|
|
2176
|
+
await self._emit_terminal_stage(
|
|
2177
|
+
stage_name,
|
|
2178
|
+
StageStatus.CANCELLED,
|
|
2179
|
+
"cancelled",
|
|
2180
|
+
results,
|
|
2181
|
+
run_id=run_id,
|
|
2182
|
+
)
|
|
2183
|
+
|
|
2165
2184
|
async def _emit_terminal_stage(
|
|
2166
2185
|
self,
|
|
2167
2186
|
stage_name: str,
|
|
@@ -150,7 +150,7 @@ def commit_stages(
|
|
|
150
150
|
identity_key = types.identity_key(out.identity)
|
|
151
151
|
try:
|
|
152
152
|
out_path = store.resolve_display_path(out)
|
|
153
|
-
hash_info = store.commit(out, out_path)
|
|
153
|
+
hash_info = store.commit(out, out_path, state_dir=default_state_dir)
|
|
154
154
|
file_hash_entries.extend(worker.output_file_hash_entries(out_path, hash_info))
|
|
155
155
|
output_hashes_by_key[identity_key] = hash_info
|
|
156
156
|
output_hashes_by_identity[out.identity] = hash_info
|
|
@@ -154,6 +154,7 @@ class WorkerStageInfo(TypedDict):
|
|
|
154
154
|
params_arg_name: str | None
|
|
155
155
|
project_root: pathlib.Path
|
|
156
156
|
state_dir: pathlib.Path
|
|
157
|
+
project_state_dir: pathlib.Path
|
|
157
158
|
collection_params: dict[str, str]
|
|
158
159
|
incremental_inputs: dict[str, types.ArtifactIdentity]
|
|
159
160
|
|
|
@@ -194,6 +195,7 @@ def execute_stage(
|
|
|
194
195
|
_ = cache_dir
|
|
195
196
|
ring_buffer = _OutputRingBuffer()
|
|
196
197
|
state_dir = stage_info["state_dir"]
|
|
198
|
+
project_state_dir = stage_info["project_state_dir"]
|
|
197
199
|
project_root = stage_info["project_root"]
|
|
198
200
|
store = store_mod.store_from_spec(stage_info["store_spec"])
|
|
199
201
|
|
|
@@ -276,7 +278,7 @@ def execute_stage(
|
|
|
276
278
|
stage_outs,
|
|
277
279
|
lock_data["output_hashes"],
|
|
278
280
|
state_db,
|
|
279
|
-
state_dir=
|
|
281
|
+
state_dir=project_state_dir,
|
|
280
282
|
file_hash_entries=restored_entries,
|
|
281
283
|
)
|
|
282
284
|
if restored:
|
|
@@ -331,7 +333,7 @@ def execute_stage(
|
|
|
331
333
|
stage_outs,
|
|
332
334
|
lock_data["output_hashes"],
|
|
333
335
|
state_db,
|
|
334
|
-
state_dir=
|
|
336
|
+
state_dir=project_state_dir,
|
|
335
337
|
file_hash_entries=restored_entries,
|
|
336
338
|
)
|
|
337
339
|
if restored:
|
|
@@ -352,7 +354,7 @@ def execute_stage(
|
|
|
352
354
|
stage_outs,
|
|
353
355
|
store,
|
|
354
356
|
state_db,
|
|
355
|
-
state_dir=
|
|
357
|
+
state_dir=project_state_dir,
|
|
356
358
|
)
|
|
357
359
|
if run_cache_skip is not None:
|
|
358
360
|
if no_commit:
|
|
@@ -405,12 +407,15 @@ def execute_stage(
|
|
|
405
407
|
stage_info["incremental_inputs"],
|
|
406
408
|
lock_data,
|
|
407
409
|
state_dir,
|
|
410
|
+
project_state_dir,
|
|
408
411
|
project_root,
|
|
409
412
|
)
|
|
410
413
|
if no_commit:
|
|
411
414
|
output_hashes = _hash_output_paths(output_paths)
|
|
412
415
|
else:
|
|
413
|
-
output_hashes = _commit_outputs_with_store(
|
|
416
|
+
output_hashes = _commit_outputs_with_store(
|
|
417
|
+
output_paths, store, project_state_dir
|
|
418
|
+
)
|
|
414
419
|
for ref, path in output_paths.items():
|
|
415
420
|
file_hash_entries.extend(
|
|
416
421
|
output_file_hash_entries(
|
|
@@ -922,7 +927,8 @@ def _prepare_incremental_inputs(
|
|
|
922
927
|
incremental_inputs: dict[str, types.ArtifactIdentity],
|
|
923
928
|
lock_data: LockData | None,
|
|
924
929
|
store: store_mod.Store,
|
|
925
|
-
|
|
930
|
+
stage_state_dir: pathlib.Path,
|
|
931
|
+
project_state_dir: pathlib.Path,
|
|
926
932
|
) -> dict[str, Any]:
|
|
927
933
|
"""Restore previous incremental outputs and load them for the stage call."""
|
|
928
934
|
if not incremental_inputs:
|
|
@@ -940,7 +946,7 @@ def _prepare_incremental_inputs(
|
|
|
940
946
|
continue
|
|
941
947
|
|
|
942
948
|
output_hash = lock_data["output_hashes"].get(identity)
|
|
943
|
-
stage_lock = lock.StageLock(stage_name, lock.get_stages_dir(
|
|
949
|
+
stage_lock = lock.StageLock(stage_name, lock.get_stages_dir(stage_state_dir))
|
|
944
950
|
if output_hash is None:
|
|
945
951
|
raise exceptions.CacheRestoreError(
|
|
946
952
|
f"Cache missing for incremental output '{types.identity_key(identity)}'. "
|
|
@@ -952,7 +958,7 @@ def _prepare_incremental_inputs(
|
|
|
952
958
|
if not store.restore_from_cache(
|
|
953
959
|
ref,
|
|
954
960
|
output_hash,
|
|
955
|
-
state_dir=
|
|
961
|
+
state_dir=project_state_dir,
|
|
956
962
|
checkout_modes=[config.CheckoutMode.COPY],
|
|
957
963
|
):
|
|
958
964
|
raise exceptions.CacheRestoreError(
|
|
@@ -978,6 +984,7 @@ def _run_stage_function_with_store(
|
|
|
978
984
|
incremental_inputs: dict[str, types.ArtifactIdentity],
|
|
979
985
|
lock_data: LockData | None,
|
|
980
986
|
state_dir: pathlib.Path,
|
|
987
|
+
project_state_dir: pathlib.Path,
|
|
981
988
|
project_root: pathlib.Path,
|
|
982
989
|
) -> dict[types.ArtifactRef, pathlib.Path]:
|
|
983
990
|
with (
|
|
@@ -1006,6 +1013,7 @@ def _run_stage_function_with_store(
|
|
|
1006
1013
|
lock_data,
|
|
1007
1014
|
store,
|
|
1008
1015
|
state_dir,
|
|
1016
|
+
project_state_dir,
|
|
1009
1017
|
)
|
|
1010
1018
|
)
|
|
1011
1019
|
|
|
@@ -1051,12 +1059,17 @@ def _hash_output_paths(
|
|
|
1051
1059
|
def _commit_outputs_with_store(
|
|
1052
1060
|
outputs_by_ref: dict[types.ArtifactRef, pathlib.Path],
|
|
1053
1061
|
store: store_mod.Store,
|
|
1062
|
+
project_state_dir: pathlib.Path,
|
|
1054
1063
|
) -> dict[str, HashInfo]:
|
|
1055
1064
|
output_hashes = dict[str, HashInfo]()
|
|
1056
1065
|
for ref, path in outputs_by_ref.items():
|
|
1057
1066
|
if not path.exists():
|
|
1058
1067
|
raise exceptions.OutputMissingError(f"Stage did not produce output: {path}")
|
|
1059
|
-
|
|
1068
|
+
if isinstance(store, store_mod.WorkspaceStore):
|
|
1069
|
+
output_hash = store.commit(ref, path, state_dir=project_state_dir)
|
|
1070
|
+
else:
|
|
1071
|
+
output_hash = store.commit(ref, path)
|
|
1072
|
+
output_hashes[types.identity_key(ref.identity)] = output_hash
|
|
1060
1073
|
return output_hashes
|
|
1061
1074
|
|
|
1062
1075
|
|
|
@@ -1217,6 +1217,11 @@ def _resolve_dotted_path(parts: tuple[str, ...], ns: dict[str, Any]) -> Any:
|
|
|
1217
1217
|
return obj
|
|
1218
1218
|
|
|
1219
1219
|
|
|
1220
|
+
def _method_function(method: Callable[..., Any]) -> Callable[..., Any]:
|
|
1221
|
+
"""Return the underlying function behind a bound method."""
|
|
1222
|
+
return cast("Callable[..., Any]", getattr(method, "__func__", method))
|
|
1223
|
+
|
|
1224
|
+
|
|
1220
1225
|
def get_loader_fingerprint(loader: "loaders.Writer[Any] | loaders.Reader[Any]") -> dict[str, str]:
|
|
1221
1226
|
"""Generate fingerprint manifest for a loader instance.
|
|
1222
1227
|
|
|
@@ -1244,13 +1249,14 @@ def get_loader_fingerprint(loader: "loaders.Writer[Any] | loaders.Reader[Any]")
|
|
|
1244
1249
|
if method is not None:
|
|
1245
1250
|
_process_callable_dependency(f"{class_name}.{method_name}", method, manifest, visited)
|
|
1246
1251
|
|
|
1247
|
-
#
|
|
1252
|
+
# Use each method definition rather than a temporary bound method so the
|
|
1253
|
+
# weak AST hash cache retains the entry across loader fingerprint calls.
|
|
1248
1254
|
if isinstance(loader, loaders_module.Writer):
|
|
1249
|
-
manifest[f"loader:{class_name}:save"] = hash_function_ast(loader.save)
|
|
1255
|
+
manifest[f"loader:{class_name}:save"] = hash_function_ast(_method_function(loader.save))
|
|
1250
1256
|
|
|
1251
1257
|
# Fingerprint load() if this is a Reader
|
|
1252
1258
|
if isinstance(loader, loaders_module.Reader):
|
|
1253
|
-
manifest[f"loader:{class_name}:load"] = hash_function_ast(loader.load)
|
|
1259
|
+
manifest[f"loader:{class_name}:load"] = hash_function_ast(_method_function(loader.load))
|
|
1254
1260
|
|
|
1255
1261
|
# Only fingerprint empty() if it's overridden in a Loader subclass
|
|
1256
1262
|
if isinstance(loader, loaders_module.Loader):
|
|
@@ -1262,7 +1268,9 @@ def get_loader_fingerprint(loader: "loaders.Writer[Any] | loaders.Reader[Any]")
|
|
|
1262
1268
|
break
|
|
1263
1269
|
if "empty" in cls.__dict__:
|
|
1264
1270
|
# Found override in a subclass
|
|
1265
|
-
manifest[f"loader:{class_name}:empty"] = hash_function_ast(
|
|
1271
|
+
manifest[f"loader:{class_name}:empty"] = hash_function_ast(
|
|
1272
|
+
_method_function(typed_loader.empty)
|
|
1273
|
+
)
|
|
1266
1274
|
break
|
|
1267
1275
|
|
|
1268
1276
|
loader_any = cast("Any", loader)
|
|
@@ -2,12 +2,15 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import contextlib
|
|
5
|
+
import errno
|
|
5
6
|
import logging
|
|
6
7
|
import os
|
|
7
8
|
import re
|
|
8
9
|
import tempfile
|
|
9
10
|
from typing import TYPE_CHECKING, Protocol, TypedDict
|
|
10
11
|
|
|
12
|
+
import xxhash
|
|
13
|
+
|
|
11
14
|
from pivot import config, exceptions, metrics
|
|
12
15
|
from pivot.remote import config as remote_config
|
|
13
16
|
from pivot.types import TransferResult
|
|
@@ -35,6 +38,9 @@ BULK_EXISTS_LIST_THRESHOLD = 50
|
|
|
35
38
|
|
|
36
39
|
_HEX_PATTERN = re.compile(r"^[a-f0-9]{16}$")
|
|
37
40
|
|
|
41
|
+
# link() errors on filesystems without hard links (vfat, exFAT, many SMB and FUSE mounts).
|
|
42
|
+
_NO_HARDLINK_ERRNO = frozenset({errno.EPERM, errno.ENOTSUP, errno.EOPNOTSUPP})
|
|
43
|
+
|
|
38
44
|
# Cached S3 config to avoid re-parsing config on every call.
|
|
39
45
|
# Safe without locking - asyncio is single-threaded, worst case race just
|
|
40
46
|
# creates duplicate config (harmless).
|
|
@@ -188,8 +194,9 @@ async def _write_all_async(fd: int, data: bytes) -> None:
|
|
|
188
194
|
async def _stream_download_to_fd(
|
|
189
195
|
response: GetObjectOutputTypeDef,
|
|
190
196
|
fd: int,
|
|
191
|
-
) ->
|
|
192
|
-
"""Stream S3 response body to
|
|
197
|
+
) -> str:
|
|
198
|
+
"""Stream S3 response body to fd and return its xxh64 integrity digest."""
|
|
199
|
+
hasher = xxhash.xxh64()
|
|
193
200
|
stream = response["Body"]
|
|
194
201
|
try:
|
|
195
202
|
while True:
|
|
@@ -199,10 +206,12 @@ async def _stream_download_to_fd(
|
|
|
199
206
|
)
|
|
200
207
|
if not chunk:
|
|
201
208
|
break
|
|
209
|
+
hasher.update(chunk)
|
|
202
210
|
await _write_all_async(fd, chunk)
|
|
203
211
|
finally:
|
|
204
212
|
with contextlib.suppress(Exception):
|
|
205
213
|
stream.close() # pyright: ignore[reportUnknownMemberType]
|
|
214
|
+
return hasher.hexdigest()
|
|
206
215
|
|
|
207
216
|
|
|
208
217
|
async def _atomic_download(
|
|
@@ -210,12 +219,19 @@ async def _atomic_download(
|
|
|
210
219
|
bucket: str,
|
|
211
220
|
key: str,
|
|
212
221
|
local_path: Path,
|
|
222
|
+
expected_hash: str,
|
|
213
223
|
*,
|
|
224
|
+
overwrite: bool,
|
|
214
225
|
readonly: bool = False,
|
|
215
226
|
) -> None:
|
|
216
|
-
"""Download
|
|
227
|
+
"""Download an expected cache blob atomically and verify its xxh64 integrity.
|
|
228
|
+
|
|
229
|
+
The digest detects accidental corruption during storage or transfer. It does not
|
|
230
|
+
authenticate remote content.
|
|
217
231
|
|
|
218
232
|
Args:
|
|
233
|
+
overwrite: Replace an existing file at local_path. When False, an existing file is
|
|
234
|
+
kept: a content-addressed cache entry another process may be hardlinking.
|
|
219
235
|
readonly: If True, set file permissions to 0o444 (for cache files).
|
|
220
236
|
"""
|
|
221
237
|
local_path.parent.mkdir(parents=True, exist_ok=True)
|
|
@@ -235,13 +251,40 @@ async def _atomic_download(
|
|
|
235
251
|
f"Access denied to {bucket}/{key}. Check AWS credentials."
|
|
236
252
|
) from exc
|
|
237
253
|
raise exceptions.RemoteError(f"Failed to download from S3: {exc}") from exc
|
|
238
|
-
await _stream_download_to_fd(response, fd)
|
|
254
|
+
actual_hash = await _stream_download_to_fd(response, fd)
|
|
239
255
|
os.close(fd)
|
|
240
256
|
fd = -1 # Mark as closed
|
|
257
|
+
if actual_hash != expected_hash:
|
|
258
|
+
raise exceptions.RemoteError(
|
|
259
|
+
f"Hash mismatch: expected {expected_hash}, got {actual_hash} "
|
|
260
|
+
+ f"for {bucket}/{key}. Delete {bucket}/{key} from the remote, then run "
|
|
261
|
+
+ "`pivot push` on a machine whose cache has a good copy and has not run "
|
|
262
|
+
+ "`pivot push`, `pivot pull`, `pivot fetch`, or `pivot status --remote` "
|
|
263
|
+
+ "against this remote for this object. Pivot records hashes those commands "
|
|
264
|
+
+ "upload, download, or find, then skips recorded hashes. If every machine "
|
|
265
|
+
+ "with a good copy has recorded it, delete `.pivot/state.lmdb/` before "
|
|
266
|
+
+ "pushing; this also clears run history and local caches."
|
|
267
|
+
)
|
|
241
268
|
if readonly:
|
|
242
269
|
os.chmod(tmp_path, 0o444)
|
|
243
|
-
|
|
244
|
-
|
|
270
|
+
if overwrite:
|
|
271
|
+
os.replace(tmp_path, local_path)
|
|
272
|
+
move_succeeded = True
|
|
273
|
+
else:
|
|
274
|
+
# link() refuses an existing name, so a concurrent reader never sees the
|
|
275
|
+
# entry disappear. The temp file is removed below either way.
|
|
276
|
+
try:
|
|
277
|
+
os.link(tmp_path, local_path)
|
|
278
|
+
except FileExistsError:
|
|
279
|
+
pass
|
|
280
|
+
except OSError as exc:
|
|
281
|
+
if exc.errno not in _NO_HARDLINK_ERRNO:
|
|
282
|
+
raise
|
|
283
|
+
# No hard links here, so nothing can be hardlinking the entry either:
|
|
284
|
+
# rename unless another process already promoted it.
|
|
285
|
+
if not os.path.exists(local_path):
|
|
286
|
+
os.replace(tmp_path, local_path)
|
|
287
|
+
move_succeeded = True
|
|
245
288
|
finally:
|
|
246
289
|
if fd >= 0:
|
|
247
290
|
os.close(fd)
|
|
@@ -555,6 +598,8 @@ class S3Remote:
|
|
|
555
598
|
self._bucket,
|
|
556
599
|
_hash_to_key(self._prefix, cache_hash),
|
|
557
600
|
local_path,
|
|
601
|
+
cache_hash,
|
|
602
|
+
overwrite=True,
|
|
558
603
|
readonly=readonly,
|
|
559
604
|
)
|
|
560
605
|
except botocore_exc.ClientError as e:
|
|
@@ -614,7 +659,10 @@ class S3Remote:
|
|
|
614
659
|
*,
|
|
615
660
|
readonly: bool = False,
|
|
616
661
|
) -> list[TransferResult]:
|
|
617
|
-
"""Download multiple
|
|
662
|
+
"""Download multiple cache entries in parallel with atomic writes and streaming.
|
|
663
|
+
|
|
664
|
+
A destination that already exists is kept, never replaced: destinations are
|
|
665
|
+
content-addressed cache entries that other processes may be hardlinking.
|
|
618
666
|
|
|
619
667
|
Args:
|
|
620
668
|
readonly: If True, set file permissions to 0o444 (for cache files).
|
|
@@ -643,6 +691,8 @@ class S3Remote:
|
|
|
643
691
|
self._bucket,
|
|
644
692
|
_hash_to_key(self._prefix, hash_),
|
|
645
693
|
local_path,
|
|
694
|
+
hash_,
|
|
695
|
+
overwrite=False,
|
|
646
696
|
readonly=readonly,
|
|
647
697
|
)
|
|
648
698
|
completed += 1
|
|
@@ -56,6 +56,16 @@ When multiple `pivot` processes share the same StateDB:
|
|
|
56
56
|
- LMDB's internal write serialization (via `lock.mdb`) handles actual mutual exclusion
|
|
57
57
|
- On timeout: raises `PivotDBWriteTimeoutError` with diagnostic message
|
|
58
58
|
|
|
59
|
+
## Restore Locking
|
|
60
|
+
|
|
61
|
+
- Restores with a `state_dir` take an exclusive per-target `flock` in `state_dir/locks/`.
|
|
62
|
+
- Restore lock files persist. Do not unlink or age-delete them: a waiter can hold the
|
|
63
|
+
old inode while a new opener locks a replacement inode, defeating mutual exclusion.
|
|
64
|
+
- File restores stage their link or copy beside the target, under a name tagged with the
|
|
65
|
+
target's token, and atomically replace it. Directory restores build in a private
|
|
66
|
+
temporary directory and swap it. Both run under the target's restore lock, and each
|
|
67
|
+
first removes the token-tagged entries a killed restore of the same target left.
|
|
68
|
+
|
|
59
69
|
## Path Storage
|
|
60
70
|
|
|
61
71
|
All paths stored in the database must be **relative** to ensure portability across machines and correct cache behavior.
|