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

Move uncancelable down to Bracket. Add laws specifying interaction. #241

Merged
merged 10 commits into from
May 28, 2018

Conversation

oleg-py
Copy link
Contributor

@oleg-py oleg-py commented May 23, 2018

A little cherry-pick from discussions in #192: since we require uninterruptible acquisition and cleanup, uncancelable can be described in terms of Bracket.

The rationale for pushing it to typeclass is two-fold:

Additionally, I added ensuring as a helper method I've found myself reinventing few times, and IIRC @alexandru mentioned it should probably be added :)

I had to remove the uncancelable on Concurrent (causes VerifyErrors for IO instance), so I moved the doc to the class level.

There's also an oddity for Kleisli: looks like we assumed the function could be side-effecting, so we suspended the uncancelable. Not sure what to do with that :)

* }}}
*/
def uncancelable[A](task: F[A]): F[A] =
bracket(task)(pure)(_ => unit)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

due to this law:
https://github.com/typelevel/cats-effect/pull/241/files#diff-321005aff53c79253fc4a54b188dc3b4R30hh

And the monad law f.flatMap(pure(_)) == f, isn't this the same as identity?

What am I missing?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clicking on the link gets me nowhere :)
You're missing the interaction with data types that support cancellation, as they need to ensure that acquire and release cannot be cancelled

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

N.B. this is in the model that supports auto-cancelable flatMaps.

Basically if you have auto-cancelable flatMaps, there's no way to implement bracket without an acquire that's not uncancelable. It wouldn't work.

Consider that when acquiring a resource, you might do multiple flatMaps, but then the user cancels the tasks before bracket has a chance to install the finalizers. It's harder to end up in such a situation without auto-cancelable binds because as a user you can reason about how acquire behaves.

Copy link
Contributor Author

@oleg-py oleg-py May 24, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@alexandru are auto-cancelable binds required to trigger odd behavior? I thought it would be enough if acquire part contains a cancel boundary.

Copy link
Member

@alexandru alexandru May 24, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, auto-cancelable binds are required. Example:

IO(new FileReader(file)).bracket { f => 
  IO(f.read())
} { f => 
  IO(f.close())
}

You can reason about this code and you don't need to ensure that acquire is uncancelable because it already is. You can see that it is.

And in case acquire is cancelable, it's usually something that's safe to cancel. Stupid sample, but you can picture:

val acquire = IO.cancelable { cb =>
  val fh = new FileReader(file)
  ec.execute(() => cb(Right(f)))
  IO(fh.close())
}

// And then we don't care about the cancelability of acquire:
acquire.bracket(use)(release)

This mental model completely breaks down with auto-cancelable binds. Which is what I was ranting about in #192.

It starts from the implementation actually. It's not only desirable for acquire to be uncancelable. Rather, it's impossible to implement otherwise 😉

* F.uncancelable { F.bracketCase(F.unit)(_ => fa) { case Cancelled(_) => action; case _ => action2 } } <-> F.ensuring(fa)(action2)
* }}}
*/
def uncancelable[A](task: F[A]): F[A] =
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: I think we usually call these parameters fa. Makes them a bit less concrete.

* A special case of [[bracket]], which is not meant for resource acquisition
*/
def ensuring[A](task: F[A])(finalizer: F[Unit]): F[A] =
bracket(unit)(_ => task)(_ => finalizer)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 I've written this a couple times.

}) <-> F.uncancelable(F.ensuring(fa)(onFinish))

def acquireAndReleaseAreUncancelable[A, B](fa: F[A], use: A => F[B], release: A => F[Unit]) =
F.bracket(F.uncancelable(fa))(use)(a => F.uncancelable(release(a))) <-> F.bracket(fa)(use)(release)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we have canceled F[A] in our arbitraries to make this a more interesting law?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Canceled F[A]s cannot be made uncancelable :) We need F[A]s that might get concurrently cancelled or not, which is a plenty of work that probably should be done later.

* _ <- fiber.cancel
* _ <- fiber.join
* } yield {
* println("Tick!")

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe it should be _ <- F.delay(println("Tick!"))? Just to avoid bad practices in docs ;)

@@ -38,6 +38,21 @@ trait BracketLaws[F[_], E] extends MonadErrorLaws[F, E] {

def bracketIsDerivedFromBracketCase[A, B](fa: F[A], use: A => F[B], release: A => F[Unit]) =
F.bracket(fa)(use)(release) <-> F.bracketCase(fa)(use)((a, _) => release(a))

def uncancelableIsDerivedFromBracket[A, B](fa: F[A]) =
F.uncancelable(fa) <-> F.bracket(fa)(F.pure)(_ => F.unit)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This law is useful for us to read, but it's not useful in terms of tests via ScalaCheck.

We need something stronger in ConcurrentLaws. Don't we have a law for uncancelable in ConcurrentLaws? We need one for bracket's acquire and maybe release too.

But make sure it works with Monix 😛

@codecov-io
Copy link

codecov-io commented May 24, 2018

Codecov Report

Merging #241 into master will increase coverage by 0.33%.
The diff coverage is 100%.

@@            Coverage Diff             @@
##           master     #241      +/-   ##
==========================================
+ Coverage   88.73%   89.07%   +0.33%     
==========================================
  Files          58       58              
  Lines        1492     1520      +28     
  Branches      145      157      +12     
==========================================
+ Hits         1324     1354      +30     
+ Misses        168      166       -2

@johnynek
Copy link

johnynek commented May 24, 2018

some current laws:

// law 0
F.bracket(fa)(use)(release) <-> F.bracketCase(fa)(use)((a, _) => release(a))
// law 1
F.bracketCase(fa)(f)((_, _) => F.unit) <-> F.flatMap(fa)(f)
// law 2
F.uncancelable(fa) <-> F.bracket(fa)(F.pure)(_ => F.unit)

recall the monad law: F.flatMap(fa)(F.pure(_)) <-> fa

So putting this together:

// apply law 0 to the left hand side of this equation
F.bracket(fa)(F.pure)(_ => F.unit) <-> F.bracketCase(fa)(F.pure)((_, _) => F.unit)
// apply law1 to the right hand side of above.
F.bracket(fa)(F.pure)(_ => F.unit) <-> F.flatMap(fa)(F.pure)
// apply the monad bind identity, to right hand side of above.
F.bracket(fa)(F.pure)(_ => F.unit) <-> fa
// apply law2 to the left hand side of above.
F.uncancelable(fa) <-> fa

Did I make a mistake? All lawful instances have to be identity according to these laws if I didn't.

@oleg-py
Copy link
Contributor Author

oleg-py commented May 24, 2018

Law 1 doesn't look right to me. In presence of cancelable binds and concurrent cancellation - which we don't do a great job of testing against - it can't hold.

@oleg-py
Copy link
Contributor Author

oleg-py commented May 25, 2018

@johnynek yeah, it needs to be restated, since now acquire has additional semantics. Thanks for noticing 👍

@alexandru
Copy link
Member

@oleg-py ensuring might be a bad name, because we already have ensure on MonadError, see: https://github.com/typelevel/cats/blob/master/core/src/main/scala/cats/MonadError.scala#L13

What a waste 🙂 release would be an alternative. It mirrors the naming on bracket. finally would have been great, but it's a reserved word. Or we could keep ensuring and ignore ensure on MonadError.

That said, can you think of any sample for which ensuring is useful?

Right now all I can think of is that on usage of ensuring you're tying the source's termination to a finalizer that releases an outer resource and given my new explanation for bracket, I can't help but think that many use-cases are invalid, as you can't really rely on an IO task to release resources that were not acquired in the same bracket call.

@alexandru
Copy link
Member

Or in other words, whenever there's a call like:

F.bracket(F.unit)(_ => task)(_ => release)

I'm getting the feeling that all such calls are either not valid under auto-cancelable binds or very error prone (e.g. requiring idempotency on evaluating release).

@oleg-py
Copy link
Contributor Author

oleg-py commented May 25, 2018

Good point about the name. I'll change it to guarantee then, as was attempted to be done previously 😄

Besides, stdlib has its own ensuring which throws assertion errors, added by implicit conversion.

My use-case for it is not for resource handling, but for doing effecty stuff like logging or sending a message once the task has successfully completed. Previously I did .attempt in for-comprehension followed by rethrow, but that didn't work well with transformers for obvious reasons.

That said, it's rare enough that I can live without, but it's a convenience I'd like to have baked in (the whole thing that attracted me in FP in the first place was having tons of such generic conveniences)

@alexandru
Copy link
Member

It’s fine, we can have it 👍 and guarantee sounds good.

@mpilquist
Copy link
Member

mpilquist commented May 25, 2018 via email

val lh =
for {
mVar <- F.liftIO(MVar[IO].of(b1))
task = F.bracket(F.unit)(_ => F.unit)(_ => F.liftIO(mVar.put(b2)))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The use in this case should probably be F.never to prevent release from going off without a fiber.cancel.

Copy link
Member

@alexandru alexandru left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

Copy link
Member

@alexandru alexandru left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, wait, so you did not change IO's implementation?

If the laws aren't failing our IO, we have a problem somewhere.

See implementation: https://github.com/typelevel/cats-effect/blob/master/core/shared/src/main/scala/cats/effect/internals/IOBracket.scala#L33

def acquireIsNotCancelable[A](a1: A, a2: A) = {
val lh =
for {
mVar <- F.liftIO(MVar[IO].of(a1))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seeing this MVar[IO] used via F.liftIO makes me uncomfortable. Why are we doing that, instead of working with MVar[F] directly?

task = F.bracket(F.liftIO(mVar.put(a2)))(_ => F.never[A])(_ => F.unit)
fiber <- F.start(task)
_ <- fiber.cancel
_ <- F.liftIO(ioTimer.shift)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I haven't paid attention to the shift PR, I guess that's when the ioTimer happened, but why do we have an ioTimer in ConcurrentLaws?

If anything, we need a Timer[F]. If a Timer[IO] is needed to test F[_], that kind of smells badly.

Copy link
Contributor Author

@oleg-py oleg-py May 27, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We use Deferred[IO, ?] to test cancelable builder. Building that Deferred requires Concurrent[IO], which now requires Timer[IO]. And there's no way to get Timer[IO] from Timer[F] in Concurrent

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferred works with just Async. Do we really need a cancelable Deferred in the cancelable builder?

Seeing that Timer[IO] in there seems odd given we are testing F[_].

@oleg-py
Copy link
Contributor Author

oleg-py commented May 27, 2018

@alexandru seems we're using Deferred[IO, ?] only as a latch to check that cancel was called, so cancelability of get is not required. So I made the change to Timer[F]

@alexandru
Copy link
Member

Looking good 👍

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

7 participants