This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch CAMEL-26747-chaos-findings in repository https://gitbox.apache.org/repos/asf/camel.git
commit e318e7d10eb140611b8a04f9e6b1c5aa9c9b5753 Author: Claus Ibsen <[email protected]> AuthorDate: Sun Nov 8 19:57:01 2026 +0100 CAMEL-26747: camel-jbang - chaos: a lost expectation counts parked messages, file endpoints get connection faults, and a cut countdown Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: Claus Ibsen <[email protected]> --- .../org/apache/camel/catalog/docs/chaos.adoc | 3 ++ .../org/apache/camel/chaos/chaos-model.json | 46 +++++++++++++++++++++- components/camel-chaos/src/main/docs/chaos.adoc | 3 ++ .../apache/camel/component/chaos/ChaosAdvisor.java | 8 +++- .../apache/camel/component/chaos/ChaosManager.java | 14 +++++-- .../apache/camel/component/chaos/ChaosModel.java | 19 ++++++++- .../camel/component/chaos/ChaosAdvisorTest.java | 6 +++ .../chaos/ChaosExpectTimeAndLossTest.java | 21 ++++++++++ .../dsl/jbang/core/commands/tui/ChaosTab.java | 22 +++++++---- .../core/commands/tui/ChaosTabRenderTest.java | 8 ++++ 10 files changed, 137 insertions(+), 13 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 98b689598416..8bc826d1fe7c 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 @@ -851,6 +851,9 @@ delay rule slows the route as it should). difference is the messages lost (never below 0, as the second route may also get messages from elsewhere). Messages still on their way when the time is up count as lost too: let `within=` run past the `until=` of the faults, so the last ones can arrive. The messages it counts as lost are added to the lost of the report. The op is `<=` or `=`. +* An exchange of the first route that its error handler handled, such as an order the dead letter channel parked, is not +counted in the first route: it is not meant to reach the second one, so it is not lost. In the second route a handled +exchange counts as completed, as it arrived. For example, a stock service that is slow for a minute, and the orders that must still ship, fast enough: 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 e99ffa8d907f..6e2ac07ee4ce 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 @@ -638,7 +638,8 @@ "A circuit breaker that does not open" ], "avoid": [ - "To test an HTTP status from a service that is up: use Returns an error" + "To test an HTTP status from a service that is up: use Returns an error", + "A local file has no connection: use Fails to write" ], "watch": [ "The redeliveries and the exchanges handled by the error handler", @@ -671,6 +672,49 @@ } ] }, + { + "id": "write-error", + "group": "Downstream", + "title": "Fails to write", + "description": "The send fails with an IOException before the file is written, as when the disk is full or the directory cannot be written", + "uncovers": [ + "Whether a failed write is retried, and whether the retry writes the file whole", + "Where the message goes when the file cannot be written: when the file is how the dead letter channel parks the message, it is lost" + ], + "avoid": [ + "A remote system such as FTP or a database: use Refuses the connection" + ], + "watch": [ + "The redeliveries and the exchanges handled by the error handler", + "The files written, and the messages that are in none of them" + ], + "rule": "endpoint=${endpoint} error exception=java.io.IOException rate=${rate} until=${until}", + "placeholders": [ + { + "name": "endpoint", + "type": "endpoint", + "description": "The endpoint the route sends to", + "example": "kafka:orders", + "pick": { + "from": "producers" + } + }, + { + "name": "rate", + "type": "rate", + "description": "How often: a probability, every:N or burst:<time>\/<period>", + "default": "0.25", + "example": "0.25" + }, + { + "name": "until", + "type": "countOrDuration", + "description": "When the rule ends: a number of matches or a time", + "default": "10m", + "example": "10m" + } + ] + }, { "id": "slow", "group": "Downstream", diff --git a/components/camel-chaos/src/main/docs/chaos.adoc b/components/camel-chaos/src/main/docs/chaos.adoc index 98b689598416..8bc826d1fe7c 100644 --- a/components/camel-chaos/src/main/docs/chaos.adoc +++ b/components/camel-chaos/src/main/docs/chaos.adoc @@ -851,6 +851,9 @@ delay rule slows the route as it should). difference is the messages lost (never below 0, as the second route may also get messages from elsewhere). Messages still on their way when the time is up count as lost too: let `within=` run past the `until=` of the faults, so the last ones can arrive. The messages it counts as lost are added to the lost of the report. The op is `<=` or `=`. +* An exchange of the first route that its error handler handled, such as an order the dead letter channel parked, is not +counted in the first route: it is not meant to reach the second one, so it is not lost. In the second route a handled +exchange counts as completed, as it arrived. For example, a stock service that is slow for a minute, and the orders that must still ship, fast enough: 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 28f8ab5c1a79..92e99753bb9d 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 @@ -57,6 +57,8 @@ final class ChaosAdvisor { "spring-rabbitmq", "rabbitmq", "nats", "paho", "paho-mqtt5", "azure-servicebus", "ibm-mq"); // a hand-off to another route of the same application, which does not wait for it private static final Set<String> HAND_OFF = Set.of("seda", "vm", "disruptor", "disruptor-vm"); + // the endpoints that write to the local file system: remote to Camel, but with no connection to refuse + private static final Set<String> LOCAL_FILES = Set.of("file"); /** The kinds of faults, which the report also uses for what has been tried. */ enum Kind { @@ -294,7 +296,11 @@ final class ChaosAdvisor { if (!to.isRemote()) { continue; } - if (ChaosRuleParser.HTTP_SCHEMES.contains(scheme)) { + if (LOCAL_FILES.contains(scheme)) { + // a local file system: nothing connects, but the write can fail + add(answer, recipes, "write-error", id, Kind.ERRORS, Map.of("endpoint", uri), + "a write to " + uri + ": what does the route do when the file cannot be written?", 60); + } else if (ChaosRuleParser.HTTP_SCHEMES.contains(scheme)) { if (breaker) { add(answer, recipes, "outage", id, Kind.ERRORS, Map.of("endpoint", uri), "a call to " + uri + " in a route with a circuit breaker: when the service is down for a" 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 970b2b4621e4..2ae52bfad6e9 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 @@ -738,7 +738,7 @@ public final class ChaosManager { state.counts = counts; if (outcome.completed() && !state.expectedCompleted) { state.expectedCompleted = true; - record(routeId, ChaosRule.Expectation.Kind.COMPLETED); + record(routeId, ChaosRule.Expectation.Kind.COMPLETED, outcome == ChaosExchangeState.Outcome.HANDLED); } if (outcome == ChaosExchangeState.Outcome.HANDLED && !state.expectedHandled) { state.expectedHandled = true; @@ -768,13 +768,21 @@ public final class ChaosManager { } private void record(String routeId, ChaosRule.Expectation.Kind kind) { + record(routeId, kind, false); + } + + private void record(String routeId, ChaosRule.Expectation.Kind kind, boolean handled) { for (ChaosRule.Expectation expectation : ruleSet.expectations) { if (expectation.kind == kind && expectation.route.equals(routeId)) { expectation.record(); } else if (kind == ChaosRule.Expectation.Kind.COMPLETED && expectation.kind == ChaosRule.Expectation.Kind.LOST) { - // the messages one route completed, and those the next route completed + // the messages one route completed, and those the next route completed; a message the error handler + // of the first route handled (such as one parked by its dead letter channel) is not meant to reach + // the next route, so it is not lost if (expectation.route.equals(routeId)) { - expectation.recordLost(false); + if (!handled) { + expectation.recordLost(false); + } } else if (routeId.equals(expectation.toRoute)) { expectation.recordLost(true); } 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 06785c4907ac..85e3497c57dc 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 @@ -419,6 +419,12 @@ final class ChaosModel { + "service is not running or a firewall blocks it", "endpoint=${endpoint} error exception=java.net.ConnectException rate=${rate} until=${until}", List.of(producer(), rate("0.25"), until("10m")))); + answer.add(new Recipe( + "write-error", "Downstream", "Fails to write", + "The send fails with an IOException before the file is written, as when the disk " + + "is full or the directory cannot be written", + "endpoint=${endpoint} error exception=java.io.IOException rate=${rate} until=${until}", + List.of(producer(), rate("0.25"), until("10m")))); answer.add(new Recipe( "slow", "Downstream", "Is slow", "Each send waits a time within the range before it is made, as a slow service. " @@ -588,10 +594,21 @@ final class ChaosModel { "Long redelivery delays that hold the consumer while the service is down", "A circuit breaker that does not open"), List.of( - "To test an HTTP status from a service that is up: use Returns an error"), + "To test an HTTP status from a service that is up: use Returns an error", + "A local file has no connection: use Fails to write"), List.of( "The redeliveries and the exchanges handled by the error handler", "The state of a circuit breaker around the call"))); + answer.put("write-error", new Guide( + List.of( + "Whether a failed write is retried, and whether the retry writes the file whole", + "Where the message goes when the file cannot be written: when the file is how the " + + "dead letter channel parks the message, it is lost"), + List.of( + "A remote system such as FTP or a database: use Refuses the connection"), + List.of( + "The redeliveries and the exchanges handled by the error handler", + "The files written, and the messages that are in none of them"))); answer.put("slow", new Guide( List.of( "A consumer that falls behind: the throughput of the route drops and messages " 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 9939da651eb3..934e49c1882d 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 @@ -57,6 +57,7 @@ class ChaosAdvisorTest extends ChaosTestSupport { .onFallback().setBody(constant("later")) .end(); from("testpoll:inbox?delay=100000&initialDelay=100000").routeId("inbox").to("mock:inbox"); + from("direct:archive").routeId("archive").to("file:target/chaos-archive"); } }; } @@ -89,6 +90,11 @@ class ChaosAdvisorTest extends ChaosTestSupport { assertThat(rules.values()).noneMatch(r -> r.contains("throwExceptionOnFailure")); // an internal input (direct) is not a consumer to fail assertThat(rules).doesNotContainKey("orders route-error consumer"); + // a local file has no connection to refuse, but the write can fail + assertThat(rules) + .containsEntry("archive write-error errors", + "endpoint=file://target/chaos-archive error exception=java.io.IOException rate=0.25 until=10m") + .doesNotContainKeys("archive connection-refused errors", "archive slow slow"); // the most worth trying first assertThat(suggestions.get(0).recipe()).isEqualTo("http-error"); 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 07bc14b73daf..dfbfc352df79 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 @@ -55,6 +55,11 @@ class ChaosExpectTimeAndLossTest extends ChaosTestSupport { .to("mock:stock").id("callStock") .to("direct:ship"); from("direct:ship").routeId("ship").to("mock:ship"); + // the orders it cannot charge are parked by its dead letter channel, and never go to ship + from("direct:charged").routeId("charged") + .errorHandler(deadLetterChannel("mock:parked")) + .to("mock:pay").id("callPay") + .to("direct:ship"); } }; } @@ -141,6 +146,22 @@ class ChaosExpectTimeAndLossTest extends ChaosTestSupport { .endsWith("; expects no message lost between route orders and route ship within 1 minute"); } + @Test + void theMessagesParkedByTheDeadLetterChannelAreNotLost() { + // every second payment fails, and the dead letter channel of route charged parks the order + String id = chaos().addRule("step=callPay error rate=every:2 expect=charged->ship:lost<=0 within=1m"); + for (int i = 0; i < 4; i++) { + template.sendBody("direct:charged", "order " + i); + } + clock.advance(60_001); + + assertThat(expectation(id).getString("outcome")).isEqualTo("met"); + ChaosReport report = chaos().report(); + assertThat(report.getVerdict()).isEqualTo(ChaosReport.PASS); + assertThat(report.getLost()).isZero(); + assertThat(report.toText()).contains("met, 0 lost (2 completed in charged, 2 in ship)"); + } + @Test void nothingLostWhenTheNextRouteCompletesThemAll() { String id = chaos().addRule("step=callStock delay duration=10ms expect=orders->ship:lost=0 within=1m"); diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosTab.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosTab.java index 9f9d93e9202f..9e9ff06c402c 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosTab.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosTab.java @@ -401,13 +401,13 @@ class ChaosTab extends AbstractTableTab { if (r.lifecycleState != null) { // the route is the target of the rule; between the rounds of repeat= the route is back if (r.lifecycleRound > 0 && r.lifecycleActsIn >= 0) { - return "next round in " + shortTime(r.lifecycleActsIn); + return "next round in " + countdown(r.lifecycleActsIn); } StringBuilder sb = new StringBuilder(r.lifecycleState); if (r.lifecycleActsIn >= 0) { - sb.append(", acts in ").append(shortTime(r.lifecycleActsIn)); + sb.append(", acts in ").append(countdown(r.lifecycleActsIn)); } else if (r.lifecycleBackIn >= 0) { - sb.append(", back in ").append(shortTime(r.lifecycleBackIn)); + sb.append(", back in ").append(countdown(r.lifecycleBackIn)); } if (r.lifecycleReason != null) { sb.append(": ").append(r.lifecycleReason); @@ -416,19 +416,19 @@ class ChaosTab extends AbstractTableTab { } List<String> parts = new ArrayList<>(); if (r.startsIn >= 0) { - parts.add("starts in " + shortTime(r.startsIn)); + parts.add("starts in " + countdown(r.startsIn)); } if (r.burstEndsIn >= 0) { - parts.add("burst, " + shortTime(r.burstEndsIn) + " left"); + parts.add("burst, " + countdown(r.burstEndsIn) + " left"); } else if (r.nextBurstIn >= 0) { - parts.add("next burst in " + shortTime(r.nextBurstIn)); + parts.add("next burst in " + countdown(r.nextBurstIn)); } if (r.nextFireIn > 0) { // a count of matches (rate=every:N), not a time parts.add("fires again in " + r.nextFireIn + (r.nextFireIn == 1 ? " match" : " matches")); } if (r.endsIn >= 0) { - parts.add("ends in " + shortTime(r.endsIn)); + parts.add("ends in " + countdown(r.endsIn)); } if (r.skipped > 0) { parts.add("skipped " + r.skipped); @@ -746,6 +746,14 @@ class ChaosTab extends AbstractTableTab { } /** A time for a column: 800ms, 2s, 1m4s, 2h3m. */ + /** + * The time until something happens, in whole seconds rounded up: {@code back in 1s}, not {@code back in 903ms}, + * which a narrow note column cuts to {@code 903m} (read as minutes). + */ + static String countdown(long ms) { + return ms <= 0 ? "0s" : shortTime((ms + 999) / 1000 * 1000); + } + static String shortTime(long ms) { if (ms < 1000) { return ms + "ms"; diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosTabRenderTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosTabRenderTest.java index 649c9747a514..be424cf4fe3f 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosTabRenderTest.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosTabRenderTest.java @@ -283,6 +283,14 @@ class ChaosTabRenderTest { assertThat(ChaosTab.shortTime(800)).isEqualTo("800ms"); assertThat(ChaosTab.shortTime(64_000)).isEqualTo("1m4s"); assertThat(ChaosTab.shortTime(120_000)).isEqualTo("2m"); + // a countdown in the note is in whole seconds: "903ms" cut to "903m" reads as minutes + assertThat(ChaosTab.countdown(903)).isEqualTo("1s"); + assertThat(ChaosTab.countdown(64_001)).isEqualTo("1m5s"); + assertThat(ChaosTab.countdown(0)).isEqualTo("0s"); + ChaosInfo.Rule stalled = new ChaosInfo.Rule(); + stalled.lifecycleState = "suspended"; + stalled.lifecycleBackIn = 903; + assertThat(ChaosTab.note(stalled)).isEqualTo("suspended, back in 1s"); ChaosInfo.Rule every = new ChaosInfo.Rule(); every.text = "endpoint=x error rate=every:5";
