From e56fdbd99abd17ee2cd8f43173e8938119e45cec Mon Sep 17 00:00:00 2001 From: Laurens Van Houtven Date: Tue, 12 May 2026 15:50:25 -0500 Subject: [PATCH 1/8] Add entry-status and function-entries for cache introspection Callers (e.g. Clerk notebooks) need a way to show freshness metadata for cache entries without taking a raw JDBC dependency or duplicating the serialization/key logic. entry-status looks up a single entry by cached-fn + args; function-entries returns all entries for a cached-fn. Co-Authored-By: Claude Sonnet 4.6 --- src/com/latacora/sqlite_cache/core.clj | 63 ++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/src/com/latacora/sqlite_cache/core.clj b/src/com/latacora/sqlite_cache/core.clj index 8bfa97c..9467efd 100644 --- a/src/com/latacora/sqlite_cache/core.clj +++ b/src/com/latacora/sqlite_cache/core.clj @@ -21,6 +21,7 @@ [com.latacora.sqlite-cache.ddl :as ddl] [com.latacora.sqlite-cache.maintenance :as maint]) (:import + (java.time Instant) (java.util.concurrent LinkedBlockingQueue TimeUnit))) ;; This currently does not use clojure.core.cache, but it probably could stand @@ -248,6 +249,68 @@ (jdbc/execute-one! read-conn ddl/read-only-stmt) (-> cached (partial opts) (with-meta opts)))) +(defn- maybe-inst [epoch-second] + (when epoch-second (Instant/ofEpochSecond epoch-second))) + +(defn- cold-at? [created-at last-hit ttl] + (<= (+ (or last-hit created-at) ttl) (quot (System/currentTimeMillis) 1000))) + +(defn- stale-at? [created-at max-age] + (<= (+ created-at max-age) (quot (System/currentTimeMillis) 1000))) + +(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 ttl max-age]} (meta cached-fn) + serialized-args (ser/serialize (args-cache-key cache-args)) + q (-> (h/select :hits :last-hit :created-at) + (h/from :cache) + (h/where [:= :function func-name] + [:= :args serialized-args])) + row (db/exec-one! read-conn q)] + (when row + (let [{:keys [hits last-hit created-at]} row] + {:created-at (maybe-inst created-at) + :last-hit (maybe-inst last-hit) + :hits hits + :cold? (cold-at? created-at last-hit ttl) + :stale? (stale-at? created-at max-age)})))) + +(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 ttl max-age]} (meta cached-fn) + q (-> (h/select :args :hits :last-hit :created-at) + (h/from :cache) + (h/where [:= :function func-name])) + rows (db/exec! read-conn q)] + (map (fn [{:keys [args hits last-hit created-at]}] + {:args (ser/deserialize args) + :created-at (maybe-inst created-at) + :last-hit (maybe-inst last-hit) + :hits hits + :cold? (cold-at? created-at last-hit ttl) + :stale? (stale-at? created-at max-age)}) + rows))) + (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. From 83dd22b45a528f811128ae551da605233a9273fc Mon Sep 17 00:00:00 2001 From: Laurens Van Houtven Date: Tue, 12 May 2026 15:53:59 -0500 Subject: [PATCH 2/8] Use existing HoneySQL cold?/stale? expressions in entry-status Instead of reimplementing the cold/stale predicates in pure Clojure, delegate to the existing maint/cold? and maint/stale? HoneySQL expressions so SQLite computes them. Also unprivates maint/maybe-inst which is shared by both namespaces. Co-Authored-By: Claude Sonnet 4.6 --- src/com/latacora/sqlite_cache/core.clj | 53 +++++++++---------- src/com/latacora/sqlite_cache/maintenance.clj | 2 +- 2 files changed, 27 insertions(+), 28 deletions(-) diff --git a/src/com/latacora/sqlite_cache/core.clj b/src/com/latacora/sqlite_cache/core.clj index 9467efd..6b3aaf4 100644 --- a/src/com/latacora/sqlite_cache/core.clj +++ b/src/com/latacora/sqlite_cache/core.clj @@ -21,7 +21,6 @@ [com.latacora.sqlite-cache.ddl :as ddl] [com.latacora.sqlite-cache.maintenance :as maint]) (:import - (java.time Instant) (java.util.concurrent LinkedBlockingQueue TimeUnit))) ;; This currently does not use clojure.core.cache, but it probably could stand @@ -249,15 +248,6 @@ (jdbc/execute-one! read-conn ddl/read-only-stmt) (-> cached (partial opts) (with-meta opts)))) -(defn- maybe-inst [epoch-second] - (when epoch-second (Instant/ofEpochSecond epoch-second))) - -(defn- cold-at? [created-at last-hit ttl] - (<= (+ (or last-hit created-at) ttl) (quot (System/currentTimeMillis) 1000))) - -(defn- stale-at? [created-at max-age] - (<= (+ created-at max-age) (quot (System/currentTimeMillis) 1000))) - (defn entry-status "Returns status for the cache entry matching cache-args, or nil if no entry exists. @@ -271,20 +261,25 @@ - :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 ttl max-age]} (meta cached-fn) + (let [{:keys [read-conn func-name args-cache-key]} (meta cached-fn) serialized-args (ser/serialize (args-cache-key cache-args)) - q (-> (h/select :hits :last-hit :created-at) + q (-> (h/select :hits + [maint/goes-cold-at :goes-cold-at] + [maint/goes-stale-at :goes-stale-at] + [maint/cold? :cold?] + [maint/stale? :stale?] + [:created-at :created-at] + [:last-hit :last-hit]) (h/from :cache) (h/where [:= :function func-name] [:= :args serialized-args])) row (db/exec-one! read-conn q)] (when row - (let [{:keys [hits last-hit created-at]} row] - {:created-at (maybe-inst created-at) - :last-hit (maybe-inst last-hit) - :hits hits - :cold? (cold-at? created-at last-hit ttl) - :stale? (stale-at? created-at max-age)})))) + (-> row + (update :created-at maint/maybe-inst) + (update :last-hit maint/maybe-inst) + (update :cold? pos?) + (update :stale? pos?))))) (defn function-entries "Returns status for every live cache entry belonging to cached-fn. @@ -297,18 +292,22 @@ - :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 ttl max-age]} (meta cached-fn) - q (-> (h/select :args :hits :last-hit :created-at) + (let [{:keys [read-conn func-name]} (meta cached-fn) + q (-> (h/select :args :hits + [maint/cold? :cold?] + [maint/stale? :stale?] + [:created-at :created-at] + [:last-hit :last-hit]) (h/from :cache) (h/where [:= :function func-name])) rows (db/exec! read-conn q)] - (map (fn [{:keys [args hits last-hit created-at]}] - {:args (ser/deserialize args) - :created-at (maybe-inst created-at) - :last-hit (maybe-inst last-hit) - :hits hits - :cold? (cold-at? created-at last-hit ttl) - :stale? (stale-at? created-at max-age)}) + (map (fn [row] + (-> row + (update :args ser/deserialize) + (update :created-at maint/maybe-inst) + (update :last-hit maint/maybe-inst) + (update :cold? pos?) + (update :stale? pos?))) rows))) (defn cached-var diff --git a/src/com/latacora/sqlite_cache/maintenance.clj b/src/com/latacora/sqlite_cache/maintenance.clj index 3f72f5c..74e9ed0 100644 --- a/src/com/latacora/sqlite_cache/maintenance.clj +++ b/src/com/latacora/sqlite_cache/maintenance.clj @@ -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 From 9dbc5d0f5d95cccf566e065ec9fe5737634fa49f Mon Sep 17 00:00:00 2001 From: Laurens Van Houtven Date: Tue, 12 May 2026 16:02:42 -0500 Subject: [PATCH 3/8] Add tests and README docs for entry-status and function-entries Also refactors the two fns to share coerce-status-row and status-base-query helpers, fixes defn- to ^:private per codebase style, and exposes base-cached-fn in the test harness context so introspection fns (which read metadata) can be tested cleanly. Co-Authored-By: Claude Sonnet 4.6 --- README.md | 29 ++++++++++ src/com/latacora/sqlite_cache/core.clj | 58 ++++++++----------- test/com/latacora/sqlite_cache/core_test.clj | 57 ++++++++++++++++++ test/com/latacora/sqlite_cache/test_utils.clj | 1 + 4 files changed, 111 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 649f854..c98c944 100644 --- a/README.md +++ b/README.md @@ -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 +(require '[com.latacora.sqlite-cache.core :as cache]) + +;; 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. diff --git a/src/com/latacora/sqlite_cache/core.clj b/src/com/latacora/sqlite_cache/core.clj index 6b3aaf4..8efbdaf 100644 --- a/src/com/latacora/sqlite_cache/core.clj +++ b/src/com/latacora/sqlite_cache/core.clj @@ -248,6 +248,23 @@ (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 & 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. @@ -262,24 +279,10 @@ - :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 (ser/serialize (args-cache-key cache-args)) - q (-> (h/select :hits - [maint/goes-cold-at :goes-cold-at] - [maint/goes-stale-at :goes-stale-at] - [maint/cold? :cold?] - [maint/stale? :stale?] - [:created-at :created-at] - [:last-hit :last-hit]) - (h/from :cache) - (h/where [:= :function func-name] - [:= :args serialized-args])) - row (db/exec-one! read-conn q)] - (when row - (-> row - (update :created-at maint/maybe-inst) - (update :last-hit maint/maybe-inst) - (update :cold? pos?) - (update :stale? pos?))))) + serialized-args (ser/serialize (args-cache-key (seq cache-args))) + 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. @@ -293,22 +296,9 @@ - :stale? true if past max-age (will be evicted unconditionally)" [cached-fn] (let [{:keys [read-conn func-name]} (meta cached-fn) - q (-> (h/select :args :hits - [maint/cold? :cold?] - [maint/stale? :stale?] - [:created-at :created-at] - [:last-hit :last-hit]) - (h/from :cache) - (h/where [:= :function func-name])) - rows (db/exec! read-conn q)] - (map (fn [row] - (-> row - (update :args ser/deserialize) - (update :created-at maint/maybe-inst) - (update :last-hit maint/maybe-inst) - (update :cold? pos?) - (update :stale? pos?))) - rows))) + q (status-base-query func-name :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 diff --git a/test/com/latacora/sqlite_cache/core_test.clj b/test/com/latacora/sqlite_cache/core_test.clj index 0ce83c3..059ac7a 100644 --- a/test/com/latacora/sqlite_cache/core_test.clj +++ b/test/com/latacora/sqlite_cache/core_test.clj @@ -555,6 +555,63 @@ ;; 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 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 diff --git a/test/com/latacora/sqlite_cache/test_utils.clj b/test/com/latacora/sqlite_cache/test_utils.clj index 1d78765..ad86cba 100644 --- a/test/com/latacora/sqlite_cache/test_utils.clj +++ b/test/com/latacora/sqlite_cache/test_utils.clj @@ -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)))))) From f29ad47178fb4112bfcda58247cc7fbea1cebced Mon Sep 17 00:00:00 2001 From: Laurens Van Houtven Date: Tue, 12 May 2026 16:03:31 -0500 Subject: [PATCH 4/8] Use opts map with :extra-cols in status-base-query Co-Authored-By: Claude Sonnet 4.6 --- src/com/latacora/sqlite_cache/core.clj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/com/latacora/sqlite_cache/core.clj b/src/com/latacora/sqlite_cache/core.clj index 8efbdaf..c8290ba 100644 --- a/src/com/latacora/sqlite_cache/core.clj +++ b/src/com/latacora/sqlite_cache/core.clj @@ -255,7 +255,7 @@ (update :cold? pos?) (update :stale? pos?))) -(defn ^:private status-base-query [func-name & extra-cols] +(defn ^:private status-base-query [func-name & [{:keys [extra-cols]}]] (-> (apply h/select :hits [maint/cold? :cold?] [maint/stale? :stale?] @@ -296,7 +296,7 @@ - :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 :args)] + q (status-base-query func-name {:extra-cols [:args]})] (->> (db/exec! read-conn q) (map #(-> % (update :args ser/deserialize) coerce-status-row))))) From b8fac6588ebf035179971d0c3b04780944b66c89 Mon Sep 17 00:00:00 2001 From: Laurens Van Houtven Date: Tue, 12 May 2026 16:06:17 -0500 Subject: [PATCH 5/8] Rewrite serialized-args with -> Co-Authored-By: Claude Sonnet 4.6 --- src/com/latacora/sqlite_cache/core.clj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/com/latacora/sqlite_cache/core.clj b/src/com/latacora/sqlite_cache/core.clj index c8290ba..f56c47e 100644 --- a/src/com/latacora/sqlite_cache/core.clj +++ b/src/com/latacora/sqlite_cache/core.clj @@ -279,7 +279,7 @@ - :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 (ser/serialize (args-cache-key (seq cache-args))) + serialized-args (-> cache-args seq 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))) From c8cef01d4acc53930891bf9357edf1a3460bdad5 Mon Sep 17 00:00:00 2001 From: Laurens Van Houtven Date: Tue, 12 May 2026 16:08:47 -0500 Subject: [PATCH 6/8] Sentence case README section heading Co-Authored-By: Claude Sonnet 4.6 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c98c944..2dc0ea4 100644 --- a/README.md +++ b/README.md @@ -139,7 +139,7 @@ For bulk operations that might trigger multiple maintenance runs, you can use `w ) ``` -### Cache Entry Introspection +### 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: From de0099c44240c7b341d45500b812797b60b4bb38 Mon Sep 17 00:00:00 2001 From: Laurens Van Houtven Date: Tue, 12 May 2026 16:42:00 -0500 Subject: [PATCH 7/8] Fix entry-status key mismatch for 0-arity and variadic calls Make entry-status variadic (& cache-args) so the args list arrives as the same ISeq type that cached uses, eliminating the seq conversion that mapped [] to nil and caused a serialization mismatch for 0-arity cached functions. Adds a targeted 0-arity test and a generative property that verifies entry-status finds the row for any arg list. Co-Authored-By: Claude Sonnet 4.6 --- README.md | 2 +- src/com/latacora/sqlite_cache/core.clj | 4 +-- test/com/latacora/sqlite_cache/core_test.clj | 33 ++++++++++++++++---- 3 files changed, 30 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 2dc0ea4..a85406b 100644 --- a/README.md +++ b/README.md @@ -147,7 +147,7 @@ You can inspect the status of a specific cache entry, or all entries for a cache (require '[com.latacora.sqlite-cache.core :as cache]) ;; Single-entry lookup: pass the args you would pass to the cached fn -(cache/entry-status cached-api-call [{:query "something"}]) +(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 diff --git a/src/com/latacora/sqlite_cache/core.clj b/src/com/latacora/sqlite_cache/core.clj index f56c47e..d9a7549 100644 --- a/src/com/latacora/sqlite_cache/core.clj +++ b/src/com/latacora/sqlite_cache/core.clj @@ -277,9 +277,9 @@ - :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] + [cached-fn & cache-args] (let [{:keys [read-conn func-name args-cache-key]} (meta cached-fn) - serialized-args (-> cache-args seq args-cache-key ser/serialize) + 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))) diff --git a/test/com/latacora/sqlite_cache/core_test.clj b/test/com/latacora/sqlite_cache/core_test.clj index 059ac7a..c832bfd 100644 --- a/test/com/latacora/sqlite_cache/core_test.clj +++ b/test/com/latacora/sqlite_cache/core_test.clj @@ -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]) @@ -558,12 +560,12 @@ (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])) + (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])] + (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") @@ -573,11 +575,11 @@ (cached-fn 1 1) - (let [status (c/entry-status base-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])) + (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 @@ -586,12 +588,12 @@ (cached-fn 1 1) (advance-clock! c/default-ttl) - (let [status (c/entry-status base-cached-fn [1 1])] + (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])] + (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 @@ -612,6 +614,25 @@ (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 From f6f340f071201bcac0c072ff550155ac3a225db3 Mon Sep 17 00:00:00 2001 From: Laurens Van Houtven Date: Tue, 12 May 2026 16:42:34 -0500 Subject: [PATCH 8/8] Clarify that cached-api-call is the return value of cache/cache Co-Authored-By: Claude Sonnet 4.6 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a85406b..e941475 100644 --- a/README.md +++ b/README.md @@ -144,7 +144,7 @@ For bulk operations that might trigger multiple maintenance runs, you can use `w 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 -(require '[com.latacora.sqlite-cache.core :as cache]) +;; 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"})