Skip to content

Commit b448958

Browse files
committed
add River Rust's conformance adapter
Add `riverqueue-conformance`, an unpublished workspace binary that serves the 14-method contract in `conformance/protocol` for River's Rust client, so the harness can run its scenarios with Rust as the candidate against River Go. One `Server<B: Backend>` handles every method. `Backend` holds only what differs between PostgreSQL and SQLite: opening the pool, the schema a client uses, beginning a transaction and handing it to River's `.tx` requests, and migrating. Everything else goes through River's database-independent `Client`. The built-in worker implements the ten behaviors, cancelling with a `JobCancelError` reason as Go's adapter does with `JobCancel`. A pilot implements the claim barrier, and a hook, an error handler, and an event subscription record the stats. Jobs are reported with their stored args and metadata as raw JSON, so numbers no float can hold, such as `1e400`, keep their exact text as they do in Go's adapter. `make test/conformance/rust` and `make test/conformance/rust/nightly` build the adapter first so a compile error is reported once. `cargo package --workspace` includes `publish = false` crates, so `check/rust/package` excludes the adapter by name; `cargo publish --workspace` already skips it.
1 parent 9aab879 commit b448958

10 files changed

Lines changed: 1532 additions & 1 deletion

File tree

‎Makefile‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,22 @@ test/conformance/js: build/js/conformance
160160
test/conformance/nightly: ## Run conformance scenarios plus the nightly chaos and performance tier against CANDIDATE
161161
cd conformance && RIVER_CONFORMANCE=$(CANDIDATE) RIVER_CONFORMANCE_NIGHTLY=1 go test ./harness -count=1 -timeout 30m
162162

163+
# The harness builds the Rust adapter itself, but building it first reports
164+
# a compile error once rather than as every scenario's failure.
165+
.PHONY: build/rust/conformance
166+
build/rust/conformance:
167+
cd rust && cargo build --locked -p riverqueue-conformance
168+
169+
.PHONY: test/conformance/rust
170+
test/conformance/rust: ## Run conformance scenarios against River Rust
171+
test/conformance/rust: build/rust/conformance
172+
$(MAKE) test/conformance CANDIDATE=rust
173+
174+
.PHONY: test/conformance/rust/nightly
175+
test/conformance/rust/nightly: ## Run conformance scenarios plus the nightly tier against River Rust
176+
test/conformance/rust/nightly: build/rust/conformance
177+
$(MAKE) test/conformance/nightly CANDIDATE=rust
178+
163179
# `--cfg river_postgres_tests` builds the Rust PostgreSQL integration tests.
164180
# It goes to both rustc and rustdoc so any doctest gated on it runs too, and
165181
# into its own target directory so switching it on and off doesn't rebuild
@@ -267,10 +283,12 @@ check/rust/dependencies: ## Audit Rust advisories, licenses, bans, and sources
267283
# name and version never change contents. It reuses an extracted copy and a
268284
# compiled build of the same version, so without the cleanup a crate can be
269285
# verified against an earlier revision of another workspace crate.
286+
# `cargo package --workspace`, unlike `cargo publish`, includes crates marked
287+
# `publish = false`, so the conformance adapter is excluded by name.
270288
.PHONY: check/rust/package
271289
check/rust/package: ## Build and verify publishable crate archives without publishing
272290
rm -rf "$${CARGO_TARGET_DIR:-rust/target}"/package "$${CARGO_TARGET_DIR:-rust/target}"/debug/.fingerprint/riverqueue* "$${CARGO_HOME:-$$HOME/.cargo}"/registry/src/*/riverqueue*
273-
cd rust && cargo package --workspace --allow-dirty --locked
291+
cd rust && cargo package --workspace --exclude riverqueue-conformance --allow-dirty --locked
274292
cd rust && for crate in riverqueue riverqueue-cli riverqueue-macros riverqueue-migrate riverqueue-test; do \
275293
! cargo package --list --allow-dirty --locked -p $$crate | grep -E '(^|/)(tests|fixtures|testdata)/|\.json$$' | grep -vxF .cargo_vcs_info.json || exit 1; \
276294
done

‎rust/Cargo.lock‎

Lines changed: 13 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎rust/Cargo.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
members = [
33
"riverqueue",
44
"riverqueue-cli",
5+
"riverqueue-conformance",
56
"riverqueue-macros",
67
"riverqueue-migrate",
78
"riverqueue-test",

‎rust/README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ preview.
1414
- `riverqueue-cli`: the `riverqueue` command-line program for migrations and
1515
benchmarks.
1616
- `riverqueue-test`: typed fixtures and worker-test helpers.
17+
- `riverqueue-conformance`: the unpublished adapter River's cross-language
18+
conformance harness runs against River Go (`make test/conformance/rust`).
1719

1820
The API uses a caller-owned SQLx pool, Tokio, typed workers, and
1921
`CancellationToken`. `Client` isn't generic over the database: it accepts a
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
[package]
2+
name = "riverqueue-conformance"
3+
description = "River's Rust adapter for the cross-language conformance harness"
4+
# The harness builds and runs it from the workspace; it's never published.
5+
publish = false
6+
version.workspace = true
7+
edition.workspace = true
8+
rust-version.workspace = true
9+
license.workspace = true
10+
11+
[dependencies]
12+
async-trait.workspace = true
13+
chrono.workspace = true
14+
riverqueue = { path = "../riverqueue", features = ["postgres", "sqlite"] }
15+
serde.workspace = true
16+
serde_json.workspace = true
17+
tokio = { workspace = true, features = ["io-std", "io-util", "rt-multi-thread"] }
18+
tokio-util.workspace = true
19+
20+
[lints]
21+
workspace = true
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
//! What differs between the databases: connecting, transactions, the schema
2+
//! a client uses, and migrations. Everything else goes through River's
3+
//! database-independent `Client`.
4+
5+
use std::{str::FromStr, time::Duration};
6+
7+
use riverqueue::{
8+
BoxError, Client, ClientBuilder,
9+
database::{
10+
DatabaseTransactionExecutor, PostgresDatabase, SchemaName, SqliteDatabase, begin_postgres,
11+
begin_sqlite_write,
12+
},
13+
migrate::{Direction, MigrateOpts, MigrateResult, PostgresMigrator, SqliteMigrator},
14+
sqlx::{
15+
self, PgPool, SqlitePool, Transaction,
16+
postgres::{PgConnectOptions, PgPoolOptions},
17+
sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions},
18+
},
19+
};
20+
21+
use crate::protocol::RpcError;
22+
23+
/// A database River supports, which the server is generic over.
24+
pub trait Backend: Sized {
25+
type Db: sqlx::Database;
26+
27+
/// The contract's name for the driver.
28+
const DRIVER: &'static str;
29+
30+
/// Opens a pool on `url`, whose connections use `application_name` where
31+
/// the database has one.
32+
fn connect(url: &str, application_name: &str) -> Result<Self, BoxError>;
33+
34+
/// Starts building a client on `schema`, or on the default for an empty
35+
/// one.
36+
fn builder(&self, schema: &str) -> Result<ClientBuilder, RpcError>;
37+
38+
/// Begins a transaction for River's `.tx` requests.
39+
async fn begin(&self) -> Result<Transaction<'static, Self::Db>, sqlx::Error>;
40+
41+
/// Passes a transaction to River's `.tx` requests.
42+
fn executor<'t>(
43+
transaction: &'t mut Transaction<'static, Self::Db>,
44+
) -> impl DatabaseTransactionExecutor<'t>;
45+
46+
/// Migrates `schema`.
47+
async fn migrate(
48+
&self,
49+
schema: &str,
50+
direction: Direction,
51+
opts: MigrateOpts,
52+
) -> Result<MigrateResult, RpcError>;
53+
}
54+
55+
#[derive(Debug)]
56+
pub struct Postgres(PgPool);
57+
58+
impl Postgres {
59+
fn schema(schema: &str) -> Result<Option<SchemaName>, RpcError> {
60+
(!schema.is_empty())
61+
.then(|| SchemaName::new(schema).map_err(RpcError::rejected))
62+
.transpose()
63+
}
64+
}
65+
66+
impl Backend for Postgres {
67+
type Db = sqlx::Postgres;
68+
69+
const DRIVER: &'static str = "postgres";
70+
71+
fn connect(url: &str, application_name: &str) -> Result<Self, BoxError> {
72+
// The URL's `options` set the search path, which River uses when no
73+
// schema is given.
74+
let options = PgConnectOptions::from_str(url)?.application_name(application_name);
75+
Ok(Self(
76+
PgPoolOptions::new()
77+
.max_connections(10)
78+
.connect_lazy_with(options),
79+
))
80+
}
81+
82+
fn builder(&self, schema: &str) -> Result<ClientBuilder, RpcError> {
83+
let mut database = PostgresDatabase::new(self.0.clone());
84+
if let Some(schema) = Self::schema(schema)? {
85+
database = database.with_schema(schema);
86+
}
87+
Ok(Client::builder(database))
88+
}
89+
90+
async fn begin(&self) -> Result<Transaction<'static, Self::Db>, sqlx::Error> {
91+
begin_postgres(&self.0).await
92+
}
93+
94+
fn executor<'t>(
95+
transaction: &'t mut Transaction<'static, Self::Db>,
96+
) -> impl DatabaseTransactionExecutor<'t> {
97+
transaction
98+
}
99+
100+
async fn migrate(
101+
&self,
102+
schema: &str,
103+
direction: Direction,
104+
opts: MigrateOpts,
105+
) -> Result<MigrateResult, RpcError> {
106+
let mut migrator = PostgresMigrator::new(self.0.clone());
107+
if let Some(schema) = Self::schema(schema)? {
108+
migrator = migrator.with_schema(schema);
109+
}
110+
migrator
111+
.migrate(direction, opts)
112+
.await
113+
.map_err(RpcError::rejected)
114+
}
115+
}
116+
117+
#[derive(Debug)]
118+
pub struct Sqlite(SqlitePool);
119+
120+
/// SQLite has no schemas.
121+
fn no_schema(schema: &str) -> Result<(), RpcError> {
122+
if schema.is_empty() {
123+
Ok(())
124+
} else {
125+
Err(RpcError::rejected("SQLite has no schemas"))
126+
}
127+
}
128+
129+
impl Backend for Sqlite {
130+
type Db = sqlx::Sqlite;
131+
132+
const DRIVER: &'static str = "sqlite";
133+
134+
fn connect(url: &str, _application_name: &str) -> Result<Self, BoxError> {
135+
// Several processes share the file, so the busy timeout must cover
136+
// another's writes, including its switch of a new database to WAL.
137+
let options = SqliteConnectOptions::new()
138+
.filename(url)
139+
.create_if_missing(true)
140+
.busy_timeout(Duration::from_secs(5))
141+
.journal_mode(SqliteJournalMode::Wal)
142+
.foreign_keys(true);
143+
Ok(Self(
144+
SqlitePoolOptions::new()
145+
.max_connections(4)
146+
.connect_lazy_with(options),
147+
))
148+
}
149+
150+
fn builder(&self, schema: &str) -> Result<ClientBuilder, RpcError> {
151+
no_schema(schema)?;
152+
Ok(Client::builder(SqliteDatabase::new(self.0.clone())))
153+
}
154+
155+
async fn begin(&self) -> Result<Transaction<'static, Self::Db>, sqlx::Error> {
156+
begin_sqlite_write(&self.0).await
157+
}
158+
159+
fn executor<'t>(
160+
transaction: &'t mut Transaction<'static, Self::Db>,
161+
) -> impl DatabaseTransactionExecutor<'t> {
162+
transaction
163+
}
164+
165+
async fn migrate(
166+
&self,
167+
schema: &str,
168+
direction: Direction,
169+
opts: MigrateOpts,
170+
) -> Result<MigrateResult, RpcError> {
171+
no_schema(schema)?;
172+
SqliteMigrator::new(self.0.clone())
173+
.migrate(direction, opts)
174+
.await
175+
.map_err(RpcError::rejected)
176+
}
177+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
//! River Rust's conformance adapter: the contract defined by the Go package
2+
//! `conformance/protocol`, which the harness in `conformance/harness` runs
3+
//! against River Go's adapter. One server, generic over the backend, serves
4+
//! both PostgreSQL and SQLite through River's `Client`.
5+
6+
mod backend;
7+
mod protocol;
8+
mod server;
9+
mod worker;
10+
11+
use std::{env, process::ExitCode, time::Duration};
12+
13+
use riverqueue::BoxError;
14+
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
15+
16+
use crate::{
17+
backend::{Backend, Postgres, Sqlite},
18+
protocol::{Request, Response, RpcError},
19+
server::Server,
20+
};
21+
22+
/// How long stopping the client and rolling back transactions may take once
23+
/// stdin closes.
24+
const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(10);
25+
26+
#[tokio::main]
27+
async fn main() -> ExitCode {
28+
match run().await {
29+
Ok(()) => ExitCode::SUCCESS,
30+
Err(error) => {
31+
eprintln!("River Rust conformance adapter: {error}");
32+
ExitCode::FAILURE
33+
}
34+
}
35+
}
36+
37+
async fn run() -> Result<(), BoxError> {
38+
let url = env::var("RIVER_CONFORMANCE_DATABASE_URL")
39+
.map_err(|_| "RIVER_CONFORMANCE_DATABASE_URL is required")?;
40+
let application_name = env::var("RIVER_CONFORMANCE_APPLICATION_NAME").unwrap_or_default();
41+
match env::var("RIVER_CONFORMANCE_DRIVER")
42+
.unwrap_or_default()
43+
.as_str()
44+
{
45+
"postgres" => serve(Postgres::connect(&url, &application_name)?).await,
46+
"sqlite" => serve(Sqlite::connect(&url, &application_name)?).await,
47+
driver => Err(format!("unsupported RIVER_CONFORMANCE_DRIVER {driver:?}").into()),
48+
}
49+
}
50+
51+
/// Answers requests from stdin, one per line, until it closes.
52+
async fn serve<B: Backend>(backend: B) -> Result<(), BoxError> {
53+
let mut server = Server::new(backend);
54+
let mut lines = BufReader::new(tokio::io::stdin()).lines();
55+
let mut stdout = tokio::io::stdout();
56+
let outcome = async {
57+
while let Some(line) = lines.next_line().await? {
58+
let mut response = serde_json::to_vec(&respond(&mut server, &line).await)?;
59+
response.push(b'\n');
60+
stdout.write_all(&response).await?;
61+
stdout.flush().await?;
62+
}
63+
Ok::<_, BoxError>(())
64+
}
65+
.await;
66+
let _ = tokio::time::timeout(SHUTDOWN_TIMEOUT, server.shutdown()).await;
67+
outcome
68+
}
69+
70+
async fn respond<B: Backend>(server: &mut Server<B>, line: &str) -> Response {
71+
let mut response = Response {
72+
error: None,
73+
id: 0,
74+
jsonrpc: "2.0",
75+
result: None,
76+
};
77+
let request: Request = match serde_json::from_str(line) {
78+
Ok(request) => request,
79+
Err(error) => {
80+
response.error = Some(RpcError::new(RpcError::PARSE_ERROR, error));
81+
return response;
82+
}
83+
};
84+
response.id = request.id;
85+
if request.jsonrpc != "2.0" {
86+
response.error = Some(RpcError::new(
87+
RpcError::INVALID_REQUEST,
88+
"jsonrpc must be 2.0",
89+
));
90+
return response;
91+
}
92+
match server
93+
.handle(&request.method, request.params.as_deref())
94+
.await
95+
{
96+
Ok(result) => response.result = Some(result),
97+
Err(error) => response.error = Some(error),
98+
}
99+
response
100+
}

0 commit comments

Comments
 (0)