Skip to content

Make InMemoryReactiveSessionRegistry updates atomic#19339

Open
junhyeong9812 wants to merge 2 commits into
spring-projects:mainfrom
junhyeong9812:fix/reactivesessionregistry-atomic-save-remove
Open

Make InMemoryReactiveSessionRegistry updates atomic#19339
junhyeong9812 wants to merge 2 commits into
spring-projects:mainfrom
junhyeong9812:fix/reactivesessionregistry-atomic-save-remove

Conversation

@junhyeong9812

Copy link
Copy Markdown

Overview

InMemoryReactiveSessionRegistry can lose a session when saveSessionInformation and removeSessionInformation run concurrently for the same principal.

Problem

The per-principal session set was updated non-atomically:

  • saveSessionInformation added the session id outside the computeIfAbsent mapping function.
  • removeSessionInformation pruned the principal key with a non-atomic getisEmpty()remove(principal).

Interleaving (principal P owns one session S1):

  1. Thread A removes S1; the set becomes empty and A is about to remove key P.
  2. Thread B saves S2: computeIfAbsent(P) still sees key P, returns the same set, and adds S2.
  3. Thread A removes key P → the set {S2} is orphaned; getAllSessions(P) is empty and S2 is lost.

The blocking SessionRegistryImpl already avoids this by performing both updates atomically with compute/computeIfPresent (on the same ConcurrentMap field shape and constructor); the reactive registry did not.

Fix

Mirror the blocking SessionRegistryImpl: perform the add inside compute, and the remove-and-prune inside computeIfPresent (returning null to drop the key only while the set is empty). This makes the per-principal updates atomic.

A 1000-iteration, latch-synchronized concurrent save/remove stress test is added; it fails on the previous code and passes with the fix.

Closes gh-19338

saveSessionInformation added the session id outside the computeIfAbsent
mapping function, and removeSessionInformation pruned the principal key
with a non-atomic isEmpty()-then-remove. A concurrent save and remove
for the same principal could therefore drop a newly added session. Use
compute and computeIfPresent so the per-principal updates are atomic,
matching the blocking SessionRegistryImpl.

Closes spring-projectsgh-19338

Signed-off-by: junhyeong9812 <pickjog@gmail.com>
@spring-projects-issues spring-projects-issues added the status: waiting-for-triage An issue we've not yet triaged label Jun 14, 2026
(key, sessionsUsedByPrincipal) -> {
sessionsUsedByPrincipal.remove(sessionId);
return sessionsUsedByPrincipal.isEmpty() ? null : sessionsUsedByPrincipal;
});

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.

private Set<String> addSessionToSet(String principal, String sessionId, Set<String> existing) {
    if (existing == null) {
        existing = new CopyOnWriteArraySet<>();
    }
    existing.add(sessionId);
    return existing;
}

private Set<String> removeSessionFromSet(Set<String> sessions, String sessionId) {
    sessions.remove(sessionId);
    return sessions.isEmpty() ? null : sessions;
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Thanks for taking the time to review this, I appreciate it! I'd prefer to keep the add/remove logic inline here, though. This change is specifically about bringing the reactive registry into line with the blocking SessionRegistryImpl, which performs the exact same mutation inline inside compute/computeIfPresent (same shape, same sessionsUsedByPrincipal parameter name). Keeping it inline mirrors that sibling and keeps the diff minimal.

Extracting also has a couple of downsides here: the proposed addSessionToSet(String principal, ...) leaves principal unused, and pulling the body out of the remapping function makes the "this must run inside the atomic compute block" intent a bit less visible (it doesn't break atomicity as long as the helper is used as the remapping function, but it's easier to misuse later). Happy to revisit if a maintainer prefers the extracted form.

public Mono<ReactiveSessionInformation> removeSessionInformation(String sessionId) {
return getSessionInformation(sessionId).doOnNext((sessionInformation) -> {
this.sessionById.remove(sessionId);
Set<String> sessionsUsedByPrincipal = this.sessionIdsByPrincipal.get(sessionInformation.getPrincipal());

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.

sessionsUsedByPrincipal-> sessions

@junhyeong9812 junhyeong9812 Jun 15, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

You're right that sessionsUsedByPrincipal here is the compute/computeIfPresent remapping parameter. I kept that name intentionally to mirror the blocking SessionRegistryImpl, which uses the exact same compute/computeIfPresent shape and the same sessionsUsedByPrincipal parameter name — this PR is largely about aligning the reactive registry with that sibling, so I'd lean towards keeping it for consistency. That said, it's purely stylistic, so I'm happy to shorten it to sessions in both lambdas if maintainers prefer.

.collectList()
.block();
assertThat(sessions).extracting(ReactiveSessionInformation::getSessionId).contains(added);
this.sessionRegistry.removeSessionInformation(added).block();

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.

this iterative block can be declared as a function - would be modular

@junhyeong9812 junhyeong9812 Jun 15, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good point. I left the loop body inline because it's a single save/remove-concurrently/assert race scenario run under load, and only this one test uses it — keeping the setup, the concurrent actions, and the assertion together makes the race being exercised easier to audit, and a failure points straight at the relevant lines. I can extract it into a helper if you feel strongly, but I'm not sure it adds much here.

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.

got it. My suggestion was actually more for better readability than future maintenance. I thought separating the method here would better the understandability of the functionality.

@junhyeong9812 junhyeong9812 Jun 16, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Thanks for clarifying — I gave the readability angle some more thought and I think you're right. Rather than having the reader work through the entire loop body inline, describing one race in a named method and modularizing it reads better and makes the scenario's intent clearer. I've pushed a follow-up commit (72feea0d) extracting the per-iteration body into assertAddedSessionSurvivesConcurrentSaveAndRemove(...), so the loop now just runs that race N times. Would appreciate it if you could take a look — thanks for the nudge!

Signed-off-by: junhyeong9812 <pickjog@gmail.com>
@junhyeong9812 junhyeong9812 force-pushed the fix/reactivesessionregistry-atomic-save-remove branch from ed74f65 to 72feea0 Compare June 16, 2026 20:12
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.

InMemoryReactiveSessionRegistry can lose a session under concurrent save/remove

3 participants