Skip to content

fix: safely collect runtime revisions in a leader-elected runnable - #2742

Merged
EItanya merged 22 commits into
mainfrom
fix/runtime-revision-gc
Sep 9, 2026
Merged

EItanya merged 22 commits into
mainfrom
fix/runtime-revision-gc

Conversation

@EItanya

@EItanya EItanya commented Sep 8, 2026 •

Copy link
Copy Markdown
Contributor

Rebased onto main at 214b5568, including #2774’s atomic RecordRuntimeRevision operation and #2780’s generic database error categories. This PR keeps that operation atomic and acquires pair locks before revision writes to preserve GC lock ordering.

Deleting or replacing a prepared AgentTemplate left a retired pair pointing at its last successful revision. GC deleted compute and then hit a RESTRICT foreign key; it also missed checkpoint references and raced with new instance references. A single failed deletion could fail unrelated pair reconciles.

Run GC as a separate leader-elected manager runnable. Controller leader election defaults to enabled even with one replica because rolling updates overlap controllers; LEADER_ELECT=false is available for local testing. Helm relies on that default and always grants lease permissions. GC sweeps on startup and every minute, logs each failed candidate, continues collecting other revisions, and retries durable deletions without template events. Database discovery and each candidate have one-minute deadlines.

Pair reconciliation only prepares runtimes and manages references. It exclusively owns KRT readiness observations, keyed by pair and tagged with their revision; changed inputs ignore stale observations, and a fresh reconciler can clean them up without remembering previous calls. When a desired digest is being deleted, clearing its observation makes KRT derive a pending pair. The existing pending-template poll retries that state outside the error retry budget. There are no private retry or observation maps, and GC has no KRT dependency or callback.

Begin deletion durably before touching Substrate. Explicit row locks in the store transactions serialize pair, instance, and checkpoint acquisition with deletion: an acquisition that wins prevents collection; deletion that wins rejects new references with ErrObjectDeleting. Pair writes check both the desired and retained last-successful revision before committing. Ordinary and scheduled instance creation acquire the revision lock in their shared insertion helper. There are no reference triggers or custom SQL exceptions. Lock the revision first, then check the shared eligibility view in a separate statement so references committed during the lock wait are visible. No database lock spans a network call.

Retain revision and ActorTemplate identity until runtime cleanup succeeds. Finalization atomically releases retired success pointers and deletes the row, checks the ActorTemplate UID against stale collectors, and preserves pointers when skipped. Operations touching both pairs and revisions lock pairs first to avoid a finalization/reactivation deadlock. Preserve the current UID pair's last-good revision across invalid edits, retire replaced identities, and publish Ready only after the successful revision is persisted.

Queues use ten attempts with exponential backoff from one to thirty seconds. Pending preparation still polls, but a removed pair whose retirement exhausts its retries is no longer discoverable from the graph; a restart can also lose that event. Operator repair is required until the system doctor in #2768 compares persisted identities with the synced Kubernetes graph. Leader election reduces overlapping collectors, but full stale-request fencing still requires Substrate to support UID preconditions on deletion.

The schema is unreleased, so the requested changes are folded into migration 1. Existing RESTRICT foreign keys remain. The store uses main's inline pgx queries and transaction helpers; no sqlc queries or generated accessors are restored. The Migration Immutability workflow rejects this baseline rewrite; no workflow bypass is included.

Validation:

  • PostgreSQL coverage: retirement, instance/checkpoint retention, both concurrent acquisition/deletion orderings for instances, new pairs, and reactivated pairs, both finalization/reactivation and finalization/recording lock orderings, skipped finalization, restart recovery, and stale collector UID protection.
  • Controller coverage: startup and periodic sweeps, list/delete failures, healthy candidates behind failed or blocked cleanup, cancellation, real KRT Ready-to-pending transitions, fresh-reconciler observation replacement and cleanup, bounded backoff, preparation retries during deletion, readiness recovery, and fresh-collector recovery after failed compute deletion, a lost response, or successful compute deletion followed by failed database finalization.
  • Database/controller/app/env tests, database/controller/migration race checks, and full Go lint pass. PostgreSQL coverage includes preparation of every inline SQL statement against the migrated schema. Baseline Up/Down, related service/Substrate/gRPC checks, and E2E compilation also pass.
  • All 279 Helm tests pass, including single-replica lease RBAC. App tests cover default-enabled election and the local opt-out.
  • Real Substrate lifecycle E2E validates immediate instance creation after Ready, last-good invocation, checkpoint retention, eventual ActorTemplate and golden-actor cleanup after the final checkpoint is deleted with no subsequent template change, and recreation/invocation under a new template UID. Before the KRT-ownership follow-up, the strengthened test passed in 85 seconds while the preserved runsc state exit-128 deletion failed on each independent sweep; unrelated pair reconciles no longer reported that GC error.

Live Substrate E2E was not rerun after the KRT changes or this rebase: the dedicated test cluster was removed before rollout.

GC metrics remain in #2744; failed or abandoned checkpoint recovery remains in #2746. Sweeps are serial; bounded workers can be added if slow deletions cause a material backlog.

@EItanya
EItanya requested a review from a team as a code owner September 8, 2026 14:39
@github-actions github-actions Bot added the bug Something isn't working label Sep 8, 2026

@jjamroga jjamroga left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Automated deep review (8 finder angles, each candidate independently verified against main). Five actionable findings on this diff, one linked follow-up, two nits. One suspected issue was investigated and refuted: "MarkRuntimeRevisionSuccessful matching 0 rows publishes Ready falsely" is unreachable under the single serialized pairs-queue worker with leader election, and in a two-leader overlap the no-op mark is the correct outcome for a superseded revision.


// cleanupUnreferencedRevisions removes immutable ActorTemplates after their
// final pair or AgentInstance database reference has been released.
// final pair, AgentInstance, or checkpoint database reference has been released.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

GC deletes the Substrate ActorTemplate before a DB delete that can silently no-op

Introduced here? The race is pre-existing, but this PR rewrites both the function and the query and converts loud FK errors into silent no-ops (the new checkpoint guard plus the release step). Right moment to fix the ordering.

How a user hits it: someone deletes an AgentTemplate at the same moment another user (or CI) creates an instance of it. The create request reads the revision just before retirement; GC deletes the ActorTemplate in Substrate; the instance insert commits; the guarded DB DELETE matches 0 rows and returns nil (:exec discards RowsAffected). The instance is stuck in CREATING forever — the create workflow (workflow.go:107) needs the now-deleted ActorTemplate by name — with no error and no self-heal, since a referenced revision never reappears in the unreferenced list.

Fix: make DeleteUnreferencedRuntimeRevision :execrows, delete the DB row first, and only call DeleteActorTemplate when a row was actually removed. The DB delete is the guarded, reversible half; let it decide.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 06ad033 with a durable deletion claim before the Substrate call. Reference acquisition and claiming serialize on the revision row; the row and runtime UID remain until compute deletion succeeds. PostgreSQL tests cover both race orderings, including an instance that selected its revision before retirement, and controller tests cover failed/lost delete responses and restart. Finalization also checks the UID against delayed collectors.

return fmt.Errorf("retire replaced AgentTemplate/Harness pair %s: %w", key, err)
}
if state.Revision == nil || state.RevisionID.IsZero() {
if err := r.store.RetireAgentTemplateHarnessPair(ctx, state.Pair.AgentTemplate.Namespace, state.Pair.AgentTemplate.Name, state.Pair.Harness.Name); err != nil {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

A transient compile failure now silently destroys the pair's last-good revision

Introduced here? Partially. On main a compile failure also deleted the ActorTemplate, but the RESTRICT FK made the revision-row delete fail loudly and the row survived. With ReleaseRetiredRuntimeRevisionReferences, the destruction completes silently, and reactivation cannot recover (the upsert's ON CONFLICT never touches latest_successful_revision).

How a user hits it: edit a running agent's AgentTemplate to reference a ModelConfig/Secret/MCP server that does not exist yet — a typo, or applying the template before its Secret. Any CompileAgentTemplate error yields state.Revision == nil (only Failure is set), so this branch retires the pair; GC then releases the retired pointer and deletes the revision row and ActorTemplate. Fix the typo a minute later: the pair reactivates with latest_successful_revision = NULL, so every CreateAgentInstance returns NotFound until a whole new golden snapshot bakes. Note the asymmetry: post-compile failures (state.Failure != nil with a revision) keep the pair active and preserve the pointer — only compile-stage failures get the destructive treatment.

Fix: do not retire on compile failure — retire here only when state.Failure == nil, so a bad ref behaves like every other failure path: pair stays active, last-good preserved, GC blocked.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed: compile failure preserves the current template/harness UID pair and its last-good runtime, while still retiring a replaced UID at the same name. Controller/PostgreSQL coverage exercises both cases; the real Substrate lifecycle E2E also invokes last-good inputs after an invalid edit.

func (c *Client) DeleteUnreferencedRuntimeRevision(ctx context.Context, revision string) error {
return c.q.DeleteUnreferencedRuntimeRevision(ctx, revision)
return c.withTx(ctx, func(q *dbgen.Queries) error {
if err := q.ReleaseRetiredRuntimeRevisionReferences(ctx, &revision); err != nil {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The release UPDATE commits even when the DELETE no-ops — and the whole mechanism is hand-rolled ON DELETE SET NULL

Introduced here? Yes — ReleaseRetiredRuntimeRevisionReferences is new in this PR.

How a user hits it: a revision is listed as unreferenced, but an instance or checkpoint reference lands before the delete runs (this PR's own TestRuntimeRevisionCollectionPreservesInstanceAndCheckpoint sits in exactly this state on every assertRetained()). The DELETE no-ops but the retired pair's latest_successful_revision has already been nulled and the tx commits. If that pair is later reactivated (re-apply the same template), instance creation NotFounds until a new snapshot bakes — even though the revision row still exists.

Fix: delete this query, the withTx wrapper, and the dbgen method; change the FK in migrations/core/000001_initial.sql:56 to ON DELETE SET NULL. It fires only when the row is actually deleted, and it is safe: the DELETE's own guards still protect active pairs, and the only reader (GetLatestRuntimeRevisionForInstance) inner-joins with retired_at IS NULL. One word of schema replaces ~30 lines.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed: finalization must find a claimed revision with the expected runtime UID before releasing any retired success pointers. A skipped finalization leaves those pointers intact; clearing and deletion remain one transaction with RESTRICT preserved. The regression test retires, attempts an unclaimed finalization, reactivates, and successfully creates from last-good.

// atomically. Instance creation must never observe the current pair retired
// during an otherwise unchanged reconciliation.
return c.withTx(ctx, func(q *dbgen.Queries) error {
if err := q.RetireAgentTemplateHarnessPair(ctx, dbgen.RetireAgentTemplateHarnessPairParams{

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The unguarded retire inside this tx rewrites the active row — and every historical retired row — on every reconcile, including the 1 Hz pending poll

Introduced here? Yes — main ran the upsert as a single statement.

How a user hits it: create 20 AgentTemplates. While their snapshots bake, pollPendingTemplates re-enqueues each pending pair every second, and each reconcile runs BEGIN / retire-UPDATE / upsert / COMMIT. RetireAgentTemplateHarnessPair has no UID exclusion and no retired_at IS NULL filter, and SET updated_at = NOW() is unconditional — so every pass writes N+1 dead tuples (N = lifetime UID generations at that name; pair rows are never deleted). Shows up as Postgres bloat and autovacuum churn scaling with template count, worst when Substrate is slow and pairs sit pending.

Fix: use a dedicated query inside this tx: same UPDATE plus AND retired_at IS NULL AND (agent_template_uid, harness_uid) IS DISTINCT FROM ($4, $5). Semantics preserved — the upsert reactivates that exact UID anyway — and steady state writes zero retire tuples. Do not change the shared RetireAgentTemplateHarnessPair; the reconciler's two other call sites need the current UID retired.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed: the replacement retirement query now updates only active rows with different template/harness UIDs. The current UID is never retired during its upsert, and already-retired history is not rewritten by pending-template polls.

AND NOT EXISTS (
SELECT 1 FROM agent_instance i WHERE i.prepared_revision = r.revision
)
AND NOT EXISTS (

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The three-clause "unreferenced" predicate is now copy-pasted between List and Delete

Introduced here? The duplication predates the PR, but this diff is the first time it bit: the checkpoint clause had to be hand-added to both copies (four counting dbgen).

How it bites: the next table that references runtime_revision gets added to one query only. Missing from Delete is FK-backstopped; missing from List hands GC a revision whose ActorTemplate then gets destroyed out from under a live reference (see the ordering comment on cleanupUnreferencedRevisions), or an eternal list/no-op-delete GC loop that nobody notices because neither call errors.

Fix: make one predicate authoritative — DELETE FROM runtime_revision WHERE revision = $1 AND revision IN (<the List query>), or a unreferenced_runtime_revision view in the migration.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Consolidated eligibility into the unreferenced_runtime_revision SQL view, used by both candidate discovery and claiming. Finalization operates on an already-claimed row; reference triggers prevent new references after the claim.

SELECT 1 FROM agent_instance i WHERE i.prepared_revision = r.revision
)
AND NOT EXISTS (
SELECT 1 FROM agent_instance_checkpoint c WHERE c.prepared_revision = r.revision

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Follow-up (not this PR): FAILED / abandoned-CREATING checkpoints are undeletable and now silently pin their revision and ActorTemplate forever

Introduced here? No — the checkpoint state machine and the RESTRICT FK are pre-existing, and for READY checkpoints this guard is a genuine fix (pre-PR their ActorTemplate got destroyed and GC error-looped). What changes: the pin used to scream via FK violations; now it is silent indefinite retention.

How a user hits it: checkpoint an instance and the snapshot tagging step fails once (S3/Substrate hiccup) → checkpoint lands in FAILED. It is invisible to Get/List (READY filter), BeginDelete rejects it (state IN ('READY','DELETING')), the sole DELETE requires DELETING, and there is no reaper or cascade from instance deletion — so its prepared_revision pins the revision and ActorTemplate for the life of the database. A crash between reserve and finalize is worse: the partial unique index on state='CREATING' blocks all future checkpoints on that instance.

Fix (follow-up issue): allow BeginDelete on FAILED rows and add a reaper for stale CREATING rows. Do not add a state filter to this guard alone — that would resurrect the FK violation.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Tracked separately in #2746. FAILED checkpoints currently cannot enter deletion, and abandoned CREATING reservations need coordinated recovery (the original request can resume them today). GC continues to retain every checkpoint row until its lifecycle owner safely cleans it up; filtering by state would bypass that invariant.

if err := r.store.RetireAgentTemplateHarnessPair(ctx, state.Pair.AgentTemplate.Namespace, state.Pair.AgentTemplate.Name, state.Pair.Harness.Name); err != nil {
return fmt.Errorf("retire replaced AgentTemplate/Harness pair %s: %w", key, err)
}
if state.Revision == nil || state.RevisionID.IsZero() {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Nit: this branch and the state == nil branch above now do the identical retire-then-cleanup, 8 lines apart with divergent error strings. Merge into one shared exit — this falls out naturally if compile failures stop retiring (see the comment below on line 255).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Simplified alongside the compile-failure fix: the pair is built once, and invalid compilation only retires replaced identities. The branch that retired the current pair on compile failure is gone.

}
}

func TestReconcilerCollectsRetiredRevisions(t *testing.T) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Nit: this test starts its own Postgres container inside the test body (dbtest.StartT + raw pgxpool.New) instead of the database package's TestMain + database.Connect pattern, and the "first"/"replacement" loop fully tears down between iterations — the second pass repeats the first from an empty DB and does not test replacement-over-an-active-pair (TestRuntimeRevisionPairReplacement in the database package covers that). Suggest one iteration, or dropping the test in favor of the database-package coverage.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Replaced the repeated empty deletion cycle with distinct failure scenarios: invalid edits versus replacement UIDs, plus deletion failing before removal or succeeding with its response lost, followed by retry through a new reconciler/store. The integration test now uses database.Connect; it retains its own PostgreSQL fixture because the controller and database tests are separate packages.

@jjamroga

jjamroga commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

Now human feedback - there are lots of failure cases we can run into managing actors on behalf of the user. Generally speaking, Golden Actor may take a while, but substrate is quick enough that we should be able to reliably exercise many of these cases in e2e tests.

@EItanya EItanya changed the title fix: retain runtime revisions safely during reconciliation fix: claim runtime revisions before deleting compute Sep 8, 2026
@EItanya

EItanya commented Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

Added and ran TestRuntimeRevisionLifecycle against real Substrate v0.0.26 and a fresh database. It exercises creation immediately after Ready, last-good invocation after an invalid edit, checkpoint forking after source/template deletion, and invocation after recreating the same name with a new UID. The final run passed in 41 seconds. Deterministic PostgreSQL races and failed/lost-delete-response controller tests cover the interleavings that E2E cannot reliably schedule. A separate run hit an API/DNS outage and left a backend runsc deletion failure; its claim survived restart, and the remaining retry/failure-isolation behavior is documented in #2744.

@github-actions github-actions Bot added bug Something isn't working and removed bug Something isn't working labels Sep 8, 2026
@EItanya EItanya changed the title fix: claim runtime revisions before deleting compute fix: safely collect runtime revisions in a leader-elected runnable Sep 8, 2026
@github-actions github-actions Bot added bug Something isn't working and removed bug Something isn't working labels Sep 8, 2026
@EItanya
EItanya force-pushed the fix/runtime-revision-gc branch from aecfa3b to 6a76fc5 Compare September 9, 2026 10:40
@github-actions github-actions Bot added bug Something isn't working and removed bug Something isn't working labels Sep 9, 2026
@EItanya
EItanya force-pushed the fix/runtime-revision-gc branch from 6a76fc5 to dca20e8 Compare September 9, 2026 11:10
@github-actions github-actions Bot added bug Something isn't working and removed bug Something isn't working labels Sep 9, 2026
@github-actions github-actions Bot added bug Something isn't working and removed bug Something isn't working labels Sep 9, 2026
@EItanya
EItanya force-pushed the fix/runtime-revision-gc branch from 0f4a567 to d98bb79 Compare September 9, 2026 18:59
@github-actions github-actions Bot added bug Something isn't working and removed bug Something isn't working labels Sep 9, 2026
@EItanya
EItanya merged commit d393f17 into main Sep 9, 2026
27 of 28 checks passed
@EItanya
EItanya deleted the fix/runtime-revision-gc branch September 9, 2026 20:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants