Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/architecture/configuration-and-compilation.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,9 @@ copied into Kubernetes status. The reconciliation queue owns failure retries,
with exponential backoff from one second to thirty seconds and no operational
attempt budget. It clears the failure after the prerequisite or service recovers.
Periodic polling only checks observed templates whose golden snapshots are still
pending. Observation and status updates do not bypass backoff; changed desired
pending. It skips Agents whose preparation is running or has failed, so a poll
cannot turn a failure into an immediate retry.
Observation and status updates do not bypass backoff; changed desired
revisions, unresolved inputs, and Agent deletion are reconciled immediately.
Compilation errors, immutable template conflicts, and golden-snapshot failures
stop preparation until the desired inputs change.
Expand Down
121 changes: 119 additions & 2 deletions go/core/internal/controller/preparation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"errors"
"strings"
"sync"
"sync/atomic"
"testing"
"testing/synctest"
"time"
Expand Down Expand Up @@ -344,12 +345,125 @@ func TestPreparationQueueAndPollerBackoff(t *testing.T) {
}
}

func TestPreparationSlowPollFailureBackoff(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
collections, _ := newPreparationTestCollections(t, "microvm")
initial := collections.Reconciliations.List()[0]
backend := &preparationTestBackend{templates: &fakeActorTemplates{}, store: &fakeRuntimeRevisionStore{}}
r := newReconciler(collections, backend, backend, kagentfake.NewSimpleClientset(initial.Agent.DeepCopy()).ApiV1alpha3())
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
go r.Run(ctx.Done())
synctest.Wait()
calls := func() (n int) {
backend.inspect(func() { n = backend.store.pairCalls })
return n
}
advance := func(d time.Duration) {
time.Sleep(d)
synctest.Wait()
}
require.Equal(t, 1, calls())
require.NotNil(t, collections.Reconciliations.GetKey(initial.ResourceName()).ObservedActorTemplate)
backend.inspect(func() {
// Off-second timing keeps completions away from poll ticks.
backend.getDelay = 1250 * time.Millisecond
backend.templates.getErr = errors.New("Substrate unavailable")
})

// The poll at t=1 fails at t=2.25. The tick at t=2 must not queue
// a retry that bypasses the one-second backoff.
advance(3250*time.Millisecond - time.Millisecond)
require.Equal(t, 2, calls(), "a poll during a slow request must not cause an immediate retry")
advance(time.Millisecond)
require.Equal(t, 3, calls())
require.NotNil(t, collections.Reconciliations.GetKey(initial.ResourceName()).PreparationFailure)

// The retry at t=3.25 fails at t=4.5; recovery waits for the two-second backoff.
advance(1750 * time.Millisecond)
backend.inspect(func() {
backend.getDelay = 0
backend.templates.getErr = nil
})
advance(1500*time.Millisecond - time.Millisecond)
require.Equal(t, 3, calls())
advance(time.Millisecond)
require.Equal(t, 4, calls(), "recovery must not require a Kubernetes event")
require.Nil(t, collections.Reconciliations.GetKey(initial.ResourceName()).PreparationFailure)
cancel()
synctest.Wait()
require.NoError(t, r.agents.WaitForClose(time.Second))
})
}

func TestPreparationPollSkipsRunningAndFailedAgents(t *testing.T) {
current, old := v2translator.RevisionID{1}, v2translator.RevisionID{2}
failure := &ReconciliationFailure{Condition: kagentv1alpha3.AgentConditionReady, Reason: "RuntimePreparationFailed", Retryable: true}
for _, test := range []struct {
name string
running bool
failureRevision *v2translator.RevisionID
polled bool
}{
{name: "pending", polled: true},
{name: "running", running: true},
{name: "failed current revision", failureRevision: &current},
{name: "failed old revision", failureRevision: &old, polled: true},
} {
t.Run(test.name, func(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
opts := krt.NewOptionsBuilder(t.Context().Done(), "test-poll", nil)
// The graph still shows a healthy pending template, as it does
// before it catches up with a just-recorded failure.
state := AgentReconciliation{
Agent: &kagentv1alpha3.Agent{ObjectMeta: metav1.ObjectMeta{Namespace: "team-a", Name: "assistant"}},
Target: &compiledTarget{RevisionID: current},
ObservedActorTemplate: &ateapipb.ActorTemplate{},
}
var observations []AgentRuntimeObservation
if test.failureRevision != nil {
observations = append(observations, AgentRuntimeObservation{
Namespace: "team-a", AgentName: "assistant", RevisionID: *test.failureRevision, Failure: failure,
})
}
r := &Reconciler{collections: Collections{
Reconciliations: krt.NewStaticCollection(nil, []AgentReconciliation{state}, opts.WithName("Reconciliations")...),
AgentRuntimeObservations: krt.NewStaticCollection(nil, observations, opts.WithName("AgentRuntimeObservations")...),
}}
if test.running {
r.preparing.Store(state.ResourceName(), struct{}{})
}
var polls atomic.Int32
r.agents = newReconciliationQueue("test-poll", func(any) error {
polls.Add(1)
return nil
})
stop := make(chan struct{})
go r.agents.Run(stop)
go r.pollPendingTemplates(stop)
time.Sleep(time.Second)
synctest.Wait()
close(stop)
synctest.Wait()
require.NoError(t, r.agents.WaitForClose(time.Second))

want := int32(0)
if test.polled {
want = 1
}
require.Equal(t, want, polls.Load())
})
})
}
}

// Waiting for quiescence synchronizes worker writes with test reads, but test
// mutations also need synchronization with the next timer-driven attempt.
type preparationTestBackend struct {
mu sync.Mutex
templates *fakeActorTemplates
store *fakeRuntimeRevisionStore
getDelay time.Duration
}

var _ actorTemplateClient = (*preparationTestBackend)(nil)
Expand All @@ -369,8 +483,11 @@ func (p *preparationTestBackend) EnsureAtespace(ctx context.Context, atespace st

func (p *preparationTestBackend) GetActorTemplate(ctx context.Context, atespace, name string) (*ateapipb.ActorTemplate, error) {
p.mu.Lock()
defer p.mu.Unlock()
return p.templates.GetActorTemplate(ctx, atespace, name)
delay := p.getDelay
template, err := p.templates.GetActorTemplate(ctx, atespace, name)
p.mu.Unlock()
time.Sleep(delay)
return template, err
}

func (p *preparationTestBackend) CreateActorTemplate(ctx context.Context, template *ateapipb.ActorTemplate) (*ateapipb.ActorTemplate, error) {
Expand Down
25 changes: 22 additions & 3 deletions go/core/internal/controller/reconciler.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"math"
"reflect"
"strings"
"sync"
"time"

atev1alpha1 "github.com/agent-substrate/substrate/pkg/api/v1alpha1"
Expand Down Expand Up @@ -183,6 +184,10 @@ type Reconciler struct {
agentHandler krt.HandlerRegistration
agentStatusHandler krt.HandlerRegistration
modelConfigStatusHandler krt.HandlerRegistration

// Polling an Agent while it is being prepared would mark it dirty in the
// queue, so a failure would be retried immediately instead of backing off.
preparing sync.Map
}

// NewReconciler creates the Kubernetes and database write boundary. Run starts
Expand All @@ -208,7 +213,10 @@ func newReconciler(
status: status,
}
r.agents = newReconciliationQueue("v2-agents", func(item any) error {
return r.reconcileAgent(context.Background(), item.(string))
key := item.(string)
r.preparing.Store(key, struct{}{})
defer r.preparing.Delete(key)
return r.reconcileAgent(context.Background(), key)
})
r.agentStatuses = newReconciliationQueue("v2-agent-status", func(item any) error {
return r.reconcileAgentStatus(context.Background(), item.(string))
Expand Down Expand Up @@ -278,9 +286,20 @@ func (r *Reconciler) pollPendingTemplates(stop <-chan struct{}) {
case <-ticker.C:
for _, state := range r.collections.Reconciliations.List() {
golden := state.ObservedActorTemplate.GetStatus().GetGoldenSnapshotStatus()
if state.Target != nil && state.PreparationFailure == nil && state.ObservedActorTemplate != nil && golden.GetGoldenTag() == nil {
r.agents.Add(state.ResourceName())
if state.Target == nil || state.PreparationFailure != nil || state.ObservedActorTemplate == nil || golden.GetGoldenTag() != nil {
continue
}
key := state.ResourceName()
if _, running := r.preparing.Load(key); running {
continue
}
// The graph can lag a just-recorded failure whose retry is already
// waiting on backoff.
if observed := r.collections.AgentRuntimeObservations.GetKey(key); observed != nil &&
observed.RevisionID == state.Target.RevisionID && observed.Failure != nil {
continue
}
r.agents.Add(key)
Comment on lines +293 to +302

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.

Can we keep polling and retry scheduling in the queue and remove the independent poller and preparing map? The map duplicates the queue's bookkeeping, and the checks here are still separate from enqueueing.

For example, an Agent can be queued behind another Agent's slow preparation. The next poll passes both guards while that Agent is still queued, the worker starts preparing it, and then the poll calls Add, marking the running Agent dirty. If preparation fails, the queue runs it again immediately, bypassing backoff. This happened in all 10 runs of a probe that forced that interleaving.

Successful preparation with a pending snapshot should request a delayed requeue; errors should use the queue's exponential backoff. RequeueAfter semantics would keep both decisions in one place. The current Istio queue wrapper would need equivalent support, or we could use a queue implementation that already provides it.

}
}
}
Expand Down
Loading