Skip to content

Commit

Permalink
Add microsecond provider
Browse files Browse the repository at this point in the history
The microsecond provider uses more precise clock resolution to
create randomness values that should monotonically increase on
timestamp (millisecond) collision.

When creating a new ULID, we extract the number of microseconds from
our timestamp and use them as the first two bytes of the randomness value.

Caveats:

When using `ulid.from_timestamp`, we fallback to the default implementation
of 10 bytes of pure randomness. The reason for this, is that the timestamp
that is being passed in by the caller is in milliseconds. Since we don't have
a more accurate timestamp to "steal" bytes from, there's not much we can do. We
cannot make a new epoch timestamp as the microsecond remainder is only accurate
when working within the same millisecond.
  • Loading branch information
ahawker committed Sep 15, 2020
1 parent b85fda2 commit 0b2e0b0
Show file tree
Hide file tree
Showing 18 changed files with 305 additions and 20 deletions.
17 changes: 5 additions & 12 deletions tests/test_api_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ def mock_provider(mocker):
Fixture that yields a mock provider.
"""
provider = mocker.Mock(spec=providers.Provider)
provider.new = mocker.Mock(side_effect=providers.DEFAULT.new)
provider.timestamp = mocker.Mock(side_effect=providers.DEFAULT.timestamp)
provider.randomness = mocker.Mock(side_effect=providers.DEFAULT.randomness)
return provider
Expand Down Expand Up @@ -55,22 +56,14 @@ def test_all_defined_expected_methods():
]


def test_api_new_calls_provider_timestamp(mock_api):
def test_api_new_calls_provider_new(mock_api):
"""
Assert :meth:`~ulid.api.api.Api.new` calls :meth:`~ulid.providers.base.Provider.timestamp` for a value.
Assert :meth:`~ulid.api.api.Api.new` calls :meth:`~ulid.providers.base.Provider.new` for timestamp
and randomness values.
"""
mock_api.new()

mock_api.provider.timestamp.assert_called_once_with()


def test_api_new_calls_provider_randomness(mocker, mock_api):
"""
Assert :meth:`~ulid.api.api.Api.new` calls :meth:`~ulid.providers.base.Provider.randomness` for a value.
"""
mock_api.new()

mock_api.provider.randomness.assert_called_once_with(mocker.ANY)
mock_api.provider.new.assert_called_once_with()


def test_api_from_timestamp_calls_provider_randomness(mocker, mock_api, valid_bytes_48):
Expand Down
7 changes: 7 additions & 0 deletions tests/test_api_default.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,10 @@ def test_module_exposes_expected_interface():
Assert that :attr:`~ulid.api.default.__all__` exposes expected interface.
"""
assert default.__all__ == ALL


def test_module_api_uses_correct_provider():
"""
Assert that the API instance uses the correct provider type.
"""
assert isinstance(default.API.provider, type(default.providers.DEFAULT))
30 changes: 30 additions & 0 deletions tests/test_api_microsecond.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"""
test_api_microsecond
~~~~~~~~~~~~~~~~~~~~
Tests for the :mod:`~ulid.api.microsecond` module.
"""
from ulid.api import microsecond
from ulid.api.api import ALL


def test_module_has_dunder_all():
"""
Assert that :mod:`~ulid.api.microsecond` exposes the :attr:`~ulid.api.__all__` attribute as a list.
"""
assert hasattr(microsecond, '__all__')
assert isinstance(microsecond.__all__, list)


def test_module_exposes_expected_interface():
"""
Assert that :attr:`~ulid.api.microsecond.__all__` exposes expected interface.
"""
assert microsecond.__all__ == ALL


def test_module_api_uses_correct_provider():
"""
Assert that the API instance uses the correct provider type.
"""
assert isinstance(microsecond.API.provider, type(microsecond.providers.MICROSECOND))
7 changes: 7 additions & 0 deletions tests/test_api_monotonic.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,10 @@ def test_module_exposes_expected_interface():
Assert that :attr:`~ulid.api.monotonic.__all__` exposes expected interface.
"""
assert monotonic.__all__ == ALL


def test_module_api_uses_correct_provider():
"""
Assert that the API instance uses the correct provider type.
"""
assert isinstance(monotonic.API.provider, type(monotonic.providers.MONOTONIC))
12 changes: 12 additions & 0 deletions tests/test_providers_default.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,18 @@ def test_provider_derives_from_base():
assert issubclass(default.Provider, base.Provider)


def test_provider_new_returns_bytes_pair(provider):
"""
Assert that :meth:`~ulid.providers.default.Provider.new` returns timestamp and randomness
bytes of expected length as a two item tuple.
"""
value = provider.new()
assert isinstance(value, tuple)
assert len(value) == 2
assert len(value[0]) == 6
assert len(value[1]) == 10


def test_provider_timestamp_returns_bytes(provider):
"""
Assert that :meth:`~ulid.providers.default.Provider.timestamp` returns bytes of expected length.
Expand Down
115 changes: 115 additions & 0 deletions tests/test_providers_microsecond.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
"""
test_providers_microsecond
~~~~~~~~~~~~~~~~~~~~~~~~~~
Tests for the :mod:`~ulid.providers.microsecond` module.
"""
import pytest

from ulid.providers import base, default, microsecond, time


@pytest.fixture(scope='function')
def provider():
"""
Fixture that yields a microsecond provider instance.
"""
return microsecond.Provider(default.Provider())


@pytest.fixture(scope='function')
def valid_epoch_milliseconds():
"""
Fixture that yields a epoch value in milliseconds.
"""
return time.PROVIDER.milliseconds()


@pytest.fixture(scope='function')
def valid_epoch_microseconds():
"""
Fixture that yields a epoch value in microseconds.
"""
return time.PROVIDER.microseconds()


@pytest.fixture(scope='function')
def mock_time_provider(mocker, valid_epoch_milliseconds, valid_epoch_microseconds):
"""
Fixture that yields a mock time provider.
"""
provider = mocker.Mock(spec=time.Provider)
provider.milliseconds = mocker.Mock(return_value=valid_epoch_milliseconds)
provider.microseconds = mocker.Mock(return_value=valid_epoch_microseconds)
return provider


@pytest.fixture(scope='function')
def provider_time_mock(mocker, mock_time_provider):
"""
Fixture that yields a provider with time mock.
"""
mocker.patch.object(microsecond.time, 'milliseconds', side_effect=mock_time_provider.milliseconds)
mocker.patch.object(microsecond.time, 'microseconds', side_effect=mock_time_provider.microseconds)
return microsecond.Provider(default.Provider())


def test_provider_derives_from_base():
"""
Assert that :class:`~ulid.providers.microsecond.Provider` derives from :class:`~ulid.providers.base.Provider`.
"""
assert issubclass(microsecond.Provider, base.Provider)


def test_provider_new_returns_bytes_pair(provider):
"""
Assert that :meth:`~ulid.providers.microsecond.Provider.new` returns timestamp and randomness
bytes of expected length as a two item tuple.
"""
value = provider.new()
assert isinstance(value, tuple)
assert len(value) == 2
assert len(value[0]) == 6
assert len(value[1]) == 10


def test_provider_new_returns_randomness_with_microseconds(provider_time_mock):
"""
Assert that :meth:`~ulid.providers.default.Provider.new` returns timestamp and randomness
bytes that use microseconds as the first two bytes of randomness.
"""
epoch_us = time.microseconds()
epoch_ms = epoch_us // 1000
microseconds = epoch_us % epoch_ms

_, randomness = provider_time_mock.new()

assert randomness[:2] == microseconds.to_bytes(2, byteorder='big')


def test_provider_timestamp_returns_bytes(provider):
"""
Assert that :meth:`~ulid.providers.microsecond.Provider.timestamp` returns bytes of expected length.
"""
value = provider.timestamp()
assert isinstance(value, bytes)
assert len(value) == 6


def test_provider_timestamp_uses_time_epoch(provider):
"""
Assert that :meth:`~ulid.providers.microsecond.Provider.timestamp` returns the current time milliseconds
since epoch in bytes.
"""
timestamp_bytes = provider.timestamp()
timestamp_int = int.from_bytes(timestamp_bytes, byteorder='big')
assert timestamp_int <= time.milliseconds()


def test_provider_randomness_returns_bytes(provider):
"""
Assert that :meth:`~ulid.providers.microsecond.Provider.randomness` returns bytes of expected length.
"""
value = provider.randomness(provider.timestamp())
assert isinstance(value, bytes)
assert len(value) == 10
12 changes: 12 additions & 0 deletions tests/test_providers_monotonic.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,18 @@ def test_provider_derives_from_base():
assert issubclass(monotonic.Provider, base.Provider)


def test_provider_new_returns_bytes_pair(provider):
"""
Assert that :meth:`~ulid.providers.monotonic.Provider.new` returns timestamp and randomness
bytes of expected length as a two item tuple.
"""
value = provider.new()
assert isinstance(value, tuple)
assert len(value) == 2
assert len(value[0]) == 6
assert len(value[1]) == 10


def test_provider_timestamp_returns_bytes(provider):
"""
Assert that :meth:`~ulid.providers.monotonic.Provider.timestamp` returns bytes of expected length.
Expand Down
2 changes: 1 addition & 1 deletion tests/test_providers_package.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ def test_package_exposes_expected_interface():
"""
Assert that :attr:`~ulid.providers.__all__` exposes expected interface.
"""
assert providers.__all__ == ['Provider', 'DEFAULT', 'MONOTONIC']
assert providers.__all__ == ['Provider', 'DEFAULT', 'MICROSECOND', 'MONOTONIC']


def test_package_has_default_provider():
Expand Down
4 changes: 4 additions & 0 deletions tests/test_providers_time_default.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,9 @@ def test_provider_milliseconds_is_unix_epoch(provider):
since epoch.
"""
x = int(time.time() * 1000)
time.sleep(1)
y = provider.milliseconds()
time.sleep(1)
z = int(time.time() * 1000)

assert x <= y <= z
Expand All @@ -60,7 +62,9 @@ def test_provider_microseconds_is_unix_epoch(provider):
since epoch.
"""
x = int(time.time() * 1000 * 1000)
time.sleep(1)
y = provider.microseconds()
time.sleep(1)
z = int(time.time() * 1000 * 1000)

assert x <= y <= z
4 changes: 4 additions & 0 deletions tests/test_providers_time_nanosecond.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,9 @@ def test_provider_milliseconds_is_unix_epoch(provider):
since epoch.
"""
x = int(time.time() * 1000)
time.sleep(1)
y = provider.milliseconds()
time.sleep(1)
z = int(time.time() * 1000)

assert x <= y <= z
Expand All @@ -64,7 +66,9 @@ def test_provider_microseconds_is_unix_epoch(provider):
since epoch.
"""
x = int(time.time() * 1000 * 1000)
time.sleep(1)
y = provider.microseconds()
time.sleep(1)
z = int(time.time() * 1000 * 1000)

assert x <= y <= z
2 changes: 1 addition & 1 deletion ulid/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
:copyright: (c) 2017 Andrew Hawker.
:license: Apache 2.0, see LICENSE for more details.
"""
from .api import default, monotonic
from .api import default, microsecond, monotonic

create = default.create
from_bytes = default.from_bytes
Expand Down
3 changes: 1 addition & 2 deletions ulid/api/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,7 @@ def new(self) -> ulid.ULID:
:return: ULID from current timestamp
:rtype: :class:`~ulid.ulid.ULID`
"""
timestamp = self.provider.timestamp()
randomness = self.provider.randomness(timestamp)
timestamp, randomness = self.provider.new()
return ulid.ULID(timestamp + randomness)

def parse(self, value: ULIDPrimitive) -> ulid.ULID:
Expand Down
2 changes: 1 addition & 1 deletion ulid/api/default.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
ulid/api/default
~~~~~~~~~~~~~~~~
Defaults the public API of the `ulid` package using the default provider.
Contains the public API of the `ulid` package using the default provider.
"""
from .. import consts, providers, ulid
from . import api
Expand Down
33 changes: 33 additions & 0 deletions ulid/api/microsecond.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"""
ulid/api/microsecond
~~~~~~~~~~~~~~~~~~~~
Contains the public API of the `ulid` package using the microsecond provider.
"""
from .. import consts, providers, ulid
from . import api

API = api.Api(providers.MICROSECOND)

create = API.create
from_bytes = API.from_bytes
from_int = API.from_int
from_randomness = API.from_randomness
from_str = API.from_str
from_timestamp = API.from_timestamp
from_uuid = API.from_uuid
new = API.new
parse = API.parse

MIN_TIMESTAMP = consts.MIN_TIMESTAMP
MAX_TIMESTAMP = consts.MAX_TIMESTAMP
MIN_RANDOMNESS = consts.MIN_RANDOMNESS
MAX_RANDOMNESS = consts.MAX_RANDOMNESS
MIN_ULID = consts.MIN_ULID
MAX_ULID = consts.MAX_ULID

Timestamp = ulid.Timestamp
Randomness = ulid.Randomness
ULID = ulid.ULID

__all__ = api.ALL
2 changes: 1 addition & 1 deletion ulid/api/monotonic.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
ulid/api/monotonic
~~~~~~~~~~~~~~~~~~
Defaults the public API of the `ulid` package using a monotonic randomness provider.
Contains the public API of the `ulid` package using the monotonic provider.
"""
from .. import consts, providers, ulid
from . import api
Expand Down
5 changes: 3 additions & 2 deletions ulid/providers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@
Contains functionality for timestamp/randomness data providers.
"""

from . import base, default, monotonic
from . import base, default, microsecond, monotonic

Provider = base.Provider
DEFAULT = default.Provider()
MICROSECOND = microsecond.Provider(DEFAULT)
MONOTONIC = monotonic.Provider(DEFAULT)

__all__ = ['Provider', 'DEFAULT', 'MONOTONIC']
__all__ = ['Provider', 'DEFAULT', 'MICROSECOND', 'MONOTONIC']
Loading

0 comments on commit 0b2e0b0

Please sign in to comment.