model-mirror-cli 0.2.0__py3-none-any.whl
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.
- model_mirror/__init__.py +7 -0
- model_mirror/audit.py +214 -0
- model_mirror/checksums.py +371 -0
- model_mirror/cli.py +2436 -0
- model_mirror/config.py +300 -0
- model_mirror/hub.py +901 -0
- model_mirror/lock.py +119 -0
- model_mirror/mirror.py +201 -0
- model_mirror/progress.py +260 -0
- model_mirror/removal.py +164 -0
- model_mirror/repair.py +399 -0
- model_mirror/state.py +150 -0
- model_mirror/torrent.py +453 -0
- model_mirror/torrent_coverage.py +400 -0
- model_mirror/torrent_hashes.py +168 -0
- model_mirror/torrent_import.py +620 -0
- model_mirror/torrent_publication.py +582 -0
- model_mirror/torrent_seed.py +228 -0
- model_mirror/verify.py +153 -0
- model_mirror_cli-0.2.0.dist-info/METADATA +550 -0
- model_mirror_cli-0.2.0.dist-info/RECORD +24 -0
- model_mirror_cli-0.2.0.dist-info/WHEEL +4 -0
- model_mirror_cli-0.2.0.dist-info/entry_points.txt +2 -0
- model_mirror_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
model_mirror/lock.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import errno
|
|
4
|
+
import fcntl
|
|
5
|
+
import os
|
|
6
|
+
import socket
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import yaml
|
|
11
|
+
|
|
12
|
+
from .state import utc_now
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
LOCK_FILE = ".verification.lock"
|
|
16
|
+
REMOVAL_MARKER = ".model-mirror-removal.json"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ModelBusyError(RuntimeError):
|
|
20
|
+
def __init__(self, root: Path, info: dict):
|
|
21
|
+
self.root = root
|
|
22
|
+
self.info = info
|
|
23
|
+
detail = lock_label(info)
|
|
24
|
+
super().__init__(f"model mirror is busy: {root} ({detail})")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(slots=True)
|
|
28
|
+
class ModelLock:
|
|
29
|
+
root: Path
|
|
30
|
+
command: str
|
|
31
|
+
repo_id: str
|
|
32
|
+
repo_type: str = "model"
|
|
33
|
+
wait: bool = False
|
|
34
|
+
handle: object | None = None
|
|
35
|
+
|
|
36
|
+
def __enter__(self):
|
|
37
|
+
self.root.mkdir(parents=True, exist_ok=True)
|
|
38
|
+
path = lock_path(self.root)
|
|
39
|
+
self.handle = path.open("a+", encoding="utf-8")
|
|
40
|
+
flags = fcntl.LOCK_EX if self.wait else fcntl.LOCK_EX | fcntl.LOCK_NB
|
|
41
|
+
try:
|
|
42
|
+
fcntl.flock(self.handle.fileno(), flags)
|
|
43
|
+
except OSError as exc:
|
|
44
|
+
if exc.errno not in {errno.EACCES, errno.EAGAIN}:
|
|
45
|
+
raise
|
|
46
|
+
self.handle.seek(0)
|
|
47
|
+
info = yaml.safe_load(self.handle.read()) or {}
|
|
48
|
+
raise ModelBusyError(self.root, info) from exc
|
|
49
|
+
|
|
50
|
+
marker = self.root / REMOVAL_MARKER
|
|
51
|
+
if self.command != "remove" and (marker.exists() or marker.is_symlink()):
|
|
52
|
+
info = {
|
|
53
|
+
"command": "remove-pending",
|
|
54
|
+
"repo_id": self.repo_id,
|
|
55
|
+
"repo_type": self.repo_type,
|
|
56
|
+
}
|
|
57
|
+
fcntl.flock(self.handle.fileno(), fcntl.LOCK_UN)
|
|
58
|
+
self.handle.close()
|
|
59
|
+
self.handle = None
|
|
60
|
+
raise ModelBusyError(self.root, info)
|
|
61
|
+
|
|
62
|
+
info = {
|
|
63
|
+
"pid": os.getpid(),
|
|
64
|
+
"host": socket.gethostname(),
|
|
65
|
+
"command": self.command,
|
|
66
|
+
"repo_id": self.repo_id,
|
|
67
|
+
"repo_type": self.repo_type,
|
|
68
|
+
"started_at_utc": utc_now(),
|
|
69
|
+
}
|
|
70
|
+
self.handle.seek(0)
|
|
71
|
+
self.handle.truncate()
|
|
72
|
+
self.handle.write(yaml.safe_dump(info, sort_keys=True))
|
|
73
|
+
self.handle.flush()
|
|
74
|
+
os.fsync(self.handle.fileno())
|
|
75
|
+
return self
|
|
76
|
+
|
|
77
|
+
def __exit__(self, exc_type, exc, tb):
|
|
78
|
+
if self.handle is None:
|
|
79
|
+
return False
|
|
80
|
+
self.handle.seek(0)
|
|
81
|
+
self.handle.truncate()
|
|
82
|
+
self.handle.flush()
|
|
83
|
+
os.fsync(self.handle.fileno())
|
|
84
|
+
fcntl.flock(self.handle.fileno(), fcntl.LOCK_UN)
|
|
85
|
+
self.handle.close()
|
|
86
|
+
self.handle = None
|
|
87
|
+
return False
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def lock_path(root: Path) -> Path:
|
|
91
|
+
return root / LOCK_FILE
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def read_active_lock(root: Path) -> dict | None:
|
|
95
|
+
path = lock_path(root)
|
|
96
|
+
if not path.exists():
|
|
97
|
+
return None
|
|
98
|
+
with path.open("r", encoding="utf-8") as handle:
|
|
99
|
+
try:
|
|
100
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
101
|
+
except OSError as exc:
|
|
102
|
+
if exc.errno not in {errno.EACCES, errno.EAGAIN}:
|
|
103
|
+
raise
|
|
104
|
+
handle.seek(0)
|
|
105
|
+
return yaml.safe_load(handle.read()) or {}
|
|
106
|
+
else:
|
|
107
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def lock_label(info: dict | None) -> str:
|
|
112
|
+
if not info:
|
|
113
|
+
return "lock held"
|
|
114
|
+
parts = []
|
|
115
|
+
for key in ("command", "pid", "host", "started_at_utc"):
|
|
116
|
+
value = info.get(key)
|
|
117
|
+
if value:
|
|
118
|
+
parts.append(f"{key}={value}")
|
|
119
|
+
return ", ".join(parts) if parts else "lock held"
|
model_mirror/mirror.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .checksums import write_checksums
|
|
7
|
+
from .config import Config, archive_path
|
|
8
|
+
from .hub import HuggingFaceHub, cached_manifest_verifies, compatible_snapshot_plan, get_snapshot, write_snapshot_plan
|
|
9
|
+
from .lock import ModelLock
|
|
10
|
+
from .repair import derive_state
|
|
11
|
+
from .state import VerificationState, read_verification_state, write_verification_state
|
|
12
|
+
from .verify import verify_remote
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(slots=True)
|
|
16
|
+
class MirrorResult:
|
|
17
|
+
status: str
|
|
18
|
+
path: Path
|
|
19
|
+
files: int
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def mirror(
|
|
23
|
+
config: Config,
|
|
24
|
+
repo_id: str,
|
|
25
|
+
*,
|
|
26
|
+
hub=None,
|
|
27
|
+
repo_type: str | None = None,
|
|
28
|
+
revision: str | None = None,
|
|
29
|
+
force: bool = False,
|
|
30
|
+
verify_after: bool = True,
|
|
31
|
+
stall_timeout_seconds: int | None = None,
|
|
32
|
+
) -> MirrorResult:
|
|
33
|
+
selected_type = repo_type or config.repo_type
|
|
34
|
+
selected_revision = revision or config.revision
|
|
35
|
+
selected_hub = hub or HuggingFaceHub(config)
|
|
36
|
+
destination = archive_path(config, repo_id, selected_type)
|
|
37
|
+
with ModelLock(destination, "mirror", repo_id, selected_type):
|
|
38
|
+
existing_state = read_verification_state(destination)
|
|
39
|
+
if existing_state is None:
|
|
40
|
+
write_verification_state(
|
|
41
|
+
destination,
|
|
42
|
+
VerificationState(
|
|
43
|
+
status="in_progress",
|
|
44
|
+
repo_id=repo_id,
|
|
45
|
+
repo_type=selected_type,
|
|
46
|
+
requested_revision=selected_revision,
|
|
47
|
+
issues=["mirror started"],
|
|
48
|
+
),
|
|
49
|
+
)
|
|
50
|
+
return mirror_locked(
|
|
51
|
+
config,
|
|
52
|
+
repo_id,
|
|
53
|
+
selected_hub,
|
|
54
|
+
selected_type,
|
|
55
|
+
selected_revision,
|
|
56
|
+
destination,
|
|
57
|
+
existing_state=existing_state,
|
|
58
|
+
force=force,
|
|
59
|
+
verify_after=verify_after,
|
|
60
|
+
stall_timeout_seconds=stall_timeout_seconds,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def mirror_locked(
|
|
65
|
+
config: Config,
|
|
66
|
+
repo_id: str,
|
|
67
|
+
selected_hub,
|
|
68
|
+
selected_type: str,
|
|
69
|
+
selected_revision: str,
|
|
70
|
+
destination: Path,
|
|
71
|
+
*,
|
|
72
|
+
existing_state: VerificationState | None,
|
|
73
|
+
force: bool,
|
|
74
|
+
verify_after: bool,
|
|
75
|
+
stall_timeout_seconds: int | None,
|
|
76
|
+
) -> MirrorResult:
|
|
77
|
+
snapshot = select_mirror_snapshot(
|
|
78
|
+
selected_hub,
|
|
79
|
+
repo_id,
|
|
80
|
+
selected_type,
|
|
81
|
+
selected_revision,
|
|
82
|
+
destination,
|
|
83
|
+
existing_state=existing_state,
|
|
84
|
+
force=force,
|
|
85
|
+
)
|
|
86
|
+
from .torrent_publication import assert_commit_update_allowed, load_fenced_publication
|
|
87
|
+
|
|
88
|
+
assert_commit_update_allowed(destination, snapshot.resolved_commit)
|
|
89
|
+
metadata = snapshot.files
|
|
90
|
+
|
|
91
|
+
if not force and verify_remote(destination, metadata, check_hashes=False).ok:
|
|
92
|
+
if not verify_after:
|
|
93
|
+
return MirrorResult("complete", destination, len(metadata))
|
|
94
|
+
if (
|
|
95
|
+
existing_state is not None
|
|
96
|
+
and existing_state.clean
|
|
97
|
+
and existing_state.resolved_commit == snapshot.resolved_commit
|
|
98
|
+
):
|
|
99
|
+
return MirrorResult("complete", destination, len(metadata))
|
|
100
|
+
checksums_written = cached_manifest_verifies(destination, metadata)
|
|
101
|
+
if config.checksum and not checksums_written:
|
|
102
|
+
write_checksums(destination, max_workers=config.checksum_workers)
|
|
103
|
+
checksums_written = True
|
|
104
|
+
state = derive_state(
|
|
105
|
+
config,
|
|
106
|
+
repo_id,
|
|
107
|
+
selected_hub,
|
|
108
|
+
selected_type,
|
|
109
|
+
selected_revision,
|
|
110
|
+
destination,
|
|
111
|
+
snapshot=snapshot,
|
|
112
|
+
upstream_commit=snapshot.resolved_commit,
|
|
113
|
+
cached=False,
|
|
114
|
+
from_manifest=checksums_written,
|
|
115
|
+
)
|
|
116
|
+
return MirrorResult("complete" if state.clean else "downloaded-unverified", destination, len(metadata))
|
|
117
|
+
|
|
118
|
+
fenced = load_fenced_publication(destination)
|
|
119
|
+
if fenced is not None:
|
|
120
|
+
raise RuntimeError(
|
|
121
|
+
f"published payload cannot be replaced by mirror: {fenced[0].publication_id}; "
|
|
122
|
+
"use same-commit repair or retire the publication"
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
write_verification_state(
|
|
126
|
+
destination,
|
|
127
|
+
VerificationState(
|
|
128
|
+
status="in_progress",
|
|
129
|
+
repo_id=repo_id,
|
|
130
|
+
repo_type=selected_type,
|
|
131
|
+
requested_revision=selected_revision,
|
|
132
|
+
resolved_commit=snapshot.resolved_commit,
|
|
133
|
+
upstream_commit=snapshot.resolved_commit,
|
|
134
|
+
upstream_status="current",
|
|
135
|
+
issues=["mirror in progress"],
|
|
136
|
+
),
|
|
137
|
+
)
|
|
138
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
139
|
+
write_snapshot_plan(destination, snapshot)
|
|
140
|
+
download_snapshot(selected_hub, snapshot, destination, stall_timeout_seconds=stall_timeout_seconds)
|
|
141
|
+
checksums_written = cached_manifest_verifies(destination, metadata)
|
|
142
|
+
if config.checksum and not checksums_written:
|
|
143
|
+
write_checksums(destination, max_workers=config.checksum_workers)
|
|
144
|
+
checksums_written = True
|
|
145
|
+
if verify_after:
|
|
146
|
+
state = derive_state(
|
|
147
|
+
config,
|
|
148
|
+
repo_id,
|
|
149
|
+
selected_hub,
|
|
150
|
+
selected_type,
|
|
151
|
+
selected_revision,
|
|
152
|
+
destination,
|
|
153
|
+
snapshot=snapshot,
|
|
154
|
+
upstream_commit=snapshot.resolved_commit,
|
|
155
|
+
cached=False,
|
|
156
|
+
from_manifest=checksums_written,
|
|
157
|
+
)
|
|
158
|
+
return MirrorResult("downloaded" if state.clean else "downloaded-unverified", destination, len(metadata))
|
|
159
|
+
write_verification_state(
|
|
160
|
+
destination,
|
|
161
|
+
VerificationState(
|
|
162
|
+
status="dirty",
|
|
163
|
+
repo_id=repo_id,
|
|
164
|
+
repo_type=selected_type,
|
|
165
|
+
requested_revision=selected_revision,
|
|
166
|
+
resolved_commit=snapshot.resolved_commit,
|
|
167
|
+
upstream_commit=snapshot.resolved_commit,
|
|
168
|
+
upstream_status="current",
|
|
169
|
+
issues=["verification skipped"],
|
|
170
|
+
),
|
|
171
|
+
)
|
|
172
|
+
return MirrorResult("downloaded", destination, len(metadata))
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def select_mirror_snapshot(
|
|
176
|
+
selected_hub,
|
|
177
|
+
repo_id: str,
|
|
178
|
+
selected_type: str,
|
|
179
|
+
selected_revision: str,
|
|
180
|
+
destination: Path,
|
|
181
|
+
*,
|
|
182
|
+
existing_state: VerificationState | None,
|
|
183
|
+
force: bool,
|
|
184
|
+
):
|
|
185
|
+
if not force and existing_state is not None and existing_state.status == "in_progress":
|
|
186
|
+
frozen = compatible_snapshot_plan(
|
|
187
|
+
destination,
|
|
188
|
+
repo_id=repo_id,
|
|
189
|
+
repo_type=selected_type,
|
|
190
|
+
requested_revision=selected_revision,
|
|
191
|
+
)
|
|
192
|
+
if frozen is not None:
|
|
193
|
+
return frozen
|
|
194
|
+
return get_snapshot(selected_hub, repo_id, selected_type, selected_revision)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def download_snapshot(selected_hub, snapshot, destination: Path, *, stall_timeout_seconds: int | None = None) -> None:
|
|
198
|
+
if hasattr(selected_hub, "download_snapshot"):
|
|
199
|
+
selected_hub.download_snapshot(snapshot, destination, stall_timeout_seconds=stall_timeout_seconds)
|
|
200
|
+
return
|
|
201
|
+
selected_hub.snapshot_download(snapshot.repo_id, snapshot.repo_type, snapshot.resolved_commit, destination)
|
model_mirror/progress.py
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
PROGRESS_DIR = ".model-mirror"
|
|
13
|
+
PROGRESS_FILE = "progress.json"
|
|
14
|
+
PROGRESS_SCHEMA = "model-mirror-progress"
|
|
15
|
+
PROGRESS_VERSION = 1
|
|
16
|
+
DEFAULT_STALL_TIMEOUT_SECONDS = 600
|
|
17
|
+
DEFAULT_STALL_RETRIES = 3
|
|
18
|
+
SKIP_DIRS = {".model-mirror", ".cache", ".archive"}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True, slots=True)
|
|
22
|
+
class ProgressEntry:
|
|
23
|
+
path: str
|
|
24
|
+
stage: str
|
|
25
|
+
bytes_done: int
|
|
26
|
+
bytes_total: int | None
|
|
27
|
+
updated_at_utc: str
|
|
28
|
+
idle_seconds: int | None
|
|
29
|
+
stalled: bool
|
|
30
|
+
source: str
|
|
31
|
+
rate_bytes_per_second: float | None = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True, slots=True)
|
|
35
|
+
class ProgressSnapshot:
|
|
36
|
+
entries: list[ProgressEntry]
|
|
37
|
+
source: str
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def active(self) -> bool:
|
|
41
|
+
return bool(self.entries)
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def stalled_count(self) -> int:
|
|
45
|
+
return sum(1 for entry in self.entries if entry.stalled)
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def any_stalled(self) -> bool:
|
|
49
|
+
return self.stalled_count > 0
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ProgressRecorder:
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
root: Path,
|
|
56
|
+
*,
|
|
57
|
+
min_interval_seconds: float = 5.0,
|
|
58
|
+
min_bytes: int = 64 * 1024 * 1024,
|
|
59
|
+
):
|
|
60
|
+
self.root = root
|
|
61
|
+
self.min_interval_seconds = min_interval_seconds
|
|
62
|
+
self.min_bytes = min_bytes
|
|
63
|
+
self._active: dict[str, dict] = {}
|
|
64
|
+
self._lock = threading.Lock()
|
|
65
|
+
|
|
66
|
+
def track(self, path: str, *, total: int | None, stage: str, bytes_done: int = 0) -> FileProgress:
|
|
67
|
+
progress = FileProgress(self, path, total=total)
|
|
68
|
+
progress.update(bytes_done, stage=stage, force=True)
|
|
69
|
+
return progress
|
|
70
|
+
|
|
71
|
+
def update(
|
|
72
|
+
self,
|
|
73
|
+
path: str,
|
|
74
|
+
*,
|
|
75
|
+
total: int | None,
|
|
76
|
+
stage: str,
|
|
77
|
+
bytes_done: int,
|
|
78
|
+
rate_bytes_per_second: float | None,
|
|
79
|
+
) -> None:
|
|
80
|
+
now = utc_now_iso()
|
|
81
|
+
with self._lock:
|
|
82
|
+
entry = self._active.get(path)
|
|
83
|
+
if entry is None:
|
|
84
|
+
entry = {
|
|
85
|
+
"path": path,
|
|
86
|
+
"bytes_total": total,
|
|
87
|
+
"started_at_utc": now,
|
|
88
|
+
}
|
|
89
|
+
entry.update(
|
|
90
|
+
{
|
|
91
|
+
"stage": stage,
|
|
92
|
+
"bytes_done": bytes_done,
|
|
93
|
+
"bytes_total": total,
|
|
94
|
+
"updated_at_utc": now,
|
|
95
|
+
"rate_bytes_per_second": rate_bytes_per_second,
|
|
96
|
+
}
|
|
97
|
+
)
|
|
98
|
+
self._active[path] = entry
|
|
99
|
+
self._write_locked()
|
|
100
|
+
|
|
101
|
+
def finish(self, path: str) -> None:
|
|
102
|
+
with self._lock:
|
|
103
|
+
self._active.pop(path, None)
|
|
104
|
+
if self._active:
|
|
105
|
+
self._write_locked()
|
|
106
|
+
else:
|
|
107
|
+
progress_path(self.root).unlink(missing_ok=True)
|
|
108
|
+
|
|
109
|
+
def _write_locked(self) -> None:
|
|
110
|
+
path = progress_path(self.root)
|
|
111
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
112
|
+
payload = {
|
|
113
|
+
"schema": PROGRESS_SCHEMA,
|
|
114
|
+
"version": PROGRESS_VERSION,
|
|
115
|
+
"pid": os.getpid(),
|
|
116
|
+
"updated_at_utc": utc_now_iso(),
|
|
117
|
+
"active_files": self._active,
|
|
118
|
+
}
|
|
119
|
+
tmp = path.with_name(f"{path.name}.tmp")
|
|
120
|
+
tmp.write_text(json.dumps(payload, sort_keys=True, indent=2) + "\n", encoding="utf-8")
|
|
121
|
+
tmp.replace(path)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class FileProgress:
|
|
125
|
+
def __init__(self, recorder: ProgressRecorder, path: str, *, total: int | None):
|
|
126
|
+
self.recorder = recorder
|
|
127
|
+
self.path = path
|
|
128
|
+
self.total = total
|
|
129
|
+
self.stage = "starting"
|
|
130
|
+
self.last_emit_time: float | None = None
|
|
131
|
+
self.last_emit_bytes: int | None = None
|
|
132
|
+
|
|
133
|
+
def update(self, bytes_done: int, *, stage: str | None = None, force: bool = False) -> None:
|
|
134
|
+
now = time.monotonic()
|
|
135
|
+
selected_stage = stage or self.stage
|
|
136
|
+
should_emit = force or self.last_emit_time is None
|
|
137
|
+
if not should_emit and now - self.last_emit_time >= self.recorder.min_interval_seconds:
|
|
138
|
+
should_emit = True
|
|
139
|
+
if (
|
|
140
|
+
not should_emit
|
|
141
|
+
and self.last_emit_bytes is not None
|
|
142
|
+
and bytes_done - self.last_emit_bytes >= self.recorder.min_bytes
|
|
143
|
+
):
|
|
144
|
+
should_emit = True
|
|
145
|
+
if not should_emit:
|
|
146
|
+
self.stage = selected_stage
|
|
147
|
+
return
|
|
148
|
+
|
|
149
|
+
rate = None
|
|
150
|
+
if self.last_emit_time is not None and self.last_emit_bytes is not None:
|
|
151
|
+
elapsed = now - self.last_emit_time
|
|
152
|
+
if elapsed > 0:
|
|
153
|
+
rate = max(0, bytes_done - self.last_emit_bytes) / elapsed
|
|
154
|
+
self.last_emit_time = now
|
|
155
|
+
self.last_emit_bytes = bytes_done
|
|
156
|
+
self.stage = selected_stage
|
|
157
|
+
self.recorder.update(
|
|
158
|
+
self.path,
|
|
159
|
+
total=self.total,
|
|
160
|
+
stage=selected_stage,
|
|
161
|
+
bytes_done=bytes_done,
|
|
162
|
+
rate_bytes_per_second=rate,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
def finish(self) -> None:
|
|
166
|
+
self.recorder.finish(self.path)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def progress_path(root: Path) -> Path:
|
|
170
|
+
return root / PROGRESS_DIR / PROGRESS_FILE
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def progress_snapshot(
|
|
174
|
+
root: Path,
|
|
175
|
+
*,
|
|
176
|
+
stall_timeout_seconds: int = DEFAULT_STALL_TIMEOUT_SECONDS,
|
|
177
|
+
now: datetime | None = None,
|
|
178
|
+
scan_incomplete: bool = True,
|
|
179
|
+
) -> ProgressSnapshot:
|
|
180
|
+
now = now or datetime.now(timezone.utc)
|
|
181
|
+
entries = progress_file_entries(root, stall_timeout_seconds=stall_timeout_seconds, now=now)
|
|
182
|
+
if entries:
|
|
183
|
+
return ProgressSnapshot(entries=entries, source="heartbeat")
|
|
184
|
+
if not scan_incomplete:
|
|
185
|
+
return ProgressSnapshot(entries=[], source="heartbeat")
|
|
186
|
+
return ProgressSnapshot(
|
|
187
|
+
entries=incomplete_file_entries(root, stall_timeout_seconds=stall_timeout_seconds, now=now),
|
|
188
|
+
source="partial-file",
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def progress_file_entries(root: Path, *, stall_timeout_seconds: int, now: datetime) -> list[ProgressEntry]:
|
|
193
|
+
path = progress_path(root)
|
|
194
|
+
if not path.exists():
|
|
195
|
+
return []
|
|
196
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
197
|
+
if data.get("schema") != PROGRESS_SCHEMA or data.get("version") != PROGRESS_VERSION:
|
|
198
|
+
return []
|
|
199
|
+
entries = []
|
|
200
|
+
for rel, item in sorted((data.get("active_files") or {}).items()):
|
|
201
|
+
updated_at = str(item.get("updated_at_utc") or "")
|
|
202
|
+
idle = idle_seconds(updated_at, now)
|
|
203
|
+
entries.append(
|
|
204
|
+
ProgressEntry(
|
|
205
|
+
path=str(item.get("path") or rel),
|
|
206
|
+
stage=str(item.get("stage") or "unknown"),
|
|
207
|
+
bytes_done=int(item.get("bytes_done") or 0),
|
|
208
|
+
bytes_total=item.get("bytes_total"),
|
|
209
|
+
updated_at_utc=updated_at,
|
|
210
|
+
idle_seconds=idle,
|
|
211
|
+
stalled=is_stalled(idle, stall_timeout_seconds),
|
|
212
|
+
source="heartbeat",
|
|
213
|
+
rate_bytes_per_second=item.get("rate_bytes_per_second"),
|
|
214
|
+
)
|
|
215
|
+
)
|
|
216
|
+
return entries
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def incomplete_file_entries(root: Path, *, stall_timeout_seconds: int, now: datetime) -> list[ProgressEntry]:
|
|
220
|
+
entries = []
|
|
221
|
+
for path in sorted(root.rglob("*.incomplete")):
|
|
222
|
+
if not path.is_file():
|
|
223
|
+
continue
|
|
224
|
+
rel = path.relative_to(root)
|
|
225
|
+
if rel.parts and rel.parts[0] in SKIP_DIRS:
|
|
226
|
+
continue
|
|
227
|
+
updated = datetime.fromtimestamp(path.stat().st_mtime, timezone.utc).isoformat(timespec="seconds")
|
|
228
|
+
idle = idle_seconds(updated, now)
|
|
229
|
+
rel_path = rel.as_posix()[: -len(".incomplete")]
|
|
230
|
+
entries.append(
|
|
231
|
+
ProgressEntry(
|
|
232
|
+
path=rel_path,
|
|
233
|
+
stage="partial",
|
|
234
|
+
bytes_done=path.stat().st_size,
|
|
235
|
+
bytes_total=None,
|
|
236
|
+
updated_at_utc=updated,
|
|
237
|
+
idle_seconds=idle,
|
|
238
|
+
stalled=is_stalled(idle, stall_timeout_seconds),
|
|
239
|
+
source="partial-file",
|
|
240
|
+
)
|
|
241
|
+
)
|
|
242
|
+
return entries
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def idle_seconds(updated_at_utc: str, now: datetime) -> int | None:
|
|
246
|
+
try:
|
|
247
|
+
updated = datetime.fromisoformat(updated_at_utc)
|
|
248
|
+
except ValueError:
|
|
249
|
+
return None
|
|
250
|
+
if updated.tzinfo is None:
|
|
251
|
+
updated = updated.replace(tzinfo=timezone.utc)
|
|
252
|
+
return max(0, int((now - updated).total_seconds()))
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def is_stalled(idle: int | None, stall_timeout_seconds: int) -> bool:
|
|
256
|
+
return stall_timeout_seconds > 0 and idle is not None and idle >= stall_timeout_seconds
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def utc_now_iso() -> str:
|
|
260
|
+
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|