Skip to content

fix(opal-server): git resilience — never stuck on an offline repo (PR3) - #924

Merged
Zivxx merged 122 commits into
masterfrom
david/per-15157-pr3-git-resilience-never-stuck-on-an-offline-repo
Aug 18, 2026
Merged

fix(opal-server): git resilience — never stuck on an offline repo (PR3)#924
Zivxx merged 122 commits into
masterfrom
david/per-15157-pr3-git-resilience-never-stuck-on-an-offline-repo

Conversation

@dshoen619

@dshoen619 dshoen619 commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

PR3 — Git resilience: never stuck on an offline repo

Closes PER-15157 and PER-15158 (PR4 #932 is closed; its parallel-boot charter is delivered here — see §5).

This description was rewritten from the code at c6899e11. The previous one had drifted several rounds out of date — it described an orphan sweep that is no longer in this PR, asserted an invariant the code has never held, and listed 3 of 6 config keys. Reviewers were reading it as the spec. If anything below disagrees with the code, the code is right and this is a bug.

1. Git resilience — a hung remote can't starve healthy scopes

Scope clone/fetch used pygit2 with no timeout, on the shared default executor (POLICY_REPO_CLONE_TIMEOUT is wired only to the legacy non-scopes path). Boot blocked indefinitely on one unreachable repo; in steady state a hung op held the per-source lock and a shared-pool thread.

  • Soft per-operation timeout (SCOPES_GIT_FETCH_TIMEOUT) on clone and fetch. Soft is load-bearing and stated everywhere: it unblocks the event loop and the awaiting coroutine, not the thread. The pinned pygit2 binds no server-read-timeout setting (libgit2 1.7.2 itself has GIT_OPT_SET_SERVER_TIMEOUT; pygit2 1.14 does not expose it), so the underlying call keeps running on its own daemon thread until the OS gives up.
  • Zombie-aware executor. Each op gets its own single-use daemon-thread executor. SCOPES_GIT_MAX_WORKERS bounds only live ops via a semaphore, and a timed-out op releases its slot immediately — that is the real content of "one hung remote can't block others", and the test_offline_repo_does_not_block_healthy_scopes gate.
  • SCOPES_GIT_MAX_ZOMBIES is a global ceiling, not a per-source guard. It counts live plus lingering ops across all sources; at the ceiling, new git ops are refused for every scope until threads drain. Per-source protection is separate (a source with an op in flight is skipped). Documented as such — an earlier description claimed "capacity is never starved by hung remotes", which is false at saturation.
  • git_op_in_flight(source_id) guards every path that frees a pygit2 handle or deletes a clone dir. Enumerated: 10 call sites, all guarded in-process.

Fork safety (not previously described, and it is where several of the nastiest fixes live): the pre-fork preload drains in-flight ops (SCOPES_GIT_PRELOAD_DRAIN_TIMEOUT), tears down the git executor, and clears the fetcher caches, so the gunicorn master does not carry git state — handles, locks, in-flight markers — across the fork into workers. _DaemonThreadPoolExecutor mirrors CPython concurrent.futures internals and shape-checks every private it touches, falling back to the stdlib on mismatch.

2. Fleet-wide memory purge — two-phase

PR2's purge was process-local: only the worker serving the DELETE dropped its caches, so the leader — which populates most of repos/repo_locks/repos_last_fetched — leaked until restart.

New every-worker channel SCOPES_PURGE_CHANNEL, two-phase:

  1. Routes publish a purge request (confirmed=False).
  2. Only the leader acts on requests: it is the only actor that can sibling-check (it reads the store), and acting on a raw request would drop caches a surviving sibling scope still uses. Runs in a background task, never on the publish path (publish() awaits subscriber callbacks inline, so an inline handler would put a lock wait on DELETE/PUT latency).
  3. The leader broadcasts the confirmation (confirmed=True), and that is what every worker's memory handler acts on.

The confirmation is published while holding lock_source: it frees this process's cached handle via the inline local subscriber, so releasing first opens a use-after-free against a re-created scope's _notify_on_changes. The repo_locks entry is popped before the publish for the same reason — the handler re-enters lock_source.

Repoint: PUT /scopes with a changed source_id publishes a purge for the old source. Note it has no local floor (see §3), so a lost broadcast on repoint strands the old clone dir.

Mixed-fleet safe: old workers aren't subscribed, confirmed is additive with a False default, external RPC peers are rejected on the channel, and a forged clone_path never reaches the filesystem (every handler re-derives from source_id).

3. Clone-dir removal — what this PR does and does not do

The distributed disk reclaim was cut out of this PR and is PER-15612, together with the orphan sweep. The seam is memory vs disk: a memory purge self-heals in both directions (a wrongly-dropped handle re-opens on next use); disk reclaim has no self-healing direction — under-purge leaks forever, over-purge forces a re-clone — and needs a cross-process design agreed before implementation.

So there is exactly one path in the server that removes a clone dir: a best-effort local purge on the worker that served the DELETE, which is what master did inline. It sibling-checks, bounds its store read, skips while a git op is in flight (master freed the handle unconditionally — that is the use-after-free 89e090be fixed), and keeps the clone if the check fails.

Its guards are process-local (lock_source is an asyncio.Lock; the in-flight marker is a module-global set), so it is not serialized against the leader cloning the same source in a sibling process on the same pod. This is not a regression: running the same two-process race against origin/master produces the same orphan, and master has no in-flight guard at all and no broadcast to reclaim it. It is also not an invariant — the previous description's "only the leader mutates the clone tree" was never true, and that claim has been removed from the code as well.

Two behaviour changes against the merge base that belong here rather than only in code comments:

  • Master serialized the record delete and the sibling check under one lock, so the last of two concurrent sibling deleters always saw no sharer. The floor takes the lock later, in a background task, so an unlucky interleaving can have both deleters see the other as live and both skip. Tracked with the rest of the reclaim work on PER-15612.
  • The floor skips while a git op is in flight, where master removed the dir unconditionally. That is the safe direction — master's version is the use-after-free 89e090be fixed — but it means deleting a scope whose remote is hung now leaves the dir on every pod, including the serving one, where master would have removed it.

What nothing reclaims, stated plainly:

  • a DELETE's dir on every pod except the serving one, when the broadcast is lost — and it is droppable at shipped defaults (a DELETE usually lands on a non-leader worker and must traverse the broadcaster, while a leader keeps a reader alive only with a connected client or STATISTICS_ENABLED, default False);
  • a REPOINT's old dir, always;
  • a dir whose source_id is unknowable because the prior record would not parse.

All three are PER-15612, and all three are stated at the point they occur in the code (scopes/purge.py, scopes/api.py) rather than only here.

4. Scope-liveness check before clone

A sync that loaded a scope before its DELETE could re-clone the dead scope and re-populate the caches. GitPolicyFetcher now takes a liveness probe, checked under lock_source immediately before the clone; ScopesService supplies one that re-reads from Redis and confirms the scope still points at this source. Fails open on store errors. Residual: the probe is a point-in-time check and the window is the whole clone, so a delete landing mid-clone can still be re-created by the finishing clone.

5. Parallel scope loading — PR4's charter

sync_scopes was serial: one await sync_scope(...) per scope, giving a ~20-minute boot on a fleet of repos — and once §1 added a per-fetch timeout, a serial loop would still stall healthy scopes behind each unreachable repo's timeout. The two ship together, which is why PR4 is closed as redundant.

Two phases, each asyncio.gather under its own semaphore, both bounded by SCOPES_GIT_MAX_WORKERS: phase 1 clones/fetches each distinct repo, phase 2 change-checks the scopes that reuse an already-handled repo. (An earlier design gave phase 2 a wider max(N, 32) floor; that was removed in review because 32 was a floor the knob could never lower, and sync_scopes_perpass_test.py now pins its absence.) Per-scope failures stay isolated. The boot path gets this too — preload_scopes() calls the same sync_scopes.

Measured head-to-head, and the win is large. A local two-image comparison exists — same fleet, same hung model, one image built pre-PR3 and one from this tree — in permit-backend@ziv/opal-staging-test-kits, commit 17874dae3 (v2/backend/scripts/opal-tests-and-results/prs-3-4-boot-improvements/, with scripts, RUNBOOK and results):

config boot to all-healthy-served preload sweep
old — serial sync_scopes, no fetch timeout 306.2s 302.3s
new@120 — this PR, prod-default timeout (concurrency alone) 53.6s 50.1s
new@15 — this PR + tuned timeout 43.2s 30.1s

30 healthy + 12 hung repos interleaved, SCOPES_GIT_MAX_WORKERS=10. The hung repos use a slowfail sidecar that accepts the handshake, sleeps ~25s, then closes — an image-independent finite failure, so both images complete and the numbers are comparable (all three configs logged 12/12 clone failures). Preload runs synchronously in the gunicorn master before any worker forks, so time-to-serve-all-healthy is the whole boot sweep. 5.7x from concurrency alone; 7.1x with a tuned timeout.

Two honest caveats. The baseline image is 76f898ec, which predates PR2 (#923); PR2 did not touch sync serialization, so the gap is this PR's, but the baseline is not literally the merge-base. And app-tests/git-leak/test_boot.py is still not a gate — BOOT_TARGET_SECONDS defaults to 2000, and tightening it to the planned 120s @ 50 scopes would not discriminate: measured on this branch and on the merge-base, an all-healthy fleet boots in 4.3s and 4.9s respectively. Healthy clone throughput was never the problem; the hung tail is, which is what the head-to-head measures and what the prod telemetry says.

Why the phase-2 bound is not a scale risk. Most scopes in a Permit deployment share one policy repo, so at prod scale nearly all of them land in phase 2. That phase was separately profiled (.superpowers/sdd/boot-prod-report.md, a local sweep at ~3MB/branch across four SCOPES_REPO_CLONES_SHARDS values — note that path is gitignored, so the numbers are reproduced here rather than linked): the duplicate-scope pass costs ~1.6ms per scope and is flat in content size (it touches only ref/commit metadata — resolve_refish, one commit object — never a tree walk or blob read), holding sub-millisecond in a bare pygit2 benchmark out to 2000 branches and ~180MB repos. At ~2000 scopes that is ~3s, so phase 2 cannot be concurrency-bound and narrowing its bound from max(N, 32) to max(1, N) is immaterial. The genuinely expensive per-scope cost — make_bundle, ~71ms locally and ~350ms at prod file counts — is lazy, on GET /scopes/{id}/policy, and never runs during preload.

OPAL_SCOPES_SYNC_CONCURRENCY, which PR4 promised as an operator lever, does not exist — SCOPES_GIT_MAX_WORKERS is the single knob, deliberately, to avoid a second easily-misconfigured bound.

6. ⚠️ Two contract changes

GET /scopes/{scope_id}/policy:

Case Before After
Scope record missing, no default scope unhandled 500 404
Scope record missing, default exists and serves default scope's bundle unchanged (see below)
Scope record missing, default exists but its clone is transiently broken unhandled 500 503 + Retry-After: 5
Record present, clone invalid/vanished default scope's bundle 503 + Retry-After: 5
Record present, raw OSError unhandled 500 503 + Retry-After: 5
Record present, clone in progress held up to SCOPES_POLICY_CLONE_WAIT_SECONDS (20s) and served if the clone lands; else 503 + Retry-After: 30 — see §7
Record present, branch genuinely unresolvable default scope's bundle 409, non-retryable

A live tenant was briefly served another tenant's policy; the condition is transient by construction, so a retryable error is the honest answer. The mid-clone case is separated out because _clone() clones into the final path, so during a recovery re-clone the branch legitimately does not exist yet — reporting that as a permanent config error is the opposite of the truth. The two Retry-After values are constants, not escalating: escalation would need per-client retry state on a stateless endpoint.

Consumer note: opal-client wraps its fetch in a tenacity retry with no retry= predicate, so it retries every non-200 regardless — including the 409 it is told is non-retryable. Direct/third-party consumers are the ones that will act on the advice. Also: a scoped client that has never completed a fetch now stays NotReady rather than coming up on the default scope's bundle — correct, but visible to k8s readiness gates on upgrade.

Known, unchanged, and still wrong: a missing record still serves the default scope's bundle at 200 while the caller is authorized only for the requested scope. That is a cross-tenant hand-off and it is the same thing the 503 above exists to prevent. Left alone to keep this PR scoped; recorded as characterization (not endorsement) in scope_policy_fallback_test.py. It needs its own ticket — it is currently pointed at PER-15157, which this PR closes, so merging as-is orphans it. Both fixes (404, or gating the fallback on the caller being authorized for default) change behaviour for anyone relying on it today, which is why it is not being done here.

7. Server-side clone wait — so the client needs no change

GET /scopes/{id}/policy no longer answers 503 the instant it finds a clone that is still being populated. It holds the request for up to OPAL_SCOPES_POLICY_CLONE_WAIT_SECONDS (default 20s), re-checking once a second, and returns the bundle the moment the clone is usable. On expiry it falls through to the unchanged answer: 503 + Retry-After: 30, same ScopePolicyUnavailable event, with the hold added to its log line. The default-scope path (unknown scope id) waits on the same terms and keeps its own contract on expiry (Retry-After: 5).

Why. The 503 is correct but the PDP does not act on it: opal-client 0.9.6 ignores Retry-After, makes five attempts with random-exponential backoff capped at 10s (~20–40s of coverage), then goes quiet until the next pub/sub policy message or a reconnect. A clone that outlives those attempts leaves that PDP with no policy and nothing scheduled to fix it — and the update-all published when the clone completes names only the scope that was syncing, so siblings sharing the clone are never woken. Holding the request converts that gap into latency the client already tolerates: five client attempts against a 20s hold cover ~2 minutes of clone time, so short and medium re-clones (the download phase) produce no client-visible gap. This is why no opal-client change is required for the rollout.

What is bounded. The wait plus at most one more bundle attempt. Time queued behind other bundle builds on the shared executor is outside the deadline — that is what the cap below bounds. 0 or negative disables the wait; nan, inf and -inf are treated as disabled (they parse and start the process); anything above 55s is clamped (below the 60s ALB idle timeout, warned once per process), because a hold longer than that idle timeout is served as a 504 rather than a bundle.

Why it is safe at fleet scale. Polling is cheap (~0.23 ms per check); releasing is not. When the clone lands, every held request builds a full bundle on the loop's default executor — ≈min(32, cpu+4) threads with an unbounded queue, shared by every off-loop call in the process — whose measured throughput falls from ~52 bundles/s at 32 concurrent builds to ~18/s at 1000. Scope git ops do not share that pool (each gets a single-use executor bounded by a semaphore), so this is the only bound on concurrent bundle builds. Unbounded, a pod holding thousands of PDPs turns the hold into a queue that outlives the ALB idle timeout, and a rolling restart drains worse (uvicorn waits for in-flight requests; gunicorn SIGKILLs at 30s, dropping that worker's websockets). So OPAL_SCOPES_POLICY_CLONE_WAIT_MAX_INFLIGHT (default 64, per worker process; 0 = no cap) bounds how many requests one process may hold at once. The excess is shed with exactly the answer it would have got before the wait existed — an immediate 503 + Retry-After: 30 — so the cap is never worse than not waiting. Size it so cap ÷ achievable bundles-per-second fits inside 60s minus the hold. Note the arithmetic at fleet scale: 12 workers × 64 = 768 requests held fleet-wide, so a full-fleet herd is mostly shed to the old immediate 503 — the feature's value is the common case (one shard, a few hundred PDPs), not a total restart.

A client that has already disconnected stops being waited for: the wait checks is_disconnected() once per poll and releases the slot to a caller that is still listening (no event, no 503 log — the reply goes nowhere).

How it stays correct. Readiness is disk-derived (CloneNotPopulatedError = no remote-tracking refs yet), never the in-process in-flight marker — the clone runs in the leader while this route is served by any worker, so only disk gives every worker the same answer. The hold is an awaited sleep loop clamped to the remaining budget (NaN-safe): no thread between polls, no lock, no cache touched, cancellation-safe. Anything a retried attempt raises reaches the handlers the first attempt would (absent branch → 409, gutted object store → 503 + Retry-After: 5); only an unpopulated clone is waited for, and the client's base_hash is re-sent on every poll so a rescued request still gets its diff bundle.

New metrics.

metric type tags
opal_server.scopes.policy_clone_wait count `outcome:served
opal_server.scopes.policy_clone_wait_inflight gauge pid — requests this worker is holding right now, against the cap
opal_server.scopes.policy_clone_wait_seconds gauge `outcome:served

Follow-up (not in this PR): --timeout-graceful-shutdown 15 on the gunicorn line in scripts/start.sh would let uvicorn cancel held requests on shutdown instead of waiting on them; the cancelled accounting is already in place for it. Deliberately not here because it changes shutdown semantics image-wide, for every request type.

8. Per-source backoff on the periodic pass — dead repos stop being hammered

Nothing recorded a git failure: a source whose clone or fetch failed was re-attempted on the very next pass, and so was every duplicate scope sharing it (no local copy → straight to clone). On prod-us that is 19 fast-failing GitOps sources producing 4,579 clone attempts in three hours, plus two SYN-drop hosts each holding a slot for the full fetch timeout every pass. The in-flight skip only covers a source whose op is still running (the hung class); the faster pass this PR ships would multiply the fast-fail class.

Behaviour. Every awaited clone/fetch failure (GitError or TimeoutError, either path) puts its source into a per-process backoff: first delay OPAL_SCOPES_GIT_BACKOFF_BASE_SECONDS (default 10 s), doubling on every consecutive failure with no ceiling — 10 s, 20 s, 40 s, … minutes, hours, days. A repository that has been unreachable for a day is checked again in two, then four, and before long only at the next restart or an explicit refresh: a repo that keeps failing is, in all likelihood, dead. (OPAL_SCOPES_GIT_BACKOFF_MAX_SECONDS is an optional cap for operators who prefer to bound the staleness of a repo that comes back on its own; default 0 = none.) The first few doublings are shorter than a pass period and skip nothing — the schedule bites from roughly the fourth failure. Only pass-originated syncs honour it — the periodic pass and the boot preload, in both phases; the check runs both before lock_source (a skipped source costs no lock and no executor slot, so it can never be refused by the zombie cap) and again under it (so N duplicates of a source arriving together cost one attempt, not min(N, MAX_WORKERS), regardless of how short the delay is). An explicit POST /scopes/{id}/refresh, POST /scopes/refresh or PUT /scopes attempts the source immediately, and any successful clone or fetch clears the entry. Backpressure from the zombie cap is not recorded (it says nothing about the remote). No jitter: at dozens to a few hundred sources per pod, with the pass bounded by SCOPES_GIT_MAX_WORKERS, lockstep retries are not a concern.

Trade-off stated plainly. Because the delay is unbounded, a GitOps repo that recovers on the git side without anyone touching its scope in Permit stays untried for as long as its delay has grown to — after a day of failures ~2 days, after a week ~2 weeks — until a pod restart (state is in-memory) or an explicit refresh. That is the intended behaviour: the customer's policy was already stale, and a fixed repo normally comes with a Permit-side change that issues the bypassing refresh.

Boot. With a periodic pass configured (POLICY_REFRESH_INTERVAL > 0, Permit's setting) the forked leader inherits what the pre-fork preload recorded, so it does not re-hammer repos that already failed at boot. Without one (the OSS default) the boot sync is the only pass-originated sync, so it drops the inherited entries and attempts every source once — duplicates of a source that fails in that pass are still collapsed to one attempt.

Kill switch / state. SCOPES_GIT_BACKOFF_BASE_SECONDS ≤ 0 (or nan/inf) disables the feature: nothing recorded, nothing skipped, gauge reads 0. In-memory, per process, mutated only on the event loop; the purge/delete paths drop it with the source's other caches. Keys are read at import: flipping needs a pod restart, which also clears the state — a clean revert.

Metrics / logs. opal_server.scopes.git_op_skipped{reason:backoff} (counter, per skipped pass), opal_server.scopes.sources_in_backoff{pid} (gauge of sources currently being skipped, emitted on every transition and once per pass); WARNING when a source enters backoff, again when its delay first exceeds a day (from then on effectively abandoned), and when a configured cap is reached (Backing off {url} for {delay}s after {n} consecutive failures: {err} — carries the redacted URL, which is how an operator finds which repo); DEBUG for other recorded failures and every skip; INFO on recovery. Also fixes a gap: fetch GitError is now counted in git_op_failures{op:fetch,reason:git_error} — that series appears from zero on deploy; it is not a regression.

Expected effect on prod-us: the 19 fast-fail sources go from one attempt per pass each to a handful per day within the first hour, then ~one per day, then rarer; the two 132 s hosts likewise; the timer pass becomes ~1 s in steady state and the sync_scope p90 falls to seconds. Caveat: attempts originating from explicit POST /scopes/{id}/refresh (which policy-sync issues) bypass the backoff by design and are not reduced.

Runbook. Symptom: sources_in_backoff > 0 or git_op_skipped{reason:backoff} non-zero. Identify the repos: search opal-server logs for Backing off (WARNING). After fixing the remote, POST /scopes/{scope_id}/refresh (or /scopes/refresh) retries immediately; a pod restart also resets every delay. Disable: OPAL_SCOPES_GIT_BACKOFF_BASE_SECONDS=0 + restart. Note for the existing sync_scope p90 alarm: skipped scopes still emit a ~0 ms span, which dilutes that percentile — the counter-based monitors are the intended replacement.

New config keys (opal-server, additive)

Env var Default Purpose
OPAL_SCOPES_GIT_FETCH_TIMEOUT 120.0 Soft timeout for one scope clone/fetch. 0 = no limit
OPAL_SCOPES_GIT_MAX_WORKERS 10 Bounds live git ops; bounds both sync phases
OPAL_SCOPES_GIT_MAX_ZOMBIES 40 Global ceiling on in-flight (live + lingering) ops. At the ceiling, new ops are refused for every scope
OPAL_SCOPES_GIT_PRELOAD_DRAIN_TIMEOUT 10.0 Pre-fork drain window for in-flight git ops
OPAL_SCOPES_PURGE_CHANNEL __opal_scope_purge__ Worker-to-worker cache-purge channel
OPAL_SCOPES_STORE_READ_TIMEOUT 10.0 Bounds the sibling-check store read taken under lock_source
OPAL_SCOPES_POLICY_CLONE_WAIT_SECONDS 20.0 How long GET /scopes/{id}/policy holds a request while the scope's clone is being populated before answering 503. 0/negative/non-finite = no wait; clamped to 55s (§7)
OPAL_SCOPES_POLICY_CLONE_WAIT_MAX_INFLIGHT 64 Per-worker cap on concurrently held requests; excess get the immediate 503. 0 = no cap (§7)
OPAL_SCOPES_GIT_BACKOFF_BASE_SECONDS 10.0 First delay of the per-source doubling backoff the periodic pass applies to a source that keeps failing (uncapped by default); explicit refresh/PUT bypass it. 0/negative/non-finite = feature disabled (§8)
OPAL_SCOPES_GIT_BACKOFF_MAX_SECONDS 0.0 Optional cap on that doubling; 0 = none. Floored at the base when set (§8)

Descriptions are pinned verbatim against documentation/docs/getting-started/configuration.mdx by config_docs_drift_test.py, in both directions.

One default is not behaviour-preserving. The scopes path previously had no fetch cap at all (POLICY_REPO_CLONE_TIMEOUT is wired only to the legacy non-scopes path), so SCOPES_GIT_FETCH_TIMEOUT=120.0 means a clone that legitimately takes longer now times out on its first pass. It self-heals — the abandoned thread finishes the clone on disk, the in-flight guard skips one cycle, and the pass after finds a valid repo — at the cost of one wasted clone and a scope not serving for a refresh interval or two. Operators with very large repos should raise it before upgrading.

Worst-case boot delay at the shipped defaults. A hung op holds its concurrency slot for the full timeout, and preload_scopes runs synchronously in the gunicorn master before any worker forks, so a burst of unreachable sources delays boot by roughly ceil(hung / SCOPES_GIT_MAX_WORKERS) x SCOPES_GIT_FETCH_TIMEOUT — 40 hung sources at the defaults is ~480s of pre-fork time serving nothing. SCOPES_GIT_FETCH_TIMEOUT is the knob; the head-to-head above used a 25s failure, not a true black hole.

Invariants (enforced and tested)

  1. repo_locks entries are popped only while holding that source's lock, and every path that abandons a source drains its entry — except a path that finds a live sibling, which leaves it alone. One deliberate exception: reset_caches clears the whole dict without holding anything, in the gunicorn master after asyncio.run has returned and before the fork, where it is single-threaded and there is nothing to serialize against.
  2. forget_repo / rmtree never free a cached, shared handle while a git op is in flight for that source (all call sites guarded in-process). Two sites free a handle they opened themselves and share with nobody — _get_valid_repo's probe and _get_current_branch_head's fresh handle — and are deliberately unguarded; opening fresh is precisely how the serving path avoids the shared-handle race.
  3. The leader is the only mutator on sync paths. A delete additionally removes on the worker that served it — see §3.

⚠️ Consumer surface

python_requires moves from >=3.9 to >=3.9,<3.13 in opal-server, opal-common and opal-client. On Python 3.13+, pip install of any of them silently resolves to the last release published without the cap and reports success — pip filters candidates by Requires-Python and backtracks to the newest compatible one. Every published opal-client is currently uncapped, so a downstream on 3.13 running pip install -U opal-client gets 0.9.6 and stays there, with nothing surfacing until a fix appears not to have arrived. Only an explicit pin (opal-client==<new>) fails outright. Silent downgrade is the worse of the two outcomes and is the one to warn users about. Docker images are python:3.10 and unaffected.

On opal-server the cap is not a choice: it depends on pygit2>=1.14.1,<1.15, and that range has no 3.13 wheels at all (the first is 1.16.0). Lifting it there means bumping pygit2 and re-verifying _DaemonThreadPoolExecutor, which mirrors CPython concurrent.futures internals — out of scope here, and worth its own ticket.

opal-common and opal-client do not depend on pygit2 and are capped to match. That is deliberate rather than incidental: CI tests 3.9–3.12 only and the classifiers already stopped at 3.12, so lifting it would advertise support nothing exercises.

Also on the consumer surface: external RPC peers can no longer subscribe to ALL_TOPICS. The purge channel's authorization gate rejects it, because notify() fans every topic into the ALL_TOPICS bucket, so an ALL_TOPICS subscriber would otherwise receive purge traffic. No in-repo opal-client uses it (only the broadcaster, which passes channel=None and is unaffected), but third-party pub/sub consumers that do will now get Unauthorized.

No OPAL_* key renamed or removed.

Verification

  • opal-server unit: 239 passed. opal-common: 93 passed. pre-commit clean.
  • app-tests/git-leak: of 22 rows, 18 pass outright. Expected red, by design: the three orphan-sweep gates (PER-15612). test_server_recovers_after_postgres_bounce passes (a)–(c) with (d) intermittently red at the same rate on the merge-base — a production property of the reader, owned by fix(opal-server): freeze publishes during broadcaster backbone gaps to keep the fleet consistent #933.
  • New behaviour is mutation-verified: each pin is checked to fail under the mutation named in its own docstring, and to fail only that.

dshoen619 and others added 3 commits June 23, 2026 14:19
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wire scope clone/fetch through run_in_git_executor with
SCOPES_GIT_FETCH_TIMEOUT, and broaden the _clone except to catch
asyncio.TimeoutError so a hung clone is logged and the scope skipped
instead of crashing the caller. Drop the now-unused run_sync import.

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-15157

PER-15158

@netlify

netlify Bot commented Jun 23, 2026

Copy link
Copy Markdown

Deploy Preview for opal-docs ready!

Name Link
🔨 Latest commit fcf5840
🔍 Latest deploy log https://app.netlify.com/projects/opal-docs/deploys/6a8424c82de8be0008b6fef2
😎 Deploy Preview https://deploy-preview-924--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
dshoen619 marked this pull request as draft June 23, 2026 11:35
@dshoen619
dshoen619 requested a review from Copilot June 24, 2026 16:59

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

This PR improves opal-server resilience when syncing scope policy repos by ensuring git clone/fetch operations can’t block indefinitely or starve the server’s shared executor.

Changes:

  • Add a dedicated, bounded ThreadPoolExecutor and run_in_git_executor(...) helper to run blocking pygit2 operations with an asyncio.wait_for timeout.
  • Apply the helper + new timeout config to scope repo clone and fetch paths.
  • Add server config keys for timeout and executor sizing, plus focused unit tests for timeout behavior.

Reviewed changes

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

Show a summary per file
File Description
packages/opal-server/opal_server/git_fetcher.py Introduces dedicated git executor + timeout helper; routes scope clone/fetch through it.
packages/opal-server/opal_server/config.py Adds SCOPES_GIT_FETCH_TIMEOUT and SCOPES_GIT_MAX_WORKERS configuration.
packages/opal-server/opal_server/tests/git_executor_test.py Tests config defaults and run_in_git_executor basic behavior.
packages/opal-server/opal_server/tests/fetch_timeout_test.py Tests that a hanging git op times out quickly (doesn’t block).
.claude/plans/docs/05-config-reference.md Internal config reference entry for the new env vars and caveat.

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

Comment thread packages/opal-server/opal_server/git_fetcher.py Outdated
Comment thread packages/opal-server/opal_server/git_fetcher.py Outdated
Comment thread packages/opal-server/opal_server/tests/fetch_timeout_test.py Outdated
dshoen619 and others added 2 commits June 24, 2026 20:09
On Python < 3.11 asyncio.TimeoutError is a distinct class from the
builtin TimeoutError, so run_in_git_executor's wait_for timeout was not
caught by `pytest.raises(TimeoutError)` — failing build (3.9)/(3.10).
Normalize to the builtin TimeoutError so the documented contract holds
on every supported Python, and update the _clone catch site to match.

Also apply black/isort/docformatter formatting to satisfy pre-commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- run_in_git_executor: use asyncio.get_running_loop() instead of the
  deprecated get_event_loop() inside an async function
- fetch_and_notify_on_changes: set repos_last_fetched only after a
  successful fetch so a timeout/error does not wrongly suppress a later
  force_fetch via _was_fetched_after
- fetch_timeout_test: measure elapsed time with time.monotonic()

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@dshoen619
dshoen619 marked this pull request as ready for review June 24, 2026 17:15
@dshoen619 dshoen619 self-assigned this Jun 24, 2026
The fetch path let TimeoutError propagate to sync_scope's catch-all,
which logged a full traceback at ERROR level for the expected
unreachable-repo case — inconsistent with the clone path's quiet
logger.error. Catch TimeoutError at the fetch site and log without a
traceback, then skip (repos_last_fetched stays stale so the next cycle
retries). Also shorten the hanging-thread sleeps in the timeout tests so
the lingering pool thread doesn't delay process teardown.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@dshoen619
dshoen619 requested review from Zivxx and zeevmoney June 24, 2026 17:23
@zeevmoney

Copy link
Copy Markdown
Contributor

Review notes — overlap + gate location (planned with the opal-development skill: references/add-config-key.md, references/debug-pubsub.md)

Faithful to the PR3 plan, with two improvements over it: normalizing asyncio.TimeoutError → builtin TimeoutError (so callers catch the builtin on 3.9/3.10 where they're distinct), and moving repos_last_fetched to after a successful fetch — a timed-out fetch no longer falsely marks the source "fresh," and it's lock-safe because _should_fetch runs inside the per-source repo_lock. The two config keys follow references/add-config-key.md (server-only OpalServerConfig, bare names, mandatory descriptions, no double-prefix).

Three things to resolve:

  1. Direct overlap with Fix git clone/fetch hanging indefinitely on unreachable repos #875 (PER-13817). Same root cause — pygit2 clone_repository / remotes.fetch going through run_sync with no timeout — and the same edited blocks in git_fetcher.py (_clone and fetch_and_notify_on_changes). They cannot both merge; whichever lands second will conflict. This PR is the stronger of the two:

    Recommend closing Fix git clone/fetch hanging indefinitely on unreachable repos #875 in favor of this PR.

  2. Its regression gate lives in PR1 (test(opal-server): git leak/resilience test environment (PR1) #922). test_offline_repo_does_not_block_healthy_scopes is the fail-now/pass-after gate for this fix and only exists on test(opal-server): git leak/resilience test environment (PR1) #922. So this can't be validated end-to-end until test(opal-server): git leak/resilience test environment (PR1) #922 merges (or is merged into this branch). Suggested order: test(opal-server): git leak/resilience test environment (PR1) #922 → this.

  3. This PR is currently check-blocked by branch protection (required checks / review), not by a merge conflict — needs CI green + an approval.

Minor:

  • The dedicated pool reads SCOPES_GIT_MAX_WORKERS once, lazily, and caches the executor for the process lifetime — it isn't runtime-reconfigurable and is never shut down. Matches the plan's design; worth a one-line note in the docstring.
  • The line numbers cited in .claude/plans/docs/05-config-reference.md for the two keys are stale vs where they actually land on this branch (cosmetic).

- git_fetcher: document that the dedicated scope-git ThreadPoolExecutor
  reads SCOPES_GIT_MAX_WORKERS once on first use, caches for the process
  lifetime (not runtime-reconfigurable), and is never explicitly shut
  down — matches the PR3 design.
- 05-config-reference: fix stale config.py line refs after the master
  merge shifted the keys — SCOPES_GIT_FETCH_TIMEOUT 150-156 -> 196-202,
  SCOPES_GIT_MAX_WORKERS 157-163 -> 203-209.

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

Copy link
Copy Markdown
Contributor Author

Thanks @zeevmoney — addressed in bad21c1.

Minor items

  • Executor lifetime docstring — added a note to _get_git_executor: SCOPES_GIT_MAX_WORKERS is read once on first use, the executor is cached for the process lifetime (not runtime-reconfigurable) and is never explicitly shut down, matching the PR3 design.
  • Stale line numbers — good catch. They'd drifted after the master merge shifted config.py. Fixed the 05-config-reference.md refs: SCOPES_GIT_FETCH_TIMEOUT 150-156196-202, SCOPES_GIT_MAX_WORKERS 157-163203-209.

Things to resolve

  1. Overlap with Fix git clone/fetch hanging indefinitely on unreachable repos #875 (PER-13817) — agreed this is the stronger fix (default-on OPAL_SCOPES_GIT_FETCH_TIMEOUT=120, dedicated bounded pool, best-effort boot). Plan is to close Fix git clone/fetch hanging indefinitely on unreachable repos #875 in favor of this.
  2. Regression gate in test(opal-server): git leak/resilience test environment (PR1) #922 — agreed; the fail-now/pass-after gate (test_offline_repo_does_not_block_healthy_scopes) lives on PR1, so the plan is to land test(opal-server): git leak/resilience test environment (PR1) #922 → this and validate end-to-end there.
  3. Check-blocked — required checks (E2E, builds 3.9–3.12, pre-commit) were green on the prior head and are re-running on bad21c1e; the remaining blocker is the required approving review. A review once it's green would unblock it.

Also confirmed the two improvements you flagged are in place: asyncio.TimeoutError → builtin normalization, and repos_last_fetched moved to after a successful fetch.

… concurrent sync

Addresses review findings on PR3 (never stuck on an offline repo):

- CRITICAL: reset the dedicated git ThreadPoolExecutor after fork
  (os.register_at_fork) and shut it down at the end of preload_scopes. A
  pool built in the pre-fork gunicorn master was inherited with dead worker
  threads by every worker, so the leader's scope sync stalled forever
  (silent policy staleness). Verified with a fork repro on 3.12.

- HIGH: never use the non-thread-safe pygit2 Repository from two threads.
  A timed-out clone/fetch keeps running on its pool thread while the
  per-source_id lock is released; a per-source_id in-flight guard now skips
  a cycle while a prior op is still lingering. run_in_git_executor switches
  asyncio.wait_for -> asyncio.wait so a timeout never cancels the future
  (the thread runs to completion and clears the in-flight marker).

- HIGH: sync scopes concurrently, bounded by SCOPES_GIT_MAX_WORKERS, so one
  unreachable repo no longer serially blocks boot and other scopes.

- MEDIUM: daemon-thread pool so a lingering git op can't block interpreter
  shutdown.

- MEDIUM: stamp repos_last_fetched with the fetch start time on success
  (was completion time, which could wrongly suppress a force_fetch whose
  req_time falls within an in-flight fetch).

- rmtree(ignore_errors) for the abandoned-clone race; harden the
  env-sensitive config-defaults test; correct config/doc wording
  ("logged and skipped" instead of "marked failed").

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.

Review (PER-15157 / PR3) — git resilience: never stuck on an offline repo

What this PR does. Makes scope git clone/fetch resilient to unreachable repos. It moves scope git work off the shared default executor onto a dedicated daemon-thread ThreadPoolExecutor (SCOPES_GIT_MAX_WORKERS, default 10), wraps each clone/fetch in a soft per-op timeout (SCOPES_GIT_FETCH_TIMEOUT, default 120s) via run_in_git_executor using asyncio.wait (so a timeout unblocks the event loop without cancelling the still-running pygit2 call), adds a per-repo in-flight guard so a lingering timed-out op is not touched concurrently (pygit2 Repository is not thread-safe), records repos_last_fetched only on fetch success, makes scope sync concurrent (bounded by the pool via a semaphore), and adds fork-safety (os.register_at_fork reset + shutdown_git_executor() after pre-fork preload). Plus two unit-test files and a private config-reference doc.

Verdict: REQUEST_CHANGES. Per the severity rule, there is one Postable HIGH finding (credential-redaction bypass in the new log lines) → REQUEST_CHANGES. Independently, the PR is not mergeable: GitHub reports CONFLICTING and git_fetcher.py has a content conflict with master (see Blockers).

The core design is sound and a real improvement over master: isolating git work onto a dedicated pool means a hung clone/fetch can no longer starve bundle serving or the event loop (on master these share the default executor via run_sync, so one offline repo hangs the whole server). The soft-timeout + single-flight + fork-safety mechanics are correct and well-tested (test_busy_key_stays_in_flight_until_call_returns, test_hanging_git_op_raises_timeout, the config-default tests). The two prior open Copilot threads are already addressed by the current code (asyncio.get_running_loop() replaces get_event_loop; repos_last_fetched is now written only after a successful fetch) — not re-raised.

Findings

Postable:

# Severity File:Line Category Description
1 HIGH packages/opal-server/opal_server/git_fetcher.py:412 (also 376, 465) Security New skip/timeout/clone-error log lines log the raw self._source.url; master redacts every repo URL in this file via redact_url(). Once merged these are the only un-redacted URL logs → credential exposure. redact_url isn't imported.
2 MEDIUM .claude/plans/docs/05-config-reference.md:27 Doc accuracy The ceil(offline / workers) × timeout boot/poll bound is optimistic: timed-out ops keep their pool thread until the OS network timeout, so with offline >= workers healthy repos queue behind lingering threads longer than the stated bound.

Informational (not posted inline):

# Severity File:Line Category Description
3 LOW packages/opal-server/opal_server/git_fetcher.py:52-91 Maintainability _DaemonThreadPoolExecutor._adjust_thread_count reimplements CPython concurrent.futures.thread internals. It falls back to super() if _worker/_threads_queues disappear, but a signature change to _worker (name kept, args changed) would pass the hasattr check yet break. Acceptable given the fallback + # pragma: no cover, but a fragility to track across Python upgrades (repo targets 3.9–3.12).
4 LOW .claude/plans/docs/05-config-reference.md (new tracked file) Cross-PR coordination PR #922 adds .claude/ to .gitignore while this PR tracks a file under .claude/plans/docs/. Not a conflict on master today (.claude/ isn't ignored), but once both land the tracked doc sits under a gitignored path — coordinate.

Design note (not a blocker). The "never stuck" guarantee delivered is: the event loop / HTTP surface / bundle serving never block, and each sync slot stalls at most SCOPES_GIT_FETCH_TIMEOUT. It is not that a fixed pool immediately reclaims capacity on timeout — a timed-out op lingers on its thread until the OS network timeout (by design; pygit2 can't be cancelled). For the realistic case (a few offline repos among many healthy) this is fine — the offline ops each hold one lingering thread and the rest of the pool serves healthy repos. The pathological case (offline repos >= pool size) can saturate the git pool; the daemon threads still let the process exit promptly and other server work is unaffected. Finding #2 asks the doc to reflect this precisely.

Blast radius: Production opal-server git-fetcher + scopes sync path — affects every scoped deployment's boot and poll behavior. No client-facing symbol from references/pdp-impact.md §3 is renamed/removed (new module-level helpers + two config keys only; GitPolicyFetcher public shape unchanged), so no PDP import-surface break. Two new OPAL_* keys are additive with sane defaults, no env-name collision, no OPAL_ double-prefix. Pub/sub topology unchanged — this only changes how the leader fetches git; the scope publish path is untouched, so PDP policy-update propagation is unaffected except that offline repos now fail fast (skip + retry) instead of hanging.

Isolation / scope: Well-isolated. All changes serve the stated purpose (git resilience); no unrelated refactors, no half-done work.

Blockers:

  • CONFLICTING / not mergeable. git_fetcher.py has a content conflict with master (master added redact_url() to the log lines this PR also edits). Rebase/merge master and resolve — and when doing so, apply redact_url() to the new log lines too (finding #1). Reviewed here against the merge-base three-dot diff (d30e462...d31a0b63), which is unaffected by the conflict.

Comment thread packages/opal-server/opal_server/git_fetcher.py Outdated
Comment thread .claude/plans/docs/05-config-reference.md Outdated
dshoen619 and others added 5 commits July 7, 2026 13:53
…tuck-on-an-offline-repo

Resolve git_fetcher.py conflict: keep the PR's soft-timeout fetch path
(run_in_git_executor + stamping repos_last_fetched with the start time
only on success) and the combined pygit2.GitError/TimeoutError clone
handler; drop master's run_sync double-fetch and its separate
`except pygit2.GitError` clause.

Apply master's redact_url() to the three new offline/error log lines the
PR added — single-flight skip, fetch-timeout, and clone-error — so scope
git URLs (which can embed user:token@host) are never logged raw once the
redaction control from master is in effect (review finding #1, HIGH).

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

The `ceil(offline / workers) × timeout` bound was optimistic: the soft
timeout unblocks the awaiting coroutine but the timed-out op keeps its
pool thread until the OS network timeout, so with offline >= workers a
healthy repo queues behind lingering threads up to the OS/TCP timeout,
not `ceil × timeout`. Restate the guarantee this actually delivers
(event-loop isolation + a bounded per-slot stall), reference the
app-tests/git-leak 40-offline/10-worker case, and fix the two config.py
line refs (196-203, 204-211).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…-git-resilience-never-stuck-on-an-offline-repo

# Conflicts:
#	packages/opal-server/opal_server/git_fetcher.py
#	packages/opal-server/opal_server/scopes/service.py
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Zivxx and others added 4 commits August 13, 2026 14:19
ScopeRepository.all() scans for keys and then reads each one. A scope deleted
between those two steps comes back as None, and Scope.parse_raw(None) raises
ValidationError — which kills the entire scan, not just that entry.

Every caller inherits it:

- sync_scopes() opens with `await self._scopes.all()`. One concurrently-deleted
  scope therefore aborts a whole sync pass; _sync_all catches and logs, so the
  pass is silently skipped.
- the sibling check a delete's purge depends on
  (find_scope_sharing_source -> scopes.all()) fails the same way, and BOTH
  consumers then take their fail-open branch.

Observed under the bed's churn, which deletes 20 scopes in quick succession:

    Local sibling check for 9d676208...-0 failed after deleting scope churn-4;
    keeping this worker's clone: ValidationError(model='Scope', errors=[{'msg':
    'the JSON object must be str, bytes or bytearray, not NoneType'}])

Two clone dirs stranded, memory perfectly clean — each delete's own scan raced
another delete. test_churn_releases_caches failed invariant I1 on it.

A key that no longer exists is simply not a scope, so it is skipped.
Deliberately narrow: a record that IS present but does not parse still raises,
because that is corruption and master's own docstring calls out that one
malformed record should not pass unnoticed.

Pinned by test_scope_repository_all_skips_a_key_deleted_mid_scan, which also
asserts the corrupt-record case still raises. Dropping the guard fails it.

Found by the docker bed, not by the unit suite — the race needs concurrent
deletes against a real Redis.

opal-server unit: 219 passed. Bed: test_transitions.py + test_leak.py 11 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…s the clone on a store fault and is drained at shutdown

Four fixes, all on the delete/serve paths.

1. A clone in progress was reported as a permanent config error.

_clone() rmtree's the destination and clones INTO THE FINAL PATH, so for the
whole duration of a recovery re-clone the dir exists with no origin/<branch>
ref yet. _get_current_branch_head cannot tell that from a misconfigured branch
name, so it raised BranchHeadNotFoundError -> 409 "not retryable" — during the
very recovery that fixes it. §5's own justification for the 503 is that the
condition is transient by construction; this was the most transient case there
is. opal-client retries regardless (no retry= predicate on its tenacity call),
so the wrong advice lands specifically on third-party consumers who honour it.

The in-flight marker is the discriminator: set for the whole clone, clear
otherwise. A genuinely wrong branch coinciding with a fetch briefly gets the
503, then the 409 once the marker clears — right answers, right order.

Retry-After is now two named constants rather than a bare "5": 5s for "a sync
will re-create this on its next tick", 30s for "a clone is running right now",
which on a large repo runs for tens of seconds. Stateless by design — escalating
per client would need per-client retry state on a stateless endpoint, and
opal-client ignores the header entirely.

2. The floor purged defensively when its sibling check raised, which could
delete a clone a LIVE sibling scope still shares — taking that tenant's policy
offline until the re-clone finished, triggered by a transient store error.
Master made the same choice, but master's over-purge argument was about cheap
self-healing; this is not cheap. It now keeps the clone. The cost is an orphan
dir (PER-15612): disk against availability, and this is a best-effort floor —
the optimistic path by construction, so it takes the conservative branch when
it cannot tell.

(The frequent trigger for this branch turned out to be a bug of its own, fixed
separately in 254f987.)

3. ScopesService had no stop(). A DELETE that returned 204 and was followed by
SIGTERM lost its floor: a detached task nobody references, and the clone dir it
was about to remove survives with nothing left to reclaim it. It now drains,
wired into the watcher's existing bounded shutdown alongside
LeaderScopePurger.stop() — routine on any rolling deploy.

4. _DaemonThreadPoolExecutor's shape-check guarded _worker, _threads_queues and
_initializer, then used self._idle_semaphore, which was not guarded. A CPython
that dropped it would rewrite its own _adjust_thread_count accordingly, so the
stdlib fallback would still work — but ours would raise AttributeError out of
submit(), failing every scope git op. That matters beyond its severity: this
guard is the stated basis for capping python_requires <3.13 across all three
packages.

Each fix is mutation-verified against the mutation named in its test docstring,
and each kills only its own test.

opal-server unit: 219 passed. Bed: test_transitions.py + test_leak.py 11 passed.

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

SCOPES_GIT_MAX_WORKERS told operators, in config.py and the public config
reference, that "capacity is never starved by hung remotes." That is false.

SCOPES_GIT_MAX_ZOMBIES is checked against git_busy_count(), which is
len(_git_busy) — ONE GLOBAL set across every source, covering live and lingering
ops alike (git_fetcher.py:51,238,304). At the ceiling, new git ops are refused
for EVERY scope, healthy ones included; service.py treats the refusal as
expected backpressure and skips the scope that pass. Against remotes that never
return, that state can persist.

No code change. I proposed one — reserving headroom by refusing at
MAX_ZOMBIES + MAX_WORKERS — and it does not hold: with more stuck sources than
the ceiling, the healthy op is refused anyway. It moves the cliff by
MAX_WORKERS and leaves the promise false.

Nor could I demonstrate the starvation. The bed's black hole
(socat SYSTEM:sleep 3600) produces clones that TIME OUT but whose threads then
finish, so markers clear: 60 black-holed sources gave 60 timeouts, 0 completed
clones, 0 cap refusals, and a fresh scope put afterwards cloned and served 200.
Reproducing it would need a sidecar that hangs permanently, which would change
the semantics every existing offline-repo gate depends on.

So the honest fix is the text. Both descriptions now say what the mechanism
actually is:

- MAX_WORKERS: a timed-out op stops holding a concurrency slot (true, and the
  real content of "one hung remote can't block others"), with a pointer to the
  ceiling rather than a promise that contradicts it.
- MAX_ZOMBIES: named as a GLOBAL, last-resort ceiling; states that per-source
  protection is a separate mechanism (skipping a source that already has an op
  in flight); states plainly that at the ceiling every scope is refused; tells
  operators to set it well above MAX_WORKERS and alert on the refusal log.

Both files move together — the drift guard compares them verbatim, and the mdx
entries are regenerated from the config.py literals rather than hand-edited.

opal-server unit: 219 passed. Drift guard: 8 passed.

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

Six of round 8's fifteen threads survived the disk-reclaim cut. This closes
them.

- The floor's early returns leaked a repo_locks entry each. lock_source MINTS
  the entry on the way in, so every path that abandons the source has to drain
  it (invariant I4) — the leader path does this with a finally and this one did
  not. Now the same finally, with the same lock-identity guard, and the same
  exception: the live-sibling path leaves the entry alone, because that source
  is still in use and the bed asserts it survives a sibling delete.

- The floor's rmtree target was the only destructive path in the series that
  never went through the confined-path derivation. Not wire-controlled (it comes
  from the stored record, not a pub/sub message), so consistency rather than a
  live hole — but the body now derives and operates on that path throughout, and
  refuses a mismatch. _confined_clone_path becomes confined_clone_path, since it
  is now used across modules.

- test_delete_publishes_request_without_touching_local_memory asserted the
  caches were untouched immediately after delete_scope. That held only while the
  backgrounded floor had not been scheduled yet — a coin flip on the fake's
  timing — and "local memory is untouched" stopped being the contract when the
  floor landed. It now asserts only the publish contract; the floor's effect is
  pinned by the tests that drain it.

- config_docs_drift_test's non-empty check was a module-level assert, which
  raises during COLLECTION — and pytest aborts the whole run on a collection
  error, so one drifted regex would have hidden every other test's result. It is
  a test now: same guarantee, local failure.

- The bed gate's docstring claimed the leader's purge would otherwise have
  removed the dir. True when written, false after the cut — the floor is now the
  only path in the server that removes a clone dir, which is precisely what
  makes the gate attributable. Rewritten, with the measured both-ways numbers,
  and given the timeout marker it was missing (the only test in that file
  without one, and there is no global default).

- The bed matrix was missing that gate and its count was stale: 22 rows, 18 pass
  outright.

Also corrected a stale claim in the bed's own helper: hard_reset's wait loop
said it was waiting for the boot orphan sweep to reclaim dirs. Nothing reclaims
them — it waits for repo_locks to settle, and the flushed scopes' dirs stay on
disk, which is why the tests calling it carry an I1 exemption.

Every new pin is mutation-verified. One of them was vacuous as first written:
the confinement test survived removing the comparison, because the real
protection is that the body operates on the derived path — its docstring now
names the mutation that actually kills it.

opal-server unit: 222 passed. Bed: test_transitions.py + test_leak.py 11 passed.

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

Zivxx commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Round-8 addressed — 15/15 threads, and the PR description is rewritten

f6c39533 the cut · c1c09753 d359ffb2 254f987a d95e0459 c6899e11 fixes · 3f68bcb6 e2256031 claims. Per-finding detail is in each thread.

Six of the fifteen were resolved by deletion, not by fixing

The disk-reclaim cut took the deferred-purge retry (and with it round 8's HIGH-2, which you under-called — there was a second, more reachable entrance via shutdown), SCOPES_DEFERRED_PURGE_TIMEOUT and both of its LOWs, the in-flight deferral branch, and the 157-line / cc-17 complexity finding. purge.py is 232 lines lighter.

Two I pushed back on, with evidence rather than opinion

HIGH-1's severity. Running your race against origin/master's own code: master produces the same orphan, unconditionally and permanently, and has no git_op_in_flight guard at all. The floor is master's exposure minus a guard master lacked. But you were right about what mattered — the code claimed an invariant it has never held, and purge.py:6 contradicted itself ten lines later. That claim is gone from all three sites.

The zombie cap. I proposed a headroom fix and withdrew it: with more stuck sources than the ceiling, the healthy op is refused anyway. Nor could I reproduce the starvation — the bed's black hole produces clones that time out but whose threads then finish (60 black-holed sources → 60 timeouts, 0 refusals, and a fresh scope served 200 afterwards). So that one is a documentation fix, and SCOPES_GIT_MAX_WORKERS no longer tells operators "capacity is never starved by hung remotes".

Three bugs your findings led to that were outside their own lines

  • ScopeRepository.all() aborts on a mid-scan delete. A scope deleted between the key scan and the read comes back None, and Scope.parse_raw(None) killed the whole scan. One concurrently-deleted scope was silently aborting an entire sync_scopes pass. Pre-existing, found chasing your fail-open finding.
  • The floor deleted a live re-created scope's clone. It excluded the deleted scope_id from its sibling check, copying master — which blinds it to a re-create on the same source under the same id.
  • The I4 drain churned a live sibling's lock. My round-6 finally was applied unconditionally; the bed's test_shared_repo_survives_sibling_scope_delete caught it.

None of the three was caught by the unit suite. Two needed the docker bed.

The description is rewritten

It had drifted several rounds out of date and reviewers were reading it as the spec. It now describes what is actually here, including the parts it never mentioned: the fork-safety workstream, the python_requires <3.13 cap reaching opal-client and opal-common (which needs an owner to lift), the second contract change, that stock opal-client retries the 409 it is told not to retry, the cold-start readiness change, and that the boot-time fix ships unmeasured because BOOT_TARGET_SECONDS is still 2000 and the plan to tighten it belonged to the now-closed PR4.

It also states plainly what nothing reclaims: a DELETE's dir on non-serving pods when the broadcast is lost, a REPOINT's old dir always, and a dir whose source_id is unknowable. All PER-15612.

Verification

opal-server unit 222 passed (218 at round 8)
opal-common unit 93 passed
app-tests/git-leak test_transitions.py + test_leak.py 11 passed
bed matrix 22 rows, 18 pass outright; 3 orphan-sweep gates red by design
pre-commit clean

Every new pin is mutation-verified against the mutation named in its own docstring. One was vacuous as first written — the confinement test survived removing the comparison, because the real protection is that the body operates on the derived path — and its docstring now names the mutation that actually kills it.

Zivxx and others added 5 commits August 13, 2026 18:13
Both defects here are the same shape: a fix that reads or writes per-process
state to decide something that every worker must decide identically. Both were
added in the last two rounds, and both were mine.

1. The 503/409 split was keyed on the in-flight marker.

d95e045 taught GET /scopes/{id}/policy to answer 503 instead of a
non-retryable 409 while a recovery re-clone is in progress. It discriminated on
git_op_in_flight(source_id) — a module-global set, written only by
run_in_git_executor via fetch_and_notify_on_changes, whose only caller is
sync_scope, and the watcher that drives sync is constructed under the
leadership lock. GET has no leader affinity; gunicorn spreads it over
SERVER_WORKER_COUNT workers (default = core count).

So the marker is permanently empty on every NON-leader worker, and the 503
branch was unreachable there. Same pod, same instant, same on-disk state: the
leader answered 503 "retry shortly", every other worker answered 409 "check the
configured branch; not retryable". With 4 workers that is 75% of bundle
requests told their config is broken during the recovery that fixes it — the
exact inversion the split was introduced to prevent, on N-1 of N workers.

The discriminator is now DISK, which is identical on every worker: _clone()
rmtree's the destination and clones into the final path, so an empty
refs/remotes/<remote>/* namespace means the clone is not populated yet
(transient), while refs present but not ours means the branch is misconfigured
(permanent). New CloneNotPopulatedError carries that, and api.py no longer
imports git_op_in_flight at all.

Documented caveat: a remote with genuinely zero branches now gets 503 forever
rather than 409. Rare, and the safe direction.

2. ScopesService.stop() drained an instance that never holds a task.

d95e045 also added a shutdown drain for the DELETE floor. There are two
ScopesService objects per process: the one server.py:276 builds and hands to
init_scope_router — which delete_scope runs on, so its _local_purges holds the
floor tasks — and a second one ScopesPolicyWatcherTask builds for itself.
task.py drained the second. It gathered an empty set, every time.

It was also structurally wrong: the watcher exists only on the leader, while a
DELETE usually lands on a non-leader, so the watcher could not be the drain
point even if it shared the object.

The drain now runs per worker, from the @app.on_event("shutdown") hook that
already exists, on the instance the router received, bounded by
_SCOPES_DRAIN_TIMEOUT. task.py drains only the purger and says why.

This one is a regression against the merge base, not a missing improvement:
master removed the clone dir inline and the rmtree completed before the 204.

Both were invisible to the tests written for them, in the same way: the drain
tests called delete_scope and stop() on the same object, or asserted stop() was
called on a hand-injected fake; the 503 test set the marker by hand, which only
ever proved the leader case. The new tests assert on what the bug could
distinguish — test_shutdown_drains_the_routers_scopes_service compares against
the object init_scope_router was handed and fails under a faithful replay of
the original bug, and
test_mid_clone_503_is_identical_on_a_non_leader_worker never sets the marker,
so it IS the non-leader case.

opal-server unit: 225 passed. Bed: test_transitions.py + test_leak.py 11 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
f6c3953 removed the leader's disk role; eleven statements that the leader
removes the clone dir survived it, two of them in the operator-facing config
reference. purge.py contradicted itself eight lines apart. This is the same
class as the "only the leader mutates the clone tree" invariant deleted in
3f68bcb — the code moved, the prose did not — and it is the class that has
produced the most severe findings on this PR.

grep for "leader.*removes the clone dir|leader's disk purge|Leader-only disk
purge|deletes clone dirs fleet-wide|leader already purged" over
packages/opal-server and documentation/ now returns nothing.

Corrected in config.py and configuration.mdx together (the drift guard pins
them verbatim, so a one-sided edit reddens the suite — and pinning prose against
prose is exactly what kept both copies wrong in lockstep):

- SCOPES_PURGE_CHANNEL no longer says the leader removes the clone dir. It says
  what the leader actually does: it is the only actor that can sibling-check, so
  it is the only one that may authorize a cache purge.
- SCOPES_STORE_READ_TIMEOUT's sibling check no longer runs "before removing a
  clone dir" — it runs before authorizing the fleet-wide cache purge.

Separately, a load-bearing claim that was simply backwards. Three places said a
leader keeps a broadcaster reader alive only with a connected client or
STATISTICS_ENABLED. BasePolicyWatcherTask._listen_to_webhook_notifications
enters get_listening_context() unconditionally when a broadcaster is configured,
and the watcher runs only under the leadership lock — so the LEADER always has a
reader, and it is NON-leader workers that can be deaf. The STATISTICS_ENABLED
gate at server.py:182 is what governs them.

That sentence is the premise of the argument that the purge broadcast is
droppable at shipped defaults, which is the justification for the delete floor
existing. The floor still earns its place — a backbone outage loses the message
for everyone, which is what the bed gate demonstrates by stopping Postgres — but
the shipped-defaults form of the argument was overstated. The branch's own
git-leak README had it right; the code and the PR body did not.

Also corrected: purge_local_memory's "the leader's disk purge does it there",
and purge.py's claim that the branch "reclaims it whenever the purge broadcast
is delivered" — delivery drains memory on every worker and touches no disk.

No code change.

opal-server unit: 225 passed. Drift guard green.

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

A mutation audit of this PR's own tests found four that could not fail. The
worst is the one carrying a SECURITY label.

1. The confinement guard had no falsifiable test.

Relaxing _SOURCE_ID_RE to r".*", so confined_clone_path never rejects anything,
left the ENTIRE suite green — 225 passed. Removing the leader's guard: green.
Removing the worker handler's guard so it falls back to the wire-supplied
clone_path: green. confined_clone_path had no direct unit test at all. The one
test naming it (test_leader_rejects_malformed_source_id) asserts a directory
still exists, which nothing in the process can delete since the disk-reclaim
cut — true by construction.

Added: a parametrized rejection test over ten hostile ids, a confinement
assertion for a valid one, and an end-to-end test that a forged source_id
cannot evict an arbitrary GitPolicyFetcher.repos entry. Relaxing the regex now
fails 11 tests. The Arabic-Indic-digit case pins the \\d -> [0-9] fix from an
earlier round specifically, which previously had no test either.

The SECURITY docstring is also corrected while I was in it: it claimed a forged
message could reach rmtree. It cannot any more — no wire-driven path in this
module removes a directory, and the one remaining removal derives its target
from the stored record. What it CAN still reach is a free() of a cached handle
under an attacker-chosen key, which is why the validation stays.

2. Both directions of the repoint fail-closed rule were unguarded.

test_leader_keeps_clone_on_repoint_when_sibling_check_raises asserted only
clone.exists(), with pubsub_endpoint=None. Since the leader has no disk code,
nothing could have deleted that directory and nothing could observe the real
effect. Deleting the repoint branch from `except Exception` left the suite
green. It now records the confirmation and asserts it was WITHHELD.

3. test_periodic_polling_survives_a_raising_sync HUNG instead of failing.

Its `while events.count("sync") < 2: await asyncio.sleep(0)` spins forever once
the loop dies, and the unit suite has no pytest-timeout dependency to catch it
— so stripping the try/except under test wedged the run rather than reddening
it. Same class as the "1 skipped — got empty parameter set" defect. Now bounded
and asserting the task is alive each turn; the mutation fails it in 0.32s.

(Deliberately fixed in the test rather than by adding a `timeout` key to
pytest.ini: pytest-timeout is not a declared dependency of the unit suite, so
an ini key would read as protection while doing nothing in CI.)

4. A false "Mutation:" claim in a test I added earlier today.

test_floor_refuses_a_path_that_is_not_the_derived_one said reverting
forget_repo/rmtree to the caller's path would fail it. It does not — the guard
is disjunctive, the comparison short-circuits first, and only removing BOTH
kills the test. Worse, the docstring invited a reader to drop the comparison as
"only an assertion", which ships green. Corrected to describe the actual
structure.

Every claim above was established by applying the mutation, running the suite,
and restoring — not by reading.

opal-server unit: 237 passed (225 before). Bed: test_transitions.py +
test_leak.py 11 passed. pre-commit clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… strengthen four bed gates

One product bug and a set of test/bed gaps found by a mutation audit.

_generate_default_scope_bundle called make_bundle WITHOUT run_sync, unlike the
primary path twelve lines up. That is a full bundle build — open the repo, walk
the commit tree, read and encode every matching file — running ON the event
loop, stalling every other request the worker is serving, including other
tenants' bundles and the pub/sub websocket traffic. Reached by any GET for an
unknown scope, which a PDP with a stale id re-hits on its poll cadence.
Pre-existing, but in a function this PR rewrote.

The same handler re-raised ScopeNotFoundError, and nothing registers an
exception handler for it, so it left the route as an unhandled 500 — for the
ordinary case of an unknown scope on a deployment with no "default" scope (the
git-leak bed is one). Now 404, matching get_scope and refresh_scope, which
already answer 404 for the identical condition. OSError joins its except tuple
for the same reason the primary path has it.

Bed and test gaps closed:

- opal_multiworker never called check_invariants. Both multiworker tests ran
  with ZERO invariant assertions while carrying no exempt marker, so they read
  as though I1-I6 held. They now check, and pass. One documented caveat: a
  single stats() sample answers from whichever worker serves it, so the
  per-process invariants are checked against one of the two — sound but
  incomplete.

- Four gates exempted I3/I4 while their docstrings already called them green,
  excusing the very invariants the purge under test exists to satisfy. Measured
  on this head: ALL FOUR pass with I3/I4 enforced. The exemptions were stale and
  are gone. I1 stays, and the reason is now written down: a delete or repoint
  racing a hung clone still leaves the DIR, and nothing reconciles it here.

- The new broadcast-lost gate needed an I1 exemption after all — not for
  anything it does, but because orphan dirs ACCUMULATE across the session
  (earlier tests strand them deliberately), so I1 cannot hold session-wide in
  this PR. Found by running the full file rather than the test alone.

- test_scope_repoint_releases_old_repo_cache asserted the old source_id is
  ABSENT from three key lists, with nothing asserting it was ever PRESENT.
  old_sid comes from invariants.source_id, a hand-maintained mirror of
  GitPolicyFetcher.source_id: any drift made the assertion true at t=0 and the
  gate passed on a mismatch rather than on a purge. It now asserts presence
  first.

- test_leader_keeps_disk_when_live_sibling_shares_source could not fail —
  deleting the entire live-sibling keep-branch killed two other tests and left
  this one green. It now asserts the withheld confirmation and the retained
  lock; that mutation fails three tests.

- hard_reset's settle loop swallowed every exception with no post-loop check,
  degrading to a silent 30s sleep if the server never came back. It raises now.

One test I added earlier today was itself vacuous and is fixed here: the
event-loop check compared thread NAMES, but TestClient runs the loop in a worker
thread, so "not MainThread" was true either way and the test passed with
run_sync removed. It now uses asyncio.get_running_loop(), which succeeds only on
the loop thread — the actual discriminator.

opal-server unit: 239 passed. Bed: test_transitions.py + test_leak.py 11 passed
with I3/I4 newly enforced on four of them. pre-commit clean.

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

`git add app-tests/git-leak` in 8427feb staged four untracked local files and
two uncommitted working-tree modifications along with the intended bed changes.
None of them belong on this branch:

- test_boot_sharding_demo.py, head_to_head.py, ab_fetch_timeout.py and
  hh-override.yml are local exploration. Their proper home is
  permit-backend@ziv/opal-staging-test-kits (17874dae3), which already holds
  them with a README, a RUNBOOK and their results.
- docker-compose.yml and seed/seed_gitea.py had uncommitted local edits that are
  not part of this PR; restored to their committed state.

This also fixes CI: pre-commit failed on test_boot_sharding_demo.py, which was
never formatted because `pre-commit run --all-files` only walks TRACKED files —
so it was invisible locally right up until the commit made it tracked.

Co-Authored-By: Claude Opus 5 (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.

Round 9 — c68a2cb8 (+5923 / −544, 36 files)

239 passed (opal-server), 93 (opal-common) — both reproduced. All 15 round-8 threads addressed across 13 commits, and the description rewritten from the code.

No CRITICAL and no HIGH survived. That is a genuine change from rounds 2–8, each of which produced at least one HIGH. The cut removed the machinery that kept generating them, and what remains is largely pinned. What is left is concentrated in one place: guards and prose that this round added or invalidated, and did not test or update.

Round-8 follow-through — verified by mutation

Every round-8 finding is closed, and each fix fails a named test when reverted:

Fix Mutation Fails
254f987a ScopeRepository.all() mid-scan delete continue → fall through test_scope_repository_all_skips_a_key_deleted_mid_scan
c1c09753 floor vs a re-created scope re-add the scope_id exclusion test_local_floor_keeps_the_clone_of_a_re_created_scope
d359ffb2 I4 drain vs a live sibling (leader side) drain unconditionally test_leader_keeps_disk_when_live_sibling_shares_source, test_live_sibling_keeps_its_repo_lock_entry
_local_purges drain remove the drain test_service_stop_drains_the_floor, test_shutdown_drains_the_routers_scopes_service
8427febe off-loop bundle build direct call test_default_bundle_build_does_not_block_the_event_loop

The false invariant is gone from all three code sites, the floor's rmtree now routes through confined_clone_path, and the README matrix is complete (22 rows, counts correct). The security surface holds: disabling _SOURCE_ID_RE reddens 11 tests including the traversal cases, and both halves of the ALL_TOPICS/purge-channel gate are independently pinned. The cut itself is clean — DEFERRED_PURGE returns nothing tree-wide, no orphaned key, helper, or dead branch.

Two corrections to things I asserted

I would rather flag these than have them stand:

  • "239 passed" is correct, and my earlier reading of it was wrong. I measured 238 and was preparing to report the Verification line as off by one. The difference is the pytest target: packages/opal-server gives 239, packages/opal-server/opal_server/tests gives 238, and the extra test is opal_server/data/tests/test_data_update_publisher.py::test_topic_combos, which lives outside that subtree. Nothing is skipped or flag-gated. The only residual nit is that the Verification line does not name the command. Not a finding.
  • I initially concluded no vacuous pin remained beyond the one you fixed. That was wrong — there are three, and they are the first MEDIUM below. My method was the limitation: I AST-scanned for assertion-free or trivially-true tests, which cannot find a guard that simply has no test pointing at it. Only line-level mutation surfaces that class.

What is new — 6 MEDIUM, 3 LOW inline

The two worth acting on first:

  1. Three guards added this round have no test at all, including service.py:366 — the floor's copy of d359ffb2's fix, whose leader-side twin at purge.py:399 is pinned. Same fix, two sites, one covered. And test_floor_refuses_a_path_that_is_not_the_derived_one passes with the guard it is named for deleted.
  2. A transient clone failure on the default-bundle path returns a permanent 404 with no Retry-After, where the identical exception set yields 503 + Retry-After: 5 on the primary path. Against master this is 500 → 404: an improvement for the case its comment describes, a regression in retry semantics for the transient case, on the endpoint §6 tells third-party consumers to act on.

Then: the drift guard is bypassable four ways (one hides a key from it entirely, three let a tracked key's docs say something false); SCOPES_STORE_READ_TIMEOUT's description now states the opposite of what the code does and is pinned verbatim into the public reference; §3's "exactly one path" is false; and the bed's I1 exemption rests on a condition its own fixture makes impossible.

Smaller description mismatches (not inline — no diff line to anchor)

The rewrite says "If anything below disagrees with the code, the code is right and this is a bug", so:

  • §1's "Enumerated: 10 call sites, all guarded in-process" contradicts Invariant 2 two sections later, which names two deliberately unguarded sites (git_fetcher.py:748, :859-862). Separately, git_fetcher.py:617/619 and :669 have no git_op_in_flight check at all — they are serialized by lock_source and by being the in-flight op themselves. Only git_fetcher.py:954, service.py:368 and purge.py:126 actually consult the marker.
  • Invariant 3's "the leader is the only mutator on sync paths" is false for the gunicorn master: preload_scopes() runs asyncio.run(service.sync_scopes(...)) (task.py:169-183) from scripts/gunicorn_conf.py:16 with no leader lock, and task.py:211 then asserts sync "is leader-only" to justify a conclusion about those very caches. Benign — it is single-threaded pre-fork, the same reason reset_caches is safe there — but the invariant as stated is not accurate.
  • app-tests/git-leak/README.md:69 still says "PASSES here (nothing purges on master)". PR2 (6498da1e) is merged to master, so that framing is stale; same at :121-124.
  • Optional: the three orphan-sweep gates are correctly kept — they are pre-existing on master (this PR's diff to them is docstring-only) and pytest.ini's testpaths = packages means they never run by default, so they cost nothing. But their red-by-design status lives only in README prose. @pytest.mark.xfail(reason="orphan sweep deferred to PER-15612", strict=True) would make it machine-checked and flip loudly when PER-15612 lands.

On round 8's HIGH-1, since you pushed back with evidence

You were right and I was wrong on severity. Running the race against master does produce the same orphan, unconditionally, with no in-flight guard at all — the floor is master's exposure minus a guard master lacked, and rating it HIGH against an ideal rather than the merge base was my error. What survived is the part you agreed with: the code asserted an invariant it never held. That is now gone from all three sites and replaced with an honest statement of what goes unreclaimed — including the correction that the leader always has a reader, so it is non-leader workers that can be deaf. Better than what I asked for.

Process disclosure

Three reviewers were dispatched; two delivered and their findings are folded in above, each independently reproduced by me before posting (the 404 probe, the unpinned-guard mutations with the purge.py:399 control, the fixture/compose trace behind the I1 finding). One never returned a report despite four requests, so I covered its scope myself with first-hand mutation results rather than leave it unreviewed. Flagging it so you can weigh the coverage accordingly.

Requesting changes on the untested guards and the 404 semantics. The rest are worth fixing and none of them blocks.

Comment thread packages/opal-server/opal_server/scopes/service.py
Comment thread packages/opal-server/opal_server/scopes/api.py
Comment thread packages/opal-server/opal_server/tests/config_docs_drift_test.py Outdated
Comment thread packages/opal-server/opal_server/config.py Outdated
Comment thread app-tests/git-leak/test_transitions.py Outdated
Comment thread packages/opal-server/opal_server/git_fetcher.py
Comment thread packages/opal-server/opal_server/git_fetcher.py
Comment thread packages/opal-client/setup.py
Comment thread packages/opal-server/opal_server/pubsub.py Outdated

@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 — every round-9 concern validated, nothing above MEDIUM

Superseding my earlier CHANGES_REQUESTED on this round. That verdict was inconsistent with the rubric I have been applying all nine rounds — nothing exceeded MEDIUM, so it should have been a comment, and I carried the verdict forward from prior rounds instead of re-deriving it. Correcting that here.

Every concern re-validated against the code

Concern Severity Validation
Bed I1 exemption possibly masking its own violation MEDIUM Resolved — it is not masking anything. The scope store is Redis (OPAL_REDIS_URL), while bounce_postgres stops only Postgres, the broadcaster backbone — so the floor's sibling check keeps answering and it never fails open. The test also hangs no remote, so git_op_in_flight cannot trigger the skip branch. It cannot orphan a dir. The exemption is dead weight with a false justification, not cover for a failure.
Three guards with no test MEDIUM Guards are present and correct — purge.py:399, the pinned twin of the unpinned service.py:366, is the same logic and passes. Only coverage is missing.
Transient clone failure → 404 MEDIUM Narrow (record missing and a default scope exists and its clone is transiently broken); 500 → 404 against master is a net improvement; opal-client retries every non-200 regardless.
Drift guard bypassable four ways MEDIUM Test efficacy only; no runtime behaviour depends on it.
SCOPES_STORE_READ_TIMEOUT prose contradicts the code MEDIUM Documentation only.
§3's "exactly one path removes a clone dir" MEDIUM Documentation only.
Shape-check claim / python_requires / garbled docstring LOW No supported interpreter reaches the first; the others are doc and packaging.

No CRITICAL and no HIGH. The security surface was attacked directly and held: disabling _SOURCE_ID_RE reddens 11 tests including every traversal case, both halves of the purge-channel authorization gate are independently pinned, no wire-supplied clone_path reaches an rmtree, and this PR narrows the cross-tenant surface by removing master's second _generate_default_scope_bundle caller. All 15 round-8 findings are closed and mutation-pinned. 239 + 93 passing, the cut left zero dangling references.

Two things to handle at merge, neither blocking

  1. PER-15157 is closed by this PR, and the known cross-tenant fallback is still pointed at it. The missing-record → 200 with the default scope's bundle is pre-existing, deliberately out of scope, and honestly recorded as characterization rather than endorsement — that disposition is right. But it is a genuinely HIGH-severity issue in the codebase, and merging as-is orphans its tracking, as the description itself notes. Please give it its own ticket before or at merge. Flagging it plainly so it is not lost: it is not a finding against this PR, and it should not disappear either.
  2. The nine open threads from this round are worth landing as follow-ups. The three untested guards are the cheapest and most valuable — the PR's own standard is that every pin fails the mutation named in its docstring, and d359ffb2 currently meets that on the leader side only.

On the review itself

Round 8's HIGH-1 severity was mine to get wrong, and your master-comparison was the right correction. In this round I also filed "239 passed" as an off-by-one and was wrong again — the number is correct for pytest packages/opal-server; I had used a narrower target. Both are noted in the round-9 body.

Nine rounds in, the trend is what convergence looks like: CRITICAL/HIGH through round 8, nothing above MEDIUM now, and the remaining items are tests and prose rather than behaviour. Good work on the cut — deferring the distributed disk reclaim to PER-15612 is what made this reviewable.

Zivxx and others added 2 commits August 16, 2026 16:50
…nned guards, a bypassable drift guard

Six MEDIUM and three LOW from the approving review. Most are mine from round 8.

**A transient fault on the default-bundle path got a permanent 404.** Round 8
turned an unhandled 500 there into a 404, which is right for the case the
comment described — an unknown scope on a deployment with no "default" scope.
But the except tuple was far wider than that justification and swallowed every
transient git/OS fault from a default scope that DOES exist, answering 404 with
no Retry-After where the primary path answers 503 + Retry-After 5 for the
identical exception. §6 tells third-party consumers to act on these codes, so it
was wrong in the unsafe direction. Split: ScopeNotFoundError -> 404, the git/OS
set -> 503, matching the primary path.

**Three guards added last round had no test.** Verified by mutation: the floor's
live-sibling `minted = None` and the floor's derived-path comparison both left
the full suite green when deleted, while the leader's identical guard reddens two
tests — so d359ffb was half-tested, and the untested half is the one that runs
on whichever worker served the DELETE. Both are pinned now.
test_floor_refuses_a_path_that_is_not_the_derived_one, which was named for the
comparison and passed with it deleted, now fails when it is removed: the derived
path is real and populated while the caller's disagrees, so removing the check
makes the floor act on it.

The third — the `minted = None` at the pop-before-publish hand-off — turned out
to be untestable because it is dead code: the identity check already refuses to
pop a successor, since the successor is a different object. Removing the
identity check fails three tests; removing the assignment changes nothing. So
the assignment is gone and its reasoning moved into the guard's comment, rather
than inventing a test that cannot discriminate.

**The drift guard was bypassable by source formatting.** It regex-matched
`\n    SCOPES_\w+ = confi\.`, so a type annotation or a stray second space made
a live key invisible to BOTH directions at once — the same unguarded-by-omission
failure the derivation was introduced to remove, moved from the list level to
the formatting level. It now derives from `dir(opal_server_config)`, which
formatting cannot affect, and additionally rejects a duplicate `#### OPAL_<key>`
heading (the guard reads the first match, so a second section renders on the
page unseen). Both bypasses verified to fail now.

**SCOPES_STORE_READ_TIMEOUT's description stated the opposite of the code.** It
survived the disk-reclaim cut unchanged, telling operators a DELETE purges the
clone defensively on expiry when the delete floor deliberately KEEPS it — the
round-8 over-purge fix. Rewritten for post-cut behaviour: the reason-based split
now decides the fleet-wide MEMORY purge only, and the clone is kept either way.
config.py and the .mdx moved together, as the guard requires.

**The I1 exemption on the broadcast-lost gate was wrong twice over.** Its stated
reason — inherited orphans from earlier tests — cannot apply, because
opal_multiworker force-recreates opal_server and its clone tree is
container-local. Measured: disk is EMPTY at teardown and I1 holds, so exempting
I1 was suppressing the gate's own subject. The violations are I2/I3/I4: the test
stops Postgres, so the leader's confirmation cannot cross to the other worker
and that worker's entries survive by construction. Since stats() answers from
whichever worker serves, whether that is observed was a coin flip — which is how
the wrong marker survived two green runs. I1 is now enforced, I2/I3/I4 exempted
with the real reason, and three consecutive full-file runs are clean.

Also: "exactly ONE path removes a clone dir" was false — there are three rmtree
sites and two are modified by this PR; reworded to RECLAIMS, with the other two
enumerated and why each is safe. The executor's shape-check now covers all six
privates it touches rather than one of them. And the purge-channel authz
docstring, which my round-8 claim sweep had spliced into an unparseable
sentence, is repaired.

opal-server unit: 243 passed. Bed: test_transitions.py + test_leak.py 11 passed,
three consecutive clean runs. pre-commit clean.

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

Every one of PR3's failure modes is a *level* — how many git ops are
stuck, whether a leader exists, whether the broadcaster reader is alive —
and the service emitted no state metrics at all, only permit.opal.startup.
Monitoring therefore had to be built on log greps, which is why the
clone-failure monitor breaks the moment this PR widens its except clause
and why the zombie-cap signal is a log that latches once per episode.

Each metric below reads a value the code already computes and hands it to
statsd at a call site that already exists: no new control flow, no new
state, no new I/O, no new failure path. The two metrics that would have
needed one of those (clone-dir bytes, connected clients) are deliberately
left out — they belong to their own change, not to a PR under review.

- scopes.git_ops_in_flight is now tagged by pid. Untagged, the pod's 8
  workers collapsed into one last-write-wins series, reading as an
  arbitrary worker's count rather than anything about the pod.
- scopes.git_op_failures{op,reason} separates the steady timeout rate
  that SCOPES_GIT_FETCH_TIMEOUT now makes expected from a genuine clone
  failure — the distinction the shared log string cannot carry.
- scopes.git_ops_refused counts every zombie-cap refusal. The ERROR log
  latches via _zombie_cap_logged so an outage cannot bury the cap-reached
  line; that leaves it unable to answer how hard or for how long.
- scopes.count is emitted before the poll-updates filter, so one gauge
  cannot alternate between the fleet total and the polled subset.
- scopes.leader is a heartbeat from _periodic_polling, which runs only
  inside the leadership lock. sum by env reaching 0 means no worker holds
  it anywhere and scope syncing has silently stopped, while pods stay
  Ready and /healthcheck stays 200. Not emitted from sync_scopes: that
  also runs in the pre-fork master, which is not the leader.
- broadcaster_reader_healthy publishes the verdict /healthcheck already
  reaches. Staging runs no liveness probe, so nothing otherwise acts on
  the 503.

metrics_emission_test.py pins the claim behind each one; every assertion
was verified by applying its own mutation (untag the gauge, latch the
counter, move the count past the filter, invert and untag the health
value) and confirming the matching test fails.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Zivxx and others added 12 commits August 17, 2026 15:55
…ile the clone is being populated

The 503 + Retry-After: 30 this route answers while a scope's clone is being
populated is honest and useless to the only client that matters. opal-client
0.9.6 ignores Retry-After: it makes five attempts with random-exponential
backoff capped at 10s — roughly 20-40s of coverage — and then goes quiet
until the next pub/sub policy message or a reconnect. A clone that outlives
those attempts leaves that PDP with no policy and nothing scheduled to fix
it. The update-all published when the clone completes names only the scope
that was syncing, so siblings sharing that clone are never woken, and a PDP
that burned its attempts inside the window stays stranded either way.

So hold the request instead, for at most SCOPES_POLICY_CLONE_WAIT_SECONDS
(default 20s), re-checking once a second and returning the bundle the moment
the clone is usable. Five client attempts against a 20s hold cover about two
minutes of clone time, so short and medium re-clones now produce no
client-visible gap at all. The bound matters in both directions: 20s is well
under the 60s ALB idle timeout — a longer hold would surface as a 504 the
client cannot tell from a dead server — and far under the client's 300s
aiohttp total timeout. 0 restores the previous immediate 503 exactly.

Readiness stays DISK-derived (CloneNotPopulatedError, no remote-tracking refs
yet), never the in-process in-flight marker: the clone runs in the leader
while this route is served by any worker, so only disk gives every worker the
same answer. The hold is an awaited sleep loop clamped to the remaining
budget — no thread between polls, no lock, no cache touched, cancellation-safe
— so the event loop and the gunicorn heartbeat are unaffected.

Whatever a retried attempt raises reaches the handlers the first attempt
would have: an absent branch is still a non-retryable 409, a gutted object
store still a 503 + Retry-After 5. Only an unpopulated clone is waited for.
On expiry the route falls through to the unchanged 503 (same event, same
Retry-After), with the hold added to its log line, and each wait that
happened is counted once as opal_server.scopes.policy_clone_wait with
outcome:served or outcome:timeout, so a dashboard can separate rescued
requests from stranded ones.

Two tests in scope_policy_fallback_test now pin their verdicts at wait=0:
what they assert is which status each condition produces, not how long the
503 takes to arrive, and at the default they sat through the full budget.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…isconnect, and full accounting

The wait was correct for one request and unbounded for twelve thousand.

Polling is cheap (~0.23ms per check). RELEASING is not: when the clone
lands, every held request builds a full bundle on the loop's shared default
executor — about min(32, cpu+4) threads with an unbounded queue — and
measured bundle throughput falls from ~52/s at 32 concurrent builds to ~18/s
at 1000. At production scale the total a client sees is hold + queue +
build, which walks straight past the 60s ALB idle timeout: the 504 the hold
exists to prevent. A rolling restart is worse, because uvicorn waits on
in-flight requests while gunicorn SIGKILLs the worker at 30s, hard-dropping
the ~1000 websockets that worker still holds.

So the hold is now capped per process by SCOPES_POLICY_CLONE_WAIT_MAX_INFLIGHT
(default 64). Requests past the cap get the immediate 503 + Retry-After 30
they would have got before the wait existed, so the cap can never be worse
than not waiting. The count is a module int mutated only on the event loop —
no lock, because the increment and the cap check cannot interleave — with a
try/finally decrement, and it is published as a pid-tagged gauge (not
scope-tagged: the cap is a per-process resource, and scope_id is unbounded
cardinality).

Three more bounds on the same hold:

- The budget is validated and clamped. NaN was the interesting one: `nan <= 0`
  is False and every arithmetic on it yields NaN, so a NaN budget passed the
  old sign check and then polled forever. Non-finite, non-positive or
  unparsable disables the wait (it is read on the request path, so the
  alternative to parsing defensively is a 500 per clone-in-progress request);
  anything above 55s is clamped below the load balancer's idle timeout, warned
  about once per process.
- A client that has hung up is no longer waited for. The route hands the
  Starlette Request to the wait, which checks is_disconnected() once per poll
  and gives the slot back to a caller that is still listening.
- Every request that reaches the wait is now counted exactly once, tagged with
  how it ended: served, timeout, shed, disconnected, cancelled or error.
  Cancellation is a BaseException, so it had no arm at all before and a fleet
  whose waits were all being torn down looked like one where nothing waited.
  The hold duration is published as a gauge on served/timeout so the knob can
  be tuned against what it saved.

The default-scope path (`_generate_default_scope_bundle`) now waits on the
same terms. It is the branch every PDP holding a stale scope id takes, and it
was the one place a mid-clone request still got an immediate 503. It keeps ITS
contract on expiry (Retry-After 5, from its own handler) — the wait re-raises
into each caller rather than shaping a response itself.

Wording corrections in the config description and docstrings: what is bounded
is the wait plus at most one bundle attempt (queue time on the shared executor
is outside the deadline — that is what the cap bounds); the hold occupies no
thread BETWEEN polls; the rmtree-and-init window before the download is not
waited on at all (it answers 503 + Retry-After 5), so the "no client-visible
gap" claim is now scoped to the download phase.

Tests: the 27-mutation review pass left 8 survivors; all are closed. The
expiry test bounds the poll COUNT, not just the sleep sum, so a spin loop
fails it; the clamp test bounds wall clock; the poll constant is pinned
against the "once a second" sentence in both config.py and the published
reference; the don't-wait test is parametrized over the three first-attempt
faults (BranchHeadNotFoundError included — it is a ValueError subclass, so
widening the caught type by one level swallows a permanent misconfiguration);
the reported hold is asserted as a NUMBER (>= 0.9 of the budget) rather than
as "a number is present"; base_hash is asserted preserved across polls, since
dropping it silently upgrades every rescued client to a full bundle.
waited_seconds is declared on CloneNotPopulatedError with a 0.0 default and
set explicitly on every raise path, so no reader needs a getattr default.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… a NaN budget

`_bounded_clone_wait` already refuses a NaN budget, so this cannot happen in
the shipped code. It is pinned separately because the loop's own termination
should not depend on a check three frames away staying correct, and because
the failure it prevents is worse than the one the guard prevents: NaN compares
False against BOTH `<= 0` and `> 0`, so `if remaining <= 0: break` never fires
on a NaN deadline. The request would then poll for the life of the process
while holding one of the SCOPES_POLICY_CLONE_WAIT_MAX_INFLIGHT slots — a slot
that is never returned, permanently lowering the cap for that worker.

`if not (remaining > 0)` breaks on NaN as well as on a passed deadline.

Also pins +inf in the budget parametrization. The documented reading is that
any non-finite value disables the wait; without the isfinite check +inf would
instead fall through to the clamp and hold for 55s, which is a different
behaviour from the one the public config reference promises.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… gauge publish inside the slot's try

Review nits from the re-reviews, none of which change the mechanism.

The MAX_INFLIGHT description claimed bundle builds share "the same pool
SCOPES_GIT_MAX_WORKERS bounds". That is false, and it mattered: it is the
sentence an operator sizes the cap against. Scope git ops each get their OWN
single-use daemon-thread executor, bounded by SCOPES_GIT_MAX_WORKERS through
an asyncio semaphore; bundle builds go to the loop's DEFAULT executor, shared
with every other off-loop call in the process. So this key is the only bound
on bundle-build concurrency, which the description now says instead.

The budget guard's rationale was wrong in the same way — it claimed the config
is read on the request path, so an unparsable value would 500 every request.
Confi parses the environment once, at import: OPAL_..._SECONDS=notanumber
fails the process at startup and never reaches the guard (verified). The
load-bearing half is the FINITENESS check, because nan, inf and -inf all parse
cleanly and start the process — inf would silently mean the clamped maximum
hold on every clone-in-progress request. The float() guard stays (it covers a
value assigned at runtime) with its actual, smaller justification.

Three guards that existed only as prose are now pinned by tests, each naming
the mutation it catches:

- `0 or negative means no cap` — `if cap <= n` instead of `if 0 < cap <= n`
  sheds EVERY request at cap=0 (0 <= 0), inverting the documented escape
  hatch into "never wait at all".
- `_CLONE_WAIT_MAX_SECONDS = 55.0` is coupled to the "55s" sentence in
  config.py and in the published reference, the way the poll interval already
  was. Neither constant is a config key, so the docs are their only contract.
- The default-scope path's expiry log now reports the hold, and has its own
  `except CloneNotPopulatedError` arm to do it. Without one, the broad
  transient tuple below it catches the same exception (it subclasses
  ValueError) and produces a log indistinguishable from a 503 that never
  waited — on the branch every PDP with a stale scope id takes.

The in-flight gauge is now published INSIDE the slot's try. It ends in a
metrics sink; a sink that raised between the increment and the try leaked the
slot for the life of the process, permanently lowering that worker's cap.

A client that has hung up no longer produces a `ScopePolicyUnavailable` event
or the 503 INFO line. That 503 is shaped for a socket nobody is reading, and
counting it inflates the exact rate an operator watches to decide whether
clients are being served. The wait already logs the abandonment once, with the
hold. Marked by `client_disconnected`, declared on CloneNotPopulatedError
beside `waited_seconds` for the same reason: no getattr defaults at readers.

Two outcomes that were counted but never logged now have one INFO line each:
`cancelled` (with the hold) and `error` (with the hold and the exception that
ended it). The `error` line is emitted only for the unclassified failure, so
the timeout and disconnect paths are not double-logged.

Tests: the NaN-deadline guard is now driven through an ASGI client under
`asyncio.wait_for` — this repo sets no pytest timeout, so its previous form
could only fail by hanging the suite. And since that guard exists, a NaN budget
no longer polls, which makes `inf` (not NaN) the detector for dropping
`math.isfinite` — visible end-to-end only by waiting out the full 55s ceiling.
So the budget refusal is asserted directly on `_bounded_clone_wait` for the
whole non-finite trio, and only the cheap params still run end-to-end. Both
mutations are caught in ~2s instead of 45.

Also: the redundant wall-clock assertion in the budget test (one attempt and
no sleeps already prove it), a more generous bound on the clamp test's wall
clock (sum-of-sleeps is what pins the clamp itself), and a blank line before
each of the three affected mdx headings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… pass (per-source, doubling, capped; explicit refresh bypasses)

Nothing recorded a git failure: a source whose clone or fetch failed was
re-attempted on the very next pass, and so was every duplicate scope sharing
it (no local copy -> straight to clone, serialized under lock_source). On
prod-us that is 19 fast-failing GitOps sources producing 4,579 clone attempts
in three hours, 2,410 of them from ONE repo shared by ~56 scopes, plus two
SYN-drop hosts each holding a slot for the full fetch timeout every pass. The
in-flight skip only covers a source whose op is STILL running, i.e. the hung
class; the faster pass this PR ships multiplies the fast-fail class.

Now every awaited clone/fetch failure (GitError or TimeoutError, on either
path) puts its SOURCE into a per-process backoff: first skip = one
POLICY_REFRESH_INTERVAL (60s when polling is off), doubling per consecutive
failure, capped at SCOPES_GIT_BACKOFF_MAX_SECONDS (3600), +-20% jitter,
exponent clamped so a days-long failure cannot overflow. Only pass-originated
syncs honour it (periodic pass and boot preload, both phases - so a dead
source shared by N scopes costs one attempt per pass, not N); an explicit
POST /scopes/{id}/refresh, POST /scopes/refresh or PUT /scopes attempts the
source immediately, and any success clears the entry. The check runs before
lock_source and before any executor slot, so a skipped source can neither
queue behind a hung op nor be refused by the zombie cap. Backpressure from
the cap is deliberately not recorded (it says nothing about the remote).

State is in-memory per process; reset_caches() leaves it so the forked
leader inherits what the pre-fork preload learned; the purge/delete paths
drop it with the source's other caches. Fetch GitError is now also counted
in git_op_failures{op:fetch,reason:git_error} (it was uncounted). New metrics:
opal_server.scopes.git_op_skipped{reason:backoff} and
opal_server.scopes.sources_in_backoff{pid}. 0/negative/nan/inf disable the
feature. 27 tests; 12 single-line mutations verified caught.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
GitOps scopes are synced only by the periodic pass, so a customer who repairs
their repository on the git host (without touching Permit, which would issue a
bypassing PUT/refresh) waits up to 1.2x the cap for the next attempt. 30 min
keeps ~96% of the attempt reduction (about 10 attempts per dead source per
3 h instead of hundreds) at half the worst-case recovery latency.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…se at one attempt, floor the cap at the base, live-only gauge emitted per pass, warn only on transitions, boot honours only with a periodic pass, cap 900s

Review round 1 (three lenses):
- Concurrent phase-2 duplicates all passed the pre-lock check before the
  first had failed and recorded, then each did its own clone attempt under
  lock_source (measured: 10 attempts for a 56-scope dead repo per unblocked
  pass). Re-check the backoff after acquiring the lock -> 1 attempt.
- The first delay was one POLICY_REFRESH_INTERVAL (60s) while a hung host
  costs a full SCOPES_GIT_FETCH_TIMEOUT (120s), so it expired before the pass
  that armed it finished. base = max(interval, fetch timeout).
- A cap below the base made the feature silently inert; cap = max(cap, base).
- The gauge counted expired entries and was emitted only on transitions
  (NO DATA in exactly the steady state the feature creates); it now counts
  live entries only and is emitted once per pass as well.
- Every recorded failure logged WARNING, including the explicit-refresh path
  policy-sync re-issues constantly for a broken repo; now WARNING only when a
  source enters backoff or its delay reaches the cap, DEBUG otherwise.
- With POLICY_REFRESH_INTERVAL <= 0 the boot sync is the only pass-originated
  sync, so a transient preload failure would strand a source; start() now
  honours the backoff only when a periodic pass follows.
- Default cap 1800 -> 900: past ~900s the load saving per doubling is a few
  dozen attempts per 3h while worst-case post-fix staleness for timer-only
  GitOps customers doubles (18 -> 36 min).
Also: fakes mirror the real keyword-only , liveness-skip and
no-fetch paths pinned as non-recording. 35 tests; 20 mutations caught.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ed entries at boot instead of not honouring

Passing honor_backoff=False for the boot sync when POLICY_REFRESH_INTERVAL <= 0
un-stranded a transiently-failed source but also switched off the within-pass
duplicate collapse for the whole boot pass (a dead repo shared by 56 scopes
cost 56 clone attempts at boot, ~11 min at defaults). Clear the inherited
entries instead and keep honouring: the transient source is re-attempted, and
duplicates of a source that fails in this pass are still collapsed to one.
Also exposes emit_sources_in_backoff() as the public name for the per-pass
emission (service.py imported the private one). Test counts the state, not
just the flag.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…our, gauge honours the kill switch, unpinned guards pinned

- The public description still said a forked worker inherits the preload's
  failures; with POLICY_REFRESH_INTERVAL=0 (the shipped default) the boot
  sync now drops them, so state both cases. Same fix in reset_caches'
  docstring; the '1.2x this key' line now says 'the effective cap'.
- sources_in_backoff read the live count even with the key at 0, and the
  per-pass emission re-published it every pass while the feature was off;
  it now reads 0 under the kill switch.
- Comments: the pre-lock/under-lock checks describe the window they close
  (phase 1 recorded nothing: refused at the cap, scope gone, no fetch), and
  the boot clear states why clearing the whole dict is safe there.
- Tests pin: base uses the configured interval (not only the fallback) and a
  non-finite timeout is no floor; forget re-emits the gauge; purge forgets
  even mid-zombie; gauge is 0 under the kill switch. Removed the customer-
  shaped incident numbers from the operator reference.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…lt, no jitter

Product decision: a repository that keeps failing is in all likelihood dead —
check it again in twice the time it has been failing, and before long only at
the next restart or an explicit refresh. So:
- SCOPES_GIT_BACKOFF_BASE_SECONDS (new, 10.0; 0/negative/nan/inf disable the
  feature) is the first delay; every consecutive failure doubles it, with no
  ceiling. The first few doublings are shorter than a pass period and skip
  nothing — the schedule bites from about the fourth failure (minutes), then
  hours, then days. Duplicates in the same pass are still collapsed by the
  re-check under lock_source regardless of the delay.
- SCOPES_GIT_BACKOFF_MAX_SECONDS becomes an OPTIONAL cap (default 0 = none),
  floored at the base when set; it no longer doubles as the kill switch.
- Jitter removed: at dozens to a few hundred sources per pod, and with the
  pass bounded by SCOPES_GIT_MAX_WORKERS, lockstep retries are not a concern
  worth the non-determinism.
- WARNING when a source enters backoff, again when its delay first exceeds a
  day (effectively abandoned until restart/refresh), and when a configured cap
  is reached; DEBUG otherwise. Overflow clamp kept (2**64 doublings).
Tests rewritten for the new schedule (38 in the file; 9 new mutations caught).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ocstrings; drop the dead random import

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

The public configuration reference is MDX; a literal {scope_id} in the copied
description was compiled as a JSX expression (ReferenceError at docs build).
Use :scope_id in the route example instead — both in config.py and the mdx,
which the drift test keeps verbatim.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Zivxx
Zivxx merged commit f6c3d83 into master Aug 18, 2026
12 checks passed
@Zivxx
Zivxx deleted the david/per-15157-pr3-git-resilience-never-stuck-on-an-offline-repo branch August 18, 2026 09:54
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