438 lines
17 KiB
Zig
438 lines
17 KiB
Zig
const std = @import("std");
|
|
const dbmod = @import("db.zig");
|
|
const batchmod = @import("batch.zig");
|
|
const txnmod = @import("txn.zig");
|
|
const txntable = @import("txn_table.zig");
|
|
const querymod = @import("query.zig");
|
|
|
|
const Allocator = std.mem.Allocator;
|
|
const Db = dbmod.Db;
|
|
const Transaction = txnmod.Transaction;
|
|
const TableTxn = txntable.TableTxn;
|
|
const WriteBatch = batchmod.WriteBatch;
|
|
|
|
/// A thread-safe facade over `*Db`. `Db` itself keeps no internal locking -
|
|
/// a fact documented (and worked around ad hoc) at every call site that
|
|
/// already needs concurrent access from more than one OS thread:
|
|
/// server.zig takes its own `mutex` before touching `db` on every request,
|
|
/// and every session note about txn_table.zig/query.zig/collections.zig
|
|
/// repeats the same known gap for the same reason. `GuardedDb` is that
|
|
/// exact pattern promoted to one reusable, independently tested primitive
|
|
/// instead of being reimplemented by hand wherever it is next needed.
|
|
///
|
|
/// This does not make `Db` itself thread-safe: it makes "every access to
|
|
/// this particular `Db` goes through this particular lock" enforceable,
|
|
/// which is the same guarantee `Server` already relies on. Two independent
|
|
/// `GuardedDb` wrapping the same underlying `*Db` would NOT be safe - the
|
|
/// mutex only serializes callers that agree to go through it.
|
|
pub const GuardedDb = struct {
|
|
db: *Db,
|
|
mutex: std.Thread.Mutex = .{},
|
|
|
|
pub fn init(db: *Db) GuardedDb {
|
|
return .{ .db = db };
|
|
}
|
|
|
|
/// Exclusive handle returned by `acquire()`. Holds the lock until
|
|
/// `release()` runs, so a caller that needs several `Db` calls to
|
|
/// happen as one atomic unit (a read-modify-write, or several puts
|
|
/// that must never be observed half-applied by another guarded
|
|
/// reader) can acquire once and issue every call through `.db`
|
|
/// without releasing in between - the same shape as
|
|
/// `Server.dispatch` locking `self.mutex` once per request no matter
|
|
/// how many `Db` calls that request needs.
|
|
pub const Guard = struct {
|
|
db: *Db,
|
|
mutex: *std.Thread.Mutex,
|
|
|
|
pub fn release(self: Guard) void {
|
|
self.mutex.unlock();
|
|
}
|
|
};
|
|
|
|
pub fn acquire(self: *GuardedDb) Guard {
|
|
self.mutex.lock();
|
|
return .{ .db = self.db, .mutex = &self.mutex };
|
|
}
|
|
|
|
pub fn put(self: *GuardedDb, key: []const u8, value: []const u8) !void {
|
|
const g = self.acquire();
|
|
defer g.release();
|
|
try g.db.put(key, value);
|
|
}
|
|
|
|
pub fn delete(self: *GuardedDb, key: []const u8) !void {
|
|
const g = self.acquire();
|
|
defer g.release();
|
|
try g.db.delete(key);
|
|
}
|
|
|
|
pub fn get(self: *GuardedDb, allocator: Allocator, key: []const u8) !?[]u8 {
|
|
const g = self.acquire();
|
|
defer g.release();
|
|
return g.db.get(allocator, key);
|
|
}
|
|
|
|
pub fn apply(self: *GuardedDb, batch: *WriteBatch) !void {
|
|
const g = self.acquire();
|
|
defer g.release();
|
|
try g.db.apply(batch);
|
|
}
|
|
|
|
pub fn flush(self: *GuardedDb) !void {
|
|
const g = self.acquire();
|
|
defer g.release();
|
|
try g.db.flush();
|
|
}
|
|
|
|
pub fn compact(self: *GuardedDb) !void {
|
|
const g = self.acquire();
|
|
defer g.release();
|
|
try g.db.compact();
|
|
}
|
|
|
|
/// Atomic read-modify-write counter: decodes the current value at
|
|
/// `key` as a little-endian i64 (0 if absent or the wrong width),
|
|
/// adds `delta`, writes the new value back - the whole read, decide,
|
|
/// write sequence runs under one lock acquisition, so no other
|
|
/// guarded caller can observe or clobber the value partway through.
|
|
/// A bare `db.get` followed later by a separate `db.put` cannot make
|
|
/// that guarantee, which is exactly the lost-update hazard this
|
|
/// exists to close (see the test below that proves it with real
|
|
/// concurrent OS threads, not a simulated interleaving).
|
|
pub fn incrBy(self: *GuardedDb, allocator: Allocator, key: []const u8, delta: i64) !i64 {
|
|
const g = self.acquire();
|
|
defer g.release();
|
|
var current: i64 = 0;
|
|
if (try g.db.get(allocator, key)) |raw| {
|
|
defer allocator.free(raw);
|
|
if (raw.len == 8) current = std.mem.readInt(i64, raw[0..8], .little);
|
|
}
|
|
const next = current + delta;
|
|
var buf: [8]u8 = undefined;
|
|
std.mem.writeInt(i64, &buf, next, .little);
|
|
try g.db.put(key, &buf);
|
|
return next;
|
|
}
|
|
};
|
|
|
|
fn openTemp(tmp: *std.testing.TmpDir, allocator: Allocator) !Db {
|
|
const dbPath = try tmp.dir.realpathAlloc(allocator, ".");
|
|
defer allocator.free(dbPath);
|
|
return Db.open(allocator, dbPath, .{});
|
|
}
|
|
|
|
test "put/get/delete round-trip through the guard like a plain Db call" {
|
|
const allocator = std.testing.allocator;
|
|
var tmp = std.testing.tmpDir(.{});
|
|
defer tmp.cleanup();
|
|
var db = try openTemp(&tmp, allocator);
|
|
defer db.close();
|
|
var guarded = GuardedDb.init(&db);
|
|
|
|
try guarded.put("k", "v1");
|
|
{
|
|
const got = try guarded.get(allocator, "k");
|
|
defer if (got) |g| allocator.free(g);
|
|
try std.testing.expectEqualStrings("v1", got.?);
|
|
}
|
|
|
|
try guarded.delete("k");
|
|
const afterDelete = try guarded.get(allocator, "k");
|
|
try std.testing.expectEqual(@as(?[]u8, null), afterDelete);
|
|
}
|
|
|
|
test "incrBy on a fresh key starts from zero and returns the running total" {
|
|
const allocator = std.testing.allocator;
|
|
var tmp = std.testing.tmpDir(.{});
|
|
defer tmp.cleanup();
|
|
var db = try openTemp(&tmp, allocator);
|
|
defer db.close();
|
|
var guarded = GuardedDb.init(&db);
|
|
|
|
try std.testing.expectEqual(@as(i64, 5), try guarded.incrBy(allocator, "ctr", 5));
|
|
try std.testing.expectEqual(@as(i64, 3), try guarded.incrBy(allocator, "ctr", -2));
|
|
try std.testing.expectEqual(@as(i64, 103), try guarded.incrBy(allocator, "ctr", 100));
|
|
}
|
|
|
|
const IncrWorker = struct {
|
|
guarded: *GuardedDb,
|
|
key: []const u8,
|
|
iterations: usize,
|
|
|
|
fn run(self: IncrWorker) void {
|
|
const allocator = std.heap.page_allocator;
|
|
var i: usize = 0;
|
|
while (i < self.iterations) : (i += 1) {
|
|
_ = self.guarded.incrBy(allocator, self.key, 1) catch |err| {
|
|
std.debug.panic("incrBy failed under real thread contention: {s}", .{@errorName(err)});
|
|
};
|
|
}
|
|
}
|
|
};
|
|
|
|
test "incrBy survives real concurrent OS threads with zero lost updates" {
|
|
const allocator = std.testing.allocator;
|
|
var tmp = std.testing.tmpDir(.{});
|
|
defer tmp.cleanup();
|
|
var db = try openTemp(&tmp, allocator);
|
|
defer db.close();
|
|
var guarded = GuardedDb.init(&db);
|
|
|
|
const threadCount = 6;
|
|
const perThread = 300;
|
|
var threads: [threadCount]std.Thread = undefined;
|
|
for (&threads) |*t| {
|
|
t.* = try std.Thread.spawn(.{}, IncrWorker.run, .{IncrWorker{
|
|
.guarded = &guarded,
|
|
.key = "shared-counter",
|
|
.iterations = perThread,
|
|
}});
|
|
}
|
|
for (threads) |t| t.join();
|
|
|
|
const raw = try guarded.get(allocator, "shared-counter");
|
|
defer allocator.free(raw.?);
|
|
const total = std.mem.readInt(i64, raw.?[0..8], .little);
|
|
// A bare unsynchronized `db.get` + `db.put` read-modify-write would
|
|
// routinely lose updates here (two threads read the same stale value,
|
|
// both write back the same next value, one increment vanishes). The
|
|
// mutex inside GuardedDb makes every incrBy fully serialized, so the
|
|
// total below must land exactly on threadCount*perThread every single
|
|
// run - not "usually", not "close to" - or the guard has a real bug.
|
|
try std.testing.expectEqual(@as(i64, threadCount * perThread), total);
|
|
}
|
|
|
|
const TransferWorker = struct {
|
|
guarded: *GuardedDb,
|
|
rounds: usize,
|
|
|
|
fn run(self: TransferWorker) void {
|
|
const allocator = std.heap.page_allocator;
|
|
var i: usize = 0;
|
|
while (i < self.rounds) : (i += 1) {
|
|
const g = self.guarded.acquire();
|
|
defer g.release();
|
|
const aRaw = g.db.get(allocator, "balanceA") catch @panic("get a failed");
|
|
defer allocator.free(aRaw.?);
|
|
const bRaw = g.db.get(allocator, "balanceB") catch @panic("get b failed");
|
|
defer allocator.free(bRaw.?);
|
|
const a = std.mem.readInt(i64, aRaw.?[0..8], .little);
|
|
const b = std.mem.readInt(i64, bRaw.?[0..8], .little);
|
|
if (a < 1) continue;
|
|
var aBuf: [8]u8 = undefined;
|
|
var bBuf: [8]u8 = undefined;
|
|
std.mem.writeInt(i64, &aBuf, a - 1, .little);
|
|
std.mem.writeInt(i64, &bBuf, b + 1, .little);
|
|
g.db.put("balanceA", &aBuf) catch @panic("put a failed");
|
|
g.db.put("balanceB", &bBuf) catch @panic("put b failed");
|
|
}
|
|
}
|
|
};
|
|
|
|
const InvariantReader = struct {
|
|
guarded: *GuardedDb,
|
|
checks: usize,
|
|
violations: *std.atomic.Value(usize),
|
|
|
|
fn run(self: InvariantReader) void {
|
|
const allocator = std.heap.page_allocator;
|
|
var i: usize = 0;
|
|
while (i < self.checks) : (i += 1) {
|
|
const g = self.guarded.acquire();
|
|
const aRaw = g.db.get(allocator, "balanceA") catch @panic("get a failed");
|
|
const bRaw = g.db.get(allocator, "balanceB") catch @panic("get b failed");
|
|
const a = std.mem.readInt(i64, aRaw.?[0..8], .little);
|
|
const b = std.mem.readInt(i64, bRaw.?[0..8], .little);
|
|
g.release();
|
|
allocator.free(aRaw.?);
|
|
allocator.free(bRaw.?);
|
|
if (a + b != 1000) _ = self.violations.fetchAdd(1, .monotonic);
|
|
}
|
|
}
|
|
};
|
|
|
|
test "acquire lets a writer move value between two keys atomically under a concurrent reader" {
|
|
const allocator = std.testing.allocator;
|
|
var tmp = std.testing.tmpDir(.{});
|
|
defer tmp.cleanup();
|
|
var db = try openTemp(&tmp, allocator);
|
|
defer db.close();
|
|
var guarded = GuardedDb.init(&db);
|
|
|
|
var initA: [8]u8 = undefined;
|
|
var initB: [8]u8 = undefined;
|
|
std.mem.writeInt(i64, &initA, 1000, .little);
|
|
std.mem.writeInt(i64, &initB, 0, .little);
|
|
try guarded.put("balanceA", &initA);
|
|
try guarded.put("balanceB", &initB);
|
|
|
|
var violations = std.atomic.Value(usize).init(0);
|
|
const writerThread = try std.Thread.spawn(.{}, TransferWorker.run, .{TransferWorker{
|
|
.guarded = &guarded,
|
|
.rounds = 1000,
|
|
}});
|
|
const readerThread = try std.Thread.spawn(.{}, InvariantReader.run, .{InvariantReader{
|
|
.guarded = &guarded,
|
|
.checks = 1000,
|
|
.violations = &violations,
|
|
}});
|
|
writerThread.join();
|
|
readerThread.join();
|
|
|
|
// Every single reader observation is required to see the invariant
|
|
// balanceA + balanceB == 1000 hold, because each transfer's two puts
|
|
// happen inside one `acquire()`/`release()` pair the reader's own
|
|
// acquire() must wait out entirely - a real race would show up here
|
|
// as a nonzero violation count, not as a crash, which is why this
|
|
// asserts the counter is exactly zero instead of merely "did not hang".
|
|
try std.testing.expectEqual(@as(usize, 0), violations.load(.monotonic));
|
|
|
|
const finalARaw = try guarded.get(allocator, "balanceA");
|
|
defer allocator.free(finalARaw.?);
|
|
const finalBRaw = try guarded.get(allocator, "balanceB");
|
|
defer allocator.free(finalBRaw.?);
|
|
const finalA = std.mem.readInt(i64, finalARaw.?[0..8], .little);
|
|
const finalB = std.mem.readInt(i64, finalBRaw.?[0..8], .little);
|
|
try std.testing.expectEqual(@as(i64, 1000), finalA + finalB);
|
|
try std.testing.expectEqual(@as(i64, 0), finalA);
|
|
try std.testing.expectEqual(@as(i64, 1000), finalB);
|
|
}
|
|
|
|
test "apply is usable concurrently: disjoint batches from different threads never clobber each other" {
|
|
const allocator = std.testing.allocator;
|
|
var tmp = std.testing.tmpDir(.{});
|
|
defer tmp.cleanup();
|
|
var db = try openTemp(&tmp, allocator);
|
|
defer db.close();
|
|
var guarded = GuardedDb.init(&db);
|
|
|
|
const ApplyWorker = struct {
|
|
guarded: *GuardedDb,
|
|
prefix: u8,
|
|
count: usize,
|
|
|
|
fn run(self: @This()) void {
|
|
var batch = WriteBatch.init(std.heap.page_allocator);
|
|
defer batch.deinit();
|
|
var i: usize = 0;
|
|
while (i < self.count) : (i += 1) {
|
|
const key = [_]u8{ self.prefix, @intCast(i) };
|
|
batch.put(&key, "v") catch @panic("batch put failed");
|
|
}
|
|
self.guarded.apply(&batch) catch |err| {
|
|
std.debug.panic("apply failed under contention: {s}", .{@errorName(err)});
|
|
};
|
|
}
|
|
};
|
|
|
|
var threads: [4]std.Thread = undefined;
|
|
for (&threads, 0..) |*t, idx| {
|
|
t.* = try std.Thread.spawn(.{}, ApplyWorker.run, .{ApplyWorker{
|
|
.guarded = &guarded,
|
|
.prefix = @intCast('a' + idx),
|
|
.count = 50,
|
|
}});
|
|
}
|
|
for (threads) |t| t.join();
|
|
|
|
inline for (.{ 'a', 'b', 'c', 'd' }) |prefix| {
|
|
var iter = try guarded.db.scanPrefix(allocator, &[_]u8{prefix});
|
|
defer iter.deinit();
|
|
var n: usize = 0;
|
|
while (try iter.next()) |_| n += 1;
|
|
try std.testing.expectEqual(@as(usize, 50), n);
|
|
}
|
|
}
|
|
|
|
const TableTransferWorker = struct {
|
|
guarded: *GuardedDb,
|
|
rounds: usize,
|
|
|
|
/// Runs a full `Transaction` + `TableTxn` transfer lifecycle (begin,
|
|
/// read both rows, stage both writes, commit) under a single
|
|
/// `acquire()`/`release()` pair, closing the specific gap every prior
|
|
/// session on this project flagged and deferred: "brak testu
|
|
/// wspolbieznosci wielowatkowej (dwa watki komituja TableTxn na tym
|
|
/// samym Db jednoczesnie)". `Db.get`/`Transaction.commit` touch shared
|
|
/// mutable state (the memtable, the OCC read-set bookkeeping) with no
|
|
/// locking of their own, so without the guard two real OS threads
|
|
/// racing through `TableTxn.open`/`get`/`put`/`commit` at once would
|
|
/// be exactly the kind of unguarded concurrent access every other
|
|
/// module in this project takes its own mutex to avoid.
|
|
fn run(self: TableTransferWorker) void {
|
|
const allocator = std.heap.page_allocator;
|
|
var i: usize = 0;
|
|
while (i < self.rounds) : (i += 1) {
|
|
const g = self.guarded.acquire();
|
|
defer g.release();
|
|
|
|
var txn = Transaction.begin(g.db);
|
|
defer txn.deinit();
|
|
var t = TableTxn.open(&txn, allocator, "accounts") catch @panic("TableTxn.open failed");
|
|
defer t.deinit();
|
|
|
|
var from = (t.get(.{ .int = 1 }) catch @panic("get 1 failed")).?;
|
|
defer from.deinit();
|
|
var to = (t.get(.{ .int = 2 }) catch @panic("get 2 failed")).?;
|
|
defer to.deinit();
|
|
if (from.get("balance").?.int < 1) continue;
|
|
|
|
t.put(.{ .int = 1 }, &.{.{ .name = "balance", .value = .{ .int = from.get("balance").?.int - 1 } }}) catch @panic("put 1 failed");
|
|
t.put(.{ .int = 2 }, &.{.{ .name = "balance", .value = .{ .int = to.get("balance").?.int + 1 } }}) catch @panic("put 2 failed");
|
|
|
|
// The whole sequence runs under one lock, so no external writer
|
|
// can ever appear mid-transfer here: commit() must never see a
|
|
// real error.Conflict in this test, unlike the deliberately
|
|
// racy scenario in txn_table.zig's own "all-or-nothing" test.
|
|
txn.commit() catch |err| std.debug.panic("unexpected commit failure under serialized access: {s}", .{@errorName(err)});
|
|
}
|
|
}
|
|
};
|
|
|
|
test "TableTxn commits from many real OS threads never lose or duplicate a unit of balance" {
|
|
const allocator = std.testing.allocator;
|
|
var tmp = std.testing.tmpDir(.{});
|
|
defer tmp.cleanup();
|
|
var db = try openTemp(&tmp, allocator);
|
|
defer db.close();
|
|
|
|
const startBalance: i64 = 2000;
|
|
{
|
|
var setup = try querymod.Table.open(&db, allocator, "accounts");
|
|
defer setup.deinit();
|
|
try setup.put(.{ .int = 1 }, &.{.{ .name = "balance", .value = .{ .int = startBalance } }});
|
|
try setup.put(.{ .int = 2 }, &.{.{ .name = "balance", .value = .{ .int = 0 } }});
|
|
}
|
|
|
|
var guarded = GuardedDb.init(&db);
|
|
const threadCount = 4;
|
|
const roundsPerThread = 200;
|
|
var threads: [threadCount]std.Thread = undefined;
|
|
for (&threads) |*t| {
|
|
t.* = try std.Thread.spawn(.{}, TableTransferWorker.run, .{TableTransferWorker{
|
|
.guarded = &guarded,
|
|
.rounds = roundsPerThread,
|
|
}});
|
|
}
|
|
for (threads) |t| t.join();
|
|
|
|
var verify = try querymod.Table.open(&db, allocator, "accounts");
|
|
defer verify.deinit();
|
|
var row1 = (try verify.get(.{ .int = 1 })).?;
|
|
defer row1.deinit();
|
|
var row2 = (try verify.get(.{ .int = 2 })).?;
|
|
defer row2.deinit();
|
|
const balance1 = row1.get("balance").?.int;
|
|
const balance2 = row2.get("balance").?.int;
|
|
|
|
// Every transfer moves exactly 1 unit and every transfer's two writes
|
|
// commit together or not at all, so the total must be exactly
|
|
// preserved and account 2 must have gained exactly what account 1
|
|
// lost - no interleaving of `get`+`put`+`commit` across threads can
|
|
// produce any other pair of numbers.
|
|
try std.testing.expectEqual(startBalance, balance1 + balance2);
|
|
try std.testing.expectEqual(@as(i64, threadCount * roundsPerThread), balance2);
|
|
try std.testing.expectEqual(startBalance - threadCount * roundsPerThread, balance1);
|
|
}
|