Repository navigation
feat(omnigraph): add the maintenance window and the pre-deploy plan gate - #480
Merged
blarghmatey merged 8 commits intoOct 13, 2026
Conversation
On omnigraph 0.13 `optimize` and `cleanup` are refused with `state_lock_held` while the server holds its `serve` lock, and the server entrypoint applies schema and policy changes only after `Recreate` has stopped the old pod. The nightly sweeps therefore need the server stopped, and a change the apply refuses is first seen by a pod that cannot start. This adds two commands to the omnigraph-server image for the `omnigraph` stack to run as a Job's `command`: - `omnigraph_maintenance.py` scales the server Deployment to 0, waits for its pods to exit, takes over the `serve` lock, runs `optimize` (and `cleanup` on its weekday) per graph in `cluster.yaml`, and restores the replica count. It restores on SIGTERM and on a failed step too. It does not restore when a migration was armed during the window, or when its own lock could not be removed. - `omnigraph_plan_gate.py` runs the entrypoint's plan check and nothing else, so the same refusal fails the deploy while the old pod is serving. Both import the entrypoint for the Lease, the pod check and the CLI wrappers. `run_omnigraph` now stops its child on any exception and takes a timeout, because a SIGTERM handler raising in the main thread used to leave `Popen.__exit__` waiting for the child to finish on its own. Spec: docs/internals/design/omnigraph-0-13-upgrade-spec.md (D4, D5). Claude-Session: https://claude.ai/code/session_01W4TjT25fD1W4TS1uRUY1hC
Every Lease and pod-list test replaces `KubernetesApi` with a fake, so a wrong URL, a token read once, or a 409 not reported as one would leave the force-unlock gate unusable with the suite green. This runs the real client against a local TLS server signed by a throwaway ServiceAccount CA, and adds cases for `main`, a Lease lost between the two unlocks of one boot, the non-deployment branch of `release_apply_lock`, and `confirm()` racing the renew thread. Claude-Session: https://claude.ai/code/session_01W4TjT25fD1W4TS1uRUY1hC
A review of the window found four ways it ended wrong: - A scale-down that was applied but whose response was lost left the server at 0 with no restore, because the window only recorded the stop after the request returned. - A restore that never succeeded still exited 0. - The close removed any `graph_operation` lock, including one that was on the root before the window ran a command. - The pod selector came from the environment, and one that matched no pod read as "the server has stopped", so its `serve` lock would have been removed while it was still shutting down. The stop is now recorded before the request, the exit status reflects the restore, the window only removes a lock after it has started a command, and the selector is read from the Deployment. SIGTERM now sets a flag that is checked between steps and once a second while a command runs, where it used to raise in the main thread. A signal during the close, during `Popen`, or while the Lease was being taken could abort that step halfway. The window also waits up to 90s for a Lease a just-booted server still holds, and `cluster status` and `force-unlock` are bounded at 60s. Claude-Session: https://claude.ai/code/session_01W4TjT25fD1W4TS1uRUY1hC
…graph Production's cleanup sweep took up to 1552s over the last 30 days, which is longer than the window allows. With optimize and cleanup interleaved per graph in `cluster.yaml` order, a window that ran out would skip the same graphs at the end of the list every week. Every graph is now optimized first, and the cleanup pass starts one graph further along each ISO week. Claude-Session: https://claude.ai/code/session_01W4TjT25fD1W4TS1uRUY1hC
blarghmatey
added a commit
to mitodl/ol-infrastructure
that referenced
this pull request
Oct 13, 2026
…6355) * feat(omnigraph): add the 0.13 plan gate and the nightly stop window At storage format 14 the stack creates no cluster-apply Job and suspends the optimize and cleanup CronJobs, because both are refused beside a serving 0.12+ server. That left a format-14 environment with no pre-deploy check on a schema or policy change and no compaction or version cleanup. This adds, gated on the served format like the lock handoff: - a read-only `cluster plan` Job in the apply Job's slot, which the Deployment depends on, so a change the entrypoint's apply would refuse fails the update while the old pod is still serving; - one `omnigraph-maintenance` CronJob in place of optimize and cleanup. It scales the server to 0 for a nightly window, under its own IRSA ServiceAccount with a Role limited to the unlock Lease, the pod list and the server Deployment's scale; - the `ol.mit.edu/migration-armed` annotation on the Deployment, which the window reads so it does not scale the server back up under an armed migration; - a council probe schedule that skips the window's hour. Both commands are in the omnigraph-server image (mitodl/agent-kit#480). Environments on format 9 get a second ServiceAccount on the IRSA role and no other change. Claude-Session: https://claude.ai/code/session_01W4TjT25fD1W4TS1uRUY1hC * fix(omnigraph): force the Deployment patch at format 14 and test the gating The maintenance window writes `spec.replicas` through the scale subresource, which makes its client a second field manager of that field. Server-side apply then refuses a Pulumi update that changes the count, and the update that does is the one arming a migration. The Deployment now carries `pulumi.com/patchForce` wherever a window exists. Nothing ran the format and migration branches of `create_data_tier`, so a plan gate created during a migration, or one missing at format 14, would have passed every test. `test_data_tier_wiring.py` runs the whole function under Pulumi mocks for format 9, format 14 and an armed migration from each. Also: `omnigraph:cleanup_iso_weekday: 0` was read as unset and became Sunday without reaching the range check, and the daily staleness alert's description now says what `omnigraph-maintenance` is and where its recovery steps are. Claude-Session: https://claude.ai/code/session_01W4TjT25fD1W4TS1uRUY1hC
…maintenance-window # Conflicts: # mcp/servers/witan/tests/test_omnigraph_kubernetes_api.py
…maintenance-window
…maintenance-window
* feat(witan): move to omnigraph 0.13 and storage format 14 The pin is on 0.11.0, which reads storage format 9. The entrypoint, maintenance window and plan gate under this commit only work on 0.13, so the image built from main has to carry the 0.13 binary in the same merge. This moves the version, release tag, three asset digests and declared format together in witan-core's installer and both Dockerfiles. Renovate's bump (#474) moves only the version lines. The digests were hashed from complete downloads and match the published .sha256 files and the release API. Declaring format 14 turns on RESPONSE_CONTRACT_REQUIRED, so a client on this pin treats an unstamped response as a contract violation. In each environment the data tier has to be on 0.13 before the witan clients are. Claude-Session: https://claude.ai/code/session_01W4TjT25fD1W4TS1uRUY1hC * doc(witan-core): state what the contract gate refuses on a 0.11 server Claude-Session: https://claude.ai/code/session_01W4TjT25fD1W4TS1uRUY1hC * test(omnigraph): skip the serve-lock cases without omnigraph-server Three real-binary cases start omnigraph-server. They skipped on the 0.11 pin, and at format 14 they run and fail with FileNotFoundError, because CI and `witan setup` install only the `omnigraph` CLI from the tarball. Claude-Session: https://claude.ai/code/session_01W4TjT25fD1W4TS1uRUY1hC
blarghmatey
merged commit Oct 13, 2026
10318bb
into
omnigraph-013-entrypoint-ledger-v2
17 checks passed
blarghmatey
added a commit
that referenced
this pull request
Oct 13, 2026
…479) * feat(omnigraph): hand the cluster lock over in the server entrypoint omnigraph 0.13 has no `cluster import` or `cluster refresh`, and every writer of a cluster root (the server, `cluster apply`, `optimize`, `cleanup`) leaves an exclusive lock behind when it exits. The shell entrypoint would fail on its first command, and a pod restart would be refused with `state_lock_held` until someone force-unlocked by hand. This replaces the shell entrypoint with a Python one that takes a Kubernetes Lease, waits until no other live pod matches the Deployment's selector, renders group membership, plans, releases the previous owner's `serve` or `deployment` lock, applies, releases its own apply's lock and execs the server. It exits 1 on a `graph_operation` lock, on an outstanding deployment, and on a cluster.yaml that drops a graph outside OMNIGRAPH_GRAPH_REMOVAL_ALLOWLIST, because 0.13 deletes a dropped graph's storage. Losing the Lease stops the running child and unlocks nothing. The policy fixture graph `code_example` becomes `code-example`: 0.13's `cluster apply` accepts the underscore but omnigraph-server refuses to start on it. policy/check.sh drops `cluster import`. This only works on 0.13. On 0.11 `cluster apply` needs the ledger that `import` created, so policy/check.sh fails there, and the real-binary tests skip until the pin reports storage format 14. It has to merge with the pin bump. Design: docs/internals/design/omnigraph-0-13-upgrade-spec.md (D3, D4), #461 Claude-Session: https://claude.ai/code/session_011nyp32f2v7efTTBMqnmEuG * fix(omnigraph): keep a metadata-only Lease change from failing a boot Three things from review of the entrypoint rewrite. A renewal that hit a 409 and found the Lease still naming this holder returned failure, so a confirm() that landed on a relabelled Lease refused the boot. It now retries once on the new resourceVersion. A truncated Kubernetes API response raises http.client.HTTPException, which is neither OSError nor ValueError. It escaped _request and ended the renew thread, and the running apply was then stopped 20 seconds later for no reason. `--cluster=<value>` was not recognised, so a storage-URI root passed that way would have converged the default directory. The Dockerfile comment said python3-minimal lacks json and ssl. Both import there; urllib.request and secrets are the ones that do not (checked in debian:trixie-slim). Claude-Session: https://claude.ai/code/session_011nyp32f2v7efTTBMqnmEuG * fix(omnigraph): require an opt-in to unlock outside Kubernetes Outside Kubernetes the entrypoint has no Lease and no pod list, and it still force-unlocked whatever `serve` or `deployment` lock it found. A `docker run` whose cluster.yaml points at a served storage root would have removed the live server's lock and applied over it. It now refuses to force-unlock there unless OMNIGRAPH_UNLOCK_WITHOUT_PEERS=1 is set. `cluster apply` always leaves a lock, so a local run needs the variable on an empty root too. Claude-Session: https://claude.ai/code/session_011nyp32f2v7efTTBMqnmEuG * test(omnigraph): cover the entrypoint's Kubernetes client and main() Every Lease and pod-list test replaces `KubernetesApi` with a fake, so a wrong URL, a token read once, or a 409 not reported as one would leave the force-unlock gate unusable with the suite green. This runs the real client against a local TLS server signed by a throwaway ServiceAccount CA, and adds cases for `main`, a Lease lost between the two unlocks of one boot, the non-deployment branch of `release_apply_lock`, and `confirm()` racing the renew thread. Claude-Session: https://claude.ai/code/session_01W4TjT25fD1W4TS1uRUY1hC * test(omnigraph): refuse TLS below 1.2 in the fake Kubernetes API CodeQL flags the test server's SSLContext (py/insecure-protocol) because PROTOCOL_TLS_SERVER leaves TLS 1.0 and 1.1 negotiable. Nothing connects to it but the client under test, which does not need them. Claude-Session: https://claude.ai/code/session_01W4TjT25fD1W4TS1uRUY1hC * feat(omnigraph): add the maintenance window and the pre-deploy plan gate (#480) * feat(omnigraph): add the maintenance window and the pre-deploy plan gate On omnigraph 0.13 `optimize` and `cleanup` are refused with `state_lock_held` while the server holds its `serve` lock, and the server entrypoint applies schema and policy changes only after `Recreate` has stopped the old pod. The nightly sweeps therefore need the server stopped, and a change the apply refuses is first seen by a pod that cannot start. This adds two commands to the omnigraph-server image for the `omnigraph` stack to run as a Job's `command`: - `omnigraph_maintenance.py` scales the server Deployment to 0, waits for its pods to exit, takes over the `serve` lock, runs `optimize` (and `cleanup` on its weekday) per graph in `cluster.yaml`, and restores the replica count. It restores on SIGTERM and on a failed step too. It does not restore when a migration was armed during the window, or when its own lock could not be removed. - `omnigraph_plan_gate.py` runs the entrypoint's plan check and nothing else, so the same refusal fails the deploy while the old pod is serving. Both import the entrypoint for the Lease, the pod check and the CLI wrappers. `run_omnigraph` now stops its child on any exception and takes a timeout, because a SIGTERM handler raising in the main thread used to leave `Popen.__exit__` waiting for the child to finish on its own. Spec: docs/internals/design/omnigraph-0-13-upgrade-spec.md (D4, D5). Claude-Session: https://claude.ai/code/session_01W4TjT25fD1W4TS1uRUY1hC * test(omnigraph): cover the entrypoint's Kubernetes client and main() Every Lease and pod-list test replaces `KubernetesApi` with a fake, so a wrong URL, a token read once, or a 409 not reported as one would leave the force-unlock gate unusable with the suite green. This runs the real client against a local TLS server signed by a throwaway ServiceAccount CA, and adds cases for `main`, a Lease lost between the two unlocks of one boot, the non-deployment branch of `release_apply_lock`, and `confirm()` racing the renew thread. Claude-Session: https://claude.ai/code/session_01W4TjT25fD1W4TS1uRUY1hC * fix(omnigraph): close the maintenance window's failure paths A review of the window found four ways it ended wrong: - A scale-down that was applied but whose response was lost left the server at 0 with no restore, because the window only recorded the stop after the request returned. - A restore that never succeeded still exited 0. - The close removed any `graph_operation` lock, including one that was on the root before the window ran a command. - The pod selector came from the environment, and one that matched no pod read as "the server has stopped", so its `serve` lock would have been removed while it was still shutting down. The stop is now recorded before the request, the exit status reflects the restore, the window only removes a lock after it has started a command, and the selector is read from the Deployment. SIGTERM now sets a flag that is checked between steps and once a second while a command runs, where it used to raise in the main thread. A signal during the close, during `Popen`, or while the Lease was being taken could abort that step halfway. The window also waits up to 90s for a Lease a just-booted server still holds, and `cluster status` and `force-unlock` are bounded at 60s. Claude-Session: https://claude.ai/code/session_01W4TjT25fD1W4TS1uRUY1hC * fix(omnigraph): rotate the cleanup pass so a short window starves no graph Production's cleanup sweep took up to 1552s over the last 30 days, which is longer than the window allows. With optimize and cleanup interleaved per graph in `cluster.yaml` order, a window that ran out would skip the same graphs at the end of the list every week. Every graph is now optimized first, and the cleanup pass starts one graph further along each ISO week. Claude-Session: https://claude.ai/code/session_01W4TjT25fD1W4TS1uRUY1hC * feat(witan): move to omnigraph 0.13 and storage format 14 (#482) * feat(witan): move to omnigraph 0.13 and storage format 14 The pin is on 0.11.0, which reads storage format 9. The entrypoint, maintenance window and plan gate under this commit only work on 0.13, so the image built from main has to carry the 0.13 binary in the same merge. This moves the version, release tag, three asset digests and declared format together in witan-core's installer and both Dockerfiles. Renovate's bump (#474) moves only the version lines. The digests were hashed from complete downloads and match the published .sha256 files and the release API. Declaring format 14 turns on RESPONSE_CONTRACT_REQUIRED, so a client on this pin treats an unstamped response as a contract violation. In each environment the data tier has to be on 0.13 before the witan clients are. Claude-Session: https://claude.ai/code/session_01W4TjT25fD1W4TS1uRUY1hC * doc(witan-core): state what the contract gate refuses on a 0.11 server Claude-Session: https://claude.ai/code/session_01W4TjT25fD1W4TS1uRUY1hC * test(omnigraph): skip the serve-lock cases without omnigraph-server Three real-binary cases start omnigraph-server. They skipped on the 0.11 pin, and at format 14 they run and fail with FileNotFoundError, because CI and `witan setup` install only the `omnigraph` CLI from the tarball. Claude-Session: https://claude.ai/code/session_01W4TjT25fD1W4TS1uRUY1hC
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What are the relevant tickets?
N/A (witan tasks tk-ol-infrastructure-omnigraph-stack-for-0-12-clust-2dee94 and tk-cover-the-omnigraph-server-entrypoint-s-untested-f43c59, part of the omnigraph 0.13 upgrade, spec in #461 D4 and D5)
Description (What does it do?)
Stacked on #479. On omnigraph 0.13
optimizeandcleanupare refused withstate_lock_heldwhile the server holds itsservelock, so the nightly sweeps need the server stopped. The entrypoint in #479 also applies schema and policy changes only afterRecreatehas stopped the old pod, so a change the apply refuses is first seen by a pod that cannot start.This adds two commands to the omnigraph-server image, for the
omnigraphstack in ol-infrastructure to run as a Job'scommand. They are here and not in ol-infrastructure because both need the entrypoint's Lease, pod check and plan check, and this repo's CI has the real binary.docker/omnigraph_maintenance.pyis the stop window from D5:ol.mit.edu/migration-armedor is already at 0 replicas.resourceVersionit read, and wait until no live pod matches the selector.servelock. Any other lock stops the window.cluster.yaml:optimize, force-unlock. OnOMNIGRAPH_CLEANUP_ISO_WEEKDAY, a second pass:cleanup --older-than … --confirm --yes, force-unlock. A failed graph does not stop the rest. The cleanup pass starts one graph further along each ISO week, because Production's cleanup sweep took up to 1552s in the last 30 days (beside a live 0.11 server) and a window that runs out must not skip the same graphs every week.Step 6 also runs on SIGTERM, on a failed step and when the window's own deadline passes. The restore is skipped when the Deployment is no longer at 0 or a migration was armed in the meantime (the write is conditional, so an arm between the read and the write is seen on the retry). If the window's own
graph_operationlock could not be removed (Lease lost, orforce-unlockfailed), the Deployment stays at 0, as D5 describes.docker/omnigraph_plan_gate.pyrenders group membership and runscluster planthrough the entrypoint'scheck_plan, and nothing else. It takes no Lease and never unlocks.Changes to the entrypoint from #479:
run_omnigraphstops its child on any exception and takes a timeout. A SIGTERM handler raising in the main thread used to leavePopen.__exit__waiting for the child to finish on its own.KubernetesApigainsget_deploymentandreplace_scale, andUnlockLeasegainsrelease().convergeis nowchecked_plan, which the gate calls.Two differences from D5 as written:
/bin/sh -cwith traps in the CronJob.cluster status(lock_operation == "graph_operation"), not from thelock_id=the CLI prints on stderr. The root is unlocked before each command, the Lease excludes other unlockers and no server pod is live, so agraph_operationlock found afterwards is that command's. Root-addressedcluster status --cluster <uri>does not report the operation, so the CronJob has to mountcluster.yamland the window reads the graph list and storage root from it.The second commit is the coverage Copilot asked for on #479: the real
KubernetesApiagainst a local TLS server,main, a Lease lost between the two unlocks, the non-deployment branch ofrelease_apply_lock, andconfirm()racing the renew thread.Like #479, this only works on 0.13 and merges with the pin bump.
How can this be tested?
test_omnigraph_maintenance.py,test_omnigraph_server_entrypoint.pyandtest_omnigraph_kubernetes_api.py, 99 passed, none skipped. Two of the new cases use the real binary on afile://root: a whole window after a real server has left itsservelock (optimize and cleanup on three graphs, no lock left, the server boots again), and the gate reading a served root without touching its lock and failing on a schema change that adds a required field.docker runon a local volume: the gate passes on an empty root and beside a running server, the server boots, stops and boots again, andomnigraph_maintenance.pyimports the entrypoint through the symlink (it gets as far as the missingOMNIGRAPH_SERVER_DEPLOYMENT).deployments/scalePUT honours the Deployment'sresourceVersionis from the API conventions, not observed), a real SIGTERM delivered to the process (the test raises the handler's exception), and anything on S3. These get observed in the CI cutover.just test-witan-councilsuite.https://claude.ai/code/session_01W4TjT25fD1W4TS1uRUY1hC