-
Notifications
You must be signed in to change notification settings - Fork 288
fix(opal-server): memory leak — purge GitPolicyFetcher caches on scope delete + webhook task cleanup (PR2) #923
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Zivxx
merged 66 commits into
master
from
ziv/per-15156-pr2-memory-leak-fix-gitpolicyfetcher-caches-webhook-task
Jul 19, 2026
Merged
Changes from 21 commits
Commits
Show all changes
66 commits
Select commit
Hold shift + click to select a range
1f9c816
feat(opal-server): gated /internal git-fetcher cache stats endpoint
dshoen619 c176ce9
test(git-leak): add OPAL git leak/resilience test bed
dshoen619 bd49676
test(git-leak): add GiteaAdmin and make_repo_unreachable helpers
dshoen619 afeb969
test(git-leak): correct postgres-bounce framing (passes on master)
dshoen619 92353f6
style(git-leak): apply black/isort/docformatter (pre-commit)
dshoen619 db54ae6
test: scope root pytest collection to packages/ (exclude git-leak bed)
dshoen619 6f9a089
test(git-leak): address Copilot review feedback
dshoen619 1b23ac0
test(git-leak): isolate scopes per test and fix false repeat-sync gate
dshoen619 6046a10
test(git-leak): make the regression gates trustworthy (address PR rev…
dshoen619 f810db8
style(git-leak): apply black/isort/docformatter (pre-commit)
dshoen619 82ff33a
test(git-leak): tighten stat polling and pin test-bed images (PR review)
dshoen619 d719c34
Merge branch 'master' into david/per-15155-pr1-git-leakresilience-tes…
dshoen619 75ad43a
test(git-leak): isolate offline-hang healthy probe to a never-cloned …
dshoen619 15f3cfe
test(git-leak): harden harness teardown and tighten assertions (PR re…
dshoen619 a502f2e
Merge branch 'master' into david/per-15155-pr1-git-leakresilience-tes…
dshoen619 ac29c2e
feat(opal-server): add GitPolicyFetcher.forget_repo to release cached…
Zivxx b792f23
fix(opal-server): purge git fetcher caches on scope delete
Zivxx 8db1b6b
fix(opal-server): clean all finished webhook tasks (no remove-while-i…
Zivxx fdebcd5
Merge remote-tracking branch 'origin/master' into ziv/per-15156-pr2-m…
Zivxx 92c8515
fix(opal-server): actually purge fetcher caches on scope DELETE
Zivxx 8d5cfdf
style(opal-server): fix import order in delete_scope_route_test (isort)
Zivxx 89e090b
fix(opal-server): serialize the scope-delete cache purge under the re…
Zivxx 1e7df1c
fix(opal-server): surface rmtree and Repository.free() failures in sc…
Zivxx deb6a5f
fix(opal-server): retrieve webhook task exceptions before dropping them
Zivxx 36b020f
feat(opal-server): expose pid and cache keys in internal fetcher stats
Zivxx 6b44351
fix(opal-server): only stamp repos_last_fetched after a successful fetch
Zivxx b7f1ba0
test(opal-server): pin fetch-START semantics of the repos_last_fetche…
Zivxx 138deeb
fix(opal-server): make invalid-clone recovery drop the stale handle a…
Zivxx d980c9c
test(opal-server): delete-then-recreate serializes on the repo lock
Zivxx 6b07f9c
test(opal-server): stop() cancels and gathers in-flight webhook triggers
Zivxx 436a8a0
fix(opal-server): read fetcher cache stats from atomic snapshots
Zivxx b0255c6
test(opal-server): assert recovery actually free()s the stale handle
Zivxx 788ae68
test(opal-server): invariant checker + pid-stability teardown for the…
Zivxx 04273d3
docs(opal-server): document stats() merge semantics and fix stale hints
Zivxx 7358ce4
test(opal-server): bed gate for delete/recreate storms on one source
Zivxx 138dd48
test(opal-server): warm-boot gate — restart must reuse on-disk clones
Zivxx 9206590
test(opal-server): seeded randomized churn driver with invariant checks
Zivxx 779dad5
test(opal-server): characterize upstream force-push and branch-delete…
Zivxx a987bde
test(opal-server): delete-during-hung-fetch gates (no-crash green, bo…
Zivxx b0bc27b
test(opal-server): repoint-during-hung-fetch gate (red until PR3 upda…
Zivxx d5a740f
test(opal-server): system gate for corrupt-clone recovery (no re-clon…
Zivxx 52a1fe9
test(opal-server): corrupt clone contents in place so recovery exerci…
Zivxx 8ccd3ac
fix(opal-server): detect gutted object stores during clone validation
Zivxx 3839b6a
test(opal-server): orphan-dir and redis-wipe boot gates (red until or…
Zivxx f9ee0bd
test(opal-server): clean the wiped scope's clone dir so later I1 chec…
Zivxx 8d5c612
test(opal-server): boot-while-remotes-down gate (red until PR3 fetch …
Zivxx ac3f9da
test(opal-server): clean stranded blackhole clones after the boot-dow…
Zivxx a2e7359
test(opal-server): shard-reconfig boot gate (serves green, orphans re…
Zivxx 0ff543a
test(opal-server): multi-worker churn gate — every worker must drain …
Zivxx 06a0b00
docs(opal-server): correct the multi-worker leak mechanism in the chu…
Zivxx 4b7a99c
docs(opal-server): extend git-leak gate matrix with lifecycle transit…
Zivxx bf53477
test(opal-server): make stats_by_pid min_pids an actual early-exit
Zivxx 4826a7d
fix(opal-server): stop make_bundle reading the shared pygit2 handle o…
Zivxx bd7d007
fix(opal-server): serialize sibling-check and purge on the source loc…
Zivxx 0093be6
fix(opal-server): log rmtree failures in recovery and clone pre-clean
Zivxx 2596cd4
fix(opal-server): run the clone-validation probe off-loop and stamp r…
Zivxx 152eeee
fix(opal-server): redact credentialed URLs in webhook task failure logs
Zivxx aed67f7
test(opal-server): cover _get_valid_repo healthy paths and probe life…
Zivxx b796c38
fix(opal-server): purge defensively when the post-delete sibling chec…
Zivxx d8d9f6d
fix(opal-server): offload recovery rmtrees and widen the PR3 delete-r…
Zivxx 0450eac
fix(opal-server): fall back to the default bundle when a clone dir va…
Zivxx a295717
fix(opal-server): stamp clone start time after a reclone
Zivxx bd6c86d
refactor(opal-server): reuse redact_url_in_text for webhook failure logs
Zivxx bb171e6
fix(opal-server): keep the delete purge reachable and extract the pur…
Zivxx caaf713
fix(opal-server): re-check scope liveness right before each queued sync
Zivxx bf72ca5
test(opal-server): cover the clone-vanish default-bundle fallback
Zivxx File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
136 changes: 136 additions & 0 deletions
136
packages/opal-server/opal_server/tests/delete_scope_cache_purge_test.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,136 @@ | ||
| import pytest | ||
| from opal_common.schemas.policy_source import GitPolicyScopeSource, NoAuthData | ||
| from opal_common.schemas.scopes import Scope | ||
| from opal_server.git_fetcher import GitPolicyFetcher | ||
| from opal_server.scopes.scope_repository import ScopeNotFoundError | ||
| from opal_server.scopes.service import ScopesService | ||
|
|
||
|
|
||
| class FakeScopeRepository: | ||
| def __init__(self, scopes): | ||
| self._scopes = {s.scope_id: s for s in scopes} | ||
|
|
||
| async def get(self, scope_id): | ||
| if scope_id not in self._scopes: | ||
| raise ScopeNotFoundError(scope_id) | ||
| return self._scopes[scope_id] | ||
|
|
||
| async def all(self): | ||
| return list(self._scopes.values()) | ||
|
|
||
| async def delete(self, scope_id): | ||
| self._scopes.pop(scope_id, None) | ||
|
|
||
|
|
||
| def _scope(scope_id, url, branch="main"): | ||
| return Scope( | ||
| scope_id=scope_id, | ||
| policy=GitPolicyScopeSource( | ||
| source_type="git", | ||
| url=url, | ||
| branch=branch, | ||
| auth=NoAuthData(auth_type="none"), | ||
| ), | ||
| data={"entries": []}, | ||
| ) | ||
|
|
||
|
|
||
| @pytest.fixture(autouse=True) | ||
| def clear_caches(): | ||
| GitPolicyFetcher.repos.clear() | ||
| GitPolicyFetcher.repos_last_fetched.clear() | ||
| GitPolicyFetcher.repo_locks.clear() | ||
| yield | ||
| GitPolicyFetcher.repos.clear() | ||
| GitPolicyFetcher.repos_last_fetched.clear() | ||
| GitPolicyFetcher.repo_locks.clear() | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_delete_unique_scope_purges_caches(tmp_path, monkeypatch): | ||
| scope = _scope("only", "https://git/repo-a.git") | ||
| repo = FakeScopeRepository([scope]) | ||
| svc = ScopesService(base_dir=tmp_path, scopes=repo, pubsub_endpoint=None) | ||
|
|
||
| src = scope.policy | ||
| sid = GitPolicyFetcher.source_id(src) | ||
| clone_path = str(GitPolicyFetcher.repo_clone_path(tmp_path, src)) | ||
| GitPolicyFetcher.repos[clone_path] = object() | ||
| GitPolicyFetcher.repos_last_fetched[sid] = "ts" | ||
| GitPolicyFetcher.repo_locks[sid] = object() | ||
|
|
||
| monkeypatch.setattr( | ||
| "opal_server.scopes.service.shutil.rmtree", lambda *a, **k: None | ||
| ) | ||
|
|
||
| await svc.delete_scope("only") | ||
|
|
||
| assert clone_path not in GitPolicyFetcher.repos | ||
| assert sid not in GitPolicyFetcher.repos_last_fetched | ||
| assert sid not in GitPolicyFetcher.repo_locks | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_delete_keeps_caches_when_sibling_shares_source(tmp_path, monkeypatch): | ||
| a = _scope("a", "https://git/shared.git") | ||
| b = _scope("b", "https://git/shared.git") # same url+branch -> same source_id | ||
| repo = FakeScopeRepository([a, b]) | ||
| svc = ScopesService(base_dir=tmp_path, scopes=repo, pubsub_endpoint=None) | ||
|
|
||
| sid = GitPolicyFetcher.source_id(a.policy) | ||
| clone_path = str(GitPolicyFetcher.repo_clone_path(tmp_path, a.policy)) | ||
| GitPolicyFetcher.repos[clone_path] = object() | ||
| GitPolicyFetcher.repos_last_fetched[sid] = "ts" | ||
| GitPolicyFetcher.repo_locks[sid] = object() | ||
|
|
||
| rmtree_calls = [] | ||
| monkeypatch.setattr( | ||
| "opal_server.scopes.service.shutil.rmtree", | ||
| lambda p, **k: rmtree_calls.append(p), | ||
| ) | ||
|
|
||
| await svc.delete_scope("a") | ||
|
|
||
| assert rmtree_calls == [] # sibling shares the source id; clone must survive | ||
| assert clone_path in GitPolicyFetcher.repos | ||
| assert sid in GitPolicyFetcher.repos_last_fetched | ||
| assert sid in GitPolicyFetcher.repo_locks | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_delete_purges_when_sibling_shares_url_but_not_source( | ||
| tmp_path, monkeypatch | ||
| ): | ||
| """Same url, different branch, sharded clones (SCOPES_REPO_CLONES_SHARDS>1) | ||
| resolve to different source_ids -> different clone dirs. | ||
|
|
||
| Deleting one must still purge its own clone + caches; the url- | ||
| sharing sibling lives elsewhere. | ||
| """ | ||
| # shards=4: branch "main" -> index 1, "dev" -> index 3 (distinct source_ids). | ||
| monkeypatch.setattr( | ||
| "opal_server.git_fetcher.opal_server_config.SCOPES_REPO_CLONES_SHARDS", 4 | ||
| ) | ||
| a = _scope("a", "https://git/shared.git", branch="main") | ||
| b = _scope("b", "https://git/shared.git", branch="dev") # same url, diff source_id | ||
| assert GitPolicyFetcher.source_id(a.policy) != GitPolicyFetcher.source_id(b.policy) | ||
|
|
||
| repo = FakeScopeRepository([a, b]) | ||
| svc = ScopesService(base_dir=tmp_path, scopes=repo, pubsub_endpoint=None) | ||
|
|
||
| sid_a = GitPolicyFetcher.source_id(a.policy) | ||
| clone_path_a = str(GitPolicyFetcher.repo_clone_path(tmp_path, a.policy)) | ||
| GitPolicyFetcher.repos[clone_path_a] = object() | ||
| GitPolicyFetcher.repos_last_fetched[sid_a] = "ts" | ||
|
|
||
| rmtree_calls = [] | ||
| monkeypatch.setattr( | ||
| "opal_server.scopes.service.shutil.rmtree", | ||
| lambda p, **k: rmtree_calls.append(str(p)), | ||
| ) | ||
|
|
||
| await svc.delete_scope("a") | ||
|
|
||
| assert rmtree_calls == [clone_path_a] # its own clone dir removed | ||
| assert clone_path_a not in GitPolicyFetcher.repos | ||
| assert sid_a not in GitPolicyFetcher.repos_last_fetched |
97 changes: 97 additions & 0 deletions
97
packages/opal-server/opal_server/tests/delete_scope_route_test.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,97 @@ | ||
| import pytest | ||
| from fastapi import FastAPI | ||
| from fastapi.testclient import TestClient | ||
| from opal_common.schemas.policy_source import GitPolicyScopeSource, NoAuthData | ||
| from opal_common.schemas.scopes import Scope | ||
| from opal_server.git_fetcher import GitPolicyFetcher | ||
| from opal_server.scopes.api import init_scope_router | ||
| from opal_server.scopes.scope_repository import ScopeNotFoundError | ||
| from opal_server.scopes.service import ScopesService | ||
|
|
||
|
|
||
| class FakeScopeRepository: | ||
| def __init__(self, scopes): | ||
| self._scopes = {s.scope_id: s for s in scopes} | ||
|
|
||
| async def get(self, scope_id): | ||
| if scope_id not in self._scopes: | ||
| raise ScopeNotFoundError(scope_id) | ||
| return self._scopes[scope_id] | ||
|
|
||
| async def all(self): | ||
| return list(self._scopes.values()) | ||
|
|
||
| async def delete(self, scope_id): | ||
| self._scopes.pop(scope_id, None) | ||
|
|
||
|
|
||
| class FakeAuthenticator: | ||
| """Mimics a JWTAuthenticator whose verifier is disabled (no public key).""" | ||
|
|
||
| enabled = False | ||
|
|
||
| def __call__(self): | ||
| return {} | ||
|
|
||
|
|
||
| def _scope(scope_id, url, branch="main"): | ||
| return Scope( | ||
| scope_id=scope_id, | ||
| policy=GitPolicyScopeSource( | ||
| source_type="git", | ||
| url=url, | ||
| branch=branch, | ||
| auth=NoAuthData(auth_type="none"), | ||
| ), | ||
| data={"entries": []}, | ||
| ) | ||
|
|
||
|
|
||
| def _client(repo, base_dir): | ||
| service = ScopesService(base_dir=base_dir, scopes=repo, pubsub_endpoint=None) | ||
| app = FastAPI() | ||
| app.include_router( | ||
| init_scope_router(repo, FakeAuthenticator(), None, service), | ||
| prefix="/scopes", | ||
| ) | ||
| return TestClient(app) | ||
|
|
||
|
|
||
| @pytest.fixture(autouse=True) | ||
| def clear_caches(): | ||
| GitPolicyFetcher.repos.clear() | ||
| GitPolicyFetcher.repos_last_fetched.clear() | ||
| GitPolicyFetcher.repo_locks.clear() | ||
| yield | ||
| GitPolicyFetcher.repos.clear() | ||
| GitPolicyFetcher.repos_last_fetched.clear() | ||
| GitPolicyFetcher.repo_locks.clear() | ||
|
|
||
|
|
||
| def test_delete_route_purges_fetcher_caches(tmp_path, monkeypatch): | ||
| """DELETE /scopes/{id} must flow through ScopesService.delete_scope so the | ||
| GitPolicyFetcher caches drain (the git-leak churn gate).""" | ||
| scope = _scope("only", "https://git/repo-a.git") | ||
| repo = FakeScopeRepository([scope]) | ||
|
|
||
| sid = GitPolicyFetcher.source_id(scope.policy) | ||
| clone_path = str(GitPolicyFetcher.repo_clone_path(tmp_path, scope.policy)) | ||
| GitPolicyFetcher.repos[clone_path] = object() | ||
| GitPolicyFetcher.repos_last_fetched[sid] = "ts" | ||
|
|
||
| monkeypatch.setattr( | ||
| "opal_server.scopes.service.shutil.rmtree", lambda *a, **k: None | ||
| ) | ||
|
|
||
| resp = _client(repo, tmp_path).delete("/scopes/only") | ||
|
|
||
| assert resp.status_code == 204 | ||
| assert clone_path not in GitPolicyFetcher.repos | ||
| assert sid not in GitPolicyFetcher.repos_last_fetched | ||
|
|
||
|
|
||
| def test_delete_route_missing_scope_stays_204(tmp_path): | ||
| """Deleting a nonexistent scope was a silent no-op (204) before the purge | ||
| wiring and must remain one.""" | ||
| resp = _client(FakeScopeRepository([]), tmp_path).delete("/scopes/ghost") | ||
| assert resp.status_code == 204 |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.