@@ -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.
129147func 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,71 @@ 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 , id string , lockTTL , heartbeatInterval time.Duration ) (context.Context , func (), error ) {
263+ 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}
0 commit comments