From e2fdee45bf5722a963cef16384cf6a7e9e9c1a63 Mon Sep 17 00:00:00 2001 From: Nick Franck <46548427+CyMule@users.noreply.github.com> Date: Wed, 22 Jul 2026 11:01:03 -0400 Subject: [PATCH 1/7] fix(gzip): bound decompression memory --- prepline_general/api/general.py | 27 ++++++++++++++++++++++----- test_general/api/test_gzip.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 5 deletions(-) diff --git a/prepline_general/api/general.py b/prepline_general/api/general.py index 2b4a46e84..2dc143c8d 100644 --- a/prepline_general/api/general.py +++ b/prepline_general/api/general.py @@ -7,10 +7,12 @@ import mimetypes import os import secrets +import shutil +import tempfile from base64 import b64encode from concurrent.futures import ThreadPoolExecutor from functools import partial -from typing import IO, Any, Dict, List, Mapping, Optional, Sequence, Tuple, Union, cast +from typing import IO, Any, BinaryIO, Dict, List, Mapping, Optional, Sequence, Tuple, Union, cast import backoff import pandas as pd @@ -31,6 +33,7 @@ from starlette.datastructures import Headers from starlette.types import Send +from prepline_general.api import __version__ as api_version from prepline_general.api.filetypes import get_validated_mimetype from prepline_general.api.models.form_params import GeneralFormParams from unstructured.documents.elements import Element @@ -41,11 +44,13 @@ elements_from_json, ) from unstructured_inference.models.base import UnknownModelException -from prepline_general.api import __version__ as api_version app = FastAPI() router = APIRouter() +_GZIP_COPY_CHUNK_SIZE = 1024 * 1024 +_GZIP_SPOOL_MAX_MEMORY_BYTES = 1024 * 1024 + def is_compatible_response_type(media_type: str, response_type: type) -> bool: """True when `response_type` can be converted to `media_type` for HTTP Response.""" @@ -613,10 +618,22 @@ def return_content_type(filename: str): if filename.endswith(".gz"): filename = filename[:-3] - gzip_file = gzip.open(file.file).read() + output_file = tempfile.SpooledTemporaryFile( + max_size=_GZIP_SPOOL_MAX_MEMORY_BYTES, + mode="w+b", + ) + try: + with gzip.open(file.file) as gzip_file: + shutil.copyfileobj(gzip_file, output_file, length=_GZIP_COPY_CHUNK_SIZE) + uncompressed_size = output_file.tell() + output_file.seek(0) + except Exception: + output_file.close() + raise + return UploadFile( - file=io.BytesIO(gzip_file), - size=len(gzip_file), + file=cast(BinaryIO, output_file), + size=uncompressed_size, filename=filename, headers=Headers({"content-type": return_content_type(filename)}), ) diff --git a/test_general/api/test_gzip.py b/test_general/api/test_gzip.py index 24940a516..1c4c0867c 100644 --- a/test_general/api/test_gzip.py +++ b/test_general/api/test_gzip.py @@ -9,13 +9,42 @@ import pandas as pd import pytest from deepdiff import DeepDiff +from fastapi import UploadFile from fastapi.testclient import TestClient from prepline_general.api.app import app +from prepline_general.api.general import _GZIP_SPOOL_MAX_MEMORY_BYTES, ungz_file MAIN_API_ROUTE = "general/v0/general" +def _gzip_upload(content: bytes, filename: str = "sample.txt.gz") -> UploadFile: + compressed = io.BytesIO(gzip.compress(content)) + return UploadFile(file=compressed, filename=filename) + + +def test_ungz_file_keeps_small_outputs_in_memory(): + content = b"small gzip payload" + + result = ungz_file(_gzip_upload(content)) + + assert result.filename == "sample.txt" + assert result.content_type == "text/plain" + assert result.size == len(content) + assert result.file.read() == content + assert result.file._rolled is False + + +def test_ungz_file_spills_large_outputs_to_disk(): + content_size = _GZIP_SPOOL_MAX_MEMORY_BYTES + 1 + + result = ungz_file(_gzip_upload(b"x" * content_size)) + + assert result.size == content_size + assert result.file._rolled is True + assert result.file.read(16) == b"x" * 16 + + @pytest.mark.xfail(reason="The outputs are different as of unstructured==0.13.5") @pytest.mark.parametrize("output_format", ["application/json", "text/csv"]) @pytest.mark.parametrize( From 51050710ea7113eab43c2966e584cccd2a644176 Mon Sep 17 00:00:00 2001 From: Nick Franck <46548427+CyMule@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:30:28 -0400 Subject: [PATCH 2/7] fix(gzip): close decompressed temporary files --- prepline_general/api/general.py | 16 ++++++++------ test_general/api/test_gzip.py | 38 ++++++++++++++++++++++----------- 2 files changed, 36 insertions(+), 18 deletions(-) diff --git a/prepline_general/api/general.py b/prepline_general/api/general.py index 2dc143c8d..a75cf068e 100644 --- a/prepline_general/api/general.py +++ b/prepline_general/api/general.py @@ -631,12 +631,16 @@ def return_content_type(filename: str): output_file.close() raise - return UploadFile( - file=cast(BinaryIO, output_file), - size=uncompressed_size, - filename=filename, - headers=Headers({"content-type": return_content_type(filename)}), - ) + # Reuse the request-owned UploadFile so FastAPI closes the decompressed spool when the + # request finishes. A newly-created UploadFile would not belong to the parsed FormData and + # would therefore remain open after the response. + file.file.close() + file.file = cast(BinaryIO, output_file) + file.size = uncompressed_size + file.filename = filename + file.headers = Headers({"content-type": return_content_type(filename)}) + file._max_mem_size = getattr(output_file, "_max_size", 0) + return file @router.get("/general/v0/general", include_in_schema=False) diff --git a/test_general/api/test_gzip.py b/test_general/api/test_gzip.py index 1c4c0867c..49d9559ac 100644 --- a/test_general/api/test_gzip.py +++ b/test_general/api/test_gzip.py @@ -25,24 +25,38 @@ def _gzip_upload(content: bytes, filename: str = "sample.txt.gz") -> UploadFile: def test_ungz_file_keeps_small_outputs_in_memory(): content = b"small gzip payload" + upload = _gzip_upload(content) + compressed_file = upload.file - result = ungz_file(_gzip_upload(content)) + result = ungz_file(upload) - assert result.filename == "sample.txt" - assert result.content_type == "text/plain" - assert result.size == len(content) - assert result.file.read() == content - assert result.file._rolled is False + try: + assert result is upload + assert compressed_file.closed + assert result.filename == "sample.txt" + assert result.content_type == "text/plain" + assert result.size == len(content) + assert result.file.read() == content + assert result.file._rolled is False + finally: + result.file.close() def test_ungz_file_spills_large_outputs_to_disk(): content_size = _GZIP_SPOOL_MAX_MEMORY_BYTES + 1 - - result = ungz_file(_gzip_upload(b"x" * content_size)) - - assert result.size == content_size - assert result.file._rolled is True - assert result.file.read(16) == b"x" * 16 + upload = _gzip_upload(b"x" * content_size) + compressed_file = upload.file + + result = ungz_file(upload) + + try: + assert result is upload + assert compressed_file.closed + assert result.size == content_size + assert result.file._rolled is True + assert result.file.read(16) == b"x" * 16 + finally: + result.file.close() @pytest.mark.xfail(reason="The outputs are different as of unstructured==0.13.5") From f843828ba7bf3f2002acf33e004d87659e98b26f Mon Sep 17 00:00:00 2001 From: Nick Franck <46548427+CyMule@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:47:51 -0400 Subject: [PATCH 3/7] refactor(gzip): simplify decompression spooling --- prepline_general/api/general.py | 11 +++-------- test_general/api/test_gzip.py | 29 +++++++++-------------------- 2 files changed, 12 insertions(+), 28 deletions(-) diff --git a/prepline_general/api/general.py b/prepline_general/api/general.py index a75cf068e..8b4275707 100644 --- a/prepline_general/api/general.py +++ b/prepline_general/api/general.py @@ -48,8 +48,7 @@ app = FastAPI() router = APIRouter() -_GZIP_COPY_CHUNK_SIZE = 1024 * 1024 -_GZIP_SPOOL_MAX_MEMORY_BYTES = 1024 * 1024 +_GZIP_SPOOL_MAX_MEMORY_BYTES = 10 * 1024 * 1024 def is_compatible_response_type(media_type: str, response_type: type) -> bool: @@ -618,13 +617,10 @@ def return_content_type(filename: str): if filename.endswith(".gz"): filename = filename[:-3] - output_file = tempfile.SpooledTemporaryFile( - max_size=_GZIP_SPOOL_MAX_MEMORY_BYTES, - mode="w+b", - ) + output_file = tempfile.SpooledTemporaryFile(max_size=_GZIP_SPOOL_MAX_MEMORY_BYTES) try: with gzip.open(file.file) as gzip_file: - shutil.copyfileobj(gzip_file, output_file, length=_GZIP_COPY_CHUNK_SIZE) + shutil.copyfileobj(gzip_file, output_file) uncompressed_size = output_file.tell() output_file.seek(0) except Exception: @@ -639,7 +635,6 @@ def return_content_type(filename: str): file.size = uncompressed_size file.filename = filename file.headers = Headers({"content-type": return_content_type(filename)}) - file._max_mem_size = getattr(output_file, "_max_size", 0) return file diff --git a/test_general/api/test_gzip.py b/test_general/api/test_gzip.py index 49d9559ac..41c4f4f3c 100644 --- a/test_general/api/test_gzip.py +++ b/test_general/api/test_gzip.py @@ -23,8 +23,14 @@ def _gzip_upload(content: bytes, filename: str = "sample.txt.gz") -> UploadFile: return UploadFile(file=compressed, filename=filename) -def test_ungz_file_keeps_small_outputs_in_memory(): - content = b"small gzip payload" +@pytest.mark.parametrize( + ("content", "spills_to_disk"), + [ + pytest.param(b"small gzip payload", False, id="small-stays-in-memory"), + pytest.param(b"x" * (_GZIP_SPOOL_MAX_MEMORY_BYTES + 1), True, id="large-spills-to-disk"), + ], +) +def test_ungz_file_bounds_decompression_memory(content: bytes, spills_to_disk: bool): upload = _gzip_upload(content) compressed_file = upload.file @@ -37,24 +43,7 @@ def test_ungz_file_keeps_small_outputs_in_memory(): assert result.content_type == "text/plain" assert result.size == len(content) assert result.file.read() == content - assert result.file._rolled is False - finally: - result.file.close() - - -def test_ungz_file_spills_large_outputs_to_disk(): - content_size = _GZIP_SPOOL_MAX_MEMORY_BYTES + 1 - upload = _gzip_upload(b"x" * content_size) - compressed_file = upload.file - - result = ungz_file(upload) - - try: - assert result is upload - assert compressed_file.closed - assert result.size == content_size - assert result.file._rolled is True - assert result.file.read(16) == b"x" * 16 + assert result.file._rolled is spills_to_disk finally: result.file.close() From a59313e6b21b3a3669e40626872c08dc4a549186 Mon Sep 17 00:00:00 2001 From: Nick Franck <46548427+CyMule@users.noreply.github.com> Date: Thu, 23 Jul 2026 06:45:28 -0400 Subject: [PATCH 4/7] fix(gzip): prevent downstream spool copies --- prepline_general/api/general.py | 34 ++++++++++++++++++++++++++++++--- test_general/api/test_gzip.py | 13 +++++++++++++ 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/prepline_general/api/general.py b/prepline_general/api/general.py index 8b4275707..5b9aef231 100644 --- a/prepline_general/api/general.py +++ b/prepline_general/api/general.py @@ -48,7 +48,32 @@ app = FastAPI() router = APIRouter() -_GZIP_SPOOL_MAX_MEMORY_BYTES = 10 * 1024 * 1024 +_GZIP_COPY_CHUNK_SIZE = 1024 * 1024 +_GZIP_SPOOL_MAX_MEMORY_BYTES = 1024 * 1024 + + +class _SpooledFileProxy: + """Expose a spooled file without its concrete type triggering downstream copies.""" + + def __init__(self, file: IO[bytes], name: str | None): + self._file = file + self.name = name + + def __getattr__(self, name: str) -> Any: + return getattr(self._file, name) + + def __enter__(self) -> _SpooledFileProxy: + self._file.__enter__() + return self + + def __exit__(self, *args: Any) -> Any: + return self._file.__exit__(*args) + + def __iter__(self): + return iter(self._file) + + def __next__(self) -> bytes: + return next(self._file) def is_compatible_response_type(media_type: str, response_type: type) -> bool: @@ -620,7 +645,7 @@ def return_content_type(filename: str): output_file = tempfile.SpooledTemporaryFile(max_size=_GZIP_SPOOL_MAX_MEMORY_BYTES) try: with gzip.open(file.file) as gzip_file: - shutil.copyfileobj(gzip_file, output_file) + shutil.copyfileobj(gzip_file, output_file, length=_GZIP_COPY_CHUNK_SIZE) uncompressed_size = output_file.tell() output_file.seek(0) except Exception: @@ -631,7 +656,10 @@ def return_content_type(filename: str): # request finishes. A newly-created UploadFile would not belong to the parsed FormData and # would therefore remain open after the response. file.file.close() - file.file = cast(BinaryIO, output_file) + decompressed_file = ( + _SpooledFileProxy(output_file, filename) if output_file._rolled else output_file + ) + file.file = cast(BinaryIO, decompressed_file) file.size = uncompressed_size file.filename = filename file.headers = Headers({"content-type": return_content_type(filename)}) diff --git a/test_general/api/test_gzip.py b/test_general/api/test_gzip.py index 41c4f4f3c..7653cee86 100644 --- a/test_general/api/test_gzip.py +++ b/test_general/api/test_gzip.py @@ -44,6 +44,19 @@ def test_ungz_file_bounds_decompression_memory(content: bytes, spills_to_disk: b assert result.size == len(content) assert result.file.read() == content assert result.file._rolled is spills_to_disk + assert isinstance(result.file, tempfile.SpooledTemporaryFile) is not spills_to_disk + finally: + result.file.close() + assert result.file.closed + + +def test_ungz_file_proxy_preserves_special_file_methods(): + result = ungz_file(_gzip_upload(b"first\nsecond\n" * _GZIP_SPOOL_MAX_MEMORY_BYTES)) + + try: + assert iter(result.file) is not result.file + assert next(result.file) == b"first\n" + assert result.file.__enter__() is result.file finally: result.file.close() From b8145ff868bd0701ee7039ebcaf9fa132c25488f Mon Sep 17 00:00:00 2001 From: Nick Franck <46548427+CyMule@users.noreply.github.com> Date: Thu, 23 Jul 2026 07:30:30 -0400 Subject: [PATCH 5/7] fix(gzip): avoid private spool state access --- prepline_general/api/general.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/prepline_general/api/general.py b/prepline_general/api/general.py index 5b9aef231..3e27fb8e8 100644 --- a/prepline_general/api/general.py +++ b/prepline_general/api/general.py @@ -657,7 +657,9 @@ def return_content_type(filename: str): # would therefore remain open after the response. file.file.close() decompressed_file = ( - _SpooledFileProxy(output_file, filename) if output_file._rolled else output_file + _SpooledFileProxy(output_file, filename) + if uncompressed_size > _GZIP_SPOOL_MAX_MEMORY_BYTES + else output_file ) file.file = cast(BinaryIO, decompressed_file) file.size = uncompressed_size From 21df65e1222ff5db10bf6a000aeb4723437a0218 Mon Sep 17 00:00:00 2001 From: Nick Franck <46548427+CyMule@users.noreply.github.com> Date: Thu, 23 Jul 2026 08:34:48 -0400 Subject: [PATCH 6/7] fix(api): narrow response body type --- prepline_general/api/general.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/prepline_general/api/general.py b/prepline_general/api/general.py index 28115e12e..6ec94f65b 100644 --- a/prepline_general/api/general.py +++ b/prepline_general/api/general.py @@ -793,7 +793,7 @@ def join_responses( frames = [ pd.read_csv(io.BytesIO(response.body)) # pyright: ignore[reportUnknownMemberType] for response in responses - if response.body.strip() + if cast(bytes, response.body).strip() ] if not frames: return PlainTextResponse(responses[0].body) From 16a49f86a35d092ba20bf063cc4b5d62fd083a2f Mon Sep 17 00:00:00 2001 From: Nick Franck <46548427+CyMule@users.noreply.github.com> Date: Thu, 23 Jul 2026 08:34:48 -0400 Subject: [PATCH 7/7] chore(release): add 0.1.11 changelog entry --- CHANGELOG.md | 6 ++++++ prepline_general/api/__version__.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e45c14985..d6e9292b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## 0.1.11 + +### Fixes + +- **Bound gzip decompression memory**: gzip uploads are decompressed incrementally into a spooled temporary file instead of being materialized as a complete in-memory `bytes` value. Expanded outputs larger than 1 MiB remain disk-backed through MIME detection and partitioning, avoiding downstream document-sized copies while preserving the partition API. + ## 0.1.10 ### Fixes diff --git a/prepline_general/api/__version__.py b/prepline_general/api/__version__.py index f15b0cd2d..346129b4e 100644 --- a/prepline_general/api/__version__.py +++ b/prepline_general/api/__version__.py @@ -1 +1 @@ -__version__ = "0.1.10" # pragma: no cover +__version__ = "0.1.11" # pragma: no cover