typelevel / cats-effect

The pure asynchronous runtime for Scala
https://typelevel.org/cats-effect/
Apache License 2.0
1.99k stars 511 forks source link

`Resource#attempt` holds onto acquired resources even in case of error #3757

Open armanbilge opened 11 months ago

armanbilge commented 11 months ago
//> using dep org.typelevel::cats-effect::3.5.1

import cats.effect.*
import cats.syntax.all.*

object Bug extends IOApp.Simple:
  def run = IO.ref(0).flatMap { ref =>
    val resource = Resource.make(ref.update(_ + 1))(_ => ref.update(_ + 1))
    val error = Resource.raiseError[IO, Unit, Throwable](new Exception)

    val attempt = (resource *> error).attempt

    attempt.use { r =>
      ref.get.flatMap { i =>
        IO {
          // acquiring the resource as a whole failed, so it should be released in entirety
          assert(r.isLeft, r.toString)
          assert(i == 2, i.toString) // assertion failed: 1
        }
      }
    }
  }
durban commented 11 months ago

Hm, if it failed, why is the body of use called at all? But, I don't think it failed: it succeeded with Left(new Exception).

armanbilge commented 11 months ago

Yes you are exactly right. However although the resource acquisition failed (prior to the attempt) it has not run all the finalizers yet.

durban commented 11 months ago

So, you're basically saying, that since resource *> error failed, it should run the finalizer of resource even before continuing to the .attempt, right? I guess that makes sense, (even if it seems a little strange to me)...

armanbilge commented 11 months ago

even if it seems a little strange to me

why does that feel strange? I'm drawing a distinction between (resource *> error).attempt and resource *> error.attempt. Currently they behave the same.

durban commented 11 months ago

Yes, exactly: it seems strange to me that those two would be different. (To be clear, we're obviously assuming resource does not fail here.) Based on this comment by @djspiewak, it seems I might not be alone. At least I think that law would imply that they should be the same...

armanbilge commented 11 months ago

To be clear, we're obviously assuming resource does not fail here

👍

At least I think that law would imply that they should be the same...

Hmm, yes, I think I interpret that law the same way. Thanks for the pointer, let me think about this a bit 🤔

armanbilge commented 11 months ago

Since I haven't mentioned it yet, the problem with the current semantics is that if you try to implement some sort of retry then failed resources don't get released in a timely fashion.

For example, here's a rough sketch of a connection pool as used in Ember client or Skunk.

def getSocketFromPool: Resource[IO, Socket[IO]] = ???

def raiseIfConnectionIsDead(socket: Socket[IO]): IO[Unit] = ???

def getAliveSocketFromPool =
  getSocketFromPool.evalTap(raiseIfConnectionIsDead).attempt.flatMap {
    case Right(socket) => Resource.pure(socket)
    case Left(DeadConnection) => getAliveSocketFromPool // retry to obtain a fresh connection
    case Left(ex) => Resource.raiseError(ex) // raise all other errors
  }

Under the current semantics, every time getAliveSocketFromPool loops after the attempt it will keep requesting a new socket from the pool without returning the dead one (so that the pool can replace it). This is problematic because in practice there are limits (e.g. the number of open file descriptors or connections in the pool per-host) so the retry may get stuck because each iteration only takes from the pool and does not return anything back to the pool.

A similar bug plagued us in https://github.com/http4s/http4s/issues/7216#issuecomment-1638353193, although note Resource#attempt was not being used there.

SystemFw commented 10 months ago

Format is ugly, but this is relevant context:

https://discord.com/channels/632277896739946517/839263556754472990/1141812346877132860

SystemFw — Today at 21:14 maybe I'm weird, and I get that retry will be a pain, but it behaves like I expect it

armanbilge — Today at 21:14 really? daniel — Today at 21:15 that's absolutely fascinating

armanbilge — Today at 21:15 the fact that its holding onto resources that you can no longer access seems really wrong to me

SystemFw — Today at 21:15 it's not holding onto them though

daniel — Today at 21:15 so fwiw, this is a major way in which fa.attempt *> fb is different from fa !> fb

armanbilge — Today at 21:15 by "holding on" I mean "not finalizing"

SystemFw — Today at 21:15 meaning, Resource has no concept of "no longer have access" that's defined by exiting it, with use

armanbilge — Today at 21:16 I forget what !> is

SystemFw — Today at 21:16 as far as Resource is concerned, all resources are open except for !>, which closes a scope

daniel — Today at 21:16 it's forceR. a bit like productR except it discards all errors recursively on the left and can behave asymmetrically with ap, and Resource interprets it to close scopes that scope-closing behavior isn't arbitrary either, iirc

armanbilge — Today at 21:17 hmmm

daniel — Today at 21:17 like I think it's wound up with MonadCancel semantics in some odd way that I can't recall so basically retry is fine as long as you use !> instead of trying to .attempt your way to victory

armanbilge — Today at 21:17 but with !> you can't pass the result value

SystemFw — Today at 21:18 let me put it this way: there is no early finalisation in Resource it boils down to that

daniel — Today at 21:18 (apart from !>) so basically for retry, you have to run your step and then see if you got errors if you didn't get errors, lovely! return if you did get errors, forceR the recursion

armanbilge — Today at 21:19 interesting

daniel — Today at 21:19 it's that recursive step that you need to cover. instead of using *> there, use !>

armanbilge — Today at 21:19 yeah I see, hmm

SystemFw — Today at 21:19 iirc we were discussing Stream.++ and scoping and this popped up

daniel — Today at 21:19 I love the ++ vs >> dichotomy on this one

armanbilge — Today at 21:19 oooh

daniel — Today at 21:19 with Stream it's a bit more intuitive though because you have flatMap vs append

armanbilge — Today at 21:20 yeah

daniel — Today at 21:20 Resource is harder because it's all flatMap

armanbilge — Today at 21:20 I need to thonk it but I can see the parallel yeah, yeah, right that helps

daniel — Today at 21:20 !> is a bit like .drain ++

SystemFw — Today at 21:20 it's even closer if you also think about Stream and Pull

daniel — Today at 21:20 yep

SystemFw — Today at 21:20 ++ is Pull.flatMap

armanbilge — Today at 21:20 I'm not fully convinced this will help but I need to try it

SystemFw — Today at 21:20 but Stream has to discard the Pull output to encode things and similarly, you have !> but not !>>=

daniel — Today at 21:21 the day I realized this was one of the happiest Scala-related days I've ever had. the duality is so obvious and so smart Pull is just stupidly clever

SystemFw — Today at 21:21 and how we have to copy all this to github 🙂 😦

daniel — Today at 21:21 lmao yeah this is all very important context

armanbilge commented 10 months ago

Yes, exactly: it seems strange to me that those two would be different. (To be clear, we're obviously assuming resource does not fail here.) Based on this comment by @djspiewak, it seems I might not be alone. At least I think that law would imply that they should be the same...

Just playing around, seems this law is not held for other transformer implementations e.g. WriterT.

//> using dep org.typelevel::cats-core::2.10.0
//> using option -Ykind-projector:underscores

import cats.*
import cats.data.*
import cats.syntax.all.*

@main def main =
  val write = WriterT.tell[Option, String]("hello")
  val error = (()).raiseError[WriterT[Option, String, _], Unit]

  println((write *> error).attempt.run)
  println((write *> error.attempt).run)
Some((,Left(())))
Some((hello,Left(())))