Раздел 10 · Databases

Своя СУБД на Haskell · урок 11: бинарный протокол и сервер

senior~110 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Своя СУБД на Haskell · урок 11: бинарный протокол и сервер

Кому это. За десять уроков мы построили на Haskell целый движок: страницы, буферный пул, кучу, B+Tree, парсер SQL, планировщик и исполнитель, журнал, транзакции, блокировки и vacuum. В прошлом уроке он научился убирать за собой. Но говорить с движком умеет только тот, кто импортировал OurDb.Engine.Database в свою программу. Снаружи к нашей СУБД не подключиться.

Зачем именно Haskell. Сегодня выставляем движок в сеть: свой бинарный протокол в духе Postgres, TCP-сервер и клиент. На Haskell урок держится на одном решении: весь протокол это обработчик байт. Кадрирование потока это чистая функция «состояние и чанк в кадры и новое состояние». Кодеки сообщений чистые. Сессия соединения превращает SQL-строку в список сообщений поверх Database. А соединение целиком это функция «чанк байт в ответ и флаг закрытия». Сокетов в основной библиотеке нет вовсе. Пакет network нужен только в отдельной подбиблиотеке ourdb:net, где сервер читает чанки из сокета, зовёт обработчик и пишет ответ.

Важно про объём и про этап. Урок аддитивный: движок не меняется, сверху появляются модули OurDb.Wire.* и подбиблиотека ourdb:net. И одно практическое следствие решения выше. Раннер курса, где проверяются этапы, собирает код только из boot-пакетов GHC, а network среди них нет. Поэтому финальный этап проверяет обработчик соединения над байтами: тесты кормят его кадрами, нарезанными как угодно, и разбирают ответ. Сетевую обвязку из ourdb:net мы разберём как материал для чтения, а запустить её можно локально в эталоне через cabal test.

Где мы в движке

┌────────────────────────────────────────────────┐
│  Бинарный протокол + TCP-сервер                  │  урок 11  ← мы здесь
├────────────────────────────────────────────────┤
│  Исполнитель (Volcano) + EXPLAIN                 │  урок 6
│  Планировщик, binder, каталог                    │  урок 6
│  Парсер SQL                                      │  урок 5
├────────────────────────────────────────────────┤
│  Vacuum (сборка мёртвых версий)                  │  урок 10
│  Блокировки (2PL), конкуренция                   │  урок 9
│  MVCC (видимость, версии кортежа)                │  урок 8
│  Менеджер транзакций (xid, состояния, undo)      │  урок 8
│  WAL (журнал, redo, recovery)                    │  урок 7
├────────────────────────────────────────────────┤
│  B+Tree индекс                                   │  урок 4
│  Куча (slotted pages, seq scan)                  │  урок 3
├────────────────────────────────────────────────┤
│  Буферный пул (кэш страниц, вытеснение)           │  урок 2
│  DiskManager (страницы поверх файла)              │  урок 1
└────────────────────────────────────────────────┘

Верхний слой лежит над движком, а не внутри него. Раскладка модулей такая:

src/OurDb/Wire/Framing.hs    кадры из потока байт (чистое состояние)
src/OurDb/Wire/Messages.hs   кодеки сообщений (чистые)
src/OurDb/Wire/Session.hs    SQL в серверные сообщения, транзакция соединения
src/OurDb/Wire/Conn.hs       соединение сервера над байтами
src/OurDb/Wire/Client.hs     клиентский аккумулятор ответа (чистый)
net/OurDb/Net/Server.hs      ourdb:net, TCP-сервер, поток на соединение
net/OurDb/Net/Client.hs      ourdb:net, TCP-клиент

Идея протокола

Мы берём simple query protocol Postgres, урезанный до сути. Клиент шлёт два вида сообщений, сервер пять.

НаправлениеТипБайтСмысл
клиентQueryQтекст SQL с завершающим NUL
клиентTerminateXзакрыть соединение
серверRowDescriptionTимена и типы колонок
серверDataRowDодна строка, значения текстом
серверCommandCompleteCтег: SELECT 2, INSERT 0 1
серверErrorResponseEкод в духе SQLSTATE и текст
серверReadyForQueryZготов к запросу, статус транзакции

Каждый ответ заканчивается ReadyForQuery с одним байтом статуса: I (вне транзакции), T (в транзакции), E (транзакция упала, до ROLLBACK ничего нельзя). По этому байту клиент понимает, что ответ закончился, и в каком состоянии сессия.

Кадрирование

TCP это поток байт, а не поток сообщений. Один recv может вернуть половину сообщения, три сообщения сразу или полтора. Поэтому каждое сообщение упаковано в кадр: байт типа, четыре байта длины big-endian и payload. Длина включает свои 4 байта, но не байт типа, как в Postgres.

-- src/OurDb/Wire/Framing.hs

-- | Кадрирование протокола (урок 11): u8 тип, u32 длина big-endian (включает 4 байта
-- длины, не включает байт типа), payload длиной length - 4.
--
-- 'FrameReader' превращает поток произвольно нарезанных TCP-чанков в целые кадры: один
-- чанк может нести несколько кадров или половину кадра. Это чистое состояние: push
-- возвращает собравшиеся кадры и новый остаток.
module OurDb.Wire.Framing
  ( Frame (..)
  , encodeFrame
  , FrameReader
  , newFrameReader
  , pushChunk
  , pendingBytes
  ) where

import qualified Data.ByteString as BS
import qualified Data.ByteString.Builder as B
import Data.Word (Word8)
import OurDb.Bytes (build, getU32BE, runGetAt)

data Frame = Frame {frameType :: !Word8, framePayload :: !BS.ByteString}
  deriving (Eq, Show)

frameHeaderSize :: Int
frameHeaderSize = 5

encodeFrame :: Word8 -> BS.ByteString -> BS.ByteString
encodeFrame kind payload =
  build (B.word8 kind <> B.word32BE (fromIntegral (4 + BS.length payload)) <> B.byteString payload)

newtype FrameReader = FrameReader BS.ByteString

newFrameReader :: FrameReader
newFrameReader = FrameReader BS.empty

-- | Недочитанный хвост (для тестов и проверки "ровно один кадр").
pendingBytes :: FrameReader -> BS.ByteString
pendingBytes (FrameReader buffer) = buffer

-- | Добавить чанк и вернуть все кадры, собравшиеся целиком.
pushChunk :: FrameReader -> BS.ByteString -> ([Frame], FrameReader)
pushChunk (FrameReader buffer) chunk = go (buffer <> chunk) []
  where
    go bytes acc
      | BS.length bytes < frameHeaderSize = (reverse acc, FrameReader bytes)
      | otherwise =
          let total = either (const 0) ((+ 1) . fromIntegral) (runGetAt getU32BE bytes 1)
           in if BS.length bytes < total
                then (reverse acc, FrameReader bytes) -- кадр ещё не дочитан
                else
                  let frame = Frame (BS.head bytes) (BS.drop frameHeaderSize (BS.take total bytes))
                   in go (BS.drop (max 1 total) bytes) (frame : acc)

В Effect- и TS-версиях этого урока FrameReader это изменяемый объект с буфером внутри. Здесь он значение: newtype над недочитанным хвостом. pushChunk берёт старое состояние и чанк, а возвращает пару: все целые кадры и новое состояние. Та же форма s -> (a, s), что у менеджера блокировок в уроке 9. Кто хранит состояние между чанками, решает вызывающий: сервер положит его в IORef, клиент в MVar, а тест просто протянет значения руками.

go отрезает кадры, пока хватает байт. Меньше пяти байт: заголовок не дочитан, ждём. Длина есть, но байт меньше total: кадр не дочитан, ждём. Иначе отрезаем кадр и идём дальше. total это длина из заголовка плюс байт типа. max 1 total гарантирует продвижение даже на мусорном заголовке с нулевой длиной: цикл не застрянет на одном месте. BS.take и BS.drop не копируют байты, поэтому кадры это срезы одного буфера.

Сообщения

Кодеки сообщений тоже чистые. Самое интересное в них это тип серверного сообщения.

-- src/OurDb/Wire/Messages.hs

-- | Сообщения протокола (урок 11): simple query protocol в духе Postgres, числа
-- big-endian. Чистые кодеки payload-ов; кадрирование живёт в "OurDb.Wire.Framing".
--
-- Значения в DataRow это текст: int десятичной строкой, bool как t/f, text как есть.
-- Серверное сообщение параметризовано типом строки: сервер шлёт 'Value', клиент получает
-- сырой текст (Maybe Text) и разбирает его по типам из RowDescription.

-- | Статус транзакции в ReadyForQuery.
data TxnStatus = StatusIdle | StatusInTx | StatusFailed
  deriving (Eq, Show)

statusByte :: TxnStatus -> Word8
statusByte = \case
  StatusIdle -> 0x49 -- 'I'
  StatusInTx -> 0x54 -- 'T'
  StatusFailed -> 0x45 -- 'E'

msgQuery, msgTerminate, msgRowDescription, msgDataRow, msgCommandComplete, msgErrorResponse, msgReadyForQuery :: Word8
msgQuery = 0x51 -- 'Q'
msgTerminate = 0x58 -- 'X'
msgRowDescription = 0x54 -- 'T'
msgDataRow = 0x44 -- 'D'
msgCommandComplete = 0x43 -- 'C'
msgErrorResponse = 0x45 -- 'E'
msgReadyForQuery = 0x5a -- 'Z'

data ClientMessage = Query !Text | Terminate
  deriving (Eq, Show)

data ServerMsg row
  = RowDescription ![Column]
  | DataRow !row
  | CommandComplete !Text
  | -- | Код (SQLSTATE-подобный) и текст.
    ErrorResponse !Text !Text
  | ReadyForQuery !TxnStatus
  deriving (Eq, Show)

type ServerMessage = ServerMsg [Value]

type DecodedServerMessage = ServerMsg [Maybe Text]

ServerMsg параметризован типом строки. Сервер собирает ServerMessage, где DataRow несёт типизированные [Value] прямо из исполнителя. На проводе значения превращаются в текст. Клиент разбирает кадр в DecodedServerMessage, где та же строка это [Maybe Text]: сырой текст ячеек, Nothing для NULL. Разбирать текст по типам клиент будет сам, по RowDescription. Один тип данных описывает обе стороны, а параметр говорит, на какой стороне провода ты стоишь. Перепутать их нельзя: функция, ждущая ServerMessage, не примет DecodedServerMessage.

-- src/OurDb/Wire/Messages.hs (продолжение)

clientMessageType :: ClientMessage -> Word8
clientMessageType = \case
  Query _ -> msgQuery
  Terminate -> msgTerminate

-- | SQL плюс завершающий NUL.
encodeClientPayload :: ClientMessage -> BS.ByteString
encodeClientPayload = \case
  Query sql -> utf8 sql <> BS.singleton 0
  Terminate -> BS.empty

decodeClientMessage :: Word8 -> BS.ByteString -> Either Text ClientMessage
decodeClientMessage kind payload
  | kind == msgQuery =
      let body = if not (BS.null payload) && BS.last payload == 0 then BS.init payload else payload
       in Right (Query (fromUtf8 body))
  | kind == msgTerminate = Right Terminate
  | otherwise = Left ("неизвестный тип клиентского сообщения 0x" <> T.pack (showHex kind ""))

serverMessageType :: ServerMsg row -> Word8
serverMessageType = \case
  RowDescription _ -> msgRowDescription
  DataRow _ -> msgDataRow
  CommandComplete _ -> msgCommandComplete
  ErrorResponse _ _ -> msgErrorResponse
  ReadyForQuery _ -> msgReadyForQuery

encodeServerPayload :: ServerMessage -> BS.ByteString
encodeServerPayload = \case
  RowDescription fields -> build (u16 (length fields) <> foldMap field fields)
  DataRow values -> build (u16 (length values) <> foldMap cell values)
  CommandComplete tag -> utf8 tag
  ErrorResponse code message -> let c = utf8 code in build (u16 (BS.length c) <> B.byteString c <> B.byteString (utf8 message))
  ReadyForQuery status -> BS.singleton (statusByte status)
  where
    u16 = B.word16BE . fromIntegral
    field (Column name t) = let n = utf8 name in u16 (BS.length n) <> B.byteString n <> B.word8 (typeCode t)
    cell value = case encodeValueText value of
      Nothing -> B.int32BE (-1)
      Just bytes -> B.int32BE (fromIntegral (BS.length bytes)) <> B.byteString bytes

serverMessageType работает для любого row: тип кадра не зависит от того, что лежит в строке. А encodeServerPayload принимает только серверную сторону, ServerMessage. Ячейка DataRow это длина i32 и байты, а длина -1 означает NULL, как в Postgres. Функции field и cell возвращают Builder, и foldMap склеивает их в один кусок без промежуточных строк.

Разбор серверного сообщения нужен клиенту и тестам:

-- src/OurDb/Wire/Messages.hs (продолжение)

-- | Разобрать серверное сообщение (DataRow остаётся сырым текстом).
decodeServerMessage :: Word8 -> BS.ByteString -> Either Text DecodedServerMessage
decodeServerMessage kind payload
  | kind == msgRowDescription = runGet (RowDescription <$> counted field) payload
  | kind == msgDataRow = runGet (DataRow <$> counted cell) payload
  | kind == msgCommandComplete = Right (CommandComplete (fromUtf8 payload))
  | kind == msgErrorResponse = runGet errorResponse payload
  | kind == msgReadyForQuery = runGet (getU8 >>= either fail' (pure . ReadyForQuery) . status) payload
  | otherwise = Left ("неизвестный тип серверного сообщения 0x" <> T.pack (showHex kind ""))
  where
    fail' = fail . T.unpack
    counted item = getU16BE >>= \n -> replicateM (fromIntegral n) item
    field = do
      name <- getU16BE >>= fmap fromUtf8 . getBytes . fromIntegral
      code <- getU8
      case code of
        0 -> pure (Column name TInt)
        1 -> pure (Column name TText)
        2 -> pure (Column name TBool)
        _ -> fail ("неизвестный код типа колонки " <> show code)
    cell =
      getI32BE >>= \case
        -1 -> pure Nothing
        len -> Just . fromUtf8 <$> getBytes (fromIntegral len)
    errorResponse = do
      code <- getU16BE >>= fmap TE.decodeLatin1 . getBytes . fromIntegral
      ErrorResponse code . fromUtf8 <$> getRest
    status byte = case chr (fromIntegral byte) of
      'I' -> Right StatusIdle
      'T' -> Right StatusInTx
      'E' -> Right StatusFailed
      c -> Left ("неизвестный статус транзакции \"" <> T.singleton c <> "\"")

-- | Текстовое представление значения для DataRow (Nothing это NULL).
encodeValueText :: Value -> Maybe BS.ByteString
encodeValueText = \case
  VNull -> Nothing
  VInt n -> Just (utf8 (T.pack (show n)))
  VBool b -> Just (if b then "t" else "f")
  VText s -> Just (utf8 s)

-- | Разобрать текстовое значение колонки по её типу.
decodeValueText :: ColumnType -> Text -> Either Text Value
decodeValueText columnType text = case columnType of
  TText -> Right (VText text)
  TBool -> Right (VBool (text == "t"))
  TInt -> case TR.signed TR.decimal text of
    Right (n, rest) | T.null rest, n >= toInteger (minBound :: Int64) && n <= toInteger (maxBound :: Int64) -> Right (VInt (fromInteger n))
    _ -> Left ("не int: " <> text)

counted item это помощник «прочитай счётчик u16, потом столько же элементов»: так устроены и RowDescription, и DataRow. Ошибка разбора внутри Get (неизвестный код колонки, обрыв payload) превращается в Left, а не в падение процесса: мусорный кадр от сервера станет WireProtocolError у клиента. decodeValueText для INT разбирает число в Integer и только потом проверяет диапазон Int64. Иначе fromInteger молча обрезал бы 99999999999999999999 по модулю 2^64.

Сессия соединения

Сессия превращает строку SQL в список серверных сообщений и помнит транзакцию своего соединения.

-- src/OurDb/Wire/Session.hs

-- | Сессия соединения (урок 11): SQL-строка в последовательность серверных сообщений,
-- состояние одной транзакции соединения и статус ReadyForQuery. Без сокетов: сервер
-- вызывает 'runSession' на каждый Query-кадр.
--
-- Каждое соединение это своя транзакция через 'begin' движка, а не общий sessionXid
-- (иначе соединения делили бы одну транзакцию). Упавшая транзакция (ошибка внутри BEGIN)
-- переходит в failed: до ROLLBACK любая команда отвергается, как в Postgres.

data Session = Session
  { sessDb :: !Database
  , sessHandle :: !(IORef (Maybe Transaction))
  , sessFailed :: !(IORef Bool)
  }

newSession :: Database -> IO Session
newSession db = Session db <$> newIORef Nothing <*> newIORef False

-- | Выполнить SQL и вернуть ответ, завершённый ReadyForQuery.
runSession :: Session -> Text -> IO [ServerMessage]
runSession session sql = case parseCommand sql of
  Left err -> respond session [errorOf (Right err)]
  Right (CmdTxn op) -> runTxn session op
  Right _ -> do
    failed <- readIORef (sessFailed session)
    if failed
      then respond session [ErrorResponse "25P02" "текущая транзакция прервана"]
      else do
        handle <- readIORef (sessHandle session)
        outcome <- trySync (maybe (execute (sessDb session) sql) (`txExecute` sql) handle)
        case outcome of
          Right result -> respond session (messagesFor result)
          Left err -> do
            mapM_ (const (writeIORef (sessFailed session) True)) handle
            respond session [errorOf err]

Помнишь dbSessionXid из урока 8? Это одна сессионная транзакция на весь движок. Для сервера она не годится: два соединения делили бы одну транзакцию. Поэтому у сессии свой хэндл Transaction из урока 9, полученный через begin. Нет хэндла, и оператор идёт через execute в autocommit. Есть, и через txExecute в транзакции соединения. Выбирает maybe: «если Nothing, первое, иначе второе применить к хэндлу».

Синтаксическая ошибка ловится ещё до движка: parseCommand вернул Left, ответ это ErrorResponse и ReadyForQuery с прежним статусом. Ошибка внутри транзакции переводит сессию в состояние failed: mapM_ (const ...) handle это опять идиома «сделай, если есть», флаг поднимается только при открытом хэндле. Ошибка autocommit-оператора транзакцию не роняет, её и нет.

Самое тонкое место сессии это trySync:

-- src/OurDb/Wire/Session.hs (продолжение)

-- | Синхронные исключения как значение, асинхронные (killThread) летят дальше.
trySync :: IO a -> IO (Either (Either SomeException DbError) a)
trySync action =
  try action >>= \case
    Right value -> pure (Right value)
    Left e
      | Just (_ :: SomeAsyncException) <- fromException e -> throwIO e
      | Just db <- fromException e -> pure (Left (Right db))
      | otherwise -> pure (Left (Left e))

errorOf :: Either SomeException DbError -> ServerMessage
errorOf = \case
  Right err -> ErrorResponse (errorCode err) (errorMessage err)
  Left other -> ErrorResponse "XX000" (T.pack (displayException other))

-- | SQLSTATE-подобный код по виду ошибки движка.
errorCode :: DbError -> Text
errorCode = \case
  UniquenessError _ -> "23505"
  SerializationError _ -> "40001"
  DeadlockError _ -> "40P01"
  LockWaitError _ -> "55P03"
  SqlError _ _ -> "42601"
  EngineError _ -> "42000"
  StorageError _ -> "XX000"

Сервер должен ответить клиенту на любую ошибку оператора, а не уронить соединение. Отсюда try с SomeException. Но ловить всё подряд опасно. В Haskell есть асинхронные исключения: killThread бросает исключение в чужой поток, и так сервер при остановке гасит потоки соединений. Если trySync проглотит такое исключение и превратит его в ErrorResponse, поток соединения выживет после killThread, и сервер никогда не дождётся его завершения. Поэтому первым проверяется SomeAsyncException, и его throwIO бросает дальше. DbError становится типизированной ошибкой с SQLSTATE-кодом, всё остальное (например, IOError от упавшего fsync) идёт как XX000. Это прямое продолжение урока про исключения и ресурсы.

errorCode это полное сопоставление по DbError. Добавишь в движок новый вид ошибки, и -Wall укажет на это место: протокол не забудет дать ей код.

Транзакционные команды сессия обрабатывает сама:

-- src/OurDb/Wire/Session.hs (продолжение)

-- | Откатить открытую транзакцию (Terminate и закрытие сокета).
closeSession :: Session -> IO ()
closeSession session =
  readIORef (sessHandle session) >>= \case
    Nothing -> pure ()
    Just handle -> do
      writeIORef (sessHandle session) Nothing
      writeIORef (sessFailed session) False
      txRollback handle

runTxn :: Session -> TxnOp -> IO [ServerMessage]
runTxn session = \case
  TxnBegin -> do
    readIORef (sessHandle session) >>= \case
      Just _ -> pure ()
      Nothing -> do
        handle <- begin (sessDb session)
        writeIORef (sessHandle session) (Just handle)
        writeIORef (sessFailed session) False
    respond session [CommandComplete "BEGIN"]
  TxnCommit -> finishTxn True >>= respond session . pure . CommandComplete
  TxnRollback -> finishTxn False >> respond session [CommandComplete "ROLLBACK"]
  where
    -- COMMIT упавшей транзакции это откат с тегом ROLLBACK
    finishTxn :: Bool -> IO Text
    finishTxn wantCommit =
      readIORef (sessHandle session) >>= \case
        Nothing -> pure (if wantCommit then "COMMIT" else "ROLLBACK")
        Just handle -> do
          writeIORef (sessHandle session) Nothing
          wasFailed <- atomicModifyIORef' (sessFailed session) (False,)
          if not wantCommit || wasFailed
            then "ROLLBACK" <$ txRollback handle
            else "COMMIT" <$ txCommit handle

respond :: Session -> [ServerMessage] -> IO [ServerMessage]
respond session messages = (\status -> messages <> [ReadyForQuery status]) <$> sessionStatus session

sessionStatus :: Session -> IO TxnStatus
sessionStatus session = do
  failed <- readIORef (sessFailed session)
  handle <- readIORef (sessHandle session)
  pure $ case (failed, handle) of
    (True, _) -> StatusFailed
    (_, Nothing) -> StatusIdle
    _ -> StatusInTx

-- | Развернуть результат в серверные сообщения (без ReadyForQuery).
messagesFor :: ExecResult -> [ServerMessage]
messagesFor = \case
  ResSelect _ fields rows -> RowDescription fields : map DataRow rows <> [CommandComplete ("SELECT " <> T.pack (show (length rows)))]
  ResCommand tag -> [CommandComplete tag]
  ResExplain plan -> [RowDescription [Column "QUERY PLAN" TText], DataRow [VText plan], CommandComplete "EXPLAIN"]

Повторный BEGIN при открытой транзакции молча проходит, как предупреждение в Postgres. COMMIT упавшей транзакции откатывает её и отвечает тегом ROLLBACK: клиент видит по тегу, что зафиксировать не вышло. atomicModifyIORef' (sessFailed session) (False,) за один шаг читает старый флаг и сбрасывает его: секция (False,) это функция \old -> (False, old), она нужна расширению TupleSections, которое входит в GHC2021. respond дописывает ReadyForQuery к любому ответу, поэтому ни одна ветка не забудет его отправить. EXPLAIN уходит одной текстовой строкой в колонке QUERY PLAN, как в psql.

дальше после входа

Урок целиком открыт всем, кто вошёл. Войди по своему email, чтобы читать дальше.

Вход пока по приглашению. Если хочешь учиться, напиши Vassiliy.