Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/*
* 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<RegisteredEntityInfo>
)

data class RegisteredEntityInfo(
val entityType: String,
/**
* 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<IdType>,
)

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<IdFieldDescriptor> = 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(
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,
)

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,
)

data class ModelTimelineResult(
val entityType: String,
val entityId: String,
val entries: List<ModelTimelineEntry>,
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?,
)
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,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) ->
Expand All @@ -181,7 +189,7 @@ public void enhance(ComponentRegistry registry) {
.order(0));

return null;
});
}, true);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,8 @@ class SetupPayloadCreator(
heartbeat = true,
threadDump = true,
clientStatusUpdates = true,
licenseEntitlement = hasEntitlementManager()
licenseEntitlement = hasEntitlementManager(),
modelInspection = hasStateManager(),
)
)
}
Expand Down Expand Up @@ -346,6 +347,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.
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -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<E : Any>(
private val delegate: EntityEvolver<E>,
) : EntityEvolver<E> {

companion object {
/** Called before [delegate.evolve]. Receives the event and the pre-evolve entity state. */
val BEFORE_CONSUMER: Context.ResourceKey<BiConsumer<EventMessage, Any?>> =
Context.ResourceKey.withLabel("AxoniqPlatformEntityEvolver.BEFORE_CONSUMER")

/** Called after [delegate.evolve]. Receives the event and the post-evolve entity state. */
val AFTER_CONSUMER: Context.ResourceKey<BiConsumer<EventMessage, Any?>> =
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<Long> =
Context.ResourceKey.withLabel("AxoniqPlatformEntityEvolver.MAX_INDEX")

/** Internal counter advanced by [evolve] when [MAX_INDEX] is set. */
val INDEX_COUNTER: Context.ResourceKey<LongCounter> =
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
}
}
Original file line number Diff line number Diff line change
@@ -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.eventsourcing;

import io.axoniq.platform.framework.AxoniqPlatformConfigurerEnhancer;
import io.axoniq.platform.framework.client.RSocketHandlerRegistrar;
import org.axonframework.common.configuration.ComponentRegistry;
import org.axonframework.common.configuration.ConfigurationEnhancer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
* 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.
*
* <p>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.</p>
*
* <p>We deliberately do <em>not</em> 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.</p>
*/
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(RSocketHandlerRegistrar.class)) {
return;
}
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;
}
}
}
Loading
Loading