acid-state 0.16.1.4 → 0.17.0
raw patch · 4 files changed
+192/−6 lines, 4 filesdep ~directorydep ~filepathPVP ok
version bump matches the API change (PVP)
Dependency ranges changed: directory, filepath
API changes (from Hackage documentation)
+ Data.Acid.Local: [localLastCheckpoint] :: LocalState st -> IORef (Maybe EntryId)
- Data.Acid.Local: LocalState :: Core st -> IORef st -> FileLog (Tagged ByteString) -> FileLog (Checkpoint st) -> FileLock -> LocalState st
+ Data.Acid.Local: LocalState :: Core st -> IORef st -> FileLog (Tagged ByteString) -> FileLog (Checkpoint st) -> FileLock -> IORef (Maybe EntryId) -> LocalState st
Files
- CHANGELOG.md +33/−0
- acid-state.cabal +7/−4
- src/Data/Acid/Local.hs +28/−2
- test/Data/Acid/CheckpointSpec.hs +124/−0
CHANGELOG.md view
@@ -1,3 +1,36 @@+0.17.0+======++_Jeremy Shaw, 2026-10-29_++- `createCheckpointAndClose` no longer writes a checkpoint when this+ process has already made one durable and no events have been logged+ since, making shutdown fast right after `createCheckpoint` (e.g. via the+ remote `CreateCheckpoint` command)+ ([#181](https://github.com/acid-state/acid-state/pull/181)).+ A checkpoint is still written when the state is freshly created, when+ events follow the last checkpoint, and when closing a state whose only+ checkpoint is the one loaded at open (so SafeCopy migrations are still+ saved).++**Breaking changes and changed behaviour**++- `LocalState` has a new field, `localLastCheckpoint`. Code that builds a+ `LocalState` with positional arguments will no longer compile. Code that+ builds one with record syntax will still compile (with only a+ `-Wmissing-fields` warning), but leaves the field undefined: the next+ `createCheckpoint` will then kill the log writer thread, and later+ updates, checkpoints and closes will hang. Initialise the field with+ `newIORef Nothing`.+- `createCheckpointAndClose` no longer always appends a checkpoint. After+ `createCheckpoint` followed by `createArchive`, the newest+ `checkpoints-*.log` file is left empty and the latest checkpoint is in+ the file before it. acid-state itself restores correctly, but backup,+ replication or monitoring scripts that copy or check only the newest+ checkpoint file must be updated to look at earlier files too.+- Tools that use the checkpoint log's size, modification time or entry+ count to confirm a clean shutdown may no longer see a change at close.+ 0.16.1.4 =========
acid-state.cabal view
@@ -1,6 +1,6 @@ Cabal-version: 1.18 Name: acid-state-Version: 0.16.1.4+Version: 0.17.0 Synopsis: Add ACID guarantees to any serializable Haskell data structure. Description: Use regular Haskell data structures as your database and get stronger ACID guarantees than most RDBMS offer. Homepage: https://github.com/acid-state/acid-state@@ -13,8 +13,8 @@ tested-with: GHC == 9.14.1- GHC == 9.12.2- GHC == 9.10.2+ GHC == 9.12.4+ GHC == 9.10.3 GHC == 9.8.4 GHC == 9.6.7 GHC == 9.4.8@@ -117,13 +117,16 @@ build-depends: base , acid-state , deepseq+ , directory+ , filepath , hspec , mtl , safecopy , template-haskell build-tool-depends: hspec-discover:hspec-discover ghc-options: -threaded -rtsopts -with-rtsopts=-N- other-modules: Data.Acid.TemplateHaskellSpec+ other-modules: Data.Acid.CheckpointSpec+ Data.Acid.TemplateHaskellSpec default-language: Haskell2010 test-suite state-machine
src/Data/Acid/Local.hs view
@@ -73,6 +73,11 @@ , localEvents :: FileLog (Tagged ByteString) , localCheckpoints :: FileLog (Checkpoint st) , localLock :: FileLock+ , localLastCheckpoint :: IORef (Maybe EntryId)+ -- ^ Event cutoff of the most recent checkpoint made durable+ -- by this process, if any. Used by+ -- 'createCheckpointAndClose' to avoid writing a checkpoint+ -- identical to one already on disk. } newtype StateIsLocked = StateIsLocked FilePath deriving (Show)@@ -193,19 +198,35 @@ withCoreState (localCore acidState) $ \st -> do eventId <- askCurrentEntryId (localEvents acidState) pushAction (localEvents acidState) $- pushEntry (localCheckpoints acidState) (Checkpoint eventId st) (putMVar mvar ())+ pushEntry (localCheckpoints acidState) (Checkpoint eventId st) $+ do recordCheckpoint acidState eventId+ putMVar mvar () takeMVar mvar +-- | Note that a checkpoint with the given event cutoff is durable.+-- Checkpoints may complete out of order, so keep the largest cutoff.+recordCheckpoint :: LocalState st -> EntryId -> IO ()+recordCheckpoint acidState eventId+ = atomicModifyIORef' (localLastCheckpoint acidState) $ \old ->+ (Just (maybe eventId (max eventId) old), ())+ -- | Save a snapshot to disk and close the AcidState as a single atomic -- action. This is useful when you want to make sure that no events -- are saved to disk after a checkpoint.+--+-- If this process has already made a checkpoint durable and no events+-- have been logged since, that checkpoint already reflects the current+-- state, so no new checkpoint is written. createCheckpointAndClose :: (IsAcidic st, Typeable st) => AcidState st -> IO () createCheckpointAndClose abstract_state = do mvar <- newEmptyMVar closeCore' (localCore acidState) $ \st -> do eventId <- askCurrentEntryId (localEvents acidState)+ lastCheckpoint <- readIORef (localLastCheckpoint acidState) pushAction (localEvents acidState) $- pushEntry (localCheckpoints acidState) (Checkpoint eventId st) (putMVar mvar ())+ if lastCheckpoint == Just eventId+ then putMVar mvar ()+ else pushEntry (localCheckpoints acidState) (Checkpoint eventId st) (putMVar mvar ()) takeMVar mvar closeFileLog (localEvents acidState) closeFileLog (localCheckpoints acidState)@@ -408,12 +429,17 @@ checkpointsLog <- openFileLog checkpointsLogKey stateCopy <- newIORef undefined withCoreState core (writeIORef stateCopy)+ -- The checkpoint loaded above is deliberately not recorded here: it+ -- may have been written with an older SafeCopy version, and closing+ -- with createCheckpointAndClose is how such data gets migrated.+ lastCheckpoint <- newIORef Nothing return $ toAcidState LocalState { localCore = core , localCopy = stateCopy , localEvents = eventsLog , localCheckpoints = checkpointsLog , localLock = lock+ , localLastCheckpoint = lastCheckpoint } maybeLockFile path = do createDirectoryIfMissing True (takeDirectory path)
+ test/Data/Acid/CheckpointSpec.hs view
@@ -0,0 +1,124 @@+{-# LANGUAGE TemplateHaskell #-}+{-# LANGUAGE TypeFamilies #-}++-- | Tests that 'createCheckpointAndClose' does not write a checkpoint+-- identical to one this process has already made durable.+module Data.Acid.CheckpointSpec where++import Test.Hspec++import Control.Exception (bracket_)+import Control.Monad.Reader (ask)+import Control.Monad.State (modify)+import Data.SafeCopy (base, deriveSafeCopy)+import System.Directory (getTemporaryDirectory, removePathForcibly)+import System.FilePath ((</>))++import Data.Acid+import Data.Acid.Local (Checkpoint, createCheckpointAndClose,+ defaultSerialisationLayer, mkCheckpointsLogKey)+import Data.Acid.Log (FileLog, askCurrentEntryId, closeFileLog, openFileLog)++newtype Counter = Counter Int++$(deriveSafeCopy 0 'base ''Counter)++increment :: Update Counter ()+increment = modify (\(Counter n) -> Counter (n + 1))++current :: Query Counter Int+current = do Counter n <- ask+ return n++$(makeAcidic ''Counter ['increment, 'current])++spec :: Spec+spec = around withStateDir $+ describe "createCheckpointAndClose" $ do+ it "writes a checkpoint when events were logged" $ \dir -> do+ n <- closeAfter dir $ \acid -> update acid Increment+ n `shouldBe` 1+ valueIn dir `shouldReturn` 1++ it "writes a checkpoint for a fresh state with no events" $ \dir -> do+ n <- closeAfter dir $ \_ -> return ()+ n `shouldBe` 1+ -- The initial value must have been saved, so a different+ -- initial value at reopen is ignored.+ reopened <- openLocalStateFrom dir (Counter 42)+ query reopened Current `shouldReturn` 0+ closeAcidState reopened++ it "rewrites the checkpoint it loaded, even with no new events" $ \dir -> do+ _ <- closeAfter dir $ \acid -> update acid Increment+ n <- closeAfter dir $ \_ -> return ()+ -- The loaded checkpoint may need a SafeCopy migration, so+ -- closing must still write one.+ n `shouldBe` 1+ valueIn dir `shouldReturn` 1++ it "skips the checkpoint right after createCheckpoint" $ \dir -> do+ n <- closeAfter dir $ \acid -> do+ update acid Increment+ createCheckpoint acid+ -- Only the explicit checkpoint; the close wrote nothing.+ n `shouldBe` 1+ valueIn dir `shouldReturn` 1++ it "skips the checkpoint after repeated checkpoints with no events" $ \dir -> do+ n <- closeAfter dir $ \acid -> do+ update acid Increment+ createCheckpoint acid+ createCheckpoint acid+ n `shouldBe` 2+ valueIn dir `shouldReturn` 1++ it "writes a checkpoint when events follow the last checkpoint" $ \dir -> do+ n <- closeAfter dir $ \acid -> do+ update acid Increment+ createCheckpoint acid+ update acid Increment+ n `shouldBe` 2+ valueIn dir `shouldReturn` 2++ it "keeps the state recoverable across repeated skipped closes" $ \dir -> do+ let cycle_ = closeAfter dir $ \acid -> do+ update acid Increment+ createCheckpoint acid+ counts <- sequence [cycle_, cycle_, cycle_]+ counts `shouldBe` [1, 1, 1]+ valueIn dir `shouldReturn` 3++-- | Open the state in @dir@, run the action, then 'createCheckpointAndClose'.+-- Returns the number of checkpoints written while the state was open.+closeAfter :: FilePath -> (AcidState Counter -> IO ()) -> IO Int+closeAfter dir action = do+ countBefore <- checkpointCount dir+ acid <- openLocalStateFrom dir (Counter 0)+ action acid+ createCheckpointAndClose acid+ countAfter <- checkpointCount dir+ return (countAfter - countBefore)++valueIn :: FilePath -> IO Int+valueIn dir = do+ acid <- openLocalStateFrom dir (Counter 0)+ n <- query acid Current+ closeAcidState acid+ return n++-- | Number of entries in the checkpoint log. Only valid while the state+-- is closed.+checkpointCount :: FilePath -> IO Int+checkpointCount dir = do+ fLog <- openFileLog (mkCheckpointsLogKey dir defaultSerialisationLayer)+ :: IO (FileLog (Checkpoint Counter))+ n <- askCurrentEntryId fLog+ closeFileLog fLog+ return n++withStateDir :: (FilePath -> IO ()) -> IO ()+withStateDir action = do+ tmp <- getTemporaryDirectory+ let dir = tmp </> "acid-state-checkpoint-spec"+ bracket_ (removePathForcibly dir) (removePathForcibly dir) (action dir)