This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch CAMEL-26726-chaos-expect-time-loss in repository https://gitbox.apache.org/repos/asf/camel.git
commit dd161c542b82e17abea1391d358edc1df94e2988 Author: Claus Ibsen <[email protected]> AuthorDate: Sat Nov 7 23:14:29 2026 +0100 CAMEL-26726: camel-chaos - expectations on the time of a route and on the messages lost between two routes - expect=orders:meanTime<=2s and expect=orders:maxTime<=5s: the time of the exchanges in the route, from entering it until the route is done with them, measured by chaos through TimeSource (virtual time in a simulation test). maxTime is unmet as soon as one exchange is slower, meanTime is decided when the time is up; a time of no exchange is not met. - expect=orders->shipping:lost<=0: the messages completed in the first route and not in the second, counted until the time is up; the missing ones count as lost in the report. - The report (reason, text, JSON spec/toRoute/samples), the rule in plain words, the chaos model, camel get chaos and the chaos screen say them; docs: Expectations on time and loss. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: Claus Ibsen <[email protected]> --- .../org/apache/camel/catalog/chaos-model.json | 14 +- .../org/apache/camel/catalog/docs/chaos.adoc | 49 +++++- .../org/apache/camel/chaos/chaos-model.json | 14 +- components/camel-chaos/src/main/docs/chaos.adoc | 49 +++++- .../camel/component/chaos/ChaosExchangeState.java | 4 + .../apache/camel/component/chaos/ChaosManager.java | 27 ++++ .../apache/camel/component/chaos/ChaosModel.java | 6 +- .../apache/camel/component/chaos/ChaosReport.java | 29 +++- .../component/chaos/ChaosRoutePolicyFactory.java | 15 +- .../apache/camel/component/chaos/ChaosRule.java | 155 +++++++++++++++--- .../camel/component/chaos/ChaosRuleParser.java | 48 +++++- .../chaos/ChaosExpectTimeAndLossTest.java | 180 +++++++++++++++++++++ .../camel/component/chaos/ChaosRuleParserTest.java | 2 +- design/chaos.adoc | 4 + .../dsl/jbang/core/commands/process/ListChaos.java | 22 ++- .../dsl/jbang/core/commands/tui/ChaosAiPrompt.java | 5 +- .../dsl/jbang/core/commands/tui/ChaosInfo.java | 3 + .../dsl/jbang/core/commands/tui/ChaosTab.java | 10 +- 18 files changed, 591 insertions(+), 45 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 214f24a833aa..1e7ad670ecd5 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 @@ -448,7 +448,7 @@ "name": "expect", "type": "expectation", "required": false, - "description": "An expectation of a route after the rule is added: <route>:<count> (at least that many completed) or <route>:<kind><op><count>", + "description": "An expectation of a route after the rule is added: <route>:<count> (at least that many completed), <route>:<kind><op><count>, a time (<route>:meanTime<=2s) or the messages lost between two routes (<route>-><route>:lost<=0)", "example": "orders:handled<=0" }, { @@ -519,6 +519,18 @@ { "name": "redeliveries", "description": "The redeliveries in the route" + }, + { + "name": "meanTime", + "description": "The mean time of the exchanges in the route, from entering it until done, such as meanTime<=2s" + }, + { + "name": "maxTime", + "description": "The longest time of an exchange in the route, from entering it until done, such as maxTime<=5s" + }, + { + "name": "lost", + "description": "The messages completed in the route and not in the next one, such as orders->shipping:lost<=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 ba00e70049de..67b3ebfdbabf 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 @@ -211,7 +211,10 @@ until: a number of matches (until=100) or a time (until=2m), counted from the time: a number with a unit (ms, s, m, h, d), also combined as 1m30s; a number alone is milliseconds expect: expect=<route id>:<count> (at least that many completed exchanges), or expect=<route id>:<kind><op><count> with kind completed | handled | failed | redeliveries - and op >= | <= | = (such as expect=orders:handled<=0); with an optional within=<time> + and op >= | <= | = (such as expect=orders:handled<=0), or a time + expect=<route id>:meanTime|maxTime<op><time> (such as expect=orders:meanTime<=2s), or the + messages lost between two routes expect=<route id>-><route id>:lost<op><count> (such as + expect=orders->shipping:lost<=0); with an optional within=<time> ---- The patterns of the targets are matched as text, with `*` for any characters; they are not regular expressions. The @@ -657,6 +660,50 @@ give their reason only. In JSON an expectation has its `kind`, `op`, `count` and the `value` counted so far (and `completed`, the same value, for an expectation of completed exchanges). +=== Expectations on time and loss + +The delay faults slow a route down, and the question is whether the route stays fast enough: an expectation on time +checks it. The question people ask about a lost message is often "did every order reach shipping", and an expectation on +two routes checks that. + +[cols="2,3"] +|=== +|Expectation |Is met when + +|`orders:meanTime<=2s` |The mean time of the exchanges in route `orders` is at most 2 s when the time is up. +|`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. +|=== + +* The time of an exchange in a route is measured by chaos, from when the exchange enters the route until the route is +done with it the first time (whether it completed, was handled by the error handler or failed), including the +redeliveries in between. It is read through `TimeSource`, so in a deterministic simulation test it is virtual time. A +time is any time of the rules, such as `500ms`, `2s` or `1m30s`; the op is `<=` or `>=` (a `meanTime>=1s` checks that a +delay rule slows the route as it should). +* A time of no exchange says nothing, so an expectation on time whose route did no exchange in the time is unmet. +* A loss counts the exchanges completed in both routes from when the rule is added until the time is up, and the +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 `=`. + +For example, a stock service that is slow for a minute, and the orders that must still ship, fast enough: + +[source,properties] +---- +camel.chaos.rules = \ + endpoint=http:stock* delay duration=1s..3s rate=0.3 until=1m expect=orders:meanTime<=2s within=2m;\ + endpoint=seda:shipping drop rate=0.01 until=1m expect=orders->shipping:lost<=0 within=2m +---- + +An unmet one gives its reason in the report, such as `route orders: mean 2s340ms of 118 exchanges within 2m0s, at most +2s expected (rule r1)` or `routes orders->shipping: 1 lost (118 completed in orders, 117 in shipping) within 2m0s, at +most 0 expected (rule r2): 1 lost`. In JSON these expectations have their `spec` as the rule writes it, the `count` and +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). + +The route statistics of Camel have no percentiles, so there is no `p99` yet. + === A rule in plain words Each rule of the list and the report has its `description`: what it does, to which share of what, with the 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 214f24a833aa..1e7ad670ecd5 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 @@ -448,7 +448,7 @@ "name": "expect", "type": "expectation", "required": false, - "description": "An expectation of a route after the rule is added: <route>:<count> (at least that many completed) or <route>:<kind><op><count>", + "description": "An expectation of a route after the rule is added: <route>:<count> (at least that many completed), <route>:<kind><op><count>, a time (<route>:meanTime<=2s) or the messages lost between two routes (<route>-><route>:lost<=0)", "example": "orders:handled<=0" }, { @@ -519,6 +519,18 @@ { "name": "redeliveries", "description": "The redeliveries in the route" + }, + { + "name": "meanTime", + "description": "The mean time of the exchanges in the route, from entering it until done, such as meanTime<=2s" + }, + { + "name": "maxTime", + "description": "The longest time of an exchange in the route, from entering it until done, such as maxTime<=5s" + }, + { + "name": "lost", + "description": "The messages completed in the route and not in the next one, such as orders->shipping:lost<=0" } ], "ops": [ diff --git a/components/camel-chaos/src/main/docs/chaos.adoc b/components/camel-chaos/src/main/docs/chaos.adoc index ba00e70049de..67b3ebfdbabf 100644 --- a/components/camel-chaos/src/main/docs/chaos.adoc +++ b/components/camel-chaos/src/main/docs/chaos.adoc @@ -211,7 +211,10 @@ until: a number of matches (until=100) or a time (until=2m), counted from the time: a number with a unit (ms, s, m, h, d), also combined as 1m30s; a number alone is milliseconds expect: expect=<route id>:<count> (at least that many completed exchanges), or expect=<route id>:<kind><op><count> with kind completed | handled | failed | redeliveries - and op >= | <= | = (such as expect=orders:handled<=0); with an optional within=<time> + and op >= | <= | = (such as expect=orders:handled<=0), or a time + expect=<route id>:meanTime|maxTime<op><time> (such as expect=orders:meanTime<=2s), or the + messages lost between two routes expect=<route id>-><route id>:lost<op><count> (such as + expect=orders->shipping:lost<=0); with an optional within=<time> ---- The patterns of the targets are matched as text, with `*` for any characters; they are not regular expressions. The @@ -657,6 +660,50 @@ give their reason only. In JSON an expectation has its `kind`, `op`, `count` and the `value` counted so far (and `completed`, the same value, for an expectation of completed exchanges). +=== Expectations on time and loss + +The delay faults slow a route down, and the question is whether the route stays fast enough: an expectation on time +checks it. The question people ask about a lost message is often "did every order reach shipping", and an expectation on +two routes checks that. + +[cols="2,3"] +|=== +|Expectation |Is met when + +|`orders:meanTime<=2s` |The mean time of the exchanges in route `orders` is at most 2 s when the time is up. +|`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. +|=== + +* The time of an exchange in a route is measured by chaos, from when the exchange enters the route until the route is +done with it the first time (whether it completed, was handled by the error handler or failed), including the +redeliveries in between. It is read through `TimeSource`, so in a deterministic simulation test it is virtual time. A +time is any time of the rules, such as `500ms`, `2s` or `1m30s`; the op is `<=` or `>=` (a `meanTime>=1s` checks that a +delay rule slows the route as it should). +* A time of no exchange says nothing, so an expectation on time whose route did no exchange in the time is unmet. +* A loss counts the exchanges completed in both routes from when the rule is added until the time is up, and the +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 `=`. + +For example, a stock service that is slow for a minute, and the orders that must still ship, fast enough: + +[source,properties] +---- +camel.chaos.rules = \ + endpoint=http:stock* delay duration=1s..3s rate=0.3 until=1m expect=orders:meanTime<=2s within=2m;\ + endpoint=seda:shipping drop rate=0.01 until=1m expect=orders->shipping:lost<=0 within=2m +---- + +An unmet one gives its reason in the report, such as `route orders: mean 2s340ms of 118 exchanges within 2m0s, at most +2s expected (rule r1)` or `routes orders->shipping: 1 lost (118 completed in orders, 117 in shipping) within 2m0s, at +most 0 expected (rule r2): 1 lost`. In JSON these expectations have their `spec` as the rule writes it, the `count` and +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). + +The route statistics of Camel have no percentiles, so there is no `p99` yet. + === A rule in plain words Each rule of the list and the report has its `description`: what it does, to which share of what, with the 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 1dff5138ff72..ed6dc216308f 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 @@ -70,6 +70,10 @@ final class ChaosExchangeState implements SafeCopyProperty { boolean expectedCompleted; boolean expectedHandled; boolean expectedFailed; + // when the exchange entered the route, for the expectations on time (meanTime, maxTime), else -1; and whether + // they counted its time + long enteredAt = -1; + boolean expectedTime; } 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 87c4bcdf578d..e8512258a934 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 @@ -29,6 +29,7 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.LongAdder; +import java.util.stream.Collectors; import org.apache.camel.CamelContext; import org.apache.camel.Consumer; @@ -352,6 +353,10 @@ public final class ChaosManager { if (rule.expectation != null && camelContext.getRoute(rule.expectation.route) == null) { answer.add("expects route " + rule.expectation.route + ", but there is no route with that id (yet)"); } + if (rule.expectation != null && rule.expectation.toRoute != null + && camelContext.getRoute(rule.expectation.toRoute) == null) { + answer.add("expects route " + rule.expectation.toRoute + ", but there is no route with that id (yet)"); + } String target = ChaosTargets.check(camelContext, rule); if (target != null) { answer.add(target); @@ -607,6 +612,17 @@ public final class ChaosManager { state.expectedFailed = true; record(routeId, ChaosRule.Expectation.Kind.FAILED); } + if (state.enteredAt >= 0 && !state.expectedTime && outcome != ChaosExchangeState.Outcome.ENTERED) { + // the time of the exchange in the route, from when it entered it until the route was done with it the + // first time + state.expectedTime = true; + long millis = Math.max(0, TimeSource.currentTimeMillis() - state.enteredAt); + for (ChaosRule.Expectation expectation : ruleSet.expectations) { + if (expectation.kind.time() && expectation.route.equals(routeId)) { + expectation.recordTime(millis); + } + } + } } /** The error handler redelivers a step of the route: counted for the report and the expectations. */ @@ -619,6 +635,13 @@ public final class ChaosManager { 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 + if (expectation.route.equals(routeId)) { + expectation.recordLost(false); + } else if (routeId.equals(expectation.toRoute)) { + expectation.recordLost(true); + } } } } @@ -888,6 +911,8 @@ public final class ChaosManager { final ChaosRule[] lifecycle; final ChaosRule.Expectation[] expectations; final ChaosRule[] expectationRules; + // the routes with an expectation on their time, whose route policy notes when an exchange enters + final Set<String> timedRoutes; RuleSet(List<ChaosRule> rules, long now) { // also the rules that start later (from=): the hooks skip them until then @@ -905,6 +930,8 @@ public final class ChaosManager { expectations = Arrays.stream(expectationRules).map(r -> r.expectation) .filter(e -> e.outcome(now) == ChaosRule.Expectation.Outcome.PENDING && !e.frozen()) .toArray(ChaosRule.Expectation[]::new); + timedRoutes = Arrays.stream(expectations).filter(e -> e.kind.time()).map(e -> e.route) + .collect(Collectors.toUnmodifiableSet()); } } 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 80159d7452a2..576d2287d61b 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 @@ -339,7 +339,9 @@ final class ChaosModel { "When the rule starts after it is added, such as after the warm-up (+30s or 30s)", "+30s"); case "expect" -> param("expect", "expectation", false, "An expectation of a route after the rule is added: <route>:<count> (at least that many" - + " completed) or <route>:<kind><op><count>", + + " completed), <route>:<kind><op><count>, a time" + + " (<route>:meanTime<=2s) or the messages lost" + + " between two routes (<route>-><route>:lost<=0)", "orders:handled<=0"); case "within" -> param("within", "duration", false, "The time of the expectation (by default until the rule ends)", "2m"); @@ -355,7 +357,7 @@ final class ChaosModel { for (Expectation.Kind kind : Expectation.Kind.values()) { JsonObject k = new JsonObject(); k.put("name", kind.key); - k.put("description", "The " + kind.words(2) + " in the route"); + k.put("description", kind.description); kinds.add(k); } jo.put("kinds", kinds); 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 440cbc5f3f4f..a86487664681 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 @@ -103,6 +103,13 @@ public final class ChaosReport { reasons.add("route " + e.route + " completed " + entry.value + " of " + e.count + " exchanges within " + ChaosRuleParser.printTime(rule.within) + " (rule " + rule.id + "): " + entry.lost + " lost"); + } else if (e.kind.time() || e.kind == ChaosRule.Expectation.Kind.LOST) { + // a time, or the messages lost between two routes (CAMEL-26726) + String limit = e.kind.time() ? ChaosRuleParser.printTime(e.count) : Long.toString(e.count); + reasons.add((e.toRoute != null ? "routes " + e.route + "->" + e.toRoute : "route " + e.route) + ": " + + entry.valueText + " within " + ChaosRuleParser.printTime(rule.within) + ", " + + e.op.words + " " + limit + " expected (rule " + rule.id + ")" + + (entry.lost > 0 ? ": " + entry.lost + " lost" : "")); } else { reasons.add("route " + e.route + ": " + entry.value + " " + e.kind.words(entry.value) + " within " + ChaosRuleParser.printTime(rule.within) + ", " + e.op.words + " " + e.count @@ -231,7 +238,7 @@ public final class ChaosReport { if (r.outcome != null) { sb.append("\n expect ").append(r.expectSpec).append(" within ") .append(ChaosRuleParser.printTime(r.within)).append(": ").append(r.outcome.key) - .append(", ").append(r.value).append(' ').append(r.expectKind.key); + .append(", ").append(r.valueText); if (r.outcome == Outcome.PENDING) { sb.append(", ").append(ChaosRuleParser.printTime(r.timeLeft)).append(" left"); } else if (r.outcome == Outcome.CANCELLED) { @@ -468,6 +475,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) + e.put("spec", r.expectSpec); + if (r.expectToRoute != null) { + e.put("toRoute", r.expectToRoute); + } + if (r.expectKind.time() || r.expectToRoute != null) { + e.put("samples", r.samples); + } e.put("outcome", r.outcome.key); if (r.outcome == Outcome.PENDING) { e.put("timeLeft", r.timeLeft); @@ -658,6 +674,11 @@ public final class ChaosReport { final long within; final Outcome outcome; final long value; + // the value as the report says it, such as 3 completed or mean 1s200ms of 12 exchanges + final String valueText; + // the next route of a lost expectation, and the exchanges a time or a lost expectation counted + final String expectToRoute; + final long samples; final long lost; final long timeLeft; // a lifecycle rule: its action, route, duration and what it did, else null @@ -726,6 +747,9 @@ public final class ChaosReport { ChaosRule.Expectation.Reading reading = expectation.read(now); outcome = reading.outcome(); value = reading.value(); + valueText = expectation.valueText(reading); + expectToRoute = expectation.toRoute; + samples = reading.samples(); lost = expectation.lost(reading); timeLeft = Math.max(0, expectation.deadline() - now); } else { @@ -736,6 +760,9 @@ public final class ChaosReport { expectCount = 0; outcome = null; value = 0; + valueText = null; + expectToRoute = null; + samples = 0; lost = 0; timeLeft = 0; } 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 a042dc0240d7..b452c41c4858 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 @@ -60,11 +60,22 @@ final class ChaosRoutePolicyFactory implements RoutePolicyFactory { @Override public void onExchangeBegin(Route route, Exchange exchange) { - ChaosRule[] rules = manager.ruleSet().entry; - if (rules.length == 0 || !ChaosExchangeState.of(exchange).enter(route.getRouteId())) { + ChaosManager.RuleSet set = manager.ruleSet(); + 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) { return; } + ChaosExchangeState exchangeState = ChaosExchangeState.of(exchange); + boolean first = exchangeState.enter(route.getRouteId()); long now = TimeSource.currentTimeMillis(); + if (timed && first) { + exchangeState.route(route.getRouteId()).enteredAt = now; + } + if (rules.length == 0 || !first) { + return; + } for (ChaosRule rule : rules) { if (rule.live(now) && rule.matchesId(route.getRouteId()) && rule.matchesWhen(exchange) && rule.fires(now)) { 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 ce273c6469ab..29887ed736f6 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 @@ -197,6 +197,8 @@ final class ChaosRule { final long untilCount; final long untilDuration; final String expectRoute; + // the next route of a lost expectation (orders->shipping:lost<=0), else null + final String expectToRoute; final long expectCount; final long within; // the action of a lifecycle rule (stall is suspend), else null @@ -277,6 +279,7 @@ final class ChaosRule { this.untilCount = b.untilCount; this.untilDuration = b.untilDuration; this.expectRoute = b.expectRoute; + this.expectToRoute = b.expectToRoute; this.expectCount = b.expectCount; this.action = b.action; this.stall = b.stall; @@ -290,7 +293,8 @@ final class ChaosRule { this.endsAt = plus(startsAt, runFor); this.within = b.within > 0 ? b.within : plus(from, untilDuration > 0 ? untilDuration : maxDuration); this.expectation = expectRoute != null - ? new Expectation(expectRoute, b.expectKind, b.expectOp, expectCount, plus(addedAt, within)) : null; + ? new Expectation(expectRoute, expectToRoute, b.expectKind, b.expectOp, expectCount, plus(addedAt, within)) + : null; for (int i = 0; i < fired.length; i++) { fired[i] = new LongAdder(); } @@ -510,12 +514,24 @@ final class ChaosRule { if (expectation != null) { Expectation e = expectation; sb.append("; expects "); - if (e.op == Expectation.Op.AT_MOST && e.count == 0) { - sb.append("no ").append(e.kind.words(0)); + if (e.kind == Expectation.Kind.MEAN_TIME) { + sb.append("a mean time of ").append(e.op.words).append(' ').append(ChaosWords.time(e.count)) + .append(" in route ").append(e.route); + } else if (e.kind == Expectation.Kind.MAX_TIME) { + sb.append("every exchange in route ").append(e.route).append(" to take ").append(e.op.words).append(' ') + .append(ChaosWords.time(e.count)); + } else if (e.kind == Expectation.Kind.LOST) { + sb.append(e.count == 0 ? "no message" : e.op.words + " " + e.count + " " + e.kind.words(e.count)) + .append(e.count == 0 ? " lost" : "").append(" between route ").append(e.route).append(" and route ") + .append(e.toRoute); } else { - sb.append(e.op.words).append(' ').append(e.count).append(' ').append(e.kind.words(e.count)); + if (e.op == Expectation.Op.AT_MOST && e.count == 0) { + sb.append("no ").append(e.kind.words(0)); + } else { + sb.append(e.op.words).append(' ').append(e.count).append(' ').append(e.kind.words(e.count)); + } + sb.append(" in route ").append(e.route); } - sb.append(" in route ").append(e.route); if (within != plus(from, runFor)) { // a within= of its own (the default is until the rule ends) sb.append(" within ").append(ChaosWords.time(within)); @@ -863,25 +879,41 @@ final class ChaosRule { /** What an expectation counts in its route. */ enum Kind { - COMPLETED("completed", "exchange completed", "exchanges completed"), - HANDLED("handled", "exchange handled by the error handler", "exchanges handled by the error handler"), - FAILED("failed", "exchange failed", "exchanges failed"), - REDELIVERIES("redeliveries", "redelivery", "redeliveries"); + COMPLETED("completed", "exchange completed", "exchanges completed", "The exchanges completed in the route"), + HANDLED("handled", "exchange handled by the error handler", "exchanges handled by the error handler", + "The exchanges handled by the error handler in the route"), + FAILED("failed", "exchange failed", "exchanges failed", "The exchanges failed in the route"), + REDELIVERIES("redeliveries", "redelivery", "redeliveries", "The redeliveries in the route"), + // the time an exchange is in the route, from when it enters until the route is done with it (CAMEL-26726) + MEAN_TIME("meanTime", "mean time", "mean time", + "The mean time of the exchanges in the route, from entering it until done, such as meanTime<=2s"), + MAX_TIME("maxTime", "max time", "max time", + "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"); final String key; // for the reason of an unmet expectation private final String one; private final String many; + final String description; - Kind(String key, String one, String many) { + Kind(String key, String one, String many, String description) { this.key = key; this.one = one; this.many = many; + this.description = description; } String words(long n) { return n == 1 ? one : many; } + + /** Whether the count of the kind is a time (in milliseconds), not a number of exchanges. */ + boolean time() { + return this == MEAN_TIME || this == MAX_TIME; + } } /** How the count of an expectation compares. */ @@ -900,11 +932,19 @@ final class ChaosRule { } final String route; + // the next route of a lost expectation, else null + final String toRoute; final Kind kind; final Op op; + // a number, or a time in milliseconds for a time kind final long count; private long deadline; private long value; + // a time kind: the exchanges done, their time together and the longest; lost: the completed of both routes + private long samples; + private long sum; + private long max; + private long toValue; // 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; @@ -915,7 +955,12 @@ final class ChaosRule { } Expectation(String route, Kind kind, Op op, long count, long deadline) { + this(route, null, kind, op, count, deadline); + } + + Expectation(String route, String toRoute, Kind kind, Op op, long count, long deadline) { this.route = route; + this.toRoute = toRoute; this.kind = kind; this.op = op; this.count = count; @@ -943,6 +988,39 @@ final class ChaosRule { } } + /** 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()); + } + + synchronized void recordTimeAt(long millis, long now) { + if (frozenAt >= 0 || now > deadline) { + return; + } + samples++; + sum += millis; + max = Math.max(max, millis); + if (decided == null && kind == Kind.MAX_TIME && op == Op.AT_MOST && millis > count) { + // one exchange took too long: it cannot become met + decided = Outcome.UNMET; + decidedAt = now; + } + } + + /** + * An exchange completed in the route ({@code next} false) or in the next route (true) of a lost expectation. + */ + synchronized void recordLost(boolean next) { + if (frozenAt >= 0 || TimeSource.currentTimeMillis() > deadline) { + return; + } + if (next) { + toValue++; + } else { + samples++; + } + } + synchronized long deadline() { return deadline; } @@ -961,7 +1039,12 @@ final class ChaosRule { } synchronized long value() { - return value; + return switch (kind) { + case MEAN_TIME -> samples > 0 ? sum / samples : 0; + case MAX_TIME -> max; + case LOST -> Math.max(0, samples - toValue); + default -> value; + }; } synchronized Outcome outcome(long now) { @@ -975,12 +1058,17 @@ final class ChaosRule { if (now <= deadline) { return Outcome.PENDING; } - // the time is up - return switch (op) { - case AT_LEAST -> Outcome.UNMET; - case AT_MOST -> Outcome.MET; - case EXACTLY -> value == count ? Outcome.MET : Outcome.UNMET; + // the time is up: a time of no exchange says nothing, so it is not met + if (kind.time() && samples == 0) { + return Outcome.UNMET; + } + long v = value(); + boolean met = switch (op) { + case AT_LEAST -> v >= count; + case AT_MOST -> v <= count; + case EXACTLY -> v == count; }; + return met ? Outcome.MET : Outcome.UNMET; } synchronized boolean frozen() { @@ -989,7 +1077,7 @@ final class ChaosRule { /** The outcome, the counted value and whether it is frozen, read together (for the report). */ synchronized Reading read(long now) { - return new Reading(outcome(now), value, frozenAt >= 0); + return new Reading(outcome(now), value(), frozenAt >= 0, samples, toValue); } /** @@ -997,18 +1085,40 @@ final class ChaosRule { * many, misses the ones that did not complete. */ long lost(Reading reading) { - return reading.outcome() == Outcome.UNMET && kind == Kind.COMPLETED && op != Op.AT_MOST - ? Math.max(0, count - reading.value()) : 0; + if (reading.outcome() != Outcome.UNMET) { + return 0; + } + if (kind == Kind.LOST) { + // the messages the next route did not complete + return reading.value(); + } + return kind == Kind.COMPLETED && op != Op.AT_MOST ? Math.max(0, count - reading.value()) : 0; } /** The expectation as the rule writes it, such as orders:100 or orders:handled<=0. */ String spec() { - return kind == Kind.COMPLETED && op == Op.AT_LEAST - ? route + ":" + count : route + ":" + kind.key + op.key + count; + if (kind == Kind.COMPLETED && op == Op.AT_LEAST) { + return route + ":" + count; + } + String routes = toRoute != null ? route + "->" + toRoute : route; + return routes + ":" + kind.key + op.key + (kind.time() ? ChaosRuleParser.printTime(count) : count); + } + + /** The value as the report says it, such as {@code 3 completed} or {@code mean 1s200ms of 12 exchanges}. */ + String valueText(Reading reading) { + return switch (kind) { + case MEAN_TIME, MAX_TIME -> reading.samples() == 0 + ? "no exchange done yet" + : (kind == Kind.MEAN_TIME ? "mean " : "max ") + ChaosRuleParser.printTime(reading.value()) + " of " + + reading.samples() + (reading.samples() == 1 ? " exchange" : " exchanges"); + case LOST -> reading.value() + " lost (" + reading.samples() + " completed in " + route + ", " + + reading.next() + " in " + toRoute + ")"; + default -> reading.value() + " " + kind.key; + }; } /** What an expectation reads at one time. */ - record Reading(Outcome outcome, long value, boolean frozen) { + record Reading(Outcome outcome, long value, boolean frozen, long samples, long next) { } } @@ -1091,6 +1201,7 @@ final class ChaosRule { long untilCount; long untilDuration; String expectRoute; + String expectToRoute; Expectation.Kind expectKind = Expectation.Kind.COMPLETED; Expectation.Op expectOp = Expectation.Op.AT_LEAST; long expectCount; 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 e7ed74c0d784..73f83bedf216 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 @@ -927,7 +927,17 @@ final class ChaosRuleParser { + " or expect=orders:handled<=0"); } b.expectRoute = expect.substring(0, colon); - if (b.expectRoute.indexOf('*') >= 0) { + int arrow = b.expectRoute.indexOf("->"); + if (arrow >= 0) { + // the messages one route completed that the next one did not (CAMEL-26726) + b.expectToRoute = b.expectRoute.substring(arrow + 2); + b.expectRoute = b.expectRoute.substring(0, arrow); + if (b.expectRoute.isEmpty() || b.expectToRoute.isEmpty() || b.expectRoute.equals(b.expectToRoute)) { + throw new IllegalArgumentException( + "expect=" + expect + " needs two routes, such as expect=orders->shipping:lost<=0"); + } + } + if (b.expectRoute.indexOf('*') >= 0 || b.expectToRoute != null && b.expectToRoute.indexOf('*') >= 0) { throw new IllegalArgumentException( "expect=" + expect + " needs the id of one route, not a pattern, such as expect=orders:100"); } @@ -949,8 +959,9 @@ final class ChaosRuleParser { if (b.expectKind == null) { throw new IllegalArgumentException( "expect=" + expect + " counts " + (kind.isEmpty() ? "nothing" : kind) - + ": the kinds are completed, handled, failed and redeliveries," - + " such as expect=orders:handled<=0"); + + ": the kinds are completed, handled, failed, redeliveries, meanTime," + + " maxTime and lost, such as expect=orders:handled<=0," + + " expect=orders:meanTime<=2s or expect=orders->shipping:lost<=0"); } String rest = spec.substring(op); if (rest.startsWith(">=")) { @@ -966,6 +977,32 @@ final class ChaosRuleParser { } count = rest.substring(b.expectOp.key.length()); } + if ((b.expectKind == ChaosRule.Expectation.Kind.LOST) != (b.expectToRoute != null)) { + throw new IllegalArgumentException( + b.expectToRoute != null + ? "expect=" + expect + " has two routes, which only lost counts: such as" + + " expect=" + b.expectRoute + "->" + b.expectToRoute + ":lost<=0" + : "expect=" + expect + " counts the messages lost between two routes, such as" + + " expect=orders->shipping:lost<=0"); + } + if (b.expectKind.time()) { + if (b.expectOp == ChaosRule.Expectation.Op.EXACTLY) { + throw new IllegalArgumentException( + "expect=" + expect + " needs <= or >= for a time, such as expect=" + b.expectRoute + ":" + + b.expectKind.key + "<=2s"); + } + b.expectCount = duration("expect", count); + if (b.expectCount < 1) { + throw new IllegalArgumentException("expect=" + expect + " needs a time longer than 0"); + } + withinOf(b, within, maxDuration); + return; + } + 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 + "->" + + b.expectToRoute + ":lost<=0"); + } if (!isDigits(count) || count.length() > 18) { throw new IllegalArgumentException( "expect=" + expect + " needs a count that is a whole number of at most 18 digits"); @@ -975,6 +1012,11 @@ final class ChaosRuleParser { // at least 0 is always met throw new IllegalArgumentException("expect=" + expect + " needs a count of 1 or more"); } + withinOf(b, within, maxDuration); + } + + /** The time of the expectation (within=), at most camel.chaos.maxDuration after the start of the rule. */ + private static void withinOf(ChaosRule.Builder b, String within, long maxDuration) { if (within != null) { b.within = duration("within", within); if (b.within > maxDuration + b.from) { 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 new file mode 100644 index 000000000000..07bc14b73daf --- /dev/null +++ b/components/camel-chaos/src/test/java/org/apache/camel/component/chaos/ChaosExpectTimeAndLossTest.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 org.apache.camel.builder.RouteBuilder; +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-26726: expectations on the time of a route (meanTime, maxTime), measured by chaos from when an exchange enters + * the route until the route is done with it, and on the messages lost between two routes (orders->ship:lost<=0). The + * clock is virtual: a delay moves it by its length. + */ +@Isolated +class ChaosExpectTimeAndLossTest extends ChaosTestSupport { + + 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() { + from("direct:orders").routeId("orders") + .to("mock:stock").id("callStock") + .to("direct:ship"); + from("direct:ship").routeId("ship").to("mock:ship"); + } + }; + } + + private void orders(int n) { + for (int i = 0; i < n; i++) { + template.sendBody("direct:orders", "order " + i); + } + } + + private JsonObject expectation(String id) { + return (JsonObject) ruleReport(id).get("expectation"); + } + + @Test + void theMeanTimeOfARouteIsDecidedWhenTheTimeIsUp() { + // every second call to stock is 3s slow: a mean of 1.5s over 4 orders + String id = chaos().addRule("step=callStock delay duration=3s rate=every:2 expect=orders:meanTime<=2s within=1m"); + orders(4); + + assertThat(expectation(id).getString("outcome")).isEqualTo("pending"); + clock.advance(60_001); + JsonObject e = expectation(id); + assertThat(e.getString("outcome")).isEqualTo("met"); + assertThat(e.getLong("value")).isEqualTo(1500); + assertThat(e.getLong("samples")).isEqualTo(4); + assertThat(e.getString("spec")).isEqualTo("orders:meanTime<=2s"); + assertThat(chaos().report().getVerdict()).isEqualTo(ChaosReport.PASS); + assertThat(chaos().report().toText()) + .contains("expect orders:meanTime<=2s within 1m0s: met, mean 1s500ms of 4 exchanges"); + assertThat(chaos().describeRule(id)) + .endsWith("; expects a mean time of at most 2 seconds in route orders within 1 minute"); + } + + @Test + void oneSlowExchangeFailsTheMaxTimeAtOnce() { + String id = chaos().addRule("step=callStock delay duration=3s rate=every:3 expect=orders:maxTime<=1s within=1m"); + orders(2); + assertThat(expectation(id).getString("outcome")).isEqualTo("pending"); + orders(1); + + // decided before the time is up + JsonObject e = expectation(id); + assertThat(e.getString("outcome")).isEqualTo("unmet"); + assertThat(e.getLong("value")).isEqualTo(3000); + ChaosReport report = chaos().report(); + assertThat(report.getVerdict()).isEqualTo(ChaosReport.FAIL); + assertThat(report.getReasons()).containsExactly( + "route orders: max 3s of 3 exchanges within 1m0s, at most 1s expected (rule " + id + ")"); + // a time is not a message lost + assertThat(report.getLost()).isZero(); + assertThat(chaos().describeRule(id)).contains("expects every exchange in route orders to take at most 1 second"); + } + + @Test + void aTimeOfNoExchangeIsNotMet() { + String id = chaos().addRule("step=callStock delay duration=1ms expect=ship:meanTime<=1s within=10s"); + clock.advance(10_001); + + JsonObject e = expectation(id); + assertThat(e.getString("outcome")).isEqualTo("unmet"); + assertThat(chaos().report().getReasons()).singleElement().asString() + .startsWith("route ship: no exchange done yet within 10s"); + } + + @Test + void theMessagesLostBetweenTwoRoutes() { + // every second order never reaches the ship route + String id = chaos().addRule("endpoint=direct:ship drop rate=every:2 expect=orders->ship:lost<=0 within=1m"); + orders(4); + assertThat(expectation(id).getString("outcome")).isEqualTo("pending"); + clock.advance(60_001); + + JsonObject e = expectation(id); + assertThat(e.getString("outcome")).isEqualTo("unmet"); + assertThat(e.getString("toRoute")).isEqualTo("ship"); + assertThat(e.getLong("value")).isEqualTo(2); + ChaosReport report = chaos().report(); + assertThat(report.getLost()).isEqualTo(2); + assertThat(report.getReasons()).containsExactly( + "routes orders->ship: 2 lost (4 completed in orders, 2 in ship) within 1m0s, at most 0 expected (rule " + id + + "): 2 lost"); + assertThat(chaos().describeRule(id)) + .endsWith("; expects no message lost between route orders and route ship within 1 minute"); + } + + @Test + void nothingLostWhenTheNextRouteCompletesThemAll() { + String id = chaos().addRule("step=callStock delay duration=10ms expect=orders->ship:lost=0 within=1m"); + orders(3); + clock.advance(60_001); + + assertThat(expectation(id).getString("outcome")).isEqualTo("met"); + assertThat(chaos().report().toText()).contains("met, 0 lost (3 completed in orders, 3 in ship)"); + } + + @Test + void theExampleOfTheDocumentation() { + assertThat(chaos().addRules( + "endpoint=http:stock* delay duration=1s..3s rate=0.3 until=1m expect=orders:meanTime<=2s within=2m;" + + "endpoint=seda:shipping drop rate=0.01 until=1m expect=orders->shipping:lost<=0 within=2m")) + .hasSize(2); + assertThat(chaos().addRule("step=callStock delay duration=1s expect=orders:meanTime>=1s")).isNotNull(); + } + + @Test + void theFormsThatAreRefused() { + assertThatThrownBy(() -> chaos().addRule("step=callStock delay duration=1ms expect=orders:meanTime=2s")) + .hasMessageContaining("needs <= or >= for a time"); + assertThatThrownBy(() -> chaos().addRule("step=callStock delay duration=1ms expect=orders:maxTime<=soon")) + .hasMessageContaining("expect="); + assertThatThrownBy(() -> chaos().addRule("step=callStock delay duration=1ms expect=orders:lost<=0")) + .hasMessageContaining("counts the messages lost between two routes, such as expect=orders->shipping:lost<=0"); + assertThatThrownBy(() -> chaos().addRule("step=callStock delay duration=1ms expect=orders->ship:completed>=1")) + .hasMessageContaining("has two routes, which only lost counts"); + assertThatThrownBy(() -> chaos().addRule("step=callStock delay duration=1ms expect=orders->ship:lost>=1")) + .hasMessageContaining("needs <= or = for the messages lost"); + 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"); + } +} 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 c776ad4e0201..375aec13f1fe 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 @@ -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:lost<=0|counts lost: the kinds are completed, handled, failed and redeliveries", + "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:<=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/design/chaos.adoc b/design/chaos.adoc index 7f8d504d67a0..74cefe8d1196 100644 --- a/design/chaos.adoc +++ b/design/chaos.adoc @@ -383,6 +383,10 @@ when the context stops first (the report of `reportOnShutdown`) or the rule is r arrive, so they are not called lost. A tool must treat a pending expectation as not yet decided, so a PASS with one pending is not final. +Stage 2 (CAMEL-26726) adds expectations on time and loss: `orders:meanTime<=2s` and `orders:maxTime<=5s`, with the time +of an exchange in the route measured by chaos through `TimeSource` (from entering the route until done), and +`orders->shipping:lost<=0`, the messages one route completed and the next did not, which count as lost. + === Control - A `chaos` dev console lists the rules and shows the report, so it is `@DevConsole(readOnly = true)`. It does not add, diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/process/ListChaos.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/process/ListChaos.java index 75ea342f94ce..f34c418d3c07 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/process/ListChaos.java +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/process/ListChaos.java @@ -432,12 +432,24 @@ public class ListChaos extends ProcessWatchCommand { String op = e.getStringOrDefault("op", ">="); Object value = e.get("value") != null ? e.get("value") : e.get("completed"); StringBuilder sb = new StringBuilder(); - sb.append(e.getString("route")).append(':'); - if (!"completed".equals(kind) || !">=".equals(op)) { - sb.append(kind).append(op); + String spec = e.getString("spec"); + if (spec != null) { + // as the rule writes it, such as orders:meanTime<=2s or orders->shipping:lost<=0 (CAMEL-26726) + sb.append(spec); + } else { + sb.append(e.getString("route")).append(':'); + if (!"completed".equals(kind) || !">=".equals(op)) { + sb.append(kind).append(op); + } + sb.append(e.get("count")); + } + sb.append(' ').append(e.getString("outcome")).append(" ("); + if (("meanTime".equals(kind) || "maxTime".equals(kind)) && value instanceof Number n) { + // a time in milliseconds + sb.append(TimeUtils.printDuration(n.longValue())).append(' ').append(kind); + } else { + sb.append(value).append(' ').append(kind); } - sb.append(e.get("count")).append(' ').append(e.getString("outcome")) - .append(" (").append(value).append(' ').append(kind); if (e.get("timeLeft") instanceof Number n) { sb.append(", ").append(TimeUtils.printDuration(n.longValue())).append(" left"); } diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosAiPrompt.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosAiPrompt.java index b9a0fb741d0a..1b26175a4045 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosAiPrompt.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosAiPrompt.java @@ -189,8 +189,9 @@ final class ChaosAiPrompt { sb.append("It fired at ").append(rule.firedAt.get("poll")).append(" polls of a consumer (no exchange).\n"); } if (rule.expectRoute != null) { - sb.append("Expectation: ").append(rule.expectRoute).append(':').append(rule.expectKind).append(rule.expectOp) - .append(rule.expectCount).append(", ").append(rule.expectOutcome != null ? rule.expectOutcome : "pending") + sb.append("Expectation: ").append(rule.expectSpec != null + ? rule.expectSpec : rule.expectRoute + ":" + rule.expectKind + rule.expectOp + rule.expectCount) + .append(", ").append(rule.expectOutcome != null ? rule.expectOutcome : "pending") .append(" (").append(rule.expectValue).append(' ').append(rule.expectKind).append(").\n"); } if (rule.lostAtDlq > 0) { diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosInfo.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosInfo.java index 997dc728130b..bfd42f763839 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosInfo.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosInfo.java @@ -82,6 +82,8 @@ final class ChaosInfo { long expectValue; String expectOutcome; long expectTimeLeft = -1; + // the expectation as the rule writes it, such as orders:meanTime<=2s (null before CAMEL-26726) + String expectSpec; // what a stall or lifecycle rule did to its route, or null String lifecycleAction; String lifecycleRoute; @@ -373,6 +375,7 @@ final class ChaosInfo { r.expectValue = num(e.get("value") != null ? e.get("value") : e.get("completed"), 0); r.expectOutcome = e.getString("outcome"); r.expectTimeLeft = num(e.get("timeLeft"), -1); + r.expectSpec = e.getString("spec"); } if (jo.get("lifecycle") instanceof JsonObject l) { r.lifecycleAction = l.getString("action"); 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 1949295904ee..8d9da595d920 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 @@ -468,8 +468,9 @@ class ChaosTab extends AbstractTableTab { } addImpact(lines, info, chaos, r); if (r.expectRoute != null) { - String spec = "completed".equals(r.expectKind) && ">=".equals(r.expectOp) - ? r.expectRoute + ":" + r.expectCount + String spec = r.expectSpec != null ? r.expectSpec + : "completed".equals(r.expectKind) && ">=".equals(r.expectOp) + ? r.expectRoute + ":" + r.expectCount : r.expectRoute + ":" + r.expectKind + r.expectOp + r.expectCount; Style outcome = switch (r.expectOutcome != null ? r.expectOutcome : "") { case "met" -> Theme.success().bold(); @@ -481,7 +482,10 @@ class ChaosTab extends AbstractTableTab { expect.add(Span.styled(" Expect ", label)); expect.add(Span.raw(spec + " ")); expect.add(Span.styled(r.expectOutcome != null ? r.expectOutcome.toUpperCase(Locale.ENGLISH) : "-", outcome)); - expect.add(Span.raw(" " + r.expectValue + " " + r.expectKind)); + // a time (meanTime, maxTime) is in milliseconds + boolean time = "meanTime".equals(r.expectKind) || "maxTime".equals(r.expectKind); + expect.add(Span.raw(" " + (time ? shortTime(r.expectValue) : String.valueOf(r.expectValue)) + " " + + r.expectKind)); if (r.expectTimeLeft >= 0 && "pending".equals(r.expectOutcome)) { expect.add(Span.styled(" " + shortTime(r.expectTimeLeft) + " left", label)); }
