Skip to content

Commit 0b79b51

Browse files
committed
WIP feat: filling out axum-multi-tenant example
1 parent d9fc489 commit 0b79b51

File tree

16 files changed

+664
-86
lines changed

16 files changed

+664
-86
lines changed

Cargo.lock

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

examples/postgres/axum-multi-tenant/Cargo.toml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,17 @@ authors.workspace = true
1212
accounts = { path = "accounts" }
1313
payments = { path = "payments" }
1414

15+
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
16+
1517
sqlx = { path = "../../..", version = "0.8.3", features = ["runtime-tokio", "postgres"] }
1618

19+
axum = "0.8.1"
20+
21+
clap = { version = "4.5.30", features = ["derive", "env"] }
22+
color-eyre = "0.6.3"
23+
dotenvy = "0.15.7"
24+
tracing-subscriber = "0.3.19"
25+
26+
1727
[lints]
1828
workspace = true

examples/postgres/axum-multi-tenant/README.md

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,20 @@
33
This example project involves three crates, each owning a different schema in one database,
44
with their own set of migrations.
55

6-
* The main crate, an Axum app.
7-
* Owns the `public` schema (tables are referenced unqualified).
6+
* The main crate, an Axum app.
7+
* Owns the `public` schema (tables are referenced unqualified).
88
* `accounts`: a subcrate simulating a reusable account-management crate.
9-
* Owns schema `accounts`.
9+
* Owns schema `accounts`.
1010
* `payments`: a subcrate simulating a wrapper for a payments API.
11-
* Owns schema `payments`.
11+
* Owns schema `payments`.
12+
13+
## Note: Schema-Qualified Names
14+
15+
This example uses schema-qualified names everywhere for clarity.
16+
17+
It can be tempting to change the `search_path` of the connection (MySQL, Postgres) to eliminate the need for schema
18+
prefixes, but this can cause some really confusing issues when names conflict.
19+
20+
This example will generate a `_sqlx_migrations` table in three different schemas, and if `search_path` is set
21+
to `public,accounts,payments` and the migrator for the main application attempts to reference the table unqualified,
22+
it would throw an error.

examples/postgres/axum-multi-tenant/accounts/Cargo.toml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ version = "0.1.0"
44
edition = "2021"
55

66
[dependencies]
7-
sqlx = { workspace = true, features = ["postgres", "time", "uuid"] }
7+
sqlx = { workspace = true, features = ["postgres", "time", "uuid", "macros", "sqlx-toml"] }
88
tokio = { version = "1", features = ["rt", "sync"] }
99

1010
argon2 = { version = "0.5.3", features = ["password-hash"] }
@@ -13,3 +13,8 @@ password-hash = { version = "0.5", features = ["std"] }
1313
uuid = "1"
1414
thiserror = "1"
1515
rand = "0.8"
16+
17+
time = "0.3.37"
18+
19+
[dev-dependencies]
20+
sqlx = { workspace = true, features = ["runtime-tokio"] }
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
-- We try to ensure every table has `created_at` and `updated_at` columns, which can help immensely with debugging
2+
-- and auditing.
3+
--
4+
-- While `created_at` can just be `default now()`, setting `updated_at` on update requires a trigger which
5+
-- is a lot of boilerplate. These two functions save us from writing that every time as instead we can just do
6+
--
7+
-- select accounts.trigger_updated_at('<table name>');
8+
--
9+
-- after a `CREATE TABLE`.
10+
create or replace function accounts.set_updated_at()
11+
returns trigger as
12+
$$
13+
begin
14+
NEW.updated_at = now();
15+
return NEW;
16+
end;
17+
$$ language plpgsql;
18+
19+
create or replace function accounts.trigger_updated_at(tablename regclass)
20+
returns void as
21+
$$
22+
begin
23+
execute format('CREATE TRIGGER set_updated_at
24+
BEFORE UPDATE
25+
ON %s
26+
FOR EACH ROW
27+
WHEN (OLD is distinct from NEW)
28+
EXECUTE FUNCTION accounts.set_updated_at();', tablename);
29+
end;
30+
$$ language plpgsql;
Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
create table accounts.account
22
(
3-
account_id uuid primary key default gen_random_uuid(),
4-
email text unique not null,
5-
password_hash text not null,
6-
created_at timestamptz not null default now(),
7-
updated_at timestamptz
3+
account_id uuid primary key default gen_random_uuid(),
4+
email text unique not null,
5+
password_hash text not null,
6+
created_at timestamptz not null default now(),
7+
updated_at timestamptz
88
);
9+
10+
select accounts.trigger_updated_at('accounts.account');

examples/postgres/axum-multi-tenant/accounts/sqlx.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[migrate]
22
create-schemas = ["accounts"]
3-
migrations-table = "accounts._sqlx_migrations"
3+
table-name = "accounts._sqlx_migrations"
44

55
[macros.table-overrides.'accounts.account']
66
'account_id' = "crate::AccountId"

examples/postgres/axum-multi-tenant/accounts/src/lib.rs

Lines changed: 41 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
11
use argon2::{password_hash, Argon2, PasswordHasher, PasswordVerifier};
2-
use std::error::Error;
32
use std::sync::Arc;
43

54
use password_hash::PasswordHashString;
65

7-
use sqlx::{PgConnection, PgTransaction};
8-
use sqlx::types::Text;
6+
use sqlx::{PgConnection, PgPool, PgTransaction};
97

108
use uuid::Uuid;
119

@@ -16,6 +14,37 @@ use tokio::sync::Semaphore;
1614
pub struct AccountId(pub Uuid);
1715

1816
pub struct AccountsManager {
17+
/// Controls how many blocking tasks are allowed to run concurrently for Argon2 hashing.
18+
///
19+
/// ### Motivation
20+
/// Tokio blocking tasks are generally not designed for CPU-bound work.
21+
///
22+
/// If no threads are idle, Tokio will automatically spawn new ones to handle
23+
/// new blocking tasks up to a very high limit--512 by default.
24+
///
25+
/// This is because blocking tasks are expected to spend their time *blocked*, e.g. on
26+
/// blocking I/O, and thus not consume CPU resources or require a lot of context switching.
27+
///
28+
/// This strategy is not the most efficient way to use threads for CPU-bound work, which
29+
/// should schedule work to a fixed number of threads to minimize context switching
30+
/// and memory usage (each new thread needs significant space allocated for its stack).
31+
///
32+
/// We can work around this by using a purpose-designed thread-pool, like Rayon,
33+
/// but we still have the problem that those APIs usually are not designed to support `async`,
34+
/// so we end up needing blocking tasks anyway, or implementing our own work queue using
35+
/// channels. Rayon also does not shut down idle worker threads.
36+
///
37+
/// `block_in_place` is not a silver bullet, either, as it simply uses `spawn_blocking`
38+
/// internally to take over from the current thread while it is executing blocking work.
39+
/// This also prevents futures from being polled concurrently in the current task.
40+
///
41+
/// We can lower the limit for blocking threads when creating the runtime, but this risks
42+
/// starving other blocking tasks that are being created by the application or the Tokio
43+
/// runtime itself
44+
/// (which are used for `tokio::fs`, stdio, resolving of hostnames by `ToSocketAddrs`, etc.).
45+
///
46+
/// Instead, we can just use a Semaphore to limit how many blocking tasks are spawned at once,
47+
/// emulating the behavior of a thread pool like Rayon without needing any additional crates.
1948
hashing_semaphore: Arc<Semaphore>,
2049
}
2150

@@ -57,7 +86,7 @@ pub enum GeneralError {
5786
PasswordHash(
5887
#[source]
5988
#[from]
60-
argon2::password_hash::Error,
89+
password_hash::Error,
6190
),
6291
#[error("task panicked")]
6392
Task(
@@ -68,12 +97,9 @@ pub enum GeneralError {
6897
}
6998

7099
impl AccountsManager {
71-
pub async fn new(
72-
conn: &mut PgConnection,
73-
max_hashing_threads: usize,
74-
) -> Result<Self, GeneralError> {
100+
pub async fn setup(pool: &PgPool, max_hashing_threads: usize) -> Result<Self, GeneralError> {
75101
sqlx::migrate!()
76-
.run(conn)
102+
.run(pool)
77103
.await
78104
.map_err(sqlx::Error::from)?;
79105

@@ -147,8 +173,8 @@ impl AccountsManager {
147173
let hash = self.hash_password(password).await?;
148174

149175
// Thanks to `sqlx.toml`, `account_id` maps to `AccountId`
150-
// language=PostgreSQL
151176
sqlx::query_scalar!(
177+
// language=PostgreSQL
152178
"insert into accounts.account(email, password_hash) \
153179
values ($1, $2) \
154180
returning account_id",
@@ -158,7 +184,9 @@ impl AccountsManager {
158184
.fetch_one(&mut **txn)
159185
.await
160186
.map_err(|e| {
161-
if e.as_database_error().and_then(|dbe| dbe.constraint()) == Some("account_account_id_key") {
187+
if e.as_database_error().and_then(|dbe| dbe.constraint())
188+
== Some("account_account_id_key")
189+
{
162190
CreateError::EmailInUse
163191
} else {
164192
GeneralError::from(e).into()
@@ -193,7 +221,8 @@ impl AccountsManager {
193221
return Err(AuthenticateError::UnknownEmail);
194222
};
195223

196-
self.verify_password(password, account.password_hash.into_inner()).await?;
224+
self.verify_password(password, account.password_hash.into_inner())
225+
.await?;
197226

198227
Ok(account.account_id)
199228
}

examples/postgres/axum-multi-tenant/payments/Cargo.toml

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,14 @@ version = "0.1.0"
44
edition = "2021"
55

66
[dependencies]
7-
sqlx = { workspace = true, features = ["postgres", "time", "uuid"] }
7+
accounts = { path = "../accounts" }
8+
9+
sqlx = { workspace = true, features = ["postgres", "time", "uuid", "rust_decimal", "sqlx-toml"] }
10+
11+
rust_decimal = "1.36.0"
12+
13+
time = "0.3.37"
14+
uuid = "1.12.1"
15+
16+
[dev-dependencies]
17+
sqlx = { workspace = true, features = ["runtime-tokio"] }
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
-- We try to ensure every table has `created_at` and `updated_at` columns, which can help immensely with debugging
2+
-- and auditing.
3+
--
4+
-- While `created_at` can just be `default now()`, setting `updated_at` on update requires a trigger which
5+
-- is a lot of boilerplate. These two functions save us from writing that every time as instead we can just do
6+
--
7+
-- select payments.trigger_updated_at('<table name>');
8+
--
9+
-- after a `CREATE TABLE`.
10+
create or replace function payments.set_updated_at()
11+
returns trigger as
12+
$$
13+
begin
14+
NEW.updated_at = now();
15+
return NEW;
16+
end;
17+
$$ language plpgsql;
18+
19+
create or replace function payments.trigger_updated_at(tablename regclass)
20+
returns void as
21+
$$
22+
begin
23+
execute format('CREATE TRIGGER set_updated_at
24+
BEFORE UPDATE
25+
ON %s
26+
FOR EACH ROW
27+
WHEN (OLD is distinct from NEW)
28+
EXECUTE FUNCTION payments.set_updated_at();', tablename);
29+
end;
30+
$$ language plpgsql;

0 commit comments

Comments
 (0)