Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
7e88b77
test(server): add a local PostgreSQL test runner
EmilienM Oct 2, 2026
f71b479
fix(server): hold the mutation guard while deleting a provider
EmilienM Sep 30, 2026
1f972e7
fix(server): release staged credentials when a refresh fails early
EmilienM Sep 30, 2026
1299a6a
feat(server): acquire mutation locks on a dedicated PostgreSQL lock pool
EmilienM Sep 30, 2026
478923f
perf(server): partition mutation locks by workspace and sandbox
EmilienM Sep 30, 2026
5b7f5d4
fix(server): reconcile endpoint status on startup one sandbox at a time
EmilienM Sep 30, 2026
fa3cac7
perf(server): skip the endpoint-status lock when a session was replaced
EmilienM Sep 30, 2026
6212023
test(server): cover staged credential cleanup on a lock timeout
EmilienM Sep 30, 2026
a084666
feat(server): export mutation lock wait and timeout metrics
EmilienM Sep 30, 2026
e824e88
test(server): add PostgreSQL mutation lock integration tests
EmilienM Sep 30, 2026
8f570d1
ci: run the PostgreSQL-backed server tests
EmilienM Oct 7, 2026
c00b632
docs: document scoped mutation locks and lock-pool sizing
EmilienM Sep 30, 2026
7663cc2
fix(server): resolve main merge conflict
FrostGod Oct 8, 2026
9acff21
test(server): cover concurrent SSH sandbox creates
FrostGod Oct 8, 2026
8775bf6
feat(server): check mutation lock ordering in debug builds
EmilienM Oct 8, 2026
a4c8879
fix(server): do not hold gateway shutdown for the endpoint-status retry
EmilienM Oct 8, 2026
40336d5
refactor(server): make the platform profile lock scope explicit
EmilienM Oct 8, 2026
4676933
perf(server): amortize local mutation lock table sweeps
EmilienM Oct 8, 2026
fa82963
feat(server): make the database pool ceiling configurable
bjw123 Aug 20, 2026
943ee5f
feat(server): make the mutation lock pool size configurable
EmilienM Oct 8, 2026
9c06d08
feat(server): export mutation lock errors and lock connections in use
EmilienM Oct 8, 2026
7829424
feat(server): time mutation lock holds and test a stalled holder
EmilienM Oct 8, 2026
9272ffb
fix(server): resolve rebase conflicts
FrostGod Oct 9, 2026
cfce8e0
fix(server): resolve rebase conflicts
FrostGod Oct 9, 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
21 changes: 21 additions & 0 deletions TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,27 @@ 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. 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
184 changes: 183 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 @@ -3143,6 +3188,143 @@ 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() {
let _lock = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let _g = EnvVarGuard::remove("OPENSHELL_DB_MAX_CONNECTIONS");

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() {
let _lock = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let _g = EnvVarGuard::set("OPENSHELL_DB_MAX_CONNECTIONS", "128");

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() {
let _lock = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let _g = EnvVarGuard::remove("OPENSHELL_DB_MAX_CONNECTIONS");

// 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() {
let _lock = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let _g = EnvVarGuard::remove("OPENSHELL_DB_LOCK_MAX_CONNECTIONS");

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() {
let _lock = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let _g = EnvVarGuard::set("OPENSHELL_DB_LOCK_MAX_CONNECTIONS", "16");

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() {
let _lock = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let _g = EnvVarGuard::remove("OPENSHELL_DB_LOCK_MAX_CONNECTIONS");

// 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