Skip to content

fix(lock): keep the PR index alive so unlock can find every lock - #87

Open
pradithya wants to merge 1 commit into
mainfrom
fix/pr-lock-index-expiry
Open

pradithya wants to merge 1 commit into
mainfrom
fix/pr-lock-index-expiry

Conversation

@pradithya

Copy link
Copy Markdown
Owner

Follow-up to #86. That fix reclaimed locks whose PR had closed; this one fixes locks that go missing while their PR is still open.

Symptom

On argocd-gitops#49, lemuria unlock replied:

No applications are locked by this PR.

while the plan comment showed three applications 🔒 Locked by this PR. Both unlock attempts no-oped, and the applications stayed blocked for every other PR.

Cause

ListByPR resolves a PR's locks solely through the lemuria:pr-locks:<repo>:<pr> index set. That index is given a TTL once, on first acquisition, and never refreshed. The lock keys themselves are rewritten on every replan, which resets their TTL.

So a PR that stays open and replans keeps its locks alive indefinitely, while the index keeps counting down from the original acquisition and eventually expires. After that the locks are orphaned: still held, still blocking, but invisible to unlock, sync and rollback alike — until the 7-day lock TTL finally runs out.

Confirmed against production Redis: three live lock keys for PR #49 (vault, external-secrets, traefik-internal-gateway) with ~6h of TTL remaining, and no pr-locks:...:49 key. DEL on the index returned 0, confirming it had already expired. PRs #55/#57 had intact indexes, which is why only #49 misbehaved.

This is also why the auto-sync restore path can silently skip applications: it is driven by the same index.

Fix

Stop the drift. Refresh the index TTL wherever the lock is written, through a single indexLock helper, and give the index a longer TTL than the locks it points at so it can never expire first. The Expire error is no longer discarded.

Recover what already drifted. Repairing only the write path would leave existing orphaned locks stuck, so the index is now a fast path rather than the source of truth: when it returns nothing for a PR, SCAN the lock keys, fold anything found back into the index, and log it. A lock the index has lost is exactly the lock that most needs releasing. A scan failure degrades to the index result instead of failing the command.

Tests

internal/lock/index_drift_test.go reproduces the production sequence with miniredis FastForward — acquire, replan at day 6, expire the index at day 8 — and covers the recovery and TTL-refresh paths. All three fail on main:

ListByPR returned 0 locks, want 3 (3 locks are still live in Redis —
unlock would report 'No applications are locked by this PR' while leaving them held)
lock on vault survived unlock (held by PR #49) — the app stays blocked
PR index TTL = 24h0m0s after replan, want it refreshed to ~7d to match the lock TTL

Full unit suite passes. The pre-existing e2e failure (401 against a live Argo CD) is unrelated and reproduces on a clean main.

Note

Production locks for PR #49 were cleared manually to unblock work; no migration is needed, since the scan fallback recovers this state on its own.

`lemuria unlock` reported "No applications are locked by this PR" on a PR
whose applications were demonstrably still locked, leaving them held and
blocking every other PR until the 7-day lock TTL expired.

ListByPR resolves a PR's locks solely through the `lemuria:pr-locks:` index
set. That index was given a TTL once, on first acquisition, and never
refreshed. The lock keys themselves are rewritten on every replan, which
resets their TTL. A PR that stays open and replans therefore keeps its locks
alive indefinitely while the index counts down from the original acquisition
and eventually expires — after which the locks are orphaned: still held,
still blocking, but invisible to unlock, sync and rollback alike.

Observed on argocd-gitops#49: three live lock keys, no index key, and two
`lemuria unlock` comments that both no-oped.

Refresh the index TTL wherever the lock is written, via a single indexLock
helper, and give the index a longer TTL than the locks it points at so it
can never expire first. The Expire error is no longer discarded.

Repairing the write path alone would strand locks already orphaned, so treat
the index as a fast path rather than the source of truth: when it comes back
missing entries, SCAN the lock keys for that PR, fold what turns up back into
the index, and log it. A lock the index has lost is precisely the lock that
most needs releasing.

Tests reproduce the production sequence — acquire, replan at day 6, expire
the index at day 8 — and cover the recovery and TTL-refresh paths.
Copilot AI lite review requested due to automatic review settings August 23, 2026 01:45

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

ListByPR currently performs a full Redis SCAN on every call, which can introduce significant operational load and should be gated to the “index missing” scenario (or another narrow trigger) before merging.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR fixes Redis lock “index drift” where the PR→locks index (lemuria:pr-locks:<repo>:<pr>) could expire while the underlying lock keys were kept alive by replans, making locks invisible to unlock/sync/rollback even though they still blocked other PRs.

Changes:

  • Refreshes the PR index TTL on every lock write via a shared indexLock helper and sets the index TTL longer than the lock TTL.
  • Adds a scan-based recovery path in ListByPR to find locks by PR even if the index is missing, and repairs the index when recovered locks are found.
  • Adds miniredis-based tests reproducing the production drift sequence and validating recovery + TTL refresh behavior.
File summaries
File Description
internal/lock/redis.go Refresh PR index TTL on lock writes; add scan fallback to recover locks when the index is missing.
internal/lock/index_drift_test.go New regression tests reproducing index TTL drift and verifying recovery/refresh behavior.
Review details
  • Files reviewed: 2/2 changed files
  • Comments generated: 1
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread internal/lock/redis.go
Comment on lines +267 to 285
recovered, err := m.scanLocksByPR(ctx, repo, prNumber, seen)
if err != nil {
// The index results are still usable; degrade rather than fail the command.
slog.Warn("could not scan for locks missing from the PR index",
"repo", repo, "pr", prNumber, "error", err)
return locks, nil
}

for _, lock := range recovered {
slog.Warn("recovered lock missing from PR index",
"app", lock.Application, "repo", repo, "pr", prNumber)
if err := m.indexLock(ctx, repo, prNumber, lock.Application); err != nil {
slog.Warn("could not repair PR index",
"app", lock.Application, "repo", repo, "pr", prNumber, "error", err)
}
locks = append(locks, lock)
}

return locks, nil
@pradithya

Copy link
Copy Markdown
Owner Author

CI note: all four failing jobs are pre-existing on main, not caused by this PR — which touches only internal/lock/.

Backend (Lint Backend, Test Backend) — both fail on the same embed error:

embed.go:21:12: pattern all:static: no matching files found
FAIL	github.com/org/lemuria/internal/server [setup failed]

static/ is gitignored and populated by the frontend build, but the backend jobs run Go directly without building the frontend first, so the //go:embed all:static directive has nothing to match. Identical failure on the latest main run (32015932004).

Every other package passed in CI, including the one this PR changes:

ok  github.com/org/lemuria/internal/lock  0.142s  coverage: 78.4% of statements

Frontend (Lint Frontend, Test Frontend) — also failing on main; no frontend files are touched here.

Happy to fix the embed/CI ordering separately (either commit a static/.keep that survives the ignore rule, or build the frontend before the Go jobs) — it is a distinct concern from this lock fix, so I have kept it out of this PR.

This branch has not been deployed

No deployments
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.

2 participants