pivot 0.2.2__tar.gz → 0.2.3__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.3}/PKG-INFO +1 -1
- {pivot-0.2.2 → pivot-0.2.3}/pyproject.toml +1 -1
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/cli/checkout.py +20 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/engine/engine.py +1 -1
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/executor/commit.py +1 -1
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/executor/core.py +1 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/executor/worker.py +21 -8
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/remote/storage.py +57 -7
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/storage/AGENTS.md +10 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/storage/cache.py +362 -262
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/storage/restore.py +12 -14
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/storage/store.py +22 -3
- {pivot-0.2.2 → pivot-0.2.3}/LICENSE +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/README.md +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/__init__.py +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/ast_utils.py +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/cli/AGENTS.md +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/cli/CLAUDE.md +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/cli/__init__.py +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/cli/_run_common.py +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/cli/check_ignore.py +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/cli/commit.py +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/cli/completion.py +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/cli/config.py +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/cli/console.py +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/cli/dag.py +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/cli/data.py +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/cli/decorators.py +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/cli/doctor.py +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/cli/errors.py +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/cli/fingerprint.py +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/cli/helpers.py +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/cli/history.py +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/cli/import_cmd.py +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/cli/init.py +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/cli/list.py +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/cli/metrics.py +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/cli/params.py +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/cli/plots.py +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/cli/remote.py +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/cli/repro.py +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/cli/run.py +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/cli/status.py +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/cli/targets.py +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/cli/track.py +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/cli/update.py +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/cli/verify.py +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/compose.py +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/config/__init__.py +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/config/io.py +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/config/models.py +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/dag/__init__.py +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/dag/render.py +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/decorators.py +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/discovery.py +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/engine/__init__.py +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/engine/agent_rpc.py +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/engine/graph.py +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/engine/scheduler.py +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/engine/sinks.py +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/engine/sources.py +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/engine/types.py +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/engine/watch.py +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/engine/worker_pool.py +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/exceptions.py +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/executor/AGENTS.md +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/executor/CLAUDE.md +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/executor/__init__.py +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/explain.py +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/fingerprint.py +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/git.py +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/ignore.py +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/import_artifact.py +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/loaders.py +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/metrics.py +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/names.py +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/outputs.py +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/parameters.py +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/path_policy.py +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/project.py +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/py.typed +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/registry.py +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/remote/AGENTS.md +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/remote/__init__.py +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/remote/config.py +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/remote/git_archive.py +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/remote/github.py +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/remote/sync.py +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/run_history.py +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/show/__init__.py +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/show/common.py +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/show/data.py +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/show/metrics.py +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/show/params.py +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/show/plots.py +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/skip.py +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/stage_def.py +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/status.py +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/storage/CLAUDE.md +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/storage/__init__.py +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/storage/artifact_lock.py +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/storage/lock.py +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/storage/presentation.py +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/storage/state.py +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/storage/track.py +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/src/pivot/types.py +0 -0
- {pivot-0.2.2 → pivot-0.2.3}/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:
|
|
@@ -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
|
|
|
@@ -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.
|