Skip to content

Commit 40cae59

Browse files
committed
revamp monotonic api surface and tests
1 parent 12e33ce commit 40cae59

2 files changed

Lines changed: 75 additions & 70 deletions

File tree

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

Lines changed: 33 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -10,28 +10,27 @@ import kotlin.jvm.JvmInline
1010
import kotlin.math.absoluteValue
1111
import kotlin.random.Random
1212

13-
// some of these are marked as internal so that tests can be conducted easier
13+
// these are marked as internal so that tests can be conducted easier
1414
// ideally, they would all be private unless there's a compelling reason to expose these
1515

16-
private const val TIMESTAMP_BIT_SIZE = 48
16+
internal const val TIMESTAMP_BIT_SIZE = 48
1717

18-
// this is really just used for testing
1918
internal const val TIMESTAMP_BYTE_SIZE = TIMESTAMP_BIT_SIZE / 8
2019
internal const val MAX_TIME = (1L shl TIMESTAMP_BIT_SIZE) - 1 // 2^48 - 1
21-
private const val RANDOM_BIT_SIZE = 80
22-
private const val ULID_BIT_SIZE = TIMESTAMP_BIT_SIZE + RANDOM_BIT_SIZE
20+
internal const val RANDOM_BIT_SIZE = 80
21+
internal const val ULID_BIT_SIZE = TIMESTAMP_BIT_SIZE + RANDOM_BIT_SIZE
2322

2423
internal const val ULID_BYTE_SIZE = ULID_BIT_SIZE / 8
25-
private const val RANDOM_BYTE_SIZE = RANDOM_BIT_SIZE / 8
24+
internal const val RANDOM_BYTE_SIZE = RANDOM_BIT_SIZE / 8
2625

27-
private const val ENCODED_ULID_BYTE_SIZE = 26
26+
internal const val ENCODED_ULID_BYTE_SIZE = 26
2827

2928
// ULIDs don't saturate the entirety of their encoding space, and zero-pad the bits they don't use at the front
3029
// as a result, we need to shift around the bit we're looking at a little bit more when placing it
31-
private const val ULID_ENCODING_FRONT_PADDING = ENCODED_ULID_BYTE_SIZE * 5 - ULID_BIT_SIZE
30+
internal const val ULID_ENCODING_FRONT_PADDING = ENCODED_ULID_BYTE_SIZE * 5 - ULID_BIT_SIZE
3231

3332
// Crockford's base32 alphabet
34-
private val ENCODING_CHARS = "0123456789ABCDEFGHJKMNPQRSTVWXYZ".toCharArray()
33+
internal val ENCODING_CHARS = "0123456789ABCDEFGHJKMNPQRSTVWXYZ".toCharArray()
3534

3635
/**
3736
* A Universally Unique Lexicographically Sortable Identifier (ULID).
@@ -90,22 +89,27 @@ public value class ULID private constructor(
9089
*/
9190
override fun toString(): String = encodeCrockfordBase32(value)
9291

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,
92+
public class MonotonicGenerator internal constructor(
93+
private val randomProvider: (ByteArray) -> Unit,
94+
private val clock: () -> Long,
9795
) {
96+
public constructor(secureRandom: Boolean = true) : this(
97+
randomProvider = { randomBytes(secureRandom, it) },
98+
clock = { Clock.System.now().toEpochMilliseconds() },
99+
)
100+
101+
// these are internal so that tests can access them
98102
private var lastTimestamp = 0L
99103
private var lastRandom = ByteArray(RANDOM_BYTE_SIZE)
100104

101105
public fun next(): ULID {
102-
val timestamp = Clock.System.now().toEpochMilliseconds()
106+
val timestamp = clock()
103107

104108
if (timestamp == lastTimestamp) {
105109
incrementRandom()
106110
} else {
107111
lastTimestamp = timestamp
108-
randomBytes(secureRandom, lastRandom)
112+
randomProvider(lastRandom)
109113
}
110114

111115
return ULID(lastTimestamp, lastRandom)
@@ -273,20 +277,20 @@ public value class ULID private constructor(
273277
}
274278
}
275279
}
280+
}
281+
}
276282

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-
}
283+
internal fun randomBytes(
284+
secure: Boolean,
285+
buf: ByteArray? = null,
286+
): ByteArray {
287+
val buffer = buf ?: ByteArray(RANDOM_BYTE_SIZE)
288+
if (secure) {
289+
CryptoRand.Default.nextBytes(
290+
buffer,
291+
)
292+
} else {
293+
Random.nextBytes(buffer)
291294
}
295+
return buffer
292296
}
Lines changed: 42 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
package dev.phillipslabs.kulid
22

3+
import kotlinx.datetime.Clock
34
import kotlin.test.Test
5+
import kotlin.test.assertFailsWith
46
import kotlin.test.assertTrue
57

68
class TestULIDMonotonic {
79
@Test
810
fun generatorProducesNonDecreasingSequence() {
9-
val gen = ULID.MonotonicGenerator(secureRandom = true)
11+
val gen = ULID.MonotonicGenerator()
1012
var prev = gen.next()
1113
// Generate a reasonably large sequence to likely span multiple milliseconds
1214
repeat(2_000) {
@@ -21,52 +23,51 @@ class TestULIDMonotonic {
2123

2224
@Test
2325
fun ulidsWithinSameMillisecondAreStrictlyIncreasing() {
24-
val gen = ULID.MonotonicGenerator(secureRandom = true)
26+
val staticTime = Clock.System.now().toEpochMilliseconds()
27+
// insecure random is faster for tests
28+
val gen = ULID.MonotonicGenerator(randomProvider = { randomBytes(false, it) }, clock = { staticTime })
2529

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
30+
val first = gen.next()
31+
val second = gen.next()
3432

35-
val prev = first
33+
assertTrue(
34+
second > first,
35+
"ULIDs within same millisecond must be strictly increasing: first=$first second=$second",
36+
)
3637

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
38+
assertTrue(
39+
second.value
40+
.toByteArray()
41+
.drop(TIMESTAMP_BYTE_SIZE)
42+
.reversed()
43+
.zip(
44+
first.value
4845
.toByteArray()
4946
.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-
},
47+
.reversed(),
6448
)
65-
foundBatch = true
66-
break
67-
}
68-
}
49+
// any short circuits once we've found a true value to the predicate
50+
// since we only have the random bytes and they're in reverse order,
51+
// we should find the byte that incremented
52+
.any { (next, prev) ->
53+
// max value check to help avoid an overflow error
54+
prev != 0xff.toByte() && (prev + 1).toByte() == next
55+
},
56+
)
57+
}
6958

70-
assertTrue(foundBatch, "Failed to observe two or more ULIDs within the same millisecond to validate strict increase")
59+
@Test
60+
fun ulidThrowsErrorWhenRandomIsAtMax() {
61+
val staticTime = Clock.System.now().toEpochMilliseconds()
62+
val gen =
63+
ULID.MonotonicGenerator(
64+
randomProvider = { it.fill(0xff.toByte()) },
65+
clock = { staticTime },
66+
)
67+
// first ULID should be ok, as it uses timestamp and random to make a max ulid
68+
gen.next()
69+
assertFailsWith<IllegalStateException>("Failed to throw at a max-random ULID increment attempt") {
70+
gen.next()
71+
}
7172
}
7273
}

0 commit comments

Comments
 (0)