Skip to content

Commit 6861d35

Browse files
authored
Persistent Node Ids (elastic#19140)
Node IDs are currently randomly generated during node startup. That means they change every time the node is restarted. While this doesn't matter for ES proper, it makes it hard for external services to track nodes. Another, more minor, side effect is that indexing the output of, say, the node stats API results in creating new fields due to node ID being used as keys. The first approach I considered was to use the node's published address as the base for the id. We already [treat nodes with the same address as the same](https://github.com/elastic/elasticsearch/blob/master/core/src/main/java/org/elasticsearch/discovery/zen/NodeJoinController.java#L387) so this is a simple change (see [here](elastic/elasticsearch@master...bleskes:node_persistent_id_based_on_address)). While this is simple and it works for probably most cases, it is not perfect. For example, if after a node restart, the node is not able to bind to the same port (because it's not yet freed by the OS), it will cause the node to still change identity. Also in environments where the host IP can change due to a host restart, identity will not be the same. Due to those limitation, I opted to go with a different approach where the node id will be persisted in the node's data folder. This has the upside of connecting the id to the nodes data. It also means that the host can be adapted in any way (replace network cards, attach storage to a new VM). I It does however also have downsides - we now run the risk of two nodes having the same id, if someone copies clones a data folder from one node to another. To mitigate this I changed the semantics of the protection against multiple nodes with the same address to be stricter - it will now reject the incoming join if a node exists with the same id but a different address. Note that if the existing node doesn't respond to pings (i.e., it's not alive) it will be removed and the new node will be accepted when it tries another join. Last, and most importantly, this change requires that *all* nodes persist data to disk. This is a change from current behavior where only data & master nodes store local files. This is the main reason for marking this PR as breaking. Other less important notes: - DummyTransportAddress is removed as we need a unique network address per node. Use `LocalTransportAddress.buildUnique()` instead. - I renamed `node.add_lid_to_custom_path` to `node.add_lock_id_to_custom_path` to avoid confusion with the node ID which is now part of the `NodeEnvironment` logic. - I removed the `version` paramater from `MetaDataStateFormat#write` , it wasn't really used and was just in the way :) - TribeNodes are special in the sense that they do start multiple sub-nodes (previously known as client nodes). Those sub-nodes do not store local files but derive their ID from the parent node id, so they are generated consistently.
1 parent ed444cd commit 6861d35

File tree

88 files changed

+862
-494
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

88 files changed

+862
-494
lines changed

benchmarks/src/main/java/org/elasticsearch/benchmark/routing/allocation/Allocators.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
import org.elasticsearch.cluster.routing.allocation.decider.AllocationDeciders;
3232
import org.elasticsearch.common.settings.ClusterSettings;
3333
import org.elasticsearch.common.settings.Settings;
34-
import org.elasticsearch.common.transport.DummyTransportAddress;
34+
import org.elasticsearch.common.transport.LocalTransportAddress;
3535
import org.elasticsearch.common.util.set.Sets;
3636
import org.elasticsearch.gateway.GatewayAllocator;
3737

@@ -102,7 +102,7 @@ public static AllocationDeciders defaultAllocationDeciders(Settings settings, Cl
102102
}
103103

104104
public static DiscoveryNode newNode(String nodeId, Map<String, String> attributes) {
105-
return new DiscoveryNode("", nodeId, DummyTransportAddress.INSTANCE, attributes, Sets.newHashSet(DiscoveryNode.Role.MASTER,
105+
return new DiscoveryNode("", nodeId, LocalTransportAddress.buildUnique(), attributes, Sets.newHashSet(DiscoveryNode.Role.MASTER,
106106
DiscoveryNode.Role.DATA), Version.CURRENT);
107107
}
108108
}

buildSrc/src/main/resources/checkstyle_suppressions.xml

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -266,7 +266,6 @@
266266
<suppress files="core[/\\]src[/\\]main[/\\]java[/\\]org[/\\]elasticsearch[/\\]cluster[/\\]metadata[/\\]MetaDataMappingService.java" checks="LineLength" />
267267
<suppress files="core[/\\]src[/\\]main[/\\]java[/\\]org[/\\]elasticsearch[/\\]cluster[/\\]metadata[/\\]MetaDataUpdateSettingsService.java" checks="LineLength" />
268268
<suppress files="core[/\\]src[/\\]main[/\\]java[/\\]org[/\\]elasticsearch[/\\]cluster[/\\]metadata[/\\]RepositoriesMetaData.java" checks="LineLength" />
269-
<suppress files="core[/\\]src[/\\]main[/\\]java[/\\]org[/\\]elasticsearch[/\\]cluster[/\\]node[/\\]DiscoveryNodes.java" checks="LineLength" />
270269
<suppress files="core[/\\]src[/\\]main[/\\]java[/\\]org[/\\]elasticsearch[/\\]cluster[/\\]routing[/\\]IndexRoutingTable.java" checks="LineLength" />
271270
<suppress files="core[/\\]src[/\\]main[/\\]java[/\\]org[/\\]elasticsearch[/\\]cluster[/\\]routing[/\\]IndexShardRoutingTable.java" checks="LineLength" />
272271
<suppress files="core[/\\]src[/\\]main[/\\]java[/\\]org[/\\]elasticsearch[/\\]cluster[/\\]routing[/\\]OperationRouting.java" checks="LineLength" />
@@ -341,12 +340,9 @@
341340
<suppress files="core[/\\]src[/\\]main[/\\]java[/\\]org[/\\]elasticsearch[/\\]discovery[/\\]DiscoveryService.java" checks="LineLength" />
342341
<suppress files="core[/\\]src[/\\]main[/\\]java[/\\]org[/\\]elasticsearch[/\\]discovery[/\\]DiscoverySettings.java" checks="LineLength" />
343342
<suppress files="core[/\\]src[/\\]main[/\\]java[/\\]org[/\\]elasticsearch[/\\]discovery[/\\]local[/\\]LocalDiscovery.java" checks="LineLength" />
344-
<suppress files="core[/\\]src[/\\]main[/\\]java[/\\]org[/\\]elasticsearch[/\\]discovery[/\\]zen[/\\]NodeJoinController.java" checks="LineLength" />
345343
<suppress files="core[/\\]src[/\\]main[/\\]java[/\\]org[/\\]elasticsearch[/\\]discovery[/\\]zen[/\\]ZenDiscovery.java" checks="LineLength" />
346344
<suppress files="core[/\\]src[/\\]main[/\\]java[/\\]org[/\\]elasticsearch[/\\]discovery[/\\]zen[/\\]elect[/\\]ElectMasterService.java" checks="LineLength" />
347345
<suppress files="core[/\\]src[/\\]main[/\\]java[/\\]org[/\\]elasticsearch[/\\]discovery[/\\]zen[/\\]fd[/\\]FaultDetection.java" checks="LineLength" />
348-
<suppress files="core[/\\]src[/\\]main[/\\]java[/\\]org[/\\]elasticsearch[/\\]discovery[/\\]zen[/\\]fd[/\\]MasterFaultDetection.java" checks="LineLength" />
349-
<suppress files="core[/\\]src[/\\]main[/\\]java[/\\]org[/\\]elasticsearch[/\\]discovery[/\\]zen[/\\]fd[/\\]NodesFaultDetection.java" checks="LineLength" />
350346
<suppress files="core[/\\]src[/\\]main[/\\]java[/\\]org[/\\]elasticsearch[/\\]discovery[/\\]zen[/\\]membership[/\\]MembershipAction.java" checks="LineLength" />
351347
<suppress files="core[/\\]src[/\\]main[/\\]java[/\\]org[/\\]elasticsearch[/\\]discovery[/\\]zen[/\\]ping[/\\]ZenPing.java" checks="LineLength" />
352348
<suppress files="core[/\\]src[/\\]main[/\\]java[/\\]org[/\\]elasticsearch[/\\]discovery[/\\]zen[/\\]publish[/\\]PendingClusterStatesQueue.java" checks="LineLength" />
@@ -357,7 +353,6 @@
357353
<suppress files="core[/\\]src[/\\]main[/\\]java[/\\]org[/\\]elasticsearch[/\\]gateway[/\\]GatewayMetaState.java" checks="LineLength" />
358354
<suppress files="core[/\\]src[/\\]main[/\\]java[/\\]org[/\\]elasticsearch[/\\]gateway[/\\]GatewayService.java" checks="LineLength" />
359355
<suppress files="core[/\\]src[/\\]main[/\\]java[/\\]org[/\\]elasticsearch[/\\]gateway[/\\]LocalAllocateDangledIndices.java" checks="LineLength" />
360-
<suppress files="core[/\\]src[/\\]main[/\\]java[/\\]org[/\\]elasticsearch[/\\]gateway[/\\]MetaDataStateFormat.java" checks="LineLength" />
361356
<suppress files="core[/\\]src[/\\]main[/\\]java[/\\]org[/\\]elasticsearch[/\\]gateway[/\\]PrimaryShardAllocator.java" checks="LineLength" />
362357
<suppress files="core[/\\]src[/\\]main[/\\]java[/\\]org[/\\]elasticsearch[/\\]gateway[/\\]ReplicaShardAllocator.java" checks="LineLength" />
363358
<suppress files="core[/\\]src[/\\]main[/\\]java[/\\]org[/\\]elasticsearch[/\\]gateway[/\\]TransportNodesListGatewayMetaState.java" checks="LineLength" />
@@ -847,7 +842,6 @@
847842
<suppress files="core[/\\]src[/\\]test[/\\]java[/\\]org[/\\]elasticsearch[/\\]discovery[/\\]BlockingClusterStatePublishResponseHandlerTests.java" checks="LineLength" />
848843
<suppress files="core[/\\]src[/\\]test[/\\]java[/\\]org[/\\]elasticsearch[/\\]discovery[/\\]DiscoveryWithServiceDisruptionsIT.java" checks="LineLength" />
849844
<suppress files="core[/\\]src[/\\]test[/\\]java[/\\]org[/\\]elasticsearch[/\\]discovery[/\\]ZenUnicastDiscoveryIT.java" checks="LineLength" />
850-
<suppress files="core[/\\]src[/\\]test[/\\]java[/\\]org[/\\]elasticsearch[/\\]discovery[/\\]zen[/\\]NodeJoinControllerTests.java" checks="LineLength" />
851845
<suppress files="core[/\\]src[/\\]test[/\\]java[/\\]org[/\\]elasticsearch[/\\]discovery[/\\]zen[/\\]ZenDiscoveryUnitTests.java" checks="LineLength" />
852846
<suppress files="core[/\\]src[/\\]test[/\\]java[/\\]org[/\\]elasticsearch[/\\]discovery[/\\]zen[/\\]publish[/\\]PublishClusterStateActionTests.java" checks="LineLength" />
853847
<suppress files="core[/\\]src[/\\]test[/\\]java[/\\]org[/\\]elasticsearch[/\\]document[/\\]DocumentActionsIT.java" checks="LineLength" />

core/src/main/java/org/elasticsearch/client/transport/TransportClientNodesService.java

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -397,9 +397,9 @@ public LivenessResponse newInstance() {
397397
// use discovered information but do keep the original transport address,
398398
// so people can control which address is exactly used.
399399
DiscoveryNode nodeWithInfo = livenessResponse.getDiscoveryNode();
400-
newNodes.add(new DiscoveryNode(nodeWithInfo.getName(), nodeWithInfo.getId(), nodeWithInfo.getHostName(),
401-
nodeWithInfo.getHostAddress(), listedNode.getAddress(), nodeWithInfo.getAttributes(),
402-
nodeWithInfo.getRoles(), nodeWithInfo.getVersion()));
400+
newNodes.add(new DiscoveryNode(nodeWithInfo.getName(), nodeWithInfo.getId(), nodeWithInfo.getEphemeralId(),
401+
nodeWithInfo.getHostName(), nodeWithInfo.getHostAddress(), listedNode.getAddress(),
402+
nodeWithInfo.getAttributes(), nodeWithInfo.getRoles(), nodeWithInfo.getVersion()));
403403
} else {
404404
// although we asked for one node, our target may not have completed
405405
// initialization yet and doesn't have cluster nodes

core/src/main/java/org/elasticsearch/cluster/node/DiscoveryNode.java

Lines changed: 69 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
package org.elasticsearch.cluster.node;
2121

2222
import org.elasticsearch.Version;
23-
import org.elasticsearch.common.Strings;
23+
import org.elasticsearch.common.UUIDs;
2424
import org.elasticsearch.common.io.stream.StreamInput;
2525
import org.elasticsearch.common.io.stream.StreamOutput;
2626
import org.elasticsearch.common.io.stream.Writeable;
@@ -64,7 +64,15 @@ public static boolean isLocalNode(Settings settings) {
6464
}
6565

6666
public static boolean nodeRequiresLocalStorage(Settings settings) {
67-
return Node.NODE_DATA_SETTING.get(settings) || Node.NODE_MASTER_SETTING.get(settings);
67+
boolean localStorageEnable = Node.NODE_LOCAL_STORAGE_SETTING.get(settings);
68+
if (localStorageEnable == false &&
69+
(Node.NODE_DATA_SETTING.get(settings) ||
70+
Node.NODE_MASTER_SETTING.get(settings))
71+
) {
72+
// TODO: make this a proper setting validation logic, requiring multi-settings validation
73+
throw new IllegalArgumentException("storage can not be disabled for master and data nodes");
74+
}
75+
return localStorageEnable;
6876
}
6977

7078
public static boolean isMasterNode(Settings settings) {
@@ -81,6 +89,7 @@ public static boolean isIngestNode(Settings settings) {
8189

8290
private final String nodeName;
8391
private final String nodeId;
92+
private final String ephemeralId;
8493
private final String hostName;
8594
private final String hostAddress;
8695
private final TransportAddress address;
@@ -97,14 +106,15 @@ public static boolean isIngestNode(Settings settings) {
97106
* and updated.
98107
* </p>
99108
*
100-
* @param nodeId the nodes unique id.
101-
* @param address the nodes transport address
102-
* @param attributes node attributes
103-
* @param roles node roles
104-
* @param version the version of the node.
109+
* @param id the nodes unique (persistent) node id. This constructor will auto generate a random ephemeral id.
110+
* @param address the nodes transport address
111+
* @param attributes node attributes
112+
* @param roles node roles
113+
* @param version the version of the node
105114
*/
106-
public DiscoveryNode(String nodeId, TransportAddress address, Map<String, String> attributes, Set<Role> roles, Version version) {
107-
this("", nodeId, address.getHost(), address.getAddress(), address, attributes, roles, version);
115+
public DiscoveryNode(String id, TransportAddress address, Map<String, String> attributes, Set<Role> roles,
116+
Version version) {
117+
this("", id, address, attributes, roles, version);
108118
}
109119

110120
/**
@@ -116,16 +126,16 @@ public DiscoveryNode(String nodeId, TransportAddress address, Map<String, String
116126
* and updated.
117127
* </p>
118128
*
119-
* @param nodeName the nodes name
120-
* @param nodeId the nodes unique id.
121-
* @param address the nodes transport address
122-
* @param attributes node attributes
123-
* @param roles node roles
124-
* @param version the version of the node.
129+
* @param nodeName the nodes name
130+
* @param nodeId the nodes unique persistent id. An ephemeral id will be auto generated.
131+
* @param address the nodes transport address
132+
* @param attributes node attributes
133+
* @param roles node roles
134+
* @param version the version of the node
125135
*/
126-
public DiscoveryNode(String nodeName, String nodeId, TransportAddress address, Map<String, String> attributes,
127-
Set<Role> roles, Version version) {
128-
this(nodeName, nodeId, address.getHost(), address.getAddress(), address, attributes, roles, version);
136+
public DiscoveryNode(String nodeName, String nodeId, TransportAddress address,
137+
Map<String, String> attributes, Set<Role> roles, Version version) {
138+
this(nodeName, nodeId, UUIDs.randomBase64UUID(), address.getHost(), address.getAddress(), address, attributes, roles, version);
129139
}
130140

131141
/**
@@ -137,23 +147,24 @@ public DiscoveryNode(String nodeName, String nodeId, TransportAddress address, M
137147
* and updated.
138148
* </p>
139149
*
140-
* @param nodeName the nodes name
141-
* @param nodeId the nodes unique id.
142-
* @param hostName the nodes hostname
143-
* @param hostAddress the nodes host address
144-
* @param address the nodes transport address
145-
* @param attributes node attributes
146-
* @param roles node roles
147-
* @param version the version of the node.
150+
* @param nodeName the nodes name
151+
* @param nodeId the nodes unique persistent id
152+
* @param ephemeralId the nodes unique ephemeral id
153+
* @param hostAddress the nodes host address
154+
* @param address the nodes transport address
155+
* @param attributes node attributes
156+
* @param roles node roles
157+
* @param version the version of the node
148158
*/
149-
public DiscoveryNode(String nodeName, String nodeId, String hostName, String hostAddress, TransportAddress address,
150-
Map<String, String> attributes, Set<Role> roles, Version version) {
159+
public DiscoveryNode(String nodeName, String nodeId, String ephemeralId, String hostName, String hostAddress,
160+
TransportAddress address, Map<String, String> attributes, Set<Role> roles, Version version) {
151161
if (nodeName != null) {
152162
this.nodeName = nodeName.intern();
153163
} else {
154164
this.nodeName = "";
155165
}
156166
this.nodeId = nodeId.intern();
167+
this.ephemeralId = ephemeralId.intern();
157168
this.hostName = hostName.intern();
158169
this.hostAddress = hostAddress.intern();
159170
this.address = address;
@@ -184,6 +195,7 @@ public DiscoveryNode(String nodeName, String nodeId, String hostName, String hos
184195
public DiscoveryNode(StreamInput in) throws IOException {
185196
this.nodeName = in.readString().intern();
186197
this.nodeId = in.readString().intern();
198+
this.ephemeralId = in.readString().intern();
187199
this.hostName = in.readString().intern();
188200
this.hostAddress = in.readString().intern();
189201
this.address = TransportAddressSerializers.addressFromStream(in);
@@ -208,6 +220,7 @@ public DiscoveryNode(StreamInput in) throws IOException {
208220
public void writeTo(StreamOutput out) throws IOException {
209221
out.writeString(nodeName);
210222
out.writeString(nodeId);
223+
out.writeString(ephemeralId);
211224
out.writeString(hostName);
212225
out.writeString(hostAddress);
213226
addressToStream(out, address);
@@ -237,6 +250,17 @@ public String getId() {
237250
return nodeId;
238251
}
239252

253+
/**
254+
* The unique ephemeral id of the node. Ephemeral ids are meant to be attached the the life span
255+
* of a node process. When ever a node is restarted, it's ephemeral id is required to change (while it's {@link #getId()}
256+
* will be read from the data folder and will remain the same across restarts). Since all node attributes and addresses
257+
* are maintained during the life span of a node process, we can (and are) using the ephemeralId in
258+
* {@link DiscoveryNode#equals(Object)}.
259+
*/
260+
public String getEphemeralId() {
261+
return ephemeralId;
262+
}
263+
240264
/**
241265
* The name of the node.
242266
*/
@@ -293,18 +317,25 @@ public String getHostAddress() {
293317
}
294318

295319
@Override
296-
public boolean equals(Object obj) {
297-
if (!(obj instanceof DiscoveryNode)) {
320+
public boolean equals(Object o) {
321+
if (this == o) {
322+
return true;
323+
}
324+
if (o == null || getClass() != o.getClass()) {
298325
return false;
299326
}
300327

301-
DiscoveryNode other = (DiscoveryNode) obj;
302-
return this.nodeId.equals(other.nodeId);
328+
DiscoveryNode that = (DiscoveryNode) o;
329+
330+
return ephemeralId.equals(that.ephemeralId);
303331
}
304332

305333
@Override
306334
public int hashCode() {
307-
return nodeId.hashCode();
335+
// we only need to hash the id because it's highly unlikely that two nodes
336+
// in our system will have the same id but be different
337+
// This is done so that this class can be used efficiently as a key in a map
338+
return ephemeralId.hashCode();
308339
}
309340

310341
@Override
@@ -313,15 +344,10 @@ public String toString() {
313344
if (nodeName.length() > 0) {
314345
sb.append('{').append(nodeName).append('}');
315346
}
316-
if (nodeId != null) {
317-
sb.append('{').append(nodeId).append('}');
318-
}
319-
if (Strings.hasLength(hostName)) {
320-
sb.append('{').append(hostName).append('}');
321-
}
322-
if (address != null) {
323-
sb.append('{').append(address).append('}');
324-
}
347+
sb.append('{').append(nodeId).append('}');
348+
sb.append('{').append(ephemeralId).append('}');
349+
sb.append('{').append(hostName).append('}');
350+
sb.append('{').append(address).append('}');
325351
if (!attributes.isEmpty()) {
326352
sb.append(attributes);
327353
}
@@ -332,6 +358,7 @@ public String toString() {
332358
public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
333359
builder.startObject(getId());
334360
builder.field("name", getName());
361+
builder.field("ephemeral_id", getEphemeralId());
335362
builder.field("transport_address", getAddress().toString());
336363

337364
builder.startObject("attributes");

core/src/main/java/org/elasticsearch/cluster/node/DiscoveryNodeService.java

Lines changed: 4 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,8 @@
2020
package org.elasticsearch.cluster.node;
2121

2222
import org.elasticsearch.Version;
23-
import org.elasticsearch.common.Randomness;
24-
import org.elasticsearch.common.UUIDs;
2523
import org.elasticsearch.common.component.AbstractComponent;
2624
import org.elasticsearch.common.inject.Inject;
27-
import org.elasticsearch.common.settings.Setting;
28-
import org.elasticsearch.common.settings.Setting.Property;
2925
import org.elasticsearch.common.settings.Settings;
3026
import org.elasticsearch.common.transport.TransportAddress;
3127
import org.elasticsearch.node.Node;
@@ -34,36 +30,27 @@
3430
import java.util.HashSet;
3531
import java.util.List;
3632
import java.util.Map;
37-
import java.util.Random;
3833
import java.util.Set;
3934
import java.util.concurrent.CopyOnWriteArrayList;
35+
import java.util.function.Supplier;
4036

4137
/**
4238
*/
4339
public class DiscoveryNodeService extends AbstractComponent {
4440

45-
public static final Setting<Long> NODE_ID_SEED_SETTING =
46-
// don't use node.id.seed so it won't be seen as an attribute
47-
Setting.longSetting("node_id.seed", 0L, Long.MIN_VALUE, Property.NodeScope);
4841
private final List<CustomAttributesProvider> customAttributesProviders = new CopyOnWriteArrayList<>();
4942

5043
@Inject
5144
public DiscoveryNodeService(Settings settings) {
5245
super(settings);
5346
}
5447

55-
public static String generateNodeId(Settings settings) {
56-
Random random = Randomness.get(settings, NODE_ID_SEED_SETTING);
57-
return UUIDs.randomBase64UUID(random);
58-
}
59-
6048
public DiscoveryNodeService addCustomAttributeProvider(CustomAttributesProvider customAttributesProvider) {
6149
customAttributesProviders.add(customAttributesProvider);
6250
return this;
6351
}
6452

65-
public DiscoveryNode buildLocalNode(TransportAddress publishAddress) {
66-
final String nodeId = generateNodeId(settings);
53+
public DiscoveryNode buildLocalNode(TransportAddress publishAddress, Supplier<String> nodeIdSupplier) {
6754
Map<String, String> attributes = new HashMap<>(Node.NODE_ATTRIBUTES.get(this.settings).getAsMap());
6855
Set<DiscoveryNode.Role> roles = new HashSet<>();
6956
if (Node.NODE_INGEST_SETTING.get(settings)) {
@@ -90,8 +77,8 @@ public DiscoveryNode buildLocalNode(TransportAddress publishAddress) {
9077
logger.warn("failed to build custom attributes from provider [{}]", e, provider);
9178
}
9279
}
93-
return new DiscoveryNode(Node.NODE_NAME_SETTING.get(settings), nodeId, publishAddress, attributes,
94-
roles, Version.CURRENT);
80+
return new DiscoveryNode(Node.NODE_NAME_SETTING.get(settings), nodeIdSupplier.get(), publishAddress, attributes, roles,
81+
Version.CURRENT);
9582
}
9683

9784
public interface CustomAttributesProvider {

0 commit comments

Comments
 (0)