Skip to content

fix: prevent non-monotonic ULIDs from timestamp/randomness clock skew#57

Open
gaoflow wants to merge 1 commit into
mdomke:mainfrom
gaoflow:fix/monotonic-timestamp-race
Open

fix: prevent non-monotonic ULIDs from timestamp/randomness clock skew#57
gaoflow wants to merge 1 commit into
mdomke:mainfrom
gaoflow:fix/monotonic-timestamp-race

Conversation

@gaoflow

@gaoflow gaoflow commented Jun 29, 2026

Copy link
Copy Markdown

Problem

from_timestamp() samples the system clock twice: once for the timestamp value and again inside randomness() to decide whether to increment or generate fresh entropy. When a millisecond boundary falls between these two samples, the encoded ULID carries the old timestamp but fresh (potentially much smaller) randomness, breaking the lexicographic sort-order guarantee within the same millisecond.

Seen in the wild:

prev=01KT6JE36AYQD46B7SP5T0K6K9  ts=01KT6JE36A rand=YQD46B7SP5T0K6K9
curr=01KT6JE36A9KNN12R9RC3JDJK0  ts=01KT6JE36A rand=9KNN12R9RC3JDJK0

Both have timestamp 01KT6JE36A but the randomness dropped from Y... to 9....

Fix

Pass the same resolved timestamp from from_timestamp() into randomness() as an optional parameter, so the timestamp used for the ULID bytes and the monotonicity decision are guaranteed identical. The parameter defaults to None (backward-compatible), preserving existing callers.

  • ulid/__init__.py: randomness() accepts optional current_timestamp; from_timestamp() captures the resolved value and passes it through (+6 / -4 lines).
  • tests/test_ulid.py: added test_z_real_time_monotonic_sorting() generating 5000 ULIDs without frozen time (the exact scenario that triggers the bug), and made the overflow test self-contained.

Tested with the reproduction from issue #56 — zero violations over 100000 iterations.

This pull request was prepared with the assistance of AI, under my direction and review.

Fixes #56
Fixes #45

from_timestamp() sampled the clock twice: once for the timestamp
value and again inside randomness() to decide whether to increment
or generate fresh entropy. When a millisecond boundary fell between
those two samples, the encoded ULID contained the old timestamp but
fresh (potentially smaller) randomness, breaking lexicographic sort
order within the same millisecond.

Pass the resolved timestamp from from_timestamp() into randomness()
so both the timestamp bytes and the monotonicity decision use the
same clock sample. The randomness() parameter is optional so any
existing callers outside from_timestamp() continue to work unchanged.

Fixes mdomke#56
Fixes mdomke#45
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.

Same millisecond generation is not monotonic Lexicographic sorting broken in version 3.1.0? [PR created]

1 participant