Skip to content

Commit 53f9848

Browse files
authored
Decouple actor lock TTL from workflow deadline via heartbeat (#14)
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 de0b0e6 commit 53f9848

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
@@ -303,7 +303,7 @@ func setupTest(t *testing.T, ns string) *testContext {
303303
}
304304

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

308308
// 5. Start REAL gRPC Server for ATE API
309309
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

@@ -156,9 +176,7 @@ func (w *ActorWorkflow) ResumeActor(ctx context.Context, atespace, id string, bo
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, id, 30*time.Second, 2*time.Second)
179+
ctx, releaseLock, err := w.acquireActorLock(ctx, id, actorLockTTL, actorLockHeartbeatInterval)
162180
if err != nil {
163181
return nil, err
164182
}
@@ -186,9 +204,7 @@ func (w *ActorWorkflow) SuspendActor(ctx context.Context, atespace, id 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, id, 30*time.Second, 2*time.Second)
207+
ctx, releaseLock, err := w.acquireActorLock(ctx, id, actorLockTTL, actorLockHeartbeatInterval)
192208
if err != nil {
193209
return nil, err
194210
}
@@ -216,9 +232,7 @@ func (w *ActorWorkflow) PauseActor(ctx context.Context, atespace, id string) (*a
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, id, 30*time.Second, 2*time.Second)
235+
ctx, releaseLock, err := w.acquireActorLock(ctx, id, actorLockTTL, actorLockHeartbeatInterval)
222236
if err != nil {
223237
return nil, err
224238
}
@@ -238,27 +252,71 @@ func (w *ActorWorkflow) PauseActor(ctx context.Context, atespace, id string) (*a
238252
return state.Actor, nil
239253
}
240254

241-
func (w *ActorWorkflow) acquireActorLock(ctx context.Context, id string, ttl time.Duration, padding time.Duration) (context.Context, func(), error) {
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, id string, lockTTL, heartbeatInterval time.Duration) (context.Context, func(), error) {
242263
lockKey := "lock:actor:" + id
243264
lockValue := uuid.New().String()
244265

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)
266+
acquired, err := w.store.AcquireLock(ctx, lockKey, lockValue, lockTTL)
250267
if err != nil {
251-
cancel()
252268
return nil, nil, fmt.Errorf("while acquiring lock: %w", err)
253269
}
254270
if !acquired {
255-
cancel()
256271
return nil, nil, status.Error(grpcCodes.Aborted, "another operation is in progress for this actor")
257272
}
258273

259-
return workflowCtx, func() {
260-
cancel()
274+
cancellableCtx, cancelCause := context.WithCancelCause(ctx)
275+
workflowCtx, cancelDeadline := context.WithTimeout(cancellableCtx, w.workflowDeadline)
276+
277+
heartbeatDone := make(chan struct{})
278+
go w.runLockHeartbeat(workflowCtx, lockKey, lockValue, id, lockTTL, heartbeatInterval, cancelCause, heartbeatDone)
279+
280+
release := func() {
281+
cancelDeadline()
282+
cancelCause(context.Canceled)
283+
<-heartbeatDone
261284
// Use context.Background() to ensure the lock is released even if the workflow context was canceled.
262285
w.store.ReleaseLock(context.Background(), lockKey, lockValue) //nolint:errcheck // best-effort release; the lock TTL is the safety net.
263-
}, nil
286+
}
287+
return workflowCtx, release, nil
288+
}
289+
290+
// runLockHeartbeat refreshes the actor lock on a ticker until ctx is done. If
291+
// a refresh fails or returns false (we no longer own the lock), it cancels the
292+
// workflow context with errLostActorLock so workflow steps tear down promptly.
293+
func (w *ActorWorkflow) runLockHeartbeat(ctx context.Context, lockKey, lockValue, actorID string, lockTTL, heartbeatInterval time.Duration, cancelCause context.CancelCauseFunc, done chan<- struct{}) {
294+
defer close(done)
295+
ticker := time.NewTicker(heartbeatInterval)
296+
defer ticker.Stop()
297+
for {
298+
select {
299+
case <-ctx.Done():
300+
return
301+
case <-ticker.C:
302+
ok, err := w.store.RefreshLock(ctx, lockKey, lockValue, lockTTL)
303+
if err != nil {
304+
// If ctx was cancelled out from under us we're already tearing
305+
// down — no need to set a misleading cause.
306+
if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
307+
slog.WarnContext(ctx, "Lock heartbeat failed; cancelling workflow",
308+
slog.String("actor_id", actorID),
309+
slog.String("err", err.Error()))
310+
cancelCause(fmt.Errorf("%w: %w", errLostActorLock, err))
311+
}
312+
return
313+
}
314+
if !ok {
315+
slog.WarnContext(ctx, "Actor lock no longer owned; cancelling workflow",
316+
slog.String("actor_id", actorID))
317+
cancelCause(errLostActorLock)
318+
return
319+
}
320+
}
321+
}
264322
}
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
@@ -799,3 +799,23 @@ func (s *Persistence) ReleaseLock(ctx context.Context, key string, value string)
799799
}
800800
return nil
801801
}
802+
803+
func (s *Persistence) RefreshLock(ctx context.Context, key string, value string, ttl time.Duration) (bool, error) {
804+
var luaRefresh = redis.NewScript(`
805+
if redis.call("get", KEYS[1]) == ARGV[1] then
806+
return redis.call("pexpire", KEYS[1], ARGV[2])
807+
else
808+
return 0
809+
end
810+
`)
811+
812+
res, err := luaRefresh.Run(ctx, s.rdb, []string{key}, value, ttl.Milliseconds()).Result()
813+
if err != nil {
814+
return false, fmt.Errorf("while refreshing lock for %q with value %q: %w", key, value, err)
815+
}
816+
n, ok := res.(int64)
817+
if !ok {
818+
return false, fmt.Errorf("while refreshing lock for %q: unexpected result type %T", key, res)
819+
}
820+
return n == 1, nil
821+
}

0 commit comments

Comments
 (0)