diff --git a/README.md b/README.md index 649f854..e941475 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 +;; 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. diff --git a/src/com/latacora/sqlite_cache/core.clj b/src/com/latacora/sqlite_cache/core.clj index 8bfa97c..d9a7549 100644 --- a/src/com/latacora/sqlite_cache/core.clj +++ b/src/com/latacora/sqlite_cache/core.clj @@ -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. 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 diff --git a/test/com/latacora/sqlite_cache/core_test.clj b/test/com/latacora/sqlite_cache/core_test.clj index 0ce83c3..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]) @@ -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 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))))))