1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
|
#+TITLE: Cache Helper Design Addendum
#+AUTHOR: Craig Jennings
#+DATE: 2026-05-10
* Status
Phase 5 design addendum to [[file:utility-consolidation.org][utility-consolidation.org]]. Specifies the cache API to extract before any code moves.
* Problem
Two modules carry a parallel cache implementation today:
- =modules/org-agenda-config.el= caches the agenda file list.
- =modules/org-refile-config.el= caches refile targets.
Both share the same shape (lines map between modules):
| Element | Purpose |
|---------|---------|
| =VAR-cache= | the cached value |
| =VAR-cache-time= | float-time when last built |
| =VAR-cache-ttl= | seconds to retain (default 3600) |
| =VAR-building= | non-nil while an async build is in progress |
| =cj/build-VAR (&optional force-rebuild)= | "use cache if valid, else rebuild" |
The build function does:
1. Check whether cache is valid: cache and cache-time set, FORCE-REBUILD nil, age < TTL.
2. If valid: assign to the consumer's variable, log cache-hit, return.
3. If a background build is running: log "waiting...", continue (no second build).
4. Otherwise: set building flag, run the rebuild closure inside =condition-case=, on success update cache+time, on error log message; the =unwind-protect= clears the building flag.
The consumer's variable (e.g. =org-agenda-files=, =org-refile-targets=) is updated as a side effect.
A separate cache pattern exists in =modules/modeline-config.el= for VC info, but it's *buffer-local*, *key-based* (the file path), and not TTL-based. It will not migrate to the same helper -- see "Out of scope" below.
* Goals
- One source of truth for the TTL+building build pattern.
- Consumers shrink from ~30 lines of cache plumbing to a single =cj/cache-value-or-rebuild= call.
- Behavior preserved for agenda and refile.
- Tests cover rebuild / TTL hit / TTL miss / nil cache value / build error / building-flag cleanup.
* Out of scope
- Modeline VC cache (=cj/modeline-vc-cache-*=). Buffer-local + key-based + non-TTL invalidation. Different lifecycle. The spec calls this out explicitly: consider only after global cache behavior is stable. Defer to a future round.
- Generic memoization. We're not introducing function memoization or LRU. Just the specific "rebuild a long-running computation behind a TTL" pattern.
* API
** =cj/cache-make=
#+begin_src emacs-lisp
(cj/cache-make &key ttl)
#+end_src
Return a fresh cache state object. TTL is in seconds; defaults to 3600.
The state is a plist with keys =:value=, =:time=, =:ttl=, =:building=. Consumers store the state in a single =defvar=.
** =cj/cache-valid-p=
#+begin_src emacs-lisp
(cj/cache-valid-p cache)
#+end_src
Return non-nil when CACHE has a non-nil value, a non-nil time, and (now - time) < ttl.
** =cj/cache-value-or-rebuild=
#+begin_src emacs-lisp
(cj/cache-value-or-rebuild cache build-fn
&key force-rebuild
on-hit
on-build-start
on-build-success
on-build-error)
#+end_src
The main entry point. Returns the cached value when valid (and FORCE-REBUILD is nil); otherwise calls BUILD-FN to compute a new value, updates CACHE, and returns the result.
The four optional callbacks let the consumer log without the helper printing on its behalf:
- =on-hit (value)= -- the helper found a valid cache.
- =on-build-start ()= -- about to call BUILD-FN.
- =on-build-success (value)= -- BUILD-FN returned cleanly.
- =on-build-error (err)= -- BUILD-FN signaled. After this fires the helper rethrows so the caller sees the error.
The =:building= flag is set before BUILD-FN runs and cleared inside an =unwind-protect= regardless of outcome. The flag is exposed read-only via =cj/cache-building-p= so callers can log "build in progress" without poking at the plist directly.
** =cj/cache-building-p=
#+begin_src emacs-lisp
(cj/cache-building-p cache)
#+end_src
Return non-nil when a build is currently in progress on CACHE. Used by callers that want to log "waiting for background build" before invoking =cj/cache-value-or-rebuild=.
** =cj/cache-invalidate=
#+begin_src emacs-lisp
(cj/cache-invalidate cache)
#+end_src
Reset the cache to "no value, no time". TTL is preserved.
* Consumer Shape (after migration)
The agenda module's ~30 lines of cache plumbing become roughly:
#+begin_src emacs-lisp
(defvar cj/--org-agenda-files-cache (cj/cache-make :ttl 3600))
(defun cj/build-org-agenda-list (&optional force-rebuild)
"..."
(interactive "P")
(when (cj/cache-building-p cj/--org-agenda-files-cache)
(cj/log-silently "Waiting for background agenda build to complete..."))
(let ((files
(cj/cache-value-or-rebuild
cj/--org-agenda-files-cache
(lambda () (cj/--scan-org-agenda-files))
:force-rebuild force-rebuild
:on-hit (lambda (v) (cj/log-silently
"Using cached agenda files (%d files)" (length v)))
:on-build-start (lambda () (cj/log-silently
"Rebuilding agenda files (slow)..."))
:on-build-success (lambda (v) (cj/log-silently
"Built agenda files (%d files)"
(length v)))
:on-build-error (lambda (err) (cj/log-silently
"Agenda build failed: %s" err)))))
(setq org-agenda-files files)
files))
#+end_src
The =cj/--scan-org-agenda-files= helper holds the slow filesystem walk; the existing in-place expression body moves there with no behavior change.
* Migration order
Per the spec: agenda first, refile second. Each is its own commit:
1. Add =modules/cj-cache.el= with the API and tests. No call-site changes.
2. Migrate =org-agenda-config.el= to the helper. Verify behavior with the existing async-cache tests.
3. Migrate =org-refile-config.el= the same way.
* Testing
For the helper itself, =tests/test-cj-cache.el=:
- Normal: hit returns cached value; miss calls BUILD-FN.
- TTL: build at t=0, request at t=ttl-1 hits; request at t=ttl+1 rebuilds.
- FORCE-REBUILD wins over a valid cache.
- nil from BUILD-FN is stored (cache-valid-p returns t). This matches today's behavior -- a build that legitimately produces nil should not loop. *Decision point*: the current implementations actually treat "cache value nil" as "cache invalid" (line 131 of agenda, line 66 of refile both check =(and cache cache-time ...)=). Preserve that to avoid a behavior change: the new helper's =cj/cache-valid-p= treats a nil :value as "not valid". That's the safer default; consumers that need "nil is a real value" can migrate to a sentinel later.
- :building flag is set during BUILD-FN, cleared after success.
- :building flag is cleared even when BUILD-FN signals.
- Each callback fires once per appropriate path (hit / start / success / error).
For the migrated consumers: the existing async-cache and rebuild tests run unchanged after the migration. No new test files for agenda/refile are required as part of Phase 5 itself; they got their tests when the original cache was added.
* Risks
- *Behavior drift on cache-hit logging.* The current code logs "Using cached agenda files (N files)" via =cj/log-silently=. The migration preserves that exact message via =:on-hit=. Verify by tail-ing =*Messages*= during a manual smoke test.
- *Building-flag leak.* The current code uses =unwind-protect= to clear =VAR-building= even on error. The helper does the same. The test "building flag cleared on error" pins this contract.
- *Async timer interaction.* Both modules schedule background builds via =run-with-idle-timer=. The migration leaves those scheduling forms in the consumer; only the cache-or-build core moves. No changes to startup timing.
|