ephemeral-pg 0.2.1.0 → 0.2.2.0
raw patch · 5 files changed
+182/−9 lines, 5 filesdep ~bytestringdep ~directorydep ~filepathPVP ok
version bump matches the API change (PVP)
Dependency ranges changed: bytestring, directory, filepath, hashable, network, process, temporary, transformers, typed-process, unix
API changes (from Hackage documentation)
Files
- CHANGELOG.md +9/−0
- README.md +65/−0
- ephemeral-pg.cabal +36/−2
- src/EphemeralPg/Internal/Cache.hs +36/−7
- test/Main.hs +36/−0
CHANGELOG.md view
@@ -1,5 +1,14 @@ # Changelog +## 0.2.2.0++### Bug Fixes++- Make initdb cache creation atomic by copying into a same-parent temporary+ directory and publishing it with an atomic rename. Concurrent cold-cache+ writers now treat an existing final cache as success instead of exposing a+ partially-copied `data` directory.+ ## 0.2.1.0 ### Bug Fixes
README.md view
@@ -158,6 +158,16 @@ pure () ``` +### Suite-level fixtures with template databases++For larger integration suites where many examples need the same migrated+schema, consider starting one cached PostgreSQL server for the whole suite,+migrating a template database once, and cloning clean per-example databases+with PostgreSQL's `CREATE DATABASE ... TEMPLATE ...`.++See [Suite-level template databases](docs/suite-template-databases.md) for the+full pattern, tradeoffs, and a complete fixture implementation.+ ### Manual Lifecycle Management For more control, use `start` and `stop` directly:@@ -430,6 +440,61 @@ Ensure you have write access to the temporary directory. By default, the system temp directory is used.++## Tracing++OpenTelemetry tracing is available through the optional companion+package `ephemeral-pg-opentelemetry`, which lives next to this library+in the same repository. It mirrors the `EphemeralPg` lifecycle API and+emits one span per public operation:++| Wrapper | Span name |+| ---------------------------- | ------------------------------- |+| `withTraced` | `ephemeralpg.with` |+| `withCachedTraced` | `ephemeralpg.with_cached` |+| `startTraced` | `ephemeralpg.start` |+| `stopTraced` | `ephemeralpg.stop` |+| `restartTraced` | `ephemeralpg.restart` |+| `createSnapshotTraced` | `ephemeralpg.snapshot.create` |+| `restoreSnapshotTraced` | `ephemeralpg.snapshot.restore` |+| `deleteSnapshotTraced` | `ephemeralpg.snapshot.delete` |+| `dumpTraced` | `ephemeralpg.dump` |+| `restoreTraced` | `ephemeralpg.restore` |++Each span carries the standard database attributes+(`db.system.name`/`db.system`, `db.namespace`/`db.name`) plus+library-specific ones (`ephemeralpg.port`,+`ephemeralpg.shutdown.mode`). Attribute name selection obeys+`OTEL_SEMCONV_STABILITY_OPT_IN` exactly the way upstream HTTP+instrumentation does — set it to `http` for stable names, `http/dup`+for both stable and legacy. Errors from `EphemeralPg.start` are+recorded uniformly with `error.type` (constructor name), span status+`Error`, and a `recordException` event.++The wrappers are designed to nest under a parent test span. Combined+with `hs-opentelemetry-instrumentation-hspec`, a test that calls+`withTraced` produces a tree of the form:++ Run tests+ └─ ephemeral-pg under OpenTelemetry+ └─ emits a span tree under withTraced+ └─ ephemeralpg.with+ ├─ ephemeralpg.start+ ├─ ephemeralpg.with.body+ └─ ephemeralpg.stop++Add the dependency to your test-suite (the package is not yet on+Hackage; reference it via the local `cabal.project`):++```cabal+build-depends:+ ephemeral-pg-opentelemetry,+ hs-opentelemetry-api,+ hs-opentelemetry-sdk,+ hs-opentelemetry-instrumentation-hspec,+```++See `ephemeral-pg-opentelemetry/test/Demo.hs` for a runnable example. ## License
ephemeral-pg.cabal view
@@ -1,6 +1,6 @@ cabal-version: 3.0 name: ephemeral-pg-version: 0.2.1.0+version: 0.2.2.0 synopsis: Temporary PostgreSQL databases for testing description: A modern library for creating temporary PostgreSQL instances for testing.@@ -84,18 +84,52 @@ import: warnings default-language: GHC2021 type: exitcode-stdio-1.0- hs-source-dirs: test+ hs-source-dirs:+ test+ src+ main-is: Main.hs+ other-modules:+ EphemeralPg+ EphemeralPg.Config+ EphemeralPg.Database+ EphemeralPg.Error+ EphemeralPg.Internal.Cache+ EphemeralPg.Internal.CopyOnWrite+ EphemeralPg.Internal.Directory+ EphemeralPg.Internal.Except+ EphemeralPg.Internal.Port+ EphemeralPg.Process+ EphemeralPg.Process.CreateDb+ EphemeralPg.Process.InitDb+ EphemeralPg.Process.Postgres+ build-depends: QuickCheck >=2.14 && <2.16, base,+ bytestring,+ directory, ephemeral-pg,+ filepath,+ hashable, hasql, hspec >=2.11 && <2.12,+ network,+ process,+ temporary, text,+ transformers,+ typed-process,+ unix, default-extensions: BlockArguments+ DeriveAnyClass+ DerivingStrategies DuplicateRecordFields+ LambdaCase+ NoFieldSelectors OverloadedRecordDot OverloadedStrings+ RecordWildCards+ StrictData
src/EphemeralPg/Internal/Cache.hs view
@@ -42,6 +42,7 @@ import Data.Text (Text) import Data.Text qualified as T import Data.Text.Encoding qualified as T+import Data.Unique (hashUnique, newUnique) import EphemeralPg.Config (Config (..)) import EphemeralPg.Internal.CopyOnWrite ( CowCapability (..),@@ -57,9 +58,11 @@ listDirectory, removeDirectoryRecursive, removeFile,+ renamePath, ) import System.Exit (ExitCode (..)) import System.FilePath ((</>))+import System.Posix.Process (getProcessID) import System.Process.Typed (byteStringOutput, proc, readProcess, setStderr, setStdout) -- | A cache key uniquely identifies a cached initdb cluster.@@ -170,16 +173,42 @@ -- | Create a cache from an initialized data directory. createCache :: CacheKey -> FilePath -> Maybe FilePath -> IO (Either Text ())-createCache key srcDataDir mRoot = do- dir <- getCacheDirectory key mRoot- createDirectoryIfMissing True dir+createCache key srcDataDir mRoot = runExceptT $ do+ dir <- liftIO $ getCacheDirectory key mRoot+ liftIO $ createDirectoryIfMissing True dir let dstDataDir = dir </> "data" - -- Detect CoW capability- cowCapability <- detectCowCapability dir+ alreadyCached <- liftIO $ doesDirectoryExist dstDataDir+ unless alreadyCached $ do+ unique <- liftIO newUnique+ pid <- liftIO getProcessID+ let tmpDataDir =+ dir </> ("data.tmp-" <> show pid <> "-" <> show (hashUnique unique)) - -- Copy the data directory to the cache- copyDirectory cowCapability srcDataDir dstDataDir+ cowCapability <- liftIO $ detectCowCapability dir+ copyResult <- liftIO $ copyDirectory cowCapability srcDataDir tmpDataDir+ case copyResult of+ Left err -> do+ liftIO $ removeDirectoryIfExists tmpDataDir+ throwE err+ Right () -> publishCache tmpDataDir dstDataDir+ where+ publishCache :: FilePath -> FilePath -> ExceptT Text IO ()+ publishCache tmpDataDir dstDataDir = do+ result <- liftIO $ try @SomeException $ renamePath tmpDataDir dstDataDir+ case result of+ Right () -> pure ()+ Left ex -> do+ winnerExists <- liftIO $ doesDirectoryExist dstDataDir+ liftIO $ removeDirectoryIfExists tmpDataDir+ unless winnerExists $+ throwE $+ "Failed to publish cache: " <> T.pack (show ex)++ removeDirectoryIfExists :: FilePath -> IO ()+ removeDirectoryIfExists path = do+ exists <- doesDirectoryExist path+ when exists $ removeDirectoryRecursive path -- | Restore from cache to a new data directory. restoreFromCache :: CacheKey -> FilePath -> Maybe FilePath -> IO (Either Text ())
test/Main.hs view
@@ -1,9 +1,20 @@ module Main where +import Control.Concurrent (forkIO, newEmptyMVar, putMVar, takeMVar)+import Control.Monad (forM_, replicateM) import Data.Text qualified as T import EphemeralPg qualified as Pg import EphemeralPg.Config qualified as Config+import EphemeralPg.Internal.Cache+ ( CacheKey (..),+ createCache,+ getCacheDirectory,+ restoreFromCache,+ ) import Hasql.Connection qualified as Connection+import System.Directory (createDirectoryIfMissing, doesDirectoryExist, doesFileExist)+import System.FilePath ((</>))+import System.IO.Temp (withSystemTempDirectory) import Test.Hspec import Test.QuickCheck @@ -74,6 +85,31 @@ Connection.release conn pure () result2 `shouldSatisfy` isRight++ it "publishes concurrently-created caches atomically" $ do+ withSystemTempDirectory "ephemeral-pg-cache-race-" $ \root -> do+ let key = CacheKey {pgVersion = "test", configHash = "atomic"}+ sourceDir = root </> "source-data"+ restoreDir = root </> "restore-data"++ createDirectoryIfMissing True (sourceDir </> "base")+ writeFile (sourceDir </> "PG_VERSION") "test\n"+ writeFile (sourceDir </> "base" </> "marker") "ok\n"++ done <- replicateM 8 newEmptyMVar+ forM_ done $ \var ->+ forkIO (createCache key sourceDir (Just root) >>= putMVar var)+ results <- traverse takeMVar done+ results `shouldSatisfy` all isRight++ cacheDir <- getCacheDirectory key (Just root)+ doesFileExist (cacheDir </> "data" </> "PG_VERSION") `shouldReturn` True+ doesFileExist (cacheDir </> "data" </> "base" </> "marker") `shouldReturn` True+ doesDirectoryExist (cacheDir </> "data" </> "source-data") `shouldReturn` False++ restoreResult <- restoreFromCache key restoreDir (Just root)+ restoreResult `shouldSatisfy` isRight+ doesFileExist (restoreDir </> "PG_VERSION") `shouldReturn` True describe "Config" $ do it "satisfies left identity (mempty <> x = x)" $