You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@camel.apache.org by da...@apache.org on 2026/06/07 07:16:13 UTC

(camel) branch feature/CAMEL-23680-group-variable-scope created (now ac96324e6e98)

This is an automated email from the ASF dual-hosted git repository.

davsclaus pushed a change to branch feature/CAMEL-23680-group-variable-scope
in repository https://gitbox.apache.org/repos/asf/camel.git


      at ac96324e6e98 CAMEL-23680: Add group as scope to variables

This branch includes the following new commits:

     new ac96324e6e98 CAMEL-23680: Add group as scope to variables

The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.



(camel) 01/01: CAMEL-23680: Add group as scope to variables

Posted by da...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

davsclaus pushed a commit to branch feature/CAMEL-23680-group-variable-scope
in repository https://gitbox.apache.org/repos/asf/camel.git

commit ac96324e6e987314e65ee0c37273b1582bc103f0
Author: Claus Ibsen <cl...@gmail.com>
AuthorDate: Sun Jun 7 09:15:59 2026 +0200

    CAMEL-23680: Add group as scope to variables
    
    Co-Authored-By: Claude <no...@anthropic.com>
    Signed-off-by: Claus Ibsen <cl...@gmail.com>
---
 .../camel/spi/VariableRepositoryFactory.java       |   7 +
 .../engine/DefaultVariableRepositoryFactory.java   |  20 ++
 .../camel/processor/SetGroupVariableTest.java      | 126 ++++++++++++
 .../camel/support/GroupVariableRepositoryTest.java | 194 +++++++++++++++++++
 .../camel/main/MainSupportModelConfigurer.java     |   4 +
 .../camel/support/GroupVariableRepository.java     | 215 +++++++++++++++++++++
 docs/user-manual/modules/ROOT/pages/variables.adoc |  38 +++-
 7 files changed, 600 insertions(+), 4 deletions(-)

diff --git a/core/camel-api/src/main/java/org/apache/camel/spi/VariableRepositoryFactory.java b/core/camel-api/src/main/java/org/apache/camel/spi/VariableRepositoryFactory.java
index 749f56adcfd3..4a190295edc2 100644
--- a/core/camel-api/src/main/java/org/apache/camel/spi/VariableRepositoryFactory.java
+++ b/core/camel-api/src/main/java/org/apache/camel/spi/VariableRepositoryFactory.java
@@ -35,6 +35,13 @@ public interface VariableRepositoryFactory {
      */
     String ROUTE_VARIABLE_REPOSITORY_ID = "route-variable-repository";
 
+    /**
+     * Registry bean id for group {@link VariableRepository}.
+     *
+     * @since 4.21
+     */
+    String GROUP_VARIABLE_REPOSITORY_ID = "group-variable-repository";
+
     /**
      * Gets the {@link VariableRepository} for the given id
      *
diff --git a/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/DefaultVariableRepositoryFactory.java b/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/DefaultVariableRepositoryFactory.java
index 8d834c145edb..d130c8c282b5 100644
--- a/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/DefaultVariableRepositoryFactory.java
+++ b/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/DefaultVariableRepositoryFactory.java
@@ -27,6 +27,7 @@ import org.apache.camel.spi.VariableRepository;
 import org.apache.camel.spi.VariableRepositoryFactory;
 import org.apache.camel.support.CamelContextHelper;
 import org.apache.camel.support.GlobalVariableRepository;
+import org.apache.camel.support.GroupVariableRepository;
 import org.apache.camel.support.LifecycleStrategySupport;
 import org.apache.camel.support.RouteVariableRepository;
 import org.apache.camel.support.service.ServiceSupport;
@@ -45,6 +46,7 @@ public class DefaultVariableRepositoryFactory extends ServiceSupport implements
     private final CamelContext camelContext;
     private VariableRepository global;
     private VariableRepository route;
+    private VariableRepository group;
     private FactoryFinder factoryFinder;
 
     public DefaultVariableRepositoryFactory(CamelContext camelContext) {
@@ -64,6 +66,9 @@ public class DefaultVariableRepositoryFactory extends ServiceSupport implements
         if (route != null && "route".equals(id)) {
             return route;
         }
+        if (group != null && "group".equals(id)) {
+            return group;
+        }
 
         VariableRepository repo = CamelContextHelper.lookup(camelContext, id, VariableRepository.class);
         if (repo == null) {
@@ -119,6 +124,18 @@ public class DefaultVariableRepositoryFactory extends ServiceSupport implements
             camelContext.getRegistry().bind(ROUTE_VARIABLE_REPOSITORY_ID, route);
         }
 
+        // let's see if there is a custom group repo
+        repo = getVariableRepository("group");
+        if (repo != null) {
+            if (!(repo instanceof GroupVariableRepository)) {
+                LOG.info("Using VariableRepository: {} as group repository", repo.getId());
+            }
+            group = repo;
+        } else {
+            group = new GroupVariableRepository();
+            camelContext.getRegistry().bind(GROUP_VARIABLE_REPOSITORY_ID, group);
+        }
+
         if (!camelContext.hasService(global)) {
             camelContext.addService(global);
         }
@@ -134,6 +151,9 @@ public class DefaultVariableRepositoryFactory extends ServiceSupport implements
                 }
             });
         }
+        if (!camelContext.hasService(group)) {
+            camelContext.addService(group);
+        }
     }
 
 }
diff --git a/core/camel-core/src/test/java/org/apache/camel/processor/SetGroupVariableTest.java b/core/camel-core/src/test/java/org/apache/camel/processor/SetGroupVariableTest.java
new file mode 100644
index 000000000000..fe7ce711b973
--- /dev/null
+++ b/core/camel-core/src/test/java/org/apache/camel/processor/SetGroupVariableTest.java
@@ -0,0 +1,126 @@
+/*
+ * 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.processor;
+
+import java.util.List;
+
+import org.apache.camel.ContextTestSupport;
+import org.apache.camel.Exchange;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+public class SetGroupVariableTest extends ContextTestSupport {
+
+    private MockEndpoint end;
+
+    @Test
+    public void testSetGroupVariable() throws Exception {
+        assertNull(context.getVariable("group:teamA:foo"));
+        assertNull(context.getVariable("group:teamB:foo"));
+
+        end.expectedMessageCount(2);
+
+        template.sendBody("direct:teamA", "<blah/>");
+        template.sendBody("direct:teamB", "<blah/>");
+
+        assertMockEndpointsSatisfied();
+
+        // variables should be stored on exchange, not accessible from exchange directly
+        List<Exchange> exchanges = end.getExchanges();
+        Exchange exchange = exchanges.get(0);
+        assertNull(exchange.getVariable("foo"));
+
+        // should be stored as group variables
+        assertEquals("bar", context.getVariable("group:teamA:foo"));
+        assertEquals("baz", context.getVariable("group:teamB:foo"));
+    }
+
+    @Test
+    public void testGroupVariableIsolation() throws Exception {
+        end.expectedMessageCount(1);
+
+        template.sendBody("direct:teamA", "<blah/>");
+
+        assertMockEndpointsSatisfied();
+
+        // teamA has the variable, teamB does not
+        assertEquals("bar", context.getVariable("group:teamA:foo"));
+        assertNull(context.getVariable("group:teamB:foo"));
+    }
+
+    @Test
+    public void testGroupVariableSimpleLanguage() throws Exception {
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedBodiesReceived("Value is bar");
+
+        template.sendBody("direct:simple", "<blah/>");
+
+        assertMockEndpointsSatisfied();
+    }
+
+    @Test
+    public void testCrossRouteGroupVariable() throws Exception {
+        MockEndpoint mock = getMockEndpoint("mock:cross");
+        mock.expectedBodiesReceived("shared-value");
+
+        // route "setter" sets the group variable, route "reader" reads it
+        template.sendBody("direct:setter", "<blah/>");
+        template.sendBody("direct:reader", "<blah/>");
+
+        assertMockEndpointsSatisfied();
+    }
+
+    @Override
+    @BeforeEach
+    public void setUp() throws Exception {
+        super.setUp();
+        end = getMockEndpoint("mock:end");
+    }
+
+    @Override
+    protected RouteBuilder createRouteBuilder() {
+        return new RouteBuilder() {
+            public void configure() {
+                from("direct:teamA").routeId("routeA")
+                        .setVariable("group:teamA:foo").constant("bar")
+                        .to("mock:end");
+
+                from("direct:teamB").routeId("routeB")
+                        .setVariable("group:teamB:foo").constant("baz")
+                        .to("mock:end");
+
+                from("direct:simple").routeId("routeSimple")
+                        .setVariable("group:teamA:foo").constant("bar")
+                        .transform().simple("Value is ${variable.group:teamA:foo}")
+                        .to("mock:result");
+
+                from("direct:setter").routeId("routeSetter")
+                        .setVariable("group:shared:myKey").constant("shared-value")
+                        .to("mock:end");
+
+                from("direct:reader").routeId("routeReader")
+                        .setBody().variable("group:shared:myKey")
+                        .to("mock:cross");
+            }
+        };
+    }
+}
diff --git a/core/camel-core/src/test/java/org/apache/camel/support/GroupVariableRepositoryTest.java b/core/camel-core/src/test/java/org/apache/camel/support/GroupVariableRepositoryTest.java
new file mode 100644
index 000000000000..435acaece322
--- /dev/null
+++ b/core/camel-core/src/test/java/org/apache/camel/support/GroupVariableRepositoryTest.java
@@ -0,0 +1,194 @@
+/*
+ * 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.support;
+
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+public class GroupVariableRepositoryTest {
+
+    private GroupVariableRepository repo;
+
+    @BeforeEach
+    public void setUp() {
+        repo = new GroupVariableRepository();
+    }
+
+    @Test
+    public void testGetId() {
+        assertEquals("group", repo.getId());
+    }
+
+    @Test
+    public void testSetAndGetVariable() {
+        repo.setVariable("teamA:foo", "bar");
+        assertEquals("bar", repo.getVariable("teamA:foo"));
+    }
+
+    @Test
+    public void testVariableIsolationBetweenGroups() {
+        repo.setVariable("teamA:key", "valueA");
+        repo.setVariable("teamB:key", "valueB");
+
+        assertEquals("valueA", repo.getVariable("teamA:key"));
+        assertEquals("valueB", repo.getVariable("teamB:key"));
+    }
+
+    @Test
+    public void testMultipleVariablesInSameGroup() {
+        repo.setVariable("teamA:foo", "1");
+        repo.setVariable("teamA:bar", "2");
+        repo.setVariable("teamA:baz", "3");
+
+        assertEquals("1", repo.getVariable("teamA:foo"));
+        assertEquals("2", repo.getVariable("teamA:bar"));
+        assertEquals("3", repo.getVariable("teamA:baz"));
+        assertEquals(3, repo.size());
+    }
+
+    @Test
+    public void testGetNonExistentVariable() {
+        assertNull(repo.getVariable("teamA:missing"));
+    }
+
+    @Test
+    public void testGetNonExistentGroup() {
+        assertNull(repo.getVariable("noSuchGroup:key"));
+    }
+
+    @Test
+    public void testRemoveVariable() {
+        repo.setVariable("teamA:foo", "bar");
+        Object removed = repo.removeVariable("teamA:foo");
+
+        assertEquals("bar", removed);
+        assertNull(repo.getVariable("teamA:foo"));
+    }
+
+    @Test
+    public void testRemoveWildcard() {
+        repo.setVariable("teamA:foo", "1");
+        repo.setVariable("teamA:bar", "2");
+        repo.setVariable("teamB:baz", "3");
+
+        repo.removeVariable("teamA:*");
+
+        assertNull(repo.getVariable("teamA:foo"));
+        assertNull(repo.getVariable("teamA:bar"));
+        assertEquals("3", repo.getVariable("teamB:baz"));
+        assertEquals(1, repo.size());
+    }
+
+    @Test
+    public void testSetNullRemoves() {
+        repo.setVariable("teamA:foo", "bar");
+        repo.setVariable("teamA:foo", null);
+
+        assertNull(repo.getVariable("teamA:foo"));
+    }
+
+    @Test
+    public void testGetGroupIds() {
+        repo.setVariable("teamA:foo", "1");
+        repo.setVariable("teamB:bar", "2");
+        repo.setVariable("teamC:baz", "3");
+
+        Set<String> ids = repo.getGroupIds();
+        assertEquals(Set.of("teamA", "teamB", "teamC"), ids);
+    }
+
+    @Test
+    public void testGetGroupIdsEmpty() {
+        assertTrue(repo.getGroupIds().isEmpty());
+    }
+
+    @Test
+    public void testGetGroupIdsAfterRemoveWildcard() {
+        repo.setVariable("teamA:foo", "1");
+        repo.setVariable("teamB:bar", "2");
+
+        repo.removeVariable("teamA:*");
+
+        assertEquals(Set.of("teamB"), repo.getGroupIds());
+    }
+
+    @Test
+    public void testNames() {
+        repo.setVariable("teamA:foo", "1");
+        repo.setVariable("teamB:bar", "2");
+
+        Set<String> names = repo.names().collect(Collectors.toSet());
+        assertEquals(Set.of("teamA:foo", "teamB:bar"), names);
+    }
+
+    @Test
+    public void testGetVariables() {
+        repo.setVariable("teamA:foo", "1");
+        repo.setVariable("teamB:bar", "2");
+
+        Map<String, Object> vars = repo.getVariables();
+        assertEquals(2, vars.size());
+        assertEquals("1", vars.get("teamA:foo"));
+        assertEquals("2", vars.get("teamB:bar"));
+    }
+
+    @Test
+    public void testHasVariables() {
+        assertFalse(repo.hasVariables());
+
+        repo.setVariable("teamA:foo", "bar");
+        assertTrue(repo.hasVariables());
+    }
+
+    @Test
+    public void testSize() {
+        assertEquals(0, repo.size());
+
+        repo.setVariable("teamA:foo", "1");
+        repo.setVariable("teamA:bar", "2");
+        repo.setVariable("teamB:baz", "3");
+        assertEquals(3, repo.size());
+    }
+
+    @Test
+    public void testClear() {
+        repo.setVariable("teamA:foo", "1");
+        repo.setVariable("teamB:bar", "2");
+
+        repo.clear();
+
+        assertFalse(repo.hasVariables());
+        assertEquals(0, repo.size());
+    }
+
+    @Test
+    public void testMissingColonThrows() {
+        assertThrows(IllegalArgumentException.class, () -> repo.getVariable("noColon"));
+        assertThrows(IllegalArgumentException.class, () -> repo.setVariable("noColon", "value"));
+        assertThrows(IllegalArgumentException.class, () -> repo.removeVariable("noColon"));
+    }
+}
diff --git a/core/camel-main/src/main/java/org/apache/camel/main/MainSupportModelConfigurer.java b/core/camel-main/src/main/java/org/apache/camel/main/MainSupportModelConfigurer.java
index 117d75e87b6d..935f7347617a 100644
--- a/core/camel-main/src/main/java/org/apache/camel/main/MainSupportModelConfigurer.java
+++ b/core/camel-main/src/main/java/org/apache/camel/main/MainSupportModelConfigurer.java
@@ -101,6 +101,10 @@ public final class MainSupportModelConfigurer {
                 id = "route";
                 key = key.substring(6);
                 key = StringHelper.replaceFirst(key, ".", ":");
+            } else if (key.startsWith("group.")) {
+                id = "group";
+                key = key.substring(6);
+                key = StringHelper.replaceFirst(key, ".", ":");
             } else if (key.startsWith("global.")) {
                 id = "global";
                 key = key.substring(7);
diff --git a/core/camel-support/src/main/java/org/apache/camel/support/GroupVariableRepository.java b/core/camel-support/src/main/java/org/apache/camel/support/GroupVariableRepository.java
new file mode 100644
index 000000000000..a9f9e33d94ee
--- /dev/null
+++ b/core/camel-support/src/main/java/org/apache/camel/support/GroupVariableRepository.java
@@ -0,0 +1,215 @@
+/*
+ * 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.support;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.stream.Stream;
+
+import org.apache.camel.CamelContext;
+import org.apache.camel.CamelContextAware;
+import org.apache.camel.StreamCache;
+import org.apache.camel.StreamCacheException;
+import org.apache.camel.spi.BrowsableVariableRepository;
+import org.apache.camel.spi.StreamCachingStrategy;
+import org.apache.camel.spi.VariableRepository;
+import org.apache.camel.support.service.ServiceSupport;
+import org.apache.camel.util.StringHelper;
+
+/**
+ * Group {@link VariableRepository} which stores variables in-memory per named group.
+ * <p>
+ * Variables are scoped by group name using the syntax {@code groupId:variableName}. This allows sharing variables
+ * across a subset of routes — wider than per-route but narrower than global scope.
+ *
+ * @since 4.21
+ */
+public final class GroupVariableRepository extends ServiceSupport implements BrowsableVariableRepository, CamelContextAware {
+
+    private final Map<String, Map<String, Object>> groups = new ConcurrentHashMap<>();
+    private CamelContext camelContext;
+    private StreamCachingStrategy strategy;
+
+    @Override
+    public CamelContext getCamelContext() {
+        return camelContext;
+    }
+
+    @Override
+    public void setCamelContext(CamelContext camelContext) {
+        this.camelContext = camelContext;
+    }
+
+    @Override
+    public Object getVariable(String name) {
+        String id = StringHelper.before(name, ":");
+        String key = StringHelper.after(name, ":");
+        if (id == null || key == null) {
+            throw new IllegalArgumentException("Name must be groupId:name syntax");
+        }
+        Object answer = null;
+        Map<String, Object> variables = groups.get(id);
+        if (variables != null) {
+            answer = variables.get(key);
+        }
+        if (answer instanceof StreamCache sc) {
+            // reset so the cache is ready to be used as a variable
+            sc.reset();
+        }
+        return answer;
+    }
+
+    @Override
+    public void setVariable(String name, Object value) {
+        String id = StringHelper.before(name, ":");
+        String key = StringHelper.after(name, ":");
+        if (id == null || key == null) {
+            throw new IllegalArgumentException("Name must be groupId:name syntax");
+        }
+
+        if (value != null && strategy != null) {
+            StreamCache sc = convertToStreamCache(value);
+            if (sc != null) {
+                value = sc;
+            }
+        }
+        if (value != null) {
+            Map<String, Object> variables = groups.computeIfAbsent(id, s -> new ConcurrentHashMap<>(8));
+            // avoid the NullPointException
+            variables.put(key, value);
+        } else {
+            // if the value is null, we just remove the key from the map
+            Map<String, Object> variables = groups.get(id);
+            if (variables != null) {
+                variables.remove(key);
+            }
+        }
+    }
+
+    public boolean hasVariables() {
+        for (var vars : groups.values()) {
+            if (!vars.isEmpty()) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    public int size() {
+        int size = 0;
+        for (var vars : groups.values()) {
+            size += vars.size();
+        }
+        return size;
+    }
+
+    public Stream<String> names() {
+        List<String> answer = new ArrayList<>();
+        for (Map.Entry<String, Map<String, Object>> entry : groups.entrySet()) {
+            String id = entry.getKey();
+            Map<String, Object> values = entry.getValue();
+            for (var e : values.entrySet()) {
+                answer.add(id + ":" + e.getKey());
+            }
+        }
+        return answer.stream();
+    }
+
+    public Map<String, Object> getVariables() {
+        Map<String, Object> answer = new ConcurrentHashMap<>();
+        for (Map.Entry<String, Map<String, Object>> entry : groups.entrySet()) {
+            String id = entry.getKey();
+            Map<String, Object> values = entry.getValue();
+            for (var e : values.entrySet()) {
+                answer.put(id + ":" + e.getKey(), e.getValue());
+            }
+        }
+        return answer;
+    }
+
+    /**
+     * Gets the ids of all groups that currently have variables.
+     */
+    public Set<String> getGroupIds() {
+        return Collections.unmodifiableSet(groups.keySet());
+    }
+
+    public void clear() {
+        groups.clear();
+    }
+
+    @Override
+    public String getId() {
+        return "group";
+    }
+
+    @Override
+    public Object removeVariable(String name) {
+        String id = StringHelper.before(name, ":");
+        String key = StringHelper.after(name, ":");
+        if (id == null || key == null) {
+            throw new IllegalArgumentException("Name must be groupId:name syntax");
+        }
+
+        Map<String, Object> variables = groups.get(id);
+        if (variables != null) {
+            if ("*".equals(key)) {
+                variables.clear();
+                groups.remove(id);
+                return null;
+            } else {
+                return variables.remove(key);
+            }
+        }
+        return null;
+    }
+
+    @Override
+    protected void doInit() throws Exception {
+        super.doInit();
+
+        if (camelContext != null && camelContext.isStreamCaching()) {
+            strategy = camelContext.getStreamCachingStrategy();
+        }
+    }
+
+    private StreamCache convertToStreamCache(Object body) {
+        // check if body is already cached
+        if (body == null) {
+            return null;
+        } else if (body instanceof StreamCache sc) {
+            // reset so the cache is ready to be used before processing
+            sc.reset();
+            return sc;
+        }
+        return tryStreamCache(body);
+    }
+
+    private StreamCache tryStreamCache(Object body) {
+        try {
+            // cache the body and if we could do that replace it as the new body
+            return strategy.cache(body);
+        } catch (Exception e) {
+            throw new StreamCacheException(body, e);
+        }
+    }
+
+}
diff --git a/docs/user-manual/modules/ROOT/pages/variables.adoc b/docs/user-manual/modules/ROOT/pages/variables.adoc
index 239019c0e855..8130a485d5ff 100644
--- a/docs/user-manual/modules/ROOT/pages/variables.adoc
+++ b/docs/user-manual/modules/ROOT/pages/variables.adoc
@@ -5,7 +5,7 @@
 In Camel 4.4, we have introduced the concept of _variables_.
 
 A variable is a key/value that can hold a value that can either be private per `Exchange`,
-or shared per route, or per `CamelContext`.
+or shared per route, per named group, or per `CamelContext`.
 
 IMPORTANT: You can also use _exchange properties_ as variables, but the exchange properties are also used internally by Camel,
 and some EIPs and components. With the newly introduced _variables_ then these are exclusively for end users.
@@ -16,6 +16,7 @@ The variables are stored in one or more `org.apache.camel.spi.VariableRepository
 
 - `ExchangeVariableRepository` - A private repository per `Exchange` that holds variables that are private for the lifecycle of each `Exchange`.
 - `RouteVariableRepository` - Uses `route` as id. A single repository, that holds variables per `Route`.
+- `GroupVariableRepository` - Uses `group` as id. A single repository, that holds variables per named group. This allows sharing variables across a subset of routes — wider than per-route but narrower than global scope.
 - `GlobalVariableRepository` - Uses `global` as id. A single global repository for the entire `CamelContext`.
 
 The `ExchangeVariableRepository` is special as its private per exchange and is the default repository when used during routing.
@@ -30,9 +31,9 @@ from management tooling, CLI, and the developer console.
 You can implement custom `org.apache.camel.spi.VariableRepository` and plugin to be used out of the box with Apache Camel.
 For example, you can build a custom repository that stores the variables in a database, so they are persisted.
 
-Each repository must have its own unique id. However, it's also possible to replace the default `global`, or `route` repositories with another.
+Each repository must have its own unique id. However, it's also possible to replace the default `global`, `route`, or `group` repositories with another.
 
-IMPORTANT: The id `exchange` and `header` is reserved by Camel internally and should not be used as id for custom repositories.
+IMPORTANT: The id `exchange`, `header`, `global`, `route`, and `group` are reserved by Camel internally and should not be used as id for custom repositories.
 
 == Getting and setting variables from Java API
 
@@ -93,6 +94,26 @@ Object val = context.getVariable("route:myRouteId:myRouteKey");
 String str = context.getVariable("route:myRouteId:myRouteKey", String.class);
 ----
 
+You can assign a variable to a named group with `group:` as follows:
+
+[source,java]
+----
+exchange.setVariable("group:teamA:myKey", someObjectHere);
+----
+
+And you can get group variables as well:
+
+[source,java]
+----
+Object val = exchange.getVariable("group:teamA:myKey");
+
+// you can get the value as a specific type
+String str = exchange.getVariable("group:teamA:myKey", String.class);
+----
+
+Group variables are shared across all routes that use the same group name,
+which makes them useful for sharing state between a subset of related routes.
+
 == Setting and getting variables from DSL
 
 It is also possible to use variables in Camel xref:routes.adoc[routes] using the:
@@ -233,7 +254,7 @@ YAML::
 
 == Configuring initial variables on startup
 
-When Camel is starting then it's possible to configure initial variables for `global` and `route` repositories only.
+When Camel is starting then it's possible to configure initial variables for `global`, `route`, and `group` repositories only.
 
 This can be done in `application.properties` as shown below:
 
@@ -256,6 +277,15 @@ camel.variable.random = 999
 
 Here the gold variable is set on the `route` repository, and the other variables are set on the `global` repository.
 
+You can also set group scoped variables, using `group.` as prefix. The dot is used to separate
+the group id from the variable name, eg `teamA.threshold`.
+
+[source,properties]
+----
+camel.variable.group.teamA.threshold = 100
+camel.variable.group.teamB.region = EU
+----
+
 The value of a variable can also be loaded from the file system, such as a JSon file. To do this, you should
 prefix the value with `resource:file:` or `resource:classpath:` to load from the file system or classpath,
 as shown below: