jonboulle / clockwork

a fake clock for golang
Apache License 2.0
625 stars 57 forks source link

Synchronous Timers? #67

Closed MichaelSnowden closed 7 months ago

MichaelSnowden commented 1 year ago

Is there an option to make it so that the callbacks from timers are invoked synchronously instead of in a goroutine? Essentially, remove go from here: https://github.com/jonboulle/clockwork/blob/8a29bc122650db93cecf642b2a37701ff6ec2332/timer.go#L43

My issue with the current method is demonstrated by this workaround https://github.com/temporalio/temporal/blob/15974517a5ea2987a7f9a02afefc423418ba658a/service/matching/liveness_test.go#L44

Because Advance is not synchronous, and we are trying to verify that the callback is not fired, there is no event for us to actually wait on in the test, so we have to resort to a time.Sleep.

DPJacques commented 12 months ago

As a thought experiment, should this be the default behavior?

Most people are probably using channels as side-effects to wait for the goroutine to start/end. This will be racey on slow machines in all circumstances. It is also more verbose and, frankly, annoying. Default synchronous behavior would alleviate that, IIAUC.

However, if a user is using AfterFunc to start a never-ending function, Advance will never return. Not great, but this feels like a very rare use case.

DPJacques commented 12 months ago

Another thought, are we looking at this the right way? What if instead of changing behavior we gave callers what they need?

What if we added an expiration/firing count that users can just read? Then instead of worrying about behavior modifications you can just read a counter.

MichaelSnowden commented 12 months ago

As a thought experiment, should this be the default behavior?

So, I think having this be the default behavior would be perfect because users could just spawn a goroutine in the callback if they need to do something that takes a while. Basically, providing a synchronous API, users can choose to do sync or async, but providing an async API, they're stuck with that. However, I don't want to break existing users who are relying on this behavior.

Another thought, are we looking at this the right way? What if instead of changing behavior we gave callers what they need?

What if we added an expiration/firing count that users can just read? Then instead of worrying about behavior modifications you can just read a counter.

I thought about something like this with a Flush method that looks at a WaitGroup which tracks pending callbacks. However, I think it would be annoying because you'd have to add Flush() to the end of every call that you need to make synchronous. If all calls are synchronous, then you can just switch between Advance and go Advance depending on your needs. It would also allow you to interleave synchronous and non-synchronous calls because, with a wait group or similar counting mechanism, there's be no way to do something like go Advance(), Advance().

DPJacques commented 11 months ago

you can just switch between Advance and go Advance depending on your needs.

I don't think this needs to be the case. API guarantee was just ~"the counter only changes by calling Advance, and only increases by the number of expired counters, regardlss of type." There should be no need to call go FakeClock.Advance()

I'm going to type to throw together a PR to see what this looks like. It should be very doable without adding new behavior dynamics.

DPJacques commented 11 months ago

TLDR: @MichaelSnowden Can you take a look at #69 and let me know

  1. Will that meet your needs?
  2. If not, why not?

Longer musing

I should have read and responded to this quote a little more closely before I responded above, so I am doing so now.

I thought about something like this with a Flush method that looks at a WaitGroup which tracks pending callbacks.

If I am understanding you correctly, isn't tracking pending callbacks is specifically what you want to avoid? In an attempt to summarize the problem, there are only 2 cases we need to cover:

  1. Did a function/behavior complete?
  2. Did the FakeClock start the execution a function/behavior (or not)? This is only difficult when one is proving that AfterFunc did not fire, because other operations are channel-based and deterministic around Advance. You can read from channels and synchronously verify.

Item 1 is on the caller to track via completion channels or side effects. It isn't clockwork's job to reason about whether a caller-defined thing completed. Example:

fc := clockwork.NewFakeClock()
ch := make(chan bool)
fc.AfterFunc(time.Second, func(){
  time.Sleep(time.Minute)
  close(ch)
})
fc.Advance(time.Second)

<-ch // waits a minute for completion of the function

Item 2 is is the focus of your ask here, becuase there is currently no way to validate that the AfterFunc function didn't fire.

I have struggled with this myself, and in hingsight I really should have offered a way to do this earlier. I believe #69 achives this.

DPJacques commented 9 months ago

I actually think this is an issue worth finding a solution for, but I don't personally mind it and no one has chimed in (@MichaelSnowden or anyone else) for a while.

I'll close this on 1 Nov unless someone comments that it is worth pursuing.

DPJacques commented 7 months ago

Closing due to lack of stakeholder interest.