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`.