Cion-lab commited on
Commit
2776a36
·
verified ·
1 Parent(s): 9fc366b

hubckpt: Content-Range + size guards on the bounded read-back, and prune_verified() (review E-042)

Browse files
Files changed (1) hide show
  1. 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: the Hub's own file listing reports the expected path with the
7
- # expected size and the expected sha256 (the `lfs.oid` field, or the git sha for small files), and at
8
- # least one file is then actually downloaded back and re-hashed. A checkpoint that cannot be read back is
9
- # not a checkpoint, and the run must stop rather than delete the only local copy.
 
 
 
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] or 0
164
  url = f"{HUB}/{repo_type}s/{repo}/resolve/main/{prefix}{pick}"
165
  chunk = 4 << 20
166
- spans = [(0, size - 1)] if size <= chunk else [(0, chunk - 1),
167
- (size // 2, min(size, size // 2 + chunk) - 1)]
 
 
 
 
 
 
 
 
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 = {"path": pick, "ranges": [[s, e] for s, e in spans],
187
- "bytes": sum(e - s + 1 for s, e in spans), "file_bytes": size}
 
188
  except Exception as e:
189
- rb = {"path": pick, "url": url,
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
- prune_local(dirpath, res, min_free_gb)
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