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.
Files changed (107) hide show
  1. {pivot-0.2.2 → pivot-0.2.4}/PKG-INFO +1 -1
  2. {pivot-0.2.2 → pivot-0.2.4}/pyproject.toml +1 -1
  3. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/cli/checkout.py +20 -0
  4. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/engine/engine.py +40 -21
  5. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/executor/commit.py +1 -1
  6. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/executor/core.py +1 -0
  7. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/executor/worker.py +21 -8
  8. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/fingerprint.py +12 -4
  9. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/remote/storage.py +57 -7
  10. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/storage/AGENTS.md +10 -0
  11. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/storage/cache.py +362 -262
  12. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/storage/restore.py +12 -14
  13. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/storage/store.py +22 -3
  14. {pivot-0.2.2 → pivot-0.2.4}/LICENSE +0 -0
  15. {pivot-0.2.2 → pivot-0.2.4}/README.md +0 -0
  16. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/__init__.py +0 -0
  17. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/ast_utils.py +0 -0
  18. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/cli/AGENTS.md +0 -0
  19. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/cli/CLAUDE.md +0 -0
  20. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/cli/__init__.py +0 -0
  21. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/cli/_run_common.py +0 -0
  22. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/cli/check_ignore.py +0 -0
  23. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/cli/commit.py +0 -0
  24. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/cli/completion.py +0 -0
  25. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/cli/config.py +0 -0
  26. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/cli/console.py +0 -0
  27. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/cli/dag.py +0 -0
  28. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/cli/data.py +0 -0
  29. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/cli/decorators.py +0 -0
  30. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/cli/doctor.py +0 -0
  31. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/cli/errors.py +0 -0
  32. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/cli/fingerprint.py +0 -0
  33. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/cli/helpers.py +0 -0
  34. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/cli/history.py +0 -0
  35. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/cli/import_cmd.py +0 -0
  36. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/cli/init.py +0 -0
  37. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/cli/list.py +0 -0
  38. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/cli/metrics.py +0 -0
  39. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/cli/params.py +0 -0
  40. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/cli/plots.py +0 -0
  41. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/cli/remote.py +0 -0
  42. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/cli/repro.py +0 -0
  43. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/cli/run.py +0 -0
  44. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/cli/status.py +0 -0
  45. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/cli/targets.py +0 -0
  46. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/cli/track.py +0 -0
  47. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/cli/update.py +0 -0
  48. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/cli/verify.py +0 -0
  49. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/compose.py +0 -0
  50. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/config/__init__.py +0 -0
  51. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/config/io.py +0 -0
  52. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/config/models.py +0 -0
  53. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/dag/__init__.py +0 -0
  54. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/dag/render.py +0 -0
  55. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/decorators.py +0 -0
  56. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/discovery.py +0 -0
  57. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/engine/__init__.py +0 -0
  58. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/engine/agent_rpc.py +0 -0
  59. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/engine/graph.py +0 -0
  60. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/engine/scheduler.py +0 -0
  61. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/engine/sinks.py +0 -0
  62. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/engine/sources.py +0 -0
  63. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/engine/types.py +0 -0
  64. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/engine/watch.py +0 -0
  65. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/engine/worker_pool.py +0 -0
  66. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/exceptions.py +0 -0
  67. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/executor/AGENTS.md +0 -0
  68. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/executor/CLAUDE.md +0 -0
  69. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/executor/__init__.py +0 -0
  70. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/explain.py +0 -0
  71. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/git.py +0 -0
  72. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/ignore.py +0 -0
  73. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/import_artifact.py +0 -0
  74. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/loaders.py +0 -0
  75. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/metrics.py +0 -0
  76. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/names.py +0 -0
  77. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/outputs.py +0 -0
  78. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/parameters.py +0 -0
  79. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/path_policy.py +0 -0
  80. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/project.py +0 -0
  81. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/py.typed +0 -0
  82. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/registry.py +0 -0
  83. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/remote/AGENTS.md +0 -0
  84. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/remote/__init__.py +0 -0
  85. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/remote/config.py +0 -0
  86. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/remote/git_archive.py +0 -0
  87. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/remote/github.py +0 -0
  88. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/remote/sync.py +0 -0
  89. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/run_history.py +0 -0
  90. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/show/__init__.py +0 -0
  91. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/show/common.py +0 -0
  92. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/show/data.py +0 -0
  93. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/show/metrics.py +0 -0
  94. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/show/params.py +0 -0
  95. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/show/plots.py +0 -0
  96. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/skip.py +0 -0
  97. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/stage_def.py +0 -0
  98. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/status.py +0 -0
  99. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/storage/CLAUDE.md +0 -0
  100. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/storage/__init__.py +0 -0
  101. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/storage/artifact_lock.py +0 -0
  102. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/storage/lock.py +0 -0
  103. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/storage/presentation.py +0 -0
  104. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/storage/state.py +0 -0
  105. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/storage/track.py +0 -0
  106. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/types.py +0 -0
  107. {pivot-0.2.2 → pivot-0.2.4}/src/pivot/yaml_config.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pivot
3
- Version: 0.2.2
3
+ Version: 0.2.4
4
4
  Summary: High-performance Python pipeline tool with automatic code change detection
5
5
  Author: Sami Jawhar
6
6
  Author-email: Sami Jawhar <sami@metr.org>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pivot"
3
- version = "0.2.2"
3
+ version = "0.2.4"
4
4
  description = "High-performance Python pipeline tool with automatic code change detection"
5
5
  requires-python = ">=3.13,<3.14"
6
6
  readme = "README.md"
@@ -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=stage_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
@@ -337,6 +337,7 @@ def prepare_worker_info(
337
337
  params_arg_name=stage_info["params_arg_name"],
338
338
  project_root=project_root,
339
339
  state_dir=state_dir,
340
+ project_state_dir=default_state_dir,
340
341
  collection_params=stage_info["collection_params"],
341
342
  )
342
343
 
@@ -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=stage_info["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=stage_info["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=stage_info["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(output_paths, 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
- state_dir: pathlib.Path,
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(state_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=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
- output_hashes[types.identity_key(ref.identity)] = store.commit(ref, path)
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
- # Fingerprint save() if this is a Writer
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(typed_loader.empty)
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
- ) -> None:
192
- """Stream S3 response body to file descriptor in chunks with timeout."""
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 S3 object to local path atomically via temp file with streaming.
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
- os.replace(tmp_path, local_path)
244
- move_succeeded = True
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 files in parallel with atomic writes and streaming.
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.