This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/main by this push:
new 5927c721d8a2 CAMEL-26791: camel-chaos - an expectation of the
duplicates a route processed (route:duplicates<=0, key=), for the duplicate
fault (#29558)
5927c721d8a2 is described below
commit 5927c721d8a2b92c2efa99bc159811e7b2eae1f6
Author: Claus Ibsen <[email protected]>
AuthorDate: Tue Nov 10 13:10:12 2026 +0100
CAMEL-26791: camel-chaos - an expectation of the duplicates a route
processed (route:duplicates<=0, key=), for the duplicate fault (#29558)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
.../org/apache/camel/catalog/chaos-model.json | 19 +++
.../org/apache/camel/catalog/docs/chaos.adoc | 28 ++++
.../org/apache/camel/chaos/chaos-model.json | 19 +++
components/camel-chaos/src/main/docs/chaos.adoc | 28 ++++
.../apache/camel/component/chaos/ChaosAdvisor.java | 39 +++++
.../camel/component/chaos/ChaosExchangeState.java | 3 +
.../apache/camel/component/chaos/ChaosManager.java | 29 ++++
.../apache/camel/component/chaos/ChaosModel.java | 10 ++
.../apache/camel/component/chaos/ChaosReport.java | 8 +-
.../component/chaos/ChaosRoutePolicyFactory.java | 18 ++-
.../apache/camel/component/chaos/ChaosRule.java | 86 +++++++++-
.../camel/component/chaos/ChaosRuleParser.java | 35 +++-
.../component/chaos/ChaosExpectDuplicatesTest.java | 180 +++++++++++++++++++++
.../chaos/ChaosExpectTimeAndLossTest.java | 3 +-
.../camel/component/chaos/ChaosModelTest.java | 4 +-
.../camel/component/chaos/ChaosRuleParserTest.java | 6 +-
.../core/commands/ai/ChaosExplorePlaybook.java | 4 +-
17 files changed, 504 insertions(+), 15 deletions(-)
diff --git
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/chaos-model.json
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/chaos-model.json
index 6e2ac07ee4ce..647d0ea14e77 100644
---
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/chaos-model.json
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/chaos-model.json
@@ -194,6 +194,7 @@
"expect",
"from",
"in",
+ "key",
"rate",
"until",
"when",
@@ -232,6 +233,7 @@
"expect",
"from",
"in",
+ "key",
"rate",
"until",
"when",
@@ -259,6 +261,7 @@
"expect",
"from",
"in",
+ "key",
"rate",
"until",
"when",
@@ -308,6 +311,7 @@
"expect",
"from",
"in",
+ "key",
"rate",
"until",
"when",
@@ -333,6 +337,7 @@
"expect",
"from",
"in",
+ "key",
"rate",
"until",
"when",
@@ -388,6 +393,7 @@
"options": [
"expect",
"from",
+ "key",
"within"
]
},
@@ -429,6 +435,7 @@
"options": [
"expect",
"from",
+ "key",
"within"
]
}
@@ -479,6 +486,14 @@
"pattern": true,
"pick": "routes"
},
+ {
+ "name": "key",
+ "type": "expression",
+ "required": false,
+ "description": "The key of a message for an expectation of duplicates
(<route>:duplicates<=0), in the simple language: two messages with the same key
are the same message; without it the body is the key",
+ "example": "${header.orderId}",
+ "language": "simple"
+ },
{
"name": "rate",
"type": "rate",
@@ -543,6 +558,10 @@
{
"name": "lost",
"description": "The messages completed in the route and not in the
next one, such as orders->shipping:lost<=0"
+ },
+ {
+ "name": "duplicates",
+ "description": "The messages the route completed a second time (the
same body, or the same key=) that no idempotent consumer recognised as a
duplicate, such as payment:duplicates<=0"
}
],
"ops": [
diff --git
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/chaos.adoc
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/chaos.adoc
index f833886500ab..8d37bfe685d2 100644
---
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/chaos.adoc
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/chaos.adoc
@@ -1008,6 +1008,8 @@ two routes checks that.
|`orders:maxTime<=5s` |No exchange of route `orders` took longer than 5 s. One
that does makes it unmet at once.
|`orders->shipping:lost<=0` (or `lost=0`) |Every message that completed in
route `orders` also completed in route
`shipping`: the count of `shipping` is at most 0 behind the count of `orders`
when the time is up.
+|`payment:duplicates<=0` (or `duplicates=0`) |Route `payment` did no message
twice: none completed in it a second time
+without an idempotent consumer recognising it as a duplicate. One more than
the count makes it unmet at once.
|===
* The time of an exchange in a route is measured by chaos, from when the
exchange enters the route until the route is
@@ -1041,6 +1043,32 @@ most 0 expected (rule r2): 1 lost`. In JSON these
expectations have their `spec`
the `value` in milliseconds for a time, the `toRoute` of a loss, and the
`samples` (the exchanges of the time, or the
ones completed in the first route).
+==== Duplicates
+
+A rule with the `duplicate` fault sends a message twice, as a sender or a
broker that delivers it again. Whether that
+did harm is a question for the route that receives it: did it do the work
twice, such as a payment taken twice? An
+expectation of duplicates answers it (CAMEL-26791):
+
+[source,text]
+----
+endpoint=http://localhost:8080/payment duplicate rate=0.1 until=1m
expect=payment:duplicates<=0 within=90s
+----
+
+* Chaos notes the key of each message as it enters the route: its body as
text, or the value of `key=` in the simple
+language when the body does not tell the messages apart
(`key=${header.orderId}`, also for a request without a body).
+A message whose key it cannot read (no body, an empty one, or a stream when
stream caching is off) is not counted.
+* A message that completes in the route with a key that completed there before
is a duplicate processed, unless an
+idempotent consumer of the route recognised it: it sets the exchange property
`CamelDuplicateMessage`, with or without
+`skipDuplicate`. A route that recognises duplicates in another way can set
that property too.
+* Each pass through the route counts: the second send of a duplicate to a
`direct` route is the same exchange again,
+and so is a redelivery that calls the route again after it completed, which
did the work twice as well.
+* The route must be a route of the integration, such as the `platform-http`
route of an HTTP call to the integration's
+own server, a `direct` or `seda` route, or the route of a stubbed input. What
another system does with a duplicate is
+not seen. The suggestions of `chaos-suggest` add this expectation to a
duplicate fault when a route of the integration
+receives the send.
+* The op is `<=` or `=`. The value is the duplicates processed, and its
`samples` the messages that completed with a
+key, such as `2 duplicates processed (41 completed in payment)`.
+
The route statistics of Camel have no percentiles, so there is no `p99` yet.
=== A rule in plain words
diff --git
a/components/camel-chaos/src/generated/resources/META-INF/org/apache/camel/chaos/chaos-model.json
b/components/camel-chaos/src/generated/resources/META-INF/org/apache/camel/chaos/chaos-model.json
index 6e2ac07ee4ce..647d0ea14e77 100644
---
a/components/camel-chaos/src/generated/resources/META-INF/org/apache/camel/chaos/chaos-model.json
+++
b/components/camel-chaos/src/generated/resources/META-INF/org/apache/camel/chaos/chaos-model.json
@@ -194,6 +194,7 @@
"expect",
"from",
"in",
+ "key",
"rate",
"until",
"when",
@@ -232,6 +233,7 @@
"expect",
"from",
"in",
+ "key",
"rate",
"until",
"when",
@@ -259,6 +261,7 @@
"expect",
"from",
"in",
+ "key",
"rate",
"until",
"when",
@@ -308,6 +311,7 @@
"expect",
"from",
"in",
+ "key",
"rate",
"until",
"when",
@@ -333,6 +337,7 @@
"expect",
"from",
"in",
+ "key",
"rate",
"until",
"when",
@@ -388,6 +393,7 @@
"options": [
"expect",
"from",
+ "key",
"within"
]
},
@@ -429,6 +435,7 @@
"options": [
"expect",
"from",
+ "key",
"within"
]
}
@@ -479,6 +486,14 @@
"pattern": true,
"pick": "routes"
},
+ {
+ "name": "key",
+ "type": "expression",
+ "required": false,
+ "description": "The key of a message for an expectation of duplicates
(<route>:duplicates<=0), in the simple language: two messages with the same key
are the same message; without it the body is the key",
+ "example": "${header.orderId}",
+ "language": "simple"
+ },
{
"name": "rate",
"type": "rate",
@@ -543,6 +558,10 @@
{
"name": "lost",
"description": "The messages completed in the route and not in the
next one, such as orders->shipping:lost<=0"
+ },
+ {
+ "name": "duplicates",
+ "description": "The messages the route completed a second time (the
same body, or the same key=) that no idempotent consumer recognised as a
duplicate, such as payment:duplicates<=0"
}
],
"ops": [
diff --git a/components/camel-chaos/src/main/docs/chaos.adoc
b/components/camel-chaos/src/main/docs/chaos.adoc
index f833886500ab..8d37bfe685d2 100644
--- a/components/camel-chaos/src/main/docs/chaos.adoc
+++ b/components/camel-chaos/src/main/docs/chaos.adoc
@@ -1008,6 +1008,8 @@ two routes checks that.
|`orders:maxTime<=5s` |No exchange of route `orders` took longer than 5 s. One
that does makes it unmet at once.
|`orders->shipping:lost<=0` (or `lost=0`) |Every message that completed in
route `orders` also completed in route
`shipping`: the count of `shipping` is at most 0 behind the count of `orders`
when the time is up.
+|`payment:duplicates<=0` (or `duplicates=0`) |Route `payment` did no message
twice: none completed in it a second time
+without an idempotent consumer recognising it as a duplicate. One more than
the count makes it unmet at once.
|===
* The time of an exchange in a route is measured by chaos, from when the
exchange enters the route until the route is
@@ -1041,6 +1043,32 @@ most 0 expected (rule r2): 1 lost`. In JSON these
expectations have their `spec`
the `value` in milliseconds for a time, the `toRoute` of a loss, and the
`samples` (the exchanges of the time, or the
ones completed in the first route).
+==== Duplicates
+
+A rule with the `duplicate` fault sends a message twice, as a sender or a
broker that delivers it again. Whether that
+did harm is a question for the route that receives it: did it do the work
twice, such as a payment taken twice? An
+expectation of duplicates answers it (CAMEL-26791):
+
+[source,text]
+----
+endpoint=http://localhost:8080/payment duplicate rate=0.1 until=1m
expect=payment:duplicates<=0 within=90s
+----
+
+* Chaos notes the key of each message as it enters the route: its body as
text, or the value of `key=` in the simple
+language when the body does not tell the messages apart
(`key=${header.orderId}`, also for a request without a body).
+A message whose key it cannot read (no body, an empty one, or a stream when
stream caching is off) is not counted.
+* A message that completes in the route with a key that completed there before
is a duplicate processed, unless an
+idempotent consumer of the route recognised it: it sets the exchange property
`CamelDuplicateMessage`, with or without
+`skipDuplicate`. A route that recognises duplicates in another way can set
that property too.
+* Each pass through the route counts: the second send of a duplicate to a
`direct` route is the same exchange again,
+and so is a redelivery that calls the route again after it completed, which
did the work twice as well.
+* The route must be a route of the integration, such as the `platform-http`
route of an HTTP call to the integration's
+own server, a `direct` or `seda` route, or the route of a stubbed input. What
another system does with a duplicate is
+not seen. The suggestions of `chaos-suggest` add this expectation to a
duplicate fault when a route of the integration
+receives the send.
+* The op is `<=` or `=`. The value is the duplicates processed, and its
`samples` the messages that completed with a
+key, such as `2 duplicates processed (41 completed in payment)`.
+
The route statistics of Camel have no percentiles, so there is no `p99` yet.
=== A rule in plain words
diff --git
a/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosAdvisor.java
b/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosAdvisor.java
index 84feba2a97ed..7ea06df426a9 100644
---
a/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosAdvisor.java
+++
b/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosAdvisor.java
@@ -297,6 +297,34 @@ final class ChaosAdvisor {
from.isRemote());
}
+ /**
+ * The route of the integration that receives a send: the route that
consumes the endpoint (direct, seda), or for an
+ * HTTP call to this machine the platform-http route of its path; null
when it is another system.
+ */
+ static String receiver(String uri, Map<String, String> consumers) {
+ String route = consumers.get(uri);
+ if (route != null) {
+ return route;
+ }
+ String scheme = ChaosPatterns.scheme(uri);
+ if (!"http".equals(scheme) && !"https".equals(scheme)) {
+ return null;
+ }
+ String rest = uri.substring(uri.indexOf("//") + 2);
+ int slash = rest.indexOf('/');
+ String host = (slash >= 0 ? rest.substring(0, slash) :
rest).split(":")[0];
+ if (!"localhost".equals(host) && !"127.0.0.1".equals(host) &&
!"0.0.0.0".equals(host)) {
+ return null;
+ }
+ String path = slash >= 0 ? rest.substring(slash) : "/";
+ for (Map.Entry<String, String> e : consumers.entrySet()) {
+ if (e.getKey().startsWith("platform-http:") &&
path.equals(httpPath(e.getKey()))) {
+ return e.getValue();
+ }
+ }
+ return null;
+ }
+
/** The method of a platform-http or rest input: the one it restricts to,
else POST. */
static String httpMethod(String uri) {
if (uri.startsWith("rest:")) {
@@ -451,10 +479,21 @@ final class ChaosAdvisor {
"a call to " + uri + ": what does a slow answer do to
the route and its input?", 50);
}
if (atLeastOnce) {
+ int before = answer.size();
add(answer, recipes, "duplicate", id, Kind.DUPLICATE,
Map.of("endpoint", uri),
"the input of route " + id + " can deliver a message
again: is the call to " + uri
+ " safe to make twice?",
80);
+ // a route of the integration receives the call: the
expectation tells whether it did the work twice
+ // (CAMEL-26791)
+ String receiver = receiver(uri, consumers);
+ if (receiver != null && answer.size() > before) {
+ Suggestion d = answer.remove(answer.size() - 1);
+ answer.add(new Suggestion(
+ d.route(), d.kind(), d.recipe(), d.title(),
d.rule() + " expect=" + receiver + ":duplicates<=0",
+ d.why() + " Route " + receiver + " receives it,
and the expectation counts what it does twice",
+ d.rank(), d.routes(), d.with()));
+ }
}
}
diff --git
a/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosExchangeState.java
b/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosExchangeState.java
index c1e1d4540d08..6c20ba3e5939 100644
---
a/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosExchangeState.java
+++
b/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosExchangeState.java
@@ -74,6 +74,9 @@ final class ChaosExchangeState implements SafeCopyProperty {
// they counted its time
long enteredAt = -1;
boolean expectedTime;
+ // the key of the message as it entered the route this time, per
expectation of duplicates (CAMEL-26791), else
+ // null
+ Map<ChaosRule.Expectation, String> passKeys;
}
private final Map<String, RouteState> routes = new HashMap<>(4);
diff --git
a/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosManager.java
b/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosManager.java
index db42b6c96976..f26bd9ab3fbe 100644
---
a/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosManager.java
+++
b/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosManager.java
@@ -38,6 +38,7 @@ import org.apache.camel.CamelContext;
import org.apache.camel.Consumer;
import org.apache.camel.Exchange;
import org.apache.camel.ExchangeExtension;
+import org.apache.camel.ExchangePropertyKey;
import org.apache.camel.ExtendedCamelContext;
import org.apache.camel.Route;
import org.apache.camel.component.chaos.ChaosRule.Hook;
@@ -266,6 +267,17 @@ public final class ChaosManager {
e);
}
}
+ if (b.key != null) {
+ try {
+ b.keyExpression =
camelContext.resolveLanguage("simple").createExpression(b.key);
+ b.keyExpression.init(camelContext);
+ } catch (RuntimeException e) {
+ throw new IllegalArgumentException(
+ "Invalid chaos rule [" + b.text + "]: key=" + b.key +
" is not a valid simple expression: "
+ + e.getMessage(),
+ e);
+ }
+ }
return b;
}
@@ -1021,6 +1033,15 @@ public final class ChaosManager {
}
ChaosExchangeState.Outcome outcome = outcome(route, exchange);
ChaosExchangeState.RouteState state =
ChaosExchangeState.of(exchange).route(routeId);
+ if (state.passKeys != null) {
+ // each pass through the route counts for the duplicates: the
second send of a duplicate fault to a direct
+ // route is the same exchange again (CAMEL-26791)
+ if (outcome == ChaosExchangeState.Outcome.COMPLETED) {
+ boolean recognised =
exchange.getProperty(ExchangePropertyKey.DUPLICATE_MESSAGE, false,
Boolean.class);
+ state.passKeys.forEach((expectation, key) ->
expectation.recordKey(key, recognised));
+ }
+ state.passKeys = null;
+ }
ChaosExchangeState.Outcome previous = state.counts == counts ?
state.outcome : ChaosExchangeState.Outcome.ENTERED;
if (previous == outcome) {
return;
@@ -1351,6 +1372,9 @@ public final class ChaosManager {
final ChaosRule[] expectationRules;
// the routes with an expectation on their time, whose route policy
notes when an exchange enters
final Set<String> timedRoutes;
+ // the rules with an expectation of duplicates, per route: the route
policy notes the key of each message as it
+ // enters (CAMEL-26791)
+ final Map<String, List<ChaosRule>> keyedRoutes;
RuleSet(List<ChaosRule> rules, long now) {
// also the rules that start later (from=): the hooks skip them
until then
@@ -1370,6 +1394,11 @@ public final class ChaosManager {
.toArray(ChaosRule.Expectation[]::new);
timedRoutes = Arrays.stream(expectations).filter(e ->
e.kind.time()).map(e -> e.route)
.collect(Collectors.toUnmodifiableSet());
+ Set<ChaosRule.Expectation> pending = Set.of(expectations);
+ keyedRoutes = Arrays.stream(expectationRules)
+ .filter(r -> r.expectation.kind ==
ChaosRule.Expectation.Kind.DUPLICATES
+ && pending.contains(r.expectation))
+ .collect(Collectors.groupingBy(r -> r.expectation.route));
}
}
diff --git
a/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosModel.java
b/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosModel.java
index 85e3497c57dc..6317e0737c25 100644
---
a/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosModel.java
+++
b/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosModel.java
@@ -343,6 +343,16 @@ final class ChaosModel {
+ "
(<route>:meanTime<=2s) or the messages lost"
+ "
between two routes (<route>-><route>:lost<=0)",
"orders:handled<=0");
+ case "key" -> {
+ JsonObject p = param("key", "expression", false,
+ "The key of a message for an expectation of
duplicates (<route>:duplicates<=0), in the"
+ + "
simple language: two messages with the same key"
+ + " are
the same message; without it the body is the"
+ + " key",
+ "${header.orderId}");
+ p.put("language", "simple");
+ yield p;
+ }
case "within" -> param("within", "duration", false,
"The time of the expectation (by default until the
rule ends)", "2m");
case "draw" -> param("draw", "ruleId", false,
diff --git
a/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosReport.java
b/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosReport.java
index d2641bf44ba4..b986a6e5b50e 100644
---
a/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosReport.java
+++
b/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosReport.java
@@ -577,13 +577,15 @@ public final class ChaosReport {
if (r.expectKind == ChaosRule.Expectation.Kind.COMPLETED) {
e.put("completed", r.value);
}
- // the expectation as the rule writes it, and for a time
(count and value in milliseconds) or the
- // messages lost between two routes, the exchanges it counted
(CAMEL-26726)
+ // the expectation as the rule writes it, and for a time
(count and value in milliseconds), the
+ // messages lost between two routes (CAMEL-26726) or the
duplicates processed (CAMEL-26791), the
+ // exchanges it counted
e.put("spec", r.expectSpec);
if (r.expectToRoute != null) {
e.put("toRoute", r.expectToRoute);
}
- if (r.expectKind.time() || r.expectToRoute != null) {
+ if (r.expectKind.time() || r.expectToRoute != null
+ || r.expectKind ==
ChaosRule.Expectation.Kind.DUPLICATES) {
e.put("samples", r.samples);
}
e.put("outcome", r.outcome.key);
diff --git
a/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosRoutePolicyFactory.java
b/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosRoutePolicyFactory.java
index b452c41c4858..b969427ced7b 100644
---
a/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosRoutePolicyFactory.java
+++
b/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosRoutePolicyFactory.java
@@ -16,6 +16,10 @@
*/
package org.apache.camel.component.chaos;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
import org.apache.camel.CamelContext;
import org.apache.camel.Exchange;
import org.apache.camel.NamedNode;
@@ -64,7 +68,9 @@ final class ChaosRoutePolicyFactory implements
RoutePolicyFactory {
ChaosRule[] rules = set.entry;
// an expectation on the time of the route needs when the exchange
entered it (CAMEL-26726)
boolean timed = set.timedRoutes.contains(route.getRouteId());
- if (rules.length == 0 && !timed) {
+ // an expectation of duplicates needs the key of the message as it
enters, each time (CAMEL-26791)
+ List<ChaosRule> keyed = set.keyedRoutes.get(route.getRouteId());
+ if (rules.length == 0 && !timed && keyed == null) {
return;
}
ChaosExchangeState exchangeState = ChaosExchangeState.of(exchange);
@@ -73,6 +79,16 @@ final class ChaosRoutePolicyFactory implements
RoutePolicyFactory {
if (timed && first) {
exchangeState.route(route.getRouteId()).enteredAt = now;
}
+ if (keyed != null) {
+ Map<ChaosRule.Expectation, String> keys = new HashMap<>(2);
+ for (ChaosRule rule : keyed) {
+ String key = rule.keyOf(exchange);
+ if (key != null) {
+ keys.put(rule.expectation, key);
+ }
+ }
+ exchangeState.route(route.getRouteId()).passKeys =
keys.isEmpty() ? null : keys;
+ }
if (rules.length == 0 || !first) {
return;
}
diff --git
a/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosRule.java
b/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosRule.java
index 14127dd59479..3c65e2831255 100644
---
a/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosRule.java
+++
b/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosRule.java
@@ -20,6 +20,7 @@ import java.io.InputStream;
import java.io.Reader;
import java.lang.reflect.Constructor;
import java.util.ArrayList;
+import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
@@ -30,6 +31,7 @@ import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.LongAdder;
import org.apache.camel.Exchange;
+import org.apache.camel.Expression;
import org.apache.camel.Predicate;
import org.apache.camel.StreamCache;
import org.apache.camel.support.MessageHelper;
@@ -169,6 +171,10 @@ final class ChaosRule {
// added, so it is null in a rule made without a context
final String when;
private final Predicate condition;
+ // the key of a message for an expectation of duplicates (key=, in the
simple language), else null: the body; the
+ // expression is made when the rule is added, so it is null in a rule made
without a context (CAMEL-26791)
+ final String key;
+ private final Expression keyExpression;
private final boolean conditionReadsBody;
// the endpoint= pattern without the // after its scheme, made once (the
hooks match it on every send and poll)
private final String uriPattern;
@@ -262,6 +268,8 @@ final class ChaosRule {
this.in = b.in;
this.when = b.when;
this.condition = b.condition;
+ this.key = b.key;
+ this.keyExpression = b.keyExpression;
this.conditionReadsBody = b.whenReadsBody;
this.uriPattern = target == Target.ENDPOINT ?
ChaosPatterns.withoutSlashes(pattern) : null;
this.hooks = Set.copyOf(b.hooks);
@@ -573,6 +581,9 @@ final class ChaosRule {
sb.append(e.op.words).append(' ').append(e.count).append('
').append(e.kind.words(e.count));
}
sb.append(" in route ").append(e.route);
+ if (key != null) {
+ sb.append(" (a message known by ").append(key).append(')');
+ }
}
if (within != plus(from, runFor)) {
// a within= of its own (the default is until the rule ends)
@@ -833,6 +844,43 @@ final class ChaosRule {
* not cached yet (such as the reply of a send) is cached first, and the
cache is reset after the check, so the
* route reads the body as before.
*/
+ /**
+ * The key of the message as it enters a route, for an expectation of
duplicates (CAMEL-26791): the value of key=,
+ * else the body as text. Null when there is none (no body, an empty one,
or a stream that cannot be read twice), as
+ * such a message is not counted.
+ */
+ String keyOf(Exchange exchange) {
+ try {
+ Object value;
+ if (keyExpression != null) {
+ if (key.contains("body")) {
+ cacheStreamBody(exchange);
+ }
+ value = keyExpression.evaluate(exchange, String.class);
+ } else {
+ cacheStreamBody(exchange);
+ Object body = exchange.getMessage().getBody();
+ if (body == null || (body instanceof InputStream || body
instanceof Reader) && !(body instanceof StreamCache)) {
+ return null;
+ }
+ value = exchange.getMessage().getBody(String.class);
+ if (body instanceof StreamCache cache) {
+ cache.reset();
+ }
+ }
+ String text = value != null ? value.toString() : null;
+ if (text == null || text.isEmpty()) {
+ return null;
+ }
+ // a long body is kept as its length and hash, not as itself
+ return text.length() <= 200 ? text : text.length() + ":" +
Integer.toHexString(text.hashCode());
+ } catch (Exception e) {
+ LOG.debug("The key of chaos rule {} ({}) failed on exchange {}:
{}", id, key != null ? "key=" + key : "the body",
+ exchange.getExchangeId(), e.getMessage(), e);
+ return null;
+ }
+ }
+
boolean matchesWhen(Exchange exchange) {
Predicate c = condition;
if (c == null) {
@@ -933,7 +981,12 @@ final class ChaosRule {
"The longest time of an exchange in the route, from
entering it until done, such as maxTime<=5s"),
// the messages one route completed that the next one did not:
<route>-><next route>:lost<=0
LOST("lost", "message lost", "messages lost",
- "The messages completed in the route and not in the next one,
such as orders->shipping:lost<=0");
+ "The messages completed in the route and not in the next one,
such as orders->shipping:lost<=0"),
+ // the messages a route completed again, with the key of one it
completed before, that no idempotent
+ // consumer recognised as a duplicate (CAMEL-26791)
+ DUPLICATES("duplicates", "duplicate processed", "duplicates
processed",
+ "The messages the route completed a second time (the
same body, or the same key=) that no"
+
+ " idempotent consumer recognised as a duplicate, such as
payment:duplicates<=0");
final String key;
// for the reason of an unmet expectation
@@ -973,6 +1026,9 @@ final class ChaosRule {
}
}
+ // the most keys a duplicates expectation keeps; the messages after
that are not counted as duplicates
+ static final int MAX_KEYS = 100_000;
+
final String route;
// the next route of a lost expectation, else null
final String toRoute;
@@ -987,6 +1043,8 @@ final class ChaosRule {
private long sum;
private long max;
private long toValue;
+ // duplicates: the keys of the messages the route completed (at most
MAX_KEYS)
+ private Set<String> keys;
// when it was decided before the time was up (met for at least, unmet
for at most and exactly), and how
private long decidedAt = -1;
private Outcome decided;
@@ -1030,6 +1088,28 @@ final class ChaosRule {
}
}
+ /**
+ * A message completed in the route of a duplicates expectation, with
its key: the second time a key completes
+ * it is a duplicate processed, unless an idempotent consumer
recognised it ({@code CamelDuplicateMessage}).
+ */
+ synchronized void recordKey(String key, boolean recognised) {
+ long now = TimeSource.currentTimeMillis();
+ if (frozenAt >= 0 || now > deadline) {
+ return;
+ }
+ samples++;
+ if (keys == null) {
+ keys = new HashSet<>();
+ }
+ if (keys.contains(key)) {
+ if (!recognised) {
+ recordAt(now);
+ }
+ } else if (keys.size() < MAX_KEYS) {
+ keys.add(key);
+ }
+ }
+
/** An exchange was done in the route, after it was so long in it (a
time kind). */
synchronized void recordTime(long millis) {
recordTimeAt(millis, TimeSource.currentTimeMillis());
@@ -1155,6 +1235,8 @@ final class ChaosRule {
+ reading.samples() + (reading.samples() == 1 ? "
exchange" : " exchanges");
case LOST -> reading.value() + " lost (" + reading.samples() +
" completed in " + route + ", "
+ reading.next() + " in " + toRoute + ")";
+ case DUPLICATES -> reading.value() + (reading.value() == 1 ? "
duplicate" : " duplicates")
+ + " processed (" + reading.samples() + "
completed in " + route + ")";
default -> reading.value() + " " + kind.key;
};
}
@@ -1232,6 +1314,8 @@ final class ChaosRule {
String when;
boolean whenReadsBody;
Predicate condition;
+ String key;
+ Expression keyExpression;
Hook at;
Set<Hook> hooks;
Fault fault;
diff --git
a/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosRuleParser.java
b/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosRuleParser.java
index 728cbad2c7ec..bfbead14a9f5 100644
---
a/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosRuleParser.java
+++
b/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosRuleParser.java
@@ -58,8 +58,8 @@ final class ChaosRuleParser {
// the options of a rule besides the parameters of its fault: all of them
for the faults at a hook, and those of a
// stall or lifecycle rule, which acts on its route at no hook
static final Set<String> GENERAL_KEYS
- = Set.of("at", "in", "when", "rate", "until", "from", "expect",
"within", "draw");
- static final Set<String> LIFECYCLE_KEYS = Set.of("from", "expect",
"within");
+ = Set.of("at", "in", "when", "rate", "until", "from", "expect",
"within", "key", "draw");
+ static final Set<String> LIFECYCLE_KEYS = Set.of("from", "expect",
"within", "key");
// the prefix of the response headers of error status=:
header.<name>=<value>
static final String HEADER_PREFIX = "header.";
private static final Set<String> ERROR_KEYS
@@ -241,6 +241,7 @@ final class ChaosRuleParser {
// stall and lifecycle act on the route: no hook, rate or until
lifecycle(b, params, maxDuration);
expectation(b, params.get("expect"), params.get("within"),
maxDuration);
+ key(b, params.get("key"));
return b;
}
@@ -321,6 +322,7 @@ final class ChaosRuleParser {
+ " fire");
}
expectation(b, params.get("expect"), params.get("within"),
maxDuration);
+ key(b, params.get("key"));
return b;
}
@@ -928,6 +930,28 @@ final class ChaosRuleParser {
}
}
+ /**
+ * The key of a message for an expectation of duplicates (key=, in the
simple language): two messages with the same
+ * key are the same message. Without it the body is the key (CAMEL-26791).
+ */
+ private static void key(ChaosRule.Builder b, String key) {
+ if (key == null) {
+ return;
+ }
+ if (b.expectKind != ChaosRule.Expectation.Kind.DUPLICATES) {
+ throw new IllegalArgumentException(
+ "key= is the key of a message for an expectation of
duplicates, such as"
+ + "
expect=payment:duplicates<=0 key=${header.orderId}");
+ }
+ if (languagePrefix(key) != null) {
+ throw new IllegalArgumentException(
+ "key= takes an expression in the simple language, without
a language in front ("
+ + languagePrefix(key) + ": is
not supported), such as"
+ + " key=${header.orderId}");
+ }
+ b.key = key;
+ }
+
private static void expectation(ChaosRule.Builder b, String expect, String
within, long maxDuration) {
if (expect == null) {
if (within != null) {
@@ -977,7 +1001,7 @@ final class ChaosRuleParser {
throw new IllegalArgumentException(
"expect=" + expect + " counts " + (kind.isEmpty() ?
"nothing" : kind)
+ ": the kinds are
completed, handled, failed, redeliveries, meanTime,"
- + " maxTime and lost, such
as expect=orders:handled<=0,"
+ + " maxTime, lost and
duplicates, such as expect=orders:handled<=0,"
+ "
expect=orders:meanTime<=2s or expect=orders->shipping:lost<=0");
}
String rest = spec.substring(op);
@@ -1015,6 +1039,11 @@ final class ChaosRuleParser {
withinOf(b, within, maxDuration);
return;
}
+ if (b.expectKind == ChaosRule.Expectation.Kind.DUPLICATES &&
b.expectOp == ChaosRule.Expectation.Op.AT_LEAST) {
+ throw new IllegalArgumentException(
+ "expect=" + expect + " needs <= or = for the duplicates
processed, such as expect=" + b.expectRoute
+ + ":duplicates<=0");
+ }
if (b.expectKind == ChaosRule.Expectation.Kind.LOST && b.expectOp ==
ChaosRule.Expectation.Op.AT_LEAST) {
throw new IllegalArgumentException(
"expect=" + expect + " needs <= or = for the messages
lost, such as expect=" + b.expectRoute + "->"
diff --git
a/components/camel-chaos/src/test/java/org/apache/camel/component/chaos/ChaosExpectDuplicatesTest.java
b/components/camel-chaos/src/test/java/org/apache/camel/component/chaos/ChaosExpectDuplicatesTest.java
new file mode 100644
index 000000000000..9e17bd7a0780
--- /dev/null
+++
b/components/camel-chaos/src/test/java/org/apache/camel/component/chaos/ChaosExpectDuplicatesTest.java
@@ -0,0 +1,180 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.component.chaos;
+
+import java.util.Map;
+
+import org.apache.camel.builder.RouteBuilder;
+import
org.apache.camel.support.processor.idempotent.MemoryIdempotentRepository;
+import org.apache.camel.test.AvailablePortFinder;
+import org.apache.camel.util.json.JsonObject;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.parallel.Isolated;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * CAMEL-26791: an expectation of the duplicates a route processed
(payment:duplicates<=0), for a duplicate fault: the
+ * messages the route completed a second time, with the same body or the same
key=, that no idempotent consumer
+ * recognised. The clock is virtual.
+ */
+@Isolated
+class ChaosExpectDuplicatesTest extends ChaosTestSupport {
+
+ // the HTTP server of the integration, which a call of the shop reaches
over HTTP
+ private static final int PORT = AvailablePortFinder.getNextAvailable();
+
+ private FakeClock clock;
+
+ @Override
+ protected void setupResources() throws Exception {
+ super.setupResources();
+ clock = FakeClock.install();
+ }
+
+ @Override
+ protected void cleanupResources() throws Exception {
+ clock.close();
+ super.cleanupResources();
+ }
+
+ @Override
+ protected RouteBuilder createRouteBuilder() {
+ return new RouteBuilder() {
+ @Override
+ public void configure() {
+ // the shop charges each order at the payment route, which
charges every message it gets
+ from("direct:orders").routeId("orders").to("direct:payment");
+ from("direct:payment").routeId("payment").to("mock:charged");
+ // the same, with an idempotent consumer in front of the charge
+ from("direct:refunds").routeId("refunds").to("direct:payback");
+ from("direct:payback").routeId("payback")
+ .idempotentConsumer(body(),
MemoryIdempotentRepository.memoryIdempotentRepository())
+ .to("mock:paidBack");
+ // a call over HTTP to a route of the same integration: each
call is an exchange of its own there, with
+ // the body as a stream
+
from("direct:checkout").routeId("checkout").to("netty-http:http://localhost:" +
PORT + "/charge");
+ from("netty-http:http://0.0.0.0:" + PORT +
"/charge").routeId("charge")
+ // read after the key was: an empty body would mean
the key consumed it
+ .convertBodyTo(String.class)
+ .to("mock:charge")
+ .setBody(constant("{\"charged\": true}"));
+ }
+ };
+ }
+
+ private JsonObject expectation(String id) {
+ return (JsonObject) ruleReport(id).get("expectation");
+ }
+
+ @Test
+ void theDuplicatesARouteProcessed() throws Exception {
+ // every second order is sent to payment twice, as a sender that
delivers it again
+ String id = chaos().addRule("endpoint=direct:payment duplicate
rate=every:2 expect=payment:duplicates<=0 within=1m");
+ for (int i = 0; i < 4; i++) {
+ template.sendBody("direct:orders", "order " + i);
+ }
+
+ // charged twice: decided at once, as it cannot become met
+ JsonObject e = expectation(id);
+ assertThat(e.getString("outcome")).isEqualTo("unmet");
+ assertThat(e.getLong("value")).isEqualTo(2);
+ assertThat(e.getLong("samples")).isEqualTo(6);
+
assertThat(getMockEndpoint("mock:charged").getReceivedCounter()).isEqualTo(6);
+ ChaosReport report = chaos().report();
+ assertThat(report.getVerdict()).isEqualTo(ChaosReport.FAIL);
+ assertThat(report.toText()).contains(
+ "expect payment:duplicates<=0 within 1m0s: unmet, 2 duplicates
processed (6 completed in payment)");
+ // a duplicate processed is not a message lost
+ assertThat(report.getLost()).isZero();
+ assertThat(chaos().describeRule(id)).endsWith("; expects no duplicates
processed in route payment within 1 minute");
+ }
+
+ @Test
+ void theDuplicatesOfAnHttpCallToARouteOfTheIntegration() {
+ String id = chaos().addRule("endpoint=netty-http:http://localhost:" +
PORT
+ + "/charge duplicate rate=every:2
expect=charge:duplicates<=0 within=1m");
+ for (int i = 0; i < 4; i++) {
+ template.sendBody("direct:checkout", "{\"order\": " + i + "}");
+ }
+
+ JsonObject e = expectation(id);
+ assertThat(e.getString("outcome")).isEqualTo("unmet");
+ assertThat(e.getLong("value")).isEqualTo(2);
+ // the key read the body, and the route still had it
+ assertThat(getMockEndpoint("mock:charge").getReceivedExchanges())
+ .extracting(x -> x.getMessage().getBody(String.class))
+ .containsExactly("{\"order\": 0}", "{\"order\": 1}",
"{\"order\": 1}", "{\"order\": 2}", "{\"order\": 3}",
+ "{\"order\": 3}");
+ }
+
+ @Test
+ void theDuplicatesAnIdempotentConsumerRecognisedAreNotCounted() {
+ String id = chaos().addRule("endpoint=direct:payback duplicate
rate=every:2 expect=payback:duplicates<=0 within=1m");
+ for (int i = 0; i < 4; i++) {
+ template.sendBody("direct:refunds", "refund " + i);
+ }
+ clock.advance(60_001);
+
+ JsonObject e = expectation(id);
+ assertThat(e.getString("outcome")).isEqualTo("met");
+ assertThat(e.getLong("value")).isZero();
+
assertThat(getMockEndpoint("mock:paidBack").getReceivedCounter()).isEqualTo(4);
+ assertThat(chaos().report().getVerdict()).isEqualTo(ChaosReport.PASS);
+ }
+
+ @Test
+ void theKeyOfAMessage() {
+ // the body does not tell the orders apart, the header does
+ String id = chaos().addRule(
+ "endpoint=direct:payment duplicate rate=every:2
expect=payment:duplicates<=0 within=1m key=${header.orderId}");
+ for (int i = 0; i < 4; i++) {
+ template.sendBodyAndHeader("direct:orders", "an order", "orderId",
"ORD-" + i);
+ }
+
+ JsonObject e = expectation(id);
+ assertThat(e.getString("outcome")).isEqualTo("unmet");
+ // with the body as the key, each order after the first would count
+ assertThat(e.getLong("value")).isEqualTo(2);
+ assertThat(chaos().describeRule(id)).contains("in route payment (a
message known by ${header.orderId})");
+ }
+
+ @Test
+ void theRouteThatReceivesADuplicateForTheSuggestion() {
+ Map<String, String> consumers = Map.of("direct://payment", "payment",
"platform-http:///refunds", "refunds");
+
+ // a suggestion of a duplicate fault adds the expectation of the route
that receives it
+ assertThat(ChaosAdvisor.receiver("direct://payment",
consumers)).isEqualTo("payment");
+ assertThat(ChaosAdvisor.receiver("http://localhost:8080/refunds",
consumers)).isEqualTo("refunds");
+ // another system, or a path no route serves
+
assertThat(ChaosAdvisor.receiver("http://payments.example.com/refunds",
consumers)).isNull();
+ assertThat(ChaosAdvisor.receiver("http://localhost:8080/stock",
consumers)).isNull();
+ assertThat(ChaosAdvisor.receiver("kafka://orders",
consumers)).isNull();
+ }
+
+ @Test
+ void aDuplicatesExpectationIsAtMostAndKeyNeedsIt() {
+ assertThatThrownBy(() -> chaos().addRule("endpoint=direct:payment
duplicate expect=payment:duplicates>=1"))
+ .hasMessageContaining("needs <= or = for the duplicates
processed, such as expect=payment:duplicates<=0");
+ assertThatThrownBy(() -> chaos().addRule("endpoint=direct:payment
duplicate expect=payment:failed<=0 key=${body}"))
+ .hasMessageContaining("key= is the key of a message for an
expectation of duplicates");
+ assertThatThrownBy(() -> chaos().addRule(
+ "endpoint=direct:payment duplicate
expect=payment:duplicates<=0 key=groovy:body.id"))
+ .hasMessageContaining("key= takes an expression in the simple
language");
+ }
+}
diff --git
a/components/camel-chaos/src/test/java/org/apache/camel/component/chaos/ChaosExpectTimeAndLossTest.java
b/components/camel-chaos/src/test/java/org/apache/camel/component/chaos/ChaosExpectTimeAndLossTest.java
index dfbfc352df79..2e42931bee3b 100644
---
a/components/camel-chaos/src/test/java/org/apache/camel/component/chaos/ChaosExpectTimeAndLossTest.java
+++
b/components/camel-chaos/src/test/java/org/apache/camel/component/chaos/ChaosExpectTimeAndLossTest.java
@@ -196,6 +196,7 @@ class ChaosExpectTimeAndLossTest extends ChaosTestSupport {
assertThatThrownBy(() -> chaos().addRule("step=callStock delay
duration=1ms expect=orders->orders:lost<=0"))
.hasMessageContaining("needs two routes");
assertThatThrownBy(() -> chaos().addRule("step=callStock delay
duration=1ms expect=orders:speed<=1"))
- .hasMessageContaining("the kinds are completed, handled,
failed, redeliveries, meanTime, maxTime and lost");
+ .hasMessageContaining(
+ "the kinds are completed, handled, failed,
redeliveries, meanTime, maxTime, lost and duplicates");
}
}
diff --git
a/components/camel-chaos/src/test/java/org/apache/camel/component/chaos/ChaosModelTest.java
b/components/camel-chaos/src/test/java/org/apache/camel/component/chaos/ChaosModelTest.java
index ed553b29af1c..28dae2733975 100644
---
a/components/camel-chaos/src/test/java/org/apache/camel/component/chaos/ChaosModelTest.java
+++
b/components/camel-chaos/src/test/java/org/apache/camel/component/chaos/ChaosModelTest.java
@@ -93,14 +93,14 @@ class ChaosModelTest {
assertThat(paramNames(byName("faults",
"lifecycle"))).containsExactly("action", "duration", "every", "repeat");
assertThat(paramNames(byName("faults", "error")))
.containsExactly("after", "body", "exception",
"header.<name>", "message", "status");
- assertThat(strings(byName("faults",
"lifecycle").get("options"))).containsExactly("expect", "from", "within");
+ assertThat(strings(byName("faults",
"lifecycle").get("options"))).containsExactly("expect", "from", "key",
"within");
assertThat(strings(byName("faults",
"error").get("hooks"))).containsExactly("send", "reply", "dlq", "poll", "entry",
"step");
// duplicate is a fault of the sends only
assertThat(strings(byName("faults",
"duplicate").get("hooks"))).containsExactly("send");
assertThat(strings(byName("faults",
"duplicate").get("targets"))).containsExactly("component", "endpoint");
assertThat(paramNames(byName("faults", "duplicate"))).isEmpty();
- assertThat(names("options")).containsExactly("at", "draw", "expect",
"from", "in", "rate", "until", "when",
+ assertThat(names("options")).containsExactly("at", "draw", "expect",
"from", "in", "key", "rate", "until", "when",
"within");
// the TUI form picks the route of in=
assertThat(byName("options",
"in").getString("pick")).isEqualTo("routes");
diff --git
a/components/camel-chaos/src/test/java/org/apache/camel/component/chaos/ChaosRuleParserTest.java
b/components/camel-chaos/src/test/java/org/apache/camel/component/chaos/ChaosRuleParserTest.java
index 375aec13f1fe..91c7311cf785 100644
---
a/components/camel-chaos/src/test/java/org/apache/camel/component/chaos/ChaosRuleParserTest.java
+++
b/components/camel-chaos/src/test/java/org/apache/camel/component/chaos/ChaosRuleParserTest.java
@@ -392,9 +392,9 @@ class ChaosRuleParserTest {
"route=orders stall duration=1m until=1m|until= does not apply to
the fault stall",
"route=orders stall duration=1m at=entry|at= does not apply to the
fault stall",
"route=orders stall duration=1m mode=headers|unknown parameter
mode for the fault stall (its parameters are"
-
+ " duration, every, repeat, expect, from, within)",
+
+ " duration, every, repeat, expect, from, key, within)",
"route=orders lifecycle action=stop duration=1m mode=x|unknown
parameter mode for the fault lifecycle (its"
-
+ "
parameters are action, duration, every, repeat, expect, from, within)",
+
+ "
parameters are action, duration, every, repeat, expect, from, key, within)",
"route=orders stall lifecycle duration=1m|a rule has one fault,
found stall and lifecycle",
"endpoint=kafka:x stall duration=1m|the fault stall acts on a
route, so it takes only a route=<id> target, not"
+ " endpoint=",
@@ -463,7 +463,7 @@ class ChaosRuleParserTest {
"endpoint=x error expect=orders:|is not <route>:<count>",
"endpoint=x error expect=orders:0|needs a count of 1 or more",
"endpoint=x error expect=orders:completed>=0|needs a count of 1 or
more",
- "endpoint=x error expect=orders:speed<=0|counts speed: the kinds
are completed, handled, failed, redeliveries, meanTime, maxTime and lost",
+ "endpoint=x error expect=orders:speed<=0|counts speed: the kinds
are completed, handled, failed, redeliveries, meanTime, maxTime, lost and
duplicates",
"endpoint=x error expect=orders:<=0|counts nothing",
"endpoint=x error expect=orders:handled<0|needs >=, <= or = after
handled",
"endpoint=x error expect=orders:handled<=x|needs a count that is a
whole number",
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ChaosExplorePlaybook.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ChaosExplorePlaybook.java
index d548793c77a8..e4f809384651 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ChaosExplorePlaybook.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ChaosExplorePlaybook.java
@@ -84,7 +84,9 @@ public final class ChaosExplorePlaybook {
wrong, not a guess such as failed<=5, which hides the messages
that were lost: expect=<route>:failed<=0 \
(nothing fails), expect=<route>-><next route>:lost<=0 (nothing
lost when a route hands messages to \
the next route, as the suggestions of kind lost name them), \
- expect=<route>:20 (20 completed), expect=<route>:maxTime<=5s
(none slower than 5s)
+ expect=<route>:20 (20 completed), expect=<route>:maxTime<=5s
(none slower than 5s), \
+ expect=<route>:duplicates<=0 for a duplicate fault (the route
that receives the message did not do \
+ the work twice; add key=${header.<id>} when the body does not
tell the messages apart)
- feed: a route that does not feed itself gets no messages
unless you give feed. When the feed \
of the suggestion is kind send, always give its endpoint, with
body (a message as the route \
expects it, such as JSON), repeat (such as 20) and interval
(such as 100..300); leave feed out \