· yuta

RDBMSを自作して学ぶ:第12章 WAL(Write-Ahead Logging)とリカバリ

#database#rdbms#go

前回はロックマネージャを実装し、同時に動くトランザクションが互いに干渉しないようにしました。しかし、まだクラッシュには無防備です。コミットした変更がバッファプールにしか載っていない状態でプロセスが落ちれば変更は失われますし、ディスクに書き出された未コミットの変更は、そのまま残ってしまいます。今回は、データベースの変更をすべて記録するWAL(Write-Ahead Logging)と、再起動時にWALを使ってデータを正しい状態に戻すリカバリを実装します。

クラッシュが起きるとどうなるか

バッファプールとディスクのずれ

第4章のバッファプールは、ダーティなページをすぐにはディスクに書きません。ページが追い出されるときか、明示的にフラッシュしたときに初めて書き込みます。そのため、メモリ上のデータとディスク上のデータには、常にずれがあります。

このずれが、クラッシュ時に2つの問題を引き起こします。

問題1: コミットした変更が失われる(永続性の違反)

TX1: users に行を挿入(バッファプール上のページだけが変わる)
TX1: COMMIT → 成功
     ← ここでクラッシュ!(ページはまだディスクに書かれていない)
再起動後: 挿入したはずの行がない

問題2: コミットしていない変更が残る(原子性の違反)

TX2: users に大量の行を挿入(まだコミットしていない)
     バッファプールが満杯になり、TX2が変更したページがディスクに書き出される
     ← ここでクラッシュ!
再起動後: コミットしていない行がディスクに残っている

単純な解決策とその問題

これらを避ける最も単純な方法は、次の2つを守ることです。

しかしFORCEでは、コミットのたびにディスク上のあちこちのページへランダムに書き込むことになり、非常に遅くなります。NO-STEALでは、大きなトランザクションが変更したページをすべてバッファプールに載せ続けなければなりません。

そこで実際のデータベースは、逆のSTEAL(未コミットのページも書き出してよい)とNO-FORCE(コミット時にページを書かなくてよい)を選び、その代わりにログを使って問題1・問題2の両方を解決します。本シリーズも同じ方針を取ります。

WALの考え方

すべての変更をログに書く

WALは、データベースへの変更を1件ずつログレコードとして、追記専用のログファイルに記録する仕組みです。ログは常にファイルの末尾に追記するだけなので、ディスクへの書き込みがシーケンシャルになり、あちこちのページを書き換えるよりずっと速く書けます。

ログがあれば、クラッシュしたときに次のことができます。

ディスクに書く順序の2つのルール

ログで復旧できるためには、ログがディスクにあることが前提です。WAL(Write-Ahead Logging)という名前は、「データより先にログを書く」ことに由来します。具体的には次の2つのルールを守ります。

ルール1: データページをディスクに書く前に、そのページへの変更を記録したログをディスクに書く

未コミットの変更がディスクに書き出されたとき(STEAL)、その変更のログがディスクになければ、クラッシュ後に「何を取り消せばよいか」がわかりません。

ルール2: コミットを完了する前に、そのトランザクションのログ(COMMITレコードを含む)をディスクに書く

コミットした変更のページがディスクになくても(NO-FORCE)、ログさえあれば再実行できます。言い換えると、COMMITレコードをfsyncし終えた瞬間が、コミットが確定する瞬間です。

LSNとページLSN

2つのルールを実装するために、ログレコードには1から始まる通し番号 LSN(Log Sequence Number) を振ります。そして各ページに、「このページに最後に反映したログレコードのLSN」をページLSNとして記録します。第5章と第6章で、ページの [16:24] を予約しておいたのはこのためです。

ページLSNは2か所で使います。

ログレコードの設計

何を記録するか

本シリーズでログに記録するのは、次のレコードです。

種類 内容 用途
BEGIN トランザクションの開始 分析
COMMIT コミット 分析(これがあればコミット済み)
ABORT ロールバックの完了 分析(取り消しが済んでいる)
INSERT ページID・スロット番号・レコードの中身 REDO・UNDO
DELETE ページID・スロット番号 REDO(ロールバックの取り消し操作で出る)
NEWPAGE 新しいページのIDと、つないだ元のページのID REDO
CHECKPOINT チェックポイント ログの先頭の目印

INSERTのレコードは、「どのページ(物理的な位置)の、どのスロットに(ページ内の論理的な操作)、何を入れたか」を記録します。ページは物理的に特定し、ページ内の変更は操作として記録するこの方式を、**物理論理ロギング(physiological logging)**と呼びます。

DELETEのレコードは、SQLのDELETE文ではなく(本シリーズのSQLにはDELETEがありません)、ロールバックでINSERTを取り消したときに出るレコードです。取り消し操作もログに記録しておくことで、「ロールバックの途中でクラッシュした」場合でも、どこまで取り消したかを再現できます。取り消し操作を記録したこのようなログは、ARIES(後述)では補償ログレコード(CLR: Compensation Log Record)と呼ばれます。

NEWPAGEは、ヒープファイルに新しいページをつないだことを記録します。この操作は、新しいページの初期化と元の末尾ページのリンクの書き換えという、2つのページにまたがる変更です。

記録しないもの

次のものはログに記録しません。

ファイル形式

ログファイルには、レコードを次の形式で順に書きます。

[本体の長さ(4B)][本体のCRC32(4B)][本体]
[本体の長さ(4B)][本体のCRC32(4B)][本体]
...

ログの末尾に書いている途中でクラッシュすると、最後のレコードが中途半端に残ります。長さが足りない、またはCRC(チェックサム)が合わないレコードを見つけたら、そこから先は書きかけだったと判断して捨てます。

実装:ページLSNとバッファプール

ページLSN

予約しておいた位置に、ページLSNを読み書きするメソッドを追加します。

@@ -66,4 +66,17 @@ func (p *Page) SetNumSlots(n uint32) {
 }
 
+// ページLSNの位置(第5章のヒープページ・第6章のB+木ノードで予約しておいた8バイト)
+const offsetLSN = 16
+
+// GetLSN はこのページに最後に反映したWALレコードのLSNを返す
+func (p *Page) GetLSN() uint64 {
+    return binary.BigEndian.Uint64(p[offsetLSN : offsetLSN+8])
+}
+
+// SetLSN はページLSNを設定する
+func (p *Page) SetLSN(lsn uint64) {
+    binary.BigEndian.PutUint64(p[offsetLSN:offsetLSN+8], lsn)
+}
+
 // Data はヘッダを除いたデータ領域のスライスを返す
 func (p *Page) Data() []byte {

バッファプールでルール1を守る

バッファプールには、「ページを書き出す前に呼ぶ関数」を登録できるようにします。ログマネージャはバッファプールより上の層にあるので、バッファプールがログマネージャのパッケージを直接importすると、依存関係が逆向きになってしまいます。そこで関数を外から渡してもらう形にしています。

@@ -26,4 +26,5 @@ type BufferPoolManager struct {
     freeList    []FrameID
     replacer    Replacer
+    flushLog    func(lsn uint64) error // WAL: ページを書き出す前に呼ぶ(第12章)
 }
 
@@ -133,4 +134,23 @@ func (bpm *BufferPoolManager) UnpinPage(pageID page.PageID, isDirty bool) error
 }
 
+// SetLogFlusher はページをディスクに書き出す前に呼ぶ関数を登録する
+// 「ページLSNまでのログがディスクにある」ことを保証するために使う(WALのルール)
+func (bpm *BufferPoolManager) SetLogFlusher(f func(lsn uint64) error) {
+    bpm.mu.Lock()
+    defer bpm.mu.Unlock()
+    bpm.flushLog = f
+}
+
+// writeBack はフレームの内容をディスクに書き出す
+// 先にページLSNまでのログをフラッシュしてから、ページを書く
+func (bpm *BufferPoolManager) writeBack(f *frame) error {
+    if bpm.flushLog != nil {
+        if err := bpm.flushLog(f.page.GetLSN()); err != nil {
+            return fmt.Errorf("buffer: ページ %d のログのフラッシュに失敗しました: %w", f.pageID, err)
+        }
+    }
+    return bpm.diskManager.WritePage(f.pageID, &f.page)
+}
+
 // FlushPage は指定ページをディスクに書き戻す
 func (bpm *BufferPoolManager) FlushPage(pageID page.PageID) error {
@@ -150,5 +170,5 @@ func (bpm *BufferPoolManager) flushFrame(pageID page.PageID) error {
         return nil
     }
-    if err := bpm.diskManager.WritePage(pageID, &f.page); err != nil {
+    if err := bpm.writeBack(f); err != nil {
         return fmt.Errorf("buffer: ページ %d の書き戻しに失敗しました: %w", pageID, err)
     }
@@ -190,5 +210,5 @@ func (bpm *BufferPoolManager) allocateFrame() (FrameID, error) {
     victim := &bpm.frames[victimID]
     if victim.isDirty {
-        if err := bpm.diskManager.WritePage(victim.pageID, &victim.page); err != nil {
+        if err := bpm.writeBack(victim); err != nil {
             // 書き戻せなかったページは追い出さず、追い出し候補に戻しておく
             bpm.replacer.Unpin(victimID)

ページを書き出す2か所(追い出しと明示的なフラッシュ)を writeBack にまとめ、書き出しの前に必ずページLSNまでのログをフラッシュするようにしました。

実装:ログレコードとLogManager

ログレコード

// transaction/log/log_record.go
package log

import (
    "encoding/binary"
    "fmt"

    "github.com/yourname/go-rdbms/storage/page"
    "github.com/yourname/go-rdbms/transaction/txid"
)

// LSN (Log Sequence Number) はログレコードに振る、1から単調増加する番号
type LSN uint64

// RecordType はログレコードの種類
type RecordType uint8

const (
    RecordBegin      RecordType = iota + 1 // トランザクション開始
    RecordCommit                           // コミット
    RecordAbort                            // ロールバック完了
    RecordInsert                           // レコードの挿入(REDO・UNDO用)
    RecordDelete                           // レコードの削除(REDO用。ロールバックの取り消し操作で出る)
    RecordNewPage                          // ヒープへのページ追加(REDO用)
    RecordCheckpoint                       // チェックポイント
)

func (t RecordType) String() string {
    names := map[RecordType]string{
        RecordBegin: "BEGIN", RecordCommit: "COMMIT", RecordAbort: "ABORT",
        RecordInsert: "INSERT", RecordDelete: "DELETE", RecordNewPage: "NEWPAGE",
        RecordCheckpoint: "CHECKPOINT",
    }
    return names[t]
}

// LogRecord は1件のログレコード
type LogRecord struct {
    LSN        LSN
    TxID       txid.TxID
    Type       RecordType
    PageID     page.PageID // INSERT/DELETE: 対象のページ, NEWPAGE: 追加したページ
    SlotID     uint16      // INSERT/DELETE: 対象のスロット
    PrevPageID page.PageID // NEWPAGE: 新しいページをつないだ元のページ
    Data       []byte      // INSERT: 挿入したレコード
}

// 固定長部分: LSN(8) + TxID(4) + Type(1) + PageID(4) + SlotID(2) + PrevPageID(4) + Dataの長さ(4)
const recordHeaderSize = 27

// encode はログレコードをバイト列にする
func (r *LogRecord) encode() []byte {
    buf := make([]byte, recordHeaderSize, recordHeaderSize+len(r.Data))
    binary.BigEndian.PutUint64(buf[0:], uint64(r.LSN))
    binary.BigEndian.PutUint32(buf[8:], uint32(r.TxID))
    buf[12] = byte(r.Type)
    binary.BigEndian.PutUint32(buf[13:], uint32(r.PageID))
    binary.BigEndian.PutUint16(buf[17:], r.SlotID)
    binary.BigEndian.PutUint32(buf[19:], uint32(r.PrevPageID))
    binary.BigEndian.PutUint32(buf[23:], uint32(len(r.Data)))
    return append(buf, r.Data...)
}

// decodeRecord はバイト列をログレコードに戻す
func decodeRecord(buf []byte) (*LogRecord, error) {
    if len(buf) < recordHeaderSize {
        return nil, fmt.Errorf("log: レコードが短すぎます")
    }
    dataLen := int(binary.BigEndian.Uint32(buf[23:]))
    if len(buf) != recordHeaderSize+dataLen {
        return nil, fmt.Errorf("log: レコードの長さが不正です")
    }
    return &LogRecord{
        LSN:        LSN(binary.BigEndian.Uint64(buf[0:])),
        TxID:       txid.TxID(binary.BigEndian.Uint32(buf[8:])),
        Type:       RecordType(buf[12]),
        PageID:     page.PageID(binary.BigEndian.Uint32(buf[13:])),
        SlotID:     binary.BigEndian.Uint16(buf[17:]),
        PrevPageID: page.PageID(binary.BigEndian.Uint32(buf[19:])),
        Data:       append([]byte(nil), buf[recordHeaderSize:]...),
    }, nil
}

LogManager

// transaction/log/log_manager.go
package log

import (
    "bufio"
    "encoding/binary"
    "errors"
    "fmt"
    "hash/crc32"
    "io"
    "os"
    "sync"
)

// ログファイル上では、1件のレコードを次の形式で書く
//
//    [本体の長さ(4B)][本体のCRC32(4B)][本体]
//
// 書き込みの途中でクラッシュすると末尾のレコードが中途半端に残るが、
// 長さが足りない・CRCが合わないことで検出して捨てられる
const frameHeaderSize = 8

// LogManager はWALファイルへの追記・フラッシュ・読み出しを管理する
type LogManager struct {
    mu         sync.Mutex
    path       string
    file       *os.File
    buf        []byte // まだファイルに書いていないログ
    nextLSN    LSN
    flushedLSN LSN // このLSNまでのログはfsync済み
}

// NewLogManager はWALファイルを開く。既存のログがあれば最後のLSNの続きから番号を振る
func NewLogManager(path string) (*LogManager, error) {
    f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0644)
    if err != nil {
        return nil, fmt.Errorf("log: %s を開けません: %w", path, err)
    }
    records, validEnd, err := readRecords(f)
    if err != nil {
        f.Close()
        return nil, err
    }
    // 末尾の壊れたレコードを切り捨て、続きはその位置から書く
    if err := f.Truncate(validEnd); err != nil {
        f.Close()
        return nil, err
    }
    if _, err := f.Seek(validEnd, io.SeekStart); err != nil {
        f.Close()
        return nil, err
    }

    lm := &LogManager{path: path, file: f, nextLSN: 1}
    if len(records) > 0 {
        lm.nextLSN = records[len(records)-1].LSN + 1
    }
    lm.flushedLSN = lm.nextLSN - 1
    return lm, nil
}

// Append はログレコードにLSNを振ってメモリ上のバッファに追加し、そのLSNを返す
// この時点ではまだディスクに書かれていない
func (lm *LogManager) Append(rec *LogRecord) LSN {
    lm.mu.Lock()
    defer lm.mu.Unlock()

    rec.LSN = lm.nextLSN
    lm.nextLSN++
    lm.buf = appendFrame(lm.buf, rec)
    return rec.LSN
}

// appendFrame はレコードを [長さ][CRC32][本体] の形式でbufの後ろに付け足す
func appendFrame(buf []byte, rec *LogRecord) []byte {
    body := rec.encode()
    buf = binary.BigEndian.AppendUint32(buf, uint32(len(body)))
    buf = binary.BigEndian.AppendUint32(buf, crc32.ChecksumIEEE(body))
    return append(buf, body...)
}

// FlushTo はlsnまでのログがディスクに永続化されていることを保証する
func (lm *LogManager) FlushTo(lsn LSN) error {
    lm.mu.Lock()
    defer lm.mu.Unlock()

    if lsn <= lm.flushedLSN {
        return nil // もうfsync済み
    }
    return lm.flushLocked()
}

// Flush はバッファにあるログをすべてディスクに永続化する
func (lm *LogManager) Flush() error {
    lm.mu.Lock()
    defer lm.mu.Unlock()
    return lm.flushLocked()
}

// flushLocked はバッファの内容をすべてファイルに書いてfsyncする(lm.muを持った状態で呼ぶ)
func (lm *LogManager) flushLocked() error {
    if len(lm.buf) > 0 {
        if _, err := lm.file.Write(lm.buf); err != nil {
            return fmt.Errorf("log: 書き込みに失敗しました: %w", err)
        }
        lm.buf = lm.buf[:0]
    }
    if err := lm.file.Sync(); err != nil {
        return fmt.Errorf("log: fsyncに失敗しました: %w", err)
    }
    lm.flushedLSN = lm.nextLSN - 1
    return nil
}

// ReadAll はログファイルの全レコードを先頭から読む(リカバリ用)
func (lm *LogManager) ReadAll() ([]*LogRecord, error) {
    lm.mu.Lock()
    defer lm.mu.Unlock()

    if err := lm.flushLocked(); err != nil {
        return nil, err
    }
    f, err := os.Open(lm.path)
    if err != nil {
        return nil, err
    }
    defer f.Close()
    records, _, err := readRecords(f)
    return records, err
}

// Truncate はログを捨て、CHECKPOINTレコード1件だけのログに置き換える
// チェックポイント(すべての変更がデータファイルに反映済み)のときにだけ呼ぶこと。
// LSNは続きの番号を使うので、データファイル上のページLSNより必ず大きくなる
func (lm *LogManager) Truncate() error {
    lm.mu.Lock()
    defer lm.mu.Unlock()

    rec := &LogRecord{LSN: lm.nextLSN, Type: RecordCheckpoint}
    lm.nextLSN++

    // 新しいログを一時ファイルに書いてfsyncし、リネームで差し替える。
    // 途中で落ちても、古いログか新しいログのどちらかが必ず残る
    tmp := lm.path + ".tmp"
    f, err := os.Create(tmp)
    if err != nil {
        return err
    }
    if _, err := f.Write(appendFrame(nil, rec)); err != nil {
        f.Close()
        return err
    }
    if err := f.Sync(); err != nil {
        f.Close()
        return err
    }
    if err := os.Rename(tmp, lm.path); err != nil {
        f.Close()
        return err
    }

    lm.file.Close()
    lm.file = f // 以降はこの新しいファイルの末尾に追記する
    lm.buf = lm.buf[:0]
    lm.flushedLSN = rec.LSN
    return nil
}

// Close はバッファを書き出してファイルを閉じる
func (lm *LogManager) Close() error {
    lm.mu.Lock()
    defer lm.mu.Unlock()

    if err := lm.flushLocked(); err != nil {
        return err
    }
    return lm.file.Close()
}

// readRecords はファイルの先頭からレコードを読み、正しく読めた末尾の位置も返す
func readRecords(f *os.File) ([]*LogRecord, int64, error) {
    if _, err := f.Seek(0, io.SeekStart); err != nil {
        return nil, 0, err
    }
    r := bufio.NewReader(f)
    var records []*LogRecord
    var offset int64
    header := make([]byte, frameHeaderSize)
    for {
        // io.ReadFull は指定した長さを読み切るまで読む(足りなければエラー)
        if _, err := io.ReadFull(r, header); err != nil {
            if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
                return records, offset, nil
            }
            return nil, 0, err
        }
        body := make([]byte, binary.BigEndian.Uint32(header[0:4]))
        if _, err := io.ReadFull(r, body); err != nil {
            return records, offset, nil // 書きかけのレコード
        }
        if crc32.ChecksumIEEE(body) != binary.BigEndian.Uint32(header[4:8]) {
            return records, offset, nil // 壊れたレコード
        }
        rec, err := decodeRecord(body)
        if err != nil {
            return records, offset, nil
        }
        records = append(records, rec)
        offset += int64(frameHeaderSize + len(body))
    }
}

ポイントを見ていきます。

Appendはメモリに書くだけ

Append はLSNを振ってメモリ上のバッファ(buf)に追加するだけで、ディスクには書きません。レコードを1件書くたびにfsyncしていたら非常に遅くなるからです。

FlushToは「そこまで」を保証する

FlushTo(lsn) は、lsn までのログがディスクにあることを保証します。すでにfsync済みなら何もしません。まだなら、バッファにあるログをすべて書いてfsyncします。バッファプールがページを書き出すとき(ルール1)と、コミットするとき(ルール2)に呼ばれます。

起動時は続きのLSNから振る

NewLogManager は既存のログファイルを読み、最後のレコードの次のLSNから番号を振ります。ページLSNとの比較で使うので、LSNが巻き戻ったり重複したりしてはいけません。末尾に書きかけのレコードがあれば、ファイルを切り詰めて取り除きます。

読み込みには io.ReadFull を使う

bufio.Reader の Read は、要求した長さより短いデータを返すことがあります。io.ReadFull なら、指定した長さを読み切るか、エラーを返すかのどちらかです。

Truncateはチェックポイント用

Truncate は、ログをCHECKPOINTレコード1件だけのファイルに置き換えます。詳しくは後のチェックポイントの節で説明します。

実装:ヒープファイルにWALを組み込む

Loggerインターフェース

ヒープファイルがページを変更するたびに、ログを書いてページLSNを更新するようにします。ただし、ストレージ層の heap パッケージが上位のログマネージャ(transaction/log)をimportすると、依存が逆向きになります。しかも log パッケージはRIDを使うために heap をimportしているので、循環importにもなってしまいます。そこで、ログを書くためのインターフェース Logger を heap パッケージの側に定義し、実装は上位から渡してもらいます。

@@ -11,4 +11,12 @@ import (
 )
 
+// Logger はヒープへの変更をWALに記録する(第12章)
+// 各メソッドは書き込んだログレコードのLSNを返す
+type Logger interface {
+    LogInsert(rid RID, data []byte) uint64
+    LogDelete(rid RID) uint64
+    LogNewPage(prevPageID, newPageID page.PageID) uint64
+}
+
 // HeapFile は1つのテーブルのレコードを、連結されたスロット付きページの列として管理する
 type HeapFile struct {
@@ -59,5 +67,6 @@ func (hf *HeapFile) FirstPageID() page.PageID {
 
 // InsertRecord はレコードを格納してRIDを返す
-func (hf *HeapFile) InsertRecord(data []byte) (RID, error) {
+// lgがnilでなければ、変更をWALに記録してページLSNを更新する
+func (hf *HeapFile) InsertRecord(lg Logger, data []byte) (RID, error) {
     if len(data) > page.MaxRecordSize {
         return RID{}, fmt.Errorf("heap: レコードが大きすぎます(%dバイト、上限%dバイト)",
@@ -77,5 +86,9 @@ func (hf *HeapFile) InsertRecord(data []byte) (RID, error) {
     slotID, err := sp.InsertRecord(data)
     if err == nil {
-        return RID{PageID: lastID, SlotID: slotID}, hf.bpm.UnpinPage(lastID, true)
+        rid := RID{PageID: lastID, SlotID: slotID}
+        if lg != nil {
+            p.SetLSN(lg.LogInsert(rid, data))
+        }
+        return rid, hf.bpm.UnpinPage(lastID, true)
     }
     if !errors.Is(err, page.ErrNoSpace) {
@@ -93,4 +106,10 @@ func (hf *HeapFile) InsertRecord(data []byte) (RID, error) {
     newSP.Init()
     sp.SetNextPageID(newID)
+    if lg != nil {
+        // ページの追加は、2つのページ(元の末尾と新しいページ)にまたがる変更
+        lsn := lg.LogNewPage(lastID, newID)
+        p.SetLSN(lsn)
+        newP.SetLSN(lsn)
+    }
     if err := hf.bpm.UnpinPage(lastID, true); err != nil {
         return RID{}, err
@@ -103,5 +122,9 @@ func (hf *HeapFile) InsertRecord(data []byte) (RID, error) {
         return RID{}, err
     }
-    return RID{PageID: newID, SlotID: slotID}, hf.bpm.UnpinPage(newID, true)
+    rid := RID{PageID: newID, SlotID: slotID}
+    if lg != nil {
+        newP.SetLSN(lg.LogInsert(rid, data))
+    }
+    return rid, hf.bpm.UnpinPage(newID, true)
 }
 
@@ -120,17 +143,24 @@ func (hf *HeapFile) GetRecord(rid RID) ([]byte, error) {
 
 // DeleteRecord はRIDのレコードを削除する
-func (hf *HeapFile) DeleteRecord(rid RID) error {
+func (hf *HeapFile) DeleteRecord(lg Logger, rid RID) error {
     hf.mu.Lock()
     defer hf.mu.Unlock()
+    return DeleteAt(hf.bpm, lg, rid)
+}
 
-    p, err := hf.bpm.FetchPage(rid.PageID)
+// DeleteAt はRIDのレコードを削除する(HeapFileを経由せずに使えるようにしたもの)
+func DeleteAt(bpm *buffer.BufferPoolManager, lg Logger, rid RID) error {
+    p, err := bpm.FetchPage(rid.PageID)
     if err != nil {
         return err
     }
     if err := page.NewSlottedPage(p).DeleteRecord(rid.SlotID); err != nil {
-        hf.bpm.UnpinPage(rid.PageID, false)
+        bpm.UnpinPage(rid.PageID, false)
         return err
     }
-    return hf.bpm.UnpinPage(rid.PageID, true)
+    if lg != nil {
+        p.SetLSN(lg.LogDelete(rid))
+    }
+    return bpm.UnpinPage(rid.PageID, true)
 }
 

変更のパターンはどれも同じで、ページをピンしている間に「ページを変更 → ログを書く → ページLSNを設定」の順で行い、それからアンピンします。ピン中のページは追い出されず、第9章の FlushAllPages もピン中のページを書き出さないので、「ページは変更済みなのに、ログもページLSNもまだ」という状態のページがディスクに書かれることはありません。

DeleteAt は、HeapFile を経由せずにバッファプールとRIDだけでレコードを削除する関数です。リカバリでは、カタログを読み込む前にページ単位で処理するので、この形が必要になります。lg に nil を渡すとログを書かないので、第5章のテストなどはそのまま使えます(引数に nil を足すだけです)。

TxLogger

トランザクションごとに、自分のTxIDでログを書く Logger の実装を用意します。

// transaction/log/tx_logger.go
package log

import (
    "github.com/yourname/go-rdbms/storage/heap"
    "github.com/yourname/go-rdbms/storage/page"
    "github.com/yourname/go-rdbms/transaction/txid"
)

// TxLogger は1つのトランザクションのヒープ操作をWALに記録する(heap.Loggerの実装)
type TxLogger struct {
    lm   *LogManager
    txID txid.TxID
}

var _ heap.Logger = (*TxLogger)(nil)

func NewTxLogger(lm *LogManager, txID txid.TxID) *TxLogger {
    return &TxLogger{lm: lm, txID: txID}
}

func (l *TxLogger) LogInsert(rid heap.RID, data []byte) uint64 {
    return uint64(l.lm.Append(&LogRecord{
        TxID: l.txID, Type: RecordInsert, PageID: rid.PageID, SlotID: rid.SlotID, Data: data,
    }))
}

func (l *TxLogger) LogDelete(rid heap.RID) uint64 {
    return uint64(l.lm.Append(&LogRecord{
        TxID: l.txID, Type: RecordDelete, PageID: rid.PageID, SlotID: rid.SlotID,
    }))
}

func (l *TxLogger) LogNewPage(prevPageID, newPageID page.PageID) uint64 {
    return uint64(l.lm.Append(&LogRecord{
        TxID: l.txID, Type: RecordNewPage, PageID: newPageID, PrevPageID: prevPageID,
    }))
}

実装:TransactionManagerにWALを組み込む

トランザクションごとに TxLogger を持たせます。

@@ -5,4 +5,5 @@ import (
     "github.com/yourname/go-rdbms/sql/catalog"
     "github.com/yourname/go-rdbms/storage/heap"
+    "github.com/yourname/go-rdbms/transaction/log"
     "github.com/yourname/go-rdbms/transaction/txid"
 )
@@ -31,4 +32,5 @@ type Transaction struct {
     status Status
     writes []WriteRecord
+    logger *log.TxLogger
 }
 
@@ -36,4 +38,7 @@ func (tx *Transaction) ID() txid.TxID  { return tx.id }
 func (tx *Transaction) Status() Status { return tx.status }
 
+// Logger はこのトランザクションのヒープ操作をWALに記録するLoggerを返す
+func (tx *Transaction) Logger() heap.Logger { return tx.logger }
+
 // AddWrite は書き込み操作を記録する(ロールバック時に逆順で取り消す)
 func (tx *Transaction) AddWrite(w WriteRecord) {

TransactionManagerは、開始時にBEGIN、コミット時にCOMMIT、ロールバックの完了時にABORTを記録します。

@@ -9,4 +9,5 @@ import (
     "github.com/yourname/go-rdbms/storage/btree"
     "github.com/yourname/go-rdbms/transaction/lock"
+    "github.com/yourname/go-rdbms/transaction/log"
     "github.com/yourname/go-rdbms/transaction/txid"
 )
@@ -14,16 +15,22 @@ import (
 // TransactionManager はトランザクションの開始・コミット・ロールバックを管理する
 type TransactionManager struct {
-    mu      sync.Mutex
-    nextID  txid.TxID
-    active  map[txid.TxID]*Transaction
-    lockMgr *lock.LockManager
+    mu            sync.Mutex
+    cond          *sync.Cond // トランザクションの終了・チェックポイントの完了を知らせる
+    nextID        txid.TxID
+    active        map[txid.TxID]*Transaction
+    lockMgr       *lock.LockManager
+    logMgr        *log.LogManager
+    checkpointing bool
 }
 
-func NewTransactionManager(lockMgr *lock.LockManager) *TransactionManager {
-    return &TransactionManager{
+func NewTransactionManager(lockMgr *lock.LockManager, logMgr *log.LogManager) *TransactionManager {
+    tm := &TransactionManager{
         nextID:  1,
         active:  make(map[txid.TxID]*Transaction),
         lockMgr: lockMgr,
+        logMgr:  logMgr,
     }
+    tm.cond = sync.NewCond(&tm.mu)
+    return tm
 }
 
@@ -33,7 +40,12 @@ func (tm *TransactionManager) Begin() *Transaction {
     defer tm.mu.Unlock()
 
+    for tm.checkpointing {
+        tm.cond.Wait() // チェックポイント中は新しいトランザクションを始めない
+    }
     tx := &Transaction{id: tm.nextID, status: StatusActive}
+    tx.logger = log.NewTxLogger(tm.logMgr, tx.id)
     tm.nextID++
     tm.active[tx.id] = tx
+    tm.logMgr.Append(&log.LogRecord{TxID: tx.id, Type: log.RecordBegin})
     return tx
 }
@@ -41,9 +53,15 @@ func (tm *TransactionManager) Begin() *Transaction {
 // Commit はトランザクションをコミットする
 func (tm *TransactionManager) Commit(tx *Transaction) error {
+    if tx.status != StatusActive {
+        return fmt.Errorf("transaction: TX %d は既に終了しています", tx.id)
+    }
+    // COMMITログをfsyncし終えた時点で、コミットが確定する(Durability)
+    lsn := tm.logMgr.Append(&log.LogRecord{TxID: tx.id, Type: log.RecordCommit})
+    if err := tm.logMgr.FlushTo(lsn); err != nil {
+        return fmt.Errorf("transaction: COMMITログを永続化できません: %w", err)
+    }
     if err := tm.finish(tx, StatusCommitted); err != nil {
         return err
     }
-    // 第12章でここにCOMMITログの書き込みとfsyncが入る
-
     // 厳格2PL: ロックはトランザクションの終了時にまとめて解放する
     tm.lockMgr.ReleaseAll(tx.id)
@@ -66,8 +84,10 @@ func (tm *TransactionManager) Rollback(tx *Transaction) error {
             }
         }
-        if err := w.Table.Heap.DeleteRecord(w.RID); err != nil {
+        // 取り消し操作もWALに記録する(クラッシュ後のREDOで再現できるように)
+        if err := w.Table.Heap.DeleteRecord(tx.logger, w.RID); err != nil {
             return err
         }
     }
+    tm.logMgr.Append(&log.LogRecord{TxID: tx.id, Type: log.RecordAbort})
     if err := tm.finish(tx, StatusAborted); err != nil {
         return err
@@ -88,4 +108,21 @@ func (tm *TransactionManager) finish(tx *Transaction, status Status) error {
     tx.status = status
     delete(tm.active, tx.id)
+    tm.cond.Broadcast()
     return nil
 }
+
+// Checkpoint は新しいトランザクションの開始を止め、実行中のトランザクションが
+// すべて終わるのを待ってから fn(ページの書き出しとログの切り詰め)を実行する
+func (tm *TransactionManager) Checkpoint(fn func() error) error {
+    tm.mu.Lock()
+    defer tm.mu.Unlock()
+
+    tm.checkpointing = true
+    for len(tm.active) > 0 {
+        tm.cond.Wait()
+    }
+    err := fn()
+    tm.checkpointing = false
+    tm.cond.Broadcast()
+    return err
+}

Commit は、COMMITレコードを追加した後、FlushTo でそこまでのログをfsyncしてから、トランザクションを完了扱いにしてロックを放します(ルール2)。一方、ロールバックではfsyncを待ちません。ABORTレコードがディスクに届く前にクラッシュしても、リカバリがそのトランザクションを未完了とみなして取り消し直すだけで、結果は同じだからです。

Checkpoint は、後述のチェックポイントのために、新しいトランザクションの開始を止めて、実行中のトランザクションがすべて終わるのを待つ関数です。

Executorは、ヒープへの挿入にトランザクションの Logger を渡すようにします。

// sql/executor/executor.go(変更部分)
rid, err := table.Heap.InsertRecord(tx.Logger(), data)

リカバリ

ARIESの考え方

クラッシュ後のリカバリ手法として最も有名なのが、IBMのC. Mohanらが1992年に発表したARIES(Algorithm for Recovery and Isolation Exploiting Semantics)です。ARIESのリカバリは次の3つのフェーズからなります。

1. 分析(Analysis): ログを読み、クラッシュ時点で終わっていなかったトランザクション(loser)を洗い出す
2. REDO:             ログを先頭から順に再実行し、クラッシュ直前の状態を再現する
3. UNDO:             loserの変更を、ログの後ろから順に取り消す

ARIESの重要な考え方は、REDOフェーズでコミットしたかどうかに関係なく、すべての変更を再実行することです(repeating history)。いったんクラッシュ直前とまったく同じ状態を作り、そのうえで未完了のトランザクションを通常のロールバックと同じ要領で取り消します。こうすると、REDOとUNDOの役割がはっきり分かれ、ロールバックの途中でクラッシュした場合のような込み入った状況も素直に扱えます。

本シリーズのリカバリも、この3フェーズの構成に従います。ARIESとの主な違いは次のとおりです。

REDOを行う関数

ページ単位でログを再実行する関数を heap パッケージに用意します。どれも、ページLSNがログのLSN以上なら何もしません。

// storage/heap/redo.go
package heap

import (
    "fmt"

    "github.com/yourname/go-rdbms/storage/buffer"
    "github.com/yourname/go-rdbms/storage/page"
)

// ここにあるのは、リカバリ(第12章)でWALを再適用するための関数。
// どれも「ページLSN >= ログのLSN なら、そのページには反映済み」と判断して何もしない。
// そのため同じログを何度適用しても結果は変わらない(冪等)。

// RedoInsert はINSERTのログを再適用する
func RedoInsert(bpm *buffer.BufferPoolManager, rid RID, data []byte, lsn uint64) error {
    p, err := bpm.FetchPage(rid.PageID)
    if err != nil {
        return err
    }
    if p.GetLSN() >= lsn {
        return bpm.UnpinPage(rid.PageID, false)
    }
    sp := page.NewSlottedPage(p)
    // ページはこのログの直前の状態のはずなので、次に割り当てられるスロットが一致する
    if sp.NumSlots() != rid.SlotID {
        bpm.UnpinPage(rid.PageID, false)
        return fmt.Errorf("heap: REDO先のスロットが一致しません(ページ %d: 期待 %d, 実際 %d)",
            rid.PageID, rid.SlotID, sp.NumSlots())
    }
    if _, err := sp.InsertRecord(data); err != nil {
        bpm.UnpinPage(rid.PageID, false)
        return err
    }
    p.SetLSN(lsn)
    return bpm.UnpinPage(rid.PageID, true)
}

// RedoDelete はDELETEのログを再適用する
func RedoDelete(bpm *buffer.BufferPoolManager, rid RID, lsn uint64) error {
    p, err := bpm.FetchPage(rid.PageID)
    if err != nil {
        return err
    }
    if p.GetLSN() >= lsn {
        return bpm.UnpinPage(rid.PageID, false)
    }
    if err := page.NewSlottedPage(p).DeleteRecord(rid.SlotID); err != nil {
        bpm.UnpinPage(rid.PageID, false)
        return err
    }
    p.SetLSN(lsn)
    return bpm.UnpinPage(rid.PageID, true)
}

// RedoNewPage はページ追加のログを再適用する(新しいページの初期化と、元の末尾ページからのリンク)
func RedoNewPage(bpm *buffer.BufferPoolManager, prevPageID, newPageID page.PageID, lsn uint64) error {
    newP, err := bpm.FetchPage(newPageID)
    if err != nil {
        return err
    }
    dirty := false
    if newP.GetLSN() < lsn {
        page.NewSlottedPage(newP).Init()
        newP.SetLSN(lsn)
        dirty = true
    }
    if err := bpm.UnpinPage(newPageID, dirty); err != nil {
        return err
    }

    prevP, err := bpm.FetchPage(prevPageID)
    if err != nil {
        return err
    }
    dirty = false
    if prevP.GetLSN() < lsn {
        page.NewSlottedPage(prevP).SetNextPageID(newPageID)
        prevP.SetLSN(lsn)
        dirty = true
    }
    return bpm.UnpinPage(prevPageID, dirty)
}

RedoInsert では、再実行する直前のページは「このログを書いたときの直前の状態」になっているはずなので、次に割り当てられるスロット番号がログのスロット番号と一致することを確かめています。第5章でスロットを常に末尾に追加し、再利用しないと決めたので、この確認ができます。

RecoveryManager

// transaction/log/recovery.go
package log

import (
    "errors"
    "fmt"

    "github.com/yourname/go-rdbms/storage/buffer"
    "github.com/yourname/go-rdbms/storage/heap"
    "github.com/yourname/go-rdbms/storage/page"
    "github.com/yourname/go-rdbms/transaction/txid"
)

// RecoveryManager は起動時にWALを読み、ヒープを一貫した状態に戻す
type RecoveryManager struct {
    bpm *buffer.BufferPoolManager
    lm  *LogManager
}

func NewRecoveryManager(bpm *buffer.BufferPoolManager, lm *LogManager) *RecoveryManager {
    return &RecoveryManager{bpm: bpm, lm: lm}
}

// Recover はリカバリを行う。前回が正常終了でなかった
// (ログにCHECKPOINT以外のレコードが残っていた)場合は true を返す
func (rm *RecoveryManager) Recover() (bool, error) {
    records, err := rm.lm.ReadAll()
    if err != nil {
        return false, err
    }

    // 1. 分析: 最後まで終わらなかったトランザクション(loser)を洗い出す
    losers := map[txid.TxID]bool{}
    unclean := false
    for _, rec := range records {
        switch rec.Type {
        case RecordCheckpoint:
            continue
        case RecordCommit, RecordAbort:
            delete(losers, rec.TxID)
        default:
            losers[rec.TxID] = true
        }
        unclean = true
    }
    if !unclean {
        return false, nil
    }

    // 2. REDO: コミットしたかどうかに関係なく、すべての変更を先頭から再適用し、
    //    クラッシュ直前のバッファプールの状態を再現する
    for _, rec := range records {
        if err := rm.redo(rec); err != nil {
            return true, fmt.Errorf("recovery: LSN %d のREDOに失敗しました: %w", rec.LSN, err)
        }
    }

    // 3. UNDO: loserが挿入したレコードを、後ろから順に削除する。
    //    削除もログに記録するので、リカバリ中に再びクラッシュしてもやり直せる
    for i := len(records) - 1; i >= 0; i-- {
        rec := records[i]
        if rec.Type != RecordInsert || !losers[rec.TxID] {
            continue
        }
        rid := heap.RID{PageID: rec.PageID, SlotID: rec.SlotID}
        err := heap.DeleteAt(rm.bpm, NewTxLogger(rm.lm, rec.TxID), rid)
        if errors.Is(err, page.ErrRecordDeleted) {
            continue // クラッシュ前のロールバックで既に取り消されていた
        }
        if err != nil {
            return true, fmt.Errorf("recovery: LSN %d のUNDOに失敗しました: %w", rec.LSN, err)
        }
    }
    for tx := range losers {
        rm.lm.Append(&LogRecord{TxID: tx, Type: RecordAbort})
    }
    return true, rm.lm.Flush()
}

func (rm *RecoveryManager) redo(rec *LogRecord) error {
    lsn := uint64(rec.LSN)
    switch rec.Type {
    case RecordInsert:
        return heap.RedoInsert(rm.bpm, heap.RID{PageID: rec.PageID, SlotID: rec.SlotID}, rec.Data, lsn)
    case RecordDelete:
        return heap.RedoDelete(rm.bpm, heap.RID{PageID: rec.PageID, SlotID: rec.SlotID}, lsn)
    case RecordNewPage:
        return heap.RedoNewPage(rm.bpm, rec.PrevPageID, rec.PageID, lsn)
    }
    return nil // BEGIN / COMMIT / ABORT / CHECKPOINT はページを変更しない
}

// Checkpoint はダーティページをすべてデータファイルに書き出してから、ログを空にする
// 実行中のトランザクションがない状態で呼ぶこと(TransactionManager.Checkpoint 経由で呼ぶ)
func Checkpoint(bpm *buffer.BufferPoolManager, lm *LogManager) error {
    if err := bpm.FlushAllPages(); err != nil {
        return err
    }
    return lm.Truncate()
}

UNDOフェーズでは、loserのINSERTを後ろから順に DeleteAt で削除します。削除もログに記録するので、リカバリの途中でもう一度クラッシュしても、次のリカバリで同じ手順をやり直せます。すでに削除済み(ロールバックの途中でクラッシュした場合など)なら ErrRecordDeleted が返るので、飛ばします。最後にloserごとにABORTを記録して、ログをフラッシュします。

インデックスの再構築

B+木の変更はログに記録していないので、クラッシュ後のB+木のページは、どこまで書かれているかわからない状態です。そこでリカバリの後、ヒープファイルの中身からインデックスを作り直します。レコードから主キーの値を取り出すにはスキーマに従ってデコードする必要があるので、この処理は executor パッケージに置きます。

// RebuildIndexes はヒープの内容から、すべてのテーブルの主キーインデックスを作り直す
// インデックスの変更はWALに記録していないので、クラッシュからの復旧後に呼ぶ(第12章)
func RebuildIndexes(cat *catalog.Catalog) error {
    for _, name := range cat.ListTables() {
        table, err := cat.GetTable(name)
        if err != nil {
            return err
        }
        pk := table.Schema.PrimaryKeyIndex()
        if pk < 0 {
            continue
        }
        if err := table.Index.Reset(); err != nil {
            return err
        }
        scanner := table.Heap.Scan()
        for {
            rid, data, ok, err := scanner.Next()
            if err != nil {
                return err
            }
            if !ok {
                break
            }
            values, err := DecodeTuple(data, table.Schema)
            if err != nil {
                return err
            }
            if err := table.Index.Insert(values[pk].Int, rid); err != nil {
                return err
            }
        }
    }
    return nil
}

B+木には、ルートを空の葉に戻す Reset を追加します。第6章でルートのPageIDを固定にしたので、カタログを書き換えずに作り直せます。古いノードのページは解放されずに残りますが、本シリーズでは空きページの管理を実装していないので、そのままにしています。

// storage/btree/btree.go(追加)
// Reset はルートを空の葉ノードに戻して、木を空にする(第12章のインデックス再構築で使う)
// 古いノードのページは解放せずにそのまま残る(空きページの管理は実装していない)
func (t *BPlusTree) Reset() error {
    t.mu.Lock()
    defer t.mu.Unlock()

    p, err := t.bpm.FetchPage(t.rootPageID)
    if err != nil {
        return err
    }
    node{p}.initLeaf()
    return t.bpm.UnpinPage(t.rootPageID, true)
}

起動処理の順番

データベースを開くときは、次の順番で処理します(第13章の db.Open でもこの順番になります)。

1. データファイル・バッファプール・ログマネージャを開き、バッファプールにログのフラッシュ関数を登録
2. リカバリ(分析 → REDO → UNDO)
3. カタログを読み込む(ヒープファイルとB+木を開く)
4. リカバリを行った場合は、インデックスを再構築
5. チェックポイント

カタログの読み込みをリカバリの後にしている点が重要です。heap.OpenHeapFile は、ページのリンクをたどって末尾のページを見つけます。REDOでNEWPAGEが再実行されるとリンクの先にページが増えるので、リカバリの前に開くと、古い末尾のページを覚えてしまいます。その状態で新しい行を挿入すると、REDOで追加されたページへのリンクを上書きしてしまいます。

チェックポイント

ログを無限に伸ばさないために

ログは追記される一方なので、放っておくとどこまでも大きくなり、リカバリのたびに読む量も増えていきます。そこでチェックポイントを取って、「ここより前のログはもう要らない」という地点を作ります。

本シリーズの方法:静止チェックポイント

本シリーズでは、最も単純な**静止チェックポイント(quiescent checkpoint)**を実装します。

1. 新しいトランザクションの開始を止める
2. 実行中のトランザクションがすべて終わるのを待つ
3. ダーティなページをすべてディスクに書き出す(FlushAllPages)
4. ログをCHECKPOINTレコード1件だけに置き換える
5. トランザクションの開始を再開する

手順2・3の後では、コミットしたトランザクションの変更はすべてデータファイルに反映済みで、未完了のトランザクションは存在しません。ログはもう1件も要らないので、捨ててかまいません。手順1・2は TransactionManager.Checkpoint が、手順3・4は log.Checkpoint が担当します。

手順4でログを空のファイルにしてしまわず、CHECKPOINTレコードを1件残しているのには理由があります。データファイル上のページには、これまでのLSNがページLSNとして残っています。ログを空にしてLSNを1から振り直すと、新しいログのLSNがページLSNより小さくなり、リカバリで「反映済み」と誤って判断されてしまいます。CHECKPOINTレコードに続きのLSNを振っておけば、次の起動時にも NewLogManager がその続きから番号を振れます。ログの置き換えは、第9章のカタログと同じく一時ファイルとリネームで行うので、途中でクラッシュしても古いログか新しいログのどちらかが残ります。

チェックポイントは、起動時(リカバリの直後)と、第13章で実装するデータベースのクローズ時に取ります。正常に終了した場合、ログにはCHECKPOINTレコードしか残っていないので、次の起動時にはリカバリを行う必要がありません。

実際のデータベースのチェックポイント

静止チェックポイントは、実行中のトランザクションが終わるまで新しいトランザクションを止めてしまうので、稼働中のシステムでは使えません。PostgreSQLやARIESは、トランザクションを止めずに取れるファジーチェックポイントを使います。ダーティなページを少しずつ書き出しながら、「リカバリはこの位置のログから始めればよい」という情報をチェックポイントレコードに記録しておく方式です。

テストを書く

LogManagerのテスト

LSNが1から振られること、Append しただけではディスクに書かれないこと、開き直すとLSNが続きから振られること、末尾の書きかけのレコードが捨てられることを確かめます(Truncate のテストは省略しています)。

// transaction/log/log_manager_test.go(抜粋)
func TestLogManager_AppendFlushRead(t *testing.T) {
    path := filepath.Join(t.TempDir(), "wal.log")
    lm, err := log.NewLogManager(path)
    if err != nil {
        t.Fatal(err)
    }

    lsn1 := lm.Append(&log.LogRecord{TxID: 1, Type: log.RecordBegin})
    lsn2 := lm.Append(&log.LogRecord{TxID: 1, Type: log.RecordInsert, PageID: 3, SlotID: 7, Data: []byte("hello")})
    lsn3 := lm.Append(&log.LogRecord{TxID: 1, Type: log.RecordCommit})
    if lsn1 != 1 || lsn2 != 2 || lsn3 != 3 {
        t.Fatalf("LSNは1から順に振られるはず: %d, %d, %d", lsn1, lsn2, lsn3)
    }

    // Append しただけではファイルは空のまま
    if info, _ := os.Stat(path); info.Size() != 0 {
        t.Errorf("Append直後にファイルへ書かれている: %dバイト", info.Size())
    }
    if err := lm.FlushTo(lsn3); err != nil {
        t.Fatal(err)
    }
    lm.Close()

    // 開き直すと、レコードが読めて、LSNは続きから振られる
    lm2, err := log.NewLogManager(path)
    if err != nil {
        t.Fatal(err)
    }
    defer lm2.Close()
    records, err := lm2.ReadAll()
    if err != nil {
        t.Fatal(err)
    }
    if len(records) != 3 {
        t.Fatalf("レコード数: want 3, got %d", len(records))
    }
    if r := records[1]; r.Type != log.RecordInsert || r.PageID != 3 || r.SlotID != 7 || string(r.Data) != "hello" {
        t.Errorf("INSERTレコードの中身が不正: %+v", r)
    }
    if lsn := lm2.Append(&log.LogRecord{TxID: 2, Type: log.RecordBegin}); lsn != 4 {
        t.Errorf("再オープン後のLSNは4から: got %d", lsn)
    }
}

func TestLogManager_TornTail(t *testing.T) {
    path := filepath.Join(t.TempDir(), "wal.log")
    lm, _ := log.NewLogManager(path)
    lm.Append(&log.LogRecord{TxID: 1, Type: log.RecordBegin})
    lm.Append(&log.LogRecord{TxID: 1, Type: log.RecordCommit})
    lm.Close()

    // 書き込みの途中でクラッシュしたように、末尾に中途半端なバイト列を足す
    f, _ := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0644)
    f.Write([]byte{0, 0, 0, 40, 1, 2, 3})
    f.Close()

    lm2, err := log.NewLogManager(path)
    if err != nil {
        t.Fatal(err)
    }
    lm2.Append(&log.LogRecord{TxID: 2, Type: log.RecordBegin})
    records, err := lm2.ReadAll()
    if err != nil {
        t.Fatal(err)
    }
    // 壊れた末尾は捨てられ、その位置から新しいレコードが続く
    if len(records) != 3 || records[2].LSN != 3 {
        t.Errorf("壊れた末尾が正しく切り捨てられていない: %d件", len(records))
    }
    lm2.Close()
}

リカバリのテスト

リカバリのテストでは、crash でバッファプールの中身をディスクに書かずにデータファイルを閉じ、プロセスが異常終了した状況を作ります。その後、open で起動処理(リカバリ → カタログ → インデックス再構築 → チェックポイント)を行い、データが正しい状態になっているかを確かめます。

// transaction/log/recovery_test.go(抜粋)
// testDB はデータファイル・カタログ・WALを束ねたテスト用のデータベース
type testDB struct {
    t        *testing.T
    dm       *disk.FileDiskManager
    bpm      *buffer.BufferPoolManager
    lm       *log.LogManager
    tm       *transaction.TransactionManager
    executor *executor.Executor
}

// open は起動処理を行う: リカバリ → カタログの読み込み → インデックス再構築 → チェックポイント
func open(t *testing.T, dir string) *testDB {
    t.Helper()
    dm, err := disk.NewFileDiskManager(filepath.Join(dir, "test.db"))
    if err != nil {
        t.Fatal(err)
    }
    bpm := buffer.NewBufferPoolManager(dm, 16) // 小さめにして追い出しを起こりやすくする
    lm, err := log.NewLogManager(filepath.Join(dir, "wal.log"))
    if err != nil {
        t.Fatal(err)
    }
    bpm.SetLogFlusher(func(lsn uint64) error { return lm.FlushTo(log.LSN(lsn)) })

    recovered, err := log.NewRecoveryManager(bpm, lm).Recover()
    if err != nil {
        t.Fatal(err)
    }
    // カタログはリカバリの後に開く(REDOでヒープにページが増えている場合があるため)
    cat, err := catalog.NewCatalog(bpm, filepath.Join(dir, "catalog.json"))
    if err != nil {
        t.Fatal(err)
    }
    if recovered {
        if err := executor.RebuildIndexes(cat); err != nil {
            t.Fatal(err)
        }
    }
    if err := log.Checkpoint(bpm, lm); err != nil {
        t.Fatal(err)
    }

    lockMgr := lock.NewLockManager()
    return &testDB{
        t: t, dm: dm, bpm: bpm, lm: lm,
        tm:       transaction.NewTransactionManager(lockMgr, lm),
        executor: executor.New(cat, lockMgr),
    }
}

// crash はバッファプールの中身を書き出さずにデータファイルを閉じる(プロセスの異常終了を模す)
func (db *testDB) crash() {
    db.dm.Close()
}

func TestRecovery_CommittedDataSurvivesCrash(t *testing.T) {
    dir := t.TempDir()
    db := open(t, dir)
    db.createUsers()

    tx := db.tm.Begin()
    for i := 1; i <= 300; i++ {
        db.exec(tx, fmt.Sprintf("INSERT INTO users VALUES (%d, 'user%03d-padding-padding-padding')", i, i))
    }
    if err := db.tm.Commit(tx); err != nil {
        t.Fatal(err)
    }
    db.crash() // コミット済みの変更の多くはまだバッファプールの中にしかない

    db2 := open(t, dir)
    if got := len(db2.query("SELECT * FROM users").Rows); got != 300 {
        t.Errorf("コミット済みの行が失われた: want 300, got %d", got)
    }
    // インデックスも再構築されているので、主キーで引ける
    res := db2.query("SELECT name FROM users WHERE id = 123")
    if len(res.Rows) != 1 || res.Rows[0][0].Str != "user123-padding-padding-padding" {
        t.Errorf("インデックス経由の検索結果が不正: %v", res.Rows)
    }
}

func TestRecovery_UncommittedChangesAreUndone(t *testing.T) {
    dir := t.TempDir()
    db := open(t, dir)
    db.createUsers()

    tx1 := db.tm.Begin()
    db.exec(tx1, "INSERT INTO users VALUES (1, 'committed')")
    db.tm.Commit(tx1)

    // TX2 は大量に挿入したがコミットしていない
    tx2 := db.tm.Begin()
    for i := 100; i < 400; i++ {
        db.exec(tx2, fmt.Sprintf("INSERT INTO users VALUES (%d, 'uncommitted-padding-padding-padding')", i))
    }
    // 未コミットの変更がデータファイルに書き出された状態を作る
    if err := db.bpm.FlushAllPages(); err != nil {
        t.Fatal(err)
    }
    db.crash()

    db2 := open(t, dir)
    res := db2.query("SELECT * FROM users")
    if len(res.Rows) != 1 || res.Rows[0][1].Str != "committed" {
        t.Errorf("未コミットの行が残っている: %d件", len(res.Rows))
    }
    // 取り消したキーは再び使える
    tx := db2.tm.Begin()
    db2.exec(tx, "INSERT INTO users VALUES (100, 'reused')")
    db2.tm.Commit(tx)
}

func TestWAL_LogIsFlushedBeforePage(t *testing.T) {
    dir := t.TempDir()
    db := open(t, dir)
    db.createUsers()

    tx := db.tm.Begin()
    db.exec(tx, "INSERT INTO users VALUES (1, 'not committed yet')")

    // コミット前なので、まだINSERTのログはディスクにない
    if countOnDisk(t, dir, log.RecordInsert) != 0 {
        t.Fatal("コミット前なのにINSERTのログがディスクにある")
    }
    // ページを書き出すと、そのページLSNまでのログが先にディスクへ書かれる
    if err := db.bpm.FlushAllPages(); err != nil {
        t.Fatal(err)
    }
    if countOnDisk(t, dir, log.RecordInsert) != 1 {
        t.Error("ページを書き出したのにINSERTのログがディスクにない(WALのルール違反)")
    }
    db.tm.Commit(tx)
}

それぞれのテストが確かめていることは次のとおりです。

テスト 確かめていること
CommittedDataSurvivesCrash コミット済みの変更は、ページがディスクになくてもREDOで復元される(インデックスも再構築される)
UncommittedChangesAreUndone 未コミットの変更は、ページがディスクに書き出されていてもUNDOで取り消される
RollbackBeforeCrash ロールバック(取り消しのDELETEとABORT)もREDOで再現される
Idempotent チェックポイントを挟まずにリカバリを2回実行しても、結果は変わらない
LogIsFlushedBeforePage ページを書き出すと、その前にログがディスクに書かれる(ルール1)
go test ./transaction/log/... -v

期待される出力(=== RUN の行は省略):

--- PASS: TestLogManager_AppendFlushRead (0.03s)
--- PASS: TestLogManager_TornTail (0.02s)
--- PASS: TestLogManager_Truncate (0.03s)
--- PASS: TestRecovery_CommittedDataSurvivesCrash (0.06s)
--- PASS: TestRecovery_UncommittedChangesAreUndone (0.08s)
--- PASS: TestRecovery_RollbackBeforeCrash (0.05s)
--- PASS: TestRecovery_Idempotent (0.07s)
--- PASS: TestWAL_LogIsFlushedBeforePage (0.05s)
PASS
ok      github.com/yourname/go-rdbms/transaction/log   0.625s

試しに RecoveryManager.redo とUNDOのループを何もしないように書き換えてテストを実行すると、リカバリの4つのテストが失敗します。テストが実際にリカバリの効果を確かめていることを、こうして確認しておくと安心です。

この実装の制限

ここまでのコード全体像

go-rdbms/
├── storage/
│   ├── page/page.go               ← ページLSN(本章)
│   ├── buffer/                    ← 書き出し前にログをフラッシュ(本章)
│   ├── heap/
│   │   ├── heap_file.go           ← 変更をログに記録(本章)
│   │   └── redo.go                ← REDO用の関数(本章)
│   └── btree/btree.go             ← Reset(本章)
├── sql/
│   └── executor/executor.go       ← Loggerの受け渡し・インデックス再構築(本章)
└── transaction/
    ├── txid/  lock/
    ├── log/
    │   ├── log_record.go          ← ログレコード(本章)
    │   ├── log_manager.go         ← 追記・フラッシュ・読み出し・切り詰め(本章)
    │   ├── tx_logger.go           ← トランザクションごとのLogger(本章)
    │   └── recovery.go            ← リカバリ・チェックポイント(本章)
    ├── transaction.go
    └── transaction_manager.go     ← BEGIN・COMMIT・ABORTの記録(本章)

WALとリカバリの設計で意識したポイント

① 書く順序で正しさを保証する

「ページより先にログ」「コミット完了より先にログ」という2つの順序さえ守れば、どのタイミングでクラッシュしても、ログからデータを正しい状態に戻せます。

② ページLSNでREDOを冪等にする

ページLSNとログのLSNを比べるだけで、変更が反映済みかどうかを判断できます。同じログを何度再実行しても結果は変わらないので、リカバリ中のクラッシュにも耐えられます。

③ まず歴史を再現してから、取り消す

コミット済みかどうかに関係なく、すべての変更をいったん再実行してから、未完了のトランザクションを取り消します。取り消し操作もログに記録するので、何度中断しても同じ手順でやり直せます。

④ 記録しにくいものは作り直す

B+木の分割のように記録と再実行が難しい変更は、ログに残さず、リカバリ後にヒープから作り直すことにしました。正しさを保ちながら、実装の量を抑えています。

まとめ

次章では、ここまで作ってきたすべてのコンポーネントを1つにまとめ、CLIから対話的にSQLを実行できるデータベースを完成させます。実際にプロセスを強制終了してリカバリを確かめたり、ベンチマークでボトルネックを探したりもします。