Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 23 additions & 11 deletions src/common/serializer/buffered_file.cpp
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#include "common/serializer/buffered_file.h"

#include <algorithm>
#include <cstring>

#include "common/assert.h"
Expand Down Expand Up @@ -78,20 +79,31 @@ void BufferedFileReader::read(uint8_t* data, uint64_t size) {
// Clear read buffer.
fileOffset -= bufferSize;
fileOffset += bufferOffset;
bufferOffset = 0;
bufferSize = 0;
// readFromFile silently short-reads at EOF, so check the range first and throw a
// catchable error instead of returning uninitialized bytes. A torn WAL tail (e.g. a
// crash or ENOSPC mid-commit) must surface as a replay failure, not a native crash.
if (fileOffset > fileSize || size > fileSize - fileOffset) {
throw RuntimeException(
std::format("Reading past the end of the file {} with size {} at offset {}",
fileInfo.path, fileSize, fileOffset));
}
fileInfo.readFromFile(data, size, fileOffset);
fileOffset += size;
bufferOffset = bufferSize;
} else if (bufferOffset + size <= bufferSize) {
memcpy(data, &buffer[bufferOffset], size);
bufferOffset += size;
} else {
auto toCopy = bufferSize - bufferOffset;
memcpy(data, &buffer[bufferOffset], toCopy);
bufferOffset += toCopy;
readNextPage();
auto remaining = size - toCopy;
memcpy(data + toCopy, buffer.get(), remaining);
bufferOffset += remaining;
// Loop across page boundaries: the final page of a torn file may hold fewer bytes
// than requested, and copying the full remainder would overrun the heap buffer.
while (size > 0) {
if (bufferOffset >= bufferSize) {
readNextPage();
}
const auto toCopy = std::min(size, bufferSize - bufferOffset);
memcpy(data, &buffer[bufferOffset], toCopy);
bufferOffset += toCopy;
data += toCopy;
size -= toCopy;
}
}
}

Expand Down
2 changes: 1 addition & 1 deletion test/transaction/checkpoint_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1927,7 +1927,7 @@ TEST_F(CheckpointRetryAfterFailureTest, StrayAbortRecordAfterCheckpointIsIgnored
conn.reset();
database.reset();
std::ofstream wal{walPath, std::ios::binary | std::ios::app};
for (const auto start : {uint64_t{1}, blockBeforeLastCheckpoint}) {
for (const auto start : {uint64_t{1}, static_cast<uint64_t>(blockBeforeLastCheckpoint)}) {
const auto record = serializeCheckpointRecord(AbortRecord{start});
wal.write(reinterpret_cast<const char*>(record.data.get()), record.size);
if (enableChecksums) {
Expand Down
39 changes: 39 additions & 0 deletions test/transaction/wal_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1127,6 +1127,45 @@ TEST_F(WalTest, CorruptedWALTailTruncated2) {
ASSERT_EQ(res->getNumTuples(), 3);
}

// A torn WAL tail inside a multi-page string record (ENOSPC/power loss mid-commit) must not
// crash the replayer: BufferedFileReader reads cross a page boundary with a short final page.
// Non-strict recovery drops the torn transaction; strict recovery throws a catchable error.
TEST_F(WalTest, TornWALTailInsideLargeStringRecord) {
if (inMemMode || systemConfig->checkpointThreshold == 0) {
GTEST_SKIP();
}
ASSERT_TRUE(conn->query("CALL force_checkpoint_on_close=false")->isSuccess());
ASSERT_TRUE(conn->query("CALL auto_checkpoint=false")->isSuccess());
ASSERT_TRUE(
conn->query("CREATE NODE TABLE Blob(id INT64 PRIMARY KEY, s STRING);")->isSuccess());
// Enough string data to span several 4KiB WAL pages, so the torn record's reads cross a
// page boundary into a short final page.
{
// Destroy the result before tearing down the DB below.
auto insertRes = conn->query("UNWIND range(0, 7) AS i CREATE (:Blob {id: i, s: "
"repeat(CAST(i AS STRING) + '-', 1500)});");
ASSERT_TRUE(insertRes->isSuccess()) << insertRes->getErrorMessage();
}
auto walFilePath = lbug::storage::StorageUtils::getWALFilePath(databasePath);
ASSERT_TRUE(std::filesystem::exists(walFilePath));
const auto walSize = std::filesystem::file_size(walFilePath);
ASSERT_GT(walSize, 8192);
conn.reset();
database.reset();
// Cut into the last transaction's records so the WAL ends mid-string.
std::filesystem::resize_file(walFilePath, walSize - 200);
// Strict recovery throws instead of crashing the process.
systemConfig->throwOnWalReplayFailure = true;
EXPECT_THROW(createDBAndConn(), lbug::common::Exception);
// Non-strict recovery drops the torn transaction instead of crashing.
systemConfig->throwOnWalReplayFailure = false;
createDBAndConn();
auto res = conn->query("MATCH (b:Blob) RETURN count(*);");
ASSERT_TRUE(res->isSuccess()) << res->getErrorMessage();
ASSERT_TRUE(res->hasNext());
EXPECT_EQ(res->getNext()->getValue(0)->getValue<int64_t>(), 0);
}

TEST_F(WalTest, WALFileLeftoverFromPreviousDBNewReadOnlyDB) {
if (inMemMode || systemConfig->checkpointThreshold == 0) {
GTEST_SKIP();
Expand Down
Loading