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"));
+    }
+}

Reply via email to