yams/tests/unit/storage/reference_counter_catch2_test.cpp
Trevon 6039a3695b
fix(storage): cache reference counter stats per SQLite generation (#291)
getStats ran the aggregate query on every call. The result is now cached
with the connection's total_changes() and data_version and reused while
both are unchanged; any write, restore, or copy invalidates it. A failed
generation probe disables reuse rather than the aggregate fallback.

Tests: reference_counter (generation reuse, invalidation, restore and copy paths).

Signed-off-by: trvon <me@trevon.dev>
2026-10-14 08:32:55 -06:00

2510 lines
96 KiB
C++

// Catch2 tests for reference counter
// Migrated from GTest: reference_counter_test.cpp
#include <catch2/catch_test_macros.hpp>
#include <sqlite3.h>
#include <algorithm>
#include <array>
#include <atomic>
#include <barrier>
#include <chrono>
#include <exception>
#include <filesystem>
#include <format>
#include <fstream>
#include <future>
#include <iostream>
#include <memory>
#include <optional>
#include <random>
#include <set>
#include <string_view>
#include <thread>
#include <vector>
#include <yams/storage/reference_counter.h>
#include <yams/storage/reference_counter_writer.h>
#include <yams/storage/storage_engine.h>
#include "../../common/test_helpers_catch2.h"
using namespace yams::storage;
namespace {
struct ReferenceCounterFixture {
ReferenceCounterFixture() {
testDbPath = std::filesystem::temp_directory_path() /
std::format("yams_refcount_catch2_{}.db",
std::chrono::system_clock::now().time_since_epoch().count());
ReferenceCounter::Config config{.databasePath = testDbPath,
.enableWAL = true,
.enableStatistics = true,
.cacheSize = 1000,
.busyTimeout = 1000,
.enableAuditLog = false};
refCounter = std::make_unique<ReferenceCounter>(std::move(config));
}
~ReferenceCounterFixture() {
refCounter.reset();
std::filesystem::remove(testDbPath);
std::filesystem::remove(testDbPath.string() + "-wal");
std::filesystem::remove(testDbPath.string() + "-shm");
}
std::string generateHash(int i) { return std::format("hash_{:064}", i); }
std::filesystem::path testDbPath;
std::unique_ptr<ReferenceCounter> refCounter;
};
// Regression: a lock held by another connection during initialization must be retried. The retry
// loop classifies transient locks from the error text, and the schema-migration path used to
// collapse every failure into a bare DatabaseError, so a locked database aborted startup instead
// of retrying. The tiny busyTimeout forces the first attempt to fail fast rather than block, which
// makes the failure deterministic instead of timing-dependent.
TEST_CASE("ReferenceCounter initialize retries while another connection holds the write lock",
"[storage][reference][contention]") {
const auto dbPath = std::filesystem::temp_directory_path() /
std::format("yams_refcount_contention_{}.db",
std::chrono::system_clock::now().time_since_epoch().count());
sqlite3* holder = nullptr;
REQUIRE(sqlite3_open(dbPath.string().c_str(), &holder) == SQLITE_OK);
REQUIRE(sqlite3_exec(holder, "BEGIN IMMEDIATE", nullptr, nullptr, nullptr) == SQLITE_OK);
std::thread releaser([&] {
std::this_thread::sleep_for(std::chrono::milliseconds(400));
sqlite3_exec(holder, "COMMIT", nullptr, nullptr, nullptr);
});
ReferenceCounter::Config config{.databasePath = dbPath,
.enableWAL = true,
.enableStatistics = false,
.cacheSize = 1000,
.busyTimeout = 1,
.enableAuditLog = false};
// The constructor initializes the database and throws if initialization fails, which is the
// behaviour under test: it must retry until the other connection releases the lock.
std::optional<std::string> initError;
try {
ReferenceCounter counter(std::move(config));
} catch (const std::exception& e) {
initError = e.what();
}
releaser.join();
sqlite3_close(holder);
std::filesystem::remove(dbPath);
std::filesystem::remove(dbPath.string() + "-wal");
std::filesystem::remove(dbPath.string() + "-shm");
REQUIRE_FALSE(initError.has_value());
if (initError) {
INFO("constructor failed: " << *initError);
}
}
struct GarbageCollectorFixture {
GarbageCollectorFixture() {
auto tempDir = std::filesystem::temp_directory_path();
testDbPath =
tempDir / std::format("yams_gc_catch2_{}.db",
std::chrono::system_clock::now().time_since_epoch().count());
testStoragePath =
tempDir / std::format("yams_gc_storage_catch2_{}",
std::chrono::system_clock::now().time_since_epoch().count());
ReferenceCounter::Config config{
.databasePath = testDbPath, .enableWAL = true, .enableStatistics = true};
refCounter = std::make_unique<ReferenceCounter>(std::move(config));
StorageConfig storageConfig{
.basePath = testStoragePath, .shardDepth = 2, .mutexPoolSize = 100};
storageEngine = std::make_unique<StorageEngine>(std::move(storageConfig));
gc = std::make_unique<GarbageCollector>(*refCounter, *storageEngine);
}
~GarbageCollectorFixture() {
gc.reset();
storageEngine.reset();
refCounter.reset();
std::filesystem::remove_all(testStoragePath);
std::filesystem::remove(testDbPath);
std::filesystem::remove(testDbPath.string() + "-wal");
std::filesystem::remove(testDbPath.string() + "-shm");
}
std::string generateHash(int i) { return std::format("{:064x}", i); }
std::filesystem::path testDbPath;
std::filesystem::path testStoragePath;
std::unique_ptr<ReferenceCounter> refCounter;
std::unique_ptr<StorageEngine> storageEngine;
std::unique_ptr<GarbageCollector> gc;
};
struct SqliteHandle {
explicit SqliteHandle(const std::filesystem::path& path) {
REQUIRE(sqlite3_open(path.string().c_str(), &db) == SQLITE_OK);
}
~SqliteHandle() {
if (db != nullptr) {
sqlite3_close(db);
}
}
SqliteHandle(const SqliteHandle&) = delete;
SqliteHandle& operator=(const SqliteHandle&) = delete;
sqlite3* db = nullptr;
};
// Observe the next real counter connection, not a parallel SQL implementation. Declare this
// guard before the fixture so its callback context outlives the database, including restore.
struct SqliteStatsObserver {
SqliteStatsObserver() {
REQUIRE(active_ == nullptr);
std::cout << "REF_STATS_SQLITE_IDENTITY version=" << sqlite3_libversion()
<< " sourceid=" << sqlite3_sourceid() << '\n';
active_ = this;
// An unsupported provider is a failed observer control, never behavioral cache RED.
const int rc = sqlite3_auto_extension(reinterpret_cast<void (*)()>(&initialize));
if (rc != SQLITE_OK) {
active_ = nullptr;
}
REQUIRE(rc == SQLITE_OK);
}
~SqliteStatsObserver() {
sqlite3_cancel_auto_extension(reinterpret_cast<void (*)()>(&initialize));
active_ = nullptr;
}
SqliteStatsObserver(const SqliteStatsObserver&) = delete;
SqliteStatsObserver& operator=(const SqliteStatsObserver&) = delete;
static int initialize(sqlite3* db, char**, const sqlite3_api_routines*) {
auto* observer = active_;
if (observer == nullptr || !observer->observeNextConnection_.exchange(false)) {
return SQLITE_OK;
}
const int rc = sqlite3_set_authorizer(db, &authorize, observer);
if (rc != SQLITE_OK) {
return rc;
}
const int traceRc =
sqlite3_trace_v2(db, SQLITE_TRACE_STMT | SQLITE_TRACE_PROFILE, &trace, observer);
if (traceRc == SQLITE_OK) {
observer->observedDb_ = db;
observer->attachments_.fetch_add(1);
}
return traceRc;
}
static int authorize(void* context, int action, const char* argument, const char*, const char*,
const char*) {
auto& observer = *static_cast<SqliteStatsObserver*>(context);
if (action == SQLITE_PRAGMA && observer.denyDataVersion_.load() && argument != nullptr &&
sqlite3_stricmp(argument, "data_version") == 0) {
return SQLITE_DENY;
}
if (action == SQLITE_READ && observer.denyBlockReads_.load() && argument != nullptr &&
sqlite3_stricmp(argument, "block_references") == 0) {
observer.deniedBlockReads_.fetch_add(1);
return SQLITE_DENY;
}
return SQLITE_OK;
}
static int trace(unsigned event, void* context, void* statement, void*) {
const char* text = sqlite3_sql(static_cast<sqlite3_stmt*>(statement));
if (text == nullptr) {
return 0;
}
// Counts only the preserved getStats aggregate SQL. The independent SQLITE_READ
// authorizer also detects reads through views/CTEs without a COUNT expression.
const std::string_view sql(text);
if (sql.find("FROM block_references") == std::string_view::npos ||
sql.find("COUNT(") == std::string_view::npos) {
return 0;
}
auto& observer = *static_cast<SqliteStatsObserver*>(context);
if (event == SQLITE_TRACE_STMT) {
observer.aggregateQueries_.fetch_add(1);
} else if (event == SQLITE_TRACE_PROFILE &&
observer.injectExternalCommit_.exchange(false)) {
// No Catch2 assertions or exceptions across SQLite's C callback. In WAL mode the
// separate writer can commit after the aggregate result, before a cache is stamped.
observer.externalCommitResult_.store(sqlite3_exec(observer.externalWriter_,
observer.externalWrite_.c_str(),
nullptr, nullptr, nullptr));
}
return 0;
}
// Constructor/rearm opens on this thread. Unrelated-thread opens cannot select the
// context, and this thread's destructor cannot race an in-flight initializer.
static inline thread_local SqliteStatsObserver* active_ = nullptr;
std::atomic<bool> observeNextConnection_{true};
std::atomic<size_t> attachments_{0};
std::atomic<bool> denyBlockReads_{false};
std::atomic<size_t> deniedBlockReads_{0};
std::atomic<bool> denyDataVersion_{false};
std::atomic<size_t> aggregateQueries_{0};
// Borrowed only while the observed fixture owns this connection; no production handle API.
sqlite3* observedDb_ = nullptr;
sqlite3* externalWriter_ = nullptr;
std::string externalWrite_;
std::atomic<bool> injectExternalCommit_{false};
std::atomic<int> externalCommitResult_{SQLITE_MISUSE};
};
void execSql(sqlite3* db, std::string_view sql) {
char* err = nullptr;
const int rc = sqlite3_exec(db, std::string(sql).c_str(), nullptr, nullptr, &err);
INFO(std::string(sql));
INFO(std::string(err != nullptr ? err : ""));
REQUIRE(rc == SQLITE_OK);
if (err != nullptr) {
sqlite3_free(err);
}
}
int64_t queryInt64(const std::filesystem::path& dbPath, std::string_view sql) {
SqliteHandle db(dbPath);
sqlite3_stmt* rawStmt = nullptr;
REQUIRE(sqlite3_prepare_v2(db.db, std::string(sql).c_str(), -1, &rawStmt, nullptr) ==
SQLITE_OK);
auto stmt =
std::unique_ptr<sqlite3_stmt, decltype(&sqlite3_finalize)>(rawStmt, &sqlite3_finalize);
REQUIRE(sqlite3_step(stmt.get()) == SQLITE_ROW);
return sqlite3_column_int64(stmt.get(), 0);
}
bool columnExists(const std::filesystem::path& dbPath, std::string_view table,
std::string_view column) {
SqliteHandle db(dbPath);
sqlite3_stmt* rawStmt = nullptr;
const auto sql = std::format("PRAGMA table_info({})", table);
REQUIRE(sqlite3_prepare_v2(db.db, sql.c_str(), -1, &rawStmt, nullptr) == SQLITE_OK);
auto stmt =
std::unique_ptr<sqlite3_stmt, decltype(&sqlite3_finalize)>(rawStmt, &sqlite3_finalize);
while (sqlite3_step(stmt.get()) == SQLITE_ROW) {
const auto* name = sqlite3_column_text(stmt.get(), 1);
if (name != nullptr && column == reinterpret_cast<const char*>(name)) {
return true;
}
}
return false;
}
} // namespace
TEST_CASE_METHOD(ReferenceCounterFixture, "ReferenceCounter basic increment decrement",
"[storage][refcount][catch2]") {
const std::string hash = generateHash(1);
const size_t blockSize = 4096;
// Initially, reference count should be 0
auto count = refCounter->getRefCount(hash);
REQUIRE(count.has_value());
CHECK(count.value() == 0u);
// Increment
auto result = refCounter->increment(hash, blockSize);
REQUIRE(result.has_value());
count = refCounter->getRefCount(hash);
REQUIRE(count.has_value());
CHECK(count.value() == 1u);
// Increment again
result = refCounter->increment(hash, blockSize);
REQUIRE(result.has_value());
count = refCounter->getRefCount(hash);
REQUIRE(count.has_value());
CHECK(count.value() == 2u);
// Decrement
result = refCounter->decrement(hash);
REQUIRE(result.has_value());
count = refCounter->getRefCount(hash);
REQUIRE(count.has_value());
CHECK(count.value() == 1u);
// Decrement to zero
result = refCounter->decrement(hash);
REQUIRE(result.has_value());
count = refCounter->getRefCount(hash);
REQUIRE(count.has_value());
CHECK(count.value() == 0u);
}
TEST_CASE_METHOD(ReferenceCounterFixture, "ReferenceCounter hasReferences",
"[storage][refcount][catch2]") {
const std::string hash = generateHash(2);
// Initially no references
auto hasRefs = refCounter->hasReferences(hash);
REQUIRE(hasRefs.has_value());
CHECK_FALSE(hasRefs.value());
// After increment, has references
refCounter->increment(hash, 1024);
hasRefs = refCounter->hasReferences(hash);
REQUIRE(hasRefs.has_value());
CHECK(hasRefs.value());
// After decrement to zero, no references
refCounter->decrement(hash);
hasRefs = refCounter->hasReferences(hash);
REQUIRE(hasRefs.has_value());
CHECK_FALSE(hasRefs.value());
}
TEST_CASE_METHOD(ReferenceCounterFixture, "ReferenceCounter batch operations",
"[storage][refcount][batch][catch2]") {
const size_t batchSize = 100;
std::vector<std::string> hashes;
for (size_t i = 0; i < batchSize; ++i) {
hashes.push_back(generateHash(i));
}
// Batch increment
auto result = refCounter->incrementBatch(hashes, 2048);
REQUIRE(result.has_value());
// Verify all incremented
for (const auto& hash : hashes) {
auto count = refCounter->getRefCount(hash);
REQUIRE(count.has_value());
CHECK(count.value() == 1u);
}
// Batch decrement
result = refCounter->decrementBatch(hashes);
REQUIRE(result.has_value());
// Verify all decremented
for (const auto& hash : hashes) {
auto count = refCounter->getRefCount(hash);
REQUIRE(count.has_value());
CHECK(count.value() == 0u);
}
}
TEST_CASE_METHOD(ReferenceCounterFixture, "ReferenceCounter batch with sizes",
"[storage][refcount][batch][catch2]") {
struct BlockInfo {
std::string hash;
size_t size;
};
std::vector<BlockInfo> blocks = {{generateHash(100), 1024},
{generateHash(101), 2048},
{generateHash(102), 4096},
{generateHash(103), 8192}};
auto result = refCounter->incrementBatchWithSizes(blocks);
REQUIRE(result.has_value());
// Verify all incremented
for (const auto& block : blocks) {
auto count = refCounter->getRefCount(block.hash);
REQUIRE(count.has_value());
CHECK(count.value() == 1u);
}
}
TEST_CASE_METHOD(ReferenceCounterFixture, "ReferenceCounter transaction commit",
"[storage][refcount][transaction][catch2]") {
const std::string hash1 = generateHash(200);
const std::string hash2 = generateHash(201);
auto txn = refCounter->beginTransaction();
REQUIRE(txn != nullptr);
REQUIRE(txn->isActive());
// Add operations to transaction
txn->increment(hash1, 1024);
txn->increment(hash2, 2048);
// Counts should still be zero (not committed)
auto count1 = refCounter->getRefCount(hash1);
REQUIRE(count1.has_value());
CHECK(count1.value() == 0u);
// Commit transaction
auto result = txn->commit();
REQUIRE(result.has_value());
CHECK_FALSE(txn->isActive());
// Now counts should be updated
count1 = refCounter->getRefCount(hash1);
REQUIRE(count1.has_value());
CHECK(count1.value() == 1u);
auto count2 = refCounter->getRefCount(hash2);
REQUIRE(count2.has_value());
CHECK(count2.value() == 1u);
auto stats = refCounter->getStats();
REQUIRE(stats.has_value());
CHECK(stats.value().transactions == 1u);
CHECK(stats.value().rollbacks == 0u);
}
TEST_CASE_METHOD(ReferenceCounterFixture,
"ReferenceCounter commitTransactionBatches commits logical transactions",
"[storage][refcount][batch][catch2]") {
const std::string hash1 = generateHash(230);
const std::string hash2 = generateHash(231);
const std::string hash3 = generateHash(232);
RefTransactionBatch batch1;
batch1.operations.push_back({.type = RefDelta::Type::Increment,
.blockHash = hash1,
.compressedSize = 1024,
.uncompressedSize = 2048});
batch1.operations.push_back({.type = RefDelta::Type::Increment,
.blockHash = hash2,
.compressedSize = 512,
.uncompressedSize = 1024});
RefTransactionBatch batch2;
batch2.operations.push_back({.type = RefDelta::Type::Increment,
.blockHash = hash1,
.compressedSize = 1024,
.uncompressedSize = 2048});
batch2.operations.push_back({.type = RefDelta::Type::Increment,
.blockHash = hash3,
.compressedSize = 256,
.uncompressedSize = 256});
std::array<RefTransactionBatch, 2> batches{batch1, batch2};
auto result = refCounter->commitTransactionBatches(batches);
REQUIRE(result.has_value());
auto count1 = refCounter->getRefCount(hash1);
auto count2 = refCounter->getRefCount(hash2);
auto count3 = refCounter->getRefCount(hash3);
REQUIRE(count1.has_value());
REQUIRE(count2.has_value());
REQUIRE(count3.has_value());
CHECK(count1.value() == 2u);
CHECK(count2.value() == 1u);
CHECK(count3.value() == 1u);
auto stats = refCounter->getStats();
REQUIRE(stats.has_value());
CHECK(stats.value().transactions == 2u);
}
TEST_CASE_METHOD(ReferenceCounterFixture, "RefCounterWriter flush and shutdown drain work",
"[storage][refcount][writer][catch2]") {
resetRefCounterWriterMetrics();
auto sharedCounter =
std::shared_ptr<ReferenceCounter>(refCounter.get(), [](ReferenceCounter*) {});
RefCounterWriter::Options options;
options.maxBatchCount = 16;
options.maxDelay = std::chrono::microseconds{50};
RefCounterWriter writer(sharedCounter, options);
std::vector<std::future<yams::Result<void>>> futures;
for (int i = 0; i < 8; ++i) {
RefTransactionBatch batch;
batch.operations.push_back({.type = RefDelta::Type::Increment,
.blockHash = generateHash(240 + i),
.compressedSize = 100 + static_cast<size_t>(i),
.uncompressedSize = 200 + static_cast<size_t>(i)});
futures.push_back(writer.submit(std::move(batch)));
}
auto flushResult = writer.flush();
REQUIRE(flushResult.has_value());
for (auto& future : futures) {
auto result = future.get();
REQUIRE(result.has_value());
}
for (int i = 0; i < 8; ++i) {
auto count = refCounter->getRefCount(generateHash(240 + i));
REQUIRE(count.has_value());
CHECK(count.value() == 1u);
}
const auto writerTimings = getRefCounterWriterTimingsSnapshot();
REQUIRE(writerTimings.at("queue_wait").calls == 8u);
CHECK(writerTimings.at("commit_batches_total").calls >= 1u);
CHECK(writerTimings.at("batch_apply_ops").calls >= 1u);
CHECK(writerTimings.at("batch_sqlite_commit").calls >= 1u);
const auto writerValues = getRefCounterWriterValueMetricsSnapshot();
CHECK(writerValues.at("submitted_batches").total == 8u);
CHECK(writerValues.at("submitted_ops").total == 8u);
CHECK(writerValues.at("writer_batch_size").total == 8u);
CHECK(writerValues.at("writer_batch_ops").total == 8u);
CHECK(writerValues.at("committed_ops").total == 8u);
writer.shutdown();
RefTransactionBatch lateBatch;
lateBatch.operations.push_back({.type = RefDelta::Type::Increment,
.blockHash = generateHash(260),
.compressedSize = 1,
.uncompressedSize = 1});
auto late = writer.submit(std::move(lateBatch)).get();
CHECK_FALSE(late.has_value());
}
TEST_CASE_METHOD(ReferenceCounterFixture, "ReferenceCounter transaction increment without size",
"[storage][refcount][transaction][catch2]") {
const std::string hash = generateHash(250);
auto txn = refCounter->beginTransaction();
REQUIRE(txn != nullptr);
txn->increment(hash);
auto result = txn->commit();
REQUIRE(result.has_value());
auto count = refCounter->getRefCount(hash);
REQUIRE(count.has_value());
CHECK(count.value() == 1u);
auto stats = refCounter->getStats();
REQUIRE(stats.has_value());
CHECK(stats.value().totalBytes == 1u);
CHECK(stats.value().totalUncompressedBytes == 1u);
}
TEST_CASE_METHOD(ReferenceCounterFixture, "ReferenceCounter transaction rollback",
"[storage][refcount][transaction][catch2]") {
const std::string incrementedHash = generateHash(300);
const std::string decrementedHash = generateHash(301);
const std::string newHash = generateHash(302);
REQUIRE(refCounter->increment(incrementedHash, 1024).has_value());
REQUIRE(refCounter->increment(decrementedHash, 2048).has_value());
REQUIRE(refCounter->increment(decrementedHash, 2048).has_value());
auto beforeStats = refCounter->getStats();
REQUIRE(beforeStats.has_value());
auto txn = refCounter->beginTransaction();
REQUIRE(txn != nullptr);
txn->increment(incrementedHash, 1024);
txn->decrement(decrementedHash);
txn->increment(newHash, 4096);
// Rollback transaction
txn->rollback();
CHECK_FALSE(txn->isActive());
auto incrementedCount = refCounter->getRefCount(incrementedHash);
REQUIRE(incrementedCount.has_value());
CHECK(incrementedCount.value() == 1u);
auto decrementedCount = refCounter->getRefCount(decrementedHash);
REQUIRE(decrementedCount.has_value());
CHECK(decrementedCount.value() == 2u);
auto newCount = refCounter->getRefCount(newHash);
REQUIRE(newCount.has_value());
CHECK(newCount.value() == 0u);
auto afterStats = refCounter->getStats();
REQUIRE(afterStats.has_value());
CHECK(afterStats.value().totalBlocks == beforeStats.value().totalBlocks);
CHECK(afterStats.value().totalReferences == beforeStats.value().totalReferences);
CHECK(afterStats.value().transactions == beforeStats.value().transactions);
CHECK(afterStats.value().rollbacks == beforeStats.value().rollbacks + 1);
}
TEST_CASE_METHOD(ReferenceCounterFixture, "ReferenceCounter transaction auto rollback",
"[storage][refcount][transaction][catch2]") {
const std::string hash = generateHash(400);
{
auto txn = refCounter->beginTransaction();
txn->increment(hash, 1024);
// Transaction destroyed without commit - should rollback
}
// Count should still be zero
auto count = refCounter->getRefCount(hash);
REQUIRE(count.has_value());
CHECK(count.value() == 0u);
}
TEST_CASE_METHOD(ReferenceCounterFixture, "ReferenceCounter transaction closed-state behavior",
"[storage][refcount][transaction][edge][catch2]") {
const std::string hash = generateHash(450);
auto txn = refCounter->beginTransaction();
REQUIRE(txn != nullptr);
txn->increment(hash, 1024);
auto firstCommit = txn->commit();
REQUIRE(firstCommit.has_value());
CHECK_FALSE(txn->isActive());
auto secondCommit = txn->commit();
CHECK_FALSE(secondCommit.has_value());
CHECK(secondCommit.error().code == yams::ErrorCode::TransactionFailed);
// After commit, increment/decrement must fail.
// In dev/CI (DCHECK enabled): YAMS_DCHECK aborts — no catchable exception.
// In release (NDEBUG): DCHECK compiles out, throw fires.
#if defined(YAMS_DISABLE_DCHECK) || (defined(NDEBUG) && !defined(YAMS_ENABLE_DCHECK))
REQUIRE_THROWS_AS(txn->increment(hash, 1024), std::runtime_error);
REQUIRE_THROWS_AS(txn->decrement(hash), std::runtime_error);
#else
// DCHECK abort path covered by fork()-based assertion tests.
SUCCEED("YAMS_DCHECK abort path is covered by fork()-based assertion tests");
#endif
}
TEST_CASE_METHOD(ReferenceCounterFixture,
"ReferenceCounter failed transaction commit leaves no partial writes",
"[storage][refcount][transaction][edge][catch2]") {
const std::string existingHash = generateHash(460);
const std::string newHash = generateHash(461);
REQUIRE(refCounter->increment(existingHash, 1024).has_value());
auto backupPath = std::filesystem::temp_directory_path() /
std::format("yams_refcount_stale_txn_backup_{}.db",
std::chrono::system_clock::now().time_since_epoch().count());
std::error_code ec;
std::filesystem::remove(backupPath, ec);
std::filesystem::remove(backupPath.string() + "-wal", ec);
std::filesystem::remove(backupPath.string() + "-shm", ec);
auto backupResult = refCounter->backup(backupPath);
REQUIRE(backupResult.has_value());
auto txn = refCounter->beginTransaction();
REQUIRE(txn != nullptr);
txn->increment(existingHash, 1024);
txn->increment(newHash, 2048);
auto restoreResult = refCounter->restore(backupPath);
REQUIRE(restoreResult.has_value());
auto commitResult = txn->commit();
CHECK_FALSE(commitResult.has_value());
CHECK(commitResult.error().code == yams::ErrorCode::TransactionFailed);
CHECK_FALSE(txn->isActive());
auto existingCount = refCounter->getRefCount(existingHash);
REQUIRE(existingCount.has_value());
CHECK(existingCount.value() == 1u);
auto newCount = refCounter->getRefCount(newHash);
REQUIRE(newCount.has_value());
CHECK(newCount.value() == 0u);
std::filesystem::remove(backupPath, ec);
std::filesystem::remove(backupPath.string() + "-wal", ec);
std::filesystem::remove(backupPath.string() + "-shm", ec);
}
TEST_CASE_METHOD(ReferenceCounterFixture, "ReferenceCounter get unreferenced blocks",
"[storage][refcount][catch2]") {
std::vector<std::string> allHashes;
std::vector<std::string> unreferencedHashes;
// Create some blocks with references
for (int i = 0; i < 5; ++i) {
auto hash = generateHash(500 + i);
allHashes.push_back(hash);
refCounter->increment(hash, 1024);
}
// Create some unreferenced blocks
for (int i = 5; i < 10; ++i) {
auto hash = generateHash(500 + i);
allHashes.push_back(hash);
unreferencedHashes.push_back(hash);
// Increment then decrement to create unreferenced entry
refCounter->increment(hash, 1024);
refCounter->decrement(hash);
}
// Get unreferenced blocks (with 0 seconds min age)
auto result = refCounter->getUnreferencedBlocks(100, std::chrono::seconds(0));
REQUIRE(result.has_value());
const auto& blocks = result.value();
CHECK(blocks.size() == unreferencedHashes.size());
// Verify we got the right blocks
std::set<std::string> foundBlocks(blocks.begin(), blocks.end());
std::set<std::string> expectedBlocks(unreferencedHashes.begin(), unreferencedHashes.end());
CHECK(foundBlocks == expectedBlocks);
}
TEST_CASE_METHOD(ReferenceCounterFixture, "ReferenceCounter statistics",
"[storage][refcount][stats][catch2]") {
// Add some blocks
const size_t numBlocks = 10;
const size_t blockSize = 1024;
for (size_t i = 0; i < numBlocks; ++i) {
refCounter->increment(generateHash(600 + i), blockSize);
}
// Decrement some to make them unreferenced
for (size_t i = 0; i < 3; ++i) {
refCounter->decrement(generateHash(600 + i));
}
// Get statistics
auto statsResult = refCounter->getStats();
REQUIRE(statsResult.has_value());
const auto& stats = statsResult.value();
CHECK(stats.totalBlocks == numBlocks);
CHECK(stats.totalReferences == numBlocks - 3); // 3 were decremented
CHECK(stats.totalBytes == numBlocks * blockSize);
CHECK(stats.unreferencedBlocks == 3u);
CHECK(stats.unreferencedBytes == 3u * blockSize);
}
TEST_CASE_METHOD(ReferenceCounterFixture, "ReferenceCounter statistics reconcile corrupt counters",
"[storage][refcount][stats][edge][catch2]") {
const std::string referencedHash = generateHash(650);
const std::string unreferencedHash = generateHash(651);
REQUIRE(refCounter->increment(referencedHash, 512, 1024).has_value());
REQUIRE(refCounter->increment(unreferencedHash, 2048, 4096).has_value());
REQUIRE(refCounter->decrement(unreferencedHash).has_value());
REQUIRE(refCounter->updateStatistics("total_blocks", -100).has_value());
REQUIRE(refCounter->updateStatistics("total_references", 50).has_value());
auto statsResult = refCounter->getStats();
REQUIRE(statsResult.has_value());
const auto& stats = statsResult.value();
CHECK(stats.totalBlocks == 2u);
CHECK(stats.totalReferences == 1u);
CHECK(stats.totalBytes == 2560u);
CHECK(stats.totalUncompressedBytes == 5120u);
CHECK(stats.unreferencedBlocks == 1u);
CHECK(stats.unreferencedBytes == 2048u);
}
// Run this control successfully before accepting any cache test as behavioral RED. It checks
// provider attachment, the preserved cold-query trace, and real-table authorizer enforcement.
TEST_CASE("ReferenceCounter statistics observer control",
"[storage][refcount][stats][observer-control]") {
SqliteStatsObserver observer;
int unrelatedOpen = SQLITE_MISUSE;
std::thread unrelated([&] {
sqlite3* db = nullptr;
unrelatedOpen = sqlite3_open(":memory:", &db);
if (db != nullptr) {
sqlite3_close(db);
}
});
unrelated.join();
REQUIRE(unrelatedOpen == SQLITE_OK);
REQUIRE(observer.attachments_.load() == 0u);
ReferenceCounterFixture fixture;
REQUIRE(observer.attachments_.load() == 1u);
const auto hash = fixture.generateHash(659);
REQUIRE(fixture.refCounter->increment(hash, 512, 1024).has_value());
observer.aggregateQueries_.store(0);
const auto first = fixture.refCounter->getStats();
REQUIRE(first.has_value());
CHECK(first.value().totalReferences == 1u);
REQUIRE(observer.aggregateQueries_.load() == 1u);
REQUIRE(fixture.refCounter->increment(hash, 512, 1024).has_value());
observer.denyBlockReads_.store(true);
const auto denied = fixture.refCounter->getStats();
REQUIRE_FALSE(denied.has_value());
CHECK(denied.error().code == yams::ErrorCode::DatabaseError);
REQUIRE(observer.deniedBlockReads_.load() > 0u);
observer.denyBlockReads_.store(false);
const auto recovered = fixture.refCounter->getStats();
REQUIRE(recovered.has_value());
CHECK(recovered.value().totalReferences == 2u);
}
TEST_CASE("ReferenceCounter unchanged statistics polls perform only one aggregate",
"[storage][refcount][stats][cache][noscan]") {
SqliteStatsObserver observer;
ReferenceCounterFixture fixture;
REQUIRE(observer.attachments_.load() == 1u);
REQUIRE(fixture.refCounter->increment(fixture.generateHash(660), 512, 1024).has_value());
observer.aggregateQueries_.store(0);
for (int poll = 0; poll < 6; ++poll) {
const auto stats = fixture.refCounter->getStats();
REQUIRE(stats.has_value());
CHECK(stats.value().totalBlocks == 1u);
CHECK(stats.value().totalReferences == 1u);
CHECK(stats.value().totalBytes == 512u);
CHECK(stats.value().totalUncompressedBytes == 1024u);
CHECK(stats.value().unreferencedBlocks == 0u);
CHECK(stats.value().unreferencedBytes == 0u);
CHECK(stats.value().transactions == 0u);
CHECK(stats.value().rollbacks == 0u);
}
CHECK(observer.aggregateQueries_.load() == 1u);
}
TEST_CASE("ReferenceCounter cached statistics do not read block_references",
"[storage][refcount][stats][cache][noscan]") {
SqliteStatsObserver observer;
ReferenceCounterFixture fixture;
REQUIRE(observer.attachments_.load() == 1u);
const auto hash = fixture.generateHash(661);
REQUIRE(fixture.refCounter->increment(hash, 512, 1024).has_value());
REQUIRE(fixture.refCounter->getStats().has_value());
observer.denyBlockReads_.store(true);
const auto cached = fixture.refCounter->getStats();
REQUIRE(cached.has_value());
CHECK(cached.value().totalReferences == 1u);
// Writes themselves read this table; disable the test policy for the increment only.
// The next denied refresh must report an error, not return an old cached success.
observer.denyBlockReads_.store(false);
REQUIRE(fixture.refCounter->increment(hash, 512, 1024).has_value());
observer.denyBlockReads_.store(true);
const auto denied = fixture.refCounter->getStats();
REQUIRE_FALSE(denied.has_value());
CHECK(denied.error().code == yams::ErrorCode::DatabaseError);
CHECK(observer.deniedBlockReads_.load() > 0u);
observer.denyBlockReads_.store(false);
const auto fresh = fixture.refCounter->getStats();
REQUIRE(fresh.has_value());
CHECK(fresh.value().totalReferences == 2u);
}
TEST_CASE("ReferenceCounter uncertain generation falls back without caching success",
"[storage][refcount][stats][cache][generation-error]") {
SqliteStatsObserver observer;
ReferenceCounterFixture fixture;
REQUIRE(observer.attachments_.load() == 1u);
REQUIRE(fixture.refCounter->increment(fixture.generateHash(669), 512, 1024).has_value());
REQUIRE(fixture.refCounter->getStats().has_value());
observer.aggregateQueries_.store(0);
observer.denyDataVersion_.store(true);
for (int poll = 0; poll < 2; ++poll) {
const auto fallback = fixture.refCounter->getStats();
REQUIRE(fallback.has_value());
CHECK(fallback.value().totalBlocks == 1u);
CHECK(fallback.value().totalReferences == 1u);
CHECK(fallback.value().totalBytes == 512u);
CHECK(fallback.value().totalUncompressedBytes == 1024u);
CHECK(fallback.value().unreferencedBlocks == 0u);
CHECK(fallback.value().unreferencedBytes == 0u);
CHECK(fallback.value().transactions == 0u);
CHECK(fallback.value().rollbacks == 0u);
}
CHECK(observer.aggregateQueries_.load() == 2u);
// Probe failure alone is not an aggregate error. If fallback itself fails, no cached
// success may hide it, even though this connection previously returned valid statistics.
observer.denyBlockReads_.store(true);
const auto denied = fixture.refCounter->getStats();
REQUIRE_FALSE(denied.has_value());
CHECK(denied.error().code == yams::ErrorCode::DatabaseError);
CHECK(observer.deniedBlockReads_.load() > 0u);
observer.denyBlockReads_.store(false);
observer.denyDataVersion_.store(false);
const auto recovered = fixture.refCounter->getStats();
REQUIRE(recovered.has_value());
CHECK(recovered.value().totalReferences == 1u);
}
TEST_CASE("ReferenceCounter null local generation requires an authoritative fallback",
"[storage][refcount][stats][cache][generation-null]") {
SqliteStatsObserver observer;
ReferenceCounterFixture fixture;
REQUIRE(observer.attachments_.load() == 1u);
REQUIRE(observer.observedDb_ != nullptr);
REQUIRE(fixture.refCounter->increment(fixture.generateHash(674), 512, 1024).has_value());
REQUIRE(fixture.refCounter->getStats().has_value());
REQUIRE(
sqlite3_create_function_v2(
observer.observedDb_, "total_changes", 0, SQLITE_UTF8, nullptr,
[](sqlite3_context* context, int, sqlite3_value**) { sqlite3_result_null(context); },
nullptr, nullptr, nullptr) == SQLITE_OK);
// Execute a provider control first. Unsupported registration/execution is not cache RED.
{
sqlite3_stmt* raw = nullptr;
REQUIRE(sqlite3_prepare_v2(observer.observedDb_, "SELECT total_changes()", -1, &raw,
nullptr) == SQLITE_OK);
auto probe =
std::unique_ptr<sqlite3_stmt, decltype(&sqlite3_finalize)>(raw, &sqlite3_finalize);
REQUIRE(sqlite3_step(probe.get()) == SQLITE_ROW);
REQUIRE(sqlite3_column_type(probe.get(), 0) == SQLITE_NULL);
}
observer.aggregateQueries_.store(0);
for (int poll = 0; poll < 2; ++poll) {
const auto fallback = fixture.refCounter->getStats();
REQUIRE(fallback.has_value());
CHECK(fallback.value().totalBlocks == 1u);
CHECK(fallback.value().totalReferences == 1u);
CHECK(fallback.value().totalBytes == 512u);
CHECK(fallback.value().totalUncompressedBytes == 1024u);
CHECK(fallback.value().unreferencedBlocks == 0u);
CHECK(fallback.value().unreferencedBytes == 0u);
CHECK(fallback.value().transactions == 0u);
CHECK(fallback.value().rollbacks == 0u);
}
CHECK(observer.aggregateQueries_.load() == 2u);
observer.denyBlockReads_.store(true);
const auto denied = fixture.refCounter->getStats();
REQUIRE_FALSE(denied.has_value());
CHECK(denied.error().code == yams::ErrorCode::DatabaseError);
CHECK(observer.deniedBlockReads_.load() > 0u);
}
TEST_CASE("ReferenceCounter restored statistics discard the previous connection snapshot",
"[storage][refcount][stats][cache][restore]") {
SqliteStatsObserver observer;
ReferenceCounterFixture fixture;
REQUIRE(observer.attachments_.load() == 1u);
const auto backupPath = std::filesystem::path(fixture.testDbPath.string() + ".stats-backup");
REQUIRE(fixture.refCounter->increment(fixture.generateHash(670), 512, 1024).has_value());
REQUIRE(fixture.refCounter->backup(backupPath).has_value());
REQUIRE(fixture.refCounter->increment(fixture.generateHash(671), 256, 512).has_value());
const auto changed = fixture.refCounter->getStats();
REQUIRE(changed.has_value());
CHECK(changed.value().totalBlocks == 2u);
observer.observeNextConnection_.store(true);
REQUIRE(fixture.refCounter->restore(backupPath).has_value());
REQUIRE(observer.attachments_.load() == 2u);
observer.aggregateQueries_.store(0);
for (int poll = 0; poll < 2; ++poll) {
const auto restored = fixture.refCounter->getStats();
REQUIRE(restored.has_value());
CHECK(restored.value().totalBlocks == 1u);
CHECK(restored.value().totalReferences == 1u);
CHECK(restored.value().totalBytes == 512u);
CHECK(restored.value().totalUncompressedBytes == 1024u);
}
CHECK(observer.aggregateQueries_.load() == 1u);
std::filesystem::remove(backupPath);
}
// Run this baseline RED separately: legacy restore closes the handle before copy_file throws,
// and the current getStats dereferences that null handle. Do not lose other baseline artifacts
// by combining this known crash characterization with the polling benchmark process.
TEST_CASE_METHOD(ReferenceCounterFixture,
"ReferenceCounter statistics fail safely after failed restore",
"[storage][refcount][stats][cache][restore-failure]") {
REQUIRE(refCounter->increment(generateHash(672), 512, 1024).has_value());
REQUIRE(refCounter->getStats().has_value());
const auto missingBackup = std::filesystem::path(testDbPath.string() + ".does-not-exist");
REQUIRE_FALSE(refCounter->restore(missingBackup).has_value());
const auto stats = refCounter->getStats();
REQUIRE_FALSE(stats.has_value());
CHECK(stats.error().code == yams::ErrorCode::DatabaseError);
}
TEST_CASE_METHOD(ReferenceCounterFixture, "ReferenceCounter statistics track local mutations",
"[storage][refcount][stats][cache]") {
REQUIRE(refCounter->getStats().has_value());
const auto checkReferences = [&](uint64_t expected) {
const auto stats = refCounter->getStats();
REQUIRE(stats.has_value());
CHECK(stats.value().totalReferences == expected);
};
const auto firstHash = generateHash(662);
const auto secondHash = generateHash(663);
REQUIRE(refCounter->increment(firstHash, 512, 1024).has_value());
checkReferences(1);
REQUIRE(refCounter->increment(firstHash, 512, 1024).has_value());
checkReferences(2);
REQUIRE(refCounter->decrement(firstHash).has_value());
checkReferences(1);
auto batch = refCounter->beginTransaction();
batch->increment(secondHash, 256, 512);
batch->decrement(firstHash);
REQUIRE(batch->commit().has_value());
auto stats = refCounter->getStats();
REQUIRE(stats.has_value());
CHECK(stats.value().totalBlocks == 2u);
CHECK(stats.value().totalReferences == 1u);
CHECK(stats.value().totalBytes == 768u);
CHECK(stats.value().totalUncompressedBytes == 1536u);
CHECK(stats.value().unreferencedBlocks == 1u);
CHECK(stats.value().unreferencedBytes == 512u);
CHECK(stats.value().transactions == 1u);
auto rolledBack = refCounter->beginTransaction();
rolledBack->increment(generateHash(664), 128, 256);
rolledBack->rollback();
stats = refCounter->getStats();
REQUIRE(stats.has_value());
CHECK(stats.value().totalBlocks == 2u);
CHECK(stats.value().rollbacks == 1u);
auto prune = refCounter->beginTransaction();
prune->pruneReference(firstHash);
REQUIRE(prune->commit().has_value());
stats = refCounter->getStats();
REQUIRE(stats.has_value());
CHECK(stats.value().totalBlocks == 1u);
CHECK(stats.value().totalReferences == 1u);
CHECK(stats.value().totalBytes == 256u);
CHECK(stats.value().totalUncompressedBytes == 512u);
CHECK(stats.value().unreferencedBlocks == 0u);
CHECK(stats.value().unreferencedBytes == 0u);
CHECK(stats.value().transactions == 2u);
CHECK(stats.value().rollbacks == 1u);
// The first operation writes, but the zero-size insert violates the schema. SQLite must
// roll it back; total_changes still changes, so invalidation must remain conservative.
auto failed = refCounter->beginTransaction();
failed->increment(secondHash, 256, 512);
failed->increment(generateHash(665), 0, 0);
REQUIRE_FALSE(failed->commit().has_value());
stats = refCounter->getStats();
REQUIRE(stats.has_value());
CHECK(stats.value().totalBlocks == 1u);
CHECK(stats.value().totalReferences == 1u);
CHECK(stats.value().totalBytes == 256u);
}
TEST_CASE_METHOD(
ReferenceCounterFixture,
"ReferenceCounter statistics overlap supported writes without stale completed rounds",
"[storage][refcount][stats][cache][concurrent]") {
constexpr size_t kRounds = 20;
const auto hash = generateHash(673);
REQUIRE(refCounter->getStats().has_value());
std::barrier<> roundSync(2);
std::array<bool, kRounds> writes{};
std::array<std::optional<RefCountStats>, kRounds * 2> reads{};
std::exception_ptr writerException;
std::exception_ptr readerException;
std::thread writer([&] {
for (size_t round = 0; round < kRounds; ++round) {
roundSync.arrive_and_wait();
try {
if (round % 2 == 0) {
writes[round] = refCounter->increment(hash, 512, 1024).has_value();
} else {
auto transaction = refCounter->beginTransaction();
transaction->increment(hash, 512, 1024);
writes[round] = transaction->commit().has_value();
}
} catch (...) {
writerException = std::current_exception();
}
roundSync.arrive_and_wait();
}
});
// This thread is the reader, so creating a second worker cannot strand the first at a
// two-party barrier on a thread-creation failure. Record failures, then join before asserting.
const auto record = [&](size_t index) {
try {
const auto stats = refCounter->getStats();
if (stats.has_value()) {
reads[index] = stats.value();
}
} catch (...) {
readerException = std::current_exception();
}
};
for (size_t round = 0; round < kRounds; ++round) {
roundSync.arrive_and_wait();
record(round * 2); // Can precede or follow this round's commit.
roundSync.arrive_and_wait();
record(round * 2 + 1); // Writer completed; next write waits at the next start barrier.
}
writer.join();
// No Catch2 assertions/exceptions cross the worker entry points. Joined results are stable.
if (writerException) {
std::rethrow_exception(writerException);
}
if (readerException) {
std::rethrow_exception(readerException);
}
for (size_t round = 0; round < kRounds; ++round) {
INFO("round=" << round);
REQUIRE(writes[round]);
for (size_t phase = 0; phase < 2; ++phase) {
const auto& observation = reads[round * 2 + phase];
REQUIRE(observation.has_value());
const auto references = observation->totalReferences;
if (phase == 0) {
CHECK((references == round || references == round + 1));
} else {
CHECK(references == round + 1);
}
CHECK(observation->totalBlocks == (references == 0 ? 0u : 1u));
CHECK(observation->totalBytes == (references == 0 ? 0u : 512u));
CHECK(observation->totalUncompressedBytes == (references == 0 ? 0u : 1024u));
CHECK(observation->unreferencedBlocks == 0u);
CHECK(observation->unreferencedBytes == 0u);
CHECK(observation->transactions == references / 2);
CHECK(observation->rollbacks == 0u);
}
}
}
TEST_CASE_METHOD(ReferenceCounterFixture,
"ReferenceCounter statistics detect external repair and preserve reconciliation",
"[storage][refcount][stats][cache][external]") {
const auto hash = generateHash(666);
REQUIRE(refCounter->increment(hash, 512, 1024).has_value());
REQUIRE(refCounter->getStats().has_value());
SqliteHandle writer(testDbPath);
execSql(writer.db, "BEGIN IMMEDIATE");
execSql(writer.db, std::format("UPDATE block_references SET ref_count=0, block_size=2048, "
"uncompressed_size=4096 WHERE block_hash='{}'",
hash));
// An uncommitted external write does not change the committed statistics snapshot.
auto stats = refCounter->getStats();
REQUIRE(stats.has_value());
CHECK(stats.value().totalReferences == 1u);
CHECK(stats.value().totalBytes == 512u);
execSql(writer.db, "COMMIT");
stats = refCounter->getStats();
REQUIRE(stats.has_value());
CHECK(stats.value().totalBlocks == 1u);
CHECK(stats.value().totalReferences == 0u);
CHECK(stats.value().totalBytes == 2048u);
CHECK(stats.value().totalUncompressedBytes == 4096u);
CHECK(stats.value().unreferencedBlocks == 1u);
CHECK(stats.value().unreferencedBytes == 2048u);
execSql(writer.db, "UPDATE ref_statistics SET stat_value=-321 WHERE stat_name='total_blocks'");
for (int poll = 0; poll < 3; ++poll) {
stats = refCounter->getStats();
REQUIRE(stats.has_value());
CHECK(stats.value().totalBlocks == 1u);
CHECK(stats.value().totalReferences == 0u);
CHECK(stats.value().totalBytes == 2048u);
CHECK(stats.value().totalUncompressedBytes == 4096u);
CHECK(stats.value().unreferencedBlocks == 1u);
CHECK(stats.value().unreferencedBytes == 2048u);
CHECK(stats.value().transactions == 0u);
CHECK(stats.value().rollbacks == 0u);
}
CHECK(queryInt64(testDbPath,
"SELECT stat_value FROM ref_statistics WHERE stat_name='total_blocks'") ==
-321);
}
TEST_CASE("ReferenceCounter does not cache an aggregate under a later external commit",
"[storage][refcount][stats][cache][external][race]") {
SqliteStatsObserver observer;
ReferenceCounterFixture fixture;
REQUIRE(observer.attachments_.load() == 1u);
const auto hash = fixture.generateHash(667);
REQUIRE(fixture.refCounter->increment(hash, 512, 1024).has_value());
SqliteHandle writer(fixture.testDbPath);
observer.aggregateQueries_.store(0);
observer.externalWriter_ = writer.db;
observer.externalWrite_ = std::format(
"UPDATE block_references SET ref_count=ref_count+1 WHERE block_hash='{}'", hash);
observer.injectExternalCommit_.store(true);
const auto beforeCommit = fixture.refCounter->getStats();
REQUIRE(beforeCommit.has_value());
CHECK(beforeCommit.value().totalReferences == 1u);
REQUIRE(observer.externalCommitResult_.load() == SQLITE_OK);
const auto afterCommit = fixture.refCounter->getStats();
REQUIRE(afterCommit.has_value());
CHECK(afterCommit.value().totalReferences == 2u);
CHECK(observer.aggregateQueries_.load() == 2u);
}
TEST_CASE_METHOD(ReferenceCounterFixture, "ReferenceCounter cached statistics expose schema errors",
"[storage][refcount][stats][cache][external][edge]") {
REQUIRE(refCounter->increment(generateHash(668), 512, 1024).has_value());
REQUIRE(refCounter->getStats().has_value());
SqliteHandle writer(testDbPath);
execSql(writer.db, "DROP TABLE ref_statistics");
const auto stats = refCounter->getStats();
REQUIRE_FALSE(stats.has_value());
CHECK(stats.error().code == yams::ErrorCode::DatabaseError);
}
TEST_CASE("ReferenceCounter statistics polling benchmark", "[.stats-benchmark]") {
// Component-only, unchanged-database polls: not an ingest-throughput or daemon CPU claim.
// The first read is a cold stats snapshot, not cold disk/cache: setup already touches pages.
// Execute this same case three times per frozen binary and retain each REF_STATS_BENCH line.
constexpr size_t kPolls = 50;
for (const size_t rows : {1000u, 100000u}) {
SqliteStatsObserver observer;
ReferenceCounterFixture fixture;
REQUIRE(observer.attachments_.load() == 1u);
SqliteHandle writer(fixture.testDbPath);
execSql(writer.db, std::format(R"(
WITH RECURSIVE blocks(i) AS (
VALUES(1) UNION ALL SELECT i+1 FROM blocks WHERE i < {}
)
INSERT INTO block_references(block_hash, ref_count, block_size, uncompressed_size,
created_at, last_accessed)
SELECT printf('%064x', i), CASE WHEN i % 3 = 0 THEN 0 ELSE 2 END, 4096, 8192,
1700000000, 1700000000
FROM blocks
)",
rows));
uint64_t expectedReferences = 2 * (rows - rows / 3);
observer.aggregateQueries_.store(0);
const auto poll = [&] {
const auto start = std::chrono::steady_clock::now();
const auto stats = fixture.refCounter->getStats();
const double elapsedMs =
std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - start)
.count();
REQUIRE(stats.has_value());
CHECK(stats.value().totalBlocks == rows);
CHECK(stats.value().totalReferences == expectedReferences);
CHECK(stats.value().totalBytes == rows * 4096u);
CHECK(stats.value().totalUncompressedBytes == rows * 8192u);
CHECK(stats.value().unreferencedBlocks == rows / 3);
CHECK(stats.value().unreferencedBytes == (rows / 3) * 4096u);
CHECK(stats.value().transactions == 0u);
CHECK(stats.value().rollbacks == 0u);
return elapsedMs;
};
const double coldMs = poll();
REQUIRE(observer.aggregateQueries_.load() == 1u);
std::vector<double> warmMs;
warmMs.reserve(kPolls);
for (size_t i = 0; i < kPolls; ++i) {
warmMs.push_back(poll());
}
const size_t warmPinnedQueries = observer.aggregateQueries_.load() - 1;
std::sort(warmMs.begin(), warmMs.end());
execSql(writer.db, "UPDATE block_references SET ref_count=ref_count+1 "
"WHERE block_hash=printf('%064x', 1)");
++expectedReferences;
const double invalidationMs = poll();
std::cout << std::format(
"REF_STATS_BENCH {{\"rows\":{},\"polls\":{},\"cold_snapshot_ms\":{:.6f},"
"\"warm_p50_ms\":{:.6f},\"warm_p95_ms\":{:.6f},\"warm_pinned_aggregate_queries\":{},"
"\"external_invalidation_ms\":{:.6f},\"total_pinned_aggregate_queries\":{}}}\n",
rows, kPolls, coldMs, (warmMs[(kPolls - 1) / 2] + warmMs[kPolls / 2]) / 2,
warmMs[(kPolls * 95 + 99) / 100 - 1], warmPinnedQueries, invalidationMs,
observer.aggregateQueries_.load());
std::cout.flush();
}
}
TEST_CASE_METHOD(ReferenceCounterFixture, "ReferenceCounter concurrent operations",
"[storage][refcount][concurrent][catch2]") {
const size_t numThreads = 10;
const size_t opsPerThread = 100;
const std::string sharedHash = generateHash(700);
std::vector<std::thread> threads;
std::atomic<size_t> successCount{0};
// Each thread increments the same hash
for (size_t t = 0; t < numThreads; ++t) {
threads.emplace_back([this, &successCount, &sharedHash]() {
for (size_t i = 0; i < opsPerThread; ++i) {
auto result = refCounter->increment(sharedHash, 1024);
if (result.has_value()) {
successCount++;
}
}
});
}
// Wait for all threads
for (auto& t : threads) {
t.join();
}
// Verify final count
auto count = refCounter->getRefCount(sharedHash);
REQUIRE(count.has_value());
CHECK(count.value() == successCount.load());
CHECK(count.value() == numThreads * opsPerThread);
}
TEST_CASE_METHOD(ReferenceCounterFixture, "ReferenceCounter concurrent transactions",
"[storage][refcount][concurrent][transaction][catch2]") {
const size_t numThreads = 5;
const size_t hashesPerThread = 20;
std::vector<std::thread> threads;
std::atomic<size_t> commitCount{0};
std::atomic<size_t> errorCount{0};
for (size_t t = 0; t < numThreads; ++t) {
threads.emplace_back([this, &commitCount, &errorCount, t]() {
try {
auto txn = refCounter->beginTransaction();
if (!txn) {
errorCount++;
return;
}
// Each thread works on its own set of hashes
for (size_t i = 0; i < hashesPerThread; ++i) {
auto hash = generateHash(800 + t * 100 + i);
txn->increment(hash, 2048);
}
auto result = txn->commit();
if (result.has_value()) {
commitCount++;
} else {
errorCount++;
}
} catch (const std::exception&) {
errorCount++;
}
});
}
for (auto& t : threads) {
t.join();
}
CHECK(errorCount.load() == 0u);
CHECK(commitCount.load() == numThreads);
// Verify all hashes were incremented correctly
for (size_t t = 0; t < numThreads; ++t) {
for (size_t i = 0; i < hashesPerThread; ++i) {
auto hash = generateHash(800 + t * 100 + i);
auto count = refCounter->getRefCount(hash);
REQUIRE(count.has_value());
CHECK(count.value() == 1u);
}
}
}
TEST_CASE_METHOD(ReferenceCounterFixture, "ReferenceCounter maintenance operations",
"[storage][refcount][maintenance][catch2]") {
// Add some data
for (int i = 0; i < 100; ++i) {
refCounter->increment(generateHash(900 + i), 1024);
}
// Test vacuum
auto result = refCounter->vacuum();
CHECK(result.has_value());
// Test checkpoint
result = refCounter->checkpoint();
CHECK(result.has_value());
// Test analyze
result = refCounter->analyze();
CHECK(result.has_value());
// Test integrity check
auto integrityResult = refCounter->verifyIntegrity();
REQUIRE(integrityResult.has_value());
CHECK(integrityResult.value());
}
TEST_CASE_METHOD(ReferenceCounterFixture, "ReferenceCounter backup restore",
"[storage][refcount][backup][catch2]") {
// Add some data
const std::string hash1 = generateHash(1000);
const std::string hash2 = generateHash(1001);
refCounter->increment(hash1, 1024);
refCounter->increment(hash2, 2048);
refCounter->increment(hash2, 2048); // Count = 2
// Create backup
auto backupPath = std::filesystem::temp_directory_path() / "yams_backup_catch2.db";
auto result = refCounter->backup(backupPath);
REQUIRE(result.has_value());
// Modify data
refCounter->decrement(hash1);
refCounter->increment(generateHash(1002), 4096);
// Restore from backup
result = refCounter->restore(backupPath);
REQUIRE(result.has_value());
// Verify restored state
auto count1 = refCounter->getRefCount(hash1);
REQUIRE(count1.has_value());
CHECK(count1.value() == 1u); // Original value
auto count2 = refCounter->getRefCount(hash2);
REQUIRE(count2.has_value());
CHECK(count2.value() == 2u); // Original value
auto count3 = refCounter->getRefCount(generateHash(1002));
REQUIRE(count3.has_value());
CHECK(count3.value() == 0u); // Not in backup
// Cleanup
std::filesystem::remove(backupPath);
}
TEST_CASE_METHOD(ReferenceCounterFixture, "ReferenceCounter backup restore error paths",
"[storage][refcount][backup][edge][catch2]") {
auto missingParent = std::filesystem::temp_directory_path() /
std::format("yams_refcount_missing_parent_{}",
std::chrono::system_clock::now().time_since_epoch().count()) /
"backup.db";
auto backupResult = refCounter->backup(missingParent);
CHECK_FALSE(backupResult.has_value());
CHECK(backupResult.error().code == yams::ErrorCode::DatabaseError);
auto missingBackup = std::filesystem::temp_directory_path() /
std::format("yams_refcount_missing_backup_{}.db",
std::chrono::system_clock::now().time_since_epoch().count());
auto restoreResult = refCounter->restore(missingBackup);
CHECK_FALSE(restoreResult.has_value());
CHECK(restoreResult.error().code == yams::ErrorCode::DatabaseError);
}
TEST_CASE("ReferenceCounter restore rejects corrupt database image",
"[storage][refcount][backup][edge][catch2]") {
auto tempDir = std::filesystem::temp_directory_path();
auto stamp = std::chrono::system_clock::now().time_since_epoch().count();
auto dbPath = tempDir / std::format("yams_refcount_corrupt_restore_{}.db", stamp);
auto corruptPath = tempDir / std::format("yams_refcount_corrupt_restore_src_{}.db", stamp);
auto cleanup = [&] {
std::error_code ec;
std::filesystem::remove(dbPath, ec);
std::filesystem::remove(dbPath.string() + "-wal", ec);
std::filesystem::remove(dbPath.string() + "-shm", ec);
std::filesystem::remove(corruptPath, ec);
};
cleanup();
{
ReferenceCounter::Config config{
.databasePath = dbPath, .enableWAL = true, .enableStatistics = true};
ReferenceCounter counter(std::move(config));
{
std::ofstream corrupt(corruptPath.string(), std::ios::binary | std::ios::trunc);
REQUIRE(corrupt.good());
corrupt << "not a sqlite database";
}
auto restoreResult = counter.restore(corruptPath);
CHECK_FALSE(restoreResult.has_value());
CHECK(restoreResult.error().code == yams::ErrorCode::DatabaseError);
}
cleanup();
}
TEST_CASE("ReferenceCounter factory returns null for invalid database path",
"[storage][refcount][factory][edge][catch2]") {
auto tempRoot = std::filesystem::temp_directory_path();
auto blockerPath =
tempRoot / std::format("yams_refcount_blocker_{}",
std::chrono::system_clock::now().time_since_epoch().count());
{
std::ofstream blocker(blockerPath.string(), std::ios::binary);
REQUIRE(blocker.good());
blocker << "x";
}
ReferenceCounter::Config config{.databasePath = blockerPath / "nested" / "refcount.db",
.enableWAL = true,
.enableStatistics = true,
.cacheSize = 1000,
.busyTimeout = 1000,
.enableAuditLog = false};
auto counter = createReferenceCounter(std::move(config));
CHECK(counter == nullptr);
std::error_code ec;
std::filesystem::remove(blockerPath, ec);
}
TEST_CASE("ReferenceCounter upgrades schema discovered via YAMS_DATA_DIR",
"[storage][refcount][bootstrap][catch2]") {
yams::test::TempDirGuard tempDir("yams_refcount_schema_");
const auto dbPath = tempDir.path() / "refs.db";
const auto schemaPath = tempDir.path() / "reference_schema.sql";
yams::test::write_file(schemaPath, R"(
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS block_references (
block_hash TEXT PRIMARY KEY,
ref_count INTEGER NOT NULL DEFAULT 0,
block_size INTEGER NOT NULL,
created_at INTEGER NOT NULL,
last_accessed INTEGER NOT NULL,
metadata TEXT,
CHECK (ref_count >= 0),
CHECK (block_size > 0)
);
CREATE TABLE IF NOT EXISTS ref_transactions (
transaction_id INTEGER PRIMARY KEY AUTOINCREMENT,
start_timestamp INTEGER NOT NULL,
commit_timestamp INTEGER,
state TEXT NOT NULL DEFAULT 'PENDING'
);
CREATE TABLE IF NOT EXISTS ref_transaction_ops (
op_id INTEGER PRIMARY KEY AUTOINCREMENT,
transaction_id INTEGER NOT NULL,
block_hash TEXT NOT NULL,
operation TEXT NOT NULL,
delta INTEGER NOT NULL DEFAULT 1,
block_size INTEGER,
timestamp INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS ref_statistics (
stat_name TEXT PRIMARY KEY,
stat_value INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
INSERT OR IGNORE INTO ref_statistics(stat_name, stat_value, updated_at) VALUES
('total_blocks', 0, strftime('%s', 'now')),
('total_references', 0, strftime('%s', 'now')),
('total_bytes', 0, strftime('%s', 'now')),
('transactions_completed', 0, strftime('%s', 'now')),
('transactions_rolled_back', 0, strftime('%s', 'now'));
)");
yams::test::ScopedEnvVar dataDirEnv("YAMS_DATA_DIR",
std::optional<std::string>{tempDir.path().string()});
{
ReferenceCounter::Config config{.databasePath = dbPath,
.enableWAL = true,
.enableStatistics = true,
.cacheSize = 1000,
.busyTimeout = 1000,
.enableAuditLog = false};
ReferenceCounter counter(std::move(config));
CHECK(columnExists(dbPath, "block_references", "uncompressed_size"));
CHECK(
queryInt64(
dbPath,
"SELECT COUNT(*) FROM ref_statistics WHERE stat_name='total_uncompressed_bytes'") ==
1);
const auto hash = std::format("{:064x}", 0xABC);
REQUIRE(counter.increment(hash, 512, 2048).has_value());
REQUIRE(counter.decrement(hash).has_value());
auto stats = counter.getStats();
REQUIRE(stats.has_value());
CHECK(stats.value().totalBlocks == 1u);
CHECK(stats.value().totalReferences == 0u);
CHECK(stats.value().totalBytes == 512u);
CHECK(stats.value().totalUncompressedBytes == 2048u);
CHECK(stats.value().unreferencedBlocks == 1u);
CHECK(stats.value().unreferencedBytes == 512u);
}
}
TEST_CASE("ReferenceCounter upgrades existing legacy database state on open",
"[storage][refcount][migration][catch2]") {
yams::test::TempDirGuard tempDir("yams_refcount_legacy_");
const auto dbPath = tempDir.path() / "refs.db";
const auto legacyHash = std::format("{:064x}", 0x1234);
{
SqliteHandle db(dbPath);
execSql(db.db, R"(
CREATE TABLE block_references (
block_hash TEXT PRIMARY KEY,
ref_count INTEGER NOT NULL DEFAULT 0,
block_size INTEGER NOT NULL,
created_at INTEGER NOT NULL,
last_accessed INTEGER NOT NULL,
metadata TEXT
);
)");
execSql(db.db, R"(
CREATE TABLE ref_statistics (
stat_name TEXT PRIMARY KEY,
stat_value INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
)");
execSql(db.db, std::format(R"(
INSERT INTO block_references
(block_hash, ref_count, block_size, created_at, last_accessed, metadata)
VALUES ('{}', 1, 111, strftime('%s', 'now'), strftime('%s', 'now'), NULL);
)",
legacyHash));
execSql(db.db, R"(
INSERT INTO ref_statistics(stat_name, stat_value, updated_at) VALUES
('total_blocks', 0, strftime('%s', 'now')),
('total_references', 0, strftime('%s', 'now')),
('total_bytes', 0, strftime('%s', 'now')),
('transactions_completed', 0, strftime('%s', 'now')),
('transactions_rolled_back', 0, strftime('%s', 'now'));
)");
}
{
ReferenceCounter::Config config{.databasePath = dbPath,
.enableWAL = true,
.enableStatistics = true,
.cacheSize = 1000,
.busyTimeout = 1000,
.enableAuditLog = false};
ReferenceCounter counter(std::move(config));
CHECK(columnExists(dbPath, "block_references", "uncompressed_size"));
CHECK(
queryInt64(
dbPath,
"SELECT COUNT(*) FROM ref_statistics WHERE stat_name='total_uncompressed_bytes'") ==
1);
auto legacyCount = counter.getRefCount(legacyHash);
REQUIRE(legacyCount.has_value());
CHECK(legacyCount.value() == 1u);
auto stats = counter.getStats();
REQUIRE(stats.has_value());
CHECK(stats.value().totalBlocks == 1u);
CHECK(stats.value().totalReferences == 1u);
CHECK(stats.value().totalBytes == 111u);
CHECK(stats.value().totalUncompressedBytes == 111u);
const auto newHash = std::format("{:064x}", 0x5678);
REQUIRE(counter.increment(newHash, 50, 75).has_value());
stats = counter.getStats();
REQUIRE(stats.has_value());
CHECK(stats.value().totalBlocks == 2u);
CHECK(stats.value().totalReferences == 2u);
CHECK(stats.value().totalBytes == 161u);
CHECK(stats.value().totalUncompressedBytes == 186u);
}
}
TEST_CASE_METHOD(ReferenceCounterFixture,
"ReferenceCounter pruneReference removes multiple rows in one commit",
"[storage][refcount][transaction][prune][catch2]") {
std::vector<std::string> hashes;
for (int i = 0; i < 3; ++i) {
hashes.push_back(generateHash(950 + i));
REQUIRE(refCounter->increment(hashes.back(), 256, 512).has_value());
REQUIRE(refCounter->decrement(hashes.back()).has_value());
}
auto before = refCounter->getStats();
REQUIRE(before.has_value());
CHECK(before.value().totalBlocks == 3u);
CHECK(before.value().unreferencedBlocks == 3u);
auto txn = refCounter->beginTransaction();
REQUIRE(txn != nullptr);
for (const auto& hash : hashes) {
txn->pruneReference(hash);
}
REQUIRE(txn->commit().has_value());
auto after = refCounter->getStats();
REQUIRE(after.has_value());
CHECK(after.value().totalBlocks == 0u);
CHECK(after.value().totalReferences == 0u);
CHECK(after.value().totalBytes == 0u);
CHECK(after.value().totalUncompressedBytes == 0u);
CHECK(after.value().unreferencedBlocks == 0u);
CHECK(after.value().unreferencedBytes == 0u);
auto unreferenced = refCounter->getUnreferencedBlocks(10, std::chrono::seconds(0));
REQUIRE(unreferenced.has_value());
CHECK(unreferenced.value().empty());
}
TEST_CASE_METHOD(ReferenceCounterFixture, "ReferenceCounter async batch operations",
"[storage][refcount][async][catch2]") {
const size_t batchSize = 50;
std::vector<std::string> hashes;
for (size_t i = 0; i < batchSize; ++i) {
hashes.push_back(generateHash(1100 + i));
}
// Async batch increment
auto future = refCounter->incrementBatchAsync(hashes, 1024);
auto result = future.get();
REQUIRE(result.has_value());
// Verify all incremented
for (const auto& hash : hashes) {
auto count = refCounter->getRefCount(hash);
REQUIRE(count.has_value());
CHECK(count.value() == 1u);
}
}
// Garbage Collector tests
TEST_CASE_METHOD(GarbageCollectorFixture, "GarbageCollector basic collection",
"[storage][gc][catch2]") {
// Store some blocks
std::vector<std::byte> data(1024, std::byte{42});
std::vector<std::string> hashes;
for (int i = 0; i < 5; ++i) {
auto hash = generateHash(i);
hashes.push_back(hash);
// Store in storage engine
auto storeResult = storageEngine->store(hash, data);
REQUIRE(storeResult.has_value());
// Add reference
auto refResult = refCounter->increment(hash, data.size());
REQUIRE(refResult.has_value());
}
// Remove references from some blocks
for (int i = 0; i < 3; ++i) {
auto refResult = refCounter->decrement(hashes[i]);
REQUIRE(refResult.has_value());
}
// Run garbage collection (with 0 second min age for testing)
GCOptions options{
.maxBlocksPerRun = 10, .minAgeSeconds = 0, .dryRun = false, .progressCallback = nullptr};
auto result = gc->collect(options);
REQUIRE(result.has_value());
const auto& stats = result.value();
CHECK(stats.blocksScanned == 3u); // 3 unreferenced blocks
CHECK(stats.blocksDeleted == 3u);
CHECK(stats.bytesReclaimed == 3u * data.size());
CHECK(stats.errors.empty());
// Verify unreferenced blocks were deleted
for (int i = 0; i < 3; ++i) {
auto exists = storageEngine->exists(hashes[i]);
REQUIRE(exists.has_value());
CHECK_FALSE(exists.value());
}
// Verify referenced blocks still exist
for (int i = 3; i < 5; ++i) {
auto exists = storageEngine->exists(hashes[i]);
REQUIRE(exists.has_value());
CHECK(exists.value());
}
}
TEST_CASE_METHOD(GarbageCollectorFixture, "GarbageCollector dry run collection",
"[storage][gc][catch2]") {
// Store some unreferenced blocks
std::vector<std::byte> data(1024, std::byte{42});
std::vector<std::string> hashes;
for (int i = 0; i < 3; ++i) {
auto hash = generateHash(100 + i);
hashes.push_back(hash);
// Store in storage engine
storageEngine->store(hash, data);
// Add and remove reference
refCounter->increment(hash, data.size());
refCounter->decrement(hash);
}
// Run dry-run collection
std::vector<std::string> progressHashes;
GCOptions options{
.maxBlocksPerRun = 10,
.minAgeSeconds = 0,
.dryRun = true,
.progressCallback = [&progressHashes](const std::string& hash, size_t /*count*/) {
progressHashes.push_back(hash);
}};
auto result = gc->collect(options);
REQUIRE(result.has_value());
const auto& stats = result.value();
CHECK(stats.blocksScanned == 3u);
CHECK(stats.blocksDeleted == 0u); // dry-run: counted as scanned, not deleted
CHECK(stats.bytesReclaimed == 0u); // dry-run: no bytes actually reclaimed
CHECK(stats.errors.empty());
CHECK(progressHashes.size() == hashes.size());
// Verify blocks still exist (dry run)
for (const auto& hash : hashes) {
auto exists = storageEngine->exists(hash);
REQUIRE(exists.has_value());
CHECK(exists.value());
auto count = refCounter->getRefCount(hash);
REQUIRE(count.has_value());
CHECK(count.value() == 0u);
}
auto lastStats = gc->getLastStats();
CHECK(lastStats.blocksScanned == stats.blocksScanned);
CHECK(lastStats.blocksDeleted == stats.blocksDeleted);
CHECK(lastStats.bytesReclaimed == stats.bytesReclaimed);
}
TEST_CASE_METHOD(GarbageCollectorFixture,
"GarbageCollector updates gc statistics only for committed deletions",
"[storage][gc][stats][catch2]") {
std::vector<std::byte> data(512, std::byte{7});
const auto hashA = generateHash(140);
const auto hashB = generateHash(141);
for (const auto& hash : {hashA, hashB}) {
REQUIRE(storageEngine->store(hash, data).has_value());
REQUIRE(refCounter->increment(hash, data.size()).has_value());
REQUIRE(refCounter->decrement(hash).has_value());
}
GCOptions dryRun{
.maxBlocksPerRun = 10, .minAgeSeconds = 0, .dryRun = true, .progressCallback = nullptr};
auto dryRunResult = gc->collect(dryRun);
REQUIRE(dryRunResult.has_value());
CHECK(queryInt64(testDbPath,
"SELECT stat_value FROM ref_statistics WHERE stat_name='gc_runs'") == 0);
CHECK(queryInt64(
testDbPath,
"SELECT stat_value FROM ref_statistics WHERE stat_name='gc_blocks_collected'") == 0);
CHECK(queryInt64(
testDbPath,
"SELECT stat_value FROM ref_statistics WHERE stat_name='gc_bytes_reclaimed'") == 0);
GCOptions realRun{
.maxBlocksPerRun = 10, .minAgeSeconds = 0, .dryRun = false, .progressCallback = nullptr};
auto realRunResult = gc->collect(realRun);
REQUIRE(realRunResult.has_value());
CHECK(realRunResult.value().blocksDeleted == 2u);
CHECK(queryInt64(testDbPath,
"SELECT stat_value FROM ref_statistics WHERE stat_name='gc_runs'") == 1);
CHECK(queryInt64(
testDbPath,
"SELECT stat_value FROM ref_statistics WHERE stat_name='gc_blocks_collected'") == 2);
CHECK(
queryInt64(testDbPath,
"SELECT stat_value FROM ref_statistics WHERE stat_name='gc_bytes_reclaimed'") ==
static_cast<int64_t>(2 * data.size()));
}
TEST_CASE_METHOD(GarbageCollectorFixture, "GarbageCollector respects minimum age horizon",
"[storage][gc][edge][catch2]") {
std::vector<std::byte> data(1024, std::byte{42});
auto hash = generateHash(150);
REQUIRE(storageEngine->store(hash, data).has_value());
REQUIRE(refCounter->increment(hash, data.size()).has_value());
REQUIRE(refCounter->decrement(hash).has_value());
GCOptions options{
.maxBlocksPerRun = 10, .minAgeSeconds = 3600, .dryRun = false, .progressCallback = nullptr};
auto result = gc->collect(options);
REQUIRE(result.has_value());
const auto& stats = result.value();
CHECK(stats.blocksScanned == 0u);
CHECK(stats.blocksDeleted == 0u);
CHECK(stats.bytesReclaimed == 0u);
CHECK(stats.errors.empty());
auto exists = storageEngine->exists(hash);
REQUIRE(exists.has_value());
CHECK(exists.value());
auto count = refCounter->getRefCount(hash);
REQUIRE(count.has_value());
CHECK(count.value() == 0u);
}
TEST_CASE_METHOD(GarbageCollectorFixture,
"GarbageCollector preserves failure evidence and continues after missing block",
"[storage][gc][edge][catch2]") {
std::vector<std::byte> data(1024, std::byte{42});
auto presentHash = generateHash(175);
auto missingHash = generateHash(176);
REQUIRE(storageEngine->store(presentHash, data).has_value());
REQUIRE(refCounter->increment(presentHash, data.size()).has_value());
REQUIRE(refCounter->decrement(presentHash).has_value());
REQUIRE(refCounter->increment(missingHash, data.size()).has_value());
REQUIRE(refCounter->decrement(missingHash).has_value());
GCOptions options{
.maxBlocksPerRun = 10, .minAgeSeconds = 0, .dryRun = false, .progressCallback = nullptr};
auto result = gc->collect(options);
REQUIRE(result.has_value());
const auto& stats = result.value();
CHECK(stats.blocksScanned == 2u);
CHECK(stats.blocksDeleted == 1u);
CHECK(stats.bytesReclaimed == data.size());
REQUIRE(stats.errors.size() == 1u);
CHECK(stats.errors.front().find(missingHash) != std::string::npos);
auto presentExists = storageEngine->exists(presentHash);
REQUIRE(presentExists.has_value());
CHECK_FALSE(presentExists.value());
auto missingExists = storageEngine->exists(missingHash);
REQUIRE(missingExists.has_value());
CHECK_FALSE(missingExists.value());
auto presentCount = refCounter->getRefCount(presentHash);
REQUIRE(presentCount.has_value());
CHECK(presentCount.value() == 0u);
auto missingCount = refCounter->getRefCount(missingHash);
REQUIRE(missingCount.has_value());
CHECK(missingCount.value() == 0u);
auto lastStats = gc->getLastStats();
CHECK(lastStats.blocksScanned == stats.blocksScanned);
CHECK(lastStats.blocksDeleted == stats.blocksDeleted);
CHECK(lastStats.errors == stats.errors);
}
TEST_CASE_METHOD(GarbageCollectorFixture, "GarbageCollector async collection",
"[storage][gc][async][catch2]") {
// Create unreferenced block
std::vector<std::byte> data(1024, std::byte{42});
auto hash = generateHash(200);
storageEngine->store(hash, data);
refCounter->increment(hash, data.size());
refCounter->decrement(hash);
// Run async collection
GCOptions options{
.maxBlocksPerRun = 10, .minAgeSeconds = 0, .dryRun = false, .progressCallback = nullptr};
auto future = gc->collectAsync(options);
auto result = future.get();
REQUIRE(result.has_value());
const auto& stats = result.value();
CHECK(stats.blocksDeleted == 1u);
}
TEST_CASE_METHOD(GarbageCollectorFixture, "GarbageCollector concurrent collection prevented",
"[storage][gc][concurrent][catch2]") {
// Create an unreferenced block so the garbage collector has work to do
std::vector<std::byte> data(1024, std::byte{42});
auto hash = generateHash(300);
storageEngine->store(hash, data);
refCounter->increment(hash, data.size());
refCounter->decrement(hash);
// Start a long-running collection
std::promise<void> collectionStartedPromise;
auto collectionStartedFuture = collectionStartedPromise.get_future();
std::atomic<bool> allowCompletion{false};
std::atomic<int> callbackCount{0};
GCOptions options{.maxBlocksPerRun = 10,
.minAgeSeconds = 0,
.dryRun = false,
.progressCallback = [&collectionStartedPromise, &allowCompletion,
&callbackCount](const std::string&, size_t) {
int count = callbackCount.fetch_add(1);
if (count == 0) {
// First call - signal that collection has started
collectionStartedPromise.set_value();
}
// Block until allowed to complete to ensure concurrent attempt fails
while (!allowCompletion) {
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
}};
// Start first collection
auto future1 = gc->collectAsync(options);
// Wait for it to start with timeout
auto status = collectionStartedFuture.wait_for(std::chrono::seconds(2));
REQUIRE(status != std::future_status::timeout);
// Small delay to ensure the async collection is actually running
std::this_thread::sleep_for(std::chrono::milliseconds(10));
// Try to start second collection - should fail immediately
auto result2 = gc->collect(options);
CHECK_FALSE(result2.has_value());
CHECK(result2.error().code == yams::ErrorCode::OperationInProgress);
// Allow first collection to complete
allowCompletion = true;
// Wait for completion with timeout
auto completeStatus = future1.wait_for(std::chrono::seconds(5));
REQUIRE(completeStatus != std::future_status::timeout);
auto result1 = future1.get();
CHECK(result1.has_value());
}
TEST_CASE_METHOD(GarbageCollectorFixture, "GarbageCollector get last stats",
"[storage][gc][stats][catch2]") {
// Initially empty stats
auto stats = gc->getLastStats();
CHECK(stats.blocksScanned == 0u);
CHECK(stats.blocksDeleted == 0u);
// Run collection
GCOptions options{
.maxBlocksPerRun = 10, .minAgeSeconds = 0, .dryRun = true, .progressCallback = nullptr};
gc->collect(options);
// Get updated stats
stats = gc->getLastStats();
static_cast<void>(stats.blocksScanned);
CHECK(stats.duration.count() >= 0); // Duration can be 0 for very fast operations
}
TEST_CASE_METHOD(GarbageCollectorFixture, "GarbageCollector isCollecting reflects active state",
"[storage][gc][isCollecting][catch2]") {
CHECK_FALSE(gc->isCollecting());
GCOptions options{
.maxBlocksPerRun = 10, .minAgeSeconds = 0, .dryRun = true, .progressCallback = nullptr};
auto future = gc->collectAsync(options);
std::this_thread::sleep_for(std::chrono::milliseconds(10));
bool wasCollecting = gc->isCollecting();
future.wait();
CHECK_FALSE(gc->isCollecting());
(void)wasCollecting;
}
TEST_CASE_METHOD(GarbageCollectorFixture, "GarbageCollector scheduleCollection starts and stops",
"[storage][gc][schedule][catch2]") {
GCOptions options{
.maxBlocksPerRun = 5, .minAgeSeconds = 0, .dryRun = true, .progressCallback = nullptr};
CHECK_FALSE(gc->isCollecting());
gc->scheduleCollection(std::chrono::seconds(1), options);
std::this_thread::sleep_for(std::chrono::milliseconds(100));
gc->stopScheduledCollection();
std::this_thread::sleep_for(std::chrono::milliseconds(200));
CHECK_FALSE(gc->isCollecting());
}
TEST_CASE("rebuildReferenceDatabase scans storage and populates refs.db",
"[storage][gc][rebuild][catch2]") {
auto tempDir = std::filesystem::temp_directory_path();
auto dbPath =
tempDir / std::format("yams_rebuild_refs_{}.db",
std::chrono::system_clock::now().time_since_epoch().count());
auto storagePath =
tempDir / std::format("yams_rebuild_storage_{}",
std::chrono::system_clock::now().time_since_epoch().count());
std::filesystem::create_directories(storagePath);
// Create a shard with test blocks
auto shardDir = storagePath / "aa";
std::filesystem::create_directories(shardDir);
std::string testHash = std::string(62, 'a') + "bb";
std::ofstream block(shardDir / (std::string(62, 'a') + "bb"));
block.write("test_block_content", 18);
block.close();
auto cleanup = [&] {
std::filesystem::remove_all(storagePath);
std::filesystem::remove(dbPath);
std::filesystem::remove(dbPath.string() + "-wal");
std::filesystem::remove(dbPath.string() + "-shm");
};
auto result = rebuildReferenceDatabase(dbPath, storagePath);
REQUIRE(result.has_value());
// Verify refs.db was created and has the expected entry
ReferenceCounter::Config config{
.databasePath = dbPath, .enableWAL = true, .enableStatistics = true};
auto refCounter = createReferenceCounter(config);
auto count = refCounter->getRefCount(testHash);
REQUIRE(count.has_value());
CHECK(count.value() >= 1u);
refCounter.reset();
cleanup();
}
TEST_CASE("rebuildReferenceDatabase handles empty storage gracefully",
"[storage][gc][rebuild][catch2]") {
auto tempDir = std::filesystem::temp_directory_path();
auto dbPath =
tempDir / std::format("yams_rebuild_empty_{}.db",
std::chrono::system_clock::now().time_since_epoch().count());
auto storagePath =
tempDir / std::format("yams_rebuild_empty_storage_{}",
std::chrono::system_clock::now().time_since_epoch().count());
std::filesystem::create_directories(storagePath);
auto cleanup = [&] {
std::filesystem::remove_all(storagePath);
std::filesystem::remove(dbPath);
std::filesystem::remove(dbPath.string() + "-wal");
std::filesystem::remove(dbPath.string() + "-shm");
};
auto result = rebuildReferenceDatabase(dbPath, storagePath);
REQUIRE(result.has_value());
cleanup();
}
TEST_CASE("ReferenceCounter recovery after forced close preserves committed data",
"[storage][refcount][crash][catch2]") {
auto tempDir = std::filesystem::temp_directory_path();
auto stamp = std::chrono::system_clock::now().time_since_epoch().count();
auto dbPath = tempDir / std::format("yams_refcount_crash_{}.db", stamp);
auto cleanup = [&] {
std::error_code ec;
std::filesystem::remove(dbPath, ec);
std::filesystem::remove(dbPath.string() + "-wal", ec);
std::filesystem::remove(dbPath.string() + "-shm", ec);
};
cleanup();
std::string committedHash;
{
ReferenceCounter::Config config{
.databasePath = dbPath, .enableWAL = true, .enableStatistics = true};
ReferenceCounter counter(std::move(config));
committedHash = std::format("{:064x}", 1);
REQUIRE(counter.increment(committedHash, 1024, 1024).has_value());
}
REQUIRE(std::filesystem::exists(dbPath));
{
ReferenceCounter::Config config{
.databasePath = dbPath, .enableWAL = true, .enableStatistics = true};
ReferenceCounter counter(std::move(config));
auto count = counter.getRefCount(committedHash);
REQUIRE(count.has_value());
CHECK(count.value() >= 1u);
auto integrity = counter.verifyIntegrity();
REQUIRE(integrity.has_value());
CHECK(integrity.value());
}
cleanup();
}
TEST_CASE("ReferenceCounter recovers from uncommitted transaction after forced close",
"[storage][refcount][crash][catch2]") {
auto tempDir = std::filesystem::temp_directory_path();
auto stamp = std::chrono::system_clock::now().time_since_epoch().count();
auto dbPath = tempDir / std::format("yams_refcount_crash2_{}.db", stamp);
auto cleanup = [&] {
std::error_code ec;
std::filesystem::remove(dbPath, ec);
std::filesystem::remove(dbPath.string() + "-wal", ec);
std::filesystem::remove(dbPath.string() + "-shm", ec);
};
cleanup();
std::string committedHash;
std::string uncommittedHash;
{
ReferenceCounter::Config config{
.databasePath = dbPath, .enableWAL = true, .enableStatistics = true};
ReferenceCounter counter(std::move(config));
committedHash = std::format("{:064x}", 10);
REQUIRE(counter.increment(committedHash, 512, 512).has_value());
uncommittedHash = std::format("{:064x}", 11);
auto txn = counter.beginTransaction();
REQUIRE(txn != nullptr);
txn->increment(uncommittedHash, 256, 256);
}
{
ReferenceCounter::Config config{
.databasePath = dbPath, .enableWAL = true, .enableStatistics = true};
ReferenceCounter counter(std::move(config));
auto count = counter.getRefCount(committedHash);
REQUIRE(count.has_value());
CHECK(count.value() >= 1u);
auto orphanCount = counter.getRefCount(uncommittedHash);
REQUIRE(orphanCount.has_value());
CHECK(orphanCount.value() == 0u);
auto integrity = counter.verifyIntegrity();
REQUIRE(integrity.has_value());
CHECK(integrity.value());
}
cleanup();
}
TEST_CASE("ReferenceCounter rejects corrupted database file on open",
"[storage][refcount][crash][catch2]") {
auto tempDir = std::filesystem::temp_directory_path();
auto stamp = std::chrono::system_clock::now().time_since_epoch().count();
auto dbPath = tempDir / std::format("yams_refcount_corrupt_open_{}_invalid.db", stamp);
auto cleanup = [&] {
std::error_code ec;
std::filesystem::remove(dbPath, ec);
std::filesystem::remove(dbPath.string() + "-wal", ec);
std::filesystem::remove(dbPath.string() + "-shm", ec);
};
cleanup();
{
std::ofstream corrupt(dbPath.string(), std::ios::binary | std::ios::trunc);
REQUIRE(corrupt.good());
corrupt << "not a valid sqlite3 database file";
corrupt.close();
}
ReferenceCounter::Config config{
.databasePath = dbPath, .enableWAL = true, .enableStatistics = true};
REQUIRE_THROWS_AS(ReferenceCounter(std::move(config)), std::exception);
cleanup();
}
TEST_CASE("ReferenceCounter returns consistent stats after interrupted commit recovery",
"[storage][refcount][crash][stats][catch2]") {
auto tempDir = std::filesystem::temp_directory_path();
auto stamp = std::chrono::system_clock::now().time_since_epoch().count();
auto dbPath = tempDir / std::format("yams_refcount_crash3_{}.db", stamp);
auto cleanup = [&] {
std::error_code ec;
std::filesystem::remove(dbPath, ec);
std::filesystem::remove(dbPath.string() + "-wal", ec);
std::filesystem::remove(dbPath.string() + "-shm", ec);
};
cleanup();
{
ReferenceCounter::Config config{
.databasePath = dbPath, .enableWAL = true, .enableStatistics = true};
ReferenceCounter counter(std::move(config));
for (int i = 0; i < 10; ++i) {
REQUIRE(counter.increment(std::format("{:064x}", 100 + i), 1024, 1024).has_value());
}
auto txn = counter.beginTransaction();
REQUIRE(txn != nullptr);
txn->increment(std::format("{:064x}", 200), 512, 512);
txn->decrement(std::format("{:064x}", 100));
}
{
ReferenceCounter::Config config{
.databasePath = dbPath, .enableWAL = true, .enableStatistics = true};
ReferenceCounter counter(std::move(config));
auto stats = counter.getStats();
REQUIRE(stats.has_value());
CHECK(stats.value().totalBlocks == 10u);
auto block0 = counter.hasReferences(std::format("{:064x}", 100));
REQUIRE(block0.has_value());
CHECK(block0.value());
auto block200 = counter.hasReferences(std::format("{:064x}", 200));
REQUIRE(block200.has_value());
CHECK_FALSE(block200.value());
}
cleanup();
}
TEST_CASE("ReferenceCounter multiple PENDING transactions survive force close",
"[storage][refcount][crash][catch2]") {
auto tempDir = std::filesystem::temp_directory_path();
auto stamp = std::chrono::system_clock::now().time_since_epoch().count();
auto dbPath = tempDir / std::format("yams_refcount_crash4_{}.db", stamp);
auto cleanup = [&] {
std::error_code ec;
std::filesystem::remove(dbPath, ec);
std::filesystem::remove(dbPath.string() + "-wal", ec);
std::filesystem::remove(dbPath.string() + "-shm", ec);
};
cleanup();
const std::string baseline = std::format("{:064x}", 500);
{
ReferenceCounter::Config config{
.databasePath = dbPath, .enableWAL = true, .enableStatistics = true};
ReferenceCounter counter(std::move(config));
REQUIRE(counter.increment(baseline, 8192, 8192).has_value());
std::vector<std::unique_ptr<IReferenceCounter::ITransaction>> pending;
for (int i = 0; i < 5; ++i) {
auto txn = counter.beginTransaction();
REQUIRE(txn != nullptr);
txn->increment(std::format("{:064x}", 600 + i), 256, 256);
pending.push_back(std::move(txn));
}
}
{
ReferenceCounter::Config config{
.databasePath = dbPath, .enableWAL = true, .enableStatistics = true};
ReferenceCounter counter(std::move(config));
auto count = counter.getRefCount(baseline);
REQUIRE(count.has_value());
CHECK(count.value() >= 1u);
for (int i = 0; i < 5; ++i) {
auto orphan = counter.getRefCount(std::format("{:064x}", 600 + i));
REQUIRE(orphan.has_value());
CHECK(orphan.value() == 0u);
}
auto stats = counter.getStats();
REQUIRE(stats.has_value());
CHECK(stats.value().totalBlocks == 1u);
}
cleanup();
}
TEST_CASE("ReferenceCounter survives WAL-only checkpoint after forced close",
"[storage][refcount][crash][wal][catch2]") {
auto tempDir = std::filesystem::temp_directory_path();
auto stamp = std::chrono::system_clock::now().time_since_epoch().count();
auto dbPath = tempDir / std::format("yams_refcount_crash5_{}.db", stamp);
auto cleanup = [&] {
std::error_code ec;
std::filesystem::remove(dbPath, ec);
std::filesystem::remove(dbPath.string() + "-wal", ec);
std::filesystem::remove(dbPath.string() + "-shm", ec);
};
cleanup();
{
ReferenceCounter::Config config{
.databasePath = dbPath, .enableWAL = true, .enableStatistics = true};
ReferenceCounter counter(std::move(config));
for (int i = 0; i < 20; ++i) {
REQUIRE(counter.increment(std::format("{:064x}", 700 + i), 512, 512).has_value());
}
REQUIRE(counter.checkpoint().has_value());
}
REQUIRE(std::filesystem::exists(dbPath));
{
ReferenceCounter::Config config{
.databasePath = dbPath, .enableWAL = true, .enableStatistics = true};
ReferenceCounter counter(std::move(config));
for (int i = 0; i < 20; ++i) {
auto count = counter.getRefCount(std::format("{:064x}", 700 + i));
REQUIRE(count.has_value());
CHECK(count.value() >= 1u);
}
auto integrity = counter.verifyIntegrity();
REQUIRE(integrity.has_value());
CHECK(integrity.value());
}
cleanup();
}
TEST_CASE("ReferenceCounter init retries when the database is transiently locked",
"[storage][reference][lock]") {
const auto dbPath = std::filesystem::temp_directory_path() /
std::format("yams_refcount_lock_catch2_{}.db",
std::chrono::system_clock::now().time_since_epoch().count());
struct Cleanup {
std::filesystem::path p;
~Cleanup() {
std::filesystem::remove(p);
std::filesystem::remove(p.string() + "-wal");
std::filesystem::remove(p.string() + "-shm");
}
} cleanup{dbPath};
// Hold a write lock on the database so the first init attempt must wait.
sqlite3* blocker = nullptr;
REQUIRE(sqlite3_open(dbPath.string().c_str(), &blocker) == SQLITE_OK);
execSql(blocker, "BEGIN IMMEDIATE");
// Release the lock shortly after init starts so the retry path is exercised.
std::thread releaser([blocker] {
std::this_thread::sleep_for(std::chrono::milliseconds(400));
char* err = nullptr;
sqlite3_exec(blocker, "ROLLBACK", nullptr, nullptr, &err);
sqlite3_free(err);
sqlite3_close(blocker);
});
// Small busy timeout keeps the inner retries fast; the outer init retry
// must succeed once the lock is released.
ReferenceCounter::Config config{.databasePath = dbPath, .enableWAL = true, .busyTimeout = 10};
std::unique_ptr<ReferenceCounter> refCounter;
REQUIRE_NOTHROW(refCounter = std::make_unique<ReferenceCounter>(std::move(config)));
releaser.join();
}