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
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,35 @@ For bulk operations that might trigger multiple maintenance runs, you can use `w
)
```

### Cache entry introspection

You can inspect the status of a specific cache entry, or all entries for a cached function, directly from the cache handle — without a separate database connection or knowledge of the internal schema:

```clojure
;; cached-api-call is the value returned by cache/cache (see Basic Usage above)

;; Single-entry lookup: pass the args you would pass to the cached fn
(cache/entry-status cached-api-call {:query "something"})
;; => {:created-at #inst "2026-05-10T14:32:00Z"
;; :last-hit #inst "2026-05-12T09:00:00Z" ; nil if never re-hit
;; :hits 7
;; :cold? false ; true if past TTL — eligible for eviction
;; :stale? false} ; true if past max-age — will be evicted unconditionally
;; or nil if no matching entry exists

;; Bulk lookup: all entries for this cached function
(cache/function-entries cached-api-call)
;; => [{:args {:query "something"}
;; :created-at #inst "2026-05-10T14:32:00Z"
;; :last-hit #inst "2026-05-12T09:00:00Z"
;; :hits 7
;; :cold? false
;; :stale? false}
;; ...]
```

Both functions use the same `args-cache-key` and serialization logic as the cache itself, so the key lookup is always consistent with what the cache stores.

### Error Handling

The cache properly handles exceptions from cached functions. When a cached function throws an exception, it is propagated to the caller without caching the error. This ensures that transient errors don't get permanently cached.
Expand Down
52 changes: 52 additions & 0 deletions src/com/latacora/sqlite_cache/core.clj
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,58 @@
(jdbc/execute-one! read-conn ddl/read-only-stmt)
(-> cached (partial opts) (with-meta opts))))

(defn ^:private coerce-status-row [row]
(-> row
(update :created-at maint/maybe-inst)
(update :last-hit maint/maybe-inst)
(update :cold? pos?)
(update :stale? pos?)))

(defn ^:private status-base-query [func-name & [{:keys [extra-cols]}]]
(-> (apply h/select :hits
[maint/cold? :cold?]
[maint/stale? :stale?]
[:created-at :created-at]
[:last-hit :last-hit]
extra-cols)
(h/from :cache)
(h/where [:= :function func-name])))

(defn entry-status
"Returns status for the cache entry matching cache-args, or nil if no entry exists.

cached-fn is the value returned by `cache` or `cached-var`.
cache-args is the argument list that would be passed to the cached function.

Returns a map with:
- :created-at java.time.Instant when the entry was first computed
- :last-hit java.time.Instant of last read, or nil if never re-hit
- :hits number of cache hits
- :cold? true if past TTL (evictable if not re-hit soon)
- :stale? true if past max-age (will be evicted unconditionally)"
[cached-fn & cache-args]
(let [{:keys [read-conn func-name args-cache-key]} (meta cached-fn)
serialized-args (-> cache-args args-cache-key ser/serialize)
q (-> (status-base-query func-name)
(h/where [:= :args serialized-args]))]
(some-> (db/exec-one! read-conn q) coerce-status-row)))

(defn function-entries
"Returns status for every live cache entry belonging to cached-fn.

Each map in the returned sequence contains:
- :args deserialized arguments (as stored by args-cache-key)
- :created-at java.time.Instant when the entry was first computed
- :last-hit java.time.Instant of last read, or nil if never re-hit
- :hits number of cache hits
- :cold? true if past TTL (evictable if not re-hit soon)
- :stale? true if past max-age (will be evicted unconditionally)"
[cached-fn]
(let [{:keys [read-conn func-name]} (meta cached-fn)
q (status-base-query func-name {:extra-cols [:args]})]
(->> (db/exec! read-conn q)
(map #(-> % (update :args ser/deserialize) coerce-status-row)))))

(defn cached-var
"A helper function for `cache` that configures the cache name based on the
fully-qualified function name of the given fn-var.
Expand Down
2 changes: 1 addition & 1 deletion src/com/latacora/sqlite_cache/maintenance.clj
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@
;; Predicate Infrastructure
;; ============================================================================

(defn ^:private maybe-inst
(defn maybe-inst
"Converts an epoch second to an Instant, or returns nil if input is nil."
[epoch-second]
(when epoch-second
Expand Down
78 changes: 78 additions & 0 deletions test/com/latacora/sqlite_cache/core_test.clj
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
[com.latacora.sqlite-cache.maintenance :as maint]
[com.latacora.sqlite-cache.test-utils :as tu]
[clojure.test :as t]
[com.gfredericks.test.chuck.clojure-test :refer [checking]]
[clojure.test.check.generators :as gen]
[next.jdbc :as jdbc]
[honey.sql.helpers :as h]
[honey.sql :as hsql])
Expand Down Expand Up @@ -555,6 +557,82 @@
;; Should run exactly once after all blocks
(t/is (= @maintenance-calls 1) "Maintenance ran exactly once after all blocks"))))

(t/deftest entry-status-test
(tu/with-harness
(fn [{:keys [cached-fn base-cached-fn]}]
(t/is (nil? (c/entry-status base-cached-fn 1 1))
"returns nil before any call")

(cached-fn 1 1)

(let [status (c/entry-status base-cached-fn 1 1)]
(t/is (some? status) "returns a map after a call")
(t/is (instance? java.time.Instant (:created-at status)))
(t/is (nil? (:last-hit status)) "no hits yet")
(t/is (= 0 (:hits status)))
(t/is (false? (:cold? status)))
(t/is (false? (:stale? status))))

(cached-fn 1 1)

(let [status (c/entry-status base-cached-fn 1 1)]
(t/is (= 1 (:hits status)))
(t/is (instance? java.time.Instant (:last-hit status))))

(t/is (nil? (c/entry-status base-cached-fn 9 9))
"returns nil for args with no entry"))))

(t/deftest entry-status-cold-stale-test
(tu/with-harness
(fn [{:keys [cached-fn base-cached-fn advance-clock!]}]
(cached-fn 1 1)

(advance-clock! c/default-ttl)
(let [status (c/entry-status base-cached-fn 1 1)]
(t/is (true? (:cold? status)) "cold after TTL elapses")
(t/is (false? (:stale? status))))

(advance-clock! (- c/default-max-age c/default-ttl))
(let [status (c/entry-status base-cached-fn 1 1)]
(t/is (true? (:stale? status)) "stale after max-age elapses")))))

(t/deftest function-entries-test
(tu/with-harness
(fn [{:keys [cached-fn base-cached-fn]}]
(t/is (empty? (c/function-entries base-cached-fn))
"empty before any calls")

(cached-fn 1 1)
(cached-fn 1 2)

(let [entries (c/function-entries base-cached-fn)]
(t/is (= 2 (count entries)))
(t/is (every? #(instance? java.time.Instant (:created-at %)) entries))
(t/is (every? #(nil? (:last-hit %)) entries))
(t/is (every? #(false? (:cold? %)) entries))
(t/is (every? #(false? (:stale? %)) entries))
(t/is (= #{(list 1 1) (list 1 2)}
(into #{} (map :args) entries)))))))

(t/deftest entry-status-zero-arity-test
(tu/with-harness
{:f (constantly 42)}
(fn [{:keys [cached-fn base-cached-fn]}]
(t/is (nil? (c/entry-status base-cached-fn))
"returns nil before call")
(cached-fn)
(t/is (some? (c/entry-status base-cached-fn))
"finds entry for 0-arity call"))))

(t/deftest entry-status-key-matches-store-generative-test
(checking 50 [args (gen/list gen/small-integer)]
(tu/with-harness
{:f (fn [& _] :result) :auto-sync true}
(fn [{:keys [cached-fn base-cached-fn]}]
(apply cached-fn args)
(t/is (some? (apply c/entry-status base-cached-fn args))
(str "entry-status finds row for args: " (pr-str args)))))))

(t/deftest function-error-caching-bug-test
"Test that demonstrates the critical bug where function exceptions get cached forever.
When a cached function throws an exception, the put-queue entry should be cleared
Expand Down
1 change: 1 addition & 0 deletions test/com/latacora/sqlite_cache/test_utils.clj
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@
base-cached-fn)]
(handler (assoc ctx
:cached-fn cached-fn
:base-cached-fn base-cached-fn
:assert-n-entries! (partial assert-n-entries! base-cached-fn)
:sync-write-queue! (partial sync-write-queue! base-cached-fn))))))

Expand Down
Loading