Skip to content

Commit 12e33ce

Browse files
committed
add initial monotonic support
1 parent 2955654 commit 12e33ce

2 files changed

Lines changed: 134 additions & 18 deletions

File tree

kulid/src/commonMain/kotlin/dev/phillipslabs/kulid/ULID.kt

Lines changed: 62 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ import kotlin.random.Random
1414
// ideally, they would all be private unless there's a compelling reason to expose these
1515

1616
private const val TIMESTAMP_BIT_SIZE = 48
17+
18+
// this is really just used for testing
19+
internal const val TIMESTAMP_BYTE_SIZE = TIMESTAMP_BIT_SIZE / 8
1720
internal const val MAX_TIME = (1L shl TIMESTAMP_BIT_SIZE) - 1 // 2^48 - 1
1821
private const val RANDOM_BIT_SIZE = 80
1922
private const val ULID_BIT_SIZE = TIMESTAMP_BIT_SIZE + RANDOM_BIT_SIZE
@@ -52,6 +55,17 @@ private val ENCODING_CHARS = "0123456789ABCDEFGHJKMNPQRSTVWXYZ".toCharArray()
5255
public value class ULID private constructor(
5356
public val value: ByteString,
5457
) : Comparable<ULID> {
58+
private constructor(timestamp: Long, randomness: ByteArray) : this(
59+
Buffer()
60+
.apply {
61+
// the timestamp we use is 48 bits, so we need to drop the uppermost 16 bits of a Long
62+
// toShort() uses the least significant 16 bits of a number
63+
writeShort((timestamp shr 32).toShort())
64+
writeInt(timestamp.toInt())
65+
write(randomness)
66+
}.readByteString(),
67+
)
68+
5569
init {
5670
require(value.size == ULID_BYTE_SIZE) { "ULID should have $ULID_BYTE_SIZE bytes, but had ${value.size}" }
5771
}
@@ -76,6 +90,38 @@ public value class ULID private constructor(
7690
*/
7791
override fun toString(): String = encodeCrockfordBase32(value)
7892

93+
// we actually do need a class bc we have to keep track of the generator's state
94+
// ... then again, do we really? This at least will make things faster than ripping it out
95+
public class MonotonicGenerator(
96+
private val secureRandom: Boolean = true,
97+
) {
98+
private var lastTimestamp = 0L
99+
private var lastRandom = ByteArray(RANDOM_BYTE_SIZE)
100+
101+
public fun next(): ULID {
102+
val timestamp = Clock.System.now().toEpochMilliseconds()
103+
104+
if (timestamp == lastTimestamp) {
105+
incrementRandom()
106+
} else {
107+
lastTimestamp = timestamp
108+
randomBytes(secureRandom, lastRandom)
109+
}
110+
111+
return ULID(lastTimestamp, lastRandom)
112+
}
113+
114+
private fun incrementRandom() {
115+
for (i in lastRandom.indices.reversed()) {
116+
if (lastRandom[i] != 0xFF.toByte()) {
117+
lastRandom[i]++
118+
return
119+
}
120+
}
121+
error("Random component has reached maximum value")
122+
}
123+
}
124+
79125
/**
80126
* Companion object providing factory methods and constants for ULID creation and manipulation.
81127
*/
@@ -117,24 +163,7 @@ public value class ULID private constructor(
117163
): ULID {
118164
check(timestamp >= 0L) { "Time must be non-negative" }
119165
check(timestamp <= MAX_TIME) { "Time must be less than $MAX_TIME" }
120-
121-
val buf = Buffer()
122-
// the timestamp we use is 48 bits, so we need to drop the uppermost 16 bits of a Long
123-
// toShort() uses the least significant 16 bits of a number
124-
buf.writeShort((timestamp shr 32).toShort())
125-
buf.writeInt(timestamp.toInt())
126-
127-
val randomness =
128-
if (secureRandom) {
129-
CryptoRand.Default.nextBytes(
130-
ByteArray(RANDOM_BYTE_SIZE),
131-
)
132-
} else {
133-
Random.nextBytes(ByteArray(RANDOM_BYTE_SIZE))
134-
}
135-
buf.write(randomness)
136-
137-
return ULID(buf.readByteString())
166+
return ULID(timestamp, randomBytes(secureRandom))
138167
}
139168

140169
/**
@@ -244,5 +273,20 @@ public value class ULID private constructor(
244273
}
245274
}
246275
}
276+
277+
private fun randomBytes(
278+
secure: Boolean,
279+
buf: ByteArray? = null,
280+
): ByteArray {
281+
val buffer = buf ?: ByteArray(RANDOM_BYTE_SIZE)
282+
if (secure) {
283+
CryptoRand.Default.nextBytes(
284+
buffer,
285+
)
286+
} else {
287+
Random.nextBytes(buffer)
288+
}
289+
return buffer
290+
}
247291
}
248292
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package dev.phillipslabs.kulid
2+
3+
import kotlin.test.Test
4+
import kotlin.test.assertTrue
5+
6+
class TestULIDMonotonic {
7+
@Test
8+
fun generatorProducesNonDecreasingSequence() {
9+
val gen = ULID.MonotonicGenerator(secureRandom = true)
10+
var prev = gen.next()
11+
// Generate a reasonably large sequence to likely span multiple milliseconds
12+
repeat(2_000) {
13+
val next = gen.next()
14+
assertTrue(
15+
prev <= next,
16+
"MonotonicGenerator produced a decreasing ULID: prev=$prev next=$next",
17+
)
18+
prev = next
19+
}
20+
}
21+
22+
@Test
23+
fun ulidsWithinSameMillisecondAreStrictlyIncreasing() {
24+
val gen = ULID.MonotonicGenerator(secureRandom = true)
25+
26+
// Keep trying to capture a batch generated within the same millisecond.
27+
// We will iterate up to some upper bound to avoid an infinite loop on very slow environments.
28+
var attempts = 0
29+
var foundBatch = false
30+
while (attempts++ < 50 && !foundBatch) {
31+
// Start a new batch
32+
val first = gen.next()
33+
val tsPrefix = first.toString().take(10) // timestamp portion in Crockford Base32
34+
35+
val prev = first
36+
37+
// Collect additional ULIDs while still in the same millisecond (same timestamp prefix)
38+
val candidate = gen.next()
39+
if (candidate.toString().startsWith(tsPrefix)) {
40+
// Within same millisecond, should be strictly increasing by randomness
41+
assertTrue(
42+
prev < candidate,
43+
"ULIDs within same millisecond must be strictly increasing: prev=$prev next=$candidate",
44+
)
45+
46+
assertTrue(
47+
candidate.value
48+
.toByteArray()
49+
.drop(TIMESTAMP_BYTE_SIZE)
50+
.reversed()
51+
.zip(
52+
prev.value
53+
.toByteArray()
54+
.drop(TIMESTAMP_BYTE_SIZE)
55+
.reversed(),
56+
)
57+
// any short circuits once we've found a true value to the predicate
58+
// since we only have the random bytes and they're in reverse order,
59+
// we should find the byte that incremented
60+
.any { (next, prev) ->
61+
// max value check to help avoid an overflow error
62+
prev != 0xff.toByte() && (prev + 1).toByte() == next
63+
},
64+
)
65+
foundBatch = true
66+
break
67+
}
68+
}
69+
70+
assertTrue(foundBatch, "Failed to observe two or more ULIDs within the same millisecond to validate strict increase")
71+
}
72+
}

0 commit comments

Comments
 (0)