ppad-lmdb-0.1.0: lib/Database/LMDB.hs
{-# OPTIONS_HADDOCK prune #-}
{-# LANGUAGE BangPatterns #-}
{-# LANGUAGE EmptyDataDecls #-}
-- |
-- Module: Database.LMDB
-- Copyright: (c) 2026 Jared Tobin
-- License: MIT
-- Maintainer: Jared Tobin <jared@ppad.tech>
--
-- Minimal bindings to
-- [LMDB](https://www.symas.com/lmdb), an embedded ACID key-value
-- store. The upstream LMDB C source is vendored under @cbits\/@ at
-- release @LMDB_0.9.33@; no external @liblmdb@ is needed.
--
-- LMDB has a single-writer, many-reader transaction model and uses
-- memory mapping for zero-copy reads. This module presents that as a
-- bracketed @IO@-only interface.
--
-- Read-only and read-write transactions are distinguished at the type
-- level via a phantom parameter on 'Txn', so using a write primitive
-- (e.g. 'put') on a read transaction is a compile-time error.
--
-- Most operations throw 'LMDBException' on error. 'get' and 'del' are
-- the exceptions: they map a missing key onto 'Nothing' and 'False'
-- respectively, since absence is normal control flow.
module Database.LMDB (
-- * Environments
Env
, EnvFlags(..)
, defaultEnvFlags
, open
, close
, withEnv
-- * Transactions
, Txn
, RO
, RW
, withReadTxn
, withWriteTxn
-- * Databases
, Dbi
, openDbi
-- * Key-value operations
, get
, put
, del
-- * Cursors
, Cursor
, withCursor
, cursorFirst
, cursorLast
, cursorNext
, cursorPrev
, cursorSeek
-- * Errors
, LMDBException(..)
) where
import Control.Exception
( Exception, bracket, bracketOnError, mask, onException, throwIO)
import qualified Data.ByteString as BS
import qualified Data.ByteString.Unsafe as BU
import Foreign.C.String (peekCString, withCString)
import Foreign.C.Types (CInt, CSize, CUInt)
import Foreign.Marshal.Alloc (alloca, allocaBytes)
import Foreign.Ptr (Ptr, nullPtr, plusPtr)
import Foreign.Storable (peek, peekByteOff, pokeByteOff)
import Database.LMDB.Internal
-- handles --------------------------------------------------------------------
-- | An LMDB environment. Roughly, an open database file (or, with
-- 'envNoSubdir' off, a directory containing one).
newtype Env = Env (Ptr MDB_env)
-- | Phantom tag for read-only transactions.
data RO
-- | Phantom tag for read-write transactions.
data RW
-- | A transaction. The phantom @s@ distinguishes 'RO' from 'RW' so
-- that write operations refuse to type-check against read-only
-- transactions.
newtype Txn s = Txn (Ptr MDB_txn)
-- | A database identifier within an 'Env'.
newtype Dbi = Dbi MDB_dbi
-- | A cursor for range-scanning a 'Dbi'.
newtype Cursor s = Cursor (Ptr MDB_cursor)
-- environment flags ----------------------------------------------------------
-- | Configuration for 'open'.
data EnvFlags = EnvFlags
{ envMapSize :: !Int
-- ^ map size in bytes; LMDB will refuse writes beyond this. The
-- default of 10 MiB is intentionally small — set it to something
-- appropriate for your workload before opening.
, envMaxDbs :: !Int
-- ^ maximum number of named sub-databases. Default 1.
, envNoSubdir :: !Bool
-- ^ if 'True', treat the path as a regular file rather than a
-- directory containing @data.mdb@ and @lock.mdb@.
, envReadOnly :: !Bool
-- ^ open the environment read-only.
, envNoSync :: !Bool
-- ^ skip @fsync@ at commit time. Faster, but a crash may lose
-- the last transaction. The database remains internally
-- consistent.
} deriving Show
-- | Default 'EnvFlags': 10 MiB map, one database, directory layout,
-- read-write, syncing at commit.
--
-- >>> envMapSize defaultEnvFlags
-- 10485760
defaultEnvFlags :: EnvFlags
defaultEnvFlags = EnvFlags
{ envMapSize = 10 * 1024 * 1024
, envMaxDbs = 1
, envNoSubdir = False
, envReadOnly = False
, envNoSync = False
}
envFlagBits :: EnvFlags -> CUInt
envFlagBits EnvFlags { envNoSubdir = nsd, envReadOnly = ro, envNoSync = ns } =
(if nsd then _MDB_NOSUBDIR else 0)
+ (if ro then _MDB_RDONLY else 0)
+ (if ns then _MDB_NOSYNC else 0)
-- error handling -------------------------------------------------------------
-- | A typed wrapper around LMDB error codes. 'LMDBOther' carries the
-- raw error code plus the message from @mdb_strerror@.
data LMDBException
= LMDBKeyExist
| LMDBNotFound -- ^ Only seen via "Database.LMDB.Internal";
-- the safe layer maps this to 'Nothing'
-- or 'False' instead of throwing.
| LMDBMapFull
| LMDBCorrupted
| LMDBPanic
| LMDBVersionMismatch
| LMDBOther !Int !String
deriving Show
instance Exception LMDBException
throwLmdb :: CInt -> IO a
throwLmdb code
| code == _MDB_KEYEXIST = throwIO LMDBKeyExist
| code == _MDB_NOTFOUND = throwIO LMDBNotFound
| code == _MDB_MAP_FULL = throwIO LMDBMapFull
| code == _MDB_CORRUPTED = throwIO LMDBCorrupted
| code == _MDB_PANIC = throwIO LMDBPanic
| code == _MDB_VERSION_MISMATCH = throwIO LMDBVersionMismatch
| otherwise = do
msg <- mdb_strerror code >>= peekCString
throwIO (LMDBOther (fromIntegral code) msg)
check :: CInt -> IO ()
check 0 = pure ()
check c = throwLmdb c
{-# INLINE check #-}
-- environment lifecycle ------------------------------------------------------
-- | Open an LMDB environment at the given path. The path must already
-- exist (as a directory unless 'envNoSubdir' is set, in which case
-- the file's parent directory must exist).
--
-- Throws 'LMDBException' on failure.
--
-- >>> env <- open "/tmp/mydb" defaultEnvFlags { envNoSubdir = True }
open :: FilePath -> EnvFlags -> IO Env
open path flags = mask $ \restore -> do
envp <- alloca $ \pp -> do
check =<< mdb_env_create pp
peek pp
let env = Env envp
flip onException (close env) . restore $ do
check =<< mdb_env_set_mapsize envp
(fromIntegral (envMapSize flags))
check =<< mdb_env_set_maxdbs envp
(fromIntegral (envMaxDbs flags))
withCString path $ \cpath ->
check =<< mdb_env_open envp cpath (envFlagBits flags) 0o644
pure env
-- | Close an environment. Must not be called while transactions are
-- live.
close :: Env -> IO ()
close (Env envp) = mdb_env_close envp
-- | Bracketed environment lifecycle: 'open' the environment, run the
-- action, 'close' on exit (including async exceptions).
--
-- >>> withEnv "/tmp/mydb" defaultEnvFlags $ \env -> ...
withEnv :: FilePath -> EnvFlags -> (Env -> IO a) -> IO a
withEnv path flags = bracket (open path flags) close
-- transactions ---------------------------------------------------------------
beginTxn :: Env -> CUInt -> IO (Ptr MDB_txn)
beginTxn (Env envp) flags = alloca $ \pp -> do
check =<< mdb_txn_begin envp nullPtr flags pp
peek pp
-- | Run an action in a read-only transaction. The transaction is
-- aborted on exit; read transactions have no work to commit.
--
-- >>> withReadTxn env $ \txn -> ...
withReadTxn :: Env -> (Txn RO -> IO a) -> IO a
withReadTxn env act = bracketOnError
(beginTxn env _MDB_RDONLY)
mdb_txn_abort
(\txnp -> do
r <- act (Txn txnp)
mdb_txn_abort txnp
pure r)
-- | Run an action in a read-write transaction. Commits if the action
-- returns normally; aborts on exception.
--
-- >>> withWriteTxn env $ \txn -> ...
withWriteTxn :: Env -> (Txn RW -> IO a) -> IO a
withWriteTxn env act = bracketOnError
(beginTxn env 0)
mdb_txn_abort
(\txnp -> do
r <- act (Txn txnp)
check =<< mdb_txn_commit txnp
pure r)
-- databases ------------------------------------------------------------------
-- | Open a database within a transaction. Pass 'Nothing' for the
-- unnamed default database. If the @create@ flag is 'True', the
-- database is created if it does not already exist — and the
-- transaction must be a read-write transaction.
--
-- >>> dbi <- openDbi txn Nothing True
openDbi
:: Txn s
-> Maybe BS.ByteString -- ^ database name; 'Nothing' for the default
-> Bool -- ^ create if not present
-> IO Dbi
openDbi (Txn txnp) mname create = alloca $ \pp -> do
let flags = if create then _MDB_CREATE else 0
go cstr = do
check =<< mdb_dbi_open txnp cstr flags pp
Dbi <$> peek pp
case mname of
Nothing -> go nullPtr
Just nm -> BU.unsafeUseAsCString nm go
-- bytestring <-> MDB_val helpers --------------------------------------------
-- An MDB_val is two words: a CSize length at offset 0, a payload Ptr
-- at offset 8 (on 64-bit). We poke fields directly rather than going
-- through the Storable instance so hot paths don't depend on it being
-- inlined.
withBSAsVal :: BS.ByteString -> (Ptr MDB_val -> IO a) -> IO a
withBSAsVal bs k =
BU.unsafeUseAsCStringLen bs $ \(p, n) ->
allocaBytes 16 $ \vp -> do
pokeByteOff vp 0 (fromIntegral n :: CSize)
pokeByteOff vp 8 p
k vp
{-# INLINE withBSAsVal #-}
peekVal :: Ptr MDB_val -> IO BS.ByteString
peekVal vp = do
sz <- peekByteOff vp 0 :: IO CSize
pd <- peekByteOff vp 8
BS.packCStringLen (pd, fromIntegral sz)
{-# INLINE peekVal #-}
-- key-value ops --------------------------------------------------------------
-- | Look up a key. Returns 'Nothing' if not found. Other LMDB errors
-- throw 'LMDBException'.
--
-- The returned 'BS.ByteString' is a copy of the data; LMDB's
-- memory-mapped buffer is not aliased into Haskell.
--
-- >>> get txn dbi "hello"
-- Just "world"
get
:: Txn s
-> Dbi
-> BS.ByteString -- ^ key
-> IO (Maybe BS.ByteString) -- ^ value, if present
get (Txn txnp) (Dbi dbi) k =
BU.unsafeUseAsCStringLen k $ \(kdata, klen) ->
allocaBytes 32 $ \kp -> do
let !vp = kp `plusPtr` 16
pokeByteOff kp 0 (fromIntegral klen :: CSize)
pokeByteOff kp 8 kdata
code <- mdb_get txnp dbi kp vp
if code == _MDB_NOTFOUND
then pure Nothing
else do
check code
Just <$> peekVal vp
{-# INLINE get #-}
-- | Insert or replace a key. Throws 'LMDBException' on failure.
--
-- >>> put txn dbi "hello" "world"
put
:: Txn RW
-> Dbi
-> BS.ByteString -- ^ key
-> BS.ByteString -- ^ value
-> IO ()
put (Txn txnp) (Dbi dbi) k v =
BU.unsafeUseAsCStringLen k $ \(kdata, klen) ->
BU.unsafeUseAsCStringLen v $ \(vdata, vlen) ->
allocaBytes 32 $ \kp -> do
let !vp = kp `plusPtr` 16
pokeByteOff kp 0 (fromIntegral klen :: CSize)
pokeByteOff kp 8 kdata
pokeByteOff vp 0 (fromIntegral vlen :: CSize)
pokeByteOff vp 8 vdata
check =<< mdb_put txnp dbi kp vp 0
{-# INLINE put #-}
-- | Delete a key. Returns 'True' if the key existed and was removed,
-- 'False' if it was already absent.
--
-- >>> del txn dbi "hello"
-- True
del
:: Txn RW
-> Dbi
-> BS.ByteString -- ^ key
-> IO Bool -- ^ 'True' if the key was present and removed
del (Txn txnp) (Dbi dbi) k =
withBSAsVal k $ \kp -> do
code <- mdb_del txnp dbi kp nullPtr
if code == _MDB_NOTFOUND then pure False
else do
check code
pure True
{-# INLINE del #-}
-- cursors --------------------------------------------------------------------
-- | Bracketed cursor lifecycle: open a cursor over the given 'Dbi',
-- run the action, close the cursor on exit (including async
-- exceptions).
--
-- >>> withCursor txn dbi $ \cur -> ...
withCursor :: Txn s -> Dbi -> (Cursor s -> IO a) -> IO a
withCursor (Txn txnp) (Dbi dbi) act = bracket open' mdb_cursor_close use'
where
open' = alloca $ \pp -> do
check =<< mdb_cursor_open txnp dbi pp
peek pp
use' p = act (Cursor p)
-- Scan-position helper (no input key): one 32-byte buffer for both
-- output MDB_vals.
cursorMove
:: Cursor s
-> CInt -- MDB_cursor_op
-> IO (Maybe (BS.ByteString, BS.ByteString))
cursorMove (Cursor cp) op =
allocaBytes 32 $ \kp -> do
let !vp = kp `plusPtr` 16
code <- mdb_cursor_get cp kp vp op
if code == _MDB_NOTFOUND
then pure Nothing
else do
check code
k <- peekVal kp
v <- peekVal vp
pure (Just (k, v))
{-# INLINE cursorMove #-}
-- | Position at the first key/value pair, or 'Nothing' on an empty
-- database.
--
-- >>> cursorFirst cur
-- Just ("a","alpha")
cursorFirst :: Cursor s -> IO (Maybe (BS.ByteString, BS.ByteString))
cursorFirst c = cursorMove c _MDB_FIRST
{-# INLINE cursorFirst #-}
-- | Position at the last key/value pair, or 'Nothing' on an empty
-- database.
cursorLast :: Cursor s -> IO (Maybe (BS.ByteString, BS.ByteString))
cursorLast c = cursorMove c _MDB_LAST
{-# INLINE cursorLast #-}
-- | Advance to the next key/value pair, or 'Nothing' if already at
-- the end.
cursorNext :: Cursor s -> IO (Maybe (BS.ByteString, BS.ByteString))
cursorNext c = cursorMove c _MDB_NEXT
{-# INLINE cursorNext #-}
-- | Step back to the previous key/value pair, or 'Nothing' if already
-- at the start.
cursorPrev :: Cursor s -> IO (Maybe (BS.ByteString, BS.ByteString))
cursorPrev c = cursorMove c _MDB_PREV
{-# INLINE cursorPrev #-}
-- | Position at the first key /greater than or equal to/ the given
-- key, or 'Nothing' if no such key exists.
--
-- >>> cursorSeek cur "m"
-- Just ("n","november")
cursorSeek
:: Cursor s
-> BS.ByteString
-> IO (Maybe (BS.ByteString, BS.ByteString))
cursorSeek (Cursor cp) k =
BU.unsafeUseAsCStringLen k $ \(kdata, klen) ->
allocaBytes 32 $ \kp -> do
let !vp = kp `plusPtr` 16
pokeByteOff kp 0 (fromIntegral klen :: CSize)
pokeByteOff kp 8 kdata
code <- mdb_cursor_get cp kp vp _MDB_SET_RANGE
if code == _MDB_NOTFOUND
then pure Nothing
else do
check code
ok <- peekVal kp
ov <- peekVal vp
pure (Just (ok, ov))