-
Notifications
You must be signed in to change notification settings - Fork 1.9k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Iterative consumption of flows #3274
Comments
Continuing conversation from #3278 (comment). We don't mind if it's slowI've mentioned this in other contexts, but perhaps not when you were present, so let me say this as the coroutines lead on Google's internal Kotlin team: we consider performance to be our lowest priority for coroutines, behind correctness/ease of writing correct code, clear API design, and testability. We think this API has advantages for all three of those. We're not using flows (or coroutines) for the performance, we're using them because they're a clear and clean API. The 30x slowdown you estimate is a little on the high side for us, but only a little; we would be excited to use this API even if it represented a 30x slowdown, even in production, even if it's only to make code easier to follow. Coroutines just aren't a bottleneck, or even within 30x of being a bottleneck; outages and bugs are much more expensive. Test use casesThe canonical example of the test case looks something like this. Here's a very simple test: // with Truth, other assertion libraries will presumably be similar
assertThat(stub.rpc(flowOf(1,2,3)).toList()).containsExactly(2, 4, 5, 6).inOrder() This is great. But what if we want to assert that the RPC doesn't wait until the requests flow completes, but emits responses as we go? val requests = SharedFlow()
stub.rpc(requests.take(3)).iterate {
// this assumes that hasNext is not required as with ChannelIterator.
requests.emit(1)
assertThat(next()).isEqualTo(2)
requests.emit(2)
assertThat(next()).isEqualTo(4)
assertThat(next()).isEqualTo(5)
requests.emit(3)
assertThat(next()).isEqualTo(6)
assertThat(hasNext()).isFalse()
} This makes different assertions at each request, which is a poor match for push-based APIs. I can imagine several push-based approaches to this problem, but they all separate stimulus and response, making the test harder to write, read, and debug. Most streaming RPCs need tests like this, and we have a lot of those. https://github.com/cashapp/turbine is a preexisting library by @JakeWharton that addressed the specific use case of testing in this style; its README also discusses use cases in more detail. We've been hesitant to fully adopt Turbine because it dallies with UNDISPATCHED, which we've seen lead to impossible-to-debug issues, but its API is great for testing flows -- but a more general operator applicable to production would be even better. I'm sure Jake could comment more, but he did thumbs-up the proposal above. Flow operators and production use casesFor the production use case, fun Flow<T>.pairElements() = flow {
var even = true
lateinit var prev: T
collect {
if (even) {
prev = it
} else {
emit(Pair(prev, it))
}
even = !even
}
} This is a very simple state machine, but it's much clearer to write fun Flow<T>.pairElements() = flow {
iterate {
while (hasNext()) {
emit(Pair(next(), next()))
}
}
} Coroutines are all about capturing state machines from program control flow, and they do a great job here. The use case being simplified is parsing streams of bytes from a Tests for this use case also ran the hard way into the race condition in channel-based solutions, parsing byte streams interrupted by exceptions. Debugging
A clever dispatcher doesn't solve this problem, though! #3278 (comment) shows the control flow diagrams that are the root of the issue, which is that two separate coroutines are both eligible to run at the same point in the program, as far as any dispatcher is concerned, but we don't want one of them to run (yet) when debugging -- and the dispatcher API means that it can't possibly know which one we want. A fully sequential, single-threaded dispatcher prevents "races" in the sense of JVM races, but doesn't solve the "race" in the sense that the dispatcher chooses which of two eligible coroutines get resumed first, and debugging requires the dispatcher to make a specific choice there that a dispatcher can't guess. The fix is to prevent the race, where the only coroutine eligible to run is the right one to step forward, and that means something more like |
(To clarify, in the "parsing streams of bytes" use case, we are returning a |
Hi, I'm still processing your input and trying to internalize all the things in order to further weigh the pros and cons of the solution. Let's discuss individual details in order to be on the same page.
It's good to hear that, meaning that we have a bit more space in our design. But it's important to remember that we have plenty of other consumers, including, but not limited to, Android developers, server-side developers, high-performance web-frameworks, high-throughput backend apps and IDEs. Having two similar APIs that, at the first glance, differs only in code-style preferences, but one being 30 times slower, will bring irreversible damage to the ecosystem in the long term, especially when both pull and push based APIs are equally appealing.
This is an example of what can bring potential harm. One can implement it via Would it be okay for you to semi-finish the PR? It is a decent first peek at the API shape, but unfortunately, it doesn't compile and doesn't have any tests. It would be really nice to see API and tests that you already have written to give us a taste of how you are leveraging it. There are some examples, but they have their own pitfalls. Real-world use-cases are always better :)
This particular example looks really concise, works and passes basic testing. As long as the original flow emits an even number of elements, otherwise, it throws
Thanks for the control flow diagram, it really made the picture clear. I've immediately started thinking about more intrusive
Could you please elaborate on that and show a few examples of such issues? I'm looking forward to seeing more examples (I suppose the parsing is the most interesting and hard-to-emulate one) to see if there are ways to improve the API and get around potential drawbacks |
Do you mean |
I don't have specific issues I can refer to about Unconfined or UNDISPATCHED, though both make us extremely nervous. Certainly in Java land we've had lots of issues caused by directExecutor. We specifically haven't seen any such issues with Turbine, but then, we have it on a tight allowlist just because we don't trust that there aren't issues with Unconfined and UNDISPATCHED too subtle for us to notice. Similarly, we would be unhappy about more intrusive primitives and their edge cases. We care about performance, to be clear, but we think race conditions in common patterns that are subtle enough that they're not obvious to you or Roman at first glance are pretty damaging to the ecosystem themselves. I've committed a working version with some of our tests to the PR, though it doesn't have as many tests as I'd like yet. I've also modified it to avoid the use of channels, instead just passing continuations back and forth. I'm less confident in its correctness, though. (Possibly an advantage of the API in the current PR: it can't actually be for-eached over, which makes it more likely to be used in iterator-specific ways?) |
Turbines use of unconfined is an optimization to avoid double dispatch similar to one of your goals here. It isn't strictly required, and runTest seems to have broken its behavior so probably worth removing. |
It's not just Turbine-type interactions that have need to "pull" consumption. We also need it for interaction with fakes. Internally at Cash, we've been attacking this problem by using a "pull" consumption tool that Kotlin already has: We facade class AwaitChannel<T> {
private val channel = Channel<T>(capacity = UNLIMITED)
fun add(value: T) { channel.trySend(value) }
val isEmpty: Boolean get() { return channel.isEmpty }
/**
* Wait for a value on this channel for 1s, throwing a [CancellationException] if none arrives.
*/
suspend fun <T> awaitValue() = try {
withTimeout(1000L) {
channel.receive()
}
}
/**
* Return a value, or null if empty.
*
* To be used from legacy, non-suspendful call sites. Will throw
* when called from a non-suspending context.
*/
fun <T> takeValue(): T? {
assertCallingContextIsNotSuspended()
return channel.tryReceive().getOrNull()
} This is essentially the same thing Turbine provides, but in a form that works in our Fakes. I think the "it's just a channel" idea can work in Turbine, too. I did some homework on this last week, and haven't yet found a major roadblock. |
Does that approach address the race issue discussed in the PR here? |
We have only been concerned with races in test. Test has firm expectations of consistency: many immaterial changes in order may occur in multithreaded prod without harm, but immaterial changes in ordering under test will result in flaky test failures. Since races are introduced by threading, not coroutines, we police dispatchers aggressively:
|
There are plenty of races that can happen as a result of coroutines and not threading! #3278 (comment) is an example of code that nondeterministically throws or succeeds, even run on a single-threaded dispatcher. |
I've stewed on this claim for a few weeks, and reviewing the discussion I think that it doesn't really hit at anything I really care about. And that is because I truly care about actual nondeterminism: identical code that yields different results on different invocations. The following paragraph I think makes the different issues pretty clear:
So we have two issues: JVM races, and dispatcher races. I agree that these are both problems: as a programmer writing a test, I care quite a bit about dispatcher races. Dispatcher races make it difficult to understand what the outcome of my test code will be, and that makes test writing very finicky. But: that is a different order of problem from a JVM race. As someone who tends to get pinged about test issues in my organization, I don't generally get out of bed for a dispatcher race, because it is a problem localized to that engineer. If the engineer is experiencing a JVM race, on the other hand, that is a major issue. JVM races can expose every single engineer in the team to flaky CI tests. That's when I zoom in and apply the three rules given above to root cause the problem. So: our language is letting us down here. If I say "race", I may be referring to a dispatcher race (which is a conceptual race), or a JVM race (which is an actual race). I admit to using "race" myself interchangeably for these two very ideas. It's definitely a cause of discord: anytime you say "race condition," engineers run screaming. And they should! But maybe sometimes faster than others. |
I agree that these are two different sorts of problem, but I'm not actually convinced of your relative ordering of them. Here are some counterpoints I'd like to make:
But finally, I think it comes down to the issue that a developer should be able to predict the deterministic behavior of this code in advance. There's no good reason for it to be hard to predict. Worse, it looks predictable and isn't. |
Those arguments make sense to me. But even if I grant all of them, is the conclusion (dispatcher races are dangerous) actionable? Even with the proposed E.g. consider an operation like fun <T> combine(sourceFlows: List<Flow<T>) = flow {
val items = Channel<T>()
coroutineScope {
sourceFlows.forEach {
launch { it.collect { item -> items.send(item) } }
}
items.consumeEach { emit(it) }
}
} For this flow, the order of items emitted depends on the order in which the How do we get around that? A tool like Here's my POV: there is no silver bullet to make any coroutines code conceptually "deterministic," because there's none for concurrent code in general. Programming coroutines code necessarily means programming concurrently, and thus programming in a world where dispatchers will race. This is why the whole coroutines/channels/jobs toolkit exists: to provide a toolset for connecting concurrent processes with one another that can be reasoned about, even in the presence of actual nondeterminism. But actual nondeterminism is a huge problem under test. (I know; I've tried it, intrepid fool that I am.) We have to rely on the JVM determinism of a single threaded dispatcher to patch things over that last bit, and eat the issues with coroutines upgrades. (We're a lot smaller than you; I shudder to think of the pain you're experiencing there) So my approach has been to say, "Coroutines tests are just coroutines code. That means they're concurrent. That means learning new techniques to govern the concurrent flow of data and execution in your tests (e.g. use channels to build fakes that you can interact with, and don't use shared state to communicate with test code). It also means abiding by a few rules about dispatchers to avoid flakiness on CI. Do this, and you'll find yourself more fluent in your test code and in prod, since you'll be using the same basic toolset in both places." I've personally been pretty happy with the result from a TDD perspective. I'd love to see an even better alternative, of course, but until that happens this has been quite effective for us. |
I agree that there's no way to make any coroutines code "conceptually deterministic," but I certainly claim that you can have deterministic tests for many coroutine tests, especially at the size of unit tests, and that where we can build deterministic tests it's good to do so. The code you've given pretty much reinvents I'm really thinking about the specific case of testing simple streaming RPCs. That's a small piece that can be deterministic, that we need to test all over the place -- and it's difficult to test deterministically today. I would bet that "streaming RPC tests that could be deterministically tested" are at least a single-digit percentage of all |
It looks to me like the pull-based model can be easily circumvented even by accident, as the rest of the facilities are not prepared for such use. The example above of Let's say we have a flow that attempts to emit numbers from 0 to 9, but if the consumer is too slow, it drops the old values. val someFlow: Flow<Int> = flow {
repeat(10) {
emit(it)
}
}.buffer(onBufferOverflow = BufferOverflow.DROP_OLDEST) Let's try testing it with the proposed API: @Test
fun x() = runBlocking {
val collected = mutableListOf<Int>()
someFlow.iterate {
while (hasNext()) {
collected.add(next())
}
}
assertEquals((0..9).toList(), collected)
} Doesn't work, Let's try adding a val someFlow: Flow<Int> = flow {
repeat(10) {
emit(it)
yield()
}
}.buffer(onBufferOverflow = BufferOverflow.DROP_OLDEST) Suddenly, Ok, let's run the collection procedure in val collected = mutableListOf<Int>()
withContext(Dispatchers.Unconfined) {
someFlow.iterate {
while (hasNext()) {
collected.add(next())
}
}
}
assertEquals((0..9).toList(), collected) Doesn't change a thing, the results are still the same, because Naturally, if val someFlow: Flow<Int> = flow {
repeat(10) {
emit(it)
if (Random.nextInt() % 2 == 0) yield()
}
}.buffer(onBufferOverflow = BufferOverflow.DROP_OLDEST)
When I ran it, I got I'd argue that this case is quite simple and practical: I've seen the logic of dropping old values used for sending cumulative statistics where the new emission supersedes the old ones, or for processing rapid clicks so that an action is only fired once if a button was pressed several times in quick succession, etc. |
This is the product of some library design work we've been doing at Google, and we wanted to take this upstream in case there was interest. The
Flow
API is almost exclusively "push"-based, not "pull" based. The main alternative approach is to adapt it to aChannel
, but that often introduces inconvenient concurrency issues (arbitrary buffering, the risk of forgetting to cancel the channel).What we want is more or less a scoped iteration method. Here is a sketch of some examples that we think would be better with the proposed API than what
Flow
can do today.In short, what we're proposing is
It's not precisely clear what the
??Iterator
should be: we might reuseChannelIterator
, but we're not particularly fond of the requirement to callhasNext()
in the test case especially, and of course this isn't actually aChannel
. For ourselves, we are inclined to introduce a separateFlowIterator
type.We have an implementation that does not introduce undue concurrency. To be a little more specific, it does not continue the backing
Flow.collect
until the the next call tohasNext
, etc.; it's a linear transformation in the same way asmap
andfilter
but notbuffer
orconflate
. We can PR it if there's interest.The text was updated successfully, but these errors were encountered: