Header-only C++26 module library for typed, file-backed tables with schema reflection, migrations, queries, and crash-safe commits. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
94 lines
3.5 KiB
C++
94 lines
3.5 KiB
C++
// SPDX-License-Identifier: LGPL-3.0-only
|
|
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
|
|
|
// Readers scanning (with tombstones present, so the per-row flag test is on)
|
|
// while one writer appends and erases. Every row a scan yields must be a
|
|
// published, live-at-some-point row with increasing ids, and nothing may
|
|
// read past the published slots. Guards the sentinel-based iteration and the
|
|
// tombstone flag against the Append race.
|
|
|
|
import Crafter.DB;
|
|
import std;
|
|
|
|
using namespace Crafter;
|
|
namespace fs = std::filesystem;
|
|
|
|
namespace {
|
|
int failures = 0;
|
|
|
|
void Check(bool condition, std::string_view what) {
|
|
if (!condition) {
|
|
std::println("FAIL: {}", what);
|
|
++failures;
|
|
}
|
|
}
|
|
|
|
// 16-byte rows: a power-of-two size, so a full extent ends exactly on a
|
|
// row boundary and the slot after the last published one can be the
|
|
// first byte of the unmapped reservation.
|
|
struct Tick {
|
|
std::uint64_t sequence;
|
|
std::uint64_t value;
|
|
static constexpr std::uint32_t version = 1;
|
|
static constexpr auto orderedBy = &Tick::sequence;
|
|
};
|
|
static_assert(DB::Layout<Tick>::rowSize == 24 || DB::Layout<Tick>::rowSize == 32);
|
|
}
|
|
|
|
int main() {
|
|
const fs::path dir = fs::temp_directory_path() / "crafter-db-concurrent";
|
|
fs::remove_all(dir);
|
|
DB::Table<Tick> table(dir / "ticks.cdb");
|
|
table.Append({ 0, 0 });
|
|
table.Append({ 1, 1 });
|
|
table.Erase(0); // a tombstone from the start: scans run with the flag test
|
|
|
|
std::atomic<bool> stop{ false };
|
|
std::atomic<std::uint64_t> scans{ 0 };
|
|
std::atomic<std::uint64_t> badOrder{ 0 };
|
|
std::atomic<std::uint64_t> badId{ 0 };
|
|
std::atomic<std::uint64_t> sawErased{ 0 };
|
|
|
|
const auto reader = [&] {
|
|
while (!stop.load(std::memory_order_relaxed)) {
|
|
std::uint64_t last = 0;
|
|
bool first = true;
|
|
const std::uint64_t slots = table.SlotCount();
|
|
for (DB::Row<Tick> row : table) {
|
|
const std::uint64_t id = row.Id();
|
|
if (!first && id <= last) badOrder.fetch_add(1, std::memory_order_relaxed);
|
|
if (id >= table.SlotCount()) badId.fetch_add(1, std::memory_order_relaxed);
|
|
if (row[&Tick::sequence] != id) badId.fetch_add(1, std::memory_order_relaxed);
|
|
first = false;
|
|
last = id;
|
|
}
|
|
if (slots > 2 && table.Rows(0, 1).Any()) sawErased.fetch_add(1, std::memory_order_relaxed);
|
|
scans.fetch_add(1, std::memory_order_relaxed);
|
|
}
|
|
};
|
|
|
|
std::vector<std::jthread> readers;
|
|
for (std::int32_t i = 0; i < 4; i++) readers.emplace_back(reader);
|
|
|
|
std::vector<Tick> batch;
|
|
for (std::uint64_t sequence = 2; sequence < 400'000; sequence++) {
|
|
batch.push_back({ sequence, sequence });
|
|
if (batch.size() == 64) {
|
|
table.Append(batch);
|
|
batch.clear();
|
|
if (sequence % 1024 == 1) table.Erase(sequence - 500);
|
|
}
|
|
}
|
|
stop.store(true);
|
|
readers.clear();
|
|
|
|
Check(scans.load() > 0, "readers ran");
|
|
Check(badOrder.load() == 0, std::format("ids strictly increase within a scan ({} violations)", badOrder.load()));
|
|
Check(badId.load() == 0, std::format("every yielded row is a published row ({} violations)", badId.load()));
|
|
Check(sawErased.load() == 0, std::format("the erased first row never shows up ({} times)", sawErased.load()));
|
|
Check(table.Verify().Ok(), "table verifies after the run");
|
|
|
|
fs::remove_all(dir);
|
|
if (failures == 0) std::println("ok");
|
|
return failures == 0 ? 0 : 1;
|
|
}
|