Skip to content

Commit 08d456c

Browse files
jacalataclaude
andcommitted
Address review feedback on DOWNLOAD_CHUNK_SIZE_MB
- Validate the env var: max(1, int(...)) inside try/except ValueError, so TSC_DOWNLOAD_CHUNK_SIZE_MB=0 or non-numeric doesn't silently corrupt output - Document that very large values (~thousands of MB) will OOM on constrained hosts - Correct CHANGELOG: PDF/image are NOT unified; only CSV/Excel and file-backed downloads Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent db8682a commit 08d456c

4 files changed

Lines changed: 59 additions & 17 deletions

File tree

CHANGELOG.md

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,21 @@
55
hierarchy path (e.g. `"Marketing/Q1 Reports"`). The walk is performed level by
66
level using the REST API name filter, so a path with *n* components issues *n*
77
requests. Returns the matching `ProjectItem` or `None` if no project is found.
8-
* Unified streaming download chunk size across `views.populate_csv` /
9-
`_pdf` / `_excel`, `custom_views.*`, `workbooks.download`,
10-
`datasources.download`, and `flows.download`. Previously the mixed 1024-byte
11-
and 10240-byte chunks caused multi-second latency for large view exports.
12-
Downloads now use a dedicated `DOWNLOAD_CHUNK_SIZE_MB` config value
13-
(default 1 MB, overridable via the `TSC_DOWNLOAD_CHUNK_SIZE_MB` env var).
14-
Upload / chunked-publish continues to use `CHUNK_SIZE_MB` (default 50 MB,
15-
overridable via `TSC_CHUNK_SIZE_MB`) -- they are separate knobs because a
16-
large read chunk delays first-byte yield on slow connections while a large
17-
write chunk reduces per-request overhead. Behavior note: callers that
18-
previously streamed 1 KB at a time will now hold up to 1 MB resident per
19-
chunk; memory-constrained callers can drop this via the env var.
8+
* Unified streaming download chunk size for the file-backed download paths --
9+
`views.populate_csv` and `views.populate_excel`, the CSV/Excel branches of
10+
`custom_views.*`, and `workbooks.download` / `datasources.download` /
11+
`flows.download`. Previously the mixed 1024-byte and 10240-byte chunks caused
12+
multi-second latency for large view exports. These paths now use a dedicated
13+
`DOWNLOAD_CHUNK_SIZE_MB` config value (default 1 MB, overridable via the
14+
`TSC_DOWNLOAD_CHUNK_SIZE_MB` env var). Upload / chunked-publish continues to
15+
use `CHUNK_SIZE_MB` (default 50 MB, overridable via `TSC_CHUNK_SIZE_MB`) --
16+
they are separate knobs because a large read chunk delays first-byte yield on
17+
slow connections while a large write chunk reduces per-request overhead.
18+
Behavior note: callers that previously streamed 1 KB at a time will now hold
19+
up to 1 MB resident per chunk; memory-constrained callers can drop this via
20+
the env var. Follow-up: `views.populate_pdf` and `views.populate_image` still
21+
buffer the full response in memory via `server_response.content` and are not
22+
covered by this change.
2023
* Preserve HTTP method and body across 3xx redirects. Previously `requests`
2124
followed 301/302/303 by converting POST to GET and dropping the body, so
2225
endpoints like `users.add`, `workbooks.publish`, and any write hitting a

tableauserverclient/config.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,17 +19,32 @@ def FILESIZE_LIMIT_MB(self):
1919
def CHUNK_SIZE_MB(self):
2020
return int(os.getenv("TSC_CHUNK_SIZE_MB", 5 * 10)) # 5MB felt too slow, upped it to 50
2121

22-
# Chunk size for streaming *downloads* (view CSV / Excel / PDF, workbook /
22+
# Chunk size for streaming *downloads* (view CSV / Excel, workbook /
2323
# datasource / flow downloads). Kept separate from the upload knob because
2424
# a large read chunk delays the first-byte yield on slow connections --
2525
# requests.iter_content buffers up to chunk_size before yielding, so on a
2626
# 1 Mbps link a 50 MB chunk means ~7 minutes before the first yield.
2727
# 1 MB is empirically a reasonable balance between per-chunk overhead and
2828
# progressive-yield latency; callers who want a different tradeoff can
2929
# tune via TSC_DOWNLOAD_CHUNK_SIZE_MB.
30+
#
31+
# No upper bound is enforced, but very large values (thousands of MB) will
32+
# OOM on constrained hosts because each chunk is buffered in memory before
33+
# it is written or yielded. Keep this under ~100 MB unless the caller has
34+
# specifically measured a benefit at higher values.
35+
#
36+
# Bounds: values <= 0 or non-numeric input are treated as invalid and fall
37+
# back to the 1 MB default; 0 or a negative chunk size would silently
38+
# corrupt the output because iter_content interprets it as "read all".
3039
@property
31-
def DOWNLOAD_CHUNK_SIZE_MB(self):
32-
return int(os.getenv("TSC_DOWNLOAD_CHUNK_SIZE_MB", 1))
40+
def DOWNLOAD_CHUNK_SIZE_MB(self) -> int:
41+
raw = os.getenv("TSC_DOWNLOAD_CHUNK_SIZE_MB", "1")
42+
try:
43+
value = int(raw)
44+
except ValueError:
45+
# invalid env value; fall back to default
46+
value = 1
47+
return max(1, value)
3348

3449
# Default page size
3550
@property

test/test_view.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -340,7 +340,10 @@ def test_stream_content_uses_configured_chunk_size(server: TSC.Server, monkeypat
340340
def spy_iter_content(self, chunk_size=None, decode_unicode=False):
341341
captured.append(chunk_size)
342342
assert real_iter_content is not None
343-
return real_iter_content(self, chunk_size, decode_unicode)
343+
# Pass chunk_size / decode_unicode by name so a signature reorder in
344+
# requests fails loudly here instead of silently binding to the wrong
345+
# parameter.
346+
return real_iter_content(self, chunk_size=chunk_size, decode_unicode=decode_unicode)
344347

345348
import requests
346349

@@ -360,6 +363,24 @@ def spy_iter_content(self, chunk_size=None, decode_unicode=False):
360363
assert captured[0] == config.DOWNLOAD_CHUNK_SIZE_MB * BYTES_PER_MB
361364

362365

366+
def test_download_chunk_size_clamps_zero(monkeypatch) -> None:
367+
# TSC_DOWNLOAD_CHUNK_SIZE_MB=0 would cause requests.iter_content to read the
368+
# entire response as a single chunk (defeating the streaming behavior); make
369+
# sure the config clamps to the 1 MB default instead.
370+
from tableauserverclient.config import config
371+
372+
monkeypatch.setenv("TSC_DOWNLOAD_CHUNK_SIZE_MB", "0")
373+
assert config.DOWNLOAD_CHUNK_SIZE_MB == 1
374+
375+
376+
def test_download_chunk_size_rejects_non_numeric(monkeypatch) -> None:
377+
# Non-numeric env values must not crash the download path; fall back to 1 MB.
378+
from tableauserverclient.config import config
379+
380+
monkeypatch.setenv("TSC_DOWNLOAD_CHUNK_SIZE_MB", "abc")
381+
assert config.DOWNLOAD_CHUNK_SIZE_MB == 1
382+
383+
363384
def test_populate_image_missing_id(server: TSC.Server) -> None:
364385
single_view = TSC.ViewItem()
365386
single_view._id = None

test/test_workbook.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -351,7 +351,10 @@ def test_download_uses_configured_chunk_size(server: TSC.Server, tmp_path: Path,
351351
def spy_iter_content(self, chunk_size=None, decode_unicode=False):
352352
captured.append(chunk_size)
353353
assert real_iter_content is not None
354-
return real_iter_content(self, chunk_size, decode_unicode)
354+
# Pass chunk_size / decode_unicode by name so a signature reorder in
355+
# requests fails loudly here instead of silently binding to the wrong
356+
# parameter.
357+
return real_iter_content(self, chunk_size=chunk_size, decode_unicode=decode_unicode)
355358

356359
import requests
357360

0 commit comments

Comments
 (0)