├── .github └── workflows │ └── test-commit.yaml ├── .gitignore ├── CHANGES.org ├── README.adoc ├── note-0.1.5.org ├── project.clj ├── src ├── chime.clj └── chime │ ├── core.clj │ ├── core_async.clj │ ├── joda_time.clj │ └── util.clj └── test ├── chime ├── core_async_test.clj └── core_test.clj └── chime_test.clj /.github/workflows/test-commit.yaml: -------------------------------------------------------------------------------- 1 | name: Chime CI 2 | 3 | on: push 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - uses: actions/checkout@v2 11 | 12 | - name: cache-deps 13 | uses: actions/cache@v1 14 | env: 15 | cache-name: cache-deps 16 | with: 17 | path: ~/.m2 18 | key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('project.clj') }} 19 | restore-keys: | 20 | ${{ runner.os }}-build-${{ env.cache-name }}- 21 | ${{ runner.os }}-build- 22 | ${{ runner.os }}- 23 | 24 | - name: install-deps 25 | run: lein deps 26 | - name: run-tests 27 | run: lein test 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /lib 3 | /classes 4 | /checkouts 5 | pom.xml 6 | pom.xml.asc 7 | *.jar 8 | *.class 9 | .lein-deps-sum 10 | .lein-failures 11 | .lein-plugins 12 | .lein-repl-history 13 | .lein-env 14 | /.nrepl-port 15 | -------------------------------------------------------------------------------- /CHANGES.org: -------------------------------------------------------------------------------- 1 | * Changes 2 | ** 0.3.3 3 | Changes: 4 | - #43: Passing thread-bindings through to scheduled task 5 | - #41: Allow user to override the thread-factory used by Chime, makes Chime Loom-friendly (thanks @jimpil) 6 | - Allow specifying 'clock' to `chime-at` (thanks @jimpil) 7 | - #36: ensure we only call on-finished once (thanks @dazld) 8 | - #33: include thread-count in thread name (thanks @orestis and @pmonks) 9 | - #34: add `IPending` implementation to return value of `chime-at` (thanks @nukep) 10 | - The without-past-times didn't work with joda dates, even with chime.joda-date imported. 11 | 12 | ** 0.3.2 13 | I left in some println debugging. Apologies. 14 | 15 | ** 0.3.1 16 | Have finally gotten around to spending some non-trivial time modernising Chime, thanks for your patience :) 17 | 18 | This is a backwards compatible release - the original =chime= namespace still behaves 100% as before. 19 | 20 | The plan now is to move to a 1.0.0 release imminently (in line with the rest of the Clojure community, apparently!) in which I'll remove this layer - so feel free to use this release to migrate over. 21 | 22 | Changes: 23 | - Main chime namespace is now =chime.core=, rather than the =chime= top-level namespace 24 | - core.async is no longer a required dependency - =chime-ch= is in =chime.core-async/chime-ch= 25 | - =chime-at= now returns an =AutoCloseable= rather than a close function - call =.close= to stop the schedule. 26 | - Chime no longer removes past times for you - this was causing counter-intuitive issues. 27 | I've added =chime.core/without-past-times= if you want to restore this behaviour. 28 | - Error handling has changed slightly - you now need to return truthy to continue the schedule, falsy to stop it. 29 | 30 | Just to confirm, these changes only apply to =chime.core= / =chime.core-async= - the behaviour of the =chime= namespace hasn't changed. 31 | ** 0.3.0 32 | Chime was resurrected somewhat, due to Tick (temporarily) removing their schedule support. 33 | 34 | Breaking changes: 35 | - Chime now uses java.time by default (thanks to [[https://github.com/holyjak][Jakub Holy]] for the PR!). 36 | You'll want to =(:require chime.joda-time)= to preserve backward compatibility 37 | - Chime no longer includes Joda time as a runtime dependency 38 | 39 | ** 0.2.2 40 | core.async bumped from 0.2.374 t0 0.3.442 to avoid spec problem (#21) - thanks to 41 | [[https://github.com/Dexterminator][Dexter Gramfors]] for the PR 42 | 43 | ** 0.2.1 44 | Chime correctly handles empty time seqs - thanks to [[https://github.com/aterweele][Alex ter Weele]] for the report. 45 | 46 | ** 0.2.0 47 | 48 | Breaking change/functionality clarification: previously, if a job was scheduled before the timer was cancelled, the job 49 | would still run. After 0.2.0, jobs that haven't started before the timer is cancelled will not run. 50 | 51 | Thanks to [[https://github.com/dkharlan][Dave Harlan]] for raising the issue! 52 | 53 | ** 0.1.8 / 0.1.9 54 | 55 | Bugfix to work around http://dev.clojure.org/jira/browse/ASYNC-138 (work around in 56 | http://dev.clojure.org/jira/browse/ASYNC-32). Thanks to [[https://github.com/bronsa][Nicola Mometto]] and 57 | [[https://github.com/schmfr][@schmfr]] for helping to track it down! 58 | 59 | Deps bump to Clojure 1.7, core.async 0.2.374, and clj-time 0.11 - thanks [[https://github.com/scribahti][@scribahti]]. 60 | 61 | Deploying 0.1.9 because Clojars part-failed on 0.1.8 62 | 63 | ** 0.1.7 64 | 65 | We delegate to the thread's uncaught exception handler by default, if ~error-handler~ isn't provided. 66 | 67 | Thanks [[https://github.com/cichli][Michael Griffiths]] :) 68 | 69 | ** 0.1.6 70 | 71 | PR from [[https://github.com/BartAdv][Bartosz]] to add an =on-finished= callback to =chime-at= - thanks! 72 | 73 | ** 0.1.5 74 | 75 | Bugfix from [[https://github.com/cassiel][Nick Rothwell]] fixing a negative interval bug - thanks! 76 | 77 | Also, allowing you to close the channel returned by =chime-ch= to cancel the schedule. 78 | 79 | No breaking changes 80 | 81 | ** 0.1.4 82 | 83 | Bugfix from [[https://github.com/rockolo][Philipp Rockel]] fixing a negative interval race condition - thanks! 84 | 85 | No breaking changes 86 | 87 | ** 0.1.3 88 | 89 | Added =chime-ch=, returning a channel. Re-implemented =chime-at= in terms of =chime-at=. 90 | 91 | No breaking changes 92 | 93 | ** 0.1.2 94 | 95 | Bug-fix for race condition - I advise everyone on earlier 0.1.x versions to upgrade. 96 | 97 | ** 0.1.1 98 | 99 | No breaking changes. Added error-handling to =chime-at=. 100 | 101 | ** 0.1.0 102 | 103 | Initial release 104 | -------------------------------------------------------------------------------- /README.adoc: -------------------------------------------------------------------------------- 1 | = Chime 2 | 3 | Chime is a *really* lightweight Clojure scheduler. 4 | 5 | == Dependency 6 | 7 | Add the following to your `project.clj`/`deps.edn` file: 8 | 9 | [source,clojure] 10 | ---- 11 | [jarohen/chime "0.3.3"] 12 | ---- 13 | 14 | [source,clojure] 15 | ---- 16 | {jarohen/chime {:mvn/version "0.3.3"}} 17 | ---- 18 | 19 | == The ‘Big Idea’™ behind Chime 20 | 21 | The main goal of Chime was to create the simplest possible scheduler. 22 | Many scheduling libraries have gone before, most attempting to either mimic cron-style syntax, or creating whole DSLs of their own. 23 | This is all well and good, until your scheduling needs cannot be (easily) expressed using these syntaxes. 24 | 25 | When returning to the grass roots of a what a scheduler actually is, we realised that a scheduler is really just a promise to execute a function at a (possibly infinite) sequence of times. 26 | So, that is exactly what Chime is - and no more! 27 | 28 | Chime doesn't really mind how you generate this sequence of times - in the spirit of composability *you are free to choose whatever method you like!* (yes, even including other cron-style/scheduling DSLs!) 29 | 30 | When using Chime in other projects, I have settled on a couple of patterns (mainly involving the rather excellent time functions provided by https://docs.oracle.com/javase/8/docs/api/java/time/package-summary.html[java.time] - more on this below.) 31 | 32 | == Usage 33 | Chime consists of one main function, `chime.core/chime-at`. 34 | 35 | `chime-at` is called with the sequence of times, and a callback function: 36 | 37 | [source,clojure] 38 | ---- 39 | (require '[chime.core :as chime]) 40 | (import '[java.time Instant]) 41 | 42 | (let [now (Instant/now)] 43 | (chime/chime-at [(.plusSeconds now 2) 44 | (.plusSeconds now 4)] 45 | (fn [time] 46 | (println "Chiming at" time)))) 47 | ---- 48 | 49 | With `chime-at`, it is the caller's responsibility to handle over-running jobs. 50 | `chime-at` will never execute jobs of the same scheduler in parallel or drop jobs. 51 | If a schedule is cancelled before a job is started, the job will not run. 52 | 53 | `chime-at` returns an `AutoCloseable` that can be closed to cancel the schedule. 54 | 55 | You can also pass an `on-finished` parameter to `chime-at` to run a callback when the schedule has finished (if it's a finite schedule, of course!): 56 | 57 | [source,clojure] 58 | ---- 59 | (let [now (Instant/now)] 60 | (chime/chime-at [(.plusSeconds now 2) 61 | (.plusSeconds now 4)] 62 | 63 | (fn [time] 64 | (println "Chiming at" time)) 65 | 66 | {:on-finished (fn [] 67 | (println "Schedule finished."))})) 68 | ---- 69 | 70 | === Recurring schedules 71 | 72 | To achieve recurring schedules, we can lazily generate an infinite sequence of times. 73 | This example runs every 5 minutes from now: 74 | 75 | [source,clojure] 76 | ---- 77 | (import '[java.time Instant Duration]) 78 | 79 | (-> (chime/periodic-seq (Instant/now) (Duration/ofMinutes 5)) 80 | rest) ; excludes *right now* 81 | ---- 82 | 83 | To start a recurring schedule at a particular time, you can combine this example with some standard Clojure functions. 84 | Let's say you want to run a function at 8pm New York time every day. 85 | To generate the sequence of times, you'll need to seed the call to `periodic-seq` with the next time you want the function to run: 86 | 87 | [source,clojure] 88 | ---- 89 | (import '[java.time LocalTime ZonedDateTime ZoneId Period]) 90 | 91 | (chime/periodic-seq (-> (LocalTime/of 20 0 0) 92 | (.adjustInto (ZonedDateTime/now (ZoneId/of "America/New_York"))) 93 | .toInstant) 94 | (Period/ofDays 1)) 95 | ---- 96 | 97 | For example, to say hello once per second: 98 | [source,clojure] 99 | ---- 100 | (chime/chime-at (chime/periodic-seq (Instant/now) (Duration/ofSeconds 1)) 101 | ;; note that the function needs to take an argument. 102 | (fn [time] 103 | (println "hello"))) 104 | ---- 105 | 106 | === Complex schedules 107 | 108 | Because there is no scheduling DSL included with Chime, the sorts of schedules that you can achieve are not limited to the scope of the DSL. 109 | 110 | Instead, complex schedules can be expressed with liberal use of standard Clojure sequence-manipulation functions: 111 | 112 | [source,clojure] 113 | ---- 114 | (import '[java.time ZonedDateTime ZoneId Period LocalTime DayOfWeek]) 115 | 116 | ;; Every Tuesday and Friday: 117 | (->> (chime/periodic-seq (-> (LocalTime/of 0 0) 118 | (.adjustInto (ZonedDateTime/now (ZoneId/of "America/New_York"))) 119 | .toInstant) 120 | (Period/ofDays 1)) 121 | 122 | (map #(.atZone % (ZoneId/of "America/New_York"))) 123 | 124 | (filter (comp #{DayOfWeek/TUESDAY DayOfWeek/FRIDAY} 125 | #(.getDayOfWeek %)))) 126 | 127 | ;; Week-days 128 | (->> (chime/periodic-seq ...) 129 | (map #(.atZone % (ZoneId/of "America/New_York"))) 130 | (remove (comp #{DayOfWeek/SATURDAY DayOfWeek/SUNDAY} 131 | #(.getDayOfWeek %)))) 132 | 133 | ;; Last Monday of the month: 134 | (->> (chime/periodic-seq ...) 135 | 136 | (map #(.atZone % (ZoneId/of "America/New_York"))) 137 | 138 | ;; Get all the Mondays 139 | (filter (comp #{DayOfWeek/MONDAY} 140 | #(.getDayOfWeek %))) 141 | 142 | ;; Split into months 143 | ;; (Make sure you use partition-by, not group-by - 144 | ;; it's an infinite series!) 145 | (partition-by #(.getMonth %)) 146 | 147 | ;; Only keep the last one in each month 148 | (map last)) 149 | 150 | ;; 'Triple witching days': 151 | ;; (The third Fridays in March, June, September and December) 152 | ;; (see http://en.wikipedia.org/wiki/Triple_witching_day) 153 | 154 | ;; Here we have to revert the start day to the first day of the month 155 | ;; so that when we split by month, we know which Friday is the third 156 | ;; Friday. 157 | 158 | (->> (chime/periodic-seq (-> (LocalTime/of 0 0) 159 | (.adjustInto (-> (ZonedDateTime/now (ZoneId/of "America/New_York")) 160 | (.withDayOfMonth 1))) 161 | .toInstant) 162 | (Period/ofDays 1)) 163 | 164 | (map #(.atZone % (ZoneId/of "America/New_York"))) 165 | 166 | (filter (comp #{DayOfWeek/FRIDAY} 167 | #(.getDayOfWeek %))) 168 | 169 | (filter (comp #{3 6 9 12} 170 | #(.getMonthValue %))) 171 | 172 | ;; Split into months 173 | (partition-by #(.getMonthValue %)) 174 | 175 | ;; Only keep the third one in each month 176 | (map #(nth % 2)) 177 | 178 | (chime/without-past-times))) 179 | ---- 180 | 181 | === Error handling 182 | 183 | You can pass an error-handler to `chime-at` - a function that takes the exception as an argument. 184 | Return truthy from this function to continue the schedule, falsy to cancel it. 185 | By default, Chime will log the error and continue the schedule. 186 | 187 | [source,clojure] 188 | ---- 189 | (chime-at [times...] 190 | do-task-fn 191 | {:error-handler (fn [e] 192 | ;; log, alert, notify etc? 193 | )}) 194 | ---- 195 | 196 | === `core.async` 197 | If you already have Clojure's core.async in your project, you may prefer `chime.core-async/chime-ch` 198 | 199 | `chime-ch` is called with an ordered sequence of https://docs.oracle.com/javase/8/docs/api/java/time/Instant.html[instants], and returns a channel that sends an event at each time in the sequence. 200 | 201 | [source,clojure] 202 | ---- 203 | (require '[chime.core-async :refer [chime-ch]] 204 | '[clojure.core.async :as a :refer [ (t/now) 13 | (.withMillisOfSecond 0)) 14 | (-> 1 t/seconds)))] 15 | (a/ times chime/without-past-times) 38 | {:ch ch})) 39 | 40 | (defn ^:deprecated chime-at 41 | "Deprecated: use `chime.core/chime-at` instead - see the source of this fn for a migration. 42 | 43 | Calls `f` with the current time at every time in the `times` list." 44 | [times f & [{:keys [error-handler on-finished] 45 | :or {on-finished #()} 46 | :as opts}]] 47 | (defonce chime-at-deprecated-warning 48 | (log/warn "`chime/chime-at` has moved to chime.core/chime-at. see source of `chime/chime-at` for the migration")) 49 | 50 | (let [sched (chime/chime-at (->> times (chime/without-past-times)) 51 | f 52 | (assoc opts 53 | :error-handler (fn [e] 54 | (if error-handler 55 | (try 56 | (error-handler e) 57 | true 58 | (catch Exception e 59 | false)) 60 | (do 61 | (log/warn e "Error running Chime schedule") 62 | (not (instance? InterruptedException e)))))))] 63 | (fn close [] 64 | (.close ^AutoCloseable sched)))) 65 | -------------------------------------------------------------------------------- /src/chime/core.clj: -------------------------------------------------------------------------------- 1 | (ns chime.core 2 | "Lightweight scheduling library." 3 | (:require [clojure.tools.logging :as log]) 4 | (:import (clojure.lang IDeref IBlockingDeref IPending) 5 | (java.time ZonedDateTime Instant Clock) 6 | (java.time.temporal ChronoUnit TemporalAmount) 7 | (java.util Date) 8 | (java.util.concurrent Executors ScheduledExecutorService ThreadFactory TimeUnit) 9 | (java.lang AutoCloseable Thread$UncaughtExceptionHandler))) 10 | 11 | ;; --------------------------------------------------------------------- time helpers 12 | (defonce utc-clock (Clock/systemUTC)) 13 | 14 | (def ^:dynamic *clock* 15 | "The clock used to determine 'now'; you can override it with `binding` for 16 | testing purposes." 17 | utc-clock) 18 | 19 | (defn now 20 | "Returns a date time for the current instant." 21 | (^java.time.Instant [] 22 | (now *clock*)) 23 | (^java.time.Instant [^Clock clock] 24 | (Instant/now clock))) 25 | 26 | (defprotocol ->Instant 27 | (->instant ^java.time.Instant [obj] 28 | "Convert `obj` to an Instant instance.")) 29 | 30 | (extend-protocol ->Instant 31 | Date 32 | (->instant [^Date date] 33 | (.toInstant date)) 34 | 35 | Instant 36 | (->instant [inst] inst) 37 | 38 | Long 39 | (->instant [epoch-msecs] 40 | (Instant/ofEpochMilli epoch-msecs)) 41 | 42 | ZonedDateTime 43 | (->instant [zdt] 44 | (.toInstant zdt))) 45 | 46 | (def ^:private default-thread-factory 47 | (let [!count (atom 0)] 48 | (reify ThreadFactory 49 | (newThread [_ r] 50 | (doto (Thread. r) 51 | (.setName (format "chime-%d" (swap! !count inc)))))))) 52 | 53 | (defn- default-error-handler [e] 54 | (log/warn e "Error running scheduled fn") 55 | (not (instance? InterruptedException e))) 56 | 57 | (defn chime-at 58 | "Calls `f` with the current time at every time in the `times` sequence. 59 | 60 | ``` 61 | (:require [chime.core :as chime]) 62 | (:import [java.time Instant]) 63 | 64 | (let [now (Instant/now)] 65 | (chime/chime-at [(.plusSeconds now 2) 66 | (.plusSeconds now 4)] 67 | (fn [time] 68 | (println \"Chiming at\" time))) 69 | ``` 70 | 71 | Returns an AutoCloseable that you can `.close` to stop the schedule. 72 | You can also deref the return value to wait for the schedule to finish. 73 | 74 | Providing a custom `thread-factory` is supported, but optional (see `chime.core/default-thread-factory`). 75 | Providing a custom `clock` is supported, but optional (see `chime.core/utc-clock`). 76 | 77 | When the schedule is either cancelled or finished, will call the `on-finished` handler. 78 | 79 | You can pass an error-handler to `chime-at` - a function that takes the exception as an argument. 80 | Return truthy from this function to continue the schedule, falsy to cancel it. 81 | By default, Chime will log the error and continue the schedule. 82 | " 83 | (^java.lang.AutoCloseable [times f] (chime-at times f {})) 84 | 85 | (^AutoCloseable [times f {:keys [error-handler on-finished thread-factory clock] 86 | :or {error-handler default-error-handler 87 | thread-factory default-thread-factory 88 | clock *clock*}}] 89 | (let [pool (Executors/newSingleThreadScheduledExecutor thread-factory) 90 | !latch (promise) 91 | f (bound-fn* f)] 92 | (letfn [(close [] 93 | (.shutdown pool) 94 | (when (and (deliver !latch nil) on-finished) 95 | (on-finished))) 96 | 97 | (schedule-loop [[time & times]] 98 | (letfn [(task [] 99 | (if (try 100 | (f time) 101 | true 102 | (catch Exception e 103 | (try 104 | (error-handler e) 105 | (catch Exception e 106 | (log/error e "error calling chime error-handler, stopping schedule"))))) 107 | 108 | (schedule-loop times) 109 | (close)))] 110 | 111 | (if time 112 | (.schedule pool ^Runnable task (.between ChronoUnit/MILLIS (now clock) time) TimeUnit/MILLISECONDS) 113 | (close))))] 114 | 115 | (schedule-loop (map ->instant times)) 116 | 117 | (reify 118 | AutoCloseable 119 | (close [_] 120 | (.shutdownNow pool) 121 | (or (.awaitTermination pool 1 TimeUnit/MINUTES) 122 | (log/warn "Failed to terminate schedule pool after 1 minute.")) 123 | (close)) 124 | 125 | IDeref 126 | (deref [_] (deref !latch)) 127 | 128 | IBlockingDeref 129 | (deref [_ ms timeout-val] (deref !latch ms timeout-val)) 130 | 131 | IPending 132 | (isRealized [_] (realized? !latch))))))) 133 | 134 | (defn periodic-seq [^Instant start ^TemporalAmount duration-or-period] 135 | (iterate #(.addTo duration-or-period ^Instant %) start)) 136 | 137 | (defn without-past-times 138 | ([times] (without-past-times times (now))) 139 | 140 | ([times now] 141 | (->> times 142 | (drop-while #(.isBefore ^Instant (->instant %) now))))) 143 | -------------------------------------------------------------------------------- /src/chime/core_async.clj: -------------------------------------------------------------------------------- 1 | (ns chime.core-async 2 | (:require [chime.core :as chime] 3 | [clojure.core.async :as a :refer [! go-loop]] 4 | [clojure.core.async.impl.protocols :as p])) 5 | 6 | (defn chime-ch 7 | "Returns a core.async channel that 'chimes' at every time in the times list. 8 | 9 | Arguments: 10 | times - (required) Sequence of java.util.Dates, java.time.Instant, 11 | java.time.ZonedDateTime or msecs since epoch 12 | 13 | ch - (optional) Channel to chime on - defaults to a new unbuffered channel 14 | Closing this channel stops the schedule. 15 | 16 | Usage: 17 | 18 | (let [chimes (chime-ch [(.plusSeconds (Instant/now) -2) 19 | (.plusSeconds (Instant/now) 2) 20 | (.plusSeconds (Instant/now) 2)])] 21 | (a/!! ch time)) 32 | {:on-finished (fn [] 33 | (a/close! ch))})] 34 | (reify 35 | p/ReadPort 36 | (take! [_ handler] 37 | (p/take! ch handler)) 38 | 39 | p/Channel 40 | (close! [_] (.close sched)) 41 | (closed? [_] (p/closed? ch))))) 42 | -------------------------------------------------------------------------------- /src/chime/joda_time.clj: -------------------------------------------------------------------------------- 1 | (ns chime.joda-time 2 | (:require [chime.core :as chime] 3 | [clj-time.core :as t]) 4 | (:import [java.time Instant] 5 | [org.joda.time ReadableInstant])) 6 | 7 | (extend-protocol chime/->Instant 8 | ReadableInstant 9 | (->instant [jt-instant] 10 | (Instant/ofEpochMilli (.getMillis jt-instant)))) 11 | -------------------------------------------------------------------------------- /src/chime/util.clj: -------------------------------------------------------------------------------- 1 | (ns chime.util) 2 | 3 | (defn merge-schedules [left right] 4 | (lazy-seq 5 | (case [(boolean (seq left)) (boolean (seq right))] 6 | [false false] [] 7 | [false true] right 8 | [true false] left 9 | [true true] (let [[l & lmore] left 10 | [r & rmore] right] 11 | (if (.isBefore l r) 12 | (cons l (merge-schedules lmore right)) 13 | (cons r (merge-schedules left rmore))))))) 14 | -------------------------------------------------------------------------------- /test/chime/core_async_test.clj: -------------------------------------------------------------------------------- 1 | (ns chime.core-async-test 2 | (:require [chime.core-async :as sut] 3 | [chime.core-test :refer [check-timeliness!]] 4 | [clojure.test :as t] 5 | [clojure.core.async :as a :refer [go-loop]]) 6 | (:import [java.time Instant])) 7 | 8 | (t/deftest test-chime-ch 9 | (let [now (Instant/now) 10 | times [(.minusSeconds now 2) 11 | (.plusSeconds now 1) 12 | (.plusSeconds now 2)] 13 | chimes (sut/chime-ch times) 14 | proof (atom [])] 15 | 16 | (a/> [value taken-at] 12 | (map #(.toEpochMilli ^Instant %)) 13 | ^long (apply -) 14 | (Math/abs))]] 15 | (t/is (< diff 20) 16 | (format "Expected to run at ±%s but run at %s, i.e. diff of %dms" value taken-at diff)))) 17 | 18 | (t/deftest test-chime-at 19 | (let [times [(.minusSeconds (Instant/now) 2) 20 | (.plusSeconds (Instant/now) 1) 21 | (.plusSeconds (Instant/now) 2)] 22 | proof (atom [])] 23 | (with-open [sched (chime/chime-at times 24 | (fn [t] 25 | (swap! proof conj [t (Instant/now)])))] 26 | (Thread/sleep 2500)) 27 | (t/is (= times (mapv first @proof))) 28 | (check-timeliness! (rest @proof)))) 29 | 30 | (t/deftest empty-times 31 | (t/testing "Empty or completely past sequences are acceptable" 32 | (let [proof (atom false)] 33 | (chime/chime-at [] 34 | identity 35 | {:on-finished (fn [] 36 | (reset! proof true))}) 37 | 38 | (t/is @proof)))) 39 | 40 | (t/deftest test-on-finished 41 | (let [proof (atom false)] 42 | (chime/chime-at [(.plusMillis (Instant/now) 500) (.plusMillis (Instant/now) 1000)] 43 | (fn [time]) 44 | {:on-finished (fn [] 45 | (Thread/sleep 100) 46 | (reset! proof true))}) 47 | (Thread/sleep 1200) 48 | (t/is @proof))) 49 | 50 | (t/deftest test-error-handler 51 | (t/testing "returning true continues the schedule" 52 | (let [proof (atom []) 53 | sched (chime/chime-at [(.plusMillis (Instant/now) 500) 54 | (.plusMillis (Instant/now) 1000)] 55 | (fn [time] 56 | (throw (ex-info "boom!" {:time time}))) 57 | {:error-handler (fn [e] 58 | (swap! proof conj e) 59 | true)})] 60 | (t/is (not= ::timeout (deref sched 1500 ::timeout))) 61 | (t/is (= 2 (count @proof))) 62 | (t/is (every? ex-data @proof)))) 63 | 64 | (t/testing "returning false stops the schedule" 65 | (let [proof (atom []) 66 | sched (chime/chime-at [(.plusMillis (Instant/now) 500) 67 | (.plusMillis (Instant/now) 1000)] 68 | (fn [time] 69 | (throw (ex-info "boom!" {:time time}))) 70 | {:error-handler (fn [e] 71 | (swap! proof conj e) 72 | false)})] 73 | (t/is (not= ::timeout (deref sched 1500 ::timeout))) 74 | (t/is (= 1 (count @proof))) 75 | (t/is (every? ex-data @proof))))) 76 | 77 | (t/deftest test-long-running-jobs 78 | (let [proof (atom []) 79 | !latch (promise) 80 | now (Instant/now) 81 | times (->> (chime/periodic-seq now (Duration/ofMillis 500)) 82 | (take 3)) 83 | sched (chime/chime-at times 84 | (fn [time] 85 | (swap! proof conj [time (Instant/now)]) 86 | (Thread/sleep 750)))] 87 | 88 | (t/is (not= ::nope (deref sched 4000 ::nope))) 89 | (t/is (= times (map first @proof))) 90 | (check-timeliness! (map vector 91 | (->> (chime/periodic-seq now (Duration/ofMillis 750)) 92 | (take 3)) 93 | (map second @proof))))) 94 | 95 | (t/deftest test-cancelling-overrunning-task 96 | (let [!proof (atom []) 97 | !error (atom nil) 98 | !latch (promise)] 99 | (with-open [sched (chime/chime-at (chime/periodic-seq (Instant/now) (Duration/ofSeconds 1)) 100 | (fn [now] 101 | (swap! !proof conj now) 102 | (Thread/sleep 3000)) 103 | {:error-handler (fn [e] 104 | (reset! !error e)) 105 | :on-finished (fn [] 106 | (deliver !latch nil))})] 107 | (Thread/sleep 2000)) 108 | 109 | (t/is (not= ::timeout (deref !latch 500 ::timeout))) 110 | 111 | (t/is (= 1 (count @!proof))) 112 | (t/is (instance? InterruptedException @!error)))) 113 | 114 | (t/deftest test-only-call-on-finished-once-36 115 | (let [!count (atom 0) 116 | now (Instant/now)] 117 | (with-open [chiming (chime/chime-at [(.plusMillis now 500) 118 | (.plusMillis now 500)] 119 | (fn [time]) 120 | {:on-finished #(swap! !count inc)})] 121 | (Thread/sleep 1000)) 122 | 123 | (t/is (= 1 @!count)))) 124 | -------------------------------------------------------------------------------- /test/chime_test.clj: -------------------------------------------------------------------------------- 1 | (ns chime-test 2 | (:require 3 | [chime :refer :all] 4 | [clojure.core.async :as a :refer [ms [d] 14 | (.toEpochMilli d)) 15 | 16 | (defn now 17 | [] 18 | (Instant/now)) 19 | 20 | (defn check-timeliness! 21 | "Checks whether the chimes actually happended at the time for they were scheduled." 22 | [proof] 23 | (doseq [[value taken-at] @proof 24 | :let [diff (->> [value taken-at] 25 | (map date->ms) 26 | ^long (apply -) 27 | (Math/abs))]] 28 | (is (< diff 20) (str "Expected to run at ±" value " but run at " 29 | taken-at ", i.e. diff of " 30 | diff "ms")))) 31 | 32 | (deftest test-chime-at 33 | (let [will-be-omitted (.minusSeconds (now) 2) 34 | t1 (.plusSeconds (now) 2) 35 | t2 (.plusSeconds (now) 3) 36 | proof (atom [])] 37 | (chime-at [will-be-omitted t1 t2] 38 | (fn [t] 39 | (swap! proof conj [t 40 | (chime-test/now)]))) 41 | (while (not (= (list t1 t2) 42 | (map first @proof)))) 43 | (is (= [t1 t2] 44 | (mapv first @proof))) 45 | (check-timeliness! proof))) 46 | 47 | (deftest test-error-handler 48 | (testing "continues the schedule" 49 | (let [proof (atom []) 50 | !latch (promise) 51 | sched (chime-at [(.plusMillis (Instant/now) 500) 52 | (.plusMillis (Instant/now) 1000)] 53 | (fn [time] 54 | (throw (ex-info "boom!" {:time time}))) 55 | {:error-handler (fn [e] 56 | (swap! proof conj e) 57 | nil) 58 | :on-finished (fn [] (deliver !latch nil))})] 59 | (is (not= ::timeout (deref !latch 1500 ::timeout))) 60 | (is (= 2 (count @proof))) 61 | (is (every? ex-data @proof)))) 62 | 63 | (testing "rethrowing the error stops the schedule" 64 | (let [proof (atom []) 65 | !latch (promise) 66 | sched (chime-at [(.plusMillis (Instant/now) 500) 67 | (.plusMillis (Instant/now) 1000)] 68 | (fn [time] 69 | (throw (ex-info "boom!" {:time time}))) 70 | {:error-handler (fn [e] 71 | (swap! proof conj e) 72 | (throw e)) 73 | :on-finished (fn [] (deliver !latch nil))})] 74 | (is (not= ::timeout (deref !latch 1500 ::timeout))) 75 | (is (= 1 (count @proof))) 76 | (is (every? ex-data @proof))))) 77 | 78 | (deftest test-chime-ch 79 | (let [will-be-omitted (.minusSeconds (now) 2) 80 | t1 (.plusSeconds (now) 2) 81 | t2 (.plusSeconds (now) 3) 82 | chimes (chime-ch [will-be-omitted 83 | t1 84 | t2]) 85 | proof (atom [])] 86 | (a/> (chime/periodic-seq (-> (.plusSeconds (chime-test/now) 2) 125 | (.truncatedTo (ChronoUnit/SECONDS))) 126 | (java.time.Duration/ofSeconds 1)) 127 | (take 3)))] 128 | 129 | (swap! proof conj [(a/ (t/date-time 1990 1 1) 170 | (t/from-time-zone (t/time-zone-for-offset -3)) 171 | (clj-time.periodic/periodic-seq (t/days 1)) 172 | (as-> x 173 | (take 100 x)))] 174 | (is (empty? (chime/without-past-times times))))) 175 | --------------------------------------------------------------------------------