packages feed

nova-nix-0.8.0.0: src/Nix/Store/DB.hs

-- | SQLite database for store path registration.
--
-- == Why a database?
--
-- The store directory is just files on disk.  But Nix needs to track
-- metadata about each path that isn't in the filesystem:
--
-- * __References__: which other store paths does this path depend on?
--   (Needed for garbage collection - can't delete a path that others
--   reference.)
-- * __Registrant__: who put this path here? (Substituted from cache?
--   Built locally?)
-- * __Deriver__: which .drv file produced this output? (For @nix-store -q
--   --deriver@.)
-- * __NAR hash__: SHA-256 of the path's NAR serialization. (For integrity
--   verification.)
-- * __NAR size__: byte count of the NAR. (For disk usage reporting.)
-- * __Validity__: has this path been verified? (A path can exist on disk
--   but be invalid if the build was interrupted.)
--
-- C++ Nix uses SQLite for this.  So do we.  The database lives at
-- @\/nix\/store\/.nova-nix\/db.sqlite@ (or @C:\\nix\\store\\.nova-nix\\db.sqlite@).
module Nix.Store.DB
  ( -- * Database handle
    StoreDB,

    -- * Types
    PathRegistration (..),
    PathInfo (..),

    -- * Lifecycle
    openStoreDB,
    closeStoreDB,

    -- * Registration
    registerPath,
    registerPaths,
    isValidPath,
    queryReferences,
    queryDeriver,
    queryPathInfo,
    queryAllValidPaths,

    -- * Unregistration
    UnregisterResult (..),
    unregisterPathRow,

    -- * Constants
    metaDirName,
    dbFileName,
  )
where

import Control.Exception (throwIO)
import Data.Text (Text)
import qualified Data.Text as T
import Database.SQLite.Simple
  ( Connection,
    Only (..),
    Query (..),
    close,
    execute,
    execute_,
    open,
    query,
    withTransaction,
  )
import Nix.Store.Path (StoreDir (..), StorePath, storePathToFilePath)
import System.Directory (createDirectoryIfMissing)
import System.FilePath ((</>))

-- ---------------------------------------------------------------------------
-- Named constants
-- ---------------------------------------------------------------------------

-- | Subdirectory under the store for metadata.
metaDirName :: FilePath
metaDirName = ".nova-nix"

-- | SQLite database filename.
dbFileName :: FilePath
dbFileName = "db.sqlite"

-- ---------------------------------------------------------------------------
-- Types
-- ---------------------------------------------------------------------------

-- | Opaque handle to the store database.
-- Wraps a SQLite connection and the store directory.
data StoreDB = StoreDB
  { sdbDir :: !StoreDir,
    sdbConn :: !Connection
  }

-- | Information needed to register a store path.
data PathRegistration = PathRegistration
  { prPath :: !StorePath,
    prNarHash :: !Text,
    prNarSize :: !Int,
    prDeriver :: !(Maybe Text),
    prReferences :: ![StorePath]
  }
  deriving (Eq, Show)

-- | Stored information about a registered path.
data PathInfo = PathInfo
  { piPath :: !Text,
    piNarHash :: !Text,
    piNarSize :: !Int,
    piDeriver :: !(Maybe Text),
    piRegTime :: !Int
  }
  deriving (Eq, Show)

-- ---------------------------------------------------------------------------
-- SQL statements
-- ---------------------------------------------------------------------------

-- | Create the ValidPaths table.
createValidPathsSQL :: String
createValidPathsSQL =
  "CREATE TABLE IF NOT EXISTS ValidPaths (\
  \  id               INTEGER PRIMARY KEY AUTOINCREMENT,\
  \  path             TEXT UNIQUE NOT NULL,\
  \  hash             TEXT NOT NULL,\
  \  registrationTime INTEGER NOT NULL,\
  \  deriver          TEXT,\
  \  narSize          INTEGER NOT NULL\
  \)"

-- | Create the Refs table.
createRefsSQL :: String
createRefsSQL =
  "CREATE TABLE IF NOT EXISTS Refs (\
  \  referrer  INTEGER NOT NULL REFERENCES ValidPaths(id),\
  \  reference INTEGER NOT NULL REFERENCES ValidPaths(id),\
  \  PRIMARY KEY (referrer, reference)\
  \)"

-- ---------------------------------------------------------------------------
-- Lifecycle
-- ---------------------------------------------------------------------------

-- | Open (or create) the store database.
-- Creates the store directory, metadata subdirectory, and database
-- tables if they don't exist.  Enables WAL mode for concurrency and
-- foreign-key enforcement for referential integrity.
openStoreDB :: StoreDir -> IO StoreDB
openStoreDB dir = do
  let storeRoot = unStoreDir dir
      metaDir = storeRoot </> metaDirName
      dbPath = metaDir </> dbFileName
  createDirectoryIfMissing True metaDir
  conn <- open dbPath
  execute_ conn "PRAGMA journal_mode=WAL"
  -- SQLite's default busy timeout is zero, so a second process hitting
  -- a peer's write transaction died on an uncaught ErrorBusy SQLError
  -- instead of waiting its turn.  Upstream waits an hour
  -- (sqlite3_busy_timeout(db, 60 * 60 * 1000), sqlite.cc:78 at
  -- 2.28.7); the pragma reaches the same API.
  execute_ conn "PRAGMA busy_timeout=3600000"
  -- SQLite leaves foreign keys OFF per connection; without this the Refs
  -- REFERENCES clauses are inert, and deleting a ValidPaths row (path
  -- deletion, garbage collection) would leave dangling Refs edges that
  -- closure JOINs silently under-report.
  execute_ conn "PRAGMA foreign_keys=ON"
  execute_ conn (fromString createValidPathsSQL)
  execute_ conn (fromString createRefsSQL)
  pure StoreDB {sdbDir = dir, sdbConn = conn}
  where
    fromString = Query . T.pack

-- | Close the store database.
closeStoreDB :: StoreDB -> IO ()
closeStoreDB db = close (sdbConn db)

-- ---------------------------------------------------------------------------
-- Registration
-- ---------------------------------------------------------------------------

-- | Register a single store path as valid with its metadata and references.
-- A convenience wrapper over 'registerPaths' for one path.
registerPath :: StoreDB -> PathRegistration -> IO ()
registerPath db reg = registerPaths db [reg]

-- | Register several store paths as valid in one transaction.
--
-- ALL path rows are inserted BEFORE any reference edge, so references among the
-- paths in this batch - e.g. intra-derivation cross-output references - are
-- never dropped.  (Registering one path at a time loses an edge whenever a
-- referrer is registered before its referent.)
--
-- Re-registering an existing path refreshes its metadata (NAR hash, size,
-- deriver) via @ON CONFLICT DO UPDATE@, so a path first registered with a
-- placeholder hash is corrected on a later real registration.
registerPaths :: StoreDB -> [PathRegistration] -> IO ()
registerPaths db regs = withTransaction (sdbConn db) $ do
  mapM_ (insertPathRow db) regs
  mapM_ (insertPathRefs db) regs

-- | Insert (or refresh) a single ValidPaths row.
insertPathRow :: StoreDB -> PathRegistration -> IO ()
insertPathRow db reg = do
  -- DB rows key store paths in PLATFORM spelling (storePathToFilePath):
  -- the database is host-local state describing this host's store tree,
  -- every writer and reader in this module uses the same spelling, and
  -- the store dir itself is host configuration.  Identity artifacts
  -- (drv ATerm, narinfo, eval-visible store-path strings) spell
  -- canonically; the DB is deliberately not one of them.
  let pathText = T.pack (storePathToFilePath (sdbDir db) (prPath reg))
  execute
    (sdbConn db)
    "INSERT INTO ValidPaths (path, hash, registrationTime, deriver, narSize) \
    \VALUES (?, ?, strftime('%s','now'), ?, ?) \
    \ON CONFLICT(path) DO UPDATE SET hash = excluded.hash, narSize = excluded.narSize, deriver = excluded.deriver"
    (pathText, prNarHash reg, prDeriver reg, prNarSize reg)

-- | Insert the reference edges for a path whose row already exists.
--
-- Re-registration REPLACES the edge set: the metadata-refresh contract
-- (see 'registerPaths') applies to references too, and keeping the union
-- of old and new edges would over-report - 'queryReferences' feeds pushed
-- narinfos, which would advertise references the path no longer has.
--
-- A reference to an unregistered path is an error, not a skip: silently
-- dropping the edge under-reports the same narinfos and hands a future GC
-- permission to delete a live dependency.  Referents must be registered
-- first or in the same 'registerPaths' batch (path rows are all inserted
-- before any edge).
insertPathRefs :: StoreDB -> PathRegistration -> IO ()
insertPathRefs db reg = do
  let conn = sdbConn db
      pathText = T.pack (storePathToFilePath (sdbDir db) (prPath reg))
  referrerRows <- query conn "SELECT id FROM ValidPaths WHERE path = ?" (Only pathText) :: IO [Only Int]
  case referrerRows of
    (Only referrerId : _) -> do
      execute conn "DELETE FROM Refs WHERE referrer = ?" (Only referrerId)
      mapM_ (insertRef conn referrerId) (prReferences reg)
    [] -> pure () -- Should not happen: the row was just inserted above.
  where
    insertRef conn referrerId refPath = do
      let refPathText = T.pack (storePathToFilePath (sdbDir db) refPath)
      refRows <- query conn "SELECT id FROM ValidPaths WHERE path = ?" (Only refPathText) :: IO [Only Int]
      case refRows of
        (Only refId : _) ->
          execute conn "INSERT OR IGNORE INTO Refs (referrer, reference) VALUES (?, ?)" (referrerId, refId)
        [] ->
          throwIO
            ( userError
                ( "registerPaths: "
                    <> storePathToFilePath (sdbDir db) (prPath reg)
                    <> " references unregistered path "
                    <> T.unpack refPathText
                )
            )

-- ---------------------------------------------------------------------------
-- Queries
-- ---------------------------------------------------------------------------

-- | Check if a store path is registered as valid.
isValidPath :: StoreDB -> StorePath -> IO Bool
isValidPath db sp = do
  let pathText = T.pack (storePathToFilePath (sdbDir db) sp)
  rows <- query (sdbConn db) "SELECT 1 FROM ValidPaths WHERE path = ? LIMIT 1" (Only pathText) :: IO [Only Int]
  pure (not (null rows))

-- | Query the references of a registered store path.
-- Returns the full path strings of referenced store paths.
--
-- Reads return the stored text without re-parsing: every row was
-- written from a validated 'StorePath' inside this module's
-- transactions, so this is trust-on-read of host-local state the
-- module itself wrote (the delete path documents the same stance for
-- its raw-basename key).  Callers that need a 'StorePath' back parse
-- at their own boundary.
queryReferences :: StoreDB -> StorePath -> IO [Text]
queryReferences db sp = do
  let pathText = T.pack (storePathToFilePath (sdbDir db) sp)
  rows <-
    query
      (sdbConn db)
      "SELECT vp2.path FROM Refs r \
      \JOIN ValidPaths vp1 ON r.referrer = vp1.id \
      \JOIN ValidPaths vp2 ON r.reference = vp2.id \
      \WHERE vp1.path = ?"
      (Only pathText) ::
      IO [Only Text]
  pure [p | Only p <- rows]

-- | Query every registered valid path, as full path text in registration
-- order of the table (sorted for determinism).
queryAllValidPaths :: StoreDB -> IO [Text]
queryAllValidPaths db = do
  rows <- query (sdbConn db) "SELECT path FROM ValidPaths ORDER BY path" () :: IO [Only Text]
  pure [p | Only p <- rows]

-- | Query the deriver of a registered store path.
queryDeriver :: StoreDB -> StorePath -> IO (Maybe Text)
queryDeriver db sp = do
  let pathText = T.pack (storePathToFilePath (sdbDir db) sp)
  rows <- query (sdbConn db) "SELECT deriver FROM ValidPaths WHERE path = ?" (Only pathText) :: IO [Only (Maybe Text)]
  case rows of
    (Only deriver : _) -> pure deriver
    [] -> pure Nothing

-- ---------------------------------------------------------------------------
-- Unregistration
-- ---------------------------------------------------------------------------

-- | Outcome of 'unregisterPathRow'.
data UnregisterResult
  = -- | The row and its outgoing reference edges were removed.
    RowUnregistered
  | -- | No row carries this path text.
    RowAbsent
  | -- | Other valid paths still reference this one (their path texts,
    -- sorted); nothing was changed.
    RowReferenced ![Text]
  deriving (Eq, Show)

-- | Remove a path's ValidPaths row and outgoing Refs edges, keyed by the
-- EXACT stored path text.  Deliberately not keyed by 'StorePath': the
-- rows this exists to clean up include ones whose names the current
-- validator rejects, and those cannot round-trip through a parse.
--
-- Refuses while any OTHER valid path references this one; a
-- self-reference does not block.  Lookup, referrer check, and deletion
-- run in one transaction, so a registration cannot interleave between
-- the check and the delete.
unregisterPathRow :: StoreDB -> Text -> IO UnregisterResult
unregisterPathRow db pathText = withTransaction conn $ do
  idRows <- query conn "SELECT id FROM ValidPaths WHERE path = ?" (Only pathText) :: IO [Only Int]
  case idRows of
    [] -> pure RowAbsent
    (Only pathId : _) -> do
      referrerRows <-
        query
          conn
          "SELECT vp.path FROM Refs r \
          \JOIN ValidPaths vp ON r.referrer = vp.id \
          \WHERE r.reference = ? AND r.referrer != ? \
          \ORDER BY vp.path"
          (pathId, pathId) ::
          IO [Only Text]
      case [p | Only p <- referrerRows] of
        referrers@(_ : _) -> pure (RowReferenced referrers)
        [] -> do
          execute conn "DELETE FROM Refs WHERE referrer = ?" (Only pathId)
          execute conn "DELETE FROM ValidPaths WHERE id = ?" (Only pathId)
          pure RowUnregistered
  where
    conn = sdbConn db

-- | Query full path info for a registered store path.
queryPathInfo :: StoreDB -> StorePath -> IO (Maybe PathInfo)
queryPathInfo db sp = do
  let pathText = T.pack (storePathToFilePath (sdbDir db) sp)
  rows <-
    query
      (sdbConn db)
      "SELECT path, hash, narSize, deriver, registrationTime FROM ValidPaths WHERE path = ?"
      (Only pathText) ::
      IO [(Text, Text, Int, Maybe Text, Int)]
  case rows of
    ((pth, hsh, sz, drv, regTime) : _) ->
      pure $
        Just
          PathInfo
            { piPath = pth,
              piNarHash = hsh,
              piNarSize = sz,
              piDeriver = drv,
              piRegTime = regTime
            }
    [] -> pure Nothing