// storage.mbt
//
// The storage abstraction. MoonBit traits cannot be used as types, so the
// engine talks to a closed enum of storage implementations through the
// dispatch helpers below.
//
// Memory โ an in-process map of file name to bytes (tests, embedders)
// File โ a real directory via moonbitlang/x/fs (whole-file IO;
// durability therefore comes from the WAL group commit)
// Failpoint โ wraps a Memory storage and injects deterministic faults for
// the crash-consistency suite (docs/acceptance.md ยง8)
//
// File names are relative paths inside the storage (e.g. "WAL",
// "L0_000042.sst"); File turns them into dir + "/" + name.
pub(all) enum Storage {
Memory(MemoryStorage)
File(FileStorage)
Failpoint(FailpointStorage)
}
pub(all) struct MemoryStorage {
files : @hashmap.HashMap[String, Bytes]
}
pub fn MemoryStorage::new() -> MemoryStorage {
{ files: @hashmap.HashMap([], capacity=16) }
}
pub(all) struct FileStorage {
dir : String
}
pub fn FileStorage::new(dir : String) -> FileStorage {
{ dir, }
}
/// Fault kinds injectable through a FailpointStorage. `LostWrite` models a
/// write that reported success but never reached the medium (a lost fsync);
/// it only ever fires on WAL writes, because losing a table write silently
/// would be indistinguishable from corruption of the whole file.
pub(all) enum FaultKind {
FailRead
TornWrite
LostWrite
} derive(Eq, @debug.Debug)
pub extend FaultKind with Eq::{not_equal, equal}
pub extend FaultKind with @debug.Debug::{to_repr}
pub(all) struct FailpointStorage {
inner : MemoryStorage
armed_kind : Ref[Option[FaultKind]]
armed_name : Ref[Option[String]]
fired : Ref[Bool]
}
pub fn FailpointStorage::new() -> FailpointStorage {
{
inner: MemoryStorage::new(),
armed_kind: Ref(None),
armed_name: Ref(None),
fired: Ref(false),
}
}
/// Arms a one-shot fault on the next matching operation, any file.
pub fn FailpointStorage::arm(self : FailpointStorage, kind : FaultKind) -> Unit {
self.armed_kind.val = Some(kind)
self.armed_name.val = None
self.fired.val = false
}
/// Arms a one-shot fault that only fires for files whose name starts with
/// `name_prefix` (e.g. "WAL", "L0_", "L1_"). Other operations pass through.
pub fn FailpointStorage::arm_on(
self : FailpointStorage,
kind : FaultKind,
name_prefix : String
) -> Unit {
self.armed_kind.val = Some(kind)
self.armed_name.val = Some(name_prefix)
self.fired.val = false
}
pub fn FailpointStorage::disarm(self : FailpointStorage) -> Unit {
self.armed_kind.val = None
}
/// True while an armed fault has not fired yet.
pub fn FailpointStorage::armed(self : FailpointStorage) -> Bool {
self.armed_kind.val is Some(_) && !self.fired.val
}
fn FailpointStorage::match_name(self : FailpointStorage, name : String) -> Bool {
match self.armed_name.val {
None => true
Some(prefix) => name.has_prefix(prefix)
}
}
fn io_failure(what : String, path : String, msg : String) -> DbError {
DbError::Io("\{what} \{path}: \{msg}")
}
/// Reads a whole file; `None` means the file does not exist.
pub fn read(storage : Storage, name : String) -> Option[Bytes] raise DbError {
match storage {
Memory(m) => m.files.get(name)
File(f) => {
let path = "\{f.dir}/\{name}"
if !@fs.path_exists(path) {
None
} else {
let content =
@fs.read_file_to_bytes(path) catch {
@fs.IOError(msg) => raise io_failure("read", path, msg)
}
Some(content)
}
}
Failpoint(fp) => {
if fp.armed() && fp.match_name(name) && fp.armed_kind.val == Some(FaultKind::FailRead) {
fp.fired.val = true
raise DbError::Fault("injected read failure: \{name}")
}
read(Storage::Memory(fp.inner), name)
}
}
}
/// Writes a whole file, creating or replacing it.
pub fn write(storage : Storage, name : String, data : Bytes) -> Unit raise DbError {
match storage {
Memory(m) => m.files.set(name, data)
File(f) => {
let path = "\{f.dir}/\{name}"
@fs.write_bytes_to_file(path, data) catch {
@fs.IOError(msg) => raise io_failure("write", path, msg)
}
}
Failpoint(fp) => {
if fp.armed() {
match fp.armed_kind.val {
Some(FaultKind::TornWrite) => if fp.match_name(name) {
fp.fired.val = true
// A torn write: the first half of the payload reaches the
// medium, then the operation fails. The engine must treat the
// file as absent or truncated on recovery.
let half = data.length() / 2
write(Storage::Memory(fp.inner), name, data[0:half].to_owned())
raise DbError::Fault("injected torn write: \{name}")
}
Some(FaultKind::LostWrite) => {
if name == "WAL" {
// The write reports success but nothing reaches the medium.
fp.fired.val = true
return
}
}
_ => ()
}
}
write(Storage::Memory(fp.inner), name, data)
}
}
}
/// Removes a file; removing a missing file is a no-op.
pub fn remove(storage : Storage, name : String) -> Unit raise DbError {
match storage {
Memory(m) => m.files.remove(name)
File(f) => {
let path = "\{f.dir}/\{name}"
if @fs.path_exists(path) {
@fs.remove_file(path) catch {
@fs.IOError(msg) => raise io_failure("remove", path, msg)
}
}
}
Failpoint(fp) => remove(Storage::Memory(fp.inner), name)
}
}
/// Lists file names with the given prefix, sorted by name.
pub fn list(storage : Storage, prefix : String) -> Array[String] raise DbError {
match storage {
Memory(m) => {
let names : Array[String] = []
for (k, _v) in m.files.iter() {
if k.has_prefix(prefix) {
names.push(k)
}
}
names.sort()
names
}
File(f) => {
if !@fs.path_exists(f.dir) {
@fs.create_dir(f.dir) catch {
@fs.IOError(msg) => raise io_failure("create_dir", f.dir, msg)
}
}
let entries =
@fs.read_dir(f.dir) catch {
@fs.IOError(msg) => raise io_failure("read_dir", f.dir, msg)
}
let all = entries.to_owned()
let names : Array[String] = []
for name in all {
if name.has_prefix(prefix) {
names.push(name)
}
}
names.sort()
names
}
Failpoint(fp) => list(Storage::Memory(fp.inner), prefix)
}
}
/// True if the named file exists.
pub fn exists(storage : Storage, name : String) -> Bool raise DbError {
match storage {
Memory(m) => m.files.contains(name)
File(f) => @fs.path_exists("\{f.dir}/\{name}")
Failpoint(fp) => exists(Storage::Memory(fp.inner), name)
}
}
/// Prepares the storage for use (creates the directory of a File storage).
pub fn ensure_ready(storage : Storage) -> Unit raise DbError {
match storage {
File(f) => {
if !@fs.path_exists(f.dir) {
@fs.create_dir(f.dir) catch {
@fs.IOError(msg) => raise io_failure("create_dir", f.dir, msg)
}
}
}
_ => ()
}
}