Skip to content

perf: offload blocking SQLite I/O off the event loop - #1327

Merged
groupthinking merged 2 commits into
mainfrom
perf/sqlite-connect-block
Aug 4, 2026
Merged

perf: offload blocking SQLite I/O off the event loop#1327
groupthinking merged 2 commits into
mainfrom
perf/sqlite-connect-block

Conversation

@groupthinking

@groupthinking groupthinking commented Aug 4, 2026

Copy link
Copy Markdown
Owner

Canonical issue

Closes #1325

Outcome

The SQLite branch of DatabaseConnectionPool / QueryOptimizer ran every blocking
call directly on the event loop. Four calls are now offloaded with asyncio.to_thread,
matching the idiom already used six times in performance_monitor.py:

Location Was
initialize os.makedirs(...)
get_connection sqlite3.connect(...)
execute_query cursor.execute() + cursor.fetchall()
release_connection connection.close()

The most valuable part of this change is not the raw speedup — it is that
execute_batch_queries already ships a semaphore, asyncio.gather and fail-fast
cancellation, and its docstring already claims queries "run concurrently". Because
nothing underneath ever yielded, that concurrency was delivering zero benefit.
This PR does not add concurrency; it activates concurrency that was already merged.

Production evidence

SQLite is the production default, not a test-only fallback —
database_url = os.getenv("DATABASE_URL", "sqlite:////tmp/uvai_data/app.db"), with an
explicit Cloud Run comment. main.py:506 calls initialize_database_optimization() at
startup, so this is the live path.

Measured on a file-backed 400k-row table, 16 batched queries, mirroring
execute_batch_queries' own semaphore + gather structure:

single query cost           :  14.4 ms
serial floor for 16 queries : 230.5 ms

CURRENT (blocking)  batch = 230.0 ms   heartbeat ticks =  0 (ideal ~46)   responsiveness =  0.0%
FIXED   (to_thread) batch =  64.9 ms   heartbeat ticks = 10 (ideal ~12)   responsiveness = 83.3%

batch speedup: 3.55x

Two numbers matter more than the speedup:

  1. 230.0 ms actual vs a 230.5 ms serial floor. The batch was fully serial. The
    concurrency machinery was inert.
  2. 0.0% event-loop responsiveness. A 5 ms heartbeat task got zero ticks for the
    whole batch. Nothing else in the process — health checks, other requests — could
    make progress during database work.

Risk

Low, and contained to the SQLite branch.

  • The asyncpg path is untouched; pool.acquire() / pool.release() / connection.fetch()
    are already awaited natively.
  • check_same_thread=False is required, not incidental. Once sqlite3.connect moves
    to a worker thread, the returned connection would raise ProgrammingError on its first
    use from the loop thread — which initialize_database_optimization() does directly at
    startup. This is covered by a dedicated regression test.
  • Thread-safety holds because a connection is owned by exactly one caller between
    get_connection() and release_connection(), so it is never touched by two threads
    concurrently.
  • No new imports: asyncio and sqlite3 were already imported.

Verification

324 passed — 169 in test_database_optimizer.py (161 pre-existing + 8 new) and 155 in
the dependent test_comprehensive_benchmarking.py. ruff check clean on both changed files.

Prove-fail against pre-change source (stashed the fix, kept the tests):

FAILED test_cursor_work_runs_off_the_event_loop_thread
FAILED test_event_loop_stays_responsive_during_query
FAILED test_connect_runs_off_the_event_loop_thread
FAILED test_close_runs_off_the_event_loop_thread
FAILED test_batch_queries_are_not_serialised
FAILED test_batch_queries_use_distinct_threads
6 failed, 1 passed

with diagnostics that reproduce the production finding in miniature:

AssertionError: the event loop got zero ticks during a 250ms query
AssertionError: batch took 0.623s against a serial floor of 0.600s — the queries did not overlap

The 1 pre-existing pass is test_connection_is_usable_from_the_event_loop_thread, which
is a forward-looking guard: it passes before and after, and exists specifically to fail
if someone later adds the to_thread offload without check_same_thread=False.

The tests assert on thread identity and a max-concurrent-execute counter held
under a threading.Lock, not wall-clock ratios, so they stay deterministic on loaded CI
hosts — following the review feedback on #1319 about host-dependent timing.

Cancellation safety (added in 7c4dc0931 after review)

Review caught a defect this PR introduced: cancelling a query unwound the await but left
the worker thread inside cursor.execute(). CancelledError is a BaseException, so
except Exception did not catch it and finally closed the connection on a second
thread while the first was still using it. check_same_thread=False disables sqlite3's
check, not the single-owner requirement. This was unreachable before this PR because
nothing yielded — activating the concurrency is what exposed it.

Fixed by running the offload as a shielded task and draining it in finally before
release. TestBatchCancellationDoesNotCloseConnectionMidQuery proves it, per connection:

source batch outcome
without the fix Batch failed (0.59ms) — abandons the running thread, close() overlaps → fails
with the fix Batch failed (202.26ms) — drains the 200 ms worker first → passes

The same commit fixes two batch tests that passed dicts where the API takes
(query, params) tuples; unpacking a 2-key dict yields its keys, so they had been
executing the literal string "query" — green but vacuous.

Scope

Deliberately excluded, to keep this reviewable:

  • Real connection pooling. get_connection still opens a fresh connection per call and
    release_connection closes it — DatabaseConnectionPool does not currently pool anything
    on the SQLite path. Verified: an in-memory database loses its data across a
    release/acquire cycle. That is a larger change, and the offload alone delivers the
    measured 3.55x.
  • The running average in get_connection. avg = (avg * len(history) + t) / (len(history) + 1)
    where connection_history is a deque(maxlen=1000). Once saturated, len() pins at 1000
    forever, so this is neither a cumulative mean nor a well-formed EMA. Not verified as
    user-visible, so left alone.
  • initialize_database_optimization's own synchronous cur.execute calls. Startup-only,
    before the server accepts traffic.

Agent handoff

Questions for reviewers

  1. Is check_same_thread=False acceptable given the single-owner invariant between
    get_connection() and release_connection(), or would you prefer the connection be
    confined to the worker thread instead?
  2. Should real pooling (reusing connections rather than reconnecting per call) be a
    follow-up issue, or folded in here?
  3. Are the two batch timing assertions (2x margin against a serial floor) tight enough to
    be meaningful but loose enough for CI, or should they be thread-identity only?

sqlite3.connect(), cursor.execute()/fetchall() and connection.close()
all ran directly on the event loop. That froze the loop for the entire
duration of every query and silently defeated execute_batch_queries'
semaphore + gather concurrency, since nothing underneath ever yielded.

Measured on a 400k-row table with 16 batched queries: the batch took
230.0ms against a computed serial floor of 230.5ms, and a 5ms heartbeat
task got zero ticks. Offloaded, the same batch takes 64.9ms (3.55x) and
the heartbeat reaches 83% of its ideal tick count.

check_same_thread=False is required rather than incidental: the
connection is now created on a worker thread but is still used from the
loop thread by initialize_database_optimization().

The asyncpg path is untouched.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings August 4, 2026 02:48
@vercel

vercel Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
v0-uvai Ready Ready Preview, v0 Aug 4, 2026 3:02am

@github-actions github-actions Bot added the python label Aug 4, 2026
@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@groupthinking, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 29 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Repository YAML (base), Repository UI (inherited), Organization UI (inherited)

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 2cf14625-c192-41b2-a8a9-be7c8c429480

📥 Commits

Reviewing files that changed from the base of the PR and between 80088e7 and 7c4dc09.

⛔ Files ignored due to path filters (1)
  • tests/unit/test_database_optimizer.py is excluded by !tests/**
📒 Files selected for processing (1)
  • src/youtube_extension/backend/services/database_optimizer.py

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@groupthinking

Copy link
Copy Markdown
Owner Author

@linear-code @coderabbitai review

This offloads four blocking SQLite calls off the event loop. The headline is not the 3.55x — it is that execute_batch_queries already had a semaphore + gather + fail-fast cancellation and was delivering zero concurrency, because nothing beneath it ever yielded. Measured batch time was 230.0ms against a computed serial floor of 230.5ms, with a 5ms heartbeat getting zero ticks.

Three specific things I would like a second opinion on:

  1. check_same_thread=False — required here, not incidental: initialize_database_optimization() calls conn.cursor() / cur.execute() directly on the loop thread using a connection now created on a worker thread. I am relying on the invariant that a connection has exactly one owner between get_connection() and release_connection(). Is that invariant strong enough for you, or would you rather the connection be confined to a single worker thread?

  2. No real poolingget_connection still opens a fresh connection per call and release_connection closes it, so DatabaseConnectionPool does not currently pool anything on the SQLite path (an in-memory DB verifiably loses its data across release/acquire). I scoped that out. Follow-up issue, or should it be folded in here?

  3. Test assertions — I deliberately assert on threading.get_ident() rather than wall-clock, after the host-dependent-timing feedback on perf: enqueue Cloud Tasks batches concurrently #1319. The two batch tests do use timing, with a 2x margin against a serial floor. Too tight for CI, or fine?

Prove-fail is recorded in ## Verification: 6 of 7 new tests fail against pre-change source with zero ticks during a 250ms query and batch took 0.623s against a serial floor of 0.600s.

Note the test / coverage reds are pre-existing on main (the test_gh_aw_workflow_governance credential test, already owned by #1317/#1320) — not from this change.

@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

Snapshot Warnings

⚠️: No snapshots were found for the head SHA 7c4dc09.
Ensure that dependencies are being submitted on PR branches and consider enabling retry-on-snapshot-warnings. See the documentation for more information and troubleshooting advice.

Scanned Files

None

@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown

Agent Completion Truth Gate: NOT_APPLICABLE

Evidence agrees.

Machine-readable verdict
{
  "details": {},
  "reasons": [],
  "verdict": "not_applicable"
}

Workflow evidence

@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor
🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '=== Changed files ==='
git diff --name-status HEAD^ HEAD

echo '=== Diff ==='
git diff --unified=80 HEAD^ HEAD -- '*.py'

echo '=== Candidate implementation and test files ==='
fd -t f -e py . | rg '(database.*optimiz|optimiz.*database|performance_monitor|benchmark)'

echo '=== Symbols relevant to SQLite lifecycle and batching ==='
rg -n -C 5 --glob '*.py' \
  'class DatabaseConnectionPool|class QueryOptimizer|async def initialize|async def get_connection|async def release_connection|async def execute_query|async def execute_batch_queries|def initialize_database_optimization|sqlite3\.connect|check_same_thread|asyncio\.to_thread' .

Length of output: 192948


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '=== Query execution, batching, and SQLite initialization ==='
sed -n '351,570p' src/youtube_extension/backend/services/database_optimizer.py
sed -n '920,990p' src/youtube_extension/backend/services/database_optimizer.py

echo '=== Existing cancellation-safe worker patterns ==='
sed -n '45,85p' src/youtube_extension/services/cloud/cloud_tasks_queue.py
sed -n '50,90p' src/youtube_extension/backend/code_generator.py

echo '=== Relevant test coverage and direct pool consumers ==='
rg -n -C 4 --glob '*.py' \
  'execute_batch_queries\(|get_connection\(|release_connection\(|CancelledError|cancel\(' \
  tests/unit/test_database_optimizer.py src/youtube_extension/backend/services/database_optimizer.py

Length of output: 24695


@groupthinking I found one blocking concurrency issue.

check_same_thread=False is required for this design. The current single-owner invariant is not strong enough during cancellation.

execute_batch_queries() cancels active execute_query() tasks after one query fails. If a task is awaiting asyncio.to_thread(_run_sync_query), cancellation stops the await but does not stop the worker thread. The finally block then calls release_connection(), which can call connection.close() while _run_sync_query still uses that connection.

This creates concurrent access to one SQLite connection. The active worker can fail with a closed-connection error. It also invalidates the safety claim for check_same_thread=False.

Make the SQLite worker cancellation-safe. Keep a task for the asyncio.to_thread(...) operation, shield and drain it after cancellation, then release the connection. Re-raise CancelledError only after the worker has completed. The repository already has this pattern in src/youtube_extension/services/cloud/cloud_tasks_queue.py.

Add a regression test that starts a delayed SQLite query, cancels it after the worker starts, and asserts that close() occurs only after fetchall() completes.

For the three design questions:

  1. check_same_thread=False is acceptable after the cancellation fix. The connection does not need permanent worker-thread affinity. It does require strict serialization for every connection operation, including close and cancellation cleanup.

  2. Real pooling should remain a follow-up issue. It changes lifecycle, capacity enforcement, transaction behavior, and in-memory SQLite semantics. It is not required to fix this event-loop blocking defect.

  3. Test assertions should retain thread-identity checks. The current elapsed-time batch assertion is meaningful, but it can still fail on an overloaded CI host. Prefer an overlap assertion that records the maximum number of simultaneous cursor.execute() calls under a threading.Lock. This proves concurrent execution without requiring completion before half of a fixed serial duration.

✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

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.

Pull request overview

Offloads blocking SQLite operations from the event loop, enabling existing batch concurrency and addressing issue #1325.

Changes:

  • Moves SQLite setup, connection, query, and close operations to worker threads.
  • Adds regression tests for responsiveness, thread usage, and batch overlap.
  • Enables cross-thread SQLite access with single-owner semantics.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
database_optimizer.py Offloads blocking SQLite operations.
test_database_optimizer.py Adds SQLite offloading and concurrency tests.
Suppressed comments (1)

tests/unit/test_database_optimizer.py:1552

  • As above, execute_batch_queries expects (query, params) tuples; iterating this two-key dict supplies its key names rather than SELECT {i} and False. Build tuples so this regression test runs the queries it claims to run.
        queries = [{"query": f"SELECT {i}", "use_cache": False} for i in range(4)]

cursor.execute(query)
return cursor.fetchall()

result = await asyncio.to_thread(_run_sync_query)
Comment thread tests/unit/test_database_optimizer.py Outdated
pool.release_connection = AsyncMock()
optimizer = QueryOptimizer(pool)

queries = [{"query": f"SELECT {i}", "use_cache": False} for i in range(count)]
Comment thread tests/unit/test_database_optimizer.py Outdated

Copy link
Copy Markdown
Owner Author

Independent review (triggered by the review request) — both open findings verified against 861d762. Both are true positives; #1 should block merge.

1. Cancellation can close a SQLite connection under an in-flight query — real correctness bug.
execute_query runs the query with await asyncio.to_thread(_run_sync_query) and unconditionally releases in its finally via release_connection(), which now does await asyncio.to_thread(connection.close). asyncio.to_thread cannot cancel its worker thread, so when execute_batch_queries cancels stragglers on the first failure (the "fail-fast cancellation" loop this PR adds around L505–511), a cancelled query's await returns immediately, the finally schedules connection.close() on a second worker thread while _run_sync_query is still executing cursor.execute()/fetchall() on the same connection. That's two worker threads touching one sqlite3.Connection at once — precisely the single-owner invariant the new comments claim holds, broken by this PR's own cancellation feature. check_same_thread=False disables the guard that would otherwise raise here, so the failure is silent/undefined rather than caught.
Fix: make the offload cancellation-safe — shield _run_sync_query and await its completion before the connection can be closed, mirroring the pattern in services/cloud/cloud_tasks_queue.py — and add a regression test asserting close() never overlaps execute().

2. Batch tests don't exercise the documented (query, params) contract.
execute_batch_queries unpacks each item as for query, params in queries_and_params. The new tests at tests/unit/test_database_optimizer.py:1530 and :1552 pass dict items {"query": f"SELECT {i}", "use_cache": False}; iterating a dict yields its keys, so each unpacks to query="query", params="use_cache" and the literal key strings are executed instead of the SQL. TestBatchQueriesActuallyOverlap therefore passes only because _RecordingCursor ignores the query text — it does not cover the batch input contract it documents.
Fix: pass tuples, e.g. ("SELECT 0", ()).

I did not push a fix: this PR targets protected main and I'm running as an unattended scheduled routine, so I'm surfacing the confirmed defects and exact fixes rather than committing to the branch autonomously. Recommend resolving #1 before merge.


Generated by Claude Code

Copy link
Copy Markdown
Owner Author

Independent verification — ⛔ do not merge yet (one real blocker)

Second opinion as requested. I traced the diff against execute_batch_queries' cancellation path and reproduced the reviewers' finding — it is real and introduced by this PR, not a false positive.

Blocker — cancellation can close a SQLite connection mid-query. When a sibling query fails, the except path cancels the in-flight tasks (database_optimizer.py:512-514). A task parked on await asyncio.to_thread(_run_sync_query) (:406) has its await unwound by CancelledError, but the worker thread keeps running cursor.execute()/fetchall() on connection — a live thread can't be cancelled. CancelledError is a BaseException, so except Exception (:434) doesn't catch it; control falls to finally (:445) → release_connection()await asyncio.to_thread(connection.close) (:254), scheduling close() on a second worker thread while the first is still using the connection. That's two threads on one sqlite3 connection — check_same_thread=False only disables the check, it doesn't make concurrent use safe, so the single-owner invariant the Risk section leans on is violated. Before this PR nothing yielded, so this race didn't exist; activating the concurrency is what exposes it. CodeRabbit's fix is the right shape: keep the to_thread as a task, shield+drain it in finally before releasing, re-raise CancelledError only after the worker returns (the cloud_tasks_queue.py pattern), plus a regression test asserting close() never overlaps fetchall().

Also real (non-blocking, but fix before merge): the two new TestBatchQueriesActuallyOverlap tests (tests/unit/test_database_optimizer.py:1530 and :1552) build [{"query": ..., "use_cache": False}] dicts and pass them to execute_batch_queries, whose for query, params in queries_and_params unpacks each 2-key dict into its keys — so every "query" executes the literal string "query". The tests pass but don't exercise the real (query, params) contract.

Answering the three questions:

  1. check_same_thread=False — acceptable, conditional on the cancellation fix above. Permanent worker-thread affinity isn't needed; strict serialization of every op on a connection (including close/cleanup) is.
  2. Real pooling — keep it a follow-up, don't fold in. It changes lifecycle/capacity/in-memory semantics and isn't needed to fix the event-loop block.
  3. Test assertions — the threading.get_ident() checks are good; the 2× timing margin is acceptable but a max-concurrent-execute counter under a Lock is more robust on loaded CI. Fix the dict-vs-tuple shape regardless.

CI: the test/coverage red is not from this change — 1 failed, 7954 passed, the sole failure being test_gh_aw_workflow_governance.py::test_ci_investigator_requires_dedicated_codex_credential (FileNotFoundError on the deleted .github/workflows/eventrelay-ci-investigator.md). Matches your note; #1317/#1320 own that. All new DB-optimizer tests pass.

Verdict: blocked on the cancellation-safety fix; mergeable once that lands (and the two batch tests use real tuples). Leaving the two review threads unresolved since they're accurate.


Generated by Claude Code

Copy link
Copy Markdown
Owner Author

Review — verified locally, change is sound

Checked out 861d762 and ran the offload suite independently.

Verification reproduced

  • 7 passed for the new tests on the fix. The batch of 6 × 100 ms queries completed in 105 ms against a 600 ms serial floor — the gather concurrency is genuinely active now, not inert.
  • Prove-fail against the base source (80088e7, tests kept): 6 failed, 1 passed — the 1 pass being test_connection_is_usable_from_the_event_loop_thread, exactly the forward-looking guard you described. The tests are non-vacuous and pin thread identity, not wall-clock.
  • Any is already imported (line 33), so the _run_sync_query annotation is fine. execute_query acquires its own connection per call and releases it in finally (including the error path), so the single-owner invariant that justifies check_same_thread=False holds end-to-end.

Answers to your reviewer questions

  1. check_same_thread=False — keep it. Confining the connection to the worker thread would break initialize_database_optimization(), which uses the connection directly on the loop thread at startup — the case test_connection_is_usable_from_the_event_loop_thread exists to catch. Given the verified single-owner bracket between get_connection()/release_connection(), check_same_thread=False is the correct choice, not a workaround. The in-code comment already documents the invariant; no change needed.

  2. Real pooling — make it a follow-up. This PR is well-scoped and the 3.55× is independent of pooling. Folding pooling in would enlarge the diff and change lifecycle semantics (your in-memory-loses-data caveat). A separate issue keeps this reviewable.

  3. Batch timing assertions — acceptable, one minor suggestion. test_batch_queries_use_distinct_threads already proves overlap deterministically via thread identity. test_batch_queries_are_not_serialised (6 queries) depends on the default executor having ≥6 workers, which could tighten under a constrained CI host or once perf: isolate blocking file I/O from the shared default executor (#1234) #1241 caps executor workers below the batch size. Consider loosening its floor (e.g. < serial_floor * 0.75) or relying on the thread-identity test alone — consistent with the perf: enqueue Cloud Tasks batches concurrently #1319 feedback you already honor elsewhere. Non-blocking.

Not merging from here. Protected main + no automerge label → this stays on human sign-off by design. The two red checks (test, Generate and Upload Coverage on test_ci_investigator_requires_dedicated_codex_credential) are the pre-existing base-branch failure owned by #1317/#1320, as you noted — not introduced here. Once those land and the governance gate is satisfied, this is ready.


Generated by Claude Code

Cancelling a query in execute_batch_queries unwound the await but left
the worker thread running cursor.execute(). CancelledError is a
BaseException, so `except Exception` did not catch it and `finally`
released — and therefore closed — the connection on a second thread while
the first was still using it. check_same_thread=False disables sqlite3's
check, not the single-owner requirement.

Run the offload as a shielded task and drain it in `finally` before
release. Also fixes two batch tests that passed dicts where the API
takes (query, params) tuples, so they never exercised the real contract.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@groupthinking

Copy link
Copy Markdown
Owner Author

Both findings reproduced and fixed in 7c4dc0931. Thank you — the blocker was real and self-inflicted by this PR.

1. Cancellation could close a connection mid-query — confirmed.

I verified the chain independently rather than taking it on trust:

  • execute_query's finally (:445) runs unconditionally, and except Exception (:434) cannot intercept CancelledError because it derives from BaseException.
  • execute_batch_queries cancels stragglers on fail-fast (:512-514).
  • release_connection closes via await asyncio.to_thread(connection.close).

So a cancelled task scheduled close() on a second thread while the first was still inside cursor.execute(). You're right that check_same_thread=False disables sqlite3's check, not the single-owner requirement — and right that this was unreachable before this PR, because nothing yielded. Activating the concurrency is what exposed it.

Fix: run the offload as an explicit task, await asyncio.shield(...) it, and drain it in finally before releasing:

if sync_worker is not None:
    while not sync_worker.done():
        with contextlib.suppress(BaseException):
            await asyncio.shield(sync_worker)
if connection:
    await self.connection_pool.release_connection(connection)

The while loop is deliberate — each shielded await can itself be cancelled again — and suppress(BaseException) prevents the worker's own exception from masking the error already propagating out of try. Termination is bounded by the query.

Prove-fail, measured both ways:

source batch outcome
without the fix Batch failed (0.59ms) — returns instantly, abandons the running thread, close() overlaps → test fails
with the fix Batch failed (202.26ms) — drains the 200 ms worker first → test passes

New test TestBatchCancellationDoesNotCloseConnectionMidQuery drives a 2-query batch where the first fails and the second is slow, then asserts close() never lands while that same connection has work in flight. Overlap is tracked per connection, not globally — my first attempt shared one counter across connections and produced a false positive, since closing connection A while B is busy is perfectly legal.

2. Dict-vs-tuple in the batch tests — confirmed, and worse than reported.

execute_batch_queries unpacks for query, params in queries_and_params (:504), so my 2-key dicts unpacked to their keys and every "query" executed the literal string "query". Green but vacuous. Now real (query, params) tuples.

Worth flagging: _run_one (:496-498) hardcodes use_cache=True, so the "use_cache": False in those dicts was never going to be honoured even in principle. The rewritten tests patch module-level cache_get/cache_set to bypass the short-circuit.

3. Took your stronger suggestion on the timing assertion. Added a max-concurrent-execute counter under a threading.Lock, asserted max_inflight > 1 alongside the existing 2× wall-clock margin — the counter is the real proof, the timing is corroboration that won't flake on loaded CI.

Real connection pooling stays a follow-up per your agreement; noted in ## Scope.

Verification: tests/unit/test_database_optimizer.py 169 passed, tests/unit/test_comprehensive_benchmarking.py 155 passed, ruff check clean on both changed files. The one red CI check is test_gh_aw_workflow_governance.py::test_ci_investigator_requires_dedicated_codex_credential, which is pre-existing on main (owned by #1317/#1320) — the job summary is 1 failed, 7954 passed.

@linear-code @coderabbitai re-review when you get a chance.

@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor
🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '=== Current PR diff summary ==='
git diff --stat HEAD^ HEAD
git diff --unified=35 HEAD^ HEAD -- \
  src/youtube_extension/backend/services/database_optimizer.py \
  tests/unit/test_database_optimizer.py

echo '=== Source structure ==='
ast-grep outline src/youtube_extension/backend/services/database_optimizer.py \
  --items all --type function,class --match 'DatabaseConnectionPool|QueryOptimizer|execute_query|execute_batch_queries|release_connection|get_connection'

echo '=== Relevant implementation ==='
rg -n -C 12 --glob '*.py' \
  'async def execute_query|async def execute_batch_queries|async def release_connection|asyncio\.shield|sync_worker|asyncio\.to_thread|contextlib\.suppress' \
  src/youtube_extension/backend/services/database_optimizer.py

echo '=== New and modified optimizer tests ==='
rg -n -C 10 --glob '*.py' \
  'TestBatchCancellationDoesNotCloseConnectionMidQuery|TestBatchQueriesActuallyOverlap|max_inflight|inflight|execute_batch_queries\(' \
  tests/unit/test_database_optimizer.py

Length of output: 32812


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '=== Locate optimizer test file ==='
fd -t f -e py 'test_database_optimizer' .

echo '=== Batch implementation ==='
sed -n '476,555p' src/youtube_extension/backend/services/database_optimizer.py

echo '=== Relevant test region at actual paths ==='
fd -t f -e py 'test_database_optimizer' . -x sh -c '
  for file do
    echo "----- $file -----"
    rg -n -C 14 \
      "class TestBatchQueriesActuallyOverlap|class TestBatchCancellationDoesNotCloseConnectionMidQuery|def test_batch_queries_are_not_serialised|def test_batch_queries_use_distinct_threads|def test_close_never_overlaps_an_in_flight_query|class _RecordingCursor|class _RecordingConnection" \
      "$file"
  done
' sh {} +

Length of output: 4519


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '=== Test file stored in HEAD ==='
git show HEAD:tests/unit/test_database_optimizer.py | \
  nl -ba | sed -n '1470,1655p'

echo '=== Synchronization primitives in committed test file ==='
git show HEAD:tests/unit/test_database_optimizer.py | \
  rg -n -C 4 \
  'Event|Barrier|Condition|started|start|inflight|close_overlapped_query|TestBatchCancellationDoesNotCloseConnectionMidQuery'

echo '=== Commit identity and changed paths ==='
git show --no-patch --format='%H%n%s' HEAD
git diff-tree --no-commit-id --name-status -r HEAD

Length of output: 943


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '=== Test file stored in HEAD ==='
git show HEAD:tests/unit/test_database_optimizer.py | \
  sed -n '1470,1655p'

echo '=== Synchronization primitives in committed test file ==='
git show HEAD:tests/unit/test_database_optimizer.py | \
  rg -n -C 4 \
  'threading\.(Event|Barrier|Condition)|asyncio\.(Event|Barrier|Condition)|started|start|inflight|close_overlapped_query|TestBatchCancellationDoesNotCloseConnectionMidQuery'

echo '=== Commit identity and changed paths ==='
git show --no-patch --format='%H%n%s' HEAD
git diff-tree --no-commit-id --name-status -r HEAD

Length of output: 13223


@groupthinking The cancellation blocker is fixed in 7c4dc0931.

execute_query() now drains sync_worker before release_connection() can close the SQLite connection. This restores the single-owner invariant during batch cancellation. The batch tests also now pass the documented (query, params) input shape. The max_inflight assertion is a useful deterministic concurrency check.

One non-blocking test concern remains. test_close_never_overlaps_an_in_flight_query() does not prove that the slow worker entered cursor.execute() before the first worker raises. On a different executor schedule, the failure can occur first. The test then passes without exercising cancellation of an active SQLite worker.

Add a threading.Event barrier:

  • Set the event when the slow connection increments its per-connection in-flight counter.
  • Make the failing connection wait for that event before it raises RuntimeError.
  • Assert that the event was set.

This makes the regression test deterministic and preserves its purpose.

Also consider removing the remaining elapsed < serial_floor / 2 assertion. max_inflight > 1 already proves overlap without dependence on default-executor capacity or CI scheduling.

🐇 ✅


✏️ Learnings added
Learnt from: groupthinking
URL: https://github.com/groupthinking/EventRelay/pull/1327

Timestamp: 2026-08-04T03:02:51.746Z
Learning: In `src/youtube_extension/backend/services/database_optimizer.py`, SQLite work offloaded with `asyncio.to_thread` must be represented by a task and drained with `asyncio.shield()` before `DatabaseConnectionPool.release_connection()` closes the connection. Cancelling the awaiting coroutine does not stop the worker thread, and `check_same_thread=False` does not permit concurrent operations on one SQLite connection.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


If you found this review helpful, would you consider giving us a shout-out on X?

Thank you for using CodeRabbit!

✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@groupthinking
groupthinking merged commit 93dbbc0 into main Aug 4, 2026
39 of 41 checks passed
@groupthinking
groupthinking deleted the perf/sqlite-connect-block branch August 4, 2026 03:30
@linear-code

linear-code Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

GRV-297

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Blocking SQLite I/O on the event loop defeats batch concurrency and freezes the loop

2 participants