Skip to content

Rework the Lock API so that locks can be auto-renewed.#482

Open
Julian Gutierrez Oschmann (juli4n) wants to merge 1 commit into
agent-substrate:mainfrom
juli4n:lease_renew
Open

Rework the Lock API so that locks can be auto-renewed.#482
Julian Gutierrez Oschmann (juli4n) wants to merge 1 commit into
agent-substrate:mainfrom
juli4n:lease_renew

Conversation

@juli4n

@juli4n Julian Gutierrez Oschmann (juli4n) commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

Instead of requiring callers to specify a fixed deadline for the lock, let the store return a Lock object that performs auto-renewal in background.

This fixes an issue when a workflow takes longer than the hard-coded TTL, most commonly when atelet takes long times to initialize an actor (e.g. pulling / untaring large OCI images).

Leases are taken for 30 seconds, but renewed every 10 seconds (TTL/3).

Removing that implicit TTL-based limit means a caller that sets no deadline of its own (e.g. kubectl-ate) could otherwise hang a Resume/Suspend/Pause indefinitely. MaxDeadlineUnaryInterceptor adds a 10-minute ceiling on every RPC as a hang-prevention backstop.

Instead of requiring callers to specify a fixed deadline for the lock,
let the store return a `Lock` object that performs auto-renewal in background.

This fixes an issue when a workflow takes longer than the hard-coded TTL, most commonly
when atelet takes long times to initialize an actor (e.g. pulling / untaring large OCI images).

Leases are taken for 30 seconds, but renewed every 10 seconds (TTL/3).

Removing that implicit TTL-based limit means a caller that sets no
deadline of its own (e.g. kubectl-ate) could otherwise hang a
Resume/Suspend/Pause indefinitely. MaxDeadlineUnaryInterceptor adds a
5-minute ceiling on every RPC as a hang-prevention backstop.
case <-ctx.Done():
return
case <-timer.C:
callCtx, cancel := context.WithTimeout(ctx, interval)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 🤖 If Redis is unreachable, the workflow can keep running past lease expiry. A retry's callCtx allows a full extra interval, and the elapsed-TTL check runs only after the attempt returns. So the lease context may not cancel until ~8s after the key expired, when another replica can already hold the lock. The old design cancelled the workflow 2s before the TTL.

Fix: declare the lease lost no later than lastRenewed.Add(ttl), e.g. cap the retry callCtx at time.Until(lastRenewed.Add(ttl)).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1. Also provided more context on the comment below, maybe also with some buffer time.

Comment thread cmd/ateapi/main.go
)

// maxRPCDeadline is the max deadline for all RPC methods exposed by this server.
const maxRPCDeadline = 10 * time.Minute

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 🤖 The PR description says the backstop is 5 minutes; this sets 10. Worth aligning one or the other.

@Jefftree Jeffrey Ying (Jefftree) left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👋 Ben asked me to take a look at this, I work on the leader election library in k8s core. Providing a couple suggestions based on my experience with k8s

slog.WarnContext(ctx, "lock renewal found lease no longer owned", "key", key)
return

case time.Since(lastRenewed) >= ttl:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

k8s has precedence in that we explicitly accept that lease is lost well ahead of the ttl duration if renew is deemed unhealthy. Inflight operations generally take time stop (eg: I see that there was an explicit stop at 28s instead of stopping at the full 30s). Getting here means the renew failed multiple times already and another instance is taking over. I understand substate has a different set of requirements so it doesn't need to exactly follow k8s shape, just providing it as reference. K8s uses a separate Deadline parameter kept below the lease duration (defaults to 2/3 of actual deadline) to account for things like clock skew so the holder gives up while there's still time left on a lease lock. This gives the renewal 2 tries before accepting that the renewal isn't working.

Suggestion: keep a padding here

case <-ctx.Done():
return
case <-timer.C:
callCtx, cancel := context.WithTimeout(ctx, interval)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1. Also provided more context on the comment below, maybe also with some buffer time.

value1 := "token-1"
value2 := "token-2"
ttl := 10 * time.Second
ttl := 300 * time.Millisecond // renewed every ~100ms; overriding s.lockTTL lets the test avoid waiting out the real lockTTL.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: using a real clock has been sometimes brittle with CI and race conditions. Would recommend eventually transitioning to inject a fake clock that you can deterministically control to test for edge cases.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we might be able to use https://go.dev/blog/testing-time

}

// 2. Fast-forward time past TTL
mr.FastForward(6 * time.Second)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I'd imagine you want to keep testing the path of actual TTL expiry (crash/give up) resulting in allowing another client to grab the lock? With renewals, maybe that involves two clients now.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants