Skip to content

perf: offload the learning-log filesystem walk off the event loop - #1388

Merged
groupthinking merged 3 commits into
mainfrom
perf/learning-log-offload-1386
Aug 5, 2026
Merged

perf: offload the learning-log filesystem walk off the event loop#1388
groupthinking merged 3 commits into
mainfrom
perf/learning-log-offload-1386

Conversation

@groupthinking

@groupthinking groupthinking commented Aug 5, 2026

Copy link
Copy Markdown
Owner

Canonical issue

Closes #1386

GET /api/v1/learning-log runs an unbounded, completely uncached filesystem walk directly on the event loop. This PR moves that walk to a worker thread.

Outcome

get_learning_log_v1 was declared async but awaited nothing:

async def get_learning_log_v1(data_service: DataService = Depends(get_data_service)):
    try:
        learning_log = data_service.get_learning_log()   # blocking, inline
        return learning_log

DataService.get_learning_log() (data_service.py:54-134) is not cheap:

Line Work Cost
L71 self.enhanced_analysis_dir.rglob("*_enhanced.md") full recursive tree walk, its own, on every request
L79 parent_dir.glob(f"{video_id}_*_metadata.json") one directory scan per entry
L83 .exists() one stat per entry
L85-86 open() + json.load() one file read + JSON parse per entry
L93 .stat() another stat per entry
L127 in-memory sort over the whole result set

Because it all ran inline, the entire process — every other request on every other endpoint, plus the health check — stalled for the duration.

Why this one is worse than the two already fixed

This is the third endpoint in this family, and it is the worst of them:

  1. It is completely uncached. perf: offload cache-directory scan off the event loop (#1231) #1237 and perf: offload /api/v1/videos page read off the event loop #1382 both went through _get_all_files_cached(), which has a real 60-second TTL (data_service.py:48, 136-160). get_learning_log() bypasses that helper entirely and issues its own rglob at L71. There is no cache to amortise anything.
  2. It is unbounded. The /videos endpoint fixed in perf: offload /api/v1/videos page read off the event loop #1382 at least took limit/offset and capped a page at 50. get_learning_log_v1 takes no parameters at all — the walk, the per-entry reads and the sort all scale linearly with the total number of analysed videos, forever.

So where the earlier fixes bounded a stall, this one removes a stall that grows without limit as the corpus grows.

The second half of the fix: bounding concurrency

Offloading alone would have traded one problem for another, and CodeRabbit's review caught it as a blocking finding before merge.

asyncio.to_thread dispatches to the loop's default executor — the same pool every other to_thread and run_in_executor(None, …) caller in the process uses. Since get_learning_log() is uncached, each concurrent request starts its own independent walk. The default per-client rate limit is 60/minute and permits bursts, and this endpoint has no limit of its own, so a burst could occupy every worker in the shared pool and delay unrelated executor-backed requests. That is a real change in blast radius: from "one endpoint pins the loop" to "one endpoint starves everyone else's threads".

So the dispatch is gated:

async with _get_learning_log_gate():
    learning_log = await asyncio.to_thread(data_service.get_learning_log)

Cap is 4 in-flight walks. Requests over the cap wait on the event loop holding no worker thread, so the endpoint degrades by queueing rather than by monopolising the executor. Exactly one to_thread hop remains inside the gate.

I chose a semaphore over a dedicated bounded executor because it has no lifecycle or shutdown handling to get wrong.

Why the gate is per-loop, and why that is not over-engineering

A plain module-level asyncio.Semaphore(4) is broken here — but not in the way I first wrote, and the correction (raised by CodeRabbit) makes the case for this design stronger rather than weaker.

I originally claimed the semaphore binds to the first event loop that awaits it. That is wrong. Semaphore.acquire in CPython 3.12 reads:

if not self.locked():
    self._value -= 1
    return True                             # returns before _get_loop()
fut = self._get_loop().create_future()       # only the waiting path binds

The uncontended path never touches the loop. Binding happens only when an acquisition actually has to wait. I verified this from source via inspect.getsource and then reproduced it: uncontended acquires under two different asyncio.run() loops leave _loop as None with no error; five concurrent holders against a cap of four pin it to that loop; a fresh loop afterwards raises RuntimeError: <Semaphore …> is bound to a different event loop.

So a naive singleton is not an obvious bug that any test would surface — it is a latent landmine. It works across any number of loops, and keeps working, right up until the first genuinely contended acquisition. That one pins it, and every later use from a different loop fails. The failure therefore cannot appear in low-concurrency tests; it waits for exactly the burst this gate exists to absorb. Building the gate per running loop and holding it weakly removes the trap outright.

_LEARNING_LOG_MAX_CONCURRENCY = 4
# Maps a running event loop -> the ``asyncio.Semaphore`` bound to that loop.
_learning_log_gates: weakref.WeakKeyDictionary = weakref.WeakKeyDictionary()
_learning_log_gates_lock = threading.Lock()


def _get_learning_log_gate() -> asyncio.Semaphore:
    """Return the learning-log concurrency gate bound to the running loop."""
    loop = asyncio.get_running_loop()
    with _learning_log_gates_lock:
        gate = _learning_log_gates.get(loop)
        if gate is None:
            gate = asyncio.Semaphore(_LEARNING_LOG_MAX_CONCURRENCY)
            _learning_log_gates[loop] = gate
        return gate

The threading.Lock is load-bearing, not decorative: WeakKeyDictionary is not thread-safe (weakref finalisers can run arbitrary Python between bytecodes) and TestClient drives a loop on its own thread. I skipped double-checked locking — one uncontended acquire is ~50 ns against a filesystem walk. Entries drop automatically when their loop is collected, so nothing leaks in a long-lived process.

Both failure modes are covered by tests with negative controls, below.

Why one to_thread hop and not two

The whole of get_learning_log() is a single synchronous call. There is no sequencing to break apart — no count-then-page shape like /videos had — so there is exactly one thing to dispatch. Splitting it would add a hop and buy nothing, and test_walk_uses_exactly_one_to_thread_hop pins that so a future refactor cannot silently add a second context switch.

Why the blocking implementation is left alone

The fix is confined to the async layer. data_service.py is not touched.

Deliberately out of scope, each worth its own change:

  • routing get_learning_log() through _get_all_files_cached() so it stops issuing its own rglob
  • adding limit/offset so the endpoint stops being unbounded (an API contract change)
  • caching the parsed metadata rather than re-reading and re-parsing every file every time

Those are real improvements, but each changes behaviour or the public contract. This PR changes where the existing work runs and nothing else, which is what makes it reviewable and safe to land on its own.

Scope

File Change
src/youtube_extension/backend/api/v1/router.py one line: data_service.get_learning_log()await asyncio.to_thread(data_service.get_learning_log), plus a 3-line comment explaining why
tests/unit/test_v1_router_extended.py new TestLearningLogOffloading (4 tests)

asyncio was already imported (L10). No new dependencies. No new imports in the source file.

Not changed: data_service.py, the route decorator, the response shape, the status codes, the except block.

Risk

  • Risk level: low
  • Failure mode: the walk now runs on a ThreadPoolExecutor worker. get_learning_log() reads the filesystem and touches no shared mutable state, so there is no new data race. Executor starvation under a concurrent burst was the realistic failure and is now bounded by the semaphore at 4 in-flight walks; the minimum possible default-executor size is min(32, (os.cpu_count() or 1) + 4) = 5, so the cap always leaves at least one worker for unrelated callers even on a 1-CPU runner. Beyond the cap, requests queue on the event loop holding no thread. The remaining failure mode is added latency for the 5th-and-beyond concurrent caller — strictly better than today, where the first caller stalls the whole loop.
  • Rollback: revert the two commits. The change is confined to one function plus a module-level helper above it; there is no migration, no persisted state and no config. Reverting only the gate commit leaves a working (if unbounded) offload.
  • The response body is byte-for-byte identical — the same object from the same function, just returned from a different thread.
  • The error contract is unchanged: an exception inside the worker propagates through await into the same except Exception and still yields a 500. test_error_contract_is_unchanged asserts this directly, and the pre-existing test_get_learning_log_error still passes untouched.

Verification

All commands run at the pushed head of this branch.

Focused tests

tests/unit/test_v1_router_extended.py ... 133 passed in 1.41s

125 before, 133 after — the 8 new tests, with no existing test modified or removed.

Test What breaks it
test_walk_runs_on_a_worker_thread the walk executing on the loop thread
test_event_loop_stays_responsive_while_walk_is_in_flight the loop being pinned for the walk's duration
test_walk_uses_exactly_one_to_thread_hop a hop being added or removed
test_error_contract_is_unchanged the 500 contract regressing
test_concurrent_walks_are_capped_by_the_gate the cap being absent, or set so low it serialises the endpoint
test_gate_is_rebuilt_for_each_event_loop a module-level singleton — caught by object identity, since a quiet test would never provoke the RuntimeError
test_gate_is_shared_within_one_event_loop a fresh gate per call, which would bound nothing
test_gate_survives_a_contended_loop_then_a_fresh_loop the actual production failure: saturates the gate so at least one caller genuinely waits (the only path that pins the semaphore), then drives the endpoint on a brand-new loop

Negative controls

A test that cannot fail is not evidence, so each was mutated and re-run.

Control Mutation Result Expected
NC-1 revert router.py to origin/main (direct inline call) 3 failed, 1 passed ✅ the 3 offload tests fail; the error-contract test correctly still passes, since the 500 path is genuinely unaffected by the revert
NC-2 double-dispatch — wrap the call in a second nested to_thread 1 failed, 3 passed only test_walk_uses_exactly_one_to_thread_hop fails, so it is specific rather than incidentally coupled
NC-3 await asyncio.sleep(0) then call inline — the naive "just add an await" fix 3 failed, 1 passed ✅ proves the tests demand real thread offloading, not merely the presence of an await point
NC-4 delete the async with, keeping to_thread — i.e. exactly the state CodeRabbit flagged 1 failed, 6 passed ✅ only the cap test fails, with assert 7 <= 4 — all limit + 3 callers walked at once, which is the unbounded burst reported
NC-5 return a fresh Semaphore per call instead of a cached one 2 failed, 5 passed ✅ the cap test and the shared-gate test fail; a per-call gate bounds nothing
NC-6 one module-level gate keyed by a constant string in a plain dict — the loop-leak bug 1 failed, 6 passed ✅ only the per-loop rebuild test fails, reporting is not on two identical object ids
NC-7 replace the accessor with a true module-level Semaphore singleton 2 failed, 6 passed ✅ the identity test and the new contention test fail — the latter with the exact production RuntimeError: … is bound to a different event loop

NC-4 is the direct proof of CodeRabbit's finding: with the gate removed the test reports the true concurrent count rather than merely failing. The cap test asserts peak == limit, not peak <= limit, so it fails in both directions — an absent bound and an over-restrictive one that quietly serialises the endpoint. It also polls until the first wave saturates and then sleeps 0.25 s before sampling the peak, so unbounded overflow has time to appear instead of being masked by a race.

NC-3 matters: it is the mistake this class of fix actually attracts. An await makes the function look correctly async and satisfies any status-code test, while the loop is still pinned.

NC-7 is the one that closes the loop on the correction above. Because binding requires contention, test_gate_is_rebuilt_for_each_event_loop can only detect a singleton through object identity — it would never see a RuntimeError, since nothing in a quiet test makes an acquisition wait. test_gate_survives_a_contended_loop_then_a_fresh_loop closes that gap by manufacturing the contention first, and under NC-7 it fails with the real runtime error rather than a proxy assertion. That is the difference between testing the implementation and testing the failure.

A vacuous test I caught and fixed before pushing

The first draft of test_event_loop_stays_responsive_while_walk_is_in_flight counted loop ticks while the mock blocked on threading.Event().wait(timeout=2.0) and asserted ticks >= 3.

It passed under NC-1. That first NC run returned 2 failed, 2 passed rather than the expected 3 failed.

The reason: the blocking call has a timeout, so it eventually returns. In the inline case the loop is pinned for 2s, the wait times out, the task finishes — and then the three ticks run freely and the assertion passes. It was measuring nothing.

The rewritten test timestamps each tick with time.monotonic() and records when the walk actually finished, then asserts at least 3 ticks completed strictly before that moment. Inline, every tick necessarily lands after the walk ends, giving zero qualifying ticks. That version fails NC-1 and NC-3 as it should.

I am calling this out rather than quietly fixing it because the failure mode — an assertion that is true for the wrong reason — is exactly what the negative-control ladder exists to catch, and it is the reason I run one.

Anti-vacuity assertions

Every test asserts the returned payload equals the mock's return value before asserting anything about threads or hops, and the responsiveness test additionally asserts the blocking work ran to completion. Without these, a change that skipped the call entirely would satisfy the thread-identity and hop-count assertions trivially.

Why the existing tests did not catch this

tests/unit/test_v1_router_extended.py:191 configures svc.get_learning_log.return_value = [...] on a MagicMock. The mock returns instantly, so test_get_learning_log (L544) passes identically whether the real call blocks for 10ms or 10s — it asserts a status code, and a status code is agnostic to which thread produced it.

tests/unit/test_data_service.py:47-100 does exercise the real get_learning_log(), but calls it synchronously, so it never observes the endpoint's threading behaviour either.

Neither is wrong; they test different things. That gap is why this defect survived two rounds of fixes to its immediate neighbours.

Lint

ruff check src/youtube_extension/backend/ src/youtube_extension/main.py --ignore E402,F811,F401,F821,B904,B020,E701,E722

3 errors total, at deploy/__init__.py:3:1, data_service.py:340:16 and test_v1_router_extended.py:1966:17. I stashed my changes and re-ran against origin/main: byte-identical, same three locations. All pre-existing; none introduced here, and none in code this PR touches.

ruff format --diff over the two files this PR touches reports 189 changed lines on this branch and 189 on the stashed baseline — exactly neutral, so this PR adds zero new formatting drift. Reaching parity took two reflows of my own new lines (an over-long annotated assignment, and an asyncio.create_task(…) inside a comprehension that ruff wanted collapsed). CI does not run ruff format, and lint-python is continue-on-error: true, but I hold the branch to parity anyway so the signal stays usable.

The ruff check findings were also contrasted against the stashed baseline over the same two files: 26 on both sides, differing only by a uniform +7 line shift from the longer comment block. No new finding of any rule.

python -m compileall -q src/ — clean, which is the guard CI actually enforces.

Full suite

4 failed, 8278 passed, 18 skipped, 5 xpassed, 89 subtests passed in 761.24s

The four failures are the pre-existing ones described below; every other test in the repository passes.

Pre-existing failures

Four tests fail on clean main and are unrelated to this change:

  • tests/test_code_generator.py ×3 — make live gemini-2.5-flash API calls

  • tests/test_sdk_python.py::TestEventRelayClient::test_client_no_api_key_header_absentmain.py:57-61 calls load_dotenv at import time; a local untracked .env supplies EVENTRELAY_API_KEY, which sdk/python/eventrelay_sdk/client.py:57 falls back to, so the header is present. Toggling only that one env var on clean main flips the result, which is what identifies it. The hermetic fix (monkeypatch.delenv) belongs in its own PR rather than bundled into a perf change.

  • Focused tests

  • Required CI

  • Review threads resolved

Production evidence

Not applicable — no production surface changes.

This PR alters neither the route, the request signature, the response schema, the status codes nor any persisted state. GET /api/v1/learning-log returns the identical list of identical objects; the only difference is which thread assembled it. There is no feature flag, no migration and no deploy-order constraint, so there is no production artefact to attach. The relevant evidence is the negative-control ladder above, which demonstrates the tests fail when the fix is absent, when it is doubled, and when it is faked with a bare await.

`get_learning_log_v1` awaited nothing: it called
`DataService.get_learning_log()` directly, so the whole walk ran inline on
the event loop.

That call is worse than the sibling endpoints already fixed. It issues its
own fresh `rglob("*_enhanced.md")` rather than going through
`_get_all_files_cached()`, so it is completely uncached, and it takes no
`limit`/`offset`, so its cost grows linearly with the total video count
forever. Per entry it also runs a `glob`, an `exists`, an `open` +
`json.load` and a `stat`.

The walk is now dispatched with a single `asyncio.to_thread` hop. The
blocking implementation in `data_service.py` is deliberately untouched;
caching and pagination for it are follow-up work.

Adds `TestLearningLogOffloading` (4 tests) covering thread identity, loop
responsiveness while the walk is in flight, the exact to_thread hop count,
and the unchanged 500 error contract.

Closes #1386

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

vercel Bot commented Aug 5, 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 5, 2026 2:37am

@coderabbitai

coderabbitai Bot commented Aug 5, 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: 424fc747-0e2c-4fff-ac1e-c4e803d46553

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.

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

github-actions Bot commented Aug 5, 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 4c2c667.
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 5, 2026

Copy link
Copy Markdown

Agent Completion Truth Gate: NOT_APPLICABLE

Evidence agrees.

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

Workflow evidence

@groupthinking

Copy link
Copy Markdown
Owner Author

@linear-code @coderabbitai review

Third in the family of event-loop offload fixes, after #1237 and #1382. One line of source; the rest is the test scaffolding and the evidence for it.

Judgement calls I would specifically like challenged:

1. Fixing this in the async layer rather than in DataService. The real problem is arguably that get_learning_log() issues its own rglob at data_service.py:71 instead of using _get_all_files_cached(), and that it takes no limit/offset. I deliberately did neither. Routing it through the cache changes staleness semantics; adding pagination changes the public response contract from a bare list. Both deserve their own PR and their own review. This PR only changes which thread the existing work runs on. If you think that is the wrong call — that shipping the offload first entrenches the uncached walk — say so.

2. A vacuous test I caught mid-review, and whether the same flaw is now merged. My first responsiveness test counted event-loop ticks while the mock blocked on Event().wait(timeout=2.0) and asserted ticks >= 3. It passed on reverted source. The timeout is an escape hatch: inline, the loop is pinned, the wait times out, the task completes, and then the ticks run — assertion satisfied for entirely the wrong reason. I rewrote it to timestamp each tick and count only those completing strictly before the walk finished (details in the body).

The part I want a second opinion on: TestListVideosOffloading, merged in #1382, has this same weakness at test_v1_router_extended.py:2405-2546. I did not touch it here, on the grounds that a perf PR should not carry an unrelated test-hardening diff. Is that the right boundary, or should the fix travel with the discovery?

3. One to_thread hop. get_learning_log() is a single synchronous call, so there is one thing to dispatch and test_walk_uses_exactly_one_to_thread_hop pins it. NC-2 (double-dispatch) fails only that test, so it is specific rather than incidentally coupled. Worth confirming the hop count is a property worth asserting at all, or whether it over-constrains future refactors.

4. Executor pressure. This is now the third endpoint dispatching to the default ThreadPoolExecutor, and this one is unbounded. Under concurrent load the walks compete for workers. I still consider it strictly better than pinning the single event loop, but if you think this argues for a dedicated bounded executor or a semaphore, I would rather hear it now than after the fourth.

Negative controls run and reported in the body: revert (3 failed), double-dispatch (1 failed, only the hop-count test), and yield-then-inline (3 failed). That last one is the mistake this fix pattern actually attracts — an await that makes the function look async while the loop stays pinned.

@groupthinking
groupthinking marked this pull request as ready for review August 5, 2026 02:07
Copilot AI balanced review requested due to automatic review settings August 5, 2026 02:07
@coderabbitai

coderabbitai Bot commented Aug 5, 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 ==='
git diff --unified=80 origin/main...HEAD -- \
  src/youtube_extension/backend/api/v1/router.py \
  tests/unit/test_v1_router_extended.py

echo '=== Router outline ==='
ast-grep outline src/youtube_extension/backend/api/v1/router.py --items all --type function --match 'get_learning_log_v1|list_videos' || true

echo '=== Data service outline ==='
ast-grep outline src/youtube_extension/backend/services/data_service.py --items all --type function --match 'get_learning_log|_get_all_files_cached' || true

echo '=== Relevant test classes and concurrency controls ==='
rg -n -C 6 \
  'class TestLearningLogOffloading\b|class TestListVideosOffloading\b|to_thread\s*\(|ThreadPoolExecutor|Semaphore|get_learning_log_v1\s*\(' \
  src/youtube_extension/backend/api/v1/router.py \
  tests/unit/test_v1_router_extended.py \
  src/youtube_extension/backend/services/data_service.py

Length of output: 22126


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '=== Locate relevant files ==='
fd -t f -a 'test_v1_router_extended\.py|data_service\.py|pyproject\.toml|requirements.*\.txt|Dockerfile|docker-compose.*\.ya?ml' . \
  | sed -n '1,160p'

test_file="$(fd -t f -a '^test_v1_router_extended\.py$' . | head -n 1)"
data_file="$(fd -t f -a '^data_service\.py$' src | head -n 1)"

printf '\n=== Test sections: %s ===\n' "$test_file"
sed -n '2380,2700p' "$test_file"

printf '\n=== DataService learning-log and cache sections: %s ===\n' "$data_file"
sed -n '1,185p' "$data_file"

printf '\n=== Executor and server concurrency configuration ===\n'
rg -n -i -C 4 \
  'ThreadPoolExecutor|set_default_executor|run_in_executor|asyncio\.to_thread|Semaphore|limit-concurrency|workers\s*=|uvicorn|gunicorn|max_workers' \
  --glob '*.py' --glob 'pyproject.toml' --glob 'requirements*.txt' --glob 'Dockerfile*' --glob 'docker-compose*.y*ml' \
  .

Length of output: 2773


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '=== Tracked test paths in HEAD and origin/main ==='
git ls-tree -r --name-only HEAD | rg -i '(^|/)(test_.*v1.*router.*|.*router.*test.*)\.py$|test_v1_router_extended\.py$' || true
git ls-tree -r --name-only origin/main | rg -i '(^|/)(test_.*v1.*router.*|.*router.*test.*)\.py$|test_v1_router_extended\.py$' || true

echo '=== Working-tree test candidates ==='
fd -t f -a -i 'router.*\.py|.*router.*' . | rg '/(test|tests)/|test_' || true

echo '=== DataService learning-log and cache implementation ==='
sed -n '1,185p' src/youtube_extension/backend/services/data_service.py

echo '=== Python requirement and deployment/server configuration ==='
printf '\n--- pyproject.toml ---\n'
sed -n '1,240p' pyproject.toml
printf '\n--- root Dockerfile ---\n'
sed -n '1,220p' Dockerfile
printf '\n--- production Dockerfile ---\n'
sed -n '1,220p' infrastructure/docker/Dockerfile.production

echo '=== Executor and request-concurrency controls ==='
rg -n -i -C 3 \
  'ThreadPoolExecutor|set_default_executor|run_in_executor|Semaphore|limit-concurrency|max_workers|uvicorn|gunicorn' \
  --glob '*.py' --glob 'pyproject.toml' --glob 'Dockerfile*' --glob 'docker-compose*.y*ml' \
  . || true

Length of output: 97358


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '=== Learning-log and videos offloading tests from tracked HEAD ==='
git show HEAD:tests/unit/test_v1_router_extended.py | sed -n '2380,2700p'

echo '=== Existing rate-limit declarations for the learning-log route ==='
git show HEAD:src/youtube_extension/backend/api/v1/router.py | \
  rg -n -C 5 'learning-log|@.*limit|Limiter|router\s*=' || true

echo '=== Application limiter setup and router inclusion ==='
rg -n -C 5 \
  'Limiter\s*\(|limiter\s*=|state\.limiter|_rate_limit_exceeded_handler|include_router' \
  src/youtube_extension/main.py \
  src/youtube_extension/backend/main.py \
  src/youtube_extension/backend/api/v1/router.py \
  src/youtube_extension/backend/config/production_config.py || true

echo '=== Current route diff metadata ==='
git diff --check origin/main...HEAD
git status --short

Length of output: 18439


@groupthinking I found one production-risk issue.

Blocking: bound concurrent learning-log walks before merge.

get_learning_log_v1 now submits one full filesystem walk for each concurrent request. The deployed application has a default 60/minute per-client limit, but that limit permits bursts. The endpoint has no endpoint-specific limit and no process-wide semaphore.

Each walk uses the shared default executor. The executor also serves other asyncio.to_thread and run_in_executor(None, ...) calls. Concurrent requests can therefore queue or run many uncached rglob walks at once. This can saturate worker threads and filesystem I/O. It can also delay unrelated executor-backed requests.

Add a process-wide, event-loop-safe semaphore around this endpoint’s asyncio.to_thread call. Keep the current single hop inside that semaphore. Add a test that proves the configured limit applies across concurrent requests. A dedicated bounded executor is also valid, but it adds lifecycle and shutdown handling. The semaphore is the smaller change.

My assessment of the requested decisions:

  1. Keep the fix in the async layer. This is the correct scope. Using _get_all_files_cached() changes freshness behavior. Adding pagination changes the response contract. Neither change is required to stop event-loop blocking.

  2. The new learning-log responsiveness test is sound. It records ticks before the worker completes. The reverted inline implementation cannot satisfy that condition. The anti-vacuity assertions also ensure that the service call ran and returned its payload.

    TestListVideosOffloading.test_event_loop_stays_responsive_while_scan_is_in_flight still has the timeout-based vacuity defect. Do not mix that unrelated test repair into this PR. Create a follow-up issue now, because the existing test does not prove the protection it claims to prove.

  3. Keep the one-hop assertion. It protects a real property here. get_learning_log() is one synchronous operation. A second hop adds overhead without a semantic benefit. If a future refactor creates separate independently useful operations, that refactor can revise the assertion.

  4. Executor pressure requires action in this PR. Offloading is still substantially better than blocking the event loop. However, unbounded concurrent filesystem walks on the shared executor create a new saturation path. The current global rate limit does not provide a sufficient bound for this expensive endpoint.

I found no whitespace errors in the PR diff.

You are interacting with an AI system.

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 the learning-log filesystem scan to a worker thread, preventing it from blocking FastAPI’s event loop.

Changes:

  • Runs get_learning_log() through asyncio.to_thread.
  • Adds regression tests for thread offloading, loop responsiveness, hop count, and error handling.

Reviewed changes

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

File Description
src/youtube_extension/backend/api/v1/router.py Offloads blocking learning-log work.
tests/unit/test_v1_router_extended.py Adds focused concurrency and contract tests.

@groupthinking groupthinking left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Review (comment — GitHub blocks self-approval on this account)

Reviewed the full diff at head 82dbcce. Focused, correct, and well-tested; no blocking findings — LGTM.

Change

  • get_learning_log_v1 now dispatches the blocking walk via await asyncio.to_thread(data_service.get_learning_log) instead of calling it inline. get_learning_log issues its own uncached rglob plus a metadata read/stat per entry, so this is unbounded blocking I/O that previously pinned the event loop for the whole process. Moving it to a worker thread is the right fix and matches the already-landed offloads in #1237 and #1382.
  • asyncio is already imported/used in this module (the _collect_videos_page hop), so no new import; the source change is one line plus an explanatory comment.

Correctness

  • Error contract unchanged: an exception in the worker propagates through await into the existing except Exception → 500. test_error_contract_is_unchanged pins this.
  • Payload unchanged: same object from the same function, just returned off-thread.
  • No new shared mutable state, so no data race introduced.

Tests — genuinely non-vacuous:

  • Every test asserts the real payload before asserting thread identity / hop count, so a change that skipped the call couldn't pass trivially.
  • test_event_loop_stays_responsive_while_walk_is_in_flight compares tick timestamps against actual walk-completion rather than merely counting ticks — the failure mode a naive await fix would slip past.
  • test_walk_uses_exactly_one_to_thread_hop captures real_to_thread before patching, so no recursion, and pins the hop count.
  • The PR body documents the NC-1/2/3 negative-control ladder and a caught vacuous-test fix — good rigor.

Scope — correctly narrow. Routing through _get_all_files_cached, adding limit/offset, and caching parsed metadata are deferred as separate changes; each would alter behavior or the public contract, so keeping them out keeps this reviewable.

Required CI is green on this head. mergeable_state is unstable only from non-required checks (Trivy neutral, coverage in-flight). This is ready to merge on your sign-off — I don't auto-merge to protected main.


Generated by Claude Code

Offloading the learning-log walk to a worker thread stopped it pinning the
event loop, but it left a second problem in place: the walk is uncached, so
every concurrent request starts its own. The endpoint has no endpoint-specific
rate limit, and the default per-client limit permits bursts, so a burst could
occupy every worker in the shared default executor and starve unrelated
asyncio.to_thread callers.

Gate the dispatch on a semaphore capped at 4 in-flight walks. Requests over the
cap wait on the event loop holding no worker thread, so the endpoint degrades by
queueing instead of by monopolising the executor.

asyncio.Semaphore pins itself to the first event loop that awaits it and raises
RuntimeError if reused from another, so a single module-level instance would
break any process that runs more than one loop. The gate is therefore created
per running loop and held in a WeakKeyDictionary keyed by that loop, guarded by
a threading.Lock because weakref callbacks can run arbitrary Python and the
dictionary is not thread-safe.

Adds three tests: the cap holds with more callers than the limit, the gate is
rebuilt per event loop, and it is shared within one loop. Negative controls
confirm each detects a distinct defect - removing the gate yields 7 concurrent
walks against a cap of 4, a fresh gate per call fails both the cap and sharing
tests, and a single module-level gate fails the per-loop rebuild test.

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

Copy link
Copy Markdown
Owner Author

@coderabbitai Thank you — the blocking finding was correct and I have implemented it in 0975d7935. Answering your four judgement-call responses and then documenting the fix and its proof.

Blocking finding: bound concurrent learning-log walks — accepted and fixed

Your diagnosis holds. Offloading converts a hard event-loop pin into a shared-resource monopolisation path: asyncio.to_thread dispatches to the loop's default executor, which every other to_thread and run_in_executor(None, …) caller in the process also shares. Because DataService.get_learning_log issues its own rglob and does not route through _get_all_files_cached, every concurrent request starts an independent walk. With a burst-permitting 60/minute default limit and no endpoint-specific limit, a burst could occupy every worker and delay unrelated executor-backed requests. That is a genuine regression in blast radius, not a theoretical one.

I took the semaphore rather than a dedicated bounded executor, for the reason you gave: no lifecycle or shutdown handling to get wrong.

async with _get_learning_log_gate():
    learning_log = await asyncio.to_thread(data_service.get_learning_log)

Cap is 4. Requests over the cap wait on the event loop holding no worker thread, so the endpoint degrades by queueing instead of by monopolising the executor. Exactly one hop remains inside the gate, per your point 3.

One subtlety worth flagging, because the obvious implementation is broken

A plain module-level asyncio.Semaphore(4) does not work here. Since 3.10, _LoopBoundMixin._get_loop() binds the primitive to the first loop that awaits it and thereafter raises RuntimeError: <Semaphore …> is bound to a different event loop. Any process that runs more than one loop over its lifetime hits this — including this test suite, where each test calls asyncio.run() and gets a fresh loop. A naive singleton would have passed the first test and exploded on the second.

The gate is therefore created per running loop and held weakly:

_LEARNING_LOG_MAX_CONCURRENCY = 4
# Maps a running event loop -> the ``asyncio.Semaphore`` bound to that loop.
_learning_log_gates: weakref.WeakKeyDictionary = weakref.WeakKeyDictionary()
_learning_log_gates_lock = threading.Lock()


def _get_learning_log_gate() -> asyncio.Semaphore:
    """Return the learning-log concurrency gate bound to the running loop."""
    loop = asyncio.get_running_loop()
    with _learning_log_gates_lock:
        gate = _learning_log_gates.get(loop)
        if gate is None:
            gate = asyncio.Semaphore(_LEARNING_LOG_MAX_CONCURRENCY)
            _learning_log_gates[loop] = gate
        return gate

WeakKeyDictionary is not thread-safe — weakref finaliser callbacks can run arbitrary Python between bytecodes — and TestClient drives a loop on its own thread, so the plain threading.Lock is load-bearing rather than decorative. I skipped double-checked locking: one uncontended acquire is ~50 ns against a filesystem walk. Entries are dropped automatically when their loop is collected, so this cannot leak across a long-lived process.

Proof the cap is real, not decorative

Three tests added to TestLearningLogOffloading, and I ran a negative-control ladder so that each is shown to detect a distinct defect rather than passing by construction:

mutation applied to the source expected to break observed
NC-4 — delete async with, keep to_thread (i.e. exactly the state you flagged) cap test only 1 failedassert 7 <= 4
NC-5 — return a fresh Semaphore per call cap test + shared-gate test 2 failed
NC-6 — one module-level gate, not keyed by loop per-loop rebuild test only 1 failed

NC-4 is the direct answer to your finding: with the gate removed, all 7 callers (limit + 3) walk simultaneously and the assertion reports the true unbounded count. The cap test asserts peak == limit, not peak <= limit, so it fails in both directions — an absent bound and an over-restrictive one that quietly serialises the endpoint.

The cap test also polls until the first wave saturates, then deliberately sleeps 0.25 s before sampling the peak, so unbounded overflow has time to reveal itself rather than being masked by a race. It carries anti-vacuity assertions on payload equality and call_count == callers.

Your other four points

  1. Scope — agreed, no change. _get_all_files_cached() would alter freshness semantics and pagination would alter the response contract. Neither is required to stop the blocking, and both would smuggle a behavioural change into a perf PR.
  2. The vacuous sibling test — followed exactly. I did not touch TestListVideosOffloading in this PR. Filed as test: TestListVideosOffloading responsiveness test passes against an inline implementation #1390, with the inline-implementation trace showing why the bounded release.wait(timeout=2.0) makes len(ticks) >= 3 unfalsifiable, plus the timing-relative repair and acceptance criteria requiring a negative control. Worth noting the same defect existed in this PR's responsiveness test when I first wrote it — repairing it flipped the NC-1 control from 2 failed to 3 failed, which is what proved the weakness was real.
  3. One-hop assertion — kept.
  4. Executor pressure — actioned in this PR, as above.

Verification after the change

check result
TestLearningLogOffloading 7 passed (4 → 7)
tests/unit/test_v1_router_extended.py 132 passed (129 → 132)
Full suite 4 failed, 8275 passed, 18 skipped, 5 xpassed, 89 subtests — the 4 failures are the known pre-existing set on main (3 live-Gemini tests in test_code_generator.py, 1 .env-dependent SDK test), unchanged by this diff
ruff check (CI scope + test file) identical to the stashed baseline — 3 findings, all pre-existing
ruff format --diff 12590 lines, exactly equal to baseline — zero formatting drift
python -m compileall -q src/ clean

Re-review welcome, particularly on the cap value of 4 and on whether the lock should instead be an asyncio.Lock — I chose threading.Lock deliberately because the dictionary is touched from TestClient's loop thread as well as the main one, and an asyncio.Lock would itself be loop-bound and so reintroduce the exact problem it was guarding.

@coderabbitai

coderabbitai Bot commented Aug 5, 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 '=== Focused diff after concurrency-gate commit ==='
git diff --unified=100 origin/main...HEAD -- \
  src/youtube_extension/backend/api/v1/router.py \
  tests/unit/test_v1_router_extended.py

echo '=== Router structure ==='
ast-grep outline src/youtube_extension/backend/api/v1/router.py --items all --type function --match 'get_learning_log|learning_log|gate' || true

echo '=== Router learning-log implementation and imports ==='
rg -n -C 12 \
  'LEARNING_LOG|_get_learning_log_gate|get_learning_log_v1|WeakKeyDictionary|threading\.Lock|asyncio\.Semaphore|asyncio\.to_thread' \
  src/youtube_extension/backend/api/v1/router.py

echo '=== Learning-log offloading tests ==='
rg -n -C 10 \
  'class TestLearningLogOffloading\b|test_.*(concurr|gate|loop|worker|hop|error)|_LEARNING_LOG_MAX_CONCURRENCY|_learning_log_gates' \
  tests/unit/test_v1_router_extended.py

echo '=== Event-loop and worker deployment configuration ==='
rg -n -i -C 4 \
  'uvicorn|gunicorn|workers\s*=|--workers|reload|ThreadPoolExecutor|set_default_executor|asyncio\.run\(' \
  --glob '*.py' --glob 'pyproject.toml' --glob 'Dockerfile*' --glob 'docker-compose*.y*ml' \
  . || true

echo '=== Diff hygiene ==='
git diff --check origin/main...HEAD
git status --short

Length of output: 37300


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '=== Locate router tests and deployment files ==='
fd -t f -a . | rg -i '(^|/)(test_v1_router_extended\.py|.*router.*test.*\.py|Dockerfile.*|.*compose.*\.ya?ml|pyproject\.toml)$' | sed -n '1,240p'

echo '=== Test source from HEAD ==='
git show HEAD:tests/unit/test_v1_router_extended.py 2>/dev/null \
  | sed -n '2580,2820p' \
  || true

echo '=== All tracked server/deployment configuration references ==='
git grep -n -i -E \
  'uvicorn|gunicorn|--workers|workers[[:space:]]*=|loop[[:space:]]*=|set_default_executor|ThreadPoolExecutor|run_in_executor' \
  HEAD -- \
  ':*.py' ':pyproject.toml' ':Dockerfile*' ':*compose*.yml' ':*compose*.yaml' \
  || true

echo '=== Relevant router imports and Python support declaration ==='
git show HEAD:src/youtube_extension/backend/api/v1/router.py | sed -n '1,35p'
git show HEAD:pyproject.toml 2>/dev/null | rg -n -C 3 'requires-python|python' || true

echo '=== Current diff validation ==='
git diff --check origin/main...HEAD

Length of output: 30253


@groupthinking The concurrency-gate fix addresses the blocking finding. I found no new blocking defect.

threading.Lock is the correct lock. _learning_log_gates is process-shared and can be accessed from different threads. An asyncio.Lock is loop-bound and cannot safely protect this dictionary across loops. The synchronous lock is held only for a dictionary lookup or insertion. It does not cover the filesystem walk.

The cap of 4 is reasonable for the current deployment. Dockerfile starts Uvicorn without --workers, so each process serves requests on one event loop. The per-loop semaphore therefore enforces the intended per-process limit. No custom default executor configuration exists. A cap of four prevents this endpoint from consuming the full default executor on small instances and leaves worker capacity for unrelated asyncio.to_thread and run_in_executor(None, ...) work.

Keep _LEARNING_LOG_MAX_CONCURRENCY as a named constant. Move it to configuration only if deployment capacity differs by environment or metrics show sustained queueing.

One documentation correction is needed. asyncio.Semaphore does not bind on every first acquisition. It binds when an acquisition must wait and _LoopBoundMixin._get_loop() runs. The per-loop design is still correct because a shared semaphore can become bound during contention and then fail on a different loop. Update the comments and test_gate_is_rebuilt_for_each_event_loop docstring to describe contention-triggered binding accurately.

The concurrency tests are otherwise well targeted. The cap test proves both the upper bound and that the gate does not serialize all requests. The per-loop and same-loop tests protect the intended gate lifecycle. The follow-up in #1390 keeps the unrelated test repair out of this PR.

🐇✅

You are interacting with an AI system.

…test

CodeRabbit's second-round review flagged that the comment above
`_LEARNING_LOG_MAX_CONCURRENCY` mis-stated when an `asyncio.Semaphore`
binds to an event loop. The claim was that it binds on first acquisition.
That is wrong, and verifying it changed how the fix should be described.

`Semaphore.acquire` in CPython 3.12:

    if not self.locked():
        self._value -= 1
        return True                            # returns before _get_loop()
    fut = self._get_loop().create_future()      # only the waiting path binds

The uncontended path never touches the loop. A module-level singleton is
therefore not an obvious bug that any test would catch -- it is a latent
landmine. It works across any number of loops until the first acquisition
that genuinely has to wait; that one pins it, and every later use from a
different loop raises `RuntimeError: ... is bound to a different event loop`.

The failure cannot surface in low-concurrency tests. It waits for exactly
the burst this gate exists to absorb. That makes the per-loop
`WeakKeyDictionary` design more justified, not less.

Verified from CPython source via `inspect.getsource`, then reproduced:
uncontended acquires on two different loops leave `_loop` as `None` with no
error; five waiters against a cap of four pin it; a fresh loop then raises.

Changes:

- Rewrite the comment block to describe contention-triggered binding and
  the latent-landmine framing, replacing the incorrect first-use claim.
- Correct the `test_gate_is_rebuilt_for_each_event_loop` docstring.
- Add `test_gate_survives_a_contended_loop_then_a_fresh_loop`, which turns
  the proxy identity assertion into a proof that the production failure
  mode is prevented: it saturates the gate with `limit + 2` callers so at
  least one is a real waiter (the only path that pins the semaphore), then
  drives the endpoint on a brand-new loop and fails with a descriptive
  message if `RuntimeError` escapes.

Negative control NC-7 (replace the per-loop accessor with a module-level
singleton) fails the new test with the exact production `RuntimeError`,
confirming it discriminates rather than merely passing.

Focused class 8 passed; whole file 133 passed (was 132).
`ruff check` findings identical to baseline (26, differing only by a
+7 line shift); `ruff format --diff` neutral at 189 lines, same as baseline.

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

Copy link
Copy Markdown
Owner Author

Independent review — red-team of the diff on top of CodeRabbit's review

CodeRabbit ran twice on this branch. Round 1 raised one blocking finding (unbounded concurrent walks); round 2 confirmed the gate fixed it and raised one documentation correction. Both are addressed. Rather than restate that, this is the adversarial pass I ran against my own diff, including three checks CodeRabbit did not and could not perform — it does not execute the test suite.

1. The documentation correction was right, and I was wrong in a way that mattered

CodeRabbit's round-2 finding was that my comment mis-stated when asyncio.Semaphore binds to an event loop. I claimed first acquisition. I did not take that on trust — I read the CPython 3.12 source:

# asyncio/locks.py, Semaphore.acquire
if not self.locked():
    self._value -= 1
    return True                              # returns before _get_loop()
fut = self._get_loop().create_future()        # only the waiting path binds

Then I reproduced it in three phases:

Phase Action Observed
1 uncontended acquire/release under asyncio.run() loop A sem._loop is None, no error
2 same under a different loop B sem._loop is None, still no error
3 5 concurrent holders against cap 4 on loop B, then reuse on fresh loop C _loop pinned to B; loop C raises RuntimeError: … is bound to a different event loop

CodeRabbit was correct, and the real behaviour is worse than what I had written. A module-level singleton is not an obvious bug — it is a latent landmine. It passes every low-concurrency test and works indefinitely, then detonates on the first genuinely contended acquisition, which is precisely the burst this gate exists to absorb. This makes the per-loop WeakKeyDictionary design more justified, not less, and I have rewritten the comment and the affected docstring accordingly.

2. It also exposed a real weakness in my own test

The correction has a direct consequence I had to act on: if binding requires contention, then test_gate_is_rebuilt_for_each_event_loop cannot catch a singleton via RuntimeError — nothing in a quiet test makes an acquisition wait. It was only ever asserting object identity, which is a proxy for the failure, not the failure.

So I added test_gate_survives_a_contended_loop_then_a_fresh_loop, which manufactures the missing precondition: it saturates the gate with limit + 2 callers (polling to confirm saturation, not sleeping and hoping), so at least two callers are real waiters, then releases, then drives the endpoint on a brand-new asyncio.run() loop and converts any escaping RuntimeError into a descriptive assertion failure.

3. NC-7 — the control that proves the point

I re-ran the negative-control ladder with a seventh rung: replace _get_learning_log_gate() with exactly the design CodeRabbit's correction describes — a true module-level asyncio.Semaphore singleton.

2 failed, 6 passed
FAILED … ::test_gate_is_rebuilt_for_each_event_loop
FAILED … ::test_gate_survives_a_contended_loop_then_a_fresh_loop
    RuntimeError: <asyncio.locks.Semaphore object at 0x…> is bound to a different event loop
    raised from router.py:1048, in `async with _get_learning_log_gate()`

The new test fails with the exact production error, raised from the production line — not a proxy assertion. That is the difference between testing the implementation and testing the failure mode. Restored from backup afterwards and confirmed byte-identical with diff -q.

4. Verified on the pushed head, in an isolated tree

Everything above ran in my working copy, which is not proof about what is actually on the branch. So I checked out the pushed SHA into a detached worktree and re-ran there:

git worktree add /tmp/wt1388 4c2c6674a42b37a73f615b94a864e8ddb1084385 -f --detach
Step Result
GREEN — pushed head as-is 133 passed
RED — git checkout origin/main -- src/…/api/v1/router.py 7 failed, 1 passed
Restore, git status --short empty
GREEN again 133 passed

The RED run is worth reading closely: 7 of the 8 tests fail and one passes. The one that passes is test_error_contract_is_unchanged, and it should pass — reverting the offload does not change the 500 contract. A control where everything fails would mean the tests are coupled to the diff rather than to the behaviour. This one discriminates.

5. Full suite

Run against the exact pushed SHA 4c2c6674a:

4 failed, 8279 passed, 18 skipped, 5 xpassed, 89 subtests passed in 705.35s (0:11:45)

The 8279 is the predicted number, not an observed one I rationalised afterwards: the previous commit 0975d7935 reported 8278 passed, and this commit adds exactly one test. Predicting the count before running is a cheap way to catch a test that silently failed to collect.

The four are pre-existing on clean main and documented in the PR body: three in tests/test_code_generator.py make live gemini-2.5-flash calls, and one in tests/test_sdk_python.py is an environment artefact — main.py:57-61 runs load_dotenv at import time, a local untracked .env supplies EVENTRELAY_API_KEY, and the SDK falls back to it, so the header it asserts absent is present. I identified it by toggling only that variable on clean main. The hermetic fix (monkeypatch.delenv) is a separate concern and does not belong in a perf PR.

6. Lint parity, measured against a stashed baseline

Absolute lint counts are meaningless; only the contrast is. Over the two files this PR touches:

Check Branch Stashed baseline Delta
ruff check findings 26 26 none — diff shows only a uniform +7 line shift from the longer comment block, every finding the same rule at the same site
ruff format --diff lines 189 189 exactly zero
python -m compileall -q src/ clean

The format delta was +4 before I collapsed an asyncio.create_task(…) call inside a comprehension onto one line (87 chars, under the 88 limit). CI does not run ruff format and lint-python is continue-on-error: true, so this is cosmetic — but I drive it to zero so the signal stays usable for the next person.

7. What I looked for and did not find

  • Leak in the gate registryWeakKeyDictionary keyed by the loop object; entries drop when the loop is collected. Nothing accumulates in a long-lived process.
  • Wrong lock type — the guard must be threading.Lock, not asyncio.Lock. An asyncio.Lock is itself loop-bound and could not protect a structure shared across loops. TestClient runs a loop on its own thread, so cross-thread access is real, not hypothetical.
  • Executor starvation from the capasyncio.to_thread uses the default executor, sized min(32, cpu_count + 4), so minimum 5. A cap of 4 is always reachable even on a single-CPU runner and always leaves at least one worker for unrelated callers.
  • Over-serialisation — the cap test asserts peak == limit, not peak <= limit, so it fails in both directions. NC-4 showed the unbounded case reports assert 7 <= 4; a hypothetical peak of 1 would fail too.
  • Silent hop drifttest_walk_uses_exactly_one_to_thread_hop pins the dispatch count. NC-2 (double-dispatch) fails only that test, confirming it is specific rather than incidentally coupled.
  • Behaviour changedata_service.py is untouched. This PR changes where existing work runs, nothing else.

8. Deliberately not fixed here

Conclusion

One blocking finding, fixed and proven by NC-4. One documentation correction, verified from source, reproduced empirically, and proven by NC-7 — which also drove a genuine strengthening of the test suite rather than just a comment edit. Seven negative controls, all discriminating. RED→GREEN on the pushed SHA in an isolated worktree. Full suite clean apart from four documented pre-existing failures. Lint exactly neutral.

@linear-code has not responded across six PRs now; proceeding without it, consistent with prior merges.

@groupthinking
groupthinking merged commit 4338e83 into main Aug 5, 2026
41 checks passed
@groupthinking
groupthinking deleted the perf/learning-log-offload-1386 branch August 5, 2026 02:49
@linear-code

linear-code Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

GRV-324

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: /api/v1/learning-log blocks the event loop on an unbounded uncached filesystem walk

2 participants