pivot 0.3.4__tar.gz → 0.3.6__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.3.4 → pivot-0.3.6}/PKG-INFO +1 -1
  2. {pivot-0.3.4 → pivot-0.3.6}/pyproject.toml +1 -1
  3. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/cli/track.py +0 -4
  4. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/exceptions.py +6 -4
  5. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/executor/worker.py +61 -13
  6. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/explain.py +11 -4
  7. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/remote/storage.py +47 -55
  8. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/storage/AGENTS.md +42 -0
  9. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/storage/cache.py +431 -80
  10. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/storage/lock.py +13 -2
  11. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/storage/restore.py +6 -7
  12. {pivot-0.3.4 → pivot-0.3.6}/LICENSE +0 -0
  13. {pivot-0.3.4 → pivot-0.3.6}/README.md +0 -0
  14. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/__init__.py +0 -0
  15. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/ast_utils.py +0 -0
  16. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/cli/AGENTS.md +0 -0
  17. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/cli/CLAUDE.md +0 -0
  18. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/cli/__init__.py +0 -0
  19. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/cli/_run_common.py +0 -0
  20. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/cli/check_ignore.py +0 -0
  21. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/cli/checkout.py +0 -0
  22. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/cli/commit.py +0 -0
  23. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/cli/completion.py +0 -0
  24. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/cli/config.py +0 -0
  25. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/cli/console.py +0 -0
  26. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/cli/dag.py +0 -0
  27. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/cli/data.py +0 -0
  28. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/cli/decorators.py +0 -0
  29. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/cli/doctor.py +0 -0
  30. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/cli/errors.py +0 -0
  31. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/cli/fingerprint.py +0 -0
  32. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/cli/helpers.py +0 -0
  33. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/cli/history.py +0 -0
  34. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/cli/import_cmd.py +0 -0
  35. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/cli/init.py +0 -0
  36. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/cli/list.py +0 -0
  37. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/cli/metrics.py +0 -0
  38. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/cli/params.py +0 -0
  39. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/cli/plots.py +0 -0
  40. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/cli/remote.py +0 -0
  41. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/cli/repro.py +0 -0
  42. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/cli/run.py +0 -0
  43. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/cli/status.py +0 -0
  44. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/cli/targets.py +0 -0
  45. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/cli/update.py +0 -0
  46. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/cli/verify.py +0 -0
  47. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/compose.py +0 -0
  48. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/config/__init__.py +0 -0
  49. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/config/io.py +0 -0
  50. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/config/models.py +0 -0
  51. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/dag/__init__.py +0 -0
  52. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/dag/render.py +0 -0
  53. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/decorators.py +0 -0
  54. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/discovery.py +0 -0
  55. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/engine/__init__.py +0 -0
  56. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/engine/agent_rpc.py +0 -0
  57. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/engine/engine.py +0 -0
  58. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/engine/graph.py +0 -0
  59. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/engine/scheduler.py +0 -0
  60. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/engine/sinks.py +0 -0
  61. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/engine/sources.py +0 -0
  62. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/engine/types.py +0 -0
  63. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/engine/watch.py +0 -0
  64. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/engine/worker_pool.py +0 -0
  65. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/executor/AGENTS.md +0 -0
  66. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/executor/CLAUDE.md +0 -0
  67. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/executor/__init__.py +0 -0
  68. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/executor/commit.py +0 -0
  69. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/executor/core.py +0 -0
  70. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/fingerprint.py +0 -0
  71. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/git.py +0 -0
  72. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/ignore.py +0 -0
  73. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/import_artifact.py +0 -0
  74. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/loaders.py +0 -0
  75. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/metrics.py +0 -0
  76. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/names.py +0 -0
  77. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/outputs.py +0 -0
  78. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/parameters.py +0 -0
  79. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/path_policy.py +0 -0
  80. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/project.py +0 -0
  81. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/py.typed +0 -0
  82. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/registry.py +0 -0
  83. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/remote/AGENTS.md +0 -0
  84. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/remote/__init__.py +0 -0
  85. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/remote/config.py +0 -0
  86. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/remote/git_archive.py +0 -0
  87. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/remote/github.py +0 -0
  88. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/remote/sync.py +0 -0
  89. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/run_history.py +0 -0
  90. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/show/__init__.py +0 -0
  91. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/show/common.py +0 -0
  92. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/show/data.py +0 -0
  93. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/show/metrics.py +0 -0
  94. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/show/params.py +0 -0
  95. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/show/plots.py +0 -0
  96. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/skip.py +0 -0
  97. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/stage_def.py +0 -0
  98. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/status.py +0 -0
  99. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/storage/CLAUDE.md +0 -0
  100. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/storage/__init__.py +0 -0
  101. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/storage/artifact_lock.py +0 -0
  102. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/storage/presentation.py +0 -0
  103. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/storage/state.py +0 -0
  104. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/storage/store.py +0 -0
  105. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/storage/track.py +0 -0
  106. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/types.py +0 -0
  107. {pivot-0.3.4 → pivot-0.3.6}/src/pivot/yaml_config.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pivot
3
- Version: 0.3.4
3
+ Version: 0.3.6
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.3.4"
3
+ version = "0.3.6"
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"
@@ -161,10 +161,6 @@ def _track_single_path(
161
161
  total_size = sum(e["size"] for e in manifest)
162
162
  num_files = len(manifest)
163
163
 
164
- # copy_to_cache is idempotent and already checks existence itself; letting it see
165
- # every entry (not just ones missing from the cache) is what lets it republish an
166
- # object `pivot track --force` finds cached non-executable by an older pivot or a
167
- # cold `pivot pull`, when this entry's bytes should be executable.
168
164
  for entry in manifest:
169
165
  file_path = path / entry["relpath"]
170
166
  file_cache_path = cache.get_cache_path(cache_dir, entry["hash"])
@@ -221,6 +221,8 @@ class CacheModeCollisionError(CacheError):
221
221
  This happens when two files with identical content disagree on their executable bit (the
222
222
  cache key is content-only) or when a cache object was populated by a path that does not
223
223
  know a file's intended executable bit (e.g. `pivot pull`, which downloads by hash alone).
224
+ Pivot never changes a published cache object, so only a private copy, or the object deleted
225
+ and published again from bytes that carry the right bit, can give the file its bit.
224
226
  """
225
227
 
226
228
  @override
@@ -228,10 +230,10 @@ class CacheModeCollisionError(CacheError):
228
230
  return (
229
231
  "Add CheckoutMode.COPY to checkout_modes (cache: checkout_mode: [..., copy] in "
230
232
  ".pivot/config.yaml) so checkout can fall back to a private, correctly-permissioned "
231
- "copy instead of sharing the cache object's own permissions; or, if you have the "
232
- "source bytes in hand, re-running what produced them (`pivot repro --force "
233
- "<stage>`, `pivot track --force <path>`) republishes the cache object with the "
234
- "right bit"
233
+ "copy instead of sharing the cache object's own permissions; or delete the cache "
234
+ "object the error names, then re-run what produced the file (`pivot repro --force "
235
+ "<stage>`, `pivot track --force <path>`) to publish it with the right bit; until it "
236
+ "is published again, any workspace file symlinked to it is broken"
235
237
  )
236
238
 
237
239
 
@@ -251,7 +251,7 @@ def execute_stage(
251
251
  lock_data = production_lock.read()
252
252
 
253
253
  with state.StateDB(state_dir, readonly=True) as state_db:
254
- outputs_missing_from_cache = False
254
+ outputs_unusable: str | None = None
255
255
  if lock_data is not None and not stage_info["force"]:
256
256
  out_paths = [types.identity_key(out.identity) for out in stage_outs]
257
257
  if can_skip_via_generation(
@@ -272,15 +272,16 @@ def execute_stage(
272
272
  stage_outs,
273
273
  )
274
274
  restored_entries = list[tuple[str, int, int, int, str]]()
275
- restored = restore_outputs_to_hashes(
275
+ restoration = restore_outputs_to_hashes(
276
276
  store,
277
277
  stage_outs,
278
278
  lock_data["output_hashes"],
279
279
  state_db,
280
280
  state_dir=project_state_dir,
281
+ report_changes=True,
281
282
  file_hash_entries=restored_entries,
282
283
  )
283
- if restored:
284
+ if restoration.available:
284
285
  return _make_result(
285
286
  StageStatus.CACHED,
286
287
  "unchanged (generation)",
@@ -288,7 +289,7 @@ def execute_stage(
288
289
  input_hash=input_hash,
289
290
  file_hash_entries=restored_entries,
290
291
  )
291
- outputs_missing_from_cache = True
292
+ outputs_unusable = _outputs_unusable_reason(restoration)
292
293
 
293
294
  dep_hashes, missing, unreadable, file_hash_entries = hash_dependencies(
294
295
  stage_info["deps"], store, state_db
@@ -316,9 +317,9 @@ def execute_stage(
316
317
  dep_hashes,
317
318
  )
318
319
 
319
- if outputs_missing_from_cache and skip_reason is not None:
320
+ if outputs_unusable is not None and skip_reason is not None:
320
321
  skip_reason = None
321
- run_reason = "outputs missing from cache"
322
+ run_reason = outputs_unusable
322
323
 
323
324
  # Override skip decision if force flag is set
324
325
  if stage_info["force"] and skip_reason is not None:
@@ -327,15 +328,16 @@ def execute_stage(
327
328
 
328
329
  if skip_reason is not None and lock_data is not None:
329
330
  restored_entries = list[tuple[str, int, int, int, str]]()
330
- restored = restore_outputs_to_hashes(
331
+ restoration = restore_outputs_to_hashes(
331
332
  store,
332
333
  stage_outs,
333
334
  lock_data["output_hashes"],
334
335
  state_db,
335
336
  state_dir=project_state_dir,
337
+ report_changes=True,
336
338
  file_hash_entries=restored_entries,
337
339
  )
338
- if restored:
340
+ if restoration.available:
339
341
  return _make_result(
340
342
  StageStatus.CACHED,
341
343
  skip_reason,
@@ -343,7 +345,7 @@ def execute_stage(
343
345
  input_hash=input_hash,
344
346
  file_hash_entries=restored_entries,
345
347
  )
346
- run_reason = "outputs missing from cache"
348
+ run_reason = _outputs_unusable_reason(restoration)
347
349
 
348
350
  # Check run cache for previously executed configuration (skip if forcing)
349
351
  if run_reason and not stage_info["force"] and run_cache_allowed:
@@ -494,13 +496,49 @@ class OutputRestoreResult(NamedTuple):
494
496
  """Whether output restoration succeeded and wrote workspace bytes.
495
497
 
496
498
  ``unavailable`` lists outputs that neither match their locked hash nor can be restored
497
- from the cache. A check (``restore=False``) lists every such output; a restore stops at
498
- the first, since the stage must run anyway.
499
+ from the cache. ``empty_dirs_unrecorded`` lists outputs that hold every file their locked
500
+ hash records plus empty directories it does not, as a hash pivot wrote before 0.3.4 cannot
501
+ record them (``unrecorded_empty_dirs_only``): restoring such an output would delete the
502
+ directories, so the stage runs instead. A check (``restore=False``) lists every such output;
503
+ a restore stops at the first, since the stage must run anyway.
499
504
  """
500
505
 
501
506
  available: bool
502
507
  outputs_restored: bool
503
508
  unavailable: list[types.ArtifactRef]
509
+ empty_dirs_unrecorded: list[types.ArtifactRef]
510
+
511
+
512
+ def unrecorded_empty_dirs_only(expected: HashInfo, actual: HashInfo) -> bool:
513
+ """Whether directory output ``actual`` is ``expected``'s files plus empty directories that
514
+ ``expected`` cannot record.
515
+
516
+ Pivot before 0.3.4 hashed a directory by its files alone and recorded none of its empty
517
+ directories. Restoring an output to such a hash deletes the empty directories its stage
518
+ made, and the stages that read the output then hash the stripped tree to the old value and
519
+ stay cached, though a fresh build would hold the directories.
520
+ """
521
+ return (
522
+ is_dir_hash(expected)
523
+ and is_dir_hash(actual)
524
+ and "empty_dirs" not in expected
525
+ and "empty_dirs" in actual
526
+ and actual["manifest"] == expected["manifest"]
527
+ )
528
+
529
+
530
+ def empty_dirs_unrecorded_reason(refs: list[types.ArtifactRef]) -> str:
531
+ """Say why a stage whose lock is current must run: ``refs`` are its outputs whose lock does
532
+ not record the empty directories they hold."""
533
+ keys = ", ".join(types.identity_key(ref.identity) for ref in refs)
534
+ return f"Output holds empty directories its lock does not record: {keys}"
535
+
536
+
537
+ def _outputs_unusable_reason(restoration: OutputRestoreResult) -> str:
538
+ """The reason a stage whose lock is current runs because its outputs could not be used."""
539
+ if restoration.empty_dirs_unrecorded:
540
+ return empty_dirs_unrecorded_reason(restoration.empty_dirs_unrecorded)
541
+ return "outputs missing from cache"
504
542
 
505
543
 
506
544
  @overload
@@ -548,6 +586,7 @@ def restore_outputs_to_hashes(
548
586
  restored from the cache, so the next check finds it without reading it.
549
587
  """
550
588
  unavailable = list[types.ArtifactRef]()
589
+ empty_dirs_unrecorded = list[types.ArtifactRef]()
551
590
  outputs_restored = False
552
591
  if not isinstance(store, store_mod.WorkspaceStore):
553
592
  unavailable = [ref for ref in outs if not store.exists(ref)]
@@ -562,6 +601,13 @@ def restore_outputs_to_hashes(
562
601
  actual_hash = None
563
602
  if actual_hash == expected_hash:
564
603
  continue
604
+ if actual_hash is not None and unrecorded_empty_dirs_only(
605
+ expected_hash, actual_hash
606
+ ):
607
+ empty_dirs_unrecorded.append(ref)
608
+ if restore:
609
+ break
610
+ continue
565
611
  if store.cache_contains(ref, expected_hash):
566
612
  if not restore:
567
613
  continue
@@ -575,13 +621,15 @@ def restore_outputs_to_hashes(
575
621
  unavailable.append(ref)
576
622
  if restore:
577
623
  break
624
+ available = not unavailable and not empty_dirs_unrecorded
578
625
  if report_changes:
579
626
  return OutputRestoreResult(
580
- available=not unavailable,
627
+ available=available,
581
628
  outputs_restored=outputs_restored,
582
629
  unavailable=unavailable,
630
+ empty_dirs_unrecorded=empty_dirs_unrecorded,
583
631
  )
584
- return not unavailable
632
+ return available
585
633
 
586
634
 
587
635
  def _check_skip_or_run(
@@ -261,8 +261,9 @@ def get_stage_explanation(
261
261
  force=force,
262
262
  )
263
263
  unavailable = list[types.ArtifactRef]()
264
+ empty_dirs_unrecorded = list[types.ArtifactRef]()
264
265
  if not allow_missing:
265
- unavailable = worker.restore_outputs_to_hashes(
266
+ restoration = worker.restore_outputs_to_hashes(
266
267
  store,
267
268
  outs,
268
269
  lock_data["output_hashes"],
@@ -270,13 +271,19 @@ def get_stage_explanation(
270
271
  state_dir=state_dir,
271
272
  restore=False,
272
273
  report_changes=True,
273
- ).unavailable
274
- if not decision["changed"] and unavailable:
274
+ )
275
+ unavailable = restoration.unavailable
276
+ empty_dirs_unrecorded = restoration.empty_dirs_unrecorded
277
+ if not decision["changed"] and (unavailable or empty_dirs_unrecorded):
275
278
  return StageExplanation(
276
279
  stage_name=stage_name,
277
280
  will_run=True,
278
281
  is_forced=force,
279
- reason=_unavailable_outputs_reason(store, unavailable),
282
+ reason=(
283
+ _unavailable_outputs_reason(store, unavailable)
284
+ if unavailable
285
+ else worker.empty_dirs_unrecorded_reason(empty_dirs_unrecorded)
286
+ ),
280
287
  code_changes=[],
281
288
  param_changes=[],
282
289
  dep_changes=[],
@@ -2,7 +2,6 @@ from __future__ import annotations
2
2
 
3
3
  import asyncio
4
4
  import contextlib
5
- import errno
6
5
  import logging
7
6
  import os
8
7
  import re
@@ -13,6 +12,7 @@ import xxhash
13
12
 
14
13
  from pivot import config, exceptions, metrics
15
14
  from pivot.remote import config as remote_config
15
+ from pivot.storage import cache
16
16
  from pivot.types import TransferResult
17
17
 
18
18
  if TYPE_CHECKING:
@@ -38,9 +38,6 @@ BULK_EXISTS_LIST_THRESHOLD = 50
38
38
 
39
39
  _HEX_PATTERN = re.compile(r"^[a-f0-9]{16}$")
40
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
-
44
41
  # Cached S3 config to avoid re-parsing config on every call.
45
42
  # Safe without locking - asyncio is single-threaded, worst case race just
46
43
  # creates duplicate config (harmless).
@@ -230,66 +227,61 @@ async def _atomic_download(
230
227
  authenticate remote content.
231
228
 
232
229
  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.
230
+ overwrite: Replace an existing file at local_path. When False, local_path is a cache
231
+ object: an existing one is kept (another process may be hardlinking it), and the
232
+ download is staged so a killed one is swept (``cache.staged_cache_file``).
235
233
  readonly: If True, set file permissions to 0o444 (for cache files).
236
234
  """
237
- local_path.parent.mkdir(parents=True, exist_ok=True)
238
- fd, tmp_path = tempfile.mkstemp(dir=local_path.parent, prefix=".pivot_download_")
239
- move_succeeded = False
240
- try:
241
- try:
242
- response = await s3.get_object(Bucket=bucket, Key=key)
243
- except Exception as exc:
244
- error_str = str(exc)
245
- if "NoSuchKey" in error_str or "404" in error_str:
246
- raise exceptions.RemoteError(
247
- f"Remote cache file not found: {bucket}/{key}"
248
- ) from exc
249
- if "AccessDenied" in error_str or "403" in error_str:
250
- raise exceptions.RemoteError(
251
- f"Access denied to {bucket}/{key}. Check AWS credentials."
252
- ) from exc
253
- raise exceptions.RemoteError(f"Failed to download from S3: {exc}") from exc
254
- actual_hash = await _stream_download_to_fd(response, fd)
255
- os.close(fd)
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
- )
235
+ with contextlib.ExitStack() as stack:
236
+ if overwrite:
237
+ local_path.parent.mkdir(parents=True, exist_ok=True)
238
+ fd, name = tempfile.mkstemp(dir=local_path.parent, prefix=".pivot_download_")
239
+ os.close(fd)
240
+ tmp_path = local_path.with_name(os.path.basename(name))
241
+ stack.callback(tmp_path.unlink, missing_ok=True)
242
+ else:
243
+ tmp_path = stack.enter_context(cache.staged_cache_file(local_path))
244
+ await _download_verified(s3, bucket, key, tmp_path, expected_hash)
268
245
  if readonly:
269
246
  os.chmod(tmp_path, 0o444)
270
247
  if overwrite:
271
248
  os.replace(tmp_path, local_path)
272
- move_succeeded = True
273
249
  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
250
+ _ = cache.publish_cache_object(tmp_path, local_path)
251
+
252
+
253
+ async def _download_verified(
254
+ s3: S3Client, bucket: str, key: str, dest: Path, expected_hash: str
255
+ ) -> None:
256
+ """Stream bucket/key into the existing file ``dest`` and check its xxh64 digest."""
257
+ try:
258
+ response = await s3.get_object(Bucket=bucket, Key=key)
259
+ except Exception as exc:
260
+ error_str = str(exc)
261
+ if "NoSuchKey" in error_str or "404" in error_str:
262
+ raise exceptions.RemoteError(f"Remote cache file not found: {bucket}/{key}") from exc
263
+ if "AccessDenied" in error_str or "403" in error_str:
264
+ raise exceptions.RemoteError(
265
+ f"Access denied to {bucket}/{key}. Check AWS credentials."
266
+ ) from exc
267
+ raise exceptions.RemoteError(f"Failed to download from S3: {exc}") from exc
268
+ fd = os.open(dest, os.O_WRONLY | os.O_TRUNC)
269
+ try:
270
+ actual_hash = await _stream_download_to_fd(response, fd)
288
271
  finally:
289
- if fd >= 0:
272
+ with contextlib.suppress(OSError):
290
273
  os.close(fd)
291
- if not move_succeeded and os.path.exists(tmp_path):
292
- os.unlink(tmp_path)
274
+ if actual_hash != expected_hash:
275
+ raise exceptions.RemoteError(
276
+ f"Hash mismatch: expected {expected_hash}, got {actual_hash} "
277
+ + f"for {bucket}/{key}. Delete {bucket}/{key} from the remote, then run "
278
+ + "`pivot push` on a machine whose cache has a good copy and has not run "
279
+ + "`pivot push`, `pivot pull`, `pivot fetch`, or `pivot status --remote` "
280
+ + "against this remote for this object. Pivot records hashes those commands "
281
+ + "upload, download, or find, then skips recorded hashes. If every machine "
282
+ + "with a good copy has recorded it, delete `.pivot/state.lmdb/` before "
283
+ + "pushing; this also clears run history and local caches."
284
+ )
293
285
 
294
286
 
295
287
  async def _stream_upload(
@@ -66,6 +66,48 @@ When multiple `pivot` processes share the same StateDB:
66
66
  temporary directory and swap it. Both run under the target's restore lock, and each
67
67
  first removes the token-tagged entries a killed restore of the same target left.
68
68
 
69
+ ## Publishing Cache Objects
70
+
71
+ - Every writer of a file object publishes it with `cache.publish_cache_object`, which links it
72
+ into place and keeps an object already there. A published object never changes, mode
73
+ included: a hardlink checkout looks the name up, then links the inode it found, and once a
74
+ rename takes that inode's last name Linux fails the link with ENOENT, though the name never
75
+ went missing; a symlink checkout reads its executable bit through the name. A directory
76
+ entry whose executable bit the object does not carry is checked out as a copy.
77
+ - On a filesystem without hard links, `publish_cache_object` renames, and only when no object is
78
+ there yet. Nothing there can hardlink an object, but the check and the rename are two steps:
79
+ two writers that both find the name free both rename, the later object wins, and a symlink
80
+ checkout reads the later object's mode.
81
+ - Every temporary entry in a prefix directory (a file staged for an object, a download, a tree
82
+ being copied) is made through `cache.staged_cache_file` or `_staging_in`, named
83
+ `.pivot_tmp_*`, while the process holds a shared `flock` on the directory's
84
+ `.pivot_cache.lock`. A process that gets the exclusive lock first (it never waits for it), at
85
+ most once per `_CACHE_SWEEP_INTERVAL` (the lock's mtime records the last sweep), removes every
86
+ `.pivot_tmp_*` entry: none has a live owner. An entry it cannot remove is logged and left.
87
+ Never judge a temporary entry abandoned by host and pid: a process in another PID namespace,
88
+ or on another host sharing the cache, looks dead.
89
+ - Sharing a cache across hosts needs `flock` that is coherent across them (NFSv4, or NFSv3 with
90
+ NLM). With host-local locks (NFS `nolock` or `local_lock=flock`/`all`, Lustre `localflock`)
91
+ the locks here exclude nothing across hosts: one host's sweep can remove another host's live
92
+ temporary entry, and two hosts can repair one tree at once. A publisher checks its tree before
93
+ marking it, which narrows the window for marking a copy that lost files without closing it: a
94
+ sweep already deleting through the copy's directory descriptors keeps deleting after the
95
+ rename. Where `flock()` is refused (Lustre without `flock`: ENOSYS; NFS when its lock manager
96
+ does not answer: ENOLCK) staging goes on without the shared lock, and this process neither
97
+ sweeps nor repairs a tree. Only where every host sharing the cache is refused does nothing
98
+ sweep: an NFSv3 client whose own `rpc.statd` is down gets ENOLCK while other clients' locks
99
+ work, and their sweeps can remove its live temporary entries, with the effects above.
100
+ - A lock file in the cache is created writable by whoever can write its directory, since NFS
101
+ takes an exclusive lock only on a file opened for writing; one this user cannot write is
102
+ opened read-only. Lock files are never deleted.
103
+ - A whole-directory SYMLINK tree is built under a temporary name beside its cache name, renamed
104
+ into place, checked, then marked (`<tree>.verified`), because a restore symlinks to a tree as
105
+ soon as its name exists. A restore or commit checks an unmarked tree (exact files, sizes,
106
+ executable bits and directories) under the tree's exclusive `<tree>.lock`, repairs it in place
107
+ from the files' own cache objects, then marks it. A tree it cannot repair is not used: a
108
+ restore goes file by file, a commit fails. `<tree>.layout-checked`, the mark pivot 0.3.4 also
109
+ gave partial trees, is not trusted.
110
+
69
111
  ## Path Storage
70
112
 
71
113
  All paths stored in the database must be **relative** to ensure portability across machines and correct cache behavior.