[Data] Generate sortable, collision-resistant Dataset IDs - #65075
Conversation
Signed-off-by: Alex Chien <alexchien130@gmail.com>
There was a problem hiding this comment.
Code Review
This pull request introduces time-sortable, practically unique Dataset ULIDs encoded in Base62, replacing the legacy counter-based Dataset IDs by default, while providing a configuration option to fall back to the legacy behavior. The review feedback focuses on improving robustness and compatibility, specifically by replacing parenthesized context managers in tests to maintain Python 3.8 compatibility, optimizing the Base62 encoding function to avoid string concatenation in a loop, and using getattr when accessing use_legacy_dataset_ids to prevent potential AttributeErrors during initialization and deserialization.
| import os | ||
| import time | ||
|
|
||
| _BASE62_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" |
There was a problem hiding this comment.
Think this is equivalent?
| _BASE62_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" | |
| import string | |
| _BASE62_ALPHABET = string.digits + string.ascii_uppercase + string.ascii_lowercase |
There was a problem hiding this comment.
Also, does this need to be ordered in ASCII order for correctness? If so, maybe worth making that explicit in code + comment
| _BASE62_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" | |
| _BASE62_ALPHABET = sorted(string.ascii_letters + string.digits) |
| _TIMESTAMP_BITS = 48 | ||
| _RANDOM_BITS = 80 |
There was a problem hiding this comment.
Nit: It's unclear if _RANDOM_BITS refers to the number of random its, or a specific value of random bits
| _TIMESTAMP_BITS = 48 | |
| _RANDOM_BITS = 80 | |
| _NUM_TIMESTAMP_BITS = 48 | |
| _NUM_RANDOM_BITS = 80 |
| if value < 0: | ||
| raise ValueError("Cannot encode a negative value in Base62") | ||
|
|
There was a problem hiding this comment.
Nit: _encode_62 is only called internally, and this condition should never happen. Think assertion is probably more appropriate here because it's an internal correctness thing.
Also, including the value is useful for debugging if this assertion ever trips.
| if value < 0: | |
| raise ValueError("Cannot encode a negative value in Base62") | |
| assert value >= 0, f"Received a negative value to encode {value}" | |
|
|
||
| return encoded.rjust(_ULID_LENGTH, "0") |
There was a problem hiding this comment.
Nit: Optionally, add a sanity check that the
| return encoded.rjust(_ULID_LENGTH, "0") | |
| encoded = encoded.rjust(_ULID_LENGTH, "0") | |
| assert len(encoded) == _ULID_LENGTH, encoded | |
| return encoded |
| if not 0 <= timestamp_ms < 1 << _TIMESTAMP_BITS: | ||
| raise ValueError("Timestamp must fit in 48 bits") |
There was a problem hiding this comment.
This can never happen, right? Make assertion?
| if not 0 <= timestamp_ms < 1 << _TIMESTAMP_BITS: | ||
| raise ValueError("Timestamp must fit in 48 bits") | ||
|
|
||
| random_value = int.from_bytes(os.urandom(_RANDOM_BITS // 8), byteorder="big") |
There was a problem hiding this comment.
Is byteorder there just because it's a required parameter? Maybe add comments so readers don't get confused about whether this is important for correctness
| assert sorted(extract_values("id", ds.take())) == list(range(2, 12)) | ||
|
|
||
|
|
||
| def test_dataset_lineage_serialization_legacy_dataset_id( |
There was a problem hiding this comment.
Do we actually need this test? Feel like it's this isn't strictly necessary for correctness
| BASE62_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" | ||
|
|
||
|
|
||
| def test_generate_dataset_ulid(): |
There was a problem hiding this comment.
Rather than a generic test_generate_dataset_ulid, I think we can split this up into more focused tests that test specific things we care about:
def test_generate_dataset_ulid_returns_time_sortable_ids():
# Optionally, could be reasanoble to make this test more robust/cases
# as long as it's still easy to read.
ulid1 = generate_dataset_ulid(get_time_ns=lambda: 0)
ulid2 = generate_dataset_ulid(get_time_ns=lambda: 1)
assert ulid1 < ulid2
def test_generate_dataset_ulid_does_not_return_collisions():
# 100,00 represents an extremely large value where collisions
# can't ever practically happen.
ulids = {}
for _ in range(100_000):
ulid = generate_dataset_ulid()
ulids.add(ulid)
assert len(ulids) == 100_000
def test_generate_dataset_ulid_only_uses_base62_chars():
ulid = generate_dataset_ulid()
assert all(c for c in ulid is in BASE62_ALPHABET)
def test_generate_dataset_ulid_returns_short_id():
# We care about readabiltiy, so this should always be 22 characters.
ulid = generate_dataset_ulid()
assert len(ulid) == 22|
|
||
| from ray.data._internal.dataset_id import generate_dataset_ulid | ||
|
|
||
| BASE62_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" |
There was a problem hiding this comment.
Ditto abotu using string module here to simplify
Signed-off-by: Alex Chien <alexchien130@gmail.com>
Signed-off-by: Alex Chien <alexchien130@gmail.com>
) `test_track_e2e_training` started failing after #65075 due to a dataset's uuid being regenerated on deserialization rather than using the pickled uuid of the controller. As `test_track_e2e_training` would check the roundtrip dataset info and dataset were identical which is no longer true. Signed-off-by: Mark Towers <mark@anyscale.com> Co-authored-by: Mark Towers <mark@anyscale.com>
Description
This PR replaces counter-based Dataset IDs with 22-character, Base62-encoded ULIDs.
Each ULID contains a timestamp and random data, making it sortable by creation time and practically collision-free.
The full ID format remains:
{dataset_name}_{dataset_ulid}_{run_index}This PR also:
RAY_DATA_USE_LEGACY_DATASET_IDSas a temporary compatibility option.Tests
Focused Dataset ID tests:
5 passedAlso updates
test_dataset_id_train_ingesttest inpython/ray/data/tests/test_stats.pyto verify that the same generated Dataset ID is used across epochs while the run index increases.Closes #65073