Skip to content

TIMX 530 - prep work and s3 client#155

Closed
ghukill wants to merge 42 commits into
mainfrom
TIMX-530-prep-work-and-s3-client
Closed

TIMX 530 - prep work and s3 client#155
ghukill wants to merge 42 commits into
mainfrom
TIMX-530-prep-work-and-s3-client

Conversation

@ghukill

@ghukill ghukill commented Aug 1, 2025

Copy link
Copy Markdown
Contributor

Purpose and background context

This PR is some small prep work and updates for the upcoming "real" changes. And, the addition of an S3Client class that will be used to download, upload, and delete files in S3.

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

Nothing very exciting to see at this time!

Includes new or updated dependencies?

YES

Changes expectations for external applications?

NO

What are the relevant tickets?

ghukill and others added 30 commits August 1, 2025 15:19
Why these changes are being introduced:

Bulk reading and writing from the TIMDEX dataset is a primary responsibility,
but occassional random access (e.g. locating a single record row) will be
helpful (e.g. looking at the original source record for a problematic record).

Each TIMDEX JSON record in Opensearch will contain a "provenance" object that will
include things like run_date, run_id, and now run_record_offset.  This offset
allows for quicker (time) and more efficient (data read) retrieval of a single
record given information in the TIMDEX provenance object.

How this addresses that need:

Parquet files have metadata embedded that describe what values can be found
in subsets of the file, but this is only helpful when the min/max values
in that metadata can inform query engines if a desired record may be
present.  Unfortunately, the timdex_record_id is a) not lexicographically
sortable (at least not easily), and b) are not ordered during write.

By adding this offset, effectively an incrementing counter as records are
yielded for writing, we have a value that is pre-sorted and provides nice
ranges in the parquet file metadata.  Query engines can utilize this to
dramatically improve random access reads.  By including this offset integer
in the TIMDEX record "provenance" section we close the loop and provide
enough information in the Opensearch record to efficiently retrieve it
from the parquet dataset.

Side effects of this change:
* Dataset will now include a new column 'run_record_offset'

Relevant ticket(s):
* https://mitlibraries.atlassian.net/browse/TIMX-465
Why these changes are being introduced:

During use of this library for batch reading transformed
records to index into Opensearch, the application TIM threw
an out-of-memory error.  It was observed that, as-is, batch
reading of records could hover around 1-2gb.  This was surprising,
as we are careful to only yield records as they are batch read from
the parquet dataset.

It turns out that pyarrow Dataset.to_batches() has some defaults
for optimistic reading ahead to improve IO, but at the cost of
memory consumption.  Tuning these down resulted in much lower
memory consumption, that aligns with how our current TIMDEX
applications are resourced.

How this addresses that need:

By surfacing the Dataset.to_batches() arguments 'batch_readahead'
and 'fragment_readahead' to this library's read methods, and
setting conservative defaults, memory consumption is significantly
lower.

Side effects of this change:
* Per the defaults set, slower IO for dataset reads.

Relevant ticket(s):
* https://mitlibraries.atlassian.net/browse/TIMX-468
Why these changes are being introduced:

With the addition of two read configurations that would be passed
around beteween multiple methods, the dataset module tipped
to where a centralized configuration object would be helpful.

Additionally, we have learned that per-operation configurations are
rare, and much more likely to be set once during TIMDEXDataset
init, or even as env vars for the duration of the library import.

How this addresses that need:

Creates a dataclass TIMDEXDatasetConfig that is passed to
TIMDEXDataset on init.  This class provides a typed object, with sensible
defaults, that are shared throughout all read and write methods.

Side effects of this change:
* None

Relevant ticket(s):
* https://mitlibraries.atlassian.net/browse/TIMX-468
Why these changes are being introduced:

As the parquet dataset architecture is getting promoted
to production, it felt like a suitable time for a 1.0
release that would mark this library's first real
usage.

Relevant ticket(s):
* None
Why these changes are being introduced:

As of pipenv 2025.0.1 the use of `pipenv check` would throw
an error, indicating that the library `safety` was not installed.
It worked to run `pipenv check --auto-install` which would
temporarily install `safety`, but this was not ideal for multiple
reasons.

First, we anticipate potentially moving away from `pipenv`.

Second, it appears that `safety` is moving to a pay / subscription
model.

Third, it remains a little obfuscated what `pipenv check` is actually
doing.

As this new situation affects all builds in Github Actions CI,
we need a way to scan for vulnerabilities that ideally is not
a massive overhaul of our vulnerability scanning approach.

How this addresses that need:

`pip-audit` is a nice standalone, open-source library that
performs very similar work to `safety`.

This commit replaces `pipenv check` (which was `safety` under
the hood) with `pip-audit`.

Side effects of this change:
* Builds will be successful in Github Actions

Relevant ticket(s):
* https://mitlibraries.atlassian.net/browse/IN-1240
Why these changes are being introduced:

One of the challenges the architecture of the TIMDEX parquet dataset presents is
quick and easy metadata about ETL "runs" in the dataset.  The year/month/day partitioning
structure is very efficient for accessing a run if you know the date, where only a few
parquet files are scanned, but it's not geared towards quickly isolating runs (parquet files)
associated with a given source.

Having metadata about runs provides a map to efficiently access meaningful subsets of data.
One example would be fully refreshing a source in Opensearch.  To do, you'd want to access
all runs for a given source since, and including, the last run_type=full run.  Those runs
represent the current state of the source in TIMDEX.

Unfortunately, this is not terribly efficient to naively perform with pyarrow or DuckDB, where
potentially thousands of parquet files are touched.  Similar to how Apache Iceberg (a parquet
dataset architecture) works, we need some metadata about each "run" in the dataset which
correlates to parquet file(s).

How this addresses that need:

A new class TIMDEXRunManager exists to provide this functionality.  This class will produce
a pandas dataframe of metadata about all runs in the dataset, including the explicit parquet
filepath the run is associated with, in a highly efficient and parallelized way.

The is achieved by:
1. Getting a list of all parquet files from the dataset.
2. Reading the *first* row from each file, which contains metadata about the run that
produced the file.
3. Aggregating the results and grouping by "run_id".

The result is a dataframe that provides a precise map of run metadata to parquet files in
the dataset.  With those parquet files identified, this unblocks further functionality
for this library like "replaying" the runs for a given source in chronological order
to refresh it in Opensearch.

Side effects of this change:
* None.  No changes are made to pre-existing functionality, just the addition of this new
information gathering class.

Relevant ticket(s):
* https://mitlibraries.atlassian.net/browse/TIMX-493
* https://mitlibraries.atlassian.net/browse/TIMX-494
Why these changes are being introduced:

With the creation of TIMDEXRunManager we now have the ability to
identify parquet files associated with current ETL runs for all
or a given source.  This could be used to limit the TIMDEXDataset
on load to only read from those parquet files.

How this addresses that need:
* Updates TIMDEXDataset.load() with a new 'current_records' flag that
if True will use TIMDEXRunManager to get a list of parquet files to
upload the dataset paths with.

Side effects of this change:
* None without explicit use.  Eventually, could be utilized by
contexts where only parquet files associated with current ETL
runs are needed.

Relevant ticket(s):
* https://mitlibraries.atlassian.net/browse/TIMX-494
Why these changes are being introduced:

With TIMDEXDataset capable of limiting to only parquet files
associated with current runs, the next logical step is providing
the ability to yield only the current version of a record.

This would support a "full refresh" of a TIMDEX source where an
application like TIM could yield only current records for a given
source and index those to Opensearch.

How this addresses that need:

When TIMDEXDataset is loaded with current_records=True, the private
attribute TIMDEXDataset._dedupe_on_read is set to True, informing
any read methods to dedupe during yielding.  Because all read
methods TIMDEXDataset.read_batches_iter() at the lowest level,
the deduping logic is required only there.

Because the ordering of the parquet files is already handled by
the load method, the read methods can be confident they are always
seeing the most recent version of a record first, and thus can
just maintain a "seen" list as they are encountered.  This keeps
the deduplication effectively instant and memory safe; no large
in-memory reordering or deduplication is required.

Side effects of this change:
* Applications like TIM now have the option of yielding only current
records for a source, or all sources, supporting new functionality
like fully reindexing a source in Opensearch from parquet dataset
data alone.

Relevant ticket(s):
* https://mitlibraries.atlassian.net/browse/TIMX-494
Why these changes are being introduced:

It was a bit confusing, and required unneeded logic branching, if
one should use .get_current_parquet_files() or
.get_current_source_parquet_files().

How this addresses that need:

* .get_current_parquet_files() becomes the public interface to use,
now with an optional 'source' keyword argument

Side effects of this change:
* None

Relevant ticket(s):
* https://mitlibraries.atlassian.net/browse/TIMX-494
Why these changes are being introduced:

Formerly, an instance of TIMDEXRunManager expected a TIMDEXDataset on init, where
it would utilize the pyarrow TIMDEXDataset.dataset.  This results in an unneeded
tightly coupling betweent these classes.

How this addresses that need:
* TIMDEXRunManager updated to only expect a pyarrow Dataset

Side effects of this change:
* None

Relevant ticket(s):
* https://mitlibraries.atlassian.net/browse/TIMX-496
Why these changes are being introduced:

Unexpected behavior was possible when using load(current_records=True)
and then applying additional filtering to the dataset before reading.
In short, a non-current record could be yielded if filtering removed
the truly current version of the record.  This happened because the
reverse chronological marking of "seen" records would not "see" this
record and happily yield an older version.

How this addresses that need:

When load(current_records=True) is used, a clone of the dataset is
saved to the TIMDEXDataset object before any additional filtering
is applied.  This dataset is just metadata, not expensive to store.

Then, during any read methods, this dataset is used to provide an
exhaustive and ordered list of timdex_record_ids.  Even if a record
has been filtered out by the read method (e.g. limiting records to only
action="index"), this secondary list of timdex_record_ids is used
as the authoritative list of "seen" timdex_record_ids.

There is a bit of network overhead to this parallel batch reading,
but fairly minimal as we are only retrieving the 'timdex_record_id';
perhaps 1-2mb of IO per millions of records.

Side effects of this change:
* Applications like TIM that will likely use this new functionality
to yield only "current" records can do so confidently, and optionally
with additional filtering, knowing they will only encounter current
versions of a record from the dataset.

Relevant ticket(s):
* https://mitlibraries.atlassian.net/browse/TIMX-497
Why these changes are being introduced:

During work on yielding only "current" records from the dataset,
where ordering of the ETL runs in the dataset is critical, it
was determined that more time granularity was needed for each
ETL run.

Currently we store the YYYY-MM-DD for each run, but if multiple
runs occur on the same day, we are unable to order them more
granularly.

How this addresses that need:
* Adds new run_timestamp to parquet dataset schema
* Timestamp is minted before any runs are written, and then
used for each row in the ETL run

Side effects of this change:
* All TIMDEX components that use this library for reading
and writing will need a terraform rebuild to pick
up this change.  Otherwise, they need no further modification.

Relevant ticket(s):
* https://mitlibraries.atlassian.net/browse/TIMX-496
Why these changes are being introduced:

We have a need to perform a onetime bulk edit of the parquet dataset,
which is very similar to a SQL database migration: addition of a column
and backfilling of data.

While we may not require the strictness of SQL schema migrations, which
often can be "replayed" in order to upgrade or reverse order to downgrade,
we nonetheless would benefit from some structure and code-as-documentation
for bulk operations we perform on the actual parquet dataset.

How this addresses that need:
* Creates a new root level migrations/ folder
* Includes a README with a proposed simple structure for migrations (mostly
naming conventions)
* Includes our first migration to backfill all pre-existing parquet files
with a new 'run_timestamp' column
  * The run_timestamp will be pulled from the creation date of the parquet
    file in S3, which is close enough for our purposes

Side effects of this change:
* TDA becomes the location for storing code related to "migrations" for
the parquet dataset
* All pre-existing parquet files will now have a run_timestamp column

Relevant ticket(s):
* https://mitlibraries.atlassian.net/browse/TIMX-496
Why these changes are being introduced:

After it was discovered that a single ETL run (run_id) has multiple, different
run_timestamps in the dataset, it was clear we needed a way to pass an explicit
run_timestamp for writing instead of relying on a run_timestamp minted as part
of the TIMDEXDataset.write() method.

How this addresses that need:

DatasetRecord was given an optional run_timestamp property, that defaults
to using run_date and producing a full ISO timestamp from that.

Side effects of this change:
* Applications like Transmogrifier can pass an explicit run_timestamp
when writing to the dataset, ensuring that even multiple invocations
of Transmogrifier + TIMDEXDataset.write() can share the same timestamp
for an ETL run.

Relevant ticket(s):
* https://mitlibraries.atlassian.net/browse/TIMX-509
Why these changes are being introduced:

As outlined in more detail in TIMX-506, this class is a new, alternate approach
to providing high level metadata about the dataset to better support bulk
operations.

This class is designed to remove the need for TIMDEXRunManager, while also providing
more helpful and granular data about the dataset.  Ultimately this will reduce code
complexity by relying less on implicit data contracts about the datasaet and more
on verifiable, data-in-hand about the dataset that can be used for bulk operations
(e.g. yielding current records).

How this addresses that need:
* New class TIMDEXDatasetMetadata is created
  * uses DuckDB to quickly crawl the dataset, generating an in-memory or on-disk
    database of record metadata
* This class is not yet wired into any TIMDEXDataset operations, this will happen
in future work

Side effects of this change:
* None

Relevant ticket(s):
* https://mitlibraries.atlassian.net/browse/TIMX-506
ghukill added 12 commits August 1, 2025 15:19
Why these changes are being introduced:

Previously, when asked to yield only current records the class
TIMDEXRunManager was used to crawl the dataset and produce a dataframe of
ETL runs that informed a second iterator to consult during the yielding
of records.  This class was superceded by TIMDEXDatasetMetadata which
can provide the same information, but also more granular record level
metadata which simplifies the yielding of current records.

How this addresses that need:

The new TIMDEXDatasetMetadata class is used to provide a mapping
of timdex_record_id to run_id that allows, during the yielding of records,
to only yield a record if it's associated with the correct run_id.

The net effect here is a simpler, and ultimately more accureate, way to
yield current records while also utilizing a class that provides more
functionality in other contexts.

Side effects of this change:
* None

Relevant ticket(s):
* https://mitlibraries.atlassian.net/browse/TIMX-507
Why these changes are being introduced:

With the introduction of TIMDEXDatasetMetadata class, there is no
functionality that TIMDEXRunManager provides that is unique.

Side effects of this change:
* None

Relevant ticket(s):
* https://mitlibraries.atlassian.net/browse/TIMX-507
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
Why these changes are being introduced:

As we prepare for expanding the role of dataset metadata,
which may be stored as a static file in S3 next to the dataset,
it will be helpful to test and develop against a realistic S3
backdrop but do so locally.

How this addresses that need:

Configures pyarrow and DuckDB to create appropriate connections
when a 'MINIO_S3_ENDPOINT_URL' env var is present.

Side effects of this change:
* None

Relevant ticket(s):
* https://mitlibraries.atlassian.net/browse/TIMX-515
Why these changes are being introduced:

While PRAGMA configurations worked for direct parquet file
reading in a local MinIO S3 instance, it did not work for
DuckDB ATTACH statements.

How this addresses that need:

By using a DuckDB secret, setting the MinIO S3 endpoint
and credentials there, other statments like ATTACH work
as expected.

This is overall a better approach anyways!  The PRAGMA
approach seemed like the only option originally, but this
edge case of ATTACH forced revisiting it and this use of
secrets does in fact work.

Side effects of this change:
* None

Relevant ticket(s):
* https://mitlibraries.atlassian.net/browse/TIMX-515
Why these changes are being introduced:

With the addition of dataset/metadata assets in S3, we will
need to perform actions like downloading the static DB file,
uploading a new one, and deleting append deltas.

How this addresses that need:

Creates new utility class S3Client that performs these
actions.

Side effects of this change:
* None

Relevant ticket(s):
* https://mitlibraries.atlassian.net/browse/TIMX-530
@ghukill ghukill closed this Aug 1, 2025
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