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>
245 lines
11 KiB
C++
245 lines
11 KiB
C++
// SPDX-License-Identifier: LGPL-3.0-only
|
|
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
|
|
|
// Crafter.DB against a cleaned-up copy of Nostro's hand-rolled format
|
|
// (iostream, uint16-prefixed strings inline, linear scans) on the five
|
|
// operations Nostro performs. Prints a table; never fails. Runs on 100k
|
|
// rows so a full suite stays fast; raise `rowCount` for a serious run.
|
|
|
|
import Crafter.DB;
|
|
import std;
|
|
|
|
using namespace Crafter;
|
|
namespace fs = std::filesystem;
|
|
using TimePoint = std::chrono::system_clock::time_point;
|
|
using Clock = std::chrono::steady_clock;
|
|
|
|
namespace {
|
|
enum class Currency : std::uint16_t { Euro };
|
|
|
|
struct Transaction {
|
|
std::string name;
|
|
std::string bic;
|
|
std::string iban;
|
|
std::string description;
|
|
Currency currency;
|
|
std::int64_t amount;
|
|
TimePoint timePoint;
|
|
|
|
static constexpr std::uint32_t version = 1;
|
|
static constexpr auto orderedBy = &Transaction::timePoint;
|
|
};
|
|
|
|
inline constexpr DB::Column<&Transaction::iban> iban{};
|
|
inline constexpr DB::Column<&Transaction::timePoint> timePoint{};
|
|
|
|
constexpr std::uint64_t rowCount = 100'000;
|
|
|
|
std::vector<Transaction> MakeRows() {
|
|
std::mt19937_64 rng(42);
|
|
const std::array<std::string_view, 6> names = { "Albert Heijn", "Jumbo Supermarkten", "NS Reizigers", "Vattenfall", "Bol.com", "Gemeente Utrecht" };
|
|
const std::array<std::string_view, 4> bics = { "ABNANL2A", "RABONL2U", "INGBNL2A", "TRIONL2U" };
|
|
std::vector<std::string> ibans;
|
|
for (std::uint32_t i = 0; i < 1000; i++) ibans.push_back(std::format("NL{:02}ABNA{:010}", i % 97, 417164300u + i));
|
|
std::vector<Transaction> rows;
|
|
rows.reserve(rowCount);
|
|
for (std::uint64_t i = 0; i < rowCount; i++) {
|
|
rows.push_back({ std::string(names[rng() % names.size()]), std::string(bics[rng() % bics.size()]), ibans[rng() % ibans.size()], std::format("payment {} for order {}", i, rng() % 100000), Currency::Euro, static_cast<std::int64_t>(rng() % 200000) - 100000, TimePoint(std::chrono::microseconds(1'700'000'000'000'000 + i * 60'000'000)) });
|
|
}
|
|
return rows;
|
|
}
|
|
|
|
// --- the Nostro format, cleaned up -----------------------------------------
|
|
namespace Nostro {
|
|
void WriteString(std::ofstream& file, std::string_view str) {
|
|
const std::uint16_t size = static_cast<std::uint16_t>(str.size());
|
|
file.write(reinterpret_cast<const char*>(&size), sizeof(size));
|
|
file.write(str.data(), size);
|
|
}
|
|
|
|
void ReadString(std::ifstream& file, std::string& str) {
|
|
std::uint16_t size;
|
|
file.read(reinterpret_cast<char*>(&size), sizeof(size));
|
|
str.resize(size);
|
|
file.read(str.data(), size);
|
|
}
|
|
|
|
void Save(const fs::path& path, std::span<const Transaction> rows) {
|
|
std::ofstream file(path, std::ios::binary | std::ios::app);
|
|
for (const Transaction& tr : rows) {
|
|
WriteString(file, tr.name);
|
|
WriteString(file, tr.bic);
|
|
WriteString(file, tr.iban);
|
|
WriteString(file, tr.description);
|
|
file.write(reinterpret_cast<const char*>(&tr.amount), sizeof(tr.amount));
|
|
file.write(reinterpret_cast<const char*>(&tr.currency), sizeof(Currency));
|
|
const std::int64_t time = std::chrono::duration_cast<std::chrono::microseconds>(tr.timePoint.time_since_epoch()).count();
|
|
file.write(reinterpret_cast<const char*>(&time), sizeof(time));
|
|
}
|
|
}
|
|
|
|
bool Load(std::ifstream& file, Transaction& tr) {
|
|
if (file.peek() == std::ifstream::traits_type::eof()) return false;
|
|
ReadString(file, tr.name);
|
|
ReadString(file, tr.bic);
|
|
ReadString(file, tr.iban);
|
|
ReadString(file, tr.description);
|
|
file.read(reinterpret_cast<char*>(&tr.amount), sizeof(tr.amount));
|
|
file.read(reinterpret_cast<char*>(&tr.currency), sizeof(tr.currency));
|
|
std::int64_t time;
|
|
file.read(reinterpret_cast<char*>(&time), sizeof(time));
|
|
tr.timePoint = TimePoint(std::chrono::microseconds(time));
|
|
return true;
|
|
}
|
|
|
|
TimePoint LastTime(const fs::path& path) {
|
|
std::ifstream file(path, std::ios::binary);
|
|
file.seekg(-static_cast<std::streamoff>(sizeof(std::int64_t)), std::ios::end);
|
|
std::int64_t time;
|
|
file.read(reinterpret_cast<char*>(&time), sizeof(time));
|
|
return TimePoint(std::chrono::microseconds(time));
|
|
}
|
|
|
|
std::vector<Transaction> LoadAll(const fs::path& path) {
|
|
std::ifstream file(path, std::ios::binary);
|
|
std::vector<Transaction> out;
|
|
Transaction tr;
|
|
while (Load(file, tr)) out.push_back(tr);
|
|
return out;
|
|
}
|
|
|
|
std::vector<Transaction> LoadRange(const fs::path& path, TimePoint from, TimePoint to) {
|
|
std::ifstream file(path, std::ios::binary);
|
|
std::vector<Transaction> out;
|
|
Transaction tr;
|
|
while (Load(file, tr)) {
|
|
if (tr.timePoint >= from && tr.timePoint <= to) out.push_back(tr);
|
|
if (tr.timePoint > to) break;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
std::vector<Transaction> LoadByIban(const fs::path& path, std::string_view wanted) {
|
|
std::ifstream file(path, std::ios::binary);
|
|
std::vector<Transaction> out;
|
|
Transaction tr;
|
|
while (Load(file, tr)) {
|
|
if (tr.iban == wanted) out.push_back(tr);
|
|
}
|
|
return out;
|
|
}
|
|
}
|
|
|
|
template<class F>
|
|
double Millis(F&& f) {
|
|
const Clock::time_point start = Clock::now();
|
|
f();
|
|
return std::chrono::duration<double, std::milli>(Clock::now() - start).count();
|
|
}
|
|
|
|
struct Line {
|
|
std::string name;
|
|
double nostro;
|
|
double crafter;
|
|
std::string note;
|
|
};
|
|
}
|
|
|
|
int main() {
|
|
const fs::path dir = fs::temp_directory_path() / "crafter-db-benchmark";
|
|
fs::remove_all(dir);
|
|
fs::create_directories(dir);
|
|
const fs::path nostroPath = dir / "nostro.bin";
|
|
const fs::path crafterPath = dir / "crafter.cdb";
|
|
const std::vector<Transaction> rows = MakeRows();
|
|
const std::string_view wantedIban = rows[rowCount / 2].iban;
|
|
const TimePoint from = rows[rowCount * 45 / 100].timePoint;
|
|
const TimePoint to = rows[rowCount * 55 / 100].timePoint;
|
|
std::vector<Line> lines;
|
|
std::size_t sink = 0;
|
|
|
|
// append
|
|
{
|
|
const double nostro = Millis([&] { Nostro::Save(nostroPath, rows); });
|
|
const double crafter = Millis([&] {
|
|
DB::Table<Transaction> table(crafterPath);
|
|
table.Append(rows);
|
|
table.Flush();
|
|
});
|
|
lines.push_back({ std::format("append {} rows", rowCount), nostro, crafter, "incl. open, Crafter.DB incl. fdatasync" });
|
|
}
|
|
|
|
// last timestamp (1000 opens each)
|
|
{
|
|
const double nostro = Millis([&] { for (std::int32_t i = 0; i < 1000; i++) sink += Nostro::LastTime(nostroPath).time_since_epoch().count() & 1; });
|
|
const double crafter = Millis([&] {
|
|
for (std::int32_t i = 0; i < 1000; i++) {
|
|
const DB::Table<Transaction> table(crafterPath, { .readOnly = true });
|
|
sink += table.Back()[&Transaction::timePoint].time_since_epoch().count() & 1;
|
|
}
|
|
});
|
|
lines.push_back({ "last timestamp (1000x open+read)", nostro, crafter, "open dominates both" });
|
|
}
|
|
|
|
DB::Table<Transaction> table(crafterPath, { .readOnly = true });
|
|
|
|
// load all
|
|
{
|
|
const double nostro = Millis([&] { sink += Nostro::LoadAll(nostroPath).size(); });
|
|
const double crafter = Millis([&] { sink += table.Materialize().size(); });
|
|
double zeroCopy = Millis([&] {
|
|
std::int64_t total = 0;
|
|
for (DB::Row<Transaction> row : table) total += row[&Transaction::amount] + static_cast<std::int64_t>(row[&Transaction::iban].Length());
|
|
sink += static_cast<std::size_t>(total & 1);
|
|
});
|
|
lines.push_back({ "load all (materialised)", nostro, crafter, std::format("zero-copy scan of two columns: {:.2f} ms", zeroCopy) });
|
|
}
|
|
|
|
// time range (10% of rows)
|
|
{
|
|
std::size_t nostroCount = 0;
|
|
std::size_t crafterCount = 0;
|
|
const double nostro = Millis([&] { nostroCount = Nostro::LoadRange(nostroPath, from, to).size(); });
|
|
const double crafter = Millis([&] { crafterCount = table.Range<&Transaction::timePoint>(from, to).Materialize().size(); });
|
|
lines.push_back({ "time range (10% of rows, materialised)", nostro, crafter, std::format("{} / {} rows", nostroCount, crafterCount) });
|
|
}
|
|
|
|
// iban ==
|
|
{
|
|
std::size_t nostroCount = 0;
|
|
std::size_t keyCount = 0;
|
|
std::size_t exprCount = 0;
|
|
std::size_t viewCount = 0;
|
|
const double nostro = Millis([&] { nostroCount = Nostro::LoadByIban(nostroPath, wantedIban).size(); });
|
|
const double withKey = Millis([&] {
|
|
const DB::Key key(wantedIban);
|
|
keyCount = table.Where([&](DB::Row<Transaction> r) { return r[&Transaction::iban] == key; }).Count();
|
|
});
|
|
const double withExpression = Millis([&] { exprCount = table.Where(iban == wantedIban).Count(); });
|
|
const double withView = Millis([&] { viewCount = table.Where([&](DB::Row<Transaction> r) { return r[&Transaction::iban] == wantedIban; }).Count(); });
|
|
lines.push_back({ "iban == x (lambda + Key)", nostro, withKey, std::format("{} / {} rows, {:.1f} ns/row", nostroCount, keyCount, withKey * 1e6 / rowCount) });
|
|
lines.push_back({ "iban == x (expression)", nostro, withExpression, std::format("{} rows, {:.1f} ns/row", exprCount, withExpression * 1e6 / rowCount) });
|
|
lines.push_back({ "iban == x (lambda + string_view)", nostro, withView, std::format("{} rows, {:.1f} ns/row", viewCount, withView * 1e6 / rowCount) });
|
|
}
|
|
|
|
// range + iban
|
|
{
|
|
std::size_t nostroCount = 0;
|
|
std::size_t crafterCount = 0;
|
|
const double nostro = Millis([&] {
|
|
for (const Transaction& tr : Nostro::LoadRange(nostroPath, from, to)) nostroCount += tr.iban == wantedIban ? 1 : 0;
|
|
});
|
|
const double crafter = Millis([&] { crafterCount = table.Where(timePoint >= from && timePoint <= to && iban == wantedIban).Count(); });
|
|
lines.push_back({ "time range + iban == x", nostro, crafter, std::format("{} / {} rows", nostroCount, crafterCount) });
|
|
}
|
|
|
|
std::println("{:<42} {:>12} {:>12} {:>8} {}", "operation", "nostro ms", "crafter ms", "speedup", "note");
|
|
for (const Line& line : lines) {
|
|
std::println("{:<42} {:>12.3f} {:>12.3f} {:>7.1f}x {}", line.name, line.nostro, line.crafter, line.nostro / std::max(line.crafter, 0.0001), line.note);
|
|
}
|
|
std::println("files: nostro {} bytes, crafter {} bytes", fs::file_size(nostroPath), fs::file_size(crafterPath));
|
|
std::println("(sink {})", sink & 1);
|
|
|
|
fs::remove_all(dir);
|
|
return 0;
|
|
}
|