@@ -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.
144162func 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}
0 commit comments