feat: NDJSON elements-file mode for partition (0.46.0) - #347
Conversation
On the split-PDF path the SDK rebuilt the whole document in memory to return it: a list per chunk, a flattened list, a json.dumps blob in create_response, and the SDK's re-parse of that blob -- four copies live at once, with the serialization step dominating peak usage on large documents. Passing accept_header_override=PartitionAcceptEnum.APPLICATION_X_NDJSON now returns PartitionResponse.elements_file -- a path to an NDJSON file, one element per line -- instead of PartitionResponse.elements. The per-chunk temp files are concatenated on disk and never parsed, so peak memory is roughly one chunk rather than the whole document. Chunk files are sniffed for their first non-whitespace character, so a server returning application/json still works; NDJSON chunks are copied through untouched. Requesting application/json remains the default and is unchanged. The caller owns the returned file and must delete it. It is deliberately written outside the operation's TemporaryDirectory, which _clear_operation removes as soon as after_success returns. ndjson_mode depends only on the Accept header, never on split_pdf_cache_tmp_data. Those are set by different parties, so gating on both let them disagree: the server would return NDJSON while the hook took the JSON path and res.json() raised on a body this client had itself requested. Both caching modes are handled -- a cached chunk contributes its existing temp-file path, an uncached one spills its body verbatim and then releases it, since every response is retained in api_successful_responses and leaving _content set would keep the document resident regardless. general.py and models/operations/partition.py are both .genignore'd: elements_file is client-side only and can never come from the OpenAPI spec, so a regeneration would silently drop it. test_regeneration_guards.py fails if either entry is lost. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two defects found in review of the elements-file mode. The elements-file marker was an `x-unstructured-elements-file` response header, and any response carrying it was trusted as an SDK-created path. Headers come off the wire, so a server could name an arbitrary local file -- and callers are documented to open `elements_file` and then delete it, making this an arbitrary-file delete rather than just a disclosure. The marker is now an httpx response extension, which is populated by the transport and cannot be set remotely; a real server body is always copied to a file this client creates. Recombination also wrote straight to its final UUID path while recording that path only on success, so a malformed chunk left a partial file behind under a name nothing owned -- the combined file is deliberately outside the operation's TemporaryDirectory, so nothing else cleaned it up. It now writes to a staging file renamed into place atomically, and unlinks it on any exception. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ships the NDJSON elements-file mode. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
All reported issues were addressed across 12 files
Shadow auto-approve: would not auto-approve because issues were found.
Re-trigger cubic
Addresses review findings on the elements-file path. Orphaned files on failure. Both `_ndjson_elements_file` helpers create their destination with delete=False, so a body that raises or is cancelled partway through left the partial copy behind; they now unlink it and re-raise. `write_chunk_body_to_temp` had the same shape -- the caller only registers the path for cleanup once the function returns, so a failed write orphaned the file, and a full disk is exactly the failure that repeats. Cancellation race. Recombination runs in a worker thread that cancellation cannot interrupt, so `_clear_operation` could tear the operation down while it was still running; publishing the path afterwards resurrected a cleared dict entry and orphaned the file. Publishing is now gated on the operation still being live, under a lock that `_clear_operation` also takes when dropping `pending_operation_ids`. Ownership is explicit either way: the success path claims the path out of `ndjson_output_path`, so anything still recorded at teardown was never delivered and is safe to delete. Docs regeneration. docs/models/operations/partitionresponse.md is generated and tracked in gen.lock, and the generation workflow runs on a daily cron, so the elements_file row would have been dropped within a day of merging. Added to .genignore alongside the model, and the regeneration guard now asserts the row. README. Noted that the memory saving applies to the split-PDF path -- unsplit inputs still buffer the body -- so the caveat is visible where the feature is advertised rather than only in the PR. The example's cleanup used a bare unlink in a finally, which would mask a failure to open the file with FileNotFoundError; it now uses Path.unlink(missing_ok=True). The spilled-body regression guard asserted against `_content` it had assigned itself, so it could not fail if the hook stopped releasing the body. It now drives `_elements_from_task_responses`, and was confirmed to fail with the release removed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
1 issue found across 7 files (changes from recent commits).
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="src/unstructured_client/_hooks/custom/request_utils.py">
<violation number="1" location="src/unstructured_client/_hooks/custom/request_utils.py:363">
P3: The new unlink-on-write-failure cleanup in write_chunk_body_to_temp — the entire point of this hardening — has no test coverage: the existing tests only exercise the success roundtrip. Consider adding a regression test that forces the write to raise (monkeypatch the file object's write or response.content) and asserts the temp file no longer exists and the exception still propagates, so the orphan-prevention behavior is pinned.</violation>
</file>
Shadow auto-approve: would not auto-approve because issues were found.
Re-trigger cubic
| The path to the spilled file. The caller owns deleting it. | ||
| """ | ||
| fd, path = tempfile.mkstemp(suffix=".ndjson", dir=dir_ or tempfile.gettempdir()) | ||
| try: |
There was a problem hiding this comment.
P3: The new unlink-on-write-failure cleanup in write_chunk_body_to_temp — the entire point of this hardening — has no test coverage: the existing tests only exercise the success roundtrip. Consider adding a regression test that forces the write to raise (monkeypatch the file object's write or response.content) and asserts the temp file no longer exists and the exception still propagates, so the orphan-prevention behavior is pinned.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/unstructured_client/_hooks/custom/request_utils.py, line 363:
<comment>The new unlink-on-write-failure cleanup in write_chunk_body_to_temp — the entire point of this hardening — has no test coverage: the existing tests only exercise the success roundtrip. Consider adding a regression test that forces the write to raise (monkeypatch the file object's write or response.content) and asserts the temp file no longer exists and the exception still propagates, so the orphan-prevention behavior is pinned.</comment>
<file context>
@@ -360,8 +360,18 @@ def write_chunk_body_to_temp(response: httpx.Response, dir_: Optional[str] = Non
fd, path = tempfile.mkstemp(suffix=".ndjson", dir=dir_ or tempfile.gettempdir())
- with os.fdopen(fd, "wb") as f:
- f.write(response.content)
+ try:
+ with os.fdopen(fd, "wb") as f:
+ f.write(response.content)
</file context>
The unlink-on-failure paths added in the previous commit had no coverage -- the existing tests only walked the success roundtrip, so the cleanup could have been removed without anything going red. Three tests, each confirmed to fail with its corresponding unlink removed: - write_chunk_body_to_temp: os.fdopen is patched so the write raises ENOSPC. The wrapper still closes the real handle, so the fd is not leaked by the test itself. - _ndjson_elements_file and its async counterpart: a response whose byte iterator raises partway through. tempfile.tempdir is redirected at the test's tmp_path so the assertion can see whether anything was left behind. Each asserts both halves of the contract: no file survives, and the original exception still propagates rather than being swallowed by the cleanup. Coverage for the general.py pair was not requested in review, but those helpers grew the same delete=False cleanup in the same commit and had the same gap. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
1 issue found across 1 file (changes from recent commits).
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="_test_unstructured_client/unit/test_ndjson_elements_file.py">
<violation number="1" location="_test_unstructured_client/unit/test_ndjson_elements_file.py:225">
P3: The orphan assertion in both copy-failure tests (`tmp_path.glob("unst_elements_*") == []`) only proves cleanup if the partial file actually landed in tmp_path — which is guaranteed solely by `_new_elements_file()` honoring the monkeypatched global `tempfile.tempdir` (it passes no explicit `dir`). The tests never verify that setup assumption, so a future change that stops routing the temp file through the global tempdir would make both tests pass vacuously while leaking a partial file into the real temp dir. Consider recording the path production actually created during the failing copy (e.g., patching `_new_elements_file` or capturing `out.name`) and asserting it was under tmp_path, so the cleanup check can't become vacuous.</violation>
</file>
Shadow auto-approve: would not auto-approve because issues were found.
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
| The destination is created with delete=False so it can outlive the helper, which is | ||
| exactly what makes an interrupted copy leak. | ||
| """ | ||
| monkeypatch.setattr(tempfile, "tempdir", str(tmp_path)) |
There was a problem hiding this comment.
P3: The orphan assertion in both copy-failure tests (tmp_path.glob("unst_elements_*") == []) only proves cleanup if the partial file actually landed in tmp_path — which is guaranteed solely by _new_elements_file() honoring the monkeypatched global tempfile.tempdir (it passes no explicit dir). The tests never verify that setup assumption, so a future change that stops routing the temp file through the global tempdir would make both tests pass vacuously while leaking a partial file into the real temp dir. Consider recording the path production actually created during the failing copy (e.g., patching _new_elements_file or capturing out.name) and asserting it was under tmp_path, so the cleanup check can't become vacuous.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At _test_unstructured_client/unit/test_ndjson_elements_file.py, line 225:
<comment>The orphan assertion in both copy-failure tests (`tmp_path.glob("unst_elements_*") == []`) only proves cleanup if the partial file actually landed in tmp_path — which is guaranteed solely by `_new_elements_file()` honoring the monkeypatched global `tempfile.tempdir` (it passes no explicit `dir`). The tests never verify that setup assumption, so a future change that stops routing the temp file through the global tempdir would make both tests pass vacuously while leaking a partial file into the real temp dir. Consider recording the path production actually created during the failing copy (e.g., patching `_new_elements_file` or capturing `out.name`) and asserting it was under tmp_path, so the cleanup check can't become vacuous.</comment>
<file context>
@@ -171,6 +178,83 @@ def test_write_chunk_body_to_temp_roundtrips(tmp_path):
+ The destination is created with delete=False so it can outlive the helper, which is
+ exactly what makes an interrupted copy leak.
+ """
+ monkeypatch.setattr(tempfile, "tempdir", str(tmp_path))
+
+ class _FailingResponse:
</file context>
What
Adds an opt-in NDJSON response mode to
partition()that returns elements as a path to a file on disk instead of a parsed list, and ships it as 0.46.0.PartitionResponse.elements_fileis set instead ofPartitionResponse.elements. The caller owns the file and must delete it. Requestingapplication/jsonremains the default and is entirely unchanged.Why
On the split-PDF path the SDK rebuilt the whole document in memory in order to return it: a list per chunk, a flattened list, a
json.dumpsblob increate_response, and then the SDK's re-parse of that blob — four copies live at once, with the serialization step dominating peak usage. For documents with largemetadata.image_base64payloads this is the difference between a job completing and being OOM-killed.In the new mode the per-chunk temp files are concatenated on disk and never parsed, so peak memory is roughly one chunk rather than the whole document.
How
combine_chunk_files_to_ndjsonconcatenates chunk files on disk. Each chunk is sniffed for its first non-whitespace character, so a server returningapplication/jsonstill works; chunks that are already NDJSON are copied through without parsing.ndjson_modedepends only on theAcceptheader, never onsplit_pdf_cache_tmp_data. Those are set by different parties, so gating on both let them disagree — the server would return NDJSON while the hook took the JSON path andres.json()raised on a body this client had itself requested.api_successful_responsesand leaving_contentset would keep the document resident regardless.TemporaryDirectory, which_clear_operationremoves as soon asafter_successreturns.Temp-file ownership
Everything this path creates is accounted for:
Security
The elements-file marker is an httpx response extension, not a response header. Extensions are populated by the transport, so a remote server cannot set the key. A header would be wire-controlled, and since callers are documented to open
elements_fileand then delete it, that would hand a hostile server an arbitrary local file to destroy. A real server body is always copied to a file this client creates.Regeneration
elements_fileis client-side only and can never come from the OpenAPI spec, so a regeneration would silently drop it. Bothgeneral.pyandmodels/operations/partition.pyare now in.genignore, andtest_regeneration_guards.pyfails if either entry is lost.Known limitation
For unsplit inputs (non-PDF, or
split_pdf_page=False) the response body is still fully read into memory before being written to disk, so peak is 2x the body rather than bounded. This does not affect the split-PDF path that motivated the change, where the SDK only ever sees an empty dummy response or the small synthetic path response. Fixing it meansstream=Truefor NDJSON requests, which makesraw_response.contentraise on the returned closed response — a user-visible change worth its own review. Tracked separately.Testing
_test_unstructured_client/unit/test_ndjson_elements_file.py— recombination across JSON-array / NDJSON / mixed chunk formats, order preservation, byte-exact payload round-trip, non-ASCII, temp-file lifecycle on success and failure, and regression guards for the header-spoofing and partial-output defects.pylint10.00/10;mypyclean.🤖 Generated with Claude Code