Skip to content

perf(performance-monitor): move blocking sqlite3 I/O off the event loop - #1196

Merged
groupthinking merged 1 commit into
mainfrom
perf/perf-monitor-sqlite-offloop
Aug 1, 2026
Merged

perf(performance-monitor): move blocking sqlite3 I/O off the event loop#1196
groupthinking merged 1 commit into
mainfrom
perf/perf-monitor-sqlite-offloop

Conversation

@groupthinking

Copy link
Copy Markdown
Owner

Canonical issue

Closes #1195

Outcome

PerformanceMonitor used the synchronous sqlite3 driver directly inside six async def methods. Every call ran connect → statement → commit() (which fsyncs) → close() on the event loop, so nothing else could be scheduled for the duration of the disk write.

This is on a live request path — router.py:1165 and :1183 await performance_monitor.record_metric(...), and record_metric calls _store_metric unconditionally (no sampling, no enable flag).

This change does This change does not
Stop the event loop stalling during sqlite connect/commit/close Make the queries themselves faster
Keep the API, SQL text and return values byte-identical Change transaction boundaries or isolation
Let other coroutines run while a metric is being written Make writes concurrent — SQLite still serialises writers
Preserve the existing swallow-and-log error handling Add retries, pooling or WAL tuning

The honest claim is "stops stalling the event loop", not "faster". Under concurrent load that is the difference that matters: a request that records a metric no longer freezes every other in-flight request while the disk write completes.

Scope

Six sites, all in performance_monitor.py, all the same defect and the same fix:

_store_metric · _store_alert · _basic_cleanup · get_current_performance_summary · _get_recent_metrics_summary · _store_benchmark_result

Each body moved verbatim into a nested sync function dispatched with await asyncio.to_thread(...). Because the original bodies were already indented inside try:, they move into the nested def without re-indentation — so the diff is genuinely structural, not a rewrite.

Design notes

Why not aiosqlite? It is not a dependency of this project, and adding a driver is a much larger change than this defect warrants. asyncio.to_thread uses the default executor and needs no new dependency.

Why nested functions rather than methods? They close over self and the local variables (cutoff_date, metric, …) that the original code already computed, so no signature or state has to be threaded through.

No use-after-close hazard. Converting a synchronous call to to_thread can introduce a use-after-close race when a caller tears the resource down in a finally — that exact bug was found in #1190. It does not apply here: every connection is opened and closed inside the same call, so there is no shared handle for a caller to close underneath an in-flight statement. PerformanceMonitor has no close(); stop_monitoring() only clears a task flag.

_init_database deliberately untouched. It is a synchronous method called from __init__ — it never runs on the event loop, so wrapping it would add noise and no benefit.

Risk

Low.

  • No SQL, schema, transaction boundary or return type changed.
  • All 113 existing tests pass with zero test edits — the observable contract is unchanged.
  • sqlite3 connections are thread-affine (check_same_thread=True by default). That constraint is satisfied, not violated: each connection is created, used and closed entirely within a single to_thread call, so it never crosses threads.
  • Failure mode if the executor is saturated is a delay, not an error; the existing except Exception handlers are unchanged.

Verification

All results below are at head b38b9fdb3.

119 passed
  • 113 pre-existing tests pass with zero test edits
  • +6 new in TestSqliteDoesNotBlockEventLoop: 3 loop-responsiveness tests and 3 preserved-behaviour guards

Non-vacuity, by behavioural mutation. Deleting the code would raise AttributeErrors, which proves nothing. Instead all six to_thread dispatches were replaced with direct synchronous calls, keeping everything else identical:

MUTATED 6 dispatch sites -> direct sync calls

FAILED TestSqliteDoesNotBlockEventLoop::test_store_metric_does_not_block_event_loop
FAILED TestSqliteDoesNotBlockEventLoop::test_record_metric_does_not_block_event_loop
FAILED TestSqliteDoesNotBlockEventLoop::test_read_path_does_not_block_event_loop
3 failed, 116 passed

Exactly the 3 responsiveness tests fail and nothing else. A uniform "everything failed" would have meant the proof was structural rather than behavioural.

The test measures whether an independent heartbeat coroutine keeps getting scheduled while sqlite3.connect is artificially slowed:

beat = asyncio.create_task(heartbeat())
await asyncio.sleep(0)          # let the heartbeat reach its first await first
result = await coro
assert ticks > 0

Blocking → 0 ticks. Off-loop → > 0.

Honest note: the 3 guards (test_store_metric_still_writes_the_row, test_summary_reflects_stored_metrics, test_store_metric_swallows_database_errors) pass under both old and new code by design. They exist to pin behaviour the change must not regress, not to prove the change. Only the 3 heartbeat tests discriminate.

ruff: All checks passed! on both touched files, exact parity with main.

Production evidence

youtube_extension.backend.services.performance_monitor is in the transitive import closure of the production entrypoint youtube_extension.main:app (root Dockerfile:93).

router.py:69    from ...services.performance_monitor import PerformanceMonitor
router.py:1165  await performance_monitor.record_metric(...)
router.py:1183  await performance_monitor.record_metric(...)
     -> record_metric        (performance_monitor.py:231)
     -> await self._store_metric(metric)   (:261, unconditional)
     -> sqlite3.connect + INSERT + commit  (:274)

Other live callers: memory_manager.py:472/475, load_balancer.py:381, database_optimizer.py:49.

Agent handoff

Reviewers: the interesting questions are in Design notes and the Verification mutation output. Specific challenges are posted as a separate comment.

`PerformanceMonitor` used the fully synchronous `sqlite3` driver directly
inside six `async def` methods. Each call ran connect + statement + commit
(which fsyncs) + close on the event loop, so nothing else on the loop could
be scheduled for the duration of the disk write.

This is on a live request path: `api/v1/router.py:69` imports the monitor and
awaits `record_metric()` at `:1165` and `:1183`, and `record_metric` calls
`_store_metric` unconditionally.

Each method's database work is now a nested synchronous function dispatched
via `await asyncio.to_thread(...)`. Statement text, transaction boundaries,
return values and error handling are unchanged; only the thread the work runs
on changes. Connections are created and closed inside each call, so there is
no shared handle a caller could tear down mid-flight.

Sites moved off-loop:
  _store_metric, _store_alert, _basic_cleanup,
  get_current_performance_summary, _get_recent_metrics_summary,
  _store_benchmark_result

`_init_database` is left alone: it is a synchronous method called from
`__init__`, so it never runs on the event loop.

Tests: 113 pre-existing pass with zero edits, plus 6 new in
`TestSqliteDoesNotBlockEventLoop`. Non-vacuity was proven by behavioural
mutation: replacing all six `to_thread` dispatches with direct calls fails
exactly the 3 heartbeat tests and no others. The other 3 new tests are
preserved-behaviour guards -- they pass under both old and new code and exist
to pin behaviour the change must not regress, not to prove the change.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings August 1, 2026 22:30
@coderabbitai

coderabbitai Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are limited based on label configuration.

🏷️ Required labels (at least one) (1)
  • [‘architecture-gap’, ‘bug’, ‘ci-cd’, ‘ci/cd’, ‘copilot-rabbit’, ‘documentation’, ‘duplicate’, ‘enhancement’, ‘frontend’, ‘github_actions’, ‘good first issue’, ‘help wanted’, ‘high-priority’, ‘invalid’, ‘javascript’, ‘ml-model’, ‘needs-triage’, ‘pipeline-critical’, ‘placeholder-code’, ‘priority:high’, ‘python’, ‘python:uv’, ‘question’, ‘styling’, ‘tests’, ‘v0’]

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

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

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 8394cb0e-b61a-4815-bf8f-22a2451f3361

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

@vercel

vercel Bot commented Aug 1, 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 Canceled Canceled v0 Aug 1, 2026 10:30pm

@github-actions

github-actions Bot commented Aug 1, 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 b38b9fd.
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

@groupthinking

Copy link
Copy Markdown
Owner Author

@coderabbitai review

Please be adversarial. Five specific challenges I want pressure-tested:

  1. Thread affinity. sqlite3 connections default to check_same_thread=True. I claim this change satisfies that constraint rather than violating it, because each connection is created, used and closed entirely inside one asyncio.to_thread call and never crosses a thread boundary. Is there any path — an exception mid-method, a generator, a closure capturing conn — where a connection could outlive its to_thread call?

  2. Write serialisation. I deliberately did not claim this makes writes concurrent; SQLite serialises writers via a database-level lock. But moving six call sites off-loop means multiple executor threads can now contend on that lock simultaneously where previously the event loop serialised them for free. Could that turn a fast implicit queue into SQLITE_BUSY errors under load? The default timeout is 5s — is relying on it acceptable, or should this set it explicitly?

  3. Use-after-close. perf(cloud-tasks): move blocking gRPC calls off the event loop #1190 taught me that to_thread is not cancellation-safe when a caller closes the underlying resource in a finally. I argue it does not apply here because connections are per-call and PerformanceMonitor has no close(). Please verify — particularly whether stop_monitoring() or any teardown path could delete the database file while a thread is mid-write.

  4. Test honesty. I have labelled 3 of my 6 new tests as preserved-behaviour guards that pass under both old and new code. Is the 3-test discriminating set actually sufficient? It covers _store_metric, record_metric and the read path — but not _store_alert, _basic_cleanup, _get_recent_metrics_summary or _store_benchmark_result. That is a real coverage gap and I would rather you name it than let it slide.

  5. Scope. Six sites in one file, one defect class, one fix. Is that coherent scope or should it be split? I would push back on splitting — a partial fix leaves the loop blocked on the remaining paths — but I want the objection on the record if you have one.

Context: this is the 8th in a series of event-loop-blocking fixes. #1190 in the same series introduced a real cancellation regression that a reviewer caught, so I would rather over-scrutinise this one.

@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown

Agent Completion Truth Gate: BLOCKED

Reasons: invalid_payload

Machine-readable verdict
{
  "details": {
    "invalid_fields": [
      "policy.agent_login",
      "policy.run_id"
    ]
  },
  "reasons": [
    "invalid_payload"
  ],
  "verdict": "blocked"
}

Workflow evidence

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.

🟢 Ready to approve

All scoped SQLite operations are safely offloaded with focused regression coverage.

This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.

Pull request overview

Moves synchronous SQLite work off the event loop while preserving existing behavior.

Changes:

  • Dispatches six database operations through asyncio.to_thread.
  • Adds responsiveness and behavior-regression tests.
File summaries
File Description
performance_monitor.py Offloads SQLite reads, writes, and cleanup.
test_performance_monitor.py Verifies loop responsiveness and persistence behavior.
Review details
  • Files reviewed: 2/2 changed files
  • Comments generated: 0
  • Review effort level: Balanced

We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.

Copy link
Copy Markdown
Owner Author

Adversarial review (automated, review-requested trigger)

Reviewed the full diff at head b38b9fdb3 against the five challenges. Independent verdict below — no rubber-stamp.

Code correctness: no blocking defect. All six sites use the identical, correct transformation: the original body moves verbatim into a nested _write/_query/_purge, dispatched with await asyncio.to_thread(...) inside the pre-existing try. Each connection is created, used, and closed entirely inside one to_thread call; none of the moved bodies mutate in-memory self state (they touch only the DB), and record_metric's in-memory append stays on-loop. So there is no data race and no handle that survives its thread. The logger.info(...) relocation in _basic_cleanup preserves semantics.

Answering the five challenges:

  1. Thread affinity — satisfied, not violated. No path lets a conn outlive its to_thread call: it is a pure local, not returned, not yielded, not captured by any escaping closure. An exception mid-_write leaks the connection (no finally), but that was true before this PR and is not a regression.
  2. Write serialisation — the one real behavioural change. Previously the event loop serialised these writes for free; now multiple executor threads can contend on SQLite's database-level write lock. Under sustained concurrency a writer can exhaust the default 5 s busy timeout and raise OperationalError: database is locked, which the existing except swallows — i.e. a dropped, logged metric rather than a stall. Acceptable for a best-effort metrics path, but I'd note it explicitly and consider a short explicit timeout=/retry if this path ever becomes load-bearing.
  3. Use-after-close — does not apply. stop_monitoring() only clears a task flag; there is no close() and no teardown that deletes the DB file mid-write. Connections are per-call, so the perf(cloud-tasks): move blocking gRPC calls off the event loop #1190 cancellation regression cannot recur here.
  4. Test honesty — gap confirmed, low risk. The three discriminating tests cover _store_metric, record_metric, and the read path but not _store_alert, _basic_cleanup, _get_recent_metrics_summary, or _store_benchmark_result. Since all six are the same mechanical transform and I verified each in the diff, residual risk is low — but the gap is real and worth a one-line acknowledgement in the test module.
  5. Scope — coherent. One defect class, one file, six sites. Splitting would leave the loop blocked on the remaining paths; keep it together.

The actual merge blocker is not this diff. The failing/blocking checks are (a) agent-completion/truth-gate/pr-1196BLOCKED: invalid_payload on policy.agent_login and policy.run_id, which is a workflow-tooling bug (the same gate machinery that sibling PRs #1151 and #1154 are reworking), and (b) a Vercel deploy that was manually canceled from the dashboard, not a test failure. There is nothing in performance_monitor.py to fix for those to clear.

Not merging: base is protected main, this carries no automerge label, and the truth-gate is red — publish remains a human decision.


Generated by Claude Code

@coderabbitai

coderabbitai Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- PR diff summary ---'
git diff --stat origin/main...HEAD || true
git diff --unified=50 origin/main...HEAD -- '*performance_monitor.py' '*test*performance*' || true

echo '--- candidate files ---'
fd -t f -e py | rg 'performance_monitor|router|memory_manager|load_balancer|database_optimizer|test'

echo '--- implementation outline ---'
pm_file="$(fd -t f -e py 'performance_monitor.py' | head -n 1)"
if [ -n "${pm_file:-}" ]; then
  ast-grep outline "$pm_file" --items all
  echo "--- $pm_file: sqlite/lifecycle/dispatch references ---"
  rg -n -C 8 'sqlite3\.connect|to_thread|def (_store_metric|_store_alert|_basic_cleanup|get_current_performance_summary|_get_recent_metrics_summary|_store_benchmark_result|stop_monitoring)|\b(remove|unlink|rmtree)\b|database.*path|db_path' "$pm_file"
fi

echo '--- project-wide teardown and database deletion references ---'
rg -n -i -C 5 '(stop_monitoring\s*\(|performance_monitor|sqlite.*(remove|unlink)|\b(remove|unlink|rmtree)\s*\(|os\.remove|Path\(.*\)\.unlink)' --glob '*.py' .

Length of output: 41757


🏁 Script executed:

#!/bin/bash
set -euo pipefail

pm='src/youtube_extension/backend/services/performance_monitor.py'
tests='tests/unit/test_performance_monitor.py'

echo '--- PerformanceMonitor construction and lifecycle ---'
sed -n '1,250p' "$pm"

echo '--- all SQLite dispatch methods, with complete bodies ---'
sed -n '250,370p' "$pm"
sed -n '480,610p' "$pm"
sed -n '760,840p' "$pm"

echo '--- PerformanceMonitor teardown and database-path references ---'
rg -n -C 12 'def stop_monitoring|stop_monitoring\(|self\.db_path|db_path\s*=|os\.(remove|unlink)|Path\(.*\)\.(unlink|rm)|shutil\.rmtree|TemporaryDirectory' \
  "$pm" tests src --glob '*.py'

echo '--- direct callers and their surrounding lifecycle context ---'
rg -n -C 10 'record_metric\s*\(|_cleanup_old_metrics\s*\(|get_current_performance_summary\s*\(|get_performance_dashboard\s*\(' \
  src/youtube_extension/backend/api/v1/router.py \
  src/youtube_extension/backend/services/memory_manager.py \
  src/youtube_extension/backend/services/load_balancer.py \
  src/youtube_extension/backend/services/database_optimizer.py 2>/dev/null || true

echo '--- full new test class ---'
sed -n '1030,1180p' "$tests"

Length of output: 137893


@groupthinking I found two items that need action.

  1. Close every connection in the worker on failure.
    Each nested closure closes conn only on the success path. For example, _store_metric() creates conn on Line 275, but an exception from cursor(), execute(), or commit() skips conn.close().

    This behavior existed before the PR. The to_thread() conversion makes it more important to fix. The exception traceback can retain conn after the worker returns. Later finalization is not a safe connection-lifecycle policy for a thread-affine resource.

    Add try/finally inside each nested _write, _purge, and _query function. Call conn.close() from finally in that same worker. Add a test that injects a failure after connect() and verifies that close() runs.

  2. Extend the discriminating responsiveness tests to all six offloaded methods.
    The current three-test set proves the write path through _store_metric() and record_metric(), plus get_current_performance_summary(). It does not prove that the other four conversions remain off-loop.

    Add heartbeat tests for _store_alert(), _basic_cleanup(), _get_recent_metrics_summary(), and _store_benchmark_result(). The existing slow-sqlite3.connect mechanism is appropriate. This is a real coverage gap because a future direct call at any of these sites would pass the preserved-behavior guards.

My assessment of the five challenges:

  1. Thread affinity: Correct on the normal path. No closure returns a connection. No generator captures one. The connection is created, used, and normally closed by one executor worker. The missing finally prevents this claim from being complete on error paths.

  2. Write serialization: Moving writes off-loop permits simultaneous writer attempts. SQLite serializes those writes. The default sqlite3.connect() timeout is five seconds, so short contention waits will not immediately become SQLITE_BUSY. Under sustained contention, a write can still time out and the existing handlers will log and drop that metric or alert. Setting timeout=5.0 explicitly does not improve behavior. Set an explicit non-default timeout only if the project defines an acceptable metric-loss latency and failure policy. That policy is outside this focused event-loop fix.

  3. Use-after-close and teardown: I found no PerformanceMonitor close path and no database-file deletion path. stop_monitoring() only cancels the background task. Cancellation can detach the awaiting task while the worker continues, but no caller owns or closes that worker connection. Therefore, the #1190 resource-race pattern does not apply. The missing worker-local finally remains the exception-path concern.

  4. Test honesty: The labels are accurate. The three behavior guards do not discriminate by design. The three heartbeat tests discriminate, but they leave four converted methods unproven. Add the four tests above.

  5. Scope: Keep the six sites together. They are one defect class in one module. Splitting the change would knowingly retain event-loop blocking paths.

  • Ensure that every worker-local SQLite connection closes in the same executor thread, including failure paths.
  • Add discriminating heartbeat tests for the four currently untested asyncio.to_thread() dispatches.

Failed to handle agent chat message. Please try again.

You are interacting with an AI system.

@groupthinking
groupthinking marked this pull request as ready for review August 1, 2026 22:40
@groupthinking
groupthinking merged commit 26b2a7c into main Aug 1, 2026
47 of 61 checks passed
@groupthinking
groupthinking deleted the perf/perf-monitor-sqlite-offloop branch August 1, 2026 22:40
@linear-code

linear-code Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

GRV-228

groupthinking added a commit that referenced this pull request Aug 1, 2026
Review round 4, finding 2 (CodeRabbit, Stability & Availability, Major).

This PR moved 14 blocking boto3 calls onto the *shared* default asyncio
executor via asyncio.to_thread. botocore's defaults leave a request
effectively unbounded, so a stalled AWS call would now pin one of that
pool's limited worker threads indefinitely and starve every other
to_thread user in the process -- including the metrics persistence
(#1194), sqlite access (#1196) and result writes (#1203) already merged
onto that same pool. _wait_for_job_completion can issue up to 120 such
calls per job, so the exposure is real rather than theoretical.

Both the Rekognition and S3 clients are now constructed with an explicit
botocore Config carrying connect_timeout, read_timeout and a bounded
standard-mode retry policy. Values are overridable via
AWS_REKOGNITION_CONNECT_TIMEOUT / _READ_TIMEOUT / _MAX_ATTEMPTS and are
validated with math.isfinite, raising rather than clamping so that
'inf', 'nan', '0' and negatives are rejected outright.

Parsing happens before initialize()'s try block: that method ends in a
catch-all `except Exception -> CloudAIError`, which would otherwise bury
a precise ConfigurationError message behind a generic init failure.

Tests: 127 pass (105 pre-existing + 22 new). Non-vacuity proven by
mutating both dimensions simultaneously -- making the env helpers ignore
the environment and dropping `config=` from both client constructions
yields exactly 18 targeted failures / 109 passed, matching the predicted
count (1 client-config + 1 override + 12 timeout rejections + 4
max-attempts rejections). ruff parity with origin/main unchanged (8 = 8).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
groupthinking added a commit that referenced this pull request Aug 1, 2026
…1205)

* perf(cloud-ai): run AWS Rekognition boto3 calls off the event loop

boto3 is a synchronous SDK. Every Rekognition call in AWSRekognition was
issued directly inside an `async def`, so each one blocked the event loop
for a full network round-trip. `_wait_for_job_completion` is the worst
case: it polls every 5s for up to 600s, so a single video analysis could
stall the loop up to 120 times.

All 14 boto3 calls now dispatch via `await asyncio.to_thread(...)`, and
the local-image read in `_prepare_image_input` goes through a new
module-level `_read_file_bytes` helper on the same path.

- 89 pre-existing tests pass with zero edits
- 6 new heartbeat tests (`TestRekognitionDoesNotBlockEventLoop`); 5 of the
  6 discriminate, proven by reverting both dimensions simultaneously
  (5 targeted failures / 90 passed)
- ruff: exact parity with origin/main (8 pre-existing findings, 0 added)

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

* test(rekognition): assert the local read leaves the loop thread, not elapsed ticks

The heartbeat form of this one test failed on CI. Unlike the four boto3 tests,
which drive a controllable 0.12s mock, the local file read is a few
microseconds of real work, so "did the loop tick while it ran" is a
load-sensitive proxy rather than a property.

Assert the property directly instead: record `threading.get_ident()` inside
`_read_file_bytes` and require it to differ from the thread running the event
loop. That is exactly what "dispatched off the loop" means, needs no sleeps,
and cannot flake under runner contention.

- 95 tests pass (89 pre-existing, unmodified, + 6 new)
- Non-vacuity: calling `_read_file_bytes` directly instead of via
  `asyncio.to_thread` yields exactly 1 targeted failure / 94 passed
- Suite runtime for the file drops to 0.85s (the 0.12s sleep is gone)

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

* test(rekognition): cover every blocking SDK operation

* test(rekognition): make off-loop read test robust to module eviction

* perf(rekognition): bound AWS client requests with botocore timeouts

Review round 4, finding 2 (CodeRabbit, Stability & Availability, Major).

This PR moved 14 blocking boto3 calls onto the *shared* default asyncio
executor via asyncio.to_thread. botocore's defaults leave a request
effectively unbounded, so a stalled AWS call would now pin one of that
pool's limited worker threads indefinitely and starve every other
to_thread user in the process -- including the metrics persistence
(#1194), sqlite access (#1196) and result writes (#1203) already merged
onto that same pool. _wait_for_job_completion can issue up to 120 such
calls per job, so the exposure is real rather than theoretical.

Both the Rekognition and S3 clients are now constructed with an explicit
botocore Config carrying connect_timeout, read_timeout and a bounded
standard-mode retry policy. Values are overridable via
AWS_REKOGNITION_CONNECT_TIMEOUT / _READ_TIMEOUT / _MAX_ATTEMPTS and are
validated with math.isfinite, raising rather than clamping so that
'inf', 'nan', '0' and negatives are rejected outright.

Parsing happens before initialize()'s try block: that method ends in a
catch-all `except Exception -> CloudAIError`, which would otherwise bury
a precise ConfigurationError message behind a generic init failure.

Tests: 127 pass (105 pre-existing + 22 new). Non-vacuity proven by
mutating both dimensions simultaneously -- making the env helpers ignore
the environment and dropping `config=` from both client constructions
yields exactly 18 targeted failures / 109 passed, matching the predicted
count (1 client-config + 1 override + 12 timeout rejections + 4
max-attempts rejections). ruff parity with origin/main unchanged (8 = 8).

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

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
groupthinking added a commit that referenced this pull request Aug 3, 2026
* perf: scan processed-video cache off the event loop

GET /api/v2/videos/list is declared async but its whole body was blocking
filesystem work: a stat, a directory glob, and one open()+json.load() per
cached video, with no bound on entry count. The handler never awaited, so
the loop was stalled for the full scan and no other request could be served.

Extract the scan into a module-level _collect_processed_videos_sync() helper
and dispatch it with asyncio.to_thread(), matching the pattern used in #1194,
#1196, #1228, #1233, #1240, #1245 and #1251. The scan logic is moved verbatim,
so the response payload, newest-first ordering, per-entry corrupt-file skip and
empty-list fallbacks are unchanged.

Measured on a 2,000-entry cache, peak event-loop stall drops from ~193 ms to
~2 ms. Scan wall time is unchanged: this is a latency and fairness fix, not a
throughput one.

Closes #1287

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

* style: Black-format _collect_processed_videos_sync helper

Normalize string quotes to double and wrap the dict-append and sort
call in _collect_processed_videos_sync to satisfy the 88-char limit,
addressing the CodeRabbit review on #1288. Behaviour-preserving:
diff is confined to the new helper and the reformat is Black's own
AST-equivalent output (verified with --target-version py311).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013rG7vUAn6z9tXoEuA3dAqz

* test: prove per-file cache read is off the event loop

The thread-recording cache directory previously asserted only that
exists()/glob() ran off-loop, and relied on the helper extraction to
imply the per-entry open()/json.load() moved with them.

glob() now yields path-like proxies whose __fspath__ records the calling
thread. Because open() resolves a non-str argument through __fspath__,
this captures the thread at the exact moment each blocking read starts,
so the read is proven off-loop rather than inferred.

Verified by reverting only the handler call site to the inline form: the
new assertion fails independently with "blocking cache entry read ran on
the event loop thread".

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

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
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.

perf: PerformanceMonitor runs blocking sqlite3 I/O on the event loop

2 participants