Skip to content

Run a Kotlin result flow's query when it is collected, and read windows as one flow of rows - #590

Merged
zantvoort merged 5 commits into
mainfrom
fix/cold-result-flow
Sep 22, 2026
Merged

zantvoort merged 5 commits into
mainfrom
fix/cold-result-flow

Conversation

@zantvoort

Copy link
Copy Markdown
Collaborator

Description

A Kotlin resultFlow opened its result stream as the flow was built (resultStream.consumeAsFlow()), while its documentation said the flow was cold. Since 1.14.1 the stream guard refuses a statement on a connection whose stream still has rows, so building a second flow inside a transaction before the first was read to its end was refused, as was any other statement in between. Two repository flows passed as the arguments of one call, the natural way to hand reads to a pure function, failed on the second argument on every database.

Cold flows. resultFlow on the query builder, and resultFlow, getResultFlow and getRefFlow on a template query, go through one streamFlow { } helper: the statement runs when collection starts, rows are read as they are emitted, the statement closes when collection completes, fails or is cancelled, and each collection runs the query again. A flow that is built and not yet collected holds nothing, so several may be built up front and collected in turn, and a flow that is never collected leaves the connection free. windows is left as it was: it already fetches nothing until collected, and wrapping it would move its argument checks from call time to collect time. The Java Stream stays eager, as its Javadoc states and as JDBC libraries do; the caller closes it.

windows(size).rows(). A Flow<Window<R>> extension that emits the windows' rows as one flow. Every window's statement has closed before its rows are emitted, so the connection is free at every row, memory stays bounded by the window size and the rows arrive in key order. It is the one-token switch for a resultFlow loop the guard refuses; the guard's message names it, with the Java form windows(size).flatMap(Slice::stream). Where a loop writes, the per-window form still batches better, and the docs say so.

Javadoc. selectById(Stream) and selectByRef(Stream) on the repositories claimed to run their query when called; they run each batch's query as the stream reaches it, and none when it is never consumed. Twelve sites across core and java21 now say that.

Docs and skills. batch-streaming.md states the cold-flow consequences and both flat forms. A new docs/streaming-design.md under Internals records the reasoning: the driver matrix that makes the constraint a protocol fact, what other frameworks do, the options considered and rejected, why two explicit shapes with one rule everywhere, and why the flow is cold while the stream is not. The Kotlin and Java query and repository skills and storm-rules carry the cold flow and the flat forms.

Related Issue

None filed; found through a production failure in a downstream application (a reach-and-frequency computation handing studyUsers::findByStudy and studyExposures::findByStudy to one function inside transaction { }).

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Documentation update

Checklist

  • My code follows the project's code style
  • I have added tests that cover my changes
  • All new and existing tests pass
  • I have updated relevant documentation
  • My changes generate no new compiler warnings

Module(s) Affected

storm-kotlin (flows, rows()), storm-core (guard message, Javadoc), storm-java21 (Javadoc), docs, website skills and sidebar.

Breaking Changes

None in the API. Behaviour a caller may notice, recorded in the changelog's upgrade note: a query failure surfaces from collect rather than from the line that built the flow; a flow collected twice runs its query twice; a flow built inside a transaction but collected outside it reads outside the transaction, on a connection of its own.

Verification

  • New tests in FlowTest: two flows built inside a transaction and collected one after the other; a flow built and never collected leaves the connection free; a flow collected twice; rows() yields the ids in key order across windows; a count and a remove at every row of windows(5).rows() inside a transaction. In QueryDefaultsTest: the template query's three flows open no stream until collected, one per collection, and close it. The first three fail on the previous code with the guard's message or "can be collected only once".
  • Full storm-kotlin suite (1,700 before the rows() tests, 1,702 after) green; StreamGuardIntegrationTest, java21 RepositoryTest and SpringTransactionBridgeTest green after the message change.
  • generate-llms-full.sh drift check passes with the new page in the sidebar and the DOCS list.

…windows as one flow of rows

A resultFlow opened its result stream as the flow was built, so building a second flow, or issuing any statement, inside
a transaction before the first was read to its end was refused by the stream guard, which is how two flows passed as the
arguments of one call failed. The flow is now cold, as its documentation states: the statement runs when collection
starts, closes when collection completes, fails or is cancelled, and runs again on each collection.

windows(size).rows() reads the windows as one Flow<R> with the connection free at every row, the one-token switch for a
resultFlow loop the guard refuses; the guard names it beside the Java form windows(size).flatMap(Slice::stream). The
batched stream reads selectById(Stream) and selectByRef(Stream) document that no query runs until the stream is consumed.
The skills and batch-streaming page carry both, and a design-notes page records the reasoning behind the two shapes.
@zantvoort zantvoort added this to the 1.14.2 milestone Sep 22, 2026
@zantvoort
zantvoort merged commit 813082e into main Sep 22, 2026
9 checks passed
@zantvoort
zantvoort deleted the fix/cold-result-flow branch September 22, 2026 07:29
@codecov

codecov Bot commented Sep 22, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

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