Skip to content

fix(api): guard lease renew/release updates with state predicates#382

Merged
duyet merged 1 commit into
mainfrom
claude/w4-lease-renew-release-guards
Jul 17, 2026
Merged

fix(api): guard lease renew/release updates with state predicates#382
duyet merged 1 commit into
mainfrom
claude/w4-lease-renew-release-guards

Conversation

@duyet

@duyet duyet commented Jul 17, 2026

Copy link
Copy Markdown
Owner

Closes #367

Summary

Implements plan 007 (plans/007-lease-update-guards.md).

renewLease and releaseLease did check-then-act: SELECT the lease,
branch on releasedAt/expiresAt in JS, then UPDATE ... WHERE id = ?
with no state guard in the WHERE. Between select and update, a
concurrent release or expiry-driven re-acquire could change the row —
renewLease would then stamp a fresh expiresAt onto an
already-released lease and return a misleading success for a
coordination primitive.

  • Both UPDATEs now carry releasedAt IS NULL (+ expiresAt > now for
    renew) guards and use .returning() to detect a lost race.
  • On a 0-row update, re-select once and map to the existing
    NOT_FOUND / LEASE_EXPIRED codes — client-visible semantics are
    unchanged; only the internal write is now correct under concurrency.
  • Acquire's mutual-exclusion guarantee (the partial unique index) was
    already race-safe — this is a correctness-of-response fix, not a
    lock-safety hole.

Plan 006 (idempotency claim-first, #365) is NOT included — it was
already fixed on main by PR #355 (commit aaf05ac, closing #289/#290)
with a stronger design (idempotency claim embedded atomically in the
same D1 batch as the mutation, plus a 60s orphan-reservation reclaim).
This branch is rebased onto current main (includes #355 and #282), so
it does not duplicate or conflict with that fix. services/leases.ts's
renewLease/releaseLease — this PR's target — were untouched by #355
(#355's lease fix guards a different function, the write-time lease gate
inside upsertState/deleteState), so no overlap there.

Test plan

  • bunx tsc --noEmit -p packages/api/tsconfig.json — exit 0
  • bunx biome check packages/api/src/ — clean except one
    pre-existing, unrelated formatting issue in lib/validation.ts
    that predates this branch (confirmed present on unmodified main)
  • cd packages/api && bunx vitest run — 364/364 pass, run after
    the rebase onto current main (not carried over from before)
  • New tests: sequential renew-after-release → 404 with
    expires_at unchanged; concurrent renew/release race → response
    always matches the actual write (no silent success without an
    actual write, no reported failure with a silent write)

Plan 007. renewLease/releaseLease did check-then-act: SELECT the lease,
branch in JS, then UPDATE with no state guard in the WHERE. A concurrent
release or expiry-driven re-acquire between the select and the update
could let renewLease stamp a fresh future expires_at onto an
already-released lease, returning a misleading success.

Add releasedAt/expiresAt predicates to both UPDATEs and detect
0-row writes via .returning(); on a lost race, re-select once and map
to the existing NOT_FOUND/LEASE_EXPIRED codes so client-visible
semantics are unchanged. Mutual exclusion on acquire was already safe
via the partial unique index; this is a correctness-of-response fix.

Co-Authored-By: Duyet Le <me@duyet.net>
Co-Authored-By: duyetbot <bot@duyet.net>
@gemini-code-assist

Copy link
Copy Markdown

Warning

You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again!

@sourcery-ai sourcery-ai Bot 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.

Sorry @duyet, you have reached your weekly rate limit of 500000 diff characters.

Please try again later or upgrade to continue using Sourcery

@coderabbitai

coderabbitai Bot commented Jul 17, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@duyet, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 34 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 97fded2d-a049-45df-90c5-3ba432580d16

📥 Commits

Reviewing files that changed from the base of the PR and between 5e6abe4 and 4ef1a6f.

📒 Files selected for processing (2)
  • packages/api/src/services/leases.ts
  • packages/api/test/v2-leases-contention.test.ts
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/w4-lease-renew-release-guards

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

duyet added a commit that referenced this pull request Jul 17, 2026
Plan 006 is superseded: main shipped a stronger fix for the same issue
(#289/#290) in aaf05ac (PR #355) after this plan's ce9a1fa baseline. The
Idempotency-Key claim is now embedded atomically in the same d1.batch() as
the mutation, rather than plan 006's separate claim-then-mutate step. Plan
007 was unaffected (#355 guarded a different code path) and ships as #382.

Plan 012's hard blocker dissolved: PR #380 implemented #344 and wrote
docs/webhooks.md with the timestamped signature scheme in the same PR, so
the docs were written against the correct format. Plan 012 is now UI-only.

Three plans went stale within hours of being written, each differently: 008
was wrong on arrival, 006 was overtaken by unrelated work, 012 by its own
dependency being solved better elsewhere. The drift check guards only the
first, and only when the executor's baseline is the pinned commit.

Also records that #370 and #380 both edit createConversation/deleteConversation
and will conflict, plus a suggested merge order.

Co-Authored-By: Duyet Le <me@duyet.net>
Co-Authored-By: duyetbot <bot@duyet.net>
duyet added a commit that referenced this pull request Jul 17, 2026
* docs(plans): track the plans directory and add a status index

The 10 implementation plans were untracked, so they existed only on one
machine while every PR referenced them by path, and no worktree could see
them. Each plan also instructs its executor to "update this plan's row in
plans/README.md" — a file that never existed, which three separate executors
hit and correctly flagged rather than fabricating.

Adds plans/README.md: status table with issue links, the cross-plan
dependency graph, and a note that plan 008 is superseded (its target design
preserves the /watch deadlock in #360, so its own regression test would hang).

Co-Authored-By: Duyet Le <me@duyet.net>
Co-Authored-By: duyetbot <bot@duyet.net>

* docs(plans): add plans 011-014 for the held product/architecture issues

Plans for the four issues deliberately held out of the parallel fix batch
because they are product decisions rather than defined fixes:

- 011 dashboard UI for the 4 unexposed primitives (#284)
- 012 webhooks docs + dashboard UI (#285)
- 013 demo sandbox / pre-signup try-it (#287)
- 014 write atomicity via db.batch() (#342)

014 confirms D1 batch() semantics against Cloudflare docs: statements execute
sequentially as one all-or-nothing SQL transaction, but all statements must be
prepared before the call — no reading a result mid-batch and branching on it.
All three call sites are batchable as-is; updateConversationMessageCount is
already a blind relative SQL update rather than a read-then-write.

Co-Authored-By: Duyet Le <me@duyet.net>
Co-Authored-By: duyetbot <bot@duyet.net>

* docs(plans): mark 006 superseded and 012 docs-done; record merge order

Plan 006 is superseded: main shipped a stronger fix for the same issue
(#289/#290) in aaf05ac (PR #355) after this plan's ce9a1fa baseline. The
Idempotency-Key claim is now embedded atomically in the same d1.batch() as
the mutation, rather than plan 006's separate claim-then-mutate step. Plan
007 was unaffected (#355 guarded a different code path) and ships as #382.

Plan 012's hard blocker dissolved: PR #380 implemented #344 and wrote
docs/webhooks.md with the timestamped signature scheme in the same PR, so
the docs were written against the correct format. Plan 012 is now UI-only.

Three plans went stale within hours of being written, each differently: 008
was wrong on arrival, 006 was overtaken by unrelated work, 012 by its own
dependency being solved better elsewhere. The drift check guards only the
first, and only when the executor's baseline is the pinned commit.

Also records that #370 and #380 both edit createConversation/deleteConversation
and will conflict, plus a suggested merge order.

Co-Authored-By: Duyet Le <me@duyet.net>
Co-Authored-By: duyetbot <bot@duyet.net>

---------

Co-authored-by: duyetbot <bot@duyet.net>
@duyet
duyet merged commit d3a329e into main Jul 17, 2026
6 checks passed
@duyet
duyet deleted the claude/w4-lease-renew-release-guards branch July 17, 2026 03:41
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.

[api] Lease renew/release UPDATEs are unguarded check-then-act — renew can succeed on a released lease

1 participant