Skip to content

TIMX 512 - row group size data migration#153

Merged
ghukill merged 1 commit into
mainfrom
TIMX-512-row-group-sizes
Jul 7, 2025
Merged

TIMX 512 - row group size data migration#153
ghukill merged 1 commit into
mainfrom
TIMX-512-row-group-sizes

Conversation

@ghukill

@ghukill ghukill commented Jul 3, 2025

Copy link
Copy Markdown
Contributor

Purpose and background context

It was discovered that some parquet files, mostly those before 2025-06-28, may have a single, large row group instead of multiple row groups with a maximum row count of 1k (when the parquet file has 1k+ total records).

This has been addressed in recent work, resulting in no files on or after 2025-06-28 having a single row group if total rows exceed 1k.

This migration script re-packs row groups (rewrites) in a parquet file when a single row group is found for a number of rows that should be split up.

How can a reviewer manually see the effects of these changes?

A clone of production, and a clone with the migration applied, has been written to a testing location: s3://ghukill-test/timdex-dataset/row-group-testing/.

You can point the script with the --dry-run flag to see that the clone still shows files that need updating, while the other dataset with the script applies skips all files because they have correct row groups.

1- Set AWS dev credentials

2- Run dry run for pre-fix dataset:

PYTHONPATH=. \
pipenv run python migrations/003_2025_07_03_fix_row_group_sizes.py \
s3://ghukill-test/timdex-dataset/row-group-testing/prod \
--dry-run

3- Run dry run for post-fix dataset:

PYTHONPATH=. \
pipenv run python migrations/003_2025_07_03_fix_row_group_sizes.py \
s3://ghukill-test/timdex-dataset/row-group-testing/prod_003 \
--dry-run
  • NOTE: no output until fully complete, indicating all files skipped

While difficult to recreate, I've also performed some "needle in a haystack" queries to retrieve records using their run_record_offset to help prune row groups. The dataset with the fix show significant -- orders of magnitude -- faster retrieval because libraries like DuckDB and pyarrow can now skip row groups where the sorted run_record_offset won't be found.

Here is some output from these tests; happy to discuss more or demonstrate how to recreate if people are curious:

################## PRE FIX ###################
INFO:timdex_dataset_api.dataset:Dataset successfully loaded: 's3://ghukill-test/timdex-dataset/row-group-testing/prod', 1.55s
INFO:timdex_dataset_api.metadata:setting up AWS credentials chain
INFO:timdex_dataset_api.metadata:creating table of full dataset metadata
100% ▕████████████████████████████████████████████████████████████▏
INFO:timdex_dataset_api.metadata:'records' table created - rows: 5339546, elapsed: 11.17069770814851
INFO:timdex_dataset_api.metadata:creating view of current records metadata
INFO:timdex_dataset_api.metadata:'current_records' view created - rows: 4333305, elapsed: 1.6118598342873156
INFO:timdex_dataset_api.metadata:metadata database setup elapsed: 13.556067041121423, path: ':memory:'
DEBUG:timdex_dataset_api.metadata:Retrieving full record from dataset - timdex_record_id: alma:9935071191506761
DEBUG:timdex_dataset_api.metadata:Parquet file identified: s3://ghukill-test/timdex-dataset/row-group-testing/prod/year=2025/month=02/day=28/64ceec69-47a0-4518-84fd-5dc6b141ff81-0.parquet, run_record_offset: 86787, elapsed: 0.18271237518638372
100% ▕████████████████████████████████████████████████████████████▏
DEBUG:timdex_dataset_api.metadata:Full record retrieved, elapsed: 11.743179291952401, total elapsed: 11.92612566659227

################## POST FIX ###################
INFO:timdex_dataset_api.dataset:Dataset successfully loaded: 's3://ghukill-test/timdex-dataset/row-group-testing/prod_003', 1.56s
INFO:timdex_dataset_api.metadata:setting up AWS credentials chain
INFO:timdex_dataset_api.metadata:creating table of full dataset metadata
100% ▕████████████████████████████████████████████████████████████▏
INFO:timdex_dataset_api.metadata:'records' table created - rows: 5339546, elapsed: 35.869934416841716
INFO:timdex_dataset_api.metadata:creating view of current records metadata
INFO:timdex_dataset_api.metadata:'current_records' view created - rows: 4333305, elapsed: 1.0270889168605208
INFO:timdex_dataset_api.metadata:metadata database setup elapsed: 37.60139649966732, path: ':memory:'
DEBUG:timdex_dataset_api.metadata:Retrieving full record from dataset - timdex_record_id: alma:9935071191506761
DEBUG:timdex_dataset_api.metadata:Parquet file identified: s3://ghukill-test/timdex-dataset/row-group-testing/prod_003/year=2025/month=02/day=28/64ceec69-47a0-4518-84fd-5dc6b141ff81-0.parquet, run_record_offset: 86787, elapsed: 0.052597166039049625
DEBUG:timdex_dataset_api.metadata:Full record retrieved, elapsed: 0.8138260003179312, total elapsed: 0.8665646249428391

The key thing to look for is the time "Full record retrieved, elapsed" in the last line of each, which demonstrates the speedup. We go from ~11 seconds to ~0.8 seconds, which is almost pure network I/O of downloading bytes we didn't need.

It's worth noting that generating the dataset metadata is a bit slower now due to more row groups. But as discussed, we are willing to pay a slight time penalty for generating the metadata, in the name of speeding up other operations.

Includes new or updated dependencies?

NO

Changes expectations for external applications?

NO

What are the relevant tickets?

Why these changes are being introduced:

It was discovered that some parquet files, mostly those before
2025-06-27, have potentially a single, very large row group instead
of maximum rows per group of 1k.

This has been addressed in recent work, resulting in no files after
2025-06-28 having a single row group if total rows exceed 1k.

How this addresses that need:

Migration script that re-packs row groups (rewrites) when a single
row group is found for a number of rows that should be split up.

Side effects of this change:
* None

Relevant ticket(s):
* https://mitlibraries.atlassian.net/browse/TIMX-512
@ghukill ghukill marked this pull request as ready for review July 3, 2025 20:07
@ghukill ghukill requested a review from a team July 3, 2025 20:07
@jonavellecuerdo jonavellecuerdo self-assigned this Jul 7, 2025

@jonavellecuerdo jonavellecuerdo 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.

These changes make sense to me! I was curious if you can include the queries you used to generate the output in the PR description?

@ghukill

ghukill commented Jul 7, 2025

Copy link
Copy Markdown
Contributor Author

These changes make sense to me! I was curious if you can include the queries you used to generate the output in the PR description?

You bet! Almost did... and then can't remember why I didn't. Worried it might confuse the issue. Here they are:

import time

from timdex_dataset_api import TIMDEXDatasetMetadata
from timdex_dataset_api.config import configure_dev_logger

configure_dev_logger()

# td = TIMDEXDataset(os.environ["TIMDEX_DATASET_LOCATION"])
# # NOTE: ergonomic mismatch... I want a TDM instance but don't need to limit...
# #   could use TDM.from_location() for sure, maybe good fit?
# #   but then why does it return full records?  that doesn't feel right...
# td.load(current_records=True)

#################
# PRE FIX
#################
print("\n################## PRE FIX ###################")
tdm_pre = TIMDEXDatasetMetadata.from_dataset_location(
    # "/Users/ghukill/dev/mit/data/timdex_dataset/prod"
    "s3://ghukill-test/timdex-dataset/row-group-testing/prod"
)
tdm_pre.get_full_record_via_duckdb(
    "alma:9935071191506761",
    "alma-full-ingest-2025-02-28t22-33-35",
)

time.sleep(5)

#################
# POST FIX
#################
print("\n################## POST FIX ###################")
tdm_post = TIMDEXDatasetMetadata.from_dataset_location(
    # "/Users/ghukill/dev/mit/data/timdex_dataset/prod_003"
    "s3://ghukill-test/timdex-dataset/row-group-testing/prod_003"
)
tdm_post.get_full_record_via_duckdb(
    "alma:9935071191506761",
    "alma-full-ingest-2025-02-28t22-33-35",
)

@ghukill

ghukill commented Jul 7, 2025

Copy link
Copy Markdown
Contributor Author

These changes make sense to me! I was curious if you can include the queries you used to generate the output in the PR description?

You bet! Almost did... and then can't remember why I didn't. Worried it might confuse the issue. Here they are:

I remember why! The method TIMDEXDatasetMetadata.get_full_record_via_duckdb() is not included in this PR, and may not get included ever for the reasons in the commented out code and internal discussion.

It was fun and helpful for testing out a needle-in-a-haystack query, but the ergonomics don't feel right for where the method should live.

Here is a sketch of that method just for fun:

def get_full_record_via_duckdb(
    self,
    timdex_record_id: str,
    run_id: str | None = None,
):
    # TODO:  docstring
    t0 = time.perf_counter()
    logger.debug(
        f"Retrieving full record from dataset - timdex_record_id: {timdex_record_id}"
    )

    # get parquet filename
    # if run_id provided, use that, else query current records and use that run_id
    t1 = time.perf_counter()
    if run_id:
        metadata_row = (
            self.conn.query(
                f"""
                select * from records
                where timdex_record_id='{timdex_record_id}'
                and run_id='{run_id}';
                """
            )
            .to_df()
            .iloc[0]
        )
    else:
        metadata_row = (
            self.conn.query(
                f"""
                select * from current_records
                where timdex_record_id='{timdex_record_id}'
                """
            )
            .to_df()
            .iloc[0]
        )
    logger.debug(
        f"Parquet file identified: {metadata_row.filename}, "
        f"run_record_offset: {metadata_row.run_record_offset}, "
        f"elapsed: {time.perf_counter() - t1}"
    )

    # retrieve full record from parquet file
    t1 = time.perf_counter()
    query = f"""
    select
        timdex_record_id,
        decode(source_record)::text as source_record,
        decode(transformed_record)::json as transformed_record,
        source,
        run_date,
        run_type,
        action,
        run_id,
        run_record_offset,
        run_timestamp
    from read_parquet('{metadata_row.filename}')
    where timdex_record_id = '{metadata_row.timdex_record_id}'
    and run_id = '{metadata_row.run_id}'
    and run_record_offset = {metadata_row.run_record_offset}
    ;
    """
    full_record = self.conn.query(query).to_df().iloc[0]
    logger.debug(
        f"Full record retrieved, elapsed: {time.perf_counter() - t1}, "
        f"total elapsed: {time.perf_counter()-t0}"
    )
    return full_record

@ghukill ghukill merged commit ed1cf59 into main Jul 7, 2025
2 checks passed
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.

2 participants