hubckpt: Content-Range + size guards on the bounded read-back, and prune_verified() (review E-042)
Browse files- train/hubckpt.py +48 -12
train/hubckpt.py
CHANGED
|
@@ -3,10 +3,13 @@
|
|
| 3 |
|
| 4 |
# Why verification cannot be "the upload call returned": `upload_folder` returns a commit sha, which
|
| 5 |
# proves the Hub accepted a commit, not that the bytes are retrievable. For LFS-tracked files the commit
|
| 6 |
-
# is a pointer. So verification here means:
|
| 7 |
-
#
|
| 8 |
-
# least one file is then actually
|
| 9 |
-
# not a checkpoint, and the run must stop rather than delete the
|
|
|
|
|
|
|
|
|
|
| 10 |
#
|
| 11 |
# Used by the trainer (main run + Phase 3 preflight) and by the preflight report scripts. No credentials
|
| 12 |
# in this file: the token comes from the private store via ounce100m_credentials (D-006).
|
|
@@ -160,11 +163,19 @@ def verify_checkpoint(repo, dirpath, path_in_repo, token=None, repo_type="datase
|
|
| 160 |
# answer the same question, and a response that is not 206 with exactly the bytes asked for is a
|
| 161 |
# failure rather than a silent full download.
|
| 162 |
pick = max(have, key=lambda k: (have[k][0] or 0))
|
| 163 |
-
size = have[pick][0]
|
| 164 |
url = f"{HUB}/{repo_type}s/{repo}/resolve/main/{prefix}{pick}"
|
| 165 |
chunk = 4 << 20
|
| 166 |
-
|
| 167 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
try:
|
| 169 |
with open(os.path.join(dirpath, pick), "rb") as loc:
|
| 170 |
for start, end in spans:
|
|
@@ -174,20 +185,27 @@ def verify_checkpoint(repo, dirpath, path_in_repo, token=None, repo_type="datase
|
|
| 174 |
rq.add_header("Range", f"bytes={start}-{end}")
|
| 175 |
with urllib.request.urlopen(rq, timeout=180) as fh:
|
| 176 |
status = getattr(fh, "status", 200)
|
|
|
|
| 177 |
data = fh.read((end - start + 1) + 1)
|
| 178 |
want_n = end - start + 1
|
| 179 |
if status != 206 or len(data) != want_n:
|
| 180 |
raise RuntimeError(f"range {start}-{end} returned HTTP {status} with "
|
| 181 |
f"{len(data)} bytes; expected 206 with exactly {want_n}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 182 |
loc.seek(start)
|
| 183 |
if loc.read(want_n) != data:
|
| 184 |
raise RuntimeError(f"bytes {start}-{end} of {pick} differ from the local file")
|
| 185 |
rb_ok = True
|
| 186 |
-
rb
|
| 187 |
-
|
|
|
|
| 188 |
except Exception as e:
|
| 189 |
-
rb
|
| 190 |
-
"error": f"{type(e).__name__}: {str(e)[:160]}"}
|
| 191 |
# ok must include the read-back: a repo whose files are listed but not downloadable is not a
|
| 192 |
# checkpoint, and pruning into that state loses the run. `extra` is gated too: a file on the Hub that
|
| 193 |
# is not in this directory means the path being pushed to already held something else, which is a
|
|
@@ -252,6 +270,24 @@ def prune_local(dirpath, res, min_free_gb=6.0):
|
|
| 252 |
return res
|
| 253 |
|
| 254 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 255 |
def push_and_prune(repo, dirpath, path_in_repo, api, repo_type="dataset", token=None,
|
| 256 |
prune=True, min_free_gb=6.0, verify_tries=3):
|
| 257 |
"""One full §3.13 cycle. Raises if verification fails and pruning was requested -- the local copy is
|
|
@@ -286,7 +322,7 @@ def push_and_prune(repo, dirpath, path_in_repo, api, repo_type="dataset", token=
|
|
| 286 |
f"hub verification failed ({len(ver['missing'])} missing, "
|
| 287 |
f"{len(ver['mismatch'])} mismatched) -- keeping the local copy in {dirpath}")
|
| 288 |
raise RuntimeError(res["prune_blocked_reason"])
|
| 289 |
-
|
| 290 |
return res
|
| 291 |
|
| 292 |
|
|
|
|
| 3 |
|
| 4 |
# Why verification cannot be "the upload call returned": `upload_folder` returns a commit sha, which
|
| 5 |
# proves the Hub accepted a commit, not that the bytes are retrievable. For LFS-tracked files the commit
|
| 6 |
+
# is a pointer. So verification here means: every LFS file's stored `lfs.oid` -- which *is* the sha256 of
|
| 7 |
+
# the object the server holds -- equals the sha256 computed on the local bytes, every size agrees, and at
|
| 8 |
+
# least one file is then actually fetched back over the download path and compared byte for byte. A
|
| 9 |
+
# checkpoint that cannot be read back is not a checkpoint, and the run must stop rather than delete the
|
| 10 |
+
# only local copy. (Git-stored small files report a sha1 blob id, not a content sha256, so for those the
|
| 11 |
+
# size is all the listing proves; `verify_checkpoint` says so in `content_sha_comparable` rather than
|
| 12 |
+
# implying more than it has.)
|
| 13 |
#
|
| 14 |
# Used by the trainer (main run + Phase 3 preflight) and by the preflight report scripts. No credentials
|
| 15 |
# in this file: the token comes from the private store via ounce100m_credentials (D-006).
|
|
|
|
| 163 |
# answer the same question, and a response that is not 206 with exactly the bytes asked for is a
|
| 164 |
# failure rather than a silent full download.
|
| 165 |
pick = max(have, key=lambda k: (have[k][0] or 0))
|
| 166 |
+
size = have[pick][0]
|
| 167 |
url = f"{HUB}/{repo_type}s/{repo}/resolve/main/{prefix}{pick}"
|
| 168 |
chunk = 4 << 20
|
| 169 |
+
rb = {"path": pick, "url": url,
|
| 170 |
+
"content_sha_comparable": bool(pick in want and have[pick][1] and
|
| 171 |
+
len(have[pick][1]) == 64 and have[pick][1] == want[pick][1])}
|
| 172 |
+
spans = []
|
| 173 |
+
if not size or size <= 0:
|
| 174 |
+
# `hub_listing` allows a None size, and a 0-byte span would "verify" by comparing nothing.
|
| 175 |
+
rb["error"] = f"the Hub lists {pick} as {size!r} bytes -- nothing to read back"
|
| 176 |
+
else:
|
| 177 |
+
spans = [(0, min(chunk, size) - 1)] if size <= chunk else \
|
| 178 |
+
[(0, chunk - 1), (size // 2, size // 2 + chunk - 1)]
|
| 179 |
try:
|
| 180 |
with open(os.path.join(dirpath, pick), "rb") as loc:
|
| 181 |
for start, end in spans:
|
|
|
|
| 185 |
rq.add_header("Range", f"bytes={start}-{end}")
|
| 186 |
with urllib.request.urlopen(rq, timeout=180) as fh:
|
| 187 |
status = getattr(fh, "status", 200)
|
| 188 |
+
crange = fh.headers.get("Content-Range")
|
| 189 |
data = fh.read((end - start + 1) + 1)
|
| 190 |
want_n = end - start + 1
|
| 191 |
if status != 206 or len(data) != want_n:
|
| 192 |
raise RuntimeError(f"range {start}-{end} returned HTTP {status} with "
|
| 193 |
f"{len(data)} bytes; expected 206 with exactly {want_n}")
|
| 194 |
+
# The length alone is not the offset: a server that answers with the right number of
|
| 195 |
+
# the WRONG bytes passes a length check, and for a file that is mostly padding (the
|
| 196 |
+
# Phase 3 preflight pushed 4x425 MB of zeros) it passes the byte compare too.
|
| 197 |
+
if crange != f"bytes {start}-{end}/{size}":
|
| 198 |
+
raise RuntimeError(f"range {start}-{end} came back as Content-Range {crange!r}, "
|
| 199 |
+
f"expected {f'bytes {start}-{end}/{size}'!r}")
|
| 200 |
loc.seek(start)
|
| 201 |
if loc.read(want_n) != data:
|
| 202 |
raise RuntimeError(f"bytes {start}-{end} of {pick} differ from the local file")
|
| 203 |
rb_ok = True
|
| 204 |
+
rb["ranges"] = [[s, e] for s, e in spans]
|
| 205 |
+
rb["bytes"] = sum(e - s + 1 for s, e in spans)
|
| 206 |
+
rb["file_bytes"] = size
|
| 207 |
except Exception as e:
|
| 208 |
+
rb["error"] = f"{type(e).__name__}: {str(e)[:200]}"
|
|
|
|
| 209 |
# ok must include the read-back: a repo whose files are listed but not downloadable is not a
|
| 210 |
# checkpoint, and pruning into that state loses the run. `extra` is gated too: a file on the Hub that
|
| 211 |
# is not in this directory means the path being pushed to already held something else, which is a
|
|
|
|
| 270 |
return res
|
| 271 |
|
| 272 |
|
| 273 |
+
def prune_verified(dirpath, res, min_free_gb=6.0):
|
| 274 |
+
"""`prune_local`, but only for a caller whose verification actually said yes.
|
| 275 |
+
|
| 276 |
+
`push_and_prune(prune=True)` gates the deletion internally, and the mid-run save path deliberately does
|
| 277 |
+
not use it: the pointer has to be rolled *between* the verification and the deletion (E-035/2), so the
|
| 278 |
+
trainer calls `push_and_prune(..., prune=False)`, rolls the pointer, and then prunes itself -- which
|
| 279 |
+
walked straight past the only guard standing between a failed verification and the loss of the last
|
| 280 |
+
local copy. Review finding E-042. The check belongs next to the deleting, not only next to the upload.
|
| 281 |
+
"""
|
| 282 |
+
ver = (res or {}).get("verify") or {}
|
| 283 |
+
if ver.get("ok") is not True:
|
| 284 |
+
raise RuntimeError(
|
| 285 |
+
f"refusing to delete {dirpath}: the Hub copy is not verified "
|
| 286 |
+
f"(missing={len(ver.get('missing') or [])} mismatched={len(ver.get('mismatch') or [])} "
|
| 287 |
+
f"extra={len(ver.get('extra') or [])} readback_ok={ver.get('readback_ok')})")
|
| 288 |
+
return prune_local(dirpath, res, min_free_gb)
|
| 289 |
+
|
| 290 |
+
|
| 291 |
def push_and_prune(repo, dirpath, path_in_repo, api, repo_type="dataset", token=None,
|
| 292 |
prune=True, min_free_gb=6.0, verify_tries=3):
|
| 293 |
"""One full §3.13 cycle. Raises if verification fails and pruning was requested -- the local copy is
|
|
|
|
| 322 |
f"hub verification failed ({len(ver['missing'])} missing, "
|
| 323 |
f"{len(ver['mismatch'])} mismatched) -- keeping the local copy in {dirpath}")
|
| 324 |
raise RuntimeError(res["prune_blocked_reason"])
|
| 325 |
+
prune_verified(dirpath, res, min_free_gb)
|
| 326 |
return res
|
| 327 |
|
| 328 |
|