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 51a10e1d2bad CAMEL-26820: camel-chaos - more to try: a circuit breaker
around a route call, the HTTP calls of toD, and replay of the files a file
route took (#29576)
51a10e1d2bad is described below
commit 51a10e1d2bad584276c0cc65d7e93a5b5b870d80
Author: Claus Ibsen <[email protected]>
AuthorDate: Wed Nov 11 07:07:25 2026 +0100
CAMEL-26820: camel-chaos - more to try: a circuit breaker around a route
call, the HTTP calls of toD, and replay of the files a file route took (#29576)
* CAMEL-26820: camel-chaos - suggestions for a circuit breaker around a
call to another route
* CAMEL-26820: camel-chaos - the files a file route took can be put back
(replay)
* CAMEL-26820: camel-chaos - replay leaves no hidden copy behind when a
move fails
* CAMEL-26820: camel-chaos - a replay of more files than the limit says how
many it left
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Claus Ibsen <[email protected]>
---
.../org/apache/camel/catalog/docs/chaos.adoc | 16 +-
components/camel-chaos/src/main/docs/chaos.adoc | 16 +-
.../apache/camel/component/chaos/ChaosAdvisor.java | 194 ++++++++++++++++++++-
.../apache/camel/component/chaos/ChaosManager.java | 107 +++++++++++-
.../camel/component/chaos/ChaosAdvisorTest.java | 48 +++++
.../camel/component/chaos/ChaosReplayTest.java | 180 +++++++++++++++++++
.../modules/ROOT/pages/camel-jbang-managing.adoc | 5 +
.../apache/camel/cli/connector/ChaosAction.java | 14 +-
.../LocalCliConnectorChaosActionTest.java | 15 ++
.../dsl/jbang/core/common/ChaosExperimentRun.java | 51 +++++-
.../jbang/core/common/ChaosExperimentRunTest.java | 24 +++
11 files changed, 658 insertions(+), 12 deletions(-)
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 8c62dc8519ab..09f72657a043 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
@@ -245,6 +245,13 @@ system: the CLI says so, and `real` is `true` in the JSON.
Run with `--stub=remo
(`timeout`)
|An HTTP call, in a route with a circuit breaker |The service is down for a
while (`outage`): does the breaker open, and
does the fallback answer?
+|An HTTP call that is known only when it sends (`toD`) |By its address up to
the first expression
+(`endpoint=http://stock/items/*` for `+http://stock/items/${variable.sku}+`):
the service answers 503 (or is down for a
+while, with a circuit breaker), and the resource is not there (`status=404`).
An address that starts with an
+expression says nothing of where it goes, and is left out
+|A call to another route (`direct`), in a route with a circuit breaker |That
route stands for the service: it is down
+for a while (`outage`), and half of the calls fail (`rate=0.5`), past the
failure rate of the breaker. Does the breaker
+open, and does the fallback answer?
|A call over another protocol |The connection is refused, and the call is slow
|An input that can deliver a message again (Kafka, JMS, AMQP, SQS and more, or
a consumer that reads a failed message
again, such as a file without `moveFailed`) |The call out is made twice
(`duplicate`): is it safe to repeat?
@@ -291,7 +298,14 @@ The rules leave the query of the endpoint out: its options
do not tell which end
`why`, `tried`, `routes` for a dead letter channel that several routes share,
`feed`, and `rules` for two faults at
once. A `feed` has its `kind` (one of `self`, `http`, `file` and `send`), a
`hint`, `real`, and `fed` when the route
gets messages without a feed: it makes its own (a timer), or a route that does
sends to it, also with an HTTP call to
-the server of the integration. Such a suggestion can run as an experiment
without sending anything. `rules` has all the
+the server of the integration. Such a suggestion can run as an experiment
without sending anything. A route that starts
+from `file:<folder>` and moved the files it took (to `.camel`, or the folder
of its `move` option) has `replay`, that
+folder, and `files`, how many there are: `ChaosManager.replayFiles(route)`
(the chaos command `replay`) puts them back
+into its folder under their own names, so the route takes the same files
again. The route moves them over the files
+there, so a replay is the same each time, as an experiment that is run again
needs. A replay puts back at most 1000 files, the
+first by name; the answer has `left`, how many it did not put back, and a WARN
is logged. A route with `noop=true` never takes
+a file of the same name again, and one with `delete=true` keeps none: they
have no `replay`. A route that such a file
+route sends to has them too, with `replayRoute`, the file route to replay for
it. `rules` has all the
rules to add together. The chaos screen of the Camel TUI lists the suggestions
(key `w`), where Enter types the rule
in to change or add (the rules of two faults at once separated by `;`).
diff --git a/components/camel-chaos/src/main/docs/chaos.adoc
b/components/camel-chaos/src/main/docs/chaos.adoc
index 8c62dc8519ab..09f72657a043 100644
--- a/components/camel-chaos/src/main/docs/chaos.adoc
+++ b/components/camel-chaos/src/main/docs/chaos.adoc
@@ -245,6 +245,13 @@ system: the CLI says so, and `real` is `true` in the JSON.
Run with `--stub=remo
(`timeout`)
|An HTTP call, in a route with a circuit breaker |The service is down for a
while (`outage`): does the breaker open, and
does the fallback answer?
+|An HTTP call that is known only when it sends (`toD`) |By its address up to
the first expression
+(`endpoint=http://stock/items/*` for `+http://stock/items/${variable.sku}+`):
the service answers 503 (or is down for a
+while, with a circuit breaker), and the resource is not there (`status=404`).
An address that starts with an
+expression says nothing of where it goes, and is left out
+|A call to another route (`direct`), in a route with a circuit breaker |That
route stands for the service: it is down
+for a while (`outage`), and half of the calls fail (`rate=0.5`), past the
failure rate of the breaker. Does the breaker
+open, and does the fallback answer?
|A call over another protocol |The connection is refused, and the call is slow
|An input that can deliver a message again (Kafka, JMS, AMQP, SQS and more, or
a consumer that reads a failed message
again, such as a file without `moveFailed`) |The call out is made twice
(`duplicate`): is it safe to repeat?
@@ -291,7 +298,14 @@ The rules leave the query of the endpoint out: its options
do not tell which end
`why`, `tried`, `routes` for a dead letter channel that several routes share,
`feed`, and `rules` for two faults at
once. A `feed` has its `kind` (one of `self`, `http`, `file` and `send`), a
`hint`, `real`, and `fed` when the route
gets messages without a feed: it makes its own (a timer), or a route that does
sends to it, also with an HTTP call to
-the server of the integration. Such a suggestion can run as an experiment
without sending anything. `rules` has all the
+the server of the integration. Such a suggestion can run as an experiment
without sending anything. A route that starts
+from `file:<folder>` and moved the files it took (to `.camel`, or the folder
of its `move` option) has `replay`, that
+folder, and `files`, how many there are: `ChaosManager.replayFiles(route)`
(the chaos command `replay`) puts them back
+into its folder under their own names, so the route takes the same files
again. The route moves them over the files
+there, so a replay is the same each time, as an experiment that is run again
needs. A replay puts back at most 1000 files, the
+first by name; the answer has `left`, how many it did not put back, and a WARN
is logged. A route with `noop=true` never takes
+a file of the same name again, and one with `delete=true` keeps none: they
have no `replay`. A route that such a file
+route sends to has them too, with `replayRoute`, the file route to replay for
it. `rules` has all the
rules to add together. The chaos screen of the Camel TUI lists the suggestions
(key `w`), where Enter types the rule
in to change or add (the rules of two faults at once separated by `;`).
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 074803331078..4f65002691ee 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
@@ -16,8 +16,12 @@
*/
package org.apache.camel.component.chaos;
+import java.io.File;
+import java.net.URLDecoder;
+import java.nio.charset.StandardCharsets;
import java.util.ArrayDeque;
import java.util.ArrayList;
+import java.util.Arrays;
import java.util.Comparator;
import java.util.Deque;
import java.util.HashMap;
@@ -36,6 +40,7 @@ import org.apache.camel.Route;
import org.apache.camel.model.CircuitBreakerDefinition;
import org.apache.camel.model.ProcessorDefinitionHelper;
import org.apache.camel.model.RouteDefinition;
+import org.apache.camel.model.ToDynamicDefinition;
import org.apache.camel.model.errorhandler.DeadLetterChannelDefinition;
import org.apache.camel.support.ScheduledPollConsumer;
import org.apache.camel.util.URISupport;
@@ -132,12 +137,19 @@ final class ChaosAdvisor {
/**
* How to feed a route with messages (CAMEL-26771), so a rule can be tried
on it.
*
- * @param kind {@code self} (the route feeds itself), {@code http}, {@code
file} or {@code send}
- * @param hint how, such as the command to run
- * @param real whether the messages go to the real system the route reads,
rather than a stub of it
- * ({@code camel run --stub=remote})
+ * @param kind {@code self} (the route feeds itself), {@code http},
{@code file} or {@code send}
+ * @param hint how, such as the command to run
+ * @param real whether the messages go to the real system the route
reads, rather than a stub of it
+ * ({@code camel run --stub=remote})
+ * @param replay the folder the route moved the files it took to, when it
holds some: the chaos command replay puts
+ * them back, so the route takes the same files again
(CAMEL-26817); null otherwise
+ * @param files how many files the replay puts back
*/
- record Feed(String kind, String hint, boolean real) {
+ record Feed(String kind, String hint, boolean real, String replay, int
files) {
+
+ Feed(String kind, String hint, boolean real) {
+ this(kind, hint, real, null, 0);
+ }
}
/**
@@ -287,6 +299,15 @@ final class ChaosAdvisor {
}
if (LOCAL_FILES.contains(scheme)) {
String folder =
ChaosPatterns.withoutSlashes(uri(from)).substring(scheme.length() + 1);
+ File done = doneFolder(from);
+ List<File> taken = done != null ? takenFiles(done) : List.of();
+ if (!taken.isEmpty()) {
+ return new Feed(
+ "file", "the " + taken.size() + (taken.size() == 1 ? "
file" : " files") + " the route took, in "
+ + done + ", put back into " + folder + ": an
experiment with --feed=" + route.getRouteId()
+ + " --body=replay:",
+ false, done.getAbsolutePath(), taken.size());
+ }
return new Feed("file", "write a file into " + folder, false);
}
// the route id as the endpoint: the send console sends to the input
of the route, with all its options
@@ -297,6 +318,61 @@ final class ChaosAdvisor {
from.isRemote());
}
+ /**
+ * The folder a file input moves the files it took to (CAMEL-26817):
{@code .camel} in its folder, or the folder of
+ * its {@code move} option. Null when it leaves them (noop, which also
never takes a file of the same name again),
+ * deletes them, or moves them to a name made by an expression other than
the file name.
+ */
+ static File doneFolder(Endpoint from) {
+ String uri = from.getEndpointUri();
+ if (!"file".equals(ChaosPatterns.scheme(uri))) {
+ return null;
+ }
+ Map<String, Object> options;
+ try {
+ options = URISupport.parseQuery(URISupport.extractQuery(uri));
+ } catch (Exception e) {
+ return null;
+ }
+ if ("true".equals(String.valueOf(options.get("noop"))) ||
"true".equals(String.valueOf(options.get("delete")))) {
+ return null;
+ }
+ String move = ".camel";
+ if (options.get("move") != null) {
+ try {
+ // as the endpoint URI has it, %24%7B for ${; a + stays a +
+ move =
URLDecoder.decode(options.get("move").toString().replace("+", "%2B"),
StandardCharsets.UTF_8);
+ } catch (IllegalArgumentException e) {
+ return null;
+ }
+ }
+ for (String name : List.of("${file:name}", "${file:onlyname}")) {
+ if (move.endsWith(name)) {
+ move = move.substring(0, move.length() - name.length());
+ }
+ }
+ if (move.isEmpty() || move.contains("${") ||
move.contains("$simple{")) {
+ return null;
+ }
+ File done = new File(move);
+ return done.isAbsolute() ? done : new File(inputFolder(from), move);
+ }
+
+ /** The folder a file input takes its files from, as its URI names it. */
+ static File inputFolder(Endpoint from) {
+ return new
File(ChaosPatterns.withoutSlashes(uri(from)).substring("file:".length()));
+ }
+
+ /** The files in the folder a file input moved them to, by name: not the
hidden ones, which it does not take. */
+ static List<File> takenFiles(File folder) {
+ File[] files = folder.listFiles(f -> f.isFile() &&
!f.getName().startsWith("."));
+ if (files == null) {
+ return List.of();
+ }
+ Arrays.sort(files, Comparator.comparing(File::getName));
+ return List.of(files);
+ }
+
/**
* 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 (http, netty-http, undertow, vertx-http) the
HTTP server route of its path, on its port
@@ -528,6 +604,20 @@ final class ChaosAdvisor {
85));
continue;
}
+ if (breaker && "direct".equals(scheme) &&
consumers.containsKey(uri) && !id.equals(consumers.get(uri))) {
+ // a call to another route of the integration in a route with
a circuit breaker (CAMEL-26817): that
+ // route stands for the service the breaker guards
+ String next = consumers.get(uri);
+ add(answer, recipes, "outage", id, Kind.ERRORS,
Map.of("endpoint", uri),
+ "route " + id + " calls route " + next + " in a
circuit breaker: when " + next
+
+ " is down for a while, does the breaker open, and does the fallback
answer?",
+ 70);
+ add(answer, recipes, "connection-refused", id, Kind.ERRORS,
Map.of("endpoint", uri, "rate", "0.5"),
+ "route " + id + " calls route " + next + " in a
circuit breaker: when half of the calls fail,"
+
+ " does the breaker open at its failure
rate, and close again?",
+ 62);
+ continue;
+ }
if (!ChaosStubs.isRemote(to)) {
continue;
}
@@ -578,6 +668,28 @@ final class ChaosAdvisor {
}
}
+ // the HTTP calls known only when they send (toD), by the address
before the first expression (CAMEL-26817)
+ for (String pattern : dynamicHttpCalls(route)) {
+ if (!seen.add(pattern)) {
+ continue;
+ }
+ if (breaker) {
+ add(answer, recipes, "outage", id, Kind.ERRORS,
Map.of("endpoint", pattern),
+ "the HTTP calls to " + pattern + " in a route with a
circuit breaker: when the service is down"
+
+ " for a while, does the breaker open, and does the fallback
answer?",
+ 70);
+ } else {
+ add(answer, recipes, "http-error", id, Kind.ERRORS,
Map.of("endpoint", pattern),
+ "the HTTP calls to " + pattern + " without a circuit
breaker: what does the route do when the"
+
+ " service answers 503?",
+ 88);
+ }
+ add(answer, recipes, "http-error", id, Kind.ERRORS,
Map.of("endpoint", pattern, "status", "404"),
+ "the HTTP calls to " + pattern + " ask for a resource per
message: what does the route do when it"
+
+ " is not there (404)?",
+ 72);
+ }
+
// the dead letter channel of the route: once for the routes that
share it, with the first of them
String dead = deadLetterUri(route);
List<String> shared = dead != null ? deadLetters.getOrDefault(dead,
List.of()) : List.of();
@@ -631,6 +743,56 @@ final class ChaosAdvisor {
}
}
+ /**
+ * The HTTP calls of the route that are known only when they send (toD),
each as a pattern of its address up to the
+ * first expression, such as {@code http://localhost:8080/stock/*} for
+ * {@code http://localhost:8080/stock/${variable.sku}}. A call whose
address starts with an expression says nothing
+ * of where it goes, and is left out.
+ */
+ static List<String> dynamicHttpCalls(Route route) {
+ if (!(route.getRoute() instanceof RouteDefinition definition)) {
+ return List.of();
+ }
+ List<String> uris = new ArrayList<>();
+ for (ToDynamicDefinition to :
ProcessorDefinitionHelper.filterTypeInOutputs(definition.getOutputs(),
+ ToDynamicDefinition.class)) {
+ if (to.getUri() != null) {
+ uris.add(to.getUri());
+ }
+ }
+ List<String> answer = new ArrayList<>();
+ for (String uri : uris) {
+ try {
+ uri = route.getCamelContext().resolvePropertyPlaceholders(uri);
+ } catch (Exception e) {
+ // a placeholder that does not resolve now: the address up to
it
+ }
+ int query = uri.indexOf('?');
+ String address = query > 0 ? uri.substring(0, query) : uri;
+ int cut = address.length();
+ for (String start : List.of("${", "$simple{", "{{")) {
+ int at = address.indexOf(start);
+ if (at >= 0) {
+ cut = Math.min(cut, at);
+ }
+ }
+ String scheme = ChaosPatterns.scheme(address);
+ if (cut == address.length() || scheme == null ||
!ChaosRuleParser.HTTP_SCHEMES.contains(scheme)) {
+ continue;
+ }
+ String prefix = address.substring(0, cut);
+ // the host must be known: a call to ${header.host} could go
anywhere
+ if (ChaosPatterns.withoutSlashes(prefix).length() <=
scheme.length() + 1) {
+ continue;
+ }
+ String pattern = prefix + "*";
+ if (!answer.contains(pattern)) {
+ answer.add(pattern);
+ }
+ }
+ return answer;
+ }
+
/**
* The URI of an endpoint as a rule targets it, without its query: the
options say nothing of which endpoint it is,
* and may hold a secret.
@@ -751,6 +913,28 @@ final class ChaosAdvisor {
return reached != null ? reached.keySet() : null;
}
+ /**
+ * Per route, the file route whose files, put back, reach it
(CAMEL-26817): a file route that took files reaches
+ * itself, and the routes it sends to, also over HTTP to the integration's
own server. A route reached through a
+ * send known only when it sends is left out.
+ */
+ static Map<String, String> replayVia(CamelContext camelContext) {
+ Map<String, String> answer = new HashMap<>();
+ for (Route route : camelContext.getRoutes()) {
+ Endpoint from = route.getEndpoint();
+ File done = from != null ? doneFolder(from) : null;
+ if (done == null || takenFiles(done).isEmpty()) {
+ continue;
+ }
+ answer.putIfAbsent(route.getRouteId(), route.getRouteId());
+ Map<String, Boolean> reached = reach(camelContext,
List.of(route.getRouteId()), true);
+ if (reached != null) {
+ reached.keySet().forEach(r -> answer.putIfAbsent(r,
route.getRouteId()));
+ }
+ }
+ return answer;
+ }
+
/**
* Why an experiment says nothing because its feed does not reach the
routes of its expectations (CAMEL-26803), or
* null when it reaches one, or that is not known. A route of an
expectation gets messages when the route the feed
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 e3d50ac64061..418cef661a39 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
@@ -16,6 +16,12 @@
*/
package org.apache.camel.component.chaos;
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.AtomicMoveNotSupportedException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Arrays;
@@ -79,6 +85,9 @@ public final class ChaosManager {
// the most removed, replaced or ended rules the report keeps
static final int MAX_HISTORY = 100;
+ // the most files a replay puts back at a time
+ static final int MAX_REPLAY = 1000;
+
private static final Logger LOG =
LoggerFactory.getLogger(ChaosManager.class);
private final CamelContext camelContext;
@@ -532,7 +541,9 @@ public final class ChaosManager {
* @return {@code suggestions}: a list of {@code route}, {@code kind},
{@code recipe}, {@code title}, {@code rule},
* {@code why}, {@code tried} when a rule of its kind faulted the
route already, and {@code routes} when it
* is for several routes (a dead letter channel they share), and
the {@code feed} of the route, with
- * {@code fed} when it gets messages without one
+ * {@code fed} when it gets messages without one, and {@code
replay} (a folder), {@code files} and
+ * {@code replayRoute} when the files the route took, or a file
route that sends to it took, can be put back
+ * ({@link #replayFiles(String)} of that route)
*/
public Map<String, Object> suggest() {
JsonArray list = new JsonArray();
@@ -549,6 +560,8 @@ public final class ChaosManager {
Map<String, ChaosAdvisor.Feed> feeds = new HashMap<>();
// the routes that get messages without a feed, so an experiment can
run without one (null when not known)
Set<String> fed = ChaosAdvisor.fedRoutes(camelContext);
+ // the routes that get messages when a file route takes the files it
took again
+ Map<String, String> replayVia = ChaosAdvisor.replayVia(camelContext);
for (ChaosAdvisor.Suggestion s : ChaosAdvisor.suggest(camelContext)) {
JsonObject jo = new JsonObject();
// a dead letter channel that routes share is tried by a rule of
its kind in any of them; two faults at once
@@ -584,6 +597,21 @@ public final class ChaosManager {
if ("self".equals(feed.kind()) || fed != null &&
fed.contains(s.route())) {
f.put("fed", true);
}
+ // the files the route took, or a file route that sends to it
took, which the chaos command replay puts
+ // back (CAMEL-26817)
+ String via = feed.replay() != null ? s.route() :
replayVia.get(s.route());
+ ChaosAdvisor.Feed files = via != null
+ ? feeds.computeIfAbsent(via, k ->
ChaosAdvisor.feed(camelContext.getRoute(k)))
+ : null;
+ if (files != null && files.replay() != null) {
+ if (!via.equals(s.route())) {
+ // the route gets its messages from the file route
+ f.put("hint", "route " + via + " sends to it: " +
files.hint());
+ }
+ f.put("replay", files.replay());
+ f.put("files", files.files());
+ f.put("replayRoute", via);
+ }
jo.put("feed", f);
}
list.add(jo);
@@ -593,6 +621,83 @@ public final class ChaosManager {
return answer;
}
+ /**
+ * Puts the files a file route took back into its folder, so it takes the
same files again (CAMEL-26817): the files
+ * it moved to {@code .camel} (or the folder of its {@code move} option),
under their own names. The route moves
+ * them there again, over the copies, so a replay is the same each time,
as an experiment that is run again needs.
+ * Each is written under a hidden name first, which the route does not
take, and then renamed. A file of that name
+ * that the route has not taken yet is left as it is.
+ *
+ * @param routeId the id of the route
+ * @return {@code route}, {@code folder} (where
the files were), {@code files} (how many
+ * were put back), {@code skipped} (those
still waiting in the folder) and
+ * {@code left} (those over the limit of
{@value #MAX_REPLAY}, not put back)
+ * @throws IllegalArgumentException when the route does not take files it
moves, or has taken none
+ */
+ public Map<String, Object> replayFiles(String routeId) {
+ Route route = routeId != null ? camelContext.getRoute(routeId) : null;
+ if (route == null) {
+ throw new IllegalArgumentException("There is no route " + routeId);
+ }
+ File done = route.getEndpoint() != null ?
ChaosAdvisor.doneFolder(route.getEndpoint()) : null;
+ if (done == null) {
+ throw new IllegalArgumentException(
+ "Route " + routeId + " does not take files it moves to a
folder (file: without noop or delete)");
+ }
+ List<File> taken = ChaosAdvisor.takenFiles(done);
+ if (taken.isEmpty()) {
+ throw new IllegalArgumentException("Route " + routeId + " has
taken no files yet: " + done + " is empty");
+ }
+ int left = 0;
+ if (taken.size() > MAX_REPLAY) {
+ // the first of them by name, the same each time; the rest are
left where they are, and said so
+ left = taken.size() - MAX_REPLAY;
+ LOG.warn("Route {} has taken {} files: only the first {} are put
back, {} are left in {}",
+ routeId, taken.size(), MAX_REPLAY, left, done);
+ taken = taken.subList(0, MAX_REPLAY);
+ }
+ File folder = ChaosAdvisor.inputFolder(route.getEndpoint());
+ int files = 0;
+ int skipped = 0;
+ for (File file : taken) {
+ Path target = folder.toPath().resolve(file.getName());
+ if (Files.exists(target)) {
+ skipped++;
+ continue;
+ }
+ Path hidden = folder.toPath().resolve(".chaos-" + file.getName());
+ try {
+ Files.copy(file.toPath(), hidden,
StandardCopyOption.REPLACE_EXISTING);
+ try {
+ Files.move(hidden, target, StandardCopyOption.ATOMIC_MOVE);
+ } catch (AtomicMoveNotSupportedException e) {
+ // the hidden file is in the same folder, but a file
system may still not move atomically
+ Files.move(hidden, target,
StandardCopyOption.REPLACE_EXISTING);
+ }
+ files++;
+ } catch (IOException e) {
+ // no hidden copy is left behind in the folder the route
consumes from
+ try {
+ Files.deleteIfExists(hidden);
+ } catch (IOException e2) {
+ e.addSuppressed(e2);
+ }
+ throw new IllegalStateException("Cannot put back " + file + ":
" + e.getMessage(), e);
+ }
+ }
+ Map<String, Object> answer = new JsonObject();
+ answer.put("route", routeId);
+ answer.put("folder", done.getAbsolutePath());
+ answer.put("files", files);
+ if (skipped > 0) {
+ answer.put("skipped", skipped);
+ }
+ if (left > 0) {
+ answer.put("left", left);
+ }
+ return answer;
+ }
+
/**
* Removes a rule. It stays in the report, and its expectation, if not
decided yet, is cancelled.
*
diff --git
a/components/camel-chaos/src/test/java/org/apache/camel/component/chaos/ChaosAdvisorTest.java
b/components/camel-chaos/src/test/java/org/apache/camel/component/chaos/ChaosAdvisorTest.java
index 4df6e1cf6776..e73ea5b9c8bf 100644
---
a/components/camel-chaos/src/test/java/org/apache/camel/component/chaos/ChaosAdvisorTest.java
+++
b/components/camel-chaos/src/test/java/org/apache/camel/component/chaos/ChaosAdvisorTest.java
@@ -21,6 +21,7 @@ import java.util.List;
import java.util.Map;
import org.apache.camel.BindToRegistry;
+import org.apache.camel.Exchange;
import org.apache.camel.builder.RouteBuilder;
import org.apache.camel.util.json.JsonArray;
import org.apache.camel.util.json.JsonObject;
@@ -58,6 +59,17 @@ class ChaosAdvisorTest extends ChaosTestSupport {
.end();
from("testpoll:inbox?delay=100000&initialDelay=100000").routeId("inbox").to("mock:inbox");
from("direct:archive").routeId("archive").to("file:target/chaos-archive");
+ // a circuit breaker around a call to another route, which
stands for the service
+ from("direct:check").routeId("check")
+ .circuitBreaker()
+ .to("direct:supplier")
+ .onFallback().setBody(constant("cached"))
+ .end();
+
from("direct:supplier").routeId("supplier").setBody(constant("in stock"));
+ // an HTTP call per message, known only when it sends
+ from("direct:lookup").routeId("lookup")
+
.toD("http://stock/items/${header.sku}?throwExceptionOnFailure=false")
+ .toD("${header.target}");
}
};
}
@@ -107,6 +119,42 @@ class ChaosAdvisorTest extends ChaosTestSupport {
assertThat(suggestions.get(0).why()).contains("without a circuit
breaker");
}
+ @Test
+ void aCircuitBreakerAroundACallToAnotherRoute() {
+ List<String> rules = ChaosAdvisor.suggest(context).stream().filter(s
-> s.route().equals("check"))
+ .map(ChaosAdvisor.Suggestion::rule).toList();
+ // down for a while, so the breaker opens, and half of the calls
failing, past its failure rate: the fallback
+ // answers, so the route does not fail
+ assertThat(rules).containsExactly(
+ "endpoint=direct://supplier error rate=burst:10s/1m until=10m
expect=check:failed<=0",
+ "endpoint=direct://supplier error
exception=java.net.ConnectException rate=0.5 until=10m"
+
+ " expect=check:failed<=0");
+ // a call to another route without a breaker is not a service to fail
+ assertThat(ChaosAdvisor.suggest(context)).noneMatch(s ->
s.rule().startsWith("endpoint=direct://check"));
+ }
+
+ @Test
+ void theHttpCallsOfToD() {
+ List<String> rules = ChaosAdvisor.suggest(context).stream().filter(s
-> s.route().equals("lookup"))
+ .map(ChaosAdvisor.Suggestion::rule).toList();
+ // by the address up to the first expression; a call to
${header.target} says nothing of where it goes
+ assertThat(rules).containsExactly(
+ "endpoint=http://stock/items/* error status=503 rate=0.25
until=10m expect=lookup:failed<=0",
+ "endpoint=http://stock/items/* error status=404 rate=0.25
until=10m expect=lookup:failed<=0");
+ }
+
+ @Test
+ void aRuleOfToDFailsTheCall() throws Exception {
+ chaos().addRule("endpoint=http://stock/items/* error status=404");
+ Exchange out = template.request("direct:lookup", e -> {
+ e.getMessage().setHeader("sku", "mug");
+ e.getMessage().setHeader("target", "mock:out");
+ });
+ // throwExceptionOnFailure=false: the response with the status, as the
HTTP component gives it
+ assertThat(out.getException()).isNull();
+
assertThat(out.getMessage().getHeader(Exchange.HTTP_RESPONSE_CODE)).isEqualTo(404);
+ }
+
@Test
void everySuggestedRuleIsValid() {
for (ChaosAdvisor.Suggestion s : ChaosAdvisor.suggest(context)) {
diff --git
a/components/camel-chaos/src/test/java/org/apache/camel/component/chaos/ChaosReplayTest.java
b/components/camel-chaos/src/test/java/org/apache/camel/component/chaos/ChaosReplayTest.java
new file mode 100644
index 000000000000..f3f78b805132
--- /dev/null
+++
b/components/camel-chaos/src/test/java/org/apache/camel/component/chaos/ChaosReplayTest.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.io.File;
+import java.nio.file.Files;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.apache.camel.util.FileUtil;
+import org.apache.camel.util.json.JsonArray;
+import org.apache.camel.util.json.JsonObject;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.awaitility.Awaitility.await;
+
+/**
+ * CAMEL-26817: the files a file route took, which it moved to .camel, are put
back, so the route takes the same files
+ * again and an experiment can be run again with the same messages.
+ */
+class ChaosReplayTest extends ChaosTestSupport {
+
+ private static final File BASE = new File("target/chaos-replay");
+
+ @BeforeEach
+ void files() throws Exception {
+ FileUtil.removeDir(BASE);
+ new File(BASE, "orders").mkdirs();
+ // written under a hidden name, which the routes do not take, and then
renamed
+ write("inbox/a.txt", "A");
+ write("inbox/b.txt", "B");
+ write("kept/c.txt", "C");
+ }
+
+ private static void write(String name, String body) throws Exception {
+ File file = new File(BASE, name);
+ file.getParentFile().mkdirs();
+ File hidden = new File(file.getParentFile(), "." + file.getName());
+ Files.writeString(hidden.toPath(), body);
+ Files.move(hidden.toPath(), file.toPath());
+ }
+
+ @Override
+ protected RouteBuilder createRouteBuilder() {
+ return new RouteBuilder() {
+ @Override
+ public void configure() {
+
from("file:target/chaos-replay/inbox?initialDelay=0&delay=50").routeId("inbox").to("mock:inbox")
+ .to("seda:ship");
+
from("seda:ship").routeId("ship").to("file:target/chaos-replay/archive");
+
from("file:target/chaos-replay/orders?move=done/${file:name}&initialDelay=0&delay=50").routeId("orders")
+ .to("mock:orders");
+
from("file:target/chaos-replay/kept?noop=true&initialDelay=0&delay=50").routeId("kept").to("mock:kept");
+ }
+ };
+ }
+
+ @Test
+ void theFilesTheRouteTookArePutBack() throws Exception {
+ MockEndpoint inbox = getMockEndpoint("mock:inbox");
+ inbox.expectedBodiesReceivedInAnyOrder("A", "B");
+ MockEndpoint.assertIsSatisfied(context);
+ File done = new File(BASE, "inbox/.camel");
+ await().atMost(10, TimeUnit.SECONDS).until(() ->
ChaosAdvisor.takenFiles(done).size() == 2);
+
+ // what to try says how: the files it took, put back
+ ChaosAdvisor.Feed feed = ChaosAdvisor.feed(context.getRoute("inbox"));
+ assertThat(feed.replay()).isEqualTo(done.getAbsolutePath());
+ assertThat(feed.files()).isEqualTo(2);
+ assertThat(feed.hint()).contains("the 2 files the route took")
+ .endsWith(": an experiment with --feed=inbox --body=replay:");
+
+ inbox.reset();
+ inbox.expectedBodiesReceivedInAnyOrder("A", "B");
+ Map<String, Object> replayed = chaos().replayFiles("inbox");
+ assertThat(replayed).containsEntry("route",
"inbox").containsEntry("files", 2);
+ MockEndpoint.assertIsSatisfied(context);
+
+ // moved over the files there: the same two, so a replay is the same
each time
+ await().atMost(10, TimeUnit.SECONDS).until(() -> {
+ File[] left = new File(BASE, "inbox").listFiles(File::isFile);
+ return left != null && left.length == 0;
+ });
+
assertThat(ChaosAdvisor.takenFiles(done)).extracting(File::getName).containsExactly("a.txt",
"b.txt");
+ }
+
+ @Test
+ void theFolderOfTheMoveOption() throws Exception {
+ MockEndpoint orders = getMockEndpoint("mock:orders");
+ orders.expectedMessageCount(0);
+
assertThat(ChaosAdvisor.feed(context.getRoute("orders")).replay()).isNull();
+ assertThatThrownBy(() ->
chaos().replayFiles("orders")).hasMessageContaining("has taken no files yet");
+
+ write("orders/1.json", "{}");
+ orders.reset();
+ orders.expectedMessageCount(1);
+ MockEndpoint.assertIsSatisfied(context);
+ File done = new File(BASE, "orders/done");
+ await().atMost(10, TimeUnit.SECONDS).until(() ->
ChaosAdvisor.takenFiles(done).size() == 1);
+
assertThat(ChaosAdvisor.feed(context.getRoute("orders")).replay()).isEqualTo(done.getAbsolutePath());
+
+ orders.reset();
+ orders.expectedMessageCount(1);
+ assertThat(chaos().replayFiles("orders")).containsEntry("files", 1);
+ MockEndpoint.assertIsSatisfied(context);
+ }
+
+ @Test
+ void theFilesOverTheLimitAreLeftAndSaidSo() throws Exception {
+ // stopped, so the files put back stay in its folder to be counted
+ context.getRouteController().stopRoute("orders");
+ File done = new File(BASE, "orders/done");
+ done.mkdirs();
+ int taken = ChaosManager.MAX_REPLAY + 2;
+ for (int i = 0; i < taken; i++) {
+ Files.writeString(new File(done, String.format("%05d.json",
i)).toPath(), "{}");
+ }
+
+ Map<String, Object> replayed = chaos().replayFiles("orders");
+ assertThat(replayed).containsEntry("files",
ChaosManager.MAX_REPLAY).containsEntry("left", 2);
+ // the first by name, the same each time
+ File[] back = new File(BASE, "orders").listFiles(File::isFile);
+ assertThat(back).hasSize(ChaosManager.MAX_REPLAY);
+ assertThat(new File(BASE, "orders/00000.json")).exists();
+ assertThat(new File(BASE, "orders/01001.json")).doesNotExist();
+ }
+
+ @Test
+ void aRouteThatLeavesItsFilesIsNotReplayed() throws Exception {
+ getMockEndpoint("mock:kept").expectedBodiesReceived("C");
+ MockEndpoint.assertIsSatisfied(context);
+ // noop: the route never takes a file of the same name again
+
assertThat(ChaosAdvisor.feed(context.getRoute("kept")).replay()).isNull();
+ assertThatThrownBy(() ->
chaos().replayFiles("kept")).hasMessageContaining("does not take files it
moves");
+ assertThatThrownBy(() ->
chaos().replayFiles("nope")).hasMessageContaining("There is no route nope");
+ }
+
+ @Test
+ void theSuggestionsSayHowToReplay() throws Exception {
+ getMockEndpoint("mock:inbox").expectedMessageCount(2);
+ MockEndpoint.assertIsSatisfied(context);
+ await().atMost(10, TimeUnit.SECONDS)
+ .until(() -> ChaosAdvisor.takenFiles(new File(BASE,
"inbox/.camel")).size() == 2);
+
+ List<Object> list = (JsonArray) chaos().suggest().get("suggestions");
+ JsonObject feed = list.stream().map(JsonObject.class::cast).filter(s
-> "inbox".equals(s.getString("route")))
+ .map(s -> (JsonObject)
s.get("feed")).findFirst().orElseThrow();
+ assertThat(feed.getString("replay")).endsWith(".camel");
+ assertThat(feed.getInteger("files")).isEqualTo(2);
+ assertThat(feed.getString("replayRoute")).isEqualTo("inbox");
+ JsonObject ship = list.stream().map(JsonObject.class::cast).filter(s
-> "ship".equals(s.getString("route")))
+ .map(s -> (JsonObject)
s.get("feed")).findFirst().orElseThrow();
+ // the route the file route sends to gets the files too
+ assertThat(ship.getString("hint")).startsWith("route inbox sends to
it: the 2 files the route took");
+ assertThat(ship.getString("replayRoute")).isEqualTo("inbox");
+ // a route the file route sends to gets the files too
+ assertThat(ChaosAdvisor.replayVia(context)).containsEntry("inbox",
"inbox").containsEntry("ship", "inbox")
+ .doesNotContainKey("kept");
+ }
+}
diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-managing.adoc
b/docs/user-manual/modules/ROOT/pages/camel-jbang-managing.adoc
index 0b120ab261b6..7e3f459c1f27 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-managing.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-managing.adoc
@@ -981,6 +981,11 @@ one on another host is not fed.
A route that starts from `file:<folder>` is fed with files: each message is
written as a file into the folder the route
polls, named so the route takes it: `chaos-<time>.json`, numbered as
`chaos-<time>-<n>.json` when the messages are
repeated, with the extension of its `include` or `antInclude` filter, else of
the body. A `CamelFileName` in `--header` names the files instead.
+With `--body=replay:` nothing is sent: the files the route took, which it
moved to `.camel` (or the folder of its `move`
+option), are put back into its folder under their own names, and again while
the experiment runs, as the route takes
+them, so a rule with a rate meets enough messages. The route moves them over
the ones there, so an experiment that is
+run again has the same messages. A route with `noop=true` never takes a file of
+the same name again, so it cannot be replayed.
A feed to a remote system that is not stubbed, such as a Kafka broker, has a
warning, as the messages go to that system:
use a local one (`camel infra run kafka`), or stub it. A broker that `camel
infra run` started on this machine is such a
diff --git
a/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/ChaosAction.java
b/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/ChaosAction.java
index eebe24a97ea2..4c91957d2a80 100644
---
a/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/ChaosAction.java
+++
b/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/ChaosAction.java
@@ -141,6 +141,16 @@ final class ChaosAction {
if (reset) {
answer.put("reset", true);
}
+ } else if ("replay".equals(command)) {
+ String route = root.getString("route");
+ if (isBlank(route)) {
+ output.error("The id of the route is required");
+ return;
+ }
+ // the files a file route took are put back, so it takes the
same files again (CAMEL-26817)
+ @SuppressWarnings("unchecked")
+ Map<String, Object> replayed = (Map<String, Object>)
call(type, chaos, "replayFiles", route);
+ answer.putAll(replayed);
} else if ("explore-start".equals(command)) {
// an exploration (CAMEL-26773): camel-chaos keeps its journal
and ends its rules at the deadline
@SuppressWarnings("unchecked")
@@ -174,8 +184,8 @@ final class ChaosAction {
answer.putAll(ended);
} else {
output.error("Unknown chaos command: " + command
- + " (add, check, change, remove, clear, load,
suggest, explore-start, explore-finish,"
- + " experiment-begin or experiment-end)");
+ + " (add, check, change, remove, clear, load,
suggest, replay, explore-start,"
+ + " explore-finish, experiment-begin or
experiment-end)");
return;
}
} catch (IllegalArgumentException | IllegalStateException e) {
diff --git
a/dsl/camel-cli-connector/src/test/java/org/apache/camel/cli/connector/LocalCliConnectorChaosActionTest.java
b/dsl/camel-cli-connector/src/test/java/org/apache/camel/cli/connector/LocalCliConnectorChaosActionTest.java
index 1f41b807f294..4d5c6896e8ac 100644
---
a/dsl/camel-cli-connector/src/test/java/org/apache/camel/cli/connector/LocalCliConnectorChaosActionTest.java
+++
b/dsl/camel-cli-connector/src/test/java/org/apache/camel/cli/connector/LocalCliConnectorChaosActionTest.java
@@ -223,6 +223,21 @@ class LocalCliConnectorChaosActionTest {
assertThat(reportRules()).isEmpty();
}
+ @Test
+ void replayPutsBackTheFilesARouteTook() throws Exception {
+ start(null, Map.of("camel.chaos.enabled", "true"));
+
+ // the route is required, and it must be a file route that moves the
files it took
+ assertThat(chaos("replay", null, null).error()).isEqualTo("The id of
the route is required");
+ JsonObject action = new JsonObject();
+ action.put("action", "chaos");
+ action.put("command", "replay");
+ action.put("route", "start");
+ CliActionOutcome replayed = CliActionOutcome.dispatch(connector,
(JsonObject) Jsoner.deserialize(action.toJson()))
+ .get(10, TimeUnit.SECONDS);
+ assertThat(replayed.error()).startsWith("Route start does not take
files it moves to a folder");
+ }
+
@Test
void anExplorationStartsAndFinishes() throws Exception {
start(null, Map.of("camel.chaos.enabled", "true"));
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/ChaosExperimentRun.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/ChaosExperimentRun.java
index ff4c2c91b557..6b7be582cf7a 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/ChaosExperimentRun.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/ChaosExperimentRun.java
@@ -107,14 +107,31 @@ public final class ChaosExperimentRun {
* The traffic of an experiment, as camel cmd send sends it.
*
* @param endpoint the endpoint or the id of the route to send to
- * @param body the body, file:<path>, or file:<folder> for
a message per file
+ * @param body the body, file:<path>, file:<folder> for a
message per file, or {@link #REPLAY} for
+ * the files a file route took, put back into its folder
(the endpoint is then the route)
* @param headers the headers as key=value lines, or null
* @param repeat how many messages in all; 0 to send once (each file of
a folder once)
* @param interval the pause between two messages, such as 200ms or a
range such as 100..500, or null
*/
public record Feed(String endpoint, String body, String headers, int
repeat, String interval) {
+
+ /**
+ * The files the file route took, which it moved to .camel, put back
into its folder under their own names, so
+ * the route takes the same files each time the experiment is run
(CAMEL-26817). They are put back again while
+ * the experiment runs, as the route takes them, so a rule with a rate
meets enough messages.
+ */
+ public static Feed replay(String route) {
+ return new Feed(route, REPLAY, null, 0, null);
+ }
+
+ boolean isReplay() {
+ return REPLAY.equals(body);
+ }
}
+ /** The body of a feed that puts back the files a file route took ({@link
Feed#replay(String)}). */
+ public static final String REPLAY = "replay:";
+
/** The time, and a pause; the tests give a clock that does not wait. */
interface Clock {
long now();
@@ -189,7 +206,9 @@ public final class ChaosExperimentRun {
http = ChaosHttpFeed.target(status, route, feed.endpoint());
// a file route reads the files its filter takes: the messages are
written as such files
String from = route != null ? route.getString("from") : null;
- String name = from != null && from.startsWith("file:") ?
fileName(from, feed, clock.now()) : null;
+ // the files of a replay keep their own names
+ String name = from != null && from.startsWith("file:") &&
!feed.isReplay()
+ ? fileName(from, feed, clock.now()) : null;
if (name != null) {
feed = new Feed(
feed.endpoint(), feed.body(),
@@ -233,6 +252,8 @@ public final class ChaosExperimentRun {
}
}
JsonObject traffic = null;
+ // the file route whose files are put back, again and again while the
experiment runs
+ String replayRoute = null;
long waited = 0;
ChaosHttpFeed.Sender sender = null;
Thread sending = null;
@@ -241,6 +262,9 @@ public final class ChaosExperimentRun {
// sent while the experiment waits for its verdict, and
stopped when it ends
sender = new ChaosHttpFeed.Sender(integration, http, feed,
clock);
sending =
Thread.ofVirtual().name("chaos-http-feed").start(sender);
+ } else if (feed != null && feed.isReplay()) {
+ replayRoute = fedRoute != null ? fedRoute.getString("routeId")
: feed.endpoint();
+ traffic = replay(integration, replayRoute);
} else if (feed != null) {
traffic = traffic(integration, feed);
}
@@ -255,6 +279,14 @@ public final class ChaosExperimentRun {
break;
}
clock.sleep(POLL);
+ if (replayRoute != null &&
"success".equals(traffic.getString("status"))) {
+ // a few files make a few messages, which a rule with a
rate may miss: they are put back again while
+ // the experiment runs, those the route took since (a file
still waiting is left as it is)
+ JsonObject again = replay(integration, replayRoute);
+ if ("success".equals(again.getString("status"))) {
+ traffic.put("files",
traffic.getIntegerOrDefault("files", 0) + again.getIntegerOrDefault("files",
0));
+ }
+ }
}
waited = clock.now() - start;
} finally {
@@ -388,6 +420,21 @@ public final class ChaosExperimentRun {
return answer;
}
+ /** Puts back the files the route took: the summary of the replay, or why
it could not. */
+ private static JsonObject replay(Integration integration, String route) {
+ JsonObject answer;
+ try {
+ answer = new JsonObject(integration.chaos("replay", root ->
root.put("route", route)));
+ answer.put("status", "success");
+ } catch (RuntimeException e) {
+ answer = new JsonObject();
+ answer.put("status", "failed");
+ answer.put("error", e.getMessage());
+ }
+ answer.put("endpoint", route);
+ return answer;
+ }
+
/** Ends the experiment: what came of it, also when the exploration ended
it at its deadline meanwhile. */
private static JsonObject end(Integration integration, int number, String
note, Clock clock)
throws InterruptedException {
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/ChaosExperimentRunTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/ChaosExperimentRunTest.java
index dc3ccf83eca9..eab6f8ea9935 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/ChaosExperimentRunTest.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/ChaosExperimentRunTest.java
@@ -410,6 +410,30 @@ class ChaosExperimentRunTest {
assertThat(integration.sent.headers()).isEqualTo("country=DK\nCamelFileName=chaos-1000000.json");
}
+ @Test
+ void aReplayPutsBackTheFilesTheRouteTookAfterTheRulesAreAdded() throws
Exception {
+ JsonObject status = statusWith(experiment("PENDING", true));
+ status.put("routes", new JsonArray(
+ List.of(new JsonObject(Map.of("routeId", "invoices", "from",
"file://invoices")))));
+ integration.statuses.add(status);
+ integration.shows("PENDING", true);
+ integration.shows("PASS", true);
+
+ JsonObject result = ChaosExperimentRun.run(integration, null, RULE,
ChaosExperimentRun.Feed.replay("invoices"),
+ 60_000, null, clock);
+
+ // nothing is sent: the files keep their names, so the route takes the
same files each time
+ assertThat(integration.sent).isNull();
+ // again while the experiment runs, so a rule with a rate meets enough
messages
+ assertThat(integration.actions).extracting(a -> a.getString("command"))
+ .containsSubsequence("experiment-begin", "replay", "replay",
"experiment-end");
+ assertThat(integration.actions).filteredOn(a ->
"replay".equals(a.getString("command")))
+ .extracting(a ->
a.getString("route")).containsOnly("invoices");
+ JsonObject traffic = (JsonObject) result.get("traffic");
+ assertThat(traffic.getString("status")).isEqualTo("success");
+ assertThat(traffic.getString("endpoint")).isEqualTo("invoices");
+ }
+
@Test
void theRouteTheFeedReachesGoesWithTheExperiment() throws Exception {
JsonObject status = statusWith(experiment("PENDING", true));