Skip to content

Prevent re-adding deleted sessions to Redis principal index - #3889

Open
seonwooj0810 wants to merge 1 commit into
spring-projects:3.5.xfrom
seonwooj0810:fix/issue-1843-principal-index-readd-on-delete
Open

Prevent re-adding deleted sessions to Redis principal index#3889
seonwooj0810 wants to merge 1 commit into
spring-projects:3.5.xfrom
seonwooj0810:fix/issue-1843-principal-index-readd-on-delete

Conversation

@seonwooj0810

Copy link
Copy Markdown

Closes gh-1843

Root cause

RedisSession#saveDelta() re-adds the session id to the resolved principal's Redis index set whenever the delta contains the principal/security-context attribute key — this branch does both a removal (of the id from whatever principal it was previously indexed under) and, if a principal still resolves, an unconditional re-add.

RedisIndexedSessionRepository#deleteById() calls cleanupPrincipalIndex(session) to remove the session id from its principal's index set, then sets maxInactiveInterval to Duration.ZERO and calls save(session), which invokes saveDelta().

Under SaveMode.ALWAYS, the RedisSession constructor unconditionally copies every current attribute (including the principal-index attribute) into delta, so saveDelta() always takes the principal-index branch when deleting such a session. Since deleteById() never removes the session's own attributes (it only zeroes the TTL), resolveIndexesFor() still resolves the same principal, and saveDelta() re-adds the id right back into the set that cleanupPrincipalIndex() had just removed it from a few lines earlier. The principal's Redis set (...index:...PRINCIPAL_NAME_INDEX_NAME:<principal>) then keeps growing with ids of sessions that were explicitly invalidated (e.g. on logout), leaking memory indefinitely.

This is the mechanism behind gh-1843's original report (SaveMode.ALWAYS + request.invalidate()) and matches the reporter's own code walkthrough in the issue.

Scope vs. related issues/PRs

This repo has a cluster of principal-index-staleness reports, but they're two distinct mechanisms:

Change

Guard the re-add in saveDelta() with RedisSession#isExpired(). At the point deleteById() calls save(session), maxInactiveInterval has just been set to Duration.ZERO (not a negative "never expires" value), so isExpired() is already true there — no new field or flag needed. The unconditional removal from the previous principal's set is left untouched, since removing a session id from an index is always correct regardless of expiration state; only the re-add was wrong.

Test evidence

Added deleteWhenSaveModeAlwaysThenPrincipalIndexNotReAdded to RedisIndexedSessionRepositoryTests, which sets SaveMode.ALWAYS, gives the session a principal-index attribute, calls deleteById, and asserts the principal set's remove still happens while add is never invoked. Before this change the test fails with TooManyActualInvocations on add (the id is re-added); after the change it passes.

Verification done

  1. No in-flight PR: gh pr list --repo spring-projects/spring-session --search "1843" / --search "3183" returned nothing; the only related open PR is Clean Redis principal index during expiration cleanup #3798, which (per above) addresses a different code path.
  2. No active self-claim on memory leak when session save model is "always" #1843 itself (last comment 2021); Redis Spring Session - Indexes have a TTL of No Limit #3183 (the duplicate) has no claim either.
  3. Code-focused: touches only RedisIndexedSessionRepository.java (1 line) and its test class.
  4. Confirmed the bug is still present on current 3.5.x/main by reading saveDelta() and deleteById() and reproducing it with the new test (fails without the fix, passes with it).
  5. No parent epic.
  6. Not waiting-for-triage (labeled type: bug + in: redis), so the triage-comment gate doesn't apply.

./gradlew :spring-session-data-redis:test --tests '*RedisIndexedSessionRepositoryTests*' → 57 tests, 0 failures. ./gradlew :spring-session-data-redis:checkFormatMain :spring-session-data-redis:checkFormatTest :spring-session-data-redis:checkstyleMain :spring-session-data-redis:checkstyleTest → all pass. Docker-backed integration tests under src/integration-test were not run (not required for this change, which is a pure unit-testable Redis-key-management fix with no new integration surface).

This PR was prepared with AI assistance (Claude); I reviewed the root-cause analysis and diff myself before opening it.

RedisSession#saveDelta() unconditionally re-adds the session id to the
resolved principal's index set whenever the delta contains the
principal/security-context attribute key. RedisIndexedSessionRepository
#deleteById() first removes the session id via cleanupPrincipalIndex(),
then sets maxInactiveInterval to Duration.ZERO and calls save(session),
which invokes saveDelta().

Under SaveMode.ALWAYS, the RedisSession constructor unconditionally
copies every current attribute (including the principal-index
attribute) into the delta, so saveDelta() always takes the
principal-index branch on this path. Since the session's own
attributes are untouched by deleteById(), resolveIndexesFor() still
resolves the same principal, and saveDelta() re-adds the id to the
index set that cleanupPrincipalIndex() had just removed it from. The
principal's Redis set (e.g. "spring:session:index:...:<principal>")
then keeps growing with ids of sessions that were explicitly
invalidated, leaking memory.

Guard the re-add with RedisSession#isExpired(), which is already true
at this point in deleteById() (maxInactiveInterval is set to Duration
.ZERO, not a negative "never expires" value). The unconditional removal
of the id from the previous principal's set is left untouched, since
removal is always correct regardless of expiration state.

Closes spring-projectsgh-1843

Signed-off-by: seonwoo_jung <79202163+seonwooj0810@users.noreply.github.com>
@spring-projects-issues spring-projects-issues added the status: waiting-for-triage An issue we've not yet triaged label Aug 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

status: waiting-for-triage An issue we've not yet triaged

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants