Skip to content

Commit 3204cc8

Browse files
committed
Merge pull request #47 from jcoleman/save-policies
Save policies
2 parents a3dd3e6 + 708a5ec commit 3204cc8

File tree

5 files changed

+96
-41
lines changed

5 files changed

+96
-41
lines changed

README.markdown

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ Add the following into your Tomcat context.xml (or the context block of the serv
5959
port="6379" <!-- optional: defaults to "6379" -->
6060
database="0" <!-- optional: defaults to "0" -->
6161
maxInactiveInterval="60" <!-- optional: defaults to "60" (in seconds) -->
62+
sessionPersistPolicies="PERSIST_POLICY_1,PERSIST_POLICY_2,.." <!-- optional -->
6263
sentinelMaster="SentinelMasterName" <!-- optional -->
6364
sentinels="sentinel-host-1:port,sentinel-host-2:port,.." <!-- optional --> />
6465

@@ -114,20 +115,15 @@ Then the example above would look like this:
114115
myArray.add(additionalArrayValue);
115116
session.setAttribute("customDirtyFlag");
116117

118+
Persistence Policies
119+
--------------------
117120

118-
Possible Issues
119-
---------------
120-
121-
There is the possibility of a race condition that would cause seeming invisibility of the session immediately after your web application logs in a user: if the response has finished streaming and the client requests a new page before the valve has been able to complete saving the session into Redis, then the new request will not see the session.
122-
123-
This condition will be detected by the session manager and a java.lang.IllegalStateException with the message `Race condition encountered: attempted to load session[SESSION_ID] which has been created but not yet serialized.` will be thrown.
124-
125-
Normally this should be incredibly unlikely (insert joke about programmers and "this should never happen" statements here) since the connection to save the session into Redis is almost guaranteed to be faster than the latency between a client receiving the response, processing it, and starting a new request.
121+
With an persistent session storage there is going to be the distinct possibility of race conditions when requests for the same session overlap/occur concurrently. Additionally, because the session manager works by serializing the entire session object into Redis, concurrent updating of the session will exhibit last-write-wins behavior for the entire session (not just specific session attributes).
126122

127-
Possible solutions:
123+
Since each situation is different, the manager gives you several options which control the details of when/how sessions are persisted. Each of the following options may be selected by setting the `sessionPersistPolicies="PERSIST_POLICY_1,PERSIST_POLICY_2,.."` attributes in your manager declaration in Tomcat's context.xml. Unless noted otherwise, the various options are all combinable.
128124

129-
- Enable the "save on change" feature by setting `saveOnChange` to `true` in your manager declaration in Tomcat's context.xml. Using this feature will degrade performance slightly as any change to the session will save the session synchronously to Redis, and technically this will still exhibit slight race condition behavior, but it eliminates as much possiblity of errors occurring as possible. __Note__: There's a tradeoff here in that if you make several changes to the session attributes over the lifetime of a long-running request while other short requests are making changes, you'll still end up having the long-running request overwrite the changes made by the short requests. Unfortunately there's no way to completely eliminate all possible race conditions here, so you'll have to determine what's necessary for your specific use case.
130-
- If you encounter errors, then you can force save the session early (before sending a response to the client) then you can retrieve the current session, and call `currentSession.manager.save(currentSession, true)` to synchronously eliminate the race condition. Note: this will only work directly if your application has the actual session object directly exposed. Many frameworks (and often even Tomcat) will expose the session in their own wrapper HttpSession implementing class. You may be able to dig through these layers to expose the actual underlying RedisSession instance--if so, then using that instance will allow you to implement the workaround.
125+
- `SAVE_ON_CHANGE`: every time `session.setAttribute()` or `session.removeAttribute()` is called the session will be saved. __Note:__ This feature cannot detect changes made to objects already stored in a specific session attribute. __Tradeoffs__: This option will degrade performance slightly as any change to the session will save the session synchronously to Redis.
126+
- `ALWAYS_SAVE_AFTER_REQUEST`: force saving after every request, regardless of whether or not the manager has detected changes to the session. This option is particularly useful if you make changes to objects already stored in a specific session attribute. __Tradeoff:__ This option make actually increase the liklihood of race conditions if not all of your requests change the session.
131127

132128
Acknowledgements
133129
----------------

example-app/src/main/java/com/orangefunction/tomcatredissessionmanager/exampleapp/WebApp.java

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -225,8 +225,8 @@ public Object handle(Request request, Response response) {
225225

226226
RedisSessionManager manager = getRedisSessionManager(request);
227227
if (null != manager) {
228-
if (key.equals("saveOnChange")) {
229-
map.put("value", new Boolean(manager.getSaveOnChange()));
228+
if (key.equals("sessionPersistPolicies")) {
229+
map.put("value", manager.getSessionPersistPolicies());
230230
} else if (key.equals("maxInactiveInterval")) {
231231
map.put("value", new Integer(manager.getMaxInactiveInterval()));
232232
}
@@ -248,9 +248,9 @@ public Object handle(Request request, Response response) {
248248

249249
RedisSessionManager manager = getRedisSessionManager(request);
250250
if (null != manager) {
251-
if (key.equals("saveOnChange")) {
252-
manager.setSaveOnChange(Boolean.parseBoolean(value));
253-
map.put("value", new Boolean(manager.getSaveOnChange()));
251+
if (key.equals("sessionPersistPolicies")) {
252+
manager.setSessionPersistPolicies(value);
253+
map.put("value", manager.getSessionPersistPolicies());
254254
} else if (key.equals("maxInactiveInterval")) {
255255
manager.setMaxInactiveInterval(Integer.parseInt(value));
256256
map.put("value", new Integer(manager.getMaxInactiveInterval()));

spec/requests/sessions_spec.rb

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -102,13 +102,15 @@
102102
describe "change on save" do
103103

104104
before :each do
105-
get("#{SETTINGS_PATH}/saveOnChange")
106-
@oldSaveOnChangeValue = json['value']
107-
post("#{SETTINGS_PATH}/saveOnChange", body: {value: 'true'})
105+
get("#{SETTINGS_PATH}/sessionPersistPolicies")
106+
@oldSessionPersistPoliciesValue = json['value']
107+
enums = @oldSessionPersistPoliciesValue.split(',')
108+
enums << 'SAVE_ON_CHANGE'
109+
post("#{SETTINGS_PATH}/sessionPersistPolicies", body: {value: enums.join(',')})
108110
end
109111

110112
after :each do
111-
post("#{SETTINGS_PATH}/saveOnChange", body: {value: @oldSaveOnChangeValue})
113+
post("#{SETTINGS_PATH}/sessionPersistPolicies", body: {value: @oldSessionPersistPoliciesValue})
112114
end
113115

114116
it 'should support persisting the session on change to minimize race conditions on simultaneous updates' do

src/main/java/com/radiadesign/catalina/session/RedisSessionHandlerValve.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ private void storeOrRemoveSession(Session session) {
3838
log.trace("Request with session completed, saving session " + session.getId());
3939
if (session.getSession() != null) {
4040
log.trace("HTTP Session present, saving " + session.getId());
41-
manager.save(session);
41+
manager.save(session, manager.getAlwaysSaveAfterRequest());
4242
} else {
4343
log.trace("No HTTP Session present, Not saving " + session.getId());
4444
}
@@ -48,7 +48,7 @@ private void storeOrRemoveSession(Session session) {
4848
}
4949
}
5050
} catch (Exception e) {
51-
// Do nothing.
51+
log.error("Error storing/removing session", e);
5252
}
5353
}
5454
}

src/main/java/com/radiadesign/catalina/session/RedisSessionManager.java

Lines changed: 76 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,31 @@
2525
import java.util.Collections;
2626
import java.util.Enumeration;
2727
import java.util.Set;
28+
import java.util.EnumSet;
2829
import java.util.HashSet;
30+
import java.util.Iterator;
2931

3032
import org.apache.juli.logging.Log;
3133
import org.apache.juli.logging.LogFactory;
3234

3335

3436
public class RedisSessionManager extends ManagerBase implements Lifecycle {
3537

38+
enum SessionPersistPolicy {
39+
DEFAULT,
40+
SAVE_ON_CHANGE,
41+
ALWAYS_SAVE_AFTER_REQUEST;
42+
43+
static SessionPersistPolicy fromName(String name) {
44+
for (SessionPersistPolicy policy : SessionPersistPolicy.values()) {
45+
if (policy.name().equalsIgnoreCase(name)) {
46+
return policy;
47+
}
48+
}
49+
throw new IllegalArgumentException("Invalid session persist policy [" + name + "]. Must be one of " + Arrays.asList(SessionPersistPolicy.values())+ ".");
50+
}
51+
}
52+
3653
protected byte[] NULL_SESSION = "null".getBytes();
3754

3855
private final Log log = LogFactory.getLog(RedisSessionManager.class);
@@ -43,7 +60,6 @@ public class RedisSessionManager extends ManagerBase implements Lifecycle {
4360
protected String password = null;
4461
protected int timeout = Protocol.DEFAULT_TIMEOUT;
4562
protected String sentinelMaster = null;
46-
protected String sentinels = null;
4763
Set<String> sentinelSet = null;
4864

4965
protected Pool<Jedis> connectionPool;
@@ -59,7 +75,7 @@ public class RedisSessionManager extends ManagerBase implements Lifecycle {
5975

6076
protected String serializationStrategyClass = "com.radiadesign.catalina.session.JavaSerializer";
6177

62-
protected boolean saveOnChange = false;
78+
protected EnumSet<SessionPersistPolicy> sessionPersistPoliciesSet = EnumSet.of(SessionPersistPolicy.DEFAULT);
6379

6480
/**
6581
* The lifecycle event support for this component.
@@ -110,16 +126,45 @@ public void setSerializationStrategyClass(String strategy) {
110126
this.serializationStrategyClass = strategy;
111127
}
112128

129+
public String getSessionPersistPolicies() {
130+
StringBuilder policies = new StringBuilder();
131+
for (Iterator<SessionPersistPolicy> iter = this.sessionPersistPoliciesSet.iterator(); iter.hasNext();) {
132+
SessionPersistPolicy policy = iter.next();
133+
policies.append(policy.name());
134+
if (iter.hasNext()) {
135+
policies.append(",");
136+
}
137+
}
138+
return policies.toString();
139+
}
140+
141+
public void setSessionPersistPolicies(String sessionPersistPolicies) {
142+
String[] policyArray = sessionPersistPolicies.split(",");
143+
EnumSet<SessionPersistPolicy> policySet = EnumSet.of(SessionPersistPolicy.DEFAULT);
144+
for (String policyName : policyArray) {
145+
SessionPersistPolicy policy = SessionPersistPolicy.fromName(policyName);
146+
policySet.add(policy);
147+
}
148+
this.sessionPersistPoliciesSet = policySet;
149+
}
150+
113151
public boolean getSaveOnChange() {
114-
return saveOnChange;
152+
return this.sessionPersistPoliciesSet.contains(SessionPersistPolicy.SAVE_ON_CHANGE);
115153
}
116154

117-
public void setSaveOnChange(boolean saveOnChange) {
118-
this.saveOnChange = saveOnChange;
155+
public boolean getAlwaysSaveAfterRequest() {
156+
return this.sessionPersistPoliciesSet.contains(SessionPersistPolicy.ALWAYS_SAVE_AFTER_REQUEST);
119157
}
120158

121159
public String getSentinels() {
122-
return sentinels;
160+
StringBuilder sentinels = new StringBuilder();
161+
for (Iterator<String> iter = this.sentinelSet.iterator(); iter.hasNext();) {
162+
sentinels.append(iter.next());
163+
if (iter.hasNext()) {
164+
sentinels.append(",");
165+
}
166+
}
167+
return sentinels.toString();
123168
}
124169

125170
public void setSentinels(String sentinels) {
@@ -129,8 +174,6 @@ public void setSentinels(String sentinels) {
129174

130175
String[] sentinelArray = sentinels.split(",");
131176
this.sentinelSet = new HashSet<String>(Arrays.asList(sentinelArray));
132-
133-
this.sentinels = sentinels;
134177
}
135178

136179
public Set<String> getSentinelSet() {
@@ -338,11 +381,14 @@ This ensures that the save(session) at the end of the request
338381
currentSessionId.set(sessionId);
339382
currentSessionIsPersisted.set(false);
340383

341-
if (null != session && this.getSaveOnChange()) {
384+
if (null != session) {
342385
try {
343-
save(session);
386+
error = saveInternal(jedis, session, true);
344387
} catch (IOException ex) {
345-
log.error("Error saving newly created session (triggered by saveOnChange=true): " + ex.getMessage());
388+
log.error("Error saving newly created session: " + ex.getMessage());
389+
currentSession.set(null);
390+
currentSessionId.set(null);
391+
session = null;
346392
}
347393
}
348394
} finally {
@@ -455,8 +501,6 @@ public RedisSession loadSessionFromRedis(String id) throws IOException {
455501
if (data == null) {
456502
log.trace("Session " + id + " not found in Redis");
457503
session = null;
458-
} else if (Arrays.equals(NULL_SESSION, data)) {
459-
throw new IllegalStateException("Race condition encountered: attempted to load session[" + id + "] which has been created but not yet serialized.");
460504
} else {
461505
log.trace("Deserializing session " + id + " from Redis");
462506
session = (RedisSession)createEmptySession();
@@ -499,10 +543,25 @@ public void save(Session session, boolean forceSave) throws IOException {
499543
Jedis jedis = null;
500544
Boolean error = true;
501545

546+
try {
547+
jedis = acquireConnection();
548+
error = saveInternal(jedis, session, forceSave);
549+
} catch (IOException e) {
550+
throw e;
551+
} finally {
552+
if (jedis != null) {
553+
returnConnection(jedis, error);
554+
}
555+
}
556+
}
557+
558+
protected boolean saveInternal(Jedis jedis, Session session, boolean forceSave) throws IOException {
559+
Boolean error = true;
560+
502561
try {
503562
log.trace("Saving session " + session + " into Redis");
504563

505-
RedisSession redisSession = (RedisSession) session;
564+
RedisSession redisSession = (RedisSession)session;
506565

507566
if (log.isTraceEnabled()) {
508567
log.trace("Session Contents [" + redisSession.getId() + "]:");
@@ -517,8 +576,6 @@ public void save(Session session, boolean forceSave) throws IOException {
517576
redisSession.resetDirtyTracking();
518577
byte[] binaryId = redisSession.getId().getBytes();
519578

520-
jedis = acquireConnection();
521-
522579
Boolean isCurrentSessionPersisted = this.currentSessionIsPersisted.get();
523580
if (forceSave || sessionIsDirty || (isCurrentSessionPersisted == null || !isCurrentSessionPersisted)) {
524581
jedis.set(binaryId, serializer.serializeFrom(redisSession));
@@ -530,14 +587,14 @@ public void save(Session session, boolean forceSave) throws IOException {
530587
jedis.expire(binaryId, getMaxInactiveInterval());
531588

532589
error = false;
590+
591+
return error;
533592
} catch (IOException e) {
534593
log.error(e.getMessage());
535594

536595
throw e;
537596
} finally {
538-
if (jedis != null) {
539-
returnConnection(jedis, error);
540-
}
597+
return error;
541598
}
542599
}
543600

0 commit comments

Comments
 (0)