Своя СУБД на Haskell · урок 8: транзакции, откат и видимость
Кому это. В прошлом уроке у движка появился журнал упреждающей записи. INSERT и CREATE переживают падение: старт проигрывает журнал. Но мы честно оставили дыру: транзакций нет, ROLLBACK движок не знает, а DELETE падение не переживает.
Зачем именно Haskell. Сегодня закрываем дыру целиком. Появятся BEGIN, COMMIT и ROLLBACK, кортеж научится версиям через xmin/xmax, и видимость строки начнёт решать движок. Это MVCC, на которой стоит Postgres. На Haskell урок раскладывается на два слоя, и это его главная мысль. Видимость версии это чистая функция над неизменяемыми значениями: кортеж, снимок, реестр статусов. Её можно проверить в ghci без единого файла. А менеджер транзакций хранит своё состояние как неизменяемую запись в одной TVar, и каждый его переход это одна STM-транзакция. Запись в журнал при этом идёт снаружи STM, и система типов не даст поставить её внутрь. Почему это правильно и что за это платим, разберём по ходу.
Важно про объём. Это самый плотный урок серии: транзакции, версии, видимость, откат, восстановление и checkpoint сходятся вместе. Но почти всё опирается на готовое: журнал из прошлого урока, исполнитель из шестого. Движок сегодня остаётся однопользовательским: одна сессионная транзакция плюс autocommit. Конкуренция нескольких транзакций и блокировки это следующий урок, но форму снимка закладываем правильную сразу.
Где мы в движке
┌────────────────────────────────────────────────┐
│ Бинарный протокол + 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
└────────────────────────────────────────────────┘
Два новых слоя: менеджер транзакций (выдаёт xid, ведёт состояния и undo-лог) и MVCC (видимость по снимку). Они пронизывают исполнитель: каждый скан фильтрует версии по видимости, удаление пишет версию вместо стирания.
Идея версий
У кортежа из урока 3 уже есть два поля заголовка, которые до сих пор ничего не значили: xmin и xmax. xmin это xid транзакции, создавшей версию. xmax это xid удалившей транзакции или 0, если версия жива. Удаление больше не стирает слот, оно ставит xmax. Обновление это удаление старой версии плюс вставка новой. А видна ли строка конкретной транзакции, решает функция видимости.
Представь библиотечную карточку книги. Её не вымарывают, когда книгу списали, а ставят штамп «списана такого-то числа приказом номер N». Читатель, пришедший раньше приказа, книгу ещё видит в каталоге. Пришедший позже нет. Приказ отменили? Штамп ничего не значит. Так одно удаление становится журналируемой пометкой, которую можно воспроизвести при recovery и откатить при rollback.
Версии в странице: setXmax
Сначала научим страницу писать xmax кортежа на месте.
-- src/OurDb/Page.hs (добавляем)
-- | Записать xmax кортежа в слоте на месте (MVCC-удаление, урок 8).
setXmax :: Page -> Int -> Int -> IO ()
setXmax page slot xmax = do
offset <- getSlot page slot
writeU32 page (offset + tupleXmaxOffset) (fromIntegral xmax)
getXmax :: Page -> Int -> IO Int
getXmax page slot = do
offset <- getSlot page slot
fromIntegral <$> readU32 page (offset + tupleXmaxOffset)
setXmax не трогает сам слот, только заголовок кортежа по его смещению. Слот остаётся живым, seq scan кортеж увидит, а дальше решит видимость. Это принципиальное отличие от deleteSlot из урока 3: тот зануляет слот (физическое удаление), а setXmax оставляет версию на месте.
Куча получает операцию MVCC-удаления, а журнал кучи второе поле:
-- src/OurDb/Heap.hs (изменения)
-- | Канал журналирования для кучи: xid, relation, page, slot и образ/xmax;
-- возвращает lsn записи для штампа page.lsn. Реализует TransactionManager.
data Journal = Journal
{ logInsert :: Int -> Int -> Int -> Int -> BS.ByteString -> IO Word64
, logSetXmax :: Int -> Int -> Int -> Int -> Int -> IO Word64
}
-- | MVCC-удаление: xmax = xid на месте, слот остаётся живым. На мёртвом слоте бросает:
-- getSlot дал бы 0, и xmax лёг бы в поле lsn заголовка страницы.
markDeleted :: HeapFile -> Rid -> Int -> IO ()
markDeleted heap (Rid pageId slot) xid = do
let pool = heapPool heap
page <- fetchPage pool pageId
live <- Page.isSlotLive page slot
if not live
then do
unpinPage pool pageId False
storageError (T.pack ("markDeleted: слот " <> show pageId <> ":" <> show slot <> " уже мёртв"))
else do
Page.setXmax page slot xid
case heapJournal heap of
Nothing -> pure ()
Just journal -> logSetXmax journal xid (heapRelationId heap) pageId slot xid >>= Page.setLsn page
unpinPage pool pageId True
-- | Текущий xmax кортежа в слоте (0 если не удалён). Для проверки write-write.
readXmax :: HeapFile -> Rid -> IO Int
readXmax heap (Rid pageId slot) = withPage (heapPool heap) pageId (\page -> (,False) <$> Page.getXmax page slot)
Journal перестал быть newtype: у записи теперь два поля. Проверка живого слота здесь не перестраховка. Мёртвый слот хранит смещение 0, и setXmax на нём записал бы xmax в поле lsn заголовка страницы: тихая порча, которую потом не найти. Лучше громкий storageError сразу. Порядок тот же, что у вставки: изменить страницу, получить lsn от журнала, проштамповать, отпустить грязной.
Для журнала нужен новый payload:
-- src/OurDb/Wal.hs (добавляем)
data SetXmaxPayload = SetXmaxPayload {spRelationId :: !Int, spPageId :: !Int, spSlot :: !Int, spXmax :: !Int}
deriving (Eq, Show)
encodeSetXmaxPayload :: SetXmaxPayload -> BS.ByteString
encodeSetXmaxPayload (SetXmaxPayload rel page slot xmax) =
build (u32 rel <> u32 page <> B.word16LE (fromIntegral slot) <> u32 xmax)
decodeSetXmaxPayload :: BS.ByteString -> Either Text SetXmaxPayload
decodeSetXmaxPayload = runGet (SetXmaxPayload <$> getInt32 <*> getInt32 <*> (fromIntegral <$> getU16LE) <*> getInt32)
Видимость: снимок и правило
Теперь сердце MVCC. Это модуль OurDb.Snapshot, и в нём нет ни одного IO.
-- src/OurDb/Snapshot.hs
-- | MVCC: снимок и видимость версий (урок 8), чистые функции над неизменяемыми данными.
-- Версия видна, если её xmin зафиксирован к моменту снимка, а xmax не удалил её видимым
-- образом. xid 0 это сентинел (DDL и "нет писателя"), xmax == 0 значит "не удалена".
module OurDb.Snapshot
( TxnState (..)
, CommitStatus
, emptyCommitStatus
, commitStatusFrom
, statusOf
, setStatus
, commitFloor
, raiseFloorTo
, absorb
, Snapshot (..)
, committedAsOf
, isVisible
) where
import qualified Data.IntMap.Strict as IM
import qualified Data.IntSet as IS
import OurDb.Tuple (Tuple (..))
data TxnState = InProgress | Committed | Aborted
deriving (Eq, Show)
-- | Реестр статусов: явные статусы открытых и недавних транзакций плюс floor (водяной
-- знак последнего checkpoint), ниже которого xid считается зафиксированным без записи.
-- Незнакомый xid выше floor считается откатанным. Аналог обрезания CLOG в Postgres.
data CommitStatus = CommitStatus {csStates :: !(IM.IntMap TxnState), csFloor :: !Int}
deriving (Eq, Show)
emptyCommitStatus :: CommitStatus
emptyCommitStatus = CommitStatus IM.empty 0
commitStatusFrom :: [(Int, TxnState)] -> CommitStatus
commitStatusFrom entries = CommitStatus (IM.fromList entries) 0
statusOf :: CommitStatus -> Int -> TxnState
statusOf (CommitStatus states floorXid) xid = case IM.lookup xid states of
Just state -> state
Nothing -> if xid /= 0 && xid < floorXid then Committed else Aborted
setStatus :: Int -> TxnState -> CommitStatus -> CommitStatus
setStatus xid state status = status {csStates = IM.insert xid state (csStates status)}
commitFloor :: CommitStatus -> Int
commitFloor = csFloor
raiseFloorTo :: Int -> CommitStatus -> CommitStatus
raiseFloorTo watermark status = status {csFloor = max watermark (csFloor status)}
-- | Перенять floor и явные статусы другого реестра (после recovery).
absorb :: CommitStatus -> CommitStatus -> CommitStatus
absorb other status =
(raiseFloorTo (csFloor other) status) {csStates = IM.union (csStates other) (csStates status)}
Реестр статусов устроен как IntMap плюс одно число, floor. Зачем число? В конце урока мы научимся обрезать журнал на checkpoint, и записи COMMIT старых транзакций исчезнут вместе с ним. Тогда реестр запомнит одно: «всё ниже такого-то xid давно зафиксировано». Это водяной знак. Незнакомый xid выше водяного знака считается откатанным: транзакция, от которой не осталось ни COMMIT, ни открытого состояния, умерла незавершённой.
Все функции возвращают новый реестр, старый не меняется. setStatus это синтаксис обновления записи, IM.insert строит новую карту, разделяя с прежней почти все узлы. Такое значение безопасно отдать кому угодно: снимок реестра, взятый на начало оператора, не поменяется под ногами, даже если менеджер транзакций рядом уже записал новый статус.
-- src/OurDb/Snapshot.hs (продолжение, этап 8)
data Snapshot = Snapshot
{ -- | Самый старый активный xid (горизонт vacuum).
snapXmin :: !Int
, -- | Верхняя граница (эксклюзивно): xid >= xmax на момент снимка ещё не начинались.
snapXmax :: !Int
, -- | Активные транзакции на момент снимка, кроме самой себя.
snapActive :: !IS.IntSet
}
deriving (Eq, Show)
-- | Зафиксирована ли транзакция к моменту снимка.
committedAsOf :: Int -> Snapshot -> CommitStatus -> Bool
committedAsOf x snapshot status =
x /= 0 && statusOf status x == Committed && x < snapXmax snapshot && not (IS.member x (snapActive snapshot))
-- | Видна ли версия транзакции self под её снимком. Свои записи видны сразу.
isVisible :: Tuple -> Snapshot -> Int -> CommitStatus -> Bool
isVisible tuple snapshot self status = inserterVisible && not deleted
where
inserterVisible = tupleXmin tuple == self || committedAsOf (tupleXmin tuple) snapshot status
deleted =
tupleXmax tuple /= 0
&& (tupleXmax tuple == self || committedAsOf (tupleXmax tuple) snapshot status)
Снимок это три поля, и одна функция committedAsOf складывает их в ответ на вопрос «зафиксирован ли x с точки зрения этого снимка». Четыре условия, все обязательны. x не ноль (ноль это «нет писателя»). Реестр говорит Committed. x меньше границы снимка: транзакция началась до него. x не в active: на момент снимка она уже завершилась. Сегодня active всегда пуст, транзакция одна, но условие уже на месте.
isVisible читается как определение из учебника. Версия видна, если автор вставки виден (это я сам, либо он зафиксирован к моему снимку) и версия не удалена видимым образом (удалил не я, и удаливший не зафиксирован к моему снимку). Свои записи видны сразу, без коммита: внутри транзакции INSERT следующий SELECT уже находит.
Попробуй сам на виджете: выбери исход транзакции T (открыта, зафиксирована, откатилась) и ставит ли она удаление, и смотри два вердикта: глазами самой T и глазами нового читателя после её исхода.
Раз видимость чистая, её тесты не открывают ни одного файла. Вот они из эталона:
-- test/Lesson08TransactionsSpec.hs (группа «видимость»)
describe "видимость" $ do
let tuple xmin xmax = Tuple [VInt 1] xmin xmax False
snap xmax = Snapshot xmax xmax IS.empty
it "зафиксированная до снимка вставка видна" $
isVisible (tuple 1 0) (snap 10) 7 (commitStatusFrom [(1, Committed)]) `shouldBe` True
it "собственная вставка видна, собственное удаление нет" $ do
isVisible (tuple 10 0) (snap 10) 10 emptyCommitStatus `shouldBe` True
isVisible (tuple 10 10) (snap 10) 10 emptyCommitStatus `shouldBe` False
it "удаление, зафиксированное до снимка, скрывает строку" $
isVisible (tuple 1 2) (snap 10) 7 (commitStatusFrom [(1, Committed), (2, Committed)]) `shouldBe` False
it "вставка откатанной или незнакомой транзакции невидима" $ do
isVisible (tuple 6 0) (snap 10) 7 (commitStatusFrom [(6, Aborted)]) `shouldBe` False
isVisible (tuple 99 0) (snap 10) 7 emptyCommitStatus `shouldBe` False
it "floor водяного знака: xid ниже него считается зафиксированным" $ do
let status = raiseFloorTo 5 emptyCommitStatus
statusOf status 4 `shouldBe` Committed
statusOf status 5 `shouldBe` Aborted
statusOf status 0 `shouldBe` Aborted
Четвёртый аргумент Tuple (False) это флаг заморозки из урока 10: тесты эталона идут по финальному движку. На твоём этапе 8 у кортежа его ещё нет.
Менеджер транзакций на TVar
Менеджер выдаёт xid, берёт снимок при begin, ведёт по каждой транзакции цепочку prev_lsn и undo-лог в памяти, и держит глобальный реестр статусов. Всё это одно неизменяемое значение.
-- src/OurDb/Txn.hs
-- | TransactionManager (урок 8): выдаёт xid, берёт снимок при begin (snapshot isolation),
-- ведёт per-txn цепочку prev_lsn, undo-log в памяти и глобальный commit-status.
-- Состояние это неизменяемая запись в TVar: каждый переход это одна STM-транзакция,
-- запись в журнал идёт снаружи (WAL это IO). Движок сериализует statement-ы своим
-- мьютексом, поэтому пара "append в журнал, затем шаг состояния" не рвётся.
-- | Запись undo-log: откат вставки (слот мёртв) или MVCC-удаления (xmax в 0).
data UndoEntry
= UndoInsert {undoRelationId :: !Int, undoPageId :: !Int, undoSlot :: !Int}
| UndoSetXmax {undoRelationId :: !Int, undoPageId :: !Int, undoSlot :: !Int}
deriving (Eq, Show)
data Active = Active
{ activeBeginLsn :: !Word64
, activeSnapshot :: !Snapshot
, activePrevLsn :: !Word64
, -- | Новые записи первыми: откат идёт по списку как есть.
activeUndo :: ![UndoEntry]
}
data TmState = TmState
{ tsNextXid :: !Int
, tsActive :: !(IM.IntMap Active)
, tsStatus :: !CommitStatus
}
data TxnManager = TxnManager {tmWal :: !WalManager, tmState :: !(TVar TmState)}
newTxnManager :: WalManager -> IO TxnManager
newTxnManager wal = TxnManager wal <$> newTVarIO (TmState 1 IM.empty emptyCommitStatus)
readState :: TxnManager -> IO TmState
readState = readTVarIO . tmState
modifyState :: TxnManager -> (TmState -> TmState) -> IO ()
modifyState tm = atomically . modifyTVar' (tmState tm)
Вся изменяемость менеджера сидит в одной TVar. Внутри неё лежит обычная запись: следующий xid, карта открытых транзакций и реестр статусов. Переход состояния это чистая функция TmState -> TmState, а modifyState применяет её атомарно. Тот, кто читает через readTVarIO, видит либо состояние до перехода, либо после, но никогда половину.
Откат описан данными, а не замыканиями: UndoEntry это два конструктора с адресом слота. Undo-лог хранится «новые первыми», и откат просто идёт по списку как есть, от последнего изменения к первому.
-- src/OurDb/Txn.hs (продолжение)
-- | Открыть транзакцию: присвоить xid, записать BEGIN, взять снимок открытых транзакций.
begin :: TxnManager -> IO Int
begin tm = do
xid <- atomically $ stateTVar (tmState tm) (\s -> (tsNextXid s, s {tsNextXid = tsNextXid s + 1}))
beginLsn <- walAppend (tmWal tm) xid WalBegin BS.empty 0
modifyState tm $ \s ->
s
{ tsActive = IM.insert xid (Active beginLsn (captureSnapshot xid s) beginLsn []) (tsActive s)
, tsStatus = setStatus xid InProgress (tsStatus s)
}
pure xid
commit :: TxnManager -> Int -> IO ()
commit tm xid = do
active <- requireActive tm xid
_ <- walAppend (tmWal tm) xid WalCommit BS.empty (activePrevLsn active)
walFlush (tmWal tm)
finish tm xid Committed
-- | Откатить: применить undo-log от новых записей к старым, записать ABORT.
abort :: TxnManager -> Int -> (UndoEntry -> IO ()) -> IO ()
abort tm xid applyUndo = do
active <- requireActive tm xid
mapM_ applyUndo (activeUndo active)
_ <- walAppend (tmWal tm) xid WalAbort BS.empty (activePrevLsn active)
finish tm xid Aborted
finish :: TxnManager -> Int -> TxnState -> IO ()
finish tm xid state = modifyState tm $ \s -> s {tsActive = IM.delete xid (tsActive s), tsStatus = setStatus xid state (tsStatus s)}
-- | Снимок: активны все открытые кроме самой себя; xmax это self.
captureSnapshot :: Int -> TmState -> Snapshot
captureSnapshot self state =
let active = IS.delete self (IM.keysSet (tsActive state))
in Snapshot (minimum (self : IS.toList active)) self active
Посмотри на begin. Это три шага, и только первый и третий в STM.
Первый: stateTVar читает следующий xid и сдвигает счётчик одной транзакцией. Функция возвращает пару «результат и новое состояние», это тот же приём, что у State-монады. Две параллельные begin не получат один xid.
Второй: walAppend пишет запись BEGIN. Это IO, и поставить его внутрь atomically нельзя: тип STM a просто не даст вызвать IO. Это не каприз. STM-транзакция оптимистична и при конфликте переигрывается с начала, а запись в журнал откатить нельзя: переигранная транзакция дописала бы BEGIN дважды. Система типов запрещает ровно то, что сломало бы журнал.
Третий: регистрация в карте открытых и статус InProgress, снова одной транзакцией.
Между шагами есть окно: xid уже выдан, а в tsActive транзакции ещё нет. Параллельный captureSnapshot в этом окне не увидел бы её в active. Почему это не дыра? Потому что операции движка идут строго по одной, как в эталоне на TS, где движок однопоточный по устройству event loop. Сегодня это обеспечивает сам вызывающий: у движка один пользователь. В уроке 9, когда транзакций станет несколько, перед Database встанет мьютекс, и комментарий в шапке модуля говорит именно о нём. STM здесь даёт атомарность каждого отдельного перехода и честное чтение состояния снаружи, а последовательность шагов держит дисциплина вызова. В уроке 9 мы вернёмся к этой границе на менеджере блокировок, где она станет интереснее.
commit дописывает COMMIT с prev_lsn последней записи транзакции, форсит журнал и только потом меняет статус. Порядок важен: если упасть между walFlush и finish, recovery найдёт COMMIT в журнале и признает транзакцию зафиксированной, и это правильно, ведь клиент мог уже получить ответ. abort получает колбэк applyUndo. Менеджер знает, что откатить, но не знает, как добраться до страницы: каталог лежит выше. Вызывающий передаёт функцию, и зависимость не идёт вверх.
captureSnapshot собирает active из всех открытых транзакций, кроме самой себя. Сегодня открыта одна, поэтому active пуст. В следующем уроке этот код не поменяется ни на символ, а active наполнится сам.
Журнал кучи от менеджера
Теперь менеджер реализует Journal из прошлого урока, но умнее autocommit-обвязки:
-- src/OurDb/Txn.hs (продолжение)
-- | Канал журналирования для кучи: запись в WAL, продвижение prev_lsn, запись undo.
journal :: TxnManager -> Journal
journal tm =
Journal
{ logInsert = \xid rel page slot image ->
logged xid WalInsert (encodeInsertPayload (InsertPayload rel page slot image)) (UndoInsert rel page slot)
, logSetXmax = \xid rel page slot xmax ->
logged xid WalSetXmax (encodeSetXmaxPayload (SetXmaxPayload rel page slot xmax)) (UndoSetXmax rel page slot)
}
where
logged xid kind payload undo = do
active <- requireActive tm xid
lsn <- walAppend (tmWal tm) xid kind payload (activePrevLsn active)
let step a = a {activePrevLsn = lsn, activeUndo = undo : activeUndo a}
modifyState tm $ \s -> s {tsActive = IM.adjust step xid (tsActive s)}
pure lsn
-- | Граница no-steal: минимум (beginLsn - 1) по открытым транзакциям, иначе текущий lsn.
flushableLsn :: TxnManager -> IO Word64
flushableLsn tm = do
active <- tsActive <$> readState tm
if IM.null active
then walCurrentLsn (tmWal tm)
else pure (minimum [activeBeginLsn a - 1 | a <- IM.elems active])
Каждая запись теперь цепляется к предыдущей записи своей транзакции через prev_lsn, а в undo-лог ложится обратная операция. Вставку откатывает UndoInsert (слот мёртв), удаление откатывает UndoSetXmax (xmax обратно в 0). Обе ветки делят помощник logged, разница только в типе записи и payload.
Без открытых транзакций граница это текущий lsn журнала. Для этого в OurDb.Wal добавляется маленький помощник:
-- src/OurDb/Wal.hs (добавляем)
-- | Наибольший присвоенный lsn (включая несброшенные записи).
walCurrentLsn :: WalManager -> IO Word64
walCurrentLsn wal = subtract 1 <$> readIORef (walNextLsn wal)
flushableLsn это граница no-steal. Пока открыта хоть одна транзакция, на диск можно отпускать только страницы, чей lsn меньше BEGIN самой старой открытой. Страница с более свежим lsn может нести незафиксированное изменение, и при падении её пришлось бы откатывать на диске. Undo-прохода при recovery у нас нет, поэтому такую страницу пул держит в памяти до COMMIT. Это и есть цена no-steal: память.
Граница нужна буферному пулу, поэтому у FlushHooks из урока 7 появляется второе поле, и newtype становится data:
-- src/OurDb/BufferPool.hs (изменения)
-- | Хуки WAL (урок 7). beforeFlush это правило write-ahead, canFlush это no-steal
-- (гейтит только вытеснение, не явный flushPage/flushAll).
data FlushHooks = FlushHooks
{ beforeFlush :: Page -> IO ()
, canFlush :: Page -> IO Bool
}
noHooks :: FlushHooks
noHooks = FlushHooks (\_ -> pure ()) (\_ -> pure True)
-- evictFrame (фрагмент): грязный кадр становится жертвой, только если canFlush разрешает
else do
flushable <- if frameDirty frame then canFlush hooks (framePage frame) else pure True
if not flushable
then skip
else do
advance
when (frameDirty frame) $ flushFrame pool frame
modifyIORef' (poolTable pool) (IM.delete (framePageId frame))
bump pool (\s -> s {evictions = evictions s + 1})
pure index
canFlush гейтит только вытеснение. Явный flushAll на checkpoint по-прежнему пишет всё: его зовут, когда открытых транзакций нет. Если часы обойдут весь пул дважды и не найдут ни одной разрешённой жертвы, evictFrame бросит buffer pool exhausted: все кадры закреплены или держат незафиксированное.
Ещё пара помощников менеджера, нужных движку:
-- src/OurDb/Txn.hs (продолжение)
-- | Следующий xid к выдаче. На checkpoint (открытых нет) это водяной знак: все выданные
-- xid уже завершились, recovery после ротации считает их зафиксированными.
watermark :: TxnManager -> IO Int
watermark tm = tsNextXid <$> readState tm
-- | Лёгкий снимок для чтения вне транзакции (autocommit SELECT): self это следующий xid
-- (не присваивается), своих записей у него нет, видно всё зафиксированное.
readSnapshot :: TxnManager -> IO (Int, Snapshot)
readSnapshot tm = do
state <- readState tm
let self = tsNextXid state
pure (self, captureSnapshot self state)
-- | Продолжить нумерацию xid и перенять commit-status (с floor) после восстановления.
resumeFrom :: TxnManager -> Int -> CommitStatus -> IO ()
resumeFrom tm nextXid recovered =
modifyState tm $ \s -> s {tsNextXid = max (tsNextXid s) nextXid, tsStatus = absorb recovered (tsStatus s)}
readSnapshot экономит на autocommit-SELECT: читателю не нужен свой xid, запись BEGIN и регистрация. Он берёт следующий xid как «себя», этот xid никому не выдаётся, своих записей у такого читателя нет, и он видит всё зафиксированное.
дальше после входа
Урок целиком открыт всем, кто вошёл. Войди по своему email, чтобы читать дальше.
Вход пока по приглашению. Если хочешь учиться, напиши Vassiliy.