srtree-db-0.1.0.0: src/Algorithm/EqSat/Storage/Postgres.hs
{-# LANGUAGE OverloadedStrings #-}
-- | PostgreSQL-backed persistence for srtree e-graphs.
--
-- Implements the same driver-neutral interface as
-- 'Algorithm.EqSat.Storage.SQLite' on top of @libpq@
-- ('Database.PostgreSQL.LibPQ'), so the shared storage code
-- ('saveGraph'/'loadGraph'/'pushFit'/'refreshFitness' and the
-- 'Algorithm.EqSat.Storage.Query' API) runs unchanged against PostgreSQL.
--
-- Connections are plain @libpq@ connections (see 'connectPostgres' /
-- 'closePostgres'); the reggression layer dispatches on a @postgres://@ /
-- @postgresql://@ DSN.
module Algorithm.EqSat.Storage.Postgres
( schemaPostgres
, connectPostgres
, closePostgres
) where
import Control.Monad (forM, forM_)
import Data.ByteString (ByteString)
import qualified Data.IntSet as IntSet
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import Database.PostgreSQL.LibPQ
( Connection, ExecStatus(..), Format(..), Oid(..), Result
, connectdb, exec, execParams, finish, getvalue, invalidOid, nfields
, ntuples, resultErrorMessage, resultStatus, toColumn, toRow )
import Algorithm.EqSat.Storage.Backend (SqlValue(..), SqlBackend(..), sqlToInt, sqlToText)
import Algorithm.EqSat.Storage.ClassStore (unhex)
-- | PostgreSQL DDL. Mirrors 'Algorithm.EqSat.Storage.Schema.schemaSQL'.
--
-- Differences from SQLite: @BIGINT@ identity keys, @DOUBLE PRECISION@
-- metrics, and foreign keys declared @DEFERRABLE INITIALLY DEFERRED@ so the
-- writer can insert @eclass_node@/@fit@ rows before their referenced
-- @eclass@ rows within the @BEGIN@..@COMMIT@ transaction of 'saveGraph'.
schemaPostgres :: [Text]
schemaPostgres =
[ "CREATE TABLE IF NOT EXISTS meta ("
<> " key TEXT PRIMARY KEY,"
<> " value TEXT NOT NULL)"
, "CREATE TABLE IF NOT EXISTS enode ("
<> " key TEXT PRIMARY KEY,"
<> " op TEXT NOT NULL,"
<> " op_detail TEXT,"
<> " a BIGINT,"
<> " b BIGINT,"
<> " x DOUBLE PRECISION)"
, "CREATE TABLE IF NOT EXISTS enode_child ("
<> " enode_key TEXT NOT NULL REFERENCES enode(key) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED,"
<> " child_eid BIGINT NOT NULL,"
<> " cnt INTEGER NOT NULL DEFAULT 1,"
<> " PRIMARY KEY (enode_key, child_eid))"
, "CREATE TABLE IF NOT EXISTS eclass ("
<> " eid BIGINT PRIMARY KEY,"
<> " canonical BIGINT NOT NULL,"
<> " height INTEGER NOT NULL DEFAULT 0)"
, "CREATE TABLE IF NOT EXISTS eclass_node ("
<> " eid BIGINT NOT NULL REFERENCES eclass(eid) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED,"
<> " enode_key TEXT NOT NULL REFERENCES enode(key) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED,"
<> " PRIMARY KEY (eid, enode_key))"
, "CREATE TABLE IF NOT EXISTS parent ("
<> " child_eid BIGINT NOT NULL REFERENCES eclass(eid) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED,"
<> " parent_eid BIGINT NOT NULL,"
<> " parent_enode_key TEXT NOT NULL REFERENCES enode(key) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED,"
<> " PRIMARY KEY (child_eid, parent_eid, parent_enode_key))"
, "CREATE TABLE IF NOT EXISTS cstore_page ("
<> " key TEXT PRIMARY KEY,"
<> " blob TEXT NOT NULL)"
, "CREATE TABLE IF NOT EXISTS frontier ("
<> " eid BIGINT PRIMARY KEY REFERENCES eclass(eid) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED,"
<> " updated_at TEXT)"
, "CREATE TABLE IF NOT EXISTS dataset ("
<> " id BIGSERIAL PRIMARY KEY,"
<> " name TEXT NOT NULL UNIQUE,"
<> " created TEXT)"
, "CREATE TABLE IF NOT EXISTS dataset_fit ("
<> " dataset_id BIGINT NOT NULL REFERENCES dataset(id) ON DELETE CASCADE,"
<> " eid BIGINT NOT NULL REFERENCES eclass(eid) ON DELETE CASCADE,"
<> " fitness DOUBLE PRECISION,"
<> " dl DOUBLE PRECISION,"
<> " theta TEXT,"
<> " size INTEGER NOT NULL DEFAULT 0,"
<> " evaluated INTEGER NOT NULL DEFAULT 0,"
<> " fitted INTEGER NOT NULL DEFAULT 0,"
<> " stale INTEGER NOT NULL DEFAULT 0,"
<> " updated_at TEXT,"
<> " PRIMARY KEY (dataset_id, eid))"
, "CREATE INDEX IF NOT EXISTS idx_dsfit_fitness ON dataset_fit(fitness)"
, "CREATE INDEX IF NOT EXISTS idx_dsfit_size ON dataset_fit(size)"
, "CREATE INDEX IF NOT EXISTS idx_dsfit_dl ON dataset_fit(dl)"
, "CREATE TABLE IF NOT EXISTS expression_index ("
<> " expression_key TEXT PRIMARY KEY,"
<> " eclass BIGINT NOT NULL REFERENCES eclass(eid) ON DELETE CASCADE,"
<> " dataset_id BIGINT REFERENCES dataset(id) ON DELETE CASCADE,"
<> " first_seen TEXT)"
]
-- | Open a PostgreSQL connection from a connection string (e.g.
-- @postgresql://user:pass@host:5432/db@).
connectPostgres :: String -> IO Connection
connectPostgres = connectdb . TE.encodeUtf8 . T.pack
-- | Close a PostgreSQL connection.
closePostgres :: Connection -> IO ()
closePostgres = finish
instance SqlBackend Connection where
execDb conn sql = do
r <- pgExec conn sql
statusOK r "exec"
runDb conn sql params = do
r <- pgExecParams conn sql params
statusOK r "run"
insertIgnore conn tail params = do
r <- pgExecParams conn ("INSERT INTO " <> tail <> " ON CONFLICT DO NOTHING") params
statusOK r "insertIgnore"
queryDb conn sql params = do
r <- pgExecParams conn sql params
st <- resultStatus r
case st of
TuplesOk -> do
ns <- ntuples r
nf <- nfields r
let n = fromEnum ns
m = fromEnum nf
forM [0 .. n - 1] $ \i ->
forM [0 .. m - 1] $ \j -> do
v <- getvalue r (toRow i) (toColumn j)
pure $ case v of
Nothing -> SqlNull
Just bs -> SqlText (TE.decodeUtf8 bs)
_ -> do
statusOK r "query"
pure []
createSchemaDb conn = mapM_ (execDb conn) schemaPostgres
-- Grid fallback (Postgres is not the out-of-core target): the cursor-based
-- streaming matcher needs 'Database.SQLite3'; here we return the full
-- distinct set up to @budget@, documented as unbounded memory.
streamByOp conn opDetail budget exclude = do
let ex = IntSet.fromList exclude
rows <- queryDb conn
"SELECT DISTINCT n.eid FROM eclass_node n \
\JOIN enode e ON e.key = n.enode_key WHERE e.op_detail = ?"
[SqlText opDetail]
pure (take budget [ eid | [eid'] <- rows, let eid = sqlToInt eid', not (IntSet.member eid ex) ])
-- Grid fallback for page streaming (unbounded; Postgres is not the
-- out-of-core target).
streamPages conn tbl k = do
rows <- queryDb conn ("SELECT key, blob FROM " <> tbl) []
forM_ rows $ \[key, blob] -> k (fromIntegral (sqlToInt key)) (unhex (sqlToText blob))
-- | Raise an exception unless the status is @CommandOk@/@TuplesOk@.
statusOK :: Result -> Text -> IO ()
statusOK r tag = do
st <- resultStatus r
case st of
CommandOk -> pure ()
TuplesOk -> pure ()
EmptyQuery -> pure ()
_ -> do
mmsg <- resultErrorMessage r
let msg = maybe "unknown error" (T.unpack . TE.decodeUtf8) mmsg
fail ("postgres: " <> T.unpack tag <> ": " <> msg)
-- | Execute a statement without parameters (DDL, BEGIN/COMMIT, DELETE).
pgExec :: Connection -> Text -> IO Result
pgExec conn sql = do
mr <- exec conn (TE.encodeUtf8 sql)
case mr of
Nothing -> fail "postgres: exec returned no result"
Just r -> pure r
-- | Execute a parameterized statement, rewriting @?@ to @$n@.
pgExecParams :: Connection -> Text -> [SqlValue] -> IO Result
pgExecParams conn sql params = do
let pgSql = toPG sql
ps = map renderParam params
mr <- execParams conn (TE.encodeUtf8 pgSql) ps Text
case mr of
Nothing -> fail "postgres: execParams returned no result"
Just r -> pure r
-- | Render a parameter for libpq's text-format protocol. NULL is never sent
-- as a parameter: the shared SQL spells it as the literal @NULL@.
renderParam :: SqlValue -> Maybe (Oid, ByteString, Format)
renderParam (SqlInteger n) = Just (invalidOid, TE.encodeUtf8 (T.pack (show n)), Text)
renderParam (SqlFloat d) = Just (invalidOid, TE.encodeUtf8 (T.pack (show d)), Text)
renderParam (SqlText t) = Just (invalidOid, TE.encodeUtf8 t, Text)
renderParam SqlNull = Nothing
-- | Rewrite the shared positional @?@ placeholders to libpq's @$n@ form
-- (single-quoted literals are skipped, in case a value embeds @?@).
toPG :: Text -> Text
toPG = T.pack . go (1 :: Int) . T.unpack
where
go :: Int -> String -> String
go _ [] = []
go n ('\'' : r) = '\'' : skip r
where
skip ('\'' : r') = '\'' : go n r'
skip (c : r') = c : skip r'
skip [] = []
go n ('?' : r) = '$' : show n ++ go (n + 1) r
go n (c : r) = c : go n r