diff --git a/AGENTS.md b/AGENTS.md index 0ce16d5..1126fbe 100755 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,6 +47,7 @@ - `:webserver` - `:source` - `:just-auth` + - `:cache` enables runtime in-memory caches when `true`; the default is `false`. - `:agiladmin :webserver` now separates internal bind settings from public URL settings: - `:host` and `:port` are Jetty bind values. - `:base-host` and `:base-path` are browser-facing URL parts. diff --git a/src/agiladmin/config.clj b/src/agiladmin/config.clj index dc3cf55..2e8412b 100644 --- a/src/agiladmin/config.clj +++ b/src/agiladmin/config.clj @@ -34,6 +34,7 @@ :ssh-key s/Str :path s/Str} (s/optional-key :projects) [s/Str] + (s/optional-key :cache) s/Bool (s/optional-key :webserver) {(s/optional-key :port) s/Num (s/optional-key :host) s/Str (s/optional-key :base-host) s/Str @@ -84,6 +85,7 @@ {:git "ssh://git@my.server.org/admin-budgets" :ssh-key "id_rsa" :path "budgets/"} + :cache false :webserver {:base-host "" :base-path "/" diff --git a/src/agiladmin/core.clj b/src/agiladmin/core.clj index 5fd74b8..0aa277d 100644 --- a/src/agiladmin/core.clj +++ b/src/agiladmin/core.clj @@ -41,11 +41,9 @@ (def timesheet-cols-projects ["B" "C" "D" "E" "F" "G" "H"]) (def timesheet-rows-hourtots [43 42 41 40 39 38]) -;; Memory-only project cache keyed by budgets path. Reload invalidates it when -;; the repo state changes, so no filesystem metadata or watcher is needed. +;; Optional memory-only caches keyed by budgets path. Reload invalidates them +;; when the repo state changes, so no filesystem metadata or watcher is needed. (def project-cache (atom {})) -;; Memory-only timesheet cache keyed by budgets path. Successful timesheet -;; commits invalidate it after the repository adopts new workbook content. (def timesheet-cache (atom {})) (def recent-projects-cache (atom {})) @@ -53,6 +51,11 @@ (declare load-all-projects) (declare load-timesheet) +(defn cache-enabled? + "Return true when runtime caching is explicitly enabled in configuration." + [conf] + (true? (get-in conf [:agiladmin :cache]))) + (defn repl "load all deps for repl" @@ -356,20 +359,27 @@ ;; (save-workbook! file wb) ;; wb)) - (defn load-all-timesheets - "Load direct-child timesheets from a budgets directory, reusing an - in-memory cache until invalidate-timesheet-cache! is called for that path." - [path regex] - (let [cache-key path] - (if-let [cached-timesheets (get @timesheet-cache cache-key)] - cached-timesheets - (let [timesheets - (vec - (for [l (map #(.getName %) (util/list-direct-files-matching path regex)) - :when (not= (first l) '\.)] - (load-timesheet (str path l))))] - (swap! timesheet-cache assoc cache-key timesheets) - timesheets)))) +(defn- load-all-timesheets-fresh + [path regex] + (vec + (for [l (map #(.getName %) (util/list-direct-files-matching path regex)) + :when (not= (first l) '\.)] + (load-timesheet (str path l))))) + +(defn load-all-timesheets + "Load direct-child timesheets from a budgets directory. Reuse the in-memory + cache only when :agiladmin :cache is explicitly true." + ([path regex] + (load-all-timesheets nil path regex)) + ([conf path regex] + (if-not (cache-enabled? conf) + (load-all-timesheets-fresh path regex) + (let [cache-key path] + (if-let [cached-timesheets (get @timesheet-cache cache-key)] + cached-timesheets + (let [timesheets (load-all-timesheets-fresh path regex)] + (swap! timesheet-cache assoc cache-key timesheets) + timesheets)))))) (defn invalidate-timesheet-cache! "Clear cached timesheets for one budgets path, or all paths with no arg." @@ -385,26 +395,34 @@ (= cache-path path)) cache))))))) +(defn- recent-project-names-fresh + [conf path current-year] + (let [recent-years #{current-year (dec current-year)}] + (->> (map-timesheets (load-all-timesheets conf path #".*_timesheet_.*xlsx$") + load-monthly-hours + (fn [info] + (when-let [month (:month info)] + (contains? recent-years + (some-> month str (split #"-") first Integer/parseInt))))) + :rows + (keep (comp upper-case trim :project)) + set))) + (defn recent-project-names "Return the uppercase project names with any recorded hour in the current or - previous year. Results are cached per budgets path and current year." - [path current-year] - (let [cache-key [path current-year] - recent-years #{current-year (dec current-year)}] - (if-let [cached-projects (get @recent-projects-cache cache-key)] - cached-projects - (let [projects - (->> (map-timesheets (load-all-timesheets path #".*_timesheet_.*xlsx$") - load-monthly-hours - (fn [info] - (when-let [month (:month info)] - (contains? recent-years - (some-> month str (split #"-") first Integer/parseInt))))) - :rows - (keep (comp upper-case trim :project)) - set)] - (swap! recent-projects-cache assoc cache-key projects) - projects)))) + previous year. Reuse the in-memory cache only when :agiladmin :cache is + explicitly true." + ([path current-year] + (recent-project-names nil path current-year)) + ([conf path current-year] + (if-not (cache-enabled? conf) + (recent-project-names-fresh conf path current-year) + (let [cache-key [path current-year]] + (if-let [cached-projects (get @recent-projects-cache cache-key)] + cached-projects + (let [projects (recent-project-names-fresh conf path current-year)] + (swap! recent-projects-cache assoc cache-key projects) + projects)))))) (defn- projects-cache-key [conf] @@ -446,11 +464,13 @@ (keys files))))) (defn load-all-projects [conf] - "Load project budgets for one budgets path, reusing an in-memory cache until - invalidate-project-cache! is called for that path." - (let [cache-key (projects-cache-key conf)] - (if-let [cached-projects (get @project-cache cache-key)] - cached-projects - (let [projects (load-all-projects-fresh conf)] - (swap! project-cache assoc cache-key projects) - projects)))) + "Load project budgets for one budgets path. Reuse the in-memory cache only + when :agiladmin :cache is explicitly true." + (if-not (cache-enabled? conf) + (load-all-projects-fresh conf) + (let [cache-key (projects-cache-key conf)] + (if-let [cached-projects (get @project-cache cache-key)] + cached-projects + (let [projects (load-all-projects-fresh conf)] + (swap! project-cache assoc cache-key projects) + projects))))) diff --git a/src/agiladmin/view_project.clj b/src/agiladmin/view_project.clj index 35006d1..d5c46fd 100644 --- a/src/agiladmin/view_project.clj +++ b/src/agiladmin/view_project.clj @@ -54,7 +54,7 @@ (defn project-hours [config projname] (let [ts-path (conf/q config [:agiladmin :budgets :path]) - timesheets (load-all-timesheets ts-path #".*_timesheet_.*xlsx$")] + timesheets (load-all-timesheets config ts-path #".*_timesheet_.*xlsx$")] (load-project-monthly-hours timesheets projname))) (defn project-costs diff --git a/test/agiladmin/config_test.clj b/test/agiladmin/config_test.clj index 480e8cb..b357887 100644 --- a/test/agiladmin/config_test.clj +++ b/test/agiladmin/config_test.clj @@ -163,7 +163,8 @@ (f/failed? conf) => false (get-in conf [:agiladmin :webserver :base-host]) => "" (get-in conf [:agiladmin :webserver :base-path]) => "/" - (get-in conf [:agiladmin :webserver :upload-max-size]) => 500000)) + (get-in conf [:agiladmin :webserver :upload-max-size]) => 500000 + (get-in conf [:agiladmin :cache]) => false)) (fact "Application config loader preserves explicit webserver base values" (let [path "/tmp/agiladmin-webserver-explicit.yaml" @@ -187,6 +188,20 @@ (get-in conf [:agiladmin :webserver :base-path]) => "/agiladmin" (get-in conf [:agiladmin :webserver :upload-max-size]) => 750000)) +(fact "Application config loader preserves explicit cache opt-in" + (let [path "/tmp/agiladmin-cache-explicit.yaml" + _ (spit path + (str "appname: agiladmin\n\n" + "agiladmin:\n" + " budgets:\n" + " git: ssh://git@example.org/admin-budgets\n" + " ssh-key: id_rsa\n" + " path: budgets/\n" + " cache: true\n")) + conf (conf/load-config path conf/default-settings)] + (f/failed? conf) => false + (get-in conf [:agiladmin :cache]) => true)) + (fact "Application config loader reports an explicit missing file" (let [conf (conf/load-config "/tmp/does-not-exist-agiladmin.yaml" conf/default-settings)] (f/failed? conf) => true @@ -202,7 +217,8 @@ (fact "Bulk project loads reuse one project file scan" (let [calls (atom 0) original-project-files @#'agiladmin.config/project-files - counting-config {:agiladmin {:budgets {:path "test/assets/"}} + counting-config {:agiladmin {:budgets {:path "test/assets/"} + :cache true} :filename "agiladmin.yaml"}] (core/invalidate-project-cache! counting-config) (with-redefs [agiladmin.config/project-files @@ -226,7 +242,8 @@ (fact "Bulk project loads reuse cached projects for the same budgets path" (let [calls (atom 0) original-project-files @#'agiladmin.config/project-files - counting-config {:agiladmin {:budgets {:path "test/assets/"}} + counting-config {:agiladmin {:budgets {:path "test/assets/"} + :cache true} :filename "agiladmin.yaml"}] (core/invalidate-project-cache! counting-config) (with-redefs [agiladmin.config/project-files @@ -237,6 +254,20 @@ (core/load-all-projects counting-config) @calls => 1))) +(fact "Bulk project loads are uncached by default" + (let [calls (atom 0) + original-project-files @#'agiladmin.config/project-files + counting-config {:agiladmin {:budgets {:path "test/assets/"}} + :filename "agiladmin.yaml"}] + (core/invalidate-project-cache! counting-config) + (with-redefs [agiladmin.config/project-files + (fn [cfg] + (swap! calls inc) + (original-project-files cfg))] + (core/load-all-projects counting-config) + (core/load-all-projects counting-config) + @calls => 2))) + (fact "Bulk project loads keep cache entries separated by budgets path" (let [dir-a "/tmp/agiladmin-project-cache-a/" dir-b "/tmp/agiladmin-project-cache-b/" @@ -244,8 +275,10 @@ _ (.mkdirs (java.io.File. dir-b)) _ (spit (str dir-a "ALPHA.yaml") "ALPHA:\n duration: 1\n") _ (spit (str dir-b "BETA.yaml") "BETA:\n duration: 2\n") - conf-a {:agiladmin {:budgets {:path dir-a}} :filename "agiladmin.yaml"} - conf-b {:agiladmin {:budgets {:path dir-b}} :filename "agiladmin.yaml"}] + conf-a {:agiladmin {:budgets {:path dir-a} :cache true} + :filename "agiladmin.yaml"} + conf-b {:agiladmin {:budgets {:path dir-b} :cache true} + :filename "agiladmin.yaml"}] (core/invalidate-project-cache! conf-a) (core/invalidate-project-cache! conf-b) (set (keys (core/load-all-projects conf-a))) => #{:ALPHA} @@ -254,7 +287,8 @@ (fact "Bulk project loads refresh after explicit cache invalidation" (let [calls (atom 0) original-project-files @#'agiladmin.config/project-files - counting-config {:agiladmin {:budgets {:path "test/assets/"}} + counting-config {:agiladmin {:budgets {:path "test/assets/"} + :cache true} :filename "agiladmin.yaml"}] (core/invalidate-project-cache! counting-config) (with-redefs [agiladmin.config/project-files diff --git a/test/agiladmin/core_test.clj b/test/agiladmin/core_test.clj index 6f8e9ff..a074eec 100644 --- a/test/agiladmin/core_test.clj +++ b/test/agiladmin/core_test.clj @@ -160,7 +160,7 @@ :tag "VOL" :hours 8.0}])) -(fact "Timesheet loads are cached per budgets path until invalidated" +(fact "Timesheet loads are uncached by default" (let [calls (atom 0)] (core/invalidate-timesheet-cache!) (with-redefs [agiladmin.utils/list-direct-files-matching @@ -172,6 +172,24 @@ {:file path})] (core/load-all-timesheets "budgets/" #".*_timesheet_.*xlsx$") (core/load-all-timesheets "budgets/" #".*_timesheet_.*xlsx$") + @calls => 2))) + +(fact "Timesheet loads are cached per budgets path when enabled" + (let [calls (atom 0)] + (core/invalidate-timesheet-cache!) + (with-redefs [agiladmin.utils/list-direct-files-matching + (fn [_ _] + [(java.io.File. "2026_timesheet_Ada-Lovelace.xlsx")]) + agiladmin.core/load-timesheet + (fn [path] + (swap! calls inc) + {:file path})] + (core/load-all-timesheets {:agiladmin {:cache true}} + "budgets/" + #".*_timesheet_.*xlsx$") + (core/load-all-timesheets {:agiladmin {:cache true}} + "budgets/" + #".*_timesheet_.*xlsx$") @calls => 1))) (fact "Timesheet caches are separated by budgets path" @@ -184,8 +202,18 @@ (fn [path] (swap! calls conj path) {:file path})] - (core/load-all-timesheets "budgets-a/" #".*_timesheet_.*xlsx$") - (core/load-all-timesheets "budgets-b/" #".*_timesheet_.*xlsx$") + (core/load-all-timesheets {:agiladmin {:cache true}} + "budgets-a/" + #".*_timesheet_.*xlsx$") + (core/load-all-timesheets {:agiladmin {:cache true}} + "budgets-b/" + #".*_timesheet_.*xlsx$") + (core/load-all-timesheets {:agiladmin {:cache true}} + "budgets-a/" + #".*_timesheet_.*xlsx$") + (core/load-all-timesheets {:agiladmin {:cache true}} + "budgets-b/" + #".*_timesheet_.*xlsx$") @calls => ["budgets-a/2026_timesheet_User.xlsx" "budgets-b/2026_timesheet_User.xlsx"]))) @@ -199,7 +227,11 @@ (fn [path] (swap! calls inc) {:file path})] - (core/load-all-timesheets "budgets/" #".*_timesheet_.*xlsx$") + (core/load-all-timesheets {:agiladmin {:cache true}} + "budgets/" + #".*_timesheet_.*xlsx$") (core/invalidate-timesheet-cache! "budgets/") - (core/load-all-timesheets "budgets/" #".*_timesheet_.*xlsx$") + (core/load-all-timesheets {:agiladmin {:cache true}} + "budgets/" + #".*_timesheet_.*xlsx$") @calls => 2)))