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.
- {pivot-0.3.4 → pivot-0.3.6}/PKG-INFO +1 -1
- {pivot-0.3.4 → pivot-0.3.6}/pyproject.toml +1 -1
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/cli/track.py +0 -4
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/exceptions.py +6 -4
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/executor/worker.py +61 -13
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/explain.py +11 -4
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/remote/storage.py +47 -55
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/storage/AGENTS.md +42 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/storage/cache.py +431 -80
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/storage/lock.py +13 -2
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/storage/restore.py +6 -7
- {pivot-0.3.4 → pivot-0.3.6}/LICENSE +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/README.md +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/__init__.py +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/ast_utils.py +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/cli/AGENTS.md +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/cli/CLAUDE.md +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/cli/__init__.py +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/cli/_run_common.py +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/cli/check_ignore.py +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/cli/checkout.py +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/cli/commit.py +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/cli/completion.py +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/cli/config.py +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/cli/console.py +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/cli/dag.py +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/cli/data.py +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/cli/decorators.py +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/cli/doctor.py +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/cli/errors.py +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/cli/fingerprint.py +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/cli/helpers.py +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/cli/history.py +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/cli/import_cmd.py +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/cli/init.py +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/cli/list.py +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/cli/metrics.py +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/cli/params.py +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/cli/plots.py +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/cli/remote.py +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/cli/repro.py +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/cli/run.py +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/cli/status.py +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/cli/targets.py +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/cli/update.py +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/cli/verify.py +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/compose.py +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/config/__init__.py +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/config/io.py +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/config/models.py +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/dag/__init__.py +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/dag/render.py +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/decorators.py +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/discovery.py +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/engine/__init__.py +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/engine/agent_rpc.py +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/engine/engine.py +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/engine/graph.py +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/engine/scheduler.py +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/engine/sinks.py +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/engine/sources.py +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/engine/types.py +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/engine/watch.py +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/engine/worker_pool.py +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/executor/AGENTS.md +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/executor/CLAUDE.md +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/executor/__init__.py +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/executor/commit.py +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/executor/core.py +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/fingerprint.py +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/git.py +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/ignore.py +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/import_artifact.py +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/loaders.py +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/metrics.py +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/names.py +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/outputs.py +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/parameters.py +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/path_policy.py +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/project.py +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/py.typed +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/registry.py +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/remote/AGENTS.md +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/remote/__init__.py +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/remote/config.py +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/remote/git_archive.py +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/remote/github.py +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/remote/sync.py +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/run_history.py +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/show/__init__.py +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/show/common.py +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/show/data.py +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/show/metrics.py +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/show/params.py +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/show/plots.py +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/skip.py +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/stage_def.py +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/status.py +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/storage/CLAUDE.md +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/storage/__init__.py +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/storage/artifact_lock.py +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/storage/presentation.py +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/storage/state.py +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/storage/store.py +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/storage/track.py +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/types.py +0 -0
- {pivot-0.3.4 → pivot-0.3.6}/src/pivot/yaml_config.py +0 -0
|
@@ -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
|
|
232
|
-
"
|
|
233
|
-
"<stage>`, `pivot track --force <path>`)
|
|
234
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
320
|
+
if outputs_unusable is not None and skip_reason is not None:
|
|
320
321
|
skip_reason = None
|
|
321
|
-
run_reason =
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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.
|
|
498
|
-
|
|
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=
|
|
627
|
+
available=available,
|
|
581
628
|
outputs_restored=outputs_restored,
|
|
582
629
|
unavailable=unavailable,
|
|
630
|
+
empty_dirs_unrecorded=empty_dirs_unrecorded,
|
|
583
631
|
)
|
|
584
|
-
return
|
|
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
|
-
|
|
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
|
-
)
|
|
274
|
-
|
|
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=
|
|
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,
|
|
234
|
-
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
-
|
|
272
|
+
with contextlib.suppress(OSError):
|
|
290
273
|
os.close(fd)
|
|
291
|
-
|
|
292
|
-
|
|
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.
|