You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@causeway.apache.org by da...@apache.org on 2026/06/04 22:46:48 UTC

(causeway) 29/42: CAUSEWAY-4010: adds default command replay mapping listener

This is an automated email from the ASF dual-hosted git repository.

danhaywood pushed a commit to branch CAUSEWAY-4010
in repository https://gitbox.apache.org/repos/asf/causeway.git

commit 612af06f0994cc334d79adac5824a030677af79f
Author: Dan Haywood <da...@haywood-associates.co.uk>
AuthorDate: Thu Jun 4 18:11:47 2026 +0100

    CAUSEWAY-4010: adds default command replay mapping listener
---
 .../applib/CausewayModuleExtCommandLogApplib.java  |   2 +
 .../spi/CommandReplayMappingListenerDefault.java   |  69 ++++++++++++
 .../CommandReplayMappingListenerDefaultTest.java   | 116 +++++++++++++++++++++
 .../design.md                                      |   6 +-
 .../proposal.md                                    |   2 +-
 .../spec.md                                        |  11 +-
 .../tasks.md                                       |  26 ++---
 7 files changed, 213 insertions(+), 19 deletions(-)

diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/CausewayModuleExtCommandLogApplib.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/CausewayModuleExtCommandLogApplib.java
index e4f03388583..cfcb63e8040 100644
--- a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/CausewayModuleExtCommandLogApplib.java
+++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/CausewayModuleExtCommandLogApplib.java
@@ -63,6 +63,7 @@
 import org.apache.causeway.extensions.commandlog.applib.job.BackgroundCommandsJobControl;
 import org.apache.causeway.extensions.commandlog.applib.job.RunBackgroundCommandsJob;
 import org.apache.causeway.extensions.commandlog.applib.spi.CommandReplayMappingListener;
+import org.apache.causeway.extensions.commandlog.applib.spi.CommandReplayMappingListenerDefault;
 import org.apache.causeway.extensions.commandlog.applib.spi.RunBackgroundCommandsJobListener;
 import org.apache.causeway.extensions.commandlog.applib.subscriber.CommandSubscriberForCommandLog;
 import org.springframework.context.annotation.Bean;
@@ -112,6 +113,7 @@
         // @Component's
         RunBackgroundCommandsJob.class,
         RunBackgroundCommandsJobListener.Noop.class,
+        CommandReplayMappingListenerDefault.BeanFactory.class,
 
         // @Service's
         CommandSubscriberForCommandLog.class,
diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/spi/CommandReplayMappingListenerDefault.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/spi/CommandReplayMappingListenerDefault.java
new file mode 100644
index 00000000000..2512909ba88
--- /dev/null
+++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/spi/CommandReplayMappingListenerDefault.java
@@ -0,0 +1,69 @@
+/*
+ *  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.causeway.extensions.commandlog.applib.spi;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+
+import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import org.apache.causeway.applib.services.bookmark.Bookmark;
+import org.apache.causeway.extensions.commandlog.applib.dom.CommandLogEntry;
+
+/**
+ * Default in-memory implementation of {@link CommandReplayMappingListener}.
+ *
+ * @since 2.1 {@index}
+ */
+public class CommandReplayMappingListenerDefault implements CommandReplayMappingListener {
+
+    private final Map<Bookmark, Bookmark> actualBookmarkByRecordedBookmark = new HashMap<>();
+
+    @Override
+    public Optional<Bookmark> remap(
+            final CommandLogEntry commandLogEntry,
+            final Bookmark recordedBookmark) {
+        return Optional.ofNullable(actualBookmarkByRecordedBookmark.get(recordedBookmark));
+    }
+
+    @Override
+    public void onReplayResultMapped(
+            final Bookmark recordedResult,
+            final Bookmark actualResult,
+            final CommandLogEntry commandLogEntry) {
+        if(!recordedResult.equals(actualResult)) {
+            actualBookmarkByRecordedBookmark.put(recordedResult, actualResult);
+        }
+    }
+
+    @Configuration(proxyBeanMethods = false)
+    public static class BeanFactory {
+
+        @Bean
+        @ConditionalOnMissingBean(CommandReplayMappingListener.class)
+        CommandReplayMappingListener commandReplayMappingListenerDefault() {
+            return new CommandReplayMappingListenerDefault();
+        }
+
+    }
+
+}
diff --git a/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/spi/CommandReplayMappingListenerDefaultTest.java b/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/spi/CommandReplayMappingListenerDefaultTest.java
new file mode 100644
index 00000000000..862e0a47935
--- /dev/null
+++ b/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/spi/CommandReplayMappingListenerDefaultTest.java
@@ -0,0 +1,116 @@
+/*
+ *  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.causeway.extensions.commandlog.applib.spi;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.context.annotation.AnnotationConfigApplicationContext;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import org.apache.causeway.applib.services.bookmark.Bookmark;
+import org.apache.causeway.extensions.commandlog.applib.dom.CommandLogEntry;
+
+class CommandReplayMappingListenerDefaultTest {
+
+    @Test
+    void records_result_mapping_and_returns_it_from_remap() {
+        CommandReplayMappingListenerDefault listener = new CommandReplayMappingListenerDefault();
+        CommandLogEntry commandLogEntry = mock(CommandLogEntry.class);
+        Bookmark recordedResult = Bookmark.forLogicalTypeNameAndIdentifier("demoInvoice", "1");
+        Bookmark actualResult = Bookmark.forLogicalTypeNameAndIdentifier("demoInvoice", "2");
+
+        listener.onReplayResultMapped(recordedResult, actualResult, commandLogEntry);
+
+        assertThat(listener.remap(commandLogEntry, recordedResult)).contains(actualResult);
+    }
+
+    @Test
+    void equal_recorded_and_actual_bookmark_is_not_recorded() {
+        CommandReplayMappingListenerDefault listener = new CommandReplayMappingListenerDefault();
+        CommandLogEntry commandLogEntry = mock(CommandLogEntry.class);
+        Bookmark recordedResult = Bookmark.forLogicalTypeNameAndIdentifier("demoInvoice", "1");
+
+        listener.onReplayResultMapped(recordedResult, recordedResult, commandLogEntry);
+
+        assertThat(listener.remap(commandLogEntry, recordedResult)).isEmpty();
+    }
+
+    @Test
+    void repeated_recorded_bookmark_mapping_uses_latest_actual_bookmark() {
+        CommandReplayMappingListenerDefault listener = new CommandReplayMappingListenerDefault();
+        CommandLogEntry commandLogEntry = mock(CommandLogEntry.class);
+        Bookmark recordedResult = Bookmark.forLogicalTypeNameAndIdentifier("demoInvoice", "1");
+        Bookmark firstActualResult = Bookmark.forLogicalTypeNameAndIdentifier("demoInvoice", "2");
+        Bookmark secondActualResult = Bookmark.forLogicalTypeNameAndIdentifier("demoInvoice", "3");
+
+        listener.onReplayResultMapped(recordedResult, firstActualResult, commandLogEntry);
+        listener.onReplayResultMapped(recordedResult, secondActualResult, commandLogEntry);
+
+        assertThat(listener.remap(commandLogEntry, recordedResult)).contains(secondActualResult);
+    }
+
+    @Test
+    void unmapped_bookmark_returns_no_replacement() {
+        CommandReplayMappingListenerDefault listener = new CommandReplayMappingListenerDefault();
+        CommandLogEntry commandLogEntry = mock(CommandLogEntry.class);
+        Bookmark recordedBookmark = Bookmark.forLogicalTypeNameAndIdentifier("demoInvoice", "1");
+
+        assertThat(listener.remap(commandLogEntry, recordedBookmark)).isEmpty();
+    }
+
+    @Test
+    void autoconfiguration_creates_default_listener_when_missing() {
+        try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(
+                CommandReplayMappingListenerDefault.BeanFactory.class)) {
+
+            assertThat(context.getBean(CommandReplayMappingListener.class))
+                    .isInstanceOf(CommandReplayMappingListenerDefault.class);
+        }
+    }
+
+    @Test
+    void autoconfiguration_backs_off_when_custom_listener_exists() {
+        try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(
+                CustomListenerConfiguration.class,
+                CommandReplayMappingListenerDefault.BeanFactory.class)) {
+
+            assertThat(context.getBeansOfType(CommandReplayMappingListener.class))
+                    .hasSize(1)
+                    .containsKey("customCommandReplayMappingListener");
+            assertThat(context.getBean(CommandReplayMappingListener.class))
+                    .isSameAs(CustomListenerConfiguration.CUSTOM_LISTENER);
+        }
+    }
+
+    @Configuration(proxyBeanMethods = false)
+    static class CustomListenerConfiguration {
+
+        static final CommandReplayMappingListener CUSTOM_LISTENER = new CommandReplayMappingListener() {};
+
+        @Bean
+        CommandReplayMappingListener customCommandReplayMappingListener() {
+            return CUSTOM_LISTENER;
+        }
+
+    }
+
+}
diff --git a/openspec/changes/default-command-replay-mapping-listener/design.md b/openspec/changes/default-command-replay-mapping-listener/design.md
index 62be40e473c..dd100090866 100644
--- a/openspec/changes/default-command-replay-mapping-listener/design.md
+++ b/openspec/changes/default-command-replay-mapping-listener/design.md
@@ -27,8 +27,10 @@ Today the SPI provides the hooks, but applications must supply their own listene
   An alternative was to add a separate mapping store service, but that would add indirection without current persistence or distribution requirements.
 
 - Use a simple in-memory `HashMap<Bookmark, Bookmark>` keyed by the recorded bookmark and valued by the actual replay bookmark.
-  `onReplayResultMapped(...)` will update the map, and `remap(...)` will return the mapped actual bookmark when present.
-  An alternative was to use a concurrent map, but command replay is expected to use this listener in a straightforward replay flow and the requested implementation explicitly calls for a simple hashmap.
+  `onReplayResultMapped(...)` will update the map only when the actual bookmark differs from the recorded bookmark, and `remap(...)` will return the mapped actual bookmark when present.
+  Equal recorded and actual bookmarks do not need saved mappings because replay can keep using the recorded bookmark unchanged.
+  An alternative was to store identity mappings too, but that would grow the map without changing later remapping behavior.
+  Another alternative was to use a concurrent map, but command replay is expected to use this listener in a straightforward replay flow and the requested implementation explicitly calls for a simple hashmap.
 
 - Register the default listener with Spring Boot autoconfiguration guarded by `@ConditionalOnMissingBean(CommandReplayMappingListener.class)`.
   This lets applications override the default by declaring their own listener bean while giving simpler applications out-of-the-box remapping behavior.
diff --git a/openspec/changes/default-command-replay-mapping-listener/proposal.md b/openspec/changes/default-command-replay-mapping-listener/proposal.md
index 85575dc94b9..27b6faae79a 100644
--- a/openspec/changes/default-command-replay-mapping-listener/proposal.md
+++ b/openspec/changes/default-command-replay-mapping-listener/proposal.md
@@ -6,7 +6,7 @@ Without a default implementation, each application must repeat the same simple i
 ## What Changes
 
 - Add a default command replay mapping listener implementation backed by a simple in-memory `HashMap` from recorded bookmarks to actual replay result bookmarks.
-- Record mappings when replay reports `onReplayResultMapped(...)` and consult the recorded-to-actual map from `remap(...)` before command execution.
+- Record mappings when replay reports `onReplayResultMapped(...)` and the actual bookmark differs from the recorded bookmark, then consult the recorded-to-actual map from `remap(...)` before command execution.
 - Register the default listener through Spring Boot autoconfiguration only when no other `CommandReplayMappingListener` bean is present.
 - Leave existing custom listener implementations in control by ensuring the default bean is conditional on missing bean.
 
diff --git a/openspec/changes/default-command-replay-mapping-listener/specs/default-command-replay-mapping-listener/spec.md b/openspec/changes/default-command-replay-mapping-listener/specs/default-command-replay-mapping-listener/spec.md
index 1110bea6bb6..aa0fa5df820 100644
--- a/openspec/changes/default-command-replay-mapping-listener/specs/default-command-replay-mapping-listener/spec.md
+++ b/openspec/changes/default-command-replay-mapping-listener/specs/default-command-replay-mapping-listener/spec.md
@@ -2,13 +2,18 @@
 
 ### Requirement: Default replay mapping listener records result mappings
 The system SHALL provide a default command replay mapping listener that records each replay result mapping by recorded result bookmark.
-When replay notifies the listener with a recorded result bookmark and an actual result bookmark, the listener SHALL remember the actual bookmark for the recorded bookmark.
-When the same recorded bookmark is notified more than once, the listener SHALL use the most recent actual bookmark for later remapping.
+When replay notifies the listener with a recorded result bookmark and a different actual result bookmark, the listener SHALL remember the actual bookmark for the recorded bookmark.
+When replay notifies the listener with equal recorded and actual result bookmarks, the listener MUST NOT remember a mapping for that notification.
+When the same recorded bookmark is notified more than once with different actual result bookmarks, the listener SHALL use the most recent actual bookmark for later remapping.
 
-#### Scenario: Result mapping is recorded
+#### Scenario: Different result mapping is recorded
 - **WHEN** command replay notifies the default listener that recorded result bookmark `demoInvoice:1` mapped to actual result bookmark `demoInvoice:2`
 - **THEN** the default listener records `demoInvoice:2` as the actual bookmark for `demoInvoice:1`
 
+#### Scenario: Equal result mapping is not recorded
+- **WHEN** command replay notifies the default listener that recorded result bookmark `demoInvoice:1` mapped to actual result bookmark `demoInvoice:1`
+- **THEN** the default listener records no mapping for `demoInvoice:1`
+
 #### Scenario: Result mapping is replaced
 - **WHEN** command replay notifies the default listener that recorded result bookmark `demoInvoice:1` mapped to actual result bookmark `demoInvoice:2`
 - **AND** command replay later notifies the default listener that recorded result bookmark `demoInvoice:1` mapped to actual result bookmark `demoInvoice:3`
diff --git a/openspec/changes/default-command-replay-mapping-listener/tasks.md b/openspec/changes/default-command-replay-mapping-listener/tasks.md
index 423344459d8..a481c0d8efd 100644
--- a/openspec/changes/default-command-replay-mapping-listener/tasks.md
+++ b/openspec/changes/default-command-replay-mapping-listener/tasks.md
@@ -1,24 +1,24 @@
 ## 1. Default Listener Implementation
 
-- [ ] 1.1 Add a default `CommandReplayMappingListener` implementation in the command log applib that stores recorded-to-actual mappings in a `HashMap<Bookmark, Bookmark>`.
-- [ ] 1.2 Implement `onReplayResultMapped(...)` to store or replace the actual bookmark for the recorded result bookmark.
-- [ ] 1.3 Implement `remap(...)` to return the remembered actual bookmark for a recorded bookmark, or `Optional.empty()` when none exists.
-- [ ] 1.4 Keep the implementation bookmark-based and avoid resolving recorded or actual bookmarks to domain objects.
+- [x] 1.1 Add a default `CommandReplayMappingListener` implementation in the command log applib that stores recorded-to-actual mappings in a `HashMap<Bookmark, Bookmark>`.
+- [x] 1.2 Implement `onReplayResultMapped(...)` to store or replace the actual bookmark for the recorded result bookmark.
+- [x] 1.3 Implement `remap(...)` to return the remembered actual bookmark for a recorded bookmark, or `Optional.empty()` when none exists.
+- [x] 1.4 Keep the implementation bookmark-based and avoid resolving recorded or actual bookmarks to domain objects.
 
 ## 2. Autoconfiguration
 
-- [ ] 2.1 Register the default listener as a Spring bean from the command log applib module configuration.
-- [ ] 2.2 Guard the bean with `@ConditionalOnMissingBean(CommandReplayMappingListener.class)` so custom listener beans replace the default.
-- [ ] 2.3 Ensure the autoconfigured listener is available to the existing replay listener collection used by `ReplayContext`.
+- [x] 2.1 Register the default listener as a Spring bean from the command log applib module configuration.
+- [x] 2.2 Guard the bean with `@ConditionalOnMissingBean(CommandReplayMappingListener.class)` so custom listener beans replace the default.
+- [x] 2.3 Ensure the autoconfigured listener is available to the existing replay listener collection used by `ReplayContext`.
 
 ## 3. Tests
 
-- [ ] 3.1 Add unit tests proving the default listener records result mappings and returns them from `remap(...)`.
-- [ ] 3.2 Add a unit test proving a repeated recorded bookmark mapping uses the latest actual bookmark.
-- [ ] 3.3 Add a unit test proving an unmapped bookmark returns no replacement.
-- [ ] 3.4 Add Spring context tests proving the default listener bean is created when missing and backs off when a custom listener bean exists.
+- [x] 3.1 Add unit tests proving the default listener records result mappings and returns them from `remap(...)`.
+- [x] 3.2 Add a unit test proving a repeated recorded bookmark mapping uses the latest actual bookmark.
+- [x] 3.3 Add a unit test proving an unmapped bookmark returns no replacement.
+- [x] 3.4 Add Spring context tests proving the default listener bean is created when missing and backs off when a custom listener bean exists.
 
 ## 4. Verification
 
-- [ ] 4.1 Run the command log applib test suite for the new listener and autoconfiguration coverage.
-- [ ] 4.2 Run `openspec validate --change default-command-replay-mapping-listener --strict`.
+- [x] 4.1 Run the command log applib test suite for the new listener and autoconfiguration coverage.
+- [x] 4.2 Run `openspec validate --change default-command-replay-mapping-listener --strict`.