AF5 model inspection - expose AF5 entity inspection over RSocket#146
AF5 model inspection - expose AF5 entity inspection over RSocket#146stefanmirkovic wants to merge 6 commits intomainfrom
Conversation
…a Axon's CriteriaResolver
…pection # Conflicts: # framework-client/src/main/java/io/axoniq/platform/framework/eventsourcing/AxoniqPlatformModelInspectionEnhancer.java # framework-client/src/main/java/io/axoniq/platform/framework/eventsourcing/RSocketModelInspectionResponder.kt # framework-client/src/main/resources/META-INF/services/org.axonframework.messaging.core.annotation.HandlerEnhancerDefinition
CodeDrivenMitch
left a comment
There was a problem hiding this comment.
It's heading in the right direction, but I still see some problem with how the id types would be used in the frontend. There's a big assumption that there will only be one id, which is not the case.
| 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 |
There was a problem hiding this comment.
I think that this is not rare. It can actually be very common. For example, the build agent makes an id per command. Depending on the command, different criteria are built. So this would not work with the build agent.
Why not do the following?
data class RegisteredEntityInfo(
val entityType: String,
val idTypes: List<IdType>,
)
data class IdType(
val type: String,
val idFields: List<IdFieldDescriptor> = emptyList(), // Only in case of not simple type
)
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,
)
The user can in the UI choose between the different id types, and the fields change based on that.
Let's only support 1-deep properties though, so no nesting (we could add it later if feature requested)
| return try { | ||
| val entityClass = Class.forName(entityTypeName) | ||
| val idClass = stateManager.registeredIdsFor(entityClass).firstOrNull() | ||
| ?: return legacyTagCriteria(entityTypeName, entityId) |
There was a problem hiding this comment.
It's not possible to have 0 registered ids for an entity. As per my previous comment, it's best to expose all ids. I don't think we need the resolveTagKey any longer. The criteria resolver will take care of this for all ids. And that would greatly simplify this class
| @Suppress("UNCHECKED_CAST") | ||
| private fun extractResolverFromRepository(entityClass: Class<*>, idClass: Class<*>): CriteriaResolver<Any>? { | ||
| return try { | ||
| val repository = stateManager.repository(entityClass, idClass) ?: return null |
There was a problem hiding this comment.
There are utils in reflection.kt, extension functions that you can use.
Here it can be simplified to:
val repository = stateManager.repository(entityClass, idClass) ?: return null
repository.getPropertyValue<CriteriaResolver<Any>>("criteriaResolver")
There was a problem hiding this comment.
Note, before you might want to call repository.unwrapPossiblyDecoratedClass(Repository::class.java).
As if it's an AxoniqPlatformRepository, which it will be when using platform, it needs to be unwrapped first to find the CriteriaResolver.
Another way would be to call repository.describeTo(CriteriaResolvingFindingComponentDescriptor()) that would drill down via the describe function. No reflection needed.
Something like this:
class CriteriaResolvingFindingComponentDescriptor: ComponentDescriptor {
private var foundResolver: CriteriaResolver<*>? = null
override fun describeProperty(name: String?, obj: Any?) {
if (obj is CriteriaResolver<*>) {
this.foundResolver = obj
} else if (obj is DescribableComponent) {
obj.describeTo(this)
}
}
}| // ISO-8601 string avoids CBOR/Jackson Instant ambiguity. | ||
| timestamp = message.timestamp().toString(), | ||
| eventType = extractPayloadTypeName(message), | ||
| eventPayload = truncateString(payloadString, maxStateSizeBytes), |
There was a problem hiding this comment.
Use the String?.truncateToBytes extension function from utils.kt here
|



Adds RSocketModelInspectionResponder with four endpoints backed by AF5's StateManager and EventStorageEngine:
Tag keys are resolved from @eventsourced / @EventSourcedEntity annotations (including meta-annotations), falling back to the class's simple name. State reconstruction walks the full event stream to keep stateBefore accurate for the first event in the requested window, but only serializes snapshots for entries in [offset, offset + limit), bounding payload size regardless of stream length.
AxoniqPlatformModelInspectionEnhancer registers the responder via the ConfigurationEnhancer SPI. SetupPayloadCreator now reports hasStateManager so the platform can detect model inspection support.
Compound id + multi-tag support
Replaces the hand-crafted
EventCriteria.havingTags(Tag.of(tagKey, entityId))(single-tag only, breaks for multi-tag entities) with a delegation to the
framework's own
CriteriaResolver:resolveCriteria(entityType, entityId)extracts the live resolver from theregistered
EventSourcingRepositoryvia reflection, honoring any customresolver the application has configured. Falls back to constructing a fresh
AnnotationBasedEventCriteriaResolver(entityClass, idClass, configuration)lookup
(entityClass, idClass)pairdescribeIdFields(idClass)introspects records, Kotlin data classes / POJOs,and
@JvmInline value classwrappers. Simple types (String/primitives/UUID/enums/BigInteger/BigDecimal) return an empty descriptor list so the frontend
keeps a single input
deserializeEntityIdhandles primitives directly, unwraps Kotlin valueclasses through their public constructor, and lets Jackson deserialize
JSON-encoded compound ids into the real typed id before invocation
Configurationparameter (wired through theenhancer) so it can build resolvers on demand
ProcessingContextis passed asnull- verified safe for the defaultannotation-based resolver which doesn't read it; custom resolvers that do
throw NPE, which is caught and falls back to the legacy path (no regression)
Relates: https://github.com/AxonIQ/axoniq-platform/pull/538