Skip to content

Commit 93b64cc

Browse files
committed
Decouple actor lock TTL from workflow deadline via heartbeat
ActorWorkflow.ResumeActor and SuspendActor used to derive their workflow ctx from the Redis lock TTL via acquireActorLock(ctx, id, 30s, 2s) — the workflow deadline and the lock TTL were a single 28s knob. That meant image pulls / restores that legitimately need more than 28s death-looped forever, while raising the knob also raised how long peers wait to retry an actor after a crashed ateapi replica. Split the two concerns: - Lock TTL stays short (30s constant, internal). Bounds peer failover. - Workflow deadline is a separate operator-configurable knob via the new --actor-workflow-deadline pflag (default 5m). Bounds a single Resume/Suspend. - A heartbeat goroutine refreshes the lock every lockTTL/3 (~10s) for the full workflow duration. On RefreshLock=false or any Redis error (peer stole the lock, Redis blip), the workflow ctx is cancelled with errLostActorLock as the cause so in-flight steps unwind cleanly and the mutual-exclusion invariant is preserved. - The release function stops the heartbeat (waits for goroutine exit) before best-effort ReleaseLock. Adds store.Interface.RefreshLock with a Redis CAS Lua script mirroring the existing ReleaseLock script.
1 parent 24ded8b commit 93b64cc

8 files changed

Lines changed: 331 additions & 25 deletions

File tree

cmd/ateapi/internal/controlapi/functional_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -299,7 +299,7 @@ func setupTest(t *testing.T, ns string) *testContext {
299299
}
300300

301301
dialer := NewAteletDialer(workerInformer.GetIndexer(), ateletInformer.GetIndexer())
302-
service := NewService(persistence, wc, actorTemplateLister, workerPoolLister, sandboxConfigLister, dialer, k8sClient)
302+
service := NewService(persistence, wc, actorTemplateLister, workerPoolLister, sandboxConfigLister, dialer, k8sClient, 30*time.Second)
303303

304304
// 5. Start REAL gRPC Server for ATE API
305305
grpcServer := grpc.NewServer(grpc.UnaryInterceptor(ateinterceptors.ServerUnaryInterceptor))

cmd/ateapi/internal/controlapi/service.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
package controlapi
1616

1717
import (
18+
"time"
19+
1820
"github.com/agent-substrate/substrate/cmd/ateapi/internal/store"
1921
"github.com/agent-substrate/substrate/cmd/ateapi/internal/workercache"
2022
listersv1alpha1 "github.com/agent-substrate/substrate/pkg/client/listers/api/v1alpha1"
@@ -34,7 +36,8 @@ type Service struct {
3436

3537
var _ ateapipb.ControlServer = (*Service)(nil)
3638

37-
// NewService creates a service.
39+
// NewService creates a service. actorWorkflowDeadline bounds how long a single
40+
// Resume/Suspend workflow can run end-to-end.
3841
func NewService(
3942
persistence store.Interface,
4043
workerCache *workercache.Cache,
@@ -43,13 +46,14 @@ func NewService(
4346
sandboxConfigLister listersv1alpha1.SandboxConfigLister,
4447
dialer *AteletDialer,
4548
kubeClient kubernetes.Interface,
49+
actorWorkflowDeadline time.Duration,
4650
) *Service {
4751
s := &Service{
4852
persistence: persistence,
4953
actorTemplateLister: actorTemplateLister,
5054
workerPoolLister: workerPoolLister,
5155
dialer: dialer,
52-
actorWorkflow: NewActorWorkflow(persistence, workerCache, dialer, actorTemplateLister, workerPoolLister, sandboxConfigLister, kubeClient),
56+
actorWorkflow: NewActorWorkflow(persistence, workerCache, dialer, actorTemplateLister, workerPoolLister, sandboxConfigLister, kubeClient, actorWorkflowDeadline),
5357
}
5458

5559
return s

cmd/ateapi/internal/controlapi/workflow.go

Lines changed: 79 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
"context"
1919
"errors"
2020
"fmt"
21+
"log/slog"
2122
"time"
2223

2324
"github.com/agent-substrate/substrate/cmd/ateapi/internal/store"
@@ -33,6 +34,18 @@ import (
3334
"k8s.io/client-go/kubernetes"
3435
)
3536

37+
// actorLockTTL is the Redis TTL on the per-actor workflow lock. It bounds how
38+
// long a peer must wait to retry an actor after this process crashes mid-workflow.
39+
const actorLockTTL = 30 * time.Second
40+
41+
// actorLockHeartbeatInterval is how often the heartbeat refreshes the lock.
42+
// Chosen so we get ~3 attempts before the TTL would otherwise lapse.
43+
const actorLockHeartbeatInterval = actorLockTTL / 3
44+
45+
// errLostActorLock is the context cause set when the heartbeat can no longer
46+
// keep the actor lock alive (peer stole it, or Redis returned an error).
47+
var errLostActorLock = errors.New("lost actor lock during workflow")
48+
3649
// WorkflowStep represents a single, idempotent operation in a workflow graph.
3750
// Params is the immutable parameters used to start the workflow.
3851
// Context is the mutable context fetched or modified during execution.
@@ -123,9 +136,14 @@ type ActorWorkflow struct {
123136
sandboxConfigLister listersv1alpha1.SandboxConfigLister
124137
kubeClient kubernetes.Interface
125138
secretCache *envSecretCache
139+
// workflowDeadline is the maximum duration of a single Resume/Suspend
140+
// workflow. The lock is kept alive across this duration by a heartbeat,
141+
// independent of actorLockTTL.
142+
workflowDeadline time.Duration
126143
}
127144

128-
// NewActorWorkflow creates a new ActorWorkflow.
145+
// NewActorWorkflow creates a new ActorWorkflow. workflowDeadline bounds how
146+
// long a single Resume/Suspend can run end-to-end.
129147
func NewActorWorkflow(
130148
store store.Interface,
131149
workerCache *workercache.Cache,
@@ -134,6 +152,7 @@ func NewActorWorkflow(
134152
workerPoolLister listersv1alpha1.WorkerPoolLister,
135153
sandboxConfigLister listersv1alpha1.SandboxConfigLister,
136154
kubeClient kubernetes.Interface,
155+
workflowDeadline time.Duration,
137156
) *ActorWorkflow {
138157
return &ActorWorkflow{
139158
store: store,
@@ -144,6 +163,7 @@ func NewActorWorkflow(
144163
sandboxConfigLister: sandboxConfigLister,
145164
kubeClient: kubeClient,
146165
secretCache: newEnvSecretCache(envSecretCacheTTL),
166+
workflowDeadline: workflowDeadline,
147167
}
148168
}
149169

@@ -155,9 +175,7 @@ func (w *ActorWorkflow) ResumeActor(ctx context.Context, id string, boot bool) (
155175
}
156176
state := &ResumeState{}
157177

158-
// Acquire lock and get the timeout context for the workflow
159-
// Lock TTL is 7 seconds, with 2 seconds padding for workflow timeout
160-
ctx, releaseLock, err := w.acquireActorLock(ctx, id, 30*time.Second, 2*time.Second)
178+
ctx, releaseLock, err := w.acquireActorLock(ctx, id, actorLockTTL, actorLockHeartbeatInterval)
161179
if err != nil {
162180
return nil, err
163181
}
@@ -184,9 +202,7 @@ func (w *ActorWorkflow) SuspendActor(ctx context.Context, id string) (*ateapipb.
184202
}
185203
state := &SuspendState{}
186204

187-
// Acquire lock and get the timeout context for the workflow
188-
// Lock TTL is 30 seconds, with 2 seconds padding for workflow timeout
189-
ctx, releaseLock, err := w.acquireActorLock(ctx, id, 30*time.Second, 2*time.Second)
205+
ctx, releaseLock, err := w.acquireActorLock(ctx, id, actorLockTTL, actorLockHeartbeatInterval)
190206
if err != nil {
191207
return nil, err
192208
}
@@ -213,9 +229,7 @@ func (w *ActorWorkflow) PauseActor(ctx context.Context, id string) (*ateapipb.Ac
213229
}
214230
state := &PauseState{}
215231

216-
// Acquire lock and get the timeout context for the workflow
217-
// Lock TTL is 30 seconds, with 2 seconds padding for workflow timeout
218-
ctx, releaseLock, err := w.acquireActorLock(ctx, id, 30*time.Second, 2*time.Second)
232+
ctx, releaseLock, err := w.acquireActorLock(ctx, id, actorLockTTL, actorLockHeartbeatInterval)
219233
if err != nil {
220234
return nil, err
221235
}
@@ -235,27 +249,71 @@ func (w *ActorWorkflow) PauseActor(ctx context.Context, id string) (*ateapipb.Ac
235249
return state.Actor, nil
236250
}
237251

238-
func (w *ActorWorkflow) acquireActorLock(ctx context.Context, id string, ttl time.Duration, padding time.Duration) (context.Context, func(), error) {
252+
// acquireActorLock takes the per-actor workflow lock and returns a workflow
253+
// context bounded by w.workflowDeadline. A background heartbeat keeps the lock
254+
// alive — independent of lockTTL — for as long as the workflow runs. If the
255+
// heartbeat fails (Redis error or another peer stole the lock) the returned
256+
// context is cancelled with errLostActorLock as the cause, and in-flight steps
257+
// will see ctx.Err() and unwind. The returned release function stops the
258+
// heartbeat, waits for it to exit, then best-effort releases the lock.
259+
func (w *ActorWorkflow) acquireActorLock(ctx context.Context, id string, lockTTL, heartbeatInterval time.Duration) (context.Context, func(), error) {
239260
lockKey := "lock:actor:" + id
240261
lockValue := uuid.New().String()
241262

242-
// Create a child context for the workflow that expires BEFORE the lock
243-
workflowTimeout := ttl - padding
244-
workflowCtx, cancel := context.WithTimeout(ctx, workflowTimeout)
245-
246-
acquired, err := w.store.AcquireLock(workflowCtx, lockKey, lockValue, ttl)
263+
acquired, err := w.store.AcquireLock(ctx, lockKey, lockValue, lockTTL)
247264
if err != nil {
248-
cancel()
249265
return nil, nil, fmt.Errorf("while acquiring lock: %w", err)
250266
}
251267
if !acquired {
252-
cancel()
253268
return nil, nil, status.Error(grpcCodes.Aborted, "another operation is in progress for this actor")
254269
}
255270

256-
return workflowCtx, func() {
257-
cancel()
271+
cancellableCtx, cancelCause := context.WithCancelCause(ctx)
272+
workflowCtx, cancelDeadline := context.WithTimeout(cancellableCtx, w.workflowDeadline)
273+
274+
heartbeatDone := make(chan struct{})
275+
go w.runLockHeartbeat(workflowCtx, lockKey, lockValue, id, lockTTL, heartbeatInterval, cancelCause, heartbeatDone)
276+
277+
release := func() {
278+
cancelDeadline()
279+
cancelCause(context.Canceled)
280+
<-heartbeatDone
258281
// Use context.Background() to ensure the lock is released even if the workflow context was canceled.
259282
w.store.ReleaseLock(context.Background(), lockKey, lockValue) //nolint:errcheck // best-effort release; the lock TTL is the safety net.
260-
}, nil
283+
}
284+
return workflowCtx, release, nil
285+
}
286+
287+
// runLockHeartbeat refreshes the actor lock on a ticker until ctx is done. If
288+
// a refresh fails or returns false (we no longer own the lock), it cancels the
289+
// workflow context with errLostActorLock so workflow steps tear down promptly.
290+
func (w *ActorWorkflow) runLockHeartbeat(ctx context.Context, lockKey, lockValue, actorID string, lockTTL, heartbeatInterval time.Duration, cancelCause context.CancelCauseFunc, done chan<- struct{}) {
291+
defer close(done)
292+
ticker := time.NewTicker(heartbeatInterval)
293+
defer ticker.Stop()
294+
for {
295+
select {
296+
case <-ctx.Done():
297+
return
298+
case <-ticker.C:
299+
ok, err := w.store.RefreshLock(ctx, lockKey, lockValue, lockTTL)
300+
if err != nil {
301+
// If ctx was cancelled out from under us we're already tearing
302+
// down — no need to set a misleading cause.
303+
if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
304+
slog.WarnContext(ctx, "Lock heartbeat failed; cancelling workflow",
305+
slog.String("actor_id", actorID),
306+
slog.String("err", err.Error()))
307+
cancelCause(fmt.Errorf("%w: %w", errLostActorLock, err))
308+
}
309+
return
310+
}
311+
if !ok {
312+
slog.WarnContext(ctx, "Actor lock no longer owned; cancelling workflow",
313+
slog.String("actor_id", actorID))
314+
cancelCause(errLostActorLock)
315+
return
316+
}
317+
}
318+
}
261319
}
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
// Copyright 2026 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package controlapi
16+
17+
import (
18+
"context"
19+
"errors"
20+
"testing"
21+
"time"
22+
23+
"github.com/agent-substrate/substrate/cmd/ateapi/internal/store/ateredis"
24+
"github.com/alicebob/miniredis/v2"
25+
"github.com/redis/go-redis/v9"
26+
)
27+
28+
func newLockTestWorkflow(t *testing.T) (*miniredis.Miniredis, *ActorWorkflow) {
29+
t.Helper()
30+
mr, err := miniredis.Run()
31+
if err != nil {
32+
t.Fatalf("miniredis.Run: %v", err)
33+
}
34+
t.Cleanup(mr.Close)
35+
rdb := redis.NewClusterClient(&redis.ClusterOptions{Addrs: []string{mr.Addr()}})
36+
return mr, &ActorWorkflow{
37+
store: ateredis.NewPersistence(rdb),
38+
workflowDeadline: 30 * time.Second,
39+
}
40+
}
41+
42+
func TestAcquireActorLock_HeartbeatKeepsLockAlivePastTTL(t *testing.T) {
43+
mr, w := newLockTestWorkflow(t)
44+
45+
lockTTL := 150 * time.Millisecond
46+
heartbeat := 40 * time.Millisecond
47+
48+
ctx, release, err := w.acquireActorLock(context.Background(), "actor-1", lockTTL, heartbeat)
49+
if err != nil {
50+
t.Fatalf("acquireActorLock: %v", err)
51+
}
52+
defer release()
53+
54+
// Wait through multiple TTLs. If the heartbeat is working the lock key
55+
// must still be in Redis — its TTL is being PEXPIRE'd back to `lockTTL`
56+
// every `heartbeat`.
57+
time.Sleep(4 * lockTTL)
58+
59+
if !mr.Exists("lock:actor:actor-1") {
60+
t.Fatalf("lock key disappeared from Redis despite heartbeat; ctx err=%v cause=%v", ctx.Err(), context.Cause(ctx))
61+
}
62+
if ctx.Err() != nil {
63+
t.Fatalf("workflow ctx cancelled while heartbeat was healthy: err=%v cause=%v", ctx.Err(), context.Cause(ctx))
64+
}
65+
}
66+
67+
func TestAcquireActorLock_LostLockCancelsWorkflow(t *testing.T) {
68+
mr, w := newLockTestWorkflow(t)
69+
70+
lockTTL := 200 * time.Millisecond
71+
heartbeat := 30 * time.Millisecond
72+
73+
ctx, release, err := w.acquireActorLock(context.Background(), "actor-2", lockTTL, heartbeat)
74+
if err != nil {
75+
t.Fatalf("acquireActorLock: %v", err)
76+
}
77+
defer release()
78+
79+
// Simulate a peer stealing the lock (or the TTL lapsing and someone else
80+
// re-acquiring): wipe our key so the next heartbeat refresh's CAS fails.
81+
mr.Del("lock:actor:actor-2")
82+
83+
select {
84+
case <-ctx.Done():
85+
case <-time.After(2 * time.Second):
86+
t.Fatalf("workflow ctx was not cancelled after lock was lost")
87+
}
88+
89+
if cause := context.Cause(ctx); !errors.Is(cause, errLostActorLock) {
90+
t.Errorf("context.Cause = %v, want errLostActorLock", cause)
91+
}
92+
}
93+
94+
func TestAcquireActorLock_ReleaseRemovesLock(t *testing.T) {
95+
mr, w := newLockTestWorkflow(t)
96+
97+
_, release, err := w.acquireActorLock(context.Background(), "actor-3", 200*time.Millisecond, 60*time.Millisecond)
98+
if err != nil {
99+
t.Fatalf("acquireActorLock: %v", err)
100+
}
101+
102+
if !mr.Exists("lock:actor:actor-3") {
103+
t.Fatalf("lock key not in Redis after acquire")
104+
}
105+
release()
106+
if mr.Exists("lock:actor:actor-3") {
107+
t.Errorf("lock key still in Redis after release")
108+
}
109+
}
110+
111+
func TestAcquireActorLock_ConflictReturnsAborted(t *testing.T) {
112+
_, w := newLockTestWorkflow(t)
113+
114+
_, release, err := w.acquireActorLock(context.Background(), "actor-4", 5*time.Second, 1*time.Second)
115+
if err != nil {
116+
t.Fatalf("first acquireActorLock: %v", err)
117+
}
118+
defer release()
119+
120+
_, _, err = w.acquireActorLock(context.Background(), "actor-4", 5*time.Second, 1*time.Second)
121+
if err == nil {
122+
t.Fatalf("expected second acquireActorLock to fail")
123+
}
124+
}

cmd/ateapi/internal/store/ateredis/ateredis.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -673,3 +673,23 @@ func (s *Persistence) ReleaseLock(ctx context.Context, key string, value string)
673673
}
674674
return nil
675675
}
676+
677+
func (s *Persistence) RefreshLock(ctx context.Context, key string, value string, ttl time.Duration) (bool, error) {
678+
var luaRefresh = redis.NewScript(`
679+
if redis.call("get", KEYS[1]) == ARGV[1] then
680+
return redis.call("pexpire", KEYS[1], ARGV[2])
681+
else
682+
return 0
683+
end
684+
`)
685+
686+
res, err := luaRefresh.Run(ctx, s.rdb, []string{key}, value, ttl.Milliseconds()).Result()
687+
if err != nil {
688+
return false, fmt.Errorf("while refreshing lock for %q with value %q: %w", key, value, err)
689+
}
690+
n, ok := res.(int64)
691+
if !ok {
692+
return false, fmt.Errorf("while refreshing lock for %q: unexpected result type %T", key, res)
693+
}
694+
return n == 1, nil
695+
}

0 commit comments

Comments
 (0)