Skip to content

[Data] Generate sortable, collision-resistant Dataset IDs - #65075

Merged
bveeramani merged 3 commits into
ray-project:masterfrom
yuhuan130:feat/data-dataset-ulids
Aug 5, 2026
Merged

[Data] Generate sortable, collision-resistant Dataset IDs#65075
bveeramani merged 3 commits into
ray-project:masterfrom
yuhuan130:feat/data-dataset-ulids

Conversation

@yuhuan130

Copy link
Copy Markdown
Contributor

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:

  • Generates a new Dataset ULID during deserialization.
  • Keeps the run index for repeated executions and training epochs.
  • Adds RAY_DATA_USE_LEGACY_DATASET_IDS as a temporary compatibility option.

Tests

Focused Dataset ID tests: 5 passed

Also updates test_dataset_id_train_ingest test in python/ray/data/tests/test_stats.py to verify that the same generated Dataset ID is used across epochs while the run index increases.


Closes #65073

Signed-off-by: Alex Chien <alexchien130@gmail.com>
@yuhuan130
yuhuan130 requested a review from a team as a code owner July 28, 2026 10:58

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread python/ray/data/tests/unit/test_dataset_id.py Outdated
Comment thread python/ray/data/_internal/dataset_id.py Outdated
Comment thread python/ray/data/dataset.py
Comment thread python/ray/data/dataset.py
@ray-gardener ray-gardener Bot added data Ray Data-related issues community-contribution Contributed by the community labels Jul 28, 2026
@bveeramani
bveeramani self-requested a review August 2, 2026 04:13

@bveeramani bveeramani left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall LGTM

Comment thread python/ray/data/_internal/dataset_id.py Outdated
import os
import time

_BASE62_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Think this is equivalent?

Suggested change
_BASE62_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
import string
_BASE62_ALPHABET = string.digits + string.ascii_uppercase + string.ascii_lowercase

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, does this need to be ordered in ASCII order for correctness? If so, maybe worth making that explicit in code + comment

Suggested change
_BASE62_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
_BASE62_ALPHABET = sorted(string.ascii_letters + string.digits)

Comment thread python/ray/data/_internal/dataset_id.py Outdated
Comment on lines +6 to +7
_TIMESTAMP_BITS = 48
_RANDOM_BITS = 80

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: It's unclear if _RANDOM_BITS refers to the number of random its, or a specific value of random bits

Suggested change
_TIMESTAMP_BITS = 48
_RANDOM_BITS = 80
_NUM_TIMESTAMP_BITS = 48
_NUM_RANDOM_BITS = 80

Comment thread python/ray/data/_internal/dataset_id.py Outdated
Comment on lines +12 to +14
if value < 0:
raise ValueError("Cannot encode a negative value in Base62")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
if value < 0:
raise ValueError("Cannot encode a negative value in Base62")
assert value >= 0, f"Received a negative value to encode {value}"

Comment thread python/ray/data/_internal/dataset_id.py Outdated
Comment on lines +19 to +20

return encoded.rjust(_ULID_LENGTH, "0")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Optionally, add a sanity check that the

Suggested change
return encoded.rjust(_ULID_LENGTH, "0")
encoded = encoded.rjust(_ULID_LENGTH, "0")
assert len(encoded) == _ULID_LENGTH, encoded
return encoded

Comment thread python/ray/data/_internal/dataset_id.py Outdated
Comment on lines +26 to +27
if not 0 <= timestamp_ms < 1 << _TIMESTAMP_BITS:
raise ValueError("Timestamp must fit in 48 bits")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can never happen, right? Make assertion?

Comment thread python/ray/data/_internal/dataset_id.py Outdated
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")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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():

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ditto abotu using string module here to simplify

Signed-off-by: Alex Chien <alexchien130@gmail.com>
@bveeramani bveeramani added the go add ONLY when ready to merge, run all tests label Aug 5, 2026
@bveeramani
bveeramani enabled auto-merge (squash) August 5, 2026 06:19
Signed-off-by: Alex Chien <alexchien130@gmail.com>
@github-actions
github-actions Bot disabled auto-merge August 5, 2026 17:06
@bveeramani
bveeramani enabled auto-merge (squash) August 5, 2026 17:41
@bveeramani
bveeramani merged commit a9c730c into ray-project:master Aug 5, 2026
7 checks passed
justinvyu pushed a commit that referenced this pull request Aug 7, 2026
)

`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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

community-contribution Contributed by the community data Ray Data-related issues go add ONLY when ready to merge, run all tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Data] Prevent Dataset ID collisions

2 participants