ipedb-0.2.0.0: src/IpeDB/Database.hs
{-# OPTIONS_GHC -Wno-orphans #-}
{- |
Module : IpeDB.Database
Description : This modules provides a database instance backed by @lsm-tree@.
Stability : experimental
Portability : portable
-}
module IpeDB.Database (
-- * Constraints
Key,
Value,
SerialiseVia (..),
SerialiseViaBinary (..),
-- * Sessions
Session,
SessionOptions (maybeSessionRoot),
defaultSessionOptions,
withNewSession,
-- * Tables
Table,
withNewTable,
inserts,
lookups,
insert,
lookup,
-- ** Table Options
TableOptions (..),
defaultTableOptions,
-- ** Table Enumerating
IteratorOptions (iteratorBufferSize),
defaultIteratorOptions,
withIterator,
-- ** Table Indexing
IndexerOptions (indexerBufferSize),
defaultIndexerOptions,
indexer,
-- ** Table Serialisation
TargetExistsError (..),
TableFormat (..),
defaultTableFormat,
withTableFrom,
SaveOptions (flatten),
defaultSaveOptions,
saveTable,
) where
import Codec.Archive.Tar qualified as Tar
import Codec.Archive.Tar.Check qualified as Tar
import Codec.Compression.GZip qualified as GZip
import Control.Exception (Exception (..), SomeException (..), bracketOnError, bracket_, catch, throwIO)
import Control.Monad (unless, when)
import Control.Monad.IO.Class (MonadIO (..))
import Data.Binary (Binary)
import Data.Binary qualified as B
import Data.ByteString.Lazy qualified as BSL
import Data.Coerce (Coercible, coerce)
import Data.Default (Default (..))
import Data.Machine ((~>))
import Data.Machine qualified as M
import Data.Maybe (fromJust, fromMaybe)
import Data.Text qualified as T
import Data.Vector (Vector)
import Data.Vector qualified as V
import Data.Void (Void)
import Data.Word (Word32)
import Database.LSMTree qualified as LSMT
import System.Directory qualified as SD
import System.FS.API.Strict qualified as FS
import System.FS.BlockIO.IO qualified as BIO
import System.FilePath qualified as SF
import System.IO.Temp (withSystemTempDirectory, withTempDirectory)
import Prelude hiding (lookup)
--------------------------------------------------------------------------------
-- Constraints
--------------------------------------------------------------------------------
{- |
The constraints needed to use a type as key in a t`Table`.
The types t`SerialiseVia` or t`SerialiseViaBinary` provide default instances.
-}
type Key k = LSMT.SerialiseKey k
{- |
The constraints needed to use a type as value in a t`Table`.
The types t`SerialiseVia` or t`SerialiseViaBinary` provide default instances.
-}
type Value v = (LSMT.SerialiseValue v, LSMT.ResolveValue v)
--------------------------------------------------------------------------------
-- Deriving Via Newtype
{- |
Wrapper that derives the constraints required by the database backend via `Coercible`.
-}
newtype SerialiseVia v u = SerialiseVia {value :: v}
--------------------------------------------------------------------------------
-- Key Via Newtype
instance (Coercible v u, LSMT.SerialiseKey u) => LSMT.SerialiseKey (SerialiseVia v u) where
serialiseKey :: SerialiseVia v u -> LSMT.RawBytes
serialiseKey = LSMT.serialiseKey . coerce @v @u . (.value)
deserialiseKey :: LSMT.RawBytes -> SerialiseVia v u
deserialiseKey = SerialiseVia . coerce @u @v . LSMT.deserialiseKey
--------------------------------------------------------------------------------
-- Value Via Newtype
instance (Coercible v u, LSMT.SerialiseValue u) => LSMT.SerialiseValue (SerialiseVia v u) where
serialiseValue :: SerialiseVia v u -> LSMT.RawBytes
serialiseValue = LSMT.serialiseValue . coerce @_ @u
deserialiseValue :: LSMT.RawBytes -> SerialiseVia v u
deserialiseValue = coerce @u @_ . LSMT.deserialiseValue
deriving via LSMT.ResolveAsFirst (SerialiseVia v u) instance LSMT.ResolveValue (SerialiseVia v u)
--------------------------------------------------------------------------------
-- Deriving Via Binary
{- |
Wrapper that derives the constraints required by the database backend via a `Binary` instance.
-}
newtype SerialiseViaBinary v = SerialiseViaBinary {value :: v}
--------------------------------------------------------------------------------
-- Key Via Binary
-- instance (Binary v) => Key (SerialiseViaBinary v)
instance (Binary v) => LSMT.SerialiseKey (SerialiseViaBinary v) where
serialiseKey :: SerialiseViaBinary v -> LSMT.RawBytes
serialiseKey = LSMT.serialiseKey . B.encode . (.value)
deserialiseKey :: LSMT.RawBytes -> SerialiseViaBinary v
deserialiseKey = SerialiseViaBinary . B.decode . LSMT.deserialiseKey
--------------------------------------------------------------------------------
-- Value Via Binary
instance (Binary v) => LSMT.SerialiseValue (SerialiseViaBinary v) where
serialiseValue :: SerialiseViaBinary v -> LSMT.RawBytes
serialiseValue = LSMT.serialiseValue . B.encode . (.value)
deserialiseValue :: LSMT.RawBytes -> SerialiseViaBinary v
deserialiseValue = SerialiseViaBinary . B.decode . LSMT.deserialiseValue
deriving via LSMT.ResolveAsFirst (SerialiseViaBinary v) instance LSMT.ResolveValue (SerialiseViaBinary v)
--------------------------------------------------------------------------------
-- Sessions
--------------------------------------------------------------------------------
{- |
Representation of database sessions.
-}
data Session
= Session
{ mountPoint :: FS.MountPoint
, sessionRoot :: FS.FsPath
, session :: LSMT.Session IO
}
{- |
The options for database sessions.
-}
newtype SessionOptions
= SessionOptions
{ maybeSessionRoot :: Maybe FilePath
}
{- |
The default database session options.
-}
defaultSessionOptions :: SessionOptions
defaultSessionOptions =
SessionOptions
{ maybeSessionRoot = Nothing
}
instance Default SessionOptions where
def :: SessionOptions
def = defaultSessionOptions
{- |
Run an action with a new session.
-}
withNewSession ::
SessionOptions ->
(Session -> IO r) ->
IO r
withNewSession SessionOptions{..} action = do
-- Create a temporary directory for the database session.
let withSessionDir :: (FilePath -> IO a) -> IO a
withSessionDir = case maybeSessionRoot of
Nothing -> withSystemTempDirectory "eventlog-live"
Just sessionRoot -> withTempDirectory sessionRoot "eventlog-live"
withSessionDir $ \sessionRoot -> do
-- Create the LSM Tree session.
!sessionAbsRoot <- SD.makeAbsolute sessionRoot
let (!mountPointPath, !sessionRelRoot) = SF.splitDrive sessionAbsRoot
let !mountPoint = FS.MountPoint mountPointPath
let !sessionRelRootDirs = SF.splitDirectories sessionRelRoot
let !sessionRootFsPath = FS.mkFsPath sessionRelRootDirs
let !sessionDirFsPath = sessionRootFsPath FS.</> FS.mkFsPath ["session"]
BIO.withIOHasBlockIO mountPoint BIO.defaultIOCtxParams $ \hasFS hasBlockIO -> do
-- Create the session directory.
FS.createDirectoryIfMissing hasFS True sessionDirFsPath
-- Create the LSM Tree session.
let sessionSalt = 0
LSMT.withNewSession mempty hasFS hasBlockIO sessionSalt sessionDirFsPath $ \session ->
-- Run the action with the session.
action Session{sessionRoot = sessionRootFsPath, ..}
--------------------------------------------------------------------------------
-- Tables
--------------------------------------------------------------------------------
{- |
A table.
Use `withNewTable`, `withNewTable`, or `withTableFrom` to construct a table.
-}
data Table k v
= Table
{ session :: !Session
, table :: !(LSMT.Table IO k v Void)
}
{- |
Options for `withNewTable`. These can be used for performance tuning.
-}
newtype TableOptions
= -- | See the @lsm-tree@ documentation for `LSMT.TableConfig`.
TableOptions
{ tableConfig :: LSMT.TableConfig
}
{- |
The default options for `withNewTable`.
-}
defaultTableOptions :: TableOptions
defaultTableOptions =
TableOptions
{ tableConfig = LSMT.defaultTableConfig
}
instance Default TableOptions where
def :: TableOptions
def = defaultTableOptions
{- |
Run an action with a new table.
-}
withNewTable ::
forall k v a.
Session ->
TableOptions ->
(Table k v -> IO a) ->
IO a
withNewTable session (TableOptions config) action = do
-- Create a new LSM Tree table.
LSMT.withTableWith config session.session $ \table ->
-- Run the action.
action Table{..}
{- |
Insert entries into a table.
-}
inserts ::
(Key k, Value v) =>
Table k v -> Vector (k, v) -> IO ()
inserts Table{..} =
LSMT.inserts table . fmap (\(k, v) -> (k, v, Nothing))
{- |
Lookup entries from a table.
-}
lookups ::
(Key k, Value v) =>
Table k v -> Vector k -> IO (Vector (Maybe v))
lookups Table{..} =
fmap (fmap LSMT.getValue) . LSMT.lookups table
{- |
Insert entries into a table.
-}
insert ::
(Key k, Value v) =>
Table k v -> k -> v -> IO ()
insert Table{..} k v =
LSMT.insert table k v Nothing
{- |
Lookup one entry from a table.
-}
lookup ::
(Key k, Value v) =>
Table k v ->
k ->
IO (Maybe v)
lookup Table{..} =
fmap LSMT.getValue . LSMT.lookup table
--------------------------------------------------------------------------------
-- Enumerating
{- |
The options for `withIterator`.
[`iteratorBufferSize` :: `Word32`]:
The size of the elems buffer in number of elements.
The default is 10.
-}
newtype IteratorOptions = IteratorOptions
{ iteratorBufferSize :: Word32
}
{- |
The default options for `withIterator`.
-}
defaultIteratorOptions :: IteratorOptions
defaultIteratorOptions =
IteratorOptions{iteratorBufferSize = 10}
instance Default IteratorOptions where
def :: IteratorOptions
def = defaultIteratorOptions
{- |
Stream entries from a table.
-}
withIterator ::
(Key k, Value v) =>
IteratorOptions ->
Table k v ->
(M.SourceT IO (k, v) -> IO a) ->
IO a
withIterator options Table{..} action = do
LSMT.withCursor table $ \cursor ->
action $ M.MachineT $ do
-- The plan for a source that repeatedly queries the cursor.
let entrySourcePlan = do
let n = fromIntegral options.iteratorBufferSize
xs <- liftIO (LSMT.take n cursor)
M.yield xs
unless (V.length xs < n) entrySourcePlan
-- Helper to convert LSMT.Entry to a key-value pair.
let toKeyValue e = (LSMT.getEntryKey e, LSMT.getEntryValue e)
M.runMachineT $
M.construct entrySourcePlan ~> M.asParts ~> M.mapping toKeyValue
--------------------------------------------------------------------------------
-- Indexing
{- |
The options for `indexer`.
[`indexerBufferSize` :: `Word32`]:
The size of the index buffer in number of elements.
The default is 10.
-}
newtype IndexerOptions = IndexerOptions
{ indexerBufferSize :: Word32
}
{- |
The default options for `indexer`.
-}
defaultIndexerOptions :: IndexerOptions
defaultIndexerOptions =
IndexerOptions{indexerBufferSize = 10}
instance Default IndexerOptions where
def :: IndexerOptions
def = defaultIndexerOptions
{- |
Index data from a GHC event stream.
-}
indexer ::
(Key k, Value v) =>
(e -> Maybe (k, v)) ->
IndexerOptions ->
Table k v ->
M.ProcessT IO e Void
indexer extractKV options table =
M.mapping extractKV
~> M.asParts
~> M.buffered (fromIntegral options.indexerBufferSize)
~> M.mapping V.fromList
~> M.repeatedly (M.await >>= liftIO . inserts table)
--------------------------------------------------------------------------------
-- Serialisation
--------------------------------------------------------------------------------
{- |
The table serialisation formats.
-}
data TableFormat
= {- |
This format creates a /directory/ that contains an @lsm-tree@ snapshot.
This format is the fastest to write, since it hard links to the runtime
database files, but requires that the session root and the target path
are on the same filesystem.
-}
LSMTreeSnapshotV2
| {- |
This format creates a tar archive of an `LSMTreeSnapshotV2` export.
This requires copying the database files.
-}
LSMTreeSnapshotV2Tar
| {- |
This format creates a compressed `LSMTreeSnapshotV2Tar` export.
This format is the slowest to write, since it requires compressing the
database files, but the it results in significantly smaller files.
-}
LSMTreeSnapshotV2TarGz
{- |
The default table format.
-}
defaultTableFormat :: TableFormat
defaultTableFormat = LSMTreeSnapshotV2TarGz
instance Default TableFormat where
def :: TableFormat
def = LSMTreeSnapshotV2TarGz
{- |
Run an action with a table.
-}
withTableFrom ::
forall k v a.
(LSMT.ResolveValue v) =>
Session ->
FilePath ->
TableFormat ->
(Table k v -> IO a) ->
IO a
withTableFrom session inputRelPath inputFormat action = do
-- Find the absolute file path to the table.
inputAbsPath <- SD.makeAbsolute inputRelPath
-- Load a table in the LSMTreeSnapshotV2 format.
let loadLSMTreeSnapshotV2 :: IO ()
loadLSMTreeSnapshotV2 = do
-- Try to represent the target directory as an FsPath.
let FS.MountPoint mountPointPath = session.mountPoint
let !targetFsPath =
fromMaybe (error $ "Cannot hardlink from " <> inputAbsPath <> "; not under mount point " <> mountPointPath <> ".") $
FS.fsFromFilePath session.mountPoint inputAbsPath
-- Export the snapshot.
let snapshotFsPath = targetFsPath FS.</> FS.mkFsPath [snapshotRelPath]
LSMT.importSnapshot session.session snapshotName snapshotFsPath
-- Load a table in the LSMTreeSnapshotV2Tar format.
let loadLSMTreeSnapshotV2Tar :: (BSL.ByteString -> BSL.ByteString) -> IO ()
loadLSMTreeSnapshotV2Tar decompress = do
-- Create temporary @active-import@ directory in the database session root.
let !sessionRootPath = FS.fsToFilePath session.mountPoint session.sessionRoot
withTempDirectory sessionRootPath "active-import" $ \importDir -> do
-- Extract the snapshot to @active-import/$tableName@.
!importAbsDir <- SD.makeAbsolute importDir
let !importDirFsPath = fromJust (FS.fsFromFilePath session.mountPoint importAbsDir)
let !snapshotDirFsPath = importDirFsPath FS.</> FS.mkFsPath [snapshotRelPath]
tarByteString <- BSL.readFile inputAbsPath
let tarEntries = Tar.read . decompress $ tarByteString
let tarCheck entry = SomeException <$> Tar.checkEntrySecurity entry
Tar.unpackAndCheck tarCheck importAbsDir tarEntries
-- Import the snapshot from @active-import/$tableName@.
LSMT.importSnapshot session.session snapshotName snapshotDirFsPath
-- Load a table based on the inferred table format.
let loadSnapshot :: IO ()
loadSnapshot = case inputFormat of
LSMTreeSnapshotV2 -> loadLSMTreeSnapshotV2
LSMTreeSnapshotV2Tar -> loadLSMTreeSnapshotV2Tar id
LSMTreeSnapshotV2TarGz -> loadLSMTreeSnapshotV2Tar GZip.decompress
-- Load the snapshot.
bracketOnError loadSnapshot (const $ deleteSnapshot session.session) $ \() ->
-- Open the table from the snapshot.
LSMT.withTableFromSnapshot session.session snapshotName snapshotLabel $ \table -> do
-- Delete the snapshot.
deleteSnapshot session.session
-- Run the action.
action Table{..}
{- |
Flatten an @lsm-tree@ t`Table`.
-}
flattenTable ::
(Value v) =>
Table k v ->
IO (Table k v)
flattenTable Table{..} = do
table' <- LSMT.union table table
pure $ Table{table = table', ..}
{- |
Options for `saveTable`.
[`flatten` :: `Bool`]:
Flatten the table before saving. This requires a number of I\/O operations
that is linear in the number of entries, but makes subsequent lookups faster.
The default is `False`.
-}
newtype SaveOptions = SaveOptions
{ flatten :: Bool
}
{- |
The default options for `saveTable`.
-}
defaultSaveOptions :: SaveOptions
defaultSaveOptions =
SaveOptions{flatten = False}
instance Default SaveOptions where
def :: SaveOptions
def = defaultSaveOptions
{- |
Save a table.
The target path must not already exist.
-}
saveTable ::
(Value v) =>
SaveOptions ->
Table k v ->
FilePath ->
TableFormat ->
IO ()
saveTable options table' targetRelPath targetFormat = do
-- If the target path already exists, throw a TargetExistsError.
targetExists <- SD.doesPathExist targetRelPath
when targetExists . throwIO $ TargetExistsError targetRelPath
-- Flatten an lsm-tree table, if options.flatten is set.
table <-
if options.flatten
then flattenTable table'
else pure table'
-- Export the lsm-tree snapshot by hard linking.
let saveLSMTreeSnapshotV2 :: IO ()
saveLSMTreeSnapshotV2 = do
-- Try to represent the target directory as an FsPath.
let FS.MountPoint mountPointPath = table.session.mountPoint
targetAbsPath <- SD.makeAbsolute targetRelPath
let !targetFsPath =
fromMaybe (error $ "Cannot hardlink to " <> targetRelPath <> "; not under mount point " <> mountPointPath <> ".") $
FS.fsFromFilePath table.session.mountPoint targetAbsPath
-- Manage the target directory.
let createTarget = SD.createDirectory targetAbsPath
let removeTarget = SD.removeDirectory targetAbsPath
-- Export the snapshot.
bracketOnError createTarget (const removeTarget) . const $ do
let snapshotFsPath = targetFsPath FS.</> FS.mkFsPath [snapshotRelPath]
LSMT.exportSnapshot table.session.session snapshotName snapshotFsPath
-- Export the lsm-tree snapshot by compressing and archiving.
let saveLSMTreeSnapshotV2Tar :: (BSL.ByteString -> BSL.ByteString) -> IO ()
saveLSMTreeSnapshotV2Tar compress = do
-- Create temporary @active-export@ directory in the database session root.
let !sessionRootPath = FS.fsToFilePath table.session.mountPoint table.session.sessionRoot
withTempDirectory sessionRootPath "active-export" $ \exportRootDir -> do
-- Export the snapshot to the temporary @active-export@ directory.
let !snapshotDir = exportRootDir SF.</> snapshotRelPath
!snapshotAbsDir <- SD.makeAbsolute snapshotDir
let !snapshotDirFsPath = fromJust (FS.fsFromFilePath table.session.mountPoint snapshotAbsDir)
LSMT.exportSnapshot table.session.session snapshotName snapshotDirFsPath
-- Create the output tar archive.
BSL.writeFile targetRelPath . compress =<< Tar.write' =<< Tar.pack' exportRootDir [snapshotRelPath]
bracket_ (saveSnapshot table.table) (deleteSnapshot table.session.session) $
case targetFormat of
LSMTreeSnapshotV2 -> saveLSMTreeSnapshotV2
LSMTreeSnapshotV2Tar -> saveLSMTreeSnapshotV2Tar id
LSMTreeSnapshotV2TarGz -> saveLSMTreeSnapshotV2Tar GZip.compress
--------------------------------------------------------------------------------
-- Internal Helpers
{- |
Internal helper.
Save an LSM Tree snapshot.
-}
saveSnapshot :: LSMT.Table IO k v Void -> IO ()
saveSnapshot = LSMT.saveSnapshot snapshotName snapshotLabel
{- |
Internal helper.
Delete an LSM Tree snapshot, but ignore any `LSMT.ErrSnapshotDoesNotExist` errors.
-}
deleteSnapshot :: LSMT.Session IO -> IO ()
deleteSnapshot session =
LSMT.deleteSnapshot session snapshotName
`catch` \LSMT.ErrSnapshotDoesNotExist{} -> pure ()
{- |
Internal helper.
The relative `FilePath` for the snapshot directory used by all databases.
-}
snapshotRelPath :: FilePath
snapshotRelPath = "data"
{- |
Internal helper.
The `LSMT.SnapshotName` used by all databases.
-}
snapshotName :: LSMT.SnapshotName
snapshotName = LSMT.toSnapshotName snapshotRelPath
{- |
Internal helper.
The `LSMT.SnapshotLabel` used by all databases.
-}
snapshotLabel :: LSMT.SnapshotLabel
snapshotLabel = LSMT.SnapshotLabel (T.pack "ipedb:1")
--------------------------------------------------------------------------------
-- Errors
--------------------------------------------------------------------------------
{- |
This error is raised if the target path already exists.
-}
newtype TargetExistsError = TargetExistsError FilePath
deriving (Show)
instance Exception TargetExistsError where
displayException :: TargetExistsError -> String
displayException (TargetExistsError targetPath) =
"The file " <> targetPath <> " already exists; refusing to overwrite."