Skip to content
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

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

Closed
armanbilge opened this issue Jul 18, 2023 · 15 comments
Closed
Assignees
Labels
Milestone

Comments

@armanbilge
Copy link
Member

//> 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
Copy link
Contributor

durban commented Jul 18, 2023

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
Copy link
Member Author

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

@durban
Copy link
Contributor

durban commented Jul 19, 2023

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
Copy link
Member Author

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
Copy link
Contributor

durban commented Jul 19, 2023

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
Copy link
Member Author

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
Copy link
Member Author

armanbilge commented Jul 20, 2023

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 http4s/http4s#7216 (comment), although note Resource#attempt was not being used there.

@SystemFw
Copy link
Contributor

SystemFw commented Aug 17, 2023

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
Copy link
Member Author

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(())))

@lenguyenthanh
Copy link
Member

lenguyenthanh commented Aug 22, 2024

I'm curious, what is the conclusion of this? Following the discussion here, it is lawful and even painful but nothing We can do. But in http4s/http4s#7445, @armanbilge said We should fix it for 3.6.0 🤔

Edit: fixed typo

@armanbilge
Copy link
Member Author

@lenguyenthanh thanks for following up. @djspiewak and I discussed this more at some point and I think that Daniel came around and supports changing the behavior to fix this.

Following the discussion here, it is lawful and even painful but nothing We can do.

When we talk about lawfulness, we need some notion of equivalence between two effectual computations. If I remember correctly, what Daniel and I discussed was how to consider Resource's finalizers when determining equivalence. In this case, if you don't consider when the finalizer runs, then we are not violating lawfulness by changing attempt as I proposed here.

This would not be the first time that we adjusted when Resource's finalizers run, also needed to change memoize.

@lenguyenthanh
Copy link
Member

thanks @armanbilge for the explanation! I'll try to see what I can do.

@lenguyenthanh
Copy link
Member

I've been looking at the code, but up until now I couldn't find a solution for this.

Look at this code:

case Allocate(resource) =>
Resource.applyFull { poll =>
resource(poll).attempt.map {
case Left(error) => (Left(error), (_: ExitCase) => F.unit)
case Right((a, release)) => (Right(a), release)
}
}

To fix this issue, We need to execute the finalizer in the left case. But with the attempt, We don't have access to the finalizer, so We need to do something else like Resource.allocatedCase, but then F has to be a MonadCancel, which We don't have here.

I probably missing something here, I don't know how to proceed. Any pointer is much appreciated.

@armanbilge
Copy link
Member Author

@lenguyenthanh that's all exactly right actually 💯

I probably missing something here, I don't know how to proceed.

You can go ahead and add the MonadCancel constraint (in fact, I think it has to be MonadCancelThrow). Meanwhile, we will keep the old version of the method and deprecate it, similar to the onError PR.

@lenguyenthanh
Copy link
Member

@armanbilge here is my naive implementation, couldn't make it work with just attempt because the ambiguity.
Could you please give some more help 🙏

  // Can't use `attempt` function here as it would cause ambiguity with the other `attempt`
  def safeAttempt(implicit F: Concurrent[F]): Resource[F, Either[Throwable, A]] = {
    // We need a Ref[Option[Resource.ExitCase => F[Unit]]] because We want to release
    // all on hold resources in case of error
    Resource.eval(F.ref(none[ExitCase => F[Unit]]).flatMap { release =>
      this
        .allocatedCase
        .flatMap { case (a, r) => release.update(_ => r.some).as(a) }
        .attempt
        .flatMap {
          case Left(error) =>
            release.get.flatMap(_.traverse_(_(ExitCase.Errored(error)))).as(error.asLeft[A])
          case Right(a) => a.asRight[Throwable].pure[F]
        }
    })
  }

lenguyenthanh added a commit to lenguyenthanh/cats-effect that referenced this issue Sep 2, 2024
Which releases acquired resource(s) in case of error and `MonadCancelThrow`
constrain is required in order to do that. This also take away implicit
`ApplicativeError` and deprecated the current attempt method.
lenguyenthanh added a commit to lenguyenthanh/cats-effect that referenced this issue Sep 2, 2024
Which releases acquired resource(s) in case of error and `MonadCancelThrow`
constrain is required in order to do that. This also take away implicit
`ApplicativeError` and deprecated the current attempt method.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

5 participants