-
Notifications
You must be signed in to change notification settings - Fork 607
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
Publisher.toStream can demand for more than one element #2666
Conversation
Context: https://discord.com/channels/632277896739946517/632310980449402880/894663878133317722 Asking for more than one element allows a much better performance. For example, when subscribing to a mongo publisher, we can use the opened cursor in much less queries.
605b55a
to
301a575
Compare
def apply[F[_]: Async, A]: F[StreamSubscriber[F, A]] = | ||
fsm[F, A].map(new StreamSubscriber(_)) | ||
apply(bufferSize = 10L) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This changes the previous behavior asking elements one by one.
It's also a magic number coming from a quick thinking about the trade-offs memory vs performance.
Should we handle that differently? Using 1L
to keep the previous behavior? Using some configuration?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What's the behavior if requesting 10 but there's only 5 available?
This does feel a bit too magical IMO -- I lean towards setting it to 1 or an arbitrary power of 2 :)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It only 5 are available, then we build a chunk of 5 elements, and it's being send in the OnComplete
step: https://github.com/typelevel/fs2/pull/2666/files#diff-493b2533a887032dfbf8ea09dca7201413e124b5f45732ad6a877b8d7f69ba2cR178-R180
By setting it to 1
by default can lead to bad performances by default. I'd suggest to take an arbitrary number, not too high to avoid buffering too many elements in memory. 16
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would strongly recommend not changing existing behavior in source-compatible ways
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yep agreed, let's stick with 1 here as that's what every has used for years.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
good point.
I see 2 possible options:
- option 1: we set the default buffer size to
1
. No surprise for existing users. Maybe surprise for new users who are struggling with bad performances. - option 2: we change the API to emit a compilation warning (see Publisher.toStream can demand for more than one element #2666 (comment)). Existing users will have a compilation warning, grabbing their attention on this particular change.
- 2.a) with compilation warning + keep existing behavior with a buffer size of
1
- 2.b) with compilation warning + using a buffer size of
16
- 2.a) with compilation warning + keep existing behavior with a buffer size of
My 2 cents from my experience as new user: I'd expect FS2 to be fast by default, and not slow by default unless I understand deeply the API and change it.
I'm not the maintainer of this lib. I let you decide.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
1 versus 10 versus 16 could make a performance difference in certain types of streams but not others, where folks would need 100 or 1000 or more. Hence, I think it's best if we make no assumptions at all. So let's go with deprecating the old API and forcing the buffer size decision on to users.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
4ad2bbb
to keep the existing behavior + deprecations
@yanns Thanks for the Pull Request! If the current integration with One small question, that may have been discussed but missed: how does the use of |
@@ -69,15 +69,19 @@ final class StreamSubscriber[F[_], A]( | |||
sub.onError(t) | |||
} | |||
|
|||
def stream(subscribe: F[Unit]): Stream[F, A] = sub.stream(subscribe) | |||
def streamChunk(subscribe: F[Unit]): Stream[F, Chunk[A]] = sub.stream(subscribe) | |||
def stream(subscribe: F[Unit]): Stream[F, A] = streamChunk(subscribe).unchunks |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Echoing @diesalbla's comment here -- I think we should offer just the stream(...): Stream[F, A]
method and let folks manually call .chunks
if they want to operate on chunks explicitly.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks great -- some minor comments
|
||
private def nonNull[B](b: B): Unit = if (b == null) throw new NullPointerException() | ||
} | ||
|
||
object StreamSubscriber { | ||
|
||
def apply[F[_]: Async, A](bufferSize: Long): F[StreamSubscriber[F, A]] = |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The bufferSize
should be an Int
here as Chunk
is Int
based.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
def apply[F[_]: Async, A]: F[StreamSubscriber[F, A]] = | ||
fsm[F, A].map(new StreamSubscriber(_)) | ||
apply(bufferSize = 10L) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What's the behavior if requesting 10 but there's only 5 available?
This does feel a bit too magical IMO -- I lean towards setting it to 1 or an arbitrary power of 2 :)
def stream(subscribe: F[Unit])(implicit ev: ApplicativeError[F, Throwable]): Stream[F, A] = | ||
def stream( | ||
subscribe: F[Unit] | ||
)(implicit ev: ApplicativeError[F, Throwable]): Stream[F, Chunk[A]] = |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Change return here to Stream[F, A]
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@@ -126,9 +134,10 @@ object StreamSubscriber { | |||
|
|||
sealed trait State | |||
case object Uninitialized extends State | |||
case class Idle(sub: Subscription) extends State | |||
case class Idle(sub: Subscription, buffer: Vector[A]) extends State |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Change Vector[A]
to Chunk[A]
and use ++
to build up the buffer.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
case class RequestBeforeSubscription(req: Out => Unit) extends State | ||
case class WaitingOnUpstream(sub: Subscription, elementRequest: Out => Unit) extends State | ||
case class WaitingOnUpstream(sub: Subscription, buffer: Vector[A], elementRequest: Out => Unit) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same here, change buffer to Chunk[A]
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
One question about backwards compatibility: for the moment, I've went with duplicating API like:
I could also go with extending the current API, and using default values like:
Calling |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🚀
@yanns That solution would be mostly source-compatible. However, we are also keen on binary compatibility, which is to say, the contents of the compiled bytecode across versions. The current version: // current API
def toStream[F[_]: Async]: Stream[F, A] =
fromPublisher(publisher) In the bytecode, we have a method def toStream[F[_]: Async](bufferSize: Int = StreamSubscriber.DefaultBufferSize): Stream[F, A] =
fromPublisher(publisher, bufferSize) is compiled in the bytecode to a single method with two parameters, not to two methods with or without the parameter. Defaults values are inserted, by the compiler, in the "caller" bytecode, as arguments for the two-parameter method. However, the existing method would then be missing in the new bytecode version. As a consequence, "callers" to that method, when linked to the new bytecode, would fail. Thus, I would recommend keeping the existing method, but add to it a deprecation notice. |
Deprecate API where the buffer size is not given.
@@ -57,6 +67,11 @@ package object reactivestreams { | |||
implicit final class PublisherOps[A](val publisher: Publisher[A]) extends AnyVal { | |||
|
|||
/** Creates a lazy stream from an `org.reactivestreams.Publisher` */ | |||
def toStream[F[_]: Async](bufferSize: Int): Stream[F, A] = |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@yanns we probably need some docs here explaining how buffer size affects performance and what value to choose.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What do you think of 357dbc0 ?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Perfect.
Can someone approve the workflow to check if all the tests pass? |
We now have a breaking change:
|
Can someone check if it's ok to exclude this: e618c8d ? |
@mpilquist I think all the points you mentioned have been addressed. Can you check again when you can? |
Are we sure that the mima exclusion can safely be suppressed? It doesn't look so to me. |
I confirmed this change is not binary compatible. Compiling this program against 3.1.4 and then running against this PR results in the following linkage error: package test
import fs2._, fs2.interop.reactivestreams._
import cats.effect._
object App extends IOApp.Simple {
def run = {
val upstream: Stream[IO, Int] = Stream(1, 2, 3).covary[IO]
val publisher: Resource[IO, StreamUnicastPublisher[IO, Int]] = upstream.toUnicastPublisher
val downstream: Stream[IO, Int] = Stream.resource(publisher).flatMap(_.toStream[IO])
downstream.foreach(IO.println).compile.drain
}
}
|
@mpilquist Thanks for having checked! I don't really understand why adding a new method with a same name breaks the binary compatibility. Is someone understands, I'd be glad to learn about it. I've removed the mima exclusion: b6e1847 If you have better suggestions, please feel free to comment. |
Definitely not a binary compatibility expert here, but the diffs in the bytecode https://editor.mergely.com/Pq7KTAXv/ |
Can someone trigger the workflow? |
@mpilquist the binary issue is now fixed. Can you review again? |
I've made a little talk about this PR: https://yanns.github.io/blog/2021/12/21/fs2-mongo-reactivestreams/ |
Context: https://discord.com/channels/632277896739946517/632310980449402880/894663878133317722
Asking for more than one element allows a much better performance.
For example, when subscribing to a mongo publisher, we can use the opened cursor in much less queries.
The change was tried with the following app: https://github.com/yanns/mongo-fs2/