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";

Reply via email to