packages feed

shikumi-cache 0.1.1.0 → 0.1.2.0

raw patch · 8 files changed

+299/−38 lines, 8 filesdep ~baikaidep ~shikumidep ~shikumi-cachePVP ok

version bump matches the API change (PVP)

Dependency ranges changed: baikai, shikumi, shikumi-cache

API changes (from Hackage documentation)

+ Shikumi.Cache: CacheConfig :: Maybe NominalDiffTime -> CacheConfig
+ Shikumi.Cache: [entryTTL] :: CacheConfig -> Maybe NominalDiffTime
+ Shikumi.Cache: cachedLLMWith :: forall (es :: [Effect]) a. (Cache :> es, LLM :> es, Time :> es) => CacheConfig -> Eff es a -> Eff es a
+ Shikumi.Cache: defaultCacheConfig :: CacheConfig
+ Shikumi.Cache: instance GHC.Classes.Eq Shikumi.Cache.CacheConfig
+ Shikumi.Cache: instance GHC.Internal.Generics.Generic Shikumi.Cache.CacheConfig
+ Shikumi.Cache: instance GHC.Internal.Show.Show Shikumi.Cache.CacheConfig
+ Shikumi.Cache: newtype CacheConfig
+ Shikumi.Cache.Backend.Effort: bestEffortIO :: a -> IO a -> IO a
+ Shikumi.Cache.Key: stripMessageTimestamps :: Value -> Value

Files

CHANGELOG.md view
@@ -2,6 +2,25 @@  ## Unreleased +## 0.1.2.0 - 2026-07-05++### Added++- `CacheConfig`, `defaultCacheConfig`, and `cachedLLMWith` for a shared policy+  layer across cache backends, including optional TTL-based entry expiry.+- `Shikumi.Cache.Backend.Effort` for best-effort backend error handling and+  `stripMessageTimestamps` for canonical cache-key inspection.++### Changed++- Cache keys now include endpoint identity, default/per-call headers, provider+  compatibility settings, and omit message construction timestamps. This bumps+  `currentKeyVersion` to `shikumi-cache/v2`, invalidating old cache entries and+  old trace replay keys.+- `cachedLLM` no longer stores in-band provider error responses, and persistent+  backend lookup/store failures degrade to cache misses or no-ops.+- Refreshed the internal `shikumi` bound for the `0.3` series.+ ## 0.1.1.0 - 2026-06-28  ### Changed
shikumi-cache.cabal view
@@ -1,6 +1,6 @@ cabal-version:   3.4 name:            shikumi-cache-version:         0.1.1.0+version:         0.1.2.0 synopsis:        Content-addressed response caching for shikumi (EP-6) category:        AI description:@@ -36,6 +36,7 @@   hs-source-dirs:  src   exposed-modules:     Shikumi.Cache+    Shikumi.Cache.Backend.Effort     Shikumi.Cache.Backend.Memory     Shikumi.Cache.Backend.SQLite     Shikumi.Cache.Key@@ -44,7 +45,7 @@    build-depends:     , aeson-    , baikai         >=0.2      && <0.3+    , baikai         >=0.3      && <0.4     , base           >=4.20     && <5     , blake3     , bytestring@@ -54,7 +55,7 @@     , generic-lens     , lens           ^>=5.3     , scientific-    , shikumi        ^>=0.2.0.0+    , shikumi        ^>=0.3.0.0     , stm     , text           ^>=2.1     , time@@ -68,18 +69,19 @@   ghc-options:    -threaded -with-rtsopts=-N   build-depends:     , aeson-    , baikai         >=0.2      && <0.3+    , baikai         >=0.3      && <0.4     , base     , bytestring     , containers+    , direct-sqlite     , directory     , effectful     , filepath     , generic-lens     , lens     , process-    , shikumi        ^>=0.2.0.0-    , shikumi-cache  ^>=0.1.1.0+    , shikumi        ^>=0.3.0.0+    , shikumi-cache  ^>=0.1.2.0     , stm     , tasty     , tasty-hunit
src/Shikumi/Cache.hs view
@@ -16,7 +16,10 @@     storeCache,      -- * The memoizing policy+    CacheConfig (..),+    defaultCacheConfig,     cachedLLM,+    cachedLLMWith,      -- * Re-exports     CacheKey (..),@@ -26,8 +29,15 @@   ) where +import Baikai (Response, StopReason (ErrorReason))+import Control.Lens ((^.))+import Control.Monad (when)+import Data.Generics.Labels ()+import Data.Maybe (isNothing)+import Data.Time.Clock (NominalDiffTime, diffUTCTime) import Effectful (Dispatch (Dynamic), DispatchOf, Eff, Effect, (:>)) import Effectful.Dispatch.Dynamic (interpose, passthrough, send)+import GHC.Generics (Generic) import Shikumi.Cache.Key (CacheKey (..), cacheKey, currentKeyVersion) import Shikumi.Cache.Types (CachedResponse (..)) import Shikumi.Effect.Time (Time, getCurrentTime)@@ -48,25 +58,72 @@ storeCache :: (Cache :> es) => CacheKey -> CachedResponse -> Eff es () storeCache k v = send (StoreCache k v) +-- | Policy knobs for 'cachedLLMWith', shared by every backend.+--+-- 'entryTTL' is the maximum age of a usable entry, measured against+-- 'CachedResponse.storedAt' at lookup time. 'Nothing' (the default) means+-- entries never expire, which is the uniform default across Memory, SQLite,+-- Redis, and Postgres. Expiry is enforced here, at the policy layer, so it+-- behaves identically no matter which backend interprets the 'Cache' effect; an+-- expired entry is treated as a MISS and overwritten by the fresh response.+newtype CacheConfig = CacheConfig+  { entryTTL :: Maybe NominalDiffTime+  }+  deriving stock (Eq, Show, Generic)++-- | No expiry.+defaultCacheConfig :: CacheConfig+defaultCacheConfig = CacheConfig {entryTTL = Nothing}+ -- | Memoize EP-1's @LLM@ @complete@ through the 'Cache' effect: on a HIT (a key--- present whose 'keyVersion' matches the live namespace) return the stored--- response without contacting the provider; on a MISS delegate to the underlying--- @LLM@ handler, store the result, and return it. A stale-version entry is--- treated as a MISS (it is overwritten on re-fetch). The streaming op is passed+-- present whose 'keyVersion' matches the live namespace and whose age satisfies+-- 'defaultCacheConfig') return the stored response without contacting the+-- provider; on a MISS delegate to the underlying @LLM@ handler, store the+-- result if it is successful, and return it. Cache backends are best-effort:+-- lookup failures are MISSes and store failures are no-ops. Entries never+-- expire unless 'entryTTL' is set via 'cachedLLMWith'. In-band error responses+-- are never cached. Under concurrent identical requests both callers may miss+-- and call the provider; this accepted check-then-act race is harmless because+-- stores are idempotent upserts keyed by content. The streaming op is passed -- through unchanged — streams are not cached. cachedLLM ::   (Cache :> es, LLM :> es, Time :> es) =>   Eff es a ->   Eff es a-cachedLLM = interpose $ \env -> \case+cachedLLM = cachedLLMWith defaultCacheConfig++-- | A configured variant of 'cachedLLM'. See 'CacheConfig' for the shared TTL+-- policy.+cachedLLMWith ::+  (Cache :> es, LLM :> es, Time :> es) =>+  CacheConfig ->+  Eff es a ->+  Eff es a+cachedLLMWith cfg = interpose $ \env -> \case   Complete model ctx opts -> do     let key = cacheKey model ctx opts     hit <- lookupCache key+    now <- getCurrentTime     case hit of-      Just cr | keyVersion cr == currentKeyVersion -> pure (response cr)+      Just cr+        | keyVersion cr == currentKeyVersion,+          fresh (entryTTL cfg) now (storedAt cr) ->+            pure (response cr)       _ -> do         resp <- complete model ctx opts-        now <- getCurrentTime-        storeCache key (CachedResponse resp now currentKeyVersion)+        stored <- getCurrentTime+        when (cacheable resp) $+          storeCache key (CachedResponse resp stored currentKeyVersion)         pure resp   other -> passthrough env other+  where+    fresh Nothing _ _ = True+    fresh (Just ttl) now written = diffUTCTime now written <= ttl++-- | Only successful responses are memoized. An in-band error response reports a+-- transient provider failure; caching it would replay the outage as the+-- permanent answer.+cacheable :: Response -> Bool+cacheable resp =+  (resp ^. #message . #stopReason) /= ErrorReason+    && isNothing (resp ^. #errorInfo)
+ src/Shikumi/Cache/Backend/Effort.hs view
@@ -0,0 +1,15 @@+-- | The one error posture every persistent cache backend shares: best-effort.+-- A failed storage action degrades to its fallback (a MISS for lookups, a no-op+-- for stores) instead of throwing. Asynchronous exceptions such as thread+-- cancellation and timeouts are re-thrown so structured concurrency keeps+-- working correctly.+module Shikumi.Cache.Backend.Effort (bestEffortIO) where++import Control.Exception (SomeAsyncException (..), SomeException, catch, fromException, throwIO)++bestEffortIO :: a -> IO a -> IO a+bestEffortIO fallback act =+  act `catch` \(e :: SomeException) ->+    case fromException e of+      Just (SomeAsyncException _) -> throwIO e+      Nothing -> pure fallback
src/Shikumi/Cache/Backend/SQLite.hs view
@@ -11,7 +11,9 @@ -- The store is a thin embedded SQLite database (no server) accessed via -- @direct-sqlite@. A single 'Database.SQLite3.Database' handle is guarded by an -- 'MVar' so the 'Cache' effect's lookups and stores are serialized (SQLite's--- default threading mode does not allow concurrent use of one connection).+-- default threading mode does not allow concurrent use of one connection). WAL+-- mode and a busy timeout make separate processes cooperate better. Lookup and+-- store failures are best-effort: they degrade to a MISS or no-op. module Shikumi.Cache.Backend.SQLite   ( SQLiteCache,     openSQLiteCache,@@ -39,6 +41,7 @@ import Effectful (Eff, IOE, liftIO, (:>)) import Effectful.Dispatch.Dynamic (interpret) import Shikumi.Cache (Cache (..), CacheKey (unCacheKey), CachedResponse (..))+import Shikumi.Cache.Backend.Effort (bestEffortIO)  -- | A handle to an open SQLite-backed cache. The 'Database' is behind an 'MVar' -- so all access through the 'Cache' effect is serialized.@@ -46,7 +49,8 @@  -- | The schema. The @key@ is the 64-hex 'CacheKey' (already version-namespaced -- via the @version@ field baked into the hash). @value@ is the UTF-8 JSON of--- 'CachedResponse'. @stored_at@ is ISO-8601 for inspection/eviction.+-- 'CachedResponse'. @stored_at@ is ISO-8601 for inspection; policy-layer TTL+-- uses the same timestamp inside the JSON value. createTableSQL :: Text createTableSQL =   """@@ -78,6 +82,8 @@ openSQLiteCache path = do   db <- SQL.open (T.pack path)   SQL.exec db createTableSQL+  SQL.exec db "PRAGMA journal_mode=WAL;"+  SQL.exec db "PRAGMA busy_timeout=5000;"   SQLiteCache <$> newMVar db  -- | Close the underlying database handle.@@ -93,8 +99,8 @@ -- simply re-fetched and overwritten — never returned. runCacheSQLite :: (IOE :> es) => SQLiteCache -> Eff (Cache : es) a -> Eff es a runCacheSQLite (SQLiteCache mv) = interpret $ \_ -> \case-  LookupCache k -> liftIO (withMVar mv (sqliteLookup k))-  StoreCache k v -> liftIO (withMVar mv (\db -> sqliteStore db k v))+  LookupCache k -> liftIO (bestEffortIO Nothing (withMVar mv (sqliteLookup k)))+  StoreCache k v -> liftIO (bestEffortIO () (withMVar mv (\db -> sqliteStore db k v)))  -- | SELECT the JSON for a key and decode it; 'Nothing' on absent key or decode -- failure.
src/Shikumi/Cache/Key.hs view
@@ -3,13 +3,18 @@ -- | The content-addressed cache key (EP-6) — the MasterPlan's integration point -- #7. A 'CacheKey' is a BLAKE3 256-bit digest (64 lowercase hex chars) over a -- /canonical/ JSON serialization of everything about a request that can change--- the model's answer: the model routing identity, the rendered prompt, the--- tools, and the per-call options. Latency, response ids, API keys, timeouts,--- headers, and per-call metadata are excluded — they do not affect the output.+-- the model's answer: the model routing identity including base URL, model+-- default headers, and compat shim; per-call headers; the rendered prompt with+-- message timestamps excluded; tools; and sampling options. Latency, response+-- ids, API keys, timeouts, and per-call metadata are excluded — they do not+-- affect a successful response's content. -- -- The serialization is canonical (object keys sorted by Unicode code point, no -- insignificant whitespace, UTF-8) so the bytes — and therefore the key — are--- reproducible. The replay engine in+-- reproducible. Any change to the field set requires bumping+-- 'currentKeyVersion'; a bump invalidates all cache entries and makes previously+-- recorded @shikumi-trace@ files unreplayable, with replay failing closed via+-- @ReplayDivergence@. The replay engine in -- @docs/plans/7-hierarchical-tracing-observability-and-replay.md@ (EP-7) reuses -- 'cacheKey' verbatim, so both plans agree byte-for-byte; the golden test in the -- test suite pins the exact hex for a fixed request to catch any drift.@@ -20,6 +25,7 @@     requestToCanonicalValue,     requestToCanonicalValueVersioned,     currentKeyVersion,+    stripMessageTimestamps,   ) where @@ -44,9 +50,12 @@ -- | The current cache key-namespace version. Baked into every hashed request -- (the @version@ field) and into 'Shikumi.Cache.Types.CachedResponse.keyVersion'. -- Bumping it changes every key, making all prior entries unreachable — a clean--- invalidation with no row deletion.+-- invalidation with no row deletion. Because @shikumi-trace@ stores cache keys+-- in trace files and recomputes them during replay, a version bump also makes+-- previously recorded trace files unreplayable; replay fails closed with+-- @ReplayDivergence@ rather than serving stale responses. currentKeyVersion :: Text-currentKeyVersion = "shikumi-cache/v1"+currentKeyVersion = "shikumi-cache/v2"  -- | A content-addressed cache key: the 64-hex BLAKE3 digest of a request's -- canonical serialization. A @newtype@ so it cannot be confused with arbitrary@@ -71,9 +80,10 @@ -- | Assemble the canonical request 'Value' under an explicit version string. -- Exposed so a test can prove that bumping the version changes the key. The -- top-level object carries exactly the integration-point-#7 field set:--- @api, maxTokens, messages, model, provider, responseFormat, systemPrompt,--- temperature, thinking, toolChoice, tools, version@. 'canonicalJSON' sorts the--- keys, so the listing order here is irrelevant.+-- @api, baseUrl, compat, maxTokens, messages, model, modelHeaders,+-- optionsHeaders, provider, responseFormat, systemPrompt, temperature,+-- thinking, toolChoice, tools, version@. 'canonicalJSON' sorts the keys, so the+-- listing order here is irrelevant. requestToCanonicalValueVersioned :: Text -> Model -> Context -> Options -> Value requestToCanonicalValueVersioned version m ctx opts =   object@@ -81,12 +91,16 @@       "model" .= (m ^. #modelId),       "provider" .= (m ^. #provider),       "api" .= toJSON (m ^. #api),+      "baseUrl" .= (m ^. #baseUrl),+      "modelHeaders" .= toJSON (m ^. #headers),+      "compat" .= toJSON (m ^. #compat),       "systemPrompt" .= (ctx ^. #systemPrompt),-      "messages" .= toJSON (ctx ^. #messages),+      "messages" .= stripMessageTimestamps (toJSON (ctx ^. #messages)),       "tools" .= toJSON (ctx ^. #tools),       "toolChoice" .= toJSON (opts ^. #toolChoice),       "temperature" .= (toScientific <$> (opts ^. #temperature)),       "maxTokens" .= (opts ^. #maxTokens),+      "optionsHeaders" .= toJSON (opts ^. #headers),       "thinking" .= toJSON (opts ^. #thinking),       "responseFormat" .= toJSON (opts ^. #responseFormat)     ]@@ -95,6 +109,26 @@     -- temperature serialize identically regardless of how the Double was built.     toScientific :: Double -> Scientific     toScientific = realToFrac++-- | Delete the payload-level @timestamp@ of every message in a serialized+-- message vector. baikai's 'Baikai.Message.Message' encodes as+-- @{"tag": ..., "contents": {..., "timestamp": ...}}@; the timestamp records+-- when the message value was built, never what the provider sees, so two+-- requests differing only in it must share a cache key. Only the+-- @contents.timestamp@ level is deleted — a @"timestamp"@ key nested inside+-- content blocks or tool arguments is real request data and is preserved.+stripMessageTimestamps :: Value -> Value+stripMessageTimestamps (Array msgs) = Array (fmap stripOne msgs)+  where+    stripOne (Object msg) =+      Object $+        case KM.lookup "contents" msg of+          Nothing -> msg+          Just contents -> KM.insert "contents" (dropTs contents) msg+    stripOne v = v+    dropTs (Object payload) = Object (KM.delete "timestamp" payload)+    dropTs v = v+stripMessageTimestamps v = v  -- | Render a 'Value' to canonical JSON bytes: every object's keys are emitted in -- ascending Unicode code-point order, with no insignificant whitespace. We do
src/Shikumi/Cache/ResponseJSON.hs view
@@ -57,7 +57,12 @@ usageOptions = defaultOptions {fieldLabelModifier = camelTo2 '_'}  -- | Parse a required object field as 'Scientific' and lift it to 'Rational',--- inverting baikai's @Rational -> Scientific@ cost encoding.+-- inverting baikai's @Rational -> Scientific@ cost encoding. A synthetic cost+-- with a non-terminating decimal expansion, such as @1 % 3@, does not satisfy+-- 'Eq' after an encode/decode round-trip because baikai's encoder drops the+-- repetend index. Real USD pricing rates are decimal, so real provider costs+-- terminate; do not rely on round-tripped 'CachedResponse' equality for+-- synthetic non-decimal costs. ratField :: Object -> Key -> Parser Rational ratField o k = toRational <$> (o .: k :: Parser Scientific) 
test/Main.hs view
@@ -12,13 +12,32 @@ -- process. module Main (main) where -import Baikai (Context, Model, Options, Response, user, _Context, _Model, _Options, _Response)+import Baikai+  ( Compat (CompatAnthropicMessages),+    Context,+    Model,+    Options,+    Response,+    StopReason (ErrorReason),+    defaultAnthropicMessagesCompat,+    user,+    userAt,+    _Context,+    _Model,+    _Options,+    _Response,+  )+import Control.Exception (bracket) import Control.Lens ((&), (.~))+import Data.Aeson (object, toJSON, (.=)) import Data.Generics.Labels () import Data.IORef (IORef, modifyIORef', newIORef, readIORef)+import Data.Map.Strict qualified as Map import Data.Text qualified as T import Data.Time.Clock (UTCTime) import Data.Vector qualified as V+import Database.SQLite3 (Database, SQLData (SQLText), StepResult (Done))+import Database.SQLite3 qualified as SQL import Effectful (Eff, IOE, liftIO, runEff, type (:>)) import Effectful.Concurrent (runConcurrent) import Effectful.Dispatch.Dynamic (interpret)@@ -27,13 +46,16 @@     CachedResponse (..),     cacheKey,     cachedLLM,+    cachedLLMWith,     currentKeyVersion,+    defaultCacheConfig,+    entryTTL,     lookupCache,     storeCache,   ) import Shikumi.Cache.Backend.Memory (newMemoryCache, runCacheMemory) import Shikumi.Cache.Backend.SQLite (runCacheSQLite, withSQLiteCache)-import Shikumi.Cache.Key (canonicalJSON, requestToCanonicalValueVersioned)+import Shikumi.Cache.Key (canonicalJSON, requestToCanonicalValueVersioned, stripMessageTimestamps) import Shikumi.Effect.Time (runTime) import Shikumi.LLM (LLM (..), complete) import System.Environment (getEnvironment, getExecutablePath, lookupEnv)@@ -64,7 +86,7 @@ -- reproduce. Captured from a first run; any drift in field set, canonical JSON, -- or hash breaks this test. pinnedKey :: T.Text-pinnedKey = "30b2015562ec8b5cd4fdb64c7cc671c84f56f80d24891deec6676c521f008113"+pinnedKey = "b31fd70140abbd0198c6b7caec748a8389bf93be909164bdcc340731b7032564"  stubResponse :: Response stubResponse = _Response@@ -148,7 +170,48 @@       testCase "a different request yields a different key" $         assertBool           "temperature change must change the key"-          (cacheKey fixModel fixCtx fixOpts /= cacheKey fixModel fixCtx (fixOpts & #temperature .~ Just 0.7))+          (cacheKey fixModel fixCtx fixOpts /= cacheKey fixModel fixCtx (fixOpts & #temperature .~ Just 0.7)),+      testCase "baseUrl changes the key" $+        assertBool+          "endpoint routing must be part of the key"+          (cacheKey fixModel fixCtx fixOpts /= cacheKey (fixModel & #baseUrl .~ "https://proxy.internal") fixCtx fixOpts),+      testCase "model headers change the key" $+        assertBool+          "model default headers must be part of the key"+          ( cacheKey fixModel fixCtx fixOpts+              /= cacheKey (fixModel & #headers .~ Map.singleton "anthropic-beta" "context-1m-2025-08-07") fixCtx fixOpts+          ),+      testCase "options headers change the key" $+        assertBool+          "per-call headers must be part of the key"+          ( cacheKey fixModel fixCtx fixOpts+              /= cacheKey fixModel fixCtx (fixOpts & #headers .~ Map.singleton "anthropic-beta" "output-128k-2025-02-19")+          ),+      testCase "compat shim changes the key" $+        assertBool+          "compat request-shaping flags must be part of the key"+          ( cacheKey fixModel fixCtx fixOpts+              /= cacheKey (fixModel & #compat .~ CompatAnthropicMessages defaultAnthropicMessagesCompat) fixCtx fixOpts+          ),+      testCase "message timestamps do not affect the key" $ do+        let t1 = read "2026-01-01 00:00:00 UTC" :: UTCTime+            t2 = read "2026-06-30 12:34:56 UTC" :: UTCTime+            ctxAt t = fixCtx & #messages .~ V.singleton (userAt t "ping")+        cacheKey fixModel (ctxAt t1) fixOpts @?= cacheKey fixModel (ctxAt t2) fixOpts+        cacheKey fixModel fixCtx fixOpts @?= cacheKey fixModel (ctxAt t1) fixOpts,+      testCase "stripMessageTimestamps removes only the payload-level timestamp" $ do+        let msg inner =+              object+                [ "tag" .= ("UserMessage" :: T.Text),+                  "contents"+                    .= object+                      [ "timestamp" .= ("2026-01-01T00:00:00Z" :: T.Text),+                        "content" .= [object ["args" .= object ["timestamp" .= inner]]]+                      ]+                ]+            stripped x = stripMessageTimestamps (toJSON [msg (x :: T.Text)])+        assertBool "nested timestamps still discriminate" (stripped "a" /= stripped "b")+        stripped "a" @?= stripped "a"     ]  memoryTests :: TestTree@@ -195,9 +258,44 @@           (wc, _, werr) <- runPhase "write"           assertBool ("write phase failed: " <> werr) (wc == ExitSuccess)           (rc, _, rerr) <- runPhase "read"-          assertBool ("read phase did not find the on-disk entry: " <> rerr) (rc == ExitSuccess)+          assertBool ("read phase did not find the on-disk entry: " <> rerr) (rc == ExitSuccess),+      testCase "a corrupt row decodes as a MISS" $+        withSystemTempDirectory "shikumi-sqlite" $ \dir -> do+          let file = dir </> "corrupt.db"+          got <- withSQLiteCache file $ \c -> do+            insertRawSQLiteRow file restartKey "not json"+            runEff . runCacheSQLite c $ lookupCache restartKey+          got @?= Nothing,+      testCase "a dropped table degrades lookup/store to MISS / no-op" $+        withSystemTempDirectory "shikumi-sqlite" $ \dir -> do+          let file = dir </> "dropped.db"+          withSQLiteCache file $ \c -> do+            withRawSQLite file (`SQL.exec` "DROP TABLE shikumi_cache;")+            got <- runEff . runCacheSQLite c $ lookupCache restartKey+            got @?= Nothing+            runEff . runCacheSQLite c $ storeCache restartKey restartEntry     ] +withRawSQLite :: FilePath -> (Database -> IO a) -> IO a+withRawSQLite file = bracket (SQL.open (T.pack file)) SQL.close++insertRawSQLiteRow :: FilePath -> CacheKey -> T.Text -> IO ()+insertRawSQLiteRow file key rawValue =+  withRawSQLite file $ \db ->+    bracket+      ( SQL.prepare+          db+          """+          INSERT OR REPLACE INTO shikumi_cache (key, value, stored_at)+          VALUES (?, ?, ?);+          """+      )+      SQL.finalize+      $ \stmt -> do+        SQL.bind stmt [SQLText (unCacheKey key), SQLText rawValue, SQLText "2026-06-08T00:00:00Z"]+        res <- SQL.step stmt+        res @?= Done+ memoizeTests :: TestTree memoizeTests =   testGroup@@ -221,7 +319,32 @@             _ <- complete fixModel fixCtx fixOpts             complete fixModel fixCtx (fixOpts & #temperature .~ Just 0.7)         n <- readIORef ref-        n @?= 2+        n @?= 2,+      testCase "an in-band error response is not memoized" $ do+        tv <- newMemoryCache+        ref <- newIORef 0+        let errResp = stubResponse & #message . #stopReason .~ ErrorReason+        _ <-+          runEff . runConcurrent . runTime . runCacheMemory tv . runCountingLLM ref errResp . cachedLLM $ do+            _ <- complete fixModel fixCtx fixOpts+            complete fixModel fixCtx fixOpts+        n <- readIORef ref+        n @?= 2,+      testCase "an entry older than entryTTL is a MISS; the refetched entry is a HIT" $ do+        tv <- newMemoryCache+        ref <- newIORef 0+        let key = cacheKey fixModel fixCtx fixOpts+            cfg = defaultCacheConfig {entryTTL = Just 3600}+        runEff . runConcurrent . runCacheMemory tv $+          storeCache key (CachedResponse stubResponse someTime currentKeyVersion)+        _ <-+          runEff . runConcurrent . runTime . runCacheMemory tv . runCountingLLM ref stubResponse . cachedLLMWith cfg $+            complete fixModel fixCtx fixOpts+        _ <-+          runEff . runConcurrent . runTime . runCacheMemory tv . runCountingLLM ref stubResponse . cachedLLMWith cfg $+            complete fixModel fixCtx fixOpts+        n <- readIORef ref+        n @?= 1     ]  versioningTests :: TestTree@@ -246,8 +369,8 @@         n @?= 1,       testCase "bumping the namespace version changes the hashed bytes" $         assertBool-          "v1 and v2 canonical serializations must differ"-          ( canonicalJSON (requestToCanonicalValueVersioned "shikumi-cache/v1" fixModel fixCtx fixOpts)-              /= canonicalJSON (requestToCanonicalValueVersioned "shikumi-cache/v2" fixModel fixCtx fixOpts)+          "current and next-version canonical serializations must differ"+          ( canonicalJSON (requestToCanonicalValueVersioned currentKeyVersion fixModel fixCtx fixOpts)+              /= canonicalJSON (requestToCanonicalValueVersioned "shikumi-cache/v3" fixModel fixCtx fixOpts)           )     ]