Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Client: Java ID generator helper #1347

Merged
merged 10 commits into from
Jan 22, 2024
7 changes: 4 additions & 3 deletions docs/design/data-modeling.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,9 +94,10 @@ A ULID ("Universally Unique Lexicographically Sortable identifier") consists of:
- 80 bits of randomness (low-order bits)

**Important**: When creating multiple objects during the same millisecond, increment the random bytes
(instead of generating new random bytes). This ensures that a sequence of objects within a
[batch](./client-requests.md#batching-events) has strictly increasing ids. (Batches of strictly
increasing ids are amenable to LSM optimizations, leading to higher database throughput).
(instead of generating new random bytes). Make sure to also store random bytes first, timestamp bytes
second, and both in little-endian. These details ensure that a sequence of objects have strictly
increasing ids according to the server. (Such ids are amenable to LSM optimizations,
leading to higher database throughput).

- ULIDs have an insignificant risk of collision.
- ULIDs do not require a central oracle.
Expand Down
4 changes: 3 additions & 1 deletion src/clients/java/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
</os>
</activation>
<properties>
<executable.extension>.exe</executable.extension>
<script.extension>.bat</script.extension>
</properties>
</profile>
Expand All @@ -69,6 +70,7 @@
</os>
</activation>
<properties>
<executable.extension></executable.extension>
<script.extension>.sh</script.extension>
</properties>
</profile>
Expand Down Expand Up @@ -209,7 +211,7 @@
<version>3.1.0</version>
<configuration>
<workingDirectory>${project.basedir}/../../../</workingDirectory>
<executable>${project.basedir}/../../../zig/zig</executable>
<executable>${project.basedir}/../../../zig/zig${executable.extension}</executable>
</configuration>
<executions>
<execution>
Expand Down
57 changes: 57 additions & 0 deletions src/clients/java/src/main/java/com/tigerbeetle/UInt128.java
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package com.tigerbeetle;

import java.security.SecureRandom;
import java.math.BigInteger;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.Objects;
import java.util.UUID;

Expand Down Expand Up @@ -172,5 +174,60 @@ public static byte[] asBytes(final BigInteger value) {
return asBytes(bigintLsb.longValueExact(), bigintMsb.longValueExact());
}

private static long idLastTimestamp = 0L;
private static final byte[] idLastRandom = new byte[80];
private static final SecureRandom idSecureRandom = new SecureRandom();

/**
* Generates a Universally Unique Sortable Identifier as 16 bytes of a 128-bit value.
*
* The ID() function is thread-safe, the bytes returned are formatted in little endian, and the
kprotty marked this conversation as resolved.
Show resolved Hide resolved
* unsigned 128-bit value always monotonically increasing.
*
* @throws ArithmeticException if the random monotonic value in the same millisecond overflows.
* @return an array of 16 bytes representing an unsigned 128-bit value in little endian.
*/
public static byte[] ID() {
long randomLo;
short randomHi;
long timestamp = System.currentTimeMillis();

// Only modify the static variables in the synchronized block.
synchronized (idSecureRandom) {
// Ensure timestamp is monotonic. If it advances forward, also generate a new random.
if (timestamp <= idLastTimestamp) {
timestamp = idLastTimestamp;
} else {
idLastTimestamp = timestamp;
idSecureRandom.nextBytes(idLastRandom);
}

var random = ByteBuffer.wrap(idLastRandom).order(ByteOrder.nativeOrder());
randomLo = random.getLong();
randomHi = random.getShort();

// If randomLo will overflow from increment, then increment randomHi as carry.
// If randomHi will overflow on increment, throw error on 80-bit random overflow.
if (randomLo == 0xFFFFFFFFFFFFFFFFL) {
if (randomHi == 0xffff) {
kprotty marked this conversation as resolved.
Show resolved Hide resolved
throw new ArithmeticException("random bits overflow on monotonic increment");
}
randomHi += 1;
}
// Wrapping increment on randomLo. Java allows overflowing arithmetic by default.
randomLo += 1;

// Write back the incremented random.
random.flip();
random.putLong(randomLo);
random.putShort(randomHi);
}

var buffer = ByteBuffer.allocate(UInt128.SIZE).order(Batch.BYTE_ORDER);
buffer.putLong(randomLo);
buffer.putShort(randomHi);
buffer.putShort((short) timestamp); // timestamp lo
buffer.putInt((int) (timestamp >> 16)); // timestamp hi
return buffer.array();
}
}
61 changes: 61 additions & 0 deletions src/clients/java/src/test/java/com/tigerbeetle/UInt128Test.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,12 @@

import static org.junit.Assert.assertArrayEquals;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.assertSame;
import java.util.concurrent.CountDownLatch;
import java.math.BigInteger;
import java.nio.ByteOrder;
import java.nio.ByteBuffer;
import java.util.UUID;
import org.junit.Test;

Expand Down Expand Up @@ -165,4 +169,61 @@ public void testAsBigIntegerZero() {
assertSame(BigInteger.ZERO, UInt128.asBigInteger(new byte[16]));
assertArrayEquals(new byte[16], UInt128.asBytes(BigInteger.ZERO));
}

@Test
public void testID() throws Exception {
{
// Generate IDs, sleeping for ~1ms after a few to test intra-millisecond monotonicity.
var idA = UInt128.asBigInteger(UInt128.ID());
for (int i = 0; i < 10_000_000; i++) {
if (i % 10_000 == 0) {
Thread.sleep(1);
}

var idB = UInt128.asBigInteger(UInt128.ID());
assertTrue(idB.compareTo(idA) > 0);

// Use the generated ID as the new reference point for the next loop.
idA = idB;
}
}

final var threadExceptions = new Exception[100];
final var latchStart = new CountDownLatch(threadExceptions.length);
final var latchFinish = new CountDownLatch(threadExceptions.length);

for (int i = 0; i < threadExceptions.length; i++) {
final int threadIndex = i;
new Thread(() -> {
try {
// Wait for all threads to spawn before starting.
latchStart.countDown();
latchStart.await();

// Same as serial test above, but with smaller bounds.
var idA = UInt128.asBigInteger(UInt128.ID());
for (int j = 0; j < 10_000; j++) {
if (j % 1000 == 0) {
Thread.sleep(1);
}

var idB = UInt128.asBigInteger(UInt128.ID());
assertTrue(idB.compareTo(idA) > 0);
idA = idB;
}

} catch (Exception e) {
threadExceptions[threadIndex] = e; // Propagate exceptions to main thread.
} finally {
latchFinish.countDown(); // Make sure to unblock the main thread.
}
}).start();
}

latchFinish.await();
for (var exception : threadExceptions) {
if (exception != null)
throw exception;
}
}
}
Loading