1个不稳定版本

0.2.0 2024年3月15日

#1518数据库接口

MIT/Apache

450KB
7K SLoC

foundationdb-simulation

该crate的目的是为了在官方FoundationDB模拟中测试Rust层。

工作原理

FoundationDB是用flow编写的,并转换为C++。Rust和C++对象不兼容,因此所有支持对象的每个方法都已转换为以对象原始指针作为第一个参数的C函数。

此crate包含4种类型的包装器

  • C++将行为映射到C绑定,并由fdbserver直接调用
  • Rust实现从C++的C绑定(C++到Rust桥接)
  • Rust将行为映射到C绑定
  • C++实现从Rust的C绑定(Rust到C++桥接)

警告

由于此crate与FoundationDB之间耦合度高,请注意

  • 目前只支持7.1和7.3
  • 需要在官方Docker镜像中构建
  • 7.3需要将链接器设置为clang

强烈建议按照提供的示例进行设置。

设置

按照库文件结构创建一个新的Rust项目

├── Cargo.toml
└── src/
    └── lib.rs

在您的Cargo.toml依赖关系部分添加foundationdb-workloads crate。编写lib部分如下

[lib]
name = "myworkload"
crate-type = ["cdylib"]
required-features = ["fdb-7_3", "fdb-docker"]

由于FoundationDB模拟期望共享对象,必须将crate-type设置为cdylib。您可以将myworkload替换为您的工作负载名称。

请使用相关的Dockerfile以获得正确的构建设置。

工作负载

我们使用以下trait抽象了FoundationDB工作负载

pub trait RustWorkload {
    fn description(&self) -> String;
    fn setup(&'static mut self, db: SimDatabase, done: Promise);
    fn start(&'static mut self, db: SimDatabase, done: Promise);
    fn check(&'static mut self, db: SimDatabase, done: Promise);
    fn get_metrics(&self) -> Vec<Metric>;
    fn get_check_timeout(&self) -> f64;
}

定义一个结构体,并在其上实现RustWorkload trait。您可以在该结构体中放置任何内容,它不需要是FFI安全的。我们建议您至少存储WorkloadContext

基本示例

struct MyWorkload {
    name: String,
    description: String,
    context: WorkloadContext,
}

impl MyWorkload {
    fn new(name: &str, context: WorkloadContext) -> Self {
        let name = name.to_string();
        let description = format!("Description of workload {:?}", name);
        Self {
            name,
            description,
            context,
        }
    }
}

impl RustWorkload for MyWorkload {
    fn description(&self) -> String {
        self.description.clone()
    }
    fn setup(&'static mut self, db: SimDatabase, done: Promise) {
        done.send(true);
    }
    fn start(&'static mut self, db: SimDatabase, done: Promise) {
        done.send(true);
    }
    fn check(&'static mut self, db: SimDatabase, done: Promise) {
        done.send(true);
    }
    fn get_metrics(&self) -> Vec<Metric> {
        Vec::new()
    }
    fn get_check_timeout(&self) -> f64 {
        3000.0
    }

入口点

创建一个您选择的名称,但具有此确切签名的函数

pub fn main(name: &str, context: WorkloadContext) -> Box<dyn RustWorkload>;

在此函数中实例化您的负载并返回它。添加 simulation_entrypoint proc 宏,您的负载现在已注册到模拟中!

#[simulation_entrypoint]
pub fn main(name: &str, context: WorkloadContext) -> Box<dyn RustWorkload> {
    Box::new(MyWorkload::new(name, context))
}

在模拟配置中,负载有一个 workloadName。这个字符串将被作为第一个参数传递到您的入口点。它被设计成您可以在一个库中实现多个负载,并在配置文件中直接选择要使用哪个,而无需重新编译。

基本示例

#[simulation_entrypoint]
pub fn main(name: &str, context: WorkloadContext) -> Box<dyn RustWorkload> {
    match name {
        "MyWorkload1" => Box::new(MyWorkload1::new(name, context)),
        "MyWorkload2" => Box::new(MyWorkload2::new(name, context)),
        "MyWorkload3" => Box::new(MyWorkload3::new(name, context)),
        name => panic!("no workload with name: {:?}", name),
    }
}

/!\ 您的项目中必须只有一个入口点。

编译

如果您遵循了这些步骤,您应该可以使用 cargo 的标准构建命令(cargo buildcargo build --release)来编译您的负载。这将创建一个共享对象文件,位于 ./target/debug/./target/release/ 中,其名称与您在 Cargo.toml 文件的 lib 部分中设置的 name 相同,具有 .so 扩展名,并以 "lib" 为前缀。在这个例子中,我们将库命名为 myworkload,所以共享对象文件的名称将是 libmyworkload.so

启动

FoundationDB 模拟器接受一个 toml 文件作为输入

fdbserver -r simulation -f ./test_file.toml

该文件描述了要运行的模拟。一个模拟可以包含多个负载(有关这部分,请参阅官方 文档)。RustWorkload 应作为 ExternalWorkload 加载,指定 testName=External。必须将 libraryPathlibraryName 指向您的共享对象

testTitle=MyTest
  testName=External
  workloadName=MyWorkload
  libraryPath=./target/debug/
  libraryName=myworkload
  myCustomOption=42

API

除了 RustWorkload trait 之外,这里列出了您在此 crate 中可以访问的所有枚举、宏、结构和方法

enum Severity {
    Debug,
    Info,
    Warn,
    WarnAlways,
    Error,
}

struct WorkloadContext {
    fn trace<S>(&self, sev: Severity, name: S, details: Vec<(String, String)>);
    fn get_process_id(&self) -> u64;
    fn set_process_id(&self);
    fn now(&self) -> f64;
    fn rnd(&self) -> u32;
    fn get_option<T>(&self, name: &str) -> Option<T>;
    fn client_id(&self) -> usize;
    fn client_count(&self) -> usize;
    fn shared_random_number(&self) -> u64;
}

struct Metric {
    fn avg<S>(name: S, value: f64);
    fn val<S>(name: S, value: f64);
}

struct Promise {
    fn send(&mut self, val: bool);
}

fn fdb_spawn<F>(future: F);

type Details = Vec<String, String>;

macro details;
macro simulation_entrypoint;

该 crate 还导出了 CPPWorkloadFactory 函数,您不应使用它!

跟踪

您可以使用 WorkloadContext::trace 在 fdbserver 记录文件中添加日志条目。

示例

fn setup(&'static mut self, db: SimDatabase, done: Promise) {
    self.context.trace(
        Severity::Info,
        "Successfully setup workload",
        details![
            "name" => self.name,
            "description" => self.description(),
        ],
    );
    done.send(true);
}

注意:任何严重性为 Severity::Error 的日志将自动停止 fdbserver

随机

WorkloadContext::rndWorkloadContext::shared_random_number 可用于在您的负载中获得或初始化确定性的随机进程。

获取选项

在模拟配置文件中,您可以向您的负载添加自定义参数。可以使用 WorkloadContext::get_option 读取这些参数。此方法首先尝试以原始字符串形式获取参数值,然后将其转换为您的选择类型。如果参数不存在,其值无效或设置为 null,则函数返回 None

示例

fn init(&mut self, context: WorkloadContext) -> bool {
    let count: usize = self
        .context
        .get_option("myCustomOption")
        .unwrap();
    true
}

注意:您必须消耗在配置文件中设置的任何参数。如果您没有读取参数,fdbserver 将触发错误。

生命周期

实例化

当fdbserver准备好后,它将加载您的共享对象并尝试从其中实例化一个工作负载。那时会调用您的entrypoint。模拟器创建随机数量的“客户端”,每个客户端运行一个工作负载。您的entrypoint会被调用与客户端数量一样多。

注意:与具有独立的 createinit 方法的 ExternalWorkload 相反,RustWorkloads 直到“初始化”阶段才会创建。

设置/启动/检查

这三个阶段按顺序对所有工作负载运行。所有工作负载都必须完成一个阶段才能开始下一个阶段。这些阶段在模拟器中异步运行,工作负载通过发送一个布尔值到其 done 承诺来指示已完成。

理解一个重要的事情是,您编写的任何代码都是阻塞的。为了确保模拟是确定性的且可重复的,只要您的代码在运行,fdbserver就会等待。换句话说,您必须将执行权交给fdbserver才能在数据库上发生任何事情。为了清楚地说明,这样做不会起作用

fn setup(&'static mut self, db: SimDatabase, done: Promise) {
    tokio::runtime::Builder::new_multi_thread()
        .enable_all()
        .build()
        .unwrap()
        .block_on(async {
            let trx = db.create_trx().unwrap();
            let version1 = trx.get_read_version().await.unwrap();
            println!("version1: {}", version1);
            let version2 = trx.get_read_version().await.unwrap();
            println!("version2: {}", version2);
        });
    done.send(true);
}

此代码是阻塞的,它创建了一个事务并尝试提交,但数据库上的任何事情都无法发生,直到 setup 返回。这是一个死锁,trx.commit().await 等待fdbserver继续,而fdbserver等待 setup 结束以执行数据库上的任何挂起操作。FoundationDB使用回调。在异步部分,您唯一能做的就是设置一个回调并让函数结束。然后fdbserver再次启动,并在准备好时调用您的回调。进入回调后,fdbserver再次停止并等待回调完成。在回调中,您可以设置另一个回调或将布尔值发送到 done 承诺。

这可能会看起来像这样

use foundationdb_sys::*;

fn setup(&'static mut self, db: Database, done: Promise) {
    let trx = db.create_trx()
    let f = fdb_transaction_get_read_version(trx);
    fdb_future_set_callback(f, callback1, CallbackData { trx, done });
}

fn callback1(f: *mut FDBFuture, data: CallbackData) {
    let mut version1;
    fdb_future_get_int64(f, &mut version1);
    println!("version1: {}", version1);
    let f = fdb_transaction_get_read_version(data.trx);
    fdb_future_set_callback(f, callback2, data);
}

fn callback2(f: *mut FDBFuture, data: CallbackData) {
    let mut version2;
    fdb_future_get_int64(f, &mut version2);
    println!("version2: {}", version1);
    data.done.send(true);
}

这实际上非常麻烦且容易出错。这是在工作和负载之间与fdbserver通信的唯一正确方式

  • (没有死锁,没有无效指针...)
  • 确保确定性

使用tokio或其他库的其他标准运行时不起作用,使用不同的线程会破坏确定性,且不受支持。一个“野”线程会被fdbserver检测到并崩溃。您将不得不注册它,但我们没有实现绑定来启用此功能。然而,我们实现了一个自定义执行器,大大简化了编写方式,但在底层做了完全相同的事情。此示例将这样编写

fn setup(&'static mut self, db: SimDatabase, done: Promise) {
    fdb_spawn(async {
        let trx = db.create_trx().unwrap();
        let version1 = trx.get_read_version().await.unwrap();
        println!("version1: {}", version1);
        let version2 = trx.get_read_version().await.unwrap();
        println!("version2: {}", version2);
        done.send(true);
    });
}

fdb_spawn 是一个使用fdbserver作为反应堆的简单未来执行器。它仅与在fdbserver中设置回调的未来兼容。因此,您不能在其中使用任何异步代码。不是由 foundationdb-rs 创建的任何未来都会导致死锁。所有这些都是高度实验性的,因此我们非常感谢任何反馈(替代方案,改进,错误...)。

常见错误

必须使用 done 承诺。如果您不这样做,fdbserver会崩溃,您应该在日志文件中看到一个说 BrokenPromise 的行。这是预期行为,由fdbserver明确跟踪并有意实现。这是为了防止死锁,因为未解决其承诺的工作负载被认为是永远不会结束的,并阻塞所有剩余阶段的执行而不会触发任何错误。

相反,设置 done 的值超过一次也是错误。这样做将通过恐慌终止工作负载。

注意:Promise::send 消耗 Promise 以防止它被解决两次。

done 中发送 false 不会触发任何错误。事实上,向 fdbserver 发送 truefalse 是严格等价的。唯一重要的是 done 已经被解析。

在解析 done 之后间接使用工作负载或数据库的指针是未定义的行为。解析 done 应该是在一个阶段的最后一件事情,它表示向 fdbserver 表明你已经完成,内存中许多结构可能已重新定位,因此你不再保证 Rust 侧任何对象的合法性。你必须等待下一个阶段,并使用你被赋予的新 selfdb 引用。因此,不要尝试存储 RustWorkloadSimDatabasePromise 实例或通过 foundation-rs 绑定创建的对象(事务、future...),因为跨阶段使用它们很可能会导致段错误。

指标

在模拟结束时,将调用 get_metrics,并且你可以返回一个 Metric 向量。每个指标可以表示原始值或平均值。

示例

fn get_metrics(&self) -> Vec<Metric> {
    vec![
        Metric::avg("foo", 42.0),
        Metric::val("bar", 418.0),
        Metric::val("baz", 1337.0),
    ]
}

依赖关系

~1.4–4.5MB
~84K SLoC