This is an automated email from the ASF dual-hosted git repository. lukaszlenart pushed a commit to branch feature/WW-3576-session-map-staleness in repository https://gitbox.apache.org/repos/asf/struts.git
commit 170bf18bf238d8f9d4f073b37ba6a603a65ee99f Author: Lukasz Lenart <[email protected]> AuthorDate: Sun Oct 18 09:04:56 2026 +0200 WW-3576 feat(core): add a sandboxed session map recording background writes Co-Authored-By: Claude Opus 5.5 <[email protected]> --- .../org/apache/struts2/dispatcher/SessionMap.java | 3 + .../interceptor/exec/SandboxedSessionMap.java | 135 +++++++++++++++++++++ .../struts2/interceptor/exec/SessionOperation.java | 31 +++++ .../interceptor/exec/SandboxedSessionMapTest.java | 92 ++++++++++++++ 4 files changed, 261 insertions(+) diff --git a/core/src/main/java/org/apache/struts2/dispatcher/SessionMap.java b/core/src/main/java/org/apache/struts2/dispatcher/SessionMap.java index 8a61c9cdf..d59ea0a79 100644 --- a/core/src/main/java/org/apache/struts2/dispatcher/SessionMap.java +++ b/core/src/main/java/org/apache/struts2/dispatcher/SessionMap.java @@ -48,6 +48,9 @@ public class SessionMap extends AbstractMap<String, Object> implements Serializa protected HttpServletRequest request; + protected SessionMap() { + } + /** * Creates a new session map given a http servlet request. Note, the enumeration of request * attributes will occur when the map entries are asked for. diff --git a/core/src/main/java/org/apache/struts2/interceptor/exec/SandboxedSessionMap.java b/core/src/main/java/org/apache/struts2/interceptor/exec/SandboxedSessionMap.java new file mode 100644 index 000000000..32991ea55 --- /dev/null +++ b/core/src/main/java/org/apache/struts2/interceptor/exec/SandboxedSessionMap.java @@ -0,0 +1,135 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.struts2.interceptor.exec; + +import jakarta.servlet.http.HttpSession; +import org.apache.struts2.dispatcher.SessionMap; + +import java.io.Serial; +import java.util.AbstractMap.SimpleEntry; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Enumeration; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * The session view an execAndWait background process works on: a shallow snapshot of the session taken at + * construction plus an ordered log of the process's own writes. It never touches the live session. + */ +public class SandboxedSessionMap extends SessionMap { + + @Serial + private static final long serialVersionUID = 2907451127739228064L; + + private final transient Map<String, Object> snapshot = new LinkedHashMap<>(); + private final transient Map<String, Object> view = new LinkedHashMap<>(); + private final transient List<SessionOperation> operations = new ArrayList<>(); + private final transient String startSessionId; + private final transient String processName; + + public SandboxedSessionMap(HttpSession session, String processName) { + super(); + this.startSessionId = session.getId(); + this.processName = processName; + Enumeration<String> names = session.getAttributeNames(); + while (names.hasMoreElements()) { + String name = names.nextElement(); + snapshot.put(name, session.getAttribute(name)); + } + view.putAll(snapshot); + } + + public String getStartSessionId() { + return startSessionId; + } + + public String getProcessName() { + return processName; + } + + public synchronized List<SessionOperation> getOperations() { + return Collections.unmodifiableList(new ArrayList<>(operations)); + } + + public synchronized Object getSnapshotValue(String key) { + return snapshot.get(key); + } + + @Override + public synchronized Object get(Object key) { + return view.get(asKey(key)); + } + + @Override + public synchronized Object put(String key, Object value) { + if (value == null) { + return remove(key); + } + operations.add(new SessionOperation.Put(key, value)); + return view.put(key, value); + } + + @Override + public synchronized Object remove(Object key) { + String name = asKey(key); + operations.add(new SessionOperation.Remove(name)); + return view.remove(name); + } + + @Override + public synchronized boolean containsKey(Object key) { + return view.get(asKey(key)) != null; + } + + @Override + public synchronized Set<Entry<String, Object>> entrySet() { + Set<Entry<String, Object>> result = new HashSet<>(); + for (Map.Entry<String, Object> current : view.entrySet()) { + String key = current.getKey(); + result.add(new SimpleEntry<>(key, current.getValue()) { + @Override + public Object setValue(Object value) { + return put(key, value); + } + }); + } + return result; + } + + @Override + public synchronized void clear() { + for (String key : new ArrayList<>(view.keySet())) { + remove(key); + } + } + + @Override + public synchronized void invalidate() { + view.clear(); + operations.add(new SessionOperation.Invalidate()); + } + + private static String asKey(Object key) { + return key != null ? key.toString() : null; + } +} diff --git a/core/src/main/java/org/apache/struts2/interceptor/exec/SessionOperation.java b/core/src/main/java/org/apache/struts2/interceptor/exec/SessionOperation.java new file mode 100644 index 000000000..c07927040 --- /dev/null +++ b/core/src/main/java/org/apache/struts2/interceptor/exec/SessionOperation.java @@ -0,0 +1,31 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.struts2.interceptor.exec; + +public sealed interface SessionOperation { + + record Put(String key, Object value) implements SessionOperation { + } + + record Remove(String key) implements SessionOperation { + } + + record Invalidate() implements SessionOperation { + } +} diff --git a/core/src/test/java/org/apache/struts2/interceptor/exec/SandboxedSessionMapTest.java b/core/src/test/java/org/apache/struts2/interceptor/exec/SandboxedSessionMapTest.java new file mode 100644 index 000000000..e8269a378 --- /dev/null +++ b/core/src/test/java/org/apache/struts2/interceptor/exec/SandboxedSessionMapTest.java @@ -0,0 +1,92 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.struts2.interceptor.exec; + +import junit.framework.TestCase; +import org.springframework.mock.web.MockHttpSession; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class SandboxedSessionMapTest extends TestCase { + + public void testReadsSeeSnapshotPlusOwnWritesAndNeverTheLiveSession() { + MockHttpSession live = new MockHttpSession(); + live.setAttribute("a", "1"); + SandboxedSessionMap sandbox = new SandboxedSessionMap(live, "p"); + + live.setAttribute("a", "changed"); + sandbox.put("b", "2"); + + assertEquals("1", sandbox.get("a")); + assertEquals("2", sandbox.get("b")); + assertNull(live.getAttribute("b")); + assertEquals(live.getId(), sandbox.getStartSessionId()); + assertEquals("p", sandbox.getProcessName()); + assertEquals(List.of(new SessionOperation.Put("b", "2")), sandbox.getOperations()); + } + + public void testRemoveClearAndInvalidateAreLoggedInOrder() { + MockHttpSession live = new MockHttpSession(); + live.setAttribute("a", "1"); + SandboxedSessionMap sandbox = new SandboxedSessionMap(live, "p"); + + sandbox.remove("a"); + sandbox.invalidate(); + sandbox.put("user", "u"); + + assertEquals(List.of(new SessionOperation.Remove("a"), new SessionOperation.Invalidate(), + new SessionOperation.Put("user", "u")), sandbox.getOperations()); + assertEquals(Map.of("user", "u"), new HashMap<>(sandbox)); + assertEquals("1", sandbox.getSnapshotValue("a")); + } + + public void testPutNullIsLoggedAsRemove() { + SandboxedSessionMap sandbox = new SandboxedSessionMap(new MockHttpSession(), "p"); + sandbox.put("a", null); + assertEquals(List.of(new SessionOperation.Remove("a")), sandbox.getOperations()); + } + + public void testEntrySetValueIsLogged() { + MockHttpSession live = new MockHttpSession(); + live.setAttribute("a", "1"); + SandboxedSessionMap sandbox = new SandboxedSessionMap(live, "p"); + + sandbox.entrySet().iterator().next().setValue("2"); + + assertEquals("2", sandbox.get("a")); + assertEquals(List.of(new SessionOperation.Put("a", "2")), sandbox.getOperations()); + } + + public void testClearLogsOneRemovePerKeyPresent() { + MockHttpSession live = new MockHttpSession(); + live.setAttribute("a", "1"); + live.setAttribute("b", "2"); + SandboxedSessionMap sandbox = new SandboxedSessionMap(live, "p"); + + sandbox.clear(); + + assertTrue(sandbox.isEmpty()); + assertEquals(2, sandbox.getOperations().size()); + assertTrue(sandbox.getOperations().contains(new SessionOperation.Remove("a"))); + assertTrue(sandbox.getOperations().contains(new SessionOperation.Remove("b"))); + assertEquals("1", live.getAttribute("a")); + } +}
