Skip to content

Commit 0422d96

Browse files
authored
Improved support for multiple classloaders in IncrementingUuidGenerator (#2853)
Corrected a test-case which fails rarely but sometimes. Added possibility to give a specific classloader identifier. Fixes: #2851 Co-authored-by: Julien Kronegg <julien [at] kronegg.ch>
1 parent 83820a1 commit 0422d96

File tree

3 files changed

+166
-9
lines changed

3 files changed

+166
-9
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
1010
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
1111

1212
## [Unreleased]
13+
- [Core] Improved support for multiple classloaders in IncrementingUuidGenerator ([#2853](https://github.com/cucumber/cucumber-jvm/pull/2853) J. Kronegg)
1314
### Changed
1415
- [TestNG] Update dependency org.testng:testng to v7.9.0
1516
- [Core] Update dependency io.cucumber:tag-expressions to v6.1.0

cucumber-core/src/main/java/io/cucumber/core/eventbus/IncrementingUuidGenerator.java

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,20 @@ public class IncrementingUuidGenerator implements UuidGenerator {
4444

4545
/**
4646
* Classloader identifier (MSB). The identifier is a pseudo-random number on
47-
* 12 bits. Note: there is no need to save the Random because it's static.
47+
* 12 bits. Since we use a random value (and cut it to 12 bits), there is a
48+
* small probability that two classloaders generate the same 12 bits random
49+
* number. This could lead to UUID collisions if the UUID parts (epoch-time,
50+
* session counter and counter) are the same. The default `classloaderId`
51+
* (random number cut to 12 bits) has a collision rate of less than 1% when
52+
* using 10 classloaders (which leads to a much smaller UUID probability
53+
* thanks to the other dynamic parts of the UUID like epoch-time and
54+
* counters). If you use multiple classloaders and want to ensure a
55+
* collision-free UUID generation, you need to provide the `classloaderId`
56+
* by yourself. You can do so using the {@link #setClassloaderId(int)}
57+
* method. Note: there is no need to save the Random because it's static.
4858
*/
4959
@SuppressWarnings("java:S2119")
50-
static final long CLASSLOADER_ID = new Random().nextInt() & 0x0fff;
60+
static long classloaderId = new Random().nextInt() & 0x0fff;
5161

5262
/**
5363
* Session counter to differentiate instances created within a given
@@ -65,6 +75,24 @@ public class IncrementingUuidGenerator implements UuidGenerator {
6575
*/
6676
final AtomicLong counter = new AtomicLong(-1);
6777

78+
/**
79+
* Defines a new classloaderId for the class. This only affects instances
80+
* created after the call (the instances created before the call keep their
81+
* classloaderId). This method should be called to specify a classloaderId
82+
* if you are using more than one class loader, and you want to guarantee a
83+
* collision-free UUID generation (instead of the default random
84+
* classloaderId which produces about 1% collision rate on the
85+
* classloaderId, and thus can have UUID collision if the epoch-time,
86+
* session counter and counter have the same values).
87+
*
88+
* @param classloaderId the new classloaderId (only the least significant 12
89+
* bits are used)
90+
* @see IncrementingUuidGenerator#classloaderId
91+
*/
92+
public static void setClassloaderId(int classloaderId) {
93+
IncrementingUuidGenerator.classloaderId = classloaderId & 0xfff;
94+
}
95+
6896
public IncrementingUuidGenerator() {
6997
long sessionId = sessionCounter.incrementAndGet();
7098
if (sessionId == MAX_SESSION_ID) {
@@ -75,7 +103,7 @@ public IncrementingUuidGenerator() {
75103
}
76104
long epochTime = System.currentTimeMillis();
77105
// msb = epochTime | sessionId | version | classloaderId
78-
msb = ((epochTime & MAX_EPOCH_TIME) << 24) | (sessionId << 16) | (8 << 12) | CLASSLOADER_ID;
106+
msb = ((epochTime & MAX_EPOCH_TIME) << 24) | (sessionId << 16) | (8 << 12) | classloaderId;
79107
}
80108

81109
/**

cucumber-core/src/test/java/io/cucumber/core/eventbus/IncrementingUuidGeneratorTest.java

Lines changed: 134 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,22 +5,31 @@
55
import org.junit.jupiter.api.Test;
66

77
import java.io.IOException;
8+
import java.lang.reflect.Field;
9+
import java.lang.reflect.InvocationTargetException;
10+
import java.lang.reflect.Method;
811
import java.net.URISyntaxException;
912
import java.nio.file.Files;
1013
import java.nio.file.Path;
14+
import java.util.ArrayList;
15+
import java.util.HashSet;
1116
import java.util.List;
1217
import java.util.Objects;
18+
import java.util.Set;
1319
import java.util.UUID;
1420
import java.util.stream.Collectors;
1521
import java.util.stream.IntStream;
1622

1723
import static org.hamcrest.MatcherAssert.assertThat;
1824
import static org.junit.jupiter.api.Assertions.assertEquals;
25+
import static org.junit.jupiter.api.Assertions.assertNotEquals;
1926
import static org.junit.jupiter.api.Assertions.assertThrows;
2027
import static org.junit.jupiter.api.Assertions.assertTrue;
2128

2229
class IncrementingUuidGeneratorTest {
2330

31+
public static final String CLASSLOADER_ID_FIELD_NAME = "classloaderId";
32+
2433
/**
2534
* Example of generated values (same epochTime, same sessionId, same
2635
* classloaderId, different counter value):
@@ -92,7 +101,7 @@ void same_thread_generates_different_UuidGenerators() {
92101
void different_classloaders_generators() {
93102
// Given/When
94103
List<UUID> uuids = IntStream.rangeClosed(1, 10)
95-
.mapToObj(i -> getUuidGeneratorFromOtherClassloader().generateId())
104+
.mapToObj(i -> getUuidGeneratorFromOtherClassloader(i).generateId())
96105
.collect(Collectors.toList());
97106

98107
// Then
@@ -160,12 +169,131 @@ private static UUID removeClassloaderId(UUID uuid) {
160169
return new UUID(uuid.getMostSignificantBits() & 0xfffffffffffff000L, uuid.getLeastSignificantBits());
161170
}
162171

163-
private static UuidGenerator getUuidGeneratorFromOtherClassloader() {
172+
/**
173+
* Create a copy of the UUID without the epoch-time part to allow
174+
* comparison.
175+
*/
176+
private static UUID removeEpochTime(UUID uuid) {
177+
return new UUID(uuid.getMostSignificantBits() & 0x0ffffffL, uuid.getLeastSignificantBits());
178+
}
179+
180+
/**
181+
* Check that classloaderId collision rate is lower than a given threshold
182+
* when using multiple classloaders. This should not be mistaken with the
183+
* UUID collision rate. Note: this test takes about 20 seconds.
184+
*/
185+
@Test
186+
void classloaderid_collision_rate_lower_than_two_percents_with_ten_classloaders()
187+
throws NoSuchFieldException, IllegalAccessException {
188+
double collisionRateWhenUsingTenClassloaders;
189+
List<Double> collisionRatesWhenUsingTenClassloaders = new ArrayList<>();
190+
do {
191+
// When I compute the classloaderId collision rate with multiple
192+
// classloaders
193+
Set<Long> classloaderIds = new HashSet<>();
194+
List<Integer> stats = new ArrayList<>();
195+
while (stats.size() < 100) {
196+
if (!classloaderIds
197+
.add(getStaticFieldValue(getUuidGeneratorFromOtherClassloader(null),
198+
CLASSLOADER_ID_FIELD_NAME))) {
199+
stats.add(classloaderIds.size() + 1);
200+
classloaderIds.clear();
201+
}
202+
}
203+
204+
// Then the classloaderId collision rate for 10 classloaders is less
205+
// than 2%
206+
collisionRateWhenUsingTenClassloaders = stats.stream()
207+
.filter(x -> x < 10).count() * 100 / (double) stats.size();
208+
collisionRatesWhenUsingTenClassloaders.add(collisionRateWhenUsingTenClassloaders);
209+
} while (collisionRateWhenUsingTenClassloaders > 2 && collisionRatesWhenUsingTenClassloaders.size() < 10);
210+
assertTrue(collisionRateWhenUsingTenClassloaders <= 2,
211+
"all retries exceed the expected collision rate : " + collisionRatesWhenUsingTenClassloaders);
212+
}
213+
214+
@Test
215+
void same_classloaderId_leads_to_same_uuid_when_ignoring_epoch_time() {
216+
// Given the two generator have the same classloaderId
217+
UuidGenerator generator1 = getUuidGeneratorFromOtherClassloader(255);
218+
UuidGenerator generator2 = getUuidGeneratorFromOtherClassloader(255);
219+
220+
// When the UUID are generated
221+
UUID uuid1 = generator1.generateId();
222+
UUID uuid2 = generator2.generateId();
223+
224+
// Then the UUID are the same
225+
assertEquals(removeEpochTime(uuid1), removeEpochTime(uuid2));
226+
}
227+
228+
@Test
229+
void different_classloaderId_leads_to_different_uuid_when_ignoring_epoch_time() {
230+
// Given the two generator have the different classloaderId
231+
UuidGenerator generator1 = getUuidGeneratorFromOtherClassloader(1);
232+
UuidGenerator generator2 = getUuidGeneratorFromOtherClassloader(2);
233+
234+
// When the UUID are generated
235+
UUID uuid1 = generator1.generateId();
236+
UUID uuid2 = generator2.generateId();
237+
238+
// Then the UUID are the same
239+
assertNotEquals(removeEpochTime(uuid1), removeEpochTime(uuid2));
240+
}
241+
242+
@Test
243+
void setClassloaderId_keeps_only_12_bits() throws NoSuchFieldException, IllegalAccessException {
244+
// When the classloaderId is defined with a value higher than 0xfff (12
245+
// bits)
246+
IncrementingUuidGenerator.setClassloaderId(0xfffffABC);
247+
248+
// Then the classloaderId is truncated to 12 bits
249+
assertEquals(0x0ABC, getStaticFieldValue(new IncrementingUuidGenerator(), CLASSLOADER_ID_FIELD_NAME));
250+
}
251+
252+
@Test
253+
void setClassloaderId_keeps_values_under_12_bits_unmodified() throws NoSuchFieldException, IllegalAccessException {
254+
// When the classloaderId is defined with a value lower than 0xfff (12
255+
// bits)
256+
IncrementingUuidGenerator.setClassloaderId(0x0123);
257+
258+
// Then the classloaderId value is left unmodified
259+
assertEquals(0x0123, getStaticFieldValue(new IncrementingUuidGenerator(), CLASSLOADER_ID_FIELD_NAME));
260+
}
261+
262+
private Long getStaticFieldValue(UuidGenerator generator, String fieldName)
263+
throws NoSuchFieldException, IllegalAccessException {
264+
// The Field cannot be cached because the IncrementingUuidGenerator
265+
// class is different at each call (because it was loaded by a
266+
// different classloader).
267+
Field declaredField = generator.getClass().getDeclaredField(fieldName);
268+
declaredField.setAccessible(true);
269+
return (Long) declaredField.get(null);
270+
}
271+
272+
private static void setClassloaderId(Class<?> generatorClass, int value)
273+
throws IllegalAccessException, NoSuchMethodException, InvocationTargetException {
274+
// The Method cannot be cached because the IncrementingUuidGenerator
275+
// class is different at each call (because it was loaded by a
276+
// different classloader).
277+
Method method = generatorClass.getDeclaredMethod("setClassloaderId", int.class);
278+
method.setAccessible(true);
279+
method.invoke(null, value);
280+
}
281+
282+
/**
283+
* Create a fresh new IncrementingUuidGenerator from a fresh new
284+
* classloader, and return a new instance.
285+
*
286+
* @param classloaderId the classloader unique identifier, or null if the
287+
* default classloader id generator must be used
288+
* @return a new IncrementingUuidGenerator instance
289+
*/
290+
private static UuidGenerator getUuidGeneratorFromOtherClassloader(Integer classloaderId) {
164291
try {
165-
return (UuidGenerator) (new NonCachingClassLoader()
166-
.findClass(IncrementingUuidGenerator.class.getName())
167-
.getConstructor()
168-
.newInstance());
292+
Class<?> aClass = new NonCachingClassLoader().findClass(IncrementingUuidGenerator.class.getName());
293+
if (classloaderId != null) {
294+
setClassloaderId(aClass, classloaderId);
295+
}
296+
return (UuidGenerator) aClass.getConstructor().newInstance();
169297
} catch (Exception e) {
170298
throw new RuntimeException("could not instantiate " + IncrementingUuidGenerator.class.getSimpleName(), e);
171299
}

0 commit comments

Comments
 (0)