juxt / tick

Time as a value.
MIT License
609 stars 58 forks source link

Fake time, using a `time/capsule` #141

Open onetom opened 3 years ago

onetom commented 3 years ago

Problem

In automated tests, it's desirable to control time. It is achieved by something typically called a fake clock. The Use a fake system clock article enumerated the expected behaviours of such a clock:

  1. skip ahead to the future
  2. go back to the past
  3. use a fixed date, and a fixed time
  4. use a fixed date, but still let the time vary
  5. increment by one second each time you 'look' at the clock
  6. change the rate at which time passes, by speeding up or slowing down by a certain factor
  7. use the normal system clock without alteration

According to your needs, you may have to use the fake system clock in some or all of these places:

  • your application code
  • your code that interacts with the database
  • your logging output
  • your framework classes

These lists completely matched my expectations and felt common sense to me. To my surprise, only few of these use-cases were supported out of the box, by java.time.Clock or this juxt/tick library. I don't see how can I achieve use-case 4, 5 and 6.

java.time.Clock/fixed solves use-case 1, 2 and 3. tick.core/AtomicClock kinda solves use-case 4, but at the cost of introducing custom variants of clojure.core/atom,{reset,swap}{,-vals}!,compare-and-set!. The mentioned article demonstrates a solution to use-case 5, by a custom implementation of j.t.Clock.

I've found a Java implementation of a MutableClock It solves use-case 4, but not 5. It's also introducing extra, custom API (set, setInstant) for changing the clock; typical parochialism. Its implementation is complected with concerns like serialization, and the way the clock might be changed. Neither of these are very desirable in Clojure context. This MutableClock is also a bit obscure, because I haven't found it mentioned in higher-level documentations, only in the auto-generated Java docs. I don't feel confident using it and pulling in this extra library, just for this simple concept, despite it's authored by the same @jodastephen, who is the shepherd of java.time and JSR-310.

Solution

To cater for use-case 3 and 4, while maintaining a convenient and idiomatic use for use-case 7, I propose constructing a time-capsule concept. We can imagine this capsule having a clock-stand, which holds a concrete clock. This clock might be standing still at a time we specify, or ticking at the same, or a different rate as the system clock. During tests, we can replace the clock on this imaginary stand, with a different one, which might be derived from the one already on the stand.

From an application's point of view, this capsule should just look like any other j.t.Clock. Since j.t.Clock is just an abstract class, not a Java interface, we cannot extend it conveniently from Clojure, without providing a full-blown implementation for it and getting sucked into OO-land.

Instead, we can treat clocks as something derefable, just like t/AtomicClock does, to conveniently take a reading of their current time. To adjust the clock though, we need access to both the clock and the stand itself, to swap! it, so references to the capsule won't be affected.

Here is a possible implementation, for Clojure-only, for the sake of clarity:

(ns xxx.time
  (:require
    [tick.alpha.api :as t]
    [tick.protocols :as p]))

(defrecord Capsule [clock-stand]
  clojure.lang.IDeref
  (deref [_] (t/instant @clock-stand))

  clojure.lang.IAtom
  (swap [_ f] (swap! clock-stand f))
  (swap [_ f x] (swap! clock-stand f x))
  (swap [_ f x y] (swap! clock-stand f x y))
  (swap [_ f x y args] (apply swap! clock-stand f x y args))

  p/IClock
  (clock [_] @clock-stand))

(defn capsule [clock-like]
  (->Capsule (atom (t/clock clock-like))))

I've also omitted reset!, reset-vals! and swap-vals! for the sake of simplifying discussion, by focusing on the core idea.

When I tried to print a time/capsule, I got an error about multi-method ambiguity, which I resolved with:

(prefer-method print-method java.util.Map clojure.lang.IDeref)

I don't have a lot of experience with hierarchies in Clojure, so I'm not sure how to avoid this ambiguity, or what's better to prefer. I should probably just provide a (defmethod print-method Capsule) (and its pprint variant), but I'm not sure yet, what should the implementation look like.

Here is a demonstration of using a time/capsule:

(defn test-capsule [clk]
  (let [cclk (xxx.time/capsule clk)]
    {:capsule cclk
     :time    @cclk
     :clk     (t/clock cclk)
     :new-clk [(swap! cclk t/>> (t/new-duration 5 :minutes))
               (t/clock cclk)]}))

(comment
  (test-capsule (t/clock))
  (test-capsule (t/instant "1000-02-03T04:05:06Z"))
  )

Pros:

  1. convenient programming interface, mimicking a j.t.Clock implementing IDeref, as opposed to an (atom (Clock/system.)), which would require (t/instant @clk-holder) to be read, or maybe @@clock-holder somehow.
  2. no need for specialized atom operations
  3. no need to drop down to Java-level

Cons:

  1. the value passed by swap! to its transformation function, is the clock, which is different from the value we get with deref, which is the time on that clock.
  2. properly implementing all methods for IAtom, IAtom2 and the ClojureScript IReset and ISwap protocols is quite a lot of boilerplate
  3. wrapping an atom in a record feels superfluous, when an atom is almost suitable for our needs
onetom commented 3 years ago

In case it's not clear from the main description, (swap! test-clk t/>> (t/new-duration 5 :minutes)) would be the way to change the time within automated tests. I would rather write it as (swap! test-clk t/>> 5 :minutes), but that's an orthogonal issue.

onetom commented 3 years ago

Another thing I haven't made clear probably is that my proposal would be the replacement of tick.core/AtomicClock. While it's a funny name, fusing the Clojure atom concept with the java.time.Clock; atomic clock also has a well-defined meaning in the problem-domain of time and I find it misleading. My first impression was that it has something to do with NTP and obtaining some hyper-precise time. That's why I came up with the time-capsule mental-model. I specifically thought of this visualization of a possible warp-drive: https://en.wikipedia.org/wiki/Alcubierre_drive

onetom commented 3 years ago

I also forgot to mention the t/with-clock facility, which is described here: https://www.juxt.land/tick/docs/index.html#_substitution

While t/with-clock solves the problem for a single thread, using dynamic vars is trickier in multi-threaded situations. For example, in an integration test, where we spin up a temporary http-kit server for our API on a random port and talk to it via a http-kit client, we are going to deal with multiple threads. Of course, it's questionable, whether we should use fake clocks in such a test, but I found it useful for exploratory, system modelling work.

henryw374 commented 3 years ago

Nice problem statement!

I agree that with-clock has issues, but they are part of the java.time/tick approach to getting the now time - you can choose to pass a clock around, or just use the ambient clock. having a *clock* binding, tick makes the ambient-clock route even more tempting. Still, I think for a lot of use cases that works ok.

Is the intent for the capsule to be referred to explicitly by any code that needs now?

FYI for another reference point... and something will make it into tick eventually, is https://tc39.es/proposal-temporal/docs/index.html#Temporal-now - which serves the same function as java.time Clock. In https://github.com/henryw374/tempo I'm looking at making an api for 'clocks' that works with Temporal.now and java.time.Clock

henryw374 commented 1 month ago

fyi mutable clock in clojure https://gist.github.com/henryw374/2291e787087eeea513f9a8e5a5bd6f69

onetom commented 1 month ago

is there any significance of using mutable_clock.MutableClock, instead of just MutableClock in the proxy call?

henryw374 commented 1 month ago

good question. I don't think so.... I tried just now without and it seems to work ok. If I had a reason it has been forgotten as this was a few months ago. Also looking at this again, it would be good to be able to atomically set zone and instant. maybe it should just be a zoned-date-time.

Circling back to the capsule idea... I am leaning towards preferring to create instances of java.time.Clock (or in js-land js/Temporal.Now) as these things work with the native APIs (just constructor fns afaik).

I think 1-6 can still be addressed with that constraint. but pls let me know otherwise.

and if so... I guess having a mutable clock in the lib would be handy. and anything more esoteric can be done in userspace.

btw in Tempo there are no zero-args 'now' fns - you have to provide a clock. I've come to prefer working that way with tick - ie not using with-clock at all.

onetom commented 1 month ago

Very exciting!

In our app we have a :clk component in our system map and everything relies on it explicitly.

We inject it into our Datomic components too, so even the db schema can be transacted in the past virtually, so we can transact theoretical past scenarios in our tests (because Datomic doesn't allow future transactions).

The only thing we couldn't control the clock of is java.net.CookieManager, which just calls System/currentTimeMillis directly in its cleanup routine, which throws away expired cookies, we couldn't test session expiry code in full integration over a web server.

I haven't explored the Tempo lib yet, but we have ended up not really utilizing the cross-platform nature of tick, so I might consider falling back to Tempo, to reduce complexity.

+1 for using java.time.Clock, if possible, for better interop.

I'll try to review this thread in the coming ~2weeks, to give some feedback.

henryw374 commented 1 month ago

another clock implementation https://gist.github.com/henryw374/38d61b20c62cd5450331f36ee9029a61