Skip to content

feat(server): scope mutation locks and bound lock waits - #4147

Open
EmilienM wants to merge 22 commits into
NVIDIA:mainfrom
EmilienM:feat/3528-scoped-locks/EmilienM
Open

EmilienM wants to merge 22 commits into
NVIDIA:mainfrom
EmilienM:feat/3528-scoped-locks/EmilienM

Conversation

@EmilienM

@EmilienM EmilienM commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Scope gateway mutation locks by workspace and sandbox and bound every lock wait, so a burst of supervisor reconnects (rollout, scale-down, redirects) no longer queues behind one fleet-wide lock.

Related Issue

Part of #3528. Follows #3978, now merged. Carries #2828 by @bjw123. Follow-ups are tracked in #4307.

Changes

  • Replace the process-wide sync_lock and the single fleet-wide advisory key with global, workspace and sandbox lock scopes (shared/exclusive) held on a dedicated PostgreSQL lock pool. Replicas still on the old release keep taking the global key, so a mixed-version rollout stays serialized. Debug builds check the lock ordering rules before every wait.
  • Bound lock waits at 10 seconds. A timeout returns UNAVAILABLE with reason MUTATION_LOCK_TIMEOUT and retry info. A lock connection PostgreSQL never opens returns INTERNAL, so it doesn't read as contention.
  • Reconcile endpoint status at startup one sandbox at a time, and skip the endpoint-status lock for a superseded supervisor session.
  • Sandbox settings are stored by sandbox name, which a per-sandbox lock doesn't protect once another sandbox reuses the name. A settings update now fails with NOT_FOUND or ABORTED instead of writing into the newer sandbox's settings.
  • Fix two races that exist on main, each in its own commit: DeleteProvider holds the mutation guard while it deletes, and staged provider credentials are released when a refresh fails early. On kind, the delete race left a dangling provider reference that crashlooped every gateway at startup.
  • Make both pool sizes configurable: feat(server): make the database pool ceiling configurable #2828's commit for the data pool (server.dbMaxConnections) and a matching server.dbLockMaxConnections, with flag, env and gateway.toml forms. Defaults stay 10 and 4 (PostgreSQL needs at least 2 data connections), so each pod opens up to 14 PostgreSQL connections instead of 10; raise max_connections before upgrading.
  • Export lock wait, timeout, error and hold-time metrics plus lock connections in use, add PostgreSQL integration tests run by mise run test:rust:postgres and a Branch Checks job, and document pool sizing. The reconnect-burst capacity check runs separately with mise run test:rust:postgres:bench, outside CI.

Testing

  • On the tip (main merged in), mise run pre-commit and mise run docs:build:strict pass, mise run rust:lint, the Helm tests and the openshell-server tests pass, and every commit builds with its tests.
  • mise run test:rust:postgres: 18 tests covering scope exclusion, interop with the old global key, cancellation, pool bounds and gauges, connection failures and a stalled holder. mise run test:rust:postgres:bench passes on an idle machine.
  • kind with external PostgreSQL: the HA suite passed 3/3, the lock metrics showed up in Prometheus with zero timeouts, and a mixed fleet (old and new replicas serving together through upgrade, rollback and upgrade) ran about 11,000 mutations with no inconsistencies and no lock timeouts. These runs predate the rebases onto main; CI's HA E2E covers the current tip.

Checklist

  • Follows Conventional Commits
  • Commits are signed off (DCO)
  • Unit and PostgreSQL integration tests added
  • Operator documentation and the cluster debug skill updated
Gemini_Generated_Image_ypqi10ypqi10ypqi

@copy-pr-bot

copy-pr-bot Bot commented Oct 2, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

@EmilienM
EmilienM force-pushed the feat/3528-scoped-locks/EmilienM branch from 26c5f0d to 71b9c52 Compare October 6, 2026 18:37
@EmilienM
EmilienM marked this pull request as ready for review October 6, 2026 18:37
@johntmyers johntmyers added test:e2e Requires end-to-end coverage test:e2e-kubernetes Requires Kubernetes end-to-end coverage labels Oct 6, 2026
@github-actions

github-actions Bot commented Oct 6, 2026

Copy link
Copy Markdown

Label test:e2e applied, but pull-request/4147 does not exist yet. A maintainer needs to comment /ok to test 71b9c5294ec18ced30b7179b177adeac54b3588f to mirror this PR. Once the mirror exists, re-apply the label or re-run Branch E2E Checks from the Actions tab.

@github-actions

github-actions Bot commented Oct 6, 2026

Copy link
Copy Markdown

Label test:e2e-kubernetes applied, but pull-request/4147 does not exist yet. A maintainer needs to comment /ok to test 71b9c5294ec18ced30b7179b177adeac54b3588f to mirror this PR. Once the mirror exists, re-apply the label or re-run Branch E2E Checks from the Actions tab.

@johntmyers

Copy link
Copy Markdown
Collaborator

/ok to test 71b9c52

@johntmyers

Copy link
Copy Markdown
Collaborator

gator-agent

PR Review Status

The independent review of scoped mutation locks, bounded waits, and provider cleanup found no blocking findings. Current-head Branch Checks, Helm Lint, Trivy Changes, and E2E workflows are queued or running; Gator will monitor their results.

Blocking findings: None.
Carried findings: None.
Non-blocking suggestions: None.

Gator metadata
  • Validation: Implements the scoped mutation-lock work in feat(ha): add production scaling signals and graceful gateway redistribution #3528 and follows merged feat(server): add gateway capacity metrics and optional HPA #3978; no active duplicate found.
  • Docs: Fern HA connection sizing, mixed-version upgrades, lock metrics, and API error guidance updated; cluster debug skill updated.
  • Checks: DCO passes; no merge conflicts. Branch Checks (37513563884), Helm Lint (37513564067), and Trivy Changes (37512710449) are dispatched for the current head.
  • E2E: test:e2e and test:e2e-kubernetes applied. Branch E2E Checks (37513564920) is running after /ok to test refreshed the mirror; labels were present before dispatch. No rerun needed.
  • Local validation: PostgreSQL test-runner database selection test and diff whitespace check passed. Full Rust/PostgreSQL tests were not run locally because this sandbox lacks the toolchain and container engine.
  • Head SHA: 71b9c5294ec18ced30b7179b177adeac54b3588f
  • Base SHA: 8760396975f4b0f13cd6fd88ac1a46342ee84343
  • Merge base SHA: 8760396975f4b0f13cd6fd88ac1a46342ee84343
  • Patch ID: 10a548689cc4281ba946a4f7a503950e90537704
  • Gator payload: 10
  • Review mode: initial
  • Previous reviewed SHA: none
  • Review budget exhausted: no
  • Maintainer decision required: no
  • Next state: gator:watch-pipeline

@johntmyers johntmyers added gator:watch-pipeline Gator is monitoring PR CI/CD status gator:approval-needed Gator completed review; maintainer approval needed gator:blocked Gator is blocked by process or repository gates and removed gator:watch-pipeline Gator is monitoring PR CI/CD status gator:approval-needed Gator completed review; maintainer approval needed labels Oct 6, 2026
@EmilienM
EmilienM force-pushed the feat/3528-scoped-locks/EmilienM branch from 71b9c52 to 51822ba Compare October 6, 2026 22:12
@johntmyers

Copy link
Copy Markdown
Collaborator

/ok to test 51822ba

@johntmyers

Copy link
Copy Markdown
Collaborator

gator-agent

PR Review Status

The independent follow-up review found no blocking findings in the author’s changes across the rebase, including settings row identity checks, provider-name validation, SSH identity lock-pool separation, and lifecycle lock adaptations. The stale test mirror has been refreshed, and current-head Branch Checks, Helm Lint, Trivy Changes, and E2E workflows are running; Gator will monitor their results.

Blocking findings: None.
Carried findings: None.

Gator metadata
  • Validation: Continues the scoped mutation-lock work in feat(ha): add production scaling signals and graceful gateway redistribution #3528 and follows merged feat(server): add gateway capacity metrics and optional HPA #3978.
  • Docs: Fern HA guidance and the cluster debug skill cover the SSH identity lock behavior and connection sizing.
  • Checks: DCO passes; no merge conflicts. Branch Checks (37539382397), Helm Lint (37539382408), and Trivy Changes (37539113467) are running for the current head. Trivy workflow execution was approved after a narrowly scoped sandbox policy update.
  • E2E: test:e2e and test:e2e-kubernetes were present before mirror refresh. Branch E2E Checks (37539383455) is running; no label-help rerun request was posted.
  • Local validation: PostgreSQL test-runner database selection test and diff whitespace check passed. Full Rust/PostgreSQL tests could not run locally because this sandbox lacks the Rust toolchain and container engine.
  • Head SHA: 51822ba9c96ae3fad3cf94761e3f9f9a24dd5a90
  • Base SHA: 9a6148fc988a5e7a20a43805a52fdfaad766f8ae
  • Merge base SHA: 360c5a02cba5b9ee62f24e2528ab1ea2aead9b6f
  • Patch ID: c19aea2c1d6810b73eaed1fc917ee38092fff512
  • Gator payload: 10
  • Review mode: follow_up
  • Previous reviewed SHA: 71b9c5294ec18ced30b7179b177adeac54b3588f
  • Review budget exhausted: no
  • Maintainer decision required: no
  • Next state: gator:watch-pipeline

@johntmyers johntmyers added gator:watch-pipeline Gator is monitoring PR CI/CD status gator:approval-needed Gator completed review; maintainer approval needed and removed gator:blocked Gator is blocked by process or repository gates gator:watch-pipeline Gator is monitoring PR CI/CD status labels Oct 6, 2026
Comment thread TESTING.md Outdated
`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.
CI does not run these tests; the Kubernetes HA e2e suite covers PostgreSQL end

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we run the focused PostgreSQL lock tests in CI? They check cancellation, connection reuse, and compatibility with old replicas

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Branch Checks has a new "Rust PostgreSQL tests" job that runs the same runner script on a Linux runner, so the 15 postgres_* tests (cancellation, connection reuse, old-replica interop and the rest) run on every PR. The burst test is out of that suite now, see the other thread.

);
// Waits must stay far from the lock timeout, where requests fail.
assert!(
p99 * 5 < MUTATION_LOCK_TIMEOUT,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we move this latency assertion into a dedicated performance benchmark? In our earlier run, all 1,000 operations succeeded, but p99 was 5.35 seconds while other tests were running. The isolated rerun passed, so this threshold depends on machine load.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, that one is a capacity check, so it shouldn't gate anything. It's now bench_postgres_lock_pool_absorbs_a_12ms_reconnect_burst: test:rust:postgres and CI skip it, and mise run test:rust:postgres:bench runs it on its own with the p99 check. The repo has no benchmark harness and the test uses crate-private APIs, so this seemed like the simplest place for it.


let mut sandbox_settings =
load_sandbox_settings(state.store.as_ref(), &workspace, sandbox.object_name()).await?;
ensure_sandbox_keeps_name(state, &sandbox).await?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we track the first settings write as a follow-up? A peer can delete the sandbox after this ownership check, and the first insert can still create settings inherited by a replacement using the same name. The row-ID check protects existing rows, but not the first insert.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, the row-ID check only covers rows that already exist. Main has the same race (lifecycle deletes don't take a database lock), so it's in #4307 with the other follow-ups.

/// Local S(global) S(workspace) X(sandbox), for provisioning-deadline
/// reconciliation, which re-derives configuration from provider and
/// profile records and must not interleave with their local writers.
pub(super) async fn lock_sandbox_local_in_workspace(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please link a follow-up issue for bounding this wait and testing it. This path can hold the sandbox lock while waiting for a workspace lock, delaying start, stop, and delete for that sandbox.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tracked in #4307, including a test with a sandbox key that sorts before its workspace key.

The openshell-server tests that need a real PostgreSQL server are
ignored by default and had no shared way to run. The mutation-replay
test reads its own OPENSHELL_REPLAY_TEST_DATABASE_URL, so every
contributor had to provision a database by hand, and the advisory-lock
tests that follow in this series need the same setup.

Add mise run test:rust:postgres. It runs every ignored postgres_* test
in openshell-server against OPENSHELL_TEST_POSTGRES_URL, or starts a
disposable PostgreSQL container with Docker or Podman (CONTAINER_ENGINE
selects one) on the image pinned by the Kubernetes e2e fixture and
removes it on exit. Tests run one at a time because advisory locks are
database-wide. The runner always points the legacy replay variable at
the selected database, so an inherited URL cannot send that test to a
different server. test:postgres-runner checks that selection with a fake
cargo and needs no database or container engine. CI does not run the
PostgreSQL tests; TESTING.md documents the task.

Part of NVIDIA#3528

Signed-off-by: Emilien Macchi <emacchi@redhat.com>
DeleteProvider checked that no sandbox referenced the provider and then
deleted the record without holding the sandbox mutation guard. Sandbox
create and provider attach take that guard while they write provider
references, so one of them could add a reference after the attached
sandbox check passed, and the delete then removed a provider that a
sandbox spec still named.

Take the guard before the attached sandbox check, as provider create and
update already do, so the check and the delete run against a stable
set of sandbox references, and reject an empty name after authorization
but before the guard. A new test holds the guard, attaches the provider
while the delete waits, and asserts that the delete fails with
FailedPrecondition and leaves the provider in place.

Part of NVIDIA#3528

Signed-off-by: Emilien Macchi <emacchi@redhat.com>
A provider credential refresh stages the minted values under new
credential handles before it commits the provider update, and deletes
those handles when validation or persistence fails. Two earlier returns
skipped that cleanup. The minted expiry was converted to a protobuf
timestamp only after staging, so an expiry outside the timestamp range
failed the refresh and left the staged values in the credential driver.
A failure to acquire the sandbox mutation guard returned the same way.

Convert the expiry before staging anything, so a bad value fails before
any handle exists, and delete the staged handles when the guard cannot
be acquired. A new test refreshes a stored credential with an expiry of
i64::MAX and asserts that the call fails, the provider keeps its
original handles, and the credential driver holds the same number of
values as before. The guard failure path has no test here, because the
SQLite guard used by unit tests cannot fail.

Part of NVIDIA#3528

Signed-off-by: Emilien Macchi <emacchi@redhat.com>
@EmilienM

EmilienM commented Oct 7, 2026

Copy link
Copy Markdown
Contributor Author

@johntmyers I think the CI failures are unrelated can you please /ok to test again?
Thanks!

@johntmyers

Copy link
Copy Markdown
Collaborator

/ok to test c00b632

@johntmyers johntmyers added gator:watch-pipeline Gator is monitoring PR CI/CD status and removed gator:blocked Gator is blocked by process or repository gates labels Oct 7, 2026
@johntmyers johntmyers added gator:approval-needed Gator completed review; maintainer approval needed gator:blocked Gator is blocked by process or repository gates and removed gator:watch-pipeline Gator is monitoring PR CI/CD status gator:approval-needed Gator completed review; maintainer approval needed labels Oct 7, 2026
Signed-off-by: divesh <dgude@nvidia.com>
@johntmyers

Copy link
Copy Markdown
Collaborator

/ok to test 7663cc2

@johntmyers

Copy link
Copy Markdown
Collaborator

gator-agent

PR Review Status

Thanks @EmilienM. I checked your request to refresh testing and posted /ok to test for the latest head; the test mirror now matches it. The independent Critical-only review of the author-only merge resolution found no new Critical defects, and no carried blocking findings remain.

Current-head Branch Checks, Helm Lint, Trivy Changes, and E2E are queued or running. Gator will inspect their results next cycle.

Blocking findings: None.
Carried findings: None.

Gator metadata
  • Validation: Continues scoped mutation-lock work in feat(ha): add production scaling signals and graceful gateway redistribution #3528 and follows merged feat(server): add gateway capacity metrics and optional HPA #3978; this head resolves the merge with main without adding a new subsystem or public configuration surface.
  • Docs: Existing Fern HA connection sizing, mixed-version upgrade, lock metric, and API error guidance remains applicable; the merge resolution adds no new user-facing contract.
  • Checks: DCO passes; no merge conflicts. Current-head Branch Checks (37753030675), Helm Lint (37753030964), and Trivy Changes (37751905835) are dispatched. Trivy workflow execution was authorized after a policy proposal scoped to that exact run was approved and reloaded.
  • E2E: test:e2e and test:e2e-kubernetes remain applied. Current-head Branch E2E Checks (37753032025) is queued after mirror refresh. No new label-help rerun instruction was posted.
  • Local validation: PostgreSQL runner database and prefix selection tests and diff whitespace check passed. Full Rust/PostgreSQL tests were not run locally because this sandbox lacks the toolchain; CI includes PostgreSQL integration coverage.
  • Head SHA: 7663cc225a538f1bbfd925f8cba367d8e51e10d0
  • Base SHA: 67adcf1a3cf054be54dfdbf6a44c65b4d426b1ec
  • Merge base SHA: 67adcf1a3cf054be54dfdbf6a44c65b4d426b1ec
  • Patch ID: 2fb46059e80b40e41aaf7379058479a41dc98084
  • Gator payload: 10
  • Review mode: critical_only
  • Previous reviewed SHA: c00b63248dea0b0dc9020959ae0eb440cb4e3358
  • Review budget exhausted: yes
  • Maintainer decision required: no — no open obligations, qualifying scope growth, or proposed Warning requires a decision.
  • Next state: gator:watch-pipeline

@johntmyers johntmyers added gator:watch-pipeline Gator is monitoring PR CI/CD status gator:approval-needed Gator completed review; maintainer approval needed and removed gator:blocked Gator is blocked by process or repository gates gator:watch-pipeline Gator is monitoring PR CI/CD status labels Oct 8, 2026
@FrostGod

FrostGod commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

Can we bound how long guarded work can run, and test a stalled holder? On timeout, the operation must be unable to write further before its lock is released.

@FrostGod

FrostGod commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

what is the rationale behind 4 locks, and 10 data connectors,
is there some reasoning behind these numbers ?

Signed-off-by: divesh <dgude@nvidia.com>
//! 2. Process-local keys follow in ascending `i64` order.
//! 3. On `PostgreSQL`, the same keys follow as session-level advisory locks in
//! ascending order, all on one lock-pool connection.
//! 4. A task never acquires a mutation guard or a local lifecycle lock while

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rules 4 and 5 carry the whole deadlock-freedom argument but are prose only. Since the scheme is correct for disjoint keys, a future mutation_guard() call inside a guarded section could pass the full suite and only deadlock in production. A task-local depth counter with a debug_assert! in mutation_guard() would make that a test failure instead.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, prose isn't enough here. A task_local depth counter doesn't fit tokio well: it needs a scope at every spawn, and guards move into the delete and failed-create workers, so the drop would hit the wrong counter. I added a debug-only check instead, keyed by tokio's task id (thread id for block_on roots like #[tokio::test] bodies). Each guard remembers the owner it registered under, and local keys, blocking lifecycle gates and the SSH identity lock now panic before waiting when they'd break rules 1, 4 or 5. Release builds compile it out. It found no real nesting, but startup endpoint-status reconciliation runs four guarded futures concurrently in one task, so that now goes through an explicit lock_order::branch(), and so do the tests (your SSH create test included) that hold a guard in the test body while driving the guarded path.

/// open a lock connection, or Postgres `lock_timeout` (SQLSTATE 55P03). A lock connection that
/// Postgres does not open with at least `LOCK_CONNECTION_MIN_BUDGET` left is not counted. RPC
/// callers return the timeout as `Status::unavailable`.
pub fn record_lock_timeout(scope: LockScope) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docs say "alert on any timeout," but a lock connection Postgres doesn't open within LOCK_CONNECTION_MIN_BUDGET returns Database and intentionally skips this counter. So when the database is overloaded — exactly when you want the alert — real mutation failures bypass it. Worth either a separate counter or a note in the docs.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. The catalog mentions it, but the HA guide says to alert on any timeout and leaves these failures with nothing to alert on, and readiness stays green because it pings the data pool. I added openshell_server_mutation_lock_errors_total{scope} for any guard acquisition that fails without timing out (a lock connection that never opened, or a failed lock statement, which also skipped the counter), with a row in the HA signals table, a PromQL example and a debug skill note. I kept it separate from the timeout counter because the response differs: contention for one, PostgreSQL capacity or connectivity for the other.

/// (`(2 × replicas + surge) × 14`). Each guard holds
/// one lock connection, so a replica sustains about 4 / c guarded operations
/// per second, where c is how long one guard is held.
pub(super) const MUTATION_LOCK_POOL_MAX_CONNECTIONS: u32 = 4;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment states the ceiling (~4/c guarded ops/sec), and c can be seconds since guarded sections call the compute driver and profile sources. But nothing exports in-use lock connections, so operators can't see they're at 3 of 4 until requests fail. A pool gauge would make this a leading indicator.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense. openshell_server_mutation_lock_connections_in_use is a GaugeSlot held by each checked-out lock connection, so it moves with the in-use count the pool already tracks and includes acquisitions waiting on a PostgreSQL lock and connections still being returned. openshell_server_mutation_lock_connections_capacity sits next to it so the ratio is one query. Both are PostgreSQL only, and capacity reads the configured pool size (server.dbLockMaxConnections, also in this PR).

let mut set = MutationLockSet::default();
match *self {
Self::Global => set.insert(MutationLockKey::Global, LockMode::Exclusive),
Self::Workspace("") => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Workspace("") silently means a fleet-wide exclusive lock. Not reachable by accident today — provider paths all go through resolve_workspace(...).name — but note the asymmetry: Sandbox maps an empty workspace to default a few lines down, while here it escalates to global. An explicit MutationScope::Platform variant would make the escalation unreachable rather than just unreached.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's reached on purpose today: the platform profile writers (import, update, delete) get an empty name from authorize_and_resolve_profile_workspace and validate sandboxes in every workspace, so they need X(global), and mapping it to default like Sandbox would under-lock them. I made the escalation explicit with MutationScope::profiles(&workspace), which picks Global for the platform scope. Global already covers platform profiles with the same lock set and label, so a Platform variant would just be an alias. Workspace("") now trips a debug_assert, and release keeps X(global) as a fail-safe that over-locks rather than under-locks.

}
}

fn lock_for(&self, key: i64) -> Arc<RwLock<()>> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

retain sweeps the whole table on every individual key acquisition while holding a blocking mutex — three sweeps per sandbox guard. Small in practice since the table is bounded by live keys, but it could be amortized rather than run on every call.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, easy one. lock_for now only sweeps when a new key grows the table past max(64, 2x the live entries left by the last sweep), so it's amortized O(1) and dead entries from cancelled waiters stay bounded at about twice the peak live set. local_registry_drops_released_entries relied on the per-call sweep, so I replaced it with a churn test for that bound. The lifecycle gate registry on main does the same per-call sweep (one key per call); I left it alone to keep this PR scoped.

EmilienM and others added 8 commits October 8, 2026 15:17
The mutation lock ordering rules were prose only, so a nested guard on
a different key passed every test and could only deadlock under
contention. Debug builds now register each held guard with the Tokio
task (or the thread, outside a task) that acquired it, and panic before
waiting when a local key, a blocking lifecycle gate or the SSH identity
lock would break rules 1, 4 or 5. Startup endpoint-status
reconciliation and the tests that hold a guard while driving guarded
code run under lock_order::branch(), which marks deliberate concurrency
within one task. Release builds compile the check out.

Part of NVIDIA#3528

Signed-off-by: Emilien Macchi <emacchi@redhat.com>
When a replacement supervisor session failed its endpoint-status reset,
the spawned cleanup task kept the session lifetime guard through
retry_endpoint_status_after_supervisor_disconnect. That retry only ends
once the reset is durable, so a gateway shutdown during a database or
lock outage waited the full 10 s and reported ownership cleanup as
incomplete. The guard now covers only the lifecycle demotion, as in
finish_supervisor_session, and a SQLite test checks that shutdown
completes while the retry is still blocked.

Part of NVIDIA#3528

Signed-off-by: Emilien Macchi <emacchi@redhat.com>
Platform-scope provider profile writers (import, update, delete) pass an
empty workspace on purpose and need X(global), because they validate
sandboxes in every workspace. MutationScope::profiles() now names that
escalation and picks Global for the platform scope, instead of relying
on MutationScope::Workspace("") to mean it. An empty workspace name now
trips a debug assertion, and release builds keep X(global) as a
fail-safe that over-locks rather than under-locks.

Part of NVIDIA#3528

Signed-off-by: Emilien Macchi <emacchi@redhat.com>
LocalMutationLocks swept its whole table on every key lookup, so a
burst of N concurrent acquisitions on one replica cost O(N^2) under a
blocking mutex. The table is now swept only when a new key grows it
past the larger of 64 and twice the live entries left by the last
sweep, which makes acquisition amortized O(1). Released entries,
including those of cancelled waiters, stay bounded at about twice the
peak live set. The lifecycle gate registry keeps its per-call sweep.

Part of NVIDIA#3528

Signed-off-by: Emilien Macchi <emacchi@redhat.com>
Both stores hardcoded their pool ceiling: 10 connections for Postgres, 5
for on-disk SQLite. One pool is shared by every database-backed RPC, so
that number is also the gateway's ceiling on concurrent database work.
Once every connection is checked out, callers queue on acquire and sqlx
logs "time to acquire exceeded slow threshold"; sandbox creates then time
out and are retried, which adds load rather than shedding it.

The right ceiling is deployment-specific — it depends on how many gateway
replicas share the database and what max_connections the server itself
allows — so it cannot be a single number baked into the binary.

Expose it on the same config surface as the rest of the gateway's
settings: --db-max-connections, OPENSHELL_DB_MAX_CONNECTIONS, and the
TOML key database_max_connections, resolved in that precedence order and
rendered by the Helm chart from server.dbMaxConnections. Omitting it
keeps each backend's previous value, so existing deployments do not move.

Values below 1 are rejected rather than silently replaced by the default:
a zero pool would block every acquire, and quietly ignoring a typo would
reproduce the ceiling the operator is trying to lift. An in-memory SQLite
database stays pinned to one connection, since the database lives inside
that connection.

Refs: NVIDIA#2561
Signed-off-by: Bryce Wilkinson <22760097+bjw123@users.noreply.github.com>
(cherry picked from commit 33517b3)
Signed-off-by: Emilien Macchi <emacchi@redhat.com>
The mutation lock pool was fixed at 4 connections per replica. That
number rests on one reconnect-burst bench rather than scale testing, so
a gateway that serves many more sandboxes needs a way to raise it
without a rebuild. Add --db-lock-max-connections,
OPENSHELL_DB_LOCK_MAX_CONNECTIONS, the database_lock_max_connections
gateway.toml key and the server.dbLockMaxConnections Helm value next to
the data pool knob, resolved in the same order. The default stays 4,
zero is rejected, SQLite ignores it with a warning, and the gateway logs
both pool sizes at startup. PostgreSQL now needs at least 2 data
connections, because the SSH identity lock keeps one while it queries
the pool. The connection sizing docs, the HA guide formula and the
debug skill now count data plus lock connections.

Part of NVIDIA#3528

Signed-off-by: Emilien Macchi <emacchi@redhat.com>
A mutation lock acquisition that failed without timing out, such as a
lock connection PostgreSQL never opened or a failed lock statement,
only logged a warning, and readiness stays healthy because it pings the
data pool. Count those failures in
openshell_server_mutation_lock_errors_total{scope}, kept apart from
timeouts because they point at PostgreSQL capacity or connectivity
rather than contention. Export
openshell_server_mutation_lock_connections_in_use as a gauge slot held
by each checked-out lock connection, next to
openshell_server_mutation_lock_connections_capacity, which reads the
configured lock pool size; both exist only with PostgreSQL. The metrics
catalog, the HA guide's signals and PromQL examples, and the debug
skill cover the new series.

Part of NVIDIA#3528

Signed-off-by: Emilien Macchi <emacchi@redhat.com>
Nothing bounds how long a mutation guard is held, so a holder stuck in
a compute driver, credential backend or middleware call keeps its scope
locked while waiters time out. Each guard now records its hold in
openshell_server_mutation_lock_hold_seconds{scope} when it is released,
and logs a warning when the hold outlasted the lock wait timeout, so
operators can find the replica and scope behind those timeouts. A
PostgreSQL test with a stalled holder pins the current contract:
waiters on its sandbox, workspace and global scopes fail by their
deadline, other scopes proceed, the holder keeps its key, and a full
lock pool on one replica leaves the other working. The metrics catalog,
the HA guide and the debug skill describe the new signals, which
observe holds without bounding them.

Part of NVIDIA#3528

Signed-off-by: Emilien Macchi <emacchi@redhat.com>
@EmilienM

EmilienM commented Oct 8, 2026

Copy link
Copy Markdown
Contributor Author

Can we bound how long guarded work can run, and test a stalled holder?

The test yes, an enforced bound not in this PR. There's now a PostgreSQL test with a stalled holder: waiters on its scope from another replica fail with MUTATION_LOCK_TIMEOUT by their deadline, other sandboxes and workspaces go through, the holder keeps its lock, and a replica whose lock pool fills up with stalled holders fails its next guarded operations by their deadline while the other replica keeps working. There's also openshell_server_mutation_lock_hold_seconds{scope}, plus a warning when a guard is released after being held past the 10 s wait timeout, so the replica and scope behind those timeouts are easy to find (neither shows a hold that is still in progress).

I didn't add a hold deadline because a client-side one can't give the guarantee you're asking for. Timing out the work and releasing is what a cancelled RPC already does (on main too), and sqlx still flushes a statement that's sent or buffered, so it can commit after the unlock. Spawned work and driver or Vault side effects outlive the dropped future as well. Getting "no writes after release" means running guarded writes in a transaction on the lock session with idle_in_transaction_session_timeout as the bound, so PostgreSQL rolls back the writes and drops the locks together. That only works once driver and middleware calls are out of the guard, which #4307 already tracks, so I added it there next to that item.

what is the rationale behind 4 locks, and 10 data connectors

Not much science behind either. The 10 isn't new: it's SQLx's default pool size, set explicitly when PostgreSQL support landed (5a15de6). The 4 is a judgment call. Each guard holds one lock connection, so a replica sustains about n/c guarded ops per second, and the reconnect-burst bench (1000 sessions landing on one replica over 12 s, about 170 ops/s at about 20 ms each) keeps about 3.3 busy, so 4 leaves headroom (p99 wait 39 ms locally) while staying well under the data pool.

Since we haven't scale-tested either number, both are now configurable in this PR. I carried #2828 for the data pool (--db-max-connections, database_max_connections, server.dbMaxConnections) with bjw123's authorship and added a matching lock pool knob (--db-lock-max-connections, database_lock_max_connections, server.dbLockMaxConnections). Defaults stay 10 and 4. One new floor: on PostgreSQL the data pool can't go below 2, because the SSH identity lock keeps one data connection while it queries the pool, so the gateway rejects 1 at startup instead of hanging. Every extra lock slot costs 2 x replicas + surge connections during a rollout, so the HA guide's sizing formula now counts data plus lock connections, and the new in-use gauge shows when raising it is worth it. The rationale is in the MUTATION_LOCK_POOL_MAX_CONNECTIONS comment.

On your merges: I checked the resolution against #4321 and nothing is lost. Gating the demotion on was_current only skips cases where it's a no-op or where stop/delete already owns the state, and the other abandon_session_setup call sites match main. One follow-up: the spawned task kept session_lifetime through the endpoint-status retry, which only ends once the reset is durable, so a shutdown during a DB or lock outage would wait the full 10 s and report cleanup incomplete. I scoped the lifetime to the demotion, like finish_supervisor_session, with a test. Your SSH create test needed one change for the new lock-order check: its holder loop takes a gate while still holding the previous guard, so the holders now go through lock_order::branch().

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

gator:approval-needed Gator completed review; maintainer approval needed test:e2e Requires end-to-end coverage test:e2e-kubernetes Requires Kubernetes end-to-end coverage

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants