packages feed

plexus-synapse-3.5.0: src/Synapse/IR/Cache.hs

{-# LANGUAGE RecordWildCards #-}

-- | IR caching with hash-based invalidation
--
-- Caches built IR keyed by plugin hash to avoid rebuilding when schemas haven't changed.
-- The plugin hash changes whenever any method or child activation changes, making it
-- perfect for cache invalidation.
module Synapse.IR.Cache
  ( IRCache
  , CacheConfig(..)
  , defaultCacheConfig
  , newIRCache
  , lookupIR
  , insertIR
  , clearCache
  , getCacheStats
  , CacheStats(..)
  ) where

import Control.Concurrent.MVar (MVar, newMVar, modifyMVar, modifyMVar_, readMVar)
import Control.Monad (when)
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as Map
import Data.Text (Text)
import qualified Data.Text as T
import System.Directory (createDirectoryIfMissing, doesFileExist, removeFile)
import System.FilePath ((</>))
import Data.Aeson (encode, eitherDecodeFileStrict')
import qualified Data.ByteString.Lazy as LBS

import Synapse.IR.Types (IR)

-- | Cache configuration
data CacheConfig = CacheConfig
  { cacheEnabled :: Bool       -- ^ Whether caching is enabled
  , cacheMaxSize :: Int        -- ^ Max number of cached IRs (0 = unlimited)
  , cachePersist :: Bool       -- ^ Persist cache to disk
  , cachePath    :: FilePath   -- ^ Path to cache directory
  }
  deriving stock (Show, Eq)

-- | Default cache configuration
-- - Enabled by default
-- - Max 100 cached IRs
-- - Persist to ~/.cache/synapse/ir
defaultCacheConfig :: FilePath -> CacheConfig
defaultCacheConfig homeDir = CacheConfig
  { cacheEnabled = True
  , cacheMaxSize = 100
  , cachePersist = True
  , cachePath = homeDir </> ".cache" </> "synapse" </> "ir"
  }

-- | Cache statistics
data CacheStats = CacheStats
  { statsHits   :: Int  -- ^ Cache hits
  , statsMisses :: Int  -- ^ Cache misses
  , statsSize   :: Int  -- ^ Current cache size
  }
  deriving stock (Show, Eq)

-- | In-memory IR cache
data IRCache = IRCache
  { cacheConfig  :: CacheConfig
  , cacheStore   :: MVar (Map Text IR)
  , cacheStats   :: MVar CacheStats
  }

-- | Create a new IR cache
newIRCache :: CacheConfig -> IO IRCache
newIRCache config = do
  when (cachePersist config) $
    createDirectoryIfMissing True (cachePath config)

  store <- newMVar Map.empty
  stats <- newMVar (CacheStats 0 0 0)

  pure IRCache
    { cacheConfig = config
    , cacheStore = store
    , cacheStats = stats
    }

-- | Look up IR by hash
lookupIR :: IRCache -> Text -> IO (Maybe IR)
lookupIR IRCache{..} hash
  | not (cacheEnabled cacheConfig) = pure Nothing
  | otherwise = do
      -- Try memory cache first
      mIR <- Map.lookup hash <$> readMVar cacheStore
      case mIR of
        Just ir -> do
          recordHit
          pure (Just ir)
        Nothing
          | cachePersist cacheConfig -> do
              -- Try disk cache
              mDiskIR <- loadFromDisk hash
              case mDiskIR of
                Just ir -> do
                  -- Load into memory cache
                  modifyMVar_ cacheStore $ \store ->
                    pure $ Map.insert hash ir store
                  recordHit
                  pure (Just ir)
                Nothing -> do
                  recordMiss
                  pure Nothing
          | otherwise -> do
              recordMiss
              pure Nothing
  where
    recordHit = modifyMVar_ cacheStats $ \stats ->
      pure $ stats { statsHits = statsHits stats + 1 }

    recordMiss = modifyMVar_ cacheStats $ \stats ->
      pure $ stats { statsMisses = statsMisses stats + 1 }

    loadFromDisk hash = do
      let path = cachePath cacheConfig </> T.unpack hash <> ".json"
      exists <- doesFileExist path
      if exists
        then do
          result <- eitherDecodeFileStrict' path
          case result of
            Right ir -> pure (Just ir)
            Left _   -> pure Nothing  -- Corrupted cache, ignore
        else pure Nothing

-- | Insert IR into cache
insertIR :: IRCache -> Text -> IR -> IO ()
insertIR IRCache{..} hash ir
  | not (cacheEnabled cacheConfig) = pure ()
  | otherwise = do
      -- Insert into memory cache
      modifyMVar_ cacheStore $ \store -> do
        let newStore = Map.insert hash ir store
        -- Evict if over max size (simple LRU: remove first element)
        let finalStore = if cacheMaxSize cacheConfig > 0 && Map.size newStore > cacheMaxSize cacheConfig
              then Map.deleteMin newStore
              else newStore
        pure finalStore

      -- Update stats
      modifyMVar_ cacheStats $ \stats ->
        pure $ stats { statsSize = statsSize stats + 1 }

      -- Persist to disk if enabled
      when (cachePersist cacheConfig) $ do
        let path = cachePath cacheConfig </> T.unpack hash <> ".json"
        LBS.writeFile path (encode ir)

-- | Clear the cache
clearCache :: IRCache -> IO ()
clearCache IRCache{..} = do
  modifyMVar_ cacheStore $ \_ -> pure Map.empty
  modifyMVar_ cacheStats $ \_ -> pure (CacheStats 0 0 0)

-- | Get cache statistics
getCacheStats :: IRCache -> IO CacheStats
getCacheStats IRCache{..} = readMVar cacheStats