diff options
| author | Craig Jennings <c@cjennings.net> | 2026-05-10 14:45:04 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-05-10 14:45:04 -0500 |
| commit | eb512124d9d587f9f26a17e9031f30a2cfff9476 (patch) | |
| tree | 69ec08b7e617c0e4e2a6943ff1c684f0d52859b9 /modules | |
| parent | 0d6865d35c78e52775e73bef2e63f08682d22d6f (diff) | |
| download | dotemacs-eb512124d9d587f9f26a17e9031f30a2cfff9476.tar.gz dotemacs-eb512124d9d587f9f26a17e9031f30a2cfff9476.zip | |
feat(cj-cache): add TTL+building cache helper
Phase 5 step 2 of utility-consolidation. Add `modules/cj-cache.el' implementing the API specified in `docs/design/cache-helper-design.org': `cj/cache-make' / `cj/cache-valid-p' / `cj/cache-value-or-rebuild' / `cj/cache-building-p' / `cj/cache-invalidate'.
The helper captures the TTL+building-guard pattern that org-agenda-config and org-refile-config currently hand-roll. Both consumers will migrate in follow-up commits. No call-site changes in this commit -- helper plus its 15 tests only.
Tests cover: default and custom TTL, fresh/recent/expired/nil-value validity, miss calls build / hit skips build, force-rebuild overrides hit, the four log callbacks (on-hit / on-build-start / on-build-success / on-build-error), error-rethrow and building-flag cleanup on both success and error paths.
Diffstat (limited to 'modules')
| -rw-r--r-- | modules/cj-cache.el | 95 |
1 files changed, 95 insertions, 0 deletions
diff --git a/modules/cj-cache.el b/modules/cj-cache.el new file mode 100644 index 00000000..b7d048c9 --- /dev/null +++ b/modules/cj-cache.el @@ -0,0 +1,95 @@ +;;; cj-cache.el --- Generic TTL cache with build-guard -*- lexical-binding: t; -*- + +;; Author: Craig Jennings <c@cjennings.net> + +;;; Commentary: + +;; Generic "rebuild a long-running computation behind a TTL" cache, +;; with a building-flag guard that prevents duplicate concurrent +;; rebuilds in async-build scenarios. +;; +;; Used by org-agenda-config and org-refile-config which previously +;; carried parallel hand-rolled implementations of this exact shape. +;; See docs/design/cache-helper-design.org for the API contract, +;; consumer migration shape, and rationale for the deliberate "nil +;; cached value reads as invalid" decision. +;; +;; Out of scope: buffer-local key-based caches like the modeline VC +;; cache (different lifecycle). + +;;; Code: + +(require 'cl-lib) + +(defun cj/cache-make (&rest plist) + "Return a fresh cache state. +PLIST keywords: +- =:ttl= seconds to retain a built value (default 3600)." + (let ((ttl (or (plist-get plist :ttl) 3600))) + (list :value nil :time nil :ttl ttl :building nil))) + +(defun cj/cache-valid-p (cache) + "Return non-nil when CACHE has a fresh, non-nil value within its TTL. +A nil cached value reads as invalid by design -- a build that legitimately +returns nil rebuilds on the next request, matching the prior agenda/refile +contract." + (let ((value (plist-get cache :value)) + (time (plist-get cache :time)) + (ttl (plist-get cache :ttl))) + (and value + time + (< (- (float-time) time) ttl)))) + +(defun cj/cache-building-p (cache) + "Return non-nil when a build is currently in progress on CACHE." + (plist-get cache :building)) + +(defun cj/cache-invalidate (cache) + "Clear CACHE's value and timestamp. TTL is preserved." + (plist-put cache :value nil) + (plist-put cache :time nil)) + +(cl-defun cj/cache-value-or-rebuild (cache build-fn + &key force-rebuild + on-hit + on-build-start + on-build-success + on-build-error) + "Return CACHE's value, calling BUILD-FN to rebuild when invalid. + +When CACHE is valid and FORCE-REBUILD is nil, return the stored value +and call ON-HIT (if given) with the value. Otherwise call BUILD-FN, +store its result, and return it. + +The four callbacks let the consumer log without this helper printing on +its behalf: +- ON-HIT (value) +- ON-BUILD-START () +- ON-BUILD-SUCCESS (value) +- ON-BUILD-ERROR (err) + +The :building flag is set before BUILD-FN runs and cleared inside an +`unwind-protect' regardless of outcome. Errors from BUILD-FN are +rethrown after ON-BUILD-ERROR fires." + (cond + ((and (not force-rebuild) (cj/cache-valid-p cache)) + (let ((value (plist-get cache :value))) + (when on-hit (funcall on-hit value)) + value)) + (t + (when on-build-start (funcall on-build-start)) + (plist-put cache :building t) + (unwind-protect + (condition-case err + (let ((value (funcall build-fn))) + (plist-put cache :value value) + (plist-put cache :time (float-time)) + (when on-build-success (funcall on-build-success value)) + value) + (error + (when on-build-error (funcall on-build-error err)) + (signal (car err) (cdr err)))) + (plist-put cache :building nil))))) + +(provide 'cj-cache) +;;; cj-cache.el ends here |
