feat: add GuardedDb and Guard
This commit is contained in:
1 parent
11230ca97c
commit
14dfdcf57a
1 file changed
+157
@@ -0,0 +1,157 @@
|
|||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
Reference in new issue
Block a user