fix(amber): close Iceberg reader streams leaked by iterator probes and bounded reads - #6882
fix(amber): close Iceberg reader streams leaked by iterator probes and bounded reads#6882aglinxinyuan wants to merge 3 commits into
Conversation
IcebergDocument's read iterator opened a Parquet reader (and the S3InputStream beneath it) inside hasNext, so probe-shaped consumers - isEmpty/nonEmpty/hasNext-only checks, e.g. VirtualDocumentSpec's "clear the document" test - opened a stream and then abandoned the iterator with no close path. The GC finalizer eventually reclaimed each one, logging "[S3InputStream] Unclosed input stream created by ..." warnings all over CI. Bounded reads had the same hole: the limit close only ran on a subsequent hasNext call that bounded consumers never make. Restructure the iterator so hasNext acquires nothing: * hasNext claims the next data file from FileScanTask.recordCount metadata alone - the same field the whole-file skip already trusts; planFiles() never splits files in Iceberg 1.9.2 and the write path is strictly append-only, so the count is exact; * the Parquet reader opens lazily in next() via openPendingFile(); * next() closes the reader deterministically the moment a bounded read (getRange) has served its last record; * all closes go through an idempotent closeCurrentReader() that also resets the record iterator, so a closed reader is never polled. Record sequences, hasNext semantics, and the incremental-snapshot refresh behavior are unchanged - only where streams open and close moved. Also close the RESTCatalog in IcebergRestCatalogIntegrationSpec's afterAll: Iceberg 1.9.2 tracks per-table FileIO instances and closes them with the catalog, removing the sibling "Unclosed S3FileIO instance" finalizer warnings from the same CI logs.
Automated Reviewer SuggestionsBased on the
|
There was a problem hiding this comment.
Pull request overview
This PR fixes real resource leaks in Amber’s Iceberg result iteration by ensuring Parquet/S3 readers are opened only when records are actually consumed and are closed deterministically for bounded reads. This change lives in the Iceberg-backed result storage layer (common/workflow-core) and adds a missing catalog close in an Amber integration spec to eliminate Iceberg finalizer warnings.
Changes:
- Refactors
IcebergDocumentiteration sohasNextno longer opens Parquet/S3 resources; readers are opened innext()and closed via a centralized idempotent close path. - Ensures bounded reads (e.g.,
getRange(...).toList) close the active reader immediately after serving the final record rather than waiting for a subsequenthasNext. - Closes
RESTCataloginIcebergRestCatalogIntegrationSpec.afterAllto release HTTP client + trackedS3FileIOinstances.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
| common/workflow-core/src/main/scala/org/apache/texera/amber/core/storage/result/iceberg/IcebergDocument.scala | Moves resource acquisition out of hasNext, adds pending-file claiming + deterministic reader close to prevent Parquet/S3 stream leaks. |
| amber/src/test/integration/org/apache/texera/amber/storage/iceberg/IcebergRestCatalogIntegrationSpec.scala | Closes RESTCatalog in afterAll to prevent S3FileIO finalizer leak warnings in integration tests. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #6882 +/- ##
============================================
+ Coverage 79.59% 81.47% +1.88%
+ Complexity 3838 212 -3626
============================================
Files 1159 529 -630
Lines 46122 30257 -15865
Branches 5127 3215 -1912
============================================
- Hits 36709 24652 -12057
+ Misses 7787 4830 -2957
+ Partials 1626 775 -851
*This pull request uses carry forward flags. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
| config | throughput | MB/s | latency | max Δ latest / 7d | |
|---|---|---|---|---|---|
| 🔴 | bs=10 sw=10 sl=64 | 620 | 0.379 | 15,292/23,034/23,034 us | 🔴 +17.5% / 🔴 +45.9% |
| ⚪ | bs=100 sw=10 sl=64 | 1,372 | 0.837 | 71,100/89,825/89,825 us | ⚪ within ±5% / 🟢 +41.2% |
| 🟢 | bs=1000 sw=10 sl=64 | 1,609 | 0.982 | 619,018/647,831/647,831 us | 🟢 -8.0% / 🟢 +60.7% |
Baseline details
Latest main 084309c from same runner
| config | metric | PR | latest main | 7d avg | Δ latest | Δ 7d |
|---|---|---|---|---|---|---|
| bs=10 sw=10 sl=64 | throughput | 620 tuples/sec | 692 tuples/sec | 771.62 tuples/sec | -10.4% | -19.6% |
| bs=10 sw=10 sl=64 | MB/s | 0.379 MB/s | 0.422 MB/s | 0.471 MB/s | -10.2% | -19.5% |
| bs=10 sw=10 sl=64 | p50 | 15,292 us | 14,789 us | 12,626 us | +3.4% | +21.1% |
| bs=10 sw=10 sl=64 | p95 | 23,034 us | 19,611 us | 15,786 us | +17.5% | +45.9% |
| bs=10 sw=10 sl=64 | p99 | 23,034 us | 19,611 us | 19,344 us | +17.5% | +19.1% |
| bs=100 sw=10 sl=64 | throughput | 1,372 tuples/sec | 1,362 tuples/sec | 971.56 tuples/sec | +0.7% | +41.2% |
| bs=100 sw=10 sl=64 | MB/s | 0.837 MB/s | 0.831 MB/s | 0.593 MB/s | +0.7% | +41.1% |
| bs=100 sw=10 sl=64 | p50 | 71,100 us | 69,791 us | 103,463 us | +1.9% | -31.3% |
| bs=100 sw=10 sl=64 | p95 | 89,825 us | 89,657 us | 110,296 us | +0.2% | -18.6% |
| bs=100 sw=10 sl=64 | p99 | 89,825 us | 89,657 us | 118,690 us | +0.2% | -24.3% |
| bs=1000 sw=10 sl=64 | throughput | 1,609 tuples/sec | 1,553 tuples/sec | 1,001 tuples/sec | +3.6% | +60.7% |
| bs=1000 sw=10 sl=64 | MB/s | 0.982 MB/s | 0.948 MB/s | 0.611 MB/s | +3.6% | +60.7% |
| bs=1000 sw=10 sl=64 | p50 | 619,018 us | 641,727 us | 1,008,988 us | -3.5% | -38.6% |
| bs=1000 sw=10 sl=64 | p95 | 647,831 us | 704,356 us | 1,055,260 us | -8.0% | -38.6% |
| bs=1000 sw=10 sl=64 | p99 | 647,831 us | 704,356 us | 1,074,689 us | -8.0% | -39.7% |
Raw CSV
config_idx,batch_size,schema_width,string_len,num_batches,total_ms,total_tuples,total_bytes,tuples_per_sec,mb_per_sec,lat_p50_us,lat_p95_us,lat_p99_us
0,10,10,64,20,322.50,200,128000,620,0.379,15291.62,23034.14,23034.14
1,100,10,64,20,1457.60,2000,1280000,1372,0.837,71099.51,89825.34,89825.34
2,1000,10,64,20,12429.29,20000,12800000,1609,0.982,619017.52,647830.75,647830.75
What changes were proposed in this PR?
Root cause.
IcebergDocument.getUsingFileSequenceOrder's iterator opened the Parquet reader (and theS3InputStreambeneath it) insidehasNext. Two consumer shapes then leak the stream until the GC finalizer reclaims it — producing the[S3InputStream] Unclosed input stream created by …warnings all over the amber CI jobs:isEmpty/nonEmpty/ lonehasNext(e.g.VirtualDocumentSpec's "clear the document" test)hasNextopened a stream to answer, the caller abandoned the iterator; no close point ever rangetRange(from, until)consumed to exactly the limithasNextcall that bounded consumers never makeFix —
hasNextno longer acquires resources:hasNextclaims the next data file fromFileScanTask.recordCountmetadata alone. This adds no new trust: the whole-file skip inseekToUsableFilealready relies on the same field;planFiles()never splits files in Iceberg 1.9.2 (splitting isplanTasks()-only) and amber's write paths are strictly append-only (no delete files anywhere in-repo), so the count is exact.next()(openPendingFile()), the only resource-acquisition point.next()closes the reader deterministically the moment a bounded read has served its last record — before → after:getRange(a, b).toListused to keep the last file's stream open until GC; it now closes inside the finalnext().closeCurrentReader()that also resets the record iterator, so a closed reader is never polled (the old code did poll one on the exhaustion path — benign with today's Iceberg iterator internals, but the hazard is gone).Record sequences,
hasNextsemantics,NoSuchElementExceptionbehavior, and the incremental-snapshot refresh (lastSnapshotIdbookkeeping) are unchanged — only where streams open and close moved.Also: close the
RESTCataloginIcebergRestCatalogIntegrationSpec.afterAll. Iceberg 1.9.2'sRESTSessionCatalogtracks per-tableFileIOinstances (FileIOTracker) and closes them with the catalog, which removes the siblingUnclosed S3FileIO instancefinalizer warnings.Residual (pre-existing, untouched) abandonment paths — notably
SyncExecutionResource.collectOperatorResult's visualization early-return — are inventoried in #6881 as follow-ups rather than expanded here.Any related issues, documentation, discussions?
Closes #6881
How was this PR tested?
The changed paths are pinned by the existing
VirtualDocumentSpeccontract suite (probe, range,getAfter, incremental second-batch arrival, concurrent writes), whichIcebergDocumentSpecruns against real Iceberg storage in theamber-integrationCI job — those specs exercise every branch of the restructured iterator. Verified locally:WorkflowCore/Test/compile,WorkflowExecutionService/Test/compile, andscalafmtCheckon both source sets all pass.Additionally verified by an exhaustive old-vs-new state-machine review covering: probe-only use, repeated
hasNext, multi-file drains, bounded reads with partial mid-file skip,frombeyond EOF, empty ranges, empty tables, snapshot arrival mid-read, zero-record files, loop termination, and closed-reader polling — record sequences andhasNextbooleans are identical in every scenario; the only differences are the intended open/close points. The partial-skip invariant (from - numOfSkippedRecordsnon-zero only for the first claimed file) follows fromseekToUsableFile'sdropWhileguarantee and is documented in the code.No new unit test is added because the leak itself is only observable through GC-finalizer instrumentation; the observable regression signal is the disappearance of the
Unclosed input streamwarnings from the amber CI logs.Was this PR authored or co-authored using generative AI tooling?
Generated-by: Claude Code (Opus 4.8 [1M context])