@@ -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
@@ -155,9 +175,7 @@ func (w *ActorWorkflow) ResumeActor(ctx context.Context, id string, boot bool) (
155175 }
156176 state := & ResumeState {}
157177
158- // Acquire lock and get the timeout context for the workflow
159- // Lock TTL is 7 seconds, with 2 seconds padding for workflow timeout
160- ctx , releaseLock , err := w .acquireActorLock (ctx , id , 30 * time .Second , 2 * time .Second )
178+ ctx , releaseLock , err := w .acquireActorLock (ctx , id , actorLockTTL , actorLockHeartbeatInterval )
161179 if err != nil {
162180 return nil , err
163181 }
@@ -184,9 +202,7 @@ func (w *ActorWorkflow) SuspendActor(ctx context.Context, id string) (*ateapipb.
184202 }
185203 state := & SuspendState {}
186204
187- // Acquire lock and get the timeout context for the workflow
188- // Lock TTL is 30 seconds, with 2 seconds padding for workflow timeout
189- ctx , releaseLock , err := w .acquireActorLock (ctx , id , 30 * time .Second , 2 * time .Second )
205+ ctx , releaseLock , err := w .acquireActorLock (ctx , id , actorLockTTL , actorLockHeartbeatInterval )
190206 if err != nil {
191207 return nil , err
192208 }
@@ -213,9 +229,7 @@ func (w *ActorWorkflow) PauseActor(ctx context.Context, id string) (*ateapipb.Ac
213229 }
214230 state := & PauseState {}
215231
216- // Acquire lock and get the timeout context for the workflow
217- // Lock TTL is 30 seconds, with 2 seconds padding for workflow timeout
218- ctx , releaseLock , err := w .acquireActorLock (ctx , id , 30 * time .Second , 2 * time .Second )
232+ ctx , releaseLock , err := w .acquireActorLock (ctx , id , actorLockTTL , actorLockHeartbeatInterval )
219233 if err != nil {
220234 return nil , err
221235 }
@@ -235,27 +249,71 @@ func (w *ActorWorkflow) PauseActor(ctx context.Context, id string) (*ateapipb.Ac
235249 return state .Actor , nil
236250}
237251
238- func (w * ActorWorkflow ) acquireActorLock (ctx context.Context , id string , ttl time.Duration , padding time.Duration ) (context.Context , func (), error ) {
252+ // acquireActorLock takes the per-actor workflow lock and returns a workflow
253+ // context bounded by w.workflowDeadline. A background heartbeat keeps the lock
254+ // alive — independent of lockTTL — for as long as the workflow runs. If the
255+ // heartbeat fails (Redis error or another peer stole the lock) the returned
256+ // context is cancelled with errLostActorLock as the cause, and in-flight steps
257+ // will see ctx.Err() and unwind. The returned release function stops the
258+ // heartbeat, waits for it to exit, then best-effort releases the lock.
259+ func (w * ActorWorkflow ) acquireActorLock (ctx context.Context , id string , lockTTL , heartbeatInterval time.Duration ) (context.Context , func (), error ) {
239260 lockKey := "lock:actor:" + id
240261 lockValue := uuid .New ().String ()
241262
242- // Create a child context for the workflow that expires BEFORE the lock
243- workflowTimeout := ttl - padding
244- workflowCtx , cancel := context .WithTimeout (ctx , workflowTimeout )
245-
246- acquired , err := w .store .AcquireLock (workflowCtx , lockKey , lockValue , ttl )
263+ acquired , err := w .store .AcquireLock (ctx , lockKey , lockValue , lockTTL )
247264 if err != nil {
248- cancel ()
249265 return nil , nil , fmt .Errorf ("while acquiring lock: %w" , err )
250266 }
251267 if ! acquired {
252- cancel ()
253268 return nil , nil , status .Error (grpcCodes .Aborted , "another operation is in progress for this actor" )
254269 }
255270
256- return workflowCtx , func () {
257- cancel ()
271+ cancellableCtx , cancelCause := context .WithCancelCause (ctx )
272+ workflowCtx , cancelDeadline := context .WithTimeout (cancellableCtx , w .workflowDeadline )
273+
274+ heartbeatDone := make (chan struct {})
275+ go w .runLockHeartbeat (workflowCtx , lockKey , lockValue , id , lockTTL , heartbeatInterval , cancelCause , heartbeatDone )
276+
277+ release := func () {
278+ cancelDeadline ()
279+ cancelCause (context .Canceled )
280+ <- heartbeatDone
258281 // Use context.Background() to ensure the lock is released even if the workflow context was canceled.
259282 w .store .ReleaseLock (context .Background (), lockKey , lockValue ) //nolint:errcheck // best-effort release; the lock TTL is the safety net.
260- }, nil
283+ }
284+ return workflowCtx , release , nil
285+ }
286+
287+ // runLockHeartbeat refreshes the actor lock on a ticker until ctx is done. If
288+ // a refresh fails or returns false (we no longer own the lock), it cancels the
289+ // workflow context with errLostActorLock so workflow steps tear down promptly.
290+ func (w * ActorWorkflow ) runLockHeartbeat (ctx context.Context , lockKey , lockValue , actorID string , lockTTL , heartbeatInterval time.Duration , cancelCause context.CancelCauseFunc , done chan <- struct {}) {
291+ defer close (done )
292+ ticker := time .NewTicker (heartbeatInterval )
293+ defer ticker .Stop ()
294+ for {
295+ select {
296+ case <- ctx .Done ():
297+ return
298+ case <- ticker .C :
299+ ok , err := w .store .RefreshLock (ctx , lockKey , lockValue , lockTTL )
300+ if err != nil {
301+ // If ctx was cancelled out from under us we're already tearing
302+ // down — no need to set a misleading cause.
303+ if ! errors .Is (err , context .Canceled ) && ! errors .Is (err , context .DeadlineExceeded ) {
304+ slog .WarnContext (ctx , "Lock heartbeat failed; cancelling workflow" ,
305+ slog .String ("actor_id" , actorID ),
306+ slog .String ("err" , err .Error ()))
307+ cancelCause (fmt .Errorf ("%w: %w" , errLostActorLock , err ))
308+ }
309+ return
310+ }
311+ if ! ok {
312+ slog .WarnContext (ctx , "Actor lock no longer owned; cancelling workflow" ,
313+ slog .String ("actor_id" , actorID ))
314+ cancelCause (errLostActorLock )
315+ return
316+ }
317+ }
318+ }
261319}
0 commit comments