Skip to content

Adds the monotonic generator with an injectable draw source - #7

Merged
johnnyt merged 1 commit into
mainfrom
uxjv-5tk-monotonic-generator
Oct 21, 2026
Merged

johnnyt merged 1 commit into
mainfrom
uxjv-5tk-monotonic-generator

Conversation

@johnnyt

@johnnyt johnnyt commented Oct 21, 2026

Copy link
Copy Markdown
Member

Why

Ids minted in the same millisecond have to increase strictly, and one generator may be shared between threads. The wire format's monotonic rule says how; the state has to live in an object the caller holds, never in a static field.

What

  • Monotonic, a final class: new Monotonic() and new Monotonic(DrawSource), with generate() and generate(UxidOptions). Each call takes the same options Uxid.generate takes, and the generator keeps the last time T and random value R per prefix and per random width, inside the instance.
  • The rule as the wire format at uxid-ex v2.10.0 states it: with no state, or a later time, a fresh seed at the current time; with a time at or before T, R + step at T, the step uniform over 1 to 2^(4w); when R + step reaches 2^(8w), T + 1 with a fresh seed. The clock is read per call, as in generation, unless the options fix the time.
  • DrawSource (seed(width) and step(width)) replaces the draws to replay known values. By default the draws come from each call's byte source: a seed is w bytes, and a step is the low 4w bits of ceil(w / 2) bytes plus one, so it is uniform with no modulo bias and nothing redrawn.
  • A ReentrantLock inside the instance orders the calls: the time is read, the draws made and the state updated with the lock held. The javadoc says so; nothing is kept in a static field.
  • A from input is refused with from_with_monotonic and fixed random bytes with rand_with_monotonic, checked in that order before anything else.
  • The state after a refused call is written in the class javadoc: a call that throws leaves every pair's state as it was, and draws it already made are not given back. So after an overflow onto a compact time the wire format refuses, a later call at or before T draws a new step and answers an id at T when that step does not overflow.
  • A monotonic xs uses compact time unless the options set the time form, as the wire format says; the random width must be at least one byte, so an xs with an explicit standard time and no random width is refused.

The ReentrantLock inside the instance and the multi-thread test were decided by the conductor under a standing consent, 2026-10-21.

Tests

  • Every case of monotonic.json and both monotonic refusals of invalid.json from the uxid-ex v2.10.0 corpus, each named by its case id. The draws are replayed through a DrawSource, and each case asserts every recorded draw was taken.
  • One generator shared by eight threads, five thousand ids each, with steps of one and a fixed time: every thread's ids increase strictly and no id repeats.
  • The state after a refused call is pinned three ways: after the from, rand and random-width refusals the next id is R + step; after an overflow onto the compact marker the next call steps from T; after a draw outside its range the next id steps from R.
  • The default draws are pinned: the byte source is asked for w bytes for a seed and ceil(w / 2) for a step, and the step is masked (an all-ones byte gives 2^(4w)).
  • Every new test carries an in-name sabotage note for a mutation that was run, turned the test red on an assertion, and was reverted.

Notes

Choices the bead did not fix:

  • The options travel with each call (generate(UxidOptions)), so one generator serves several prefixes and widths, as the rule keeps state per prefix and random width; the two refusals are therefore per call, and a refused call is defined to change nothing.
  • DrawSource.seed answers bytes, not an integer, so a 10-byte field needs no wider type; a seed of the wrong length, or a step outside 1 to 2^(4w), throws IllegalStateException, as a byte source answering the wrong count already does.
  • A random width of 0 throws a plain IllegalArgumentException: the wire format names no generation error for it. So does an overflow in millisecond 2^48 - 1, the last a time field holds, as a clock outside the time field already does.
  • The state for each pair is kept for the generator's lifetime; the javadoc says so.

Gate: mise run gate green on this head (format check, compile with Error Prone, tests, JaCoCo, the javadoc and sources jars).

Bead: uxjv-5tk

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
@johnnyt
johnnyt merged commit 36b3fe0 into main Oct 21, 2026
4 checks passed
@johnnyt
johnnyt deleted the uxjv-5tk-monotonic-generator branch October 21, 2026 22:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant