merge: pull from upstream only when downstream has demand - #3754
Open
istreeter wants to merge 1 commit into
Open
merge: pull from upstream only when downstream has demand#3754istreeter wants to merge 1 commit into
istreeter wants to merge 1 commit into
Conversation
Release the per-side guard after the chunk is emitted rather than before, so each side pulls its next chunk in response to downstream's next uncons instead of as soon as the previous chunk was handed over. This restores the demand-driven behaviour that every other fs2 combinator has. Unlike the behaviour before typelevel#3610 the guard is not tied to scope closure, so the deadlock that typelevel#3610 fixed does not return. mergeAndAwaitDownstream is now an alias for merge, the two no longer being usefully distinguishable. Drops the documented equivalence with Stream(this, that).parJoinUnbounded.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Since #3610,
mergepulls a chunk from each side as soon as the previous chunk has been handed downstream, rather than waiting for downstream to ask for the next one. So both sides read one chunk ahead of demand.This moves the guard release to after the chunk, so each side pulls in response to downstream's next
uncons. The change is one line — the stream sent through merge's internal channel goes fromStream.exec(release) ++ chunktochunk ++ Stream.exec(release).Why
fs2 is pull-based.
map,filter,evalMap,flatMapandthroughall pull from upstream only when downstream wants an element.mergeis now the exception — and sincemergeis used internally byeither,mergeHaltBoth/L/R,observe,zipWithLatestandpull.timed, the read-ahead is inherited by combinators whose call sites give no hint of it.That costs real memory in our applications, where a single chunk can hold a substantial amount of data. Buffering an extra chunk per side, invisibly, takes away control we depend on.
The asymmetry is what bothers me most. Anyone can opt into read-ahead with
.prefetch, on exactly the stream they choose. There is no way to opt out of eagerness baked intomerge— you can't callmergeAndAwaitDownstreamfrom insideeither. Being pull-based is a strength of fs2, and I'd rather see the library lean into it than relax it combinator by combinator.This does mean dropping the documented equivalence with
Stream(this, that).parJoinUnbounded. I think that's the right trade, and I'd rathermergematched the rest of fs2 than matchedparJoin.This keeps the #3610 fix
#3610 fixed a genuine deadlock and this PR does not revert it. Previously the next pull waited for the scope of the previous chunk to close.
Scope#leasecovers ancestor scopes, andparJoinholds a lease for as long as each inner stream runs — so a non-terminating inner stream meant the release never ran.Appending
Stream.exec(release)avoids that: it is not a finalizer and opens no scope, just a suspended pull step, and the only thing that resumes it is anotheruncons— whichparJoinandparEvalMapboth do eagerly. Both tests added by #3610 (merge produces when concurrently handledandissue #3598) are unchanged and still pass.Evidence
The read-ahead was visible in assertions.
merge not emit aheadexpected every value twice —List(v, v, v+1, v+1, ...)— because the prefetch happened before the downstreamevalMaphad updated itsRef. It now expectsList(v, v+1, v+2).TimedPullsSuite's "After the first uncons, timeouts start immediately" reverts to its pre-#3610 form for the same reason.Full
coreJVM/testpasses (1903 tests), as doscalafmtCheckAllandmimaReportBinaryIssues.API
mergeAndAwaitDownstreambecomes an alias formerge. Withmergeno longer reading ahead, the only thing that distinguished them was waiting on scope closure — the deadlock. The signature is unchanged so this is binary compatible, and I have deliberately not deprecated it; that seems like a maintainer's call.That also makes #3663 unnecessary: those three
mergeAndAwaitDownstreamHalt*helpers exist to escape the eagerness, and there is nothing left to escape. #3655 may be related, but I should be upfront that I could not reproduce it either before or after this change.