Skip to content

test(opal-server): git leak/resilience test environment (PR1)#922

Merged
Zivxx merged 19 commits into
masterfrom
david/per-15155-pr1-git-leakresilience-test-environment-one-big-pr
Jul 12, 2026
Merged

test(opal-server): git leak/resilience test environment (PR1)#922
Zivxx merged 19 commits into
masterfrom
david/per-15155-pr1-git-leakresilience-test-environment-one-big-pr

Conversation

@dshoen619

@dshoen619 dshoen619 commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

PR1 — Git Leak/Resilience Test Environment

Builds the test bed and the single diagnostics hook needed to exercise the git-fetcher memory leak, the offline-repo hang, the slow serial boot, and the broadcaster-disconnect path. The leak/offline tests fail on master and become the regression gates for the follow-up fix PRs; the boot and bounce tests are tunable/recovery guards (see below).

This is intentionally one large PR — it's the foundation every later PR's gate depends on. The actual fixes land in the PRs this one blocks:

Linear: PER-15155

opal-server: an off-by-default stats endpoint

  • opal_server/debug_stats.py — read-only git_fetcher_cache_stats() (sizes of the three process-global GitPolicyFetcher caches + process RSS) and a register_internal_stats_route() registrar.
  • opal_server/config.py — new OPAL_DEBUG_INTERNAL_STATS flag, default False.
  • opal_server/server.py — mounts GET /internal/git-fetcher-cache-stats beside /healthcheck, gated by the flag.
  • Unit tests for the helper, the flag default, and the gating.

No production behavior change when the flag is off (the default).

app-tests/git-leak/: a self-contained stack + pytest harness

The five flagship tests, and their baseline behavior on master

# Test On master Gate
1 test_churn_releases_caches FAILS (caches never drain) PR2
2 test_repeat_sync_rss_stays_bounded PASSES (RSS guard; the URL-keyed cache count can't grow for any impl) RSS guard
3 test_boot_loads_all_scopes passes; FAILS with BOOT_TARGET_SECONDS low PR4
4 test_offline_repo_does_not_block_healthy_scopes FAILS (no fetch timeout) PR3
5 test_server_recovers_after_postgres_bounce PASSES (2-worker; worker-PID-unchanged across bounce = in-place reconnect) PER-15065

Test #5 — verified (2-worker, PID guard). Runs 2 workers (OPAL_TEST_WORKERS) so the Postgres backbone is actually fanned out cross-worker (debug-pubsub.md §3-4); across a transient bounce it asserts the gunicorn worker PIDs are unchanged — the in-place-reconnect signal that distinguishes #915 (PER-15065) from a plain gunicorn respawn — plus a servable post-bounce scope (GET /scopes/{id}/policy == 200, not /internal counts, which are per-process on 2 workers). Re-validated live on this branch: passes (~54s). Updated per the gate-coverage review — see the latest reply for the full fail/pass-for-the-right-reason matrix.

How to run

cd app-tests/git-leak
python -m pytest -v --boot-scopes=50              # full set
python -m pytest test_leak.py -v --boot-scopes=20 # just the leak gates

Requires Docker + compose v2 and host Python with pytest pytest-timeout requests GitPython.

Verification done

  • All 4 opal-server unit tests pass inside the repo's Docker server image; import opal_server.server clean; flag defaults False with a description.
  • docker compose config -q valid; all 5 flagship tests collect.
  • Full-stack smoke: stack boots, Gitea seeds repos, opal healthy, /internal/git-fetcher-cache-stats live, PUT /scopes returns 201 with auth disabled.
  • GiteaAdmin exercised live (list/exists/create/delete over the published port); test_server_recovers_after_postgres_bounce run to a green pass.

Notes for reviewers

  • Auth disabled deliberately: OPAL_AUTH_PUBLIC_KEY left unset → JWT verifier disabled so the harness can drive scope routes without minting JWTs (require_peer_type becomes a no-op). Test bed only.
  • Scope create body sets auth: {auth_type: "none"} (required pydantic-v1 discriminated union).
  • Host ports: opal_server:7002 and gitea:13000 (uncommon, to avoid the :3000 clash; used by GiteaAdmin); Postgres is internal-only.

Cache-read determinism (how the gates stay trustworthy)

Two properties of master would make naive cache reads non-deterministic. Both are handled in this harness — they are not open issues:

  1. Per-worker caches + round-robin reads. The GitPolicyFetcher caches are per-process, so with >1 worker a round-robin /internal read can miss the worker that fetched and report 0. Resolved: the stack runs a single uvicorn worker (UVICORN_NUM_WORKERS=1), so every cache read is deterministic.
  2. repos populates on the second sync, not the first. The first-sync clone path fills only repo_locks; repos / repos_last_fetched are filled by the discover/fetch path on a subsequent sync. Resolved: the load helpers issue refresh_all() and wait before asserting on repos, so the gates assert against a cache the sync path has actually filled.

These were called out as caveats in the original design doc; the implementation above closes both, so the leak/drain assertions are deterministic single-worker.

🤖 Generated with Claude Code

dshoen619 and others added 2 commits June 23, 2026 12:24
Add an off-by-default diagnostics endpoint so tests can observe the
in-memory GitPolicyFetcher cache sizes (repo_locks/repos/repos_last_fetched)
and process RSS that the upcoming memory-leak fix eliminates.

- debug_stats.py: read-only git_fetcher_cache_stats() helper + a
  register_internal_stats_route() registrar that mounts
  GET /internal/git-fetcher-cache-stats only when enabled.
- config.py: new OPAL_DEBUG_INTERNAL_STATS flag, default False.
- server.py: register the route, gated by the flag, beside /healthcheck.

No production behavior change when the flag is off (the default). Also
ignore .claude/ so private planning artifacts are never committed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A self-contained docker-compose stack (opal-server x2 workers + Redis +
Postgres broadcaster + Gitea) plus a pytest harness that reproduces, as
tests that fail on master, the git-fetcher memory leak, the offline-repo
hang, the slow serial boot, and the broadcaster-disconnect gap. These
become the regression gates for the follow-up fixes.

- seed/: idempotent Gitea seeding sidecar (N policy repos) + Dockerfile.
- docker-compose.yml: 4-service stack, opal-server built from the repo's
  own docker/Dockerfile (server target), scopes on, Postgres broadcaster.
- helpers.py / conftest.py: HTTP + infra helpers and stack fixtures.
- test_leak.py / test_resilience.py / test_boot.py: the flagship tests.
- README.md: how to run and expected fail-on-master behavior.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@linear-code

linear-code Bot commented Jun 23, 2026

Copy link
Copy Markdown

PER-15155

@netlify

netlify Bot commented Jun 23, 2026

Copy link
Copy Markdown

Deploy Preview for opal-docs ready!

Name Link
🔨 Latest commit 9b61027
🔍 Latest deploy log https://app.netlify.com/projects/opal-docs/deploys/6a5383a26d07050008ddf2bd
😎 Deploy Preview https://deploy-preview-922--opal-docs.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

dshoen619 and others added 3 commits June 23, 2026 12:37
Complete the helpers.py surface promised in the plan's file-structure
table. Both are now functional and used, not dead code:

- make_repo_unreachable(name): returns a git URL on a routable-but-dead
  TEST-NET-1 host (RFC 5737). test_offline_repo now uses it instead of an
  inlined literal.
- GiteaAdmin: host-side Gitea admin client (list_repos / repo_exists /
  create_repo / delete_repo), exposed as the `gitea_admin` pytest fixture
  for tests that need to inspect or stage repos beyond the seed sidecar.

Gitea is published on host port 13000 (uncommon, to avoid the usual :3000
clash) so GiteaAdmin can reach it; opal_server and the seed sidecar still
use the internal http://gitea:3000. README updated with the helper and
port notes.

Verified live: GiteaAdmin lists the seeded repos and round-trips
create/exists/delete against Gitea over the published port.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Verified test_server_recovers_after_postgres_bounce against the stack: it
PASSES on master (~14-19s). On a broadcaster drop the affected worker
triggers a graceful shutdown, gunicorn respawns it, and the sibling worker
keeps serving HTTP, so the surface recovers within the window — recovery
happens via gunicorn's in-container worker supervision, not an external
supervisor and not an in-process reconnect.

Reframe #5 as a recovery guard (not a known-broken case) in the docstring
and README; the prior "FAILS on master / needs external supervisor" wording
was wrong. PER-15065's in-process reconnect would avoid the worker churn but
recovery already holds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Run the repo's pinned pre-commit formatters (black 23.1.0, isort 5.12.0,
docformatter 1.7.5) over the PR1 files to satisfy the pre-commit CI check.
Formatting only — no behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@dshoen619
dshoen619 marked this pull request as ready for review June 23, 2026 10:05
@dshoen619
dshoen619 requested a review from Copilot June 23, 2026 10:05
@dshoen619 dshoen619 self-assigned this Jun 23, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Adds an off-by-default OPAL server internal diagnostics endpoint for git-fetcher cache/RSS stats, and introduces a docker-compose-based “git leak/resilience” integration testbed intended to reproduce/regress several production issues (leak, offline repo hang, boot slowness, broadcaster disconnect recovery).

Changes:

  • Add /internal/git-fetcher-cache-stats endpoint gated by OPAL_DEBUG_INTERNAL_STATS (default off), plus unit tests.
  • Add app-tests/git-leak/ docker-compose stack (opal-server + redis/postgres/gitea) and pytest harness (boot/leak/resilience tests).
  • Add .claude/ to .gitignore.

Reviewed changes

Copilot reviewed 14 out of 15 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
packages/opal-server/opal_server/debug_stats.py New helper to read git-fetcher cache sizes + RSS and register a gated internal route.
packages/opal-server/opal_server/config.py Adds DEBUG_INTERNAL_STATS config flag (default False).
packages/opal-server/opal_server/server.py Mounts the internal stats route when enabled.
packages/opal-server/opal_server/tests/debug_stats_test.py Unit tests for stats dict sizing + flag default.
packages/opal-server/opal_server/tests/debug_stats_endpoint_test.py Unit tests for endpoint presence/absence when gated.
app-tests/git-leak/docker-compose.yml Compose stack for OPAL + dependencies + seeding sidecars.
app-tests/git-leak/helpers.py Host-side HTTP/compose helpers for the test harness.
app-tests/git-leak/conftest.py Pytest fixtures to boot/teardown the stack and provide clients.
app-tests/git-leak/test_leak.py Leak regression tests (churn + repeat sync).
app-tests/git-leak/test_resilience.py Offline-repo hang test + Postgres bounce recovery guard.
app-tests/git-leak/test_boot.py Boot timing/baseline test.
app-tests/git-leak/seed/seed_gitea.py Idempotent Gitea seeding script for N repos.
app-tests/git-leak/seed/Dockerfile Container image for the seeding sidecar.
app-tests/git-leak/README.md Documentation for running the new testbed locally.
.gitignore Ignores .claude/ working artifacts.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread app-tests/git-leak/conftest.py
Comment thread app-tests/git-leak/helpers.py Outdated
Comment thread app-tests/git-leak/helpers.py Outdated
Comment thread app-tests/git-leak/test_leak.py Outdated
Comment thread app-tests/git-leak/test_leak.py Outdated
dshoen619 and others added 3 commits June 23, 2026 13:13
The CI `build` job runs `pytest` from the repo root with no path, which
recursed into app-tests/git-leak/ and ran the flagship tests — these are
designed to FAIL on master (they are the regression gates for PR2-PR5), so
they broke the build job.

Set `testpaths = packages` so the rootdir run collects only the unit tests
under packages/ (matching master's effective behavior, since app-tests/ had
no pytest files before). testpaths only applies when pytest is invoked from
the rootdir with no args, so `cd app-tests/git-leak && pytest` still collects
and runs the test bed. Verified both: root run -> packages only; subdir run
-> all 5 flagship tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- stats(): sample the /internal endpoint several times and merge per key
  with max(). The caches are per-process on a multi-worker server, so a
  single read can hit an empty (non-leader) worker — max-merge avoids both
  false negatives (missing a populated leader) and false positives (an
  `== 0` drain assertion passing only because it hit an empty worker).
- test_leak: assert the initial-load `_wait_until` succeeded before deleting
  / before taking baseline, so the tests can't pass vacuously when load
  never completed.
- refresh_all(): correct the misleading comment — a 404 is a no-op, there
  is no client-side fallback.
- conftest: skip the suite cleanly if docker is unavailable (defense in
  depth; it's already excluded from the default pytest run via testpaths).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two review findings on the regression gates:

- Cross-test contamination: clone paths are keyed by repo URL (source_id =
  sha256(url)+branch-shard), not scope_id, so scopes from different tests that
  point at the same seeded repo share one GitPolicyFetcher cache entry. With a
  session-scoped stack and no teardown, a leftover boot-*/stable-* scope kept
  those entries alive and would make test_churn's `repos == 0` drain assertion
  fail on fixed code. OpalServerClient now tracks created scopes and the opal
  fixture deletes them on teardown (best-effort drain wait, swallows errors so
  master — where delete never purges — doesn't fail the passing test).

- False gate: test_repeat_sync_does_not_grow re-syncs identical scopes, which a
  path-keyed cache can't grow even on master, so it could never be the leak gate
  it claimed. Reframed as an honest idempotency guard (passes on master) that
  points at test_churn_releases_caches as the real leak gate; README's
  "Expected on master" reclassifies it alongside the postgres-bounce guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@dshoen619
dshoen619 requested review from Zivxx and zeevmoney June 23, 2026 10:56
@dshoen619
dshoen619 marked this pull request as draft June 23, 2026 11:37
@dshoen619
dshoen619 marked this pull request as ready for review June 23, 2026 11:49

@zeevmoney zeevmoney 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.

Requesting changes — the regression gates do not function as gates yet

Scope check first, because it's the reassuring part: the production surface is safe. The only always-on change is a benign import plus a default-off OPAL_DEBUG_INTERNAL_STATS config key; with the flag off (the default) there is zero behavior change, and when on the handler can't crash (the class dicts always exist, len() is GIL-atomic against the event-loop mutations, and _read_rss_kb swallows errors). testpaths = packages drops no existing tests. Git hygiene is clean (nothing private leaked into the diff, no rebase needed). This PR will not crash production.

The problem is the actual deliverable — the five flagship tests meant to gate PR2–PR5.

What's blocking (must fix before this can be trusted as the gate)

  1. The load/drain gates assert on GitPolicyFetcher.repos, which the first-sync clone path never populates (see the CRITICAL inline comment on test_leak.py:26). On a fresh scope, fetch_and_notify_on_changes takes the _clone() branch, which only sets repo_locksrepos is filled solely by _get_repo() on a second sync or the bundle path, and periodic re-sync is disabled in the stack (OPAL_POLICY_REFRESH_INTERVAL: "0"). As a result test_churn_releases_caches, test_repeat_sync_does_not_grow, and test_offline_repo_does_not_block_healthy_scopes hang at their _wait_until(repos >= n) load gate for the full timeout and then fail at the load stage — they never reach the leak/offline logic they exist to test, and they would not flip green after PR2/PR3 land. Only test_boot_loads_all_scopes works, and only incidentally (compose restartpreload_scopes re-discovers the on-disk clones). This is the same root cause the PR description flags as "Known caveat #2" — it needs to be fixed, not just noted.

  2. Even once load is fixed, the cache assertions are not reliable on a 2-worker stack. stats()'s max-merge cannot prevent a == 0 drain assertion from falsely passing when the samples miss the leader worker (HIGH on helpers.py:50). The cache tests should run single-worker, or the endpoint should aggregate across workers.

  3. Failure modes are invisible. compose() swallows stdout/stderr on any compose failure (HIGH on helpers.py:218), and partial seeding is never detected before the suite runs (HIGH on conftest.py:40) — so a broken stack or a half-seeded Gitea looks like a test failure for the wrong reason.

Secondary correctness (should fix)

  • test_repeat_sync_does_not_grow is tautological — len(repos) can't grow on repeat URL-keyed sync (test_leak.py:64).
  • The offline test's TEST-NET-1 address likely fails fast instead of hanging, so it may not exercise starvation (helpers.py:205).
  • The opal fixture clears _created_scopes at setup, orphaning scopes from a failed prior test and contaminating the next (conftest.py:55).
  • The postgres-bounce test asserts only HTTP liveness, not broadcaster recovery, and doesn't --wait for Postgres (test_resilience.py:51).
  • README/docstrings say "fails on master," but the suite requires this PR's /internal endpoint (README.md:30).
  • Dead/misleading 404 branch in refresh_all() (helpers.py:128).

Minor

  • Enabling the flag exposes an unauthenticated /internal route (non-sensitive payload, off by default) (debug_stats.py:39).
  • || true masks genuine gitea-admin failures (docker-compose.yml:58).
  • Boot-timing clock starts after wait_healthy, undercounting boot-sync time (test_boot.py:24).

Bottom line

All 11 plan tasks are implemented and the production change is safe, but the suite needs to (1) re-key the load/drain assertions to a cache the sync path actually populates (or force a policy fetch), (2) make the cache reads worker-deterministic, (3) surface compose/seed failures, and then (4) be validated end-to-end against a live stack to confirm each test fails/passes for the right reason. Until then it can't be relied on as the regression gate for PR2–PR5.

Comment thread app-tests/git-leak/test_leak.py Outdated
Comment thread app-tests/git-leak/helpers.py Outdated
Comment thread app-tests/git-leak/helpers.py Outdated
Comment thread app-tests/git-leak/conftest.py
Comment thread app-tests/git-leak/helpers.py
Comment thread app-tests/git-leak/conftest.py Outdated
Comment thread app-tests/git-leak/test_resilience.py Outdated
Comment thread packages/opal-server/opal_server/debug_stats.py Outdated
Comment thread app-tests/git-leak/docker-compose.yml Outdated
Comment thread app-tests/git-leak/test_boot.py Outdated

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

Copilot reviewed 15 out of 16 changed files in this pull request and generated 3 comments.

Comment thread app-tests/git-leak/helpers.py
Comment thread packages/opal-server/opal_server/debug_stats.py Outdated
Comment thread packages/opal-server/opal_server/server.py
Comment thread app-tests/git-leak/test_leak.py Outdated
…iew)

Addresses the CHANGES_REQUESTED review on PR #922. Root cause behind most
findings: a fresh scope's first sync takes the _clone() branch, which only
fills GitPolicyFetcher.repo_locks; repos/repos_last_fetched are filled on a
*second* sync. So the load gates on `repos` hung, and the 2-worker per-process
caches made `== 0` drain assertions unsafe.

Blocking:
- Single worker (UVICORN_NUM_WORKERS=1): deterministic per-process cache reads;
  removes the false `== 0` drain class.
- Load gate (CRITICAL + Zivxx HIGH): _load_scopes gates on repo_locks then
  refresh_all() to force the second sync, so repos/repos_last_fetched are
  actually populated before any drain/purge assertion.
- compose() surfaces captured stdout/stderr on failure.
- Seed completeness asserted in conftest; seed script isolates per-repo
  failures and exits non-zero with a count.

Secondary:
- test_repeat_sync asserts an RSS bound (count can't grow for any impl);
  churn asserts all three caches drain + a loose RSS backstop.
- blackhole socat sidecar replaces TEST-NET-1 (deterministic hang); offline
  test saturates the fetch executor with 40 hung clones and recovers via
  OpalServerClient.hard_reset() (stop -> redis FLUSHALL -> start).
- Per-test clean slate deletes all server scopes (fixes orphan-scope leak).
- Postgres-bounce proves broadcast recovery (PUT post-bounce, assert sync)
  and uses `up -d --wait`.
- Remove dead 404 branch in refresh_all; boot clock starts at restart;
  gitea-admin `|| true` -> "already exists"-only guard; README reworded.

opal-server:
- /internal stats route now takes the JWTAuthenticator dependency (protected
  when JWT on, no-op in the test bed); unit test asserts enforcement.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@dshoen619

Copy link
Copy Markdown
Contributor Author

Review addressed — 6046a10

Thanks for the thorough pass. All blocking items, both HIGHs from @Zivxx / @zeevmoney, and the secondary/minor items are addressed in 6046a10. Per-comment replies are inline; summary below.

Root cause behind most findings (and why the gates didn't gate): a fresh scope's first sync takes the _clone() branch, which only fills repo_locks; repos/repos_last_fetched are filled on a second sync. The load gates on repos therefore hung, and the 2-worker per-process caches made == 0 drains unsafe.

Blocking

  1. Load gate (CRITICAL + @Zivxx HIGH)_load_scopes gates on repo_locks (first-sync signal), then refresh_all() forces the second sync so repos/repos_last_fetched are populated before any drain/purge assertion. Churn now asserts all three caches drain to 0 (so a broken repos_last_fetched purge fails instead of passing vacuously).
  2. Worker determinism — stack is now UVICORN_NUM_WORKERS=1; removes the false-== 0-drain class outright (no more reliance on max-merge to paper over it).
  3. Failure visibilitycompose() re-raises with captured stdout/stderr; seed completeness is asserted in conftest; seed_gitea.py isolates per-repo failures and exits non-zero with a count.

Secondary

  • test_repeat_sync now asserts an rss_kb bound (count can't grow for a URL-keyed set).
  • Offline test uses a blackhole socat sidecar (deterministic hang, verified) and saturates the fetch executor with 40 hung clones; recovers the session stack via hard_reset() (stop → redis FLUSHALL → start).
  • Per-test clean slate deletes all server scopes (fixes the orphan-scope leak).
  • Postgres-bounce proves broadcast recovery (PUT post-bounce → assert sync), uses up -d --wait.
  • Dead 404 branch removed; boot clock starts at restart; gitea-admin || true → "already exists"-only guard; README/docstrings reworded to "fails on this branch without PR2/PR3", noting the suite needs this PR's /internal endpoint.

Minor

  • /internal route now carries the JWTAuthenticator dependency (protected when JWT on, no-op in the test bed); added a unit test asserting a rejecting dependency → 401.

Validation done: 5/5 opal-server unit tests pass in the Docker server image (incl. the new auth test); docker compose config valid; blackhole hang and the gitea-admin guard verified directly.

Still outstanding (flagging honestly): I have not yet re-run the full live integration suite end-to-end to confirm each flagship test fails/passes for the right reason against a live stack — that's the "validate end-to-end" item from your bottom line. I can kick that off next; it's a ~30 min run and several tests are expected-fail gates on this branch until PR2/PR3.

dshoen619 and others added 2 commits June 24, 2026 16:15
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- snapshot a single /internal stats read per poll in the churn-drain and
  boot gates (consistent multi-key observation; fewer HTTP round-trips)
- document why a 200 from the healthy scope can't be a masked default
  bundle, and why the stats route is intentionally a sync def
- pin alpine/socat and the seed image's pip deps for reproducibility

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@dshoen619
dshoen619 requested review from Zivxx and zeevmoney June 24, 2026 17:06
Comment thread app-tests/git-leak/test_resilience.py Outdated
dshoen619 and others added 3 commits June 28, 2026 18:20
…repo

Ziv (PR review, round 2) caught that the offline-resilience gate can
false-PASS in the full-suite run. The "healthy" scope pointed at
policy-repo-0000 (list_seeded_repos(1)[0]), but on-disk clones are keyed
by URL-hash and survive compose restart/stop/start (opal_server mounts no
volume at /opal; only `down -v` wipes them). test_boot/test_leak run first
(alphabetical) and already clone every seeded repo, so the healthy scope
hit the existing clone via _discover_repository, skipped _clone(), and
served 200 without ever touching the saturated fetch executor — the gate
that must FAIL on this branch (no PR3 timeout) passed.

Fix: seed a reserved repo (policy-repo-healthy-probe) outside the numeric
policy-repo-NNNN range that no boot/leak test enumerates, and point the
healthy probe at it. A never-cloned repo forces a genuine fresh clone
through the starved pool, so the gate fails correctly. The seed-
completeness check in conftest now covers the reserved repo too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…view)

Follow-up to PR review of the git-leak/resilience test bed:

- hard_reset: always restart opal_server + wait_healthy via a finally, so a
  failed redis FLUSHALL can't leave the server stopped (which would fail every
  later session-scoped test and, running in a test finally, mask the result).
- delete_all_scopes drain: a transient /internal read error no longer counts as
  a successful drain (was `except: return`); keep polling to the deadline so a
  not-yet-drained cache can't leak into the next test once PR2 lands.
- use stats(samples=1) for the zero-waiting drain/empty polls (the peak-merge
  only matters for load assertions; this also drops 3x HTTP per poll).
- resilience: narrow broad `except Exception` to requests.RequestException
  (and RuntimeError for wait_healthy timeout) so harness bugs surface instead of
  masquerading as "never served"/"never recovered".
- resilience: collapse `assert opal.stats()` + redundant re-read into one read.
- debug_stats_test: assert rss_kb > 0 on Linux (was `>= 0`, which passed for the
  wrong reason where /proc is absent and RSS reads fall back to 0).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@dshoen619
dshoen619 removed the request for review from zeevmoney June 30, 2026 08:30
@dshoen619
dshoen619 requested a review from zeevmoney June 30, 2026 09:11
@zeevmoney zeevmoney changed the title PR1: Git leak/resilience test environment test(opal-server): git leak/resilience test environment (PR1) Jun 30, 2026

@zeevmoney zeevmoney 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.

Approving — the test bed is sound and this PR has already been through several review rounds (Copilot / @Zivxx / @zeevmoney) with the substantive issues raised and resolved. A fresh python-pro pass (planned against the opal-development references) surfaced only non-blocking items; none are CRITICAL/HIGH, so they don't gate landing the test bed.

Issues to fix (tracked under PER-15155) — inline comments above:

Worth fixing before/with merge

  • MEDIUM pytest.ini:10testpaths = packages makes the README's documented cd app-tests/git-leak && pytest --boot-scopes=50 error with unrecognized arguments: --boot-scopes. Self-root the suite + use a targeted ignore.
  • MEDIUM test_resilience.py:123 — the bounce test's repo_locks > baseline signal is order-dependent across files (shared policy-repo-0000 + non-purging caches on master); assert GET /scopes/post-bounce/policy == 200 instead.

Follow-ups (LOW)

  • helpers.py:274 — add a timeout= to compose() so a wedged setup fails fast instead of hitting the CI job limit.
  • helpers.py:148delete_all_scopes burns ~40s/test waiting on a drain that can't happen on master; short-circuit it.
  • seed_gitea.py:159 — remove the unused /seed-output/token artifact + volume.
  • seed_gitea.py:108 — make push_url credential injection scheme-agnostic (urllib.parse).

Doc nit (not posted inline): the bounce-test docstring (test_resilience.py:85-93) still describes pre-PER-15065 "graceful shutdown + gunicorn respawn over the recovered broadcaster"; reconnect is now in-place — worth a 3-line update.

Recommend merging PR1 first so PR2 (#923) / PR3 (#924) can build on the test bed + /internal endpoint, then addressing the two MEDIUMs.

Comment thread pytest.ini
Comment thread app-tests/git-leak/test_resilience.py Outdated
Comment thread app-tests/git-leak/helpers.py Outdated
Comment thread app-tests/git-leak/helpers.py
Comment thread app-tests/git-leak/seed/seed_gitea.py Outdated
Comment thread app-tests/git-leak/seed/seed_gitea.py Outdated
Zeev's gate-coverage review found only 2 of 5 flagship tests (churn #1,
offline #4) were genuine fail-now / pass-after gates. Lift the other three:

- #5 broadcaster: run 2 workers (OPAL_TEST_WORKERS) so the Postgres backbone
  is actually fanned out cross-worker (references/debug-pubsub.md §3-4), and
  assert the gunicorn worker PIDs are unchanged across a transient bounce --
  the in-place-reconnect signal that distinguishes #915 (PER-15065) from a
  plain worker respawn. Prove recovery via a servable post-bounce scope, not
  /internal cache counts (per-process, non-deterministic on 2 workers).
- #3 boot: key completion on "all scopes served" (GET /scopes/{id}/policy ==
  200) instead of repo_locks (set at fetch start, so it undercounts the final
  clone); document the PR4 tight-BOOT_TARGET_SECONDS carry-forward.
- #2 repeat-sync: rename to test_repeat_sync_rss_stays_bounded and drop the
  tautological len(repos) assertion; RSS is the sole (load-bearing) gate.

Adds worker_pids() (/proc-based, matched host-side so the scan can't count its
own sh -c wrapper) and the opal_multiworker fixture (recreate to 2 workers,
restore to 1 on teardown).

Validated live (--boot-scopes=50): #2/#3 pass, #5 passes (worker PIDs held
across the bounce, post-bounce scope served), #1/#4 fail for the right reason
(PR2/PR3 not landed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@dshoen619

Copy link
Copy Markdown
Contributor Author

Gate coverage lifted — 8e24cb0b

Thanks, the gate-coverage matrix was exactly the right lens. You're right that as committed only #1/#4 actually gated. Addressed all three suggestions; left #1 churn and #4 offline (the load-bearing gates) untouched.

#5 broadcaster — now a real PER-15065 guard. A new opal_multiworker fixture recreates opal_server with 2 workers (UVICORN_NUM_WORKERS: ${OPAL_TEST_WORKERS:-1}) so the Postgres backbone is actually fanned out cross-worker (debug-pubsub.md §3-4), then restores single-worker on teardown so the cache gates keep their determinism. The test now asserts the gunicorn worker PIDs are unchanged across the bounce — the in-place-reconnect signal that tells #915 apart from a plain respawn. With the retry-forever default a transient blip never gives up, so the worker keeps its PID; a revert of #915 would escalate to a graceful shutdown → gunicorn respawn → new PID. Recovery is proven via a servable post-bounce scope (GET /scopes/{id}/policy == 200), not /internal counts (per-process, non-deterministic on 2 workers). worker_pids() reads /proc and matches host-side so the scan can't count its own sh -c wrapper (whose command line literally contains "gunicorn").

#3 boot — measures the right thing. Completion now keys on all scopes served (GET /scopes/{id}/policy == 200) instead of repo_locks (set at fetch start, so it undercut the final clone). Default BOOT_TARGET_SECONDS stays loose (baseline recorder); the docstring + README carry forward that PR4 must run it with a tight target (120s @ 50).

#2 repeat-sync — relabeled. Renamed test_repeat_sync_does_not_growtest_repeat_sync_rss_stays_bounded; dropped the tautological len(repos) assertion (with a comment so it isn't re-added); RSS is the sole gate.

Live validation (--boot-scopes=50, this branch):

Test Result Reason
#3 boot ✅ PASS served 50/50 in 7.4s (all-served probe)
#2 rss ✅ PASS RSS guard
#1 churn ❌ FAIL (right reason) caches stay at {repo_locks:50, repos:50, repos_last_fetched:50} — PR2 not landed
#4 offline ❌ FAIL (right reason) healthy probe ReadTimeout while 40 clones hang — PR3 not landed
#5 broadcaster ✅ PASS 2 workers, worker PIDs unchanged across bounce, post-bounce scope served

Net: gates PR2 + PR3 today, PR5 is now guarded (in-place reconnect), and PR4 is ready to gate the moment its branch sets a tight BOOT_TARGET_SECONDS.

dshoen619 and others added 2 commits July 1, 2026 13:02
Zeev's latest inline batch:
- pytest.ini: self-root the suite (app-tests/git-leak/pytest.ini) so
  `cd app-tests/git-leak && pytest --boot-scopes=N` is deterministic across
  pytest versions/cwd. (The documented command already works -- testpaths only
  applies when pytest runs from the rootdir -- but this makes it explicit.)
- compose(): add a subprocess timeout (default 1200s) so a wedged up/wait/build
  fails fast instead of hanging session-scoped fixture setup to the CI job limit
  (pytest-timeout does not cover fixture setup).
- delete_all_scopes(): cut the drain wait 20s -> 3s; on master the caches can't
  purge (the leak this gates), so the old wait burned ~40s of dead time per test
  across setup+teardown.
- seed_gitea.py: inject push creds scheme-agnostically (urllib.parse) instead of
  string-replacing "http://"; drop the unused /seed-output token artifact and the
  seed-output volume (host uses basic auth, never the token).

The order-dependent bounce signal (test_resilience.py) was already fixed in
8e24cb0 (asserts GET /scopes/post-bounce/policy == 200, not a delta on a shared
process-global counter).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Addresses the HIGH and all MEDIUM findings from the opal-development /
python-pro / backend-architect microreview:

- H1 (#5 vacuous pass): the broadcaster gate now positively verifies a
  disconnect+reconnect actually happened, not just that no respawn occurred.
  New helpers.broadcaster_connect_count() counts the reconnecting broadcaster's
  "listener connected to channel" log line; the test asserts it increased across
  the bounce (paired with worker PIDs unchanged = in-place, not respawn).
- M1 (#4): move the 40 executor-saturating PUTs inside the try/finally so a PUT
  failure still runs hard_reset() instead of leaking hung clone threads into the
  session stack.
- M2 (#1/#2): _wait_until now treats a transient requests error from opal.stats()
  as "not yet" and retries, instead of ERRORing the test.
- M3 (#3): measure a deterministic pure-cold boot (--force-recreate wipes the
  ephemeral FS -> preload cold-clones all N from Redis) instead of a
  nondeterministic warm/cold mix, so PR4's tight BOOT_TARGET_SECONDS can gate.
- M4: verify the single-worker invariant -- opal_multiworker teardown asserts the
  stack is back to 1 worker, and the opal fixture asserts single-worker at setup,
  so a botched restore fails loudly instead of silently breaking cache gates.
- M5 (#4): correct the reserved-probe comment (serving shares the fetch executor,
  so a shared repo would be starved on serve too; the probe additionally
  exercises the clone).
- M6: gitea-admin retries on "database is locked" (CLI mutating live SQLite);
  rewritten as a `|` literal block so the create call stays on one line.

Validated live (--boot-scopes=20): #2/#3 pass, #5 passes (reconnect count
increased across the bounce + PIDs unchanged + scope served), #1/#4 fail for the
right reason.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@zeevmoney zeevmoney 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.

Re-review (PER-15155) — post-approval commits

What this PR does. Adds the app-tests/git-leak/ docker-compose test bed (Gitea seed + blackhole + redis + postgres + opal_server) plus three regression suites — boot-sync timing (test_boot.py), cache-drain / RSS leak (test_leak.py), and offline-repo / postgres-bounce resilience (test_resilience.py) — that are red on master by design and turn green as PR2–PR5 land. It also adds one production surface those tests observe through: the off-by-default GET /internal/git-fetcher-cache-stats endpoint (debug_stats.py), its OPAL_DEBUG_INTERNAL_STATS config key (default False), and its wiring in server.py, plus two collected unit tests. Root pytest.ini gains testpaths = packages to keep the docker bed out of the unit CI run.

Verdict: APPROVE. Per the severity rule (verdict is driven by Postable findings only), there are zero Postable HIGH/CRITICAL findings. I already approved at a502f2e4dd (2026-06-30); the three commits pushed since (8e24cb0b, 6f002fda, 5aae85ca) touch only the app-tests/git-leak/ harness and are all gate-strengthening / cleanup-robustness changes. Re-verified below.

Prior review status. No prior finding of mine remains open (there are no unresolved review threads on the PR). The post-approval commits address earlier review rounds and strengthen the gates rather than regress them:

  • test_boot.py now keys completion on every scope's bundle being served (GET /scopes/{id}/policy == 200) and uses --force-recreate for a deterministic cold boot — stronger and less flaky than the previous repo_locks-count signal.
  • test_leak.py removed the tautological repos <= baseline_repos assertion (a cache-count that cannot grow for same-URL re-syncs) and renamed the test to reflect that RSS is the load-bearing guard — directly fixing the vacuous-gate concern. Added transient-error retry in _wait_until (fail-safe: a persistent failure still surfaces via the caller's assert).
  • test_resilience.py moved the postgres-bounce test to a 2-worker stack (opal_multiworker) so the cross-worker Postgres backbone is actually exercised, and added an anti-vacuous assertion (b): the broadcaster reconnect-log count must strictly increase, so the PID-unchanged in-place-reconnect check can't pass because the bounce failed to break anything. Offline-repo PUTs moved inside try/finally so hard_reset() always cleans up.

Verification performed.

  • The load-bearing log marker "Broadcaster listener connected to channel" in broadcaster_connect_count() matches real emitted output at packages/opal-server/opal_server/pubsub_resilience.py:277 — the guarded fix (#915 / PER-15065) is already on master, so this is a genuine regression gate, not a permanently-red test.
  • The two reconnect knobs the compose file sets (OPAL_BROADCAST_RECONNECT_BACKOFF_MAX_SECONDS, OPAL_BROADCAST_RESYNC_SETTLE_SECONDS) are real Confi keys (config.py:73-77, 92-97) consumed by pubsub.py:157,159,269; the compose comment's stated defaults (30s max backoff / 2s settle) match the declarations.
  • debug_stats.py's three caches are class-level dicts on GitPolicyFetcher (git_fetcher.py:118-120), so the "process-global" docstring is accurate. Config key DEBUG_INTERNAL_STATS has a description, no env-name collision, and no OPAL_-double-prefix.
  • Genuine gates: each suite fails on master for the stated reason (no delete-purge; no scopes-path fetch timeout) and cleans up (down -v, hard_reset, delete_all_scopes, multiworker→single-worker restore with a fail-loud assert). Determinism is handled (single worker for per-process cache reads; connect-count for the broadcaster; --force-recreate cold boot).

Findings

Postable: none.

Informational (LOW — deliberate, well-documented test-bed tradeoffs; not blocking):

# Severity File:Line Category Description
1 LOW app-tests/git-leak/helpers.py:305-346 Out-of-scope (test infra) worker_pids() treats the lowest gunicorn PID as the master. Correct for a fresh, short-lived container; technically fragile only under PID wraparound. Fine for this bed.
2 LOW app-tests/git-leak/conftest.py:84-86 Out-of-scope (test infra) The opal fixture runs a docker compose exec (worker_pids()) on every test setup as a single-worker invariant guard — minor per-test overhead, intentional.

Blast radius: Contained. New production code is the off-by-default, schema-hidden, auth-gated-when-auth-enabled read-only stats endpoint. Root pytest.ini testpaths = packages scopes the default CI pytest run to packages/ — no test files exist outside packages/, so nothing is dropped, and the intentionally-red git-leak bed (not wired into the app-tests/run.sh e2e job) stays out of CI.

Isolation / scope: Isolated and complete. The lone production addition exists to support the test bed, is off by default, and is fully finished (no half-done side-quests). The harness lands first; gates go green as later PRs merge.

…publish guard (review items 1-4)

Coverage additions from the test-coverage review pass:

1. Unit tests for the server.py wiring: build the real OpalServer app and
   assert /internal/git-fetcher-cache-stats is mounted iff
   DEBUG_INTERNAL_STATS is on — removing the register call from
   _init_fast_api_app now fails the unit suite.
2. test_shared_repo_survives_sibling_scope_delete: deleting one of two
   scopes sharing a repo URL must not purge the URL-keyed cache entry or
   break the survivor (guards PR2 against over-purging; churn only covers
   the all-scopes-gone direction).
3. test_scope_repoint_releases_old_repo_cache: PUT /scopes is
   create-or-update, so re-pointing a scope orphans the old URL's cache
   entries — a leak path churn (delete-only) never takes. Red until PR2's
   purge covers updates too.
4. Postgres-bounce test: bounce_postgres gained a during= callback (with a
   finally that restores Postgres even if it raises) and the test now PUTs
   a scope mid-outage, asserting (d) it becomes servable after recovery —
   its sync trigger must be buffered/replayed across the gap, not silently
   dropped. Uses a distinct seeded repo from (c) so a stale on-disk clone
   can't satisfy it vacuously.

Validated live (--boot-scopes=2): #2 and the bounce test pass; #3 fails
for the right reason (caches stuck at 2/2/2 after the delete — PR2 not
landed). Unit suite: 52 passed; pre-commit clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Zivxx
Zivxx merged commit b108180 into master Jul 12, 2026
11 checks passed
@Zivxx
Zivxx deleted the david/per-15155-pr1-git-leakresilience-test-environment-one-big-pr branch July 12, 2026 12:22
Zivxx added a commit that referenced this pull request Jul 19, 2026
…e delete + webhook task cleanup (PR2) (#923)

* feat(opal-server): gated /internal git-fetcher cache stats endpoint

Add an off-by-default diagnostics endpoint so tests can observe the
in-memory GitPolicyFetcher cache sizes (repo_locks/repos/repos_last_fetched)
and process RSS that the upcoming memory-leak fix eliminates.

- debug_stats.py: read-only git_fetcher_cache_stats() helper + a
  register_internal_stats_route() registrar that mounts
  GET /internal/git-fetcher-cache-stats only when enabled.
- config.py: new OPAL_DEBUG_INTERNAL_STATS flag, default False.
- server.py: register the route, gated by the flag, beside /healthcheck.

No production behavior change when the flag is off (the default). Also
ignore .claude/ so private planning artifacts are never committed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(git-leak): add OPAL git leak/resilience test bed

A self-contained docker-compose stack (opal-server x2 workers + Redis +
Postgres broadcaster + Gitea) plus a pytest harness that reproduces, as
tests that fail on master, the git-fetcher memory leak, the offline-repo
hang, the slow serial boot, and the broadcaster-disconnect gap. These
become the regression gates for the follow-up fixes.

- seed/: idempotent Gitea seeding sidecar (N policy repos) + Dockerfile.
- docker-compose.yml: 4-service stack, opal-server built from the repo's
  own docker/Dockerfile (server target), scopes on, Postgres broadcaster.
- helpers.py / conftest.py: HTTP + infra helpers and stack fixtures.
- test_leak.py / test_resilience.py / test_boot.py: the flagship tests.
- README.md: how to run and expected fail-on-master behavior.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(git-leak): add GiteaAdmin and make_repo_unreachable helpers

Complete the helpers.py surface promised in the plan's file-structure
table. Both are now functional and used, not dead code:

- make_repo_unreachable(name): returns a git URL on a routable-but-dead
  TEST-NET-1 host (RFC 5737). test_offline_repo now uses it instead of an
  inlined literal.
- GiteaAdmin: host-side Gitea admin client (list_repos / repo_exists /
  create_repo / delete_repo), exposed as the `gitea_admin` pytest fixture
  for tests that need to inspect or stage repos beyond the seed sidecar.

Gitea is published on host port 13000 (uncommon, to avoid the usual :3000
clash) so GiteaAdmin can reach it; opal_server and the seed sidecar still
use the internal http://gitea:3000. README updated with the helper and
port notes.

Verified live: GiteaAdmin lists the seeded repos and round-trips
create/exists/delete against Gitea over the published port.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(git-leak): correct postgres-bounce framing (passes on master)

Verified test_server_recovers_after_postgres_bounce against the stack: it
PASSES on master (~14-19s). On a broadcaster drop the affected worker
triggers a graceful shutdown, gunicorn respawns it, and the sibling worker
keeps serving HTTP, so the surface recovers within the window — recovery
happens via gunicorn's in-container worker supervision, not an external
supervisor and not an in-process reconnect.

Reframe #5 as a recovery guard (not a known-broken case) in the docstring
and README; the prior "FAILS on master / needs external supervisor" wording
was wrong. PER-15065's in-process reconnect would avoid the worker churn but
recovery already holds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* style(git-leak): apply black/isort/docformatter (pre-commit)

Run the repo's pinned pre-commit formatters (black 23.1.0, isort 5.12.0,
docformatter 1.7.5) over the PR1 files to satisfy the pre-commit CI check.
Formatting only — no behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test: scope root pytest collection to packages/ (exclude git-leak bed)

The CI `build` job runs `pytest` from the repo root with no path, which
recursed into app-tests/git-leak/ and ran the flagship tests — these are
designed to FAIL on master (they are the regression gates for PR2-PR5), so
they broke the build job.

Set `testpaths = packages` so the rootdir run collects only the unit tests
under packages/ (matching master's effective behavior, since app-tests/ had
no pytest files before). testpaths only applies when pytest is invoked from
the rootdir with no args, so `cd app-tests/git-leak && pytest` still collects
and runs the test bed. Verified both: root run -> packages only; subdir run
-> all 5 flagship tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(git-leak): address Copilot review feedback

- stats(): sample the /internal endpoint several times and merge per key
  with max(). The caches are per-process on a multi-worker server, so a
  single read can hit an empty (non-leader) worker — max-merge avoids both
  false negatives (missing a populated leader) and false positives (an
  `== 0` drain assertion passing only because it hit an empty worker).
- test_leak: assert the initial-load `_wait_until` succeeded before deleting
  / before taking baseline, so the tests can't pass vacuously when load
  never completed.
- refresh_all(): correct the misleading comment — a 404 is a no-op, there
  is no client-side fallback.
- conftest: skip the suite cleanly if docker is unavailable (defense in
  depth; it's already excluded from the default pytest run via testpaths).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(git-leak): isolate scopes per test and fix false repeat-sync gate

Two review findings on the regression gates:

- Cross-test contamination: clone paths are keyed by repo URL (source_id =
  sha256(url)+branch-shard), not scope_id, so scopes from different tests that
  point at the same seeded repo share one GitPolicyFetcher cache entry. With a
  session-scoped stack and no teardown, a leftover boot-*/stable-* scope kept
  those entries alive and would make test_churn's `repos == 0` drain assertion
  fail on fixed code. OpalServerClient now tracks created scopes and the opal
  fixture deletes them on teardown (best-effort drain wait, swallows errors so
  master — where delete never purges — doesn't fail the passing test).

- False gate: test_repeat_sync_does_not_grow re-syncs identical scopes, which a
  path-keyed cache can't grow even on master, so it could never be the leak gate
  it claimed. Reframed as an honest idempotency guard (passes on master) that
  points at test_churn_releases_caches as the real leak gate; README's
  "Expected on master" reclassifies it alongside the postgres-bounce guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(git-leak): make the regression gates trustworthy (address PR review)

Addresses the CHANGES_REQUESTED review on PR #922. Root cause behind most
findings: a fresh scope's first sync takes the _clone() branch, which only
fills GitPolicyFetcher.repo_locks; repos/repos_last_fetched are filled on a
*second* sync. So the load gates on `repos` hung, and the 2-worker per-process
caches made `== 0` drain assertions unsafe.

Blocking:
- Single worker (UVICORN_NUM_WORKERS=1): deterministic per-process cache reads;
  removes the false `== 0` drain class.
- Load gate (CRITICAL + Zivxx HIGH): _load_scopes gates on repo_locks then
  refresh_all() to force the second sync, so repos/repos_last_fetched are
  actually populated before any drain/purge assertion.
- compose() surfaces captured stdout/stderr on failure.
- Seed completeness asserted in conftest; seed script isolates per-repo
  failures and exits non-zero with a count.

Secondary:
- test_repeat_sync asserts an RSS bound (count can't grow for any impl);
  churn asserts all three caches drain + a loose RSS backstop.
- blackhole socat sidecar replaces TEST-NET-1 (deterministic hang); offline
  test saturates the fetch executor with 40 hung clones and recovers via
  OpalServerClient.hard_reset() (stop -> redis FLUSHALL -> start).
- Per-test clean slate deletes all server scopes (fixes orphan-scope leak).
- Postgres-bounce proves broadcast recovery (PUT post-bounce, assert sync)
  and uses `up -d --wait`.
- Remove dead 404 branch in refresh_all; boot clock starts at restart;
  gitea-admin `|| true` -> "already exists"-only guard; README reworded.

opal-server:
- /internal stats route now takes the JWTAuthenticator dependency (protected
  when JWT on, no-op in the test bed); unit test asserts enforcement.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* style(git-leak): apply black/isort/docformatter (pre-commit)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(git-leak): tighten stat polling and pin test-bed images (PR review)

- snapshot a single /internal stats read per poll in the churn-drain and
  boot gates (consistent multi-key observation; fewer HTTP round-trips)
- document why a 200 from the healthy scope can't be a masked default
  bundle, and why the stats route is intentionally a sync def
- pin alpine/socat and the seed image's pip deps for reproducibility

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(git-leak): isolate offline-hang healthy probe to a never-cloned repo

Ziv (PR review, round 2) caught that the offline-resilience gate can
false-PASS in the full-suite run. The "healthy" scope pointed at
policy-repo-0000 (list_seeded_repos(1)[0]), but on-disk clones are keyed
by URL-hash and survive compose restart/stop/start (opal_server mounts no
volume at /opal; only `down -v` wipes them). test_boot/test_leak run first
(alphabetical) and already clone every seeded repo, so the healthy scope
hit the existing clone via _discover_repository, skipped _clone(), and
served 200 without ever touching the saturated fetch executor — the gate
that must FAIL on this branch (no PR3 timeout) passed.

Fix: seed a reserved repo (policy-repo-healthy-probe) outside the numeric
policy-repo-NNNN range that no boot/leak test enumerates, and point the
healthy probe at it. A never-cloned repo forces a genuine fresh clone
through the starved pool, so the gate fails correctly. The seed-
completeness check in conftest now covers the reserved repo too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(git-leak): harden harness teardown and tighten assertions (PR review)

Follow-up to PR review of the git-leak/resilience test bed:

- hard_reset: always restart opal_server + wait_healthy via a finally, so a
  failed redis FLUSHALL can't leave the server stopped (which would fail every
  later session-scoped test and, running in a test finally, mask the result).
- delete_all_scopes drain: a transient /internal read error no longer counts as
  a successful drain (was `except: return`); keep polling to the deadline so a
  not-yet-drained cache can't leak into the next test once PR2 lands.
- use stats(samples=1) for the zero-waiting drain/empty polls (the peak-merge
  only matters for load assertions; this also drops 3x HTTP per poll).
- resilience: narrow broad `except Exception` to requests.RequestException
  (and RuntimeError for wait_healthy timeout) so harness bugs surface instead of
  masquerading as "never served"/"never recovered".
- resilience: collapse `assert opal.stats()` + redundant re-read into one read.
- debug_stats_test: assert rss_kb > 0 on Linux (was `>= 0`, which passed for the
  wrong reason where /proc is absent and RSS reads fall back to 0).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(opal-server): add GitPolicyFetcher.forget_repo to release cached repo handles

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(opal-server): purge git fetcher caches on scope delete

Capture the deleted scope's source before scanning siblings (fixing the
loop-variable shadowing bug where the clone path was computed from the
last-iterated scope). Drop the cached pygit2.Repository handle via
forget_repo and pop repos_last_fetched when no sibling shares the source.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(opal-server): clean all finished webhook tasks (no remove-while-iterating)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(opal-server): actually purge fetcher caches on scope DELETE

The PR2 purge in ScopesService.delete_scope was dead code: the HTTP
DELETE /scopes/{scope_id} route deleted the scope straight from the
ScopeRepository, so the git-leak churn gate stayed red with all cache
entries intact. Wire the route to the service (keeping delete-of-missing
a 204 no-op), and also drop the source_id-keyed repo_locks entry, which
neither forget_repo (path-keyed) nor the service purge released.

Verified against the app-tests/git-leak bed: test_churn_releases_caches
now passes and test_shared_repo_survives_sibling_scope_delete still
guards the shared-clone case.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* style(opal-server): fix import order in delete_scope_route_test (isort)

CI fixes:
- pre-commit: isort wanted `import pytest` in the top import block of the
  new test file; it was missed locally because `pre-commit run --all-files`
  only covers tracked files and the file was untracked when formatters ran.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(opal-server): serialize the scope-delete cache purge under the repo lock

delete_scope mutated GitPolicyFetcher's lock-guarded shared state (rmtree
of the clone dir, Repository.free(), cache pops) without holding
repo_locks[source_id], racing in-flight fetches that run on executor
threads — a native use-after-free. Popping the lock entry also let the
next fetcher mint a fresh lock and run unserialized (lock identity
hazard).

Replace _get_repo_lock with GitPolicyFetcher.lock_source(source_id), an
async context manager that re-checks the repo_locks entry after acquiring
and retries on the fresh lock if a delete popped it. delete_scope now
purges under that lock and pops the entry while holding it, so repo_locks
still drains to zero (keeping the PR1 churn gate green) without the
identity hazard.

Also, per the same review pass: guard delete_scope against non-git policy
sources like sync_scope does, restore the sharing scope's id in the
skip-deletion log line, and document that the purge is process-local
best-effort (fleet-wide purge incl. the leader is tracked for PR3).

Addresses review comments:
- #923 (comment) (@zeevmoney)
- #923 (comment) (@zeevmoney)
- #923 (comment) (@zeevmoney)
- #923 (comment) (@zeevmoney)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(opal-server): surface rmtree and Repository.free() failures in scope cleanup

rmtree(ignore_errors=True) silently orphaned a deleted tenant's policy
clone on any filesystem failure (EBUSY, permissions, stale NFS handle)
while the scope record was deleted anyway — no log, no way to ever find
the leftovers. Replace with an explicit try/except that tolerates a
missing dir and logs any other OSError at WARNING with the scope id and
path, still proceeding with the scope deletion.

forget_repo likewise swallowed Repository.free() exceptions at DEBUG with
no exception detail or path; a systematic free() failure would be
invisible in production. Log at WARNING with the clone path and error,
and fix the docstring claim about free() availability (the pinned pygit2
always ships it). Add the missing test for the free()-raises branch.

Addresses review comments:
- #923 (comment) (@zeevmoney)
- #923 (comment) (@zeevmoney)
- #923 (comment) (@zeevmoney)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(opal-server): retrieve webhook task exceptions before dropping them

The webhook task sweep dropped finished tasks without inspecting
t.exception(), and stop() only gathered self._tasks — so an error raised
inside trigger() never reached the app logger, surfacing only as
asyncio's generic "Task exception was never retrieved" at GC time. Log
failed trigger tasks at ERROR during the sweep and include webhook tasks
in the stop() gather.

Also de-flake the cleanup test: await the freshly scheduled trigger task
directly instead of relying on an asyncio.sleep(0) scheduler tick, and
add a test asserting the failure of a trigger task is retrieved and
logged.

Addresses review comments:
- #923 (comment) (@zeevmoney)
- #923 (comment) (@zeevmoney)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(opal-server): expose pid and cache keys in internal fetcher stats

Per-worker attribution for the git-leak bed: the caches are per-process,
so the pid identifies which worker answered and the key lists let the
invariant checker compare cache contents against live scopes and disk.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(opal-server): only stamp repos_last_fetched after a successful fetch

A failed fetch left the pre-written stamp in place, so
_was_fetched_after() suppressed the next webhook-requested forced
refresh and the scope served stale policy until an unrelated force.
Stamp the fetch START time, written only on success.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(opal-server): pin fetch-START semantics of the repos_last_fetched stamp

Review follow-up: a completion-time regression would have passed the
existing tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(opal-server): make invalid-clone recovery drop the stale handle and clear partial dirs

The recovery path rmtree'd a bad clone but kept its cached
pygit2.Repository, so the stale handle re-invalidated every fresh clone
(infinite re-clone loop, leaked handle per cycle) — recovery only worked
on a cold cache. forget_repo the handle first, clear any pre-existing
(partial) dir before cloning, and cache the fresh clone's handle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(opal-server): delete-then-recreate serializes on the repo lock

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(opal-server): stop() cancels and gathers in-flight webhook triggers

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(opal-server): read fetcher cache stats from atomic snapshots

git_fetcher_cache_stats() runs on a Starlette worker thread while the
GitPolicyFetcher repo_locks/repos/repos_last_fetched dicts are mutated on
the event-loop/executor threads; iterating a live dict's keys() across
that race can raise "dictionary changed size during iteration", and
computing len() and sorted(keys()) as two independent reads of a mutating
dict can return a self-contradictory count/keys pair (e.g. repos=1 while
listing 2 keys). Fix by snapshotting each cache once via dict.copy() (a
single, GIL-atomic C-level op) and deriving both the count and the key
list from that same snapshot.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(opal-server): assert recovery actually free()s the stale handle

test_recovery_forgets_stale_cached_handle only checked that the broken
handle was evicted from the cache, not that it was free()d — a
regression to a bare .pop() would still pass while reintroducing the
fd/mmap leak the recovery path is meant to close.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(opal-server): invariant checker + pid-stability teardown for the git-leak bed

Every bed test now asserts at teardown: no orphan clone dirs (I1), cache
keys consistent with disk and live scopes (I2-I4), worker pid-set
unchanged (I5 - a crashed worker resets the caches and can fake a clean
drain), server liveness (I6). Red gates exempt named invariants via
marker.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(opal-server): document stats() merge semantics and fix stale hints

Review follow-up (deferred behind in-flight work): counts merge with
max() while pid/key-lists are last-wins, so cross-field consistency is
only guaranteed at samples=1; annotate Dict[str, Any] accordingly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(opal-server): bed gate for delete/recreate storms on one source

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(opal-server): warm-boot gate — restart must reuse on-disk clones

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(opal-server): seeded randomized churn driver with invariant checks

Seeded random put/refresh bursts with settled end-of-round deletes (plus a
ghost delete to keep the 204 no-op path exercised); repo_locks must not
exceed live sources at every settle point. Replay any failure with
CHURN_SEED=<printed seed>.

Two deliberate constraints, both to be lifted when PR3's fleet purge lands
(no silent caps):
- repoints are excluded: a put on a live scope reuses its existing repo,
  since a repoint orphans the old source's caches by design today (the red
  repoint gate covers it).
- deletes run only after every live scope has settled: the driver's
  development run surfaced (deterministically, seed 309006536) that a
  DELETE racing an in-flight sync loses its purge — the sync re-populates
  the dead scope's caches and clone dir. Same PR3 class; deterministic
  red-gate coverage lands in
  test_repoint_during_inflight_fetch_drains_old_source.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(opal-server): characterize upstream force-push and branch-delete behavior

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(opal-server): delete-during-hung-fetch gates (no-crash green, bounded-return red until PR3)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(opal-server): repoint-during-hung-fetch gate (red until PR3 update-path purge)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(opal-server): system gate for corrupt-clone recovery (no re-clone loop)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(opal-server): corrupt clone contents in place so recovery exercises the warm-handle branch

The previous corruption (rm -rf .git/objects) deleted the directory NODE,
so pygit2.discover_repository returned None and recovery went through the
repo-not-found -> _clone() branch, never touching the cached-handle
(_get_valid_repo -> forget_repo) branch the Bug A fix lives in. Emptying
the objects dir's CONTENTS keeps discovery succeeding and forces recovery
through the warm cached handle; a new assert on the "Deleting invalid
repo" log pins the branch taken.

NOTE: this gate is currently RED — a real finding. With objects/ emptied
in place, _get_valid_repo() still judges the repo valid (open + remote-URL
verification raise no pygit2.GitError), so the invalid-repo recovery
branch never fires; the failure surfaces later in make_bundle
(_get_current_branch_head -> "SHA ... missing") and the scope serves 500
forever with no re-clone. The corrupted-but-discoverable case is not
covered by the landed Bug A fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(opal-server): detect gutted object stores during clone validation

A clone can be discoverable yet unusable: refs and config intact but the
object store gutted (crash mid-gc, disk corruption). _get_valid_repo()
judged such a repo valid (open + remote-URL verification raise no
GitError), a forced fetch then negotiated "up to date" against the intact
refs and downloaded nothing, and every policy read failed in make_bundle
with "SHA ... missing" — the scope served 500s forever with no self-heal.

Fix: validate that the tracked branch's head object is actually readable
FROM DISK before declaring the repo valid. Crucially the check uses a
short-lived fresh Repository handle, not the cached warm handle: the warm
handle keeps deleted pack files readable through its open mmaps (unlink
does not invalidate them) and reports the object as present even after
the store is emptied on disk — verified empirically in the test bed,
where a cached-handle check never fired. An unreadable head object now
routes the repo to the existing invalid-repo recovery branch
(forget cached handle -> delete dir -> re-clone, exactly once).

Residual: partial corruption deeper in the object graph is not caught
(that would need fsck-grade checks); only head-object readability is
validated.

The system gate added in 52a1fe9 (in-place object-store gutting under a
warm handle cache) flips green with this fix, including its "Deleting
invalid repo" branch-pin assert: detection warning, one re-clone, stable
clone count.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(opal-server): orphan-dir and redis-wipe boot gates (red until orphan sweep)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(opal-server): clean the wiped scope's clone dir so later I1 checks stay meaningful

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(opal-server): boot-while-remotes-down gate (red until PR3 fetch timeout)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(opal-server): clean stranded blackhole clones after the boot-down gate

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(opal-server): shard-reconfig boot gate (serves green, orphans red until sweep)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(opal-server): multi-worker churn gate — every worker must drain (red until PR3)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(opal-server): correct the multi-worker leak mechanism in the churn gate docstring

Purges are process-local; the LEADER syncs accumulate via its watcher (scopes/task.py),
and any bundle-serving worker additionally caches a pygit2 handle. Only the DELETE-serving
worker purges, so every accumulation on a different worker outlives the scope.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(opal-server): extend git-leak gate matrix with lifecycle transition tests

Adds 14 matrix rows for the new test_transitions.py/test_boot_states.py/test_remote_transitions.py tests and an Invariants section documenting I1-I6 and the invariant_exempt/allow_worker_restart markers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(opal-server): make stats_by_pid min_pids an actual early-exit

Once min_pids distinct pids are observed, take one grace sample to ensure freshness, then break.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(opal-server): stop make_bundle reading the shared pygit2 handle off-lock

_get_current_branch_head() (called from make_bundle, which is invoked via
run_sync(fetcher.make_bundle, ...) on executor threads by the policy-bundle
route, and directly on the loop by _generate_default_scope_bundle) was
calling self._get_repo(), which reads/populates the shared class-level
repos cache. Both call sites run outside lock_source, so the cached handle
can be concurrently .free()'d by forget_repo() during a scope delete or
invalid-repo recovery — a native use-after-free on the pygit2 handle.
asyncio locks don't exclude executor threads, so the lock around
fetch_and_notify_on_changes does not protect this path.

Fixed by opening a short-lived fresh Repository handle per call (freed in a
finally), the same fresh-probe pattern _get_valid_repo already uses for its
disk-truth check. All remaining shared cached-handle access now happens
under lock_source.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(opal-server): serialize sibling-check and purge on the source lock (delete TOCTOU)

delete_scope's sibling-scope check ran before lock_source and before the
record delete. Two concurrent deletes of scopes sharing a source_id could
each see the other still present in the scope store and both skip the
purge, permanently orphaning the clone dir, pygit2 handle cache, and
repos_last_fetched entry.

Restructured so the record delete, sibling re-check, and purge all happen
inside a single lock_source(deleted_source_id) critical section, with the
record delete first — so whichever of the two concurrent deletes finishes
last sees no remaining sharer and performs the purge. Also switched the
rmtree call to run_sync so it no longer blocks the event loop.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(opal-server): log rmtree failures in recovery and clone pre-clean

The invalid-repo recovery rmtree and _clone's partial-dir pre-clean both
used ignore_errors=True, silently swallowing any OSError beyond a simple
missing directory (e.g. a permissions problem or a busy mount) with no
trace in the logs. Replaced both with the same try/except pattern the
delete-scope purge path already uses: tolerate FileNotFoundError (already
gone is the intended end state) but log other OSErrors as a warning so a
persistent failure is discoverable instead of silently retried forever.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(opal-server): run the clone-validation probe off-loop and stamp recloned repos

_get_valid_repo() opens a fresh Repository handle from disk and walks refs
to detect a gutted object store; called synchronously inline in
fetch_and_notify_on_changes, a slow disk stalls the event loop and every
other request being served on the worker. Route it through run_sync so it
runs on the executor.

Also stamp repos_last_fetched on a successful clone: a reclone just
downloaded current remote state, so without the stamp _was_fetched_after()
sees no record and _should_fetch() forces a redundant fetch on the very
next sync cycle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(opal-server): redact credentialed URLs in webhook task failure logs

Webhook trigger failures were logged with repr(exception) verbatim. Git
exceptions from a failed fetch/clone can embed a credentialed remote URL
(https://user:token@host/repo) directly in the exception message, so a
webhook failure would leak the credential into the server logs. Strip any
`://user:pass@`-shaped segment before logging.

Added a unit test covering a trigger failure whose message embeds a
credentialed URL: the logged ERROR must contain the redacted form and must
not contain the raw credential.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(opal-server): cover _get_valid_repo healthy paths and probe lifecycle

_get_valid_repo's failure paths (stale cached handle, gutted object store)
were already covered, but its two healthy paths were not: (a) the tracked
branch hasn't been fetched yet (probe.lookup_reference raises KeyError,
tolerated as "fetch path handles that"), and (b) the probe confirms the
head object is actually readable from disk. Both must return the cached
repo unchanged and free() the short-lived disk probe.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(opal-server): purge defensively when the post-delete sibling check fails

delete_scope's sibling re-check runs after the record delete; its all()
does a full store scan + Scope.parse_raw, so a transient store error or
one malformed record raises. That exception escaped the source lock with
NOTHING purged, and because the record is already deleted the client's
retry is a 204 no-op (ScopeNotFoundError) — the purge became permanently
unreachable, orphaning the clone dir and both fetcher caches.

The re-check failure is now downgraded to a warning and treated as "no
sharer" (purge). Over-purging self-heals — a surviving sibling re-clones
on its next sync — while under-purging is a permanent leak with no retry
path. The record-delete itself still propagates failures normally.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(opal-server): offload recovery rmtrees and widen the PR3 delete-routing note

The invalid-repo recovery rmtree and _clone's partial-dir pre-clean ran
synchronously on the event loop while this same PR offloads the
delete-path rmtree — inconsistent, and a large clone dir on a slow disk
stalls every request on the worker. Both now go through run_sync inside
their existing try/excepts.

Also widened delete_scope's PR3 NOTE: besides the cross-process leader
gap, the same class exists in-process — a sync that loaded the scope
before the delete and acquires the fresh lock after the purge re-clones
and re-populates the caches for the dead scope. PR3's purge routing must
include a scope-liveness check before clone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(opal-server): fall back to the default bundle when a clone dir vanishes mid-request

Both bundle-route except clauses (make_bundle and
_generate_default_scope_bundle) caught InvalidGitRepositoryError,
pygit2.GitError, and ValueError but missed GitPython's NoSuchPathError —
raised when a concurrent scope delete or invalid-repo recovery fully
rmtree'd the clone dir before Repo() opens it. That turned a transient
race into a 500 instead of the intended default-scope fallback. Added
NoSuchPathError to both tuples.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(opal-server): stamp clone start time after a reclone

The reclone stamp added earlier in this PR recorded completion time,
contradicting the start-time rule the fetch path documents at the top of
the same function: negotiation reflects remote state at operation START,
so a clone that started after a webhook's req_time already satisfies it,
while a completion stamp can wrongly suppress a forced refresh requested
mid-clone. Capture clone_started immediately before run_sync(
clone_repository, ...) and stamp that value in the success branch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(opal-server): reuse redact_url_in_text for webhook failure logs

The hand-rolled _CREDENTIALED_URL_RE duplicated (a weaker form of) the
shared scrubber: it missed ?token=/?access_token= query-string
credentials that redact_url_in_text's SENSITIVE_QUERY_PARAMS path
covers. Use the vetted helper instead; kept local (rather than relying
solely on the global log patcher) because the patcher only exists once
configure_logs() has installed it.

Addresses review comments:
- #923 (comment) (@zeevmoney)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(opal-server): keep the delete purge reachable and extract the purge helper

An ambiguous store outcome (record delete committed server-side, error
surfaced to the client) aborted delete_scope before the purge: the
retry then hits ScopeNotFoundError -> 204 no-op, so the clone + cache
entries leaked permanently. Run the purge in a finally so a record-
delete failure can't gate cache cleanup; the error still propagates.

Extracting the sibling-check + purge into
_purge_source_cache_if_unshared / _find_scope_sharing_source also
brings delete_scope from complexity 10 back under the <=8 project
limit, with the critical section unchanged.

Addresses review comments:
- #923 (comment) (@zeevmoney)
- #923 (comment) (@zeevmoney)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(opal-server): re-check scope liveness right before each queued sync

sync_scopes passed the stale all() snapshot object into sync_scope, so
a DELETE that landed while the scope was still queued re-cloned the
dead scope's repo and re-populated repos/repos_last_fetched/repo_locks
(leaked until restart) — reintroducing a bounded form of the leak this
series closes. Pass scope_id instead so sync_scope re-gets fresh state
right before use, shrinking the window from the rest of the sweep to
the sync itself; the delete race now surfaces as ScopeNotFoundError
and is logged at info. The remaining window (delete landing mid-sync)
is a known limitation deferred to PR3's leader-routed purge +
scope-liveness check.

Addresses review comments:
- #923 (comment) (@zeevmoney)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(opal-server): cover the clone-vanish default-bundle fallback

The NoSuchPathError -> default-bundle branch of GET /scopes/{id}/policy
(a live scope whose clone dir vanished under a concurrent
delete/recovery) had no test; lock it in alongside the pre-existing
scope-not-found fallback. Returning a retryable error (503) for the
live-scope case instead of silently substituting the default bundle is
deferred to PR3 with the delete-routing work.

Addresses review comments:
- #923 (comment) (@zeevmoney)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: David Shoen <dbshoen@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: dshoen619 <107557675+dshoen619@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants