Skip to content

fix(sqlite): drain failed connection workers - #2468

Merged
hidb4ai merged 3 commits into
oceanbase:masterfrom
AlexStocks:codex/2084-sqlite-connect-cleanup
Nov 5, 2026
Merged

hidb4ai merged 3 commits into
oceanbase:masterfrom
AlexStocks:codex/2084-sqlite-connect-cleanup

Conversation

@AlexStocks

@AlexStocks AlexStocks commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

Which issue or RFC does this PR close?

Follow-up acceptance for #2084, found while validating the integrated master after #2004.

Rationale for this change

A missing read-only database or an invalid writable database path can fail before SQLAlchemy's connect event. With the locked aiosqlite 0.22.1, its initial-connect exception handler queues a worker shutdown callback without awaiting it. A CLI caller may then close its event loop before that callback runs, producing an unhandled worker-thread RuntimeError: Event loop is closed.

The strict acceptance replay on unmodified master reproduced this as 348 passing tests and one thread-exception error. Installing the existing stop guard only at successful pool admission cannot protect this path.

What changes are included in this PR?

  • Use SQLAlchemy's async connection creator to install the existing per-connection idempotent stop waiter before awaiting the initial aiosqlite connection.
  • Drain the queued callback on initial connection failure or cancellation, keeping cleanup protected against repeated cancellation. Reuse the same waiter after successful pool admission.
  • Preserve immediate thread-start failure without waiting for shutdown of a worker that never started.
  • Add deterministic regressions for failed writable/read-only opens and thread-start failure.
  • Refresh the managed persistence digest with a connection-lifecycle compatibility rationale.
  • Integrate the companion ci(seekdb): distribute native migrations across three shards #2458 CI commit to run all 39 native migration cases across three shards per Python version. The previous Python 3.11 shard 0 hit its 25-minute step deadline on this PR as well. All cases, preliminary checks, stability repetitions and deadlines are retained.

Are there any user-facing changes?

Failed SQLite opens finish worker cleanup before returning the original error or cancellation. No public API, stored schema, registered revision checksum, migration resources, or upgrade-source policy changes. The workaround remains restricted to aiosqlite 0.22.1. The upgrade policy remains upgrade-required.

How was this change tested?

  • The two failed-open regressions fail on the original implementation and pass with this change using real aiosqlite workers.
  • Fault injection reproduced a hang when worker startup failed; the added guard and regression preserve immediate error reporting.
  • Python 3.12 focused integration/profile regressions: 396 passed, with pytest.PytestUnhandledThreadExceptionWarning treated as an error.
  • Python 3.11 and 3.14 profile/processing-migration CLI suites: 65 passed each under the same strict warning policy.
  • Ruff check/format, changed-file pre-commit hooks, full Linux ty check, and managed upgrade-policy validation passed.
  • Windows changed-file hooks passed except the full-project type hook, which reports Linux-only APIs and interpreter/environment import resolution; the full type check passed in the native Linux validation copy with the locked environment.
  • The twelve-case installed upgrade acceptance completed on parent 1190dcbbb; those results are retained as parent evidence and are not relabeled as acceptance of this new commit.
  • Built wheel and sdist from commit 97b0e6c261bcdb9e278fde183fd663dfdc62dbf6, with actual VCS version 1.5.1.dev69+g97b0e6c26; verified the sdist rebuild and froze a new acceptance plan. The wheel SHA-256 is 621cbbae587b317841b685e453dc4d774f7edd2fdfda5117a26f8631de9cb6d2.
  • Ran the four SQLite installed-release configurations from actual 1.3.0 (no-vector, Code, vector, vector-Code) against that exact wheel: 116 passed, zero failures/errors/skips. These use isolated source and target installations. This focused SQLite subset is distinct from the complete twelve-case gate and does not authorize publication.
  • After integrating ci(seekdb): distribute native migrations across three shards #2458 at 3825d7a15718ffe41a40c9c8a8640213cfc39f53, combined CI classification/pipeline and SQLite profile/CLI regressions passed all 190 tests, with thread exceptions treated as errors; changed-file hooks and full Linux type checking passed. This merge changes only the workflow relative to 97b0e6c2; runtime, tests and managed policy bytes are unchanged. Current-Head GitHub CI is a new run.

AI usage statement

OpenAI Codex assisted with investigation, implementation, and validation.

PR oceanbase#2004 exhausted the 25-minute step after passing individual native
cases. Spread all 39 nodes over three shards for each supported CI Python
version; retain the preliminary checks and baseline stability repetitions.

Constraint: Keep the 180-second node, 25-minute step and 60-minute job budgets.
Scope-risk: CI scheduling only; no test selection or runtime behavior changes.
Tested: pytest tests/test_ci_changes.py tests/test_ci_pipeline.py -q (125 passed); exact 39-node partition; full Python 3.11 shard 0 replay including prelude and baseline repetitions passed in 1041.98 seconds.
Install the aiosqlite 0.22.1 stop waiter before the initial connection,
which may fail before SQLAlchemy emits its connect event. Await the
worker's final callback before exposing failed opens or cancellation to
callers that may immediately close their event loop. Reuse that waiter
after pool admission and report thread-start failure without waiting.

Constraint: Keep the workaround restricted to aiosqlite 0.22.1 and preserve cancellation.
Scope-risk: Connection lifecycle only; no stored schema, migration or release-policy changes. Refresh the managed-input compatibility digest.
Tested: Python 3.12 focused regressions 396 passed with thread warnings as errors; Python 3.11 and 3.14 profile/CLI suites 65 passed each; failed-connect regressions red on original code; thread-start hang reproduced before guard; full Linux ty and changed-file hooks passed.
Copilot AI balanced review requested due to automatic review settings November 4, 2026 08:27
@AlexStocks AlexStocks added the 1.5.1 v1.5.1 release tracking label Nov 4, 2026

Copilot AI left a comment

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.

🟢 Approval recommended

No active review issues remain for the SQLite connection-cleanup changes.

No open issues 🎉

What changed in this PR

This PR hardens SQLite connection cleanup so failed opens drain aiosqlite workers before callers can close the event loop. It addresses failures before pool admission without changing public APIs or stored schemas.

Changes:

  • Install the aiosqlite 0.22.1 stop guard before initial connection, with cancellation-protected cleanup and immediate reporting of thread-start failures.
  • Add regressions for failed writable/read-only opens and worker startup failure.
  • Refresh the persistence digest and compatibility rationale while retaining the upgrade-required policy.
File Description
tests/​builtin/​persistence/​test_sqlite_profile.py Add failed-open cleanup and thread-start failure regressions.
src/​powercontext/​builtin/​persistence/​sqlite/​profile.py Guard initial connections and reuse the stop waiter after pool admission.
release/​upgrade-support.json Update the persistence digest and document lifecycle-only compatibility changes.

Review effort: Balanced

@hidb4ai hidb4ai left a comment

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.

No blocking correctness findings at 97b0e6c. Installing the stop guard before the initial connection covers the failure path that never reaches pool admission. The failed-open regressions fail against the parent implementation and pass here; repeated cancellation during initial connection and failed-open cleanup also drains the real workers. No new database tables or public interfaces.

CI is still incomplete. The Windows resource-candidate failure occurs in curl while downloading a dependency, before SQLite execution; that job still needs a successful run. This approval does not establish full installed-release upgrade acceptance.

The SQLite cleanup PR still used two migration shards and its Python 3.11
shard 0 exhausted the 25-minute unified-migration step. Merge the companion
CI commit from oceanbase#2458 so this PR validates the three-shard layout itself.

Constraint: Preserve all native cases, stability repeats and existing deadlines.
Scope-risk: Merge only the two-line workflow change; SQLite source and managed policy bytes are unchanged.
Tested: Combined CI classification/pipeline and SQLite profile/CLI suites: 190 passed with thread exceptions treated as errors; changed-file hooks and full Linux ty check passed.
Copilot AI balanced review requested due to automatic review settings November 4, 2026 12:42
@AlexStocks

Copy link
Copy Markdown
Contributor Author

CI follow-up at 3825d7a15718ffe41a40c9c8a8640213cfc39f53:

  • The failed code-seekdb (3.11, 0) check reports: “The action 'Test unified migrations with embedded seekdb' has timed out after 25 minutes.” This PR still had the two-shard workflow. I merged the exact companion commit from ci(seekdb): distribute native migrations across three shards #2458 so the new run uses three shards without removing cases or extending deadlines. SQLite runtime and policy bytes are unchanged.
  • The Windows resource-candidate failure occurred while downloading the locked regex wheel: the curl process exited with 3221225477 (0xC0000005), before SQLite tests or resource production. A job-only rerun was rejected by GitHub because the workflow was still active. The new Head starts a fresh Windows candidate job; its success remains required. The corresponding Windows candidate on ci(seekdb): distribute native migrations across three shards #2458 passed.
  • The combined local CI/SQLite regression selection and hooks passed after the merge. Earlier installed-wheel evidence remains identified by its original artifact and commit; it is not relabeled as a complete upgrade result for this new Head.

The latest review contains no requested source changes or open inline threads. CI for the new Head is still pending.

Copilot AI left a comment

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.

Warning

Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.

Copilot review overview

4 open issues

Review effort: Lite

Comment on lines 582 to 585
for index in "${!seekdb_runner_nodes[@]}"; do
if [ "$((index % 2))" -ne "$SEEKDB_MIGRATION_SHARD" ]; then
if [ "$((index % 3))" -ne "$SEEKDB_MIGRATION_SHARD" ]; then
continue
fi

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.

Treating this as a non-blocking design suggestion. The workflow uses a fixed three-way partition, and changing that partition requires reviewing both the runner matrix and node assignment against the native test budget. The final #2458 run exercised all six Python/shard combinations successfully. I am keeping the explicit matrix and modulus rather than adding another configuration source solely for a possible future partition change.

Comment on lines +196 to +205
def _create_sqlite_engine(url: str | URL, **options: Any) -> AsyncEngine:
if aiosqlite.__version__ != "0.22.1":
return create_async_engine(url, **options)
parsed = make_url(url)
args, kwargs = parsed.get_dialect()().create_connect_args(parsed)

async def connect() -> Connection:
driver = aiosqlite.connect(*args, **kwargs)
stop = _make_sqlite_stop_idempotent(driver)
try:

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.

The async_creator limitation is real, but this private helper has only two supported callers: SQLiteProfile.open passes echo/hide_parameters and, for nonpersistent storage, poolclass; open_readonly passes echo/hide_parameters. Neither accepts or forwards connect_args. URL driver settings still come from the dialect's create_connect_args. There is no current supported input that loses driver overrides, so I am not adding unused connect_args support in this fix. If a caller later exposes driver overrides, those must be merged into the creator's kwargs at that boundary.

Comment on lines +207 to +223
except BaseException as error:
if driver._thread.ident is None:
raise
# Failed connects never reach SQLAlchemy's connect event. Drain the
# driver's final callback before its caller can close the event loop.
stopped = stop()
interrupted = None
if stopped is not None:
while not stopped.done():
try:
await asyncio.shield(stopped)
except asyncio.CancelledError as cancellation:
interrupted = cancellation
stopped.result()
if interrupted is not None:
raise interrupted from error
raise

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.

Rechecked this with actual aiosqlite workers for both an initial connection error and an initial cancellation, with two further cancellation requests while shutdown is blocked. In both cases the operation stays pending until the worker exits, then the task is cancelled (task.cancelled() is true) and all stop futures are complete. Raising CancelledError already preserves task cancellation; re-cancelling would add a synthetic cancellation request. Chaining to the interrupted error affects diagnostic presentation but does not demonstrate a cancellation-contract failure, so I am retaining the current behavior.

Comment on lines +67 to +75
@pytest.mark.parametrize("read_only", [False, True])
def test_failed_sqlite_open_drains_its_worker_before_reporting_the_error(tmp_path, monkeypatch, read_only) -> None:
path = tmp_path / "unavailable.db"
if not read_only:
path.mkdir()
config = SQLiteConfig(url=f"sqlite+aiosqlite:///{path}")
release = ThreadEvent()
stopping = []
original_stop = aiosqlite.Connection.stop

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.

Addressed in #2496: the existing real-worker regression now requires a nonempty shutdown-future list before checking that every future is complete. It does not skip the check based on a driver version. A local mutation that removes only future recording passes both cases without the new assertion and fails both with it, confirming that the guard catches the vacuous-success path.

@PsiACE PsiACE left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM, thanks!

@hidb4ai
hidb4ai merged commit f55daae into oceanbase:master Nov 5, 2026
100 of 106 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

1.5.1 v1.5.1 release tracking

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants