packages feed

shikumi-cache-redis (empty) → 0.1.0.0

raw patch · 4 files changed

+255/−0 lines, 4 filesdep +aesondep +baikaidep +base

Dependencies added: aeson, baikai, base, bytestring, effectful, generic-lens, hedis, lens, shikumi, shikumi-cache, shikumi-cache-redis, tasty, tasty-hunit, text, vector

Files

+ CHANGELOG.md view
@@ -0,0 +1,10 @@+# Changelog++## Unreleased++## 0.1.0.0 - 2026-06-13++### Added++- Initial Hackage release of the Redis backend for shikumi-cache.+- Redis connection lifecycle helpers, configurable TTL support, and a cache interpreter for the shikumi cache effect.
+ shikumi-cache-redis.cabal view
@@ -0,0 +1,67 @@+cabal-version:   3.4+name:            shikumi-cache-redis+version:         0.1.0.0+synopsis:        Redis-backed shikumi cache (EP-6)+category:        AI+description:+  A Redis backend for shikumi's @Cache@ effect (EP-6), kept in a separate+  package so the heavy @hedis@ client and a running Redis server are only+  pulled in when actually used. Entries are stored under the string key+  @shikumi:cache:\<hex\>@ with a configurable TTL; the value is the UTF-8 JSON of+  a @CachedResponse@ (the @Response@ round-trip comes from @shikumi-cache@'s+  @Shikumi.Cache.ResponseJSON@).++license:         BSD-3-Clause+author:          Nadeem Bitar+maintainer:      nadeem@gmail.com+build-type:      Simple+extra-doc-files: CHANGELOG.md++common common-options+  ghc-options:+    -Wall -Wcompat -Widentities -Wincomplete-uni-patterns+    -Wincomplete-record-updates -Wredundant-constraints+    -fhide-source-paths -Wmissing-export-lists -Wpartial-fields+    -Wmissing-deriving-strategies++  default-language:   GHC2024+  default-extensions:+    DeriveAnyClass+    DuplicateRecordFields+    OverloadedLabels+    OverloadedStrings++library+  import:          common-options+  hs-source-dirs:  src+  exposed-modules: Shikumi.Cache.Backend.Redis+  build-depends:+    , aeson+    , base           >=4.20     && <5+    , bytestring+    , effectful+    , hedis+    , shikumi-cache  ^>=0.1.0.0+    , text           ^>=2.1++test-suite shikumi-cache-redis-test+  import:         common-options+  type:           exitcode-stdio-1.0+  hs-source-dirs: test+  main-is:        Main.hs+  ghc-options:    -threaded -with-rtsopts=-N+  build-depends:+    , baikai+    , base+    , bytestring+    , effectful+    , generic-lens+    , hedis+    , lens+    , shikumi              ^>=0.1.0.0+    , shikumi-cache        ^>=0.1.0.0+    , shikumi-cache-redis  ^>=0.1.0.0+    , tasty+    , tasty-hunit+    , text+    , vector
+ src/Shikumi/Cache/Backend/Redis.hs view
@@ -0,0 +1,72 @@+-- | The Redis cache backend (EP-6) — a server-backed, cross-machine store.+--+-- Unlike the in-memory (process-local) and SQLite (single-file) backends, a+-- Redis-backed cache is shared by every process that can reach the server, so a+-- response computed on one machine is served to another. Entries live under the+-- string key @shikumi:cache:\<hex\>@ (the @shikumi:cache:@ prefix is operational+-- namespacing, outside the content hash) and are written with @SETEX@ so Redis+-- evicts stale entries after a configurable TTL. The value is the UTF-8 JSON of+-- a 'CachedResponse'; the baikai 'Baikai.Response.Response' round-trip is the one+-- from @shikumi-cache@ ("Shikumi.Cache.ResponseJSON").+module Shikumi.Cache.Backend.Redis+  ( RedisCache,+    defaultRedisTTL,+    openRedisCache,+    openRedisCacheWithTTL,+    closeRedisCache,+    runCacheRedis,+  )+where++import Data.Aeson (decodeStrict', encode)+import Data.ByteString (ByteString)+import Data.ByteString.Lazy qualified as BL+import Data.Text.Encoding qualified as TE+import Database.Redis qualified as R+import Effectful (Eff, IOE, liftIO, (:>))+import Effectful.Dispatch.Dynamic (interpret)+import Shikumi.Cache (Cache (..), CacheKey (unCacheKey))++-- | An open Redis-backed cache: a connection plus the TTL (seconds) applied to+-- every stored entry.+data RedisCache = RedisCache+  { redisConn :: !R.Connection,+    redisTTL :: !Integer+  }++-- | The default entry TTL: 7 days (in seconds).+defaultRedisTTL :: Integer+defaultRedisTTL = 7 * 24 * 60 * 60++-- | Connect to Redis (verifying the connection eagerly via @checkedConnect@) with+-- the 'defaultRedisTTL'. Throws 'R.ConnectError' if the server is unreachable.+openRedisCache :: R.ConnectInfo -> IO RedisCache+openRedisCache = openRedisCacheWithTTL defaultRedisTTL++-- | As 'openRedisCache' but with an explicit per-entry TTL (seconds).+openRedisCacheWithTTL :: Integer -> R.ConnectInfo -> IO RedisCache+openRedisCacheWithTTL ttl ci = do+  conn <- R.checkedConnect ci+  pure (RedisCache conn ttl)++-- | Close the Redis connection.+closeRedisCache :: RedisCache -> IO ()+closeRedisCache = R.disconnect . redisConn++-- | The operational Redis key for a content-addressed 'CacheKey'.+redisKey :: CacheKey -> ByteString+redisKey k = TE.encodeUtf8 ("shikumi:cache:" <> unCacheKey k)++-- | Discharge the 'Cache' effect against Redis. A missing key, a Redis-level+-- error, or a JSON decode failure all surface as a cache MISS ('Nothing'), so a+-- transient hiccup or a stale-schema value is simply re-fetched.+runCacheRedis :: (IOE :> es) => RedisCache -> Eff (Cache : es) a -> Eff es a+runCacheRedis cache = interpret $ \_ -> \case+  LookupCache k -> liftIO $ do+    r <- R.runRedis (redisConn cache) (R.get (redisKey k))+    pure $ case r of+      Right (Just bs) -> decodeStrict' bs+      _ -> Nothing+  StoreCache k v -> liftIO $ do+    _ <- R.runRedis (redisConn cache) (R.setex (redisKey k) (redisTTL cache) (BL.toStrict (encode v)))+    pure ()
+ test/Main.hs view
@@ -0,0 +1,106 @@+-- | EP-6 Redis backend acceptance. Round-trips a @CachedResponse@ through a live+-- Redis over the @cachedLLM@ memoizer + a counting stub provider: the first+-- request is a MISS (provider called once, entry stored), a subsequent request+-- is a HIT served from Redis (no further provider call).+--+-- The test connects to the UNIX socket named by @REDIS_SOCKET@ (set by the dev+-- shell; started by @just services@). When the variable is unset or no server is+-- reachable, the suite __skips cleanly__ (prints a notice and exits 0) so CI+-- without a Redis stays green — exactly the graceful-skip the plan requires.+module Main (main) where++import Baikai (Context, Model, Options, Response, user, _Context, _Model, _Options, _Response)+import Control.Exception (SomeException, try)+import Control.Lens ((&), (.~))+import Control.Monad (void)+import Data.ByteString (ByteString)+import Data.Generics.Labels ()+import Data.IORef (IORef, modifyIORef', newIORef, readIORef)+import Data.List.NonEmpty (NonEmpty ((:|)))+import Data.Text.Encoding qualified as TE+import Data.Vector qualified as V+import Database.Redis qualified as R+import Effectful (Eff, IOE, liftIO, runEff, type (:>))+import Effectful.Dispatch.Dynamic (interpret)+import Shikumi.Cache (CacheKey (unCacheKey), cacheKey, cachedLLM)+import Shikumi.Cache.Backend.Redis (RedisCache, closeRedisCache, openRedisCache, runCacheRedis)+import Shikumi.Effect.Time (runTime)+import Shikumi.LLM (LLM (..), complete)+import System.Environment (lookupEnv)+import System.Exit (exitSuccess)+import Test.Tasty (TestTree, defaultMain, testGroup)+import Test.Tasty.HUnit (testCase, (@?=))++fixModel :: Model+fixModel = _Model & #modelId .~ "claude-sonnet-4-6" & #provider .~ "anthropic"++fixCtx :: Context+fixCtx = _Context & #systemPrompt .~ Just "You are helpful." & #messages .~ V.singleton (user "ping")++fixOpts :: Options+fixOpts = _Options & #temperature .~ Just 0.0 & #maxTokens .~ Just 1024++stubResponse :: Response+stubResponse = _Response++-- | A counting stub interpreter of EP-1's @LLM@: every completion bumps a+-- counter and returns a fixed response.+runCountingLLM :: (IOE :> es) => IORef Int -> Response -> Eff (LLM : es) a -> Eff es a+runCountingLLM ref resp = interpret $ \_ -> \case+  Complete {} -> liftIO (modifyIORef' ref (+ 1)) >> pure resp+  Stream {} -> pure []++main :: IO ()+main = do+  msock <- lookupEnv "REDIS_SOCKET"+  case msock of+    Nothing -> skip "REDIS_SOCKET is not set"+    Just sock -> do+      let ci = R.defaultConnectInfo {R.connectAddr = R.ConnectAddrUnixSocket sock}+      attempt <- try (openRedisCache ci) :: IO (Either SomeException RedisCache)+      case attempt of+        Left _ -> skip ("no Redis reachable at socket " <> sock)+        Right cache -> do+          clearKey ci+          defaultMain (tests cache)+          closeRedisCache cache+  where+    skip reason = do+      putStrLn ("[SKIP] shikumi-cache-redis: " <> reason)+      exitSuccess++-- | Delete any entry a previous run left for the fixed request, via a throwaway+-- connection, so the MISS→HIT counter starts honest.+clearKey :: R.ConnectInfo -> IO ()+clearKey ci = do+  conn <- R.checkedConnect ci+  void $ R.runRedis conn (R.del (redisKeyFor (cacheKey fixModel fixCtx fixOpts) :| []))+  R.disconnect conn++-- | Rebuild the backend's operational key (the backend keeps it private).+redisKeyFor :: CacheKey -> ByteString+redisKeyFor k = TE.encodeUtf8 ("shikumi:cache:" <> unCacheKey k)++tests :: RedisCache -> TestTree+tests cache =+  testGroup+    "shikumi-cache-redis"+    [ testCase "memoize: first request MISS (provider once), repeat is a Redis HIT" $ do+        -- Two identical requests in one run: provider hit exactly once.+        refA <- newIORef 0+        (r1, r2) <-+          runEff . runTime . runCacheRedis cache . runCountingLLM refA stubResponse . cachedLLM $ do+            a <- complete fixModel fixCtx fixOpts+            b <- complete fixModel fixCtx fixOpts+            pure (a, b)+        nA <- readIORef refA+        nA @?= 1+        r1 @?= r2+        -- A fresh run (fresh counter) now finds the entry already in Redis → HIT.+        refB <- newIORef 0+        _ <-+          runEff . runTime . runCacheRedis cache . runCountingLLM refB stubResponse . cachedLLM $+            complete fixModel fixCtx fixOpts+        nB <- readIORef refB+        nB @?= 0+    ]