Skip to content

Fix/decoder iterator resources - #162

Merged
Quafadas merged 4 commits into
mainfrom
fix/decoder-iterator-resources
Oct 1, 2026
Merged

Quafadas merged 4 commits into
mainfrom
fix/decoder-iterator-resources

Conversation

@Quafadas

@Quafadas Quafadas commented Oct 1, 2026

Copy link
Copy Markdown
Owner

No description provided.

Simon Parten and others added 3 commits October 1, 2026 09:25
`JdbcDecoder[Option[T]]` read the column via the strict `JdbcDecoder[T]`
and only then asked `rs.wasNull()`. For every reference type that is too
late: the strict decoders for String, BigDecimal, Array[Byte] and Instant
inspect `wasNull` themselves and throw, so the wrapper inherited the throw
and a nullable VARCHAR mapped to `Option[String]` failed on its first NULL
row with "Use Option[String] for nullable columns" - advice the caller had
already taken. LocalDate, LocalDateTime and UUID had the mirror problem:
`getObject` returns null, so a non-Option column decoded to a null rather
than reporting anything.

Reading the column twice is not an option - JDBC only guarantees one
left-to-right read per column, and `JdbcRowDecoder` relies on that. So the
null policy moves onto the typeclass instead: `decode` stays strict and
`decodeOption` is the same single read with the opposite answer for NULL.
The built-ins are now all built by one `strict` combinator that reads once,
checks `wasNull`, and only then converts - which is also what stops
`BigDecimal(_)` and `Timestamp#toInstant` NPE-ing on the sentinel a NULL
read returns.

`decodeOption` has a default, so a user-supplied decoder keeps working and
still gets `Option` support; it needs overriding only when `decode` itself
consults `wasNull`.

Primitives are strict now too. `getInt` hands back 0 for a NULL, so a
non-Option Int column silently decoded NULLs to zero - a summed column
quietly short by however many rows were NULL. It now says which column and
what to write instead, consistent with what the String decoder already
claimed to do.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`hasNext` compared a counter against the range's last row, while
`sheetIterator` skips rows POI reports as absent. An entirely blank row
inside the range - a visual separator between groups of data, which is
ordinary in real spreadsheets - therefore made `hasNext` promise more rows
than the iterator had, and `next()` fell off the end with a bare
`java.util.NoSuchElementException: next on empty iterator`. On a sheet
spanning A1:B6 with one absent row, four rows came back and then it threw.

Pinned ranges are the documented recommendation for Excel, so this sat on
the recommended path.

`hasNext` now asks the row iterator. The range still bounds iteration,
because `sheetIterator` is built from `firstRow + 1 to lastRow`, and
skipping blank rows keeps reading consistent with compile time inference,
which walks the range the same way.

`currentRowIndex` is now read off the row rather than counted, so the row
number in a decode error names the row Excel shows; counting drifted as
soon as a blank row was skipped. The parsed range is memoised too - it was
being re-parsed from its string form on every `hasNext` and every row.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`CSV.resource(...)` opened a `scala.io.Source`, kept its line iterator and
dropped the `Source` itself, so nothing ever closed it; `JsonTable` did the
same with its `InputStream`. The handles came back only when the JVM
noticed the objects were unreachable, which is driven by heap pressure
rather than by reading - measured here, 20k reads of a small CSV stacked up
a high water mark of ~5,200 open descriptors before any were returned,
which is already past a 1024 `ulimit -n`. On Windows the file stays locked
for just as long. `parquet` and `db` already got this right, so the two
oldest readers were the odd ones out.

Both iterators now own an optional `AutoCloseable` and close it when the
rows run out, so the ordinary shapes - `.toSeq`, `foreach`, a forced
`LazyList` - need nothing. Both are `AutoCloseable` too, for stopping early:

    Using(CSV.absolutePath("big.csv"))(_.take(10).toList)

The same measurement after this change stays flat at the baseline for a
drained read and for `Using`. Abandoning a partially read iterator without
closing it still accumulates, and cannot not: `.take(10)` returns a *new*
iterator with no way to tell the original it is finished. That is what
`close` is for, and the docs now say so.

`next()` refuses to read past a close rather than returning whatever the
reader had buffered - a test caught that, not inspection.

Also closed three handles that were only released on the happy path: the
column and dense-array builders dropped theirs whenever `parseLine` met a
malformed line, `fromTyped` stranded one on each of its three header
validation failures, and the macro's own handle on the CSV was never closed
at all - one descriptor per expansion, for the lifetime of a build daemon.

ExcelWorkbookCache had a related bug of its own: when a cached workbook
failed its liveness check the replacement was never put back in the cache,
so every later call opened a fresh workbook - each holding a file handle -
and cached none of them. `compute` now does the lookup and the insert in
one step, which also closes the check-then-act race where two threads each
opened a workbook and the loser's was leaked, despite the method's
documented thread safety.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Quafadas
Quafadas force-pushed the fix/decoder-iterator-resources branch from d686874 to 2202829 Compare October 1, 2026 07:27
The `finally` added in the previous commit started below the header line,
so the two ways reading a header can fail still stranded the compiler's
handle: `HeaderOptions.Auto` takes `buffered.head`, which throws on an
empty CSV, and `CSVParser.parseLine` can throw on a malformed one. Those
are precisely the compiles someone repeats while still getting the file
right, so it was the wrong half to leave outside.

The `try` now opens immediately after `Source.fromFile`.

Measured, by counting handles on the CSV across the compiler's processes
while expanding 40 `TypeInferrer.FirstN(5)` call sites: 40 concurrently
open before the fix - one per call site, all alive at once - and 0 after.
FirstN is the case that needs this most, since inference stops after five
lines and leaves the reader parked mid-file with nothing to exhaust it;
but FromAllRows depended on it too, because `scala.io.Source` does not
close itself when its lines run out.

An empty CSV still fails the build, as it should, and now holds no handle
while doing so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Quafadas
Quafadas merged commit 840578a into main Oct 1, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant