ephemeral-pg-0.1.0.0: src/EphemeralPg/Internal/Directory.hs
-- | Directory management for temporary PostgreSQL instances.
module EphemeralPg.Internal.Directory
( -- * Directory creation
createTempDataDirectory,
createTempSocketDirectory,
resolveDirectory,
-- * Socket path validation
validateSocketPath,
estimateSocketPathLength,
-- * Cleanup
removeDirectoryIfExists,
retryRemoveDirectory,
)
where
import Control.Concurrent (threadDelay)
import Control.Exception (SomeException, catch, try)
import Control.Monad (when)
import Data.Text (Text)
import Data.Text qualified as T
import EphemeralPg.Config (DirectoryConfig (..), maxSocketPathLength)
import EphemeralPg.Error (ConfigError (..), ResourceError (..), StartError (..))
import System.Directory
( createDirectoryIfMissing,
doesDirectoryExist,
getTemporaryDirectory,
removeDirectoryRecursive,
)
import System.IO.Temp (createTempDirectory)
-- | Create a temporary data directory.
--
-- Returns the path and whether it should be cleaned up.
createTempDataDirectory :: Maybe FilePath -> IO (Either StartError (FilePath, Bool))
createTempDataDirectory mTempRoot = do
tmpRoot <- maybe getTemporaryDirectory pure mTempRoot
result <- try $ createTempDirectory tmpRoot "ephpg-data-"
case result of
Left (e :: SomeException) ->
pure $ Left $ ResourceError $ DirectoryCreationFailed tmpRoot (T.pack $ show e)
Right path ->
pure $ Right (path, True)
-- | Create a temporary socket directory.
--
-- Uses short names to avoid Unix socket path length limits.
-- Returns the path and whether it should be cleaned up.
createTempSocketDirectory :: Maybe FilePath -> IO (Either StartError (FilePath, Bool))
createTempSocketDirectory mTempRoot = do
tmpRoot <- maybe getTemporaryDirectory pure mTempRoot
-- Use very short prefix to minimize socket path length
result <- try $ createTempDirectory tmpRoot "pg-"
case result of
Left (e :: SomeException) ->
pure $ Left $ ResourceError $ DirectoryCreationFailed tmpRoot (T.pack $ show e)
Right path -> do
-- Validate the socket path length
case validateSocketPath path of
Left err -> do
-- Clean up the directory we just created
removeDirectoryIfExists path
pure $ Left err
Right () ->
pure $ Right (path, True)
-- | Resolve a directory configuration to an actual path.
--
-- Returns the path and whether it should be cleaned up.
resolveDirectory ::
DirectoryConfig ->
-- | Temp root
Maybe FilePath ->
-- | Directory purpose (for error messages)
Text ->
-- | Creator
(Maybe FilePath -> IO (Either StartError (FilePath, Bool))) ->
IO (Either StartError (FilePath, Bool))
resolveDirectory config mTempRoot _purpose creator =
case config of
DirectoryTemporary ->
creator mTempRoot
DirectoryPermanent path -> do
result <- try $ createDirectoryIfMissing True path
case result of
Left (e :: SomeException) ->
pure $ Left $ ResourceError $ DirectoryCreationFailed path (T.pack $ show e)
Right () ->
pure $ Right (path, False)
-- | Validate that a socket path won't exceed Unix limits.
--
-- The socket file will be named @.s.PGSQL.<port>@ which adds about 15 characters.
validateSocketPath :: FilePath -> Either StartError ()
validateSocketPath socketDir = do
let estimatedLen = estimateSocketPathLength socketDir
when (estimatedLen > maxSocketPathLength) $
Left $
ConfigError $
SocketPathTooLong
{ socketPath = socketDir,
socketPathLength = estimatedLen,
socketPathMaxLength = maxSocketPathLength
}
-- | Estimate the full socket path length.
--
-- PostgreSQL socket files are named @.s.PGSQL.<port>@.
-- Assuming max port 65535, the suffix is at most 17 characters.
estimateSocketPathLength :: FilePath -> Int
estimateSocketPathLength socketDir =
length socketDir + 1 + 17 -- +1 for path separator, +17 for ".s.PGSQL.65535"
-- | Remove a directory if it exists, ignoring errors.
removeDirectoryIfExists :: FilePath -> IO ()
removeDirectoryIfExists path = do
exists <- doesDirectoryExist path
when exists $
removeDirectoryRecursive path `catch` \(_ :: SomeException) -> pure ()
-- | Remove a directory with retries.
--
-- This handles the race condition where PostgreSQL may still be writing
-- files (like pg_stat) during shutdown.
retryRemoveDirectory :: FilePath -> Int -> Int -> IO (Either Text ())
retryRemoveDirectory path maxRetries delayMicros = go maxRetries
where
go 0 = do
result <- try $ removeDirectoryRecursive path
case result of
Left (e :: SomeException) ->
pure $ Left $ "Failed after " <> T.pack (show maxRetries) <> " retries: " <> T.pack (show e)
Right () ->
pure $ Right ()
go n = do
result <- try $ removeDirectoryRecursive path
case result of
Left (_ :: SomeException) -> do
-- Wait and retry
threadDelayMicros delayMicros
go (n - 1)
Right () ->
pure $ Right ()
threadDelayMicros :: Int -> IO ()
threadDelayMicros = threadDelay