Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions src/agiladmin/config.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 "/"
Expand Down
108 changes: 64 additions & 44 deletions src/agiladmin/core.clj
Original file line number Diff line number Diff line change
Expand Up @@ -41,18 +41,21 @@

(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 {}))

(declare load-all-timesheets)
(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"
Expand Down Expand Up @@ -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."
Expand All @@ -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]
Expand Down Expand Up @@ -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)))))
2 changes: 1 addition & 1 deletion src/agiladmin/view_project.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
46 changes: 40 additions & 6 deletions test/agiladmin/config_test.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -237,15 +254,31 @@
(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/"
_ (.mkdirs (java.io.File. dir-a))
_ (.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}
Expand All @@ -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
Expand Down
42 changes: 37 additions & 5 deletions test/agiladmin/core_test.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Expand All @@ -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"])))

Expand All @@ -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)))