Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
418f815
test(server): add a local PostgreSQL test runner
EmilienM Oct 2, 2026
48fa8aa
fix(server): hold the mutation guard while deleting a provider
EmilienM Sep 30, 2026
c76be93
fix(server): release staged credentials when a refresh fails early
EmilienM Sep 30, 2026
c13be95
feat(server): acquire mutation locks on a dedicated PostgreSQL lock pool
EmilienM Sep 30, 2026
084810e
perf(server): partition mutation locks by workspace and sandbox
EmilienM Sep 30, 2026
e508435
fix(server): reconcile endpoint status on startup one sandbox at a time
EmilienM Sep 30, 2026
69b275c
perf(server): skip the endpoint-status lock when a session was replaced
EmilienM Sep 30, 2026
eb204bd
test(server): cover staged credential cleanup on a lock timeout
EmilienM Sep 30, 2026
1a57fe9
feat(server): export mutation lock wait and timeout metrics
EmilienM Sep 30, 2026
101f147
test(server): add PostgreSQL mutation lock integration tests
EmilienM Sep 30, 2026
ac1ce1f
ci: run the PostgreSQL-backed server tests
EmilienM Oct 7, 2026
eb3eb15
docs: document scoped mutation locks and lock-pool sizing
EmilienM Sep 30, 2026
ae62a03
feat(server): check mutation lock ordering in debug builds
EmilienM Oct 8, 2026
6d9d5e0
fix(server): do not hold gateway shutdown for the endpoint-status retry
EmilienM Oct 8, 2026
d241836
refactor(server): make the platform profile lock scope explicit
EmilienM Oct 8, 2026
1b5bf6c
perf(server): amortize local mutation lock table sweeps
EmilienM Oct 8, 2026
2b8b801
feat(server): make the database pool ceiling configurable
bjw123 Aug 20, 2026
d874f84
feat(server): make the mutation lock pool size configurable
EmilienM Oct 8, 2026
a1143e2
feat(server): export mutation lock errors and lock connections in use
EmilienM Oct 8, 2026
9941f73
feat(server): time mutation lock holds and test a stalled holder
EmilienM Oct 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .github/workflows/branch-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,36 @@ jobs:
cargo nextest run --locked --profile ci --workspace --features openshell-server/test-support
cargo nextest run --locked --config-file .config/nextest.toml --profile ci --manifest-path examples/supervisor-middleware-content-guard/Cargo.toml

rust-postgres-test:
name: Rust PostgreSQL tests
needs: pr_metadata
if: needs.pr_metadata.outputs.should_run == 'true'
# Bare runner: the test script starts a disposable PostgreSQL container
# with the host's Docker.
runs-on: linux-amd64-cpu8
timeout-minutes: 30
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- uses: ./.github/actions/setup-nix
with:
prepare-shell: "true"
cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }}

- uses: ./.github/actions/setup-rust
with:
cache-key: rust-checks-v2
save-cache: "false"

- name: Test
shell: nix develop -c bash -euo pipefail {0}
env:
CONTAINER_ENGINE: docker
OPENSHELL_TELEMETRY_ENABLED: "false"
run: tasks/scripts/run-postgres-tests.sh --locked --profile ci

rust-build-modes:
name: Rust build modes (${{ matrix.system }})
needs: pr_metadata
Expand Down
22 changes: 22 additions & 0 deletions TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,28 @@ mise run test:rust # cargo test --workspace

Rust validation checks tracked Cargo lockfiles; run `mise run rust:lockfiles:check` to check them directly. If one is stale, refresh it with Cargo using its adjacent manifest, review the diff, and commit the update.

### PostgreSQL-backed tests

Tests that need a real PostgreSQL server, such as advisory-lock concurrency
across two stores, are `#[ignore]`d and named `postgres_*`. Run them with:

```shell
mise run test:rust:postgres
```

The task starts a disposable PostgreSQL container with Docker or Podman
(set `CONTAINER_ENGINE` to choose), runs the tests one at a time, and removes
the container. Each test works in its own temporary schema, except the replay
and provider refresh lock tests, which use the default schema. To use your own
disposable database, set `OPENSHELL_TEST_POSTGRES_URL`. The task overrides
`OPENSHELL_REPLAY_TEST_DATABASE_URL` so legacy tests use that same database.
Never point it at a database that a running gateway uses: the tests take
fleet-wide advisory locks.
Branch Checks runs them on x86_64 Linux.
Load-sensitive capacity checks are named `bench_postgres_*` instead, so that
task skips them. Run them on an idle machine with
`mise run test:rust:postgres:bench`.

### Native Windows validation

Use `mise run --skip-tools pre-commit` with the existing Rust/MSVC toolchain.
Expand Down
41 changes: 41 additions & 0 deletions crates/openshell-core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,24 @@ pub struct Config {
/// Database URL for persistence.
pub database_url: String,

/// Connection ceiling for the persistence pool.
///
/// `None` leaves the backend's built-in default in place. The right
/// ceiling depends on the deployment — how many gateway replicas share
/// the database, and what `max_connections` a Postgres server itself
/// allows — so it cannot be one number baked into the binary. An
/// in-memory `SQLite` database ignores it: the database lives in its single
/// connection.
pub database_max_connections: Option<u32>,

/// Connection ceiling for the `PostgreSQL` mutation lock pool.
///
/// `None` keeps the built-in default of 4. Each mutation guard holds one
/// lock connection, so this bounds how many guarded mutations a replica
/// runs or waits on in `PostgreSQL` at once. `SQLite` has no lock pool and
/// ignores it.
pub database_lock_max_connections: Option<u32>,

/// Explicit compute driver configured for the gateway.
/// `None` enables runtime auto-detection.
pub compute_driver: Option<String>,
Expand Down Expand Up @@ -892,6 +910,8 @@ impl Config {
mtls_auth: MtlsAuthConfig::default(),
gateway_jwt: None,
database_url: String::new(),
database_max_connections: None,
database_lock_max_connections: None,
compute_driver: None,
compute_driver_endpoints: BTreeMap::new(),
credential_drivers: Vec::new(),
Expand Down Expand Up @@ -944,6 +964,27 @@ impl Config {
self
}

/// Create a new configuration with a database pool connection ceiling.
///
/// `None` keeps the persistence backend's default.
#[must_use]
pub const fn with_database_max_connections(mut self, max_connections: Option<u32>) -> Self {
self.database_max_connections = max_connections;
self
}

/// Create a new configuration with a mutation lock pool connection ceiling.
///
/// `None` keeps the built-in default.
#[must_use]
pub const fn with_database_lock_max_connections(
mut self,
max_connections: Option<u32>,
) -> Self {
self.database_lock_max_connections = max_connections;
self
}

/// Create a new configuration with an explicit compute driver.
#[must_use]
pub fn with_compute_driver(mut self, driver: impl ToString) -> Self {
Expand Down
3 changes: 2 additions & 1 deletion crates/openshell-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,8 @@ cargo test -p openshell-server --features test-support,prebuilt-z3
cargo clippy -p openshell-server --all-targets --features test-support,prebuilt-z3 -- -D warnings
```

The ignored `refresh_locks_coordinate_independent_replica_sessions` test requires
The ignored `postgres_refresh_locks_coordinate_independent_replica_sessions` test
runs under `mise run test:rust:postgres` (see `TESTING.md`) and still accepts
`OPENSHELL_REFRESH_TEST_DATABASE_URL` pointing to a disposable PostgreSQL database.
It exercises independent sessions without the local mutex. Do not point this
fixture at an existing deployment database.
178 changes: 177 additions & 1 deletion crates/openshell-server/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,36 @@ struct RunArgs {
#[arg(long, env = "OPENSHELL_DB_URL")]
db_url: Option<String>,

/// Connection ceiling for the database pool, shared by every
/// database-backed request.
///
/// When unset, the gateway uses its backend default: 10 with Postgres,
/// 5 with an on-disk `SQLite` database. An in-memory `SQLite` database is
/// always a single connection. Postgres needs at least 2, because the SSH
/// identity lock keeps one connection while it queries the pool, so the
/// gateway rejects 1 there at startup. Each replica opens its own pool,
/// plus a mutation lock pool on Postgres (`--db-lock-max-connections`), so
/// keep the total below what the database server itself admits.
#[arg(
long,
env = "OPENSHELL_DB_MAX_CONNECTIONS",
value_parser = clap::value_parser!(u32).range(1..)
)]
db_max_connections: Option<u32>,

/// Connection ceiling for the Postgres mutation lock pool.
///
/// Each mutation guard holds one lock connection, so this bounds how many
/// guarded mutations a replica runs or waits on in Postgres at once.
/// When unset, the gateway uses 4. Each replica opens these on top of
/// its database pool. `SQLite` has no lock pool and ignores it.
#[arg(
long,
env = "OPENSHELL_DB_LOCK_MAX_CONNECTIONS",
value_parser = clap::value_parser!(u32).range(1..)
)]
db_lock_max_connections: Option<u32>,

/// Compute driver configured for this gateway.
///
/// Accepts one registered driver name. When unset, the gateway runs
Expand Down Expand Up @@ -516,7 +546,10 @@ fn prepare_server_config_with_drivers(
config = config.with_metrics_bind_address(addr);
}

config = config.with_database_url(db_url);
config = config
.with_database_url(db_url)
.with_database_max_connections(args.db_max_connections)
.with_database_lock_max_connections(args.db_lock_max_connections);
if let Some(driver) = &args.compute_driver {
config = config.with_compute_driver(driver);
}
Expand Down Expand Up @@ -1267,6 +1300,18 @@ fn merge_file_into_args(args: &mut RunArgs, file: &GatewayFileSection, matches:
{
args.grpc_rate_limit_window_seconds = Some(window);
}
if let Some(max_connections) = file.database_max_connections
&& args.db_max_connections.is_none()
&& arg_defaulted(matches, "db_max_connections")
{
args.db_max_connections = Some(max_connections);
}
if let Some(max_connections) = file.database_lock_max_connections
&& args.db_lock_max_connections.is_none()
&& arg_defaulted(matches, "db_lock_max_connections")
{
args.db_lock_max_connections = Some(max_connections);
}
}

fn validate_grpc_rate_limit_args(requests: Option<u64>, window_seconds: Option<u64>) -> Result<()> {
Expand Down Expand Up @@ -3133,6 +3178,137 @@ grpc_rate_limit_window_seconds = 30
assert_eq!(args.grpc_rate_limit_window_seconds, Some(30));
}

#[test]
fn file_db_max_connections_populates_args_when_cli_omits() {
Environment::new()
.remove("OPENSHELL_DB_MAX_CONNECTIONS")
.run(|| {
let (mut args, matches) =
parse_with_args(&["openshell-gateway", "--db-url", "sqlite::memory:"]);
assert_eq!(
args.db_max_connections, None,
"default leaves the pool alone"
);
let file = config_file_from_toml(
r"
[openshell.gateway]
database_max_connections = 64
",
);
merge_file_into_args(&mut args, &file.openshell.gateway, &matches);

assert_eq!(args.db_max_connections, Some(64));
});
}

#[test]
fn env_db_max_connections_overrides_file_value() {
Environment::new()
.set("OPENSHELL_DB_MAX_CONNECTIONS", "128")
.run(|| {
let (mut args, matches) =
parse_with_args(&["openshell-gateway", "--db-url", "sqlite::memory:"]);
let file = config_file_from_toml(
r"
[openshell.gateway]
database_max_connections = 64
",
);
merge_file_into_args(&mut args, &file.openshell.gateway, &matches);

assert_eq!(args.db_max_connections, Some(128));
});
}

#[test]
fn db_max_connections_rejects_a_zero_or_unparseable_ceiling() {
Environment::new()
.remove("OPENSHELL_DB_MAX_CONNECTIONS")
.run(|| {
// Zero would deadlock every `acquire`, and a silent fallback to the
// default would reproduce the ceiling the operator is trying to lift.
for bad in ["0", "-5", "many"] {
assert!(
command()
.try_get_matches_from([
"openshell-gateway",
"--db-url",
"sqlite::memory:",
"--db-max-connections",
bad,
])
.is_err(),
"input {bad:?} must be rejected"
);
}
});
}

#[test]
fn file_db_lock_max_connections_populates_args_when_cli_omits() {
Environment::new()
.remove("OPENSHELL_DB_LOCK_MAX_CONNECTIONS")
.run(|| {
let (mut args, matches) =
parse_with_args(&["openshell-gateway", "--db-url", "sqlite::memory:"]);
assert_eq!(
args.db_lock_max_connections, None,
"default leaves the lock pool alone"
);
let file = config_file_from_toml(
r"
[openshell.gateway]
database_lock_max_connections = 8
",
);
merge_file_into_args(&mut args, &file.openshell.gateway, &matches);

assert_eq!(args.db_lock_max_connections, Some(8));
});
}

#[test]
fn env_db_lock_max_connections_overrides_file_value() {
Environment::new()
.set("OPENSHELL_DB_LOCK_MAX_CONNECTIONS", "16")
.run(|| {
let (mut args, matches) =
parse_with_args(&["openshell-gateway", "--db-url", "sqlite::memory:"]);
let file = config_file_from_toml(
r"
[openshell.gateway]
database_lock_max_connections = 8
",
);
merge_file_into_args(&mut args, &file.openshell.gateway, &matches);

assert_eq!(args.db_lock_max_connections, Some(16));
});
}

#[test]
fn db_lock_max_connections_rejects_a_zero_or_unparseable_ceiling() {
Environment::new()
.remove("OPENSHELL_DB_LOCK_MAX_CONNECTIONS")
.run(|| {
// A zero-sized lock pool would time out every guarded mutation.
for bad in ["0", "-5", "many"] {
assert!(
command()
.try_get_matches_from([
"openshell-gateway",
"--db-url",
"sqlite::memory:",
"--db-lock-max-connections",
bad,
])
.is_err(),
"input {bad:?} must be rejected"
);
}
});
}

#[test]
fn aux_listener_preserves_file_ip_against_public_bind() {
use std::net::SocketAddr;
Expand Down
Loading
Loading