Repository: CSCfi/rems (clojure)

Issue:
Configurable cache reload timing
Configurable `all-applications-cache-reloader` timing. Don't always do it once per hour but maybe don't reload the cache during busy hours.

Pull request:
Close #3194 

# Checklist for author

Remove items that aren't applicable, check items that are done.

## Reviewability
- [x] Link to issue

## Backwards compatibility
- [x] Config is backwards compatible

## Documentation
- [x] Update changelog if necessary
- [ ] Update docs/ (if applicable)
- [x] New config options in config-defaults.edn

## Testing
- [x] Complex logic is unit tested

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9860746e0..f2fd800ba 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,7 @@ Changes since v2.33
 - (Experimental) Workflow can be configured to enable voting for the approval. Currently all handlers can vote (including bots). Use `:enable-voting`. (#3174)
 - There is now a Danish language translation (#3176). We are considering supporting a limited set of languages officially, and improving support for community maintained translations (see #3179).
 - Added experimental support for named format parameters in translations. (#3183)
+- Cache reloading can be configured using the new `:buzy-hours` config. REMS will then try to avoid reloading during the specified time spans. (#3194)
 
 ### Fixes
 - Email template parameters for `:application-expiration-notification` event are now documented. The parameters are different from standard event email parameters, which may have caused confusion.
diff --git a/resources/config-defaults.edn b/resources/config-defaults.edn
index 57e727cf5..3e8fb95f0 100644
--- a/resources/config-defaults.edn
+++ b/resources/config-defaults.edn
@@ -466,4 +466,19 @@
  ;; See docs/plugins.md for more information.
  :plugins nil ; by default no plugins
  :extension-points nil ; by default no plugins
- :enable-voting false} ; voting (experimental)
+ :enable-voting false ; voting (experimental)
+
+ ;; REMS can be configured to avoid heavy work
+ ;; (cache reloading) during pre-defined buzy hours.
+ ;;
+ ;; For example config:
+ ;;
+ ;; :buzy-hours [["07:00" "11:00"]
+ ;;              ["12:00" "17:00"]]
+ ;;
+ ;; would define the buzy hours to be (server time) from seven (07:00) in
+ ;; the morning to eleven (11:00). Then after a break another buzy hour period
+ ;; from noon (12:00) until five (17:00) in the evening.
+ ;;
+ ;; As few or as many periods can be defined.
+ :buzy-hours nil}
diff --git a/src/clj/rems/db/applications.clj b/src/clj/rems/db/applications.clj
index 34ce34637..b333a5456 100644
--- a/src/clj/rems/db/applications.clj
+++ b/src/clj/rems/db/applications.clj
@@ -283,7 +283,10 @@
 
 ;; empty the cache occasionally in case some of the injected entities are changed
 (mount/defstate all-applications-cache-reloader
-  :start (scheduler/start! "all-applications-cache-reloader" reload-cache! (Duration/standardHours 1))
+  :start (scheduler/start! "all-applications-cache-reloader"
+                           reload-cache!
+                           (Duration/standardHours 1)
+                           (select-keys env [:buzy-hours]))
   :stop (scheduler/stop! all-applications-cache-reloader))
 
 (defn delete-application!
diff --git a/src/clj/rems/scheduler.clj b/src/clj/rems/scheduler.clj
index 57d651975..a79f6881b 100644
--- a/src/clj/rems/scheduler.clj
+++ b/src/clj/rems/scheduler.clj
@@ -1,29 +1,74 @@
 (ns rems.scheduler
-  (:require [clojure.pprint :refer [pprint]]
-            [clojure.tools.logging :as log])
+  (:require [clj-time.core :as time-core]
+            [clj-time.format :as time-format]
+            [clojure.pprint :refer [pprint]]
+            [clojure.tools.logging :as log]
+            [clojure.test :refer [deftest is]])
   (:import [java.util.concurrent ScheduledThreadPoolExecutor TimeUnit ExecutorService]
-           [org.joda.time Duration]))
+           [org.joda.time Duration Interval]))
 
-(defn ^ExecutorService start! [name f ^Duration interval]
+(defn- seconds-since-midnight [hour-minute-string]
+  (->> hour-minute-string
+       (time-format/parse (time-format/formatters :hour-minute))
+       (time-core/interval (time-core/epoch))
+       time-core/in-seconds
+       time-core/seconds))
+
+(defn- time-on-day [dt hour-minute-string]
+  (time-core/plus
+   (time-core/with-time-at-start-of-day dt)
+   (seconds-since-midnight hour-minute-string)))
+
+(defn- intervals-on-day [dt hour-minute-strings]
+  (for [[start end] hour-minute-strings]
+    (time-core/interval (time-on-day dt start)
+                        (time-on-day dt end))))
+
+(defn- time-within-intervals? [dt intervals]
+  (some? (some #(time-core/within? ^Interval % dt) intervals)))
+
+(deftest test-time-within-intervals?
+  (let [buzy-hours (intervals-on-day (time-core/date-time 2023 10 7)
+                                     [["07:00" "11:00"]
+                                      ["12:00" "17:00"]])
+        at (fn [& [h m s]] (time-core/date-time 2023 10 7 (or h 0) (or m 0) (or s 0)))]
+    (is (not (time-within-intervals? (at 0) buzy-hours)))
+    (is (not (time-within-intervals? (at 6 59 59) buzy-hours)))
+    (is (time-within-intervals? (at 7) buzy-hours))
+    (is (time-within-intervals? (at 10 59 59) buzy-hours))
+    (is (not (time-within-intervals? (at 11) buzy-hours)))
+    (is (not (time-within-intervals? (at 11 15) buzy-hours)))
+    (is (time-within-intervals? (at 12) buzy-hours))
+    (is (not (time-within-intervals? (at 17) buzy-hours)))
+    (is (not (time-within-intervals? (at 22) buzy-hours)))))
+
+(defn- task-wrapper [f opts]
+  (try
+    (log/debug "Scheduler starting work")
+    (let [now (time-core/now)
+          buzy-hours (intervals-on-day now (:buzy-hours opts))
+          buzy-hour? (time-within-intervals? now buzy-hours)]
+      (if buzy-hour?
+        (log/debug "Scheduler skipping because of buzy hour" (:buzy-hours opts))
+        (f)))
+    (log/debug "Scheduler is done")
+    (catch InterruptedException e
+      (.interrupt (Thread/currentThread))
+      (log/info e "Scheduler shutting down"))
+    (catch Throwable t ; prevents suppressing subsequent executions
+      (log/error t "Internal error" (with-out-str (when-let [data (ex-data t)]
+                                                    (pprint data)))))))
+
+(defn start! ^ExecutorService [name f ^Duration interval & [opts]]
   (let [interval-millis (.getMillis interval)
-        task (fn []
-               (try
-                 (log/debug "Scheduler starting work")
-                 (f)
-                 (log/debug "Scheduler is done")
-                 (catch InterruptedException e
-                   (.interrupt (Thread/currentThread))
-                   (log/info e "Scheduler shutting down"))
-                 (catch Throwable t ; prevents suppressing subsequent executions
-                   (log/error t "Internal error" (with-out-str (when-let [data (ex-data t)]
-                                                                 (pprint data)))))))
         factory (proxy [java.util.concurrent.ThreadFactory] []
                   (newThread [r]
                     (let [thread (.newThread (java.util.concurrent.Executors/defaultThreadFactory) r)]
                       (.setName thread (str name "-" (.getName thread)))
                       thread)))]
+
     (doto (ScheduledThreadPoolExecutor. 1 factory)
-      (.scheduleWithFixedDelay task interval-millis interval-millis TimeUnit/MILLISECONDS))))
+      (.scheduleWithFixedDelay #(task-wrapper f opts) interval-millis interval-millis TimeUnit/MILLISECONDS))))
 
 (defn stop! [^ExecutorService scheduler]
   (.shutdownNow scheduler)


Test patch:
diff --git a/test/clj/rems/test_scheduler.clj b/test/clj/rems/test_scheduler.clj
new file mode 100644
index 000000000..6a157f3f9
--- /dev/null
+++ b/test/clj/rems/test_scheduler.clj
@@ -0,0 +1,24 @@
+(ns rems.test-scheduler
+  (:require [clj-time.core :as time-core]
+            [clojure.test :refer [deftest is]]
+            [rems.scheduler]
+            [rems.testing-util :refer [with-fixed-time]]))
+
+(deftest test-buzy-hours
+  (let [side-effect (atom nil)]
+    (with-fixed-time (time-core/date-time 2021 1 1 8 0 0)
+      (#'rems.scheduler/task-wrapper #(reset! side-effect 1) nil)
+      (is (= 1 @side-effect)))
+
+    (with-fixed-time (time-core/date-time 2021 1 1 6 59 59)
+      (#'rems.scheduler/task-wrapper #(reset! side-effect 2) {:buzy-hours [["07:00" "17:00"]]})
+      (is (= 2 @side-effect)))
+
+    (with-fixed-time (time-core/date-time 2021 1 1 8 0 0)
+      (#'rems.scheduler/task-wrapper #(reset! side-effect 3) {:buzy-hours [["07:00" "17:00"]]})
+      (is (= 2 @side-effect)
+          "scheduled task is not executed during buzy hour"))
+
+    (with-fixed-time (time-core/date-time 2021 1 1 17 0 0)
+      (#'rems.scheduler/task-wrapper #(reset! side-effect 4) {:buzy-hours [["07:00" "17:00"]]})
+      (is (= 4 @side-effect)))))