Skip to content

Commit d199db8

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 c1ab095 commit d199db8

8 files changed

Lines changed: 333 additions & 26 deletions

File tree

cmd/ateapi/internal/controlapi/functional_test.go

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

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

315315
// 5. Start REAL gRPC Server for ATE API
316316
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: 81 additions & 22 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

@@ -156,9 +176,7 @@ func (w *ActorWorkflow) ResumeActor(ctx context.Context, atespace, name string,
156176
}
157177
state := &ResumeState{}
158178

159-
// Acquire lock and get the timeout context for the workflow
160-
// Lock TTL is 30 seconds, with 2 seconds padding for workflow timeout
161-
ctx, releaseLock, err := w.acquireActorLock(ctx, atespace, name, 30*time.Second, 2*time.Second)
179+
ctx, releaseLock, err := w.acquireActorLock(ctx, atespace, name, actorLockTTL, actorLockHeartbeatInterval)
162180
if err != nil {
163181
return nil, err
164182
}
@@ -186,9 +204,7 @@ func (w *ActorWorkflow) SuspendActor(ctx context.Context, atespace, name string)
186204
}
187205
state := &SuspendState{}
188206

189-
// Acquire lock and get the timeout context for the workflow
190-
// Lock TTL is 30 seconds, with 2 seconds padding for workflow timeout
191-
ctx, releaseLock, err := w.acquireActorLock(ctx, atespace, name, 30*time.Second, 2*time.Second)
207+
ctx, releaseLock, err := w.acquireActorLock(ctx, atespace, name, actorLockTTL, actorLockHeartbeatInterval)
192208
if err != nil {
193209
return nil, err
194210
}
@@ -216,9 +232,7 @@ func (w *ActorWorkflow) PauseActor(ctx context.Context, atespace, name string) (
216232
}
217233
state := &PauseState{}
218234

219-
// Acquire lock and get the timeout context for the workflow
220-
// Lock TTL is 30 seconds, with 2 seconds padding for workflow timeout
221-
ctx, releaseLock, err := w.acquireActorLock(ctx, atespace, name, 30*time.Second, 2*time.Second)
235+
ctx, releaseLock, err := w.acquireActorLock(ctx, atespace, name, actorLockTTL, actorLockHeartbeatInterval)
222236
if err != nil {
223237
return nil, err
224238
}
@@ -238,27 +252,72 @@ func (w *ActorWorkflow) PauseActor(ctx context.Context, atespace, name string) (
238252
return state.Actor, nil
239253
}
240254

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

245-
// Create a child context for the workflow that expires BEFORE the lock
246-
workflowTimeout := ttl - padding
247-
workflowCtx, cancel := context.WithTimeout(ctx, workflowTimeout)
248-
249-
acquired, err := w.store.AcquireLock(workflowCtx, lockKey, lockValue, ttl)
267+
acquired, err := w.store.AcquireLock(ctx, lockKey, lockValue, lockTTL)
250268
if err != nil {
251-
cancel()
252269
return nil, nil, fmt.Errorf("while acquiring lock: %w", err)
253270
}
254271
if !acquired {
255-
cancel()
256272
return nil, nil, status.Error(grpcCodes.Aborted, "another operation is in progress for this actor")
257273
}
258274

259-
return workflowCtx, func() {
260-
cancel()
275+
cancellableCtx, cancelCause := context.WithCancelCause(ctx)
276+
workflowCtx, cancelDeadline := context.WithTimeout(cancellableCtx, w.workflowDeadline)
277+
278+
heartbeatDone := make(chan struct{})
279+
go w.runLockHeartbeat(workflowCtx, lockKey, lockValue, actorID, lockTTL, heartbeatInterval, cancelCause, heartbeatDone)
280+
281+
release := func() {
282+
cancelDeadline()
283+
cancelCause(context.Canceled)
284+
<-heartbeatDone
261285
// Use context.Background() to ensure the lock is released even if the workflow context was canceled.
262286
w.store.ReleaseLock(context.Background(), lockKey, lockValue) //nolint:errcheck // best-effort release; the lock TTL is the safety net.
263-
}, nil
287+
}
288+
return workflowCtx, release, nil
289+
}
290+
291+
// runLockHeartbeat refreshes the actor lock on a ticker until ctx is done. If
292+
// a refresh fails or returns false (we no longer own the lock), it cancels the
293+
// workflow context with errLostActorLock so workflow steps tear down promptly.
294+
func (w *ActorWorkflow) runLockHeartbeat(ctx context.Context, lockKey, lockValue, actorID string, lockTTL, heartbeatInterval time.Duration, cancelCause context.CancelCauseFunc, done chan<- struct{}) {
295+
defer close(done)
296+
ticker := time.NewTicker(heartbeatInterval)
297+
defer ticker.Stop()
298+
for {
299+
select {
300+
case <-ctx.Done():
301+
return
302+
case <-ticker.C:
303+
ok, err := w.store.RefreshLock(ctx, lockKey, lockValue, lockTTL)
304+
if err != nil {
305+
// If ctx was cancelled out from under us we're already tearing
306+
// down — no need to set a misleading cause.
307+
if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
308+
slog.WarnContext(ctx, "Lock heartbeat failed; cancelling workflow",
309+
slog.String("actor_id", actorID),
310+
slog.String("err", err.Error()))
311+
cancelCause(fmt.Errorf("%w: %w", errLostActorLock, err))
312+
}
313+
return
314+
}
315+
if !ok {
316+
slog.WarnContext(ctx, "Actor lock no longer owned; cancelling workflow",
317+
slog.String("actor_id", actorID))
318+
cancelCause(errLostActorLock)
319+
return
320+
}
321+
}
322+
}
264323
}
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(), "ns", "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:ns: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(), "ns", "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:ns: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(), "ns", "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:ns:actor-3") {
103+
t.Fatalf("lock key not in Redis after acquire")
104+
}
105+
release()
106+
if mr.Exists("lock:actor:ns: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(), "ns", "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(), "ns", "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
@@ -838,6 +838,26 @@ func (s *Persistence) ReleaseLock(ctx context.Context, key string, value string)
838838
return nil
839839
}
840840

841+
func (s *Persistence) RefreshLock(ctx context.Context, key string, value string, ttl time.Duration) (bool, error) {
842+
var luaRefresh = redis.NewScript(`
843+
if redis.call("get", KEYS[1]) == ARGV[1] then
844+
return redis.call("pexpire", KEYS[1], ARGV[2])
845+
else
846+
return 0
847+
end
848+
`)
849+
850+
res, err := luaRefresh.Run(ctx, s.rdb, []string{key}, value, ttl.Milliseconds()).Result()
851+
if err != nil {
852+
return false, fmt.Errorf("while refreshing lock for %q with value %q: %w", key, value, err)
853+
}
854+
n, ok := res.(int64)
855+
if !ok {
856+
return false, fmt.Errorf("while refreshing lock for %q: unexpected result type %T", key, res)
857+
}
858+
return n == 1, nil
859+
}
860+
841861
func newCreateMetadata(atespace, name string) *ateapipb.ResourceMetadata {
842862
now := timestamppb.Now()
843863
return &ateapipb.ResourceMetadata{

0 commit comments

Comments
 (0)