packages feed

shikumi-cache-postgres (empty) → 0.1.0.0

raw patch · 4 files changed

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

Dependencies added: aeson, baikai, base, effectful, ephemeral-pg, generic-lens, hasql, lens, shikumi, shikumi-cache, shikumi-cache-postgres, 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 PostgreSQL backend for shikumi-cache.+- Postgres connection lifecycle helpers, table initialization, lookup, store, and cache interpreter support.
+ shikumi-cache-postgres.cabal view
@@ -0,0 +1,67 @@+cabal-version:   3.4+name:            shikumi-cache-postgres+version:         0.1.0.0+synopsis:        Postgres-backed shikumi cache (EP-6)+category:        AI+description:+  A PostgreSQL backend for shikumi's @Cache@ effect (EP-6), in a separate+  package so the @hasql@ client (and libpq) is only pulled in when used. Entries+  live in a @shikumi_cache(key text primary key, value jsonb, stored_at+  timestamptz)@ table, upserted on conflict; the value is the JSON of a+  @CachedResponse@ (the @Response@ round-trip comes from @shikumi-cache@'s+  @Shikumi.Cache.ResponseJSON@). The test uses @ephemeral-pg@ to spin up a+  throwaway server, so it needs no external database.++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+    MultilineStrings+    OverloadedLabels+    OverloadedStrings++library+  import:          common-options+  hs-source-dirs:  src+  exposed-modules: Shikumi.Cache.Backend.Postgres+  build-depends:+    , aeson+    , base           >=4.20     && <5+    , effectful+    , hasql+    , shikumi-cache  ^>=0.1.0.0+    , text           ^>=2.1++test-suite shikumi-cache-postgres-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+    , effectful+    , ephemeral-pg+    , generic-lens+    , lens+    , shikumi                 ^>=0.1.0.0+    , shikumi-cache           ^>=0.1.0.0+    , shikumi-cache-postgres  ^>=0.1.0.0+    , tasty+    , tasty-hunit+    , text+    , vector
+ src/Shikumi/Cache/Backend/Postgres.hs view
@@ -0,0 +1,108 @@+-- | The Postgres cache backend (EP-6) — a server-backed, cross-machine store+-- with a durable SQL table.+--+-- Like the Redis backend it is shared by every process that can reach the+-- server, but it persists entries in a @jsonb@ column rather than an evicting+-- key-value store, so entries live until explicitly removed. The value column+-- holds the JSON of a 'CachedResponse' (the baikai 'Baikai.Response.Response'+-- round-trip is @shikumi-cache@'s "Shikumi.Cache.ResponseJSON"). Access goes+-- through @hasql@; the single 'Connection' is guarded by an 'MVar' so the+-- 'Cache' effect's lookups and stores are serialized.+module Shikumi.Cache.Backend.Postgres+  ( PostgresCache,+    openPostgresCache,+    closePostgresCache,+    runCachePostgres,+  )+where++import Control.Concurrent.MVar (MVar, newMVar, withMVar)+import Control.Exception (throwIO)+import Data.Aeson (Result (Error, Success), Value, fromJSON, toJSON)+import Data.Functor.Contravariant ((>$<))+import Data.Text (Text)+import Effectful (Eff, IOE, liftIO, (:>))+import Effectful.Dispatch.Dynamic (interpret)+import Hasql.Connection (Connection)+import Hasql.Connection qualified as Connection+import Hasql.Connection.Settings (Settings)+import Hasql.Decoders qualified as D+import Hasql.Encoders qualified as E+import Hasql.Session (statement)+import Hasql.Statement (Statement, preparable, unpreparable)+import Shikumi.Cache (Cache (..), CacheKey (unCacheKey), CachedResponse)++-- | An open Postgres-backed cache: a single connection behind an 'MVar' (hasql+-- connections are not safe for concurrent use), with the schema ensured at open.+newtype PostgresCache = PostgresCache {pgConn :: MVar Connection}++-- | Connect using the given hasql settings (e.g. @EphemeralPg.connectionSettings@+-- for tests, or @Hasql.Connection.Settings@ builders in production), ensuring the+-- @shikumi_cache@ table exists. Throws on a connection or schema-creation error.+openPostgresCache :: Settings -> IO PostgresCache+openPostgresCache settings = do+  res <- Connection.acquire settings+  case res of+    Left err -> throwIO (userError ("shikumi-cache-postgres: connection failed: " <> show err))+    Right conn -> do+      r <- Connection.use conn (statement () createTableStmt)+      case r of+        Left e -> throwIO (userError ("shikumi-cache-postgres: schema creation failed: " <> show e))+        Right () -> PostgresCache <$> newMVar conn++-- | Release the connection.+closePostgresCache :: PostgresCache -> IO ()+closePostgresCache (PostgresCache mv) = withMVar mv Connection.release++-- | Discharge the 'Cache' effect against Postgres. A missing row, a session+-- error, or a JSON decode failure all surface as a MISS ('Nothing'); a store+-- error is swallowed (best-effort caching — the next call simply re-fetches).+runCachePostgres :: (IOE :> es) => PostgresCache -> Eff (Cache : es) a -> Eff es a+runCachePostgres (PostgresCache mv) = interpret $ \_ -> \case+  LookupCache k -> liftIO $ do+    r <- withMVar mv (\conn -> Connection.use conn (statement (unCacheKey k) lookupStmt))+    pure $ case r of+      Right (Just v) -> case fromJSON v of+        Success cr -> Just cr+        Error _ -> Nothing+      _ -> Nothing+  StoreCache k v -> liftIO $ do+    _ <- withMVar mv (\conn -> Connection.use conn (statement (unCacheKey k, toJSON v) storeStmt))+    pure ()++-- | @CREATE TABLE IF NOT EXISTS@ (unpreparable — it is a utility statement).+createTableStmt :: Statement () ()+createTableStmt =+  unpreparable+    """+    CREATE TABLE IF NOT EXISTS shikumi_cache+      ( key       text PRIMARY KEY+      , value     jsonb NOT NULL+      , stored_at timestamptz NOT NULL DEFAULT now()+      )+    """+    mempty+    D.noResult++-- | SELECT the jsonb value for a key (NULL row → 'Nothing').+lookupStmt :: Statement Text (Maybe Value)+lookupStmt =+  preparable+    """+    SELECT value FROM shikumi_cache WHERE key = $1+    """+    (E.param (E.nonNullable E.text))+    (D.rowMaybe (D.column (D.nonNullable D.jsonb)))++-- | Upsert the jsonb value for a key (idempotent).+storeStmt :: Statement (Text, Value) ()+storeStmt =+  preparable+    """+    INSERT INTO shikumi_cache (key, value) VALUES ($1, $2)+    ON CONFLICT (key) DO UPDATE SET value = excluded.value, stored_at = now()+    """+    ( (fst >$< E.param (E.nonNullable E.text))+        <> (snd >$< E.param (E.nonNullable E.jsonb))+    )+    D.noResult
+ test/Main.hs view
@@ -0,0 +1,85 @@+-- | EP-6 Postgres backend acceptance. Round-trips a @CachedResponse@ through a+-- real PostgreSQL via the @cachedLLM@ memoizer + a counting stub provider: the+-- first request is a MISS (provider called once, row upserted), a subsequent+-- request is a HIT served from Postgres (no further provider call).+--+-- The database is a throwaway instance started by @ephemeral-pg@ (its own+-- @initdb@/@postgres@ over a private socket), so the test is hermetic and needs+-- no external server. If the cluster cannot be started (e.g. no postgres binary+-- on PATH), the suite __skips cleanly__ (prints a notice and exits 0).+module Main (main) where++import Baikai (Context, Model, Options, Response, user, _Context, _Model, _Options, _Response)+import Control.Exception (finally)+import Control.Lens ((&), (.~))+import Data.Generics.Labels ()+import Data.IORef (IORef, modifyIORef', newIORef, readIORef)+import Data.Text qualified as T+import Data.Vector qualified as V+import Effectful (Eff, IOE, liftIO, runEff, type (:>))+import Effectful.Dispatch.Dynamic (interpret)+import EphemeralPg qualified as Pg+import Shikumi.Cache (cacheKey, cachedLLM)+import Shikumi.Cache.Backend.Postgres (PostgresCache, closePostgresCache, openPostgresCache, runCachePostgres)+import Shikumi.Effect.Time (runTime)+import Shikumi.LLM (LLM (..), complete)+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+  started <- Pg.start Pg.defaultConfig+  case started of+    Left err -> do+      putStrLn ("[SKIP] shikumi-cache-postgres: " <> T.unpack (Pg.renderStartError err))+      exitSuccess+    Right db -> do+      cache <- openPostgresCache (Pg.connectionSettings db)+      defaultMain (tests cache)+        `finally` (closePostgresCache cache >> Pg.stop db)++-- Keep a reference to `cacheKey` so the import is exercised even though the+-- backend computes keys internally (documents the integration-point-#7 reuse).+tests :: PostgresCache -> TestTree+tests cache =+  let _key = cacheKey fixModel fixCtx fixOpts+   in testGroup+        "shikumi-cache-postgres"+        [ testCase "memoize: first request MISS (provider once), repeat is a Postgres HIT" $ do+            refA <- newIORef 0+            (r1, r2) <-+              runEff . runTime . runCachePostgres 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 row already in Postgres → HIT.+            refB <- newIORef 0+            _ <-+              runEff . runTime . runCachePostgres cache . runCountingLLM refB stubResponse . cachedLLM $+                complete fixModel fixCtx fixOpts+            nB <- readIORef refB+            nB @?= 0+        ]