I've been thinking about this a bit more too. I think it would make sense to have a class WriteAheadLog that owns the log file format, and encapsulates the logic that is currently spread out and somewhat duplicated across ApplyLog and WriteBatchSync.
Given the size of the change and that this doesn't seem blocking for this PR, I briefly tried implementing this with my LLM, and came up with the below interface (LLM code prototype, supervised but only lightly reviewed). Leaving it here for follow-up/reference mostly.
Some key attributes:
WriteAheadLog::Open() (+ private ctor) automatically does the dry-run check, making the class ~correct-by-construction (ignoring in-flight disk corruption)
TraverseLog function is used by Open() (with no-op side-effect) as well as by ApplyLog to avoid traversal logic duplication
WriteAheadLog& operator<<(const WalRecord& record); allows streaming records into the log idiomatically (log << WalRecord{ValueType::BLOCK_FILE_INFO, CalculateBlockFilesPos(file), serialize(BlockFileInfoWrapper{info})};). This requires making the log file format flat (i.e. each record contains its type, as opposed to grouping everything). Users can still minimize file open/closes by grouping the records they stream, but it's no longer enforced by the code.
<details>
<summary> WriteAheadLog interfce</summary>
//! A single record to write to the log: a typed, positioned payload. On disk it
//! is laid out flat as <type> <record bytes> <position> <crc32c>; the checksum
//! covers the record bytes and position.
struct WalRecord {
ValueType type;
int64_t pos; //!< absolute target offset in the data file for `type`
std::vector<std::byte> bytes; //!< serialized record, without position or checksum
};
//! Encapsulates the write-ahead log format and integrity. The log is a flat
//! stream of self-describing records terminated by a rolling crc32c over them
//! all; each record also carries its own crc32c. There is no grouping on disk:
//! records are independent positioned writes, and the order only matters to the
//! applier, which batches consecutive records targeting the same file.
//!
//! Records are streamed straight to disk via operator<<, so building a log never
//! holds more than one serialized record in memory. Open() replays an existing
//! log only after a full integrity dry-run, so a WriteAheadLog instance is always
//! backed by a healthy, complete log on disk. Mapping a ValueType to a target
//! file and applying the writes is the caller's responsibility.
class WriteAheadLog
{
fs::path m_path;
//! Held only while writing (AutoFile is not movable, so own it indirectly);
//! readers reopen m_path locally.
std::unique_ptr<AutoFile> m_file;
uint32_t m_rolling_checksum{0};
explicit WriteAheadLog(fs::path path, std::unique_ptr<AutoFile> file = nullptr)
: m_path{std::move(path)}, m_file{std::move(file)} {}
//! Walk the log, verifying every per-record crc32c and the trailing rolling
//! crc32c. on_record(type, pos, record) is called for each record; `record`
//! views a buffer reused across records (do not retain it). Returns false on
//! any incompleteness or corruption (short read, crc mismatch); never throws
//! on corruption. Allocates only the single reused read buffer.
static bool TraverseLog(const fs::path& path, const std::function<void(ValueType, int64_t, std::span<const std::byte>)>& on_record);
public:
//! Closes an un-committed file handle (e.g. on a mid-write exception) so the
//! AutoFile written-but-open contract is not violated during stack unwind.
~WriteAheadLog();
WriteAheadLog(WriteAheadLog&&) = default;
WriteAheadLog(const WriteAheadLog&) = delete;
WriteAheadLog& operator=(const WriteAheadLog&) = delete;
WriteAheadLog& operator=(WriteAheadLog&&) = delete;
//! Open a fresh log for writing.
static WriteAheadLog Create(const fs::path& path);
//! Stream one record to the log: serialized, checksummed and written immediately.
WriteAheadLog& operator<<(const WalRecord& record);
//! Write the rolling checksum, fsync and close. After this the log is durable.
void Commit();
//! Open and integrity-check an existing log. Returns nullopt and removes the
//! file if it is absent, incomplete or corrupt (safe to ignore).
static std::optional<WriteAheadLog> Open(const fs::path& path);
//! Replay validated records in write order. `record` views a reused buffer
//! (do not retain it). Throws if corruption is detected (not expected after
//! a successful Open()).
void ForEachRecord(const std::function<void(ValueType, int64_t pos, std::span<const std::byte>)>& fn) const;
//! Delete the log file.
void Remove() const;
};
</details>
<details>
<summary>git diff on 0c08cd4f62</summary>
diff --git a/src/kernel/blocktreestorage.cpp b/src/kernel/blocktreestorage.cpp
index f2afd9285b..e30352b188 100644
--- a/src/kernel/blocktreestorage.cpp
+++ b/src/kernel/blocktreestorage.cpp
@@ -117,6 +117,114 @@ static AutoFile OpenFile(const fs::path& path, std::string_view mode)
return AutoFile{file.release()};
}
+WriteAheadLog WriteAheadLog::Create(const fs::path& path)
+{
+ return WriteAheadLog{path, std::make_unique<AutoFile>(OpenFile(path, "wb").release())};
+}
+
+WriteAheadLog& WriteAheadLog::operator<<(const WalRecord& record)
+{
+ // On disk: <type> <record bytes> <position> <crc32c>. The checksum covers the
+ // record bytes and position (not the type, which only selects the size).
+ DataStream stream;
+ stream << std::span{record.bytes};
+ stream << record.pos;
+ const uint32_t checksum{crc32c::Crc32c(UCharCast(stream.data()), stream.size())};
+ m_rolling_checksum = crc32c::Extend(m_rolling_checksum, UCharCast(stream.data()), stream.size());
+
+ WriteValueType(*m_file, record.type);
+ *m_file << std::span{stream};
+ *m_file << checksum;
+ return *this;
+}
+
+void WriteAheadLog::Commit()
+{
+ *m_file << m_rolling_checksum;
+ if (!m_file->Commit()) {
+ throw BlockTreeStoreError(strprintf("Failed to commit write to log file %s", PathToString(m_path)));
+ }
+ DirectoryCommit(m_path.parent_path());
+ if (m_file->fclose() != 0) {
+ throw BlockTreeStoreError(strprintf("Failed to close after write to log file %s", PathToString(m_path)));
+ }
+ m_file.reset(); // closed cleanly; nothing for the destructor to do
+}
+
+WriteAheadLog::~WriteAheadLog()
+{
+ // A still-open file means the log was abandoned before Commit() (e.g. an
+ // exception mid-write). Close it so AutoFile's written-but-open assertion is
+ // not tripped during unwind; the partial log on disk is rejected on Open().
+ if (m_file && !m_file->IsNull()) (void)m_file->fclose();
+}
+
+void WriteAheadLog::Remove() const
+{
+ fs::remove(m_path);
+}
+
+bool WriteAheadLog::TraverseLog(const fs::path& path, const std::function<void(ValueType, int64_t, std::span<const std::byte>)>& on_record)
+{
+ auto file{OpenFile(path, "rb")};
+ file.seek(0, SEEK_END);
+ const int64_t records_end{file.tell() - static_cast<int64_t>(CHECKSUM_SIZE)}; // rolling crc occupies the final bytes
+ if (records_end < 0) return false;
+ file.seek(0, SEEK_SET);
+
+ uint32_t re_rolling_checksum{0};
+ std::vector<std::byte> buffer; // reused across records; grows to the largest record size
+ try {
+ // Records run until the trailing rolling checksum; each is self-delimiting
+ // via its leading type byte.
+ while (file.tell() < records_end) {
+ const ValueType value_type{ReadValueType(file)};
+ const uint8_t type_size{SizeFromValueType(value_type)};
+ const uint32_t payload_size = type_size + FILE_POSITION_SIZE; // record bytes + position
+ buffer.resize(payload_size);
+ file.read(buffer);
+ const uint32_t re_checksum{crc32c::Crc32c(UCharCast(buffer.data()), payload_size)};
+ re_rolling_checksum = crc32c::Extend(re_rolling_checksum, UCharCast(buffer.data()), payload_size);
+ uint32_t checksum;
+ file >> checksum;
+ if (checksum != re_checksum) return false;
+
+ int64_t pos;
+ SpanReader{std::span{buffer}.last(FILE_POSITION_SIZE)} >> pos;
+ on_record(value_type, pos, std::span{buffer}.first(type_size));
+ }
+ // The cursor should now sit exactly at the rolling checksum.
+ if (file.tell() != records_end) return false;
+ uint32_t rolling_checksum;
+ file >> rolling_checksum;
+ return rolling_checksum == re_rolling_checksum;
+ } catch (const std::ios_base::failure&) {
+ return false;
+ }
+}
+
+std::optional<WriteAheadLog> WriteAheadLog::Open(const fs::path& path)
+{
+ if (!fs::exists(path)) return std::nullopt;
+
+ // Integrity dry run: a WriteAheadLog only exists for a complete, valid log.
+ if (!TraverseLog(path, [](ValueType, int64_t, std::span<const std::byte>) {})) {
+ LogDebug(BCLog::BLOCKSTORAGE, "Corrupt or incomplete blocktree store log file found. Will not apply log.");
+ fs::remove(path);
+ return std::nullopt;
+ }
+ return WriteAheadLog{path};
+}
+
+void WriteAheadLog::ForEachRecord(const std::function<void(ValueType, int64_t, std::span<const std::byte>)>& fn) const
+{
+ // Open() already validated the log; corruption now is unexpected (e.g. the
+ // file changed underneath us) and is fatal.
+ if (!TraverseLog(m_path, fn)) {
+ throw BlockTreeStoreError("Detected on-disk file corruption.");
+ }
+}
+
void BlockTreeStore::CheckMagicAndVersion() const
{
AssertLockHeld(m_mutex);
@@ -313,119 +421,61 @@ bool BlockTreeStore::ApplyLog() const
{
AssertLockHeld(m_mutex);
- if (!fs::exists(m_log_file_path)) {
- return false;
- }
-
- auto log_file{OpenFile(m_log_file_path, "rb")};
-
- uint32_t re_rolling_checksum = 0;
- uint32_t rolling_checksum = 0;
- uint32_t number_of_types = 0;
-
- // Do a dry run to check the integrity of the log file. This should prevent corrupting the data with a corrupt/incomplete log
- try {
- log_file >> number_of_types;
- for (uint32_t i = 0; i < number_of_types; i++) {
- ValueType value_type{ReadValueType(log_file)};
- uint8_t type_size{SizeFromValueType(value_type)};
- uint32_t entry_size = type_size + FILE_POSITION_SIZE;
- uint64_t num_iterations;
- log_file >> num_iterations;
-
- std::vector<std::byte> buffer;
- buffer.resize(entry_size);
-
- for (uint64_t j = 0; j < num_iterations; j++) {
- log_file.read(buffer);
-
- uint32_t re_checksum = crc32c::Crc32c(UCharCast(buffer.data()), entry_size);
- re_rolling_checksum = crc32c::Extend(re_rolling_checksum, UCharCast(buffer.data()), entry_size);
- uint32_t checksum;
- log_file >> checksum;
- if (checksum != re_checksum) {
- LogDebug(BCLog::BLOCKSTORAGE, "Found invalid entry in blocktree store log file. Will not apply log.");
- (void)log_file.fclose();
- fs::remove(m_log_file_path);
- return false;
- }
- }
+ // Open() performs the integrity dry run; a missing/incomplete/corrupt log is
+ // discarded and yields nullopt.
+ auto log{WriteAheadLog::Open(m_log_file_path)};
+ if (!log) return false;
+
+ // Apply each record to its target data file: seek to the recorded absolute
+ // position and write the record with its (record+pos) checksum. The target
+ // file is kept open across a run of records hitting the same file and only
+ // committed/closed when the target file changes (or at the end).
+ std::optional<AutoFile> data_file;
+ fs::path open_path;
+ bool simulated_crash{false};
+
+ auto finish_file{[&] {
+ if (!data_file) return;
+ if (!data_file->Commit()) {
+ throw BlockTreeStoreError(strprintf("Failed to commit write to data file %s", PathToString(open_path)));
}
-
- log_file >> rolling_checksum;
- if (rolling_checksum != re_rolling_checksum) {
- LogDebug(BCLog::BLOCKSTORAGE, "Found incomplete blocktree store log file. Will not apply log.");
- (void)log_file.fclose();
- fs::remove(m_log_file_path);
- return false;
+ if (data_file->fclose() != 0) {
+ throw BlockTreeStoreError(strprintf("Failed to close after write to data file %s", PathToString(open_path)));
+ }
+ data_file.reset();
+ }};
+
+ // Records targeting one file are contiguous (see WriteBatchSync), so each
+ // target file is opened and fsynced once; reordering would be correct but slow.
+ log->ForEachRecord([&](ValueType type, int64_t pos, std::span<const std::byte> record) {
+ if (simulated_crash) return;
+ const fs::path& target{GetDataFilePath(type)};
+ if (!data_file || open_path != target) {
+ finish_file();
+ open_path = target;
+ data_file.emplace(OpenFile(target, "rb+").release());
}
- } catch (const std::ios_base::failure& e) {
- LogDebug(BCLog::BLOCKSTORAGE, "Corrupt or incomplete log file found, not applying: %s", e.what());
- (void)log_file.fclose();
- fs::remove(m_log_file_path);
- return false;
- }
-
- re_rolling_checksum = 0;
- log_file.seek(sizeof(uint32_t), SEEK_SET);
-
- // Run through the file again, but this time write it to the target data file.
- for (uint32_t i = 0; i < number_of_types; ++i) {
- ValueType value_type = ReadValueType(log_file);
- auto data_file_path = GetDataFilePath(value_type);
- auto data_file{OpenFile(data_file_path, "rb+")};
- uint8_t type_size{SizeFromValueType(value_type)};
- uint32_t entry_size = type_size + FILE_POSITION_SIZE;
-
- uint64_t num_iterations;
- log_file >> num_iterations;
-
- std::vector<std::byte> buffer;
- buffer.resize(entry_size);
- for (uint64_t j = 0; j < num_iterations; ++j) {
- log_file.read(buffer);
- SpanReader entry_reader{buffer};
- entry_reader.ignore(type_size);
- int64_t pos;
- entry_reader >> pos;
+ DataStream stream;
+ stream << record;
+ stream << pos;
+ const uint32_t checksum{crc32c::Crc32c(UCharCast(stream.data()), stream.size())};
- uint32_t re_checksum = crc32c::Crc32c(UCharCast(buffer.data()), entry_size);
- re_rolling_checksum = crc32c::Extend(re_rolling_checksum, UCharCast(buffer.data()), entry_size);
- uint32_t checksum;
- log_file >> checksum;
- if (re_checksum != checksum) {
- throw BlockTreeStoreError("Detected on-disk file corruption.");
- }
-
- if (data_file.tell() != pos) {
- data_file.seek(pos, SEEK_SET);
- }
-
- data_file << std::span<std::byte>{buffer.data(), type_size};
- data_file << checksum;
-
- // TEST ONLY
- if (m_incomplete_log_apply) {
- (void)data_file.fclose();
- return false;
- }
- }
+ if (data_file->tell() != pos) data_file->seek(pos, SEEK_SET);
+ *data_file << record;
+ *data_file << checksum;
- if (!data_file.Commit()) {
- throw BlockTreeStoreError(strprintf("Failed to commit write to data file %s", PathToString(data_file_path)));
- }
- if (data_file.fclose() != 0) {
- throw BlockTreeStoreError(strprintf("Failed to close after write to data file %s", PathToString(data_file_path)));
+ // TEST ONLY: simulate a crash mid-apply, leaving the log to be re-applied.
+ if (m_incomplete_log_apply) {
+ (void)data_file->fclose();
+ data_file.reset();
+ simulated_crash = true;
}
- }
-
- if (rolling_checksum != re_rolling_checksum) {
- throw BlockTreeStoreError("Detected on-disk file corruption.");
- }
+ });
+ if (simulated_crash) return false;
+ finish_file();
- (void)log_file.fclose();
- fs::remove(m_log_file_path);
+ log->Remove();
return true;
}
@@ -438,98 +488,55 @@ void BlockTreeStore::WriteBatchSync(const std::vector<std::pair<int, const CBloc
// This may occur if a previous write threw an exception when writing the logged data to the .dat files.
if (fs::exists(m_log_file_path)) (void)ApplyLog();
- std::vector<std::pair<CBlockIndex*, int64_t>> pending_header_positions;
- pending_header_positions.reserve(blockinfo.size());
-
- // Use a write-ahead log file that gets atomically flushed to the target files.
+ auto serialize{[](auto&& wrapper) {
+ DataStream s;
+ s << wrapper;
+ return std::vector<std::byte>{s.begin(), s.end()};
+ }};
- { // start log_file scope
- auto log_file{OpenFile(m_log_file_path, "wb")};
+ auto log{WriteAheadLog::Create(m_log_file_path)};
- constexpr size_t block_index_entry_size{DISK_BLOCK_INDEX_WRAPPER_SIZE + FILE_POSITION_SIZE};
+ // Records are written grouped by target file so the applier batches them; the
+ // log format itself imposes no ordering. Last block file number first:
+ log << WalRecord{ValueType::LAST_BLOCK, BLOCK_FILES_LAST_BLOCK_POS, serialize(last_file)};
- DataStream stream;
- stream.reserve(block_index_entry_size);
- uint32_t rolling_checksum = 0;
-
- log_file << uint32_t{3}; // We are writing three different types to the log file for now.
-
- // Write the last block file number to the log
- WriteValueType(log_file, ValueType::LAST_BLOCK);
- log_file << uint64_t{1}; // just the one entry
- stream << last_file;
- stream << BLOCK_FILES_LAST_BLOCK_POS;
- uint32_t checksum = crc32c::Crc32c(UCharCast(stream.data()), stream.size());
- rolling_checksum = crc32c::Extend(rolling_checksum, UCharCast(stream.data()), stream.size());
- log_file << std::span<std::byte>{stream};
- log_file << checksum;
- stream.clear();
-
- // Write the fileInfo entries to the log
- WriteValueType(log_file, ValueType::BLOCK_FILE_INFO);
- log_file << uint64_t{fileInfo.size()};
- constexpr size_t block_file_entry_size{BLOCK_FILE_INFO_WRAPPER_SIZE + FILE_POSITION_SIZE};
+ // Block file info records, positioned by file number.
for (const auto& [file, info] : fileInfo) {
- int64_t pos{CalculateBlockFilesPos(file)};
- stream << BlockFileInfoWrapper{info};
- stream << pos;
- checksum = crc32c::Crc32c(UCharCast(stream.data()), block_file_entry_size);
- rolling_checksum = crc32c::Extend(rolling_checksum, UCharCast(stream.data()), block_file_entry_size);
- log_file.write(stream);
- log_file << checksum;
- stream.clear();
+ log << WalRecord{ValueType::BLOCK_FILE_INFO, CalculateBlockFilesPos(file), serialize(BlockFileInfoWrapper{info})};
}
- // TEST ONLY
+ // TEST ONLY: simulate a crash mid-write. The log on disk is missing its
+ // remaining records and rolling checksum, so Open()'s dry run rejects it.
if (m_incomplete_log_write) {
- (void)log_file.fclose();
throw std::runtime_error("failed to write file");
}
- // Read the header data end position
+ // Block index records. New entries (header_pos == 0) are appended at the end
+ // of headers.dat; their positions are recorded in memory only after commit.
int64_t header_data_end;
{
auto header_file{OpenFile(m_header_file_path, "rb")};
header_file.seek(0, SEEK_END);
header_data_end = header_file.tell();
}
-
- // Write the header data to the log
- WriteValueType(log_file, ValueType::DISK_BLOCK_INDEX);
- log_file << uint64_t{blockinfo.size()};
-
+ std::vector<std::pair<CBlockIndex*, int64_t>> pending_header_positions;
+ pending_header_positions.reserve(blockinfo.size());
for (CBlockIndex* bi : blockinfo) {
- int64_t pos = bi->header_pos == 0 ? header_data_end : bi->header_pos;
- auto disk_bi{CDiskBlockIndex{bi}};
- stream << DiskBlockIndexWrapper{&disk_bi};
- stream << pos;
- checksum = crc32c::Crc32c(UCharCast(stream.data()), block_index_entry_size);
- rolling_checksum = crc32c::Extend(rolling_checksum, UCharCast(stream.data()), block_index_entry_size);
- log_file.write(stream);
- log_file << checksum;
- stream.clear();
+ const int64_t pos{bi->header_pos == 0 ? header_data_end : bi->header_pos};
if (bi->header_pos == 0) {
pending_header_positions.emplace_back(bi, header_data_end);
header_data_end += DISK_BLOCK_INDEX_WRAPPER_SIZE + CHECKSUM_SIZE;
}
+ auto disk_bi{CDiskBlockIndex{bi}};
+ log << WalRecord{ValueType::DISK_BLOCK_INDEX, pos, serialize(DiskBlockIndexWrapper{&disk_bi})};
}
- // Finally write the rolling checksum and commit.
- log_file << rolling_checksum;
- if (!log_file.Commit()) {
- throw BlockTreeStoreError(strprintf("Failed to commit write to log file %s", PathToString(m_log_file_path)));
- }
- DirectoryCommit(m_log_file_path.parent_path());
+ log.Commit();
- // Once committed, apply the header positions to the index and close the file.
+ // Once committed, apply the header positions to the index.
for (const auto& [block_index, header_pos] : pending_header_positions) {
block_index->header_pos = header_pos;
}
- if (log_file.fclose() != 0) {
- throw BlockTreeStoreError(strprintf("Failed to close after write to log file %s", PathToString(m_log_file_path)));
- }
-
- } // end log_file scope
if (!ApplyLog()) {
throw BlockTreeStoreError("Failed to apply write-ahead log to data files");
diff --git a/src/kernel/blocktreestorage.h b/src/kernel/blocktreestorage.h
index 0b49839e1c..210a213223 100644
--- a/src/kernel/blocktreestorage.h
+++ b/src/kernel/blocktreestorage.h
@@ -11,8 +11,12 @@
#include <sync.h>
#include <util/fs.h>
+#include <cstddef>
#include <cstdint>
#include <functional>
+#include <memory>
+#include <optional>
+#include <span>
#include <stdexcept>
#include <string>
#include <string_view>
@@ -69,6 +73,71 @@ public:
explicit BlockTreeStoreError(const std::string& msg) : std::runtime_error(msg) {}
};
+//! A single record to write to the log: a typed, positioned payload. On disk it
+//! is laid out flat as <type> <record bytes> <position> <crc32c>; the checksum
+//! covers the record bytes and position.
+struct WalRecord {
+ ValueType type;
+ int64_t pos; //!< absolute target offset in the data file for `type`
+ std::vector<std::byte> bytes; //!< serialized record, without position or checksum
+};
+
+//! Encapsulates the write-ahead log format and integrity. The log is a flat
+//! stream of self-describing records terminated by a rolling crc32c over them
+//! all; each record also carries its own crc32c. There is no grouping on disk:
+//! records are independent positioned writes, and the order only matters to the
+//! applier, which batches consecutive records targeting the same file.
+//!
+//! Records are streamed straight to disk via operator<<, so building a log never
+//! holds more than one serialized record in memory. Open() replays an existing
+//! log only after a full integrity dry-run, so a WriteAheadLog instance is always
+//! backed by a healthy, complete log on disk. Mapping a ValueType to a target
+//! file and applying the writes is the caller's responsibility.
+class WriteAheadLog
+{
+ fs::path m_path;
+ //! Held only while writing (AutoFile is not movable, so own it indirectly);
+ //! readers reopen m_path locally.
+ std::unique_ptr<AutoFile> m_file;
+ uint32_t m_rolling_checksum{0};
+
+ explicit WriteAheadLog(fs::path path, std::unique_ptr<AutoFile> file = nullptr)
+ : m_path{std::move(path)}, m_file{std::move(file)} {}
+
+ //! Walk the log, verifying every per-record crc32c and the trailing rolling
+ //! crc32c. on_record(type, pos, record) is called for each record; `record`
+ //! views a buffer reused across records (do not retain it). Returns false on
+ //! any incompleteness or corruption (short read, crc mismatch); never throws
+ //! on corruption. Allocates only the single reused read buffer.
+ static bool TraverseLog(const fs::path& path, const std::function<void(ValueType, int64_t, std::span<const std::byte>)>& on_record);
+
+public:
+ //! Closes an un-committed file handle (e.g. on a mid-write exception) so the
+ //! AutoFile written-but-open contract is not violated during stack unwind.
+ ~WriteAheadLog();
+ WriteAheadLog(WriteAheadLog&&) = default;
+ WriteAheadLog(const WriteAheadLog&) = delete;
+ WriteAheadLog& operator=(const WriteAheadLog&) = delete;
+ WriteAheadLog& operator=(WriteAheadLog&&) = delete;
+
+ //! Open a fresh log for writing.
+ static WriteAheadLog Create(const fs::path& path);
+ //! Stream one record to the log: serialized, checksummed and written immediately.
+ WriteAheadLog& operator<<(const WalRecord& record);
+ //! Write the rolling checksum, fsync and close. After this the log is durable.
+ void Commit();
+
+ //! Open and integrity-check an existing log. Returns nullopt and removes the
+ //! file if it is absent, incomplete or corrupt (safe to ignore).
+ static std::optional<WriteAheadLog> Open(const fs::path& path);
+ //! Replay validated records in write order. `record` views a reused buffer
+ //! (do not retain it). Throws if corruption is detected (not expected after
+ //! a successful Open()).
+ void ForEachRecord(const std::function<void(ValueType, int64_t pos, std::span<const std::byte>)>& fn) const;
+ //! Delete the log file.
+ void Remove() const;
+};
+
class CBlockFileInfo
{
public:
</details>