Repository navigation
feat(controller): retry failed golden builds for Agents and SandboxTemplates - #3335
Conversation
| actor_template_uid TEXT NOT NULL DEFAULT '', | ||
| -- Golden-build retries started after GoldenActorCrashed, the crashed | ||
| -- ActorTemplate UID the latest retry answers, and when it recreates. | ||
| golden_attempts INT NOT NULL DEFAULT 0 CHECK (golden_attempts >= 0), |
There was a problem hiding this comment.
could a new migration add these for existing dbs?
There was a problem hiding this comment.
We will start to have immutable DBs with RCs later this week/early next week
65a0c35 to
17249ed
Compare
9020188 to
2174e1f
Compare
EItanya
left a comment
There was a problem hiding this comment.
🤖 AI-generated review.
Can we contain more of this behavior within goldenRetries? Both reconcilers, their observations, status builders, and pollers currently need to understand golden retries. Having the recovery component return a preparation status and next-check deadline would keep those callers simpler and give us one place to configure the policy or remove it when Substrate handles recovery.
| // but database finalization fails, the durable deletion marker keeps this | ||
| // revision discoverable so the next sweep can safely retry the sequence. | ||
| if err := r.templates.DeleteActorTemplate(collectionCtx, revision.ActorTemplateAtespace, revision.ActorTemplateName); err != nil { | ||
| if err == nil && (revision.ActorTemplateUID == "" || template.GetMetadata().GetUid() != revision.ActorTemplateUID) && !revision.RecordedTemplateCrashed() { |
There was a problem hiding this comment.
Can we verify that a replacement belongs to this revision before allowing GC to delete it? RecordedTemplateCrashed() only tells us that the old UID crashed. If that template is replaced under the same name with different immutable inputs, this skips the UID mismatch check and deletes the replacement using its own UID. The normal reconciler would reject that as a conflict. We should keep that protection when collecting an unrecorded rebuild.
There was a problem hiding this comment.
Done, implemented a digest check
| for _, pinned := range []string{ | ||
| `reasonGoldenActorCrashed\s*=\s*"GoldenActorCrashed"\n`, | ||
| `r\.fail\(ctx, tmpl, reasonGoldenActorCrashed, "golden actor crashed before its snapshot was taken"\)`, | ||
| `ErrorMessage = truncateUTF8\(reason\+": "\+msg, maxGoldenErrorMessageLen\)`, |
There was a problem hiding this comment.
Optional: can we avoid asserting the exact Substrate source here? Production only depends on the GoldenActorCrashed: prefix, but these regexes also pin the private helper calls and full diagnostic text. A harmless upstream refactor or wording change would fail the test without changing the contract we consume. I'd keep the classification tests and drop the checks on those implementation details.
Done. As a bonus found when I was implementing the review comments:
|
Done. |
| func resetGoldenRetry(ctx context.Context, tx pgx.Tx, revision string) error { | ||
| return execSQL(ctx, tx, ` | ||
| UPDATE runtime_revision SET golden_attempts = 0, golden_failure_uid = '', golden_retry_at = NULL, updated_at = NOW() | ||
| WHERE revision = $1 AND golden_attempts > 0 | ||
| `, revision) | ||
| } | ||
|
|
||
| type GoldenRetry struct { | ||
| Retries int | ||
| RetryAt time.Time | ||
| } | ||
|
|
||
| func (c *Client) GoldenRetryAttempts(ctx context.Context, revision string) (int, error) { | ||
| attempts, err := queryOne(ctx, c.db, ` | ||
| SELECT golden_attempts FROM runtime_revision WHERE revision = $1 | ||
| `, pgx.RowTo[int], revision) | ||
| if err != nil { | ||
| return 0, fmt.Errorf("get golden retry attempts for runtime revision %s: %w", revision, notFoundOr(err)) | ||
| } | ||
| return attempts, nil | ||
| } | ||
|
|
||
| func (c *Client) EnsureGoldenRetry(ctx context.Context, revision, failedTemplateUID string, now time.Time, delays []time.Duration) (GoldenRetry, error) { |
There was a problem hiding this comment.
Can we please get comments on these functions so it's easy to grok what they should do
| func (g goldenRetries) observe(ctx context.Context, store goldenRetryStore, templates actorTemplateClient, revision string, | ||
| template *ateapipb.ActorTemplate, record func(database.RecordRevisionOptions) error) (goldenProgress, error) { | ||
| golden := template.GetStatus().GetGoldenSnapshotStatus() | ||
| if err := record(database.RecordRevisionOptions{Ready: golden.GetGoldenTag() != nil}); err != nil { |
There was a problem hiding this comment.
🤖 AI-generated follow-up.
Can we combine recording the revision and updating/reading its retry state into one store transaction? observe currently calls record(...), then EnsureGoldenRetry opens another transaction on the same row, or GoldenRetryAttempts does a separate read. There are no API calls between those database operations, so the record methods could handle them atomically and return the persisted retry state. That would simplify the persistence sequencing currently hidden behind the callback.
DeleteActorTemplate should stay after the transaction commits, so we don't hold database locks across the Substrate call.
73f8061 to
0b25738
Compare
| actor_template_name TEXT NOT NULL, | ||
| actor_template_uid TEXT NOT NULL DEFAULT '', | ||
| actor_template_spec_digest TEXT NOT NULL DEFAULT '', | ||
| golden_retries INT NOT NULL DEFAULT 0 CHECK (golden_retries >= 0), |
There was a problem hiding this comment.
migration-immutability check fails on this edit
When Substrate fails an ActorTemplate with GoldenActorCrashed, the Agent controller now deletes the crashed template by UID after a backoff and recreates it under the same name, up to 3 retries (15s, 1m, 4m plus up to 20% jitter). The runtime_revision row keeps the retry count, the crashed template UID and the retry time, so passes and controller restarts do not spend the budget twice and a new revision starts fresh. While waiting, Ready is False with reason ActorTemplateRetrying; after the budget, reason ActorTemplateFailed names the build count and Substrate's message. GoldenActorInvalid, GoldenTagConflict and other golden errors stay terminal. Signed-off-by: Danielle Mustillo <danielle.mustillo@solo.io>
…ackoff A golden build that crashes during an infrastructure outage longer than the old three-retry budget left the Agent failed until someone edited it. The controller now retries forever: 15s, 1m, 4m, then every 10m, each with up to 20% jitter, and reports every crash as Ready=False ActorTemplateRetrying. ActorTemplateFailed keeps meaning a terminal golden error that nothing retries. - Move the retry, its timers and stop into goldenRetries, one per queue, and drop the in-memory schedule: runtime_revision keeps the retry time, so a restarted controller re-arms the retry without spending another attempt. - Reset the stored attempts when the revision's golden build succeeds. - Keep ActorTemplateRetrying on any template delete error. - Preparing names the retry while the crashed build rebuilds. - Runtime revision GC collects a rebuild that was never recorded by deleting it by UID when the recorded UID is the crashed one. - Import ateapipb from the Substrate v0.7 path. Signed-off-by: Danielle Mustillo <danielle.mustillo@solo.io>
A SandboxTemplate whose golden build crashed stayed not Ready until someone deleted and re-applied it. The sandbox reconciler now records the revision before it checks the golden status, and hands a GoldenActorCrashed template to its own goldenRetries, built beside the preparation queue. Ready shows ActorTemplateRetrying with the retry number while a retry waits, and Preparing names the retry while the rebuild is pending. The poller skips a waiting retry, so its timer or the queue's backoff wakes it instead of a read every second. RecordSandboxRevision resets the stored golden retries when a sandbox revision becomes ready, like RecordRuntimeRevision. Other golden errors keep today's retryable PreparationFailed. docs/architecture/sandboxes.md describes the retries. Signed-off-by: Danielle Mustillo <danielle.mustillo@solo.io>
Build the agent runtime observation in one place: observePreparation now takes the golden retry count. Flatten the sandbox pollPending condition into early continues. Name the GC rebuild check RuntimeArtifact.RecordedTemplateCrashed, rename ScheduleGoldenRetry to EnsureGoldenRetry since repeat calls reuse the stored schedule, and rename GoldenRetry.Attempts to Retries. Remove comments that restated the code. Signed-off-by: Danielle Mustillo <danielle.mustillo@solo.io>
Drop the per-key timers in goldenRetries. A waiting retry now carries its RetryAt on the ReconciliationFailure, and the 1-second pending poll requeues it once that time passes, so the schedule needs no in-memory state or stop hook. Fold DeleteActorTemplateUID into DeleteActorTemplate, which now takes the UID, and use it from both the golden retry and runtime revision GC. Signed-off-by: Danielle Mustillo <danielle.mustillo@solo.io>
Move the poll decision into needsPreparationPoll so it returns a value instead of steering the loop with continue. A break there would have skipped every sandbox after one waiting golden retry, and no test could catch it reliably because KRT lists states in map order. Table-test the decision: a waiting retry is skipped, while a due retry, a missing finalizer and a deletion during a waiting retry are requeued. Signed-off-by: Danielle Mustillo <danielle.mustillo@solo.io>
Contain golden preparation in goldenRetries. observe records the revision first, through a callback that each reconciler supplies, so a retry always finds its recorded row and Ready follows persistence. It then returns one goldenProgress outcome: building (with the retry count), ready, retrying (failure and next check) or failed (the terminal golden error). Reconcilers, pollers and status builders read that outcome and no longer inspect the ActorTemplate's golden snapshot status. The outcome replaces GoldenRetries, ReconciliationFailure.RetryAt and the separate GoldenRetryAttempts reads, and the sandbox no longer keeps the observed template. Agents and sandboxes publish the outcome the same way. Both pollers wake on goldenProgress.due; the sandbox poller also wakes for a template that has not been observed yet, and other retryable preparation errors stay on the work queue's backoff. The sandbox reconciler publishes goldenProgress.failure as is, so a golden failure other than a crash reports a terminal ActorTemplateFailed with Substrate's message, as for Agents. Keep GC conflict protection for unrecorded golden rebuilds. Each runtime revision records a digest of the ActorTemplate's immutable inputs. After a golden crash, GC collects a template whose UID was never recorded only when its digest matches, so another template under the same name is refused like the reconciler's conflict check. Classify golden crashes without pinning Substrate's source, and drop comments that restate the code. Signed-off-by: Danielle Mustillo <danielle.mustillo@solo.io>
Substrate stops working on a template once it writes error_message, and each rebuild gets a new UID, so the golden actor, tag and egress policy are fresh. Delete and recreate is the only recovery for every reason, so drop the crash-only filter and the terminal ActorTemplateFailed phase, and say so in the sandbox architecture doc. Signed-off-by: Danielle Mustillo <danielle.mustillo@solo.io>
… naming Substrate formats a golden error as "<Reason>: <message>", and for GoldenActorInvalid and GoldenEgressPolicyConflict the message is a backend error that can quote configuration. Status now carries only the reason, or "unknown" when the message has no reason prefix, and the controller logs the full message. This matches the architecture doc's promise that raw backend messages stay out of Kubernetes status. Every golden error is retried now, so the names that said "crash" (the golden_crash_uid column, GoldenCrashUID, RecordedTemplateCrashed, the status message and the test helpers) say "failure" instead. The docs describe golden failures as retried with reason ActorTemplateRetrying rather than terminal. Agents checked an immutable ActorTemplate conflict twice: once in reconcileAgent and again in the KRT transform, which needed the observed template carried through the observation and a protobuf-aware Equals. reconcileAgent now publishes ActorTemplateConflict itself, the same way it publishes ActorTemplateRejected and the way sandboxes already work, so the observed template, its Equals handling and the transform branch are gone. The constructors now set the golden retry schedule. A zero-value goldenRetries no longer falls back to the defaults, so an empty schedule surfaces as an error instead of being replaced silently. GetRuntimeRevision now reads the ActorTemplate spec digest, so the Agent read model no longer returns an empty field. Signed-off-by: Danielle Mustillo <danielle.mustillo@solo.io>
Signed-off-by: Danielle Mustillo <danielle.mustillo@solo.io>
…etry The store saved golden_retry_at, so it needed the backoff delays and a random jitter that had to be computed once. It now saves golden_failed_at instead. RecordRuntimeRevision and RecordSandboxRevision update the retry state in the same transaction as the revision and return it, which removes EnsureGoldenRetry, GoldenRetryAttempts and the record callback in observe. The controller computes the retry time from the failure time, the delays and a jitter taken from a hash of the failed template's UID, so every reconcile and restart gets the same time. The golden failure is logged once, when the store first records it, instead of on every reconcile. Signed-off-by: Danielle Mustillo <danielle.mustillo@solo.io>
- GC skips the ActorTemplate delete when the template is already gone. An empty UID waives Substrate's delete precondition, so the delete could remove a template recreated under the same name. The read result is classified in collectableTemplate. - The sandbox poll no longer requeues failed preparations every second; they stay on the work queue's backoff. - Fold goldenProgress.waiting into due, its only caller. - Rename golden_attempts to golden_retries to match GoldenRetry.Retries. Signed-off-by: Danielle Mustillo <danielle.mustillo@solo.io>
Signed-off-by: Danielle Mustillo <danielle.mustillo@solo.io>
…onciler test Signed-off-by: Danielle Mustillo <danielle.mustillo@solo.io>
0b25738 to
e22795d
Compare
Why
A golden build can fail for an infrastructure reason, such as a lost worker pod or a full node disk, or for a config fault. Substrate v0.7.0-alpha1 treats every golden error as terminal: once it records
error_message, it never works on that template again. The Agent or SandboxTemplate stays not Ready until an operator deletes and reapplies it.What changed
ActorTemplateRetrying, e.g.golden build failed (GoldenActorCrashed), retry 1 at <time>. Status carries only the reason; the full Substrate error goes to the controller log, becauseGoldenActorInvalidcan quote configuration.ActorTemplateFailedis no longer produced.runtime_revisioncolumns hold the retry count, failed UID, retry time and the ActorTemplate spec digest, so a controller restart keeps the schedule. GC deletes a rebuild the controller never recorded only when its digest matches.Tradeoffs
Blast Radius
000001_initial.sqlin place, so an existing database must be reinstalled.Size
Against main
a9a53207:Verification
go testforinternal/controller,internal/database,internal/substrateandpkg/migrationsingo/core, with PostgreSQL and envtest, passes.3285acf5, Agent and SandboxTemplate with a crashed golden build: retry 1, a new template UID, then Ready. Substrate's raw error text appears in status 0 times. The key-free suite passes 37 of 37.AI use: AI-assisted, I reviewed it.