-
Notifications
You must be signed in to change notification settings - Fork 535
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
Maybe force sequencing of finalizers, on cancelation #267
Comments
My use-caseThis is just based on my intuition, would love to know if there's a better way of doing this. Say I have a database connection: and an ETL process that depends on it (say, it reads from Kafka and writes messages to the database): So my program goes like this (untested, could be completely wrong :-)): for {
etlFiber <- (for {
conn <- connection
_ <- etl(conn)
_ <- IO.never
} yield ()).use(_ => IO.unit).start
_ <- installShutdownHandler(etlFiber)
_ <- etlFiber.join
} yield ()
Now, when cancelling the ETL fiber, I'd like the ETL to be closed before closing the connection. Is there a better way to do this assuming finalisers are concurrent? I.e., to use external synchronization? I don't exactly understand how to do this since each Regarding the sequencing cons
|
This is because cancellation isn't finalization and expecting it to magically work as intended is leaky; it's a leaky abstraction:
Consider this sample: def unsafeReadAll(in: BufferedReader): String = {
val builder = new StringBuilder
var continue = true
do {
val line = in.readLine()
if (line != null) builder.append(line)
else continue = false
} while (continue)
builder.toString
}
IO(new BufferedReader(???)).bracket { in =>
IO(unsafeReadAll(in))
} { in =>
IO(in.close())
} On cancellation this piece of code will trigger But then what if we do this: def readFile(file: File): IO[String] =
IO.async { cb =>
ec.execute(() => cb(Right(unsafeReadAll(???))))
} Is there any way to actually cancel this task? The answer is no. You can pretend that you can cancel it in a race condition by closing the "gate" and ignoring its result, but that loop will keep on going for as long as it wants to. Even if the result are discarded afterwards, its side effects will last forever. Of course you can pull off magic™ and for example on top of the JVM you could do a
I mean, consider what happens when a web server has to cancel a database query because the database server isn't responding, a situation that is precisely the reason for why you want cancellation being baked in a system — having to wait on acknowledgement that the database received and processed the cancellation request or even that the TCP connection was actually closed is nuts and has been the source of much pain and suffering. I've had nights lost due to |
Completely agree with your points - the fact that cancellation is a thing in cats-effect should not lead users to believe that everything is magically cancellable. This is something that stumped me when Scalaz 8 was first announced: I didn't understand how a monolithic block of code like your loop could become magically cancellable. But this could be addressed in docs, no? Something along the lines of This is orthogonal to the issue you raised though: should finalization in normal conditions and finalisation under cancellation behave the same? I think this is what we are discussing; not the cancellation logic itself. If they don't behave the same, I find it hard to see how I can write proper finalisers in |
To be honest I'm leaning towards sequencing finalizers on cancellation, however we are going against prior art and that's not a good situation to be in. But yes, And this can be a problem for
Well that's not a good description either. You can supply cancellation logic that does the right thing, for example that loop can cooperate with the cancel signal: def readAll(in: BufferedReader, ec: ExecutionContext): IO[String] =
IO.cancelable { cb =>
// For cooperative synchronisation
val isCanceled = new AtomicBoolean
ec.execute { () =>
val builder = new StringBuilder
try {
var continue = true
do {
val line = in.readLine()
if (line != null) builder.append(line)
else continue = false
} while (continue && isActive.get)
if (isActive.getAndSet(false))
cb(Right(builder.toString))
}
catch {
case NonFatal(e) => cb(Left(e))
}
finally {
in.close()
}
}
IO(isCanceled.set(false))
} An interesting exercise would be to rewrite this in terms of I would go on a tangent here about how (what I named) the "continual" model (e.g. #262) is saner in this regard, but I'll refrain myself 🙂 |
Right, there’s the cancellable builder too. The main reason sequencing finalizers makes sense to me is consistency; I want the code to be run the same whether the bracketed IO program finishes successfully, errors out or is cancelled. This is the gist of my argument. /steps off the bucket/ |
In composing nested Scalaz 8 brackets,
All of these guarantees hold whether a fiber is terminated due to typed error, defect, or interruption. In other words, interruption has no effect on the linearization or bullet-proofing of finalizers. I'm disturbed that cats-effect does not codify (1) - (3) in laws, as they are extremely important to building leak-free software. If the implementations do not provide these guarantees, they should be fixed as quickly as possible to conform with the Scalaz 8 model. |
@jdegoes that's good to know, but I tested your implementation and it has the same behavior, as described by this issue.
In case of interruption, that's not true. Is that a bug or was that intended? |
They definitely execute linearly, not concurrently, but there could be a bug in the ordering. Do you have the code used to reproduce this? |
Ah, you're right, so you are sequencing the execution of finalisers. Except that I found a bug, some sort of race condition. Sample 1 — in which the inner finalizer never gets executed: val task = IO.unit[Void].bracket[Unit] { _ =>
IO.sync[Void, Unit](println("start 1")) *> IO.sleep(1.second) *> IO.sync(println("release 1"))
} { _ =>
IO.unit[Void].bracket[Unit] { _ =>
IO.sync[Void, Unit](println("start 2")) *> IO.sleep(1.second) *> IO.sync(println("release 2"))
} { _ =>
IO.never[Void, Unit]
}
}
task.fork.flatMap(f => f.interrupt(new RuntimeException("cancel")) *> IO.never) This fails with:
Sample 2 — the first specified finalizer gets executed twice somehow, which is really odd: IO.unit[Void]
.bracket[Unit](_ => IO.sync[Void, Unit](println("start 1")) *> IO.sleep(1.second) *> IO.sync(println("release 1")))(_ => IO.unit[Void])
.bracket[ExitStatus](_ => IO.sync[Void, Unit](println("start 2")) *> IO.sleep(1.second) *> IO.sync(println("release 2")))(_ => IO.sync(ExitStatus.ExitNow(0)))
.fork.flatMap(f => f.interrupt(new RuntimeException("cancel")) *> IO.never) Output:
And this behavior is what made me jump to conclusions. |
@jdegoes btw, thanks for the clarifications — I'm now more confident that sequencing stacked finalizers on cancellation is probably the way to go. |
@alexandru Thanks for the bug report. I'll write this up in EDIT: TBI here. |
My two cents:
I think this makes sense on nested brackets as it'll be consistent with
That's a tricky one. I'm not sure whether this is still handled ( Another question: Is the |
We don’t do async exceptions @gvolpe, that’s a Haskell-ism, but it refers to cancellation and yes we’ve got laws for it, the release being both uncancelable and resilient to errors. |
If there are no further objections, I think it's settled — we should sequence finalizers on cancellation. This change is of medium difficulty — we need to change our internal |
We should probably add a law, not sure how we can express it, because we'd need to test that:
We can probably do it through a combination of latches (aka |
Fix #267: back-pressure cancelation tokens in IO
In case we leave this to run without cancellation, the output would be:
Cancellation on the other hand is concurrent and what happens right now is that the finalizers get triggered in order, but without sequencing forced, so in case of async finalizers (which we are forcing here by usage of
sleep
), we can get a non-deterministic result, like this:Or this:
This can cause problems for example in
IOApp
. Due to our implementation, as a user reported, this doesn't work as one might expect:So when the user interrupts this app (via
SIGINT
orSIGABORT
) that finalizer doesn't get a chance to run, because there's no back-pressuring between it and the app's shutdown hook. And that's because finalizers aren't sequenced on cancellation.The questions are:
IOApp
sample above?Why Aren't Finalizers Sequenced On Cancellation?
The builder for cancellable IOs, which sort of reflects the internal ADT, is this:
However that
IO[Unit]
in that function is a replacement for() => Unit
and isn't back-pressured on cancellation. The reason is two fold:bracket(acquire1) { _ => bracket(acquire2)(use2)(release2) } (release1)
If
release1
depends onrelease2
, then on cancellation you need some sort of synchronization anyway for the impure, side effectful parts being suspended, so it wouldn't be a stretch to suggest that such synchronization should also happen for sequencing the finalizers when needed.Pro-Sequencing of Finalizers on Cancellation
The mental model users have is that of
flatMap
so it makes sense for finalizers to be sequenced on cancellation as well.@rossabaker built
IOApp
and he wasn't aware that hisIOApp
will not work for this use-case. And on reviewing his code, I also did not see a problem. So this means the current behavior can lead to unexpected behavior even when used by experienced developers.It would also be consistent with how
try/finally
behaves, even in the face ofInterruptedException
, although that's not a very good argument to be honestAnti-Sequencing of Finalizers on Cancellation
As far as I'm aware, we'd have the only implementation that does this.
The issues that come up are these ...
Case 1 — the inner
release
blocks the execution of the outer release indefinitely and this can lead to unintended leakage, because in a race condition the logic won't wait and won't care ifrelease1
is executed in order to proceed, this being the main problem with back-pressuring on an acknowledgement of interruption in network protocols, see arguments above:Case 2 — the inner
release
triggers an error:This isn't problematic for our implementation, but it is problematic for other implementations. Specifically the way Scalaz 8 implemented errors finalizers are not allowed to throw errors and when they do, they are caught by the Fiber's error handling mechanism. If you sequence finalizers, that's going to be a problem, because such errors in Scalaz are blowing up the Fiber's run-loop AFAIK.
So what this means is that we cannot add this as a law in
Concurrent
, we cannot rely on it for polymorphic code.The question is, what are we going to do about
cats.effect.IO
? Should we make it behave like this, or should we fixIOApp
in some other way?Just to make it clear, we can make
IO
behave like that on finalization, the question being, do we want to?I'm conflicted on the right answer, I'm leaning towards making it safe, but safety in this case is in the eyes of the beholder 🙂
/cc @rossabaker @jdegoes @gvolpe @iravid — with the pro and anti arguments brought above, I would appreciate feedback
The text was updated successfully, but these errors were encountered: