From c908445dea704808cfa1f75d2c9aea62082a5c97 Mon Sep 17 00:00:00 2001 From: Stefan Mirkovic Date: Mon, 20 Apr 2026 19:21:45 +0200 Subject: [PATCH 1/7] AF5 model inspection - expose AF5 entity inspection over RSocket --- .../axoniq/platform/framework/api/Routes.kt | 7 + .../framework/api/clientIdentification.kt | 2 + .../axoniq/platform/framework/api/modelApi.kt | 71 +++ ...AxoniqPlatformModelInspectionEnhancer.java | 55 +++ .../RSocketModelInspectionResponder.kt | 434 ++++++++++++++++++ ...common.configuration.ConfigurationEnhancer | 3 +- .../framework/client/SetupPayloadCreator.kt | 15 +- 7 files changed, 585 insertions(+), 2 deletions(-) create mode 100644 framework-client-api/src/main/java/io/axoniq/platform/framework/api/modelApi.kt create mode 100644 framework-client-eventsourcing/src/main/java/io/axoniq/platform/framework/eventsourcing/AxoniqPlatformModelInspectionEnhancer.java create mode 100644 framework-client-eventsourcing/src/main/java/io/axoniq/platform/framework/eventsourcing/RSocketModelInspectionResponder.kt diff --git a/framework-client-api/src/main/java/io/axoniq/platform/framework/api/Routes.kt b/framework-client-api/src/main/java/io/axoniq/platform/framework/api/Routes.kt index 558359c..8b92dcd 100644 --- a/framework-client-api/src/main/java/io/axoniq/platform/framework/api/Routes.kt +++ b/framework-client-api/src/main/java/io/axoniq/platform/framework/api/Routes.kt @@ -75,6 +75,13 @@ object Routes { const val ENTITY_STATE_AT_SEQUENCE = "entity-state-at-sequence" } + object Model { + const val REGISTERED_ENTITIES = "model-registered-entities" + const val DOMAIN_EVENTS = "model-domain-events" + const val ENTITY_STATE_AT_SEQUENCE = "model-entity-state-at-sequence" + const val REPLAY_TIMELINE = "model-replay-timeline" + } + object MessageFlow { const val STATS = "message-flow-stats" } diff --git a/framework-client-api/src/main/java/io/axoniq/platform/framework/api/clientIdentification.kt b/framework-client-api/src/main/java/io/axoniq/platform/framework/api/clientIdentification.kt index e108977..92adc2a 100644 --- a/framework-client-api/src/main/java/io/axoniq/platform/framework/api/clientIdentification.kt +++ b/framework-client-api/src/main/java/io/axoniq/platform/framework/api/clientIdentification.kt @@ -112,6 +112,8 @@ data class SupportedFeatures( val clientStatusUpdates: Boolean? = false, /* Whether the application has the entitlement manager configured, allowing it to receive licenses */ val licenseEntitlement: Boolean? = false, + /* Whether the client supports model inspection (AF5 StateManager-based entity inspection). */ + val modelInspection: Boolean? = false, ) data class Versions( diff --git a/framework-client-api/src/main/java/io/axoniq/platform/framework/api/modelApi.kt b/framework-client-api/src/main/java/io/axoniq/platform/framework/api/modelApi.kt new file mode 100644 index 0000000..4e2b7e1 --- /dev/null +++ b/framework-client-api/src/main/java/io/axoniq/platform/framework/api/modelApi.kt @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2022-2026. AxonIQ B.V. + * + * Licensed 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 io.axoniq.platform.framework.api + +data class RegisteredEntitiesResult( + val entities: List +) + +data class RegisteredEntityInfo( + val entityType: String, + val idTypes: List, +) + +data class ModelDomainEventsQuery( + val entityType: String, + val entityId: String, + val page: Int = 0, + val pageSize: Int = 10, +) + +data class ModelEntityStateAtSequenceQuery( + val entityType: String, + val entityId: String, + val maxSequenceNumber: Long = 0, +) + +data class ModelTimelineQuery( + val entityType: String, + val entityId: String, + val offset: Int = 0, + val limit: Int = 100, +) + +data class ModelTimelineResult( + val entityType: String, + val entityId: String, + val entries: List, + val offset: Int = 0, + val totalEvents: Int, + val truncated: Boolean, +) + +data class ModelTimelineEntry( + val sequenceNumber: Long, + /** + * ISO-8601 formatted timestamp (from [java.time.Instant.toString]). + * String is used here — instead of [java.time.Instant] — to avoid ambiguity in how + * the different serializers (CBOR on the RSocket leg, Jackson on the query-handler leg) + * encode the Instant: some emit an epoch-seconds number, which the frontend would + * then incorrectly treat as milliseconds. + */ + val timestamp: String, + val eventType: String, + val eventPayload: String?, + val stateBefore: String?, + val stateAfter: String?, +) diff --git a/framework-client-eventsourcing/src/main/java/io/axoniq/platform/framework/eventsourcing/AxoniqPlatformModelInspectionEnhancer.java b/framework-client-eventsourcing/src/main/java/io/axoniq/platform/framework/eventsourcing/AxoniqPlatformModelInspectionEnhancer.java new file mode 100644 index 0000000..ef664e2 --- /dev/null +++ b/framework-client-eventsourcing/src/main/java/io/axoniq/platform/framework/eventsourcing/AxoniqPlatformModelInspectionEnhancer.java @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2022-2026. AxonIQ B.V. + * + * Licensed 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 io.axoniq.platform.framework.eventsourcing; + +import io.axoniq.platform.framework.AxoniqPlatformConfigurerEnhancer; +import io.axoniq.platform.framework.client.RSocketHandlerRegistrar; +import org.axonframework.common.configuration.ComponentDefinition; +import org.axonframework.common.configuration.ComponentRegistry; +import org.axonframework.common.configuration.ConfigurationEnhancer; +import org.axonframework.common.lifecycle.Phase; +import org.axonframework.eventsourcing.eventstore.EventStorageEngine; +import org.axonframework.modelling.StateManager; + +/** + * Enhancer that registers the {@link RSocketModelInspectionResponder} when both + * {@link StateManager} and {@link EventStorageEngine} are available (AF5 applications). + */ +public class AxoniqPlatformModelInspectionEnhancer implements ConfigurationEnhancer { + + @Override + public void enhance(ComponentRegistry registry) { + if (!registry.hasComponent(StateManager.class) + || !registry.hasComponent(EventStorageEngine.class) + || !registry.hasComponent(RSocketHandlerRegistrar.class)) { + return; + } + + registry.registerComponent(ComponentDefinition + .ofType(RSocketModelInspectionResponder.class) + .withBuilder(c -> new RSocketModelInspectionResponder( + c.getComponent(StateManager.class), + c.getComponent(EventStorageEngine.class), + c.getComponent(RSocketHandlerRegistrar.class))) + .onStart(Phase.EXTERNAL_CONNECTIONS, RSocketModelInspectionResponder::start)); + } + + @Override + public int order() { + return AxoniqPlatformConfigurerEnhancer.PLATFORM_ENHANCER_ORDER + 1; + } +} diff --git a/framework-client-eventsourcing/src/main/java/io/axoniq/platform/framework/eventsourcing/RSocketModelInspectionResponder.kt b/framework-client-eventsourcing/src/main/java/io/axoniq/platform/framework/eventsourcing/RSocketModelInspectionResponder.kt new file mode 100644 index 0000000..868fe62 --- /dev/null +++ b/framework-client-eventsourcing/src/main/java/io/axoniq/platform/framework/eventsourcing/RSocketModelInspectionResponder.kt @@ -0,0 +1,434 @@ +/* + * Copyright (c) 2022-2026. AxonIQ B.V. + * + * Licensed 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 io.axoniq.platform.framework.eventsourcing + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.SerializationFeature +import com.fasterxml.jackson.databind.node.ObjectNode +import io.axoniq.platform.framework.api.* +import io.axoniq.platform.framework.client.RSocketHandlerRegistrar +import org.axonframework.eventsourcing.eventstore.EventStorageEngine +import org.axonframework.eventsourcing.eventstore.SourcingCondition +import org.axonframework.messaging.eventhandling.TerminalEventMessage +import org.axonframework.messaging.eventstreaming.EventCriteria +import org.axonframework.messaging.eventstreaming.Tag +import org.axonframework.modelling.StateManager +import org.slf4j.LoggerFactory + +open class RSocketModelInspectionResponder( + private val stateManager: StateManager, + private val eventStorageEngine: EventStorageEngine, + private val registrar: RSocketHandlerRegistrar +) { + private val logger = LoggerFactory.getLogger(this::class.java) + private val objectMapper = ObjectMapper().apply { + findAndRegisterModules() + disable(SerializationFeature.FAIL_ON_EMPTY_BEANS) + } + + fun start() { + registrar.registerHandlerWithoutPayload( + Routes.Model.REGISTERED_ENTITIES, + this::handleRegisteredEntities + ) + registrar.registerHandlerWithPayload( + Routes.Model.DOMAIN_EVENTS, + ModelDomainEventsQuery::class.java, + this::handleDomainEvents + ) + registrar.registerHandlerWithPayload( + Routes.Model.ENTITY_STATE_AT_SEQUENCE, + ModelEntityStateAtSequenceQuery::class.java, + this::handleEntityStateAtSequence + ) + registrar.registerHandlerWithPayload( + Routes.Model.REPLAY_TIMELINE, + ModelTimelineQuery::class.java, + this::handleTimelineReplay + ) + } + + /** + * Resolves the tag key for a given entity type. Checks in order: + * 1. Spring's @EventSourced annotation (directly or as meta-annotation) with non-empty tagKey + * 2. Axon's @EventSourcedEntity annotation (directly or as meta-annotation) with non-empty tagKey + * 3. Fallback to the entity class's simple name (consistent with AnnotationBasedEventCriteriaResolver) + */ + private fun resolveTagKey(entityTypeName: String): String { + return try { + val entityClass = Class.forName(entityTypeName) + + // Walk all annotations on the class (including meta-annotations) and look for any with a non-empty tagKey + val tagKey = findTagKeyInAnnotations(entityClass) + if (!tagKey.isNullOrEmpty()) { + return tagKey + } + + // Fallback: simple name of the entity class + entityClass.simpleName + } catch (e: ClassNotFoundException) { + logger.warn("Could not resolve entity class [{}], using last segment as tag key", entityTypeName) + entityTypeName.substringAfterLast('.') + } + } + + /** + * Recursively searches annotations on the class for any annotation that declares a non-empty `tagKey` attribute. + * Supports both Spring's @EventSourced (which uses @AliasFor) and Axon's @EventSourcedEntity. + */ + private fun findTagKeyInAnnotations(entityClass: Class<*>): String? { + val visited = mutableSetOf>() + for (annotation in entityClass.annotations) { + val result = findTagKeyInAnnotation(annotation, visited) + if (!result.isNullOrEmpty()) { + return result + } + } + return null + } + + private fun findTagKeyInAnnotation(annotation: Annotation, visited: MutableSet>): String? { + val annotationType = annotation.annotationClass.java + if (!visited.add(annotationType)) { + return null + } + // Skip standard Java annotations + if (annotationType.name.startsWith("java.") || annotationType.name.startsWith("kotlin.")) { + return null + } + // Check if this annotation itself has a tagKey attribute + try { + val tagKeyMethod = annotationType.getMethod("tagKey") + val value = tagKeyMethod.invoke(annotation) as? String + if (!value.isNullOrEmpty()) { + logger.debug("Found tagKey [{}] on annotation [{}]", value, annotationType.name) + return value + } + } catch (_: NoSuchMethodException) { + // This annotation doesn't have a tagKey attribute + } catch (e: Exception) { + logger.debug("Error reading tagKey from annotation [{}]", annotationType.name, e) + } + // Recursively check meta-annotations + for (metaAnnotation in annotationType.annotations) { + val result = findTagKeyInAnnotation(metaAnnotation, visited) + if (!result.isNullOrEmpty()) { + return result + } + } + return null + } + + /** + * Extracts a human-readable type name for the event. Events read directly from the + * [EventStorageEngine] often have a raw byte[] payload whose `payloadType()` returns `[B`. + * In that case the proper event type is available via `message.type().name()`. + */ + private fun extractPayloadTypeName(message: org.axonframework.messaging.eventhandling.EventMessage): String { + return try { + // Prefer the qualified MessageType if present (AF5 way) + message.type()?.name() ?: message.payloadType().name + } catch (e: Exception) { + message.payloadType().name + } + } + + /** + * Converts the event payload to a String. When reading directly from [EventStorageEngine], + * payloads are usually raw byte[] containing JSON or CBOR. We try UTF-8 decoding first + * (works for JSON), falling back to Jackson serialization for typed payloads. + */ + private fun extractPayloadAsString(message: org.axonframework.messaging.eventhandling.EventMessage): String? { + val payload = message.payload() ?: return null + return when (payload) { + is ByteArray -> try { + String(payload, Charsets.UTF_8) + } catch (e: Exception) { + payload.toString() + } + is String -> payload + else -> try { + objectMapper.writeValueAsString(payload) + } catch (e: Exception) { + payload.toString() + } + } + } + + private fun handleRegisteredEntities(): RegisteredEntitiesResult { + logger.debug("Handling Axoniq Platform MODEL_REGISTERED_ENTITIES query") + val entities = stateManager.registeredEntities().map { entityType -> + val idTypes = stateManager.registeredIdsFor(entityType) + RegisteredEntityInfo( + entityType = entityType.name, + idTypes = idTypes.map { it.name } + ) + } + return RegisteredEntitiesResult(entities = entities) + } + + private fun handleDomainEvents(query: ModelDomainEventsQuery): DomainEventsResult { + logger.info("Handling Axoniq Platform MODEL_DOMAIN_EVENTS query for entity type [{}] id [{}]", + query.entityType, query.entityId) + + val tagKey = resolveTagKey(query.entityType) + logger.info("Resolved tag key [{}] for entity type [{}]", tagKey, query.entityType) + val criteria = EventCriteria.havingTags(Tag.of(tagKey, query.entityId)) + val condition = SourcingCondition.conditionFor(criteria) + + // Use reduce() which returns a CompletableFuture that completes when the stream is done. + // MessageStream is asynchronous by design; reduce is the recommended way to fully consume a finite stream. + val stream = eventStorageEngine.source(condition) + val allEvents = try { + stream.reduce(mutableListOf()) { acc, entry -> + val message = entry.message() + // Skip the terminal marker that EventStorageEngine.source() always appends at the end + if (message != null && message !is TerminalEventMessage) { + acc.add(DomainEvent( + sequenceNumber = acc.size.toLong(), + timestamp = message.timestamp(), + payloadType = extractPayloadTypeName(message), + payload = extractPayloadAsString(message) + )) + } + acc + }.get() + } catch (e: Exception) { + logger.error("Error while sourcing events for entity type [{}] id [{}]", query.entityType, query.entityId, e) + mutableListOf() + } + + logger.info("Sourced [{}] events for entity type [{}] id [{}] with tag [{}={}]", + allEvents.size, query.entityType, query.entityId, tagKey, query.entityId) + + val totalCount = allEvents.size.toLong() + val start = query.page * query.pageSize + val end = minOf(start + query.pageSize, allEvents.size) + val pagedEvents = if (start < allEvents.size) allEvents.subList(start, end) else emptyList() + + return DomainEventsResult( + entityId = query.entityId, + entityType = query.entityType, + domainEvents = pagedEvents, + page = query.page, + pageSize = query.pageSize, + totalCount = totalCount, + ) + } + + private fun handleEntityStateAtSequence(query: ModelEntityStateAtSequenceQuery): EntityStateResult { + logger.info("Handling Axoniq Platform MODEL_ENTITY_STATE_AT_SEQUENCE query for entity type [{}] id [{}] seq [{}]", + query.entityType, query.entityId, query.maxSequenceNumber) + + val tagKey = resolveTagKey(query.entityType) + logger.info("Resolved tag key [{}] for entity type [{}]", tagKey, query.entityType) + val criteria = EventCriteria.havingTags(Tag.of(tagKey, query.entityId)) + val condition = SourcingCondition.conditionFor(criteria) + + // Collect all events up to (and including) the max sequence number + val stream = eventStorageEngine.source(condition) + val collected = try { + stream.reduce(mutableListOf()) { acc, entry -> + val message = entry.message() + // Skip the terminal marker that EventStorageEngine.source() always appends at the end + if (message !is TerminalEventMessage) { + // Include events up to and including the requested sequence number (0-indexed). + // A negative maxSequenceNumber means "all events". + val currentIndex = acc.size.toLong() + if (query.maxSequenceNumber < 0 || currentIndex <= query.maxSequenceNumber) { + acc.add(message.payload()) + } + } + acc + }.get() + } catch (e: Exception) { + logger.error("Error while sourcing events for entity type [{}] id [{}]", query.entityType, query.entityId, e) + mutableListOf() + } + + logger.info("Sourced [{}] events for entity state reconstruction of [{}] id [{}] at seq [{}]", + collected.size, query.entityType, query.entityId, query.maxSequenceNumber) + + val state = collected.lastOrNull()?.let { payload -> + when (payload) { + is ByteArray -> String(payload, Charsets.UTF_8) + is String -> payload + else -> try { + objectMapper.writeValueAsString(payload) + } catch (e: Exception) { + payload.toString() + } + } + } + + return EntityStateResult( + type = query.entityType, + entityId = query.entityId, + // Echo back the requested sequence number so the UI can navigate between sequences correctly + maxSequenceNumber = query.maxSequenceNumber, + state = state, + ) + } + + /** + * Handles a timeline replay query: sources all events for the given entity, then constructs + * a list of [ModelTimelineEntry] capturing the entity's approximated state before and after each event. + * + * State reconstruction strategy (v1): + * Since assembling the real AF5 [org.axonframework.modelling.EntityEvolver] at runtime requires + * numerous core components from the Configuration (ParameterResolverFactory, MessageTypeResolver, + * MessageConverter, EventConverter) and access to the per-entity [AnnotatedEntityMetamodel], this + * initial implementation uses a pragmatic JSON deep-merge approximation: + * - Start with an empty JSON object as the accumulated state. + * - For each event, parse its payload as JSON (if possible) and deep-merge its fields into the + * accumulator. Primitive values overwrite, nested objects recurse, arrays replace. + * This is NOT the exact domain state that the framework would load via [StateManager.loadEntity], + * but it is visually useful for showing how fields evolve over time and supports diff rendering. + * A follow-up can replace this with true EntityEvolver-based reconstruction once we have a clean + * way to obtain the evolver for an arbitrary entity type. + */ + private fun handleTimelineReplay(query: ModelTimelineQuery): ModelTimelineResult { + logger.info("Handling Axoniq Platform MODEL_REPLAY_TIMELINE query for entity type [{}] id [{}] offset [{}] limit [{}]", + query.entityType, query.entityId, query.offset, query.limit) + + val tagKey = resolveTagKey(query.entityType) + logger.info("Resolved tag key [{}] for entity type [{}]", tagKey, query.entityType) + val criteria = EventCriteria.havingTags(Tag.of(tagKey, query.entityId)) + val condition = SourcingCondition.conditionFor(criteria) + + val offset = maxOf(0, query.offset) + val limit = if (query.limit <= 0) 100 else query.limit + // Cap each state string to avoid blowing gRPC / RSocket message size limits. + val maxStateSizeBytes = 10 * 1024 // 10 KB per state snapshot + val entries = mutableListOf() + var totalEvents = 0 + var currentState: JsonNode = objectMapper.createObjectNode() + + val stream = eventStorageEngine.source(condition) + try { + stream.reduce(Unit) { _, entry -> + val message = entry.message() + if (message !is TerminalEventMessage) { + val seq = totalEvents.toLong() + totalEvents++ + // Always evolve state so stateBefore for the first event in the window is correct. + val payloadString = extractPayloadAsString(message) + val incomingNode = parseJsonOrNull(payloadString) + val evolvedState = if (incomingNode != null) { + mergeJsonState(currentState, incomingNode) + } else { + currentState + } + // Only serialize + collect entries inside the [offset, offset + limit) window. + if (seq >= offset && entries.size < limit) { + val stateBeforeJson = serializeJsonNode(currentState) + val stateAfterJson = serializeJsonNode(evolvedState) + entries.add(ModelTimelineEntry( + sequenceNumber = seq, + // ISO-8601 string avoids CBOR/Jackson Instant ambiguity. + timestamp = message.timestamp().toString(), + eventType = extractPayloadTypeName(message), + eventPayload = truncateString(payloadString, maxStateSizeBytes), + stateBefore = truncateString(stateBeforeJson, maxStateSizeBytes), + stateAfter = truncateString(stateAfterJson, maxStateSizeBytes), + )) + } + currentState = evolvedState + } + Unit + }.get() + } catch (e: Exception) { + logger.error("Error while sourcing events for timeline of entity type [{}] id [{}]", + query.entityType, query.entityId, e) + } + + val remainingAfterWindow = maxOf(0, totalEvents - offset - entries.size) + val truncated = remainingAfterWindow > 0 + logger.info("Sourced [{}] events for timeline of [{}] id [{}] (returning [{}] from offset [{}], truncated={})", + totalEvents, query.entityType, query.entityId, entries.size, offset, truncated) + + return ModelTimelineResult( + entityType = query.entityType, + entityId = query.entityId, + entries = entries, + offset = offset, + totalEvents = totalEvents, + truncated = truncated, + ) + } + + /** + * Truncates a string to at most [maxBytes] bytes (UTF-8). Appends a truncation marker if truncated. + */ + private fun truncateString(value: String?, maxBytes: Int): String? { + if (value == null) return null + val bytes = value.toByteArray(Charsets.UTF_8) + if (bytes.size <= maxBytes) return value + return String(bytes, 0, maxBytes, Charsets.UTF_8) + "\n... (truncated)" + } + + /** + * Parses the given string as a JSON tree. Returns null when the input is null/blank or when parsing fails. + */ + private fun parseJsonOrNull(raw: String?): JsonNode? { + if (raw.isNullOrBlank()) return null + return try { + objectMapper.readTree(raw) + } catch (e: Exception) { + null + } + } + + /** + * Serializes a JsonNode to a pretty-printed JSON string. Returns null on failure. + */ + private fun serializeJsonNode(node: JsonNode): String? { + return try { + objectMapper.writeValueAsString(node) + } catch (e: Exception) { + null + } + } + + /** + * Deep-merges [incoming] into [current] and returns a new JsonNode representing the merged state. + * - If both sides are objects, fields are merged recursively (incoming overrides on conflict). + * - If [incoming] is not an object, it replaces [current] entirely. + * - Arrays from [incoming] replace arrays in [current] (no element-level merging). + * Does not mutate the inputs. + */ + private fun mergeJsonState(current: JsonNode, incoming: JsonNode): JsonNode { + if (!current.isObject || !incoming.isObject) { + return incoming + } + val result: ObjectNode = (current as ObjectNode).deepCopy() + val incomingObj = incoming as ObjectNode + val fieldNames = incomingObj.fieldNames() + while (fieldNames.hasNext()) { + val name = fieldNames.next() + val incomingValue = incomingObj.get(name) + val existing = result.get(name) + if (existing != null && existing.isObject && incomingValue.isObject) { + result.set(name, mergeJsonState(existing, incomingValue)) + } else { + result.set(name, incomingValue) + } + } + return result + } +} diff --git a/framework-client-eventsourcing/src/main/resources/META-INF/services/org.axonframework.common.configuration.ConfigurationEnhancer b/framework-client-eventsourcing/src/main/resources/META-INF/services/org.axonframework.common.configuration.ConfigurationEnhancer index 1c386ce..3a153ca 100644 --- a/framework-client-eventsourcing/src/main/resources/META-INF/services/org.axonframework.common.configuration.ConfigurationEnhancer +++ b/framework-client-eventsourcing/src/main/resources/META-INF/services/org.axonframework.common.configuration.ConfigurationEnhancer @@ -14,4 +14,5 @@ # limitations under the License. # -io.axoniq.platform.framework.eventsourcing.AxoniqPlatformEventsourcingConfigurerEnhancer \ No newline at end of file +io.axoniq.platform.framework.eventsourcing.AxoniqPlatformEventsourcingConfigurerEnhancer +io.axoniq.platform.framework.eventsourcing.AxoniqPlatformModelInspectionEnhancer \ No newline at end of file diff --git a/framework-client-messaging/src/main/java/io/axoniq/platform/framework/client/SetupPayloadCreator.kt b/framework-client-messaging/src/main/java/io/axoniq/platform/framework/client/SetupPayloadCreator.kt index cbeff44..c64094c 100644 --- a/framework-client-messaging/src/main/java/io/axoniq/platform/framework/client/SetupPayloadCreator.kt +++ b/framework-client-messaging/src/main/java/io/axoniq/platform/framework/client/SetupPayloadCreator.kt @@ -81,7 +81,8 @@ class SetupPayloadCreator( heartbeat = true, threadDump = true, clientStatusUpdates = true, - licenseEntitlement = hasEntitlementManager() + licenseEntitlement = hasEntitlementManager(), + modelInspection = hasStateManager(), ) ) } @@ -339,6 +340,18 @@ class SetupPayloadCreator( } + /** + * Checks whether a StateManager has been registered, indicating AF5 model inspection support. + */ + private fun hasStateManager(): Boolean { + try { + val stateManagerClass = Class.forName("org.axonframework.modelling.StateManager") + return configuration.hasComponent(stateManagerClass) + } catch (_: ClassNotFoundException) { + return false + } + } + /** * Checks whether the PlatformLicenseSource have been configured, in which case we want updates of licenses from Platform. */ From ee73c81aeec1359865c987d9b338efdbfee85e84 Mon Sep 17 00:00:00 2001 From: Stefan Mirkovic Date: Tue, 21 Apr 2026 16:52:33 +0200 Subject: [PATCH 2/7] AF5 model inspection - support compound ids and multi-tag entities via Axon's CriteriaResolver --- .../axoniq/platform/framework/api/modelApi.kt | 16 ++ ...AxoniqPlatformModelInspectionEnhancer.java | 3 +- .../RSocketModelInspectionResponder.kt | 269 +++++++++++++++++- 3 files changed, 274 insertions(+), 14 deletions(-) diff --git a/framework-client-api/src/main/java/io/axoniq/platform/framework/api/modelApi.kt b/framework-client-api/src/main/java/io/axoniq/platform/framework/api/modelApi.kt index 4e2b7e1..ea34ec3 100644 --- a/framework-client-api/src/main/java/io/axoniq/platform/framework/api/modelApi.kt +++ b/framework-client-api/src/main/java/io/axoniq/platform/framework/api/modelApi.kt @@ -23,6 +23,22 @@ data class RegisteredEntitiesResult( data class RegisteredEntityInfo( val entityType: String, val idTypes: List, + /** + * Structural descriptors of the id class's properties. Empty when the id is a + * "simple" type (String, primitives, UUID, etc.) — in that case the frontend should + * render a single text input. When populated, the id is a compound type (record / + * data class / plain object) and the frontend should render one input per descriptor + * and send the entityId as a JSON object keyed by the descriptor names. + */ + val idFields: List = emptyList(), +) + +data class IdFieldDescriptor( + val name: String, + /** Normalized form-friendly type: "string", "number", "boolean", "uuid", or "object". */ + val type: String, + /** Fully qualified Java type name, useful for diagnostics / future extensions. */ + val javaType: String, ) data class ModelDomainEventsQuery( diff --git a/framework-client-eventsourcing/src/main/java/io/axoniq/platform/framework/eventsourcing/AxoniqPlatformModelInspectionEnhancer.java b/framework-client-eventsourcing/src/main/java/io/axoniq/platform/framework/eventsourcing/AxoniqPlatformModelInspectionEnhancer.java index ef664e2..2d0534e 100644 --- a/framework-client-eventsourcing/src/main/java/io/axoniq/platform/framework/eventsourcing/AxoniqPlatformModelInspectionEnhancer.java +++ b/framework-client-eventsourcing/src/main/java/io/axoniq/platform/framework/eventsourcing/AxoniqPlatformModelInspectionEnhancer.java @@ -44,7 +44,8 @@ public void enhance(ComponentRegistry registry) { .withBuilder(c -> new RSocketModelInspectionResponder( c.getComponent(StateManager.class), c.getComponent(EventStorageEngine.class), - c.getComponent(RSocketHandlerRegistrar.class))) + c.getComponent(RSocketHandlerRegistrar.class), + c)) .onStart(Phase.EXTERNAL_CONNECTIONS, RSocketModelInspectionResponder::start)); } diff --git a/framework-client-eventsourcing/src/main/java/io/axoniq/platform/framework/eventsourcing/RSocketModelInspectionResponder.kt b/framework-client-eventsourcing/src/main/java/io/axoniq/platform/framework/eventsourcing/RSocketModelInspectionResponder.kt index 868fe62..b999e5b 100644 --- a/framework-client-eventsourcing/src/main/java/io/axoniq/platform/framework/eventsourcing/RSocketModelInspectionResponder.kt +++ b/framework-client-eventsourcing/src/main/java/io/axoniq/platform/framework/eventsourcing/RSocketModelInspectionResponder.kt @@ -22,6 +22,9 @@ import com.fasterxml.jackson.databind.SerializationFeature import com.fasterxml.jackson.databind.node.ObjectNode import io.axoniq.platform.framework.api.* import io.axoniq.platform.framework.client.RSocketHandlerRegistrar +import org.axonframework.common.configuration.Configuration +import org.axonframework.eventsourcing.CriteriaResolver +import org.axonframework.eventsourcing.annotation.AnnotationBasedEventCriteriaResolver import org.axonframework.eventsourcing.eventstore.EventStorageEngine import org.axonframework.eventsourcing.eventstore.SourcingCondition import org.axonframework.messaging.eventhandling.TerminalEventMessage @@ -29,11 +32,14 @@ import org.axonframework.messaging.eventstreaming.EventCriteria import org.axonframework.messaging.eventstreaming.Tag import org.axonframework.modelling.StateManager import org.slf4j.LoggerFactory +import java.util.* +import java.util.concurrent.ConcurrentHashMap open class RSocketModelInspectionResponder( private val stateManager: StateManager, private val eventStorageEngine: EventStorageEngine, - private val registrar: RSocketHandlerRegistrar + private val registrar: RSocketHandlerRegistrar, + private val configuration: Configuration ) { private val logger = LoggerFactory.getLogger(this::class.java) private val objectMapper = ObjectMapper().apply { @@ -41,6 +47,14 @@ open class RSocketModelInspectionResponder( disable(SerializationFeature.FAIL_ON_EMPTY_BEANS) } + /** + * Cache of [CriteriaResolver] instances per (entityType, idType) pair. Resolvers are + * stateless and obtaining one via reflection is not free, so caching avoids repeating + * the work on every query. Keyed by class identity so redeploys with different classloaders + * get fresh entries naturally. + */ + private val criteriaResolverCache = ConcurrentHashMap, Class<*>>, CriteriaResolver>() + fun start() { registrar.registerHandlerWithoutPayload( Routes.Model.REGISTERED_ENTITIES, @@ -174,21 +188,254 @@ open class RSocketModelInspectionResponder( logger.debug("Handling Axoniq Platform MODEL_REGISTERED_ENTITIES query") val entities = stateManager.registeredEntities().map { entityType -> val idTypes = stateManager.registeredIdsFor(entityType) + // Prefer the first registered id type for introspection. Entities registered with + // multiple id types (rare) still get a sensible default; the frontend can extend + // this to switch among id types later. + val primaryIdType = idTypes.firstOrNull() RegisteredEntityInfo( entityType = entityType.name, - idTypes = idTypes.map { it.name } + idTypes = idTypes.map { it.name }, + idFields = primaryIdType?.let { describeIdFields(it) } ?: emptyList() ) } return RegisteredEntitiesResult(entities = entities) } + /** + * Returns structural descriptors for the given id class. Empty for "simple" types where + * the frontend should show a single text input (String, primitives, UUID); populated for + * records / data classes / plain objects where each property should get its own input. + */ + private fun describeIdFields(idClass: Class<*>): List { + if (isSimpleIdType(idClass)) { + return emptyList() + } + // Records: use the declared record components in canonical order. + if (idClass.isRecord) { + return idClass.recordComponents.map { component -> + IdFieldDescriptor( + name = component.name, + type = normalizedType(component.type), + javaType = component.type.name + ) + } + } + // Kotlin data classes / POJOs: walk declared fields, skip synthetic/static. + return idClass.declaredFields + .filter { field -> + !java.lang.reflect.Modifier.isStatic(field.modifiers) && + !field.isSynthetic + } + .map { field -> + IdFieldDescriptor( + name = field.name, + type = normalizedType(field.type), + javaType = field.type.name + ) + } + } + + /** + * A "simple" id type is one the frontend can reasonably capture with a single text input. + * Everything else is treated as compound (structured) and gets field-by-field descriptors. + * + * Kotlin `@JvmInline value class` wrappers (e.g. `value class OrderId(val value: String)`) are + * also treated as simple when their underlying property is itself simple — the user sees one + * input and we box the typed value at deserialization time. + */ + private fun isSimpleIdType(idClass: Class<*>): Boolean { + if (idClass.isPrimitive) return true + if (idClass.isEnum) return true + if (isKotlinValueClass(idClass)) { + val underlying = kotlinValueClassUnderlying(idClass) + return underlying != null && isSimpleIdType(underlying) + } + return when (idClass) { + String::class.java, + java.lang.Long::class.java, + java.lang.Integer::class.java, + java.lang.Short::class.java, + java.lang.Byte::class.java, + java.lang.Double::class.java, + java.lang.Float::class.java, + java.lang.Boolean::class.java, + java.lang.Character::class.java, + UUID::class.java, + java.math.BigInteger::class.java, + java.math.BigDecimal::class.java -> true + else -> false + } + } + + /** True if the class is a Kotlin `@JvmInline value class`. */ + private fun isKotlinValueClass(idClass: Class<*>): Boolean { + return idClass.isAnnotationPresent(JvmInline::class.java) + } + + /** + * Returns the underlying backing type of a Kotlin value class, or null when the class + * doesn't expose exactly one instance field (shouldn't happen for well-formed value classes, + * but we stay defensive against synthetic/static noise from compiler plugins). + */ + private fun kotlinValueClassUnderlying(idClass: Class<*>): Class<*>? { + val instanceFields = idClass.declaredFields.filter { f -> + !java.lang.reflect.Modifier.isStatic(f.modifiers) && !f.isSynthetic + } + return instanceFields.singleOrNull()?.type + } + + /** Maps a Java class to a frontend-friendly type label used to choose input widgets. */ + private fun normalizedType(type: Class<*>): String { + if (type == UUID::class.java) return "uuid" + if (type == String::class.java) return "string" + if (type == java.lang.Boolean::class.java || type == java.lang.Boolean.TYPE) return "boolean" + if (type == java.lang.Character::class.java || type == java.lang.Character.TYPE) return "string" + if (Number::class.java.isAssignableFrom(type) || type.isPrimitive) return "number" + return "object" + } + + /** + * Resolves the [EventCriteria] for a given entity query. Instead of hand-crafting a + * single `Tag.of(tagKey, entityId)` (which only works for single-tag entities with + * string ids), we invoke the entity's registered [CriteriaResolver] with a typed id, + * so multi-tag and compound-id entities produce the correct criteria. + * + * Falls back to the legacy single-tag approach only if resolver construction or + * invocation fails, so this stays backward-compatible with edge cases. + */ + private fun resolveCriteria(entityTypeName: String, entityId: String): EventCriteria { + return try { + val entityClass = Class.forName(entityTypeName) + val idClass = stateManager.registeredIdsFor(entityClass).firstOrNull() + ?: return legacyTagCriteria(entityTypeName, entityId) + // Jackson can theoretically return null for an input of `"null"`; the resolver + // signature is @Nonnull so we treat that as an invalid id and fall back. + val typedId = deserializeEntityId(entityId, idClass) + ?: return legacyTagCriteria(entityTypeName, entityId) + val resolver = obtainCriteriaResolver(entityClass, idClass) + // ProcessingContext is declared @Nonnull but the default + // AnnotationBasedEventCriteriaResolver never reads it (verified in AF5 source). + // Custom resolvers that actually rely on it will throw NPE here, which is caught + // by the outer try/catch and falls back to the legacy single-tag path. + resolver.resolve(typedId, null) + } catch (e: Exception) { + logger.warn("CriteriaResolver path failed for entity [{}] id [{}] — falling back to legacy tag lookup: {}", + entityTypeName, entityId, e.message) + legacyTagCriteria(entityTypeName, entityId) + } + } + + private fun legacyTagCriteria(entityTypeName: String, entityId: String): EventCriteria { + val tagKey = resolveTagKey(entityTypeName) + return EventCriteria.havingTags(Tag.of(tagKey, entityId)) + } + + /** + * Parses the incoming [entityId] wire string into the entity's id type. For simple id + * types we parse directly; for compound types the wire format is a JSON object whose + * keys match the id's property names. + */ + private fun deserializeEntityId(entityId: String, idClass: Class<*>): Any? { + val trimmed = entityId.trim() + return when { + idClass == String::class.java -> trimmed + idClass == UUID::class.java -> UUID.fromString(trimmed) + idClass == java.lang.Long::class.java || idClass == java.lang.Long.TYPE -> trimmed.toLong() + idClass == java.lang.Integer::class.java || idClass == java.lang.Integer.TYPE -> trimmed.toInt() + idClass == java.lang.Short::class.java || idClass == java.lang.Short.TYPE -> trimmed.toShort() + idClass == java.lang.Byte::class.java || idClass == java.lang.Byte.TYPE -> trimmed.toByte() + idClass == java.lang.Double::class.java || idClass == java.lang.Double.TYPE -> trimmed.toDouble() + idClass == java.lang.Float::class.java || idClass == java.lang.Float.TYPE -> trimmed.toFloat() + idClass == java.lang.Boolean::class.java || idClass == java.lang.Boolean.TYPE -> trimmed.toBoolean() + idClass == java.math.BigInteger::class.java -> java.math.BigInteger(trimmed) + idClass == java.math.BigDecimal::class.java -> java.math.BigDecimal(trimmed) + idClass.isEnum -> { + @Suppress("UNCHECKED_CAST") + java.lang.Enum.valueOf(idClass as Class>, trimmed) + } + isKotlinValueClass(idClass) -> deserializeValueClass(trimmed, idClass) + else -> objectMapper.readValue(trimmed, idClass) + } + } + + /** + * Deserializes a Kotlin `@JvmInline value class` from its wire form. The frontend sends the + * *underlying* value (e.g. `"abc-123"` for `value class OrderId(val value: String)`), we parse + * it against the underlying type, and box it via the value class's public constructor so the + * framework's [CriteriaResolver] sees the real typed id. + */ + private fun deserializeValueClass(raw: String, idClass: Class<*>): Any? { + val underlying = kotlinValueClassUnderlying(idClass) ?: return null + val underlyingValue = deserializeEntityId(raw, underlying) ?: return null + return try { + idClass.getDeclaredConstructor(underlying) + .apply { isAccessible = true } + .newInstance(underlyingValue) + } catch (e: NoSuchMethodException) { + logger.debug("No public constructor({}) on Kotlin value class [{}]; falling back to Jackson", + underlying.name, idClass.name) + objectMapper.readValue(raw, idClass) + } + } + + /** + * Obtains a [CriteriaResolver] for the given (entityType, idType) pair. Strategy: + * 1. Try to extract the registered resolver from the repository returned by + * [StateManager.repository] via reflection on its private `criteriaResolver` field. + * This honors custom resolvers that an application may have configured. + * 2. Fall back to constructing a fresh [AnnotationBasedEventCriteriaResolver] from + * the available [Configuration], which covers the default annotation-driven setup. + * Results are cached per class pair. + */ + @Suppress("UNCHECKED_CAST") + private fun obtainCriteriaResolver(entityClass: Class<*>, idClass: Class<*>): CriteriaResolver { + return criteriaResolverCache.getOrPut(entityClass to idClass) { + extractResolverFromRepository(entityClass, idClass) + ?: AnnotationBasedEventCriteriaResolver( + entityClass as Class, + idClass as Class, + configuration + ) as CriteriaResolver + } + } + + /** + * Reflects on the repository returned by [StateManager] to extract its `criteriaResolver` + * field. This yields the exact resolver the framework uses at runtime (respecting any + * custom configuration). Returns null when the repository is not an EventSourcingRepository + * or the field cannot be read — the caller then falls back to the annotation-based resolver. + */ + @Suppress("UNCHECKED_CAST") + private fun extractResolverFromRepository(entityClass: Class<*>, idClass: Class<*>): CriteriaResolver? { + return try { + val repository = stateManager.repository(entityClass, idClass) ?: return null + val field = findField(repository.javaClass, "criteriaResolver") ?: return null + field.isAccessible = true + field.get(repository) as? CriteriaResolver + } catch (e: Exception) { + logger.debug("Could not extract CriteriaResolver from repository for [{}]: {}", + entityClass.name, e.message) + null + } + } + + private fun findField(type: Class<*>, name: String): java.lang.reflect.Field? { + var current: Class<*>? = type + while (current != null && current != Any::class.java) { + try { + return current.getDeclaredField(name) + } catch (_: NoSuchFieldException) { + current = current.superclass + } + } + return null + } + private fun handleDomainEvents(query: ModelDomainEventsQuery): DomainEventsResult { logger.info("Handling Axoniq Platform MODEL_DOMAIN_EVENTS query for entity type [{}] id [{}]", query.entityType, query.entityId) - val tagKey = resolveTagKey(query.entityType) - logger.info("Resolved tag key [{}] for entity type [{}]", tagKey, query.entityType) - val criteria = EventCriteria.havingTags(Tag.of(tagKey, query.entityId)) + val criteria = resolveCriteria(query.entityType, query.entityId) val condition = SourcingCondition.conditionFor(criteria) // Use reduce() which returns a CompletableFuture that completes when the stream is done. @@ -213,8 +460,8 @@ open class RSocketModelInspectionResponder( mutableListOf() } - logger.info("Sourced [{}] events for entity type [{}] id [{}] with tag [{}={}]", - allEvents.size, query.entityType, query.entityId, tagKey, query.entityId) + logger.info("Sourced [{}] events for entity type [{}] id [{}]", + allEvents.size, query.entityType, query.entityId) val totalCount = allEvents.size.toLong() val start = query.page * query.pageSize @@ -235,9 +482,7 @@ open class RSocketModelInspectionResponder( logger.info("Handling Axoniq Platform MODEL_ENTITY_STATE_AT_SEQUENCE query for entity type [{}] id [{}] seq [{}]", query.entityType, query.entityId, query.maxSequenceNumber) - val tagKey = resolveTagKey(query.entityType) - logger.info("Resolved tag key [{}] for entity type [{}]", tagKey, query.entityType) - val criteria = EventCriteria.havingTags(Tag.of(tagKey, query.entityId)) + val criteria = resolveCriteria(query.entityType, query.entityId) val condition = SourcingCondition.conditionFor(criteria) // Collect all events up to (and including) the max sequence number @@ -306,9 +551,7 @@ open class RSocketModelInspectionResponder( logger.info("Handling Axoniq Platform MODEL_REPLAY_TIMELINE query for entity type [{}] id [{}] offset [{}] limit [{}]", query.entityType, query.entityId, query.offset, query.limit) - val tagKey = resolveTagKey(query.entityType) - logger.info("Resolved tag key [{}] for entity type [{}]", tagKey, query.entityType) - val criteria = EventCriteria.havingTags(Tag.of(tagKey, query.entityId)) + val criteria = resolveCriteria(query.entityType, query.entityId) val condition = SourcingCondition.conditionFor(criteria) val offset = maxOf(0, query.offset) From c278643326454e186875700c785643f49be7bfc9 Mon Sep 17 00:00:00 2001 From: Stefan Mirkovic Date: Thu, 23 Apr 2026 08:43:31 +0200 Subject: [PATCH 3/7] return back components for client --- ...AxoniqPlatformModelInspectionEnhancer.java | 56 ++ .../RSocketModelInspectionResponder.kt | 687 ++++++++++++++++++ ...common.configuration.ConfigurationEnhancer | 3 +- 3 files changed, 745 insertions(+), 1 deletion(-) create mode 100644 framework-client/src/main/java/io/axoniq/platform/framework/eventsourcing/RSocketModelInspectionResponder.kt diff --git a/framework-client/src/main/java/io/axoniq/platform/framework/eventsourcing/AxoniqPlatformModelInspectionEnhancer.java b/framework-client/src/main/java/io/axoniq/platform/framework/eventsourcing/AxoniqPlatformModelInspectionEnhancer.java index e69de29..2d0534e 100644 --- a/framework-client/src/main/java/io/axoniq/platform/framework/eventsourcing/AxoniqPlatformModelInspectionEnhancer.java +++ b/framework-client/src/main/java/io/axoniq/platform/framework/eventsourcing/AxoniqPlatformModelInspectionEnhancer.java @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2022-2026. AxonIQ B.V. + * + * Licensed 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 io.axoniq.platform.framework.eventsourcing; + +import io.axoniq.platform.framework.AxoniqPlatformConfigurerEnhancer; +import io.axoniq.platform.framework.client.RSocketHandlerRegistrar; +import org.axonframework.common.configuration.ComponentDefinition; +import org.axonframework.common.configuration.ComponentRegistry; +import org.axonframework.common.configuration.ConfigurationEnhancer; +import org.axonframework.common.lifecycle.Phase; +import org.axonframework.eventsourcing.eventstore.EventStorageEngine; +import org.axonframework.modelling.StateManager; + +/** + * Enhancer that registers the {@link RSocketModelInspectionResponder} when both + * {@link StateManager} and {@link EventStorageEngine} are available (AF5 applications). + */ +public class AxoniqPlatformModelInspectionEnhancer implements ConfigurationEnhancer { + + @Override + public void enhance(ComponentRegistry registry) { + if (!registry.hasComponent(StateManager.class) + || !registry.hasComponent(EventStorageEngine.class) + || !registry.hasComponent(RSocketHandlerRegistrar.class)) { + return; + } + + registry.registerComponent(ComponentDefinition + .ofType(RSocketModelInspectionResponder.class) + .withBuilder(c -> new RSocketModelInspectionResponder( + c.getComponent(StateManager.class), + c.getComponent(EventStorageEngine.class), + c.getComponent(RSocketHandlerRegistrar.class), + c)) + .onStart(Phase.EXTERNAL_CONNECTIONS, RSocketModelInspectionResponder::start)); + } + + @Override + public int order() { + return AxoniqPlatformConfigurerEnhancer.PLATFORM_ENHANCER_ORDER + 1; + } +} diff --git a/framework-client/src/main/java/io/axoniq/platform/framework/eventsourcing/RSocketModelInspectionResponder.kt b/framework-client/src/main/java/io/axoniq/platform/framework/eventsourcing/RSocketModelInspectionResponder.kt new file mode 100644 index 0000000..5ab62e0 --- /dev/null +++ b/framework-client/src/main/java/io/axoniq/platform/framework/eventsourcing/RSocketModelInspectionResponder.kt @@ -0,0 +1,687 @@ +/* + * Copyright (c) 2022-2026. AxonIQ B.V. + * + * Licensed 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 io.axoniq.platform.framework.eventsourcing + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.SerializationFeature +import com.fasterxml.jackson.databind.node.ObjectNode +import io.axoniq.platform.framework.api.* +import io.axoniq.platform.framework.client.RSocketHandlerRegistrar +import org.axonframework.common.configuration.Configuration +import org.axonframework.eventsourcing.CriteriaResolver +import org.axonframework.eventsourcing.annotation.AnnotationBasedEventCriteriaResolver +import org.axonframework.eventsourcing.eventstore.EventStorageEngine +import org.axonframework.eventsourcing.eventstore.SourcingCondition +import org.axonframework.messaging.eventhandling.TerminalEventMessage +import org.axonframework.messaging.eventstreaming.EventCriteria +import org.axonframework.messaging.eventstreaming.Tag +import org.axonframework.modelling.StateManager +import org.slf4j.LoggerFactory +import java.util.* +import java.util.concurrent.ConcurrentHashMap + +open class RSocketModelInspectionResponder( + private val stateManager: StateManager, + private val eventStorageEngine: EventStorageEngine, + private val registrar: RSocketHandlerRegistrar, + private val configuration: Configuration +) { + private val logger = LoggerFactory.getLogger(this::class.java) + private val objectMapper = ObjectMapper().apply { + findAndRegisterModules() + disable(SerializationFeature.FAIL_ON_EMPTY_BEANS) + } + + /** + * Cache of [CriteriaResolver] instances per (entityType, idType) pair. Resolvers are + * stateless and obtaining one via reflection is not free, so caching avoids repeating + * the work on every query. Keyed by class identity so redeploys with different classloaders + * get fresh entries naturally. + */ + private val criteriaResolverCache = ConcurrentHashMap, Class<*>>, CriteriaResolver>() + + fun start() { + registrar.registerHandlerWithoutPayload( + Routes.Model.REGISTERED_ENTITIES, + this::handleRegisteredEntities + ) + registrar.registerHandlerWithPayload( + Routes.Model.DOMAIN_EVENTS, + ModelDomainEventsQuery::class.java, + this::handleDomainEvents + ) + registrar.registerHandlerWithPayload( + Routes.Model.ENTITY_STATE_AT_SEQUENCE, + ModelEntityStateAtSequenceQuery::class.java, + this::handleEntityStateAtSequence + ) + registrar.registerHandlerWithPayload( + Routes.Model.REPLAY_TIMELINE, + ModelTimelineQuery::class.java, + this::handleTimelineReplay + ) + } + + /** + * Resolves the tag key for a given entity type. Checks in order: + * 1. Spring's @EventSourced annotation (directly or as meta-annotation) with non-empty tagKey + * 2. Axon's @EventSourcedEntity annotation (directly or as meta-annotation) with non-empty tagKey + * 3. Fallback to the entity class's simple name (consistent with AnnotationBasedEventCriteriaResolver) + */ + private fun resolveTagKey(entityTypeName: String): String { + return try { + val entityClass = Class.forName(entityTypeName) + + // Walk all annotations on the class (including meta-annotations) and look for any with a non-empty tagKey + val tagKey = findTagKeyInAnnotations(entityClass) + if (!tagKey.isNullOrEmpty()) { + return tagKey + } + + // Fallback: simple name of the entity class + entityClass.simpleName + } catch (e: ClassNotFoundException) { + logger.warn("Could not resolve entity class [{}], using last segment as tag key", entityTypeName) + entityTypeName.substringAfterLast('.') + } + } + + /** + * Recursively searches annotations on the class for any annotation that declares a non-empty `tagKey` attribute. + * Supports both Spring's @EventSourced (which uses @AliasFor) and Axon's @EventSourcedEntity. + */ + private fun findTagKeyInAnnotations(entityClass: Class<*>): String? { + val visited = mutableSetOf>() + for (annotation in entityClass.annotations) { + val result = findTagKeyInAnnotation(annotation, visited) + if (!result.isNullOrEmpty()) { + return result + } + } + return null + } + + private fun findTagKeyInAnnotation(annotation: Annotation, visited: MutableSet>): String? { + val annotationType = annotation.annotationClass.java + if (!visited.add(annotationType)) { + return null + } + // Skip standard Java annotations + if (annotationType.name.startsWith("java.") || annotationType.name.startsWith("kotlin.")) { + return null + } + // Check if this annotation itself has a tagKey attribute + try { + val tagKeyMethod = annotationType.getMethod("tagKey") + val value = tagKeyMethod.invoke(annotation) as? String + if (!value.isNullOrEmpty()) { + logger.debug("Found tagKey [{}] on annotation [{}]", value, annotationType.name) + return value + } + } catch (_: NoSuchMethodException) { + // This annotation doesn't have a tagKey attribute + } catch (e: Exception) { + logger.debug("Error reading tagKey from annotation [{}]", annotationType.name, e) + } + // Recursively check meta-annotations + for (metaAnnotation in annotationType.annotations) { + val result = findTagKeyInAnnotation(metaAnnotation, visited) + if (!result.isNullOrEmpty()) { + return result + } + } + return null + } + + /** + * Extracts a human-readable type name for the event. Events read directly from the + * [EventStorageEngine] often have a raw byte[] payload whose `payloadType()` returns `[B`. + * In that case the proper event type is available via `message.type().name()`. + */ + private fun extractPayloadTypeName(message: org.axonframework.messaging.eventhandling.EventMessage): String { + return try { + // Prefer the qualified MessageType if present (AF5 way) + message.type()?.name() ?: message.payloadType().name + } catch (e: Exception) { + message.payloadType().name + } + } + + /** + * Converts the event payload to a String. When reading directly from [EventStorageEngine], + * payloads are usually raw byte[] containing JSON or CBOR. We try UTF-8 decoding first + * (works for JSON), falling back to Jackson serialization for typed payloads. + */ + private fun extractPayloadAsString(message: org.axonframework.messaging.eventhandling.EventMessage): String? { + val payload = message.payload() ?: return null + return when (payload) { + is ByteArray -> try { + String(payload, Charsets.UTF_8) + } catch (e: Exception) { + payload.toString() + } + is String -> payload + else -> try { + objectMapper.writeValueAsString(payload) + } catch (e: Exception) { + payload.toString() + } + } + } + + private fun handleRegisteredEntities(): RegisteredEntitiesResult { + logger.debug("Handling Axoniq Platform MODEL_REGISTERED_ENTITIES query") + val entities = stateManager.registeredEntities().map { entityType -> + val idTypes = stateManager.registeredIdsFor(entityType) + // Prefer the first registered id type for introspection. Entities registered with + // multiple id types (rare) still get a sensible default; the frontend can extend + // this to switch among id types later. + val primaryIdType = idTypes.firstOrNull() + RegisteredEntityInfo( + entityType = entityType.name, + idTypes = idTypes.map { it.name }, + idFields = primaryIdType?.let { describeIdFields(it) } ?: emptyList() + ) + } + return RegisteredEntitiesResult(entities = entities) + } + + /** + * Returns structural descriptors for the given id class. Empty for "simple" types where + * the frontend should show a single text input (String, primitives, UUID); populated for + * records / data classes / plain objects where each property should get its own input. + */ + private fun describeIdFields(idClass: Class<*>): List { + if (isSimpleIdType(idClass)) { + return emptyList() + } + // Records: use the declared record components in canonical order. + if (idClass.isRecord) { + return idClass.recordComponents.map { component -> + IdFieldDescriptor( + name = component.name, + type = normalizedType(component.type), + javaType = component.type.name + ) + } + } + // Kotlin data classes / POJOs: walk declared fields, skip synthetic/static. + return idClass.declaredFields + .filter { field -> + !java.lang.reflect.Modifier.isStatic(field.modifiers) && + !field.isSynthetic + } + .map { field -> + IdFieldDescriptor( + name = field.name, + type = normalizedType(field.type), + javaType = field.type.name + ) + } + } + + /** + * A "simple" id type is one the frontend can reasonably capture with a single text input. + * Everything else is treated as compound (structured) and gets field-by-field descriptors. + * + * Kotlin `@JvmInline value class` wrappers (e.g. `value class OrderId(val value: String)`) are + * also treated as simple when their underlying property is itself simple — the user sees one + * input and we box the typed value at deserialization time. + */ + private fun isSimpleIdType(idClass: Class<*>): Boolean { + if (idClass.isPrimitive) return true + if (idClass.isEnum) return true + if (isKotlinValueClass(idClass)) { + val underlying = kotlinValueClassUnderlying(idClass) + return underlying != null && isSimpleIdType(underlying) + } + return when (idClass) { + String::class.java, + java.lang.Long::class.java, + java.lang.Integer::class.java, + java.lang.Short::class.java, + java.lang.Byte::class.java, + java.lang.Double::class.java, + java.lang.Float::class.java, + java.lang.Boolean::class.java, + java.lang.Character::class.java, + UUID::class.java, + java.math.BigInteger::class.java, + java.math.BigDecimal::class.java -> true + else -> false + } + } + + /** True if the class is a Kotlin `@JvmInline value class`. */ + private fun isKotlinValueClass(idClass: Class<*>): Boolean { + return idClass.isAnnotationPresent(JvmInline::class.java) + } + + /** + * Returns the underlying backing type of a Kotlin value class, or null when the class + * doesn't expose exactly one instance field (shouldn't happen for well-formed value classes, + * but we stay defensive against synthetic/static noise from compiler plugins). + */ + private fun kotlinValueClassUnderlying(idClass: Class<*>): Class<*>? { + val instanceFields = idClass.declaredFields.filter { f -> + !java.lang.reflect.Modifier.isStatic(f.modifiers) && !f.isSynthetic + } + return instanceFields.singleOrNull()?.type + } + + /** Maps a Java class to a frontend-friendly type label used to choose input widgets. */ + private fun normalizedType(type: Class<*>): String { + if (type == UUID::class.java) return "uuid" + if (type == String::class.java) return "string" + if (type == java.lang.Boolean::class.java || type == java.lang.Boolean.TYPE) return "boolean" + if (type == java.lang.Character::class.java || type == java.lang.Character.TYPE) return "string" + if (Number::class.java.isAssignableFrom(type) || type.isPrimitive) return "number" + return "object" + } + + /** + * Resolves the [EventCriteria] for a given entity query. Instead of hand-crafting a + * single `Tag.of(tagKey, entityId)` (which only works for single-tag entities with + * string ids), we invoke the entity's registered [CriteriaResolver] with a typed id, + * so multi-tag and compound-id entities produce the correct criteria. + * + * Falls back to the legacy single-tag approach only if resolver construction or + * invocation fails, so this stays backward-compatible with edge cases. + */ + private fun resolveCriteria(entityTypeName: String, entityId: String): EventCriteria { + return try { + val entityClass = Class.forName(entityTypeName) + val idClass = stateManager.registeredIdsFor(entityClass).firstOrNull() + ?: return legacyTagCriteria(entityTypeName, entityId) + // Jackson can theoretically return null for an input of `"null"`; the resolver + // signature is @Nonnull so we treat that as an invalid id and fall back. + val typedId = deserializeEntityId(entityId, idClass) + ?: return legacyTagCriteria(entityTypeName, entityId) + val resolver = obtainCriteriaResolver(entityClass, idClass) + // ProcessingContext is @NonNull via JSpecify @NullMarked, but the default + // AnnotationBasedEventCriteriaResolver never reads it (verified in AF5 source). + // We bypass Kotlin's nullability check via reflection; custom resolvers that + // actually rely on it will throw NPE, which is caught by the outer try/catch + // and falls back to the legacy single-tag path. + invokeResolveWithNullContext(resolver, typedId) + } catch (e: Exception) { + logger.warn("CriteriaResolver path failed for entity [{}] id [{}] — falling back to legacy tag lookup: {}", + entityTypeName, entityId, e.message) + legacyTagCriteria(entityTypeName, entityId) + } + } + + private fun legacyTagCriteria(entityTypeName: String, entityId: String): EventCriteria { + val tagKey = resolveTagKey(entityTypeName) + return EventCriteria.havingTags(Tag.of(tagKey, entityId)) + } + + /** + * Parses the incoming [entityId] wire string into the entity's id type. For simple id + * types we parse directly; for compound types the wire format is a JSON object whose + * keys match the id's property names. + */ + private fun deserializeEntityId(entityId: String, idClass: Class<*>): Any? { + val trimmed = entityId.trim() + return when { + idClass == String::class.java -> trimmed + idClass == UUID::class.java -> UUID.fromString(trimmed) + idClass == java.lang.Long::class.java || idClass == java.lang.Long.TYPE -> trimmed.toLong() + idClass == java.lang.Integer::class.java || idClass == java.lang.Integer.TYPE -> trimmed.toInt() + idClass == java.lang.Short::class.java || idClass == java.lang.Short.TYPE -> trimmed.toShort() + idClass == java.lang.Byte::class.java || idClass == java.lang.Byte.TYPE -> trimmed.toByte() + idClass == java.lang.Double::class.java || idClass == java.lang.Double.TYPE -> trimmed.toDouble() + idClass == java.lang.Float::class.java || idClass == java.lang.Float.TYPE -> trimmed.toFloat() + idClass == java.lang.Boolean::class.java || idClass == java.lang.Boolean.TYPE -> trimmed.toBoolean() + idClass == java.math.BigInteger::class.java -> java.math.BigInteger(trimmed) + idClass == java.math.BigDecimal::class.java -> java.math.BigDecimal(trimmed) + idClass.isEnum -> { + @Suppress("UNCHECKED_CAST") + java.lang.Enum.valueOf(idClass as Class>, trimmed) + } + isKotlinValueClass(idClass) -> deserializeValueClass(trimmed, idClass) + else -> objectMapper.readValue(trimmed, idClass) + } + } + + /** + * Deserializes a Kotlin `@JvmInline value class` from its wire form. The frontend sends the + * *underlying* value (e.g. `"abc-123"` for `value class OrderId(val value: String)`), we parse + * it against the underlying type, and box it via the value class's public constructor so the + * framework's [CriteriaResolver] sees the real typed id. + */ + private fun deserializeValueClass(raw: String, idClass: Class<*>): Any? { + val underlying = kotlinValueClassUnderlying(idClass) ?: return null + val underlyingValue = deserializeEntityId(raw, underlying) ?: return null + return try { + idClass.getDeclaredConstructor(underlying) + .apply { isAccessible = true } + .newInstance(underlyingValue) + } catch (e: NoSuchMethodException) { + logger.debug("No public constructor({}) on Kotlin value class [{}]; falling back to Jackson", + underlying.name, idClass.name) + objectMapper.readValue(raw, idClass) + } + } + + /** + * Obtains a [CriteriaResolver] for the given (entityType, idType) pair. Strategy: + * 1. Try to extract the registered resolver from the repository returned by + * [StateManager.repository] via reflection on its private `criteriaResolver` field. + * This honors custom resolvers that an application may have configured. + * 2. Fall back to constructing a fresh [AnnotationBasedEventCriteriaResolver] from + * the available [Configuration], which covers the default annotation-driven setup. + * Results are cached per class pair. + */ + @Suppress("UNCHECKED_CAST") + private fun obtainCriteriaResolver(entityClass: Class<*>, idClass: Class<*>): CriteriaResolver { + return criteriaResolverCache.getOrPut(entityClass to idClass) { + extractResolverFromRepository(entityClass, idClass) + ?: AnnotationBasedEventCriteriaResolver( + entityClass as Class, + idClass as Class, + configuration + ) as CriteriaResolver + } + } + + /** + * Invokes [CriteriaResolver.resolve] with a null [ProcessingContext] via reflection, + * bypassing the JSpecify/Kotlin non-null check on the parameter. See caller for rationale. + */ + private fun invokeResolveWithNullContext(resolver: CriteriaResolver, typedId: Any): EventCriteria { + val method = resolver.javaClass.methods.first { it.name == "resolve" && it.parameterCount == 2 } + return method.invoke(resolver, typedId, null) as EventCriteria + } + + /** + * Reflects on the repository returned by [StateManager] to extract its `criteriaResolver` + * field. This yields the exact resolver the framework uses at runtime (respecting any + * custom configuration). Returns null when the repository is not an EventSourcingRepository + * or the field cannot be read — the caller then falls back to the annotation-based resolver. + */ + @Suppress("UNCHECKED_CAST") + private fun extractResolverFromRepository(entityClass: Class<*>, idClass: Class<*>): CriteriaResolver? { + return try { + val repository = stateManager.repository(entityClass, idClass) ?: return null + val field = findField(repository.javaClass, "criteriaResolver") ?: return null + field.isAccessible = true + field.get(repository) as? CriteriaResolver + } catch (e: Exception) { + logger.debug("Could not extract CriteriaResolver from repository for [{}]: {}", + entityClass.name, e.message) + null + } + } + + private fun findField(type: Class<*>, name: String): java.lang.reflect.Field? { + var current: Class<*>? = type + while (current != null && current != Any::class.java) { + try { + return current.getDeclaredField(name) + } catch (_: NoSuchFieldException) { + current = current.superclass + } + } + return null + } + + private fun handleDomainEvents(query: ModelDomainEventsQuery): DomainEventsResult { + logger.info("Handling Axoniq Platform MODEL_DOMAIN_EVENTS query for entity type [{}] id [{}]", + query.entityType, query.entityId) + + val criteria = resolveCriteria(query.entityType, query.entityId) + val condition = SourcingCondition.conditionFor(criteria) + + // Use reduce() which returns a CompletableFuture that completes when the stream is done. + // MessageStream is asynchronous by design; reduce is the recommended way to fully consume a finite stream. + val stream = eventStorageEngine.source(condition) + val allEvents = try { + stream.reduce(mutableListOf()) { acc, entry -> + val message = entry.message() + // Skip the terminal marker that EventStorageEngine.source() always appends at the end + if (message != null && message !is TerminalEventMessage) { + acc.add(DomainEvent( + sequenceNumber = acc.size.toLong(), + timestamp = message.timestamp(), + payloadType = extractPayloadTypeName(message), + payload = extractPayloadAsString(message) + )) + } + acc + }.get() + } catch (e: Exception) { + logger.error("Error while sourcing events for entity type [{}] id [{}]", query.entityType, query.entityId, e) + mutableListOf() + } + + logger.info("Sourced [{}] events for entity type [{}] id [{}]", + allEvents.size, query.entityType, query.entityId) + + val totalCount = allEvents.size.toLong() + val start = query.page * query.pageSize + val end = minOf(start + query.pageSize, allEvents.size) + val pagedEvents = if (start < allEvents.size) allEvents.subList(start, end) else emptyList() + + return DomainEventsResult( + entityId = query.entityId, + entityType = query.entityType, + domainEvents = pagedEvents, + page = query.page, + pageSize = query.pageSize, + totalCount = totalCount, + ) + } + + private fun handleEntityStateAtSequence(query: ModelEntityStateAtSequenceQuery): EntityStateResult { + logger.info("Handling Axoniq Platform MODEL_ENTITY_STATE_AT_SEQUENCE query for entity type [{}] id [{}] seq [{}]", + query.entityType, query.entityId, query.maxSequenceNumber) + + val criteria = resolveCriteria(query.entityType, query.entityId) + val condition = SourcingCondition.conditionFor(criteria) + + // Collect all events up to (and including) the max sequence number + val stream = eventStorageEngine.source(condition) + val collected = try { + stream.reduce(mutableListOf()) { acc, entry -> + val message = entry.message() + // Skip the terminal marker that EventStorageEngine.source() always appends at the end + if (message !is TerminalEventMessage) { + // Include events up to and including the requested sequence number (0-indexed). + // A negative maxSequenceNumber means "all events". + val currentIndex = acc.size.toLong() + if (query.maxSequenceNumber < 0 || currentIndex <= query.maxSequenceNumber) { + acc.add(message.payload()) + } + } + acc + }.get() + } catch (e: Exception) { + logger.error("Error while sourcing events for entity type [{}] id [{}]", query.entityType, query.entityId, e) + mutableListOf() + } + + logger.info("Sourced [{}] events for entity state reconstruction of [{}] id [{}] at seq [{}]", + collected.size, query.entityType, query.entityId, query.maxSequenceNumber) + + val state = collected.lastOrNull()?.let { payload -> + when (payload) { + is ByteArray -> String(payload, Charsets.UTF_8) + is String -> payload + else -> try { + objectMapper.writeValueAsString(payload) + } catch (e: Exception) { + payload.toString() + } + } + } + + return EntityStateResult( + type = query.entityType, + entityId = query.entityId, + // Echo back the requested sequence number so the UI can navigate between sequences correctly + maxSequenceNumber = query.maxSequenceNumber, + state = state, + ) + } + + /** + * Handles a timeline replay query: sources all events for the given entity, then constructs + * a list of [ModelTimelineEntry] capturing the entity's approximated state before and after each event. + * + * State reconstruction strategy (v1): + * Since assembling the real AF5 [org.axonframework.modelling.EntityEvolver] at runtime requires + * numerous core components from the Configuration (ParameterResolverFactory, MessageTypeResolver, + * MessageConverter, EventConverter) and access to the per-entity [AnnotatedEntityMetamodel], this + * initial implementation uses a pragmatic JSON deep-merge approximation: + * - Start with an empty JSON object as the accumulated state. + * - For each event, parse its payload as JSON (if possible) and deep-merge its fields into the + * accumulator. Primitive values overwrite, nested objects recurse, arrays replace. + * This is NOT the exact domain state that the framework would load via [StateManager.loadEntity], + * but it is visually useful for showing how fields evolve over time and supports diff rendering. + * A follow-up can replace this with true EntityEvolver-based reconstruction once we have a clean + * way to obtain the evolver for an arbitrary entity type. + */ + private fun handleTimelineReplay(query: ModelTimelineQuery): ModelTimelineResult { + logger.info("Handling Axoniq Platform MODEL_REPLAY_TIMELINE query for entity type [{}] id [{}] offset [{}] limit [{}]", + query.entityType, query.entityId, query.offset, query.limit) + + val criteria = resolveCriteria(query.entityType, query.entityId) + val condition = SourcingCondition.conditionFor(criteria) + + val offset = maxOf(0, query.offset) + val limit = if (query.limit <= 0) 100 else query.limit + // Cap each state string to avoid blowing gRPC / RSocket message size limits. + val maxStateSizeBytes = 10 * 1024 // 10 KB per state snapshot + val entries = mutableListOf() + var totalEvents = 0 + var currentState: JsonNode = objectMapper.createObjectNode() + + val stream = eventStorageEngine.source(condition) + try { + stream.reduce(Unit) { _, entry -> + val message = entry.message() + if (message !is TerminalEventMessage) { + val seq = totalEvents.toLong() + totalEvents++ + // Always evolve state so stateBefore for the first event in the window is correct. + val payloadString = extractPayloadAsString(message) + val incomingNode = parseJsonOrNull(payloadString) + val evolvedState = if (incomingNode != null) { + mergeJsonState(currentState, incomingNode) + } else { + currentState + } + // Only serialize + collect entries inside the [offset, offset + limit) window. + if (seq >= offset && entries.size < limit) { + val stateBeforeJson = serializeJsonNode(currentState) + val stateAfterJson = serializeJsonNode(evolvedState) + entries.add(ModelTimelineEntry( + sequenceNumber = seq, + // ISO-8601 string avoids CBOR/Jackson Instant ambiguity. + timestamp = message.timestamp().toString(), + eventType = extractPayloadTypeName(message), + eventPayload = truncateString(payloadString, maxStateSizeBytes), + stateBefore = truncateString(stateBeforeJson, maxStateSizeBytes), + stateAfter = truncateString(stateAfterJson, maxStateSizeBytes), + )) + } + currentState = evolvedState + } + Unit + }.get() + } catch (e: Exception) { + logger.error("Error while sourcing events for timeline of entity type [{}] id [{}]", + query.entityType, query.entityId, e) + } + + val remainingAfterWindow = maxOf(0, totalEvents - offset - entries.size) + val truncated = remainingAfterWindow > 0 + logger.info("Sourced [{}] events for timeline of [{}] id [{}] (returning [{}] from offset [{}], truncated={})", + totalEvents, query.entityType, query.entityId, entries.size, offset, truncated) + + return ModelTimelineResult( + entityType = query.entityType, + entityId = query.entityId, + entries = entries, + offset = offset, + totalEvents = totalEvents, + truncated = truncated, + ) + } + + /** + * Truncates a string to at most [maxBytes] bytes (UTF-8). Appends a truncation marker if truncated. + */ + private fun truncateString(value: String?, maxBytes: Int): String? { + if (value == null) return null + val bytes = value.toByteArray(Charsets.UTF_8) + if (bytes.size <= maxBytes) return value + return String(bytes, 0, maxBytes, Charsets.UTF_8) + "\n... (truncated)" + } + + /** + * Parses the given string as a JSON tree. Returns null when the input is null/blank or when parsing fails. + */ + private fun parseJsonOrNull(raw: String?): JsonNode? { + if (raw.isNullOrBlank()) return null + return try { + objectMapper.readTree(raw) + } catch (e: Exception) { + null + } + } + + /** + * Serializes a JsonNode to a pretty-printed JSON string. Returns null on failure. + */ + private fun serializeJsonNode(node: JsonNode): String? { + return try { + objectMapper.writeValueAsString(node) + } catch (e: Exception) { + null + } + } + + /** + * Deep-merges [incoming] into [current] and returns a new JsonNode representing the merged state. + * - If both sides are objects, fields are merged recursively (incoming overrides on conflict). + * - If [incoming] is not an object, it replaces [current] entirely. + * - Arrays from [incoming] replace arrays in [current] (no element-level merging). + * Does not mutate the inputs. + */ + private fun mergeJsonState(current: JsonNode, incoming: JsonNode): JsonNode { + if (!current.isObject || !incoming.isObject) { + return incoming + } + val result: ObjectNode = (current as ObjectNode).deepCopy() + val incomingObj = incoming as ObjectNode + val fieldNames = incomingObj.fieldNames() + while (fieldNames.hasNext()) { + val name = fieldNames.next() + val incomingValue = incomingObj.get(name) + val existing = result.get(name) + if (existing != null && existing.isObject && incomingValue.isObject) { + result.set(name, mergeJsonState(existing, incomingValue)) + } else { + result.set(name, incomingValue) + } + } + return result + } +} diff --git a/framework-client/src/main/resources/META-INF/services/org.axonframework.common.configuration.ConfigurationEnhancer b/framework-client/src/main/resources/META-INF/services/org.axonframework.common.configuration.ConfigurationEnhancer index d69912f..7dd9a2c 100644 --- a/framework-client/src/main/resources/META-INF/services/org.axonframework.common.configuration.ConfigurationEnhancer +++ b/framework-client/src/main/resources/META-INF/services/org.axonframework.common.configuration.ConfigurationEnhancer @@ -17,4 +17,5 @@ io.axoniq.platform.framework.AxoniqPlatformConfigurerEnhancer io.axoniq.platform.framework.messaging.distributed.AxoniqPlatformDistributedMessagingConfigurerEnhancer io.axoniq.platform.framework.modelling.AxoniqPlatformModellingConfigurationEnhancer -io.axoniq.platform.framework.eventsourcing.AxoniqPlatformEventsourcingConfigurerEnhancer \ No newline at end of file +io.axoniq.platform.framework.eventsourcing.AxoniqPlatformEventsourcingConfigurerEnhancer +io.axoniq.platform.framework.eventsourcing.AxoniqPlatformModelInspectionEnhancer \ No newline at end of file From 989927a5fd89293a0e9b006518b33e6da7123a70 Mon Sep 17 00:00:00 2001 From: Stefan Mirkovic Date: Fri, 1 May 2026 13:57:16 +0200 Subject: [PATCH 4/7] Address PR review comments --- .../axoniq/platform/framework/api/modelApi.kt | 29 +- .../RSocketModelInspectionResponder.kt | 1028 +++++++++++------ ...oniqPlatformModelInspectionEnhancerTest.kt | 96 ++ ...cketModelInspectionResponderHelpersTest.kt | 202 ++++ ...spectionResponderReflectionDispatchTest.kt | 224 ++++ 5 files changed, 1211 insertions(+), 368 deletions(-) create mode 100644 framework-client/src/test/kotlin/io/axoniq/platform/framework/eventsourcing/AxoniqPlatformModelInspectionEnhancerTest.kt create mode 100644 framework-client/src/test/kotlin/io/axoniq/platform/framework/eventsourcing/RSocketModelInspectionResponderHelpersTest.kt create mode 100644 framework-client/src/test/kotlin/io/axoniq/platform/framework/eventsourcing/RSocketModelInspectionResponderReflectionDispatchTest.kt diff --git a/framework-client-api/src/main/java/io/axoniq/platform/framework/api/modelApi.kt b/framework-client-api/src/main/java/io/axoniq/platform/framework/api/modelApi.kt index ea34ec3..4f57b60 100644 --- a/framework-client-api/src/main/java/io/axoniq/platform/framework/api/modelApi.kt +++ b/framework-client-api/src/main/java/io/axoniq/platform/framework/api/modelApi.kt @@ -22,13 +22,24 @@ data class RegisteredEntitiesResult( data class RegisteredEntityInfo( val entityType: String, - val idTypes: List, /** - * Structural descriptors of the id class's properties. Empty when the id is a - * "simple" type (String, primitives, UUID, etc.) — in that case the frontend should - * render a single text input. When populated, the id is a compound type (record / - * data class / plain object) and the frontend should render one input per descriptor - * and send the entityId as a JSON object keyed by the descriptor names. + * All id types registered for this entity. AF5 entities can be addressed by multiple + * id types (e.g. one per command in a build agent), each producing different criteria. + * The frontend should let the user pick which id type to query against. + */ + val idTypes: List, +) + +data class IdType( + /** Fully qualified Java type name of the id class. */ + val type: String, + /** + * Structural descriptors of the id class's properties. Empty for "simple" types + * (String, primitives, UUID, etc.) — frontend renders a single text input. Populated + * for compound types (records / data classes / plain objects) — frontend renders one + * input per descriptor and sends the entityId as a JSON object keyed by descriptor names. + * Only 1-deep properties are described; nested objects are exposed as type "object" and + * left for the user to provide as raw JSON. */ val idFields: List = emptyList(), ) @@ -44,6 +55,8 @@ data class IdFieldDescriptor( data class ModelDomainEventsQuery( val entityType: String, val entityId: String, + /** FQ Java type name of the id type the user selected (must match one of [RegisteredEntityInfo.idTypes].type). */ + val idType: String, val page: Int = 0, val pageSize: Int = 10, ) @@ -51,12 +64,16 @@ data class ModelDomainEventsQuery( data class ModelEntityStateAtSequenceQuery( val entityType: String, val entityId: String, + /** FQ Java type name of the id type the user selected. */ + val idType: String, val maxSequenceNumber: Long = 0, ) data class ModelTimelineQuery( val entityType: String, val entityId: String, + /** FQ Java type name of the id type the user selected. */ + val idType: String, val offset: Int = 0, val limit: Int = 100, ) diff --git a/framework-client/src/main/java/io/axoniq/platform/framework/eventsourcing/RSocketModelInspectionResponder.kt b/framework-client/src/main/java/io/axoniq/platform/framework/eventsourcing/RSocketModelInspectionResponder.kt index 5ab62e0..18d879f 100644 --- a/framework-client/src/main/java/io/axoniq/platform/framework/eventsourcing/RSocketModelInspectionResponder.kt +++ b/framework-client/src/main/java/io/axoniq/platform/framework/eventsourcing/RSocketModelInspectionResponder.kt @@ -16,21 +16,32 @@ package io.axoniq.platform.framework.eventsourcing -import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.annotation.JsonAutoDetect +import com.fasterxml.jackson.annotation.PropertyAccessor import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.SerializationFeature -import com.fasterxml.jackson.databind.node.ObjectNode import io.axoniq.platform.framework.api.* import io.axoniq.platform.framework.client.RSocketHandlerRegistrar +import io.axoniq.platform.framework.truncateToBytes import org.axonframework.common.configuration.Configuration +import org.axonframework.common.infra.ComponentDescriptor +import org.axonframework.common.infra.DescribableComponent +import org.axonframework.conversion.Converter import org.axonframework.eventsourcing.CriteriaResolver +import org.axonframework.eventsourcing.EventSourcedEntityFactory import org.axonframework.eventsourcing.annotation.AnnotationBasedEventCriteriaResolver +import org.axonframework.eventsourcing.annotation.EventSourcingHandler import org.axonframework.eventsourcing.eventstore.EventStorageEngine import org.axonframework.eventsourcing.eventstore.SourcingCondition +import org.axonframework.eventsourcing.handler.InitializingEntityEvolver +import org.axonframework.messaging.core.unitofwork.ProcessingContext +import org.axonframework.messaging.eventhandling.EventMessage +import org.axonframework.messaging.eventhandling.GenericEventMessage import org.axonframework.messaging.eventhandling.TerminalEventMessage import org.axonframework.messaging.eventstreaming.EventCriteria -import org.axonframework.messaging.eventstreaming.Tag +import org.axonframework.modelling.EntityEvolver import org.axonframework.modelling.StateManager +import java.lang.reflect.Proxy import org.slf4j.LoggerFactory import java.util.* import java.util.concurrent.ConcurrentHashMap @@ -45,157 +56,166 @@ open class RSocketModelInspectionResponder( private val objectMapper = ObjectMapper().apply { findAndRegisterModules() disable(SerializationFeature.FAIL_ON_EMPTY_BEANS) + // AF5 entities are typically Kotlin data classes / Java records with private fields and no + // public getters in the bean-getter sense. Enable direct field access so Jackson surfaces + // the entity's actual state instead of emitting `{}` for "no discoverable properties". + setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY) + setVisibility(PropertyAccessor.GETTER, JsonAutoDetect.Visibility.NONE) + setVisibility(PropertyAccessor.IS_GETTER, JsonAutoDetect.Visibility.NONE) } /** * Cache of [CriteriaResolver] instances per (entityType, idType) pair. Resolvers are - * stateless and obtaining one via reflection is not free, so caching avoids repeating - * the work on every query. Keyed by class identity so redeploys with different classloaders - * get fresh entries naturally. + * stateless and obtaining one via describe-walking or reflection is not free, so caching + * avoids repeating the work on every query. Keyed by class identity so redeploys with + * different classloaders get fresh entries naturally. */ private val criteriaResolverCache = ConcurrentHashMap, Class<*>>, CriteriaResolver>() - fun start() { - registrar.registerHandlerWithoutPayload( - Routes.Model.REGISTERED_ENTITIES, - this::handleRegisteredEntities - ) - registrar.registerHandlerWithPayload( - Routes.Model.DOMAIN_EVENTS, - ModelDomainEventsQuery::class.java, - this::handleDomainEvents - ) - registrar.registerHandlerWithPayload( - Routes.Model.ENTITY_STATE_AT_SEQUENCE, - ModelEntityStateAtSequenceQuery::class.java, - this::handleEntityStateAtSequence - ) - registrar.registerHandlerWithPayload( - Routes.Model.REPLAY_TIMELINE, - ModelTimelineQuery::class.java, - this::handleTimelineReplay - ) - } - /** - * Resolves the tag key for a given entity type. Checks in order: - * 1. Spring's @EventSourced annotation (directly or as meta-annotation) with non-empty tagKey - * 2. Axon's @EventSourcedEntity annotation (directly or as meta-annotation) with non-empty tagKey - * 3. Fallback to the entity class's simple name (consistent with AnnotationBasedEventCriteriaResolver) + * Cache of [InitializingEntityEvolver] instances per (entityType, idType) pair. The + * initializer combines the entity factory and the raw evolver: if the current state is + * null on a given event, it creates a fresh entity via the factory; otherwise it + * delegates to the evolver. This is exactly what we need for ad-hoc state replay + * starting from a null state. Cache is safe because the initializer is stateless. */ - private fun resolveTagKey(entityTypeName: String): String { - return try { - val entityClass = Class.forName(entityTypeName) - - // Walk all annotations on the class (including meta-annotations) and look for any with a non-empty tagKey - val tagKey = findTagKeyInAnnotations(entityClass) - if (!tagKey.isNullOrEmpty()) { - return tagKey - } - - // Fallback: simple name of the entity class - entityClass.simpleName - } catch (e: ClassNotFoundException) { - logger.warn("Could not resolve entity class [{}], using last segment as tag key", entityTypeName) - entityTypeName.substringAfterLast('.') - } - } + private val initializingEvolverCache = ConcurrentHashMap, Class<*>>, InitializingEntityEvolver>() /** - * Recursively searches annotations on the class for any annotation that declares a non-empty `tagKey` attribute. - * Supports both Spring's @EventSourced (which uses @AliasFor) and Axon's @EventSourcedEntity. + * The framework-wide [Converter] used to deserialize raw event payloads (read from the event + * store as `byte[]`) into typed event objects, so the evolver's `@EventSourcingHandler` + * methods can match them by parameter class. Resolved lazily from the [Configuration]. */ - private fun findTagKeyInAnnotations(entityClass: Class<*>): String? { - val visited = mutableSetOf>() - for (annotation in entityClass.annotations) { - val result = findTagKeyInAnnotation(annotation, visited) - if (!result.isNullOrEmpty()) { - return result - } - } - return null - } - - private fun findTagKeyInAnnotation(annotation: Annotation, visited: MutableSet>): String? { - val annotationType = annotation.annotationClass.java - if (!visited.add(annotationType)) { - return null - } - // Skip standard Java annotations - if (annotationType.name.startsWith("java.") || annotationType.name.startsWith("kotlin.")) { - return null - } - // Check if this annotation itself has a tagKey attribute + private val payloadConverter: Converter? by lazy { try { - val tagKeyMethod = annotationType.getMethod("tagKey") - val value = tagKeyMethod.invoke(annotation) as? String - if (!value.isNullOrEmpty()) { - logger.debug("Found tagKey [{}] on annotation [{}]", value, annotationType.name) - return value + configuration.getComponent(Converter::class.java).also { + logger.info("[ModelInspection] Using payload converter [{}] for event deserialization", it.javaClass.simpleName) } - } catch (_: NoSuchMethodException) { - // This annotation doesn't have a tagKey attribute } catch (e: Exception) { - logger.debug("Error reading tagKey from annotation [{}]", annotationType.name, e) - } - // Recursively check meta-annotations - for (metaAnnotation in annotationType.annotations) { - val result = findTagKeyInAnnotation(metaAnnotation, visited) - if (!result.isNullOrEmpty()) { - return result - } + logger.warn("[ModelInspection] Could not obtain Converter from configuration — events will be passed to evolver with raw payloads (state replay will likely be empty): {}", + e.message) + null } - return null } /** - * Extracts a human-readable type name for the event. Events read directly from the - * [EventStorageEngine] often have a raw byte[] payload whose `payloadType()` returns `[B`. - * In that case the proper event type is available via `message.type().name()`. + * Deserializes the event message's raw `byte[]` payload into a typed instance of the event + * class indicated by `message.type().name()`. Without this, the evolver receives `byte[]` + * payloads and no `@EventSourcingHandler(EventClass)` method matches, so state never advances + * past entity creation defaults. + * + * Why not [EventMessage.withConvertedPayload]: that API short-circuits via + * `convertedPayload.class.isAssignableFrom(payloadType())`. When the converter quietly + * returns the same `byte[]` (no registered conversion path for `byte[] -> EventClass`), + * the assignability check passes and the original message is returned untouched — so + * `payloadType()` stays `byte[]` and the evolver can't dispatch to a typed handler. + * + * Instead, we explicitly call [Message.payloadAs] (forces conversion via the converter) + * and reconstruct a [GenericEventMessage] with the typed payload, so the new + * `payloadType()` is the actual event class — exactly what `AnnotatedEntityMetamodel.evolve` + * expects to find a matching `@EventSourcingHandler`. + * + * Returns the original message untouched when the converter is unavailable, the type can't + * be resolved on the classpath, conversion fails, or the result is unexpectedly null. */ - private fun extractPayloadTypeName(message: org.axonframework.messaging.eventhandling.EventMessage): String { + private fun deserializePayload(message: EventMessage): EventMessage { + val converter = payloadConverter ?: return message + val typeName = message.type()?.name() ?: return message return try { - // Prefer the qualified MessageType if present (AF5 way) - message.type()?.name() ?: message.payloadType().name + val cls = Class.forName(typeName) + // Force the converter to produce a typed instance. This bypasses + // withConvertedPayload's assignability short-circuit which keeps payloadType=byte[] + // when the converter returns the input unchanged. + val typedPayload: Any? = message.payloadAs(cls, converter) + if (typedPayload == null || cls.isInstance(typedPayload).not()) { + logger.debug("[ModelInspection] Converter returned unexpected payload for type [{}]: actual=[{}] — keeping original", + typeName, typedPayload?.javaClass?.name) + return message + } + logger.info("[ModelInspection] Converted payload: msgType.name=[{}] msgType.version=[{}] payloadCls=[{}]", + message.type().name(), + message.type()?.version(), + typedPayload.javaClass.name) + // Build a fresh GenericEventMessage whose payloadType() is the typed class. + // GenericMessage's 4-arg constructor derives payloadType from payload.getClass(), + // so the resulting message advertises the correct event class to the evolver. + // Metadata extends Map in AF5 but Kotlin needs an explicit cast + // because of its stricter generics treatment of the Java collection. + @Suppress("UNCHECKED_CAST") + GenericEventMessage( + message.identifier(), + message.type(), + typedPayload, + message.metadata() as Map, + message.timestamp(), + ) + } catch (e: ClassNotFoundException) { + logger.debug("Event type [{}] not on classpath — leaving payload as raw bytes", typeName) + message } catch (e: Exception) { - message.payloadType().name + logger.debug("Could not convert event payload of type [{}]: {}", typeName, e.message) + message } } /** - * Converts the event payload to a String. When reading directly from [EventStorageEngine], - * payloads are usually raw byte[] containing JSON or CBOR. We try UTF-8 decoding first - * (works for JSON), falling back to Jackson serialization for typed payloads. + * No-op [ProcessingContext] proxy used for ad-hoc evolve calls outside a real unit of work. + * `AnnotatedEntityMetamodel.evolve` requires a non-null context (defensive `Objects.requireNonNull`), + * but during model inspection replay we have no active processing context. The proxy returns + * sane defaults: null for resource lookups, `false` for predicates, `this` for fluent setters, + * and a no-op for void methods. Any method that would actually need a resource will see null + * and either no-op or throw — caught at the [evolveSafely] boundary. */ - private fun extractPayloadAsString(message: org.axonframework.messaging.eventhandling.EventMessage): String? { - val payload = message.payload() ?: return null - return when (payload) { - is ByteArray -> try { - String(payload, Charsets.UTF_8) - } catch (e: Exception) { - payload.toString() - } - is String -> payload - else -> try { - objectMapper.writeValueAsString(payload) - } catch (e: Exception) { - payload.toString() - } + private val noOpProcessingContext: ProcessingContext = Proxy.newProxyInstance( + ProcessingContext::class.java.classLoader, + arrayOf(ProcessingContext::class.java) + ) { proxy, method, _ -> + when (method.returnType) { + java.lang.Boolean.TYPE -> false + ProcessingContext::class.java -> proxy + Void.TYPE -> null + else -> null } + } as ProcessingContext + + fun start() { + registrar.registerHandlerWithoutPayload( + Routes.Model.REGISTERED_ENTITIES, + this::handleRegisteredEntities + ) + registrar.registerHandlerWithPayload( + Routes.Model.DOMAIN_EVENTS, + ModelDomainEventsQuery::class.java, + this::handleDomainEvents + ) + registrar.registerHandlerWithPayload( + Routes.Model.ENTITY_STATE_AT_SEQUENCE, + ModelEntityStateAtSequenceQuery::class.java, + this::handleEntityStateAtSequence + ) + registrar.registerHandlerWithPayload( + Routes.Model.REPLAY_TIMELINE, + ModelTimelineQuery::class.java, + this::handleTimelineReplay + ) } + // ------------------------------------------------------------------------------------------ + // Registered entities introspection + // ------------------------------------------------------------------------------------------ + private fun handleRegisteredEntities(): RegisteredEntitiesResult { logger.debug("Handling Axoniq Platform MODEL_REGISTERED_ENTITIES query") val entities = stateManager.registeredEntities().map { entityType -> - val idTypes = stateManager.registeredIdsFor(entityType) - // Prefer the first registered id type for introspection. Entities registered with - // multiple id types (rare) still get a sensible default; the frontend can extend - // this to switch among id types later. - val primaryIdType = idTypes.firstOrNull() + val idTypeInfos = stateManager.registeredIdsFor(entityType).map { idClass -> + IdType( + type = idClass.name, + idFields = describeIdFields(idClass), + ) + } RegisteredEntityInfo( entityType = entityType.name, - idTypes = idTypes.map { it.name }, - idFields = primaryIdType?.let { describeIdFields(it) } ?: emptyList() + idTypes = idTypeInfos, ) } return RegisteredEntitiesResult(entities = entities) @@ -205,12 +225,12 @@ open class RSocketModelInspectionResponder( * Returns structural descriptors for the given id class. Empty for "simple" types where * the frontend should show a single text input (String, primitives, UUID); populated for * records / data classes / plain objects where each property should get its own input. + * Only 1-deep properties are described — nested types surface as type = "object". */ - private fun describeIdFields(idClass: Class<*>): List { + internal fun describeIdFields(idClass: Class<*>): List { if (isSimpleIdType(idClass)) { return emptyList() } - // Records: use the declared record components in canonical order. if (idClass.isRecord) { return idClass.recordComponents.map { component -> IdFieldDescriptor( @@ -220,11 +240,9 @@ open class RSocketModelInspectionResponder( ) } } - // Kotlin data classes / POJOs: walk declared fields, skip synthetic/static. return idClass.declaredFields .filter { field -> - !java.lang.reflect.Modifier.isStatic(field.modifiers) && - !field.isSynthetic + !java.lang.reflect.Modifier.isStatic(field.modifiers) && !field.isSynthetic } .map { field -> IdFieldDescriptor( @@ -235,15 +253,7 @@ open class RSocketModelInspectionResponder( } } - /** - * A "simple" id type is one the frontend can reasonably capture with a single text input. - * Everything else is treated as compound (structured) and gets field-by-field descriptors. - * - * Kotlin `@JvmInline value class` wrappers (e.g. `value class OrderId(val value: String)`) are - * also treated as simple when their underlying property is itself simple — the user sees one - * input and we box the typed value at deserialization time. - */ - private fun isSimpleIdType(idClass: Class<*>): Boolean { + internal fun isSimpleIdType(idClass: Class<*>): Boolean { if (idClass.isPrimitive) return true if (idClass.isEnum) return true if (isKotlinValueClass(idClass)) { @@ -267,16 +277,9 @@ open class RSocketModelInspectionResponder( } } - /** True if the class is a Kotlin `@JvmInline value class`. */ - private fun isKotlinValueClass(idClass: Class<*>): Boolean { - return idClass.isAnnotationPresent(JvmInline::class.java) - } + private fun isKotlinValueClass(idClass: Class<*>): Boolean = + idClass.isAnnotationPresent(JvmInline::class.java) - /** - * Returns the underlying backing type of a Kotlin value class, or null when the class - * doesn't expose exactly one instance field (shouldn't happen for well-formed value classes, - * but we stay defensive against synthetic/static noise from compiler plugins). - */ private fun kotlinValueClassUnderlying(idClass: Class<*>): Class<*>? { val instanceFields = idClass.declaredFields.filter { f -> !java.lang.reflect.Modifier.isStatic(f.modifiers) && !f.isSynthetic @@ -284,8 +287,7 @@ open class RSocketModelInspectionResponder( return instanceFields.singleOrNull()?.type } - /** Maps a Java class to a frontend-friendly type label used to choose input widgets. */ - private fun normalizedType(type: Class<*>): String { + internal fun normalizedType(type: Class<*>): String { if (type == UUID::class.java) return "uuid" if (type == String::class.java) return "string" if (type == java.lang.Boolean::class.java || type == java.lang.Boolean.TYPE) return "boolean" @@ -294,41 +296,33 @@ open class RSocketModelInspectionResponder( return "object" } + // ------------------------------------------------------------------------------------------ + // Criteria resolution + id deserialization + // ------------------------------------------------------------------------------------------ + /** - * Resolves the [EventCriteria] for a given entity query. Instead of hand-crafting a - * single `Tag.of(tagKey, entityId)` (which only works for single-tag entities with - * string ids), we invoke the entity's registered [CriteriaResolver] with a typed id, - * so multi-tag and compound-id entities produce the correct criteria. - * - * Falls back to the legacy single-tag approach only if resolver construction or - * invocation fails, so this stays backward-compatible with edge cases. + * Resolves the [EventCriteria] for a given (entityType, idType, entityId) triple by + * obtaining the registered [CriteriaResolver] for the chosen id type and invoking it + * with the deserialized typed id. Multi-tag and compound-id entities produce the + * correct criteria automatically — no tag-key resolution needed. */ - private fun resolveCriteria(entityTypeName: String, entityId: String): EventCriteria { - return try { - val entityClass = Class.forName(entityTypeName) - val idClass = stateManager.registeredIdsFor(entityClass).firstOrNull() - ?: return legacyTagCriteria(entityTypeName, entityId) - // Jackson can theoretically return null for an input of `"null"`; the resolver - // signature is @Nonnull so we treat that as an invalid id and fall back. - val typedId = deserializeEntityId(entityId, idClass) - ?: return legacyTagCriteria(entityTypeName, entityId) - val resolver = obtainCriteriaResolver(entityClass, idClass) - // ProcessingContext is @NonNull via JSpecify @NullMarked, but the default - // AnnotationBasedEventCriteriaResolver never reads it (verified in AF5 source). - // We bypass Kotlin's nullability check via reflection; custom resolvers that - // actually rely on it will throw NPE, which is caught by the outer try/catch - // and falls back to the legacy single-tag path. - invokeResolveWithNullContext(resolver, typedId) - } catch (e: Exception) { - logger.warn("CriteriaResolver path failed for entity [{}] id [{}] — falling back to legacy tag lookup: {}", - entityTypeName, entityId, e.message) - legacyTagCriteria(entityTypeName, entityId) - } + private fun resolveCriteria(entityType: Class<*>, idClass: Class<*>, entityId: String): EventCriteria { + val typedId = deserializeEntityId(entityId, idClass) + ?: throw IllegalArgumentException("Could not deserialize id [$entityId] as type [${idClass.name}]") + return resolveCriteriaWithTypedId(entityType, idClass, typedId) } - private fun legacyTagCriteria(entityTypeName: String, entityId: String): EventCriteria { - val tagKey = resolveTagKey(entityTypeName) - return EventCriteria.havingTags(Tag.of(tagKey, entityId)) + /** + * Same as [resolveCriteria] but skips id deserialization — used by handlers that already + * have the typed id (e.g. for state reconstruction via [InitializingEntityEvolver]). + */ + private fun resolveCriteriaWithTypedId(entityType: Class<*>, idClass: Class<*>, typedId: Any): EventCriteria { + val resolver = obtainCriteriaResolver(entityType, idClass) + // ProcessingContext is @NonNull via JSpecify @NullMarked, but the default + // AnnotationBasedEventCriteriaResolver never reads it. We bypass Kotlin's nullability + // check via reflection; resolvers that actually rely on the context will throw NPE + // which propagates to the handler-level catch and surfaces a clear error. + return invokeResolveWithNullContext(resolver, typedId) } /** @@ -359,12 +353,6 @@ open class RSocketModelInspectionResponder( } } - /** - * Deserializes a Kotlin `@JvmInline value class` from its wire form. The frontend sends the - * *underlying* value (e.g. `"abc-123"` for `value class OrderId(val value: String)`), we parse - * it against the underlying type, and box it via the value class's public constructor so the - * framework's [CriteriaResolver] sees the real typed id. - */ private fun deserializeValueClass(raw: String, idClass: Class<*>): Any? { val underlying = kotlinValueClassUnderlying(idClass) ?: return null val underlyingValue = deserializeEntityId(raw, underlying) ?: return null @@ -381,17 +369,16 @@ open class RSocketModelInspectionResponder( /** * Obtains a [CriteriaResolver] for the given (entityType, idType) pair. Strategy: - * 1. Try to extract the registered resolver from the repository returned by - * [StateManager.repository] via reflection on its private `criteriaResolver` field. - * This honors custom resolvers that an application may have configured. - * 2. Fall back to constructing a fresh [AnnotationBasedEventCriteriaResolver] from - * the available [Configuration], which covers the default annotation-driven setup. - * Results are cached per class pair. + * 1. Walk the registered repository via `describeTo` and pick out the first matching + * [CriteriaResolver] — this honors any custom resolver an application may have wired in. + * 2. Fall back to constructing a fresh [AnnotationBasedEventCriteriaResolver] from the + * available [Configuration], which covers the default annotation-driven setup. + * Results are cached per (entityType, idType) pair. */ @Suppress("UNCHECKED_CAST") private fun obtainCriteriaResolver(entityClass: Class<*>, idClass: Class<*>): CriteriaResolver { return criteriaResolverCache.getOrPut(entityClass to idClass) { - extractResolverFromRepository(entityClass, idClass) + findInRepository(entityClass, idClass, CriteriaResolver::class.java) as CriteriaResolver? ?: AnnotationBasedEventCriteriaResolver( entityClass as Class, idClass as Class, @@ -401,60 +388,252 @@ open class RSocketModelInspectionResponder( } /** - * Invokes [CriteriaResolver.resolve] with a null [ProcessingContext] via reflection, - * bypassing the JSpecify/Kotlin non-null check on the parameter. See caller for rationale. + * Invokes [CriteriaResolver.resolve] with a no-op [ProcessingContext] via reflection, + * bypassing the JSpecify/Kotlin non-null check on the parameter. The default + * `AnnotationBasedEventCriteriaResolver` doesn't read the context; resolvers that do + * may interact with the proxy (returning null/false defaults) and either no-op or throw, + * which propagates to the handler-level catch. */ private fun invokeResolveWithNullContext(resolver: CriteriaResolver, typedId: Any): EventCriteria { val method = resolver.javaClass.methods.first { it.name == "resolve" && it.parameterCount == 2 } - return method.invoke(resolver, typedId, null) as EventCriteria + return method.invoke(resolver, typedId, noOpProcessingContext) as EventCriteria } + // ------------------------------------------------------------------------------------------ + // Entity evolver lookup + state reconstruction + // ------------------------------------------------------------------------------------------ + /** - * Reflects on the repository returned by [StateManager] to extract its `criteriaResolver` - * field. This yields the exact resolver the framework uses at runtime (respecting any - * custom configuration). Returns null when the repository is not an EventSourcingRepository - * or the field cannot be read — the caller then falls back to the annotation-based resolver. + * Obtains an [InitializingEntityEvolver] for the given (entityType, idType). AF5 + * repositories don't pre-instantiate `InitializingEntityEvolver`; they expose the + * `entityFactory` and `entityEvolver` as separate describable properties. We find both + * via the describe tree and construct the initializer manually — same constructor the + * framework uses internally — so null initial state on the first event triggers entity + * creation via the factory rather than no-op'ing. + * + * Returns null when either piece can't be located. */ @Suppress("UNCHECKED_CAST") - private fun extractResolverFromRepository(entityClass: Class<*>, idClass: Class<*>): CriteriaResolver? { + private fun obtainInitializingEvolver(entityClass: Class<*>, idClass: Class<*>): InitializingEntityEvolver? { + initializingEvolverCache[entityClass to idClass]?.let { return it } + val factory = findInRepository(entityClass, idClass, EventSourcedEntityFactory::class.java) + as EventSourcedEntityFactory? + val evolver = findInRepository(entityClass, idClass, EntityEvolver::class.java, preferredName = "entityEvolver") + as EntityEvolver? + if (factory == null || evolver == null) { + logger.warn("Could not assemble InitializingEntityEvolver for [{}] / [{}] — factory={}, evolver={}", + entityClass.name, idClass.name, factory?.javaClass?.name, evolver?.javaClass?.name) + return null + } + logger.info("Assembled InitializingEntityEvolver for [{}] / [{}] — factory=[{}], evolver=[{}]", + entityClass.simpleName, idClass.simpleName, factory.javaClass.simpleName, evolver.javaClass.simpleName) + val initializer = InitializingEntityEvolver(factory, evolver) + initializingEvolverCache[entityClass to idClass] = initializer + return initializer + } + + /** + * Invokes [InitializingEntityEvolver.evolve] with a null [ProcessingContext] via reflection + * — same trick as [invokeResolveWithNullContext]. The initializer handles null state by + * delegating to the entity factory before evolving. Custom code that relies on the context + * will throw NPE; we catch at the caller and keep the previous state. + * + * The 4-arg signature is `(I id, E currentState, EventMessage event, ProcessingContext ctx)`. + */ + private fun invokeEvolveWithNullContext( + evolver: InitializingEntityEvolver, + typedId: Any, + currentState: Any?, + message: EventMessage, + ): Any? { + val method = evolver.javaClass.methods.first { it.name == "evolve" && it.parameterCount == 4 } + return method.invoke(evolver, typedId, currentState, message, noOpProcessingContext) + } + + /** + * Walks the repository registered for (entityClass, idClass) via `describeTo` and returns + * the first instance of [target] that surfaces. When [preferredName] is set, the property + * with that exact name wins over any other match (used to prefer raw evolvers over + * lifecycle-wrapped ones). + */ + private fun findInRepository( + entityClass: Class<*>, + idClass: Class<*>, + target: Class, + preferredName: String? = null, + ): T? { return try { val repository = stateManager.repository(entityClass, idClass) ?: return null - val field = findField(repository.javaClass, "criteriaResolver") ?: return null - field.isAccessible = true - field.get(repository) as? CriteriaResolver + val repoClass = repository.javaClass.name + // Diagnostic dump (DEBUG): on every lookup, list every describable property — useful + // when debugging an unfamiliar repository implementation, but too noisy for INFO. + if (logger.isDebugEnabled) { + val dump = DescribePropertyDump() + (repository as? DescribableComponent)?.describeTo(dump) + logger.debug("[ModelInspection] Looking for [{}] in repository [{}] for [{}] / [{}]; describe tree:\n{}", + target.simpleName, repoClass, entityClass.simpleName, idClass.simpleName, dump.formatted()) + } + val finder = NamedDescribablePropertyFinder(target, preferredName) + (repository as? DescribableComponent)?.describeTo(finder) ?: return null + finder.found } catch (e: Exception) { - logger.debug("Could not extract CriteriaResolver from repository for [{}]: {}", - entityClass.name, e.message) + logger.debug("describeTo lookup of [{}] for entity [{}] / id [{}] failed: {}", + target.simpleName, entityClass.name, idClass.name, e.message) null } } - private fun findField(type: Class<*>, name: String): java.lang.reflect.Field? { - var current: Class<*>? = type - while (current != null && current != Any::class.java) { - try { - return current.getDeclaredField(name) - } catch (_: NoSuchFieldException) { - current = current.superclass + /** Diagnostic descriptor that records every property name + value class seen, recursing into describable nested components. */ + private class DescribePropertyDump : ComponentDescriptor { + private val lines = mutableListOf() + private var depth = 0 + private val visited = mutableSetOf() // identity-based cycle break + + private fun indent() = " ".repeat(depth) + + override fun describeProperty(name: String, value: Any?) { + val cls = value?.javaClass?.name ?: "null" + lines += "${indent()}- $name : $cls" + if (value is DescribableComponent && visited.add(System.identityHashCode(value))) { + depth++ + try { + value.describeTo(this) + } catch (_: Exception) { /* swallow — diagnostic only */ } + depth-- + } + } + + override fun describeProperty(name: String, value: Collection<*>?) { + lines += "${indent()}- $name : Collection(size=${value?.size ?: 0})" + } + + override fun describeProperty(name: String, value: Map<*, *>?) { + lines += "${indent()}- $name : Map(size=${value?.size ?: 0})" + } + + override fun describeProperty(name: String, value: String?) { + lines += "${indent()}- $name : String = $value" + } + + override fun describeProperty(name: String, value: Long?) { + lines += "${indent()}- $name : Long = $value" + } + + override fun describeProperty(name: String, value: Boolean?) { + lines += "${indent()}- $name : Boolean = $value" + } + + override fun describe(): String = "DescribePropertyDump" + + fun formatted(): String = lines.joinToString("\n") + } + + /** + * Drills through a repository's `describeTo` tree looking for a single instance of [target]. + * Property-name preference lets callers pick the canonical match (e.g. "entityEvolver") + * over wrapper variants like "initializingEntityEvolver". + */ + private class NamedDescribablePropertyFinder( + private val target: Class, + private val preferredName: String?, + ) : ComponentDescriptor { + private var preferred: T? = null + private var fallback: T? = null + + @Suppress("UNCHECKED_CAST") + override fun describeProperty(name: String, value: Any?) { + if (value == null) return + if (target.isInstance(value)) { + if (preferredName != null && name == preferredName && preferred == null) { + preferred = value as T + } else if (fallback == null) { + fallback = value as T + } + // Don't recurse into matched component — we already have what we need at this level. + return + } + if (value is DescribableComponent) { + value.describeTo(this) + } + } + + override fun describeProperty(name: String, value: Collection<*>?) { /* not needed */ } + override fun describeProperty(name: String, value: Map<*, *>?) { /* not needed */ } + override fun describeProperty(name: String, value: String?) { /* not needed */ } + override fun describeProperty(name: String, value: Long?) { /* not needed */ } + override fun describeProperty(name: String, value: Boolean?) { /* not needed */ } + override fun describe(): String = "NamedDescribablePropertyFinder<${target.simpleName}>" + + val found: T? get() = preferred ?: fallback + } + + // ------------------------------------------------------------------------------------------ + // Event payload utilities + // ------------------------------------------------------------------------------------------ + + /** + * Extracts a human-readable type name for the event. Events read directly from the + * [EventStorageEngine] often have a raw byte[] payload whose `payloadType()` returns `[B`. + * In that case the proper event type is available via `message.type().name()`. + */ + private fun extractPayloadTypeName(message: EventMessage): String { + return try { + message.type()?.name() ?: message.payloadType().name + } catch (e: Exception) { + message.payloadType().name + } + } + + /** + * Converts the event payload to a String. When reading directly from [EventStorageEngine], + * payloads are usually raw byte[] containing JSON or CBOR. We try UTF-8 decoding first + * (works for JSON), falling back to Jackson serialization for typed payloads. + */ + private fun extractPayloadAsString(message: EventMessage): String? { + val payload = message.payload() ?: return null + return when (payload) { + is ByteArray -> try { + String(payload, Charsets.UTF_8) + } catch (e: Exception) { + payload.toString() + } + is String -> payload + else -> try { + objectMapper.writeValueAsString(payload) + } catch (e: Exception) { + payload.toString() } } - return null } + private fun stateAsJson(state: Any?): String? { + if (state == null) return null + return try { + objectMapper.writeValueAsString(state) + } catch (e: Exception) { + logger.debug("Could not serialize entity state of type [{}]: {}", + state::class.java.name, e.message) + null + } + } + + // ------------------------------------------------------------------------------------------ + // Query handlers + // ------------------------------------------------------------------------------------------ + private fun handleDomainEvents(query: ModelDomainEventsQuery): DomainEventsResult { - logger.info("Handling Axoniq Platform MODEL_DOMAIN_EVENTS query for entity type [{}] id [{}]", - query.entityType, query.entityId) + logger.info("Handling Axoniq Platform MODEL_DOMAIN_EVENTS query for entity [{}] id [{}] idType [{}]", + query.entityType, query.entityId, query.idType) - val criteria = resolveCriteria(query.entityType, query.entityId) + val entityClass = Class.forName(query.entityType) + val idClass = Class.forName(query.idType) + val criteria = resolveCriteria(entityClass, idClass, query.entityId) val condition = SourcingCondition.conditionFor(criteria) - // Use reduce() which returns a CompletableFuture that completes when the stream is done. - // MessageStream is asynchronous by design; reduce is the recommended way to fully consume a finite stream. val stream = eventStorageEngine.source(condition) val allEvents = try { stream.reduce(mutableListOf()) { acc, entry -> val message = entry.message() - // Skip the terminal marker that EventStorageEngine.source() always appends at the end if (message != null && message !is TerminalEventMessage) { acc.add(DomainEvent( sequenceNumber = acc.size.toLong(), @@ -466,11 +645,12 @@ open class RSocketModelInspectionResponder( acc }.get() } catch (e: Exception) { - logger.error("Error while sourcing events for entity type [{}] id [{}]", query.entityType, query.entityId, e) + logger.error("Error while sourcing events for entity [{}] id [{}]", + query.entityType, query.entityId, e) mutableListOf() } - logger.info("Sourced [{}] events for entity type [{}] id [{}]", + logger.info("Sourced [{}] events for entity [{}] id [{}]", allEvents.size, query.entityType, query.entityId) val totalCount = allEvents.size.toLong() @@ -488,125 +668,309 @@ open class RSocketModelInspectionResponder( ) } + /** + * Reconstructs the entity's state at a specific sequence number by replaying all events + * up to (and including) that sequence through the registered [EntityEvolver]. Returns + * the JSON-serialized state. If no EntityEvolver is registered for the entity, returns + * null state — frontend can treat that as "state reconstruction not available". + */ private fun handleEntityStateAtSequence(query: ModelEntityStateAtSequenceQuery): EntityStateResult { - logger.info("Handling Axoniq Platform MODEL_ENTITY_STATE_AT_SEQUENCE query for entity type [{}] id [{}] seq [{}]", - query.entityType, query.entityId, query.maxSequenceNumber) - - val criteria = resolveCriteria(query.entityType, query.entityId) + logger.info("Handling Axoniq Platform MODEL_ENTITY_STATE_AT_SEQUENCE query for entity [{}] id [{}] idType [{}] seq [{}]", + query.entityType, query.entityId, query.idType, query.maxSequenceNumber) + + val entityClass = Class.forName(query.entityType) + val idClass = Class.forName(query.idType) + val typedId = deserializeEntityId(query.entityId, idClass) + ?: throw IllegalArgumentException("Could not deserialize id [${query.entityId}] as [${query.idType}]") + val criteria = resolveCriteriaWithTypedId(entityClass, idClass, typedId) val condition = SourcingCondition.conditionFor(criteria) + val evolver = obtainInitializingEvolver(entityClass, idClass) + + if (evolver == null) { + logger.warn("No InitializingEntityEvolver found for entity [{}] / id [{}] — returning null state", + query.entityType, query.idType) + return EntityStateResult( + type = query.entityType, + entityId = query.entityId, + maxSequenceNumber = query.maxSequenceNumber, + state = null, + ) + } - // Collect all events up to (and including) the max sequence number + // Accumulator: (eventsConsumedSoFar, currentState). MessageStream doesn't expose the + // entry index, so we maintain it inline. val stream = eventStorageEngine.source(condition) - val collected = try { - stream.reduce(mutableListOf()) { acc, entry -> + val finalState = try { + stream.reduce(StateHolder(0L, null)) { holder, entry -> val message = entry.message() - // Skip the terminal marker that EventStorageEngine.source() always appends at the end - if (message !is TerminalEventMessage) { - // Include events up to and including the requested sequence number (0-indexed). - // A negative maxSequenceNumber means "all events". - val currentIndex = acc.size.toLong() - if (query.maxSequenceNumber < 0 || currentIndex <= query.maxSequenceNumber) { - acc.add(message.payload()) - } - } - acc - }.get() + if (message == null || message is TerminalEventMessage) return@reduce holder + // Sequence numbers are 0-indexed by event order. Negative maxSequenceNumber = "all events". + val withinWindow = query.maxSequenceNumber < 0 || holder.index <= query.maxSequenceNumber + if (!withinWindow) return@reduce holder + StateHolder(holder.index + 1, evolveSafely(evolver, typedId, holder.state, deserializePayload(message))) + }.get().state } catch (e: Exception) { - logger.error("Error while sourcing events for entity type [{}] id [{}]", query.entityType, query.entityId, e) - mutableListOf() - } - - logger.info("Sourced [{}] events for entity state reconstruction of [{}] id [{}] at seq [{}]", - collected.size, query.entityType, query.entityId, query.maxSequenceNumber) - - val state = collected.lastOrNull()?.let { payload -> - when (payload) { - is ByteArray -> String(payload, Charsets.UTF_8) - is String -> payload - else -> try { - objectMapper.writeValueAsString(payload) - } catch (e: Exception) { - payload.toString() - } - } + logger.error("Error while reconstructing state for entity [{}] id [{}]", + query.entityType, query.entityId, e) + null } return EntityStateResult( type = query.entityType, entityId = query.entityId, - // Echo back the requested sequence number so the UI can navigate between sequences correctly maxSequenceNumber = query.maxSequenceNumber, - state = state, + state = stateAsJson(finalState), ) } + /** Reduce accumulator that tracks the running event index alongside the evolved state. */ + private data class StateHolder(val index: Long, val state: Any?) + + private fun evolveSafely( + evolver: InitializingEntityEvolver, + typedId: Any, + currentState: Any?, + message: EventMessage, + ): Any? { + return try { + // Capture JSON before AF5 dispatch so we can detect whether the metamodel actually + // mutated the entity, and so the [Evolve] log shows the real pre-mutation state. + // Without this snapshot, since entities mutate in place via @EventSourcingHandler, + // re-serializing currentState after evolve would just print the post-mutation state. + val beforeJson = if (currentState != null) safeJson(currentState) else "null" + + val result = invokeEvolveWithNullContext(evolver, typedId, currentState, message) + + val afterJsonInitial = if (result != null) safeJson(result) else "null" + val mutatedByMetamodel = currentState != null && beforeJson != afterJsonInitial + // If state already changed, the metamodel did its job — don't double-apply via + // reflection. If no change AND we have a non-null entity AND a typed payload, try + // direct reflection dispatch on the entity class. + val finalResult = if (!mutatedByMetamodel && result != null && message.payload() != null) { + applyEventViaReflection(result, message) + result + } else { + result + } + + if (logger.isInfoEnabled) { + logger.info("[Evolve] event=[{}] before=[{}] after=[{}] beforeIs=[{}] afterIs=[{}] reflectionFallback=[{}]", + message.payloadType().simpleName, + beforeJson, + safeJson(finalResult), + currentState?.let { System.identityHashCode(it) } ?: 0, + finalResult?.let { System.identityHashCode(it) } ?: 0, + !mutatedByMetamodel && result != null) + } + finalResult + } catch (e: Exception) { + logger.warn("EntityEvolver.evolve threw for event [{}]: {} — keeping previous state", + message.payloadType().name, e.cause?.message ?: e.message, e) + currentState + } + } + /** - * Handles a timeline replay query: sources all events for the given entity, then constructs - * a list of [ModelTimelineEntry] capturing the entity's approximated state before and after each event. + * Reflection-based fallback for entities where AF5's [AnnotationBasedEntityEvolvingComponent] + * dispatch silently no-ops in an ad-hoc inspection context (no real + * [org.axonframework.messaging.core.unitofwork.ProcessingContext], no message handler + * interceptor chain, etc.). + * + * Walks the entity's class hierarchy looking for methods annotated with + * [EventSourcingHandler] whose first parameter type accepts the message payload. Each match + * is invoked directly on the entity. The method is expected to mutate `this` in place + * (typical AF5 pattern: `void on(SomeEvent e) { this.field = e.field(); }`). For methods + * with extra parameters (e.g. for parameter resolvers), passes `null` so we don't break + * compilation, but those handlers may NPE — caught at the boundary. * - * State reconstruction strategy (v1): - * Since assembling the real AF5 [org.axonframework.modelling.EntityEvolver] at runtime requires - * numerous core components from the Configuration (ParameterResolverFactory, MessageTypeResolver, - * MessageConverter, EventConverter) and access to the per-entity [AnnotatedEntityMetamodel], this - * initial implementation uses a pragmatic JSON deep-merge approximation: - * - Start with an empty JSON object as the accumulated state. - * - For each event, parse its payload as JSON (if possible) and deep-merge its fields into the - * accumulator. Primitive values overwrite, nested objects recurse, arrays replace. - * This is NOT the exact domain state that the framework would load via [StateManager.loadEntity], - * but it is visually useful for showing how fields evolve over time and supports diff rendering. - * A follow-up can replace this with true EntityEvolver-based reconstruction once we have a clean - * way to obtain the evolver for an arbitrary entity type. + * Idempotent re-invocation of an event handler is the caller's contract; we only invoke this + * path when the AF5 dispatch produced no observable mutation, so we won't re-apply changes. */ - private fun handleTimelineReplay(query: ModelTimelineQuery): ModelTimelineResult { - logger.info("Handling Axoniq Platform MODEL_REPLAY_TIMELINE query for entity type [{}] id [{}] offset [{}] limit [{}]", - query.entityType, query.entityId, query.offset, query.limit) + internal fun applyEventViaReflection(entity: Any, message: EventMessage) { + val payload = message.payload() ?: return + val payloadCls = payload.javaClass + val handlerCandidates = collectEventSourcingHandlers(entity.javaClass) + + // Path A: payload was already deserialized by [deserializePayload] (works when + // event.type().name() is a FQN that Class.forName resolves, e.g. with + // ClassBasedMessageTypeResolver). Match handlers whose first parameter accepts the + // typed payload directly. + if (payloadCls != ByteArray::class.java) { + for ((declaringClass, method) in handlerCandidates) { + val paramType = method.parameterTypes[0] + if (!paramType.isInstance(payload)) continue + invokeHandlerSafely(entity, declaringClass, method, payload, paramType) + return + } + return + } + + // Path B: payload is still raw byte[] because Class.forName couldn't resolve the type + // name (common with @Event(namespace=...) which produces names like + // "quickstart.OrderCreatedEvent" that don't match a real class). Resolve the handler by + // matching the simple class name extracted from message.type().name() against each + // handler's parameter simple name — picking exactly one. Convert byte[] to that + // specific type only. This avoids Jackson's permissive deserialization successfully + // returning a partially-filled instance of the wrong event class (e.g. OrderShippedEvent + // accepting an OrderCreatedEvent JSON because they share an `orderId` field). + val converter = payloadConverter ?: return + val typeName = message.type()?.name() ?: return + val expectedSimpleName = simpleNameFromMessageType(typeName) + val match = handlerCandidates.firstOrNull { (_, method) -> + method.parameterTypes[0].simpleName == expectedSimpleName + } ?: run { + logger.debug("[ModelInspection] No @EventSourcingHandler param simpleName matches [{}] (from type=[{}]) on [{}]", + expectedSimpleName, typeName, entity.javaClass.simpleName) + return + } + val (declaringClass, method) = match + val paramType = method.parameterTypes[0] + val typedArg: Any? = try { + message.payloadAs(paramType, converter) + } catch (e: Exception) { + logger.debug("[ModelInspection] Converter failed for type=[{}] -> param=[{}]: {}", + typeName, paramType.simpleName, e.message) + null + } + if (typedArg == null || !paramType.isInstance(typedArg)) { + logger.debug("[ModelInspection] Converter returned non-matching payload for type=[{}] (target=[{}])", + typeName, paramType.simpleName) + return + } + invokeHandlerSafely(entity, declaringClass, method, typedArg, paramType) + } - val criteria = resolveCriteria(query.entityType, query.entityId) + /** + * Extracts the simple Java class name from an [org.axonframework.messaging.core.MessageType] + * name string. Handles both formats: + * - FQN with optional `$` (nested class): `io.axoniq.quickstart.reservation.event.ReservationEvents$SeatReservedEvent` + * → `SeatReservedEvent` + * - Namespaced short form from `@Event(namespace=...)`: `quickstart.OrderCreatedEvent` + * → `OrderCreatedEvent` + */ + internal fun simpleNameFromMessageType(typeName: String): String { + val afterDollar = typeName.substringAfterLast('$') + return afterDollar.substringAfterLast('.') + } + + private fun invokeHandlerSafely( + entity: Any, + declaringClass: Class<*>, + method: java.lang.reflect.Method, + payload: Any, + paramType: Class<*>, + ) { + try { + method.isAccessible = true + val args: Array = if (method.parameterCount == 1) { + arrayOf(payload) + } else { + Array(method.parameterCount) { idx -> if (idx == 0) payload else null } + } + method.invoke(entity, *args) + logger.debug("[ModelInspection] Reflection dispatch invoked [{}#{}] for param=[{}]", + declaringClass.simpleName, method.name, paramType.simpleName) + } catch (e: Exception) { + logger.debug("[ModelInspection] Reflection dispatch failed for [{}#{}]: {}", + declaringClass.simpleName, method.name, e.cause?.message ?: e.message) + } + } + + /** + * Walks the entity's class hierarchy collecting `@EventSourcingHandler` methods with at + * least one parameter (the event). Returned in declaring-class-first order. + */ + private fun collectEventSourcingHandlers(entityClass: Class<*>): List, java.lang.reflect.Method>> { + val result = mutableListOf, java.lang.reflect.Method>>() + var current: Class<*>? = entityClass + while (current != null && current != Any::class.java) { + for (method in current.declaredMethods) { + if (!method.isAnnotationPresent(EventSourcingHandler::class.java)) continue + if (method.parameterCount < 1) continue + result.add(current to method) + } + current = current.superclass + } + return result + } + + private fun safeJson(value: Any?): String = + if (value == null) "null" + else try { + objectMapper.writeValueAsString(value) + } catch (e: Exception) { + "" + } + + /** + * Replays all events for the entity through the registered [EntityEvolver] and emits a + * timeline of `(sequence, event, stateBefore, stateAfter)` entries within the requested window. + * + * State serialization is JSON via Jackson (handles records, Kotlin data classes, POJOs). + * State strings are byte-truncated via [String.truncateToBytes] so a single oversize entity + * cannot blow gRPC/RSocket message size limits. + * + * If no EntityEvolver is registered for the entity (e.g. non-event-sourced repositories), + * `stateBefore`/`stateAfter` are emitted as null and the frontend can collapse that section. + */ + private fun handleTimelineReplay(query: ModelTimelineQuery): ModelTimelineResult { + logger.info("Handling Axoniq Platform MODEL_REPLAY_TIMELINE query for entity [{}] id [{}] idType [{}] offset [{}] limit [{}]", + query.entityType, query.entityId, query.idType, query.offset, query.limit) + + val entityClass = Class.forName(query.entityType) + val idClass = Class.forName(query.idType) + val typedId = deserializeEntityId(query.entityId, idClass) + ?: throw IllegalArgumentException("Could not deserialize id [${query.entityId}] as [${query.idType}]") + val criteria = resolveCriteriaWithTypedId(entityClass, idClass, typedId) val condition = SourcingCondition.conditionFor(criteria) + val evolver = obtainInitializingEvolver(entityClass, idClass) + if (evolver == null) { + logger.warn("No InitializingEntityEvolver found for entity [{}] / id [{}] — timeline state fields will be null", + query.entityType, query.idType) + } val offset = maxOf(0, query.offset) val limit = if (query.limit <= 0) 100 else query.limit - // Cap each state string to avoid blowing gRPC / RSocket message size limits. - val maxStateSizeBytes = 10 * 1024 // 10 KB per state snapshot + val maxStateSizeBytes = 100 * 1024 // 100 KB per state snapshot before truncation + val maxEventSizeBytes = 50 * 1024 // 50 KB per event payload before truncation + val entries = mutableListOf() var totalEvents = 0 - var currentState: JsonNode = objectMapper.createObjectNode() + var currentState: Any? = null val stream = eventStorageEngine.source(condition) try { stream.reduce(Unit) { _, entry -> val message = entry.message() - if (message !is TerminalEventMessage) { - val seq = totalEvents.toLong() - totalEvents++ - // Always evolve state so stateBefore for the first event in the window is correct. - val payloadString = extractPayloadAsString(message) - val incomingNode = parseJsonOrNull(payloadString) - val evolvedState = if (incomingNode != null) { - mergeJsonState(currentState, incomingNode) - } else { - currentState - } - // Only serialize + collect entries inside the [offset, offset + limit) window. - if (seq >= offset && entries.size < limit) { - val stateBeforeJson = serializeJsonNode(currentState) - val stateAfterJson = serializeJsonNode(evolvedState) - entries.add(ModelTimelineEntry( - sequenceNumber = seq, - // ISO-8601 string avoids CBOR/Jackson Instant ambiguity. - timestamp = message.timestamp().toString(), - eventType = extractPayloadTypeName(message), - eventPayload = truncateString(payloadString, maxStateSizeBytes), - stateBefore = truncateString(stateBeforeJson, maxStateSizeBytes), - stateAfter = truncateString(stateAfterJson, maxStateSizeBytes), - )) - } - currentState = evolvedState + if (message == null || message is TerminalEventMessage) return@reduce Unit + + val seq = totalEvents.toLong() + totalEvents++ + + // Capture stateBefore JSON eagerly. Reservation-style entities mutate in place + // via reflection fallback (see [evolveSafely]) so currentState and nextState are + // the same instance. If we serialize stateBefore after evolveSafely, both fields + // would show the post-mutation state and the timeline UI couldn't diff between + // events. Snapshot the JSON now while currentState is still pre-mutation. + val stateBeforeJson = stateAsJson(currentState).truncateToBytes(maxStateSizeBytes) + + val nextState = evolver?.let { evolveSafely(it, typedId, currentState, deserializePayload(message)) } ?: currentState + + if (seq >= offset && entries.size < limit) { + entries.add(ModelTimelineEntry( + sequenceNumber = seq, + timestamp = message.timestamp().toString(), + eventType = extractPayloadTypeName(message), + eventPayload = extractPayloadAsString(message).truncateToBytes(maxEventSizeBytes), + stateBefore = stateBeforeJson, + stateAfter = stateAsJson(nextState).truncateToBytes(maxStateSizeBytes), + )) } + currentState = nextState Unit }.get() } catch (e: Exception) { - logger.error("Error while sourcing events for timeline of entity type [{}] id [{}]", + logger.error("Error while sourcing events for timeline of entity [{}] id [{}]", query.entityType, query.entityId, e) } @@ -624,64 +988,4 @@ open class RSocketModelInspectionResponder( truncated = truncated, ) } - - /** - * Truncates a string to at most [maxBytes] bytes (UTF-8). Appends a truncation marker if truncated. - */ - private fun truncateString(value: String?, maxBytes: Int): String? { - if (value == null) return null - val bytes = value.toByteArray(Charsets.UTF_8) - if (bytes.size <= maxBytes) return value - return String(bytes, 0, maxBytes, Charsets.UTF_8) + "\n... (truncated)" - } - - /** - * Parses the given string as a JSON tree. Returns null when the input is null/blank or when parsing fails. - */ - private fun parseJsonOrNull(raw: String?): JsonNode? { - if (raw.isNullOrBlank()) return null - return try { - objectMapper.readTree(raw) - } catch (e: Exception) { - null - } - } - - /** - * Serializes a JsonNode to a pretty-printed JSON string. Returns null on failure. - */ - private fun serializeJsonNode(node: JsonNode): String? { - return try { - objectMapper.writeValueAsString(node) - } catch (e: Exception) { - null - } - } - - /** - * Deep-merges [incoming] into [current] and returns a new JsonNode representing the merged state. - * - If both sides are objects, fields are merged recursively (incoming overrides on conflict). - * - If [incoming] is not an object, it replaces [current] entirely. - * - Arrays from [incoming] replace arrays in [current] (no element-level merging). - * Does not mutate the inputs. - */ - private fun mergeJsonState(current: JsonNode, incoming: JsonNode): JsonNode { - if (!current.isObject || !incoming.isObject) { - return incoming - } - val result: ObjectNode = (current as ObjectNode).deepCopy() - val incomingObj = incoming as ObjectNode - val fieldNames = incomingObj.fieldNames() - while (fieldNames.hasNext()) { - val name = fieldNames.next() - val incomingValue = incomingObj.get(name) - val existing = result.get(name) - if (existing != null && existing.isObject && incomingValue.isObject) { - result.set(name, mergeJsonState(existing, incomingValue)) - } else { - result.set(name, incomingValue) - } - } - return result - } } diff --git a/framework-client/src/test/kotlin/io/axoniq/platform/framework/eventsourcing/AxoniqPlatformModelInspectionEnhancerTest.kt b/framework-client/src/test/kotlin/io/axoniq/platform/framework/eventsourcing/AxoniqPlatformModelInspectionEnhancerTest.kt new file mode 100644 index 0000000..d67617d --- /dev/null +++ b/framework-client/src/test/kotlin/io/axoniq/platform/framework/eventsourcing/AxoniqPlatformModelInspectionEnhancerTest.kt @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2022-2026. AxonIQ B.V. + * + * Licensed 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 io.axoniq.platform.framework.eventsourcing + +import io.axoniq.platform.framework.client.RSocketHandlerRegistrar +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.axonframework.common.configuration.ComponentDefinition +import org.axonframework.common.configuration.ComponentRegistry +import org.axonframework.eventsourcing.eventstore.EventStorageEngine +import org.axonframework.modelling.StateManager +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +/** + * Verifies the registration guard in [AxoniqPlatformModelInspectionEnhancer]: the + * inspection responder must register only when the application has all three required + * components (StateManager + EventStorageEngine + RSocketHandlerRegistrar). Missing any + * of them is the AF4 / non-event-sourced case and registering would NPE on first request. + */ +class AxoniqPlatformModelInspectionEnhancerTest { + + private val enhancer = AxoniqPlatformModelInspectionEnhancer() + + @Test + fun `registers the responder when StateManager, EventStorageEngine and RSocketHandlerRegistrar are all present`() { + val registry = mockk(relaxed = true) + every { registry.hasComponent(StateManager::class.java) } returns true + every { registry.hasComponent(EventStorageEngine::class.java) } returns true + every { registry.hasComponent(RSocketHandlerRegistrar::class.java) } returns true + + enhancer.enhance(registry) + + verify(exactly = 1) { registry.registerComponent(any>()) } + } + + @Test + fun `skips registration when StateManager is missing — typical AF4 application`() { + val registry = mockk(relaxed = true) + every { registry.hasComponent(StateManager::class.java) } returns false + every { registry.hasComponent(EventStorageEngine::class.java) } returns true + every { registry.hasComponent(RSocketHandlerRegistrar::class.java) } returns true + + enhancer.enhance(registry) + + verify(exactly = 0) { registry.registerComponent(any>()) } + } + + @Test + fun `skips registration when EventStorageEngine is missing`() { + val registry = mockk(relaxed = true) + every { registry.hasComponent(StateManager::class.java) } returns true + every { registry.hasComponent(EventStorageEngine::class.java) } returns false + every { registry.hasComponent(RSocketHandlerRegistrar::class.java) } returns true + + enhancer.enhance(registry) + + verify(exactly = 0) { registry.registerComponent(any>()) } + } + + @Test + fun `skips registration when RSocketHandlerRegistrar is missing — console client not wired`() { + val registry = mockk(relaxed = true) + every { registry.hasComponent(StateManager::class.java) } returns true + every { registry.hasComponent(EventStorageEngine::class.java) } returns true + every { registry.hasComponent(RSocketHandlerRegistrar::class.java) } returns false + + enhancer.enhance(registry) + + verify(exactly = 0) { registry.registerComponent(any>()) } + } + + @Test + fun `runs after the platform configurer enhancer so its components are visible`() { + // The responder builder reads multiple components from the configuration during + // start; this enhancer must run AFTER the platform enhancer that registers them. + // Concretely: order > PLATFORM_ENHANCER_ORDER (currently +1 above it). + val platformOrder = io.axoniq.platform.framework.AxoniqPlatformConfigurerEnhancer.PLATFORM_ENHANCER_ORDER + assertEquals(platformOrder + 1, enhancer.order()) + } +} diff --git a/framework-client/src/test/kotlin/io/axoniq/platform/framework/eventsourcing/RSocketModelInspectionResponderHelpersTest.kt b/framework-client/src/test/kotlin/io/axoniq/platform/framework/eventsourcing/RSocketModelInspectionResponderHelpersTest.kt new file mode 100644 index 0000000..32d1303 --- /dev/null +++ b/framework-client/src/test/kotlin/io/axoniq/platform/framework/eventsourcing/RSocketModelInspectionResponderHelpersTest.kt @@ -0,0 +1,202 @@ +/* + * Copyright (c) 2022-2026. AxonIQ B.V. + * + * Licensed 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 io.axoniq.platform.framework.eventsourcing + +import io.axoniq.platform.framework.client.RSocketHandlerRegistrar +import io.mockk.mockk +import org.axonframework.common.configuration.Configuration +import org.axonframework.eventsourcing.eventstore.EventStorageEngine +import org.axonframework.modelling.StateManager +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.math.BigDecimal +import java.math.BigInteger +import java.util.UUID + +/** + * Unit tests for the pure-logic helpers on [RSocketModelInspectionResponder] that don't + * require a live AF5 configuration / event store. These helpers govern public-facing + * behaviour (id-type description for the FE form, MessageType-name parsing for handler + * dispatch) so regressing them silently breaks the inspection UI. + */ +class RSocketModelInspectionResponderHelpersTest { + + private lateinit var responder: RSocketModelInspectionResponder + + @BeforeEach + fun setUp() { + // The helpers under test never reach into these dependencies, so simple unrecorded + // mocks are enough — we don't need MockK relaxed mocks elsewhere. + responder = RSocketModelInspectionResponder( + stateManager = mockk(), + eventStorageEngine = mockk(), + registrar = mockk(), + configuration = mockk(), + ) + } + + // --------------------------------------------------------------------------------------- + // simpleNameFromMessageType + // + // Drives Path B handler resolution in applyEventViaReflection. Must produce the same + // simple name regardless of whether the MessageType.name() is a fully qualified class + // name (default ClassBasedMessageTypeResolver) or a namespaced short name from + // @Event(namespace = ...). + // --------------------------------------------------------------------------------------- + + @Test + fun `simpleNameFromMessageType strips package for fully qualified class name`() { + val name = "io.axoniq.quickstart.reservation.event.ReservationEvents\$SeatReservedEvent" + assertEquals("SeatReservedEvent", responder.simpleNameFromMessageType(name)) + } + + @Test + fun `simpleNameFromMessageType strips namespace for short namespaced form`() { + // Format produced by @Event(namespace = "quickstart") on a record class + assertEquals("OrderCreatedEvent", responder.simpleNameFromMessageType("quickstart.OrderCreatedEvent")) + } + + @Test + fun `simpleNameFromMessageType is identity for a single segment`() { + assertEquals("Foo", responder.simpleNameFromMessageType("Foo")) + } + + @Test + fun `simpleNameFromMessageType prefers dollar over dot when both present`() { + // A nested class with a namespaced prefix would be a strange case but the heuristic + // still produces the right simple class name. + assertEquals("Inner", responder.simpleNameFromMessageType("quickstart.Outer\$Inner")) + } + + // --------------------------------------------------------------------------------------- + // isSimpleIdType + // + // Drives whether the FE renders a single text input or a multi-field form. False + // negatives produce a wrong UI for compound ids. + // --------------------------------------------------------------------------------------- + + @Test + fun `isSimpleIdType is true for String UUID and primitives`() { + assertTrue(responder.isSimpleIdType(String::class.java)) + assertTrue(responder.isSimpleIdType(UUID::class.java)) + assertTrue(responder.isSimpleIdType(java.lang.Long::class.java)) + assertTrue(responder.isSimpleIdType(java.lang.Integer::class.java)) + assertTrue(responder.isSimpleIdType(java.lang.Long.TYPE)) // primitive long + assertTrue(responder.isSimpleIdType(java.lang.Integer.TYPE)) // primitive int + assertTrue(responder.isSimpleIdType(BigDecimal::class.java)) + assertTrue(responder.isSimpleIdType(BigInteger::class.java)) + } + + @Test + fun `isSimpleIdType is true for enums`() { + assertTrue(responder.isSimpleIdType(SampleEnumId::class.java)) + } + + @Test + fun `isSimpleIdType is false for record-style compound ids`() { + assertFalse(responder.isSimpleIdType(SampleCompoundId::class.java)) + } + + @Test + fun `isSimpleIdType unwraps Kotlin value classes and tests their underlying type`() { + // SampleValueId wraps a String, so it should be classified as simple. + assertTrue(responder.isSimpleIdType(SampleValueId::class.java)) + } + + // --------------------------------------------------------------------------------------- + // normalizedType + // + // Maps Java types to FE-friendly strings consumed by EntityIdForm.vue field renderer. + // --------------------------------------------------------------------------------------- + + @Test + fun `normalizedType maps common Java types to FE strings`() { + assertEquals("string", responder.normalizedType(String::class.java)) + assertEquals("uuid", responder.normalizedType(UUID::class.java)) + assertEquals("number", responder.normalizedType(java.lang.Long::class.java)) + assertEquals("number", responder.normalizedType(java.lang.Integer.TYPE)) // primitive + assertEquals("number", responder.normalizedType(BigDecimal::class.java)) + assertEquals("boolean", responder.normalizedType(java.lang.Boolean::class.java)) + assertEquals("boolean", responder.normalizedType(java.lang.Boolean.TYPE)) + assertEquals("string", responder.normalizedType(java.lang.Character::class.java)) + // Anything we don't have a special case for falls through to "object". + assertEquals("object", responder.normalizedType(SampleCompoundId::class.java)) + } + + // --------------------------------------------------------------------------------------- + // describeIdFields + // + // This shape directly drives the FE multi-field form. Records expose recordComponents, + // POJOs expose declared fields, and simple types collapse to an empty list (single + // text input on the FE). + // --------------------------------------------------------------------------------------- + + @Test + fun `describeIdFields returns empty for simple id types`() { + assertTrue(responder.describeIdFields(String::class.java).isEmpty()) + assertTrue(responder.describeIdFields(UUID::class.java).isEmpty()) + assertTrue(responder.describeIdFields(java.lang.Long::class.java).isEmpty()) + } + + @Test + fun `describeIdFields exposes record components in declaration order with normalized types`() { + val descriptors = responder.describeIdFields(SampleCompoundId::class.java) + assertEquals(2, descriptors.size) + + assertEquals("showId", descriptors[0].name) + assertEquals("string", descriptors[0].type) + assertEquals(String::class.java.name, descriptors[0].javaType) + + assertEquals("seatNumber", descriptors[1].name) + assertEquals("number", descriptors[1].type) + assertEquals(java.lang.Integer.TYPE.name, descriptors[1].javaType) + } + + @Test + fun `describeIdFields exposes plain POJO declared fields and skips static synthetic`() { + val descriptors = responder.describeIdFields(SamplePojoId::class.java) + // STATIC_FIELD must not appear; only `tenant` and `code`. + assertEquals(listOf("tenant", "code"), descriptors.map { it.name }) + assertEquals(listOf("string", "number"), descriptors.map { it.type }) + } + + // --------------------------------------------------------------------------------------- + // Test fixtures + // --------------------------------------------------------------------------------------- + + enum class SampleEnumId { A, B } + + /** A typical AF5 compound entity id (record). Mirrors `ReservationId(showId, seatNumber)`. */ + @JvmRecord + data class SampleCompoundId(val showId: String, val seatNumber: Int) + + /** A plain POJO id with a static field that must be ignored. */ + @Suppress("unused") + class SamplePojoId(val tenant: String, val code: Int) { + companion object { + @JvmStatic + val STATIC_FIELD: String = "ignore-me" + } + } + + /** A Kotlin inline value class wrapping a String — should classify as simple. */ + @JvmInline + value class SampleValueId(val raw: String) +} diff --git a/framework-client/src/test/kotlin/io/axoniq/platform/framework/eventsourcing/RSocketModelInspectionResponderReflectionDispatchTest.kt b/framework-client/src/test/kotlin/io/axoniq/platform/framework/eventsourcing/RSocketModelInspectionResponderReflectionDispatchTest.kt new file mode 100644 index 0000000..ccbafd5 --- /dev/null +++ b/framework-client/src/test/kotlin/io/axoniq/platform/framework/eventsourcing/RSocketModelInspectionResponderReflectionDispatchTest.kt @@ -0,0 +1,224 @@ +/* + * Copyright (c) 2022-2026. AxonIQ B.V. + * + * Licensed 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 io.axoniq.platform.framework.eventsourcing + +import io.axoniq.platform.framework.client.RSocketHandlerRegistrar +import io.mockk.every +import io.mockk.mockk +import org.axonframework.common.configuration.Configuration +import org.axonframework.conversion.Converter +import org.axonframework.eventsourcing.annotation.EventSourcingHandler +import org.axonframework.eventsourcing.eventstore.EventStorageEngine +import org.axonframework.messaging.core.MessageType +import org.axonframework.messaging.eventhandling.EventMessage +import org.axonframework.modelling.StateManager +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +/** + * Tests the reflection-based `@EventSourcingHandler` dispatch fallback used when AF5's + * own metamodel dispatch silently no-ops in our ad-hoc inspection context (no real + * `ProcessingContext`, no interceptor chain). + * + * Two dispatch paths are exercised: + * + * - **Path A**: payload is already a typed instance (the FQN [MessageType.name] case where + * [RSocketModelInspectionResponder.deserializePayload] succeeded). The matching handler + * is found by parameter-type assignability. + * + * - **Path B**: payload is still raw `byte[]` because the [MessageType.name] is a short + * namespaced form (e.g. `quickstart.OrderCreatedEvent` from `@Event(namespace=...)`) + * that `Class.forName` can't resolve. The handler is selected by simple-name match + * against [MessageType.name] segments, then the [Converter] is invoked to turn the + * `byte[]` into the handler's parameter type. + * + * Critically: under Path B, Jackson permissive deserialization could happily build any of + * the entity's event classes from any JSON byte sequence (filling whatever fields match, + * defaulting the rest). We must pick the handler **before** invoking the converter, + * otherwise the wrong handler fires (e.g. an `OrderCreated` event mutating the entity as + * if it were `OrderShipped`). + */ +class RSocketModelInspectionResponderReflectionDispatchTest { + + private lateinit var responder: RSocketModelInspectionResponder + private lateinit var configuration: Configuration + private lateinit var converter: Converter + + @BeforeEach + fun setUp() { + configuration = mockk() + converter = mockk() + + // Lazy `payloadConverter` reads `configuration.getComponent(Converter::class.java)`. + // Stub it so Path B can use the converter. + every { configuration.getComponent(Converter::class.java) } returns converter + + responder = RSocketModelInspectionResponder( + stateManager = mockk(), + eventStorageEngine = mockk(), + registrar = mockk(), + configuration = configuration, + ) + } + + // --------------------------------------------------------------------------------------- + // Path A — payload already deserialized + // --------------------------------------------------------------------------------------- + + @Test + fun `Path A invokes the @EventSourcingHandler whose param matches the typed payload`() { + val entity = TestOrder() + val event = TestOrder.OrderCreatedEvent("order-1", "Alice") + val message = mockk() + every { message.payload() } returns event + // Path A doesn't read message.type() at all — we never reach the converter call. + + responder.applyEventViaReflection(entity, message) + + assertEquals("order-1", entity.orderId) + assertEquals("Alice", entity.customerName) + assertEquals(TestOrder.Status.CREATED, entity.status) + } + + @Test + fun `Path A is a no-op when no handler matches the typed payload`() { + val entity = TestOrder() + // Use an event class the entity has no handler for. + val unrelatedEvent = UnrelatedEvent("payload-content") + val message = mockk() + every { message.payload() } returns unrelatedEvent + + responder.applyEventViaReflection(entity, message) + + // Entity untouched because no handler accepted UnrelatedEvent. + assertNull(entity.orderId) + assertEquals(TestOrder.Status.DRAFT, entity.status) + } + + // --------------------------------------------------------------------------------------- + // Path B — raw byte[] payload, simple-name handler resolution + // --------------------------------------------------------------------------------------- + + @Test + fun `Path B selects the handler by simple-name match and converts raw byte payload to it`() { + val entity = TestOrder() + val rawBytes = "{\"orderId\":\"order-2\",\"customerName\":\"Bob\"}".toByteArray() + val typedEvent = TestOrder.OrderCreatedEvent("order-2", "Bob") + val message = mockk() + every { message.payload() } returns rawBytes + every { message.type() } returns MessageType("quickstart.OrderCreatedEvent") + // Converter sees the request for the handler's param type and returns a typed instance. + // Note: the simple-name resolver picks OrderCreatedEvent from `quickstart.OrderCreatedEvent`, + // so the converter is invoked with that type — never with OrderShippedEvent or any other. + every { + message.payloadAs(TestOrder.OrderCreatedEvent::class.java, converter) + } returns typedEvent + + responder.applyEventViaReflection(entity, message) + + assertEquals("order-2", entity.orderId) + assertEquals("Bob", entity.customerName) + assertEquals(TestOrder.Status.CREATED, entity.status) + } + + @Test + fun `Path B does not fire any handler when no entity handler param matches the simple-name`() { + val entity = TestOrder() + val rawBytes = "{\"foo\":\"bar\"}".toByteArray() + val message = mockk() + every { message.payload() } returns rawBytes + // Simple-name "Mystery" doesn't match any of TestOrder's handler param types. + every { message.type() } returns MessageType("some.namespace.Mystery") + + responder.applyEventViaReflection(entity, message) + + // No handler fired → entity stays at defaults. Crucially, the converter was never + // invoked either, because the resolver short-circuited on the missing simple-name. + assertNull(entity.orderId) + assertEquals(TestOrder.Status.DRAFT, entity.status) + } + + @Test + fun `Path B picks correct handler when multiple share an overlapping JSON shape`() { + // Regression guard: under the previous "try every handler with Jackson" approach, + // an OrderCreatedEvent JSON could permissively deserialize to OrderShippedEvent + // (sharing only `orderId`), causing the OrderShipped handler to fire and set + // status=SHIPPED instead of CREATED. The simple-name matcher prevents this. + val entity = TestOrder() + val createdJsonBytes = "{\"orderId\":\"order-3\",\"customerName\":\"Carol\"}".toByteArray() + val message = mockk() + every { message.payload() } returns createdJsonBytes + every { message.type() } returns MessageType("quickstart.OrderCreatedEvent") + + // Only the OrderCreated path should be exercised. We stub it; if the responder + // mistakenly tried OrderShipped, MockK would throw on the unstubbed call. + every { + message.payloadAs(TestOrder.OrderCreatedEvent::class.java, converter) + } returns TestOrder.OrderCreatedEvent("order-3", "Carol") + + responder.applyEventViaReflection(entity, message) + + assertEquals(TestOrder.Status.CREATED, entity.status) // not SHIPPED + assertEquals("order-3", entity.orderId) + assertEquals("Carol", entity.customerName) + } + + // --------------------------------------------------------------------------------------- + // Test fixtures + // --------------------------------------------------------------------------------------- + + /** + * Mirrors the AF5 entity pattern under test: no-arg constructor + several + * `@EventSourcingHandler` methods that mutate `this` in place. + */ + class TestOrder { + var orderId: String? = null + var customerName: String? = null + var carrier: String? = null + var status: Status = Status.DRAFT + + enum class Status { DRAFT, CREATED, SHIPPED } + + @EventSourcingHandler + fun on(event: OrderCreatedEvent) { + this.orderId = event.orderId + this.customerName = event.customerName + this.status = Status.CREATED + } + + @EventSourcingHandler + fun on(event: OrderShippedEvent) { + // If this handler ever fires on an OrderCreated payload (the "Jackson permissive" + // bug we guard against), `status` would jump straight to SHIPPED. + this.orderId = event.orderId + this.carrier = event.carrier + this.status = Status.SHIPPED + } + + @JvmRecord + data class OrderCreatedEvent(val orderId: String, val customerName: String) + + @JvmRecord + data class OrderShippedEvent(val orderId: String, val carrier: String) + } + + /** A class no `TestOrder` handler accepts — used to assert no-op behaviour. */ + @JvmRecord + data class UnrelatedEvent(val payload: String) +} From 9ee6d08d5940e7a25150e4db47dabb99a81703c2 Mon Sep 17 00:00:00 2001 From: Mitchell Herrijgers Date: Tue, 5 May 2026 13:37:28 +0100 Subject: [PATCH 5/7] Drive model inspection through real UnitOfWork and support modules This is a proposal PR to the original PR posed by Mirko, #146. It takes a different approach to the same problem, and functionally nothing has changed ## Why the proposal The original approach was very reflection-heavy, which is partly to be expected, but can also be improved upon. The more inner reflection is present, the more likely it's to break. In addition, the approach didn't account for entites in submodules for hierarchic contexts. As such, only top-level entities would be discovered. As this all requires very deep knowledge about how the Configuration of Axon Framework works, I decided to make this proposal PR to guide by example, instead of separate comments. ## Basics of the rewrite 1. The `UnitOfWorkFeactory.create()` is used during all sourcing and read operations. This way all parameter resolvers will work correctly. This takes away the need of manually invoking methods. 2. Repository instances are discovered at boot time. If it's an EventSourcingRepository, it's registeredd to the `RSocketModelInspectionResponder` 3. The `EventSourcingRepository` is rebuilt using reflection to be able to decorate the `EntityEvolver` with the `AxoniqPlatformEntityEvolver`. 4. Reading the event stream and entity state is now a `Repository.load(...)` operation. Several resources are put in the `ProcessingContext`, which the decorators respond to. a. `AxoniqPlatformEntityEvolver.BEFORE_CONSUMER`: If present, calls with the entity state before any evolve b. `AxoniqPlatformEntityEvolver.AFTER_CONSUMER`: If present, calls with the entity state after any evolve c. AxoniqPlatformEntityEvolver.MAX_INDEX: Stops evolving the entity after a certain index. ## Result With the new code a lot of the reflection-based code could be deleted. The RSocketModelInspectionResponder.kt shrunk by half its size. We have programmed against the interfaces of the framework, which are less likely to change. In addition I added tests that confirm it works. --- .../AxoniqPlatformConfigurerEnhancer.java | 10 +- .../AxoniqPlatformEntityEvolver.kt | 80 ++ ...AxoniqPlatformModelInspectionEnhancer.java | 51 +- .../ModelInspectionDecorators.java | 155 ++++ .../RSocketModelInspectionResponder.kt | 830 ++++-------------- .../framework/messaging/HandlerMeasurement.kt | 8 - .../modelling/AxoniqPlatformStateManager.kt | 6 +- .../modelling/ModellingDecorators.java | 12 +- .../io/axoniq/platform/framework/utils.kt | 15 +- ...oniqPlatformModelInspectionEnhancerTest.kt | 39 +- ...cketModelInspectionResponderHelpersTest.kt | 40 +- ...ModelInspectionResponderIntegrationTest.kt | 333 +++++++ ...ionResponderNestedModuleIntegrationTest.kt | 195 ++++ ...spectionResponderReflectionDispatchTest.kt | 224 ----- 14 files changed, 1014 insertions(+), 984 deletions(-) create mode 100644 framework-client/src/main/java/io/axoniq/platform/framework/eventsourcing/AxoniqPlatformEntityEvolver.kt create mode 100644 framework-client/src/main/java/io/axoniq/platform/framework/eventsourcing/ModelInspectionDecorators.java create mode 100644 framework-client/src/test/kotlin/io/axoniq/platform/framework/eventsourcing/RSocketModelInspectionResponderIntegrationTest.kt create mode 100644 framework-client/src/test/kotlin/io/axoniq/platform/framework/eventsourcing/RSocketModelInspectionResponderNestedModuleIntegrationTest.kt delete mode 100644 framework-client/src/test/kotlin/io/axoniq/platform/framework/eventsourcing/RSocketModelInspectionResponderReflectionDispatchTest.kt diff --git a/framework-client/src/main/java/io/axoniq/platform/framework/AxoniqPlatformConfigurerEnhancer.java b/framework-client/src/main/java/io/axoniq/platform/framework/AxoniqPlatformConfigurerEnhancer.java index b3846e2..4ff1282 100644 --- a/framework-client/src/main/java/io/axoniq/platform/framework/AxoniqPlatformConfigurerEnhancer.java +++ b/framework-client/src/main/java/io/axoniq/platform/framework/AxoniqPlatformConfigurerEnhancer.java @@ -165,6 +165,14 @@ public void enhance(ComponentRegistry registry) { UtilsKt.doOnSubModules(registry, (componentRegistry, module) -> { + // Only event processor modules expose a processorName; the doOnSubModules walker + // visits every sub-module (event-sourced entities, command/query modules, ...), so + // skipping non-processor modules here keeps AxoniqPlatformEventHandlingComponent + // from being constructed with a null processor name. + if (!(module instanceof PooledStreamingEventProcessorModule) + && !(module instanceof SubscribingEventProcessorModule)) { + return null; + } componentRegistry .registerDecorator(DecoratorDefinition.forType(EventHandlingComponent.class) .with((cc, name, delegate) -> @@ -176,7 +184,7 @@ public void enhance(ComponentRegistry registry) { .order(0)); return null; - }); + }, true); } /** diff --git a/framework-client/src/main/java/io/axoniq/platform/framework/eventsourcing/AxoniqPlatformEntityEvolver.kt b/framework-client/src/main/java/io/axoniq/platform/framework/eventsourcing/AxoniqPlatformEntityEvolver.kt new file mode 100644 index 0000000..757a3bf --- /dev/null +++ b/framework-client/src/main/java/io/axoniq/platform/framework/eventsourcing/AxoniqPlatformEntityEvolver.kt @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2022-2026. AxonIQ B.V. + * + * Licensed 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 io.axoniq.platform.framework.eventsourcing + +import org.axonframework.messaging.core.Context +import org.axonframework.messaging.core.unitofwork.ProcessingContext +import org.axonframework.messaging.eventhandling.EventMessage +import org.axonframework.modelling.EntityEvolver +import java.util.function.BiConsumer + +/** + * Wraps the underlying [EntityEvolver] of an [org.axonframework.eventsourcing.EventSourcingRepository] + * so that inspection-time replay can fire BEFORE/AFTER hooks per event without reimplementing + * AF5's dispatch. + * + * The hooks are no-ops unless the matching [Context.ResourceKey] resources are present on the + * [ProcessingContext], so command handling and normal event sourcing go through unchanged. + * + * Constructed by [AxoniqPlatformModelInspectionEnhancer], which detects the inner + * [org.axonframework.eventsourcing.EventSourcingRepository] in the decorator chain and reconstructs + * it with this wrapper substituted for its `entityEvolver` argument. + */ +class AxoniqPlatformEntityEvolver( + private val delegate: EntityEvolver, +) : EntityEvolver { + + companion object { + /** Called before [delegate.evolve]. Receives the event and the pre-evolve entity state. */ + val BEFORE_CONSUMER: Context.ResourceKey> = + Context.ResourceKey.withLabel("AxoniqPlatformEntityEvolver.BEFORE_CONSUMER") + + /** Called after [delegate.evolve]. Receives the event and the post-evolve entity state. */ + val AFTER_CONSUMER: Context.ResourceKey> = + Context.ResourceKey.withLabel("AxoniqPlatformEntityEvolver.AFTER_CONSUMER") + + /** + * Zero-based event index — when present, [evolve] returns the current entity unchanged + * once it has applied this many events. Lets inspection reconstruct state up to a given + * sequence without doing the bookkeeping outside the framework. + */ + val MAX_INDEX: Context.ResourceKey = + Context.ResourceKey.withLabel("AxoniqPlatformEntityEvolver.MAX_INDEX") + + /** Internal counter advanced by [evolve] when [MAX_INDEX] is set. */ + val INDEX_COUNTER: Context.ResourceKey = + Context.ResourceKey.withLabel("AxoniqPlatformEntityEvolver.INDEX_COUNTER") + } + + /** Tiny mutable holder so we can advance the index without re-putting a Long resource each call. */ + class LongCounter(var value: Long = 0) + + override fun evolve(entity: E, event: EventMessage, context: ProcessingContext): E { + val maxIndex = context.getResource(MAX_INDEX) + if (maxIndex != null) { + val counter = context.computeResourceIfAbsent(INDEX_COUNTER) { LongCounter() } + if (counter.value > maxIndex) { + return entity + } + counter.value++ + } + context.getResource(BEFORE_CONSUMER)?.accept(event, entity) + val result = delegate.evolve(entity, event, context) + context.getResource(AFTER_CONSUMER)?.accept(event, result) + return result + } +} diff --git a/framework-client/src/main/java/io/axoniq/platform/framework/eventsourcing/AxoniqPlatformModelInspectionEnhancer.java b/framework-client/src/main/java/io/axoniq/platform/framework/eventsourcing/AxoniqPlatformModelInspectionEnhancer.java index 2d0534e..07eb91f 100644 --- a/framework-client/src/main/java/io/axoniq/platform/framework/eventsourcing/AxoniqPlatformModelInspectionEnhancer.java +++ b/framework-client/src/main/java/io/axoniq/platform/framework/eventsourcing/AxoniqPlatformModelInspectionEnhancer.java @@ -18,39 +18,54 @@ import io.axoniq.platform.framework.AxoniqPlatformConfigurerEnhancer; import io.axoniq.platform.framework.client.RSocketHandlerRegistrar; -import org.axonframework.common.configuration.ComponentDefinition; import org.axonframework.common.configuration.ComponentRegistry; import org.axonframework.common.configuration.ConfigurationEnhancer; -import org.axonframework.common.lifecycle.Phase; -import org.axonframework.eventsourcing.eventstore.EventStorageEngine; -import org.axonframework.modelling.StateManager; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** - * Enhancer that registers the {@link RSocketModelInspectionResponder} when both - * {@link StateManager} and {@link EventStorageEngine} are available (AF5 applications). + * Service-loaded enhancer that wires the {@link RSocketModelInspectionResponder} when the + * application has the platform client connected ({@link RSocketHandlerRegistrar} present) and + * {@code axon-eventsourcing} is on the classpath. + * + *

This class is deliberately free of direct references to {@code axon-eventsourcing} types + * so it can be loaded even when event sourcing is absent from the classpath. The actual + * decorator wiring lives in {@link ModelInspectionDecorators} and is only touched after the + * runtime classpath probe succeeds.

+ * + *

We deliberately do not probe for {@code StateManager} either: it's registered by + * {@code ModellingConfigurationDefaults} at {@link Integer#MAX_VALUE}, after this enhancer's + * order, so the probe would falsely return {@code false} during boot.

*/ public class AxoniqPlatformModelInspectionEnhancer implements ConfigurationEnhancer { + private static final Logger logger = LoggerFactory.getLogger(AxoniqPlatformModelInspectionEnhancer.class); + private static final String EVENTSOURCING_PROBE_CLASS = "org.axonframework.eventsourcing.eventstore.EventStorageEngine"; + @Override public void enhance(ComponentRegistry registry) { - if (!registry.hasComponent(StateManager.class) - || !registry.hasComponent(EventStorageEngine.class) - || !registry.hasComponent(RSocketHandlerRegistrar.class)) { + if (!registry.hasComponent(RSocketHandlerRegistrar.class)) { return; } - - registry.registerComponent(ComponentDefinition - .ofType(RSocketModelInspectionResponder.class) - .withBuilder(c -> new RSocketModelInspectionResponder( - c.getComponent(StateManager.class), - c.getComponent(EventStorageEngine.class), - c.getComponent(RSocketHandlerRegistrar.class), - c)) - .onStart(Phase.EXTERNAL_CONNECTIONS, RSocketModelInspectionResponder::start)); + if (!isClasspathAvailable()) { + logger.debug("axon-eventsourcing not on classpath; skipping model inspection wiring."); + return; + } + ModelInspectionDecorators.apply(registry); } @Override public int order() { return AxoniqPlatformConfigurerEnhancer.PLATFORM_ENHANCER_ORDER + 1; } + + private static boolean isClasspathAvailable() { + try { + Class.forName(EVENTSOURCING_PROBE_CLASS, false, + AxoniqPlatformModelInspectionEnhancer.class.getClassLoader()); + return true; + } catch (ClassNotFoundException e) { + return false; + } + } } diff --git a/framework-client/src/main/java/io/axoniq/platform/framework/eventsourcing/ModelInspectionDecorators.java b/framework-client/src/main/java/io/axoniq/platform/framework/eventsourcing/ModelInspectionDecorators.java new file mode 100644 index 0000000..c1c8d2e --- /dev/null +++ b/framework-client/src/main/java/io/axoniq/platform/framework/eventsourcing/ModelInspectionDecorators.java @@ -0,0 +1,155 @@ +/* + * Copyright (c) 2022-2026. AxonIQ B.V. + * + * Licensed 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 io.axoniq.platform.framework.eventsourcing; + +import io.axoniq.platform.framework.ReflectionKt; +import io.axoniq.platform.framework.client.RSocketHandlerRegistrar; +import org.axonframework.common.configuration.ComponentDefinition; +import org.axonframework.common.configuration.ComponentRegistry; +import org.axonframework.common.configuration.DecoratorDefinition; +import org.axonframework.common.lifecycle.Phase; +import org.axonframework.eventsourcing.EventSourcedEntityFactory; +import org.axonframework.eventsourcing.EventSourcingRepository; +import org.axonframework.eventsourcing.eventstore.EventStorageEngine; +import org.axonframework.eventsourcing.eventstore.EventStore; +import org.axonframework.eventsourcing.handler.SourcingHandler; +import org.axonframework.modelling.EntityEvolver; +import org.axonframework.modelling.repository.Repository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.concurrent.CompletableFuture; + +/** + * Holds the actual decorator and component wiring for model inspection. Kept separate from + * {@link AxoniqPlatformModelInspectionEnhancer} so the enhancer class can be loaded even when + * {@code axon-eventsourcing} is not on the classpath — this class is only touched after a + * {@code Class.forName} probe confirms the module is present. + * + *

We do not walk submodules: AF5's nested module structure shares a single + * {@link org.axonframework.common.configuration.DefaultComponentRegistry} (each {@code BaseModule} + * resolves the parent's {@code ComponentRegistry} component instead of creating its own), so a + * single {@code Repository} decorator at the top covers every event-sourced entity in the + * application — top-level or arbitrarily nested.

+ */ +final class ModelInspectionDecorators { + + private static final Logger logger = LoggerFactory.getLogger(ModelInspectionDecorators.class); + + private ModelInspectionDecorators() { + } + + static void apply(ComponentRegistry registry) { + if (!registry.hasComponent(EventStorageEngine.class)) { + return; + } + // The enhancer pipeline can fire multiple times against the same registry as nested + // module configurations build. Idempotency guard: once the responder is in place, the + // decorator and its lifecycle hook are already registered, so re-running would + // duplicate the wrapping and double-evolve every event. + if (registry.hasComponent(RSocketModelInspectionResponder.class)) { + return; + } + + registry.registerComponent(ComponentDefinition + .ofType(RSocketModelInspectionResponder.class) + .withBuilder(c -> new RSocketModelInspectionResponder( + c.getComponent(EventStorageEngine.class), + c.getComponent(RSocketHandlerRegistrar.class), + c)) + .onStart(Phase.EXTERNAL_CONNECTIONS, RSocketModelInspectionResponder::start)); + + // Single decorator at the top covers every Repository registered in the application, + // top-level or nested — AF5's nested modules share the same component registry. + // + // The decorator reconstructs the underlying EventSourcingRepository with entityEvolver + // wrapped in AxoniqPlatformEntityEvolver. We deliberately do NOT decorate the registered + // EntityMetamodel — AnnotatedEventSourcedEntityModule casts the registered metamodel to + // AnnotatedEntityMetamodel inside its EntityIdResolver builder, and a wrapper would + // make that cast fail at startup. + // + // The .onStart hook then registers the rebuilt repository with the responder so it + // knows about this entity for the registered-entities query. + registry.registerDecorator(DecoratorDefinition + .forType(Repository.class) + .with((config, name, delegate) -> rebuildIfEventSourcingRepository(delegate)) + .onStart(Phase.LOCAL_MESSAGE_HANDLER_REGISTRATIONS, (configuration, component) -> { + configuration.getComponent(RSocketModelInspectionResponder.class) + .registerRepository(component); + return CompletableFuture.completedFuture(null); + })); + } + + /** + * Walks the wrapper chain from {@code delegate} downward to find an + * {@link EventSourcingRepository}, reconstructs that ESR with {@code entityEvolver} wrapped + * in {@link AxoniqPlatformEntityEvolver}, and swaps the wrapping component's {@code delegate} + * field to point at the new ESR. The outer wrapper(s) are kept intact so any platform-side + * decoration (e.g. {@code AxoniqPlatformRepository} for metrics) still applies. + * + *

Why peel rather than match {@code instanceof EventSourcingRepository} on the input: + * by the time this decorator runs, lower-order decorators (notably the metrics-adding + * {@code AxoniqPlatformRepository} from the modelling layer at {@code Integer.MIN_VALUE}) + * have already wrapped the ESR. Matching directly would miss every real configuration.

+ * + *

Logged-and-passthrough on reflection failure: if the field layout shifts in a future + * AF release we don't want to break command handling, just lose inspection hooks.

+ */ + @SuppressWarnings({"rawtypes", "unchecked"}) + private static Repository rebuildIfEventSourcingRepository(Repository delegate) { + Object current = delegate; + Object parent = null; + while (current != null && !(current instanceof EventSourcingRepository)) { + parent = current; + current = ReflectionKt.getPropertyValue(current, "delegate"); + } + if (!(current instanceof EventSourcingRepository esr)) { + return delegate; + } + try { + Class idType = ReflectionKt.getPropertyValue(esr, "idType"); + Class entityType = ReflectionKt.getPropertyValue(esr, "entityType"); + EventStore eventStore = ReflectionKt.getPropertyValue(esr, "eventStore"); + EventSourcedEntityFactory factory = ReflectionKt.getPropertyValue(esr, "entityFactory"); + EntityEvolver evolver = ReflectionKt.getPropertyValue(esr, "entityEvolver"); + SourcingHandler sourcingHandler = ReflectionKt.getPropertyValue(esr, "sourcingHandler"); + + EntityEvolver wrappedEvolver = new AxoniqPlatformEntityEvolver(evolver); + EventSourcingRepository rebuilt = new EventSourcingRepository( + idType, + entityType, + eventStore, + factory, + wrappedEvolver, + sourcingHandler + ); + if (parent == null) { + // No wrapper between us and the ESR — return the rebuilt ESR directly. + return rebuilt; + } + // Swap the parent wrapper's delegate to point at the rebuilt ESR. Keeps any outer + // wrappers (metrics, etc.) intact, just rewires the bottom of the chain. + ReflectionKt.setPropertyValue(parent, "delegate", rebuilt); + return delegate; + } catch (Exception e) { + logger.warn("[ModelInspection] Could not reconstruct EventSourcingRepository for [{}] — " + + "inspection hooks will be unavailable for this entity, but command handling is unaffected: {}", + esr.entityType().getName(), e.getMessage()); + return delegate; + } + } +} diff --git a/framework-client/src/main/java/io/axoniq/platform/framework/eventsourcing/RSocketModelInspectionResponder.kt b/framework-client/src/main/java/io/axoniq/platform/framework/eventsourcing/RSocketModelInspectionResponder.kt index 18d879f..0188391 100644 --- a/framework-client/src/main/java/io/axoniq/platform/framework/eventsourcing/RSocketModelInspectionResponder.kt +++ b/framework-client/src/main/java/io/axoniq/platform/framework/eventsourcing/RSocketModelInspectionResponder.kt @@ -24,179 +24,73 @@ import io.axoniq.platform.framework.api.* import io.axoniq.platform.framework.client.RSocketHandlerRegistrar import io.axoniq.platform.framework.truncateToBytes import org.axonframework.common.configuration.Configuration -import org.axonframework.common.infra.ComponentDescriptor -import org.axonframework.common.infra.DescribableComponent -import org.axonframework.conversion.Converter -import org.axonframework.eventsourcing.CriteriaResolver -import org.axonframework.eventsourcing.EventSourcedEntityFactory -import org.axonframework.eventsourcing.annotation.AnnotationBasedEventCriteriaResolver -import org.axonframework.eventsourcing.annotation.EventSourcingHandler import org.axonframework.eventsourcing.eventstore.EventStorageEngine -import org.axonframework.eventsourcing.eventstore.SourcingCondition -import org.axonframework.eventsourcing.handler.InitializingEntityEvolver import org.axonframework.messaging.core.unitofwork.ProcessingContext +import org.axonframework.messaging.core.unitofwork.UnitOfWorkFactory import org.axonframework.messaging.eventhandling.EventMessage -import org.axonframework.messaging.eventhandling.GenericEventMessage -import org.axonframework.messaging.eventhandling.TerminalEventMessage -import org.axonframework.messaging.eventstreaming.EventCriteria -import org.axonframework.modelling.EntityEvolver -import org.axonframework.modelling.StateManager -import java.lang.reflect.Proxy +import org.axonframework.modelling.repository.Repository import org.slf4j.LoggerFactory import java.util.* +import java.util.concurrent.CompletableFuture import java.util.concurrent.ConcurrentHashMap +import java.util.function.BiConsumer open class RSocketModelInspectionResponder( - private val stateManager: StateManager, - private val eventStorageEngine: EventStorageEngine, + @Suppress("unused") private val eventStorageEngine: EventStorageEngine, private val registrar: RSocketHandlerRegistrar, - private val configuration: Configuration + private val configuration: Configuration, ) { private val logger = LoggerFactory.getLogger(this::class.java) + private val objectMapper = ObjectMapper().apply { findAndRegisterModules() disable(SerializationFeature.FAIL_ON_EMPTY_BEANS) - // AF5 entities are typically Kotlin data classes / Java records with private fields and no - // public getters in the bean-getter sense. Enable direct field access so Jackson surfaces - // the entity's actual state instead of emitting `{}` for "no discoverable properties". + // AF5 entities are typically Kotlin data classes / Java records with private fields and + // no public bean-style getters. Field access lets Jackson surface the actual state + // instead of `{}`. setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY) setVisibility(PropertyAccessor.GETTER, JsonAutoDetect.Visibility.NONE) setVisibility(PropertyAccessor.IS_GETTER, JsonAutoDetect.Visibility.NONE) } - /** - * Cache of [CriteriaResolver] instances per (entityType, idType) pair. Resolvers are - * stateless and obtaining one via describe-walking or reflection is not free, so caching - * avoids repeating the work on every query. Keyed by class identity so redeploys with - * different classloaders get fresh entries naturally. - */ - private val criteriaResolverCache = ConcurrentHashMap, Class<*>>, CriteriaResolver>() - - /** - * Cache of [InitializingEntityEvolver] instances per (entityType, idType) pair. The - * initializer combines the entity factory and the raw evolver: if the current state is - * null on a given event, it creates a fresh entity via the factory; otherwise it - * delegates to the evolver. This is exactly what we need for ad-hoc state replay - * starting from a null state. Cache is safe because the initializer is stateless. - */ - private val initializingEvolverCache = ConcurrentHashMap, Class<*>>, InitializingEntityEvolver>() - - /** - * The framework-wide [Converter] used to deserialize raw event payloads (read from the event - * store as `byte[]`) into typed event objects, so the evolver's `@EventSourcingHandler` - * methods can match them by parameter class. Resolved lazily from the [Configuration]. - */ - private val payloadConverter: Converter? by lazy { - try { - configuration.getComponent(Converter::class.java).also { - logger.info("[ModelInspection] Using payload converter [{}] for event deserialization", it.javaClass.simpleName) - } - } catch (e: Exception) { - logger.warn("[ModelInspection] Could not obtain Converter from configuration — events will be passed to evolver with raw payloads (state replay will likely be empty): {}", - e.message) - null - } + private val unitOfWorkFactory: UnitOfWorkFactory by lazy { + configuration.getComponent(UnitOfWorkFactory::class.java) } /** - * Deserializes the event message's raw `byte[]` payload into a typed instance of the event - * class indicated by `message.type().name()`. Without this, the evolver receives `byte[]` - * payloads and no `@EventSourcingHandler(EventClass)` method matches, so state never advances - * past entity creation defaults. - * - * Why not [EventMessage.withConvertedPayload]: that API short-circuits via - * `convertedPayload.class.isAssignableFrom(payloadType())`. When the converter quietly - * returns the same `byte[]` (no registered conversion path for `byte[] -> EventClass`), - * the assignability check passes and the original message is returned untouched — so - * `payloadType()` stays `byte[]` and the evolver can't dispatch to a typed handler. - * - * Instead, we explicitly call [Message.payloadAs] (forces conversion via the converter) - * and reconstruct a [GenericEventMessage] with the typed payload, so the new - * `payloadType()` is the actual event class — exactly what `AnnotatedEntityMetamodel.evolve` - * expects to find a matching `@EventSourcingHandler`. - * - * Returns the original message untouched when the converter is unavailable, the type can't - * be resolved on the classpath, conversion fails, or the result is unexpectedly null. + * Repositories collected at boot from each event-sourced submodule via the decorator hook + * in [AxoniqPlatformModelInspectionEnhancer]. Replaces walking the top-level state manager, + * which only sees the top-level state manager and misses everything registered in submodules. */ - private fun deserializePayload(message: EventMessage): EventMessage { - val converter = payloadConverter ?: return message - val typeName = message.type()?.name() ?: return message - return try { - val cls = Class.forName(typeName) - // Force the converter to produce a typed instance. This bypasses - // withConvertedPayload's assignability short-circuit which keeps payloadType=byte[] - // when the converter returns the input unchanged. - val typedPayload: Any? = message.payloadAs(cls, converter) - if (typedPayload == null || cls.isInstance(typedPayload).not()) { - logger.debug("[ModelInspection] Converter returned unexpected payload for type [{}]: actual=[{}] — keeping original", - typeName, typedPayload?.javaClass?.name) - return message - } - logger.info("[ModelInspection] Converted payload: msgType.name=[{}] msgType.version=[{}] payloadCls=[{}]", - message.type().name(), - message.type()?.version(), - typedPayload.javaClass.name) - // Build a fresh GenericEventMessage whose payloadType() is the typed class. - // GenericMessage's 4-arg constructor derives payloadType from payload.getClass(), - // so the resulting message advertises the correct event class to the evolver. - // Metadata extends Map in AF5 but Kotlin needs an explicit cast - // because of its stricter generics treatment of the Java collection. - @Suppress("UNCHECKED_CAST") - GenericEventMessage( - message.identifier(), - message.type(), - typedPayload, - message.metadata() as Map, - message.timestamp(), - ) - } catch (e: ClassNotFoundException) { - logger.debug("Event type [{}] not on classpath — leaving payload as raw bytes", typeName) - message - } catch (e: Exception) { - logger.debug("Could not convert event payload of type [{}]: {}", typeName, e.message) - message - } - } + private val repositories = ConcurrentHashMap, Class<*>>, Repository>() - /** - * No-op [ProcessingContext] proxy used for ad-hoc evolve calls outside a real unit of work. - * `AnnotatedEntityMetamodel.evolve` requires a non-null context (defensive `Objects.requireNonNull`), - * but during model inspection replay we have no active processing context. The proxy returns - * sane defaults: null for resource lookups, `false` for predicates, `this` for fluent setters, - * and a no-op for void methods. Any method that would actually need a resource will see null - * and either no-op or throw — caught at the [evolveSafely] boundary. - */ - private val noOpProcessingContext: ProcessingContext = Proxy.newProxyInstance( - ProcessingContext::class.java.classLoader, - arrayOf(ProcessingContext::class.java) - ) { proxy, method, _ -> - when (method.returnType) { - java.lang.Boolean.TYPE -> false - ProcessingContext::class.java -> proxy - Void.TYPE -> null - else -> null - } - } as ProcessingContext + @Suppress("UNCHECKED_CAST") + fun registerRepository(repository: Repository<*, *>) { + val key = repository.entityType() to repository.idType() + repositories[key] = repository as Repository + logger.info("[ModelInspection] Registered repository for entity=[{}] id=[{}]", + repository.entityType().name, repository.idType().name) + } fun start() { registrar.registerHandlerWithoutPayload( Routes.Model.REGISTERED_ENTITIES, - this::handleRegisteredEntities + this::handleRegisteredEntities, ) registrar.registerHandlerWithPayload( Routes.Model.DOMAIN_EVENTS, ModelDomainEventsQuery::class.java, - this::handleDomainEvents + this::handleDomainEvents, ) registrar.registerHandlerWithPayload( Routes.Model.ENTITY_STATE_AT_SEQUENCE, ModelEntityStateAtSequenceQuery::class.java, - this::handleEntityStateAtSequence + this::handleEntityStateAtSequence, ) registrar.registerHandlerWithPayload( Routes.Model.REPLAY_TIMELINE, ModelTimelineQuery::class.java, - this::handleTimelineReplay + this::handleTimelineReplay, ) } @@ -204,28 +98,29 @@ open class RSocketModelInspectionResponder( // Registered entities introspection // ------------------------------------------------------------------------------------------ - private fun handleRegisteredEntities(): RegisteredEntitiesResult { + internal fun handleRegisteredEntities(): RegisteredEntitiesResult { logger.debug("Handling Axoniq Platform MODEL_REGISTERED_ENTITIES query") - val entities = stateManager.registeredEntities().map { entityType -> - val idTypeInfos = stateManager.registeredIdsFor(entityType).map { idClass -> - IdType( - type = idClass.name, - idFields = describeIdFields(idClass), - ) - } + val grouped: Map, List>> = repositories.keys + .groupBy({ it.first }, { it.second }) + + val entities = grouped.map { (entityType, idClasses) -> RegisteredEntityInfo( entityType = entityType.name, - idTypes = idTypeInfos, + idTypes = idClasses.map { idClass -> + IdType( + type = idClass.name, + idFields = describeIdFields(idClass), + ) + }, ) } + logger.debug("Found entities: {}", entities) return RegisteredEntitiesResult(entities = entities) } /** - * Returns structural descriptors for the given id class. Empty for "simple" types where - * the frontend should show a single text input (String, primitives, UUID); populated for - * records / data classes / plain objects where each property should get its own input. - * Only 1-deep properties are described — nested types surface as type = "object". + * Returns structural descriptors for the given id class. Empty for "simple" types (single + * text input on the frontend); populated for compound types (one input per descriptor). */ internal fun describeIdFields(idClass: Class<*>): List { if (isSimpleIdType(idClass)) { @@ -236,7 +131,7 @@ open class RSocketModelInspectionResponder( IdFieldDescriptor( name = component.name, type = normalizedType(component.type), - javaType = component.type.name + javaType = component.type.name, ) } } @@ -248,7 +143,7 @@ open class RSocketModelInspectionResponder( IdFieldDescriptor( name = field.name, type = normalizedType(field.type), - javaType = field.type.name + javaType = field.type.name, ) } } @@ -297,39 +192,9 @@ open class RSocketModelInspectionResponder( } // ------------------------------------------------------------------------------------------ - // Criteria resolution + id deserialization + // Id deserialization // ------------------------------------------------------------------------------------------ - /** - * Resolves the [EventCriteria] for a given (entityType, idType, entityId) triple by - * obtaining the registered [CriteriaResolver] for the chosen id type and invoking it - * with the deserialized typed id. Multi-tag and compound-id entities produce the - * correct criteria automatically — no tag-key resolution needed. - */ - private fun resolveCriteria(entityType: Class<*>, idClass: Class<*>, entityId: String): EventCriteria { - val typedId = deserializeEntityId(entityId, idClass) - ?: throw IllegalArgumentException("Could not deserialize id [$entityId] as type [${idClass.name}]") - return resolveCriteriaWithTypedId(entityType, idClass, typedId) - } - - /** - * Same as [resolveCriteria] but skips id deserialization — used by handlers that already - * have the typed id (e.g. for state reconstruction via [InitializingEntityEvolver]). - */ - private fun resolveCriteriaWithTypedId(entityType: Class<*>, idClass: Class<*>, typedId: Any): EventCriteria { - val resolver = obtainCriteriaResolver(entityType, idClass) - // ProcessingContext is @NonNull via JSpecify @NullMarked, but the default - // AnnotationBasedEventCriteriaResolver never reads it. We bypass Kotlin's nullability - // check via reflection; resolvers that actually rely on the context will throw NPE - // which propagates to the handler-level catch and surfaces a clear error. - return invokeResolveWithNullContext(resolver, typedId) - } - - /** - * Parses the incoming [entityId] wire string into the entity's id type. For simple id - * types we parse directly; for compound types the wire format is a JSON object whose - * keys match the id's property names. - */ private fun deserializeEntityId(entityId: String, idClass: Class<*>): Any? { val trimmed = entityId.trim() return when { @@ -367,204 +232,43 @@ open class RSocketModelInspectionResponder( } } - /** - * Obtains a [CriteriaResolver] for the given (entityType, idType) pair. Strategy: - * 1. Walk the registered repository via `describeTo` and pick out the first matching - * [CriteriaResolver] — this honors any custom resolver an application may have wired in. - * 2. Fall back to constructing a fresh [AnnotationBasedEventCriteriaResolver] from the - * available [Configuration], which covers the default annotation-driven setup. - * Results are cached per (entityType, idType) pair. - */ - @Suppress("UNCHECKED_CAST") - private fun obtainCriteriaResolver(entityClass: Class<*>, idClass: Class<*>): CriteriaResolver { - return criteriaResolverCache.getOrPut(entityClass to idClass) { - findInRepository(entityClass, idClass, CriteriaResolver::class.java) as CriteriaResolver? - ?: AnnotationBasedEventCriteriaResolver( - entityClass as Class, - idClass as Class, - configuration - ) as CriteriaResolver - } - } - - /** - * Invokes [CriteriaResolver.resolve] with a no-op [ProcessingContext] via reflection, - * bypassing the JSpecify/Kotlin non-null check on the parameter. The default - * `AnnotationBasedEventCriteriaResolver` doesn't read the context; resolvers that do - * may interact with the proxy (returning null/false defaults) and either no-op or throw, - * which propagates to the handler-level catch. - */ - private fun invokeResolveWithNullContext(resolver: CriteriaResolver, typedId: Any): EventCriteria { - val method = resolver.javaClass.methods.first { it.name == "resolve" && it.parameterCount == 2 } - return method.invoke(resolver, typedId, noOpProcessingContext) as EventCriteria - } - // ------------------------------------------------------------------------------------------ - // Entity evolver lookup + state reconstruction + // UoW-driven inspection load // ------------------------------------------------------------------------------------------ /** - * Obtains an [InitializingEntityEvolver] for the given (entityType, idType). AF5 - * repositories don't pre-instantiate `InitializingEntityEvolver`; they expose the - * `entityFactory` and `entityEvolver` as separate describable properties. We find both - * via the describe tree and construct the initializer manually — same constructor the - * framework uses internally — so null initial state on the first event triggers entity - * creation via the factory rather than no-op'ing. - * - * Returns null when either piece can't be located. + * Resolves the (entityType, idType) pair to the registered repository. Returns null if no + * matching repository was registered at boot — e.g. the entity exists in a non-event-sourced + * module, or the user-supplied class names don't resolve. */ - @Suppress("UNCHECKED_CAST") - private fun obtainInitializingEvolver(entityClass: Class<*>, idClass: Class<*>): InitializingEntityEvolver? { - initializingEvolverCache[entityClass to idClass]?.let { return it } - val factory = findInRepository(entityClass, idClass, EventSourcedEntityFactory::class.java) - as EventSourcedEntityFactory? - val evolver = findInRepository(entityClass, idClass, EntityEvolver::class.java, preferredName = "entityEvolver") - as EntityEvolver? - if (factory == null || evolver == null) { - logger.warn("Could not assemble InitializingEntityEvolver for [{}] / [{}] — factory={}, evolver={}", - entityClass.name, idClass.name, factory?.javaClass?.name, evolver?.javaClass?.name) - return null - } - logger.info("Assembled InitializingEntityEvolver for [{}] / [{}] — factory=[{}], evolver=[{}]", - entityClass.simpleName, idClass.simpleName, factory.javaClass.simpleName, evolver.javaClass.simpleName) - val initializer = InitializingEntityEvolver(factory, evolver) - initializingEvolverCache[entityClass to idClass] = initializer - return initializer + private fun lookupRepository(entityType: Class<*>, idType: Class<*>): Repository? { + return repositories[entityType to idType] } /** - * Invokes [InitializingEntityEvolver.evolve] with a null [ProcessingContext] via reflection - * — same trick as [invokeResolveWithNullContext]. The initializer handles null state by - * delegating to the entity factory before evolving. Custom code that relies on the context - * will throw NPE; we catch at the caller and keep the previous state. - * - * The 4-arg signature is `(I id, E currentState, EventMessage event, ProcessingContext ctx)`. + * Runs [block] inside a real [org.axonframework.messaging.core.unitofwork.UnitOfWork], wiring + * the supplied hooks onto the [ProcessingContext] so the framework's own event-sourcing + * pipeline drives state replay. The repository's load is invoked through the framework path — + * criteria resolution, payload conversion, and `@EventSourcingHandler` dispatch all happen as + * they would in a real command flow. We never append events, so commit is a no-op for storage. */ - private fun invokeEvolveWithNullContext( - evolver: InitializingEntityEvolver, + private fun withInspectionUoW( + repository: Repository, typedId: Any, - currentState: Any?, - message: EventMessage, - ): Any? { - val method = evolver.javaClass.methods.first { it.name == "evolve" && it.parameterCount == 4 } - return method.invoke(evolver, typedId, currentState, message, noOpProcessingContext) - } - - /** - * Walks the repository registered for (entityClass, idClass) via `describeTo` and returns - * the first instance of [target] that surfaces. When [preferredName] is set, the property - * with that exact name wins over any other match (used to prefer raw evolvers over - * lifecycle-wrapped ones). - */ - private fun findInRepository( - entityClass: Class<*>, - idClass: Class<*>, - target: Class, - preferredName: String? = null, - ): T? { - return try { - val repository = stateManager.repository(entityClass, idClass) ?: return null - val repoClass = repository.javaClass.name - // Diagnostic dump (DEBUG): on every lookup, list every describable property — useful - // when debugging an unfamiliar repository implementation, but too noisy for INFO. - if (logger.isDebugEnabled) { - val dump = DescribePropertyDump() - (repository as? DescribableComponent)?.describeTo(dump) - logger.debug("[ModelInspection] Looking for [{}] in repository [{}] for [{}] / [{}]; describe tree:\n{}", - target.simpleName, repoClass, entityClass.simpleName, idClass.simpleName, dump.formatted()) + beforeConsumer: BiConsumer? = null, + afterConsumer: BiConsumer? = null, + maxIndex: Long? = null, + extract: (entity: Any?) -> R, + ): R { + return unitOfWorkFactory.create().executeWithResult { ctx: ProcessingContext -> + beforeConsumer?.let { ctx.putResource(AxoniqPlatformEntityEvolver.BEFORE_CONSUMER, it) } + afterConsumer?.let { ctx.putResource(AxoniqPlatformEntityEvolver.AFTER_CONSUMER, it) } + maxIndex?.let { ctx.putResource(AxoniqPlatformEntityEvolver.MAX_INDEX, it) } + + repository.load(typedId, ctx).thenApply { managed -> + extract(managed?.entity()) } - val finder = NamedDescribablePropertyFinder(target, preferredName) - (repository as? DescribableComponent)?.describeTo(finder) ?: return null - finder.found - } catch (e: Exception) { - logger.debug("describeTo lookup of [{}] for entity [{}] / id [{}] failed: {}", - target.simpleName, entityClass.name, idClass.name, e.message) - null - } - } - - /** Diagnostic descriptor that records every property name + value class seen, recursing into describable nested components. */ - private class DescribePropertyDump : ComponentDescriptor { - private val lines = mutableListOf() - private var depth = 0 - private val visited = mutableSetOf() // identity-based cycle break - - private fun indent() = " ".repeat(depth) - - override fun describeProperty(name: String, value: Any?) { - val cls = value?.javaClass?.name ?: "null" - lines += "${indent()}- $name : $cls" - if (value is DescribableComponent && visited.add(System.identityHashCode(value))) { - depth++ - try { - value.describeTo(this) - } catch (_: Exception) { /* swallow — diagnostic only */ } - depth-- - } - } - - override fun describeProperty(name: String, value: Collection<*>?) { - lines += "${indent()}- $name : Collection(size=${value?.size ?: 0})" - } - - override fun describeProperty(name: String, value: Map<*, *>?) { - lines += "${indent()}- $name : Map(size=${value?.size ?: 0})" - } - - override fun describeProperty(name: String, value: String?) { - lines += "${indent()}- $name : String = $value" - } - - override fun describeProperty(name: String, value: Long?) { - lines += "${indent()}- $name : Long = $value" - } - - override fun describeProperty(name: String, value: Boolean?) { - lines += "${indent()}- $name : Boolean = $value" - } - - override fun describe(): String = "DescribePropertyDump" - - fun formatted(): String = lines.joinToString("\n") - } - - /** - * Drills through a repository's `describeTo` tree looking for a single instance of [target]. - * Property-name preference lets callers pick the canonical match (e.g. "entityEvolver") - * over wrapper variants like "initializingEntityEvolver". - */ - private class NamedDescribablePropertyFinder( - private val target: Class, - private val preferredName: String?, - ) : ComponentDescriptor { - private var preferred: T? = null - private var fallback: T? = null - - @Suppress("UNCHECKED_CAST") - override fun describeProperty(name: String, value: Any?) { - if (value == null) return - if (target.isInstance(value)) { - if (preferredName != null && name == preferredName && preferred == null) { - preferred = value as T - } else if (fallback == null) { - fallback = value as T - } - // Don't recurse into matched component — we already have what we need at this level. - return - } - if (value is DescribableComponent) { - value.describeTo(this) - } - } - - override fun describeProperty(name: String, value: Collection<*>?) { /* not needed */ } - override fun describeProperty(name: String, value: Map<*, *>?) { /* not needed */ } - override fun describeProperty(name: String, value: String?) { /* not needed */ } - override fun describeProperty(name: String, value: Long?) { /* not needed */ } - override fun describeProperty(name: String, value: Boolean?) { /* not needed */ } - override fun describe(): String = "NamedDescribablePropertyFinder<${target.simpleName}>" - - val found: T? get() = preferred ?: fallback + }.get() } // ------------------------------------------------------------------------------------------ @@ -572,9 +276,9 @@ open class RSocketModelInspectionResponder( // ------------------------------------------------------------------------------------------ /** - * Extracts a human-readable type name for the event. Events read directly from the - * [EventStorageEngine] often have a raw byte[] payload whose `payloadType()` returns `[B`. - * In that case the proper event type is available via `message.type().name()`. + * Extracts a human-readable type name. Events read from [EventStorageEngine] often have a + * raw `byte[]` payload whose `payloadType()` returns `[B`; the proper event type is in + * `message.type().name()`. */ private fun extractPayloadTypeName(message: EventMessage): String { return try { @@ -585,9 +289,9 @@ open class RSocketModelInspectionResponder( } /** - * Converts the event payload to a String. When reading directly from [EventStorageEngine], - * payloads are usually raw byte[] containing JSON or CBOR. We try UTF-8 decoding first - * (works for JSON), falling back to Jackson serialization for typed payloads. + * Converts the event payload to a String. Payloads sourced from the event store are + * usually raw `byte[]` containing JSON or CBOR. UTF-8 first (works for JSON) and Jackson as + * fallback for typed payloads. */ private fun extractPayloadAsString(message: EventMessage): String? { val payload = message.payload() ?: return null @@ -621,42 +325,48 @@ open class RSocketModelInspectionResponder( // Query handlers // ------------------------------------------------------------------------------------------ - private fun handleDomainEvents(query: ModelDomainEventsQuery): DomainEventsResult { + internal fun handleDomainEvents(query: ModelDomainEventsQuery): DomainEventsResult { logger.info("Handling Axoniq Platform MODEL_DOMAIN_EVENTS query for entity [{}] id [{}] idType [{}]", query.entityType, query.entityId, query.idType) val entityClass = Class.forName(query.entityType) val idClass = Class.forName(query.idType) - val criteria = resolveCriteria(entityClass, idClass, query.entityId) - val condition = SourcingCondition.conditionFor(criteria) - - val stream = eventStorageEngine.source(condition) - val allEvents = try { - stream.reduce(mutableListOf()) { acc, entry -> - val message = entry.message() - if (message != null && message !is TerminalEventMessage) { - acc.add(DomainEvent( - sequenceNumber = acc.size.toLong(), - timestamp = message.timestamp(), - payloadType = extractPayloadTypeName(message), - payload = extractPayloadAsString(message) - )) - } - acc - }.get() + val repository = lookupRepository(entityClass, idClass) + ?: return DomainEventsResult( + entityId = query.entityId, + entityType = query.entityType, + domainEvents = emptyList(), + page = query.page, + pageSize = query.pageSize, + totalCount = 0L, + ) + val typedId = deserializeEntityId(query.entityId, idClass) + ?: throw IllegalArgumentException("Could not deserialize id [${query.entityId}] as [${query.idType}]") + + val collected = mutableListOf() + try { + withInspectionUoW( + repository = repository, + typedId = typedId, + beforeConsumer = { event, _ -> + collected += DomainEvent( + sequenceNumber = collected.size.toLong(), + timestamp = event.timestamp(), + payloadType = extractPayloadTypeName(event), + payload = extractPayloadAsString(event), + ) + }, + extract = {}, + ) } catch (e: Exception) { logger.error("Error while sourcing events for entity [{}] id [{}]", query.entityType, query.entityId, e) - mutableListOf() } - logger.info("Sourced [{}] events for entity [{}] id [{}]", - allEvents.size, query.entityType, query.entityId) - - val totalCount = allEvents.size.toLong() + val totalCount = collected.size.toLong() val start = query.page * query.pageSize - val end = minOf(start + query.pageSize, allEvents.size) - val pagedEvents = if (start < allEvents.size) allEvents.subList(start, end) else emptyList() + val end = minOf(start + query.pageSize, collected.size) + val pagedEvents = if (start < collected.size) collected.subList(start, end) else emptyList() return DomainEventsResult( entityId = query.entityId, @@ -668,47 +378,30 @@ open class RSocketModelInspectionResponder( ) } - /** - * Reconstructs the entity's state at a specific sequence number by replaying all events - * up to (and including) that sequence through the registered [EntityEvolver]. Returns - * the JSON-serialized state. If no EntityEvolver is registered for the entity, returns - * null state — frontend can treat that as "state reconstruction not available". - */ - private fun handleEntityStateAtSequence(query: ModelEntityStateAtSequenceQuery): EntityStateResult { + internal fun handleEntityStateAtSequence(query: ModelEntityStateAtSequenceQuery): EntityStateResult { logger.info("Handling Axoniq Platform MODEL_ENTITY_STATE_AT_SEQUENCE query for entity [{}] id [{}] idType [{}] seq [{}]", query.entityType, query.entityId, query.idType, query.maxSequenceNumber) val entityClass = Class.forName(query.entityType) val idClass = Class.forName(query.idType) + val repository = lookupRepository(entityClass, idClass) + ?: return EntityStateResult( + type = query.entityType, + entityId = query.entityId, + maxSequenceNumber = query.maxSequenceNumber, + state = null, + ) val typedId = deserializeEntityId(query.entityId, idClass) ?: throw IllegalArgumentException("Could not deserialize id [${query.entityId}] as [${query.idType}]") - val criteria = resolveCriteriaWithTypedId(entityClass, idClass, typedId) - val condition = SourcingCondition.conditionFor(criteria) - val evolver = obtainInitializingEvolver(entityClass, idClass) - - if (evolver == null) { - logger.warn("No InitializingEntityEvolver found for entity [{}] / id [{}] — returning null state", - query.entityType, query.idType) - return EntityStateResult( - type = query.entityType, - entityId = query.entityId, - maxSequenceNumber = query.maxSequenceNumber, - state = null, - ) - } - // Accumulator: (eventsConsumedSoFar, currentState). MessageStream doesn't expose the - // entry index, so we maintain it inline. - val stream = eventStorageEngine.source(condition) val finalState = try { - stream.reduce(StateHolder(0L, null)) { holder, entry -> - val message = entry.message() - if (message == null || message is TerminalEventMessage) return@reduce holder - // Sequence numbers are 0-indexed by event order. Negative maxSequenceNumber = "all events". - val withinWindow = query.maxSequenceNumber < 0 || holder.index <= query.maxSequenceNumber - if (!withinWindow) return@reduce holder - StateHolder(holder.index + 1, evolveSafely(evolver, typedId, holder.state, deserializePayload(message))) - }.get().state + withInspectionUoW( + repository = repository, + typedId = typedId, + // Negative sequence = "all events" — don't set MAX_INDEX so nothing is skipped. + maxIndex = if (query.maxSequenceNumber < 0) null else query.maxSequenceNumber, + extract = { entity -> entity }, + ) } catch (e: Exception) { logger.error("Error while reconstructing state for entity [{}] id [{}]", query.entityType, query.entityId, e) @@ -723,268 +416,77 @@ open class RSocketModelInspectionResponder( ) } - /** Reduce accumulator that tracks the running event index alongside the evolved state. */ - private data class StateHolder(val index: Long, val state: Any?) - - private fun evolveSafely( - evolver: InitializingEntityEvolver, - typedId: Any, - currentState: Any?, - message: EventMessage, - ): Any? { - return try { - // Capture JSON before AF5 dispatch so we can detect whether the metamodel actually - // mutated the entity, and so the [Evolve] log shows the real pre-mutation state. - // Without this snapshot, since entities mutate in place via @EventSourcingHandler, - // re-serializing currentState after evolve would just print the post-mutation state. - val beforeJson = if (currentState != null) safeJson(currentState) else "null" - - val result = invokeEvolveWithNullContext(evolver, typedId, currentState, message) - - val afterJsonInitial = if (result != null) safeJson(result) else "null" - val mutatedByMetamodel = currentState != null && beforeJson != afterJsonInitial - // If state already changed, the metamodel did its job — don't double-apply via - // reflection. If no change AND we have a non-null entity AND a typed payload, try - // direct reflection dispatch on the entity class. - val finalResult = if (!mutatedByMetamodel && result != null && message.payload() != null) { - applyEventViaReflection(result, message) - result - } else { - result - } - - if (logger.isInfoEnabled) { - logger.info("[Evolve] event=[{}] before=[{}] after=[{}] beforeIs=[{}] afterIs=[{}] reflectionFallback=[{}]", - message.payloadType().simpleName, - beforeJson, - safeJson(finalResult), - currentState?.let { System.identityHashCode(it) } ?: 0, - finalResult?.let { System.identityHashCode(it) } ?: 0, - !mutatedByMetamodel && result != null) - } - finalResult - } catch (e: Exception) { - logger.warn("EntityEvolver.evolve threw for event [{}]: {} — keeping previous state", - message.payloadType().name, e.cause?.message ?: e.message, e) - currentState - } - } - - /** - * Reflection-based fallback for entities where AF5's [AnnotationBasedEntityEvolvingComponent] - * dispatch silently no-ops in an ad-hoc inspection context (no real - * [org.axonframework.messaging.core.unitofwork.ProcessingContext], no message handler - * interceptor chain, etc.). - * - * Walks the entity's class hierarchy looking for methods annotated with - * [EventSourcingHandler] whose first parameter type accepts the message payload. Each match - * is invoked directly on the entity. The method is expected to mutate `this` in place - * (typical AF5 pattern: `void on(SomeEvent e) { this.field = e.field(); }`). For methods - * with extra parameters (e.g. for parameter resolvers), passes `null` so we don't break - * compilation, but those handlers may NPE — caught at the boundary. - * - * Idempotent re-invocation of an event handler is the caller's contract; we only invoke this - * path when the AF5 dispatch produced no observable mutation, so we won't re-apply changes. - */ - internal fun applyEventViaReflection(entity: Any, message: EventMessage) { - val payload = message.payload() ?: return - val payloadCls = payload.javaClass - val handlerCandidates = collectEventSourcingHandlers(entity.javaClass) - - // Path A: payload was already deserialized by [deserializePayload] (works when - // event.type().name() is a FQN that Class.forName resolves, e.g. with - // ClassBasedMessageTypeResolver). Match handlers whose first parameter accepts the - // typed payload directly. - if (payloadCls != ByteArray::class.java) { - for ((declaringClass, method) in handlerCandidates) { - val paramType = method.parameterTypes[0] - if (!paramType.isInstance(payload)) continue - invokeHandlerSafely(entity, declaringClass, method, payload, paramType) - return - } - return - } - - // Path B: payload is still raw byte[] because Class.forName couldn't resolve the type - // name (common with @Event(namespace=...) which produces names like - // "quickstart.OrderCreatedEvent" that don't match a real class). Resolve the handler by - // matching the simple class name extracted from message.type().name() against each - // handler's parameter simple name — picking exactly one. Convert byte[] to that - // specific type only. This avoids Jackson's permissive deserialization successfully - // returning a partially-filled instance of the wrong event class (e.g. OrderShippedEvent - // accepting an OrderCreatedEvent JSON because they share an `orderId` field). - val converter = payloadConverter ?: return - val typeName = message.type()?.name() ?: return - val expectedSimpleName = simpleNameFromMessageType(typeName) - val match = handlerCandidates.firstOrNull { (_, method) -> - method.parameterTypes[0].simpleName == expectedSimpleName - } ?: run { - logger.debug("[ModelInspection] No @EventSourcingHandler param simpleName matches [{}] (from type=[{}]) on [{}]", - expectedSimpleName, typeName, entity.javaClass.simpleName) - return - } - val (declaringClass, method) = match - val paramType = method.parameterTypes[0] - val typedArg: Any? = try { - message.payloadAs(paramType, converter) - } catch (e: Exception) { - logger.debug("[ModelInspection] Converter failed for type=[{}] -> param=[{}]: {}", - typeName, paramType.simpleName, e.message) - null - } - if (typedArg == null || !paramType.isInstance(typedArg)) { - logger.debug("[ModelInspection] Converter returned non-matching payload for type=[{}] (target=[{}])", - typeName, paramType.simpleName) - return - } - invokeHandlerSafely(entity, declaringClass, method, typedArg, paramType) - } - - /** - * Extracts the simple Java class name from an [org.axonframework.messaging.core.MessageType] - * name string. Handles both formats: - * - FQN with optional `$` (nested class): `io.axoniq.quickstart.reservation.event.ReservationEvents$SeatReservedEvent` - * → `SeatReservedEvent` - * - Namespaced short form from `@Event(namespace=...)`: `quickstart.OrderCreatedEvent` - * → `OrderCreatedEvent` - */ - internal fun simpleNameFromMessageType(typeName: String): String { - val afterDollar = typeName.substringAfterLast('$') - return afterDollar.substringAfterLast('.') - } - - private fun invokeHandlerSafely( - entity: Any, - declaringClass: Class<*>, - method: java.lang.reflect.Method, - payload: Any, - paramType: Class<*>, - ) { - try { - method.isAccessible = true - val args: Array = if (method.parameterCount == 1) { - arrayOf(payload) - } else { - Array(method.parameterCount) { idx -> if (idx == 0) payload else null } - } - method.invoke(entity, *args) - logger.debug("[ModelInspection] Reflection dispatch invoked [{}#{}] for param=[{}]", - declaringClass.simpleName, method.name, paramType.simpleName) - } catch (e: Exception) { - logger.debug("[ModelInspection] Reflection dispatch failed for [{}#{}]: {}", - declaringClass.simpleName, method.name, e.cause?.message ?: e.message) - } - } - - /** - * Walks the entity's class hierarchy collecting `@EventSourcingHandler` methods with at - * least one parameter (the event). Returned in declaring-class-first order. - */ - private fun collectEventSourcingHandlers(entityClass: Class<*>): List, java.lang.reflect.Method>> { - val result = mutableListOf, java.lang.reflect.Method>>() - var current: Class<*>? = entityClass - while (current != null && current != Any::class.java) { - for (method in current.declaredMethods) { - if (!method.isAnnotationPresent(EventSourcingHandler::class.java)) continue - if (method.parameterCount < 1) continue - result.add(current to method) - } - current = current.superclass - } - return result - } - - private fun safeJson(value: Any?): String = - if (value == null) "null" - else try { - objectMapper.writeValueAsString(value) - } catch (e: Exception) { - "" - } - - /** - * Replays all events for the entity through the registered [EntityEvolver] and emits a - * timeline of `(sequence, event, stateBefore, stateAfter)` entries within the requested window. - * - * State serialization is JSON via Jackson (handles records, Kotlin data classes, POJOs). - * State strings are byte-truncated via [String.truncateToBytes] so a single oversize entity - * cannot blow gRPC/RSocket message size limits. - * - * If no EntityEvolver is registered for the entity (e.g. non-event-sourced repositories), - * `stateBefore`/`stateAfter` are emitted as null and the frontend can collapse that section. - */ - private fun handleTimelineReplay(query: ModelTimelineQuery): ModelTimelineResult { + internal fun handleTimelineReplay(query: ModelTimelineQuery): ModelTimelineResult { logger.info("Handling Axoniq Platform MODEL_REPLAY_TIMELINE query for entity [{}] id [{}] idType [{}] offset [{}] limit [{}]", query.entityType, query.entityId, query.idType, query.offset, query.limit) val entityClass = Class.forName(query.entityType) val idClass = Class.forName(query.idType) + val repository = lookupRepository(entityClass, idClass) + ?: return ModelTimelineResult( + entityType = query.entityType, + entityId = query.entityId, + entries = emptyList(), + offset = query.offset, + totalEvents = 0, + truncated = false, + ) val typedId = deserializeEntityId(query.entityId, idClass) ?: throw IllegalArgumentException("Could not deserialize id [${query.entityId}] as [${query.idType}]") - val criteria = resolveCriteriaWithTypedId(entityClass, idClass, typedId) - val condition = SourcingCondition.conditionFor(criteria) - val evolver = obtainInitializingEvolver(entityClass, idClass) - if (evolver == null) { - logger.warn("No InitializingEntityEvolver found for entity [{}] / id [{}] — timeline state fields will be null", - query.entityType, query.idType) - } val offset = maxOf(0, query.offset) val limit = if (query.limit <= 0) 100 else query.limit - val maxStateSizeBytes = 100 * 1024 // 100 KB per state snapshot before truncation - val maxEventSizeBytes = 50 * 1024 // 50 KB per event payload before truncation + val maxStateSizeBytes = 100 * 1024 + val maxEventSizeBytes = 50 * 1024 val entries = mutableListOf() - var totalEvents = 0 - var currentState: Any? = null + val totalEvents = intArrayOf(0) + // BEFORE/AFTER snapshots have to be paired — capture stateBefore in BEFORE_CONSUMER so it + // reflects the pre-evolve state even when the entity mutates in place. + val pending = arrayOfNulls(2) // [eventMessage, stateBeforeJson] - val stream = eventStorageEngine.source(condition) try { - stream.reduce(Unit) { _, entry -> - val message = entry.message() - if (message == null || message is TerminalEventMessage) return@reduce Unit - - val seq = totalEvents.toLong() - totalEvents++ - - // Capture stateBefore JSON eagerly. Reservation-style entities mutate in place - // via reflection fallback (see [evolveSafely]) so currentState and nextState are - // the same instance. If we serialize stateBefore after evolveSafely, both fields - // would show the post-mutation state and the timeline UI couldn't diff between - // events. Snapshot the JSON now while currentState is still pre-mutation. - val stateBeforeJson = stateAsJson(currentState).truncateToBytes(maxStateSizeBytes) - - val nextState = evolver?.let { evolveSafely(it, typedId, currentState, deserializePayload(message)) } ?: currentState - - if (seq >= offset && entries.size < limit) { - entries.add(ModelTimelineEntry( - sequenceNumber = seq, - timestamp = message.timestamp().toString(), - eventType = extractPayloadTypeName(message), - eventPayload = extractPayloadAsString(message).truncateToBytes(maxEventSizeBytes), - stateBefore = stateBeforeJson, - stateAfter = stateAsJson(nextState).truncateToBytes(maxStateSizeBytes), - )) - } - currentState = nextState - Unit - }.get() + withInspectionUoW( + repository = repository, + typedId = typedId, + beforeConsumer = { event, stateBefore -> + pending[0] = event + pending[1] = stateAsJson(stateBefore).truncateToBytes(maxStateSizeBytes) + }, + afterConsumer = { event, stateAfter -> + val seq = totalEvents[0].toLong() + totalEvents[0]++ + if (seq >= offset && entries.size < limit) { + entries += ModelTimelineEntry( + sequenceNumber = seq, + timestamp = event.timestamp().toString(), + eventType = extractPayloadTypeName(event), + eventPayload = extractPayloadAsString(event).truncateToBytes(maxEventSizeBytes), + stateBefore = pending[1] as String?, + stateAfter = stateAsJson(stateAfter).truncateToBytes(maxStateSizeBytes), + ) + } + pending[0] = null + pending[1] = null + }, + extract = {}, + ) } catch (e: Exception) { logger.error("Error while sourcing events for timeline of entity [{}] id [{}]", query.entityType, query.entityId, e) } - val remainingAfterWindow = maxOf(0, totalEvents - offset - entries.size) + val remainingAfterWindow = maxOf(0, totalEvents[0] - offset - entries.size) val truncated = remainingAfterWindow > 0 logger.info("Sourced [{}] events for timeline of [{}] id [{}] (returning [{}] from offset [{}], truncated={})", - totalEvents, query.entityType, query.entityId, entries.size, offset, truncated) + totalEvents[0], query.entityType, query.entityId, entries.size, offset, truncated) return ModelTimelineResult( entityType = query.entityType, entityId = query.entityId, entries = entries, offset = offset, - totalEvents = totalEvents, + totalEvents = totalEvents[0], truncated = truncated, ) } diff --git a/framework-client/src/main/java/io/axoniq/platform/framework/messaging/HandlerMeasurement.kt b/framework-client/src/main/java/io/axoniq/platform/framework/messaging/HandlerMeasurement.kt index 4d3241e..60d5a25 100644 --- a/framework-client/src/main/java/io/axoniq/platform/framework/messaging/HandlerMeasurement.kt +++ b/framework-client/src/main/java/io/axoniq/platform/framework/messaging/HandlerMeasurement.kt @@ -58,10 +58,6 @@ class HandlerMeasurement( } fun registerMetricValue(metric: Metric, timeInNs: Long) { - if (completedTime != null) { - logger.warn { "HandlerMeasurement for handler [${message.type()}] is already completed. Can not register metric [$metric] with value [$timeInNs]. Ignoring." } - return - } registeredMetrics.compute(metric) { _, it -> // Sum the metric if it was already registered (it ?: 0L) + timeInNs @@ -69,10 +65,6 @@ class HandlerMeasurement( } fun reportMessageDispatched(messageIdentifier: MessageIdentifier) { - if (completedTime != null) { - logger.warn { "HandlerMeasurement for handler [${message.type()}] is already completed. Can not report dispatched message [$messageIdentifier]. Ignoring." } - return - } dispatchedMessages.add(messageIdentifier) } diff --git a/framework-client/src/main/java/io/axoniq/platform/framework/modelling/AxoniqPlatformStateManager.kt b/framework-client/src/main/java/io/axoniq/platform/framework/modelling/AxoniqPlatformStateManager.kt index 8ac2f69..f029c5b 100644 --- a/framework-client/src/main/java/io/axoniq/platform/framework/modelling/AxoniqPlatformStateManager.kt +++ b/framework-client/src/main/java/io/axoniq/platform/framework/modelling/AxoniqPlatformStateManager.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022-2025. AxonIQ B.V. + * Copyright (c) 2022-2026. AxonIQ B.V. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -26,6 +26,10 @@ class AxoniqPlatformStateManager( private val delegate: StateManager ): StateManager { override fun register(repository: Repository): StateManager { + if(repository is AxoniqPlatformRepository) { + delegate.register(repository) + return this + } delegate.register(AxoniqPlatformRepository(repository)) return this } diff --git a/framework-client/src/main/java/io/axoniq/platform/framework/modelling/ModellingDecorators.java b/framework-client/src/main/java/io/axoniq/platform/framework/modelling/ModellingDecorators.java index 6c80d20..3a7c4e5 100644 --- a/framework-client/src/main/java/io/axoniq/platform/framework/modelling/ModellingDecorators.java +++ b/framework-client/src/main/java/io/axoniq/platform/framework/modelling/ModellingDecorators.java @@ -35,21 +35,27 @@ private ModellingDecorators() { static void apply(ComponentRegistry registry) { registry.registerDecorator(DecoratorDefinition.forType(StateManager.class) - .with((cc, name, delegate) -> - new AxoniqPlatformStateManager(delegate)) + .with((cc, name, delegate) -> { + if(delegate instanceof AxoniqPlatformStateManager) { + return delegate; + } + return new AxoniqPlatformStateManager(delegate); + }) .order(Integer.MAX_VALUE)); UtilsKt.doOnSubModules(registry, (componentRegistry, module) -> { componentRegistry .registerDecorator(DecoratorDefinition.forType(Repository.class) .with((cc, name, delegate) -> + delegate instanceof AxoniqPlatformRepository ? delegate : new AxoniqPlatformRepository<>(delegate)) .order(Integer.MIN_VALUE)) .registerDecorator(DecoratorDefinition.forType(StateManager.class) .with((cc, name, delegate) -> + delegate instanceof AxoniqPlatformStateManager ? delegate : new AxoniqPlatformStateManager(delegate)) .order(Integer.MAX_VALUE)); return null; - }); + }, true); } } diff --git a/framework-client/src/main/java/io/axoniq/platform/framework/utils.kt b/framework-client/src/main/java/io/axoniq/platform/framework/utils.kt index 6eefda3..2fcfbbc 100644 --- a/framework-client/src/main/java/io/axoniq/platform/framework/utils.kt +++ b/framework-client/src/main/java/io/axoniq/platform/framework/utils.kt @@ -18,6 +18,7 @@ package io.axoniq.platform.framework import io.micrometer.core.instrument.MeterRegistry import io.micrometer.core.instrument.Timer import org.axonframework.common.ReflectionUtils +import org.axonframework.common.configuration.BaseModule import org.axonframework.common.configuration.ComponentRegistry import org.axonframework.common.configuration.Module import java.lang.reflect.Field @@ -188,13 +189,19 @@ fun String?.truncateToBytes(maxBytes: Int): String? { return truncatedContent + truncationMessage } -fun ComponentRegistry.doOnSubModules(block: (ComponentRegistry, Module?) -> Unit) { +fun ComponentRegistry.doOnSubModules(block: (ComponentRegistry, Module?) -> Unit, recursive: Boolean = true) { val modules = this.getPropertyValue>("modules") modules?.forEach { entry -> val module = entry.value - module.getPropertyValue("componentRegistry")?.let { cr -> - block(cr, module) - cr.doOnSubModules(block) + block(this, module) + if (recursive && module is BaseModule<*>) { + // BaseModule's nested registry only materialises when the module is built. Defer + // the inner walk via the public componentRegistry(action) API; the action runs on + // the module's own registry at build time, with arbitrary-depth nesting visible to + // recursive doOnSubModules calls inside. + module.componentRegistry { innerRegistry -> + innerRegistry.doOnSubModules(block, recursive) + } } } } \ No newline at end of file diff --git a/framework-client/src/test/kotlin/io/axoniq/platform/framework/eventsourcing/AxoniqPlatformModelInspectionEnhancerTest.kt b/framework-client/src/test/kotlin/io/axoniq/platform/framework/eventsourcing/AxoniqPlatformModelInspectionEnhancerTest.kt index d67617d..dca2a61 100644 --- a/framework-client/src/test/kotlin/io/axoniq/platform/framework/eventsourcing/AxoniqPlatformModelInspectionEnhancerTest.kt +++ b/framework-client/src/test/kotlin/io/axoniq/platform/framework/eventsourcing/AxoniqPlatformModelInspectionEnhancerTest.kt @@ -23,24 +23,32 @@ import io.mockk.verify import org.axonframework.common.configuration.ComponentDefinition import org.axonframework.common.configuration.ComponentRegistry import org.axonframework.eventsourcing.eventstore.EventStorageEngine -import org.axonframework.modelling.StateManager import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test /** - * Verifies the registration guard in [AxoniqPlatformModelInspectionEnhancer]: the - * inspection responder must register only when the application has all three required - * components (StateManager + EventStorageEngine + RSocketHandlerRegistrar). Missing any - * of them is the AF4 / non-event-sourced case and registering would NPE on first request. + * Verifies the guard in [AxoniqPlatformModelInspectionEnhancer]: the responder must register + * only when the platform client is wired ([RSocketHandlerRegistrar] present) and an + * [EventStorageEngine] is available. Missing either is the no-event-sourcing / no-platform-client + * case where registering would have nothing to act on. + * + * The enhancer itself does not probe for [EventStorageEngine] directly — it first probes for + * {@code axon-eventsourcing} on the classpath and delegates the rest of the wiring to + * [ModelInspectionDecorators], which is where the [EventStorageEngine] check lives. In tests + * the classpath probe always succeeds (axon-eventsourcing is a test dependency), so we exercise + * both branches via the registry mocks. + * + * StateManager is intentionally NOT probed: ModellingConfigurationDefaults registers it at + * Integer.MAX_VALUE, after this enhancer's order, so the probe would falsely return false + * during a real boot. */ class AxoniqPlatformModelInspectionEnhancerTest { private val enhancer = AxoniqPlatformModelInspectionEnhancer() @Test - fun `registers the responder when StateManager, EventStorageEngine and RSocketHandlerRegistrar are all present`() { + fun `registers the responder when EventStorageEngine and RSocketHandlerRegistrar are both present`() { val registry = mockk(relaxed = true) - every { registry.hasComponent(StateManager::class.java) } returns true every { registry.hasComponent(EventStorageEngine::class.java) } returns true every { registry.hasComponent(RSocketHandlerRegistrar::class.java) } returns true @@ -50,21 +58,8 @@ class AxoniqPlatformModelInspectionEnhancerTest { } @Test - fun `skips registration when StateManager is missing — typical AF4 application`() { + fun `skips registration when EventStorageEngine is missing — typical AF4 application`() { val registry = mockk(relaxed = true) - every { registry.hasComponent(StateManager::class.java) } returns false - every { registry.hasComponent(EventStorageEngine::class.java) } returns true - every { registry.hasComponent(RSocketHandlerRegistrar::class.java) } returns true - - enhancer.enhance(registry) - - verify(exactly = 0) { registry.registerComponent(any>()) } - } - - @Test - fun `skips registration when EventStorageEngine is missing`() { - val registry = mockk(relaxed = true) - every { registry.hasComponent(StateManager::class.java) } returns true every { registry.hasComponent(EventStorageEngine::class.java) } returns false every { registry.hasComponent(RSocketHandlerRegistrar::class.java) } returns true @@ -76,8 +71,6 @@ class AxoniqPlatformModelInspectionEnhancerTest { @Test fun `skips registration when RSocketHandlerRegistrar is missing — console client not wired`() { val registry = mockk(relaxed = true) - every { registry.hasComponent(StateManager::class.java) } returns true - every { registry.hasComponent(EventStorageEngine::class.java) } returns true every { registry.hasComponent(RSocketHandlerRegistrar::class.java) } returns false enhancer.enhance(registry) diff --git a/framework-client/src/test/kotlin/io/axoniq/platform/framework/eventsourcing/RSocketModelInspectionResponderHelpersTest.kt b/framework-client/src/test/kotlin/io/axoniq/platform/framework/eventsourcing/RSocketModelInspectionResponderHelpersTest.kt index 32d1303..3152e32 100644 --- a/framework-client/src/test/kotlin/io/axoniq/platform/framework/eventsourcing/RSocketModelInspectionResponderHelpersTest.kt +++ b/framework-client/src/test/kotlin/io/axoniq/platform/framework/eventsourcing/RSocketModelInspectionResponderHelpersTest.kt @@ -20,7 +20,6 @@ import io.axoniq.platform.framework.client.RSocketHandlerRegistrar import io.mockk.mockk import org.axonframework.common.configuration.Configuration import org.axonframework.eventsourcing.eventstore.EventStorageEngine -import org.axonframework.modelling.StateManager import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertTrue @@ -32,9 +31,8 @@ import java.util.UUID /** * Unit tests for the pure-logic helpers on [RSocketModelInspectionResponder] that don't - * require a live AF5 configuration / event store. These helpers govern public-facing - * behaviour (id-type description for the FE form, MessageType-name parsing for handler - * dispatch) so regressing them silently breaks the inspection UI. + * require a live AF5 configuration / event store. These helpers govern the FE id-type form, + * so regressing them silently breaks the inspection UI. */ class RSocketModelInspectionResponderHelpersTest { @@ -45,46 +43,12 @@ class RSocketModelInspectionResponderHelpersTest { // The helpers under test never reach into these dependencies, so simple unrecorded // mocks are enough — we don't need MockK relaxed mocks elsewhere. responder = RSocketModelInspectionResponder( - stateManager = mockk(), eventStorageEngine = mockk(), registrar = mockk(), configuration = mockk(), ) } - // --------------------------------------------------------------------------------------- - // simpleNameFromMessageType - // - // Drives Path B handler resolution in applyEventViaReflection. Must produce the same - // simple name regardless of whether the MessageType.name() is a fully qualified class - // name (default ClassBasedMessageTypeResolver) or a namespaced short name from - // @Event(namespace = ...). - // --------------------------------------------------------------------------------------- - - @Test - fun `simpleNameFromMessageType strips package for fully qualified class name`() { - val name = "io.axoniq.quickstart.reservation.event.ReservationEvents\$SeatReservedEvent" - assertEquals("SeatReservedEvent", responder.simpleNameFromMessageType(name)) - } - - @Test - fun `simpleNameFromMessageType strips namespace for short namespaced form`() { - // Format produced by @Event(namespace = "quickstart") on a record class - assertEquals("OrderCreatedEvent", responder.simpleNameFromMessageType("quickstart.OrderCreatedEvent")) - } - - @Test - fun `simpleNameFromMessageType is identity for a single segment`() { - assertEquals("Foo", responder.simpleNameFromMessageType("Foo")) - } - - @Test - fun `simpleNameFromMessageType prefers dollar over dot when both present`() { - // A nested class with a namespaced prefix would be a strange case but the heuristic - // still produces the right simple class name. - assertEquals("Inner", responder.simpleNameFromMessageType("quickstart.Outer\$Inner")) - } - // --------------------------------------------------------------------------------------- // isSimpleIdType // diff --git a/framework-client/src/test/kotlin/io/axoniq/platform/framework/eventsourcing/RSocketModelInspectionResponderIntegrationTest.kt b/framework-client/src/test/kotlin/io/axoniq/platform/framework/eventsourcing/RSocketModelInspectionResponderIntegrationTest.kt new file mode 100644 index 0000000..646f541 --- /dev/null +++ b/framework-client/src/test/kotlin/io/axoniq/platform/framework/eventsourcing/RSocketModelInspectionResponderIntegrationTest.kt @@ -0,0 +1,333 @@ +/* + * Copyright (c) 2022-2026. AxonIQ B.V. + * + * Licensed 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 io.axoniq.platform.framework.eventsourcing + +import io.axoniq.platform.framework.api.ModelDomainEventsQuery +import io.axoniq.platform.framework.api.ModelEntityStateAtSequenceQuery +import io.axoniq.platform.framework.api.ModelTimelineQuery +import io.axoniq.platform.framework.client.RSocketHandlerRegistrar +import io.axoniq.platform.framework.client.strategy.CborJackson3EncodingStrategy +import org.axonframework.common.configuration.AxonConfiguration +import org.axonframework.common.configuration.BaseModule +import org.axonframework.common.configuration.ComponentDefinition +import org.axonframework.common.configuration.Configuration +import org.axonframework.common.configuration.LifecycleRegistry +import org.axonframework.common.configuration.Module +import org.axonframework.eventsourcing.annotation.EventSourcedEntity +import org.axonframework.eventsourcing.annotation.EventTag +import org.axonframework.eventsourcing.annotation.reflection.EntityCreator +import org.axonframework.eventsourcing.annotation.reflection.InjectEntityId +import org.axonframework.eventsourcing.configuration.EventSourcedEntityModule +import org.axonframework.eventsourcing.configuration.EventSourcingConfigurer +import org.axonframework.eventsourcing.annotation.EventSourcingHandler +import org.axonframework.messaging.commandhandling.configuration.CommandHandlingModule +import org.axonframework.messaging.eventhandling.EventSink +import org.axonframework.messaging.eventhandling.GenericEventMessage +import org.axonframework.messaging.core.MessageType +import org.axonframework.modelling.SimpleStateManager +import org.axonframework.modelling.StateManager +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +/** + * End-to-end test for the model inspection feature: spins up a real + * [EventSourcingConfigurer] with the inspection enhancer wired in, registers an + * event-sourced entity in a sub-module, publishes events through the in-memory event + * store, then drives the responder's query handlers and asserts on their outputs. + * + * The submodule structure is what makes this interesting: the entity is registered as a + * nested module (via [EventSourcedEntityModule.autodetected]) so the enhancer's + * `doOnSubModules` walker has to drill in to find it. A bug there would cause the + * registered-entities query to come back empty. + */ +class RSocketModelInspectionResponderIntegrationTest { + + private lateinit var configuration: AxonConfiguration + private lateinit var responder: RSocketModelInspectionResponder + + @BeforeEach + fun setUp() { + // Build a minimal AF5 application: + // - InMemoryEventStorageEngine (default) + // - one annotated event-sourced entity registered as a sub-module + // - a stub RSocketHandlerRegistrar (the inspection enhancer requires its presence, + // but we don't exercise the RSocket transport — we call responder methods directly) + // - the AxoniqPlatformModelInspectionEnhancer registered manually + // The AxoniqPlatformModelInspectionEnhancer is auto-discovered via the + // META-INF/services SPI registration — no need to register it manually. + configuration = EventSourcingConfigurer.create() + .registerEntity(EventSourcedEntityModule.autodetected(String::class.java, Reservation::class.java)) + .componentRegistry { cr -> + cr.registerComponent(ComponentDefinition.ofType(RSocketHandlerRegistrar::class.java) + .withBuilder { RSocketHandlerRegistrar(CborJackson3EncodingStrategy()) }) + } + .start() + + responder = configuration.getComponent(RSocketModelInspectionResponder::class.java) + + // Publish a known sequence of events for entity "RES-1". + val sink = configuration.getComponent(EventSink::class.java) + sink.publish( + null, + event(ReservationCreated("RES-1", "alice")), + event(ReservationConfirmed("RES-1")), + event(ReservationCancelled("RES-1", "double-booked")), + ).get() + } + + @AfterEach + fun tearDown() { + configuration.shutdown() + } + + private fun event(payload: Any) = GenericEventMessage( + MessageType(payload.javaClass), + payload, + ) + + // ------------------------------------------------------------------------------------------ + // Registered entities + // ------------------------------------------------------------------------------------------ + + @Test + fun `registered entities query discovers entities defined in nested modules`() { + val result = invokeRegisteredEntities() + assertEquals(1, result.entities.size, "expected the Reservation entity to be visible") + + val entity = result.entities.first() + assertEquals(Reservation::class.java.name, entity.entityType) + assertEquals(1, entity.idTypes.size) + assertEquals(String::class.java.name, entity.idTypes.first().type) + // String is a simple id type — no sub-fields surface to the FE. + assertTrue(entity.idTypes.first().idFields.isEmpty()) + } + + // ------------------------------------------------------------------------------------------ + // Domain events listing + // ------------------------------------------------------------------------------------------ + + @Test + fun `domain events query returns the published events in publication order with typed names`() { + val result = responder.handleDomainEvents(domainEventsQuery("RES-1")) + + assertEquals(3, result.totalCount, "all three events should be returned") + assertEquals(3, result.domainEvents.size) + + val payloadTypes = result.domainEvents.map { it.payloadType } + assertEquals( + listOf( + ReservationCreated::class.java.name, + ReservationConfirmed::class.java.name, + ReservationCancelled::class.java.name, + ), + payloadTypes, + ) + + // Sequence numbers are 0-indexed and dense across the listed events. + assertEquals(listOf(0L, 1L, 2L), result.domainEvents.map { it.sequenceNumber }) + } + + @Test + fun `domain events query returns empty result for an unknown entity id without throwing`() { + val result = responder.handleDomainEvents(domainEventsQuery("RES-DOES-NOT-EXIST")) + assertEquals(0, result.totalCount) + assertTrue(result.domainEvents.isEmpty()) + } + + // ------------------------------------------------------------------------------------------ + // Entity state at sequence + // ------------------------------------------------------------------------------------------ + + @Test + fun `entity state at sequence reconstructs intermediate state by replaying through the metamodel`() { + // After event 0 (created): status = CREATED, eventCount = 1 + val afterCreated = responder.handleEntityStateAtSequence(stateQuery("RES-1", 0)) + assertNotNull(afterCreated.state) + assertTrue(afterCreated.state!!.contains("\"status\":\"CREATED\""), afterCreated.state) + assertTrue(afterCreated.state!!.contains("\"eventCount\":1"), afterCreated.state) + + // After event 1 (confirmed): status = CONFIRMED, eventCount = 2 + val afterConfirmed = responder.handleEntityStateAtSequence(stateQuery("RES-1", 1)) + assertNotNull(afterConfirmed.state) + assertTrue(afterConfirmed.state!!.contains("\"status\":\"CONFIRMED\""), afterConfirmed.state) + assertTrue(afterConfirmed.state!!.contains("\"eventCount\":2"), afterConfirmed.state) + + // After event 2 (cancelled): status = CANCELLED, eventCount = 3, reason captured + val afterCancelled = responder.handleEntityStateAtSequence(stateQuery("RES-1", 2)) + assertNotNull(afterCancelled.state) + assertTrue(afterCancelled.state!!.contains("\"status\":\"CANCELLED\""), afterCancelled.state) + assertTrue(afterCancelled.state!!.contains("\"eventCount\":3"), afterCancelled.state) + assertTrue(afterCancelled.state!!.contains("\"cancelReason\":\"double-booked\""), afterCancelled.state) + } + + @Test + fun `entity state at negative sequence replays all events`() { + val result = responder.handleEntityStateAtSequence(stateQuery("RES-1", -1)) + assertNotNull(result.state) + assertTrue(result.state!!.contains("\"status\":\"CANCELLED\"")) + assertTrue(result.state!!.contains("\"eventCount\":3")) + } + + @Test + fun `entity state for unknown id returns null state`() { + val result = responder.handleEntityStateAtSequence(stateQuery("RES-MISSING", -1)) + assertNull(result.state) + } + + // ------------------------------------------------------------------------------------------ + // Timeline replay + // ------------------------------------------------------------------------------------------ + + @Test + fun `timeline replay produces stateBefore and stateAfter pairs for every event`() { + val result = responder.handleTimelineReplay(timelineQuery("RES-1")) + + assertEquals(3, result.totalEvents) + assertEquals(3, result.entries.size) + + // Event 0 — AF5's factory has just created the entity (defaults applied: status=CREATED, + // eventCount=0, customerId=null) BEFORE the @EventSourcingHandler runs. So stateBefore + // captures that just-constructed shape; stateAfter captures the post-handler state with + // customerId set and eventCount=1. + val first = result.entries[0] + assertEquals(0L, first.sequenceNumber) + assertEquals(ReservationCreated::class.java.name, first.eventType) + assertNotNull(first.stateBefore) + assertTrue(first.stateBefore!!.contains("\"eventCount\":0")) + assertTrue(first.stateBefore!!.contains("\"customerId\":null")) + assertNotNull(first.stateAfter) + assertTrue(first.stateAfter!!.contains("\"eventCount\":1")) + assertTrue(first.stateAfter!!.contains("\"customerId\":\"alice\"")) + + // Event 1 — stateBefore = CREATED, stateAfter = CONFIRMED. + val second = result.entries[1] + assertEquals(1L, second.sequenceNumber) + assertTrue(second.stateBefore!!.contains("\"status\":\"CREATED\"")) + assertTrue(second.stateAfter!!.contains("\"status\":\"CONFIRMED\"")) + + // Event 2 — stateBefore = CONFIRMED, stateAfter = CANCELLED. + val third = result.entries[2] + assertEquals(2L, third.sequenceNumber) + assertTrue(third.stateBefore!!.contains("\"status\":\"CONFIRMED\"")) + assertTrue(third.stateAfter!!.contains("\"status\":\"CANCELLED\"")) + } + + @Test + fun `timeline replay honours offset and limit`() { + val result = responder.handleTimelineReplay(timelineQuery("RES-1", offset = 1, limit = 1)) + + // Total still reflects the full stream so the FE can drive pagination. + assertEquals(3, result.totalEvents) + assertEquals(1, result.entries.size) + assertEquals(1L, result.entries.first().sequenceNumber) + assertEquals(ReservationConfirmed::class.java.name, result.entries.first().eventType) + assertTrue(result.truncated, "events remain after the requested window") + } + + // ------------------------------------------------------------------------------------------ + // Helpers + // ------------------------------------------------------------------------------------------ + + private fun invokeRegisteredEntities() = responder.handleRegisteredEntities() + + private fun domainEventsQuery(id: String) = ModelDomainEventsQuery( + entityType = Reservation::class.java.name, + entityId = id, + idType = String::class.java.name, + ) + + private fun stateQuery(id: String, maxSeq: Long) = ModelEntityStateAtSequenceQuery( + entityType = Reservation::class.java.name, + entityId = id, + idType = String::class.java.name, + maxSequenceNumber = maxSeq, + ) + + private fun timelineQuery(id: String, offset: Int = 0, limit: Int = 100) = ModelTimelineQuery( + entityType = Reservation::class.java.name, + entityId = id, + idType = String::class.java.name, + offset = offset, + limit = limit, + ) + + // ------------------------------------------------------------------------------------------ + // Test fixture: entity + events + // ------------------------------------------------------------------------------------------ + + /** + * Status enum (declared as a top-level type within the test file) — using an enum gives us + * a state field whose JSON representation is a clean string we can assert against. + */ + enum class Status { CREATED, CONFIRMED, CANCELLED } + + /** + * Mutable event-sourced entity with `@EventSourcingHandler` methods. We mutate in place + * (the AF5 default for annotated entities) so the test exercises the same dispatch path + * a real application uses. + */ + @EventSourcedEntity(tagKey = "reservationId") + class Reservation @EntityCreator constructor( + // @InjectEntityId disambiguates the id from the payload parameter — without it, + // AF5 treats the first ctor arg as the event payload type and no match exists. + @Suppress("unused") @InjectEntityId val reservationId: String, + ) { + var status: Status = Status.CREATED + var customerId: String? = null + var cancelReason: String? = null + var eventCount: Int = 0 + + @EventSourcingHandler + fun on(event: ReservationCreated) { + customerId = event.customerId + status = Status.CREATED + eventCount++ + } + + @EventSourcingHandler + fun on(@Suppress("unused") event: ReservationConfirmed) { + status = Status.CONFIRMED + eventCount++ + } + + @EventSourcingHandler + fun on(event: ReservationCancelled) { + status = Status.CANCELLED + cancelReason = event.reason + eventCount++ + } + } + + data class ReservationCreated( + @field:EventTag(key = "reservationId") val reservationId: String, + val customerId: String, + ) + + data class ReservationConfirmed( + @field:EventTag(key = "reservationId") val reservationId: String, + ) + + data class ReservationCancelled( + @field:EventTag(key = "reservationId") val reservationId: String, + val reason: String, + ) +} diff --git a/framework-client/src/test/kotlin/io/axoniq/platform/framework/eventsourcing/RSocketModelInspectionResponderNestedModuleIntegrationTest.kt b/framework-client/src/test/kotlin/io/axoniq/platform/framework/eventsourcing/RSocketModelInspectionResponderNestedModuleIntegrationTest.kt new file mode 100644 index 0000000..363d409 --- /dev/null +++ b/framework-client/src/test/kotlin/io/axoniq/platform/framework/eventsourcing/RSocketModelInspectionResponderNestedModuleIntegrationTest.kt @@ -0,0 +1,195 @@ +/* + * Copyright (c) 2022-2026. AxonIQ B.V. + * + * Licensed 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 io.axoniq.platform.framework.eventsourcing + +import io.axoniq.platform.framework.api.ModelEntityStateAtSequenceQuery +import io.axoniq.platform.framework.client.RSocketHandlerRegistrar +import io.axoniq.platform.framework.client.strategy.CborJackson3EncodingStrategy +import org.axonframework.common.configuration.AxonConfiguration +import org.axonframework.common.configuration.BaseModule +import org.axonframework.common.configuration.ComponentDefinition +import org.axonframework.eventsourcing.annotation.EventSourcedEntity +import org.axonframework.eventsourcing.annotation.EventTag +import org.axonframework.eventsourcing.annotation.reflection.EntityCreator +import org.axonframework.eventsourcing.annotation.reflection.InjectEntityId +import org.axonframework.eventsourcing.annotation.EventSourcingHandler +import org.axonframework.eventsourcing.configuration.EventSourcedEntityModule +import org.axonframework.eventsourcing.configuration.EventSourcingConfigurer +import org.axonframework.messaging.core.MessageType +import org.axonframework.messaging.eventhandling.EventSink +import org.axonframework.messaging.eventhandling.GenericEventMessage +import org.axonframework.modelling.SimpleStateManager +import org.axonframework.modelling.StateManager +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +/** + * End-to-end test for the case the existing integration test doesn't cover: an event-sourced + * entity buried inside a custom user-defined [BaseModule]. Without this, you can't tell whether + * inspection works only for the conveniently top-level entity registration that + * `registerEntity(...)` produces, or also for arbitrary user nesting. + * + * The setup: + * - {@link OuterEntity} registered at the top level via the usual `registerEntity(...)` path. + * - [MySubModule] — a hand-rolled [BaseModule] that registers its own [StateManager] and an + * {@link InnerEntity} inside it via `registerModule(EventSourcedEntityModule.autodetected(...))`. + * + * Both entities must surface in the registered-entities query, and queries against the inner one + * must reconstruct state correctly — proving the model-inspection enhancer's submodule walk + * reaches arbitrary depth, not just one level. + */ +class RSocketModelInspectionResponderNestedModuleIntegrationTest { + + private lateinit var configuration: AxonConfiguration + private lateinit var responder: RSocketModelInspectionResponder + + @BeforeEach + fun setUp() { + configuration = EventSourcingConfigurer.create() + .registerEntity(EventSourcedEntityModule.autodetected(String::class.java, OuterEntity::class.java)) + .componentRegistry { cr -> + cr.registerComponent(ComponentDefinition.ofType(RSocketHandlerRegistrar::class.java) + .withBuilder { RSocketHandlerRegistrar(CborJackson3EncodingStrategy()) }) + // The custom BaseModule lives directly under the root component registry. + // Inside it, a further sub-module registers InnerEntity — two levels deep. + cr.registerModule(MySubModule()) + } + .start() + + responder = configuration.getComponent(RSocketModelInspectionResponder::class.java) + + val sink = configuration.getComponent(EventSink::class.java) + sink.publish( + null, + event(OuterCreated("OUTER-1", "blue")), + event(InnerOpened("INNER-1", 42)), + event(InnerClosed("INNER-1")), + ).get() + } + + @AfterEach + fun tearDown() { + configuration.shutdown() + } + + private fun event(payload: Any) = GenericEventMessage(MessageType(payload.javaClass), payload) + + @Test + fun `registered entities query surfaces both top-level and deeply nested entities`() { + val result = responder.handleRegisteredEntities() + val typeNames = result.entities.map { it.entityType }.toSet() + + assertTrue(typeNames.contains(OuterEntity::class.java.name), + "expected OuterEntity (top-level) to be registered") + assertTrue(typeNames.contains(InnerEntity::class.java.name), + "expected InnerEntity (nested inside MySubModule) to be registered — submodule walker must reach it") + } + + @Test + fun `state at sequence reconstructs the inner entity in the nested module`() { + val result = responder.handleEntityStateAtSequence(ModelEntityStateAtSequenceQuery( + entityType = InnerEntity::class.java.name, + entityId = "INNER-1", + idType = String::class.java.name, + maxSequenceNumber = -1, + )) + + assertNotNull(result.state, "state must be reconstructed for the inner entity") + assertTrue(result.state!!.contains("\"open\":false"), result.state) + assertTrue(result.state!!.contains("\"value\":42"), result.state) + } + + @Test + fun `state at sequence reconstructs the outer entity registered at the root`() { + val result = responder.handleEntityStateAtSequence(ModelEntityStateAtSequenceQuery( + entityType = OuterEntity::class.java.name, + entityId = "OUTER-1", + idType = String::class.java.name, + maxSequenceNumber = -1, + )) + + assertNotNull(result.state) + assertTrue(result.state!!.contains("\"colour\":\"blue\""), result.state) + } + + // ------------------------------------------------------------------------------------------ + // Test fixtures + // ------------------------------------------------------------------------------------------ + + /** + * Custom user module that owns its own [StateManager] and registers an event-sourced entity + * as a sub-module. Mirrors how a real application might package a bounded context. + */ + class MySubModule : BaseModule("MySubModule") { + init { + componentRegistry { cr -> + cr.registerComponent(ComponentDefinition.ofType(StateManager::class.java) + .withBuilder { SimpleStateManager.named("MySubModuleStateManager") }) + cr.registerModule(EventSourcedEntityModule.autodetected(String::class.java, InnerEntity::class.java)) + } + } + } + + @EventSourcedEntity(tagKey = "outerId") + class OuterEntity @EntityCreator constructor( + @Suppress("unused") @InjectEntityId val outerId: String, + ) { + var colour: String = "" + + @EventSourcingHandler + fun on(event: OuterCreated) { + colour = event.colour + } + } + + data class OuterCreated( + @field:EventTag(key = "outerId") val outerId: String, + val colour: String, + ) + + @EventSourcedEntity(tagKey = "innerId") + class InnerEntity @EntityCreator constructor( + @Suppress("unused") @InjectEntityId val innerId: String, + ) { + var open: Boolean = false + var value: Int = 0 + + @EventSourcingHandler + fun on(event: InnerOpened) { + open = true + value = event.value + } + + @EventSourcingHandler + fun on(@Suppress("unused") event: InnerClosed) { + open = false + } + } + + data class InnerOpened( + @field:EventTag(key = "innerId") val innerId: String, + val value: Int, + ) + + data class InnerClosed( + @field:EventTag(key = "innerId") val innerId: String, + ) +} diff --git a/framework-client/src/test/kotlin/io/axoniq/platform/framework/eventsourcing/RSocketModelInspectionResponderReflectionDispatchTest.kt b/framework-client/src/test/kotlin/io/axoniq/platform/framework/eventsourcing/RSocketModelInspectionResponderReflectionDispatchTest.kt deleted file mode 100644 index ccbafd5..0000000 --- a/framework-client/src/test/kotlin/io/axoniq/platform/framework/eventsourcing/RSocketModelInspectionResponderReflectionDispatchTest.kt +++ /dev/null @@ -1,224 +0,0 @@ -/* - * Copyright (c) 2022-2026. AxonIQ B.V. - * - * Licensed 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 io.axoniq.platform.framework.eventsourcing - -import io.axoniq.platform.framework.client.RSocketHandlerRegistrar -import io.mockk.every -import io.mockk.mockk -import org.axonframework.common.configuration.Configuration -import org.axonframework.conversion.Converter -import org.axonframework.eventsourcing.annotation.EventSourcingHandler -import org.axonframework.eventsourcing.eventstore.EventStorageEngine -import org.axonframework.messaging.core.MessageType -import org.axonframework.messaging.eventhandling.EventMessage -import org.axonframework.modelling.StateManager -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertNull -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test - -/** - * Tests the reflection-based `@EventSourcingHandler` dispatch fallback used when AF5's - * own metamodel dispatch silently no-ops in our ad-hoc inspection context (no real - * `ProcessingContext`, no interceptor chain). - * - * Two dispatch paths are exercised: - * - * - **Path A**: payload is already a typed instance (the FQN [MessageType.name] case where - * [RSocketModelInspectionResponder.deserializePayload] succeeded). The matching handler - * is found by parameter-type assignability. - * - * - **Path B**: payload is still raw `byte[]` because the [MessageType.name] is a short - * namespaced form (e.g. `quickstart.OrderCreatedEvent` from `@Event(namespace=...)`) - * that `Class.forName` can't resolve. The handler is selected by simple-name match - * against [MessageType.name] segments, then the [Converter] is invoked to turn the - * `byte[]` into the handler's parameter type. - * - * Critically: under Path B, Jackson permissive deserialization could happily build any of - * the entity's event classes from any JSON byte sequence (filling whatever fields match, - * defaulting the rest). We must pick the handler **before** invoking the converter, - * otherwise the wrong handler fires (e.g. an `OrderCreated` event mutating the entity as - * if it were `OrderShipped`). - */ -class RSocketModelInspectionResponderReflectionDispatchTest { - - private lateinit var responder: RSocketModelInspectionResponder - private lateinit var configuration: Configuration - private lateinit var converter: Converter - - @BeforeEach - fun setUp() { - configuration = mockk() - converter = mockk() - - // Lazy `payloadConverter` reads `configuration.getComponent(Converter::class.java)`. - // Stub it so Path B can use the converter. - every { configuration.getComponent(Converter::class.java) } returns converter - - responder = RSocketModelInspectionResponder( - stateManager = mockk(), - eventStorageEngine = mockk(), - registrar = mockk(), - configuration = configuration, - ) - } - - // --------------------------------------------------------------------------------------- - // Path A — payload already deserialized - // --------------------------------------------------------------------------------------- - - @Test - fun `Path A invokes the @EventSourcingHandler whose param matches the typed payload`() { - val entity = TestOrder() - val event = TestOrder.OrderCreatedEvent("order-1", "Alice") - val message = mockk() - every { message.payload() } returns event - // Path A doesn't read message.type() at all — we never reach the converter call. - - responder.applyEventViaReflection(entity, message) - - assertEquals("order-1", entity.orderId) - assertEquals("Alice", entity.customerName) - assertEquals(TestOrder.Status.CREATED, entity.status) - } - - @Test - fun `Path A is a no-op when no handler matches the typed payload`() { - val entity = TestOrder() - // Use an event class the entity has no handler for. - val unrelatedEvent = UnrelatedEvent("payload-content") - val message = mockk() - every { message.payload() } returns unrelatedEvent - - responder.applyEventViaReflection(entity, message) - - // Entity untouched because no handler accepted UnrelatedEvent. - assertNull(entity.orderId) - assertEquals(TestOrder.Status.DRAFT, entity.status) - } - - // --------------------------------------------------------------------------------------- - // Path B — raw byte[] payload, simple-name handler resolution - // --------------------------------------------------------------------------------------- - - @Test - fun `Path B selects the handler by simple-name match and converts raw byte payload to it`() { - val entity = TestOrder() - val rawBytes = "{\"orderId\":\"order-2\",\"customerName\":\"Bob\"}".toByteArray() - val typedEvent = TestOrder.OrderCreatedEvent("order-2", "Bob") - val message = mockk() - every { message.payload() } returns rawBytes - every { message.type() } returns MessageType("quickstart.OrderCreatedEvent") - // Converter sees the request for the handler's param type and returns a typed instance. - // Note: the simple-name resolver picks OrderCreatedEvent from `quickstart.OrderCreatedEvent`, - // so the converter is invoked with that type — never with OrderShippedEvent or any other. - every { - message.payloadAs(TestOrder.OrderCreatedEvent::class.java, converter) - } returns typedEvent - - responder.applyEventViaReflection(entity, message) - - assertEquals("order-2", entity.orderId) - assertEquals("Bob", entity.customerName) - assertEquals(TestOrder.Status.CREATED, entity.status) - } - - @Test - fun `Path B does not fire any handler when no entity handler param matches the simple-name`() { - val entity = TestOrder() - val rawBytes = "{\"foo\":\"bar\"}".toByteArray() - val message = mockk() - every { message.payload() } returns rawBytes - // Simple-name "Mystery" doesn't match any of TestOrder's handler param types. - every { message.type() } returns MessageType("some.namespace.Mystery") - - responder.applyEventViaReflection(entity, message) - - // No handler fired → entity stays at defaults. Crucially, the converter was never - // invoked either, because the resolver short-circuited on the missing simple-name. - assertNull(entity.orderId) - assertEquals(TestOrder.Status.DRAFT, entity.status) - } - - @Test - fun `Path B picks correct handler when multiple share an overlapping JSON shape`() { - // Regression guard: under the previous "try every handler with Jackson" approach, - // an OrderCreatedEvent JSON could permissively deserialize to OrderShippedEvent - // (sharing only `orderId`), causing the OrderShipped handler to fire and set - // status=SHIPPED instead of CREATED. The simple-name matcher prevents this. - val entity = TestOrder() - val createdJsonBytes = "{\"orderId\":\"order-3\",\"customerName\":\"Carol\"}".toByteArray() - val message = mockk() - every { message.payload() } returns createdJsonBytes - every { message.type() } returns MessageType("quickstart.OrderCreatedEvent") - - // Only the OrderCreated path should be exercised. We stub it; if the responder - // mistakenly tried OrderShipped, MockK would throw on the unstubbed call. - every { - message.payloadAs(TestOrder.OrderCreatedEvent::class.java, converter) - } returns TestOrder.OrderCreatedEvent("order-3", "Carol") - - responder.applyEventViaReflection(entity, message) - - assertEquals(TestOrder.Status.CREATED, entity.status) // not SHIPPED - assertEquals("order-3", entity.orderId) - assertEquals("Carol", entity.customerName) - } - - // --------------------------------------------------------------------------------------- - // Test fixtures - // --------------------------------------------------------------------------------------- - - /** - * Mirrors the AF5 entity pattern under test: no-arg constructor + several - * `@EventSourcingHandler` methods that mutate `this` in place. - */ - class TestOrder { - var orderId: String? = null - var customerName: String? = null - var carrier: String? = null - var status: Status = Status.DRAFT - - enum class Status { DRAFT, CREATED, SHIPPED } - - @EventSourcingHandler - fun on(event: OrderCreatedEvent) { - this.orderId = event.orderId - this.customerName = event.customerName - this.status = Status.CREATED - } - - @EventSourcingHandler - fun on(event: OrderShippedEvent) { - // If this handler ever fires on an OrderCreated payload (the "Jackson permissive" - // bug we guard against), `status` would jump straight to SHIPPED. - this.orderId = event.orderId - this.carrier = event.carrier - this.status = Status.SHIPPED - } - - @JvmRecord - data class OrderCreatedEvent(val orderId: String, val customerName: String) - - @JvmRecord - data class OrderShippedEvent(val orderId: String, val carrier: String) - } - - /** A class no `TestOrder` handler accepts — used to assert no-op behaviour. */ - @JvmRecord - data class UnrelatedEvent(val payload: String) -} From 3418c6917fd8bfa0758dd39a6b257c14c0d0feda Mon Sep 17 00:00:00 2001 From: Stefan Mirkovic Date: Fri, 8 May 2026 15:37:44 +0200 Subject: [PATCH 6/7] Cap timeline event payloads at 5 KB and state snapshots at 20 KB so a page-of-100 stays gzip-friendly. --- .../eventsourcing/RSocketModelInspectionResponder.kt | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/framework-client/src/main/java/io/axoniq/platform/framework/eventsourcing/RSocketModelInspectionResponder.kt b/framework-client/src/main/java/io/axoniq/platform/framework/eventsourcing/RSocketModelInspectionResponder.kt index 0188391..f9c2c15 100644 --- a/framework-client/src/main/java/io/axoniq/platform/framework/eventsourcing/RSocketModelInspectionResponder.kt +++ b/framework-client/src/main/java/io/axoniq/platform/framework/eventsourcing/RSocketModelInspectionResponder.kt @@ -436,8 +436,13 @@ open class RSocketModelInspectionResponder( val offset = maxOf(0, query.offset) val limit = if (query.limit <= 0) 100 else query.limit - val maxStateSizeBytes = 100 * 1024 - val maxEventSizeBytes = 50 * 1024 + // Per-entry truncation budgets sized so a page-of-100 timeline response stays under + // ~2.5 MB pre-gzip (≈ 750 KB post-gzip with the RSocket gzip extension enabled). + // Each entry holds an event payload + before + after state — keep events tight (5 KB) + // and snapshots conservative (20 KB) since most entities serialize well below either + // bound. Larger payloads still come through truncated with a "[truncated]" marker. + val maxStateSizeBytes = 20 * 1024 + val maxEventSizeBytes = 5 * 1024 val entries = mutableListOf() val totalEvents = intArrayOf(0) From 4fc458613fa5b573dfddf467edaf440d86cf9017 Mon Sep 17 00:00:00 2001 From: Stefan Mirkovic Date: Thu, 14 May 2026 15:21:04 +0200 Subject: [PATCH 7/7] Drop redundant stateBefore on non-leading timeline entries since the FE rehydrates it from the previous entry's stateAfter. --- .../RSocketModelInspectionResponder.kt | 26 +++++++ ...cketModelInspectionResponderHelpersTest.kt | 69 +++++++++++++++++++ 2 files changed, 95 insertions(+) diff --git a/framework-client/src/main/java/io/axoniq/platform/framework/eventsourcing/RSocketModelInspectionResponder.kt b/framework-client/src/main/java/io/axoniq/platform/framework/eventsourcing/RSocketModelInspectionResponder.kt index f9c2c15..8ce31b3 100644 --- a/framework-client/src/main/java/io/axoniq/platform/framework/eventsourcing/RSocketModelInspectionResponder.kt +++ b/framework-client/src/main/java/io/axoniq/platform/framework/eventsourcing/RSocketModelInspectionResponder.kt @@ -483,6 +483,12 @@ open class RSocketModelInspectionResponder( val remainingAfterWindow = maxOf(0, totalEvents[0] - offset - entries.size) val truncated = remainingAfterWindow > 0 + // Drop stateBefore on every entry except the first in the page: for i > 0, + // entries[i].stateBefore is exactly entries[i-1].stateAfter, so sending both is + // redundant. The FE rehydrates the field by looking back one position. On a + // page-of-100 with 20 KB snapshots this saves ~99 × 20 KB ≈ 1.9 MB raw per + // response (~45% of the pre-gzip envelope). + trimRedundantStateBefore(entries) logger.info("Sourced [{}] events for timeline of [{}] id [{}] (returning [{}] from offset [{}], truncated={})", totalEvents[0], query.entityType, query.entityId, entries.size, offset, truncated) @@ -495,4 +501,24 @@ open class RSocketModelInspectionResponder( truncated = truncated, ) } + + /** + * Blanks the `stateBefore` field on every entry past the first. The FE rehydrates these + * positions from the previous entry's `stateAfter` since the two are equal by definition + * of event sourcing — so transmitting both is wasted bytes (a 20 KB string per entry). + * + * The first entry of each page keeps its `stateBefore` because the FE has no in-band + * lookback at the page boundary; transmitting it preserves the "show pre-event state" UX + * without requiring a separate request for the previous page's tail. + * + * Visible for tests so the post-processing logic can be exercised without standing up + * a full event-sourcing context. + */ + internal fun trimRedundantStateBefore(entries: MutableList) { + for (i in 1 until entries.size) { + if (entries[i].stateBefore != null) { + entries[i] = entries[i].copy(stateBefore = null) + } + } + } } diff --git a/framework-client/src/test/kotlin/io/axoniq/platform/framework/eventsourcing/RSocketModelInspectionResponderHelpersTest.kt b/framework-client/src/test/kotlin/io/axoniq/platform/framework/eventsourcing/RSocketModelInspectionResponderHelpersTest.kt index 3152e32..df31e58 100644 --- a/framework-client/src/test/kotlin/io/axoniq/platform/framework/eventsourcing/RSocketModelInspectionResponderHelpersTest.kt +++ b/framework-client/src/test/kotlin/io/axoniq/platform/framework/eventsourcing/RSocketModelInspectionResponderHelpersTest.kt @@ -16,12 +16,14 @@ package io.axoniq.platform.framework.eventsourcing +import io.axoniq.platform.framework.api.ModelTimelineEntry import io.axoniq.platform.framework.client.RSocketHandlerRegistrar import io.mockk.mockk import org.axonframework.common.configuration.Configuration import org.axonframework.eventsourcing.eventstore.EventStorageEngine import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -141,6 +143,73 @@ class RSocketModelInspectionResponderHelpersTest { assertEquals(listOf("string", "number"), descriptors.map { it.type }) } + // --------------------------------------------------------------------------------------- + // trimRedundantStateBefore + // + // Trims stateBefore from every entry past the first in a page. The FE rehydrates these + // positions from the previous entry's stateAfter, so transmitting both is wasted bytes. + // Regressing this silently doubles a page-of-100 timeline response (~1.9 MB pre-gzip). + // --------------------------------------------------------------------------------------- + + @Test + fun `trimRedundantStateBefore keeps stateBefore on the first entry and nulls the rest`() { + val entries = mutableListOf( + entry(seq = 0, before = "{\"v\":\"initial\"}", after = "{\"v\":\"a\"}"), + entry(seq = 1, before = "{\"v\":\"a\"}", after = "{\"v\":\"b\"}"), + entry(seq = 2, before = "{\"v\":\"b\"}", after = "{\"v\":\"c\"}"), + ) + + responder.trimRedundantStateBefore(entries) + + assertEquals("{\"v\":\"initial\"}", entries[0].stateBefore) + assertNull(entries[1].stateBefore) + assertNull(entries[2].stateBefore) + // stateAfter and the other fields must survive untouched — the FE relies on the after + // chain for its lookback rehydration. + assertEquals("{\"v\":\"a\"}", entries[0].stateAfter) + assertEquals("{\"v\":\"b\"}", entries[1].stateAfter) + assertEquals("{\"v\":\"c\"}", entries[2].stateAfter) + } + + @Test + fun `trimRedundantStateBefore is a no-op on an empty page`() { + val entries = mutableListOf() + responder.trimRedundantStateBefore(entries) + assertTrue(entries.isEmpty()) + } + + @Test + fun `trimRedundantStateBefore is a no-op on a single-entry page (no later entries to trim)`() { + val entries = mutableListOf(entry(seq = 0, before = "{\"v\":\"only\"}", after = "{\"v\":\"a\"}")) + responder.trimRedundantStateBefore(entries) + assertEquals("{\"v\":\"only\"}", entries[0].stateBefore) + } + + @Test + fun `trimRedundantStateBefore leaves an already-null stateBefore alone`() { + // The very first event of an entity has no prior state, so the upstream collector + // may already emit stateBefore = null. The trim must not throw on that path. + val entries = mutableListOf( + entry(seq = 0, before = null, after = "{\"v\":\"a\"}"), + entry(seq = 1, before = "{\"v\":\"a\"}", after = "{\"v\":\"b\"}"), + ) + + responder.trimRedundantStateBefore(entries) + + assertNull(entries[0].stateBefore) + assertNull(entries[1].stateBefore) + } + + private fun entry(seq: Long, before: String?, after: String?): ModelTimelineEntry = + ModelTimelineEntry( + sequenceNumber = seq, + timestamp = "2026-01-01T00:00:00Z", + eventType = "SampleEvent", + eventPayload = "{}", + stateBefore = before, + stateAfter = after, + ) + // --------------------------------------------------------------------------------------- // Test fixtures // ---------------------------------------------------------------------------------------