From 0f927a7ff9ab4c46acaecfb9026d17253761e35f Mon Sep 17 00:00:00 2001 From: JohnnyT Date: Wed, 21 Oct 2026 16:36:52 -0600 Subject: [PATCH] Adds the monotonic generator with an injectable draw source Monotonic is an object the caller holds. It keeps the last time and random value per prefix and random width, as the wire format's monotonic rule states: a later time takes a fresh seed, a time at or before the last one steps the random field by a value drawn uniformly from 1 to 2^(4w), and an overflow moves the time one millisecond on with a fresh seed. A monotonic xs uses compact time unless the time form is set, since a standard xs has no field to step. The draws come from each call's byte source by default, the step masked from ceil(w / 2) bytes so it is uniform with no modulo bias. A DrawSource replaces them to replay known values, which is how the corpus's monotonic cases are reproduced exactly. A ReentrantLock inside the instance orders the calls, so one generator shared between threads still gives strictly increasing ids; a multi-thread test proves it. Nothing is kept in a static field. A from input is refused with from_with_monotonic and fixed random bytes with rand_with_monotonic, and a call that throws leaves the state as it was; the javadoc says so and tests pin it. Refs: uxjv-5tk --- CLAUDE.md | 9 +- changelog.d/uxjv-5tk.md | 6 + .../java/systems/riddler/uxid/DrawSource.java | 36 ++ .../java/systems/riddler/uxid/Monotonic.java | 279 ++++++++++++ src/main/java/systems/riddler/uxid/Uxid.java | 6 +- .../riddler/uxid/MonotonicCorpusTest.java | 117 +++++ .../systems/riddler/uxid/MonotonicTest.java | 402 ++++++++++++++++++ .../systems/riddler/uxid/TestSources.java | 64 +++ 8 files changed, 914 insertions(+), 5 deletions(-) create mode 100644 changelog.d/uxjv-5tk.md create mode 100644 src/main/java/systems/riddler/uxid/DrawSource.java create mode 100644 src/main/java/systems/riddler/uxid/Monotonic.java create mode 100644 src/test/java/systems/riddler/uxid/MonotonicCorpusTest.java create mode 100644 src/test/java/systems/riddler/uxid/MonotonicTest.java diff --git a/CLAUDE.md b/CLAUDE.md index ec6231b..67ce573 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -163,10 +163,11 @@ package it exports, `LICENSE`, `CHANGELOG.md` with the fragment rule in (`.github/workflows/ci.yml`) and the release workflow (`.github/workflows/release.yml`). The package holds time-based and deterministic generation (`Uxid.generate` and `UxidOptions`), decoding -(`Uxid.decode`, `DecodeOptions` and `DecodeResult`) and the validity checks -(`Uxid.isValid` and `Uxid.isDeterministic`). The monotonic generator and the -vendored corpus arrive with later changes; until they do, the sentences in this file about the -1.0.0 package and its corpus describe the target, not the current tree. +(`Uxid.decode`, `DecodeOptions` and `DecodeResult`), the validity checks +(`Uxid.isValid` and `Uxid.isDeterministic`) and the monotonic generator +(`Monotonic`, with its `DrawSource`). The vendored corpus arrives with a later +change; until it does, the sentences in this file about the 1.0.0 package and +its corpus describe the target, not the current tree. - **The package is `systems.riddler:uxid`.** Version 1.0.0 is its first release on Maven Central. diff --git a/changelog.d/uxjv-5tk.md b/changelog.d/uxjv-5tk.md new file mode 100644 index 0000000..397dfd4 --- /dev/null +++ b/changelog.d/uxjv-5tk.md @@ -0,0 +1,6 @@ +### Added + +- `Monotonic` is a generator the caller holds whose ids for one prefix and random width increase strictly, also within one millisecond and when the clock moves back: `generate()` and `generate(UxidOptions)` keep the last time and random value per prefix and random width, step the random field by a value drawn uniformly from 1 to 2^(4w) within a millisecond, and move the time one millisecond forward with a fresh seed when the field overflows. A monotonic `xs` uses compact time unless the options set the time form. +- One `Monotonic` may be shared between threads: a lock inside the instance orders the calls, so its ids still increase strictly. Nothing is kept in a static field, and two generators never affect each other. +- `DrawSource` replaces a generator's seeds and steps to replay known draws; by default they come from each call's byte source, the step masked from its bytes with no modulo bias. +- `Monotonic.generate(UxidOptions)` refuses a `from` input with `from_with_monotonic` and fixed random bytes with `rand_with_monotonic`. A call that throws leaves the generator's state as it was. diff --git a/src/main/java/systems/riddler/uxid/DrawSource.java b/src/main/java/systems/riddler/uxid/DrawSource.java new file mode 100644 index 0000000..c77e535 --- /dev/null +++ b/src/main/java/systems/riddler/uxid/DrawSource.java @@ -0,0 +1,36 @@ +package systems.riddler.uxid; + +/** + * Where a {@link Monotonic} generator takes its random values from: a fresh random field, the seed, + * and the step that moves the field on within a millisecond. + * + *

A generator built with {@link Monotonic#Monotonic()} has no draw source of its own: it draws + * from each call's {@link UxidOptions.Builder#byteSource(ByteSource) byte source}, as {@link + * Monotonic} describes. A draw source of your own, passed to {@link + * Monotonic#Monotonic(DrawSource)}, replaces those draws, and the byte source is then never called. + * It exists to replay known values, as a test or a conformance case does, and is never a source of + * randomness for real ids: a monotonic id is exactly as hard to guess as its draws. + * + *

A generator calls its draw source with its lock held, so a draw source used by one generator + * is called by one thread at a time; one shared by several generators must be safe for that. + */ +public interface DrawSource { + + /** + * Returns a fresh random value for a random field of {@code width} bytes: a new array of exactly + * {@code width} bytes, read as one big-endian unsigned integer. + * + * @param width the random field's width in bytes, from 1 to 10 + * @return a new array of {@code width} bytes + */ + byte[] seed(int width); + + /** + * Returns a step drawn uniformly from 1 to 2^(4 x {@code width}) inclusive, half the random + * field's bit width. + * + * @param width the random field's width in bytes, from 1 to 10 + * @return the step, from 1 to 2^(4 x {@code width}) + */ + long step(int width); +} diff --git a/src/main/java/systems/riddler/uxid/Monotonic.java b/src/main/java/systems/riddler/uxid/Monotonic.java new file mode 100644 index 0000000..cbc13da --- /dev/null +++ b/src/main/java/systems/riddler/uxid/Monotonic.java @@ -0,0 +1,279 @@ +package systems.riddler.uxid; + +import java.math.BigInteger; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.locks.ReentrantLock; + +/** + * A monotonic generator: for one prefix and random width, the time and random value of each id it + * makes are greater than the last one's, also when several fall in one millisecond and when the + * clock moves back, so among ids of one letter case, delimiter and time form each sorts after the + * last as a string. It follows the "Monotonic generation" rule of the wire format. A monotonic id + * is an ordinary time-based id of its size and length: nothing in it says how it was made, and + * {@link Uxid#decode(String)} reads it as any other. + * + *

Each call takes a {@link UxidOptions} as {@link Uxid#generate(UxidOptions)} does, and the + * generator keeps its state per prefix and per random width: for each such pair it has seen, the + * last time {@code T} it gave and the last random value {@code R}, the random field read as one + * big-endian unsigned integer of {@code w} bytes. For a call at time {@code now} (the options' + * time, or {@link System#currentTimeMillis()} read at the call): + * + *

    + *
  1. with no state for the pair, or {@code now} later than {@code T}, the id carries {@code now} + * and a fresh random value, the seed; + *
  2. with {@code now} equal to or earlier than {@code T}, the id carries {@code T} and {@code R + * + step}, where {@code step} is drawn uniformly from 1 to 2^(4w) inclusive, half the field's + * bit width; + *
  3. when {@code R + step} is 2^(8w) or more, the id carries {@code T + 1} and a fresh seed + * instead. + *
+ * + *

The draws come from the {@link DrawSource} the generator was built with, or, for a generator + * built with {@link #Monotonic()}, from each call's {@link + * UxidOptions.Builder#byteSource(ByteSource) byte source}: a seed is {@code w} bytes, read + * big-endian, and a step is the low {@code 4w} bits of {@code ceil(w / 2)} bytes, read big-endian, + * plus one. The range of a step holds a power of two values, so the masked bits are exactly uniform + * over it, with no modulo bias and nothing redrawn. + * + *

The step is not a plus-one counter: consecutive ids in a millisecond are spread over a window + * of 2^(4w) values. That stops trivial enumeration, but it is a mitigation, not unpredictability, + * and a small random field stays small: a standard {@code s} id has 16 random bits. A compact time + * holds only the low 40 bits of the time, so compact ids keep their order only within one 2^40 ms + * window, as every compact id does. + * + *

At size {@link Size#XS} the generator uses compact time unless the options set the time form, + * because a standard {@code xs} has no random field to step and a compact {@code xs} has a one-byte + * field. The random width (the size's, or the options' {@link UxidOptions.Builder#randSize(int) + * random width}) must be at least one byte, so an {@code xs} with an explicit standard time and no + * random width is refused. + * + *

Thread safety. One generator may be shared between threads. A {@link + * java.util.concurrent.locks.ReentrantLock} inside the instance orders the calls: each call reads + * the time, draws and updates the state with the lock held, so the ids one generator makes for one + * prefix and random width increase strictly, as above, in the order the calls take the lock, + * whichever thread makes them. The draw source or byte source is called with the lock held. The + * state lives in the instance alone, with nothing kept in a static field, so two generators never + * affect each other, and ids from two generators carry no order between them within a millisecond. + * The state for each pair is kept for the generator's lifetime. + * + *

A refused call. A call that throws leaves the generator as it was: the state of every + * pair stays what it was before the call, and the next call starts from it. Draws a refused call + * already made are not given back. So after a step that overflows the field onto a compact time the + * wire format refuses, a later call at or before {@code T} draws a new step: it is refused again if + * that step also overflows the field, and with a step that does not, it answers an id at {@code T}. + * + *

{@code
+ * Monotonic ids = new Monotonic();
+ * UxidOptions options = UxidOptions.builder().prefix("usr").size(Size.S).build();
+ * String first = ids.generate(options);
+ * String second = ids.generate(options); // sorts after first, also in the same millisecond
+ * }
+ */ +public final class Monotonic { + /** The deterministic marker's value, which a compact time may not start with. */ + private static final long MARKER = 31; + + private final ReentrantLock lock = new ReentrantLock(); + private final DrawSource draws; + private final Map state = new HashMap<>(); + + /** + * Builds a generator that draws its seeds and steps from each call's {@link + * UxidOptions.Builder#byteSource(ByteSource) byte source}, by default {@link + * ByteSource#secureRandom()}. + */ + public Monotonic() { + this.draws = null; + } + + /** + * Builds a generator that takes its seeds and steps from {@code draws}; the options' byte source + * is never called. This replays known draws, as a test or a conformance case does; ids built from + * known draws are exactly as guessable as those draws, so production code uses {@link + * #Monotonic()}. + * + * @param draws the source of every seed and step + * @throws NullPointerException when {@code draws} is null + */ + public Monotonic(DrawSource draws) { + this.draws = Objects.requireNonNull(draws, "draws"); + } + + /** + * Generates the next id with every default: no prefix, size {@link Size#XL} with a standard time + * read from the clock now, lower case, and draws from {@link ByteSource#secureRandom()} unless + * the generator has a draw source. + * + * @return a 26-character id + * @throws IllegalArgumentException when the clock reads outside 0 to 2^48 - 1 milliseconds, or + * the field overflows in millisecond 2^48 - 1 + * @throws IllegalStateException when a draw answers outside its range + */ + public String generate() { + return generate(UxidOptions.builder().build()); + } + + /** + * Generates the next id for the options' prefix and random width, under the rule the class + * describes. The options' prefix, size, random width, time form, letter case, delimiter, time and + * byte source mean what they mean to {@link Uxid#generate(UxidOptions)}, with the same defaults, + * except that an {@link Size#XS} with no time form set uses compact time. + * + *

A call that throws leaves the generator as it was (see the class description). + * + * @param options the request + * @return the id + * @throws UxidException with {@link ErrorCode#FROM_WITH_MONOTONIC} when the options carry a + * {@link UxidOptions.Builder#from(String) from} input, since a deterministic id has no random + * field to step; {@link ErrorCode#RAND_WITH_MONOTONIC} for fixed {@link + * UxidOptions.Builder#rand(byte[]) random bytes}, since the generator chooses the field; + * {@link ErrorCode#RAND_SIZE_TOO_LARGE} for a random width above 10 bytes; {@link + * ErrorCode#COMPACT_TIME_OUT_OF_RANGE} for a compact time the id would carry whose first + * character would be the deterministic marker, also when that time is the millisecond after + * {@code T}, reached by an overflow. The first two are checked in that order before anything + * else. + * @throws IllegalArgumentException when the random width is 0 bytes (an {@code xs} with an + * explicit standard time and no random width, or a random width of 0); when the clock reads + * outside 0 to 2^48 - 1 milliseconds; or when the field overflows in millisecond 2^48 - 1, + * the last a time field holds + * @throws IllegalStateException when a byte source returns null or a count of bytes other than + * the one asked for, or a draw source answers a seed of the wrong length or a step outside 1 + * to 2^(4w) + * @throws NullPointerException when {@code options} is null + */ + public String generate(UxidOptions options) { + if (options.fromUtf8() != null) { + throw new UxidException( + ErrorCode.FROM_WITH_MONOTONIC, + "a monotonic generator makes no deterministic id: it has no random field to step"); + } + if (options.hasRand()) { + throw new UxidException( + ErrorCode.RAND_WITH_MONOTONIC, + "a monotonic generator takes no fixed random bytes: it chooses the random field"); + } + Boolean explicitCompact = options.compactTime(); + boolean compact = + explicitCompact == null ? options.size() == Size.XS : explicitCompact.booleanValue(); + Integer explicitWidth = options.randSize(); + int width = explicitWidth == null ? options.size().randBytes(compact) : explicitWidth; + if (width > Uxid.MAX_RAND_BYTES) { + throw new UxidException( + ErrorCode.RAND_SIZE_TOO_LARGE, + "the random field is at most " + Uxid.MAX_RAND_BYTES + " bytes, got " + width); + } + if (width == 0) { + throw new IllegalArgumentException( + "a monotonic id needs a random field to step, but this request's random width is 0" + + " bytes; leave the time form unset at size xs, or set a random width from 1 to 10"); + } + + lock.lock(); + try { + Long fixedTime = options.time(); + long now = + fixedTime == null ? UxidOptions.requireTime(System.currentTimeMillis()) : fixedTime; + Key key = new Key(options.prefix(), width); + Last last = state.get(key); + Last next; + if (last != null && now <= last.time()) { + BigInteger value = last.value().add(BigInteger.valueOf(step(options, width))); + if (value.bitLength() <= 8 * width) { + next = new Last(last.time(), value); + } else if (last.time() == UxidOptions.MAX_TIME) { + throw new IllegalArgumentException( + "the random field overflowed in millisecond 2^48 - 1, the last a time field holds"); + } else { + next = new Last(last.time() + 1, seed(options, width)); + } + } else { + next = new Last(now, seed(options, width)); + } + + String id = format(options, compact, width, next); + state.put(key, next); + return id; + } finally { + lock.unlock(); + } + } + + private static String format(UxidOptions options, boolean compact, int width, Last next) { + long compactTime = next.time() & ((1L << 40) - 1); + if (compact && compactTime >>> 35 == MARKER) { + throw new UxidException( + ErrorCode.COMPACT_TIME_OUT_OF_RANGE, + "a compact time from " + + next.time() + + " ms would start with the deterministic marker; use a standard time"); + } + StringBuilder id = new StringBuilder(options.prefix().length() + 30); + if (!options.prefix().isEmpty()) { + id.append(options.prefix()).append(options.delimiter()); + } + if (compact) { + Base32.appendInteger(id, compactTime, 8, options.letterCase()); + } else { + Base32.appendInteger(id, next.time(), 10, options.letterCase()); + } + Base32.appendBytes(id, toBytes(next.value(), width), options.letterCase()); + return id.toString(); + } + + /** A fresh random value for a field of {@code width} bytes. */ + private BigInteger seed(UxidOptions options, int width) { + byte[] bytes = + draws == null + ? Uxid.draw(options.byteSource(), width) + : checkSeed(draws.seed(width), width); + return new BigInteger(1, bytes); + } + + /** A step from 1 to 2^(4w), uniform over it. */ + private long step(UxidOptions options, int width) { + int bits = 4 * width; + long max = 1L << bits; + if (draws == null) { + byte[] bytes = Uxid.draw(options.byteSource(), (width + 1) / 2); + long drawn = 0; + for (byte b : bytes) { + drawn = (drawn << 8) | (b & 0xff); + } + return (drawn & (max - 1)) + 1; + } + long step = draws.step(width); + if (step < 1 || step > max) { + throw new IllegalStateException( + "the draw source answered the step " + step + ", outside 1 to 2^" + bits); + } + return step; + } + + private static byte[] checkSeed(byte[] seed, int width) { + if (seed == null || seed.length != width) { + throw new IllegalStateException( + "the draw source answered a seed of " + + (seed == null ? "null" : seed.length + " bytes") + + " for a field of " + + width); + } + return seed.clone(); + } + + /** The {@code width}-byte big-endian form of {@code value}, which is below 2^(8 x width). */ + private static byte[] toBytes(BigInteger value, int width) { + byte[] raw = value.toByteArray(); + byte[] bytes = new byte[width]; + int copy = Math.min(raw.length, width); + System.arraycopy(raw, raw.length - copy, bytes, width - copy, copy); + return bytes; + } + + /** The pair the state is kept for. */ + private record Key(String prefix, int width) {} + + /** The last id's time and random value for one pair. */ + private record Last(long time, BigInteger value) {} +} diff --git a/src/main/java/systems/riddler/uxid/Uxid.java b/src/main/java/systems/riddler/uxid/Uxid.java index 424b8d2..4127330 100644 --- a/src/main/java/systems/riddler/uxid/Uxid.java +++ b/src/main/java/systems/riddler/uxid/Uxid.java @@ -32,6 +32,9 @@ * #isValid(String, String, String)} and {@link #isDeterministic(String, String)} answer the two * questions most callers ask of a string. * + *

Ids that must increase strictly within one millisecond come from a {@link Monotonic} + * generator, an object the caller holds with its own state. + * *

Every method is static and safe to call from several threads at once; the class holds no * configuration. */ @@ -301,7 +304,8 @@ private static MessageDigest sha256() { } } - private static byte[] draw(ByteSource source, int width) { + /** Draws {@code width} bytes from {@code source}, refusing an answer of any other length. */ + static byte[] draw(ByteSource source, int width) { byte[] bytes = source.nextBytes(width); if (bytes == null || bytes.length != width) { throw new IllegalStateException( diff --git a/src/test/java/systems/riddler/uxid/MonotonicCorpusTest.java b/src/test/java/systems/riddler/uxid/MonotonicCorpusTest.java new file mode 100644 index 0000000..78aaeac --- /dev/null +++ b/src/test/java/systems/riddler/uxid/MonotonicCorpusTest.java @@ -0,0 +1,117 @@ +package systems.riddler.uxid; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HexFormat; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +/** + * Cases copied from the conformance corpus at uxid-ex v2.10.0 ({@code conformance/monotonic.json} + * and the monotonic refusals of {@code conformance/invalid.json}), each named by its case id. A + * list is written space-separated. The full corpus runner replaces this hand-picked copy. + */ +class MonotonicCorpusTest { + + @ParameterizedTest(name = "{0}") // (sabotage: overflow keeps T -> the two overflow cases failed) + @CsvSource( + nullValues = "null", + value = { + "monotonic/xl-same-millisecond, usr, xl, false, 123456789, null, 1 1099511627776 42," + + " 1700000000000 1700000000000 1700000000000 1700000000000," + + " usr_01hf7yat0000000000003nqk8n usr_01hf7yat0000000000003nqk8p" + + " usr_01hf7yat0000000001003nqk8p usr_01hf7yat0000000001003nqka0", + "monotonic/m-clock-moves-back, usr, m, false, 1000, null, 5 1048576 7," + + " 1700000000000 1699999999999 1699999999990 1700000000000," + + " usr_01hf7yat00000000z8 usr_01hf7yat00000000zd usr_01hf7yat00000100zd" + + " usr_01hf7yat00000100zm", + "monotonic/s-overflow-bumps-time, usr, s, false, 65000, 1234, 200 250 100 7 3," + + " 1700000000000 1700000000000 1700000000000 1700000000000 1700000000000" + + " 1700000000001," + + " usr_01hf7yat00zqm0 usr_01hf7yat00ztr0 usr_01hf7yat00zyn0 usr_01hf7yat010k90" + + " usr_01hf7yat010kc1 usr_01hf7yat010ke0", + "monotonic/xs-compact-overflow-clock-back, usr, xs, true, 250, 9, 5 16," + + " 1700000000000 1700000000000 1699999999999," + + " usr_hf7yat00z2 usr_hf7yat00z7 usr_hf7yat0111", + "monotonic/l-compact-single, usr, l, true, 0, null, '', 1700000000000," + + " usr_hf7yat000000000000000", + "monotonic/s-compact-no-prefix-largest-step, null, s, true, 0, null, 4096," + + " 1700000000000 1700000000000, hf7yat0000000 hf7yat0000800", + }) + void aMonotonicCaseGivesTheCorpusIds( + String id, + String prefix, + String size, + boolean compact, + long initialSeed, + Long overflowSeed, + String steps, + String times, + String expected) { + List seeds = new ArrayList<>(List.of(initialSeed)); + if (overflowSeed != null) { + seeds.add(overflowSeed); + } + TestSources.Replay draws = new TestSources.Replay(seeds, longs(steps)); + Monotonic generator = new Monotonic(draws); + + List ids = new ArrayList<>(); + for (long time : longs(times)) { + UxidOptions.Builder builder = + UxidOptions.builder() + .size(Size.parse(size)) + .compactTime(compact) + .time(time) + .byteSource(TestSources.refusing()); + if (prefix != null) { + builder.prefix(prefix); + } + ids.add(generator.generate(builder.build())); + } + + assertEquals(Arrays.asList(expected.split(" ", -1)), ids, id); + assertTrue(draws.drained(), id + ": every recorded draw is drawn"); + } + + @Test // (sabotage: from check skipped with a prefix -> assertThrows saw draws) + void fromWithAMonotonicGeneratorIsRefused() { + UxidOptions options = UxidOptions.builder().prefix("usr").from("alice@example.com").build(); + Monotonic generator = new Monotonic(TestSources.refusingDraws()); + + UxidException refusal = assertThrows(UxidException.class, () -> generator.generate(options)); + assertEquals( + ErrorCode.FROM_WITH_MONOTONIC, refusal.code(), "invalid/generate-from-with-monotonic"); + } + + @Test // (sabotage: rand check skipped with a prefix -> assertThrows saw draws) + void fixedRandomBytesWithAMonotonicGeneratorAreRefused() { + UxidOptions options = + UxidOptions.builder() + .prefix("usr") + .rand(HexFormat.of().parseHex("0102")) + .size(Size.S) + .time(1_700_000_000_000L) + .build(); + Monotonic generator = new Monotonic(TestSources.refusingDraws()); + + UxidException refusal = assertThrows(UxidException.class, () -> generator.generate(options)); + assertEquals( + ErrorCode.RAND_WITH_MONOTONIC, refusal.code(), "invalid/generate-rand-with-monotonic"); + } + + private static List longs(String list) { + List values = new ArrayList<>(); + for (String value : list.trim().split(" ", -1)) { + if (!value.isEmpty()) { + values.add(Long.parseLong(value)); + } + } + return values; + } +} diff --git a/src/test/java/systems/riddler/uxid/MonotonicTest.java b/src/test/java/systems/riddler/uxid/MonotonicTest.java new file mode 100644 index 0000000..fe4a207 --- /dev/null +++ b/src/test/java/systems/riddler/uxid/MonotonicTest.java @@ -0,0 +1,402 @@ +package systems.riddler.uxid; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.HexFormat; +import java.util.List; +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Test; + +class MonotonicTest { + private static final long TIME = 1_700_000_000_000L; + + /** The last compact time whose first character is not the marker: 31 x 2^35 - 1. */ + private static final long LAST_COMPACT = 1_065_151_889_407L; + + @Test // (sabotage: step multiplied by 0 -> the second id assertion failed) + void aCallInTheSameMillisecondAddsTheStepToTheLastValue() { + TestSources.Replay draws = new TestSources.Replay(List.of(100L), List.of(7L, 1L)); + Monotonic generator = new Monotonic(draws); + UxidOptions options = at(TIME).prefix("usr").size(Size.S).build(); + + assertEquals(plain(at(TIME).prefix("usr").size(Size.S), 2, 100), generator.generate(options)); + assertEquals(plain(at(TIME).prefix("usr").size(Size.S), 2, 107), generator.generate(options)); + assertEquals(plain(at(TIME).prefix("usr").size(Size.S), 2, 108), generator.generate(options)); + assertEquals(List.of("seed", "step", "step"), draws.calls); + } + + @Test // (sabotage: now <= T read as now == T -> a seed was drawn, the replay ran dry) + void aClockThatMovesBackKeepsTheLastTime() { + TestSources.Replay draws = new TestSources.Replay(List.of(100L), List.of(3L)); + Monotonic generator = new Monotonic(draws); + + generator.generate(at(TIME).prefix("usr").size(Size.M).build()); + assertEquals( + plain(at(TIME).prefix("usr").size(Size.M), 5, 103), + generator.generate(at(TIME - 50).prefix("usr").size(Size.M).build())); + assertEquals(List.of("seed", "step"), draws.calls); + } + + @Test // (sabotage: now <= T + 1 -> a step was drawn, the replay ran dry) + void aLaterTimeTakesAFreshSeedAndTheNewTime() { + TestSources.Replay draws = new TestSources.Replay(List.of(100L, 5L), List.of()); + Monotonic generator = new Monotonic(draws); + + generator.generate(at(TIME).prefix("usr").size(Size.S).build()); + assertEquals( + plain(at(TIME + 1).prefix("usr").size(Size.S), 2, 5), + generator.generate(at(TIME + 1).prefix("usr").size(Size.S).build())); + assertEquals(List.of("seed", "seed"), draws.calls); + } + + @Test // (sabotage: overflow keeps T -> the id assertion failed on the time) + void anOverflowMovesTheTimeOneMillisecondForwardWithAFreshSeed() { + TestSources.Replay draws = new TestSources.Replay(List.of(250L, 9L), List.of(6L)); + Monotonic generator = new Monotonic(draws); + UxidOptions options = at(TIME).prefix("usr").size(Size.XS).build(); + + generator.generate(options); + assertEquals( + plain(at(TIME + 1).prefix("usr").size(Size.XS).compactTime(true), 1, 9), + generator.generate(options)); + assertEquals(List.of("seed", "step", "seed"), draws.calls); + } + + @Test // (sabotage: key without the prefix -> cus stepped, the replay ran dry) + void theStateIsKeptPerPrefixAndPerRandomWidth() { + TestSources.Replay draws = + new TestSources.Replay(List.of(1L, 2L, 3L, 4L), List.of(10L, 20L, 30L, 40L)); + Monotonic generator = new Monotonic(draws); + UxidOptions usrS = at(TIME).prefix("usr").size(Size.S).build(); + UxidOptions cusS = at(TIME).prefix("cus").size(Size.S).build(); + UxidOptions usrM = at(TIME).prefix("usr").size(Size.M).build(); + UxidOptions usrWidth3 = at(TIME).prefix("usr").size(Size.S).randSize(3).build(); + + List ids = new ArrayList<>(); + for (int round = 0; round < 2; round++) { + ids.add(generator.generate(usrS)); + ids.add(generator.generate(cusS)); + ids.add(generator.generate(usrM)); + ids.add(generator.generate(usrWidth3)); + } + + List expected = + List.of( + plain(at(TIME).prefix("usr").size(Size.S), 2, 1), + plain(at(TIME).prefix("cus").size(Size.S), 2, 2), + plain(at(TIME).prefix("usr").size(Size.M), 5, 3), + plain(at(TIME).prefix("usr").size(Size.S).randSize(3), 3, 4), + plain(at(TIME).prefix("usr").size(Size.S), 2, 11), + plain(at(TIME).prefix("cus").size(Size.S), 2, 22), + plain(at(TIME).prefix("usr").size(Size.M), 5, 33), + plain(at(TIME).prefix("usr").size(Size.S).randSize(3), 3, 44)); + assertEquals(expected, ids); + } + + @Test // (sabotage: one static state map -> the second generator stepped, replay dry) + void twoGeneratorsKeepTheirOwnState() { + TestSources.Replay one = new TestSources.Replay(List.of(1L), List.of()); + TestSources.Replay two = new TestSources.Replay(List.of(2L), List.of(1L)); + UxidOptions options = at(TIME).prefix("usr").size(Size.S).build(); + + assertEquals( + plain(at(TIME).prefix("usr").size(Size.S), 2, 1), new Monotonic(one).generate(options)); + assertEquals( + plain(at(TIME).prefix("usr").size(Size.S), 2, 2), new Monotonic(two).generate(options)); + } + + @Test // (sabotage: step mask dropped -> the compact s id assertion failed) + void theDefaultDrawsMaskTheByteSourceToTheStepRange() { + Monotonic s = new Monotonic(); + UxidOptions sOptions = + UxidOptions.builder() + .time(TIME) + .prefix("usr") + .size(Size.S) + .compactTime(true) + .byteSource(answering("000000", "ffff")) + .build(); + s.generate(sOptions); + assertEquals( + plain(at(TIME).prefix("usr").size(Size.S).compactTime(true), 3, 4096), + s.generate(sOptions)); + + Monotonic xs = new Monotonic(); + UxidOptions xsOptions = + UxidOptions.builder() + .time(TIME) + .prefix("usr") + .size(Size.XS) + .byteSource(answering("00", "ff")) + .build(); + xs.generate(xsOptions); + assertEquals( + plain(at(TIME).prefix("usr").size(Size.XS).compactTime(true), 1, 16), + xs.generate(xsOptions)); + } + + @Test // (sabotage: step drew w bytes -> the counts assertion failed) + void theDefaultDrawsAskTheByteSourceForTheSeedAndHalfTheWidth() { + TestSources.Counting source = new TestSources.Counting(); + Monotonic generator = new Monotonic(); + UxidOptions options = + UxidOptions.builder().time(TIME).prefix("usr").size(Size.M).byteSource(source).build(); + + generator.generate(options); + generator.generate(options); + assertEquals(List.of(5, 3), source.counts); + } + + @Test // (sabotage: draw source dropped -> the refusing byte source was called) + void aDrawSourceReplacesTheByteSource() { + TestSources.Replay draws = new TestSources.Replay(List.of(1L), List.of(1L)); + Monotonic generator = new Monotonic(draws); + UxidOptions options = at(TIME).prefix("usr").size(Size.L).build(); + + generator.generate(options); + assertEquals(plain(at(TIME).prefix("usr").size(Size.L), 7, 2), generator.generate(options)); + } + + @Test // (sabotage: compact default moved to xl -> assertDoesNotThrow failed) + void anXsWithNoTimeFormUsesCompactTime() { + TestSources.Replay draws = new TestSources.Replay(List.of(1L), List.of()); + UxidOptions options = at(TIME).prefix("usr").size(Size.XS).build(); + + Monotonic generator = new Monotonic(draws); + + assertEquals("usr_hf7yat0001", assertDoesNotThrow(() -> generator.generate(options))); + } + + @Test // (sabotage: width-0 check removed -> assertThrows saw the refusing draws) + void aRandomWidthOfZeroIsRefused() { + Monotonic generator = new Monotonic(TestSources.refusingDraws()); + UxidOptions standardXs = at(TIME).size(Size.XS).compactTime(false).build(); + UxidOptions zeroWidth = at(TIME).randSize(0).build(); + + assertThrows(IllegalArgumentException.class, () -> generator.generate(standardXs)); + assertThrows(IllegalArgumentException.class, () -> generator.generate(zeroWidth)); + } + + @Test // (sabotage: width below 11 ignored -> assertDoesNotThrow failed) + void anExplicitWidthOnAStandardXsIsStepped() { + TestSources.Replay draws = new TestSources.Replay(List.of(1L), List.of(2L)); + Monotonic generator = new Monotonic(draws); + UxidOptions options = at(TIME).size(Size.XS).compactTime(false).randSize(2).build(); + + assertDoesNotThrow(() -> generator.generate(options)); + assertEquals( + plain(at(TIME).size(Size.XS).compactTime(false).randSize(2), 2, 3), + assertDoesNotThrow(() -> generator.generate(options))); + } + + @Test // (sabotage: cap raised to 11 -> assertThrows saw the refusing draws) + void aRandomWidthAboveTenIsRefused() { + Monotonic generator = new Monotonic(TestSources.refusingDraws()); + UxidOptions options = at(TIME).randSize(11).build(); + + UxidException refusal = assertThrows(UxidException.class, () -> generator.generate(options)); + assertEquals(ErrorCode.RAND_SIZE_TOO_LARGE, refusal.code()); + } + + @Test // (sabotage: from skipped when rand is set -> the code assertion failed) + void fromIsRefusedBeforeFixedRandomBytes() { + Monotonic generator = new Monotonic(TestSources.refusingDraws()); + UxidOptions options = + at(TIME).from("alice@example.com").rand(new byte[] {1, 2}).size(Size.S).build(); + + UxidException refusal = assertThrows(UxidException.class, () -> generator.generate(options)); + assertEquals(ErrorCode.FROM_WITH_MONOTONIC, refusal.code()); + } + + @Test // (sabotage: state cleared on the from refusal -> the R + step id failed) + void aRefusedCallLeavesTheGeneratorAsItWas() { + TestSources.Replay draws = new TestSources.Replay(List.of(100L, 1L), List.of(5L)); + Monotonic generator = new Monotonic(draws); + UxidOptions options = at(TIME).prefix("usr").size(Size.S).build(); + + generator.generate(options); + assertThrows( + UxidException.class, + () -> + generator.generate(at(TIME).prefix("usr").size(Size.S).from("a@example.com").build())); + assertThrows( + UxidException.class, + () -> generator.generate(at(TIME).prefix("usr").size(Size.S).rand(new byte[2]).build())); + assertThrows( + UxidException.class, + () -> generator.generate(at(TIME).prefix("usr").size(Size.S).randSize(11).build())); + assertEquals(plain(at(TIME).prefix("usr").size(Size.S), 2, 105), generator.generate(options)); + assertEquals(List.of("seed", "step"), draws.calls); + } + + @Test // (sabotage: state stored before the guard -> assertDoesNotThrow failed) + void anOverflowOntoTheMarkerIsRefusedAndLeavesTheLastTime() { + TestSources.Replay draws = new TestSources.Replay(List.of(0xfeL, 7L), List.of(2L, 1L)); + Monotonic generator = new Monotonic(draws); + UxidOptions options = at(LAST_COMPACT).prefix("usr").size(Size.XS).build(); + + assertEquals( + plain(at(LAST_COMPACT).prefix("usr").size(Size.XS).compactTime(true), 1, 0xfe), + generator.generate(options)); + UxidException refusal = assertThrows(UxidException.class, () -> generator.generate(options)); + assertEquals(ErrorCode.COMPACT_TIME_OUT_OF_RANGE, refusal.code()); + assertEquals( + plain(at(LAST_COMPACT).prefix("usr").size(Size.XS).compactTime(true), 1, 0xff), + assertDoesNotThrow(() -> generator.generate(options))); + assertEquals(List.of("seed", "step", "seed", "step"), draws.calls); + } + + @Test // (sabotage: step range widened -> assertThrows saw nothing thrown) + void aDrawOutsideItsRangeIsRefusedAndLeavesTheGeneratorAsItWas() { + TestSources.Replay draws = new TestSources.Replay(List.of(100L), List.of(0L, 257L, 4L)); + Monotonic generator = new Monotonic(draws); + UxidOptions options = at(TIME).prefix("usr").size(Size.S).build(); + + generator.generate(options); + assertThrows(IllegalStateException.class, () -> generator.generate(options)); + assertThrows(IllegalStateException.class, () -> generator.generate(options)); + assertEquals(plain(at(TIME).prefix("usr").size(Size.S), 2, 104), generator.generate(options)); + } + + @Test // (sabotage: a short seed accepted -> assertThrows saw nothing thrown) + void aSeedOfTheWrongLengthIsRefused() { + UxidOptions options = at(TIME).size(Size.S).build(); + Monotonic shortSeed = new Monotonic(seeding(new byte[1])); + Monotonic nullSeed = new Monotonic(seeding(null)); + Monotonic shortBytes = new Monotonic(); + UxidOptions shortSource = + UxidOptions.builder().time(TIME).size(Size.S).byteSource(count -> new byte[0]).build(); + + assertThrows(IllegalStateException.class, () -> shortSeed.generate(options)); + assertThrows(IllegalStateException.class, () -> nullSeed.generate(options)); + assertThrows(IllegalStateException.class, () -> shortBytes.generate(shortSource)); + } + + @Test // (sabotage: last-millisecond guard removed -> assertThrows saw a seed drawn) + void anOverflowInTheLastMillisecondIsRefused() { + long last = (1L << 48) - 1; + TestSources.Replay draws = new TestSources.Replay(List.of(0xffffL), List.of(1L)); + Monotonic generator = new Monotonic(draws); + UxidOptions options = at(last).size(Size.S).build(); + + assertEquals("7zzzzzzzzzzzz1", generator.generate(options)); + assertThrows(IllegalArgumentException.class, () -> generator.generate(options)); + } + + @Test // (sabotage: clock read replaced by 0 -> the time range assertion failed) + void aCallWithNoTimeReadsTheClock() { + Monotonic generator = new Monotonic(); + long before = System.currentTimeMillis(); + String first = generator.generate(); + String second = generator.generate(); + long after = System.currentTimeMillis(); + + assertEquals(26, first.length(), first); + assertTrue(first.compareTo(second) < 0, first + " then " + second); + DecodeResult.Decoded decoded = (DecodeResult.Decoded) Uxid.decode(first); + long time = decoded.time().orElseThrow(); + assertTrue(time >= before && time <= after, first); + } + + @Test // (sabotage: a new lock per call -> a thread's ids did not increase) + void oneGeneratorSharedByThreadsGivesStrictlyIncreasingIds() throws Exception { + int threads = 8; + int perThread = 5_000; + Monotonic generator = new Monotonic(constantDraws()); + UxidOptions options = at(TIME).prefix("usr").size(Size.XL).build(); + CountDownLatch start = new CountDownLatch(1); + ExecutorService pool = Executors.newFixedThreadPool(threads); + try { + List>> results = new ArrayList<>(); + for (int t = 0; t < threads; t++) { + results.add( + pool.submit( + () -> { + start.await(); + List ids = new ArrayList<>(perThread); + for (int i = 0; i < perThread; i++) { + ids.add(generator.generate(options)); + } + return ids; + })); + } + start.countDown(); + Set all = new HashSet<>(); + for (Future> result : results) { + List ids = result.get(60, TimeUnit.SECONDS); + for (int i = 1; i < ids.size(); i++) { + assertTrue(ids.get(i - 1).compareTo(ids.get(i)) < 0, ids.get(i - 1) + " " + ids.get(i)); + } + all.addAll(ids); + } + assertEquals(threads * perThread, all.size()); + assertTrue( + all.contains(plain(at(TIME).prefix("usr").size(Size.XL), 10, threads * perThread - 1))); + } finally { + pool.shutdownNow(); + } + } + + /** A builder with a fixed time and a byte source that fails the test if it is called. */ + private static UxidOptions.Builder at(long time) { + return UxidOptions.builder().time(time).byteSource(TestSources.refusing()); + } + + /** + * The id {@link Uxid#generate(UxidOptions)} makes for {@code builder} with its {@code width}-byte + * random field fixed to {@code value}: what a monotonic call with that time and value answers. + */ + private static String plain(UxidOptions.Builder builder, int width, long value) { + byte[] field = new byte[width]; + long rest = value; + for (int i = width - 1; i >= 0; i--) { + field[i] = (byte) rest; + rest >>>= 8; + } + return Uxid.generate(builder.rand(field).build()); + } + + /** A byte source answering each hex string in turn, asked for exactly its length. */ + private static ByteSource answering(String... hex) { + List answers = new ArrayList<>(); + for (String h : hex) { + answers.add(HexFormat.of().parseHex(h)); + } + return count -> { + assertTrue(!answers.isEmpty(), "the byte source was asked for more than its answers"); + byte[] answer = answers.remove(0); + assertEquals(answer.length, count, "the count asked for"); + return answer; + }; + } + + /** A draw source whose every seed is {@code seed} and every step one. */ + private static DrawSource seeding(byte[] seed) { + return new DrawSource() { + @Override + public byte[] seed(int width) { + return seed; + } + + @Override + public long step(int width) { + return 1; + } + }; + } + + /** Seeds of zero and steps of one: safe to share, and any repeated id stays visible. */ + private static DrawSource constantDraws() { + return seeding(new byte[10]); + } +} diff --git a/src/test/java/systems/riddler/uxid/TestSources.java b/src/test/java/systems/riddler/uxid/TestSources.java index da8065b..a3acb98 100644 --- a/src/test/java/systems/riddler/uxid/TestSources.java +++ b/src/test/java/systems/riddler/uxid/TestSources.java @@ -1,6 +1,8 @@ package systems.riddler.uxid; +import java.util.ArrayDeque; import java.util.ArrayList; +import java.util.Deque; import java.util.List; /** Byte sources for tests. */ @@ -28,4 +30,66 @@ public byte[] nextBytes(int count) { return bytes; } } + + /** A draw source that fails the test if it is ever called. */ + static DrawSource refusingDraws() { + return new DrawSource() { + @Override + public byte[] seed(int width) { + throw new AssertionError("a seed was drawn for " + width + " bytes"); + } + + @Override + public long step(int width) { + throw new AssertionError("a step was drawn for " + width + " bytes"); + } + }; + } + + /** + * A draw source that answers recorded values in order, as the corpus gives them: each seed an + * unsigned integer written big-endian in the field's width, each step as it is. It records each + * draw as {@code seed} or {@code step} in {@link #calls}. + */ + static final class Replay implements DrawSource { + final List calls = new ArrayList<>(); + private final Deque seeds = new ArrayDeque<>(); + private final Deque steps = new ArrayDeque<>(); + + Replay(List seeds, List steps) { + this.seeds.addAll(seeds); + this.steps.addAll(steps); + } + + @Override + public byte[] seed(int width) { + calls.add("seed"); + Long value = seeds.poll(); + if (value == null) { + throw new AssertionError("no seed left to draw"); + } + byte[] bytes = new byte[width]; + long rest = value; + for (int i = width - 1; i >= 0; i--) { + bytes[i] = (byte) rest; + rest >>>= 8; + } + return bytes; + } + + @Override + public long step(int width) { + calls.add("step"); + Long value = steps.poll(); + if (value == null) { + throw new AssertionError("no step left to draw"); + } + return value; + } + + /** Whether every recorded seed and step was drawn. */ + boolean drained() { + return seeds.isEmpty() && steps.isEmpty(); + } + } }