Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Conversation

@mschuwalow
Copy link
Member

Sorry for taking forever with this.
This is an initial version of the schedule changes. I'll definitely need to another pass to update docs, double check / cleanup implementations and unfuck Stream#aggregateWithinEither which I currently broke. I plan to spend some more time tomorrow to polish this.

Still I believe this is developed enough to take a look at the new api and decide whether we want to stick with it.
In general I think this is very clean to program with and we lost only very few combinators in the process.
One thing that is a bit unintuitive with the new encoding is building delays in a composable fashion. In order to make this easier I split up all time-based schedules into functions that produce non-sleeping schedules that emit durations and a function addDelay that allows to 'materialize' this into sleeps. This seems to work alright and feels very similar to programming with streams. I don't know whether that is the right approach though. I think @adamgfraser had ideas for other possible encodings.

@jdegoes @adamgfraser It would be great if you could take a look

@adamgfraser
Copy link
Contributor

@mschuwalow Thanks for taking the lead on this! This is really great work and goes a long way to cleaning up a complicated part of the codebase with a lot of dependencies.

The main concern I have with the current implementation is that it pushes more work onto the user to specify whether a Schedule should do "sleeps" or not. For example, consider this snippet:

ZIO.effectTotal(println("Hello, World!")).repeat(ZSchedule.fixed(1.second))

In the current version of ZIO, this would print "Hello, World!" to the console repeatedly with a one second delay. In this PR, that line would instead immediately print "Hello, World!" an infinite number of times with no delay. To get an actual delay we need to instead do:

ZIO.effectTotal(println("Hello, World!")).repeat(ZSchedule.fixed(1.second).addDelay)

And the user in general has to do that every time they call a Schedule method. We generally can't do it within ZIO library combinators because addDelay adds a clock dependency so is only appropriate if we know that the schedule will always involve "sleeps" and not just for example be repeating while a condition is true. This is not great from a user perspective. Incidentally if we do go down this road I would suggest renaming addDelay to something like run to highlight its nature of adding real Clock effects and being irreversible (we can addDelay to any schedule but we can't do that reverse because at that point we have actual sleep effects that we can't introspect).

I think broadly this PR goes in the right direction by continuing to model a schedule as a "description" of a series of steps with delays rather than in each step doing the delay, at least until the schedule is "run" with addDelay. We generally benefit by describing effects versus actually doing them and this true here as well where if each step does a "sleep" in combinators like spaced we lose the ability to modify those delay in some way because by the time we "see" them they have already occurred, so for example we can add delays but can't reduce them and || would need to behavior more like ZSteam.merge. I think there is a consistent set of combinators there but they are different than the existing one and in some ways more restrictive, so I think you are right to model most combinators as descriptions and only introduce actually doing the "sleeping" as late as possible.

What I think we need to add is using the type system more to automatically "derive" whether a schedule should use Clock and do sleeping, versus the user having to specify that using addDelay. Conceptually, the compiler should "know" that a schedule using Schedule.fixed should do real sleeps and that one only using Schedule.doWhile shouldn't. To do that, every schedule needs to maintain one additional piece of information, which we could think of as the "ExecutionStrategy" for the schedule, which could either be "live" (do sleeping), or not. Then I think the composition rule is that if we combine schedules and either schedule uses "sleep", the combined combinator uses sleep. I think if we take that aspect from #1720 and incorporate it we could get schedules that automatically propagate the appropriate clock dependency, which I think is ultimately where we want to be.

If that is right then I think we conceptually end up at a similar place to #1720 (schedules are descriptions of a series of delays that include an instruction for how a client can "run" those delays). And then I think we have to see how different they. If schedules really are descriptions of a series of delays with a way for a client to effectually "run" them to potentially actually do a "sleep", then we can implement all downstream method with almost no change and remove the Clock dependency as in #1720.

@jdegoes
Copy link
Member

jdegoes commented Sep 23, 2019

In order to make this easier I split up all time-based schedules into functions that produce non-sleeping schedules that emit durations and a function addDelay that allows to 'materialize' this into sleeps.

I too am not a fan of this. It impacts user-land ergonomics enormously.

What is wrong with just sleeping? Seems like you almost had that working at the hackathon. Run into more problems there???

@mschuwalow
Copy link
Member Author

In order to make this easier I split up all time-based schedules into functions that produce non-sleeping schedules that emit durations and a function addDelay that allows to 'materialize' this into sleeps.

I too am not a fan of this. It impacts user-land ergonomics enormously.

What is wrong with just sleeping? Seems like you almost had that working at the hackathon. Run into more problems there???

I guess the one that is specifically proving challenging is jittered.
I believe having the option to at least randomly extend how long schedules are sleeping is super useful, but it's not quite clear to me how to express this on top of the new api. One option is see is
measuring how long schedules are taking with timed and extending it with a second sleep, but this feels quite dirty.
The second is the approach I've taken in the pr, but I agree that the ergonomics are bad.

@jdegoes Do you have an idea how to express this?

@adamgfraser
Copy link
Contributor

Isn’t the problem more fundamental? Take the example of:

ZSchedule.spaced(10.seconds).modifyDelay(_ / 2)

If a schedule just describes delays, so each step doesn’t actually sleep but returns some state indicating how long to sleep this is easy because we just divide the number by two.

But if a schedule “sleeps” then how do we implement this? We just have an opaque effect that we can either do nothing with or run, in which case we actually sleep for 10 seconds and it is already too late to achieve the goal of sleeping for only 5 seconds. By converting schedules from descriptions to actions we have lost some ability to compose them.

We could instead implement modifyDelay as only adding additional delays and jittered as increasing each delay by a random amount instead of potentially increasing or reducing the delay, but I think that is just one example of how the semantics of schedules that just sleep will be somewhat different than the existing behavior.

@iravid
Copy link
Member

iravid commented Sep 23, 2019

Just throwing something out here. What if we add a delay "instruction", represented by an ADT that is parameterized by R? This ADT can be translated to a ZIO but it keeps the R.

Something like ...

sealed trait Something[-R]
object Something {
  case object DoesNothing extends Something[Any]
  case object AddsDelay(delay: Duration) extends Something[Clock]
}

Keep this on the decision, and you can both introspect it and translate it to a ZIO while keeping the R precise. What do you think @mschuwalow @adamgfraser ?

@adamgfraser
Copy link
Contributor

adamgfraser commented Sep 23, 2019

@iravid Yes that is exactly the direction I think we need to go in. Here is what I had in #1720 that does this. Obviously we could change the names.

sealed trait Delay[-R] extends (Duration => ZIO[R, Nothing, Unit]) {
  def combine[R1 <: R](that: Delay[R1]): Delay[R1]
}
final case object Live extends Delay[Clock] {
  def apply(d: Duration): ZIO[Clock, Nothing, Unit] =
    clock.sleep(d)
  def combine[R1 <: Clock](that: Delay[R1]): Delay[R1] =
    this
}
final case object Mock extends Delay[Any] {
  def apply(d: Duration): ZIO[Any, Nothing, Unit] =
    ZIO.unit
  def combine[R1 <: Any](that: Delay[R1]): Delay[R1] =
    that
}

In that version the length of the delay is a separate field in Decision but it could easily be moved into Delay instead.

@regiskuckaertz
Copy link
Member

@adamgfraser It seems with that encoding, you will always end up writing decision.delay(decision.duration). Why not specialise Decision itself?

sealed abstract class Decision[R, A, B](cont: Boolean, state: A, finish: () => B) {
  def delay: ZIO[R, Nothing, Unit] = ZIO.unit
}
final case class Sleep(duration: Duration, cont: Boolean, state: A, finish: () => B) extends Decision[Clock, A, B](cont, state, finish) {
  def delay = clock.sleep(duration)
}
final case class At(instant: Instant, cont: Boolean, state: A, finish: () => B) extends Decision[Clock, A, B](cont, state, finish) {
  def delay = clock.now.map(instant.toEpochMillis - _).flatMap(n => if (n <= 0) ZIO.unit else clock.sleep(n))
}

@iravid
Copy link
Member

iravid commented Sep 23, 2019

@regiskuckaertz nice!!

@adamgfraser
Copy link
Contributor

@regiskuckaertz Yes you could definitely do that. You end up not having to write it very often because you do it in a few key combinators like retry and repeat and then from a user perspective it is completely transparent. In my original version I was trying to remove the Clock dependency with minimal other changes so it worked well to leave duration as a field on Decision.

@adamgfraser
Copy link
Contributor

One advantage of this approach is that it should be possible to implement all the methods built on top of Schedule with almost no changes. You should just have to change any references to clock.sleep(duration) to decision.delay.

@mschuwalow
Copy link
Member Author

I really like @regiskuckaertz encoding if we want to go with this style. Especially the At decision would be a very nice way to tackle the cron stuff

@regiskuckaertz
Copy link
Member

regiskuckaertz commented Sep 23, 2019 via email

@mschuwalow
Copy link
Member Author

Hm maybe we can combine the two approaches. What if we keep the encoding in this pr, which allows nice fold-based usage instead of cont, but introduce an Delayed abstraction very close to what @regiskuckaertz proposed. Something like:

trait ZSchedule0[-R, -A, +B] {
  type State
  type RunningState <: State

  val initial: ZIO[R, Nothing, State]
  val extract: RunningState => B
  val update: (A, State) => ZIO[R, RunningState, Delayed[R, RunningState]]
}

object ZSchedule0 {

  sealed abstract class Delayed[-R, +A] {
    def delay: ZIO[R, Nothing, A]
  }
  final case class Sleep[A](duration: Duration, result: A) extends Delayed[Clock, A] {
    val delay = clock.sleep(duration).as(result)
  }
  final case class At[A](instant: Instant, result: A) extends Delayed[Clock, A] {
    val delay = clock.currentTime(TimeUnit.MILLISECONDS).map(instant.toEpochMilli - _).flatMap(n => if (n <= 0) ZIO.unit else clock.sleep(n.millis)).as(result)
  }
}

This also makes it much clearer that a schedule is not supposed to delay when it short circuits. We could even make this more polymorphic in order to make this more explicit, at the cost of another parameter:

trait ZSchedule0[-R0, -R, -A, +B] {
  type State
  type RunningState <: State

  val initial: ZIO[R0, Nothing, State]
  val extract: RunningState => B
  val update: (A, State) => ZIO[R0, RunningState, Delayed[R, RunningState]]
}

@jdegoes
Copy link
Member

jdegoes commented Sep 26, 2019

@mschuwalow

I guess the one that is specifically proving challenging is jittered.

If you make jittered only work on schedules with Clock with Random, it becomes trivial.

In general, modifyDelay could work only on Clock with Random.

We have seen in #676 that it is not sufficient to have a Duration. It's a leaky abstraction because it does not permit you to create schedules that run at specific times. The very concept of modifyDelay assumes all delays are relative, not absolute; which is true for some common cases but not other common cases (cron-style).

Essentially, I believe that losing modifyDelay (and friends) is a good thing. We throw away some structure (which has cost, of course!) and get something that is more general, which can satisfy more use cases. Yes, we have fewer operations, but then the data type becomes less constrained, which means we can use it for broader use cases.

@jdegoes Do you have an idea how to express this?

I would press forward with the generic approach. It results in less code, cleaner code, and yes, fewer operations, but a more capable data type. The alternative will be adding even more and highly irregular structure (you can see that emerging in #676).

For jitter in particular, you are right that it is useful. I would make jitter work only on Clock with Random, or you can easily make it work on R >: DefaultEnvironment#Environment <: Clock with Random, i.e. set of all ZIO built-in services, but at least Clock and Random for the jitter. That takes care of the common useful case in a way that's repeatable (we can do the same thing elsewhere, if we want), and we can then sacrifice modifyDelay and such as being too specialized and not compatible with scheduling at absolute date/times.

@mschuwalow
Copy link
Member Author

Alright, I'll update the pr and get back to you

@mschuwalow
Copy link
Member Author

mschuwalow commented Sep 27, 2019

True, you would probably end up special casing behavior for the different sleep styles all the time O.o
I've added this formulation of jittered. It's a bit sad that we can't write this fully generically without macros 😢

There is one further issue I ran into with this encoding.
We currently are unable to extract the output from a schedule without updating once, which is desired behavior. The problem arises when we want to interrupt the schedule, but still want to be able to extract the output from it. This happens whenever we race two schedules and require both of their outputs.

The prime candidates where this is necessary are && and ***.
I don't think it's possible to implement intersection without making the type signatures very unergonomic and Arrow's split at all.

I believe the following encoding works for all we want to do, but does not enforce the correct usage as strongly.

trait ZSchedule[-R, -A, +B] extends Serializable { self =>
  type State
  val initial: ZIO[R, Nothing, State]
  val extract: (A, State) => B
  val update: (A, State) => ZIO[R, Unit, State]
}

Where initial returns the state to be paired with the first element and update($a_1, $state_1) returns state_2 and so on. Failure with () signals that the schedule should stop and extract with the current a. Alternatively we might be able to do this, but I don't know whether this is any better and I haven't tried it out.

trait ZSchedule[-R, -A, +B] extends Serializable { self =>
  type State
  val initial: A => ZIO[R, Nothing, State]
  val extract: State => B
  val update: (A, State) => ZIO[R, Unit, State]
}

What do you think?

/cc @jdegoes

@iravid
Copy link
Member

iravid commented Sep 27, 2019

I admit that just by having a cursory look, there are quite a few semantic and signature changes than I feel comfortable with.

Are all of them necessitated by the changes required to support a specific R? Or can we roll back some of the encoding changes and save them for 2.0?

@iravid
Copy link
Member

iravid commented Sep 27, 2019

In particular I should note that the initial/update/extract style of usage, seen in ZSink, is very hard to get right across all combinators and constructors.

@adamgfraser
Copy link
Contributor

We could merge #1720, which would remove the Clock dependency with basically no changes to other combinators, and then use this PR as the basis for additional work. That would let us start implementing other combinators on ZIO in terms of Schedule, which we're already seeing more requests for, and potentially alleviate some of the pressure to get this in immediately.

@iravid
Copy link
Member

iravid commented Sep 27, 2019

@mschuwalow What limitations on #1720 led to exploring a different encoding?

@mschuwalow
Copy link
Member Author

@iravid @adamgfraser
I believe the main motivation for this is having a better basis to implement more sophisticated behavior on top of schedules (e.g. #676).
I agree though that this is shaping up to be a rather large change and it might make sense to delay this in favor of #1720. Especially as the design space for this seems quite large and we might need a few more iterations

@mschuwalow
Copy link
Member Author

mschuwalow commented Sep 29, 2019

Ok, I think this is at a point where it can be reviewed. I'm still unsure about the specific encoding of schedule, but I believe the current semantics work out nicely for everything we want to do.

@jdegoes
Copy link
Member

jdegoes commented Sep 30, 2019

I will take a detailed look this PR and the other one this week.

The main goal is allowing schedules that run at absolute times, so we can define compositional cron-like languages; secondary goal would be eliminating Clock requirement from repeat / retry combinators, if they use schedules that do not require Clock; third goal would be simplifying the core Schedule model, if possible.

I think the main goal (cron) is worth pushing in a "big enough" change to get this through (it is a long requested feature); ideally the reworking would result in something not harder to use than the old API.

@mschuwalow mschuwalow changed the title Schedule rework (wip) Schedule rework Oct 5, 2019
iravid
iravid previously approved these changes Oct 23, 2019
Copy link
Member

@iravid iravid left a comment

Choose a reason for hiding this comment

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

The changes look good to me and I definitely like the new encoding! update(...).foldM is much nicer than the previous pattern of update, check the cont flag, delay manually...

Great work @mschuwalow!

@mschuwalow
Copy link
Member Author

The changes look good to me and I definitely like the new encoding! update(...).foldM is much nicer than the previous pattern of update, check the cont flag, delay manually...

Great work @mschuwalow!

Thanks for reviewing! I'll address your remarks tomorrow and also resolve conflicts with master

@reibitto
Copy link
Contributor

I checked out this branch and can confirm it solves the particular issue I described in #1909. Thanks @mschuwalow! Looking forward to this.

@mschuwalow mschuwalow requested a review from iravid October 25, 2019 15:38
Copy link
Member

@jdegoes jdegoes left a comment

Choose a reason for hiding this comment

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

Minor comments but this is looking really amazing. I did not review Stream but leave that for @iravid.

f: (A1, ZSchedule.Decision[State, B]) => UIO[ZSchedule.Decision[State, C]]
): ZSchedule[R, A1, C] =
final def reconsider[R1 <: R, A1 <: A](
f: (A1, Either[State, State]) => ZIO[R1, Unit, State]
Copy link
Member

Choose a reason for hiding this comment

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

This isn't quite equivalent to the old reconsider, nor can it be without fixing the env.

Copy link
Member Author

Choose a reason for hiding this comment

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

I'm not quite sure what we are losing compared to the old reconsider.
Can you explain in a little more detail?

* that in `(op: IO[E, A]).repeat(Schedule.recurs(0)) `, op is not done at all.
*/
final def recurs(n: Int): Schedule[Any, Int] = forever.whileOutput(_ <= n)
final def recurs(n: Int): Schedule[Any, Int] = forever.whileOutput(_ < n)
Copy link
Member

Choose a reason for hiding this comment

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

Intentional change?

Copy link
Member Author

Choose a reason for hiding this comment

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

Sadly check subtly changed its semantics as we now have to extract before updating and not after.
This is a consequence of that change

@mschuwalow
Copy link
Member Author

@jdegoes thanks for the review and the kind feedback 🙏
I believe I have addressed most remarks in your review, but there were a few question that arose. I have left the relevant comments unresolved. Would be great if you could take another look -- I'll finish the changes asap after.

adamgfraser
adamgfraser previously approved these changes Oct 27, 2019
@iravid iravid merged commit db0b28d into zio:master Oct 28, 2019
ghostdogpr pushed a commit to ghostdogpr/scalaz-zio that referenced this pull request Nov 5, 2019
…nt (zio#1798)

* initial changes towards new schedule

* wip

* update encoding and begin cleanup

* update docs

* finish updating tests & cleanup

* add all the type annotation for 2.11

* apply scalafmt

* add js and jvm specific jittered syntax

* update ZStream#fixed

* fixes after rebase

* update schedule docs

* followup

* merge master

* implement ZSchedule#jittered in terms of ZEnv#map* and remove ZScheduleSyntax

* readd modifyDelay

* update tests and mdoc

* add type annotations

* please dotty

* retrigger circle

* retrigger circle

* fix ZSchedule#never and add ZSchedule#randomDelay

* followup

* update random#nextLong for 2.11

* apply fmt

* changes requested in review

* add type parameters for 2.11

* update ZSchedule#ensuring implementation

* remove unused import

* ensure that ZSchedule#ensuring only runs the finalizer once

* apply fmt

* add type annotations for 2.11

* retrigger ci

* retrigger ci

* retrigger ci
Twizty pushed a commit to Twizty/zio that referenced this pull request Nov 13, 2019
…nt (zio#1798)

* initial changes towards new schedule

* wip

* update encoding and begin cleanup

* update docs

* finish updating tests & cleanup

* add all the type annotation for 2.11

* apply scalafmt

* add js and jvm specific jittered syntax

* update ZStream#fixed

* fixes after rebase

* update schedule docs

* followup

* merge master

* implement ZSchedule#jittered in terms of ZEnv#map* and remove ZScheduleSyntax

* readd modifyDelay

* update tests and mdoc

* add type annotations

* please dotty

* retrigger circle

* retrigger circle

* fix ZSchedule#never and add ZSchedule#randomDelay

* followup

* update random#nextLong for 2.11

* apply fmt

* changes requested in review

* add type parameters for 2.11

* update ZSchedule#ensuring implementation

* remove unused import

* ensure that ZSchedule#ensuring only runs the finalizer once

* apply fmt

* add type annotations for 2.11

* retrigger ci

* retrigger ci

* retrigger ci
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants