feat(wal): add RecoveryException and RecoveryManager
This commit is contained in:
1 parent
ade22da256
commit
945d7a9be0
1 file changed
+163
@@ -0,0 +1,163 @@
|
|||||||
|
package kestrel.store.wal
|
||||||
|
|
||||||
|
import kestrel.KestrelException
|
||||||
|
import kestrel.store.page.DiskManager
|
||||||
|
import kestrel.store.page.Page
|
||||||
|
|
||||||
|
class RecoveryException(message: String) : KestrelException(message)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ARIES-lite recovery over a [WriteAheadLog] of whole-page physical images.
|
||||||
|
*
|
||||||
|
* Because every [LogRecord.Update] carries the exact after-image a page had
|
||||||
|
* once that write completed, replaying every update from the last
|
||||||
|
* checkpoint forward in LSN order reproduces the pre-crash contents exactly
|
||||||
|
* - no page-LSN comparison is needed to make redo idempotent, unlike
|
||||||
|
* classic ARIES with incremental (delta) logging. The tradeoff is a bigger
|
||||||
|
* log: every logged write carries a full page image rather than a diff.
|
||||||
|
*
|
||||||
|
* Undo walks each unresolved transaction's [LogRecord.Update] chain
|
||||||
|
* backward via `prevLsn`, restoring the before-image at each step. A crash
|
||||||
|
* partway through undoing a transaction requires re-running undo for it
|
||||||
|
* from the start on the next recovery pass ([recover]'s forward redo pass
|
||||||
|
* reproduces whatever partial undo already happened before the crash, via
|
||||||
|
* any [LogRecord.CompensationUpdate] records it wrote, and the backward undo
|
||||||
|
* pass then continues from there) rather than resuming partway through a
|
||||||
|
* still-in-flight undo.
|
||||||
|
*
|
||||||
|
* [rollbackTo] undoes only a *suffix* of a transaction's updates, back to a
|
||||||
|
* savepoint LSN captured earlier in the same transaction, and writes a
|
||||||
|
* [LogRecord.CompensationUpdate] for each [LogRecord.Update] it undoes. A
|
||||||
|
* later full [rollback] (or a rollback to an earlier savepoint) that walks
|
||||||
|
* back through a CLR does not undo it again - it follows [LogRecord.CompensationUpdate.undoNextLsn]
|
||||||
|
* straight past the range it already compensated for. This is the classic
|
||||||
|
* ARIES technique for making undo of undo safe without needing a full CLR
|
||||||
|
* chain of its own.
|
||||||
|
*
|
||||||
|
* Redo only ever targets pages already allocated in [diskManager]; page
|
||||||
|
* allocation itself is not logged in this scope, so the log can only
|
||||||
|
* restore content, not resurrect a page directory lost mid-crash.
|
||||||
|
*
|
||||||
|
* Because [rollback] restores before-images with plain physical writes
|
||||||
|
* rather than logging its own compensation records, a transaction that
|
||||||
|
* finished with an [LogRecord.Abort] before this recovery pass ran already
|
||||||
|
* has the correct (pre-write) bytes on disk. Blindly redoing every
|
||||||
|
* [LogRecord.Update] forward in LSN order - as classic ARIES redo does,
|
||||||
|
* since it always has CLRs to make that safe - would reapply that
|
||||||
|
* transaction's after-images and resurrect data it rolled back. [recover]
|
||||||
|
* therefore runs an analysis pass first to learn each transaction's final
|
||||||
|
* disposition, then skips redoing updates belonging to one that aborted.
|
||||||
|
*/
|
||||||
|
class RecoveryManager(private val wal: WriteAheadLog, private val diskManager: DiskManager) {
|
||||||
|
|
||||||
|
class Result(val redoneUpdates: Int, val rolledBackTxns: List<Long>)
|
||||||
|
|
||||||
|
fun recover(): Result {
|
||||||
|
val startLsn = if (wal.lastCheckpointLsn == WriteAheadLog.NO_LSN) 0L else wal.lastCheckpointLsn
|
||||||
|
val history = wal.readFrom(startLsn)
|
||||||
|
|
||||||
|
val aborted = HashSet<Long>()
|
||||||
|
for ((_, record) in history) {
|
||||||
|
if (record is LogRecord.Abort) aborted.add(record.txnId)
|
||||||
|
}
|
||||||
|
|
||||||
|
val activeTxns = LinkedHashMap<Long, Long>()
|
||||||
|
var redone = 0
|
||||||
|
|
||||||
|
for ((lsn, record) in history) {
|
||||||
|
when (record) {
|
||||||
|
is LogRecord.Begin -> activeTxns[record.txnId] = lsn
|
||||||
|
is LogRecord.Update -> {
|
||||||
|
activeTxns[record.txnId] = lsn
|
||||||
|
if (record.txnId !in aborted) {
|
||||||
|
applyImage(record.pageId, record.after)
|
||||||
|
redone++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is LogRecord.CompensationUpdate -> {
|
||||||
|
activeTxns[record.txnId] = lsn
|
||||||
|
if (record.txnId !in aborted) {
|
||||||
|
applyImage(record.pageId, record.undoImage)
|
||||||
|
redone++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is LogRecord.Commit -> activeTxns.remove(record.txnId)
|
||||||
|
is LogRecord.Abort -> activeTxns.remove(record.txnId)
|
||||||
|
is LogRecord.Checkpoint -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val rolledBack = activeTxns.keys.toList()
|
||||||
|
for (txnId in rolledBack) rollback(txnId, activeTxns.getValue(txnId))
|
||||||
|
diskManager.fsync()
|
||||||
|
return Result(redone, rolledBack)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rolls back a transaction by walking its update chain backward from
|
||||||
|
* [fromLsn] (defaulting to the tail this [wal] instance has tracked for
|
||||||
|
* a still-live transaction) and restoring each before-image. Also used
|
||||||
|
* by [recover] to undo transactions left active by a crash.
|
||||||
|
*/
|
||||||
|
fun rollback(txnId: Long, fromLsn: Long = wal.lastLsnOf(txnId)) {
|
||||||
|
require(fromLsn != WriteAheadLog.NO_LSN) { "transaction $txnId has no recorded updates" }
|
||||||
|
var lsn = fromLsn
|
||||||
|
while (lsn != WriteAheadLog.NO_LSN) {
|
||||||
|
when (val record = wal.readAt(lsn)) {
|
||||||
|
is LogRecord.Update -> {
|
||||||
|
applyImage(record.pageId, record.before)
|
||||||
|
lsn = record.prevLsn
|
||||||
|
}
|
||||||
|
is LogRecord.CompensationUpdate -> lsn = record.undoNextLsn
|
||||||
|
is LogRecord.Begin -> lsn = WriteAheadLog.NO_LSN
|
||||||
|
else -> throw RecoveryException("unexpected record in undo chain for txn $txnId at lsn $lsn")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wal.markAborted(txnId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Undoes [txnId]'s updates back to [savepointLsn] - the LSN [WriteAheadLog.lastLsnOf] reported for
|
||||||
|
* [txnId] at the moment the savepoint was established - without resolving the transaction: it
|
||||||
|
* remains active, can accumulate further writes, and can later be committed, fully rolled back, or
|
||||||
|
* rolled back to another savepoint. Each undone [LogRecord.Update] gets a
|
||||||
|
* [LogRecord.CompensationUpdate] so this is safe to call again (e.g. a second rollback to the same
|
||||||
|
* savepoint after more writes) and safe to crash through (see the class doc).
|
||||||
|
*
|
||||||
|
* Walking backward from the transaction's current tail, a [LogRecord.CompensationUpdate] already on
|
||||||
|
* the chain (written by an earlier, deeper savepoint rollback) is skipped via [LogRecord.CompensationUpdate.undoNextLsn]
|
||||||
|
* rather than undone again - the range it compensates for is already at its pre-savepoint bytes.
|
||||||
|
*/
|
||||||
|
fun rollbackTo(txnId: Long, savepointLsn: Long) {
|
||||||
|
var lsn = wal.lastLsnOf(txnId)
|
||||||
|
while (lsn != savepointLsn) {
|
||||||
|
if (lsn == WriteAheadLog.NO_LSN) {
|
||||||
|
throw RecoveryException("savepoint lsn $savepointLsn is not on transaction $txnId's undo chain")
|
||||||
|
}
|
||||||
|
when (val record = wal.readAt(lsn)) {
|
||||||
|
is LogRecord.Update -> {
|
||||||
|
applyImage(record.pageId, record.before)
|
||||||
|
wal.logCompensation(txnId, record.pageId, record.before, record.prevLsn)
|
||||||
|
lsn = record.prevLsn
|
||||||
|
}
|
||||||
|
is LogRecord.CompensationUpdate -> lsn = record.undoNextLsn
|
||||||
|
is LogRecord.Begin -> throw RecoveryException(
|
||||||
|
"savepoint lsn $savepointLsn is not on transaction $txnId's undo chain"
|
||||||
|
)
|
||||||
|
else -> throw RecoveryException("unexpected record in undo chain for txn $txnId at lsn $lsn")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyImage(pageId: Long, image: ByteArray) {
|
||||||
|
if (pageId >= diskManager.allocatedPageCount()) {
|
||||||
|
throw RecoveryException(
|
||||||
|
"page $pageId referenced by the log was never allocated; " +
|
||||||
|
"this recovery scope only redoes updates to already-allocated pages"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val page = Page(pageId)
|
||||||
|
System.arraycopy(image, 0, page.data, 0, image.size)
|
||||||
|
diskManager.writePage(page)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user