Skip to content

fix(invitation): auto-delete expired invitations on a schedule#1744

Merged
whoAbhishekSah merged 6 commits into
mainfrom
feat/auto-delete-expired-invitations
Jul 10, 2026
Merged

fix(invitation): auto-delete expired invitations on a schedule#1744
whoAbhishekSah merged 6 commits into
mainfrom
feat/auto-delete-expired-invitations

Conversation

@whoAbhishekSah

@whoAbhishekSah whoAbhishekSah commented Jul 9, 2026

Copy link
Copy Markdown
Member

The problem

Expired invitations were not getting cleaned up.

If you try to accept an expired invite, it just fails. If someone sends a new invite to the same person, it writes over the old one. Neither of these actually removes the expired invite.

So an invite that is never accepted and never deleted stays around forever. Each invite is stored in three places: one row in the invitations table, and two records in SpiceDB (one links the invite to the user, one links it to the org). All three keep piling up over time.

The fix

Add a job that runs once a day, at midnight UTC, and deletes expired invites. This matches how the domain and session cleanup jobs already work. If the job ever crashes, the crash is caught so it can't take down the server.

The job does not delete an invite the moment it expires. It waits until the invite has been expired for 7 days. That way a recently expired invite still shows up in the list for a week, and the job cleans it up after that.

To delete, the job uses the existing invitation.Delete. That one call removes the table row and both SpiceDB records together.

This is the important part. The code already had a GarbageCollect function that deleted only the table row, with a plain SQL delete. That would have left the two SpiceDB records behind with nothing pointing to them. So I removed it and replaced it with a read-only ListExpired that just finds the old invites. The job then deletes each one the safe way.

If one invite fails to delete, the job logs it and moves on, so the rest still get cleaned up.

🤖 Generated with Claude Code

@vercel

vercel Bot commented Jul 9, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
frontier Ready Ready Preview, Comment Jul 10, 2026 6:14am

@coderabbitai

coderabbitai Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 9f1ef274-cd0e-42d4-aa40-3159e70f16cd

📥 Commits

Reviewing files that changed from the base of the PR and between c3f8f2e and 17a1b28.

📒 Files selected for processing (1)
  • core/invitation/service.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • core/invitation/service.go

📝 Walkthrough

Summary by CodeRabbit

  • New Features
    • Added an automatic daily cleanup that removes invitations expired beyond the seven-day retention window.
    • Cleanup deletes expired invitations and their related access tuples.
    • Cleanup continues processing even if deleting a specific invitation fails.
  • Bug Fixes
    • Improved scheduled-job resilience so cleanup failures won’t crash the server.
  • Tests
    • Added coverage for listing expired invitations and for cleanup behavior, including no-op runs and per-invitation failure handling.

Walkthrough

Changes

The invitation cleanup flow lists invitations expired beyond a seven-day retention window, removes their relations and rows, runs daily through cron, and is started and stopped with the server lifecycle.

Expired invitation cleanup

Layer / File(s) Summary
Expired invitation query contract and storage
core/invitation/service.go, core/invitation/mocks/repository.go, internal/store/postgres/invitation_repository.go, internal/store/postgres/invitation_repository_test.go
Adds ListExpired to the repository contract and mock, replaces GarbageCollect with cutoff-based querying, and tests retention filtering.
Scheduled cleanup service
core/invitation/service.go, core/invitation/service_test.go
Adds daily cron scheduling, seven-day retention handling, relation and invitation deletion, per-item failure handling, panic recovery, and scheduler shutdown.
Server lifecycle wiring
cmd/serve.go
Initializes invitation cleanup during server startup and closes it during deferred shutdown with warning logs.

Estimated code review effort: 3 (Moderate) | ~20 minutes

🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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.

Nothing removed an invitation once it expired. Trying to accept an
expired invite just fails with an error, and creating a new invite for
the same user writes over the old one — neither path deletes the expired
invite. So an invite that was never accepted or deleted kept its
invitations row and both SpiceDB tuples (#user and #org) forever.

Add a daily background job (robfig/cron, "0 0 * * *" UTC, same pattern
as the domain-verification and session-cleanup jobs) that finds expired
invitations and deletes them.

The job deletes through the existing invitation.Delete, which removes
both SpiceDB tuples and the invitations row together. This is important:
the repository's old GarbageCollect ran a raw row DELETE, which would
have left the SpiceDB tuples behind as orphans. GarbageCollect is
removed and replaced with a read-only ListExpired that the service
iterates over.

- core/invitation: InitInvitationCleanup / DeleteExpiredInvitations /
  Close, and a ListExpired repository method.
- cmd/serve: start the job at boot and stop it on shutdown, next to the
  other periodic jobs.
- store/postgres: GarbageCollect -> ListExpired.
- tests for the sweep and for ListExpired.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ention

The domain-verification and session-cleanup jobs both declare their
daily schedule as a package const named refreshTime. Use the same name
and inline comment here instead of a bespoke const.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coveralls

coveralls commented Jul 9, 2026

Copy link
Copy Markdown

Coverage Report for CI Build 29073272998

Warning

Build has drifted: This PR's base is out of sync with its target branch, so coverage data may include unrelated changes.
Quick fix: rebase this PR. Learn more →

Coverage decreased (-0.003%) to 44.876%

Details

  • Coverage decreased (-0.003%) from the base build.
  • Patch coverage: 30 uncovered changes across 3 files (33 of 63 lines covered, 52.38%).
  • 104 coverage regressions across 4 files.

Uncovered Changes

File Changed Covered %
core/invitation/service.go 36 21 58.33%
cmd/serve.go 8 0 0.0%
internal/store/postgres/invitation_repository.go 19 12 63.16%

Coverage Regressions

104 previously-covered lines in 4 files lost coverage.

File Lines Losing Coverage Coverage
internal/api/v1beta1connect/group.go 42 83.05%
internal/store/postgres/group_repository.go 32 76.03%
core/group/service.go 22 54.78%
cmd/group.go 8 43.8%

Coverage Stats

Coverage Status
Relevant Lines: 37644
Covered Lines: 16893
Line Coverage: 44.88%
Coverage Strength: 12.54 hits per line

💛 - Coveralls

@coveralls

Copy link
Copy Markdown

Coverage Report for CI Build 29002914971

Coverage increased (+0.001%) to 44.88%

Details

  • Coverage increased (+0.001%) from the base build.
  • Patch coverage: 30 uncovered changes across 3 files (27 of 57 lines covered, 47.37%).
  • No coverage regressions found.

Uncovered Changes

File Changed Covered %
core/invitation/service.go 31 16 51.61%
cmd/serve.go 8 0 0.0%
internal/store/postgres/invitation_repository.go 18 11 61.11%

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 37665
Covered Lines: 16904
Line Coverage: 44.88%
Coverage Strength: 12.5 hits per line

💛 - Coveralls

Only delete invitations that expired at least 7 days ago, instead of
everything past its expiry. A recently expired invite still shows up in
the list APIs; the daily job removes it once it crosses the retention
window.

The retention window lives in the service (business logic) as a cutoff
time (now minus 7 days) that is passed to ListExpired, so the repository
just filters by the given time.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@whoAbhishekSah

Copy link
Copy Markdown
Member Author

How this was tested

Ran locally against a real Frontier (Postgres + SpiceDB). Set the cleanup cron to
@every 15s so the sweep runs on demand, then checked the DB row and the SpiceDB
tuples after each step.

# What we did Expected Result
1 Create an invite 1 DB row + 2 tuples (#org, #user)
2 Invite expired 8 days ago (past the 7-day keep window) swept: row + both tuples gone
3 Invite expired 1 hour ago (still inside the keep window) left alone, still listed
4 Wait for another sweep with nothing old enough nothing deleted, no error
5 Unit tests (go test ./core/invitation/...) pass
6 gofmt, go vet, go build clean

The invite is removed through Delete, so the row and both tuples go together —
the old row-only cleanup would have left the tuples behind. None were left after
the sweep.

@AmanGIT07

Copy link
Copy Markdown
Contributor

How this was tested

@whoAbhishekSah Can you validate these scenarios too -

  1. user invited once, the invitation hasn't expired and we re-invite the same user.
  2. user invite expired and cleaned up and we re-invite

Comment thread internal/store/postgres/invitation_repository.go Outdated
@whoAbhishekSah

whoAbhishekSah commented Jul 10, 2026

Copy link
Copy Markdown
Member Author

@AmanGIT07 Validated both re-invite scenarios locally against a real Frontier (Postgres + SpiceDB). Checked the invitations row and both SpiceDB tuples (#user, #org) after each step.

# Scenario Expected Result
1 Invite a user, then re-invite the same user while the invite is still active Same invite is reused: 1 row, 2 tuples, no duplicate ✅ Same invite ID reused. Still 1 row, expiry unchanged (not extended), still 2 tuples
2 Invite a user, clean up the invite, then re-invite Fresh invite: 1 row, 2 tuples, no leftovers ✅ Cleanup removed the row and both tuples. Re-invite created a new invite ID with 1 row and 2 tuples

For scenario 2 the cleanup was done through the same invitation.Delete the cron runs per invite (via DeleteOrganizationInvitation), so the row and both tuples go together. The earlier comment already covered the cron picking the right invites to sweep.

One extra case worth flagging

Re-inviting a user whose invite has expired but is still inside the 7-day keep window (not swept yet) creates a second row plus 2 new tuples, and leaves the old expired row in place. So for a short time there are 2 rows and 4 tuples for the same user in the same org.

This is not new. Create only reuses an invite when it has not expired, and the upsert keys on id only (there is no unique constraint on org + user), so this has always happened. This PR actually makes it better: the stale duplicate now gets swept 7 days after it expires instead of staying forever.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 1

🧹 Nitpick comments (2)
core/invitation/service.go (1)

279-320: 🚀 Performance & Scalability | 🔵 Trivial

Cleanup job lifecycle logic looks correct.

InitInvitationCleanup/DeleteExpiredInvitations/Close correctly compute a UTC cutoff, log-and-continue per invitation on Delete failure, and stop the scheduler cleanly. One scalability note: ListExpired has no batch limit, so a large backlog (e.g., first run after this job is deployed) is fetched and processed sequentially with one SpiceDB call + one DB delete per invitation, on a single goroutine. Given the daily cadence and small expected steady-state volume this is unlikely to bite in practice, but worth keeping in mind if invitation volume grows.

internal/store/postgres/invitation_repository.go (1)

246-282: 🚀 Performance & Scalability | 🔵 Trivial

Correct replacement for GarbageCollect.

Query logic and error handling are correct and consistent with the existing List method. Two points worth a look, not blockers:

  • No LIMIT/batching on the query — if this ever needs to catch up on a large backlog, the whole result set is loaded and deleted in one run.
  • Confirm expires_at is indexed so this daily scan stays cheap as the invitations table grows.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 7b2fdbb1-cfef-42f8-86b7-27887be3585d

📥 Commits

Reviewing files that changed from the base of the PR and between 545ee21 and fdf6949.

📒 Files selected for processing (6)
  • cmd/serve.go
  • core/invitation/mocks/repository.go
  • core/invitation/service.go
  • core/invitation/service_test.go
  • internal/store/postgres/invitation_repository.go
  • internal/store/postgres/invitation_repository_test.go

Comment thread core/invitation/service.go
cron.New defaults to time.Local, so on a non-UTC host the job would run
at local midnight instead of the midnight UTC the comment claims. Pass
cron.WithLocation(time.UTC) so the schedule matches the comment.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@whoAbhishekSah whoAbhishekSah merged commit 9339bb2 into main Jul 10, 2026
8 checks passed
@whoAbhishekSah whoAbhishekSah deleted the feat/auto-delete-expired-invitations branch July 10, 2026 06:19
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