Skip to content

Commit 14683ee

Browse files
committed
Implement same ms monotonic sort order
Close #1
1 parent 51b4ab8 commit 14683ee

File tree

4 files changed

+66
-8
lines changed

4 files changed

+66
-8
lines changed

CHANGELOG.rst

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@ Versions follow `Semantic Versioning <http://www.semver.org>`_
1010
Changed
1111
~~~~~~~
1212
* Added Python 3.13 to the trove classifiers.
13-
* Optimze ``@property`` to ``@cached_property`` `<@WH-2099 <https://github.com/WH-2099>`_.
13+
* Optimze ``@property`` to ``@cached_property`` `@WH-2099 <https://github.com/WH-2099>`_.
14+
* When generating ULIDs within the same millisecond, the library will ensure monotonic sort order by
15+
incrementing the randomness component by 1 bit. This process is descrbied in the
16+
`spec <https://github.com/ulid/spec/blob/master/README.md#monotonicity>`_.
1417

1518
Fixed
1619
~~~~~

tests/test_ulid.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,19 @@ def test_ulid_monotonic_sorting(tick: int) -> None:
6161
assert_sorted([v.bytes for v in ulids])
6262

6363

64+
@freeze_time()
65+
def test_same_millisecond_monotonic_sorting() -> None:
66+
ulids = [ULID() for _ in range(1000)]
67+
assert_sorted(ulids)
68+
69+
70+
@freeze_time()
71+
def test_same_millisecond_overflow() -> None:
72+
ULID.provider.prev_randomness = constants.MAX_RANDOMNESS
73+
with pytest.raises(ValueError, match="Randomness within same millisecond exhausted"):
74+
ULID()
75+
76+
6477
def assert_sorted(seq: list) -> None:
6578
last = seq[0]
6679
for item in seq[1:]:
@@ -154,6 +167,11 @@ def test_ulid_from_timestamp() -> None:
154167
assert ulid1.timestamp == ulid2.timestamp
155168

156169

170+
def test_ulid_from_timestamp_overflow() -> None:
171+
with pytest.raises(ValueError, match="Value exceeds maximum possible timestamp"):
172+
ULID.from_timestamp(constants.MAX_TIMESTAMP + 1)
173+
174+
157175
Params = Union[bytes, str, int, float]
158176

159177

ulid/__init__.py

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import uuid
77
from datetime import datetime
88
from datetime import timezone
9+
from threading import Lock
910
from typing import Any
1011
from typing import cast
1112
from typing import Generic
@@ -53,8 +54,42 @@ def wrapped(cls: Any, value: T) -> R:
5354
return wrapped
5455

5556

57+
class ValueProvider:
58+
def __init__(self) -> None:
59+
self.lock = Lock()
60+
self.prev_timestamp = constants.MIN_TIMESTAMP
61+
self.prev_randomness = constants.MIN_RANDOMNESS
62+
63+
def timestamp(self, value: float | None = None) -> int:
64+
if value is None:
65+
value = time.time_ns() // constants.NANOSECS_IN_MILLISECS
66+
elif isinstance(value, float):
67+
value = int(value * constants.MILLISECS_IN_SECS)
68+
if value > constants.MAX_TIMESTAMP:
69+
raise ValueError("Value exceeds maximum possible timestamp")
70+
return value
71+
72+
def randomness(self) -> bytes:
73+
with self.lock:
74+
current_timestamp = self.timestamp()
75+
if current_timestamp == self.prev_timestamp:
76+
if self.prev_randomness == constants.MAX_RANDOMNESS:
77+
raise ValueError("Randomness within same millisecond exhausted")
78+
randomness = (int.from_bytes(self.prev_randomness) + 1).to_bytes(
79+
constants.RANDOMNESS_LEN, byteorder="big"
80+
)
81+
else:
82+
randomness = os.urandom(constants.RANDOMNESS_LEN)
83+
84+
self.prev_randomness = randomness
85+
self.prev_timestamp = current_timestamp
86+
return randomness
87+
88+
5689
@functools.total_ordering
5790
class ULID:
91+
provider = ValueProvider()
92+
5893
"""The :class:`ULID` object consists of a timestamp part of 48 bits and of 80 random bits.
5994
6095
.. code-block:: text
@@ -82,9 +117,7 @@ class ULID:
82117
def __init__(self, value: bytes | None = None) -> None:
83118
if value is not None and len(value) != constants.BYTES_LEN:
84119
raise ValueError("ULID has to be exactly 16 bytes long.")
85-
self.bytes: bytes = (
86-
value or ULID.from_timestamp(time.time_ns() // constants.NANOSECS_IN_MILLISECS).bytes
87-
)
120+
self.bytes: bytes = value or ULID.from_timestamp(self.provider.timestamp()).bytes
88121

89122
@classmethod
90123
@validate_type(datetime)
@@ -113,10 +146,8 @@ def from_timestamp(cls, value: float) -> Self:
113146
>>> ULID.from_timestamp(time.time())
114147
ULID(01E75QWN5HKQ0JAVX9FG1K4YP4)
115148
"""
116-
if isinstance(value, float):
117-
value = int(value * constants.MILLISECS_IN_SECS)
118-
timestamp = int.to_bytes(value, constants.TIMESTAMP_LEN, "big")
119-
randomness = os.urandom(constants.RANDOMNESS_LEN)
149+
timestamp = int.to_bytes(cls.provider.timestamp(value), constants.TIMESTAMP_LEN, "big")
150+
randomness = cls.provider.randomness()
120151
return cls.from_bytes(timestamp + randomness)
121152

122153
@classmethod

ulid/constants.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
MILLISECS_IN_SECS = 1000
22
NANOSECS_IN_MILLISECS = 1000000
33

4+
MIN_RANDOMNESS = b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"
5+
MAX_RANDOMNESS = b"\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff"
6+
7+
MIN_TIMESTAMP = 0
8+
MAX_TIMESTAMP = 2**48 - 1
9+
410
TIMESTAMP_LEN = 6
511
RANDOMNESS_LEN = 10
612
BYTES_LEN = TIMESTAMP_LEN + RANDOMNESS_LEN

0 commit comments

Comments
 (0)