feat(cats-effect): make the streaming writer's spill directory injectable - #515
feat(cats-effect): make the streaming writer's spill directory injectable#515arcaputo3 wants to merge 4 commits into
Conversation
…able Closes #514. The two-pass writers spill their phase-1 worksheet body to a scratch file, and that file always landed in the JVM's `java.io.tmpdir` with no way for a caller to move it. Containers routinely give /tmp a small tmpfs or mount it read-only, and a large streaming write wants its scratch on the same fast volume as its output — POI solved the same problem with TempFileCreationStrategy. `ExcelIO.instance[IO].withSpillDir(dir)` now routes both spill sites. A missing or unwritable directory fails at acquire with a message that names the setting, so it is not mistaken for a problem with the workbook being written. Shape notes: - `spillDir` is an overridable member, not a constructor parameter. A parameter (even a defaulted one) would have changed the class's erased constructor from `(Function1, Async)` to `(Function1, Option, Async)` and broken consumers compiled against an earlier release; an auxiliary constructor does not help, since the context bound makes it `(Function1, Async, Async)`. Verified with `javap`: the constructor is byte-identical to before and `spillDir()` is purely additive. - Not a `WriterConfig` field. That config describes the document's shape — SST policy, compression, XML backend — and is also consumed by the in-memory writer, which never spills. A scratch location is the interpreter's business. - The knob is per-instance rather than per-call, and instances are cheap, so a call site needing its own directory builds one and leaves the rest of the program on the default. The tests this makes possible are the second half of the issue. #513 could only watch the shared tmpdir and settle for up to 2s against foreign traffic; the cleanup assertions now point the writer at a directory inside the test's own fixture and are exact — no filter, no polling, no residual flake window. The positive control survives in sharper form: with an isolated directory, "no leftovers" would also hold if the writer ignored the setting entirely, so each test still proves the spill appeared there first. Verified both ways — stubbing the bracket release fails them instantly naming the file (0.006s, down from 2s), and making `createSpillFile` ignore its argument fails four of the five. One test deliberately still watches java.io.tmpdir, to pin that the default is unchanged. It is positive-only — a foreign spill can satisfy it, never break it. Also folds in the two hygiene items filed on the issue: sibling fixtures move off the `xl-stream-` namespace so the spill pattern is unambiguous, and `remapWorksheetEntry` writes into the fixture directory instead of stranding a file in the shared one on abrupt exit. Docs: performance guide gains a "where the scratch file goes" note under row-stream writes. Test counts to 5,452 / xl-cats-effect 146. Gates: `__.test`, `checkFormatAll`, `scripts/test-examples.sh`, `scripts/verify-skill-snippets.sh --local` all green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review: injectable spill directoryRead the diff plus the surrounding
The test rework is a real improvement — swapping a 2s settle loop against a shared directory for an exact assertion in an isolated one, while keeping a positive control so "empty afterwards" cannot pass vacuously. The fault-injection table is the right way to demonstrate that. Below: one design point, one latent leak, and some smaller items. 1.
|
Review round on #515. The multi-sheet writer takes one spill per sheet inside a single bracket acquire, and bracket's release never runs for a failed acquire — so a spill that could not be created partway through stranded every file already taken. Pre-existing, but this PR is what makes it reachable: a broken java.io.tmpdir fails on sheet 1 with nothing to clean up, whereas a caller-supplied directory adds ENOSPC, quota and per-directory permission failures that land on sheet 3 of 5, in the user's own directory rather than one the OS eventually reaps. Acquire now folds over the sheets and unwinds what it took before rethrowing. The test drives it through the `spillDir` seam — real directory for the first two sheets, missing one for the third — and fails without the fix, naming both orphans. Also from the review: - `withSpillDir` is `final`, and says in its scaladoc that it carries the warning handler and nothing else, so a subclass should override `spillDir` rather than call it and silently lose its own overrides - a test pins the load-bearing detail that made this shape work at all: the handler survives `withSpillDir`, chaining takes the last directory, and the companion shorthand agrees. Verified it fails when withSpillDir is simplified to build from a fresh instance - performance guide notes that redirecting the spill makes the directory's permissions the caller's business; the file itself stays rw------- either way - dead duration import removed, fixture-adjacent copies tolerate a parentless path, and the missing-directory test asserts before building its message Counts to 5,454 / xl-cats-effect 148. Gates green: __.test, checkFormatAll, test-examples.sh, verify-skill-snippets.sh --local. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Both of the pre-merge items taken, plus the smaller ones. 3aaa8d5. #2 — acquire-path leak. Correct, and correctly identified as made reachable by this PR rather than introduced by it. Acquire now folds over the sheets and unwinds what it already took before rethrowing. Driving it in a test turned out to be easy through the seam this PR added: override #1 — #3 — the load-bearing detail was untested. Added all three assertions you listed. The warning one needed a warning-producing file, so the spec grew a #4 — security note. Added. Kept your framing that the file mode does not regress ( #5 — smaller items. Dead duration import removed; both fixture-adjacent copies now go through a Filed rather than fixed:
Counts to 5,454 / xl-cats-effect 148. All four gates re-run green. |
Review —
|
…nwinding Second review round on #515. `createSpillFile` read the setting twice — once to create, once to build the failure message — so an override that varies between reads could report a directory other than the one that actually threw, undercutting the whole point of the wrapped message. Now snapshotted once, and the scaladoc states the arity ("consulted exactly once per spilled sheet") so the contract is explicit rather than emergent. The reviewer noticed this via the unwind test's counter override, which was hitting spillDir four times for three sheets and passing only because both reads on sheet 3 landed past its predicate. The unwind's `deleteIfExists` calls were unguarded, so a delete that threw would leak the remaining spills AND replace the carefully-worded cause with the delete failure — in exactly the pathological-filesystem case the block exists for. They now suppress onto the original exception. Adds the read-only-directory case: a container mounting its scratch volume ro is the scenario this feature is for, and it arrives as AccessDeniedException rather than the NoSuchFileException the missing-directory test covers. Skips itself on non-POSIX filesystems and when running as a user the mode bits do not constrain. The old test's output file was named `unwritable.xlsx` while testing no such thing; renamed to match what it actually asserts. `fixtureDirOf` now fails instead of falling back to the working directory. Both call sites are fixture-local, and since these copies rely on fixture teardown rather than deleteOnExit, a silent CWD write would strand files in the repo root. Counts to 5,455 / xl-cats-effect 149. Four gates green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Both weighted items taken, and item 1 was a good catch — made better by the fact that my own new test was the thing exposing it. 11a5f47. #1 — double read of #2 — unwind can swallow the diagnostic. Taken in the #4 — the test name implied coverage it didn't have. Rather than rename, added the case: a #5 — #3 — stale counts in the description. Fixed; the body now says 5,455 / xl-cats-effect 149, re-measured from #6 minors. Added the arity clause (above). Left Filed:
|
Review: injectable spill directory for the two-pass streaming writersStrong PR. The problem statement is real (containers with a tiny/ro Comments below, roughly by severity. Nothing here is a blocker. Correctness / robustness1. The helper exists to guarantee the failure names the setting, but 2. The unwritable-directory test can turn a skip into a failure on non-POSIX —
3. Behaviour-preserving (you rethrow API design4. The scaladoc is honest about it: 5. Binary-compat guarantee is manual. The TestsGenuinely improved: exact assertions against an isolated directory beat a 2s settle loop against shared Gaps I'd consider:
Nit — Observation, pre-existing, not this PR's job: the failure tests assert the spill directory is empty, but the two-pass writers open Performance
Docs / securityThe performance-guide note is a good addition and the permissions claim is accurate — One thing I'd add to it: the spill is not registered with Doc drift (mostly pre-existing): the test count now lives in four places and only two are updated here — On #517For the CLI, a system-property default consulted by Nice work — the writeup alone made this a fast review. |
… sites Final review round on #515. The spill is deleted by the write's own bracket rather than registered with deleteOnExit — correct, since deleteOnExit leaks in long-lived JVMs, but it means a SIGKILL or OOM-kill mid-write strands the worksheet body. On java.io.tmpdir the OS eventually sweeps it; on the persistent fast volume this feature exists to point at, it stays until someone removes it. Callers redirecting the spill have quietly taken on sweeping orphans, so the performance guide now says so next to the permissions note. The test count lived in four places and this PR was updating two, so testing-guide.md (stale by ~1,370 — it still had a 4,085 total) and roadmap.md now carry the same measured numbers as CLAUDE.md and STATUS.md. All four agree at 5,455 and the per-module rows sum to it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Last two taken. 7910a30. The orphan-on-SIGKILL trade. Good catch, and the right frame: the consequence isn't the same on both paths. On Doc drift. Fixed all four rather than two. On #517: added your Gates re-run green. |
|
Review: injectable spill directory Nice piece of work. The problem is real (POI's Findings below, roughly in descending order of how much I would care. 1. Consistency: the rest of the codebase already colocates scratch with output
So after this PR the library has two different answers to "where does scratch go", and the streaming writer — the one that moves the most bytes — is the one that picks the small tmpfs. Keeping the default unchanged is defensible for compat, but it is worth stating in the scaladoc why the two-pass spill differs from its neighbours, or reconsidering the default: the output's parent is already known at both spill sites, is on the same volume as the thing being written, and is by construction writable if the write is going to succeed at all. Concretely, this also shrinks 517 to one line. ExcelIO
.withSpillDir[IO](Option(outputPath.getParent).getOrElse(Path.of(".")))
.writeStreamWithAutoDetect(outputPath, sheetName)That fixes the motivating case named in the PR body ( 2. The outer 3.
4.
5. "Consulted exactly once per spilled sheet" is now public contract ( The partial-acquire test ( 6. Well documented, and Test coverage Strong overall; the positive controls are the right shape, and pinning the default with a deliberately positive-only test is a good call. Gaps I would close:
Test nits
Docs The performance-guide section is the best part of the docs change; naming both inherited consequences (directory permissions, and the SIGKILL case where no Note on verification: I reviewed this statically. None of the above is blocking. Item 1 is the one I would most like to see addressed, and it is the cheapest. |
Closes #514.
Problem
The two-pass streaming writers (
writeStreamWithAutoDetect,writeStreamsSeqWithAutoDetect) buy their up-front<dimension>by spilling the worksheet body to a scratch file — one per sheet — and that file always landed in the JVM'sjava.io.tmpdirwith no way for a caller to move it.Two consequences, one for users and one for the test suite:
/tmpa small tmpfs or mount it read-only, and a large write wants its scratch on the same fast volume as its output. POI solved the same problem withTempFileCreationStrategy.API
A missing or unwritable directory fails at acquire with a message naming the setting, so it isn't mistaken for a problem with the workbook being written.
Three shape decisions
spillDiris an overridable member, not a constructor parameter. A parameter — even a defaulted one — changes the erased constructor from(Function1, Async)to(Function1, Option, Async), breaking consumers compiled against an earlier release. An auxiliary constructor doesn't rescue it either: the context bound makes it(Function1, Async, Async), which I confirmed withjavapbefore backing it out. As shipped, the constructor is byte-identical to before andspillDir()is purely additive. (No MiMa in CI, so that's a manual guarantee — see follow-ups.)Not a
WriterConfigfield, even though the config is already threaded to both spill sites and would have been the smaller diff.WriterConfigdescribes the document's shape — SST policy, compression, XML backend, escaping — and is also consumed by the in-memory writer, which never spills. A filesystem scratch location is the interpreter's business, not the format's.Per-instance rather than per-call. Instances are cheap, so a call site that needs its own directory builds one and leaves the rest of the program on the default — one mechanism covering both.
Two bugs found on the way
java.io.tmpdirfails on sheet 1 with nothing to clean up, whereas a caller-supplied directory adds ENOSPC, quota and permission failures that land on sheet 3 of 5, in the user's own directory. Acquire now unwinds what it took, suppressing any delete failure onto the original cause rather than replacing it.createSpillFileread the overridablespillDirtwice — once to create, once to build the message. Snapshotted once, with the arity now stated in the scaladoc.What this buys the tests
The cleanup assertions now point the writer at a directory inside the test's own fixture, so they are exact: no name filter, no settle loop, no residual flake window. The positive control survives in sharper form — with an isolated directory, "no leftovers" would also hold if the writer ignored the setting, so each test proves the spill appeared there first.
One test deliberately still watches
java.io.tmpdir, pinning that the default is unchanged. It's positive-only: a foreign spill can satisfy it, never break it.Verification
Each fault was injected into production code, observed, then reverted:
createSpillFileignores its argumentxl-stream-1-*.xml,xl-stream-2-*.xml)withSpillDirrebuilt from a fresh instancewarningHandlerRelease gates, all green:
./mill __.test,ScalafmtModule/checkFormatAll,scripts/test-examples.sh,scripts/verify-skill-snippets.sh --local.Also in this PR
The two hygiene items filed on #514: sibling fixtures move off the
xl-stream-namespace (xl-fixture-sst-etc.) so the spill pattern is unambiguous, andremapWorksheetEntrywrites into the fixture directory instead of stranding a file in the shared one when a run ends abruptly.Docs: the performance guide gains a "where the scratch file goes" note under row-stream writes, including that redirecting the spill makes the directory's permissions the caller's business (the file itself is
rw-------either way). Test counts to 5,455 / xl-cats-effect 149.Follow-ups filed
XlsxWriter.writeToByteshas the same failure with no lever (pure module, no interpreter to carry the setting).--spill-dir. The CLI's global options thread through manymapNcombinators and an 8-parameterrun, so it doesn't belong here, butxl import --streampushes real volume through/tmp.🤖 Generated with Claude Code