Repository navigation
fix(lock): reclaim locks orphaned by a lost PR-closed webhook - #86
Conversation
Locks are released by the `closed` pull_request webhook. When that delivery never lands the lock outlives its PR and blocks every subsequent PR touching the same application until the 7-day TTL. Observed in practice: the worker crash-looped on a Redis connection failure while PRs #45/#46/#48 were merged. Their `closed` deliveries were dropped, so 16 applications stayed locked by merged PRs and every later plan reported a conflict against a PR that no longer existed. Guard lock acquisition with a live PR-state check: on conflict, ask the VCS whether the holding PR is still open, and force-unlock + retry only when it is closed. Any uncertainty — nil holder, malformed repo, API error, failed release — keeps the lock, since refusing to plan is safer than stealing one from an active PR. Also fix the renderer swallowing these conflicts. The new/deleted early-return ran before the lock-status check, so a deleted app that rendered no diff printed "Application will be deleted" with no mention that the lock was held elsewhere and nothing had been planned. Hoist the foreign-lock check above both branches.
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #86 +/- ##
==========================================
+ Coverage 80.05% 80.14% +0.09%
==========================================
Files 58 58
Lines 7686 7738 +52
==========================================
+ Hits 6153 6202 +49
- Misses 1190 1192 +2
- Partials 343 344 +1
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Pull request overview
This PR adds a reconciliation backstop for Redis application locks that were stranded due to missed PR-closed webhooks, and fixes plan rendering so foreign-lock conflicts aren’t silently omitted for new/deleted apps that render no diffs.
Changes:
- Add stale-lock reclamation during plan lock acquisition by verifying the holding PR state via VCS and force-unlocking when the holder PR is closed.
- Hoist and refactor “foreign lock” detection in the plan renderer so lock conflicts are surfaced before new/deleted early-returns.
- Add focused unit tests covering stale-lock reclaim behavior and renderer foreign-lock behavior.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| internal/commands/plan.go | Adds stale-lock reclamation during lock acquisition (VCS PR state check + force-unlock + retry). |
| internal/commands/stale_lock_test.go | New unit tests for stale-lock reclaim behavior and guard cases. |
| pkg/diff/renderer.go | Moves foreign-lock reporting ahead of new/deleted early returns; extracts isForeignLock. |
| pkg/diff/renderer_test.go | Adds tests ensuring foreign locks are still reported for new/deleted apps and validates isForeignLock. |
Suppressed comments (2)
internal/commands/plan.go:521
- The docstring says "cross-repo holder" is treated as an uncertainty that prevents reclaim, but
reclaimStaleLockcurrently doesn’t check for cross-repo holders (it just validatesheld.Repois parseable). This comment is misleading; update it to match the actual guards (nil holder / malformed repo / PR lookup error / failed unlock).
// Returns true if the lock was released and the caller should retry.
// On any uncertainty (nil holder, cross-repo holder, API error) it returns
// false and leaves the lock intact — refusing to plan is always safer than
// stealing a lock from a genuinely active PR.
internal/commands/plan.go:559
- There’s a race window between verifying the holder PR is closed and calling
ForceUnlock: the lock could be released (e.g. webhook) and re-acquired by a different PR, andForceUnlockwould then delete the new lock. Consider a best-effort guard by reading the current lock and confirming it’s still held by the same repo/PR before force-unlocking.
if err := e.lock.ForceUnlock(ctx, application); err != nil {
slog.Warn("failed to release stale lock",
"app", application,
"holder_pr", held.PRNumber,
"error", err,
)
return false
}
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Co-authored-by: pradithya <4023015+pradithya@users.noreply.github.com>
Co-authored-by: pradithya <4023015+pradithya@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 6 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
internal/commands/plan.go:525
- The doc comment says reclaimStaleLock returns false on a “cross-repo holder”, but the function does not (and cannot, given its current parameters) check whether the holder’s repo matches the current PR’s repo. This is misleading for future maintainers; either implement an explicit cross-repo check (by passing the current repo into the function) or adjust the comment to match the actual guard conditions.
// Returns true if the lock was released and the caller should retry.
// On any uncertainty (nil holder, cross-repo holder, API error) it returns
// false and leaves the lock intact — refusing to plan is always safer than
// stealing a lock from a genuinely active PR.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 6 changed files in this pull request and generated no new comments.
Files not reviewed (1)
- web/package-lock.json: Generated file
Suppressed comments (3)
internal/commands/plan.go:525
- The function comment claims cross-repo holders are treated as an uncertainty, but the implementation does not check for cross-repo and will attempt to verify/reclaim locks regardless of repo. Please align the comment with the actual fail-safe conditions to avoid misleading future maintenance.
// Returns true if the lock was released and the caller should retry.
// On any uncertainty (nil holder, cross-repo holder, API error) it returns
// false and leaves the lock intact — refusing to plan is always safer than
// stealing a lock from a genuinely active PR.
internal/commands/plan.go:569
- splitRepoFullName’s comment says it splits an "owner/repo" identifier, but the function intentionally splits only on the first slash and allows additional slashes in the repo portion (as covered by the new tests). Clarifying this prevents incorrect reuse of the helper.
// splitRepoFullName splits an "owner/repo" identifier into its parts.
func splitRepoFullName(fullName string) (owner, repo string, ok bool) {
web/package-lock.json:6
- This PR’s description and code changes are focused on stale lock reclamation in the Go backend, but it also includes a full regeneration of web/package-lock.json (including a lockfileVersion bump). If this is not intentional, it should be reverted or moved to a separate PR to avoid unrelated dependency churn and noisy diffs.
What
Locks whose holding PR is already merged/closed are now reclaimed on the next plan instead of blocking until the 7-day TTL.
Why
Locks are released by the
closedpull_request webhook. That is the only release path, so a single lost delivery strands the lock permanently.This happened on
pradithya/argocd-gitops. The worker crash-looped on a Redis dial failure (connect: operation not permitted) across the window in which #45, #46 and #48 were merged. Theircloseddeliveries never got processed — the surviving asynq task set containsopened/synchronizefor those PRs but noclosed— so 16 applications stayed locked by merged PRs.Every plan afterwards reported conflicts against PRs that no longer existed:
and
lemuria syncthen answered⚠️ No applications are locked by this PR— plan had acquired nothing, so there was nothing to sync. The two messages read as contradictory and the PR was stuck with no self-service way out short oflemuria unlockon each merged PR.Fix
1. Self-healing lock acquisition (
internal/commands/plan.go)On conflict, verify the holder via
vcs.GetPRbefore reporting it. If that PR is closed, the lock is orphaned — force-unlock and retry once. Fail-safe in every uncertain case (nil holder, malformed repo, API error, failed release): keep the lock and report the conflict, because refusing to plan is always safer than stealing a lock from a genuinely active PR.This complements rather than replaces the webhook: the webhook stays the fast path, and this is the reconciliation backstop for when it is missed.
2. Renderer no longer swallows the conflict (
pkg/diff/renderer.go)The new/deleted early-returns ran before the lock-status check. A deleted app that rendered no diff took the early return and printed:
with no indication that the lock was held by another PR and nothing had been planned. 9 of the 13 apps in the affected PR rendered exactly this. Hoisted the foreign-lock check above both branches and extracted it into
isForeignLock.Tests
internal/commands/stale_lock_test.go— reclaim on merged holder, reclaim on closed-unmerged holder, no reclaim on open holder, no reclaim on PR-lookup error, conflict preserved when force-unlock fails, guards for nil/empty/malformed repo, plussplitRepoFullNametable.pkg/diff/renderer_test.go— foreign lock reported for deleted and new apps, own lock still renders the deletion notice,isForeignLocktable.go build ./...,go vet ./...and the fullgo test ./internal/... ./pkg/...suite pass.Verification against the live incident
Released the stale locks via
lemuria unlockon the merged PRs, then re-ranlemuria planon the blocked PR — it acquired all 13 locks and rendered a correct plan. With this change that manual step is no longer needed.