From 766a2ee29f19a70b43409e7a381ebfeb08c91804 Mon Sep 17 00:00:00 2001 From: Stefan Mirkovic Date: Thu, 30 Apr 2026 12:52:09 +0200 Subject: [PATCH 01/10] Add AF5 DeadLetterManager and RSocket DLQ responder --- .../axoniq/platform/framework/api/Routes.kt | 3 + .../platform/framework/api/deadLetterApi.kt | 25 ++ framework-client/pom.xml | 6 + ...qPlatformDeadLetterConfigurerEnhancer.java | 100 +++++ .../eventprocessor/DeadLetterManager.kt | 379 ++++++++++++++++++ .../ProcessingGroupInfoSource.kt | 34 ++ .../eventprocessor/ProcessorReportCreator.kt | 20 +- .../eventprocessor/RSocketDlqResponder.kt | 168 ++++++++ ...common.configuration.ConfigurationEnhancer | 3 +- 9 files changed, 736 insertions(+), 2 deletions(-) create mode 100644 framework-client/src/main/java/io/axoniq/platform/framework/eventprocessor/AxoniqPlatformDeadLetterConfigurerEnhancer.java create mode 100644 framework-client/src/main/java/io/axoniq/platform/framework/eventprocessor/DeadLetterManager.kt create mode 100644 framework-client/src/main/java/io/axoniq/platform/framework/eventprocessor/ProcessingGroupInfoSource.kt create mode 100644 framework-client/src/main/java/io/axoniq/platform/framework/eventprocessor/RSocketDlqResponder.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 558359cb..1ef61bf9 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 @@ -57,6 +57,9 @@ object Routes { object DeadLetter { const val LETTERS = "dlq-query-dead-letters" const val SEQUENCE_SIZE = "dlq-query-dead-letter-sequence-size" + // Paginated lookup of letters within a single sequence — added in AF5 framework-client + // 5.1.0 so the platform UI can browse very long sequences without loading them all. + const val SEQUENCE_LETTERS = "dlq-query-dead-letter-sequence-letters" const val DELETE_SEQUENCE = "dlq-command-delete-sequence" const val DELETE_ALL_SEQUENCES = "dlq-command-delete-all-sequences" diff --git a/framework-client-api/src/main/java/io/axoniq/platform/framework/api/deadLetterApi.kt b/framework-client-api/src/main/java/io/axoniq/platform/framework/api/deadLetterApi.kt index 49f1bbd6..b9f7786d 100644 --- a/framework-client-api/src/main/java/io/axoniq/platform/framework/api/deadLetterApi.kt +++ b/framework-client-api/src/main/java/io/axoniq/platform/framework/api/deadLetterApi.kt @@ -71,3 +71,28 @@ data class DeadLetterProcessRequest( val processingGroup: String, val messageIdentifier: String ) + +/** + * Request paginated letters belonging to a single sequence inside the DLQ. Used by the platform UI + * detail modal to browse long sequences without loading them all up-front. + * + * @param processingGroup The processing group / DLQ identifier. + * @param sequenceIdentifier Synthetic sequence id as previously returned by [DeadLetter.sequenceIdentifier]. + * @param offset Zero-based offset into the sequence. + * @param size Number of letters to return (capped server-side). + */ +data class FetchSequenceLettersRequest( + val processingGroup: String, + val sequenceIdentifier: String, + val offset: Int, + val size: Int, +) + +/** + * Response payload for [FetchSequenceLettersRequest]. Carries the requested slice of letters along + * with the total number of letters in the sequence so the UI can render full pagination. + */ +data class SequenceLettersResponse( + val letters: List, + val totalCount: Long = letters.size.toLong(), +) diff --git a/framework-client/pom.xml b/framework-client/pom.xml index fe44c45e..52880799 100644 --- a/framework-client/pom.xml +++ b/framework-client/pom.xml @@ -83,6 +83,12 @@ provided true + + io.axoniq.framework + axoniq-dead-letter + provided + true + tools.jackson.core diff --git a/framework-client/src/main/java/io/axoniq/platform/framework/eventprocessor/AxoniqPlatformDeadLetterConfigurerEnhancer.java b/framework-client/src/main/java/io/axoniq/platform/framework/eventprocessor/AxoniqPlatformDeadLetterConfigurerEnhancer.java new file mode 100644 index 00000000..331ca7b9 --- /dev/null +++ b/framework-client/src/main/java/io/axoniq/platform/framework/eventprocessor/AxoniqPlatformDeadLetterConfigurerEnhancer.java @@ -0,0 +1,100 @@ +/* + * 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.eventprocessor; + +import io.axoniq.platform.framework.AxoniqPlatformConfiguration; +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.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import static io.axoniq.platform.framework.AxoniqPlatformConfigurerEnhancer.PLATFORM_ENHANCER_ORDER; + +/** + * Service-loaded enhancer that registers the dead-letter queue inspection components only when the + * {@code axoniq-dead-letter} module is present on the classpath. Kept free of direct references to + * {@link DeadLetterManager} or {@link RSocketDlqResponder} (which import optional types) so the class can be + * loaded even when the addon is absent. + */ +public class AxoniqPlatformDeadLetterConfigurerEnhancer implements ConfigurationEnhancer { + + private static final Logger LOGGER = + LoggerFactory.getLogger(AxoniqPlatformDeadLetterConfigurerEnhancer.class); + private static final String DEAD_LETTER_PROBE_CLASS = + "io.axoniq.framework.messaging.deadletter.SequencedDeadLetterQueue"; + + @Override + public void enhance(ComponentRegistry registry) { + if (!registry.hasComponent(AxoniqPlatformConfiguration.class)) { + return; + } + // Enhancers can be invoked more than once during context refresh — bail out if the DLQ + // components are already registered to avoid ComponentOverrideException. + if (registry.hasComponent(DeadLetterManager.class) + || registry.hasComponent(ProcessingGroupInfoSource.class)) { + return; + } + if (!isClasspathAvailable()) { + LOGGER.debug("axoniq-dead-letter not on classpath; skipping dead-letter queue inspection wiring."); + return; + } + register(registry); + } + + @Override + public int order() { + // Run after the main platform enhancer so the RSocketHandlerRegistrar component is already declared. + return PLATFORM_ENHANCER_ORDER + 1; + } + + private static void register(ComponentRegistry registry) { + registry.registerComponent(ComponentDefinition + .ofType(DeadLetterManager.class) + .withBuilder(DeadLetterManager::new)); + + // The Spring-backed ComponentRegistry exposes a registered component under all of its + // implemented interfaces automatically, so registering DeadLetterManager already makes + // ProcessingGroupInfoSource available. The plain AF5 ComponentRegistry is exact-typed + // though, so only register the seam there to keep ProcessorReportCreator's lookup + // (`getOptionalComponent(ProcessingGroupInfoSource.class)`) working in both worlds. + if (!registry.hasComponent(ProcessingGroupInfoSource.class)) { + registry.registerComponent(ComponentDefinition + .ofType(ProcessingGroupInfoSource.class) + .withBuilder(c -> c.getComponent(DeadLetterManager.class))); + } + + registry.registerComponent(ComponentDefinition + .ofType(RSocketDlqResponder.class) + .withBuilder(c -> new RSocketDlqResponder( + c.getComponent(DeadLetterManager.class), + c.getComponent(RSocketHandlerRegistrar.class))) + .onStart(Phase.EXTERNAL_CONNECTIONS, RSocketDlqResponder::start)); + } + + private static boolean isClasspathAvailable() { + try { + Class.forName(DEAD_LETTER_PROBE_CLASS, false, + AxoniqPlatformDeadLetterConfigurerEnhancer.class.getClassLoader()); + return true; + } catch (ClassNotFoundException e) { + return false; + } + } +} diff --git a/framework-client/src/main/java/io/axoniq/platform/framework/eventprocessor/DeadLetterManager.kt b/framework-client/src/main/java/io/axoniq/platform/framework/eventprocessor/DeadLetterManager.kt new file mode 100644 index 00000000..b52e8679 --- /dev/null +++ b/framework-client/src/main/java/io/axoniq/platform/framework/eventprocessor/DeadLetterManager.kt @@ -0,0 +1,379 @@ +/* + * 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.eventprocessor + +import io.axoniq.framework.messaging.deadletter.DeadLetter +import io.axoniq.framework.messaging.deadletter.SequencedDeadLetterProcessor +import io.axoniq.framework.messaging.deadletter.SequencedDeadLetterQueue +import io.axoniq.platform.framework.api.DeadLetterResponse +import io.axoniq.platform.framework.api.SequenceLettersResponse +import org.axonframework.common.configuration.Configuration +import org.axonframework.messaging.eventhandling.EventHandlingComponent +import org.axonframework.messaging.eventhandling.EventMessage +import org.slf4j.LoggerFactory +import java.util.concurrent.TimeUnit +import io.axoniq.platform.framework.api.DeadLetter as ApiDeadLetter + +private const val LETTER_PAYLOAD_SIZE_LIMIT = 1024 +private val logger = LoggerFactory.getLogger(DeadLetterManager::class.java) + +/** + * Inspects and operates on the dead-letter queues belonging to event handling components configured on this + * application. + * + * In AF5 each event handling component within a Pooled Streaming processor may have its own dead-letter queue. + * Queues are registered in the [Configuration] under names of the form + * `DeadLetterQueue[EventHandlingComponent[][]]`. + * + * To stay compatible with the platform's AF4-based DLQ API (which expects a single "processing group" identifier per + * DLQ) this manager exposes each DLQ under a synthesised identifier: + * - if a processor has a single DLQ the identifier equals the processor name (matches the issue requirement); + * - if a processor has multiple DLQs each is exposed as `::` so they remain + * addressable individually. + */ +class DeadLetterManager( + private val configuration: Configuration, +) : ProcessingGroupInfoSource { + + override fun infoFor(processorName: String): List = + dlqInfoForProcessor(processorName).map { + ProcessingGroupInfoSource.ProcessingGroupInfo(it.processingGroup, it.sequenceCount) + } + + + /** + * Internal view of a discovered DLQ together with all metadata required to address it through the public API. + */ + private data class DlqEntry( + val processingGroup: String, + val processorName: String, + val componentName: String, + val dlqComponentName: String, + val dlq: SequencedDeadLetterQueue, + ) + + private val dlqNamePattern = + Regex("""^DeadLetterQueue\[EventHandlingComponent\[([^]]+)]\[(.+)]]$""") + + fun deadLetters( + processingGroup: String, + offset: Int = 0, + size: Int = 25, + // TEMPORARILY raised from 10 to 1000 so the platform UI can show the real per-sequence + // count instead of the "10+" placeholder. Revert once the response carries an explicit + // total-count field per sequence. + maxSequenceLetters: Int = 1000, + ): DeadLetterResponse { + val entry = dlqFor(processingGroup) + val sequences = entry.dlq.deadLetters(null).join() + val pageOfSequences = sequences + .drop(offset) + .take(size) + .map { sequence -> + val letters = sequence.toList() + // The AF5 SequencedDeadLetterQueue does not expose the underlying sequence + // identifier (the Object passed to enqueue()) on a DeadLetter, so we synthesise + // a stable identifier from the first letter's message id and apply it to every + // letter in the sequence. Operations look up sequences by walking deadLetters() + // and matching this synthetic id — see findSequence(...). + val syntheticSequenceId = letters.firstOrNull()?.message()?.identifier() ?: "" + letters + .take(maxSequenceLetters) + .map { it.toApiLetter(syntheticSequenceId) } + } + val total = entry.dlq.amountOfSequences(null).join() + return DeadLetterResponse(pageOfSequences, total) + } + + fun sequenceSize(processingGroup: String, sequenceIdentifier: String): Long { + val dlq = dlqFor(processingGroup).dlq + return findSequence(dlq, sequenceIdentifier)?.count()?.toLong() ?: 0L + } + + /** + * Returns a paginated slice of letters belonging to the sequence identified by [sequenceIdentifier]. + * Used by the platform UI's detail modal so very long sequences can be browsed without loading + * them all up-front through the [deadLetters] batch query. + */ + fun lettersForSequence( + processingGroup: String, + sequenceIdentifier: String, + offset: Int, + size: Int, + ): SequenceLettersResponse { + val sequence = findSequence(dlqFor(processingGroup).dlq, sequenceIdentifier) + ?: return SequenceLettersResponse(emptyList(), 0) + val total = sequence.size.toLong() + val safeOffset = offset.coerceAtLeast(0) + val safeSize = size.coerceAtLeast(1) + val slice = sequence + .drop(safeOffset) + .take(safeSize) + .map { it.toApiLetter(sequenceIdentifier) } + return SequenceLettersResponse(slice, total) + } + + /** + * Evicts every letter belonging to the sequence identified by [sequenceIdentifier]. + * + * @return the number of letters that were actually evicted (0 when the synthetic id no longer + * resolves — e.g. the operator's view was stale). + */ + fun delete(processingGroup: String, sequenceIdentifier: String): Int { + val dlq = dlqFor(processingGroup).dlq + val sequence = findSequence(dlq, sequenceIdentifier) + if (sequence == null) { + logger.warn( + "DLQ delete-sequence: no sequence in [{}] matches synthetic id [{}] — nothing to evict", + processingGroup, sequenceIdentifier, + ) + return 0 + } + logger.info( + "DLQ delete-sequence: evicting {} letters from sequence [{}] in [{}]", + sequence.size, sequenceIdentifier, processingGroup, + ) + var evicted = 0 + sequence.forEach { + dlq.evict(it, null).join() + evicted++ + } + return evicted + } + + /** + * Evicts a single letter identified by [messageIdentifier] from the sequence identified by + * [sequenceIdentifier]. Returns `true` when an eviction was performed; `false` indicates the + * synthetic id or message id no longer resolves (typically because the caller's view was stale). + */ + fun delete(processingGroup: String, sequenceIdentifier: String, messageIdentifier: String): Boolean { + val dlq = dlqFor(processingGroup).dlq + val sequence = findSequence(dlq, sequenceIdentifier) + if (sequence == null) { + logger.warn( + "DLQ delete-letter: no sequence in [{}] matches synthetic id [{}] (message id was [{}]) — caller view likely stale", + processingGroup, sequenceIdentifier, messageIdentifier, + ) + return false + } + val target = sequence.firstOrNull { it.message().identifier() == messageIdentifier } + if (target == null) { + logger.warn( + "DLQ delete-letter: sequence [{}] in [{}] (size={}) does not contain message id [{}] — already evicted?", + sequenceIdentifier, processingGroup, sequence.size, messageIdentifier, + ) + return false + } + logger.info( + "DLQ delete-letter: evicting message [{}] from sequence [{}] in [{}]", + messageIdentifier, sequenceIdentifier, processingGroup, + ) + dlq.evict(target, null).join() + return true + } + + /** + * Resolves a DLQ sequence by the synthetic identifier this manager exposes through the API + * (the message id of the sequence's first letter). Walks all sequences once and matches. + */ + private fun findSequence( + dlq: SequencedDeadLetterQueue, + syntheticSequenceId: String, + ): List>? { + val sequences = dlq.deadLetters(null).join() + for (sequence in sequences) { + val letters = sequence.toList() + if (letters.firstOrNull()?.message()?.identifier() == syntheticSequenceId) { + return letters + } + } + return null + } + + fun process(processingGroup: String, messageIdentifier: String): Boolean { + val processor = deadLetterProcessorFor(processingGroup) + return processor.process { it.message().identifier() == messageIdentifier } + .get(60, TimeUnit.SECONDS) + } + + fun processAll( + processingGroup: String, + maxMessages: Int? = null, + timeoutSeconds: Long = 600, + ): Int { + val processor = deadLetterProcessorFor(processingGroup) + var processed = 0 + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(timeoutSeconds) + while (maxMessages == null || processed < maxMessages) { + if (System.nanoTime() > deadline) break + val didProcess = processor.process { true }.get(timeoutSeconds, TimeUnit.SECONDS) + if (!didProcess) break + processed++ + } + return processed + } + + fun deleteAll(processingGroup: String, timeoutSeconds: Long = 600): Int { + val dlq = dlqFor(processingGroup).dlq + val totalCount = dlq.size(null).get(timeoutSeconds, TimeUnit.SECONDS).toInt() + dlq.clear(null).get(timeoutSeconds, TimeUnit.SECONDS) + return totalCount + } + + /** + * Returns the DLQ entries belonging to the given processor — used by [ProcessorReportCreator] to surface DLQ size + * per processing group in the processor report. + */ + fun dlqInfoForProcessor(processorName: String): List = + discover() + .filter { it.processorName == processorName } + .map { DlqInfo(it.processingGroup, it.dlq.amountOfSequences(null).join()) } + + private fun dlqFor(processingGroup: String): DlqEntry = + discover().firstOrNull { it.processingGroup == processingGroup } + ?: throw IllegalArgumentException( + "There is no dead-letter queue for processing group [$processingGroup]") + + private fun deadLetterProcessorFor(processingGroup: String): SequencedDeadLetterProcessor { + val entry = dlqFor(processingGroup) + + // EventHandlingComponents live in per-processor sub-modules. Direct lookup by name on the + // global Configuration only sees the global scope, so we use getComponents(...) — which + // (like for SequencedDeadLetterQueue) walks all module scopes — and then pick the entry + // whose registered name matches the one encoded inside the DLQ component name. + val ehc = configuration + .getComponents(EventHandlingComponent::class.java)[entry.dlqComponentName] + ?: throw IllegalArgumentException( + "No event handling component registered for [${entry.dlqComponentName}]") + + return findDeadLetterProcessor(ehc) + ?: throw IllegalStateException( + "Component [${entry.dlqComponentName}] is not wrapped with dead-letter processing") + } + + /** + * Walks the [EventHandlingComponent] decorator chain looking for the [SequencedDeadLetterProcessor] + * implementation. Other framework-client decorators may wrap the dead-lettering component, hiding it from a direct + * cast. + */ + @Suppress("UNCHECKED_CAST") + private fun findDeadLetterProcessor(root: EventHandlingComponent): SequencedDeadLetterProcessor? { + val seen = mutableSetOf() + var current: Any? = root + while (current != null && seen.add(current)) { + if (current is SequencedDeadLetterProcessor<*>) { + return current as SequencedDeadLetterProcessor + } + current = readDelegate(current) + } + return null + } + + private fun readDelegate(target: Any): Any? { + var clazz: Class<*>? = target.javaClass + while (clazz != null && clazz != Any::class.java) { + try { + val field = clazz.getDeclaredField("delegate") + field.isAccessible = true + return field.get(target) + } catch (_: NoSuchFieldException) { + clazz = clazz.superclass + } + } + return null + } + + @Suppress("UNCHECKED_CAST") + private fun discover(): List { + data class ParsedDlq( + val processor: String, + val component: String, + val dlq: SequencedDeadLetterQueue, + ) + + val parsed = configuration.getComponents(SequencedDeadLetterQueue::class.java) + .mapNotNull { (dlqName, dlq) -> + val match = dlqNamePattern.find(dlqName) ?: return@mapNotNull null + ParsedDlq( + processor = match.groupValues[1], + component = match.groupValues[2], + dlq = dlq as SequencedDeadLetterQueue, + ) + } + val perProcessor = parsed.groupingBy { it.processor }.eachCount() + return parsed.map { (processor, component, dlq) -> + DlqEntry( + processingGroup = if (perProcessor[processor] == 1) processor else "$processor::$component", + processorName = processor, + componentName = component, + // The DLQ component factory keys EventHandlingComponents by the inner name used in the DLQ key. + dlqComponentName = "EventHandlingComponent[$processor][$component]", + dlq = dlq, + ) + } + } + + private fun DeadLetter.toApiLetter(sequenceIdentifier: String): ApiDeadLetter { + val message = this.message() + return ApiDeadLetter( + messageIdentifier = message.identifier(), + message = serializePayload(message), + messageType = messageTypeOf(message), + causeType = this.cause().map { it.type() }.orElse(null), + causeMessage = this.cause().map { it.message() }.orElse(null), + enqueuedAt = this.enqueuedAt(), + lastTouched = this.lastTouched(), + diagnostics = this.diagnostics(), + sequenceIdentifier = sequenceIdentifier, + ) + } + + /** + * Best-effort human-readable type name for the payload. When the DLQ has the message in its + * still-serialised form the JVM type is `byte[]`, which is useless to display, so we fall back + * to the qualified name carried on the message's [org.axonframework.messaging.core.MessageType]. + */ + private fun messageTypeOf(message: EventMessage): String { + val payloadClass = message.payloadType() + if (payloadClass == ByteArray::class.java) { + return runCatching { message.type().name() }.getOrDefault("byte[]") + } + return payloadClass.simpleName ?: payloadClass.name + } + + private fun serializePayload(message: EventMessage): String { + val raw: String = try { + when (val payload = message.payload()) { + null -> "" + is ByteArray -> String(payload, Charsets.UTF_8) + is String -> payload + else -> payload.toString() + } + } catch (_: Exception) { + "" + } + // UTF-8-safe truncation so multi-byte characters can't get split mid-codepoint. + return raw.toByteArray(Charsets.UTF_8) + .let { if (it.size <= LETTER_PAYLOAD_SIZE_LIMIT) raw else String(it, 0, LETTER_PAYLOAD_SIZE_LIMIT, Charsets.UTF_8) } + } + + /** + * Lightweight DTO returned to [ProcessorReportCreator] so it can populate per-processor DLQ size information + * without exposing the full dead-letter API. + */ + data class DlqInfo(val processingGroup: String, val sequenceCount: Long) +} diff --git a/framework-client/src/main/java/io/axoniq/platform/framework/eventprocessor/ProcessingGroupInfoSource.kt b/framework-client/src/main/java/io/axoniq/platform/framework/eventprocessor/ProcessingGroupInfoSource.kt new file mode 100644 index 00000000..13ec7c89 --- /dev/null +++ b/framework-client/src/main/java/io/axoniq/platform/framework/eventprocessor/ProcessingGroupInfoSource.kt @@ -0,0 +1,34 @@ +/* + * 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.eventprocessor + +/** + * Always-loadable seam used by [ProcessorReportCreator] to learn about processing groups (and their DLQ size, if + * any) belonging to a processor. + * + * Carrying this contract in a class with no references to the optional `axoniq-dead-letter` types lets + * [ProcessorReportCreator] stay free of those types so it can run on classpaths where the addon is absent. + */ +interface ProcessingGroupInfoSource { + + fun infoFor(processorName: String): List + + data class ProcessingGroupInfo( + val processingGroup: String, + val dlqSize: Long?, + ) +} diff --git a/framework-client/src/main/java/io/axoniq/platform/framework/eventprocessor/ProcessorReportCreator.kt b/framework-client/src/main/java/io/axoniq/platform/framework/eventprocessor/ProcessorReportCreator.kt index 494ebf2c..9c88c68f 100644 --- a/framework-client/src/main/java/io/axoniq/platform/framework/eventprocessor/ProcessorReportCreator.kt +++ b/framework-client/src/main/java/io/axoniq/platform/framework/eventprocessor/ProcessorReportCreator.kt @@ -16,6 +16,7 @@ package io.axoniq.platform.framework.eventprocessor +import io.axoniq.platform.framework.api.ProcessingGroupStatus import io.axoniq.platform.framework.api.ProcessorMode import io.axoniq.platform.framework.api.ProcessorStatus import io.axoniq.platform.framework.api.ProcessorStatusReport @@ -30,9 +31,14 @@ import org.axonframework.messaging.eventhandling.processing.streaming.pooled.Poo import org.axonframework.messaging.eventhandling.processing.streaming.segmenting.EventTrackerStatus import org.axonframework.messaging.eventhandling.processing.streaming.token.store.TokenStore import org.axonframework.messaging.eventhandling.processing.subscribing.SubscribingEventProcessor +import org.slf4j.LoggerFactory class ProcessorReportCreator(private val processingConfig: Configuration) { + private val logger = LoggerFactory.getLogger(ProcessorReportCreator::class.java) private val metricsRegistry = processingConfig.getComponent(ProcessorMetricsRegistry::class.java) + // Optional — only present when an addon (currently only axoniq-dead-letter) registers a source. + private val processingGroupInfoSource: ProcessingGroupInfoSource? = + processingConfig.getOptionalComponent(ProcessingGroupInfoSource::class.java).orElse(null) companion object { const val MULTI_TENANT_PROCESSOR_CLASS = "org.axonframework.extensions.multitenancy.components.eventhandeling.MultiTenantEventProcessor" } @@ -51,7 +57,7 @@ class ProcessorReportCreator(private val processingConfig: Configuration) { private fun streamingStatus(name: String, processor: StreamingEventProcessor) = ProcessorStatus( name, - emptyList(), + processingGroupsFor(name), processor.tokenStoreIdentifier, processor.toType(), processor.isRunning, @@ -61,6 +67,18 @@ class ProcessorReportCreator(private val processingConfig: Configuration) { processor.processingStatus().map { (_, segment) -> segment.toStatus(name) }, ) + private fun processingGroupsFor(processorName: String): List { + val source = processingGroupInfoSource ?: return emptyList() + return try { + source.infoFor(processorName) + .map { ProcessingGroupStatus(it.processingGroup, it.dlqSize) } + } catch (e: Exception) { + // A failing probe must not break processor reporting. + logger.warn("Failed to collect processing group information for processor [{}]", processorName, e) + emptyList() + } + } + private fun subscribingStatus(name: String, processor: SubscribingEventProcessor) = ProcessorStatus( name, diff --git a/framework-client/src/main/java/io/axoniq/platform/framework/eventprocessor/RSocketDlqResponder.kt b/framework-client/src/main/java/io/axoniq/platform/framework/eventprocessor/RSocketDlqResponder.kt new file mode 100644 index 00000000..c0f31172 --- /dev/null +++ b/framework-client/src/main/java/io/axoniq/platform/framework/eventprocessor/RSocketDlqResponder.kt @@ -0,0 +1,168 @@ +/* + * 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.eventprocessor + +import io.axoniq.platform.framework.api.DeadLetterProcessRequest +import io.axoniq.platform.framework.api.DeadLetterRequest +import io.axoniq.platform.framework.api.DeadLetterResponse +import io.axoniq.platform.framework.api.DeadLetterSequenceDeleteRequest +import io.axoniq.platform.framework.api.DeadLetterSequenceSize +import io.axoniq.platform.framework.api.DeadLetterSingleDeleteRequest +import io.axoniq.platform.framework.api.DeleteAllDeadLetterSequencesRequest +import io.axoniq.platform.framework.api.FetchSequenceLettersRequest +import io.axoniq.platform.framework.api.ProcessAllDeadLetterSequencesRequest +import io.axoniq.platform.framework.api.Routes +import io.axoniq.platform.framework.api.SequenceLettersResponse +import io.axoniq.platform.framework.client.RSocketHandlerRegistrar +import org.slf4j.LoggerFactory + +open class RSocketDlqResponder( + private val deadLetterManager: DeadLetterManager, + private val registrar: RSocketHandlerRegistrar, +) { + private val logger = LoggerFactory.getLogger(this::class.java) + + fun start() { + registrar.registerHandlerWithPayload( + Routes.ProcessingGroup.DeadLetter.LETTERS, + DeadLetterRequest::class.java, + this::handleDeadLetterQuery, + ) + registrar.registerHandlerWithPayload( + Routes.ProcessingGroup.DeadLetter.SEQUENCE_SIZE, + DeadLetterSequenceSize::class.java, + this::handleSequenceSizeQuery, + ) + registrar.registerHandlerWithPayload( + Routes.ProcessingGroup.DeadLetter.SEQUENCE_LETTERS, + FetchSequenceLettersRequest::class.java, + this::handleSequenceLettersQuery, + ) + registrar.registerHandlerWithPayload( + Routes.ProcessingGroup.DeadLetter.DELETE_SEQUENCE, + DeadLetterSequenceDeleteRequest::class.java, + this::handleDeleteSequenceCommand, + ) + registrar.registerHandlerWithPayload( + Routes.ProcessingGroup.DeadLetter.DELETE_LETTER, + DeadLetterSingleDeleteRequest::class.java, + this::handleDeleteLetterCommand, + ) + registrar.registerHandlerWithPayload( + Routes.ProcessingGroup.DeadLetter.PROCESS, + DeadLetterProcessRequest::class.java, + this::handleProcessCommand, + ) + registrar.registerHandlerWithPayload( + Routes.ProcessingGroup.DeadLetter.PROCESS_ALL_SEQUENCES, + ProcessAllDeadLetterSequencesRequest::class.java, + this::handleProcessAllSequencesCommand, + ) + registrar.registerHandlerWithPayload( + Routes.ProcessingGroup.DeadLetter.DELETE_ALL_SEQUENCES, + DeleteAllDeadLetterSequencesRequest::class.java, + this::handleDeleteAllSequencesCommand, + ) + } + + private fun handleDeadLetterQuery(request: DeadLetterRequest): DeadLetterResponse { + logger.debug("Handling Axoniq Platform DEAD_LETTERS query [{}]", request) + return deadLetterManager.deadLetters( + request.processingGroup, + request.offset, + request.size, + request.maxSequenceLetters, + ) + } + + private fun handleSequenceSizeQuery(request: DeadLetterSequenceSize): Long { + logger.debug( + "Handling Axoniq Platform DEAD_LETTER_SEQUENCE_SIZE query for processing group [{}]", + request.processingGroup, + ) + return deadLetterManager.sequenceSize(request.processingGroup, request.sequenceIdentifier) + } + + private fun handleSequenceLettersQuery(request: FetchSequenceLettersRequest): SequenceLettersResponse { + logger.debug( + "Handling Axoniq Platform DEAD_LETTER_SEQUENCE_LETTERS query for processing group [{}], sequence [{}], offset={}, size={}", + request.processingGroup, + request.sequenceIdentifier, + request.offset, + request.size, + ) + return deadLetterManager.lettersForSequence( + request.processingGroup, + request.sequenceIdentifier, + request.offset, + request.size, + ) + } + + private fun handleDeleteSequenceCommand(request: DeadLetterSequenceDeleteRequest) { + logger.debug( + "Handling Axoniq Platform DELETE_SEQUENCE command for processing group [{}], sequence [{}]", + request.processingGroup, request.sequenceIdentifier, + ) + val evicted = deadLetterManager.delete(request.processingGroup, request.sequenceIdentifier) + logger.info( + "DELETE_SEQUENCE for [{}] sequence [{}] → evicted {} letter(s)", + request.processingGroup, request.sequenceIdentifier, evicted, + ) + } + + private fun handleDeleteLetterCommand(request: DeadLetterSingleDeleteRequest) { + logger.debug( + "Handling Axoniq Platform DELETE_LETTER command for processing group [{}], sequence [{}], message [{}]", + request.processingGroup, request.sequenceIdentifier, request.messageIdentifier, + ) + val evicted = deadLetterManager.delete( + request.processingGroup, + request.sequenceIdentifier, + request.messageIdentifier, + ) + logger.info( + "DELETE_LETTER for [{}] sequence [{}] message [{}] → {}", + request.processingGroup, request.sequenceIdentifier, request.messageIdentifier, + if (evicted) "evicted" else "no-op (id no longer resolves)", + ) + } + + private fun handleProcessCommand(request: DeadLetterProcessRequest): Boolean { + logger.debug( + "Handling Axoniq Platform PROCESS command for processing group [{}]", + request.processingGroup, + ) + return deadLetterManager.process(request.processingGroup, request.messageIdentifier) + } + + private fun handleProcessAllSequencesCommand(request: ProcessAllDeadLetterSequencesRequest): Int { + logger.debug( + "Handling Axoniq Platform PROCESS_ALL_SEQUENCES command for processing group [{}]", + request.processingGroup, + ) + return deadLetterManager.processAll(request.processingGroup, request.maxMessages) + } + + private fun handleDeleteAllSequencesCommand(request: DeleteAllDeadLetterSequencesRequest): Int { + logger.debug( + "Handling Axoniq Platform DELETE_ALL_SEQUENCES command for processing group [{}]", + request.processingGroup, + ) + return deadLetterManager.deleteAll(request.processingGroup) + } +} 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 d69912fb..4ec06058 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.eventprocessor.AxoniqPlatformDeadLetterConfigurerEnhancer \ No newline at end of file From 72cd6d8b971d7a12a85b0df7142a502cb9ed925b Mon Sep 17 00:00:00 2001 From: Mitchell Herrijgers Date: Wed, 6 May 2026 13:50:10 +0100 Subject: [PATCH 02/10] Discover dlqs eagerly on boot to reduce runtime strain --- ...qPlatformDeadLetterConfigurerEnhancer.java | 5 +- .../eventprocessor/DeadLetterManager.kt | 115 +++++++----------- 2 files changed, 48 insertions(+), 72 deletions(-) diff --git a/framework-client/src/main/java/io/axoniq/platform/framework/eventprocessor/AxoniqPlatformDeadLetterConfigurerEnhancer.java b/framework-client/src/main/java/io/axoniq/platform/framework/eventprocessor/AxoniqPlatformDeadLetterConfigurerEnhancer.java index 331ca7b9..de2c489b 100644 --- a/framework-client/src/main/java/io/axoniq/platform/framework/eventprocessor/AxoniqPlatformDeadLetterConfigurerEnhancer.java +++ b/framework-client/src/main/java/io/axoniq/platform/framework/eventprocessor/AxoniqPlatformDeadLetterConfigurerEnhancer.java @@ -67,7 +67,10 @@ public int order() { private static void register(ComponentRegistry registry) { registry.registerComponent(ComponentDefinition .ofType(DeadLetterManager.class) - .withBuilder(DeadLetterManager::new)); + .withBuilder(DeadLetterManager::new) + // Discover DLQs after event processors have started, by which point the + // EventHandlingComponent decorator chain has materialised every DLQ. + .onStart(Phase.INSTRUCTION_COMPONENTS, DeadLetterManager::start)); // The Spring-backed ComponentRegistry exposes a registered component under all of its // implemented interfaces automatically, so registering DeadLetterManager already makes diff --git a/framework-client/src/main/java/io/axoniq/platform/framework/eventprocessor/DeadLetterManager.kt b/framework-client/src/main/java/io/axoniq/platform/framework/eventprocessor/DeadLetterManager.kt index b52e8679..0177a7b3 100644 --- a/framework-client/src/main/java/io/axoniq/platform/framework/eventprocessor/DeadLetterManager.kt +++ b/framework-client/src/main/java/io/axoniq/platform/framework/eventprocessor/DeadLetterManager.kt @@ -22,7 +22,6 @@ import io.axoniq.framework.messaging.deadletter.SequencedDeadLetterQueue import io.axoniq.platform.framework.api.DeadLetterResponse import io.axoniq.platform.framework.api.SequenceLettersResponse import org.axonframework.common.configuration.Configuration -import org.axonframework.messaging.eventhandling.EventHandlingComponent import org.axonframework.messaging.eventhandling.EventMessage import org.slf4j.LoggerFactory import java.util.concurrent.TimeUnit @@ -49,6 +48,17 @@ class DeadLetterManager( private val configuration: Configuration, ) : ProcessingGroupInfoSource { + @Volatile + private var entries: List? = null + + /** + * Discovers the DLQs configured on this application by walking each event-processor module. + * Called once via the lifecycle; subsequent invocations refresh the cached view. + */ + fun start() { + entries = discoverEntries() + } + override fun infoFor(processorName: String): List = dlqInfoForProcessor(processorName).map { ProcessingGroupInfoSource.ProcessingGroupInfo(it.processingGroup, it.sequenceCount) @@ -62,8 +72,8 @@ class DeadLetterManager( val processingGroup: String, val processorName: String, val componentName: String, - val dlqComponentName: String, val dlq: SequencedDeadLetterQueue, + val processor: SequencedDeadLetterProcessor, ) private val dlqNamePattern = @@ -205,7 +215,7 @@ class DeadLetterManager( } fun process(processingGroup: String, messageIdentifier: String): Boolean { - val processor = deadLetterProcessorFor(processingGroup) + val processor = dlqFor(processingGroup).processor return processor.process { it.message().identifier() == messageIdentifier } .get(60, TimeUnit.SECONDS) } @@ -215,7 +225,7 @@ class DeadLetterManager( maxMessages: Int? = null, timeoutSeconds: Long = 600, ): Int { - val processor = deadLetterProcessorFor(processingGroup) + val processor = dlqFor(processingGroup).processor var processed = 0 val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(timeoutSeconds) while (maxMessages == null || processed < maxMessages) { @@ -248,85 +258,48 @@ class DeadLetterManager( ?: throw IllegalArgumentException( "There is no dead-letter queue for processing group [$processingGroup]") - private fun deadLetterProcessorFor(processingGroup: String): SequencedDeadLetterProcessor { - val entry = dlqFor(processingGroup) - - // EventHandlingComponents live in per-processor sub-modules. Direct lookup by name on the - // global Configuration only sees the global scope, so we use getComponents(...) — which - // (like for SequencedDeadLetterQueue) walks all module scopes — and then pick the entry - // whose registered name matches the one encoded inside the DLQ component name. - val ehc = configuration - .getComponents(EventHandlingComponent::class.java)[entry.dlqComponentName] - ?: throw IllegalArgumentException( - "No event handling component registered for [${entry.dlqComponentName}]") - - return findDeadLetterProcessor(ehc) - ?: throw IllegalStateException( - "Component [${entry.dlqComponentName}] is not wrapped with dead-letter processing") - } - - /** - * Walks the [EventHandlingComponent] decorator chain looking for the [SequencedDeadLetterProcessor] - * implementation. Other framework-client decorators may wrap the dead-lettering component, hiding it from a direct - * cast. - */ - @Suppress("UNCHECKED_CAST") - private fun findDeadLetterProcessor(root: EventHandlingComponent): SequencedDeadLetterProcessor? { - val seen = mutableSetOf() - var current: Any? = root - while (current != null && seen.add(current)) { - if (current is SequencedDeadLetterProcessor<*>) { - return current as SequencedDeadLetterProcessor - } - current = readDelegate(current) - } - return null - } - - private fun readDelegate(target: Any): Any? { - var clazz: Class<*>? = target.javaClass - while (clazz != null && clazz != Any::class.java) { - try { - val field = clazz.getDeclaredField("delegate") - field.isAccessible = true - return field.get(target) - } catch (_: NoSuchFieldException) { - clazz = clazz.superclass - } - } - return null - } - @Suppress("UNCHECKED_CAST") - private fun discover(): List { - data class ParsedDlq( + private fun discoverEntries(): List { + data class Parsed( + val module: Configuration, val processor: String, val component: String, val dlq: SequencedDeadLetterQueue, ) - val parsed = configuration.getComponents(SequencedDeadLetterQueue::class.java) - .mapNotNull { (dlqName, dlq) -> - val match = dlqNamePattern.find(dlqName) ?: return@mapNotNull null - ParsedDlq( - processor = match.groupValues[1], - component = match.groupValues[2], - dlq = dlq as SequencedDeadLetterQueue, - ) - } + val parsed = configuration.moduleConfigurations.flatMap { module -> + module.getComponents(SequencedDeadLetterQueue::class.java) + .mapNotNull { (name, dlq) -> + val match = dlqNamePattern.find(name) ?: return@mapNotNull null + Parsed( + module = module, + processor = match.groupValues[1], + component = match.groupValues[2], + dlq = dlq as SequencedDeadLetterQueue, + ) + } + } val perProcessor = parsed.groupingBy { it.processor }.eachCount() - return parsed.map { (processor, component, dlq) -> + return parsed.map { + val ehcName = "EventHandlingComponent[${it.processor}][${it.component}]" + val processor = it.module + .getOptionalComponent(SequencedDeadLetterProcessor::class.java, ehcName) + .orElseThrow { + IllegalStateException( + "Component [$ehcName] is not wrapped with dead-letter processing") + } as SequencedDeadLetterProcessor DlqEntry( - processingGroup = if (perProcessor[processor] == 1) processor else "$processor::$component", - processorName = processor, - componentName = component, - // The DLQ component factory keys EventHandlingComponents by the inner name used in the DLQ key. - dlqComponentName = "EventHandlingComponent[$processor][$component]", - dlq = dlq, + processingGroup = if (perProcessor[it.processor] == 1) it.processor else "${it.processor}::${it.component}", + processorName = it.processor, + componentName = it.component, + dlq = it.dlq, + processor = processor, ) } } + private fun discover(): List = entries ?: discoverEntries().also { entries = it } + private fun DeadLetter.toApiLetter(sequenceIdentifier: String): ApiDeadLetter { val message = this.message() return ApiDeadLetter( From b4dd9127710cf0e72df23358c9e87254597f2e34 Mon Sep 17 00:00:00 2001 From: Stefan Mirkovic Date: Fri, 8 May 2026 09:08:14 +0200 Subject: [PATCH 03/10] Capped DeadLetterManager.deadLetters maxSequenceLetters back to 10 to keep the per-poll payload small (a 1000-letter cap caused ~7s refresh cycles on local DLQs at page-size 25). --- .../framework/eventprocessor/DeadLetterManager.kt | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/framework-client/src/main/java/io/axoniq/platform/framework/eventprocessor/DeadLetterManager.kt b/framework-client/src/main/java/io/axoniq/platform/framework/eventprocessor/DeadLetterManager.kt index 0177a7b3..3b7b14c9 100644 --- a/framework-client/src/main/java/io/axoniq/platform/framework/eventprocessor/DeadLetterManager.kt +++ b/framework-client/src/main/java/io/axoniq/platform/framework/eventprocessor/DeadLetterManager.kt @@ -83,10 +83,16 @@ class DeadLetterManager( processingGroup: String, offset: Int = 0, size: Int = 25, - // TEMPORARILY raised from 10 to 1000 so the platform UI can show the real per-sequence - // count instead of the "10+" placeholder. Revert once the response carries an explicit - // total-count field per sequence. - maxSequenceLetters: Int = 1000, + // Per-sequence cap intentionally small. The list query is meant to give the platform UI + // a *page* of sequences with enough preview letters to seed the detail modal — not to + // ship every letter every refresh. Mitchell observed 7-second refresh cycles on a local + // DLQ when this defaulted to 1000 (page-size 25 sequences x up to 1000 letters each = + // ~25k letter records serialised on every poll). 10 matches the historical "10+" + // placeholder behaviour the platform UI already displays for capped sequences and keeps + // the per-letter payload off the hot path. The detail modal pulls full pages lazily + // through `sequenceLetters(...)` (FetchDeadLettersForSequence) so long sequences are + // still browsable end-to-end without inflating the list query. + maxSequenceLetters: Int = 10, ): DeadLetterResponse { val entry = dlqFor(processingGroup) val sequences = entry.dlq.deadLetters(null).join() From 697bed6283ab5497350706f0f65fe2d0a02c93dc Mon Sep 17 00:00:00 2001 From: Stefan Mirkovic Date: Thu, 14 May 2026 15:25:55 +0200 Subject: [PATCH 04/10] add test coverage for dlq --- ...latformDeadLetterConfigurerEnhancerTest.kt | 102 ++++ .../eventprocessor/DeadLetterManagerTest.kt | 520 ++++++++++++++++++ .../ProcessorReportCreatorTest.kt | 104 ++++ .../eventprocessor/RSocketDlqResponderTest.kt | 181 ++++++ 4 files changed, 907 insertions(+) create mode 100644 framework-client/src/test/kotlin/io/axoniq/platform/framework/eventprocessor/AxoniqPlatformDeadLetterConfigurerEnhancerTest.kt create mode 100644 framework-client/src/test/kotlin/io/axoniq/platform/framework/eventprocessor/DeadLetterManagerTest.kt create mode 100644 framework-client/src/test/kotlin/io/axoniq/platform/framework/eventprocessor/ProcessorReportCreatorTest.kt create mode 100644 framework-client/src/test/kotlin/io/axoniq/platform/framework/eventprocessor/RSocketDlqResponderTest.kt diff --git a/framework-client/src/test/kotlin/io/axoniq/platform/framework/eventprocessor/AxoniqPlatformDeadLetterConfigurerEnhancerTest.kt b/framework-client/src/test/kotlin/io/axoniq/platform/framework/eventprocessor/AxoniqPlatformDeadLetterConfigurerEnhancerTest.kt new file mode 100644 index 00000000..a2305d14 --- /dev/null +++ b/framework-client/src/test/kotlin/io/axoniq/platform/framework/eventprocessor/AxoniqPlatformDeadLetterConfigurerEnhancerTest.kt @@ -0,0 +1,102 @@ +/* + * 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.eventprocessor + +import io.axoniq.platform.framework.AxoniqPlatformConfiguration +import io.axoniq.platform.framework.AxoniqPlatformConfigurerEnhancer +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.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +/** + * Verifies the guards in [AxoniqPlatformDeadLetterConfigurerEnhancer]: the DLQ components must + * register only when the host application is wired with the platform client + * ([AxoniqPlatformConfiguration] present) and the addon hasn't already been registered. + * + * The classpath probe is intentionally NOT covered here — `axoniq-dead-letter` is on the test + * classpath via `provided` scope, so [Class.forName] always succeeds in tests. Exercising the + * absent branch would require classloader trickery that adds more risk than coverage. + */ +class AxoniqPlatformDeadLetterConfigurerEnhancerTest { + + private val enhancer = AxoniqPlatformDeadLetterConfigurerEnhancer() + + @Test + fun `registers all three components when AxoniqPlatformConfiguration is present and neither DLQ component is registered yet`() { + val registry = mockk(relaxed = true) + every { registry.hasComponent(AxoniqPlatformConfiguration::class.java) } returns true + every { registry.hasComponent(DeadLetterManager::class.java) } returns false + every { registry.hasComponent(ProcessingGroupInfoSource::class.java) } returns false + + enhancer.enhance(registry) + + // DeadLetterManager + ProcessingGroupInfoSource + RSocketDlqResponder + verify(exactly = 3) { registry.registerComponent(any>()) } + } + + @Test + fun `no-op when AxoniqPlatformConfiguration is absent — host is not a platform client`() { + val registry = mockk(relaxed = true) + every { registry.hasComponent(AxoniqPlatformConfiguration::class.java) } returns false + + enhancer.enhance(registry) + + verify(exactly = 0) { registry.registerComponent(any>()) } + } + + @Test + fun `idempotent — no registrations when DeadLetterManager is already registered`() { + val registry = mockk(relaxed = true) + every { registry.hasComponent(AxoniqPlatformConfiguration::class.java) } returns true + every { registry.hasComponent(DeadLetterManager::class.java) } returns true + every { registry.hasComponent(ProcessingGroupInfoSource::class.java) } returns false + + enhancer.enhance(registry) + + verify(exactly = 0) { registry.registerComponent(any>()) } + } + + @Test + fun `idempotent — no registrations when ProcessingGroupInfoSource is already registered`() { + val registry = mockk(relaxed = true) + every { registry.hasComponent(AxoniqPlatformConfiguration::class.java) } returns true + every { registry.hasComponent(DeadLetterManager::class.java) } returns false + every { registry.hasComponent(ProcessingGroupInfoSource::class.java) } returns true + + enhancer.enhance(registry) + + verify(exactly = 0) { registry.registerComponent(any>()) } + } + + // Note: the "Spring-path" branch in `register(...)` that skips re-registering + // ProcessingGroupInfoSource when it's already exposed by the Spring-backed registry is not + // reachable on the current code path — the top-level guard above bails out if EITHER + // DeadLetterManager or ProcessingGroupInfoSource is already registered. The pair of + // idempotency tests above therefore cover both flags of that combined guard. If the + // top-level guard is ever loosened, this branch would need its own dedicated test. + + @Test + fun `order is PLATFORM_ENHANCER_ORDER + 1 so the platform client components are visible`() { + // RSocketDlqResponder needs RSocketHandlerRegistrar at start-time; that component is + // registered by the main platform enhancer, so this enhancer must run after it. + assertEquals(AxoniqPlatformConfigurerEnhancer.PLATFORM_ENHANCER_ORDER + 1, enhancer.order()) + } +} diff --git a/framework-client/src/test/kotlin/io/axoniq/platform/framework/eventprocessor/DeadLetterManagerTest.kt b/framework-client/src/test/kotlin/io/axoniq/platform/framework/eventprocessor/DeadLetterManagerTest.kt new file mode 100644 index 00000000..1dc1107d --- /dev/null +++ b/framework-client/src/test/kotlin/io/axoniq/platform/framework/eventprocessor/DeadLetterManagerTest.kt @@ -0,0 +1,520 @@ +/* + * 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.eventprocessor + +import io.axoniq.framework.messaging.deadletter.Cause +import io.axoniq.framework.messaging.deadletter.DeadLetter +import io.axoniq.framework.messaging.deadletter.SequencedDeadLetterProcessor +import io.axoniq.framework.messaging.deadletter.SequencedDeadLetterQueue +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.axonframework.common.configuration.Configuration +import org.axonframework.messaging.core.Metadata +import org.axonframework.messaging.core.MessageType +import org.axonframework.messaging.eventhandling.EventMessage +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.time.Instant +import java.util.Optional +import java.util.concurrent.CompletableFuture + +/** + * Unit tests for [DeadLetterManager] — discovery, synthetic-id mapping, pagination, payload + * truncation, and delegation to the underlying [SequencedDeadLetterQueue]. Builds the queue + * fakes with mockk; the real DLQ implementation isn't on the classpath we want to exercise. + * + * Intentionally out of scope: + * - `findDeadLetterProcessor` reflective walk over `EventHandlingComponent` decorators — that + * needs the AF5 module wiring to materialise, which is integration territory. + * - `process(...)` / `processAll(...)` — these delegate to the resolved + * `SequencedDeadLetterProcessor` whose `process(Predicate)` future-form is asymmetric to + * construct from the test side; the colleague explicitly said no integration tests. + */ +class DeadLetterManagerTest { + + // --------------------------------------------------------------------------------------- + // Discovery / processing group naming + // --------------------------------------------------------------------------------------- + + @Test + fun `exposes processor name as processing group when the processor has a single DLQ`() { + val dlq = fakeDlq() + val configuration = configurationWith( + "DeadLetterQueue[EventHandlingComponent[orders][OrderProjector]]" to dlq, + ) + val manager = DeadLetterManager(configuration).also { it.start() } + + val infos = manager.infoFor("orders") + + assertEquals(listOf("orders"), infos.map { it.processingGroup }) + } + + @Test + fun `exposes processor__component identifier when a processor has multiple DLQs`() { + val configuration = configurationWith( + "DeadLetterQueue[EventHandlingComponent[orders][OrderProjector]]" to fakeDlq(), + "DeadLetterQueue[EventHandlingComponent[orders][AuditProjector]]" to fakeDlq(), + ) + val manager = DeadLetterManager(configuration).also { it.start() } + + val infos = manager.infoFor("orders") + + assertEquals( + setOf("orders::OrderProjector", "orders::AuditProjector"), + infos.map { it.processingGroup }.toSet(), + ) + } + + @Test + fun `ignores components whose names do not match the DLQ pattern`() { + val configuration = configurationWith( + "DeadLetterQueue[EventHandlingComponent[orders][OrderProjector]]" to fakeDlq(), + "SomeOtherComponent" to fakeDlq(), + "DeadLetterQueue[Other][format]" to fakeDlq(), + ) + val manager = DeadLetterManager(configuration).also { it.start() } + + assertEquals(listOf("orders"), manager.infoFor("orders").map { it.processingGroup }) + } + + @Test + fun `infoFor returns only DLQs belonging to the requested processor`() { + val configuration = configurationWith( + "DeadLetterQueue[EventHandlingComponent[orders][OrderProjector]]" to fakeDlq(sequenceCount = 3), + "DeadLetterQueue[EventHandlingComponent[shipping][ShipmentProjector]]" to fakeDlq(sequenceCount = 7), + ) + val manager = DeadLetterManager(configuration).also { it.start() } + + val ordersInfo = manager.infoFor("orders") + val shippingInfo = manager.infoFor("shipping") + + assertEquals(listOf("orders" to 3L), ordersInfo.map { it.processingGroup to it.dlqSize }) + assertEquals(listOf("shipping" to 7L), shippingInfo.map { it.processingGroup to it.dlqSize }) + } + + // --------------------------------------------------------------------------------------- + // Synthetic sequence id + // --------------------------------------------------------------------------------------- + + @Test + fun `deadLetters stamps every letter in a sequence with the first letter's message id`() { + val sequence = listOf( + fakeLetter(messageId = "first"), + fakeLetter(messageId = "second"), + fakeLetter(messageId = "third"), + ) + val dlq = fakeDlq(sequences = listOf(sequence)) + val configuration = configurationWith( + "DeadLetterQueue[EventHandlingComponent[orders][OrderProjector]]" to dlq, + ) + val manager = DeadLetterManager(configuration).also { it.start() } + + val response = manager.deadLetters("orders") + + assertEquals(1, response.sequences.size) + assertEquals(listOf("first", "first", "first"), response.sequences[0].map { it.sequenceIdentifier }) + } + + @Test + fun `empty sequence gets an empty-string synthetic id without crashing`() { + // Degenerate but documented: if a sequence iterator yields no letters, the synthetic id + // is the empty string. The mapped list is empty, so there's nothing to inspect on it — + // we just need this not to throw. + val dlq = fakeDlq(sequences = listOf(emptyList())) + val configuration = configurationWith( + "DeadLetterQueue[EventHandlingComponent[orders][OrderProjector]]" to dlq, + ) + val manager = DeadLetterManager(configuration).also { it.start() } + + val response = manager.deadLetters("orders") + + assertEquals(1, response.sequences.size) + assertTrue(response.sequences[0].isEmpty()) + } + + // --------------------------------------------------------------------------------------- + // lettersForSequence pagination + // --------------------------------------------------------------------------------------- + + @Test + fun `lettersForSequence returns the requested slice in order with the correct total`() { + val sequence = (1..5).map { fakeLetter(messageId = "m$it", payload = "p$it") } + val dlq = fakeDlq(sequences = listOf(sequence)) + val manager = DeadLetterManager(configurationWith( + "DeadLetterQueue[EventHandlingComponent[orders][OrderProjector]]" to dlq, + )).also { it.start() } + + val response = manager.lettersForSequence("orders", "m1", offset = 1, size = 2) + + assertEquals(5L, response.totalCount) + assertEquals(listOf("m2", "m3"), response.letters.map { it.messageIdentifier }) + } + + @Test + fun `lettersForSequence coerces a negative offset to zero`() { + val sequence = (1..3).map { fakeLetter(messageId = "m$it") } + val dlq = fakeDlq(sequences = listOf(sequence)) + val manager = DeadLetterManager(configurationWith( + "DeadLetterQueue[EventHandlingComponent[orders][OrderProjector]]" to dlq, + )).also { it.start() } + + val response = manager.lettersForSequence("orders", "m1", offset = -5, size = 2) + + assertEquals(listOf("m1", "m2"), response.letters.map { it.messageIdentifier }) + } + + @Test + fun `lettersForSequence coerces a non-positive size to one`() { + val sequence = (1..3).map { fakeLetter(messageId = "m$it") } + val dlq = fakeDlq(sequences = listOf(sequence)) + val manager = DeadLetterManager(configurationWith( + "DeadLetterQueue[EventHandlingComponent[orders][OrderProjector]]" to dlq, + )).also { it.start() } + + val response = manager.lettersForSequence("orders", "m1", offset = 0, size = 0) + + assertEquals(1, response.letters.size) + assertEquals("m1", response.letters[0].messageIdentifier) + } + + @Test + fun `lettersForSequence returns an empty response when no sequence matches the synthetic id`() { + val sequence = listOf(fakeLetter(messageId = "real")) + val dlq = fakeDlq(sequences = listOf(sequence)) + val manager = DeadLetterManager(configurationWith( + "DeadLetterQueue[EventHandlingComponent[orders][OrderProjector]]" to dlq, + )).also { it.start() } + + val response = manager.lettersForSequence("orders", "stale-id", 0, 10) + + assertTrue(response.letters.isEmpty()) + assertEquals(0L, response.totalCount) + } + + @Test + fun `lettersForSequence caps the slice at the size argument even when the sequence is larger`() { + val sequence = (1..10).map { fakeLetter(messageId = "m$it") } + val dlq = fakeDlq(sequences = listOf(sequence)) + val manager = DeadLetterManager(configurationWith( + "DeadLetterQueue[EventHandlingComponent[orders][OrderProjector]]" to dlq, + )).also { it.start() } + + val response = manager.lettersForSequence("orders", "m1", offset = 0, size = 3) + + assertEquals(3, response.letters.size) + assertEquals(10L, response.totalCount) + } + + // --------------------------------------------------------------------------------------- + // delete / deleteAll delegation + // --------------------------------------------------------------------------------------- + + @Test + fun `delete by sequence evicts every letter in that sequence`() { + val letters = listOf(fakeLetter("m1"), fakeLetter("m2"), fakeLetter("m3")) + val dlq = fakeDlq(sequences = listOf(letters)) + val manager = DeadLetterManager(configurationWith( + "DeadLetterQueue[EventHandlingComponent[orders][OrderProjector]]" to dlq, + )).also { it.start() } + + val evicted = manager.delete("orders", "m1") + + assertEquals(3, evicted) + letters.forEach { verify(exactly = 1) { dlq.evict(it, null) } } + } + + @Test + fun `delete by sequence is a no-op when the sequence does not exist`() { + val dlq = fakeDlq(sequences = listOf(listOf(fakeLetter("real")))) + val manager = DeadLetterManager(configurationWith( + "DeadLetterQueue[EventHandlingComponent[orders][OrderProjector]]" to dlq, + )).also { it.start() } + + val evicted = manager.delete("orders", "ghost") + + assertEquals(0, evicted) + verify(exactly = 0) { dlq.evict(any>(), any()) } + } + + @Test + fun `delete by message evicts only the matching letter`() { + val letter1 = fakeLetter("m1") + val letter2 = fakeLetter("m2") + val dlq = fakeDlq(sequences = listOf(listOf(letter1, letter2))) + val manager = DeadLetterManager(configurationWith( + "DeadLetterQueue[EventHandlingComponent[orders][OrderProjector]]" to dlq, + )).also { it.start() } + + val evicted = manager.delete("orders", "m1", "m2") + + assertTrue(evicted) + verify(exactly = 1) { dlq.evict(letter2, null) } + verify(exactly = 0) { dlq.evict(letter1, null) } + } + + @Test + fun `delete by message is a no-op when the message id is unknown in the sequence`() { + val letter1 = fakeLetter("m1") + val dlq = fakeDlq(sequences = listOf(listOf(letter1))) + val manager = DeadLetterManager(configurationWith( + "DeadLetterQueue[EventHandlingComponent[orders][OrderProjector]]" to dlq, + )).also { it.start() } + + val evicted = manager.delete("orders", "m1", "missing") + + assertFalse(evicted) + verify(exactly = 0) { dlq.evict(any>(), any()) } + } + + @Test + fun `delete by message is a no-op when the sequence does not resolve`() { + val dlq = fakeDlq(sequences = listOf(listOf(fakeLetter("real")))) + val manager = DeadLetterManager(configurationWith( + "DeadLetterQueue[EventHandlingComponent[orders][OrderProjector]]" to dlq, + )).also { it.start() } + + val evicted = manager.delete("orders", "ghost", "anything") + + assertFalse(evicted) + verify(exactly = 0) { dlq.evict(any>(), any()) } + } + + @Test + fun `deleteAll returns the queue size and clears the queue`() { + val dlq = fakeDlq(totalSize = 42L) + val manager = DeadLetterManager(configurationWith( + "DeadLetterQueue[EventHandlingComponent[orders][OrderProjector]]" to dlq, + )).also { it.start() } + + val deleted = manager.deleteAll("orders") + + assertEquals(42, deleted) + verify(exactly = 1) { dlq.clear(null) } + } + + @Test + fun `sequenceSize returns the count of letters for the matching synthetic id`() { + val sequence = (1..4).map { fakeLetter("m$it") } + val dlq = fakeDlq(sequences = listOf(sequence)) + val manager = DeadLetterManager(configurationWith( + "DeadLetterQueue[EventHandlingComponent[orders][OrderProjector]]" to dlq, + )).also { it.start() } + + assertEquals(4L, manager.sequenceSize("orders", "m1")) + } + + @Test + fun `sequenceSize returns zero when the synthetic id does not resolve`() { + val sequence = listOf(fakeLetter("real")) + val dlq = fakeDlq(sequences = listOf(sequence)) + val manager = DeadLetterManager(configurationWith( + "DeadLetterQueue[EventHandlingComponent[orders][OrderProjector]]" to dlq, + )).also { it.start() } + + assertEquals(0L, manager.sequenceSize("orders", "ghost")) + } + + // --------------------------------------------------------------------------------------- + // Payload truncation + messageType fallback + // --------------------------------------------------------------------------------------- + + @Test + fun `payload at or below 1024 UTF-8 bytes is returned untouched`() { + val payload = "x".repeat(1024) + val sequence = listOf(fakeLetter("m1", payload = payload)) + val dlq = fakeDlq(sequences = listOf(sequence)) + val manager = DeadLetterManager(configurationWith( + "DeadLetterQueue[EventHandlingComponent[orders][OrderProjector]]" to dlq, + )).also { it.start() } + + val response = manager.deadLetters("orders") + + assertEquals(payload, response.sequences[0][0].message) + } + + @Test + fun `payload over 1024 UTF-8 bytes is truncated without splitting a multi-byte codepoint`() { + // "č" is U+010D, two bytes in UTF-8. Filling beyond 1024 bytes guarantees the cutoff + // lands inside a multi-byte sequence — a naive byte slice would yield a malformed + // codepoint there. The implementation must round down to the previous valid boundary. + val char = "č" + val payload = char.repeat(600) // 600 * 2 = 1200 bytes + val sequence = listOf(fakeLetter("m1", payload = payload)) + val dlq = fakeDlq(sequences = listOf(sequence)) + val manager = DeadLetterManager(configurationWith( + "DeadLetterQueue[EventHandlingComponent[orders][OrderProjector]]" to dlq, + )).also { it.start() } + + val result = manager.deadLetters("orders").sequences[0][0].message + + // Truncated string fits within the limit ... + assertTrue(result.toByteArray(Charsets.UTF_8).size <= 1024) + // ... and contains no replacement characters from a mid-codepoint split. + assertFalse(result.contains('�')) + // The resulting string is composed entirely of valid "č" codepoints. + assertTrue(result.all { it == 'č' }) + } + + @Test + fun `messageType falls back to message type name when payload class is ByteArray`() { + val message = fakeEventMessage( + id = "m1", + payload = "still serialised".toByteArray(), + payloadType = ByteArray::class.java, + typeName = "com.example.OrderPlaced", + ) + val letter = fakeLetterFromMessage(message) + val dlq = fakeDlq(sequences = listOf(listOf(letter))) + val manager = DeadLetterManager(configurationWith( + "DeadLetterQueue[EventHandlingComponent[orders][OrderProjector]]" to dlq, + )).also { it.start() } + + val apiLetter = manager.deadLetters("orders").sequences[0][0] + + assertEquals("com.example.OrderPlaced", apiLetter.messageType) + } + + @Test + fun `messageType uses payload class simple name for non-ByteArray payloads`() { + val sequence = listOf(fakeLetter("m1", payload = "hello", payloadType = String::class.java)) + val dlq = fakeDlq(sequences = listOf(sequence)) + val manager = DeadLetterManager(configurationWith( + "DeadLetterQueue[EventHandlingComponent[orders][OrderProjector]]" to dlq, + )).also { it.start() } + + val apiLetter = manager.deadLetters("orders").sequences[0][0] + + assertEquals("String", apiLetter.messageType) + } + + // --------------------------------------------------------------------------------------- + // dlqFor error + // --------------------------------------------------------------------------------------- + + @Test + fun `unknown processing group throws IllegalArgumentException`() { + val manager = DeadLetterManager(configurationWith( + "DeadLetterQueue[EventHandlingComponent[orders][OrderProjector]]" to fakeDlq(), + )).also { it.start() } + + assertThrows(IllegalArgumentException::class.java) { + manager.sequenceSize("unknown-group", "whatever") + } + } + + // --------------------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------------------- + + /** + * Builds a [Configuration] backed by a single module that exposes the given DLQs and a + * matching [SequencedDeadLetterProcessor] for each — the manager looks up the latter by the + * `EventHandlingComponent[][]` name to materialise its + * `DlqEntry.processor` field. + */ + private fun configurationWith( + vararg dlqsByName: Pair>, + ): Configuration { + val module = mockk(relaxed = true) + every { module.getComponents(SequencedDeadLetterQueue::class.java) } returns + dlqsByName.toMap().mapValues { it.value as SequencedDeadLetterQueue<*> } + // Every DLQ name carries the processor + component segment used to address its processor. + dlqsByName.forEach { (name, _) -> + val match = Regex("""^DeadLetterQueue\[EventHandlingComponent\[([^]]+)]\[(.+)]]$""").find(name) + if (match != null) { + val ehcName = "EventHandlingComponent[${match.groupValues[1]}][${match.groupValues[2]}]" + val processor = mockk>(relaxed = true) + every { + module.getOptionalComponent(SequencedDeadLetterProcessor::class.java, ehcName) + } returns Optional.of(processor) + } + } + val root = mockk(relaxed = true) + every { root.moduleConfigurations } returns listOf(module) + return root + } + + private fun fakeDlq( + sequences: List>> = emptyList(), + sequenceCount: Long = sequences.size.toLong(), + totalSize: Long = sequences.sumOf { it.size.toLong() }, + ): SequencedDeadLetterQueue { + val dlq = mockk>(relaxed = true) + // `deadLetters(null)` returns a CompletableFuture>>; the + // manager calls `.join()` and iterates with `.toList()` on each inner sequence, so any + // Iterable shape works here. + every { dlq.deadLetters(null) } returns CompletableFuture.completedFuture( + sequences as Iterable>>, + ) + every { dlq.amountOfSequences(null) } returns CompletableFuture.completedFuture(sequenceCount) + every { dlq.size(null) } returns CompletableFuture.completedFuture(totalSize) + every { dlq.clear(null) } returns CompletableFuture.completedFuture(null) + every { dlq.evict(any>(), null) } returns CompletableFuture.completedFuture(null) + return dlq + } + + private fun fakeLetter( + messageId: String, + payload: Any? = "payload-$messageId", + payloadType: Class<*> = (payload?.javaClass ?: String::class.java), + causeType: String? = "java.lang.RuntimeException", + causeMessage: String? = "boom", + ): DeadLetter { + val message = fakeEventMessage(messageId, payload, payloadType) + return fakeLetterFromMessage(message, causeType, causeMessage) + } + + private fun fakeLetterFromMessage( + message: EventMessage, + causeType: String? = "java.lang.RuntimeException", + causeMessage: String? = "boom", + ): DeadLetter { + val letter = mockk>(relaxed = true) + every { letter.message() } returns message + val cause: Optional = if (causeType == null) Optional.empty() else { + val c = mockk() + every { c.type() } returns causeType + every { c.message() } returns (causeMessage ?: "") + Optional.of(c) + } + every { letter.cause() } returns cause + every { letter.enqueuedAt() } returns Instant.EPOCH + every { letter.lastTouched() } returns Instant.EPOCH + every { letter.diagnostics() } returns Metadata.emptyInstance() + return letter + } + + private fun fakeEventMessage( + id: String, + payload: Any?, + payloadType: Class<*>, + typeName: String = "com.example.${payloadType.simpleName ?: "Anonymous"}", + ): EventMessage { + val message = mockk(relaxed = true) + every { message.identifier() } returns id + every { message.payload() } returns payload + every { message.payloadType() } returns payloadType + val type = mockk() + every { type.name() } returns typeName + every { message.type() } returns type + return message + } +} diff --git a/framework-client/src/test/kotlin/io/axoniq/platform/framework/eventprocessor/ProcessorReportCreatorTest.kt b/framework-client/src/test/kotlin/io/axoniq/platform/framework/eventprocessor/ProcessorReportCreatorTest.kt new file mode 100644 index 00000000..9f6ecf21 --- /dev/null +++ b/framework-client/src/test/kotlin/io/axoniq/platform/framework/eventprocessor/ProcessorReportCreatorTest.kt @@ -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.eventprocessor + +import io.axoniq.platform.framework.api.ProcessingGroupStatus +import io.mockk.every +import io.mockk.mockk +import org.axonframework.common.configuration.Configuration +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import java.util.Optional + +/** + * Focused unit tests for the [ProcessingGroupInfoSource] integration in [ProcessorReportCreator]. + * The full `createReport()` flow needs a live AF5 configuration with processors and segment + * tracker statuses — that's integration territory and not what we're testing here. Instead we + * reach into the private `processingGroupsFor` via reflection so we can exercise the optional + * source seam in isolation. + * + * Reflection is the right tool here because the production class deliberately keeps this + * method private (it's a callee of `streamingStatus(...)` only), and widening visibility just + * for tests would leak the seam into the public surface. + */ +class ProcessorReportCreatorTest { + + @Test + fun `processingGroupsFor returns empty when no ProcessingGroupInfoSource is registered`() { + val config = baseConfigurationWith(infoSource = Optional.empty()) + val creator = ProcessorReportCreator(config) + + val result = invokeProcessingGroupsFor(creator, "orders") + + assertEquals(emptyList(), result) + } + + @Test + fun `processingGroupsFor maps source infos to ProcessingGroupStatus entries`() { + val source = mockk() + every { source.infoFor("orders") } returns listOf( + ProcessingGroupInfoSource.ProcessingGroupInfo("orders", 3L), + ProcessingGroupInfoSource.ProcessingGroupInfo("orders::audit", 0L), + ) + val config = baseConfigurationWith(infoSource = Optional.of(source)) + val creator = ProcessorReportCreator(config) + + val result = invokeProcessingGroupsFor(creator, "orders") + + assertEquals( + listOf( + ProcessingGroupStatus("orders", 3L), + ProcessingGroupStatus("orders::audit", 0L), + ), + result, + ) + } + + @Test + fun `processingGroupsFor swallows source exceptions and returns empty list`() { + // A failing probe must not break processor reporting — the warning is logged but the + // caller receives an empty list and the rest of the report still renders. + val source = mockk() + every { source.infoFor("orders") } throws RuntimeException("boom") + val config = baseConfigurationWith(infoSource = Optional.of(source)) + val creator = ProcessorReportCreator(config) + + val result = invokeProcessingGroupsFor(creator, "orders") + + assertEquals(emptyList(), result) + } + + /** + * The constructor calls `getComponent(ProcessorMetricsRegistry::class.java)` and + * `getOptionalComponent(ProcessingGroupInfoSource::class.java)`. We provide just enough of + * each so construction succeeds; the metrics registry isn't exercised by the test path + * (no segments => no metrics lookups), so a relaxed mock is fine. + */ + private fun baseConfigurationWith(infoSource: Optional): Configuration { + val config = mockk(relaxed = true) + every { config.getComponent(ProcessorMetricsRegistry::class.java) } returns ProcessorMetricsRegistry() + every { config.getOptionalComponent(ProcessingGroupInfoSource::class.java) } returns infoSource + return config + } + + @Suppress("UNCHECKED_CAST") + private fun invokeProcessingGroupsFor(creator: ProcessorReportCreator, processorName: String): List { + val method = ProcessorReportCreator::class.java.getDeclaredMethod("processingGroupsFor", String::class.java) + method.isAccessible = true + return method.invoke(creator, processorName) as List + } +} diff --git a/framework-client/src/test/kotlin/io/axoniq/platform/framework/eventprocessor/RSocketDlqResponderTest.kt b/framework-client/src/test/kotlin/io/axoniq/platform/framework/eventprocessor/RSocketDlqResponderTest.kt new file mode 100644 index 00000000..e31b4028 --- /dev/null +++ b/framework-client/src/test/kotlin/io/axoniq/platform/framework/eventprocessor/RSocketDlqResponderTest.kt @@ -0,0 +1,181 @@ +/* + * 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.eventprocessor + +import io.axoniq.platform.framework.api.DeadLetterProcessRequest +import io.axoniq.platform.framework.api.DeadLetterRequest +import io.axoniq.platform.framework.api.DeadLetterResponse +import io.axoniq.platform.framework.api.DeadLetterSequenceDeleteRequest +import io.axoniq.platform.framework.api.DeadLetterSequenceSize +import io.axoniq.platform.framework.api.DeadLetterSingleDeleteRequest +import io.axoniq.platform.framework.api.DeleteAllDeadLetterSequencesRequest +import io.axoniq.platform.framework.api.FetchSequenceLettersRequest +import io.axoniq.platform.framework.api.ProcessAllDeadLetterSequencesRequest +import io.axoniq.platform.framework.api.Routes +import io.axoniq.platform.framework.api.SequenceLettersResponse +import io.axoniq.platform.framework.client.RSocketHandlerRegistrar +import io.mockk.CapturingSlot +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import io.mockk.slot +import io.mockk.verify +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +/** + * Verifies that [RSocketDlqResponder] registers a handler for every DLQ route on + * [RSocketHandlerRegistrar.registerHandlerWithPayload] and that each captured handler delegates + * to the corresponding [DeadLetterManager] method. We capture the lambda handed to the registrar + * so we exercise both registration AND the handler's body in a single test per route. + * + * The slots are typed as `(T) -> Any` because that is the exact functional shape + * `registerHandlerWithPayload` declares; the production handlers happen to return more specific + * types but the registrar erases them down to `Any`. + */ +class RSocketDlqResponderTest { + + private lateinit var manager: DeadLetterManager + private lateinit var registrar: RSocketHandlerRegistrar + private lateinit var responder: RSocketDlqResponder + + private val letterHandler = slot<(DeadLetterRequest) -> Any>() + private val sequenceSizeHandler = slot<(DeadLetterSequenceSize) -> Any>() + private val sequenceLettersHandler = slot<(FetchSequenceLettersRequest) -> Any>() + private val deleteSequenceHandler = slot<(DeadLetterSequenceDeleteRequest) -> Any>() + private val deleteLetterHandler = slot<(DeadLetterSingleDeleteRequest) -> Any>() + private val processHandler = slot<(DeadLetterProcessRequest) -> Any>() + private val processAllHandler = slot<(ProcessAllDeadLetterSequencesRequest) -> Any>() + private val deleteAllHandler = slot<(DeleteAllDeadLetterSequencesRequest) -> Any>() + + @BeforeEach + fun setUp() { + manager = mockk(relaxed = true) + registrar = mockk(relaxed = true) + captureHandler(Routes.ProcessingGroup.DeadLetter.LETTERS, DeadLetterRequest::class.java, letterHandler) + captureHandler(Routes.ProcessingGroup.DeadLetter.SEQUENCE_SIZE, DeadLetterSequenceSize::class.java, sequenceSizeHandler) + captureHandler(Routes.ProcessingGroup.DeadLetter.SEQUENCE_LETTERS, FetchSequenceLettersRequest::class.java, sequenceLettersHandler) + captureHandler(Routes.ProcessingGroup.DeadLetter.DELETE_SEQUENCE, DeadLetterSequenceDeleteRequest::class.java, deleteSequenceHandler) + captureHandler(Routes.ProcessingGroup.DeadLetter.DELETE_LETTER, DeadLetterSingleDeleteRequest::class.java, deleteLetterHandler) + captureHandler(Routes.ProcessingGroup.DeadLetter.PROCESS, DeadLetterProcessRequest::class.java, processHandler) + captureHandler(Routes.ProcessingGroup.DeadLetter.PROCESS_ALL_SEQUENCES, ProcessAllDeadLetterSequencesRequest::class.java, processAllHandler) + captureHandler(Routes.ProcessingGroup.DeadLetter.DELETE_ALL_SEQUENCES, DeleteAllDeadLetterSequencesRequest::class.java, deleteAllHandler) + + responder = RSocketDlqResponder(manager, registrar) + responder.start() + } + + @Test + fun `start registers a handler for each of the eight DLQ routes`() { + verify(exactly = 1) { registrar.registerHandlerWithPayload(Routes.ProcessingGroup.DeadLetter.LETTERS, DeadLetterRequest::class.java, any()) } + verify(exactly = 1) { registrar.registerHandlerWithPayload(Routes.ProcessingGroup.DeadLetter.SEQUENCE_SIZE, DeadLetterSequenceSize::class.java, any()) } + verify(exactly = 1) { registrar.registerHandlerWithPayload(Routes.ProcessingGroup.DeadLetter.SEQUENCE_LETTERS, FetchSequenceLettersRequest::class.java, any()) } + verify(exactly = 1) { registrar.registerHandlerWithPayload(Routes.ProcessingGroup.DeadLetter.DELETE_SEQUENCE, DeadLetterSequenceDeleteRequest::class.java, any()) } + verify(exactly = 1) { registrar.registerHandlerWithPayload(Routes.ProcessingGroup.DeadLetter.DELETE_LETTER, DeadLetterSingleDeleteRequest::class.java, any()) } + verify(exactly = 1) { registrar.registerHandlerWithPayload(Routes.ProcessingGroup.DeadLetter.PROCESS, DeadLetterProcessRequest::class.java, any()) } + verify(exactly = 1) { registrar.registerHandlerWithPayload(Routes.ProcessingGroup.DeadLetter.PROCESS_ALL_SEQUENCES, ProcessAllDeadLetterSequencesRequest::class.java, any()) } + verify(exactly = 1) { registrar.registerHandlerWithPayload(Routes.ProcessingGroup.DeadLetter.DELETE_ALL_SEQUENCES, DeleteAllDeadLetterSequencesRequest::class.java, any()) } + } + + @Test + fun `LETTERS handler forwards all request parameters to DeadLetterManager deadLetters`() { + val expected = DeadLetterResponse(emptyList(), 7) + every { manager.deadLetters("g", 1, 10, 5) } returns expected + + val result = letterHandler.captured(DeadLetterRequest("g", offset = 1, size = 10, maxSequenceLetters = 5)) + + assertEquals(expected, result) + verify(exactly = 1) { manager.deadLetters("g", 1, 10, 5) } + } + + @Test + fun `SEQUENCE_SIZE handler forwards processing group and sequence id`() { + every { manager.sequenceSize("g", "seq-1") } returns 42L + + val result = sequenceSizeHandler.captured(DeadLetterSequenceSize("g", "seq-1")) + + assertEquals(42L, result) + verify(exactly = 1) { manager.sequenceSize("g", "seq-1") } + } + + @Test + fun `SEQUENCE_LETTERS handler forwards pagination arguments`() { + val expected = SequenceLettersResponse(emptyList(), 0) + every { manager.lettersForSequence("g", "seq-1", 5, 25) } returns expected + + val result = sequenceLettersHandler.captured(FetchSequenceLettersRequest("g", "seq-1", offset = 5, size = 25)) + + assertEquals(expected, result) + verify(exactly = 1) { manager.lettersForSequence("g", "seq-1", 5, 25) } + } + + @Test + fun `DELETE_SEQUENCE handler delegates to DeadLetterManager delete-by-sequence`() { + every { manager.delete("g", "seq-1") } returns 3 + + deleteSequenceHandler.captured(DeadLetterSequenceDeleteRequest("g", "seq-1")) + + verify(exactly = 1) { manager.delete("g", "seq-1") } + } + + @Test + fun `DELETE_LETTER handler delegates to DeadLetterManager delete-by-message`() { + every { manager.delete("g", "seq-1", "msg-1") } returns true + + deleteLetterHandler.captured(DeadLetterSingleDeleteRequest("g", "seq-1", "msg-1")) + + verify(exactly = 1) { manager.delete("g", "seq-1", "msg-1") } + } + + @Test + fun `PROCESS handler returns the manager's result`() { + every { manager.process("g", "msg-1") } returns true + + val result = processHandler.captured(DeadLetterProcessRequest("g", "msg-1")) + + assertEquals(true, result) + verify(exactly = 1) { manager.process("g", "msg-1") } + } + + @Test + fun `PROCESS_ALL_SEQUENCES handler forwards maxMessages`() { + every { manager.processAll("g", 9) } returns 7 + + val result = processAllHandler.captured(ProcessAllDeadLetterSequencesRequest("g", maxMessages = 9)) + + assertEquals(7, result) + verify(exactly = 1) { manager.processAll("g", 9) } + } + + @Test + fun `DELETE_ALL_SEQUENCES handler returns the cleared count`() { + every { manager.deleteAll("g") } returns 11 + + val result = deleteAllHandler.captured(DeleteAllDeadLetterSequencesRequest("g")) + + assertEquals(11, result) + verify(exactly = 1) { manager.deleteAll("g") } + } + + private fun captureHandler(route: String, payloadType: Class, handler: CapturingSlot<(T) -> Any>) { + every { + registrar.registerHandlerWithPayload(route, payloadType, capture(handler)) + } just runs + } +} From 8735aff70d108dddd2e53fcac1389892931bab53 Mon Sep 17 00:00:00 2001 From: Stefan Mirkovic Date: Thu, 21 May 2026 14:09:10 +0200 Subject: [PATCH 05/10] Address review feedback: stable sequence ids, restored dlq-mode modes, integration test, drop verbose maxSequenceLetters comment --- .../AxoniqPlatformConfiguration.java | 45 + ...qPlatformDeadLetterConfigurerEnhancer.java | 9 +- .../eventprocessor/DeadLetterManager.kt | 181 +++- .../SequenceIdentifierResolver.java | 95 ++ .../eventprocessor/DeadLetterManagerTest.kt | 839 +++++++++++------- .../RSocketDlqResponderIntegrationTest.kt | 249 ++++++ 6 files changed, 1059 insertions(+), 359 deletions(-) create mode 100644 framework-client/src/main/java/io/axoniq/platform/framework/eventprocessor/SequenceIdentifierResolver.java create mode 100644 framework-client/src/test/kotlin/io/axoniq/platform/framework/eventprocessor/RSocketDlqResponderIntegrationTest.kt diff --git a/framework-client/src/main/java/io/axoniq/platform/framework/AxoniqPlatformConfiguration.java b/framework-client/src/main/java/io/axoniq/platform/framework/AxoniqPlatformConfiguration.java index 1c8366ed..52835ab1 100644 --- a/framework-client/src/main/java/io/axoniq/platform/framework/AxoniqPlatformConfiguration.java +++ b/framework-client/src/main/java/io/axoniq/platform/framework/AxoniqPlatformConfiguration.java @@ -16,9 +16,13 @@ package io.axoniq.platform.framework; +import io.axoniq.platform.framework.api.AxoniqConsoleDlqMode; import org.axonframework.common.BuilderUtils; import java.lang.management.ManagementFactory; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; import java.util.UUID; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; @@ -41,6 +45,9 @@ public class AxoniqPlatformConfiguration { private ScheduledExecutorService reportingTaskExecutor; private Integer reportingThreadPoolSize = 2; + private AxoniqConsoleDlqMode dlqMode = AxoniqConsoleDlqMode.FULL; + private List dlqDiagnosticsWhitelist = new ArrayList<>(); + /** * Constructor to instantiate a {@link AxoniqPlatformConfiguration} based on the fields contained in the * {@link AxoniqPlatformConfiguration}. Requires the {@code environmentId}, {@code accessToken} and @@ -186,4 +193,42 @@ public String getHostname() { public Long getInitialDelay() { return initialDelay; } + + /** + * Controls how much DLQ data is exposed through the platform API. Defaults to + * {@link AxoniqConsoleDlqMode#FULL} to preserve the existing behaviour. Use {@code MASKED} when + * the platform may contain sensitive information, {@code LIMITED} to strip payload but keep + * sequence identifiers as-is for filtered diagnostics, or {@code NONE} to expose only the + * sequence count without any letter contents. + * + * @param dlqMode The dead-letter exposure mode. + * @return The builder for fluent interfacing. + */ + public AxoniqPlatformConfiguration dlqMode(AxoniqConsoleDlqMode dlqMode) { + BuilderUtils.assertNonNull(dlqMode, "Axoniq Platform dlqMode may not be null"); + this.dlqMode = dlqMode; + return this; + } + + /** + * Adds a diagnostics metadata key to the whitelist that survives {@link AxoniqConsoleDlqMode#LIMITED} + * and {@link AxoniqConsoleDlqMode#MASKED} modes. All other keys are dropped before the letter + * leaves this client. + * + * @param key The diagnostics metadata key to permit. + * @return The builder for fluent interfacing. + */ + public AxoniqPlatformConfiguration addDlqDiagnosticsWhitelistKey(String key) { + BuilderUtils.assertNonEmpty(key, "Axoniq Platform diagnostics whitelist key may not be null or empty"); + this.dlqDiagnosticsWhitelist.add(key); + return this; + } + + public AxoniqConsoleDlqMode getDlqMode() { + return dlqMode; + } + + public List getDlqDiagnosticsWhitelist() { + return Collections.unmodifiableList(dlqDiagnosticsWhitelist); + } } diff --git a/framework-client/src/main/java/io/axoniq/platform/framework/eventprocessor/AxoniqPlatformDeadLetterConfigurerEnhancer.java b/framework-client/src/main/java/io/axoniq/platform/framework/eventprocessor/AxoniqPlatformDeadLetterConfigurerEnhancer.java index de2c489b..a66b737e 100644 --- a/framework-client/src/main/java/io/axoniq/platform/framework/eventprocessor/AxoniqPlatformDeadLetterConfigurerEnhancer.java +++ b/framework-client/src/main/java/io/axoniq/platform/framework/eventprocessor/AxoniqPlatformDeadLetterConfigurerEnhancer.java @@ -67,7 +67,14 @@ public int order() { private static void register(ComponentRegistry registry) { registry.registerComponent(ComponentDefinition .ofType(DeadLetterManager.class) - .withBuilder(DeadLetterManager::new) + .withBuilder(c -> { + AxoniqPlatformConfiguration platformConfig = + c.getComponent(AxoniqPlatformConfiguration.class); + return new DeadLetterManager( + c, + platformConfig.getDlqMode(), + platformConfig.getDlqDiagnosticsWhitelist()); + }) // Discover DLQs after event processors have started, by which point the // EventHandlingComponent decorator chain has materialised every DLQ. .onStart(Phase.INSTRUCTION_COMPONENTS, DeadLetterManager::start)); diff --git a/framework-client/src/main/java/io/axoniq/platform/framework/eventprocessor/DeadLetterManager.kt b/framework-client/src/main/java/io/axoniq/platform/framework/eventprocessor/DeadLetterManager.kt index 3b7b14c9..036a70af 100644 --- a/framework-client/src/main/java/io/axoniq/platform/framework/eventprocessor/DeadLetterManager.kt +++ b/framework-client/src/main/java/io/axoniq/platform/framework/eventprocessor/DeadLetterManager.kt @@ -19,15 +19,20 @@ package io.axoniq.platform.framework.eventprocessor import io.axoniq.framework.messaging.deadletter.DeadLetter import io.axoniq.framework.messaging.deadletter.SequencedDeadLetterProcessor import io.axoniq.framework.messaging.deadletter.SequencedDeadLetterQueue +import io.axoniq.platform.framework.api.AxoniqConsoleDlqMode import io.axoniq.platform.framework.api.DeadLetterResponse import io.axoniq.platform.framework.api.SequenceLettersResponse +import org.apache.commons.codec.digest.DigestUtils import org.axonframework.common.configuration.Configuration +import org.axonframework.messaging.eventhandling.EventHandlingComponent import org.axonframework.messaging.eventhandling.EventMessage import org.slf4j.LoggerFactory import java.util.concurrent.TimeUnit import io.axoniq.platform.framework.api.DeadLetter as ApiDeadLetter private const val LETTER_PAYLOAD_SIZE_LIMIT = 1024 +private const val MASKED = "" +private const val LIMITED = "" private val logger = LoggerFactory.getLogger(DeadLetterManager::class.java) /** @@ -43,9 +48,16 @@ private val logger = LoggerFactory.getLogger(DeadLetterManager::class.java) * - if a processor has a single DLQ the identifier equals the processor name (matches the issue requirement); * - if a processor has multiple DLQs each is exposed as `::` so they remain * addressable individually. + * + * Sequence identifiers exposed through the API come from the [EventHandlingComponent]'s configured sequencing + * policy (via [EventHandlingComponent.sequenceIdentifierFor]), matching AF4 semantics. This makes sequence ids + * stable across letter eviction — deleting the first letter no longer renames the sequence as it did under the + * earlier "first letter's message id" synthetic scheme. */ -class DeadLetterManager( +class DeadLetterManager @JvmOverloads constructor( private val configuration: Configuration, + private val dlqMode: AxoniqConsoleDlqMode = AxoniqConsoleDlqMode.FULL, + private val dlqDiagnosticsWhitelist: List = emptyList(), ) : ProcessingGroupInfoSource { @Volatile @@ -67,6 +79,11 @@ class DeadLetterManager( /** * Internal view of a discovered DLQ together with all metadata required to address it through the public API. + * + * The [eventHandlingComponent] reference is captured during discovery so the sequence identifier of every + * letter can be derived from the same [EventHandlingComponent.sequenceIdentifierFor] the framework uses on + * enqueue. May be `null` if the component cannot be resolved from the configuration — in that case the + * manager falls back to the letter's own message id (documented in [sequenceIdentifierFor]). */ private data class DlqEntry( val processingGroup: String, @@ -74,6 +91,7 @@ class DeadLetterManager( val componentName: String, val dlq: SequencedDeadLetterQueue, val processor: SequencedDeadLetterProcessor, + val eventHandlingComponent: EventHandlingComponent?, ) private val dlqNamePattern = @@ -83,41 +101,31 @@ class DeadLetterManager( processingGroup: String, offset: Int = 0, size: Int = 25, - // Per-sequence cap intentionally small. The list query is meant to give the platform UI - // a *page* of sequences with enough preview letters to seed the detail modal — not to - // ship every letter every refresh. Mitchell observed 7-second refresh cycles on a local - // DLQ when this defaulted to 1000 (page-size 25 sequences x up to 1000 letters each = - // ~25k letter records serialised on every poll). 10 matches the historical "10+" - // placeholder behaviour the platform UI already displays for capped sequences and keeps - // the per-letter payload off the hot path. The detail modal pulls full pages lazily - // through `sequenceLetters(...)` (FetchDeadLettersForSequence) so long sequences are - // still browsable end-to-end without inflating the list query. + // Capped at 10 to keep poll payloads small; long sequences are browsed via sequenceLetters(...). maxSequenceLetters: Int = 10, ): DeadLetterResponse { val entry = dlqFor(processingGroup) + if (dlqMode == AxoniqConsoleDlqMode.NONE) { + return DeadLetterResponse(emptyList(), entry.dlq.amountOfSequences(null).join()) + } val sequences = entry.dlq.deadLetters(null).join() val pageOfSequences = sequences .drop(offset) .take(size) .map { sequence -> val letters = sequence.toList() - // The AF5 SequencedDeadLetterQueue does not expose the underlying sequence - // identifier (the Object passed to enqueue()) on a DeadLetter, so we synthesise - // a stable identifier from the first letter's message id and apply it to every - // letter in the sequence. Operations look up sequences by walking deadLetters() - // and matching this synthetic id — see findSequence(...). - val syntheticSequenceId = letters.firstOrNull()?.message()?.identifier() ?: "" + val sequenceId = letters.firstOrNull()?.let { sequenceIdentifierFor(entry, it) } ?: "" letters .take(maxSequenceLetters) - .map { it.toApiLetter(syntheticSequenceId) } + .map { it.toApiLetter(sequenceId) } } val total = entry.dlq.amountOfSequences(null).join() return DeadLetterResponse(pageOfSequences, total) } fun sequenceSize(processingGroup: String, sequenceIdentifier: String): Long { - val dlq = dlqFor(processingGroup).dlq - return findSequence(dlq, sequenceIdentifier)?.count()?.toLong() ?: 0L + val entry = dlqFor(processingGroup) + return findSequence(entry, sequenceIdentifier)?.count()?.toLong() ?: 0L } /** @@ -131,7 +139,11 @@ class DeadLetterManager( offset: Int, size: Int, ): SequenceLettersResponse { - val sequence = findSequence(dlqFor(processingGroup).dlq, sequenceIdentifier) + val entry = dlqFor(processingGroup) + if (dlqMode == AxoniqConsoleDlqMode.NONE) { + return SequenceLettersResponse(emptyList(), 0) + } + val sequence = findSequence(entry, sequenceIdentifier) ?: return SequenceLettersResponse(emptyList(), 0) val total = sequence.size.toLong() val safeOffset = offset.coerceAtLeast(0) @@ -146,15 +158,15 @@ class DeadLetterManager( /** * Evicts every letter belonging to the sequence identified by [sequenceIdentifier]. * - * @return the number of letters that were actually evicted (0 when the synthetic id no longer - * resolves — e.g. the operator's view was stale). + * @return the number of letters that were actually evicted (0 when the id no longer resolves — e.g. the + * operator's view was stale). */ fun delete(processingGroup: String, sequenceIdentifier: String): Int { - val dlq = dlqFor(processingGroup).dlq - val sequence = findSequence(dlq, sequenceIdentifier) + val entry = dlqFor(processingGroup) + val sequence = findSequence(entry, sequenceIdentifier) if (sequence == null) { logger.warn( - "DLQ delete-sequence: no sequence in [{}] matches synthetic id [{}] — nothing to evict", + "DLQ delete-sequence: no sequence in [{}] matches id [{}] — nothing to evict", processingGroup, sequenceIdentifier, ) return 0 @@ -165,7 +177,7 @@ class DeadLetterManager( ) var evicted = 0 sequence.forEach { - dlq.evict(it, null).join() + entry.dlq.evict(it, null).join() evicted++ } return evicted @@ -174,14 +186,14 @@ class DeadLetterManager( /** * Evicts a single letter identified by [messageIdentifier] from the sequence identified by * [sequenceIdentifier]. Returns `true` when an eviction was performed; `false` indicates the - * synthetic id or message id no longer resolves (typically because the caller's view was stale). + * sequence id or message id no longer resolves (typically because the caller's view was stale). */ fun delete(processingGroup: String, sequenceIdentifier: String, messageIdentifier: String): Boolean { - val dlq = dlqFor(processingGroup).dlq - val sequence = findSequence(dlq, sequenceIdentifier) + val entry = dlqFor(processingGroup) + val sequence = findSequence(entry, sequenceIdentifier) if (sequence == null) { logger.warn( - "DLQ delete-letter: no sequence in [{}] matches synthetic id [{}] (message id was [{}]) — caller view likely stale", + "DLQ delete-letter: no sequence in [{}] matches id [{}] (message id was [{}]) — caller view likely stale", processingGroup, sequenceIdentifier, messageIdentifier, ) return false @@ -198,22 +210,29 @@ class DeadLetterManager( "DLQ delete-letter: evicting message [{}] from sequence [{}] in [{}]", messageIdentifier, sequenceIdentifier, processingGroup, ) - dlq.evict(target, null).join() + entry.dlq.evict(target, null).join() return true } /** - * Resolves a DLQ sequence by the synthetic identifier this manager exposes through the API - * (the message id of the sequence's first letter). Walks all sequences once and matches. + * Resolves a DLQ sequence by the identifier this manager exposes through the API. Walks every sequence + * in the queue, derives each sequence's identifier via [sequenceIdentifierFor], and matches. + * + * When [dlqMode] is [AxoniqConsoleDlqMode.MASKED] the API-side identifier is a SHA-256 hash, so the + * lookup compares the hash of each candidate id against the supplied [sequenceIdentifier] — this keeps + * the delete/process operations working even when the operator only sees masked ids. */ private fun findSequence( - dlq: SequencedDeadLetterQueue, - syntheticSequenceId: String, + entry: DlqEntry, + sequenceIdentifier: String, ): List>? { - val sequences = dlq.deadLetters(null).join() + val sequences = entry.dlq.deadLetters(null).join() for (sequence in sequences) { val letters = sequence.toList() - if (letters.firstOrNull()?.message()?.identifier() == syntheticSequenceId) { + val firstLetter = letters.firstOrNull() ?: continue + val rawId = sequenceIdentifierFor(entry, firstLetter) + val candidateId = if (dlqMode == AxoniqConsoleDlqMode.MASKED) rawId.hashed() else rawId + if (candidateId == sequenceIdentifier) { return letters } } @@ -294,31 +313,87 @@ class DeadLetterManager( IllegalStateException( "Component [$ehcName] is not wrapped with dead-letter processing") } as SequencedDeadLetterProcessor + // The EHC is needed for sequence-identifier resolution. Looking it up here (once per discovery + // run) keeps the hot path on deadLetters/lettersForSequence cheap and matches the "discover once" + // shape of the rest of this manager. + val ehc = it.module + .getOptionalComponent(EventHandlingComponent::class.java, ehcName) + .orElse(null) DlqEntry( processingGroup = if (perProcessor[it.processor] == 1) it.processor else "${it.processor}::${it.component}", processorName = it.processor, componentName = it.component, dlq = it.dlq, processor = processor, + eventHandlingComponent = ehc, ) } } private fun discover(): List = entries ?: discoverEntries().also { entries = it } + /** + * Resolves the sequence identifier for a letter via [SequenceIdentifierResolver], which walks the + * [EventHandlingComponent] decorator chain to find a layer that can resolve the id without a live + * [org.axonframework.messaging.core.unitofwork.ProcessingContext]. Result shape mirrors the AF4 + * implementation: + * - String results are used verbatim; + * - non-String results fall back to `hashCode().toString()`; + * - if every decorator layer requires a context (or the EHC reference could not be captured at + * discovery time, or a custom policy throws on null context) the letter's message identifier is + * used so each letter still has a unique id. + */ + private fun sequenceIdentifierFor( + entry: DlqEntry, + letter: DeadLetter, + ): String { + val ehc = entry.eventHandlingComponent ?: return letter.message().identifier() + val raw: Any? = SequenceIdentifierResolver.resolve(ehc, letter.message()) + return when (raw) { + null -> letter.message().identifier() + is String -> raw + else -> raw.hashCode().toString() + } + } + private fun DeadLetter.toApiLetter(sequenceIdentifier: String): ApiDeadLetter { val message = this.message() - return ApiDeadLetter( - messageIdentifier = message.identifier(), - message = serializePayload(message), - messageType = messageTypeOf(message), - causeType = this.cause().map { it.type() }.orElse(null), - causeMessage = this.cause().map { it.message() }.orElse(null), - enqueuedAt = this.enqueuedAt(), - lastTouched = this.lastTouched(), - diagnostics = this.diagnostics(), - sequenceIdentifier = sequenceIdentifier, - ) + return when (dlqMode) { + AxoniqConsoleDlqMode.NONE, + AxoniqConsoleDlqMode.MASKED -> ApiDeadLetter( + messageIdentifier = message.identifier(), + message = MASKED, + messageType = messageTypeOf(message), + causeType = this.cause().map { it.type() }.orElse(null), + causeMessage = this.cause().map { MASKED }.orElse(null), + enqueuedAt = this.enqueuedAt(), + lastTouched = this.lastTouched(), + diagnostics = emptyMap(), + sequenceIdentifier = sequenceIdentifier.hashed(), + ) + AxoniqConsoleDlqMode.LIMITED -> ApiDeadLetter( + messageIdentifier = message.identifier(), + message = LIMITED, + messageType = messageTypeOf(message), + causeType = this.cause().map { it.type() }.orElse(null), + causeMessage = this.cause().map { LIMITED }.orElse(null), + enqueuedAt = this.enqueuedAt(), + lastTouched = this.lastTouched(), + diagnostics = this.diagnostics().filteredByWhitelist(), + sequenceIdentifier = sequenceIdentifier, + ) + AxoniqConsoleDlqMode.FULL -> ApiDeadLetter( + messageIdentifier = message.identifier(), + message = serializePayload(message), + messageType = messageTypeOf(message), + causeType = this.cause().map { it.type() }.orElse(null), + causeMessage = this.cause().map { it.message() }.orElse(null), + enqueuedAt = this.enqueuedAt(), + lastTouched = this.lastTouched(), + diagnostics = this.diagnostics(), + sequenceIdentifier = sequenceIdentifier, + ) + } } /** @@ -350,6 +425,16 @@ class DeadLetterManager( .let { if (it.size <= LETTER_PAYLOAD_SIZE_LIMIT) raw else String(it, 0, LETTER_PAYLOAD_SIZE_LIMIT, Charsets.UTF_8) } } + /** + * Applies the whitelist filter used in LIMITED mode. Returns only entries whose key is in the + * configured whitelist; an empty whitelist removes all diagnostics. + */ + private fun org.axonframework.messaging.core.Metadata.filteredByWhitelist(): Map = + if (dlqDiagnosticsWhitelist.isEmpty()) emptyMap() + else subset(*dlqDiagnosticsWhitelist.toTypedArray()) + + private fun String.hashed(): String = DigestUtils.sha256Hex(this) + /** * Lightweight DTO returned to [ProcessorReportCreator] so it can populate per-processor DLQ size information * without exposing the full dead-letter API. diff --git a/framework-client/src/main/java/io/axoniq/platform/framework/eventprocessor/SequenceIdentifierResolver.java b/framework-client/src/main/java/io/axoniq/platform/framework/eventprocessor/SequenceIdentifierResolver.java new file mode 100644 index 00000000..b0240d73 --- /dev/null +++ b/framework-client/src/main/java/io/axoniq/platform/framework/eventprocessor/SequenceIdentifierResolver.java @@ -0,0 +1,95 @@ +/* + * 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.eventprocessor; + +import org.axonframework.messaging.eventhandling.DelegatingEventHandlingComponent; +import org.axonframework.messaging.eventhandling.EventHandlingComponent; +import org.axonframework.messaging.eventhandling.EventMessage; + +import java.lang.reflect.Field; + +/** + * Resolves the sequence identifier for an event by walking the {@link EventHandlingComponent} decorator chain. + * + *

The AF5 framework wraps every user-provided event handling component in a chain of decorators that includes + * {@code SequenceCachingEventHandlingComponent}. That decorator's {@code sequenceIdentifierFor} reads from a + * {@code ProcessingContext} resource — which means it NPEs when called outside of any live unit of work. The DLQ + * manager, however, is exactly in that position: it inspects already-enqueued letters with no context to provide.

+ * + *

This resolver unwraps {@link DelegatingEventHandlingComponent} layers until it finds a component whose + * {@code sequenceIdentifierFor} does not require a context (the stock {@code SimpleEventHandlingComponent} and + * {@code SequenceOverridingEventHandlingComponent} do not), and calls it. Calling with a {@code null} + * {@code ProcessingContext} is safe for the stock sequencing policies (constant, property, metadata, hierarchical, + * fallback) — none of them read the context.

+ * + *

If every attempt throws, the caller falls back to the letter's own message identifier (see + * {@code DeadLetterManager.sequenceIdentifierFor}).

+ */ +final class SequenceIdentifierResolver { + + private SequenceIdentifierResolver() { + } + + /** + * Walks the decorator chain on {@code component} and invokes {@code sequenceIdentifierFor(event, null)} on the + * first component whose method completes without throwing. Returns {@code null} when every layer either throws + * or has no policy that can run outside a unit of work — the caller treats {@code null} as "fall back to the + * letter's message identifier". + */ + static Object resolve(EventHandlingComponent component, EventMessage event) { + EventHandlingComponent current = component; + // Bound the unwrap depth so an exotic delegate chain can't loop forever. 16 is comfortably + // higher than the standard AF5 decorator stack (sequence-caching, sequence-overriding, + // dead-lettering, intercepting, tracing, axoniq-platform — six layers in the worst case). + for (int i = 0; i < 16 && current != null; i++) { + try { + return current.sequenceIdentifierFor(event, null); + } catch (RuntimeException ignore) { + // This layer needed a ProcessingContext (e.g. SequenceCachingEventHandlingComponent). + // Walk one step deeper and try again. + } + current = unwrap(current); + } + return null; + } + + /** + * Reads the private {@code delegate} field that every {@link DelegatingEventHandlingComponent} carries — and + * that {@code SequenceOverridingEventHandlingComponent}, which does NOT extend that base class, also keeps under + * the same name. Falling back to introspection by field name keeps us independent of API additions in the AF5 + * decorator chain. + */ + private static EventHandlingComponent unwrap(EventHandlingComponent component) { + Class cls = component.getClass(); + while (cls != null) { + try { + Field delegate = cls.getDeclaredField("delegate"); + delegate.setAccessible(true); + Object value = delegate.get(component); + if (value instanceof EventHandlingComponent) { + return (EventHandlingComponent) value; + } + return null; + } catch (NoSuchFieldException e) { + cls = cls.getSuperclass(); + } catch (IllegalAccessException e) { + return null; + } + } + return null; + } +} diff --git a/framework-client/src/test/kotlin/io/axoniq/platform/framework/eventprocessor/DeadLetterManagerTest.kt b/framework-client/src/test/kotlin/io/axoniq/platform/framework/eventprocessor/DeadLetterManagerTest.kt index 1dc1107d..83d7bcae 100644 --- a/framework-client/src/test/kotlin/io/axoniq/platform/framework/eventprocessor/DeadLetterManagerTest.kt +++ b/framework-client/src/test/kotlin/io/axoniq/platform/framework/eventprocessor/DeadLetterManagerTest.kt @@ -20,403 +20,594 @@ import io.axoniq.framework.messaging.deadletter.Cause import io.axoniq.framework.messaging.deadletter.DeadLetter import io.axoniq.framework.messaging.deadletter.SequencedDeadLetterProcessor import io.axoniq.framework.messaging.deadletter.SequencedDeadLetterQueue +import io.axoniq.platform.framework.api.AxoniqConsoleDlqMode import io.mockk.every import io.mockk.mockk import io.mockk.verify +import org.apache.commons.codec.digest.DigestUtils import org.axonframework.common.configuration.Configuration import org.axonframework.messaging.core.Metadata import org.axonframework.messaging.core.MessageType +import org.axonframework.messaging.eventhandling.EventHandlingComponent import org.axonframework.messaging.eventhandling.EventMessage import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotEquals import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test import java.time.Instant import java.util.Optional import java.util.concurrent.CompletableFuture /** - * Unit tests for [DeadLetterManager] — discovery, synthetic-id mapping, pagination, payload - * truncation, and delegation to the underlying [SequencedDeadLetterQueue]. Builds the queue - * fakes with mockk; the real DLQ implementation isn't on the classpath we want to exercise. + * Unit tests for [DeadLetterManager]. Behaviour is grouped under [Nested] inner classes so each + * area (discovery, sequence-identifier resolution, pagination, mutations, payload handling, DLQ + * modes, error paths) is easy to scan and runs in isolation. * - * Intentionally out of scope: - * - `findDeadLetterProcessor` reflective walk over `EventHandlingComponent` decorators — that - * needs the AF5 module wiring to materialise, which is integration territory. - * - `process(...)` / `processAll(...)` — these delegate to the resolved - * `SequencedDeadLetterProcessor` whose `process(Predicate)` future-form is asymmetric to - * construct from the test side; the colleague explicitly said no integration tests. + * The realistic end-to-end flow lives in [RSocketDlqResponderIntegrationTest], which boots a real + * AF5 configuration with a Pooled Streaming processor and a deliberately-failing event handler. + * These unit tests intentionally cover mockable edge cases the integration test cannot easily + * exercise (negative pagination arguments, ByteArray payload-type fallback, every DlqMode). */ class DeadLetterManagerTest { - // --------------------------------------------------------------------------------------- - // Discovery / processing group naming - // --------------------------------------------------------------------------------------- + @Nested + inner class Discovery { - @Test - fun `exposes processor name as processing group when the processor has a single DLQ`() { - val dlq = fakeDlq() - val configuration = configurationWith( - "DeadLetterQueue[EventHandlingComponent[orders][OrderProjector]]" to dlq, - ) - val manager = DeadLetterManager(configuration).also { it.start() } + @Test + fun `exposes processor name as processing group when the processor has a single DLQ`() { + val dlq = fakeDlq() + val manager = managerWith( + "DeadLetterQueue[EventHandlingComponent[orders][OrderProjector]]" to dlq, + ) - val infos = manager.infoFor("orders") + val infos = manager.infoFor("orders") - assertEquals(listOf("orders"), infos.map { it.processingGroup }) - } + assertEquals(listOf("orders"), infos.map { it.processingGroup }) + } - @Test - fun `exposes processor__component identifier when a processor has multiple DLQs`() { - val configuration = configurationWith( - "DeadLetterQueue[EventHandlingComponent[orders][OrderProjector]]" to fakeDlq(), - "DeadLetterQueue[EventHandlingComponent[orders][AuditProjector]]" to fakeDlq(), - ) - val manager = DeadLetterManager(configuration).also { it.start() } + @Test + fun `exposes processor__component identifier when a processor has multiple DLQs`() { + val manager = managerWith( + "DeadLetterQueue[EventHandlingComponent[orders][OrderProjector]]" to fakeDlq(), + "DeadLetterQueue[EventHandlingComponent[orders][AuditProjector]]" to fakeDlq(), + ) - val infos = manager.infoFor("orders") + val infos = manager.infoFor("orders") - assertEquals( - setOf("orders::OrderProjector", "orders::AuditProjector"), - infos.map { it.processingGroup }.toSet(), - ) - } + assertEquals( + setOf("orders::OrderProjector", "orders::AuditProjector"), + infos.map { it.processingGroup }.toSet(), + ) + } - @Test - fun `ignores components whose names do not match the DLQ pattern`() { - val configuration = configurationWith( - "DeadLetterQueue[EventHandlingComponent[orders][OrderProjector]]" to fakeDlq(), - "SomeOtherComponent" to fakeDlq(), - "DeadLetterQueue[Other][format]" to fakeDlq(), - ) - val manager = DeadLetterManager(configuration).also { it.start() } + @Test + fun `ignores components whose names do not match the DLQ pattern`() { + val manager = managerWith( + "DeadLetterQueue[EventHandlingComponent[orders][OrderProjector]]" to fakeDlq(), + "SomeOtherComponent" to fakeDlq(), + "DeadLetterQueue[Other][format]" to fakeDlq(), + ) - assertEquals(listOf("orders"), manager.infoFor("orders").map { it.processingGroup }) - } + assertEquals(listOf("orders"), manager.infoFor("orders").map { it.processingGroup }) + } - @Test - fun `infoFor returns only DLQs belonging to the requested processor`() { - val configuration = configurationWith( - "DeadLetterQueue[EventHandlingComponent[orders][OrderProjector]]" to fakeDlq(sequenceCount = 3), - "DeadLetterQueue[EventHandlingComponent[shipping][ShipmentProjector]]" to fakeDlq(sequenceCount = 7), - ) - val manager = DeadLetterManager(configuration).also { it.start() } + @Test + fun `infoFor returns only DLQs belonging to the requested processor`() { + val manager = managerWith( + "DeadLetterQueue[EventHandlingComponent[orders][OrderProjector]]" to fakeDlq(sequenceCount = 3), + "DeadLetterQueue[EventHandlingComponent[shipping][ShipmentProjector]]" to fakeDlq(sequenceCount = 7), + ) - val ordersInfo = manager.infoFor("orders") - val shippingInfo = manager.infoFor("shipping") + val ordersInfo = manager.infoFor("orders") + val shippingInfo = manager.infoFor("shipping") - assertEquals(listOf("orders" to 3L), ordersInfo.map { it.processingGroup to it.dlqSize }) - assertEquals(listOf("shipping" to 7L), shippingInfo.map { it.processingGroup to it.dlqSize }) + assertEquals(listOf("orders" to 3L), ordersInfo.map { it.processingGroup to it.dlqSize }) + assertEquals(listOf("shipping" to 7L), shippingInfo.map { it.processingGroup to it.dlqSize }) + } } - // --------------------------------------------------------------------------------------- - // Synthetic sequence id - // --------------------------------------------------------------------------------------- + @Nested + inner class SequenceIdentifiers { - @Test - fun `deadLetters stamps every letter in a sequence with the first letter's message id`() { - val sequence = listOf( - fakeLetter(messageId = "first"), - fakeLetter(messageId = "second"), - fakeLetter(messageId = "third"), - ) - val dlq = fakeDlq(sequences = listOf(sequence)) - val configuration = configurationWith( - "DeadLetterQueue[EventHandlingComponent[orders][OrderProjector]]" to dlq, - ) - val manager = DeadLetterManager(configuration).also { it.start() } + @Test + fun `String result from policy is used verbatim as the sequence identifier`() { + val ehc = ehcWithPolicy { _ -> "saga-42" } + val sequence = listOf(fakeLetter("m1"), fakeLetter("m2")) + val manager = managerWith( + "DeadLetterQueue[EventHandlingComponent[orders][OrderProjector]]" to fakeDlq(sequences = listOf(sequence)), + ehc = ehc, + ) - val response = manager.deadLetters("orders") + val response = manager.deadLetters("orders") - assertEquals(1, response.sequences.size) - assertEquals(listOf("first", "first", "first"), response.sequences[0].map { it.sequenceIdentifier }) - } + assertEquals(listOf("saga-42", "saga-42"), response.sequences[0].map { it.sequenceIdentifier }) + } - @Test - fun `empty sequence gets an empty-string synthetic id without crashing`() { - // Degenerate but documented: if a sequence iterator yields no letters, the synthetic id - // is the empty string. The mapped list is empty, so there's nothing to inspect on it — - // we just need this not to throw. - val dlq = fakeDlq(sequences = listOf(emptyList())) - val configuration = configurationWith( - "DeadLetterQueue[EventHandlingComponent[orders][OrderProjector]]" to dlq, - ) - val manager = DeadLetterManager(configuration).also { it.start() } + @Test + fun `non-String result from policy is reduced to hashCode toString`() { + val payloadObject = 12345 + val ehc = ehcWithPolicy { _ -> payloadObject } + val sequence = listOf(fakeLetter("m1")) + val manager = managerWith( + "DeadLetterQueue[EventHandlingComponent[orders][OrderProjector]]" to fakeDlq(sequences = listOf(sequence)), + ehc = ehc, + ) - val response = manager.deadLetters("orders") + val response = manager.deadLetters("orders") - assertEquals(1, response.sequences.size) - assertTrue(response.sequences[0].isEmpty()) - } + assertEquals(payloadObject.hashCode().toString(), response.sequences[0][0].sequenceIdentifier) + } - // --------------------------------------------------------------------------------------- - // lettersForSequence pagination - // --------------------------------------------------------------------------------------- + @Test + fun `null result from policy falls back to message identifier`() { + val ehc = ehcWithPolicy { _ -> null } + val sequence = listOf(fakeLetter("m1")) + val manager = managerWith( + "DeadLetterQueue[EventHandlingComponent[orders][OrderProjector]]" to fakeDlq(sequences = listOf(sequence)), + ehc = ehc, + ) - @Test - fun `lettersForSequence returns the requested slice in order with the correct total`() { - val sequence = (1..5).map { fakeLetter(messageId = "m$it", payload = "p$it") } - val dlq = fakeDlq(sequences = listOf(sequence)) - val manager = DeadLetterManager(configurationWith( - "DeadLetterQueue[EventHandlingComponent[orders][OrderProjector]]" to dlq, - )).also { it.start() } + val response = manager.deadLetters("orders") - val response = manager.lettersForSequence("orders", "m1", offset = 1, size = 2) + assertEquals("m1", response.sequences[0][0].sequenceIdentifier) + } - assertEquals(5L, response.totalCount) - assertEquals(listOf("m2", "m3"), response.letters.map { it.messageIdentifier }) - } + @Test + fun `policy is invoked once per sequence (using the first letter) and the id is stamped across the sequence`() { + val ehc = ehcWithPolicy { event -> event.identifier() } + // The sequence contains m1 + m2 + m3; only the first letter's id is used. + val sequence = listOf(fakeLetter("m1"), fakeLetter("m2"), fakeLetter("m3")) + val manager = managerWith( + "DeadLetterQueue[EventHandlingComponent[orders][OrderProjector]]" to fakeDlq(sequences = listOf(sequence)), + ehc = ehc, + ) - @Test - fun `lettersForSequence coerces a negative offset to zero`() { - val sequence = (1..3).map { fakeLetter(messageId = "m$it") } - val dlq = fakeDlq(sequences = listOf(sequence)) - val manager = DeadLetterManager(configurationWith( - "DeadLetterQueue[EventHandlingComponent[orders][OrderProjector]]" to dlq, - )).also { it.start() } + val response = manager.deadLetters("orders") - val response = manager.lettersForSequence("orders", "m1", offset = -5, size = 2) + assertEquals(listOf("m1", "m1", "m1"), response.sequences[0].map { it.sequenceIdentifier }) + } - assertEquals(listOf("m1", "m2"), response.letters.map { it.messageIdentifier }) - } + @Test + fun `when no EventHandlingComponent is registered the manager falls back to the letter's message id`() { + val sequence = listOf(fakeLetter("only-letter")) + // Note: no ehc parameter — the configurationWith helper registers a relaxed mock that returns + // Optional.empty() for EventHandlingComponent lookup, exercising the null-EHC fallback path. + val manager = managerWith( + "DeadLetterQueue[EventHandlingComponent[orders][OrderProjector]]" to fakeDlq(sequences = listOf(sequence)), + ehc = null, + ) - @Test - fun `lettersForSequence coerces a non-positive size to one`() { - val sequence = (1..3).map { fakeLetter(messageId = "m$it") } - val dlq = fakeDlq(sequences = listOf(sequence)) - val manager = DeadLetterManager(configurationWith( - "DeadLetterQueue[EventHandlingComponent[orders][OrderProjector]]" to dlq, - )).also { it.start() } + val response = manager.deadLetters("orders") + + assertEquals("only-letter", response.sequences[0][0].sequenceIdentifier) + } - val response = manager.lettersForSequence("orders", "m1", offset = 0, size = 0) + @Test + fun `empty sequence yields an empty letter list without crashing`() { + val manager = managerWith( + "DeadLetterQueue[EventHandlingComponent[orders][OrderProjector]]" to fakeDlq(sequences = listOf(emptyList())), + ) - assertEquals(1, response.letters.size) - assertEquals("m1", response.letters[0].messageIdentifier) + val response = manager.deadLetters("orders") + + assertEquals(1, response.sequences.size) + assertTrue(response.sequences[0].isEmpty()) + } } - @Test - fun `lettersForSequence returns an empty response when no sequence matches the synthetic id`() { - val sequence = listOf(fakeLetter(messageId = "real")) - val dlq = fakeDlq(sequences = listOf(sequence)) - val manager = DeadLetterManager(configurationWith( - "DeadLetterQueue[EventHandlingComponent[orders][OrderProjector]]" to dlq, - )).also { it.start() } + @Nested + inner class Pagination { - val response = manager.lettersForSequence("orders", "stale-id", 0, 10) + @Test + fun `lettersForSequence returns the requested slice in order with the correct total`() { + val sequence = (1..5).map { fakeLetter(messageId = "m$it", payload = "p$it") } + val ehc = ehcWithPolicy { event -> event.identifier() } + val manager = managerWith( + "DeadLetterQueue[EventHandlingComponent[orders][OrderProjector]]" to fakeDlq(sequences = listOf(sequence)), + ehc = ehc, + ) - assertTrue(response.letters.isEmpty()) - assertEquals(0L, response.totalCount) - } + val response = manager.lettersForSequence("orders", "m1", offset = 1, size = 2) - @Test - fun `lettersForSequence caps the slice at the size argument even when the sequence is larger`() { - val sequence = (1..10).map { fakeLetter(messageId = "m$it") } - val dlq = fakeDlq(sequences = listOf(sequence)) - val manager = DeadLetterManager(configurationWith( - "DeadLetterQueue[EventHandlingComponent[orders][OrderProjector]]" to dlq, - )).also { it.start() } + assertEquals(5L, response.totalCount) + assertEquals(listOf("m2", "m3"), response.letters.map { it.messageIdentifier }) + } - val response = manager.lettersForSequence("orders", "m1", offset = 0, size = 3) + @Test + fun `lettersForSequence coerces a negative offset to zero`() { + val sequence = (1..3).map { fakeLetter("m$it") } + val ehc = ehcWithPolicy { event -> event.identifier() } + val manager = managerWith( + "DeadLetterQueue[EventHandlingComponent[orders][OrderProjector]]" to fakeDlq(sequences = listOf(sequence)), + ehc = ehc, + ) - assertEquals(3, response.letters.size) - assertEquals(10L, response.totalCount) - } + val response = manager.lettersForSequence("orders", "m1", offset = -5, size = 2) - // --------------------------------------------------------------------------------------- - // delete / deleteAll delegation - // --------------------------------------------------------------------------------------- + assertEquals(listOf("m1", "m2"), response.letters.map { it.messageIdentifier }) + } - @Test - fun `delete by sequence evicts every letter in that sequence`() { - val letters = listOf(fakeLetter("m1"), fakeLetter("m2"), fakeLetter("m3")) - val dlq = fakeDlq(sequences = listOf(letters)) - val manager = DeadLetterManager(configurationWith( - "DeadLetterQueue[EventHandlingComponent[orders][OrderProjector]]" to dlq, - )).also { it.start() } + @Test + fun `lettersForSequence coerces a non-positive size to one`() { + val sequence = (1..3).map { fakeLetter("m$it") } + val ehc = ehcWithPolicy { event -> event.identifier() } + val manager = managerWith( + "DeadLetterQueue[EventHandlingComponent[orders][OrderProjector]]" to fakeDlq(sequences = listOf(sequence)), + ehc = ehc, + ) - val evicted = manager.delete("orders", "m1") + val response = manager.lettersForSequence("orders", "m1", offset = 0, size = 0) - assertEquals(3, evicted) - letters.forEach { verify(exactly = 1) { dlq.evict(it, null) } } - } + assertEquals(1, response.letters.size) + assertEquals("m1", response.letters[0].messageIdentifier) + } - @Test - fun `delete by sequence is a no-op when the sequence does not exist`() { - val dlq = fakeDlq(sequences = listOf(listOf(fakeLetter("real")))) - val manager = DeadLetterManager(configurationWith( - "DeadLetterQueue[EventHandlingComponent[orders][OrderProjector]]" to dlq, - )).also { it.start() } + @Test + fun `lettersForSequence returns an empty response when no sequence matches the id`() { + val sequence = listOf(fakeLetter("real")) + val ehc = ehcWithPolicy { event -> event.identifier() } + val manager = managerWith( + "DeadLetterQueue[EventHandlingComponent[orders][OrderProjector]]" to fakeDlq(sequences = listOf(sequence)), + ehc = ehc, + ) - val evicted = manager.delete("orders", "ghost") + val response = manager.lettersForSequence("orders", "stale-id", 0, 10) - assertEquals(0, evicted) - verify(exactly = 0) { dlq.evict(any>(), any()) } - } + assertTrue(response.letters.isEmpty()) + assertEquals(0L, response.totalCount) + } - @Test - fun `delete by message evicts only the matching letter`() { - val letter1 = fakeLetter("m1") - val letter2 = fakeLetter("m2") - val dlq = fakeDlq(sequences = listOf(listOf(letter1, letter2))) - val manager = DeadLetterManager(configurationWith( - "DeadLetterQueue[EventHandlingComponent[orders][OrderProjector]]" to dlq, - )).also { it.start() } + @Test + fun `lettersForSequence caps the slice at the size argument even when the sequence is larger`() { + val sequence = (1..10).map { fakeLetter("m$it") } + val ehc = ehcWithPolicy { event -> event.identifier() } + val manager = managerWith( + "DeadLetterQueue[EventHandlingComponent[orders][OrderProjector]]" to fakeDlq(sequences = listOf(sequence)), + ehc = ehc, + ) - val evicted = manager.delete("orders", "m1", "m2") + val response = manager.lettersForSequence("orders", "m1", offset = 0, size = 3) - assertTrue(evicted) - verify(exactly = 1) { dlq.evict(letter2, null) } - verify(exactly = 0) { dlq.evict(letter1, null) } + assertEquals(3, response.letters.size) + assertEquals(10L, response.totalCount) + } } - @Test - fun `delete by message is a no-op when the message id is unknown in the sequence`() { - val letter1 = fakeLetter("m1") - val dlq = fakeDlq(sequences = listOf(listOf(letter1))) - val manager = DeadLetterManager(configurationWith( - "DeadLetterQueue[EventHandlingComponent[orders][OrderProjector]]" to dlq, - )).also { it.start() } + @Nested + inner class Mutations { - val evicted = manager.delete("orders", "m1", "missing") + @Test + fun `delete by sequence evicts every letter in that sequence`() { + val letters = listOf(fakeLetter("m1"), fakeLetter("m2"), fakeLetter("m3")) + val dlq = fakeDlq(sequences = listOf(letters)) + val ehc = ehcWithPolicy { event -> event.identifier() } + val manager = managerWith( + "DeadLetterQueue[EventHandlingComponent[orders][OrderProjector]]" to dlq, + ehc = ehc, + ) - assertFalse(evicted) - verify(exactly = 0) { dlq.evict(any>(), any()) } - } + val evicted = manager.delete("orders", "m1") - @Test - fun `delete by message is a no-op when the sequence does not resolve`() { - val dlq = fakeDlq(sequences = listOf(listOf(fakeLetter("real")))) - val manager = DeadLetterManager(configurationWith( - "DeadLetterQueue[EventHandlingComponent[orders][OrderProjector]]" to dlq, - )).also { it.start() } + assertEquals(3, evicted) + letters.forEach { verify(exactly = 1) { dlq.evict(it, null) } } + } - val evicted = manager.delete("orders", "ghost", "anything") + @Test + fun `delete by sequence is a no-op when the sequence does not exist`() { + val dlq = fakeDlq(sequences = listOf(listOf(fakeLetter("real")))) + val manager = managerWith( + "DeadLetterQueue[EventHandlingComponent[orders][OrderProjector]]" to dlq, + ) - assertFalse(evicted) - verify(exactly = 0) { dlq.evict(any>(), any()) } - } + val evicted = manager.delete("orders", "ghost") - @Test - fun `deleteAll returns the queue size and clears the queue`() { - val dlq = fakeDlq(totalSize = 42L) - val manager = DeadLetterManager(configurationWith( - "DeadLetterQueue[EventHandlingComponent[orders][OrderProjector]]" to dlq, - )).also { it.start() } + assertEquals(0, evicted) + verify(exactly = 0) { dlq.evict(any>(), any()) } + } - val deleted = manager.deleteAll("orders") + @Test + fun `delete by message evicts only the matching letter`() { + val letter1 = fakeLetter("m1") + val letter2 = fakeLetter("m2") + val dlq = fakeDlq(sequences = listOf(listOf(letter1, letter2))) + val ehc = ehcWithPolicy { event -> event.identifier() } + val manager = managerWith( + "DeadLetterQueue[EventHandlingComponent[orders][OrderProjector]]" to dlq, + ehc = ehc, + ) + + val evicted = manager.delete("orders", "m1", "m2") + + assertTrue(evicted) + verify(exactly = 1) { dlq.evict(letter2, null) } + verify(exactly = 0) { dlq.evict(letter1, null) } + } - assertEquals(42, deleted) - verify(exactly = 1) { dlq.clear(null) } - } + @Test + fun `delete by message is a no-op when the message id is unknown in the sequence`() { + val letter1 = fakeLetter("m1") + val dlq = fakeDlq(sequences = listOf(listOf(letter1))) + val ehc = ehcWithPolicy { event -> event.identifier() } + val manager = managerWith( + "DeadLetterQueue[EventHandlingComponent[orders][OrderProjector]]" to dlq, + ehc = ehc, + ) - @Test - fun `sequenceSize returns the count of letters for the matching synthetic id`() { - val sequence = (1..4).map { fakeLetter("m$it") } - val dlq = fakeDlq(sequences = listOf(sequence)) - val manager = DeadLetterManager(configurationWith( - "DeadLetterQueue[EventHandlingComponent[orders][OrderProjector]]" to dlq, - )).also { it.start() } + val evicted = manager.delete("orders", "m1", "missing") - assertEquals(4L, manager.sequenceSize("orders", "m1")) - } + assertFalse(evicted) + verify(exactly = 0) { dlq.evict(any>(), any()) } + } - @Test - fun `sequenceSize returns zero when the synthetic id does not resolve`() { - val sequence = listOf(fakeLetter("real")) - val dlq = fakeDlq(sequences = listOf(sequence)) - val manager = DeadLetterManager(configurationWith( - "DeadLetterQueue[EventHandlingComponent[orders][OrderProjector]]" to dlq, - )).also { it.start() } + @Test + fun `delete by message is a no-op when the sequence does not resolve`() { + val dlq = fakeDlq(sequences = listOf(listOf(fakeLetter("real")))) + val manager = managerWith( + "DeadLetterQueue[EventHandlingComponent[orders][OrderProjector]]" to dlq, + ) - assertEquals(0L, manager.sequenceSize("orders", "ghost")) - } + val evicted = manager.delete("orders", "ghost", "anything") - // --------------------------------------------------------------------------------------- - // Payload truncation + messageType fallback - // --------------------------------------------------------------------------------------- + assertFalse(evicted) + verify(exactly = 0) { dlq.evict(any>(), any()) } + } - @Test - fun `payload at or below 1024 UTF-8 bytes is returned untouched`() { - val payload = "x".repeat(1024) - val sequence = listOf(fakeLetter("m1", payload = payload)) - val dlq = fakeDlq(sequences = listOf(sequence)) - val manager = DeadLetterManager(configurationWith( - "DeadLetterQueue[EventHandlingComponent[orders][OrderProjector]]" to dlq, - )).also { it.start() } + @Test + fun `deleteAll returns the queue size and clears the queue`() { + val dlq = fakeDlq(totalSize = 42L) + val manager = managerWith( + "DeadLetterQueue[EventHandlingComponent[orders][OrderProjector]]" to dlq, + ) - val response = manager.deadLetters("orders") + val deleted = manager.deleteAll("orders") - assertEquals(payload, response.sequences[0][0].message) - } + assertEquals(42, deleted) + verify(exactly = 1) { dlq.clear(null) } + } + + @Test + fun `sequenceSize returns the count of letters for the matching id`() { + val sequence = (1..4).map { fakeLetter("m$it") } + val ehc = ehcWithPolicy { event -> event.identifier() } + val manager = managerWith( + "DeadLetterQueue[EventHandlingComponent[orders][OrderProjector]]" to fakeDlq(sequences = listOf(sequence)), + ehc = ehc, + ) + + assertEquals(4L, manager.sequenceSize("orders", "m1")) + } + + @Test + fun `sequenceSize returns zero when the id does not resolve`() { + val sequence = listOf(fakeLetter("real")) + val manager = managerWith( + "DeadLetterQueue[EventHandlingComponent[orders][OrderProjector]]" to fakeDlq(sequences = listOf(sequence)), + ) - @Test - fun `payload over 1024 UTF-8 bytes is truncated without splitting a multi-byte codepoint`() { - // "č" is U+010D, two bytes in UTF-8. Filling beyond 1024 bytes guarantees the cutoff - // lands inside a multi-byte sequence — a naive byte slice would yield a malformed - // codepoint there. The implementation must round down to the previous valid boundary. - val char = "č" - val payload = char.repeat(600) // 600 * 2 = 1200 bytes - val sequence = listOf(fakeLetter("m1", payload = payload)) - val dlq = fakeDlq(sequences = listOf(sequence)) - val manager = DeadLetterManager(configurationWith( - "DeadLetterQueue[EventHandlingComponent[orders][OrderProjector]]" to dlq, - )).also { it.start() } - - val result = manager.deadLetters("orders").sequences[0][0].message - - // Truncated string fits within the limit ... - assertTrue(result.toByteArray(Charsets.UTF_8).size <= 1024) - // ... and contains no replacement characters from a mid-codepoint split. - assertFalse(result.contains('�')) - // The resulting string is composed entirely of valid "č" codepoints. - assertTrue(result.all { it == 'č' }) + assertEquals(0L, manager.sequenceSize("orders", "ghost")) + } } - @Test - fun `messageType falls back to message type name when payload class is ByteArray`() { - val message = fakeEventMessage( - id = "m1", - payload = "still serialised".toByteArray(), - payloadType = ByteArray::class.java, - typeName = "com.example.OrderPlaced", - ) - val letter = fakeLetterFromMessage(message) - val dlq = fakeDlq(sequences = listOf(listOf(letter))) - val manager = DeadLetterManager(configurationWith( - "DeadLetterQueue[EventHandlingComponent[orders][OrderProjector]]" to dlq, - )).also { it.start() } + @Nested + inner class PayloadHandling { - val apiLetter = manager.deadLetters("orders").sequences[0][0] + @Test + fun `payload at or below 1024 UTF-8 bytes is returned untouched`() { + val payload = "x".repeat(1024) + val sequence = listOf(fakeLetter("m1", payload = payload)) + val manager = managerWith( + "DeadLetterQueue[EventHandlingComponent[orders][OrderProjector]]" to fakeDlq(sequences = listOf(sequence)), + ) + + val response = manager.deadLetters("orders") + + assertEquals(payload, response.sequences[0][0].message) + } - assertEquals("com.example.OrderPlaced", apiLetter.messageType) + @Test + fun `payload over 1024 UTF-8 bytes is truncated without splitting a multi-byte codepoint`() { + // "č" is U+010D, two bytes in UTF-8. Filling beyond 1024 bytes guarantees the cutoff + // lands inside a multi-byte sequence — a naive byte slice would yield a malformed + // codepoint there. The implementation must round down to the previous valid boundary. + val char = "č" + val payload = char.repeat(600) // 600 * 2 = 1200 bytes + val sequence = listOf(fakeLetter("m1", payload = payload)) + val manager = managerWith( + "DeadLetterQueue[EventHandlingComponent[orders][OrderProjector]]" to fakeDlq(sequences = listOf(sequence)), + ) + + val result = manager.deadLetters("orders").sequences[0][0].message + + assertTrue(result.toByteArray(Charsets.UTF_8).size <= 1024) + assertFalse(result.contains('�')) + assertTrue(result.all { it == 'č' }) + } + + @Test + fun `messageType falls back to message type name when payload class is ByteArray`() { + val message = fakeEventMessage( + id = "m1", + payload = "still serialised".toByteArray(), + payloadType = ByteArray::class.java, + typeName = "com.example.OrderPlaced", + ) + val letter = fakeLetterFromMessage(message) + val manager = managerWith( + "DeadLetterQueue[EventHandlingComponent[orders][OrderProjector]]" to fakeDlq(sequences = listOf(listOf(letter))), + ) + + val apiLetter = manager.deadLetters("orders").sequences[0][0] + + assertEquals("com.example.OrderPlaced", apiLetter.messageType) + } + + @Test + fun `messageType uses payload class simple name for non-ByteArray payloads`() { + val sequence = listOf(fakeLetter("m1", payload = "hello", payloadType = String::class.java)) + val manager = managerWith( + "DeadLetterQueue[EventHandlingComponent[orders][OrderProjector]]" to fakeDlq(sequences = listOf(sequence)), + ) + + val apiLetter = manager.deadLetters("orders").sequences[0][0] + + assertEquals("String", apiLetter.messageType) + } } - @Test - fun `messageType uses payload class simple name for non-ByteArray payloads`() { - val sequence = listOf(fakeLetter("m1", payload = "hello", payloadType = String::class.java)) - val dlq = fakeDlq(sequences = listOf(sequence)) - val manager = DeadLetterManager(configurationWith( - "DeadLetterQueue[EventHandlingComponent[orders][OrderProjector]]" to dlq, - )).also { it.start() } + @Nested + inner class DlqModes { - val apiLetter = manager.deadLetters("orders").sequences[0][0] + @Test + fun `NONE returns an empty list of sequences but still reports the total`() { + val sequence = listOf(fakeLetter("m1"), fakeLetter("m2")) + val ehc = ehcWithPolicy { event -> event.identifier() } + val manager = managerWith( + "DeadLetterQueue[EventHandlingComponent[orders][OrderProjector]]" to fakeDlq(sequences = listOf(sequence)), + ehc = ehc, + dlqMode = AxoniqConsoleDlqMode.NONE, + ) - assertEquals("String", apiLetter.messageType) + val response = manager.deadLetters("orders") + + assertTrue(response.sequences.isEmpty()) + assertEquals(1L, response.totalCount) + } + + @Test + fun `NONE returns an empty SequenceLettersResponse`() { + val sequence = listOf(fakeLetter("m1")) + val ehc = ehcWithPolicy { event -> event.identifier() } + val manager = managerWith( + "DeadLetterQueue[EventHandlingComponent[orders][OrderProjector]]" to fakeDlq(sequences = listOf(sequence)), + ehc = ehc, + dlqMode = AxoniqConsoleDlqMode.NONE, + ) + + val response = manager.lettersForSequence("orders", "m1", 0, 10) + + assertTrue(response.letters.isEmpty()) + assertEquals(0L, response.totalCount) + } + + @Test + fun `LIMITED strips payload and cause message, keeps sequence id unhashed`() { + val sequence = listOf(fakeLetter("m1", payload = "secret-payload")) + val ehc = ehcWithPolicy { event -> event.identifier() } + val manager = managerWith( + "DeadLetterQueue[EventHandlingComponent[orders][OrderProjector]]" to fakeDlq(sequences = listOf(sequence)), + ehc = ehc, + dlqMode = AxoniqConsoleDlqMode.LIMITED, + ) + + val letter = manager.deadLetters("orders").sequences[0][0] + + assertEquals("", letter.message) + assertEquals("", letter.causeMessage) + assertEquals("m1", letter.sequenceIdentifier) + } + + @Test + fun `LIMITED filters diagnostics down to the configured whitelist`() { + val whitelisted = mapOf("attempts" to "3", "cause" to "boom", "internal" to "shh") + val sequence = listOf(fakeLetter("m1", diagnostics = whitelisted)) + val ehc = ehcWithPolicy { event -> event.identifier() } + val manager = managerWith( + "DeadLetterQueue[EventHandlingComponent[orders][OrderProjector]]" to fakeDlq(sequences = listOf(sequence)), + ehc = ehc, + dlqMode = AxoniqConsoleDlqMode.LIMITED, + whitelist = listOf("attempts", "cause"), + ) + + val diagnostics = manager.deadLetters("orders").sequences[0][0].diagnostics + + assertEquals(setOf("attempts", "cause"), diagnostics.keys) + assertFalse(diagnostics.containsKey("internal")) + } + + @Test + fun `LIMITED with empty whitelist drops every diagnostic`() { + val sequence = listOf(fakeLetter("m1", diagnostics = mapOf("a" to "1", "b" to "2"))) + val ehc = ehcWithPolicy { event -> event.identifier() } + val manager = managerWith( + "DeadLetterQueue[EventHandlingComponent[orders][OrderProjector]]" to fakeDlq(sequences = listOf(sequence)), + ehc = ehc, + dlqMode = AxoniqConsoleDlqMode.LIMITED, + ) + + val diagnostics = manager.deadLetters("orders").sequences[0][0].diagnostics + + assertTrue(diagnostics.isEmpty()) + } + + @Test + fun `MASKED returns MASKED markers and SHA-256 hashes the sequence id`() { + val sequence = listOf(fakeLetter("m1", payload = "secret")) + val ehc = ehcWithPolicy { _ -> "saga-123" } + val manager = managerWith( + "DeadLetterQueue[EventHandlingComponent[orders][OrderProjector]]" to fakeDlq(sequences = listOf(sequence)), + ehc = ehc, + dlqMode = AxoniqConsoleDlqMode.MASKED, + ) + + val letter = manager.deadLetters("orders").sequences[0][0] + + assertEquals("", letter.message) + assertEquals("", letter.causeMessage) + assertEquals(DigestUtils.sha256Hex("saga-123"), letter.sequenceIdentifier) + assertNotEquals("saga-123", letter.sequenceIdentifier) + assertTrue(letter.diagnostics.isEmpty()) + } + + @Test + fun `MASKED still allows delete-by-sequence using the hashed id`() { + val letters = listOf(fakeLetter("m1"), fakeLetter("m2")) + val dlq = fakeDlq(sequences = listOf(letters)) + val ehc = ehcWithPolicy { _ -> "saga-xyz" } + val manager = managerWith( + "DeadLetterQueue[EventHandlingComponent[orders][OrderProjector]]" to dlq, + ehc = ehc, + dlqMode = AxoniqConsoleDlqMode.MASKED, + ) + + val hashedId = DigestUtils.sha256Hex("saga-xyz") + val evicted = manager.delete("orders", hashedId) + + assertEquals(2, evicted) + } + + @Test + fun `FULL preserves payload, cause message and raw sequence id`() { + val sequence = listOf(fakeLetter("m1", payload = "fully-visible")) + val ehc = ehcWithPolicy { _ -> "saga-99" } + val manager = managerWith( + "DeadLetterQueue[EventHandlingComponent[orders][OrderProjector]]" to fakeDlq(sequences = listOf(sequence)), + ehc = ehc, + dlqMode = AxoniqConsoleDlqMode.FULL, + ) + + val letter = manager.deadLetters("orders").sequences[0][0] + + assertEquals("fully-visible", letter.message) + assertEquals("boom", letter.causeMessage) + assertEquals("saga-99", letter.sequenceIdentifier) + } } - // --------------------------------------------------------------------------------------- - // dlqFor error - // --------------------------------------------------------------------------------------- + @Nested + inner class Errors { - @Test - fun `unknown processing group throws IllegalArgumentException`() { - val manager = DeadLetterManager(configurationWith( - "DeadLetterQueue[EventHandlingComponent[orders][OrderProjector]]" to fakeDlq(), - )).also { it.start() } + @Test + fun `unknown processing group throws IllegalArgumentException`() { + val manager = managerWith( + "DeadLetterQueue[EventHandlingComponent[orders][OrderProjector]]" to fakeDlq(), + ) - assertThrows(IllegalArgumentException::class.java) { - manager.sequenceSize("unknown-group", "whatever") + assertThrows(IllegalArgumentException::class.java) { + manager.sequenceSize("unknown-group", "whatever") + } } } @@ -425,18 +616,27 @@ class DeadLetterManagerTest { // --------------------------------------------------------------------------------------- /** - * Builds a [Configuration] backed by a single module that exposes the given DLQs and a - * matching [SequencedDeadLetterProcessor] for each — the manager looks up the latter by the - * `EventHandlingComponent[][]` name to materialise its - * `DlqEntry.processor` field. + * Builds a manager backed by a synthetic configuration that exposes the given DLQs and matching + * processors / event-handling components. Pass [ehc] = `null` (the default) to leave the EHC + * lookup empty — which exercises the manager's "no EHC available → message-id fallback" path. */ - private fun configurationWith( + private fun managerWith( vararg dlqsByName: Pair>, + ehc: EventHandlingComponent? = null, + dlqMode: AxoniqConsoleDlqMode = AxoniqConsoleDlqMode.FULL, + whitelist: List = emptyList(), + ): DeadLetterManager { + val configuration = configurationWith(dlqsByName.asList(), ehc) + return DeadLetterManager(configuration, dlqMode, whitelist).also { it.start() } + } + + private fun configurationWith( + dlqsByName: List>>, + ehc: EventHandlingComponent?, ): Configuration { val module = mockk(relaxed = true) every { module.getComponents(SequencedDeadLetterQueue::class.java) } returns dlqsByName.toMap().mapValues { it.value as SequencedDeadLetterQueue<*> } - // Every DLQ name carries the processor + component segment used to address its processor. dlqsByName.forEach { (name, _) -> val match = Regex("""^DeadLetterQueue\[EventHandlingComponent\[([^]]+)]\[(.+)]]$""").find(name) if (match != null) { @@ -445,6 +645,9 @@ class DeadLetterManagerTest { every { module.getOptionalComponent(SequencedDeadLetterProcessor::class.java, ehcName) } returns Optional.of(processor) + every { + module.getOptionalComponent(EventHandlingComponent::class.java, ehcName) + } returns Optional.ofNullable(ehc) } } val root = mockk(relaxed = true) @@ -452,15 +655,28 @@ class DeadLetterManagerTest { return root } + private fun ehcWithPolicy(policy: (EventMessage) -> Any?): EventHandlingComponent { + val ehc = mockk(relaxed = true) + // `sequenceIdentifierFor` is `@NullMarked` non-null in source, but in real life policies do + // return `null` (and the manager's branch on that is what we want to exercise). MockK lets + // us answer with whatever object reference; the unchecked cast keeps the compiler happy. + @Suppress("UNCHECKED_CAST") + every { ehc.sequenceIdentifierFor(any(), any()) } answers { + policy(firstArg()) as Any + } + @Suppress("UNCHECKED_CAST") + every { ehc.sequenceIdentifierFor(any(), isNull()) } answers { + policy(firstArg()) as Any + } + return ehc + } + private fun fakeDlq( sequences: List>> = emptyList(), sequenceCount: Long = sequences.size.toLong(), totalSize: Long = sequences.sumOf { it.size.toLong() }, ): SequencedDeadLetterQueue { val dlq = mockk>(relaxed = true) - // `deadLetters(null)` returns a CompletableFuture>>; the - // manager calls `.join()` and iterates with `.toList()` on each inner sequence, so any - // Iterable shape works here. every { dlq.deadLetters(null) } returns CompletableFuture.completedFuture( sequences as Iterable>>, ) @@ -477,15 +693,17 @@ class DeadLetterManagerTest { payloadType: Class<*> = (payload?.javaClass ?: String::class.java), causeType: String? = "java.lang.RuntimeException", causeMessage: String? = "boom", + diagnostics: Map = emptyMap(), ): DeadLetter { val message = fakeEventMessage(messageId, payload, payloadType) - return fakeLetterFromMessage(message, causeType, causeMessage) + return fakeLetterFromMessage(message, causeType, causeMessage, diagnostics) } private fun fakeLetterFromMessage( message: EventMessage, causeType: String? = "java.lang.RuntimeException", causeMessage: String? = "boom", + diagnostics: Map = emptyMap(), ): DeadLetter { val letter = mockk>(relaxed = true) every { letter.message() } returns message @@ -498,7 +716,8 @@ class DeadLetterManagerTest { every { letter.cause() } returns cause every { letter.enqueuedAt() } returns Instant.EPOCH every { letter.lastTouched() } returns Instant.EPOCH - every { letter.diagnostics() } returns Metadata.emptyInstance() + every { letter.diagnostics() } returns + if (diagnostics.isEmpty()) Metadata.emptyInstance() else Metadata.from(diagnostics) return letter } diff --git a/framework-client/src/test/kotlin/io/axoniq/platform/framework/eventprocessor/RSocketDlqResponderIntegrationTest.kt b/framework-client/src/test/kotlin/io/axoniq/platform/framework/eventprocessor/RSocketDlqResponderIntegrationTest.kt new file mode 100644 index 00000000..46444b0a --- /dev/null +++ b/framework-client/src/test/kotlin/io/axoniq/platform/framework/eventprocessor/RSocketDlqResponderIntegrationTest.kt @@ -0,0 +1,249 @@ +/* + * 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.eventprocessor + +import io.axoniq.framework.messaging.deadletter.GenericDeadLetter +import io.axoniq.framework.messaging.deadletter.SequencedDeadLetterQueue +import io.axoniq.framework.messaging.eventhandling.deadletter.DeadLetterQueueConfiguration +import org.axonframework.common.configuration.AxonConfiguration +import org.axonframework.common.configuration.ComponentDefinition +import org.axonframework.eventsourcing.configuration.EventSourcingConfigurer +import org.axonframework.messaging.core.MessageType +import org.axonframework.messaging.core.QualifiedName +import org.axonframework.messaging.core.sequencing.SequencingPolicy +import org.axonframework.messaging.eventhandling.EventMessage +import org.axonframework.messaging.eventhandling.GenericEventMessage +import org.axonframework.messaging.eventhandling.SimpleEventHandlingComponent +import org.axonframework.messaging.eventhandling.processing.streaming.pooled.PooledStreamingEventProcessorConfiguration +import org.axonframework.messaging.eventhandling.processing.streaming.pooled.PooledStreamingEventProcessorModule +import org.axonframework.messaging.eventhandling.processing.streaming.segmenting.SequenceOverridingEventHandlingComponent +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 +import java.util.Optional + +/** + * End-to-end integration test for the AF5 DLQ inspection wiring. + * + * Boots a real AF5 configuration (via [EventSourcingConfigurer]) with a single Pooled Streaming + * event processor whose [SimpleEventHandlingComponent] is wrapped in a + * [SequenceOverridingEventHandlingComponent] using a deterministic [SequencingPolicy]. With the + * DLQ enabled on the processor, the framework materialises a real [SequencedDeadLetterQueue] + * under the `DeadLetterQueue[EventHandlingComponent[][]]` name that + * [DeadLetterManager] discovers. + * + * Rather than driving the processor end-to-end (which requires an event store, an event sink, and + * deterministic failure timing), we enqueue letters directly onto the materialised DLQ — the bit + * the test is verifying is the discovery + sequence-identifier resolution + mutation flow, not the + * processor's own machinery. + * + * What we assert: + * - the manager exposes the processor name as a single processing group; + * - `deadLetters(...)` returns the policy-derived sequence identifier (NOT the synthetic first + * message id, which is the bug the AF4 sequencing policy port fixed); + * - the sequence id stays stable across `delete(seq, messageId)` — deleting the first letter + * must not rename the sequence; + * - `lettersForSequence(...)` paginates correctly; + * - `delete(seq)` evicts every letter. + * + * DLQ-mode behaviour is intentionally out of scope here — that's covered exhaustively in the + * unit-test [DeadLetterManagerTest]'s `DlqModes` nested class, which can mock cheaply. + */ +class RSocketDlqResponderIntegrationTest { + + companion object { + private const val PROCESSOR_NAME = "audit" + private const val COMPONENT_NAME = "AuditProjector" + private const val SEQUENCE_ID = "tenant-42" + private val EVENT_NAME = QualifiedName(TestEvent::class.java) + } + + /** + * Payload type used by the integration test. A real AF5 `EventMessage` requires a `MessageType` + * which we derive from this class via [QualifiedName]. + */ + data class TestEvent(val value: String) + + private lateinit var configuration: AxonConfiguration + private lateinit var manager: DeadLetterManager + + @BeforeEach + fun boot() { + configuration = EventSourcingConfigurer.create() + .componentRegistry { registry -> + registry.registerComponent(ComponentDefinition.ofType(DeadLetterManager::class.java) + .withBuilder { c -> DeadLetterManager(c) }) + } + .messaging { messaging -> + messaging.eventProcessing { eventProcessing -> + eventProcessing.pooledStreaming { psep -> + psep.processor( + PooledStreamingEventProcessorModule(PROCESSOR_NAME) + .eventHandlingComponents { components -> + components.declarative(COMPONENT_NAME) { _ -> + SequenceOverridingEventHandlingComponent( + constantSequencingPolicy(), + SimpleEventHandlingComponent.create(COMPONENT_NAME), + ) + } + } + .customized { _, cfg -> + cfg.extend( + DeadLetterQueueConfiguration::class.java, + ) { DeadLetterQueueConfiguration().enabled() } + }, + ) + } + } + } + .build() + configuration.start() + manager = configuration.getComponent(DeadLetterManager::class.java) + manager.start() + // Push two letters with the same policy-derived sequence id so we can test pagination and + // confirm the id remains stable as letters get evicted. + val dlq = resolveDlq() + enqueue(dlq, sequenceId = SEQUENCE_ID, "letter-1") + enqueue(dlq, sequenceId = SEQUENCE_ID, "letter-2") + enqueue(dlq, sequenceId = SEQUENCE_ID, "letter-3") + } + + @AfterEach + fun shutdown() { + configuration.shutdown() + } + + @Test + fun `manager discovers the configured processor as a processing group`() { + val infos = manager.infoFor(PROCESSOR_NAME) + assertEquals(1, infos.size) + assertEquals(PROCESSOR_NAME, infos[0].processingGroup) + } + + @Test + fun `the framework wraps the EHC in a caching decorator and the resolver unwraps past it`() { + // Sanity check that exercises the live decorator chain: AF5 wraps every EHC with + // SequenceCachingEventHandlingComponent whose sequenceIdentifierFor NPEs without a live + // ProcessingContext. The resolver must unwrap past it and reach a layer whose policy can + // run with `null` context. If this regresses, the four flow tests below also fail — this + // test fails first with a clearer signal. + val expectedEhcName = "EventHandlingComponent[$PROCESSOR_NAME][$COMPONENT_NAME]" + val ehc = configuration.moduleConfigurations.asSequence() + .mapNotNull { module -> + module.getOptionalComponent( + org.axonframework.messaging.eventhandling.EventHandlingComponent::class.java, + expectedEhcName, + ).orElse(null) + } + .firstOrNull() + assertNotNull(ehc, "Expected an EventHandlingComponent registered as [$expectedEhcName]") + val event: EventMessage = GenericEventMessage(MessageType(EVENT_NAME), TestEvent("sanity")) + assertEquals(SEQUENCE_ID, SequenceIdentifierResolver.resolve(ehc!!, event)) + } + + @Test + fun `deadLetters returns the policy-derived sequence id (not the first letter's message id)`() { + val response = manager.deadLetters(PROCESSOR_NAME) + assertEquals(1, response.sequences.size) + val letters = response.sequences[0] + // Helpful failure context: surface the actual ids when the assertion fails so future + // breakage points at "got " vs "got " without re-running with a debugger. + val seqIds = letters.map { it.sequenceIdentifier } + assertTrue( + letters.all { it.sequenceIdentifier == SEQUENCE_ID }, + "Expected every letter in the sequence to carry policy id [$SEQUENCE_ID] but got $seqIds", + ) + assertTrue(letters.first().messageIdentifier != SEQUENCE_ID) + } + + @Test + fun `lettersForSequence paginates within the sequence`() { + val response = manager.lettersForSequence(PROCESSOR_NAME, SEQUENCE_ID, offset = 1, size = 1) + assertEquals(3L, response.totalCount) + assertEquals(1, response.letters.size) + } + + @Test + fun `sequence id stays stable across delete-letter (the AF4 regression that motivated this rewrite)`() { + // Capture the first letter's message id, delete it, then re-fetch the sequence: the + // sequence id MUST still be `tenant-42`, not the message id of the (now-second) letter. + val firstLetterId = manager.deadLetters(PROCESSOR_NAME).sequences[0][0].messageIdentifier + val evicted = manager.delete(PROCESSOR_NAME, SEQUENCE_ID, firstLetterId) + assertTrue(evicted) + val after = manager.deadLetters(PROCESSOR_NAME).sequences[0] + assertEquals(2, after.size) + assertTrue(after.all { it.sequenceIdentifier == SEQUENCE_ID }) + assertTrue(after.none { it.messageIdentifier == firstLetterId }) + } + + @Test + fun `delete-sequence evicts every letter in the sequence`() { + val evicted = manager.delete(PROCESSOR_NAME, SEQUENCE_ID) + assertEquals(3, evicted) + // After deletion the manager's infoFor returns zero sequences for this processor. + assertEquals(0L, manager.infoFor(PROCESSOR_NAME).single().dlqSize) + } + + // ----------------------------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------------------------- + + /** + * Walks the module configurations the same way [DeadLetterManager.discoverEntries] does and + * returns the materialised DLQ for the test processor's component. Failing here means the + * framework wiring didn't produce a DLQ — likely a misconfigured + * [DeadLetterQueueConfiguration] in [boot]. + */ + @Suppress("UNCHECKED_CAST") + private fun resolveDlq(): SequencedDeadLetterQueue { + val expectedName = "DeadLetterQueue[EventHandlingComponent[$PROCESSOR_NAME][$COMPONENT_NAME]]" + val dlq = configuration.moduleConfigurations.asSequence() + .flatMap { it.getComponents(SequencedDeadLetterQueue::class.java).entries.asSequence() } + .firstOrNull { it.key == expectedName } + ?.value + assertNotNull(dlq, "Expected the framework to materialise a DLQ named [$expectedName]") + return dlq as SequencedDeadLetterQueue + } + + private fun enqueue( + dlq: SequencedDeadLetterQueue, + sequenceId: String, + payloadValue: String, + ) { + val message: EventMessage = GenericEventMessage( + MessageType(EVENT_NAME), + TestEvent(payloadValue), + ) + val letter = GenericDeadLetter( + sequenceId, + message, + RuntimeException("deliberate failure for [$payloadValue]"), + ) + dlq.enqueue(sequenceId, letter, null).join() + } + + /** + * Returns the same sequence identifier for every event in the test, so all enqueued letters + * end up in a single sequence — that's what lets the assertions about pagination and stable + * sequence id work. + */ + private fun constantSequencingPolicy(): SequencingPolicy = + SequencingPolicy { _, _ -> Optional.of(SEQUENCE_ID) } +} From b760195326a5d532792483a76edb5ea29999f42a Mon Sep 17 00:00:00 2001 From: Stefan Mirkovic Date: Thu, 21 May 2026 15:05:28 +0200 Subject: [PATCH 06/10] Wire dlq-mode through Spring properties to setup payload; Show DLQ mode badge (MASKED/LIMITED/NONE) in UI and disable bulk actions in NONE; fix findSequence logging and double-hash --- .../framework/client/SetupPayloadCreator.kt | 15 ++++++- .../eventprocessor/DeadLetterManager.kt | 43 +++++++++++++++++-- .../eventprocessor/DeadLetterManagerTest.kt | 23 ++++++++++ .../AxoniqPlatformAutoConfiguration.kt | 2 + 4 files changed, 79 insertions(+), 4 deletions(-) diff --git a/framework-client/src/main/java/io/axoniq/platform/framework/client/SetupPayloadCreator.kt b/framework-client/src/main/java/io/axoniq/platform/framework/client/SetupPayloadCreator.kt index d026996b..e2ddf050 100644 --- a/framework-client/src/main/java/io/axoniq/platform/framework/client/SetupPayloadCreator.kt +++ b/framework-client/src/main/java/io/axoniq/platform/framework/client/SetupPayloadCreator.kt @@ -16,7 +16,9 @@ package io.axoniq.platform.framework.client +import io.axoniq.platform.framework.AxoniqPlatformConfiguration import io.axoniq.platform.framework.api.AxonServerEventStoreMessageSourceInformation +import io.axoniq.platform.framework.api.AxoniqConsoleDlqMode import io.axoniq.platform.framework.api.CommandBusInformation import io.axoniq.platform.framework.api.CommonProcessorInformation import io.axoniq.platform.framework.api.EventProcessorInformation @@ -81,7 +83,8 @@ class SetupPayloadCreator( heartbeat = true, threadDump = true, clientStatusUpdates = true, - licenseEntitlement = hasEntitlementManager() + licenseEntitlement = hasEntitlementManager(), + deadLetterQueuesInsights = axoniqPlatformConfiguration()?.dlqMode ?: AxoniqConsoleDlqMode.FULL, ) ) } @@ -346,6 +349,16 @@ class SetupPayloadCreator( } + /** + * Resolves the [AxoniqPlatformConfiguration] from the application configuration, returning `null` + * when the platform module hasn't been wired (legacy or non-Spring setups). The caller falls back + * to a sensible default (`FULL`) so existing applications keep their current visibility — only + * applications that explicitly opt into a restrictive mode (`LIMITED`/`MASKED`/`NONE`) will see + * the corresponding gates applied in the platform UI. + */ + private fun axoniqPlatformConfiguration(): AxoniqPlatformConfiguration? = + configuration.getOptionalComponent(AxoniqPlatformConfiguration::class.java).orElse(null) + /** * Checks whether the PlatformLicenseSource have been configured, in which case we want updates of licenses from Platform. */ diff --git a/framework-client/src/main/java/io/axoniq/platform/framework/eventprocessor/DeadLetterManager.kt b/framework-client/src/main/java/io/axoniq/platform/framework/eventprocessor/DeadLetterManager.kt index 036a70af..ac4eba8f 100644 --- a/framework-client/src/main/java/io/axoniq/platform/framework/eventprocessor/DeadLetterManager.kt +++ b/framework-client/src/main/java/io/axoniq/platform/framework/eventprocessor/DeadLetterManager.kt @@ -66,9 +66,25 @@ class DeadLetterManager @JvmOverloads constructor( /** * Discovers the DLQs configured on this application by walking each event-processor module. * Called once via the lifecycle; subsequent invocations refresh the cached view. + * + * Also logs the active [dlqMode] at INFO so operators can confirm from application logs that the + * configured exposure level (`axoniq.platform.dlq-mode`) has actually taken effect. When the mode + * deviates from the default `FULL` we surface a warning so accidental misconfiguration (e.g. a + * forgotten `MASKED` override) is hard to miss in production logs. */ fun start() { entries = discoverEntries() + when (dlqMode) { + AxoniqConsoleDlqMode.FULL -> logger.info( + "AxoniqPlatform DLQ inspection initialised in FULL mode — payloads, causes and diagnostics are exposed verbatim.") + AxoniqConsoleDlqMode.LIMITED -> logger.warn( + "AxoniqPlatform DLQ inspection initialised in LIMITED mode — payloads are hidden; diagnostics are filtered through whitelist {} (empty whitelist removes all diagnostic entries).", + dlqDiagnosticsWhitelist) + AxoniqConsoleDlqMode.MASKED -> logger.warn( + "AxoniqPlatform DLQ inspection initialised in MASKED mode — sequence ids are SHA-256 hashed; payloads, causes and diagnostics are not exposed. Operator delete/process actions still work via the hashed identifier.") + AxoniqConsoleDlqMode.NONE -> logger.warn( + "AxoniqPlatform DLQ inspection initialised in NONE mode — only sequence counts are exposed. List queries return empty results regardless of letter contents.") + } } override fun infoFor(processorName: String): List = @@ -114,10 +130,15 @@ class DeadLetterManager @JvmOverloads constructor( .take(size) .map { sequence -> val letters = sequence.toList() - val sequenceId = letters.firstOrNull()?.let { sequenceIdentifierFor(entry, it) } ?: "" + // Compute the API-side sequence id ONCE here: raw policy result, hashed in MASKED. + // toApiLetter then just passes this through verbatim, avoiding the double-hash + // that would otherwise hit letters in the lettersForSequence response (where the + // incoming sequenceIdentifier is already hashed). + val rawSequenceId = letters.firstOrNull()?.let { sequenceIdentifierFor(entry, it) } ?: "" + val apiSequenceId = if (dlqMode == AxoniqConsoleDlqMode.MASKED) rawSequenceId.hashed() else rawSequenceId letters .take(maxSequenceLetters) - .map { it.toApiLetter(sequenceId) } + .map { it.toApiLetter(apiSequenceId) } } val total = entry.dlq.amountOfSequences(null).join() return DeadLetterResponse(pageOfSequences, total) @@ -227,6 +248,10 @@ class DeadLetterManager @JvmOverloads constructor( sequenceIdentifier: String, ): List>? { val sequences = entry.dlq.deadLetters(null).join() + // Track candidates so we can log a diagnostic when nothing matches — the most common cause + // is a stale identifier (the sequence's first letter has changed) or a mode mismatch between + // the value the UI cached and what the manager now computes. Capped to keep log noise low. + val candidates = mutableListOf() for (sequence in sequences) { val letters = sequence.toList() val firstLetter = letters.firstOrNull() ?: continue @@ -235,7 +260,17 @@ class DeadLetterManager @JvmOverloads constructor( if (candidateId == sequenceIdentifier) { return letters } + if (candidates.size < 5) candidates.add(candidateId) } + logger.warn( + "DLQ findSequence: no sequence in [{}] matches id [{}] (dlqMode={}, scanned {} sequence(s), first {} candidate id(s): {}). Operator's view may be stale, or the first letter of the sequence has changed since the list query.", + entry.processingGroup, + sequenceIdentifier, + dlqMode, + candidates.size, + candidates.size, + candidates, + ) return null } @@ -358,6 +393,8 @@ class DeadLetterManager @JvmOverloads constructor( private fun DeadLetter.toApiLetter(sequenceIdentifier: String): ApiDeadLetter { val message = this.message() + // `sequenceIdentifier` is expected to be in its final API form (hashed in MASKED, raw in FULL/ + // LIMITED). Hashing is the caller's responsibility — see `deadLetters(...)`. return when (dlqMode) { AxoniqConsoleDlqMode.NONE, AxoniqConsoleDlqMode.MASKED -> ApiDeadLetter( @@ -369,7 +406,7 @@ class DeadLetterManager @JvmOverloads constructor( enqueuedAt = this.enqueuedAt(), lastTouched = this.lastTouched(), diagnostics = emptyMap(), - sequenceIdentifier = sequenceIdentifier.hashed(), + sequenceIdentifier = sequenceIdentifier, ) AxoniqConsoleDlqMode.LIMITED -> ApiDeadLetter( messageIdentifier = message.identifier(), diff --git a/framework-client/src/test/kotlin/io/axoniq/platform/framework/eventprocessor/DeadLetterManagerTest.kt b/framework-client/src/test/kotlin/io/axoniq/platform/framework/eventprocessor/DeadLetterManagerTest.kt index 83d7bcae..00236565 100644 --- a/framework-client/src/test/kotlin/io/axoniq/platform/framework/eventprocessor/DeadLetterManagerTest.kt +++ b/framework-client/src/test/kotlin/io/axoniq/platform/framework/eventprocessor/DeadLetterManagerTest.kt @@ -578,6 +578,29 @@ class DeadLetterManagerTest { assertEquals(2, evicted) } + @Test + fun `MASKED lettersForSequence returns the paginated slice when looked up by the hashed id`() { + // Regression: the detail modal sends the hashed sequence id back; the manager must + // re-hash candidate ids while walking the DLQ so the lookup succeeds and the modal + // doesn't render an empty slice. + val letters = (1..5).map { fakeLetter("m$it", payload = "payload-$it") } + val dlq = fakeDlq(sequences = listOf(letters)) + val ehc = ehcWithPolicy { _ -> "saga-abc" } + val manager = managerWith( + "DeadLetterQueue[EventHandlingComponent[orders][OrderProjector]]" to dlq, + ehc = ehc, + dlqMode = AxoniqConsoleDlqMode.MASKED, + ) + val hashedId = DigestUtils.sha256Hex("saga-abc") + + val response = manager.lettersForSequence("orders", hashedId, offset = 0, size = 3) + + assertEquals(5L, response.totalCount) + assertEquals(3, response.letters.size) + // Every letter in the response carries the hashed id verbatim — no double-hashing. + response.letters.forEach { assertEquals(hashedId, it.sequenceIdentifier) } + } + @Test fun `FULL preserves payload, cause message and raw sequence id`() { val sequence = listOf(fakeLetter("m1", payload = "fully-visible")) diff --git a/spring-boot-starter/src/main/java/io/axoniq/console/framework/starter/AxoniqPlatformAutoConfiguration.kt b/spring-boot-starter/src/main/java/io/axoniq/console/framework/starter/AxoniqPlatformAutoConfiguration.kt index db95ece9..9c7b6950 100644 --- a/spring-boot-starter/src/main/java/io/axoniq/console/framework/starter/AxoniqPlatformAutoConfiguration.kt +++ b/spring-boot-starter/src/main/java/io/axoniq/console/framework/starter/AxoniqPlatformAutoConfiguration.kt @@ -56,6 +56,8 @@ class AxoniqPlatformAutoConfiguration { .host(properties.host) .secure(properties.isSecure) .initialDelay(properties.initialDelay) + .dlqMode(properties.dlqMode) + .also { properties.dlqDiagnosticsWhitelist.forEach(it::addDlqDiagnosticsWhitelistKey) } } private fun getApplicationName( From 315ee7001e1ba6b626daa7168979dec4b1290f83 Mon Sep 17 00:00:00 2001 From: Stefan Mirkovic Date: Fri, 22 May 2026 21:15:23 +0200 Subject: [PATCH 07/10] Address review #3: real UnitOfWork for sequence ids, GDPR-safe NONE default, nits --- .../AxoniqPlatformConfiguration.java | 12 ++- .../framework/client/SetupPayloadCreator.kt | 8 +- .../eventprocessor/DeadLetterManager.kt | 102 +++++++++++------- .../SequenceIdentifierResolver.java | 95 ---------------- .../eventprocessor/DeadLetterManagerTest.kt | 42 ++++++-- .../RSocketDlqResponderIntegrationTest.kt | 22 ++-- 6 files changed, 122 insertions(+), 159 deletions(-) delete mode 100644 framework-client/src/main/java/io/axoniq/platform/framework/eventprocessor/SequenceIdentifierResolver.java diff --git a/framework-client/src/main/java/io/axoniq/platform/framework/AxoniqPlatformConfiguration.java b/framework-client/src/main/java/io/axoniq/platform/framework/AxoniqPlatformConfiguration.java index 52835ab1..aff4c24f 100644 --- a/framework-client/src/main/java/io/axoniq/platform/framework/AxoniqPlatformConfiguration.java +++ b/framework-client/src/main/java/io/axoniq/platform/framework/AxoniqPlatformConfiguration.java @@ -45,7 +45,7 @@ public class AxoniqPlatformConfiguration { private ScheduledExecutorService reportingTaskExecutor; private Integer reportingThreadPoolSize = 2; - private AxoniqConsoleDlqMode dlqMode = AxoniqConsoleDlqMode.FULL; + private AxoniqConsoleDlqMode dlqMode = AxoniqConsoleDlqMode.NONE; private List dlqDiagnosticsWhitelist = new ArrayList<>(); /** @@ -196,10 +196,12 @@ public Long getInitialDelay() { /** * Controls how much DLQ data is exposed through the platform API. Defaults to - * {@link AxoniqConsoleDlqMode#FULL} to preserve the existing behaviour. Use {@code MASKED} when - * the platform may contain sensitive information, {@code LIMITED} to strip payload but keep - * sequence identifiers as-is for filtered diagnostics, or {@code NONE} to expose only the - * sequence count without any letter contents. + * {@link AxoniqConsoleDlqMode#NONE} so applications must deliberately opt into exposing letter + * contents (which may include personal data and would make the platform a data processor under + * GDPR). Use {@code MASKED} when sequence identifiers must still be addressable but contents + * must not leak, {@code LIMITED} to strip payload but keep sequence identifiers as-is for + * filtered diagnostics, or {@code FULL} for unrestricted access (typically only safe in + * development). * * @param dlqMode The dead-letter exposure mode. * @return The builder for fluent interfacing. diff --git a/framework-client/src/main/java/io/axoniq/platform/framework/client/SetupPayloadCreator.kt b/framework-client/src/main/java/io/axoniq/platform/framework/client/SetupPayloadCreator.kt index e2ddf050..e12ee507 100644 --- a/framework-client/src/main/java/io/axoniq/platform/framework/client/SetupPayloadCreator.kt +++ b/framework-client/src/main/java/io/axoniq/platform/framework/client/SetupPayloadCreator.kt @@ -84,7 +84,7 @@ class SetupPayloadCreator( threadDump = true, clientStatusUpdates = true, licenseEntitlement = hasEntitlementManager(), - deadLetterQueuesInsights = axoniqPlatformConfiguration()?.dlqMode ?: AxoniqConsoleDlqMode.FULL, + deadLetterQueuesInsights = axoniqPlatformConfiguration()?.dlqMode ?: AxoniqConsoleDlqMode.NONE, ) ) } @@ -352,9 +352,9 @@ class SetupPayloadCreator( /** * Resolves the [AxoniqPlatformConfiguration] from the application configuration, returning `null` * when the platform module hasn't been wired (legacy or non-Spring setups). The caller falls back - * to a sensible default (`FULL`) so existing applications keep their current visibility — only - * applications that explicitly opt into a restrictive mode (`LIMITED`/`MASKED`/`NONE`) will see - * the corresponding gates applied in the platform UI. + * to `NONE` so applications that haven't deliberately opted in stay closed by default — exposing + * letter contents (which can include personal data) requires an explicit `dlqMode` override + * (`LIMITED`/`MASKED`/`FULL`) on the application's [AxoniqPlatformConfiguration]. */ private fun axoniqPlatformConfiguration(): AxoniqPlatformConfiguration? = configuration.getOptionalComponent(AxoniqPlatformConfiguration::class.java).orElse(null) diff --git a/framework-client/src/main/java/io/axoniq/platform/framework/eventprocessor/DeadLetterManager.kt b/framework-client/src/main/java/io/axoniq/platform/framework/eventprocessor/DeadLetterManager.kt index ac4eba8f..f49911fb 100644 --- a/framework-client/src/main/java/io/axoniq/platform/framework/eventprocessor/DeadLetterManager.kt +++ b/framework-client/src/main/java/io/axoniq/platform/framework/eventprocessor/DeadLetterManager.kt @@ -24,9 +24,15 @@ import io.axoniq.platform.framework.api.DeadLetterResponse import io.axoniq.platform.framework.api.SequenceLettersResponse import org.apache.commons.codec.digest.DigestUtils import org.axonframework.common.configuration.Configuration +import org.axonframework.messaging.core.EmptyApplicationContext +import org.axonframework.messaging.core.Metadata +import org.axonframework.messaging.core.unitofwork.ProcessingContext +import org.axonframework.messaging.core.unitofwork.SimpleUnitOfWorkFactory +import org.axonframework.messaging.core.unitofwork.UnitOfWorkFactory import org.axonframework.messaging.eventhandling.EventHandlingComponent import org.axonframework.messaging.eventhandling.EventMessage import org.slf4j.LoggerFactory +import java.util.concurrent.CompletableFuture import java.util.concurrent.TimeUnit import io.axoniq.platform.framework.api.DeadLetter as ApiDeadLetter @@ -56,34 +62,48 @@ private val logger = LoggerFactory.getLogger(DeadLetterManager::class.java) */ class DeadLetterManager @JvmOverloads constructor( private val configuration: Configuration, - private val dlqMode: AxoniqConsoleDlqMode = AxoniqConsoleDlqMode.FULL, + private val dlqMode: AxoniqConsoleDlqMode = AxoniqConsoleDlqMode.NONE, private val dlqDiagnosticsWhitelist: List = emptyList(), ) : ProcessingGroupInfoSource { @Volatile private var entries: List? = null + /** + * Factory used to materialise a real [org.axonframework.messaging.core.unitofwork.UnitOfWork] when the + * manager needs to call [EventHandlingComponent.sequenceIdentifierFor] on a dead letter — that call + * requires a non-null [ProcessingContext] because some decorator layers (notably + * `SequenceCachingEventHandlingComponent`) store per-event resources on the context. + * + * An [EmptyApplicationContext] is used because the stock sequencing-policy chain (constant, + * property, metadata, hierarchical, fallback) does not look up application components; the context + * is only consulted as a resource bag. If a custom policy ever needs richer context resolution the + * wiring can be revisited. + */ + private val unitOfWorkFactory: UnitOfWorkFactory = + SimpleUnitOfWorkFactory(EmptyApplicationContext.INSTANCE) + /** * Discovers the DLQs configured on this application by walking each event-processor module. * Called once via the lifecycle; subsequent invocations refresh the cached view. * - * Also logs the active [dlqMode] at INFO so operators can confirm from application logs that the - * configured exposure level (`axoniq.platform.dlq-mode`) has actually taken effect. When the mode - * deviates from the default `FULL` we surface a warning so accidental misconfiguration (e.g. a - * forgotten `MASKED` override) is hard to miss in production logs. + * Logs the active [dlqMode] at INFO so operators can confirm from application logs that the + * configured exposure level (`axoniq.platform.dlq-mode`) has actually taken effect. We stay at + * INFO regardless of mode because some users alert on WARN-and-above and an expected + * configuration choice shouldn't trip those alerts. */ fun start() { entries = discoverEntries() when (dlqMode) { AxoniqConsoleDlqMode.FULL -> logger.info( - "AxoniqPlatform DLQ inspection initialised in FULL mode — payloads, causes and diagnostics are exposed verbatim.") - AxoniqConsoleDlqMode.LIMITED -> logger.warn( - "AxoniqPlatform DLQ inspection initialised in LIMITED mode — payloads are hidden; diagnostics are filtered through whitelist {} (empty whitelist removes all diagnostic entries).", + "Axoniq Platform DLQ inspection initialised in FULL mode — payloads, causes and diagnostics are exposed verbatim.") + AxoniqConsoleDlqMode.LIMITED -> logger.info( + "Axoniq Platform DLQ inspection initialised in LIMITED mode — payloads are hidden; diagnostics are filtered through whitelist {} (empty whitelist removes all diagnostic entries).", dlqDiagnosticsWhitelist) - AxoniqConsoleDlqMode.MASKED -> logger.warn( - "AxoniqPlatform DLQ inspection initialised in MASKED mode — sequence ids are SHA-256 hashed; payloads, causes and diagnostics are not exposed. Operator delete/process actions still work via the hashed identifier.") - AxoniqConsoleDlqMode.NONE -> logger.warn( - "AxoniqPlatform DLQ inspection initialised in NONE mode — only sequence counts are exposed. List queries return empty results regardless of letter contents.") + AxoniqConsoleDlqMode.MASKED -> logger.info( + "Axoniq Platform DLQ inspection initialised in MASKED mode — sequence ids are SHA-256 hashed; payloads, causes and diagnostics are not exposed. Operator delete/process actions still work via the hashed identifier.") + AxoniqConsoleDlqMode.NONE -> logger.info( + "Axoniq Platform DLQ inspection initialised in NONE mode — only sequence counts are exposed. List queries return empty results regardless of letter contents.") } } @@ -130,10 +150,6 @@ class DeadLetterManager @JvmOverloads constructor( .take(size) .map { sequence -> val letters = sequence.toList() - // Compute the API-side sequence id ONCE here: raw policy result, hashed in MASKED. - // toApiLetter then just passes this through verbatim, avoiding the double-hash - // that would otherwise hit letters in the lettersForSequence response (where the - // incoming sequenceIdentifier is already hashed). val rawSequenceId = letters.firstOrNull()?.let { sequenceIdentifierFor(entry, it) } ?: "" val apiSequenceId = if (dlqMode == AxoniqConsoleDlqMode.MASKED) rawSequenceId.hashed() else rawSequenceId letters @@ -368,24 +384,38 @@ class DeadLetterManager @JvmOverloads constructor( private fun discover(): List = entries ?: discoverEntries().also { entries = it } /** - * Resolves the sequence identifier for a letter via [SequenceIdentifierResolver], which walks the - * [EventHandlingComponent] decorator chain to find a layer that can resolve the id without a live - * [org.axonframework.messaging.core.unitofwork.ProcessingContext]. Result shape mirrors the AF4 - * implementation: + * Resolves the sequence identifier for a letter by spinning up a real + * [org.axonframework.messaging.core.unitofwork.UnitOfWork] and calling + * [EventHandlingComponent.sequenceIdentifierFor] with its [ProcessingContext]. The UoW gives the + * decorator chain (including `SequenceCachingEventHandlingComponent`) a non-null context to read + * resources from, matching the framework's own invariants and avoiding the NPE that calling with + * `null` would trigger. The UoW does no real work — the lambda completes synchronously on the + * direct executor, so there's no scheduling cost. Result shape mirrors the AF4 implementation: * - String results are used verbatim; * - non-String results fall back to `hashCode().toString()`; - * - if every decorator layer requires a context (or the EHC reference could not be captured at - * discovery time, or a custom policy throws on null context) the letter's message identifier is - * used so each letter still has a unique id. + * - if the EHC reference could not be captured at discovery time, or sequence resolution throws + * or returns `null`, the letter's message identifier is used so each letter still has a + * unique id. */ private fun sequenceIdentifierFor( entry: DlqEntry, letter: DeadLetter, ): String { - val ehc = entry.eventHandlingComponent ?: return letter.message().identifier() - val raw: Any? = SequenceIdentifierResolver.resolve(ehc, letter.message()) + val message = letter.message() + val ehc = entry.eventHandlingComponent ?: return message.identifier() + val raw: Any? = try { + unitOfWorkFactory.create().executeWithResult { context: ProcessingContext -> + CompletableFuture.completedFuture(ehc.sequenceIdentifierFor(message, context)) + }.join() + } catch (ex: Exception) { + logger.debug( + "Sequence identifier resolution threw for message [{}] in [{}] — falling back to message id.", + message.identifier(), entry.processingGroup, ex, + ) + null + } return when (raw) { - null -> letter.message().identifier() + null -> message.identifier() is String -> raw else -> raw.hashCode().toString() } @@ -396,7 +426,8 @@ class DeadLetterManager @JvmOverloads constructor( // `sequenceIdentifier` is expected to be in its final API form (hashed in MASKED, raw in FULL/ // LIMITED). Hashing is the caller's responsibility — see `deadLetters(...)`. return when (dlqMode) { - AxoniqConsoleDlqMode.NONE, + AxoniqConsoleDlqMode.NONE -> error( + "DLQ in NONE mode must not serialise letters — short-circuit in deadLetters/lettersForSequence was bypassed.") AxoniqConsoleDlqMode.MASKED -> ApiDeadLetter( messageIdentifier = message.identifier(), message = MASKED, @@ -434,17 +465,12 @@ class DeadLetterManager @JvmOverloads constructor( } /** - * Best-effort human-readable type name for the payload. When the DLQ has the message in its - * still-serialised form the JVM type is `byte[]`, which is useless to display, so we fall back - * to the qualified name carried on the message's [org.axonframework.messaging.core.MessageType]. + * Best-effort human-readable type name for the message. In AF5 the qualified name carried on the + * message's [org.axonframework.messaging.core.MessageType] is the primary type identifier and is + * always set; the payload class is only a fallback for the unlikely case the type lookup throws. */ - private fun messageTypeOf(message: EventMessage): String { - val payloadClass = message.payloadType() - if (payloadClass == ByteArray::class.java) { - return runCatching { message.type().name() }.getOrDefault("byte[]") - } - return payloadClass.simpleName ?: payloadClass.name - } + private fun messageTypeOf(message: EventMessage): String = + runCatching { message.type().name() }.getOrDefault(message.payloadType().name) private fun serializePayload(message: EventMessage): String { val raw: String = try { @@ -466,7 +492,7 @@ class DeadLetterManager @JvmOverloads constructor( * Applies the whitelist filter used in LIMITED mode. Returns only entries whose key is in the * configured whitelist; an empty whitelist removes all diagnostics. */ - private fun org.axonframework.messaging.core.Metadata.filteredByWhitelist(): Map = + private fun Metadata.filteredByWhitelist(): Map = if (dlqDiagnosticsWhitelist.isEmpty()) emptyMap() else subset(*dlqDiagnosticsWhitelist.toTypedArray()) diff --git a/framework-client/src/main/java/io/axoniq/platform/framework/eventprocessor/SequenceIdentifierResolver.java b/framework-client/src/main/java/io/axoniq/platform/framework/eventprocessor/SequenceIdentifierResolver.java deleted file mode 100644 index b0240d73..00000000 --- a/framework-client/src/main/java/io/axoniq/platform/framework/eventprocessor/SequenceIdentifierResolver.java +++ /dev/null @@ -1,95 +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.eventprocessor; - -import org.axonframework.messaging.eventhandling.DelegatingEventHandlingComponent; -import org.axonframework.messaging.eventhandling.EventHandlingComponent; -import org.axonframework.messaging.eventhandling.EventMessage; - -import java.lang.reflect.Field; - -/** - * Resolves the sequence identifier for an event by walking the {@link EventHandlingComponent} decorator chain. - * - *

The AF5 framework wraps every user-provided event handling component in a chain of decorators that includes - * {@code SequenceCachingEventHandlingComponent}. That decorator's {@code sequenceIdentifierFor} reads from a - * {@code ProcessingContext} resource — which means it NPEs when called outside of any live unit of work. The DLQ - * manager, however, is exactly in that position: it inspects already-enqueued letters with no context to provide.

- * - *

This resolver unwraps {@link DelegatingEventHandlingComponent} layers until it finds a component whose - * {@code sequenceIdentifierFor} does not require a context (the stock {@code SimpleEventHandlingComponent} and - * {@code SequenceOverridingEventHandlingComponent} do not), and calls it. Calling with a {@code null} - * {@code ProcessingContext} is safe for the stock sequencing policies (constant, property, metadata, hierarchical, - * fallback) — none of them read the context.

- * - *

If every attempt throws, the caller falls back to the letter's own message identifier (see - * {@code DeadLetterManager.sequenceIdentifierFor}).

- */ -final class SequenceIdentifierResolver { - - private SequenceIdentifierResolver() { - } - - /** - * Walks the decorator chain on {@code component} and invokes {@code sequenceIdentifierFor(event, null)} on the - * first component whose method completes without throwing. Returns {@code null} when every layer either throws - * or has no policy that can run outside a unit of work — the caller treats {@code null} as "fall back to the - * letter's message identifier". - */ - static Object resolve(EventHandlingComponent component, EventMessage event) { - EventHandlingComponent current = component; - // Bound the unwrap depth so an exotic delegate chain can't loop forever. 16 is comfortably - // higher than the standard AF5 decorator stack (sequence-caching, sequence-overriding, - // dead-lettering, intercepting, tracing, axoniq-platform — six layers in the worst case). - for (int i = 0; i < 16 && current != null; i++) { - try { - return current.sequenceIdentifierFor(event, null); - } catch (RuntimeException ignore) { - // This layer needed a ProcessingContext (e.g. SequenceCachingEventHandlingComponent). - // Walk one step deeper and try again. - } - current = unwrap(current); - } - return null; - } - - /** - * Reads the private {@code delegate} field that every {@link DelegatingEventHandlingComponent} carries — and - * that {@code SequenceOverridingEventHandlingComponent}, which does NOT extend that base class, also keeps under - * the same name. Falling back to introspection by field name keeps us independent of API additions in the AF5 - * decorator chain. - */ - private static EventHandlingComponent unwrap(EventHandlingComponent component) { - Class cls = component.getClass(); - while (cls != null) { - try { - Field delegate = cls.getDeclaredField("delegate"); - delegate.setAccessible(true); - Object value = delegate.get(component); - if (value instanceof EventHandlingComponent) { - return (EventHandlingComponent) value; - } - return null; - } catch (NoSuchFieldException e) { - cls = cls.getSuperclass(); - } catch (IllegalAccessException e) { - return null; - } - } - return null; - } -} diff --git a/framework-client/src/test/kotlin/io/axoniq/platform/framework/eventprocessor/DeadLetterManagerTest.kt b/framework-client/src/test/kotlin/io/axoniq/platform/framework/eventprocessor/DeadLetterManagerTest.kt index 00236565..808e6d48 100644 --- a/framework-client/src/test/kotlin/io/axoniq/platform/framework/eventprocessor/DeadLetterManagerTest.kt +++ b/framework-client/src/test/kotlin/io/axoniq/platform/framework/eventprocessor/DeadLetterManagerTest.kt @@ -427,7 +427,9 @@ class DeadLetterManagerTest { } @Test - fun `messageType falls back to message type name when payload class is ByteArray`() { + fun `messageType uses the qualified name carried on the message type (primary in AF5)`() { + // In AF5 `message.type().name()` is the primary identifier regardless of whether the + // payload class is the still-serialised `ByteArray` or the deserialised user type. val message = fakeEventMessage( id = "m1", payload = "still serialised".toByteArray(), @@ -445,15 +447,21 @@ class DeadLetterManagerTest { } @Test - fun `messageType uses payload class simple name for non-ByteArray payloads`() { - val sequence = listOf(fakeLetter("m1", payload = "hello", payloadType = String::class.java)) + fun `messageType falls back to payload class fqn when the message type lookup throws`() { + // Defensive fallback only — in well-formed AF5 messages this branch should never trip. + val message = mockk(relaxed = true) + every { message.identifier() } returns "m1" + every { message.payload() } returns "hello" + every { message.payloadType() } returns String::class.java + every { message.type() } throws IllegalStateException("no type") + val letter = fakeLetterFromMessage(message) val manager = managerWith( - "DeadLetterQueue[EventHandlingComponent[orders][OrderProjector]]" to fakeDlq(sequences = listOf(sequence)), + "DeadLetterQueue[EventHandlingComponent[orders][OrderProjector]]" to fakeDlq(sequences = listOf(listOf(letter))), ) val apiLetter = manager.deadLetters("orders").sequences[0][0] - assertEquals("String", apiLetter.messageType) + assertEquals("java.lang.String", apiLetter.messageType) } } @@ -632,6 +640,26 @@ class DeadLetterManagerTest { manager.sequenceSize("unknown-group", "whatever") } } + + @Test + fun `NONE mode never reaches toApiLetter — bypassing the short-circuit throws IllegalStateException`() { + // Defence in depth: deadLetters/lettersForSequence short-circuit before serialising + // letters in NONE mode. If a future refactor accidentally drops that guard, we want + // toApiLetter to fail loudly rather than silently leak placeholders. + val manager = managerWith( + "DeadLetterQueue[EventHandlingComponent[orders][OrderProjector]]" to fakeDlq(), + dlqMode = AxoniqConsoleDlqMode.NONE, + ) + val toApiLetter = DeadLetterManager::class.java.declaredMethods + .single { it.name == "toApiLetter" } + .apply { isAccessible = true } + val letter = fakeLetter("m1") + + val thrown = assertThrows(java.lang.reflect.InvocationTargetException::class.java) { + toApiLetter.invoke(manager, letter, "any-id") + } + assertTrue(thrown.cause is IllegalStateException) + } } // --------------------------------------------------------------------------------------- @@ -687,10 +715,6 @@ class DeadLetterManagerTest { every { ehc.sequenceIdentifierFor(any(), any()) } answers { policy(firstArg()) as Any } - @Suppress("UNCHECKED_CAST") - every { ehc.sequenceIdentifierFor(any(), isNull()) } answers { - policy(firstArg()) as Any - } return ehc } diff --git a/framework-client/src/test/kotlin/io/axoniq/platform/framework/eventprocessor/RSocketDlqResponderIntegrationTest.kt b/framework-client/src/test/kotlin/io/axoniq/platform/framework/eventprocessor/RSocketDlqResponderIntegrationTest.kt index 46444b0a..cc8a146f 100644 --- a/framework-client/src/test/kotlin/io/axoniq/platform/framework/eventprocessor/RSocketDlqResponderIntegrationTest.kt +++ b/framework-client/src/test/kotlin/io/axoniq/platform/framework/eventprocessor/RSocketDlqResponderIntegrationTest.kt @@ -19,6 +19,7 @@ package io.axoniq.platform.framework.eventprocessor import io.axoniq.framework.messaging.deadletter.GenericDeadLetter import io.axoniq.framework.messaging.deadletter.SequencedDeadLetterQueue import io.axoniq.framework.messaging.eventhandling.deadletter.DeadLetterQueueConfiguration +import io.axoniq.platform.framework.api.AxoniqConsoleDlqMode import org.axonframework.common.configuration.AxonConfiguration import org.axonframework.common.configuration.ComponentDefinition import org.axonframework.eventsourcing.configuration.EventSourcingConfigurer @@ -89,7 +90,9 @@ class RSocketDlqResponderIntegrationTest { configuration = EventSourcingConfigurer.create() .componentRegistry { registry -> registry.registerComponent(ComponentDefinition.ofType(DeadLetterManager::class.java) - .withBuilder { c -> DeadLetterManager(c) }) + // FULL exposure so the assertions on payload + sequence id can run. The + // production default is now NONE (see AxoniqPlatformConfiguration#dlqMode). + .withBuilder { c -> DeadLetterManager(c, AxoniqConsoleDlqMode.FULL) }) } .messaging { messaging -> messaging.eventProcessing { eventProcessing -> @@ -138,12 +141,13 @@ class RSocketDlqResponderIntegrationTest { } @Test - fun `the framework wraps the EHC in a caching decorator and the resolver unwraps past it`() { + fun `the framework wraps the EHC in a caching decorator and the manager resolves through it`() { // Sanity check that exercises the live decorator chain: AF5 wraps every EHC with - // SequenceCachingEventHandlingComponent whose sequenceIdentifierFor NPEs without a live - // ProcessingContext. The resolver must unwrap past it and reach a layer whose policy can - // run with `null` context. If this regresses, the four flow tests below also fail — this - // test fails first with a clearer signal. + // SequenceCachingEventHandlingComponent whose sequenceIdentifierFor reads a per-event + // resource off a ProcessingContext. The manager spins up a real UnitOfWork on every + // sequence-id resolution so the caching decorator gets the non-null context it requires. + // If this regresses, the four flow tests below also fail — this test fails first with a + // clearer signal because it bypasses pagination. val expectedEhcName = "EventHandlingComponent[$PROCESSOR_NAME][$COMPONENT_NAME]" val ehc = configuration.moduleConfigurations.asSequence() .mapNotNull { module -> @@ -154,8 +158,10 @@ class RSocketDlqResponderIntegrationTest { } .firstOrNull() assertNotNull(ehc, "Expected an EventHandlingComponent registered as [$expectedEhcName]") - val event: EventMessage = GenericEventMessage(MessageType(EVENT_NAME), TestEvent("sanity")) - assertEquals(SEQUENCE_ID, SequenceIdentifierResolver.resolve(ehc!!, event)) + // Drive the resolution through the public API rather than poking at the manager's + // internals — that's the contract the rest of the production code relies on. + val firstLetterSequenceId = manager.deadLetters(PROCESSOR_NAME).sequences[0][0].sequenceIdentifier + assertEquals(SEQUENCE_ID, firstLetterSequenceId) } @Test From e30e24bd5e2ba4a300407c1d6f39f55a17fce047 Mon Sep 17 00:00:00 2001 From: Stefan Mirkovic Date: Mon, 25 May 2026 10:34:57 +0200 Subject: [PATCH 08/10] Default deadLetterQueuesInsights to null when DLQ library is absent The setup payload reported `NONE` for every client, conflating two distinct states: the DLQ feature exists but data is hidden (NONE) vs. the client has no DLQ library on its classpath at all (no feature). Now `null` signals the latter; the platform UI can drop DLQ-specific chrome for those clients instead of showing a misleading NONE badge. --- .../framework/api/clientIdentification.kt | 6 +++-- .../framework/client/SetupPayloadCreator.kt | 24 ++++++++++++++++++- 2 files changed, 27 insertions(+), 3 deletions(-) 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 e108977d..aec4e339 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 @@ -104,8 +104,10 @@ data class SupportedFeatures( val pauseReports: Boolean? = false, /* Whether the client supports thread dumps.*/ val threadDump: Boolean? = false, - /* Whether the client supports DLQ insights. Can be FULL, LIMITED, MASKED, or NONE (default).*/ - val deadLetterQueuesInsights: AxoniqConsoleDlqMode = AxoniqConsoleDlqMode.NONE, + /* DLQ insight level for this client. `null` means the application has no DLQ library on + * its classpath, so DLQ inspection isn't a feature of this client at all (distinct from + * `NONE`, which means the feature exists but the operator hid all letter data). */ + val deadLetterQueuesInsights: AxoniqConsoleDlqMode? = null, /* Whether the client supports domain events insights. Can be FULL, LOAD_DOMAIN_STATE_ONLY, PREVIEW_PAYLOAD_ONLY, or NONE (default).*/ val domainEventsInsights: DomainEventAccessMode = DomainEventAccessMode.NONE, /* Whether the client supports client status updates .*/ diff --git a/framework-client/src/main/java/io/axoniq/platform/framework/client/SetupPayloadCreator.kt b/framework-client/src/main/java/io/axoniq/platform/framework/client/SetupPayloadCreator.kt index e12ee507..9a5b4acd 100644 --- a/framework-client/src/main/java/io/axoniq/platform/framework/client/SetupPayloadCreator.kt +++ b/framework-client/src/main/java/io/axoniq/platform/framework/client/SetupPayloadCreator.kt @@ -84,7 +84,7 @@ class SetupPayloadCreator( threadDump = true, clientStatusUpdates = true, licenseEntitlement = hasEntitlementManager(), - deadLetterQueuesInsights = axoniqPlatformConfiguration()?.dlqMode ?: AxoniqConsoleDlqMode.NONE, + deadLetterQueuesInsights = resolveDeadLetterQueuesInsights(), ) ) } @@ -359,6 +359,28 @@ class SetupPayloadCreator( private fun axoniqPlatformConfiguration(): AxoniqPlatformConfiguration? = configuration.getOptionalComponent(AxoniqPlatformConfiguration::class.java).orElse(null) + /** + * Returns the DLQ insight level reported on the setup payload, or `null` when this application + * has no DLQ library on its classpath (in which case DLQ inspection isn't a feature of this + * client at all — semantically distinct from [AxoniqConsoleDlqMode.NONE], which means the feature + * exists but the operator hid all letter data). + */ + private fun resolveDeadLetterQueuesInsights(): AxoniqConsoleDlqMode? { + if (!isDeadLetterLibraryAvailable()) return null + return axoniqPlatformConfiguration()?.dlqMode ?: AxoniqConsoleDlqMode.NONE + } + + private fun isDeadLetterLibraryAvailable(): Boolean = try { + Class.forName( + "io.axoniq.framework.messaging.deadletter.SequencedDeadLetterQueue", + false, + SetupPayloadCreator::class.java.classLoader, + ) + true + } catch (_: ClassNotFoundException) { + false + } + /** * Checks whether the PlatformLicenseSource have been configured, in which case we want updates of licenses from Platform. */ From 60fbafcf1c1202c5b9f99885ce94c4212128cce9 Mon Sep 17 00:00:00 2001 From: Mitchell Herrijgers Date: Mon, 1 Jun 2026 09:49:51 +0100 Subject: [PATCH 09/10] Unwrap AxoniqPlatformEventHandlingComponent in DLQ --- .../eventprocessor/AxoniqPlatformEventHandlingComponent.kt | 5 +---- .../platform/framework/eventprocessor/DeadLetterManager.kt | 7 ++++++- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/framework-client/src/main/java/io/axoniq/platform/framework/eventprocessor/AxoniqPlatformEventHandlingComponent.kt b/framework-client/src/main/java/io/axoniq/platform/framework/eventprocessor/AxoniqPlatformEventHandlingComponent.kt index 0ac37ac7..5e3badc8 100644 --- a/framework-client/src/main/java/io/axoniq/platform/framework/eventprocessor/AxoniqPlatformEventHandlingComponent.kt +++ b/framework-client/src/main/java/io/axoniq/platform/framework/eventprocessor/AxoniqPlatformEventHandlingComponent.kt @@ -29,17 +29,14 @@ import org.axonframework.messaging.core.Message import org.axonframework.messaging.core.MessageStream import org.axonframework.messaging.core.QualifiedName import org.axonframework.messaging.core.unitofwork.ProcessingContext -import org.axonframework.messaging.eventhandling.EventHandler -import org.axonframework.messaging.eventhandling.EventHandlerRegistry import org.axonframework.messaging.eventhandling.EventHandlingComponent import org.axonframework.messaging.eventhandling.EventMessage import org.axonframework.messaging.eventhandling.processing.streaming.segmenting.Segment -import org.axonframework.messaging.eventhandling.replay.ResetContext import java.time.Instant import java.time.temporal.ChronoUnit class AxoniqPlatformEventHandlingComponent( - private val delegate: EventHandlingComponent, + val delegate: EventHandlingComponent, private val processorName: String, private val handlerMetricsRegistry: HandlerMetricsRegistry, private val processorMetricRegistry: ProcessorMetricsRegistry diff --git a/framework-client/src/main/java/io/axoniq/platform/framework/eventprocessor/DeadLetterManager.kt b/framework-client/src/main/java/io/axoniq/platform/framework/eventprocessor/DeadLetterManager.kt index f49911fb..3ead64ff 100644 --- a/framework-client/src/main/java/io/axoniq/platform/framework/eventprocessor/DeadLetterManager.kt +++ b/framework-client/src/main/java/io/axoniq/platform/framework/eventprocessor/DeadLetterManager.kt @@ -359,7 +359,12 @@ class DeadLetterManager @JvmOverloads constructor( return parsed.map { val ehcName = "EventHandlingComponent[${it.processor}][${it.component}]" val processor = it.module - .getOptionalComponent(SequencedDeadLetterProcessor::class.java, ehcName) + .getOptionalComponent(EventHandlingComponent::class.java, ehcName) + .map { ehc -> + if(ehc is AxoniqPlatformEventHandlingComponent) { + ehc.delegate as? SequencedDeadLetterProcessor<*> + } else null + } .orElseThrow { IllegalStateException( "Component [$ehcName] is not wrapped with dead-letter processing") From 9e7be5879b012eb39f62e94c90a4d4698f660c67 Mon Sep 17 00:00:00 2001 From: Stefan Mirkovic Date: Tue, 2 Jun 2026 12:10:08 +0200 Subject: [PATCH 10/10] test fix --- .../eventprocessor/DeadLetterManagerTest.kt | 54 +++++++++---------- .../RSocketDlqResponderIntegrationTest.kt | 22 ++++++++ 2 files changed, 49 insertions(+), 27 deletions(-) diff --git a/framework-client/src/test/kotlin/io/axoniq/platform/framework/eventprocessor/DeadLetterManagerTest.kt b/framework-client/src/test/kotlin/io/axoniq/platform/framework/eventprocessor/DeadLetterManagerTest.kt index 808e6d48..434b3b6d 100644 --- a/framework-client/src/test/kotlin/io/axoniq/platform/framework/eventprocessor/DeadLetterManagerTest.kt +++ b/framework-client/src/test/kotlin/io/axoniq/platform/framework/eventprocessor/DeadLetterManagerTest.kt @@ -158,7 +158,6 @@ class DeadLetterManagerTest { @Test fun `policy is invoked once per sequence (using the first letter) and the id is stamped across the sequence`() { val ehc = ehcWithPolicy { event -> event.identifier() } - // The sequence contains m1 + m2 + m3; only the first letter's id is used. val sequence = listOf(fakeLetter("m1"), fakeLetter("m2"), fakeLetter("m3")) val manager = managerWith( "DeadLetterQueue[EventHandlingComponent[orders][OrderProjector]]" to fakeDlq(sequences = listOf(sequence)), @@ -173,8 +172,6 @@ class DeadLetterManagerTest { @Test fun `when no EventHandlingComponent is registered the manager falls back to the letter's message id`() { val sequence = listOf(fakeLetter("only-letter")) - // Note: no ehc parameter — the configurationWith helper registers a relaxed mock that returns - // Optional.empty() for EventHandlingComponent lookup, exercising the null-EHC fallback path. val manager = managerWith( "DeadLetterQueue[EventHandlingComponent[orders][OrderProjector]]" to fakeDlq(sequences = listOf(sequence)), ehc = null, @@ -409,9 +406,6 @@ class DeadLetterManagerTest { @Test fun `payload over 1024 UTF-8 bytes is truncated without splitting a multi-byte codepoint`() { - // "č" is U+010D, two bytes in UTF-8. Filling beyond 1024 bytes guarantees the cutoff - // lands inside a multi-byte sequence — a naive byte slice would yield a malformed - // codepoint there. The implementation must round down to the previous valid boundary. val char = "č" val payload = char.repeat(600) // 600 * 2 = 1200 bytes val sequence = listOf(fakeLetter("m1", payload = payload)) @@ -428,8 +422,6 @@ class DeadLetterManagerTest { @Test fun `messageType uses the qualified name carried on the message type (primary in AF5)`() { - // In AF5 `message.type().name()` is the primary identifier regardless of whether the - // payload class is the still-serialised `ByteArray` or the deserialised user type. val message = fakeEventMessage( id = "m1", payload = "still serialised".toByteArray(), @@ -448,7 +440,6 @@ class DeadLetterManagerTest { @Test fun `messageType falls back to payload class fqn when the message type lookup throws`() { - // Defensive fallback only — in well-formed AF5 messages this branch should never trip. val message = mockk(relaxed = true) every { message.identifier() } returns "m1" every { message.payload() } returns "hello" @@ -588,9 +579,6 @@ class DeadLetterManagerTest { @Test fun `MASKED lettersForSequence returns the paginated slice when looked up by the hashed id`() { - // Regression: the detail modal sends the hashed sequence id back; the manager must - // re-hash candidate ids while walking the DLQ so the lookup succeeds and the modal - // doesn't render an empty slice. val letters = (1..5).map { fakeLetter("m$it", payload = "payload-$it") } val dlq = fakeDlq(sequences = listOf(letters)) val ehc = ehcWithPolicy { _ -> "saga-abc" } @@ -605,7 +593,6 @@ class DeadLetterManagerTest { assertEquals(5L, response.totalCount) assertEquals(3, response.letters.size) - // Every letter in the response carries the hashed id verbatim — no double-hashing. response.letters.forEach { assertEquals(hashedId, it.sequenceIdentifier) } } @@ -643,9 +630,6 @@ class DeadLetterManagerTest { @Test fun `NONE mode never reaches toApiLetter — bypassing the short-circuit throws IllegalStateException`() { - // Defence in depth: deadLetters/lettersForSequence short-circuit before serialising - // letters in NONE mode. If a future refactor accidentally drops that guard, we want - // toApiLetter to fail loudly rather than silently leak placeholders. val manager = managerWith( "DeadLetterQueue[EventHandlingComponent[orders][OrderProjector]]" to fakeDlq(), dlqMode = AxoniqConsoleDlqMode.NONE, @@ -668,8 +652,9 @@ class DeadLetterManagerTest { /** * Builds a manager backed by a synthetic configuration that exposes the given DLQs and matching - * processors / event-handling components. Pass [ehc] = `null` (the default) to leave the EHC - * lookup empty — which exercises the manager's "no EHC available → message-id fallback" path. + * processors / event-handling components. Pass [ehc] = `null` (the default) to use a delegate + * whose `sequenceIdentifierFor` returns null — exercises the manager's policy-null → message-id + * fallback path. */ private fun managerWith( vararg dlqsByName: Pair>, @@ -692,13 +677,17 @@ class DeadLetterManagerTest { val match = Regex("""^DeadLetterQueue\[EventHandlingComponent\[([^]]+)]\[(.+)]]$""").find(name) if (match != null) { val ehcName = "EventHandlingComponent[${match.groupValues[1]}][${match.groupValues[2]}]" - val processor = mockk>(relaxed = true) - every { - module.getOptionalComponent(SequencedDeadLetterProcessor::class.java, ehcName) - } returns Optional.of(processor) + + val delegate = ehc ?: defaultDelegateMock() + val wrapper = AxoniqPlatformEventHandlingComponent( + delegate, + match.groupValues[1], + mockk(relaxed = true), + mockk(relaxed = true), + ) every { module.getOptionalComponent(EventHandlingComponent::class.java, ehcName) - } returns Optional.ofNullable(ehc) + } returns Optional.of(wrapper) } } val root = mockk(relaxed = true) @@ -706,11 +695,22 @@ class DeadLetterManagerTest { return root } + private fun defaultDelegateMock(): EventHandlingComponent { + val mock = mockk( + moreInterfaces = arrayOf(SequencedDeadLetterProcessor::class), + relaxed = true, + ) + @Suppress("UNCHECKED_CAST") + every { mock.sequenceIdentifierFor(any(), any()) } answers { null as Any } + return mock + } + private fun ehcWithPolicy(policy: (EventMessage) -> Any?): EventHandlingComponent { - val ehc = mockk(relaxed = true) - // `sequenceIdentifierFor` is `@NullMarked` non-null in source, but in real life policies do - // return `null` (and the manager's branch on that is what we want to exercise). MockK lets - // us answer with whatever object reference; the unchecked cast keeps the compiler happy. + val ehc = mockk( + moreInterfaces = arrayOf(SequencedDeadLetterProcessor::class), + relaxed = true, + ) + @Suppress("UNCHECKED_CAST") every { ehc.sequenceIdentifierFor(any(), any()) } answers { policy(firstArg()) as Any diff --git a/framework-client/src/test/kotlin/io/axoniq/platform/framework/eventprocessor/RSocketDlqResponderIntegrationTest.kt b/framework-client/src/test/kotlin/io/axoniq/platform/framework/eventprocessor/RSocketDlqResponderIntegrationTest.kt index cc8a146f..d24369eb 100644 --- a/framework-client/src/test/kotlin/io/axoniq/platform/framework/eventprocessor/RSocketDlqResponderIntegrationTest.kt +++ b/framework-client/src/test/kotlin/io/axoniq/platform/framework/eventprocessor/RSocketDlqResponderIntegrationTest.kt @@ -20,12 +20,16 @@ import io.axoniq.framework.messaging.deadletter.GenericDeadLetter import io.axoniq.framework.messaging.deadletter.SequencedDeadLetterQueue import io.axoniq.framework.messaging.eventhandling.deadletter.DeadLetterQueueConfiguration import io.axoniq.platform.framework.api.AxoniqConsoleDlqMode +import io.axoniq.platform.framework.messaging.HandlerMetricsRegistry +import io.mockk.mockk import org.axonframework.common.configuration.AxonConfiguration import org.axonframework.common.configuration.ComponentDefinition +import org.axonframework.common.configuration.DecoratorDefinition import org.axonframework.eventsourcing.configuration.EventSourcingConfigurer import org.axonframework.messaging.core.MessageType import org.axonframework.messaging.core.QualifiedName import org.axonframework.messaging.core.sequencing.SequencingPolicy +import org.axonframework.messaging.eventhandling.EventHandlingComponent import org.axonframework.messaging.eventhandling.EventMessage import org.axonframework.messaging.eventhandling.GenericEventMessage import org.axonframework.messaging.eventhandling.SimpleEventHandlingComponent @@ -93,6 +97,10 @@ class RSocketDlqResponderIntegrationTest { // FULL exposure so the assertions on payload + sequence id can run. The // production default is now NONE (see AxoniqPlatformConfiguration#dlqMode). .withBuilder { c -> DeadLetterManager(c, AxoniqConsoleDlqMode.FULL) }) + registry.registerComponent(ComponentDefinition.ofType(HandlerMetricsRegistry::class.java) + .withBuilder { mockk(relaxed = true) }) + registry.registerComponent(ComponentDefinition.ofType(ProcessorMetricsRegistry::class.java) + .withBuilder { ProcessorMetricsRegistry() }) } .messaging { messaging -> messaging.eventProcessing { eventProcessing -> @@ -111,6 +119,20 @@ class RSocketDlqResponderIntegrationTest { cfg.extend( DeadLetterQueueConfiguration::class.java, ) { DeadLetterQueueConfiguration().enabled() } + } + .componentRegistry { moduleRegistry -> + moduleRegistry.registerDecorator( + DecoratorDefinition.forType(EventHandlingComponent::class.java) + .with { cc, _, delegate -> + AxoniqPlatformEventHandlingComponent( + delegate, + PROCESSOR_NAME, + cc.getComponent(HandlerMetricsRegistry::class.java), + cc.getComponent(ProcessorMetricsRegistry::class.java), + ) + } + .order(0), + ) }, ) }