Skip to content

Commit 9a70797

Browse files
authored
feat: Emit build revision dimension and add it to to sys.servers table (#19123)
This patch exposes the build revision (git commit SHA) of the JAR running on each node: - buildRevision metric dimension — emitted on every metric so you can confirm all nodes in a cluster are running the intended revision during rolling deployments. Empty string when running outside a packaged JAR (e.g., during mvn test). - build_revision column in sys.servers — query it directly with SELECT server, version, build_revision FROM sys.servers. For more information, see SERVERS table.
1 parent 0e70914 commit 9a70797

12 files changed

Lines changed: 274 additions & 1 deletion

File tree

docs/operations/metrics.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ All Druid metrics share a common set of fields:
3131
* `metric`: the name of the metric
3232
* `service`: the service name that emitted the metric
3333
* `host`: the host name that emitted the metric
34+
* `version`: the Druid version of the service that emitted the metric
35+
* `buildRevision`: the git commit of the build that produced the service binary. Useful for verifying that all nodes in a cluster are running the intended revision during rolling deployments.
3436
* `value`: some numeric value associated with the metric
3537

3638
Metrics may have additional dimensions beyond those listed above.

docs/querying/sql-metadata-tables.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,7 @@ Servers table lists all discovered servers in the cluster.
237237
|is_leader|BIGINT|1 if the server is currently the 'leader' (for services which have the concept of leadership), otherwise 0 if the server is not the leader, or null if the server type does not have the concept of leadership|
238238
|start_time|STRING|Timestamp in ISO8601 format when the server was announced in the cluster|
239239
|version|VARCHAR|Druid version running on the server|
240+
|build_revision|VARCHAR|The git commit of the build that produced the server binary|
240241
|labels|VARCHAR|Labels for the server configured using the property [`druid.labels`](../configuration/index.md)|
241242
|available_processors|BIGINT|Total number of CPU processors available to the server|
242243
|total_memory|BIGINT|Total memory in bytes available to the server|
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
20+
package org.apache.druid.server;
21+
22+
import org.apache.druid.java.util.common.logger.Logger;
23+
24+
import java.io.IOException;
25+
import java.io.InputStream;
26+
import java.net.URL;
27+
import java.util.jar.Manifest;
28+
29+
/**
30+
* Utility class for reading build metadata from the JAR manifest.
31+
*/
32+
public class BuildInfo
33+
{
34+
private static final Logger log = new Logger(BuildInfo.class);
35+
36+
private BuildInfo()
37+
{
38+
// Utility class; do not instantiate.
39+
}
40+
41+
/**
42+
* Reads the {@code Build-Revision} attribute from the {@code META-INF/MANIFEST.MF} of the JAR
43+
* that contains this class. Returns an empty string when running outside a packaged JAR
44+
* (e.g., during {@code mvn test}).
45+
*/
46+
public static String getBuildRevision()
47+
{
48+
try {
49+
URL classUrl = BuildInfo.class.getResource(BuildInfo.class.getSimpleName() + ".class");
50+
if (classUrl != null && "jar".equals(classUrl.getProtocol())) {
51+
String classPath = classUrl.toString();
52+
String manifestPath = classPath.substring(0, classPath.lastIndexOf('!') + 1) + "/META-INF/MANIFEST.MF";
53+
try (InputStream is = new URL(manifestPath).openStream()) {
54+
return readRevisionFromManifest(is);
55+
}
56+
}
57+
}
58+
catch (IOException e) {
59+
log.warn(e, "Failed to read Build-Revision from JAR manifest");
60+
}
61+
return "";
62+
}
63+
64+
/**
65+
* Reads the {@code Build-Revision} attribute from a manifest {@link InputStream}.
66+
* Returns an empty string if the attribute is absent.
67+
*/
68+
static String readRevisionFromManifest(InputStream is) throws IOException
69+
{
70+
String revision = new Manifest(is).getMainAttributes().getValue("Build-Revision");
71+
return revision != null ? revision : "";
72+
}
73+
}

server/src/main/java/org/apache/druid/server/DruidNode.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,10 @@ public class DruidNode
9292
UNKNOWN_VERSION
9393
);
9494

95+
@JsonProperty
96+
@NotNull
97+
private final String buildRevision = BuildInfo.getBuildRevision();
98+
9599
@JsonProperty
96100
private Map<String, String> labels;
97101

@@ -266,6 +270,11 @@ public String getVersion()
266270
return version;
267271
}
268272

273+
public String getBuildRevision()
274+
{
275+
return buildRevision;
276+
}
277+
269278
public DruidNode withService(String service)
270279
{
271280
return new DruidNode(service, host, bindOnHost, plaintextPort, tlsPort, enablePlaintextPort, enableTlsPort);

server/src/main/java/org/apache/druid/server/emitter/EmitterModule.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
import org.apache.druid.java.util.emitter.core.Emitter;
4444
import org.apache.druid.java.util.emitter.service.ServiceEmitter;
4545
import org.apache.druid.java.util.metrics.TaskHolder;
46+
import org.apache.druid.server.BuildInfo;
4647
import org.apache.druid.server.DruidNode;
4748

4849
import java.lang.annotation.Annotation;
@@ -92,6 +93,9 @@ public void configure(Binder binder)
9293
extraServiceDimensions
9394
.addBinding("version")
9495
.toInstance(StringUtils.nullToEmptyNonDruidDataString(version)); // Version is null during `mvn test`.
96+
extraServiceDimensions
97+
.addBinding("buildRevision")
98+
.toInstance(getBuildRevision());
9599
}
96100

97101
@Provides
@@ -177,4 +181,13 @@ public Emitter get()
177181
return emitter;
178182
}
179183
}
184+
185+
/**
186+
* Returns the {@code Build-Revision} for the current build, delegating to {@link BuildInfo}.
187+
* Overridable for testing.
188+
*/
189+
protected String getBuildRevision()
190+
{
191+
return BuildInfo.getBuildRevision();
192+
}
180193
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
20+
package org.apache.druid.server;
21+
22+
import org.junit.Assert;
23+
import org.junit.Test;
24+
25+
import java.io.ByteArrayInputStream;
26+
import java.io.IOException;
27+
import java.io.InputStream;
28+
import java.nio.charset.StandardCharsets;
29+
30+
public class BuildInfoTest
31+
{
32+
@Test
33+
public void testGetBuildRevisionReturnsEmptyStringOutsideJar()
34+
{
35+
// During mvn test the class loads from the filesystem, not a JAR, so this must return "".
36+
Assert.assertEquals("", BuildInfo.getBuildRevision());
37+
}
38+
39+
@Test
40+
public void testReadRevisionFromManifestWithRevisionPresent() throws IOException
41+
{
42+
String manifest = "Manifest-Version: 1.0\nBuild-Revision: abc123\n\n";
43+
InputStream is = new ByteArrayInputStream(manifest.getBytes(StandardCharsets.UTF_8));
44+
Assert.assertEquals("abc123", BuildInfo.readRevisionFromManifest(is));
45+
}
46+
47+
@Test
48+
public void testReadRevisionFromManifestWithRevisionAbsent() throws IOException
49+
{
50+
String manifest = "Manifest-Version: 1.0\n\n";
51+
InputStream is = new ByteArrayInputStream(manifest.getBytes(StandardCharsets.UTF_8));
52+
Assert.assertEquals("", BuildInfo.readRevisionFromManifest(is));
53+
}
54+
}

server/src/test/java/org/apache/druid/server/emitter/EmitterModuleTest.java

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,4 +205,74 @@ public void configure(Binder binder)
205205
)
206206
);
207207
}
208+
209+
@Test
210+
public void testBuildRevisionDimensionEmitsKnownValue()
211+
{
212+
// EmitterModule with a known revision verifies that getBuildRevision() is wired into the buildRevision dimension.
213+
EmitterModule emitterModule = new EmitterModule()
214+
{
215+
@Override
216+
protected String getBuildRevision()
217+
{
218+
return "abc1234def567890";
219+
}
220+
};
221+
Injector injector = makeInjectorForEmitterModule(emitterModule);
222+
ServiceEmitter serviceEmitter = injector.getInstance(ServiceEmitter.class);
223+
serviceEmitter.start();
224+
serviceEmitter.emit(new ServiceMetricEvent.Builder().setMetric("test", 1));
225+
226+
StubServiceEmitter stubEmitter = (StubServiceEmitter) injector.getInstance(Emitter.class);
227+
EventMap map = ((ServiceMetricEvent) stubEmitter.getEvents().get(0)).toMap();
228+
Assert.assertEquals("abc1234def567890", map.get("buildRevision"));
229+
}
230+
231+
@Test
232+
public void testBuildRevisionDimensionFallsBackToEmptyStringWhenUnavailable()
233+
{
234+
// When getBuildRevision() returns "" (e.g. running outside a packaged JAR), buildRevision dimension is an empty string.
235+
EmitterModule emitterModule = new EmitterModule()
236+
{
237+
@Override
238+
protected String getBuildRevision()
239+
{
240+
return "";
241+
}
242+
};
243+
Injector injector = makeInjectorForEmitterModule(emitterModule);
244+
ServiceEmitter serviceEmitter = injector.getInstance(ServiceEmitter.class);
245+
serviceEmitter.start();
246+
serviceEmitter.emit(new ServiceMetricEvent.Builder().setMetric("test", 1));
247+
248+
StubServiceEmitter stubEmitter = (StubServiceEmitter) injector.getInstance(Emitter.class);
249+
EventMap map = ((ServiceMetricEvent) stubEmitter.getEvents().get(0)).toMap();
250+
Assert.assertEquals("", map.get("buildRevision"));
251+
}
252+
253+
private Injector makeInjectorForEmitterModule(EmitterModule emitterModule)
254+
{
255+
Properties props = new Properties();
256+
props.setProperty("druid.emitter", "stub");
257+
emitterModule.setProps(props);
258+
return Guice.createInjector(
259+
new JacksonModule(),
260+
new LifecycleModule(),
261+
binder -> {
262+
JsonConfigProvider.bindInstance(
263+
binder,
264+
Key.get(DruidNode.class, Self.class),
265+
new DruidNode("test-service", "localhost", false, 8080, null, true, false)
266+
);
267+
binder.bind(Validator.class).toInstance(Validation.buildDefaultValidatorFactory().getValidator());
268+
binder.bindScope(LazySingleton.class, Scopes.SINGLETON);
269+
binder.bind(Properties.class).toInstance(props);
270+
binder.bind(TaskHolder.class).toInstance(new TestTaskHolder("test", "id1", "type1", "group1"));
271+
binder.bind(LoadSpecHolder.class).to(DefaultLoadSpecHolder.class).in(LazySingleton.class);
272+
},
273+
ServerInjectorBuilder.registerNodeRoleModule(ImmutableSet.of()),
274+
emitterModule,
275+
new StubServiceEmitterModule()
276+
);
277+
}
208278
}

sql/src/main/java/org/apache/druid/sql/calcite/schema/SystemSchema.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,7 @@ public class SystemSchema extends AbstractSchema
192192
.add("is_leader", ColumnType.LONG)
193193
.add("start_time", ColumnType.STRING)
194194
.add("version", ColumnType.STRING)
195+
.add("build_revision", ColumnType.STRING)
195196
.add("labels", ColumnType.STRING)
196197
.add("available_processors", ColumnType.LONG)
197198
.add("total_memory", ColumnType.LONG)
@@ -697,6 +698,7 @@ private Object[] buildRowForNonDataServer(DiscoveryDruidNode discoveryDruidNode)
697698
null,
698699
toStringOrNull(discoveryDruidNode.getStartTime()),
699700
node.getVersion(),
701+
node.getBuildRevision(),
700702
node.getLabels() == null ? null : JacksonUtils.writeValueAsString(jsonMapper, node.getLabels()),
701703
(long) discoveryDruidNode.getAvailableProcessors(),
702704
discoveryDruidNode.getTotalMemory()
@@ -725,6 +727,7 @@ private Object[] buildRowForNonDataServerWithLeadership(
725727
isLeader ? 1L : 0L,
726728
toStringOrNull(discoveryDruidNode.getStartTime()),
727729
node.getVersion(),
730+
node.getBuildRevision(),
728731
node.getLabels() == null ? null : JacksonUtils.writeValueAsString(jsonMapper, node.getLabels()),
729732
(long) discoveryDruidNode.getAvailableProcessors(),
730733
discoveryDruidNode.getTotalMemory()
@@ -765,6 +768,7 @@ private Object[] buildRowForDiscoverableDataServer(
765768
null,
766769
toStringOrNull(discoveryDruidNode.getStartTime()),
767770
node.getVersion(),
771+
node.getBuildRevision(),
768772
node.getLabels() == null ? null : JacksonUtils.writeValueAsString(jsonMapper, node.getLabels()),
769773
(long) discoveryDruidNode.getAvailableProcessors(),
770774
discoveryDruidNode.getTotalMemory()

0 commit comments

Comments
 (0)