This guide covers all the ways to clear and invalidate cache entries in Memento.
Cached data becomes stale when the underlying data changes. Good invalidation ensures users see fresh data while still benefiting from caching. Memento provides several invalidation strategies:
(require '[memento.core :as m])
(m/memo-clear! get-user) ; Clears all cached results for get-user
Pass the same arguments that were used to cache the value:
;; Clear the cached result for (get-user 123)
(m/memo-clear! get-user 123)
;; For multi-argument functions
(m/memo-clear! get-user-orders 123 :pending) ; Clears (get-user-orders 123 :pending)
If multiple functions share a cache, clear all entries at once:
(def shared-cache (m/create {mc/type mc/caffeine mc/size< 10000}))
(m/bind #'get-user {} shared-cache)
(m/bind #'get-user-orders {} shared-cache)
;; Clears entries from BOTH functions
(m/memo-clear-cache! shared-cache)
The real power of Memento is invalidating related data across multiple functions with a single call. A secondary index maps arbitrary secondary IDs to cache entries. Secondary IDs are independent of mount tags: mount tags only select functions for scoped caching and event broadcast.
In a typical application you have:
get-user, get-user-orders, get-user-preferences)update-user!, delete-user!, merge-users!)Without secondary-index invalidation, every modifying function must know about every cached function:
;; Every modifier must list ALL cached functions - maintenance nightmare!
(defn update-user! [user-id data]
(db/update-user! user-id data)
(m/memo-clear! get-user user-id)
(m/memo-clear! get-user-orders user-id)
(m/memo-clear! get-user-preferences user-id)
;; Did we forget one? Will we remember to add new ones?
)
This creates an N×M maintenance burden:
Secondary-index invalidation decouples producers from consumers. They only need to agree on a secondary-ID scheme. A vector such as [:user user-id] is a useful composite secondary ID, but any hashable value works:
;; CACHED FUNCTIONS: index returned entries, don't care who invalidates
(m/defmemo get-user
{mc/type mc/caffeine}
[user-id]
(-> (db/fetch-user user-id)
(m/with-sec-id [:user user-id])))
;; MODIFYING FUNCTIONS: invalidate the matching secondary ID.
(defn update-user! [user-id data]
(m/with-invalidation [[:user user-id]]
(db/update-user! user-id data)))
Now you can add cached functions or modifiers independently.
(m/defmemo get-user
{mc/type mc/caffeine}
[user-id]
(db/fetch-user user-id))
(m/defmemo get-user-orders
{mc/type mc/caffeine}
[user-id]
(db/fetch-orders user-id))
Use m/with-sec-id to associate cached values with entity IDs:
(m/defmemo get-user
{mc/type mc/caffeine}
[user-id]
(-> (db/fetch-user user-id)
(m/with-sec-id [:user user-id])))
(m/defmemo get-user-orders
{mc/type mc/caffeine}
[user-id]
(-> (db/fetch-orders user-id)
(m/with-sec-id [:user user-id])))
;; Clears every entry indexed by [:user 123].
(m/memo-clear-sec-id! [:user 123])
For writes, prefer with-invalidation: it starts the invalidation before the write so loads that overlap the write cannot publish stale results.
A cached value can have multiple secondary IDs. This is essential for aggregated data like dashboards.
(m/defmemo get-order
{mc/type mc/caffeine}
[order-id]
(let [order (db/fetch-order order-id)]
(-> order
(m/with-sec-id [:order order-id])
(m/with-sec-id [:user (:user-id order)]))))
;; Now you can invalidate by either:
(m/memo-clear-sec-id! [:order 456]) ; Clear this specific order
(m/memo-clear-sec-id! [:user 123]) ; Clear all orders for user 123
Consider a dashboard showing the last 10 users who logged in. If any of those users is modified, the dashboard cache should be invalidated:
(m/defmemo get-recent-users-dashboard
{mc/type mc/caffeine}
[]
(let [users (db/fetch-recent-users 10)]
;; Add an ID for every user that appears in this cached result.
(reduce (fn [result user]
(m/with-sec-id result [:user (:id user)]))
{:users users :generated-at (java.time.Instant/now)}
users)))
;; Now if ANY of those 10 users is modified:
(defn update-user! [user-id data]
(m/with-invalidation [[:user user-id]]
(db/update-user! user-id data)))
The dashboard is automatically invalidated when any user it displays is modified, but NOT when unrelated users are modified.
ret-fn for Cleaner CodeInstead of adding with-sec-id inside your function, use ret-fn to separate caching concerns:
(defn index-user-data [[user-id] result]
(m/with-sec-id result [:user user-id]))
(m/defmemo get-user
{mc/type mc/caffeine
mc/ret-fn index-user-data}
[user-id]
(db/fetch-user user-id)) ; Clean function, no caching logic
Start the invalidation before changing underlying data when a concurrent cache load could otherwise
read stale data during the write. The returned function is single-use: call it with true after a
successful write to clear matching entries, or false after a failed write to only end the lockout.
(let [finish! (m/start-invalidation! [:user user-id])]
(let [result (try
(db/update-user! user-id data)
(catch Throwable t
(finish! false)
(throw t)))]
(finish! true)
result))
with-invalidation provides the same lifecycle and ends the lockout without clearing if its body
throws:
(m/with-invalidation [[:user user-id]]
(db/update-user! user-id data))
While the lockout is open, a call to a memoized function whose result carries a locked-out
secondary ID blocks until the lockout ends, and then loads fresh data. If the entry was
already cached, the waiter does not even run your function — it waits and then re-reads, so
ending the lockout with false serves the existing entry untouched. The one-minute timeout is
only a safeguard for callers that remain blocked; it does not limit how long the invalidating
write may take when callers are not waiting on it.
This is what makes the lockout useful: callers never observe a value computed across your write. It also means two things you must respect.
Always end the lockout. If the function returned by start-invalidation! is never called,
every affected secondary ID stays locked out. Callers wait for up to one minute and then receive
an IllegalStateException identifying the affected IDs and likely leaked completion. Prefer
with-invalidation. If you need manual control, call finish!(false) only when the write fails;
once either completion call starts, the function is consumed even if backend finalization throws:
;; Prefer this
(m/with-invalidation [[:user user-id]]
(db/update-user! user-id data))
;; If you need manual control, guarantee exactly one call
(let [finish! (m/start-invalidation! [:user user-id])]
(let [result (try
(db/update-user! user-id data)
(catch Throwable t
(finish! false)
(throw t)))]
(finish! true)
result))
Do not read through the lockout from inside it. The thread holding the lockout open must not
call a memoized function that returns one of the locked-out secondary IDs. The call waits on the
lockout that its own thread cannot finish, then throws a diagnostic IllegalStateException after
one minute:
;; TIMES OUT: get-user returns [:user user-id], which this block has locked out
(m/with-invalidation [[:user user-id]]
(db/update-user! user-id data)
(get-user user-id))
;; Fine: read after the lockout closes
(m/with-invalidation [[:user user-id]]
(db/update-user! user-id data))
(get-user user-id)
Keep the lockout body as short as the write itself. Waiters are interruptible, so a thread stuck
on a leaked or self-inflicted lockout can be released with Thread/interrupt, which surfaces as
an InterruptedException from the memoized call. The timeout deadline begins when the waiter
starts waiting and is not reset by unrelated or spurious wakeups. This prevents repeated
invalidation activity from hiding a leaked completion indefinitely.
You can pre-populate or manually update cache entries:
;; Add entries to a function's cache
;; Keys are argument vectors, values are the cached results
(m/memo-add! get-user {[123] {:id 123 :name "Alice"}
[456] {:id 456 :name "Bob"}})
This is useful for:
Use m/do-not-cache to prevent certain results from being cached:
(m/defmemo get-user
{mc/type mc/caffeine}
[user-id]
(if-let [user (db/fetch-user user-id)]
user
(m/do-not-cache nil))) ; Don't cache "not found" results
Or use ret-fn for cleaner separation:
(defn no-cache-errors [_ response]
(if (>= (:status response) 400)
(m/do-not-cache response)
response))
(m/defmemo fetch-api-data
{mc/type mc/caffeine
mc/ret-fn no-cache-errors}
[endpoint]
(http/get endpoint))
Use if-cached to check without triggering a cache miss:
(m/if-cached [user (get-user 123)]
(println "User was cached:" user)
(println "User not in cache"))
Update cache after successful writes:
(defn update-user! [user-id data]
(let [updated-user (db/update-user! user-id data)]
;; Option 1: Invalidate and let next read refresh
(m/memo-clear-sec-id! [:user user-id])
;; Option 2: Update cache directly (write-through)
(m/memo-add! get-user {[user-id] updated-user})
updated-user))
Invalidate based on events from a message queue:
(defn handle-event [event]
(case (:type event)
:user-updated (m/memo-clear-sec-id! [:user (:user-id event)])
:order-completed ((m/start-invalidation! [:order (:order-id event)]
[:user (:user-id event)]) true)
nil))
;; Get all mount points for a tag
(m/mounts-by-tag :user)
;; Clear all caches for a tag (without specifying an ID)
(doseq [mp (m/mounts-by-tag :user)]
(m/memo-clear! mp))
If multiple threads request the same uncached key simultaneously, only one actually calls the function. The others wait and receive the same result.
If a key is invalidated while being loaded, the load is retried to ensure fresh data. The loading thread is interrupted when an invalidation finds its SpecialPromise through an existing index entry; first loads with result-derived secondary IDs are instead detected by timeline validation when they complete. A load that is rejected because its secondary IDs are still locked out waits for the lockout to end before retrying, so a long invalidation window costs one computation rather than a retry storm. There is a narrow boundary after a completed load is published: an invalidation may remove the published cache entry while the loader or callers already waiting on that load still return its computed value. Subsequent calls miss and load fresh data.
The hardest concurrency challenge with caching is invalidating call trees - cached functions that call other cached functions.
Consider this scenario:
(m/defmemo get-user-summary [user-id] ; Calls get-user-details
(let [details (get-user-details user-id)]
(summarize details)))
(m/defmemo get-user-details [user-id] ; Lower-level cache
(db/fetch-user user-id))
When you invalidate both caches, there's a race condition:
get-user-summary for user 123get-user-details, another thread calls get-user-summaryget-user-details, which still has stale dataget-user-summaryget-user-details - but get-user-summary now has stale dataWhen using secondary-index invalidation (memo-clear-sec-id!, start-invalidation!, or
with-invalidation), Memento records serialized start and end transitions on a timeline while each
loaded cache backend clears its own indexes. Timeline reads remain lock-free, and loads validate the
timeline before publication to coordinate concurrent loads without relying on mount tags.
;; Secondary-ID invalidation coordinates across functions.
(m/memo-clear-sec-id! [:user user-id])
Request-scoped caching sidesteps the problem entirely: each request starts with a fresh cache and discards it at the end. Within a request, you can be very liberal with cache clearing - just nuke everything after any write operation:
(defn handle-request [request]
(m/with-caches :request (constantly (m/create {mc/type mc/caffeine}))
;; ... do reads, cache is populated ...
(when (write-operation? request)
;; After any DB write, just clear the entire request cache
;; No need to be precise - it's cheap and guarantees correctness
(m/memo-clear-cache! (m/active-cache get-user))
;; Or clear all caches for the tag:
(doseq [mp (m/mounts-by-tag :request)]
(m/memo-clear! mp)))
;; ... continue with fresh data ...
))
You sacrifice some caching performance (entries you could have kept are cleared), but you gain simplicity and correctness. Since the cache only lives for one request anyway, the cost is limited. This is much easier than tracking exactly which cached functions are affected by each write.
For long-lived caches, if you can afford to clear all cached data (not just one entity), backing related functions with a shared cache and clearing it atomically eliminates the race condition:
(def user-cache (m/create {mc/type mc/caffeine mc/size< 10000}))
(m/bind #'get-user-details {} user-cache)
(m/bind #'get-user-summary {} user-cache)
;; Clears ALL entries for ALL functions - atomic, no race condition
(m/memo-clear-cache! user-cache)
This is a sledgehammer approach - it clears everything, not just user 123's data. Only practical when you genuinely need to invalidate all cached data.
Most caching libraries don't address the call tree problem at all. Memento's secondary-index invalidation with lockout coordination handles the common cases correctly.
See Internals for details on the lockout mechanism.
Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |