Files
cobalt/src/concurrent.zig
T

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);
}