Skip to content

Commit df379ec

Browse files
jjamrogaEItanya
authored andcommitted
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 ae900e3 commit df379ec

10 files changed

Lines changed: 322 additions & 28 deletions

File tree

charts/substrate-crds/templates/ate.dev_actortemplates.yaml

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,26 @@ spec:
6464
description: A single application container that you want to run
6565
within a WorkerPool.
6666
properties:
67+
args:
68+
description: |-
69+
Arguments to the entrypoint. Not executed within a shell. The container
70+
image's CMD is used if this is not provided (unless command is set,
71+
which discards the image's CMD).
72+
73+
Unlike Kubernetes, variable references $(VAR_NAME) are NOT expanded.
74+
items:
75+
type: string
76+
maxItems: 64
77+
type: array
78+
x-kubernetes-list-type: atomic
6779
command:
68-
description: Entrypoint array. Not executed within a shell.
80+
description: |-
81+
Entrypoint array. Not executed within a shell. The container image's
82+
ENTRYPOINT is used if this is not provided; if it is provided, the
83+
image's ENTRYPOINT and CMD are both ignored and the process argv is
84+
command + args.
85+
86+
Unlike Kubernetes, variable references $(VAR_NAME) are NOT expanded.
6987
items:
7088
type: string
7189
maxItems: 64

cmd/ateapi/internal/controlapi/functional_test.go

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

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

319319
// 5. Start REAL gRPC Server for ATE API
320320
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: 80 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.
@@ -138,9 +151,14 @@ type ActorWorkflow struct {
138151
sandboxConfigLister listersv1alpha1.SandboxConfigLister
139152
kubeClient kubernetes.Interface
140153
secretCache *envSecretCache
154+
// workflowDeadline is the maximum duration of a single Resume/Suspend
155+
// workflow. The lock is kept alive across this duration by a heartbeat,
156+
// independent of actorLockTTL.
157+
workflowDeadline time.Duration
141158
}
142159

143-
// NewActorWorkflow creates a new ActorWorkflow.
160+
// NewActorWorkflow creates a new ActorWorkflow. workflowDeadline bounds how
161+
// long a single Resume/Suspend can run end-to-end.
144162
func NewActorWorkflow(
145163
store store.Interface,
146164
workerCache *workercache.Cache,
@@ -149,6 +167,7 @@ func NewActorWorkflow(
149167
workerPoolLister listersv1alpha1.WorkerPoolLister,
150168
sandboxConfigLister listersv1alpha1.SandboxConfigLister,
151169
kubeClient kubernetes.Interface,
170+
workflowDeadline time.Duration,
152171
) *ActorWorkflow {
153172
return &ActorWorkflow{
154173
store: store,
@@ -159,6 +178,7 @@ func NewActorWorkflow(
159178
sandboxConfigLister: sandboxConfigLister,
160179
kubeClient: kubeClient,
161180
secretCache: newEnvSecretCache(envSecretCacheTTL),
181+
workflowDeadline: workflowDeadline,
162182
}
163183
}
164184

@@ -171,9 +191,7 @@ func (w *ActorWorkflow) ResumeActor(ctx context.Context, atespace, name string,
171191
}
172192
state := &ResumeState{}
173193

174-
// Acquire lock and get the timeout context for the workflow
175-
// Lock TTL is 30 seconds, with 2 seconds padding for workflow timeout
176-
ctx, releaseLock, err := w.acquireActorLock(ctx, atespace, name, 30*time.Second, 2*time.Second)
194+
ctx, releaseLock, err := w.acquireActorLock(ctx, atespace+":"+name, actorLockTTL, actorLockHeartbeatInterval)
177195
if err != nil {
178196
return nil, err
179197
}
@@ -201,9 +219,7 @@ func (w *ActorWorkflow) SuspendActor(ctx context.Context, atespace, name string)
201219
}
202220
state := &SuspendState{}
203221

204-
// Acquire lock and get the timeout context for the workflow
205-
// Lock TTL is 30 seconds, with 2 seconds padding for workflow timeout
206-
ctx, releaseLock, err := w.acquireActorLock(ctx, atespace, name, 30*time.Second, 2*time.Second)
222+
ctx, releaseLock, err := w.acquireActorLock(ctx, atespace+":"+name, actorLockTTL, actorLockHeartbeatInterval)
207223
if err != nil {
208224
return nil, err
209225
}
@@ -231,9 +247,7 @@ func (w *ActorWorkflow) PauseActor(ctx context.Context, atespace, name string) (
231247
}
232248
state := &PauseState{}
233249

234-
// Acquire lock and get the timeout context for the workflow
235-
// Lock TTL is 30 seconds, with 2 seconds padding for workflow timeout
236-
ctx, releaseLock, err := w.acquireActorLock(ctx, atespace, name, 30*time.Second, 2*time.Second)
250+
ctx, releaseLock, err := w.acquireActorLock(ctx, atespace+":"+name, actorLockTTL, actorLockHeartbeatInterval)
237251
if err != nil {
238252
return nil, err
239253
}
@@ -253,27 +267,71 @@ func (w *ActorWorkflow) PauseActor(ctx context.Context, atespace, name string) (
253267
return state.Actor, nil
254268
}
255269

256-
func (w *ActorWorkflow) acquireActorLock(ctx context.Context, atespace, name string, ttl time.Duration, padding time.Duration) (context.Context, func(), error) {
257-
lockKey := "lock:actor:" + atespace + ":" + name
270+
// acquireActorLock takes the per-actor workflow lock and returns a workflow
271+
// context bounded by w.workflowDeadline. A background heartbeat keeps the lock
272+
// alive — independent of lockTTL — for as long as the workflow runs. If the
273+
// heartbeat fails (Redis error or another peer stole the lock) the returned
274+
// context is cancelled with errLostActorLock as the cause, and in-flight steps
275+
// will see ctx.Err() and unwind. The returned release function stops the
276+
// heartbeat, waits for it to exit, then best-effort releases the lock.
277+
func (w *ActorWorkflow) acquireActorLock(ctx context.Context, id string, lockTTL, heartbeatInterval time.Duration) (context.Context, func(), error) {
278+
lockKey := "lock:actor:" + id
258279
lockValue := uuid.New().String()
259280

260-
// Create a child context for the workflow that expires BEFORE the lock
261-
workflowTimeout := ttl - padding
262-
workflowCtx, cancel := context.WithTimeout(ctx, workflowTimeout)
263-
264-
acquired, err := w.store.AcquireLock(workflowCtx, lockKey, lockValue, ttl)
281+
acquired, err := w.store.AcquireLock(ctx, lockKey, lockValue, lockTTL)
265282
if err != nil {
266-
cancel()
267283
return nil, nil, fmt.Errorf("while acquiring lock: %w", err)
268284
}
269285
if !acquired {
270-
cancel()
271286
return nil, nil, status.Error(grpcCodes.Aborted, "another operation is in progress for this actor")
272287
}
273288

274-
return workflowCtx, func() {
275-
cancel()
289+
cancellableCtx, cancelCause := context.WithCancelCause(ctx)
290+
workflowCtx, cancelDeadline := context.WithTimeout(cancellableCtx, w.workflowDeadline)
291+
292+
heartbeatDone := make(chan struct{})
293+
go w.runLockHeartbeat(workflowCtx, lockKey, lockValue, id, lockTTL, heartbeatInterval, cancelCause, heartbeatDone)
294+
295+
release := func() {
296+
cancelDeadline()
297+
cancelCause(context.Canceled)
298+
<-heartbeatDone
276299
// Use context.Background() to ensure the lock is released even if the workflow context was canceled.
277300
w.store.ReleaseLock(context.Background(), lockKey, lockValue) //nolint:errcheck // best-effort release; the lock TTL is the safety net.
278-
}, nil
301+
}
302+
return workflowCtx, release, nil
303+
}
304+
305+
// runLockHeartbeat refreshes the actor lock on a ticker until ctx is done. If
306+
// a refresh fails or returns false (we no longer own the lock), it cancels the
307+
// workflow context with errLostActorLock so workflow steps tear down promptly.
308+
func (w *ActorWorkflow) runLockHeartbeat(ctx context.Context, lockKey, lockValue, actorID string, lockTTL, heartbeatInterval time.Duration, cancelCause context.CancelCauseFunc, done chan<- struct{}) {
309+
defer close(done)
310+
ticker := time.NewTicker(heartbeatInterval)
311+
defer ticker.Stop()
312+
for {
313+
select {
314+
case <-ctx.Done():
315+
return
316+
case <-ticker.C:
317+
ok, err := w.store.RefreshLock(ctx, lockKey, lockValue, lockTTL)
318+
if err != nil {
319+
// If ctx was cancelled out from under us we're already tearing
320+
// down — no need to set a misleading cause.
321+
if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
322+
slog.WarnContext(ctx, "Lock heartbeat failed; cancelling workflow",
323+
slog.String("actor_id", actorID),
324+
slog.String("err", err.Error()))
325+
cancelCause(fmt.Errorf("%w: %w", errLostActorLock, err))
326+
}
327+
return
328+
}
329+
if !ok {
330+
slog.WarnContext(ctx, "Actor lock no longer owned; cancelling workflow",
331+
slog.String("actor_id", actorID))
332+
cancelCause(errLostActorLock)
333+
return
334+
}
335+
}
336+
}
279337
}

cmd/ateapi/internal/controlapi/workflow_test.go

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ import (
2121
"time"
2222

2323
"github.com/agent-substrate/substrate/cmd/ateapi/internal/store"
24+
"github.com/agent-substrate/substrate/cmd/ateapi/internal/store/ateredis"
25+
"github.com/alicebob/miniredis/v2"
26+
"github.com/redis/go-redis/v9"
2427
"google.golang.org/grpc/codes"
2528
"google.golang.org/grpc/status"
2629
"k8s.io/apimachinery/pkg/util/wait"
@@ -205,3 +208,93 @@ func TestRunWorkflow_RetryOnPersistenceConflict(t *testing.T) {
205208
}
206209
})
207210
}
211+
212+
func newLockTestWorkflow(t *testing.T) (*miniredis.Miniredis, *ActorWorkflow) {
213+
t.Helper()
214+
mr, err := miniredis.Run()
215+
if err != nil {
216+
t.Fatalf("miniredis.Run: %v", err)
217+
}
218+
t.Cleanup(mr.Close)
219+
rdb := redis.NewClusterClient(&redis.ClusterOptions{Addrs: []string{mr.Addr()}})
220+
return mr, &ActorWorkflow{
221+
store: ateredis.NewPersistence(rdb),
222+
workflowDeadline: 30 * time.Second,
223+
}
224+
}
225+
226+
func TestAcquireActorLock_HeartbeatKeepsLockAlivePastTTL(t *testing.T) {
227+
mr, w := newLockTestWorkflow(t)
228+
229+
lockTTL := 150 * time.Millisecond
230+
heartbeat := 40 * time.Millisecond
231+
232+
ctx, release, err := w.acquireActorLock(context.Background(), "actor-1", lockTTL, heartbeat)
233+
if err != nil {
234+
t.Fatalf("acquireActorLock: %v", err)
235+
}
236+
defer release()
237+
238+
time.Sleep(4 * lockTTL)
239+
240+
if !mr.Exists("lock:actor:actor-1") {
241+
t.Fatalf("lock key disappeared from Redis despite heartbeat; ctx err=%v cause=%v", ctx.Err(), context.Cause(ctx))
242+
}
243+
if ctx.Err() != nil {
244+
t.Fatalf("workflow ctx cancelled while heartbeat was healthy: err=%v cause=%v", ctx.Err(), context.Cause(ctx))
245+
}
246+
}
247+
248+
func TestAcquireActorLock_LostLockCancelsWorkflow(t *testing.T) {
249+
mr, w := newLockTestWorkflow(t)
250+
251+
ctx, release, err := w.acquireActorLock(context.Background(), "actor-2", 200*time.Millisecond, 30*time.Millisecond)
252+
if err != nil {
253+
t.Fatalf("acquireActorLock: %v", err)
254+
}
255+
defer release()
256+
257+
mr.Del("lock:actor:actor-2")
258+
259+
select {
260+
case <-ctx.Done():
261+
case <-time.After(2 * time.Second):
262+
t.Fatalf("workflow ctx was not cancelled after lock was lost")
263+
}
264+
265+
if cause := context.Cause(ctx); !errors.Is(cause, errLostActorLock) {
266+
t.Errorf("context.Cause = %v, want errLostActorLock", cause)
267+
}
268+
}
269+
270+
func TestAcquireActorLock_ReleaseRemovesLock(t *testing.T) {
271+
mr, w := newLockTestWorkflow(t)
272+
273+
_, release, err := w.acquireActorLock(context.Background(), "actor-3", 200*time.Millisecond, 60*time.Millisecond)
274+
if err != nil {
275+
t.Fatalf("acquireActorLock: %v", err)
276+
}
277+
278+
if !mr.Exists("lock:actor:actor-3") {
279+
t.Fatalf("lock key not in Redis after acquire")
280+
}
281+
release()
282+
if mr.Exists("lock:actor:actor-3") {
283+
t.Errorf("lock key still in Redis after release")
284+
}
285+
}
286+
287+
func TestAcquireActorLock_ConflictReturnsAborted(t *testing.T) {
288+
_, w := newLockTestWorkflow(t)
289+
290+
_, release, err := w.acquireActorLock(context.Background(), "actor-4", 5*time.Second, time.Second)
291+
if err != nil {
292+
t.Fatalf("first acquireActorLock: %v", err)
293+
}
294+
defer release()
295+
296+
_, _, err = w.acquireActorLock(context.Background(), "actor-4", 5*time.Second, time.Second)
297+
if err == nil {
298+
t.Fatalf("expected second acquireActorLock to fail")
299+
}
300+
}

cmd/ateapi/internal/controlapi/workflow_testutil_test.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
"context"
1919
"slices"
2020
"testing"
21+
"time"
2122

2223
"github.com/agent-substrate/substrate/cmd/ateapi/internal/store"
2324
atev1alpha1 "github.com/agent-substrate/substrate/pkg/api/v1alpha1"
@@ -41,7 +42,7 @@ func newTestActorWorkflow(t *testing.T, st store.Interface, tmplNamespace, tmplN
4142
}); err != nil {
4243
t.Fatalf("add template to indexer: %v", err)
4344
}
44-
return NewActorWorkflow(st, nil, nil, listersv1alpha1.NewActorTemplateLister(indexer), nil, nil, nil)
45+
return NewActorWorkflow(st, nil, nil, listersv1alpha1.NewActorTemplateLister(indexer), nil, nil, nil, time.Minute)
4546
}
4647

4748
// seedWorkflowActor stores an actor with the given status, bound to the given

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

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -809,3 +809,23 @@ func newUpdateMetadata(current *ateapipb.ResourceMetadata) *ateapipb.ResourceMet
809809
next.UpdateTime = timestamppb.Now()
810810
return next
811811
}
812+
813+
func (s *Persistence) RefreshLock(ctx context.Context, key string, value string, ttl time.Duration) (bool, error) {
814+
var luaRefresh = redis.NewScript(`
815+
if redis.call("get", KEYS[1]) == ARGV[1] then
816+
return redis.call("pexpire", KEYS[1], ARGV[2])
817+
else
818+
return 0
819+
end
820+
`)
821+
822+
res, err := luaRefresh.Run(ctx, s.rdb, []string{key}, value, ttl.Milliseconds()).Result()
823+
if err != nil {
824+
return false, fmt.Errorf("while refreshing lock for %q with value %q: %w", key, value, err)
825+
}
826+
n, ok := res.(int64)
827+
if !ok {
828+
return false, fmt.Errorf("while refreshing lock for %q: unexpected result type %T", key, res)
829+
}
830+
return n == 1, nil
831+
}

0 commit comments

Comments
 (0)