Skip to content

Commit f3dafef

Browse files
jim-falclaude
andauthored
fix(cli): report upload progress in bytes transferred (#1171)
* fix(cli): report upload progress in bytes transferred `fal files upload` sized its progress task in parts, so a 100 MB file - ten 10 MB parts uploaded by ten concurrent workers - had nothing to advance until those parts all landed at the end. The bar sat near 0% for the whole transfer and then jumped to done. A full-file MD5 pass ran before the first byte went out, adding a second phase with no advancement at all. Progress is now sized in bytes and advanced as each part body is handed to the transport, and the digest is folded into the reader thread the upload already runs, so verifying the etag costs no extra read and no silent wait. The small (non-multipart) branch advances the same way while its request body is written. `BaseMultipartUpload.upload_file` gains an `on_bytes_uploaded` callback and a `content_md5` property; `on_part_complete` and existing callers are unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(cli): make upload integrity and retry safety explicit Three follow-ups to the byte-progress change. Retry safety no longer rests on the HTTP client rewinding a consumed body. `_request` takes an optional `files_factory` and rebuilds the payload on every attempt, so a part that fails and retries resends its bytes by construction rather than because httpx happens to seek the stream back to zero. The raw `bytes` body used when no progress callback is supplied is unchanged. The part count is fixed from the size sampled before the read, so a file that shrank underneath the reader would upload a prefix and report success. The reader now counts the bytes it consumed and the upload fails if that does not match the sampled size. This restores the end-to-end guarantee that was lost when the digest stopped being computed from an independent pass over the file. Computing the digest is now opt-in via `compute_md5`. Only the `/data` path verifies the etag; app file sync was paying for a hash over every uploaded file and discarding it. Each of the three is covered by a test that fails when the change is reverted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(cli): settle the multipart bar on the uploaded size Part callbacks compute their running total under the tracker lock but are invoked outside it, so the last callback to arrive is not necessarily the highest. The multipart branch took whatever the final callback reported, unlike the small-file branch which already writes the full size at the end, so a successful upload could leave the bar short of 100%. Found by an end-to-end sweep against live storage: a backwards delivery is rare (one in ~255,000 updates) but nothing made the final value correct, and no test covered it -- the monotonicity tests run at concurrency 1 and the concurrent one asserts only the maximum, never the last value. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(cli): show a bracketed filename verbatim in the progress bar A Rich task description is parsed as markup, so any bracketed span that looks like a tag is dropped from the label: `x[bold]y.bin` rendered as `xy.bin`, and `x[not a tag]y.bin` as `xy.bin`. Both filenames are legal. Escaping the basename keeps the label matching what is on disk. Pre-existing on both progress paths, not introduced by the byte-progress change. A closing tag is not reachable here -- it needs `/`, which is the path separator, so os.path.basename removes everything before it -- which means this mangles the display and cannot raise. Also corrects the digest comment: the byte-count check catches an edit that changes the file's length, but an edit that preserves it is not detected, since the digest is taken from the same bytes that were sent. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(cli): satisfy mypy on the per-attempt body factory `on_progress` is Optional, and narrowing it with `if on_progress is None` does not carry into the nested `build_files`, since a closure could run after the name was rebound. Bind the narrowed callback to a non-Optional local that the closure captures instead. Caught by the pre-commit mypy hook (v1.3.0, --check-untyped-defs), which I had not run on the earlier commit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 504e5a7 commit f3dafef

5 files changed

Lines changed: 694 additions & 62 deletions

File tree

projects/fal/src/fal/files.py

Lines changed: 40 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import hashlib
21
import os
32
import posixpath
43
from functools import cached_property
@@ -12,20 +11,13 @@
1211
MULTIPART_MAX_CONCURRENCY,
1312
MULTIPART_THRESHOLD,
1413
DataFileMultipartUpload,
14+
ProgressFileReader,
1515
)
1616

1717
if TYPE_CHECKING:
1818
import httpx
1919

2020

21-
def _compute_md5(lpath, chunk_size=8192):
22-
hasher = hashlib.md5()
23-
with open(lpath, "rb") as fobj:
24-
for chunk in iter(lambda: fobj.read(chunk_size), b""):
25-
hasher.update(chunk)
26-
return hasher.hexdigest()
27-
28-
2921
class FalFileSystem(AbstractFileSystem):
3022
def __init__(
3123
self,
@@ -153,13 +145,15 @@ def get_file(self, rpath, lpath, **kwargs):
153145
fobj.write(response.content)
154146

155147
def _put_file_multipart(self, lpath, rpath, size, progress):
156-
num_parts = max(1, (size + MULTIPART_CHUNK_SIZE - 1) // MULTIPART_CHUNK_SIZE)
157-
task = progress.add_task("Calculating checksum...", total=num_parts)
158-
md5 = _compute_md5(lpath)
159-
progress.update(task, description=f"Uploading {os.path.basename(lpath)}")
148+
from rich.markup import escape
160149

161-
def on_part_complete(part_number: int):
162-
progress.advance(task)
150+
# A task description is parsed as Rich markup, so a name containing
151+
# tag-like brackets would be mangled or raise.
152+
name = escape(os.path.basename(lpath))
153+
task = progress.add_task(f"Uploading {name}", total=size)
154+
155+
def on_bytes_uploaded(uploaded: int):
156+
progress.update(task, completed=uploaded)
163157

164158
multipart = DataFileMultipartUpload(
165159
client=self._client,
@@ -168,15 +162,34 @@ def on_part_complete(part_number: int):
168162
max_concurrency=MULTIPART_MAX_CONCURRENCY,
169163
)
170164

171-
etag = multipart.upload_file(lpath, on_part_complete=on_part_complete)
165+
etag = multipart.upload_file(
166+
lpath, on_bytes_uploaded=on_bytes_uploaded, compute_md5=True
167+
)
172168

169+
# Concurrent parts report their running totals outside the tracker lock,
170+
# so the last callback to arrive is not necessarily the highest. Settle
171+
# the bar on the size that was actually uploaded.
172+
progress.update(task, completed=size)
173+
174+
# The digest is taken from the bytes that were read and sent, so this
175+
# compares the stored object against the upload, not against the file on
176+
# disk. An edit that changes the file's length fails the byte-count check
177+
# in upload_file; one that keeps it identical is not detected here.
178+
md5 = multipart.content_md5
173179
if etag and etag != md5:
174180
raise RuntimeError(
175181
f"MD5 mismatch on {rpath}: {etag} != {md5}, please contact support"
176182
)
177183

178184
def put_file(self, lpath, rpath, mode="overwrite", **kwargs):
179-
from rich.progress import BarColumn, Progress, SpinnerColumn, TextColumn
185+
from rich.markup import escape
186+
from rich.progress import (
187+
BarColumn,
188+
DownloadColumn,
189+
Progress,
190+
SpinnerColumn,
191+
TextColumn,
192+
)
180193

181194
if os.path.isdir(lpath):
182195
return
@@ -189,18 +202,25 @@ def put_file(self, lpath, rpath, mode="overwrite", **kwargs):
189202
TextColumn("[progress.description]{task.description}"),
190203
BarColumn(),
191204
TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
205+
DownloadColumn(),
192206
) as progress:
193207
if size > MULTIPART_THRESHOLD:
194208
self._put_file_multipart(lpath, abs_rpath, size, progress)
195209
else:
196-
task = progress.add_task(f"{os.path.basename(lpath)}", total=1)
210+
# A zero total renders as an indeterminate pulse forever.
211+
total = size or 1
212+
task = progress.add_task(escape(os.path.basename(lpath)), total=total)
197213
with open(lpath, "rb") as fobj:
214+
reader = ProgressFileReader(
215+
fobj,
216+
lambda uploaded: progress.update(task, completed=uploaded),
217+
)
198218
self._request(
199219
"POST",
200220
f"/files/file/local/{abs_rpath}",
201-
files={"file_upload": (posixpath.basename(lpath), fobj)},
221+
files={"file_upload": (posixpath.basename(lpath), reader)},
202222
)
203-
progress.advance(task)
223+
progress.update(task, completed=total)
204224
self.dircache.clear()
205225

206226
def put_file_from_url(self, url, rpath, mode="overwrite", **kwargs):

projects/fal/src/fal/upload.py

Lines changed: 133 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import concurrent.futures
2+
import hashlib
3+
import io
24
import logging
35
import math
46
import os
57
import queue
68
import time
79
from threading import Lock, Thread
8-
from typing import Any, Callable, Dict, List, Optional, Tuple, cast
10+
from typing import Any, BinaryIO, Callable, Dict, List, Optional, Tuple, cast
911

1012
import httpx
1113

@@ -18,6 +20,55 @@
1820
MULTIPART_THRESHOLD = 10 * 1024 * 1024 # 10MB
1921

2022

23+
class ProgressFileReader:
24+
"""Read-through view of a binary stream that reports the absolute offset.
25+
26+
Reporting the offset rather than a delta keeps the count honest across a
27+
retry, which rewinds the body and resends it from the start.
28+
"""
29+
30+
def __init__(
31+
self,
32+
fobj: BinaryIO,
33+
on_progress: Callable[[int], None],
34+
) -> None:
35+
self._fobj = fobj
36+
self._on_progress = on_progress
37+
38+
def read(self, size: int = -1) -> bytes:
39+
chunk = self._fobj.read(size)
40+
self._on_progress(self._fobj.tell())
41+
return chunk
42+
43+
def seek(self, offset: int, whence: int = os.SEEK_SET) -> int:
44+
return self._fobj.seek(offset, whence)
45+
46+
def tell(self) -> int:
47+
return self._fobj.tell()
48+
49+
50+
class _BytesUploadedTracker:
51+
"""Folds concurrent per-part offsets into one cumulative byte count.
52+
53+
The total is recomputed from the latest offset of every part rather than
54+
accumulated, so a rewound part cannot inflate it.
55+
"""
56+
57+
def __init__(self, on_bytes_uploaded: Callable[[int], None]) -> None:
58+
self._on_bytes_uploaded = on_bytes_uploaded
59+
self._offsets: Dict[int, int] = {}
60+
self._lock = Lock()
61+
62+
def for_part(self, part_number: int) -> Callable[[int], None]:
63+
def report(offset: int) -> None:
64+
with self._lock:
65+
self._offsets[part_number] = offset
66+
total = sum(self._offsets.values())
67+
self._on_bytes_uploaded(total)
68+
69+
return report
70+
71+
2172
class BaseMultipartUpload:
2273
def __init__(
2374
self,
@@ -31,6 +82,16 @@ def __init__(
3182
self._upload_id: Optional[str] = None
3283
self._parts: List[Dict[str, object]] = []
3384
self._parts_lock = Lock()
85+
self._content_md5: Optional[str] = None
86+
87+
@property
88+
def content_md5(self) -> Optional[str]:
89+
"""MD5 of the bytes sent by the last successful `upload_file` call.
90+
91+
`None` until a call has read the file through, so callers must treat a
92+
missing digest as "not verified" rather than "verified".
93+
"""
94+
return self._content_md5
3495

3596
@property
3697
def upload_id(self) -> str:
@@ -65,11 +126,16 @@ def _request(
65126
method: str,
66127
path: str,
67128
max_retries: int = 3,
129+
files_factory: Optional[Callable[[], Dict[str, Any]]] = None,
68130
**kwargs,
69131
) -> httpx.Response:
70132
last_exception = None
71133

72134
for attempt in range(max_retries):
135+
# A single-use body must be rebuilt per attempt: a stream left at
136+
# EOF by a failed attempt would otherwise resend nothing.
137+
if files_factory is not None:
138+
kwargs["files"] = files_factory()
73139
try:
74140
response = self.client.request(method, path, **kwargs)
75141

@@ -139,14 +205,33 @@ def initiate(self) -> str:
139205
return self.upload_id
140206

141207
def _upload_part(
142-
self, part_number: int, data: bytes, filename: str = ""
208+
self,
209+
part_number: int,
210+
data: bytes,
211+
filename: str = "",
212+
on_progress: Optional[Callable[[int], None]] = None,
143213
) -> Dict[str, object]:
144214
file_name = filename or "chunk"
145-
response = self._request(
146-
"PUT",
147-
f"{self.part_url}/{part_number}",
148-
files={"file_upload": (file_name, data, "application/octet-stream")},
149-
)
215+
if on_progress is None:
216+
response = self._request(
217+
"PUT",
218+
f"{self.part_url}/{part_number}",
219+
files={"file_upload": (file_name, data, "application/octet-stream")},
220+
)
221+
else:
222+
# Bound outside the closure: narrowing does not carry into a nested
223+
# function, which could be called after the name was rebound.
224+
report: Callable[[int], None] = on_progress
225+
226+
def build_files() -> Dict[str, Any]:
227+
reader = ProgressFileReader(io.BytesIO(data), report)
228+
return {"file_upload": (file_name, reader, "application/octet-stream")}
229+
230+
response = self._request(
231+
"PUT",
232+
f"{self.part_url}/{part_number}",
233+
files_factory=build_files,
234+
)
150235
result = response.json()
151236
part_info = {
152237
"part_number": result["part_number"],
@@ -177,9 +262,25 @@ def upload_file(
177262
self,
178263
file_path: str,
179264
on_part_complete: Optional[Callable[[int], None]] = None,
265+
on_bytes_uploaded: Optional[Callable[[int], None]] = None,
266+
compute_md5: bool = False,
180267
) -> str:
268+
"""Upload `file_path` and return the server etag.
269+
270+
`on_part_complete` fires once per finished part. `on_bytes_uploaded`
271+
fires as the body of each part is handed to the transport, with the
272+
running total of payload bytes sent across all parts. `compute_md5`
273+
populates `content_md5`; it costs a hash over the whole file, so it is
274+
opt-in for callers that verify the etag.
275+
"""
181276
size = os.path.getsize(file_path)
182277

278+
tracker = (
279+
_BytesUploadedTracker(on_bytes_uploaded)
280+
if on_bytes_uploaded is not None
281+
else None
282+
)
283+
183284
# Handle empty files specially - upload single empty part
184285
if size == 0:
185286
try:
@@ -191,6 +292,10 @@ def upload_file(
191292
self._upload_part(1, b"")
192293
if on_part_complete:
193294
on_part_complete(1)
295+
if on_bytes_uploaded:
296+
on_bytes_uploaded(0)
297+
if compute_md5:
298+
self._content_md5 = hashlib.md5(b"").hexdigest()
194299
return self.complete()
195300
except FileExistsError:
196301
return ""
@@ -209,6 +314,8 @@ def upload_file(
209314
maxsize=self.max_concurrency * 2
210315
)
211316
read_error: List[Exception] = []
317+
hasher = hashlib.md5() if compute_md5 else None
318+
bytes_read: List[int] = [0]
212319

213320
def reader_thread():
214321
"""Reads file chunks and puts them in bounded queue"""
@@ -217,6 +324,11 @@ def reader_thread():
217324
for part_number in range(1, num_parts + 1):
218325
chunk = f.read(self.chunk_size)
219326
if chunk:
327+
# Hashing here rides along with the read the upload
328+
# already needs, instead of a second full-file pass.
329+
if hasher is not None:
330+
hasher.update(chunk)
331+
bytes_read[0] += len(chunk)
220332
chunk_queue.put((part_number, chunk))
221333
# Sentinel to signal completion
222334
chunk_queue.put(None)
@@ -243,6 +355,9 @@ def reader_thread():
243355
self._upload_part,
244356
part_number,
245357
chunk,
358+
on_progress=(
359+
tracker.for_part(part_number) if tracker else None
360+
),
246361
)
247362
futures.append((part_number, future))
248363

@@ -257,6 +372,17 @@ def reader_thread():
257372
if read_error:
258373
raise read_error[0]
259374

375+
# The part count is fixed from the size sampled before the read, so
376+
# a file that changes underneath us would otherwise upload a prefix
377+
# and report success.
378+
if bytes_read[0] != size:
379+
raise RuntimeError(
380+
f"{file_path} changed while uploading: read "
381+
f"{bytes_read[0]} bytes, expected {size}"
382+
)
383+
384+
if hasher is not None:
385+
self._content_md5 = hasher.hexdigest()
260386
return self.complete()
261387
except FileExistsError:
262388
return ""

projects/fal/tests/unit/test_files_checksum_progress.py

Lines changed: 0 additions & 35 deletions
This file was deleted.

0 commit comments

Comments
 (0)