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

sergehuber pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/unomi.git


The following commit(s) were added to refs/heads/master by this push:
     new 854d0d615 UNOMI-1006: Generate a node id per node and run the 
heartbeat on every node (#916)
854d0d615 is described below

commit 854d0d61545623f039a406c6edacdf20b4aa8068
Author: Serge Huber <[email protected]>
AuthorDate: Thu Oct 15 06:49:46 2026 +0200

    UNOMI-1006: Generate a node id per node and run the heartbeat on every node 
(#916)
    
    Co-authored-by: Claude Opus 5.5 <[email protected]>
---
 .../java/org/apache/unomi/api/ClusterNode.java     |  39 ++++
 docker/README.md                                   |   2 +
 .../test/java/org/apache/unomi/itests/AllITs.java  |   1 +
 .../org/apache/unomi/itests/NodeIdentityIT.java    |  85 ++++++++
 kar/src/main/feature/feature.xml                   |   1 +
 lifecycle-watcher/pom.xml                          |  41 ++++
 .../org/apache/unomi/lifecycle/BundleWatcher.java  |  15 ++
 .../apache/unomi/lifecycle/BundleWatcherImpl.java  |  92 +++++++++
 .../org/apache/unomi/lifecycle/NodeIdentity.java   | 103 ++++++++++
 .../resources/OSGI-INF/blueprint/blueprint.xml     |   3 +
 .../src/main/resources/org.apache.unomi.node.cfg   |  26 +++
 .../lifecycle/BundleWatcherNodeIdentityTest.java   | 221 +++++++++++++++++++++
 .../apache/unomi/lifecycle/NodeIdentityTest.java   |  95 +++++++++
 manual/src/main/asciidoc/clustering.adoc           |  37 +++-
 manual/src/main/asciidoc/scheduler.adoc            |  11 +-
 package/pom.xml                                    |   9 +
 .../main/resources/etc/custom.system.properties    |  12 +-
 .../services/impl/cluster/ClusterServiceImpl.java  |  14 +-
 .../scheduler/PersistenceSchedulerProvider.java    |   4 +-
 .../impl/scheduler/SchedulerServiceImpl.java       |  12 +-
 .../resources/OSGI-INF/blueprint/blueprint.xml     |   4 +-
 .../main/resources/org.apache.unomi.cluster.cfg    |   8 +-
 .../main/resources/org.apache.unomi.services.cfg   |   6 +-
 .../impl/cluster/ClusterServiceImplTest.java       |  60 +++++-
 .../PersistenceSchedulerProviderTest.java          |  23 +++
 .../impl/scheduler/SchedulerServiceNodeIdTest.java |  39 ++++
 26 files changed, 926 insertions(+), 37 deletions(-)

diff --git a/api/src/main/java/org/apache/unomi/api/ClusterNode.java 
b/api/src/main/java/org/apache/unomi/api/ClusterNode.java
index 302d0b533..3a088b86a 100644
--- a/api/src/main/java/org/apache/unomi/api/ClusterNode.java
+++ b/api/src/main/java/org/apache/unomi/api/ClusterNode.java
@@ -39,6 +39,8 @@ public class ClusterNode extends Item {
     private boolean master;
     private boolean data;
     private long startTime;
+    private String nodeName;
+    private Boolean executorNode;
     private long lastHeartbeat;
 
     // Server information
@@ -196,6 +198,43 @@ public class ClusterNode extends Item {
         this.startTime = startTime;
     }
 
+    /**
+     * User friendly name of this node. Unlike the item id it is only 
informative and may change
+     * between restarts.
+     *
+     * @return node name, or {@code null} when unknown
+     */
+    public String getNodeName() {
+        return nodeName;
+    }
+
+    /**
+     * Sets the user friendly name of this node.
+     *
+     * @param nodeName node name
+     */
+    public void setNodeName(String nodeName) {
+        this.nodeName = nodeName;
+    }
+
+    /**
+     * Whether this node runs scheduled tasks ({@code scheduler.executorNode}).
+     *
+     * @return {@code true} or {@code false}, or {@code null} when the node 
did not report it
+     */
+    public Boolean getExecutorNode() {
+        return executorNode;
+    }
+
+    /**
+     * Sets whether this node runs scheduled tasks.
+     *
+     * @param executorNode whether the node runs scheduled tasks
+     */
+    public void setExecutorNode(Boolean executorNode) {
+        this.executorNode = executorNode;
+    }
+
     /**
      * When this node last sent a heartbeat (milliseconds since epoch).
      *
diff --git a/docker/README.md b/docker/README.md
index 426c72f40..06e9fbe30 100644
--- a/docker/README.md
+++ b/docker/README.md
@@ -126,6 +126,8 @@ Note: Linux doesn't support the host.docker.internal DNS 
lookup method yet, it s
 ### Common Variables
 - `UNOMI_ROOT_PASSWORD`: Required admin (`karaf`) password — no known default
 - `UNOMI_HEALTHCHECK_PASSWORD`: Required health-check (`health`) password — no 
known default
+- `UNOMI_CLUSTER_NODEID`: Optional node id, unique per container. When unset, 
Unomi generates one on the first start and stores it inside the container; set 
this variable to keep the same id when the container is recreated
+- `UNOMI_CLUSTER_NODENAME`: Optional user friendly node name (defaults to the 
container host name); it is the prefix of a generated id
 - `UNOMI_AUTO_START`: Boolean to specify if unomi auto start with karaf 
(defaults to `true`)
 - `UNOMI_DISTRIBUTION`: Specifies the Unomi Distribution Feature to use 
(`unomi-distribution-elasticsearch` or `unomi-distribution-opensearch`, 
defaults to `unomi-distribution-elasticsearch`)
 
diff --git a/itests/src/test/java/org/apache/unomi/itests/AllITs.java 
b/itests/src/test/java/org/apache/unomi/itests/AllITs.java
index 5e07fde44..50f160411 100644
--- a/itests/src/test/java/org/apache/unomi/itests/AllITs.java
+++ b/itests/src/test/java/org/apache/unomi/itests/AllITs.java
@@ -96,6 +96,7 @@ import org.junit.runners.Suite.SuiteClasses;
         TenantIT.class,
         BackgroundTenantTasksIT.class,
         SchedulerIT.class,
+        NodeIdentityIT.class,
         EventsCollectorIT.class,
         RolloverIT.class,
         HealthCheckIT.class
diff --git a/itests/src/test/java/org/apache/unomi/itests/NodeIdentityIT.java 
b/itests/src/test/java/org/apache/unomi/itests/NodeIdentityIT.java
new file mode 100644
index 000000000..da8efe972
--- /dev/null
+++ b/itests/src/test/java/org/apache/unomi/itests/NodeIdentityIT.java
@@ -0,0 +1,85 @@
+/*
+ * 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.unomi.itests;
+
+import org.apache.unomi.api.ClusterNode;
+import org.apache.unomi.api.services.ClusterService;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.ops4j.pax.exam.junit.PaxExam;
+import org.ops4j.pax.exam.spi.reactors.ExamReactorStrategy;
+import org.ops4j.pax.exam.spi.reactors.PerSuite;
+
+import java.io.IOException;
+import java.io.Reader;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.Objects;
+import java.util.Properties;
+
+import static org.junit.Assert.*;
+
+/**
+ * The ITs configure no node id, so the container goes through what a fresh 
install does: the lifecycle
+ * bundle generates an id, Karaf hands it to the cluster and scheduler 
services through their
+ * configurations, and it is written back to etc/ for the next start.
+ */
+@RunWith(PaxExam.class)
+@ExamReactorStrategy(PerSuite.class)
+public class NodeIdentityIT extends BaseIT {
+
+    private static final String NODE_CONFIG_FILE = "org.apache.unomi.node.cfg";
+
+    @Test
+    public void testGeneratedNodeIdIsUsedByClusterAndSchedulerAndStored() 
throws Exception {
+        String nodeId = bundleWatcher.getNodeId();
+        String nodeName = bundleWatcher.getNodeName();
+        assertNotNull("The lifecycle bundle should have resolved a node id", 
nodeId);
+        assertTrue("Node id " + nodeId + " should be generated as <name>-<8 
hex characters>", nodeId.matches(".+-[0-9a-f]{8}"));
+        assertFalse("The node should have a name", nodeName == null || 
nodeName.trim().isEmpty());
+
+        assertEquals("The scheduler should use the node id", nodeId, 
schedulerService.getNodeId());
+
+        ClusterService clusterService = getOsgiService(ClusterService.class, 
600000);
+        ClusterNode clusterNode = keepTrying("Node " + nodeId + " should be 
registered in the cluster",
+                () -> clusterService.getClusterNodes().stream().filter(node -> 
nodeId.equals(node.getItemId())).findFirst().orElse(null),
+                Objects::nonNull, DEFAULT_TRYING_TIMEOUT, 
DEFAULT_TRYING_TRIES);
+        assertEquals("The cluster record should carry the node name", 
nodeName, clusterNode.getNodeName());
+        assertEquals("The cluster record should say the node runs tasks", 
Boolean.TRUE, clusterNode.getExecutorNode());
+
+        // Karaf writes the configuration back to its file asynchronously
+        Path nodeConfigFile = Paths.get(System.getProperty("karaf.etc"), 
NODE_CONFIG_FILE);
+        Properties stored = keepTrying("Node id " + nodeId + " should be 
stored in " + nodeConfigFile,
+                () -> load(nodeConfigFile), properties -> 
nodeId.equals(properties.getProperty("nodeId")),
+                DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
+        assertEquals(nodeName, stored.getProperty("nodeName"));
+        assertTrue("The explanations in " + NODE_CONFIG_FILE + " should 
survive the update",
+                new String(Files.readAllBytes(nodeConfigFile), 
StandardCharsets.UTF_8).contains("Identity of this Apache Unomi node"));
+    }
+
+    private static Properties load(Path file) {
+        Properties properties = new Properties();
+        try (Reader reader = Files.newBufferedReader(file, 
StandardCharsets.UTF_8)) {
+            properties.load(reader);
+        } catch (IOException e) {
+            // Not there yet, or being written: the caller retries
+        }
+        return properties;
+    }
+}
diff --git a/kar/src/main/feature/feature.xml b/kar/src/main/feature/feature.xml
index dfecb485e..832ce089d 100644
--- a/kar/src/main/feature/feature.xml
+++ b/kar/src/main/feature/feature.xml
@@ -65,6 +65,7 @@
         <bundle>mvn:org.apache.groovy/groovy-xml/${groovy.version}</bundle>
         <bundle>mvn:org.apache.groovy/groovy-json/${groovy.version}</bundle>
 
+        <configfile 
finalname="/etc/org.apache.unomi.node.cfg">mvn:org.apache.unomi/unomi-lifecycle-watcher/${project.version}/cfg/nodecfg</configfile>
         
<bundle>mvn:org.apache.unomi/unomi-lifecycle-watcher/${project.version}</bundle>
         <bundle>mvn:org.apache.unomi/unomi-api/${project.version}</bundle>
         <bundle>mvn:org.apache.unomi/unomi-common/${project.version}</bundle>
diff --git a/lifecycle-watcher/pom.xml b/lifecycle-watcher/pom.xml
index b1556c5c7..25f4f7e78 100644
--- a/lifecycle-watcher/pom.xml
+++ b/lifecycle-watcher/pom.xml
@@ -46,6 +46,11 @@
             <artifactId>osgi.core</artifactId>
             <scope>provided</scope>
         </dependency>
+        <dependency>
+            <groupId>org.osgi</groupId>
+            <artifactId>org.osgi.service.cm</artifactId>
+            <scope>provided</scope>
+        </dependency>
         <dependency>
             <groupId>org.apache.unomi</groupId>
             <artifactId>unomi-api</artifactId>
@@ -66,6 +71,18 @@
             <artifactId>slf4j-api</artifactId>
             <scope>provided</scope>
         </dependency>
+
+        <!-- Test dependencies -->
+        <dependency>
+            <groupId>org.junit.jupiter</groupId>
+            <artifactId>junit-jupiter</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.mockito</groupId>
+            <artifactId>mockito-core</artifactId>
+            <scope>test</scope>
+        </dependency>
     </dependencies>
 
     <build>
@@ -107,6 +124,30 @@
                     </instructions>
                 </configuration>
             </plugin>
+            <plugin>
+                <groupId>org.codehaus.mojo</groupId>
+                <artifactId>build-helper-maven-plugin</artifactId>
+                <executions>
+                    <execution>
+                        <id>attach-artifacts</id>
+                        <phase>package</phase>
+                        <goals>
+                            <goal>attach-artifact</goal>
+                        </goals>
+                        <configuration>
+                            <artifacts>
+                                <artifact>
+                                    <file>
+                                        
src/main/resources/org.apache.unomi.node.cfg
+                                    </file>
+                                    <type>cfg</type>
+                                    <classifier>nodecfg</classifier>
+                                </artifact>
+                            </artifacts>
+                        </configuration>
+                    </execution>
+                </executions>
+            </plugin>
         </plugins>
     </build>
 </project>
diff --git 
a/lifecycle-watcher/src/main/java/org/apache/unomi/lifecycle/BundleWatcher.java 
b/lifecycle-watcher/src/main/java/org/apache/unomi/lifecycle/BundleWatcher.java
index 6e8ef96da..e76c92a55 100644
--- 
a/lifecycle-watcher/src/main/java/org/apache/unomi/lifecycle/BundleWatcher.java
+++ 
b/lifecycle-watcher/src/main/java/org/apache/unomi/lifecycle/BundleWatcher.java
@@ -35,6 +35,21 @@ public interface BundleWatcher {
      */
     List<ServerInfo> getServerInfos();
 
+    /**
+     * Retrieves the id of this node, which stays the same across restarts. It 
is the id the cluster
+     * and the scheduler use, unless they are configured with their own.
+     *
+     * @return the node id
+     */
+    String getNodeId();
+
+    /**
+     * Retrieves the user friendly name of this node.
+     *
+     * @return the node name
+     */
+    String getNodeName();
+
     /**
      * Indicates whether Unomi startup has completed. Implementations 
typically track
      * required bundles and initialization tasks to decide when the system is 
fully ready.
diff --git 
a/lifecycle-watcher/src/main/java/org/apache/unomi/lifecycle/BundleWatcherImpl.java
 
b/lifecycle-watcher/src/main/java/org/apache/unomi/lifecycle/BundleWatcherImpl.java
index 63850456c..72423cc1c 100644
--- 
a/lifecycle-watcher/src/main/java/org/apache/unomi/lifecycle/BundleWatcherImpl.java
+++ 
b/lifecycle-watcher/src/main/java/org/apache/unomi/lifecycle/BundleWatcherImpl.java
@@ -19,6 +19,8 @@ package org.apache.unomi.lifecycle;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.unomi.api.ServerInfo;
 import org.osgi.framework.*;
+import org.osgi.service.cm.Configuration;
+import org.osgi.service.cm.ConfigurationAdmin;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -29,6 +31,7 @@ import java.net.URL;
 import java.text.MessageFormat;
 import java.util.*;
 import java.util.concurrent.*;
+import java.util.function.Function;
 import java.util.stream.Collectors;
 
 /**
@@ -39,6 +42,17 @@ public class BundleWatcherImpl implements 
SynchronousBundleListener, ServiceList
 
     private static final Logger LOGGER = 
LoggerFactory.getLogger(BundleWatcherImpl.class.getName());
 
+    /** What the operator set (UNOMI_CLUSTER_NODEID / UNOMI_CLUSTER_NODENAME), 
blank when the id is left to Unomi. */
+    static final String CLUSTER_NODE_ID_PROPERTY = 
"org.apache.unomi.cluster.nodeId";
+    static final String CLUSTER_NODE_NAME_PROPERTY = 
"org.apache.unomi.cluster.nodeName";
+    static final String CLUSTER_NODE_ID_VARIABLE = "UNOMI_CLUSTER_NODEID";
+    /** Set by an operator who wants the scheduler to have an id of its own. */
+    static final String SCHEDULER_NODE_ID_OVERRIDE_PROPERTY = 
"org.apache.unomi.scheduler.nodeId";
+    /** {@code <pid>.<key>}: what Karaf substitutes for {@code 
scheduler.nodeId} in org.apache.unomi.services. */
+    static final String SCHEDULER_NODE_ID_PROPERTY = 
"org.apache.unomi.services.scheduler.nodeId";
+    /** Default of the node id in the etc/custom.system.properties of earlier 
versions, the same on every node. */
+    static final String FORMER_DEFAULT_NODE_ID = "unomi-node-1";
+
     private long startupTime;
     private Map<String, Boolean> requiredBundles = new ConcurrentHashMap<>();
     private Map<String, Boolean> requiredBundlesFromFeatures = new 
ConcurrentHashMap<>();
@@ -51,6 +65,10 @@ public class BundleWatcherImpl implements 
SynchronousBundleListener, ServiceList
     private long matchedRequiredServicesCount = 0;
 
     private BundleContext bundleContext;
+    private ConfigurationAdmin configurationAdmin;
+    private NodeIdentity nodeIdentity;
+    /** Environment lookup, replaceable in tests. */
+    Function<String, String> environment = System::getenv;
     private volatile boolean startupMessageAlreadyDisplayed = false;
     private boolean shutdownMessageAlreadyDisplayed = false;
 
@@ -86,7 +104,12 @@ public class BundleWatcherImpl implements 
SynchronousBundleListener, ServiceList
         this.bundleContext = bundleContext;
     }
 
+    public void setConfigurationAdmin(ConfigurationAdmin configurationAdmin) {
+        this.configurationAdmin = configurationAdmin;
+    }
+
     public void init() {
+        resolveNodeIdentity();
         scheduler = Executors.newSingleThreadScheduledExecutor();
         checkExistingBundles();
         bundleContext.addBundleListener(this);
@@ -96,6 +119,75 @@ public class BundleWatcherImpl implements 
SynchronousBundleListener, ServiceList
         LOGGER.info("Bundle watcher initialized.");
     }
 
+    /**
+     * Decides the identity of this node and makes it the id of the cluster 
and scheduler services.
+     * <p>
+     * This runs before the services bundle is created (it needs the {@link 
BundleWatcher} service). The
+     * two system properties set here are named after the configuration and 
key they stand for, which is
+     * what Karaf substitutes for {@code nodeId} in org.apache.unomi.cluster 
and {@code scheduler.nodeId}
+     * in org.apache.unomi.services when a bundle reads them. The identity is 
remembered in the
+     * {@link NodeIdentity#PID} configuration, which Karaf writes back to 
etc/, so the node keeps its id
+     * across restarts.
+     */
+    void resolveNodeIdentity() {
+        String configuredId = System.getProperty(CLUSTER_NODE_ID_PROPERTY);
+        if (FORMER_DEFAULT_NODE_ID.equals(configuredId) && 
!FORMER_DEFAULT_NODE_ID.equals(environment.apply(CLUSTER_NODE_ID_VARIABLE))) {
+            LOGGER.warn("Ignoring node id {}: it is the default of an 
etc/custom.system.properties kept from an earlier "
+                    + "version, which every node would share. Set {}={} to 
keep it.",
+                    FORMER_DEFAULT_NODE_ID, CLUSTER_NODE_ID_VARIABLE, 
FORMER_DEFAULT_NODE_ID);
+            configuredId = null;
+        }
+
+        // Only set once the stored identity could be read: one that could not 
be read must not be overwritten
+        Configuration configuration = null;
+        Dictionary<String, Object> stored = new Hashtable<>();
+        try {
+            Configuration readable = 
configurationAdmin.getConfiguration(NodeIdentity.PID, "?");
+            Dictionary<String, Object> properties = readable.getProperties();
+            if (properties != null) {
+                stored = properties;
+            }
+            configuration = readable;
+        } catch (Exception e) {
+            LOGGER.warn("Could not read the stored node identity ({}): the 
node id will only be valid until this node stops",
+                    NodeIdentity.PID, e);
+        }
+        String storedId = Objects.toString(stored.get(NodeIdentity.NODE_ID), 
null);
+        String storedName = 
Objects.toString(stored.get(NodeIdentity.NODE_NAME), null);
+
+        NodeIdentity identity = NodeIdentity.resolve(configuredId, storedId, 
System.getProperty(CLUSTER_NODE_NAME_PROPERTY),
+                NodeIdentity.localHostName(), NodeIdentity::randomSuffix);
+
+        if (configuration != null && !(identity.nodeId.equals(storedId) && 
identity.nodeName.equals(storedName))) {
+            try {
+                // The other properties stay: felix.fileinstall.filename is 
what ties the configuration to its file
+                stored.put(NodeIdentity.NODE_ID, identity.nodeId);
+                stored.put(NodeIdentity.NODE_NAME, identity.nodeName);
+                configuration.update(stored);
+            } catch (Exception e) {
+                LOGGER.warn("Could not store the node identity: node id {} is 
only valid until this node stops. "
+                        + "Set {} to keep it.", identity.nodeId, 
CLUSTER_NODE_ID_VARIABLE, e);
+            }
+        }
+
+        System.setProperty(CLUSTER_NODE_ID_PROPERTY, identity.nodeId);
+        if 
(StringUtils.isBlank(System.getProperty(SCHEDULER_NODE_ID_OVERRIDE_PROPERTY))) {
+            System.setProperty(SCHEDULER_NODE_ID_PROPERTY, identity.nodeId);
+        }
+        nodeIdentity = identity;
+        LOGGER.info("Node identity: id={} ({}), name={}", identity.nodeId, 
identity.source, identity.nodeName);
+    }
+
+    @Override
+    public String getNodeId() {
+        return nodeIdentity.nodeId;
+    }
+
+    @Override
+    public String getNodeName() {
+        return nodeIdentity.nodeName;
+    }
+
     @Override
     public List<ServerInfo> getServerInfos() {
         return serverInfos;
diff --git 
a/lifecycle-watcher/src/main/java/org/apache/unomi/lifecycle/NodeIdentity.java 
b/lifecycle-watcher/src/main/java/org/apache/unomi/lifecycle/NodeIdentity.java
new file mode 100644
index 000000000..0ae41cd5f
--- /dev/null
+++ 
b/lifecycle-watcher/src/main/java/org/apache/unomi/lifecycle/NodeIdentity.java
@@ -0,0 +1,103 @@
+/*
+ * 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.unomi.lifecycle;
+
+import org.apache.commons.lang3.StringUtils;
+
+import java.net.InetAddress;
+import java.security.SecureRandom;
+import java.util.Locale;
+import java.util.function.Supplier;
+
+/**
+ * Identity of this Unomi node: an id that stays the same across restarts and 
a user friendly name.
+ * <p>
+ * The id is the configured one when there is one, otherwise the id this node 
used before, otherwise
+ * a new one built from the node name and a random suffix (for example {@code 
web-1-3f9a2c1b}).
+ * The name is the configured one, otherwise the host name. Unlike the id, the 
name is resolved
+ * again at every start, so it follows the configuration or the host name.
+ */
+final class NodeIdentity {
+
+    /** Configuration (stored as {@code etc/org.apache.unomi.node.cfg}) that 
remembers the identity. */
+    static final String PID = "org.apache.unomi.node";
+    static final String NODE_ID = "nodeId";
+    static final String NODE_NAME = "nodeName";
+
+    static final String DEFAULT_NODE_NAME = "unomi";
+    private static final int MAX_NAME_LENGTH_IN_ID = 40;
+    private static final SecureRandom RANDOM = new SecureRandom();
+
+    final String nodeId;
+    final String nodeName;
+    /** Where the id comes from: {@code configured}, {@code stored} or {@code 
generated}. */
+    final String source;
+
+    private NodeIdentity(String nodeId, String nodeName, String source) {
+        this.nodeId = nodeId;
+        this.nodeName = nodeName;
+        this.source = source;
+    }
+
+    /**
+     * @param configuredId   id set by the operator, blank when none
+     * @param storedId       id this node used at its previous start, blank 
when none
+     * @param configuredName name set by the operator, blank when none
+     * @param hostName       host name of this machine, blank when unknown
+     * @param suffix         source of the random suffix of a generated id
+     * @return the identity to use for this start
+     */
+    static NodeIdentity resolve(String configuredId, String storedId, String 
configuredName, String hostName,
+            Supplier<String> suffix) {
+        String nodeName = StringUtils.firstNonBlank(configuredName, hostName, 
DEFAULT_NODE_NAME).trim();
+        if (StringUtils.isNotBlank(configuredId)) {
+            return new NodeIdentity(configuredId.trim(), nodeName, 
"configured");
+        }
+        if (StringUtils.isNotBlank(storedId)) {
+            return new NodeIdentity(storedId.trim(), nodeName, "stored");
+        }
+        return new NodeIdentity(toIdPrefix(nodeName) + "-" + suffix.get(), 
nodeName, "generated");
+    }
+
+    /**
+     * Reduces a name to what is safe everywhere a node id ends up (document 
ids, logs, URLs).
+     */
+    static String toIdPrefix(String nodeName) {
+        String prefix = 
nodeName.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9]+", "-");
+        prefix = StringUtils.strip(StringUtils.left(prefix, 
MAX_NAME_LENGTH_IN_ID), "-");
+        return prefix.isEmpty() ? DEFAULT_NODE_NAME : prefix;
+    }
+
+    static String randomSuffix() {
+        return String.format("%08x", RANDOM.nextInt());
+    }
+
+    /**
+     * The environment is asked first, since it is what a container sets.
+     */
+    static String localHostName() {
+        String hostName = StringUtils.firstNonBlank(System.getenv("HOSTNAME"), 
System.getenv("COMPUTERNAME"));
+        if (hostName != null) {
+            return hostName;
+        }
+        try {
+            return InetAddress.getLocalHost().getHostName();
+        } catch (Exception e) {
+            return null;
+        }
+    }
+}
diff --git 
a/lifecycle-watcher/src/main/resources/OSGI-INF/blueprint/blueprint.xml 
b/lifecycle-watcher/src/main/resources/OSGI-INF/blueprint/blueprint.xml
index ede5554de..343758c1b 100644
--- a/lifecycle-watcher/src/main/resources/OSGI-INF/blueprint/blueprint.xml
+++ b/lifecycle-watcher/src/main/resources/OSGI-INF/blueprint/blueprint.xml
@@ -30,8 +30,11 @@
         </cm:default-properties>
     </cm:property-placeholder>
 
+    <reference id="configurationAdmin" 
interface="org.osgi.service.cm.ConfigurationAdmin"/>
+
     <bean id="bundleWatcherImpl" init-method="init" destroy-method="destroy" 
class="org.apache.unomi.lifecycle.BundleWatcherImpl">
         <property name="bundleContext" ref="blueprintBundleContext"/>
+        <property name="configurationAdmin" ref="configurationAdmin"/>
         <property name="requiredServices" 
value="${lifecycle.requiredServices}" />
         <property name="checkStartupStateRefreshInterval" 
value="${lifecycle.checkStartupState.refresh.interval}"/>
         <property name="requiredBundles">
diff --git a/lifecycle-watcher/src/main/resources/org.apache.unomi.node.cfg 
b/lifecycle-watcher/src/main/resources/org.apache.unomi.node.cfg
new file mode 100644
index 000000000..0c5307fdb
--- /dev/null
+++ b/lifecycle-watcher/src/main/resources/org.apache.unomi.node.cfg
@@ -0,0 +1,26 @@
+#
+# 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.
+#
+
+# Identity of this Apache Unomi node, written by Unomi itself at startup.
+#
+# nodeId is generated on the first start and reused afterwards, so the node 
keeps its place in the
+# cluster across restarts. Setting UNOMI_CLUSTER_NODEID 
(org.apache.unomi.cluster.nodeId) replaces it.
+# To get a new id, unset that variable, blank the value below and restart; do 
not delete this file.
+nodeId=
+
+# User friendly name, from UNOMI_CLUSTER_NODENAME 
(org.apache.unomi.cluster.nodeName) or the host name.
+nodeName=
diff --git 
a/lifecycle-watcher/src/test/java/org/apache/unomi/lifecycle/BundleWatcherNodeIdentityTest.java
 
b/lifecycle-watcher/src/test/java/org/apache/unomi/lifecycle/BundleWatcherNodeIdentityTest.java
new file mode 100644
index 000000000..7bd626c5f
--- /dev/null
+++ 
b/lifecycle-watcher/src/test/java/org/apache/unomi/lifecycle/BundleWatcherNodeIdentityTest.java
@@ -0,0 +1,221 @@
+/*
+ * 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.unomi.lifecycle;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.osgi.service.cm.Configuration;
+import org.osgi.service.cm.ConfigurationAdmin;
+
+import java.io.IOException;
+import java.util.Dictionary;
+import java.util.HashMap;
+import java.util.Hashtable;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * How the lifecycle bundle decides, remembers and publishes the identity of 
the node at startup.
+ */
+class BundleWatcherNodeIdentityTest {
+
+    private static final String FILE_NAME_KEY = "felix.fileinstall.filename";
+    private static final String FILE_NAME = 
"file:/opt/unomi/etc/org.apache.unomi.node.cfg";
+    private static final String[] PROPERTIES = {
+            BundleWatcherImpl.CLUSTER_NODE_ID_PROPERTY, 
BundleWatcherImpl.CLUSTER_NODE_NAME_PROPERTY,
+            BundleWatcherImpl.SCHEDULER_NODE_ID_OVERRIDE_PROPERTY, 
BundleWatcherImpl.SCHEDULER_NODE_ID_PROPERTY};
+
+    private ConfigurationAdmin configurationAdmin;
+    private Configuration configuration;
+    private final Map<String, String> environment = new HashMap<>();
+
+    @BeforeEach
+    void setUp() throws IOException {
+        clearProperties();
+        // Fixed name, so the tests do not depend on the host they run on
+        System.setProperty(BundleWatcherImpl.CLUSTER_NODE_NAME_PROPERTY, 
"web-1");
+        configuration = mock(Configuration.class);
+        configurationAdmin = mock(ConfigurationAdmin.class);
+        when(configurationAdmin.getConfiguration(NodeIdentity.PID, 
"?")).thenReturn(configuration);
+    }
+
+    @AfterEach
+    void clearProperties() {
+        for (String property : PROPERTIES) {
+            System.clearProperty(property);
+        }
+    }
+
+    /** What Karaf holds for the configuration once it has loaded 
etc/org.apache.unomi.node.cfg. */
+    private void stored(String nodeId, String nodeName) {
+        Dictionary<String, Object> properties = new Hashtable<>();
+        properties.put(NodeIdentity.NODE_ID, nodeId);
+        properties.put(NodeIdentity.NODE_NAME, nodeName);
+        properties.put(FILE_NAME_KEY, FILE_NAME);
+        when(configuration.getProperties()).thenReturn(properties);
+    }
+
+    /** One JVM start: Karaf sets the properties from the environment, then 
the lifecycle bundle starts. */
+    private BundleWatcherImpl start() {
+        BundleWatcherImpl bundleWatcher = new BundleWatcherImpl();
+        bundleWatcher.setConfigurationAdmin(configurationAdmin);
+        bundleWatcher.environment = environment::get;
+        bundleWatcher.resolveNodeIdentity();
+        return bundleWatcher;
+    }
+
+    @SuppressWarnings("unchecked")
+    private Dictionary<String, Object> storedByUpdate() throws IOException {
+        ArgumentCaptor<Dictionary<String, Object>> captor = 
ArgumentCaptor.forClass(Dictionary.class);
+        verify(configuration).update(captor.capture());
+        return captor.getValue();
+    }
+
+    private static void assertGenerated(String nodeId) {
+        assertTrue(nodeId.matches("web-1-[0-9a-f]{8}"), nodeId);
+    }
+
+    @Test
+    void firstStartGeneratesStoresAndPublishesTheId() throws IOException {
+        stored("", "");
+
+        BundleWatcherImpl bundleWatcher = start();
+
+        String nodeId = bundleWatcher.getNodeId();
+        assertGenerated(nodeId);
+        assertEquals("web-1", bundleWatcher.getNodeName());
+
+        Dictionary<String, Object> update = storedByUpdate();
+        assertEquals(nodeId, update.get(NodeIdentity.NODE_ID));
+        assertEquals("web-1", update.get(NodeIdentity.NODE_NAME));
+        // Without it Karaf would no longer write the configuration back to 
its file in etc/
+        assertEquals(FILE_NAME, update.get(FILE_NAME_KEY));
+
+        // What the cluster and scheduler configurations are resolved with
+        assertEquals(nodeId, 
System.getProperty(BundleWatcherImpl.CLUSTER_NODE_ID_PROPERTY));
+        assertEquals(nodeId, 
System.getProperty(BundleWatcherImpl.SCHEDULER_NODE_ID_PROPERTY));
+    }
+
+    @Test
+    void restartReusesTheStoredIdWithoutRewritingIt() throws IOException {
+        stored("web-1-99999999", "web-1");
+
+        BundleWatcherImpl bundleWatcher = start();
+
+        assertEquals("web-1-99999999", bundleWatcher.getNodeId());
+        assertEquals("web-1-99999999", 
System.getProperty(BundleWatcherImpl.CLUSTER_NODE_ID_PROPERTY));
+        assertEquals("web-1-99999999", 
System.getProperty(BundleWatcherImpl.SCHEDULER_NODE_ID_PROPERTY));
+        verify(configuration, never()).update(any());
+    }
+
+    @Test
+    void renamingTheNodeKeepsItsId() throws IOException {
+        stored("web-1-99999999", "web-1");
+        System.setProperty(BundleWatcherImpl.CLUSTER_NODE_NAME_PROPERTY, 
"frontend");
+
+        BundleWatcherImpl bundleWatcher = start();
+
+        assertEquals("web-1-99999999", bundleWatcher.getNodeId());
+        assertEquals("frontend", bundleWatcher.getNodeName());
+        assertEquals("frontend", storedByUpdate().get(NodeIdentity.NODE_NAME));
+    }
+
+    @Test
+    void configuredIdWinsAndIsRememberedOnceTheVariableIsRemoved() throws 
IOException {
+        stored("web-1-99999999", "web-1");
+        System.setProperty(BundleWatcherImpl.CLUSTER_NODE_ID_PROPERTY, 
"unomi-node-7");
+
+        assertEquals("unomi-node-7", start().getNodeId());
+        Dictionary<String, Object> update = storedByUpdate();
+        assertEquals("unomi-node-7", update.get(NodeIdentity.NODE_ID));
+
+        // Next start without UNOMI_CLUSTER_NODEID: the id given back to the 
node stays
+        System.clearProperty(BundleWatcherImpl.CLUSTER_NODE_ID_PROPERTY);
+        when(configuration.getProperties()).thenReturn(update);
+        assertEquals("unomi-node-7", start().getNodeId());
+    }
+
+    /**
+     * An etc/custom.system.properties kept from an earlier version sets the 
id of every node to the same
+     * default. Only an operator who really chose that value, through the 
environment, gets it.
+     */
+    @Test
+    void formerSharedDefaultIsIgnoredUnlessChosenInTheEnvironment() throws 
IOException {
+        stored("", "");
+        System.setProperty(BundleWatcherImpl.CLUSTER_NODE_ID_PROPERTY, 
BundleWatcherImpl.FORMER_DEFAULT_NODE_ID);
+
+        assertGenerated(start().getNodeId());
+
+        System.setProperty(BundleWatcherImpl.CLUSTER_NODE_ID_PROPERTY, 
BundleWatcherImpl.FORMER_DEFAULT_NODE_ID);
+        environment.put(BundleWatcherImpl.CLUSTER_NODE_ID_VARIABLE, 
BundleWatcherImpl.FORMER_DEFAULT_NODE_ID);
+        assertEquals(BundleWatcherImpl.FORMER_DEFAULT_NODE_ID, 
start().getNodeId());
+    }
+
+    @Test
+    void schedulerOverrideIsLeftAlone() {
+        stored("web-1-99999999", "web-1");
+        
System.setProperty(BundleWatcherImpl.SCHEDULER_NODE_ID_OVERRIDE_PROPERTY, 
"scheduler-a");
+
+        start();
+
+        assertEquals("web-1-99999999", 
System.getProperty(BundleWatcherImpl.CLUSTER_NODE_ID_PROPERTY));
+        
assertNull(System.getProperty(BundleWatcherImpl.SCHEDULER_NODE_ID_PROPERTY));
+    }
+
+    @Test
+    void worksWhenTheConfigurationDoesNotExistYet() throws IOException {
+        when(configuration.getProperties()).thenReturn(null);
+
+        String nodeId = start().getNodeId();
+
+        assertGenerated(nodeId);
+        assertEquals(nodeId, storedByUpdate().get(NodeIdentity.NODE_ID));
+    }
+
+    @Test
+    void nodeStillGetsAnIdWhenItCannotBeStored() throws IOException {
+        stored("", "");
+        doThrow(new IOException("etc is 
read-only")).when(configuration).update(any());
+
+        String nodeId = start().getNodeId();
+
+        assertGenerated(nodeId);
+        assertEquals(nodeId, 
System.getProperty(BundleWatcherImpl.CLUSTER_NODE_ID_PROPERTY));
+    }
+
+    @Test
+    void storedIdentityThatCannotBeReadIsNotOverwritten() throws IOException {
+        when(configuration.getProperties()).thenThrow(new 
IllegalStateException("unavailable"));
+
+        String nodeId = start().getNodeId();
+
+        assertGenerated(nodeId);
+        assertEquals(nodeId, 
System.getProperty(BundleWatcherImpl.SCHEDULER_NODE_ID_PROPERTY));
+        verify(configuration, never()).update(any());
+    }
+}
diff --git 
a/lifecycle-watcher/src/test/java/org/apache/unomi/lifecycle/NodeIdentityTest.java
 
b/lifecycle-watcher/src/test/java/org/apache/unomi/lifecycle/NodeIdentityTest.java
new file mode 100644
index 000000000..0a5bc6559
--- /dev/null
+++ 
b/lifecycle-watcher/src/test/java/org/apache/unomi/lifecycle/NodeIdentityTest.java
@@ -0,0 +1,95 @@
+/*
+ * 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.unomi.lifecycle;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+
+import java.util.HashSet;
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class NodeIdentityTest {
+
+    private static NodeIdentity resolve(String configuredId, String storedId, 
String configuredName, String hostName) {
+        return NodeIdentity.resolve(configuredId, storedId, configuredName, 
hostName, () -> "0a1b2c3d");
+    }
+
+    @Test
+    void generatesIdFromNameWhenNothingIsConfiguredOrStored() {
+        NodeIdentity identity = resolve(null, " ", null, "web-1");
+        assertEquals("web-1-0a1b2c3d", identity.nodeId);
+        assertEquals("web-1", identity.nodeName);
+        assertEquals("generated", identity.source);
+    }
+
+    @Test
+    void storedIdIsReusedAndDoesNotFollowNameChanges() {
+        NodeIdentity identity = resolve("", "web-1-99999999", "frontend", 
"web-2");
+        assertEquals("web-1-99999999", identity.nodeId);
+        assertEquals("frontend", identity.nodeName);
+        assertEquals("stored", identity.source);
+    }
+
+    @Test
+    void configuredIdWinsOverStoredId() {
+        NodeIdentity identity = resolve(" unomi-node-7 ", "web-1-99999999", 
null, "web-1");
+        assertEquals("unomi-node-7", identity.nodeId);
+        assertEquals("configured", identity.source);
+    }
+
+    @Test
+    void configuredNameWinsOverHostName() {
+        NodeIdentity identity = resolve(null, null, "Frontend A", "web-1");
+        assertEquals("Frontend A", identity.nodeName);
+        assertEquals("frontend-a-0a1b2c3d", identity.nodeId);
+    }
+
+    @Test
+    void fallsBackToDefaultNameWithoutHostName() {
+        NodeIdentity identity = resolve(null, null, " ", null);
+        assertEquals("unomi", identity.nodeName);
+        assertEquals("unomi-0a1b2c3d", identity.nodeId);
+    }
+
+    @ParameterizedTest
+    @CsvSource({
+            "web-1, web-1",
+            "Web_1.Example.COM, web-1-example-com",
+            "'  node  ', node",
+            "--a--, a",
+            "éà, unomi",
+            "0123456789012345678901234567890123456789-tail, 
0123456789012345678901234567890123456789"
+    })
+    void idPrefixIsSafeForIdsLogsAndUrls(String nodeName, String 
expectedPrefix) {
+        assertEquals(expectedPrefix, NodeIdentity.toIdPrefix(nodeName));
+    }
+
+    @Test
+    void randomSuffixIsEightHexCharactersAndVaries() {
+        Set<String> suffixes = new HashSet<>();
+        for (int i = 0; i < 200; i++) {
+            String suffix = NodeIdentity.randomSuffix();
+            assertTrue(suffix.matches("[0-9a-f]{8}"), suffix);
+            suffixes.add(suffix);
+        }
+        assertTrue(suffixes.size() > 190, "suffixes should differ from one 
another");
+    }
+}
diff --git a/manual/src/main/asciidoc/clustering.adoc 
b/manual/src/main/asciidoc/clustering.adoc
index 6ffd776e4..356bc75ab 100644
--- a/manual/src/main/asciidoc/clustering.adoc
+++ b/manual/src/main/asciidoc/clustering.adoc
@@ -30,23 +30,52 @@ Configure each node in `etc/unomi.custom.system.properties` 
(or via environment
 ----
 
org.apache.unomi.cluster.public.address=${env:UNOMI_CLUSTER_PUBLIC_ADDRESS:-http://localhost:8181}
 
org.apache.unomi.cluster.internal.address=${env:UNOMI_CLUSTER_INTERNAL_ADDRESS:-https://localhost:9443}
-org.apache.unomi.cluster.nodeId=${env:UNOMI_CLUSTER_NODEID:-unomi-node-1}
+org.apache.unomi.cluster.nodeId=${env:UNOMI_CLUSTER_NODEID:-}
+org.apache.unomi.cluster.nodeName=${env:UNOMI_CLUSTER_NODENAME:-}
 
org.apache.unomi.cluster.nodeStatisticsUpdateFrequency=${env:UNOMI_CLUSTER_NODESTATISTICS_UPDATEFREQUENCY:-10000}
 ----
 
 * **public.address** — URL other nodes and operators use to reach this node 
(HTTP, port 8181 by default).
 * **internal.address** — HTTPS URL for the private REST API (port 9443 by 
default).
-* **nodeId** — **Must be unique** on every node in the cluster (for example 
`unomi-node-1`, `unomi-node-2`).
+* **nodeId** — Optional. Identifies the node in the cluster and in the 
scheduler; when it is not set, Unomi
+  generates one (see <<_node_identity,Node identity>>). When you set it, it 
**must be unique** in the cluster.
+* **nodeName** — Optional user friendly name, shown next to the id. Defaults 
to the host name.
 * **nodeStatisticsUpdateFrequency** — Interval in milliseconds between 
heartbeat / statistics updates (default 10 seconds).
 
-If `nodeId` is missing or duplicate, cluster registration fails at startup.
+[#_node_identity]
+==== Node identity
+
+Every node needs an id that no other node uses and that it keeps across 
restarts: the id owns the node's cluster
+record and its scheduler task locks. Nothing has to be configured for this.
+
+* On its first start a node generates an id made of its name and a random 
suffix, for example `web-1-3f9a2c1b`, and
+  stores it with its name in `etc/org.apache.unomi.node.cfg`. Later starts 
reuse it. A second node, container or
+  copy of the image generates its own, so a single node grows into a cluster 
without any change.
+* `UNOMI_CLUSTER_NODEID` (`org.apache.unomi.cluster.nodeId`) sets the id 
explicitly. It replaces the stored id and
+  is stored in turn, so the node keeps it after the variable is removed. Unomi 
does not check that an id you set is
+  unique: two nodes with the same id share one cluster record and the same 
task locks.
+* The name comes from `UNOMI_CLUSTER_NODENAME` 
(`org.apache.unomi.cluster.nodeName`), or from the host name. It is
+  read again at every start: renaming a node does not change its id.
+* The scheduler uses the same id.
+
+The stored id lives in the node's `etc/` directory and is lost with it, for 
example when a container is recreated.
+The node then joins under a new id; the old cluster record is removed once its 
heartbeat is stale and its task
+locks expire. To give a node its previous id back, start it once with 
`UNOMI_CLUSTER_NODEID` set to that id (it is
+in the node's log at startup and in the cluster REST endpoint). To make a node 
take a new id, unset
+`UNOMI_CLUSTER_NODEID`, blank `nodeId` in `etc/org.apache.unomi.node.cfg` and 
restart the node; do not delete the
+file, Unomi could then no longer save the id there.
+
+Copying an installation together with its `etc/` directory (a cloned virtual 
machine, an image built from a node
+that already ran, an `etc/` volume shared by several containers) copies the id 
too: blank it on the copies.
 
 ==== How it works
 
 On startup the cluster service:
 
 1. Registers this node in persistence (`ClusterNode` item).
-2. Schedules a recurring task to update CPU, load, and uptime statistics.
+2. Schedules a recurring task to update CPU, load, and uptime statistics. It 
is the node's heartbeat and runs on
+   every node, including those with `scheduler.executorNode=false`; the record 
also says whether the node runs
+   scheduled tasks.
 3. Schedules stale-node cleanup (nodes with no heartbeat for roughly three 
times the update interval are removed).
 
 Other nodes see the same registry through the shared search backend. The 
health-check extension reports
diff --git a/manual/src/main/asciidoc/scheduler.adoc 
b/manual/src/main/asciidoc/scheduler.adoc
index 8d902cd02..d42806766 100644
--- a/manual/src/main/asciidoc/scheduler.adoc
+++ b/manual/src/main/asciidoc/scheduler.adoc
@@ -447,7 +447,9 @@ end note
 
 ==== Node affinity timeline
 
-For a due exclusive task with active nodes `[N0, N1, N2]` (sorted) and primary 
= `hash(taskId) % 3`:
+For a due exclusive task with active nodes `[N0, N1, N2]` (sorted) and primary 
= `hash(taskId) % 3`. Active nodes are
+the nodes that sent a heartbeat in the last 5 minutes, leaving out those with 
`scheduler.executorNode=false`, which
+never run exclusive tasks:
 
 [cols="1,2,3",options="header"]
 |===
@@ -510,7 +512,8 @@ Every checker tick (~1s) on a node runs recovery:
 
 ==== Operational checklist for clustering
 
-* Set a **stable unique** `scheduler.nodeId` per JVM.
+* Each JVM needs a **stable unique** node id. Unomi generates and remembers 
one per node
+  (see <<_node_identity,Node identity>>) and the scheduler uses it.
 * Put at least one `scheduler.executorNode=true` in the cluster for persistent 
work.
 * Use `disallowParallelExecution()` for singleton side-effecting jobs.
 * Keep `scheduler.lockTimeout` at ~5s or above (default 10s): lease renewal 
keeps long executions safe automatically, but the heartbeat needs headroom over 
the `wait_for` refresh cycle (see 
<<_renewal_timing_limits_with_the_default_1_s_refresh_interval,Renewal timing 
limits>>).
@@ -610,7 +613,7 @@ Scheduler settings live in 
`etc/org.apache.unomi.services.cfg` (OSGi PID `org.ap
 |Property |Default |Description
 
 |`scheduler.thread.poolSize` |`5` |Thread pool size for task execution
-|`scheduler.nodeId` |`test-scheduler-node` |Unique ID for this node in 
scheduler logs and task metadata. Set a stable value per node in production 
clusters.
+|`scheduler.nodeId` |the node id |Unique ID for this node in scheduler locks 
and task metadata. Unomi provides the id of the node (see <<_node_identity,Node 
identity>>); a value set here is ignored. To give the scheduler an id of its 
own, set the `org.apache.unomi.scheduler.nodeId` system property, to a value 
unique per node.
 |`scheduler.executorNode` |`true` |Whether this node runs tasks. Set `false` 
on dedicated REST-only nodes if you split roles.
 |`scheduler.lockTimeout` |`10000` |Lock timeout in milliseconds
 |`scheduler.purgeTaskEnabled` |`true` |Enables the built-in periodic purge of 
old completed tasks
@@ -623,11 +626,9 @@ Scheduler settings live in 
`etc/org.apache.unomi.services.cfg` (OSGi PID `org.ap
 ----
 # Node 1 and 2: REST + task execution
 scheduler.executorNode=true
-scheduler.nodeId=unomi-executor-1
 
 # Node 3: API only, no task execution
 scheduler.executorNode=false
-scheduler.nodeId=unomi-api-3
 ----
 
 After changing configuration, restart the Unomi bundle or the Karaf container.
diff --git a/package/pom.xml b/package/pom.xml
index e633c44aa..eb3debe16 100644
--- a/package/pom.xml
+++ b/package/pom.xml
@@ -229,6 +229,15 @@
                                     </outputDirectory>
                                     
<destFileName>org.apache.unomi.persistence.opensearch.cfg</destFileName>
                                 </artifactItem>
+                                <artifactItem>
+                                    <groupId>org.apache.unomi</groupId>
+                                    
<artifactId>unomi-lifecycle-watcher</artifactId>
+                                    <version>${project.version}</version>
+                                    <classifier>nodecfg</classifier>
+                                    <type>cfg</type>
+                                    
<outputDirectory>${project.build.directory}/assembly/etc</outputDirectory>
+                                    
<destFileName>org.apache.unomi.node.cfg</destFileName>
+                                </artifactItem>
                                 <artifactItem>
                                     <groupId>org.apache.unomi</groupId>
                                     <artifactId>unomi-services</artifactId>
diff --git a/package/src/main/resources/etc/custom.system.properties 
b/package/src/main/resources/etc/custom.system.properties
index 126afb1a4..eb1aeed2d 100644
--- a/package/src/main/resources/etc/custom.system.properties
+++ b/package/src/main/resources/etc/custom.system.properties
@@ -84,10 +84,14 @@ 
org.apache.unomi.admin.servlet.context=${env:UNOMI_ADMIN_CONTEXT:-/cxs}
 # to switch to using HTTPS with a proper SSL certificate installed.
 
org.apache.unomi.cluster.public.address=${env:UNOMI_CLUSTER_PUBLIC_ADDRESS:-http://localhost:8181}
 
org.apache.unomi.cluster.internal.address=${env:UNOMI_CLUSTER_INTERNAL_ADDRESS:-https://localhost:9443}
-# The nodeId is a required setting that uniquely identifies this node in the 
cluster.
-# It must be set to a unique value for each node in the cluster.
-# Example: nodeId=node1
-org.apache.unomi.cluster.nodeId=${env:UNOMI_CLUSTER_NODEID:-unomi-node-1}
+# The nodeId uniquely identifies this node in the cluster and in the 
scheduler. Leave it unset to let
+# Unomi generate one on the first start and reuse it afterwards (it is 
remembered in
+# etc/org.apache.unomi.node.cfg). Set it to choose the id yourself, or to give 
a node its previous id
+# back; it must be unique in the cluster.
+org.apache.unomi.cluster.nodeId=${env:UNOMI_CLUSTER_NODEID:-}
+# User friendly name of this node, shown next to its id and used as the prefix 
of a generated id.
+# Defaults to the host name.
+org.apache.unomi.cluster.nodeName=${env:UNOMI_CLUSTER_NODENAME:-}
 # The nodeStatisticsUpdateFrequency controls the frequency of the update of 
system statistics such as CPU load,
 # system load average and uptime. This value is set in milliseconds and is set 
to 10 seconds by default. Each node
 # will retrieve the local values and broadcast them through a cluster event to 
all the other nodes to update
diff --git 
a/services/src/main/java/org/apache/unomi/services/impl/cluster/ClusterServiceImpl.java
 
b/services/src/main/java/org/apache/unomi/services/impl/cluster/ClusterServiceImpl.java
index 65fb93abe..e56e8dd27 100644
--- 
a/services/src/main/java/org/apache/unomi/services/impl/cluster/ClusterServiceImpl.java
+++ 
b/services/src/main/java/org/apache/unomi/services/impl/cluster/ClusterServiceImpl.java
@@ -174,7 +174,14 @@ public class ClusterServiceImpl implements ClusterService {
                 }
             }
         };
-        schedulerService.createRecurringTask("clusterNodeStatisticsUpdate", 
nodeStatisticsUpdateFrequency, TimeUnit.MILLISECONDS, statisticsTask, false);
+        // Every node heartbeats, executor or not: a node that stops is 
removed from the cluster as stale
+        schedulerService.newTask("clusterNodeStatisticsUpdate")
+                .withPeriod(nodeStatisticsUpdateFrequency, 
TimeUnit.MILLISECONDS)
+                .withFixedRate()
+                .withSimpleExecutor(statisticsTask)
+                .nonPersistent()
+                .runOnAllNodes()
+                .schedule();
 
         // Schedule cleanup of stale nodes
         TimerTask cleanupTask = new TimerTask() {
@@ -285,6 +292,8 @@ public class ClusterServiceImpl implements ClusterService {
 
         ClusterNode clusterNode = new ClusterNode();
         clusterNode.setItemId(nodeId);
+        clusterNode.setNodeName(bundleWatcher != null ? 
bundleWatcher.getNodeName() : null);
+        clusterNode.setExecutorNode(schedulerService != null ? 
schedulerService.isExecutorNode() : null);
         clusterNode.setPublicHostAddress(publicAddress);
         clusterNode.setInternalHostAddress(internalAddress);
         clusterNode.setStartTime(nodeStartTime);
@@ -376,6 +385,9 @@ public class ClusterServiceImpl implements ClusterService {
         try {
             // Update its stats
             updateSystemStatsForNode(node);
+            if (schedulerService != null) {
+                node.setExecutorNode(schedulerService.isExecutorNode());
+            }
 
             // Update server info if needed
             if (bundleWatcher != null && 
!bundleWatcher.getServerInfos().isEmpty()) {
diff --git 
a/services/src/main/java/org/apache/unomi/services/impl/scheduler/PersistenceSchedulerProvider.java
 
b/services/src/main/java/org/apache/unomi/services/impl/scheduler/PersistenceSchedulerProvider.java
index 77a383807..cb538fd47 100644
--- 
a/services/src/main/java/org/apache/unomi/services/impl/scheduler/PersistenceSchedulerProvider.java
+++ 
b/services/src/main/java/org/apache/unomi/services/impl/scheduler/PersistenceSchedulerProvider.java
@@ -394,7 +394,9 @@ public class PersistenceSchedulerProvider implements 
SchedulerProvider {
                     long cutoffTime = System.currentTimeMillis() - (5 * 60 * 
1000); // 5 minutes threshold
 
                     for (ClusterNode node : clusterNodes) {
-                        if (node.getLastHeartbeat() > cutoffTime) {
+                        // A node that does not run tasks must not be picked 
as the primary owner of one; a node
+                        // that does not report its role (earlier version) 
still runs tasks.
+                        if (node.getLastHeartbeat() > cutoffTime && 
!Boolean.FALSE.equals(node.getExecutorNode())) {
                             activeNodes.add(node.getItemId());
                         }
                     }
diff --git 
a/services/src/main/java/org/apache/unomi/services/impl/scheduler/SchedulerServiceImpl.java
 
b/services/src/main/java/org/apache/unomi/services/impl/scheduler/SchedulerServiceImpl.java
index 5e84267da..ee10a362c 100644
--- 
a/services/src/main/java/org/apache/unomi/services/impl/scheduler/SchedulerServiceImpl.java
+++ 
b/services/src/main/java/org/apache/unomi/services/impl/scheduler/SchedulerServiceImpl.java
@@ -771,6 +771,14 @@ public class SchedulerServiceImpl implements 
SchedulerService {
      * Blueprint post-construct hook; starts task checking on executor nodes.
      */
     public void postConstruct() {
+        // The id comes from the node identity (see BundleWatcher). A blank 
one would be shared by every node.
+        if (nodeId == null || nodeId.isBlank()) {
+            String errorMessage = "CRITICAL: scheduler nodeId is not set. 
Check that scheduler.nodeId is present in "
+                    + "etc/org.apache.unomi.services.cfg.";
+            LOGGER.error(errorMessage);
+            throw new IllegalStateException(errorMessage);
+        }
+
         if (bundleContext == null) {
             LOGGER.error("BundleContext is null, cannot initialize service 
trackers");
             return;
@@ -799,10 +807,6 @@ public class SchedulerServiceImpl implements 
SchedulerService {
             queuePendingOperation(OperationType.INITIALIZE_TASK_PURGE, 
"Initialize task purge");
         }
 
-        if (nodeId == null) {
-            nodeId = UUID.randomUUID().toString();
-        }
-
         LOGGER.info("Scheduler service initialized. Node ID: {}, Executor 
node: {}, Thread pool size: {}",
             nodeId, executorNode, Math.max(MIN_THREAD_POOL_SIZE, 
threadPoolSize));
 
diff --git a/services/src/main/resources/OSGI-INF/blueprint/blueprint.xml 
b/services/src/main/resources/OSGI-INF/blueprint/blueprint.xml
index 6002b721a..96d69dfff 100644
--- a/services/src/main/resources/OSGI-INF/blueprint/blueprint.xml
+++ b/services/src/main/resources/OSGI-INF/blueprint/blueprint.xml
@@ -117,7 +117,7 @@
             <cm:property name="scheduler.thread.poolSize" value="5"/>
             <cm:property name="scheduler.executorNode" value="true" />
             <cm:property name="scheduler.completedTaskTtlDays" value="30" />
-            <cm:property name="scheduler.nodeId" value="test-scheduler-node" />
+            <cm:property name="scheduler.nodeId" value="" />
             <cm:property name="scheduler.lockTimeout" value="10000" />
             <cm:property name="scheduler.purgeTaskEnabled" value="true" />
             <cm:property name="scopes.refresh.interval" value="1000"/>
@@ -129,7 +129,7 @@
     <cm:property-placeholder persistent-id="org.apache.unomi.cluster"
                              update-strategy="reload" 
placeholder-prefix="${cluster.">
         <cm:default-properties>
-            <cm:property name="nodeId" value="unomi-node-1"/>
+            <cm:property name="nodeId" value=""/>
             <cm:property name="contextserver.publicAddress" 
value="https://localhost:9443"/>
             <cm:property name="contextserver.internalAddress" 
value="http://127.0.0.1:8181"/>
             <cm:property name="nodeStatisticsUpdateFrequency" value="10000"/>
diff --git a/services/src/main/resources/org.apache.unomi.cluster.cfg 
b/services/src/main/resources/org.apache.unomi.cluster.cfg
index 44fd720db..2d1f5ee89 100644
--- a/services/src/main/resources/org.apache.unomi.cluster.cfg
+++ b/services/src/main/resources/org.apache.unomi.cluster.cfg
@@ -19,10 +19,10 @@
 
contextserver.publicAddress=${org.apache.unomi.cluster.public.address:-http://localhost:8181}
 
contextserver.internalAddress=${org.apache.unomi.cluster.internal.address:-https://localhost:9443}
 #
-# The nodeId is a required setting that uniquely identifies this node in the 
cluster.
-# It must be set to a unique value for each node in the cluster.
-# Example: nodeId=node1
-nodeId=${org.apache.unomi.cluster.nodeId:-unomi-node-1}
+# The nodeId uniquely identifies this node in the cluster. Do not set it here: 
when
+# org.apache.unomi.cluster.nodeId (UNOMI_CLUSTER_NODEID) is not set, Unomi 
generates an id on the
+# first start, remembers it in etc/org.apache.unomi.node.cfg and provides it 
through that property.
+nodeId=${org.apache.unomi.cluster.nodeId:-}
 #
 # The nodeStatisticsUpdateFrequency controls the frequency of the update of 
system statistics such as CPU load,
 # system load average and uptime. This value is set in milliseconds and is set 
to 10 seconds by default. Each node
diff --git a/services/src/main/resources/org.apache.unomi.services.cfg 
b/services/src/main/resources/org.apache.unomi.services.cfg
index 86c1b8a1a..dccffafda 100644
--- a/services/src/main/resources/org.apache.unomi.services.cfg
+++ b/services/src/main/resources/org.apache.unomi.services.cfg
@@ -89,8 +89,10 @@ 
rules.optimizationActivated=${org.apache.unomi.rules.optimizationActivated:-true
 # The number of threads to compose the pool size of the scheduler.
 scheduler.thread.poolSize=${org.apache.unomi.scheduler.thread.poolSize:-5}
 
-# The node id to use for the scheduler.
-scheduler.nodeId=${org.apache.unomi.scheduler.nodeId:-test-scheduler-node}
+# The node id to use for the scheduler. Do not set it here: Unomi provides the 
id of the node (see
+# etc/org.apache.unomi.node.cfg). Set org.apache.unomi.scheduler.nodeId only 
to give the scheduler an id
+# of its own, which must then be unique per node.
+scheduler.nodeId=${org.apache.unomi.scheduler.nodeId:-}
 
 # The lock timeout to use for the scheduler.
 scheduler.lockTimeout=${org.apache.unomi.scheduler.lockTimeout:-10000}
diff --git 
a/services/src/test/java/org/apache/unomi/services/impl/cluster/ClusterServiceImplTest.java
 
b/services/src/test/java/org/apache/unomi/services/impl/cluster/ClusterServiceImplTest.java
index bd7bc231f..8819220b6 100644
--- 
a/services/src/test/java/org/apache/unomi/services/impl/cluster/ClusterServiceImplTest.java
+++ 
b/services/src/test/java/org/apache/unomi/services/impl/cluster/ClusterServiceImplTest.java
@@ -18,6 +18,7 @@ package org.apache.unomi.services.impl.cluster;
 
 import org.apache.unomi.api.ClusterNode;
 import org.apache.unomi.api.ServerInfo;
+import org.apache.unomi.api.services.SchedulerService;
 import org.apache.unomi.lifecycle.BundleWatcher;
 import org.apache.unomi.persistence.spi.PersistenceService;
 import 
org.apache.unomi.persistence.spi.conditions.evaluator.ConditionEvaluatorDispatcher;
@@ -157,6 +158,45 @@ public class ClusterServiceImplTest {
     }
 
     // Add a new test to verify behavior when BundleWatcher is not available
+    @Test
+    public void testHeartbeatRunsOnNonExecutorNodeAndReportsItsRole() {
+        ClusterServiceImpl apiNode = 
TestHelper.createClusterService(persistenceService, "api-node", PUBLIC_ADDRESS, 
INTERNAL_ADDRESS, bundleContext);
+        apiNode.setNodeStatisticsUpdateFrequency(100);
+        SchedulerServiceImpl apiScheduler = TestHelper.createSchedulerService(
+            "api-node", persistenceService, executionContextManager, 
bundleContext, apiNode, -1, false, true);
+        apiNode.setSchedulerService(apiScheduler);
+        try {
+            apiNode.init();
+            ClusterNode registered = persistenceService.load("api-node", 
ClusterNode.class);
+            assertEquals(Boolean.FALSE, registered.getExecutorNode());
+            long initialHeartbeat = registered.getLastHeartbeat();
+
+            // Without a heartbeat the node would be removed from the cluster 
as stale
+            ClusterNode updated = null;
+            for (int i = 0; i < 100 && (updated == null || 
updated.getLastHeartbeat() <= initialHeartbeat); i++) {
+                Thread.sleep(100);
+                updated = persistenceService.load("api-node", 
ClusterNode.class);
+            }
+            assertTrue(updated.getLastHeartbeat() > initialHeartbeat, "the 
heartbeat of a non-executor node should run");
+            assertEquals(Boolean.FALSE, updated.getExecutorNode());
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            fail("interrupted");
+        } finally {
+            apiScheduler.preDestroy();
+        }
+    }
+
+    @Test
+    public void testInitRegistersNodeNameFromBundleWatcher() {
+        when(bundleWatcher.getNodeName()).thenReturn("web-1");
+        clusterService.setBundleWatcher(bundleWatcher);
+
+        clusterService.init();
+
+        assertEquals("web-1", persistenceService.load(TEST_NODE_ID, 
ClusterNode.class).getNodeName());
+    }
+
     @Test
     public void testInitRegistersNodeInPersistenceWithoutBundleWatcher() {
         // Execute without setting a BundleWatcher
@@ -174,26 +214,26 @@ public class ClusterServiceImplTest {
     @Test
     public void testInitSchedulesStatisticsUpdateTask() {
         // We need to use a mock scheduler service for this test to verify the 
task creation
-        SchedulerServiceImpl mockSchedulerService = 
mock(SchedulerServiceImpl.class);
+        SchedulerService mockSchedulerService = mock(SchedulerService.class);
+        SchedulerService.TaskBuilder heartbeatBuilder = 
mock(SchedulerService.TaskBuilder.class, RETURNS_SELF);
+        
when(mockSchedulerService.newTask("clusterNodeStatisticsUpdate")).thenReturn(heartbeatBuilder);
         clusterService.setSchedulerService(mockSchedulerService);
 
         // Execute
         clusterService.init();
 
-        // Verify
-        verify(mockSchedulerService).createRecurringTask(
-            eq("clusterNodeStatisticsUpdate"),
-            eq(NODE_STATISTICS_UPDATE_FREQUENCY),
-            eq(TimeUnit.MILLISECONDS),
-            any(TimerTask.class),
-            eq(false)
-        );
+        // Verify: a local, recurring heartbeat that runs on every node, 
executor or not
+        verify(heartbeatBuilder).withPeriod(NODE_STATISTICS_UPDATE_FREQUENCY, 
TimeUnit.MILLISECONDS);
+        verify(heartbeatBuilder).nonPersistent();
+        verify(heartbeatBuilder).runOnAllNodes();
+        verify(heartbeatBuilder).schedule();
     }
 
     @Test
     public void testInitSchedulesStaleNodesCleanupTask() {
         // We need to use a mock scheduler service for this test to verify the 
task creation
-        SchedulerServiceImpl mockSchedulerService = 
mock(SchedulerServiceImpl.class);
+        SchedulerService mockSchedulerService = mock(SchedulerService.class);
+        
when(mockSchedulerService.newTask(any())).thenReturn(mock(SchedulerService.TaskBuilder.class,
 RETURNS_SELF));
         clusterService.setSchedulerService(mockSchedulerService);
 
         // Execute
diff --git 
a/services/src/test/java/org/apache/unomi/services/impl/scheduler/PersistenceSchedulerProviderTest.java
 
b/services/src/test/java/org/apache/unomi/services/impl/scheduler/PersistenceSchedulerProviderTest.java
index f191c210b..b05619bbd 100644
--- 
a/services/src/test/java/org/apache/unomi/services/impl/scheduler/PersistenceSchedulerProviderTest.java
+++ 
b/services/src/test/java/org/apache/unomi/services/impl/scheduler/PersistenceSchedulerProviderTest.java
@@ -164,6 +164,29 @@ public class PersistenceSchedulerProviderTest {
         assertFalse(nodes.contains("stale"));
     }
 
+    @Test
+    public void testGetActiveNodesLeavesOutNodesThatDoNotRunTasks() {
+        ClusterNode executor = new ClusterNode();
+        executor.setItemId("executor");
+        executor.setExecutorNode(true);
+        executor.setLastHeartbeat(System.currentTimeMillis());
+        ClusterNode apiOnly = new ClusterNode();
+        apiOnly.setItemId("api-only");
+        apiOnly.setExecutorNode(false);
+        apiOnly.setLastHeartbeat(System.currentTimeMillis());
+        // Written by an earlier version, which does not report the role
+        ClusterNode unknown = new ClusterNode();
+        unknown.setItemId("unknown-role");
+        unknown.setLastHeartbeat(System.currentTimeMillis());
+        
when(clusterService.getClusterNodes()).thenReturn(Arrays.asList(executor, 
apiOnly, unknown));
+
+        List<String> nodes = provider.getActiveNodes();
+        assertTrue(nodes.contains("provider-node"));
+        assertTrue(nodes.contains("executor"));
+        assertTrue(nodes.contains("unknown-role"));
+        assertFalse(nodes.contains("api-only"));
+    }
+
     @Test
     public void testGetActiveNodesFallbackWhenClusterUnavailable() {
         when(clusterService.getClusterNodes()).thenThrow(new 
RuntimeException("down"));
diff --git 
a/services/src/test/java/org/apache/unomi/services/impl/scheduler/SchedulerServiceNodeIdTest.java
 
b/services/src/test/java/org/apache/unomi/services/impl/scheduler/SchedulerServiceNodeIdTest.java
new file mode 100644
index 000000000..e0624ad64
--- /dev/null
+++ 
b/services/src/test/java/org/apache/unomi/services/impl/scheduler/SchedulerServiceNodeIdTest.java
@@ -0,0 +1,39 @@
+/*
+ * 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.unomi.services.impl.scheduler;
+
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.NullSource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+class SchedulerServiceNodeIdTest {
+
+    /**
+     * A blank id would be the lock owner on every node that has it, so the 
scheduler must not start
+     * with one, like the cluster service.
+     */
+    @ParameterizedTest
+    @NullSource
+    @ValueSource(strings = {"", "  "})
+    void postConstructFailsOnBlankNodeId(String blankNodeId) {
+        SchedulerServiceImpl scheduler = new SchedulerServiceImpl();
+        scheduler.setNodeId(blankNodeId);
+        assertThrows(IllegalStateException.class, scheduler::postConstruct);
+    }
+}

Reply via email to