Skip to content

bug: managed-workspace E2E tests intermittently fail with SQLite lock contention #3679

Open

Description

@purp

User Story

As an OpenShell contributor, I want the managed-workspace Kubernetes E2E suite to produce reliable results under its normal concurrency, so that unrelated PRs are not blocked by intermittent database-lock failures.

Problem Statement

Two tests in e2e/rust/tests/workspace_namespace_managed.rs failed while creating workspaces in PR #3651's CI run:

  • managed_full_lifecycle_with_multiple_sandboxes — assertion at line 664.
  • managed_namespace_survives_with_remaining_sandboxes — assertion at line 279.

Both returned persist workspace failed: database error: error returned from database: (code: 5) database is locked after approximately 5.1 seconds. The suite reported 8 passed and 2 failed. Rerunning the failed job at the same PR head, without code changes, passed all 10 tests.

The observed symptom is SQLite lock contention during concurrent workspace creation. The lock holder and underlying cause have not been established; the successful rerun does not establish that the underlying problem is fixed.

Impact / Why This Matters

Priority: Medium (requested by the maintainer).

This failure blocks the required Core E2E gate on otherwise passing PRs and consumes contributor time investigating unrelated failures. Rerunning the job is the current workaround, but adds CI cost and delay and can obscure a real gateway concurrency defect. No production impact was established by this investigation.

Acceptance Criteria

  • Identify the source of lock contention and determine whether the fix belongs in gateway persistence, test isolation, or the test runner.
  • Add regression coverage or a repeatable diagnostic reproducer that exercises the failing concurrent workspace-creation behavior.
  • Both named tests and the full managed-workspace suite pass repeated runs under the normal CI configuration; document the repetition count and results.
  • Preserve the lifecycle and namespace-survival assertions; skipping the tests or adding blanket retries alone does not resolve the issue.

Reproduction Steps

  1. Use PR ci(qualification): add Ubuntu 24.04 k3s scenario #3651 head c8e53de90ffb25e975872b022303f917394653a9 and the existing test:e2e CI configuration.
  2. Run the kubernetes-workspace-managed-e2e / Kubernetes E2E (workspace managed mode) job, which invokes mise run e2e:kubernetes:workspace-managed against its kind cluster using the e2e-kubernetes nextest profile.
  3. Observe concurrent workspace creation in the two named tests. In the recorded failing attempt, both calls failed after approximately 5.1 seconds with SQLite code 5.
  4. Compare the unchanged-code rerun, which passed all 10 tests. Reproduction is intermittent; no deterministic local reproducer has yet been established.

Environment

  • OpenShell PR head: c8e53de90ffb25e975872b022303f917394653a9 (ci(qualification): add Ubuntu 24.04 k3s scenario #3651).
  • Date: September 24, 2026.
  • CI: Linux amd64 runner; Kubernetes kind cluster; managed workspace mode; SQLite gateway persistence.
  • Workflow run: 36063204811, attempts 1 and 2.
  • Runner version in the failing attempt: 2.337.0.
  • Nextest profile: e2e-kubernetes; 10 tests in workspace_namespace_managed.

Logs

FAIL [5.106s] managed_full_lifecycle_with_multiple_sandboxes
FAIL [5.107s] managed_namespace_survives_with_remaining_sandboxes
workspace create failed: Error: code: 'Internal error', message:
"persist workspace failed: database error: error returned from database:
(code: 5) database is locked"
Summary [25.820s] 10 tests run: 8 passed, 2 failed, 0 skipped

Unchanged-code rerun:

Summary [34.614s] 10 tests run: 10 passed, 0 skipped

Evidence and Related Work

Activity

  1. added theissue type on Sep 24, 2026
  2. kvnloo commented on Sep 27, 2026

    @kvnloo

    I can investigate this.

    I would avoid adding retries first, because that could hide whether this is a gateway persistence bug or test-level database sharing.

    My first pass would be:

    1. reproduce the two named managed-workspace tests under the same nextest concurrency;
    2. record connection acquisition, transaction start/end, and SQLite busy/lock timing around workspace creation;
    3. A/B the same workload with only the persistence operation serialized;
    4. A/B again with test-isolated databases while preserving the original concurrency.

    That should distinguish:

    • legitimate concurrent gateway writes contending on the production database;
    • an unnecessarily long transaction;
    • shared test-state contention;
    • a runner/isolation problem.

    Stop condition for the investigation: identify the lock holder/path well enough that we can reproduce it deterministically.

    I would not treat a blanket retry or a longer sleep as the fix.

  3. fitzyracing1 commented on Oct 4, 2026

    @fitzyracing1

    @kvnloo, are you still working on this? If not, I'd like to pick it up. The ~5.1s failures match sqlx's default 5s busy_timeout (the SQLite connect options in persistence/sqlite.rs don't set one), so the MustCreate insert in the workspace create handler looks like it's waiting on another connection that holds the write lock for more than 5 seconds. My first step would be a persistence-level stress test that runs concurrent workspace creates alongside the BEGIN IMMEDIATE paths and the relaxed pool, and records which one holds the lock, before touching any timeouts.

  4. kvnloo commented on Oct 4, 2026

    @kvnloo

    Thanks for checking, and for digging into this! Please go ahead! don't wait on me. I outlined an investigation here, but I don't have a patch or reproducer to hand over.

    Your persistence-level stress test sounds like a useful next step, particularly identifying which path holds the write lock before changing timeouts. Thanks for picking this up, and sorry I didn't follow up sooner.

  5. fitzyracing1 commented on Oct 7, 2026

    @fitzyracing1

    Thanks @kvnloo, I'll take it from here. I'll start with the persistence-level stress test to see which path holds the write lock, and I'll post what I find here before changing any timeouts.

  6. fitzyracing1 commented on Oct 7, 2026

    @fitzyracing1

    Update from the persistence-level stress test. Short version: I don't think any single path holds the write lock for more than 5s. The failing waiter gets starved by a steady stream of short writes, and the failing build was still on the rollback journal.

    What the code shows

    • busy_timeout is never set in persistence/sqlite.rs, so it's sqlx's implicit 5s (sqlx-sqlite 0.9.0, options/mod.rs:203). That lines up with the ~5.1s failures.
    • No write transaction spans non-DB work. The only explicit write transactions are create_if_workspace_count_below and put_policy_revision_atomic (both BEGIN IMMEDIATE, DB statements only). Everything else, including the workspace MustCreate insert, is a single autocommit statement.
    • Run 36063204811 checked out exactly c8e53de, which doesn't contain perf(server): enable WAL for the SQLite store; relax sync only for SSH session issuance #3543. That gateway used the rollback journal (sqlx doesn't set journal_mode), synchronous=FULL, and the 5-connection pool, with no relaxed pool. All tests in the binary share one SQLite file on the gateway PVC.

    CI history (Branch E2E, runs created Sep 24–Oct 7, every attempt of the workspace-managed job): 494 job instances, 4 failed with database is locked. All 4 were SHAs without #3543 (2194310, e60098d, c8e53de, f155eeb), so 4 of the 21 pre-WAL instances. Of the 473 instances with WAL, none failed with database is locked.

    Stress test. 8 concurrent simulated tests (workspace create, then template BEGIN IMMEDIATE, sandbox create, put_policy_revision_atomic, status CAS updates, relaxed SSH-session create), plus readers and a batched cleanup delete. Each write is instrumented with a busy handler that copies SQLite's default backoff, so it records who waited and for how long. A slower CI disk is modeled by adding latency to each fsync through an LD_PRELOAD shim. That latency is injected, not measured from CI.

    Real Store, failed workspace creates (max workspace-create latency in parentheses):

    config +0 ms/fsync +20 ms +50 ms +100 ms
    c8e53de options (rollback journal) 0/160 (140 ms) 6/40 (5.06 s) – –
    main (WAL + relaxed pool) 0/160 (62 ms) 0/40 (1.86 s) 0/40 (4.14 s) 9/24 (5.02 s)

    The errors are the CI message exactly: database error: error returned from database: (code: 5) database is locked.

    Lock attribution for one failed create (rollback journal, +20 ms/fsync): it waited 5005.7 ms over 60 busy-handler retries. In that window 61 other writes committed: 31 status CAS updates, 6 policy-atomic transactions, 6 template creates, 6 workspace creates, 6 sandbox creates, 5 SSH-session creates, 1 cleanup delete. Their holds sum to about 5.0 s, and the longest single hold was 83.9 ms. Each rollback-journal commit holds the lock for about 4 fsyncs (about 82 ms at +20 ms/fsync), and a run does about 11x as many fsyncs as on WAL. SQLite's busy handler is a polling sleep that doesn't preserve arrival order, so the waiter keeps losing.

    A/B (standalone harness, same SQL, rollback journal, +20 ms/fsync, 5 runs × 8 tests):

    • baseline: 10/40 and 10/40 workspace creates failed
    • workspace-create persistence op serialized only: 0/40, but 13 reads and 2 cleanup deletes now fail with database is locked (the contention just moves)
    • isolated DB per test: 0/40, max 193 ms
    • busy_timeout 30s: 0/40, but max wait 6.9 s
    • in-process FIFO write queue: 0/40 creates, max 746 ms (reads still fail under the rollback journal)

    On WAL, the same harness fails at +100 ms/fsync (8/24) and passes with the in-process write queue (0/24, max 1.2 s).

    Takeaways

    1. perf(server): enable WAL for the SQLite store; relax sync only for SSH session issuance #3543 (WAL) most likely explains why this hasn't recurred since Sep 25. It raises the threshold a lot but doesn't remove the failure mode.
    2. Raising busy_timeout only hides the starvation. Isolating DBs per test would also hide a real gateway behavior.
    3. The change I'd propose: keep the 5s busy_timeout but set it explicitly, and queue writers in-process behind a FIFO tokio::sync::Mutex in SqliteStore. Then in-process writers wait in order instead of polling, and the busy timeout only covers contention from outside the process. With that on main: 0/24 at +100 ms/fsync (max 1.4 s), 0/40 at +50 ms (max 0.72 s), and the openshell-server lib tests pass. I'd also add the stress test as an #[ignore]d reproducer.

    I haven't run the kind-based E2E suite locally, so I don't have the repeated-run numbers the acceptance criteria ask for yet. I'd like a maintainer's view on the write-queue approach before opening a PR.

  7. EmilienM commented on Oct 13, 2026

    @EmilienM
    Contributor

    Another data point: the new sqlite_concurrent_writes_return_their_own_resource_version test from #4408 fails with the same (code: 5) database is locked on both Windows MSVC lanes (x64 and arm64) in #4147's runs, about 8 s in. #4147 doesn't change that SQLite path, and these look like the first test:windows runs since #4408 merged. Windows runners have slow fsync, so this looks like the starvation you measured at high fsync latency, and that test could double as a reproducer for the write-queue change.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions