TIMX 494 - yield deduped, most recent records#144
Conversation
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
Pull Request Test Coverage Report for Build 15189612409Details
💛 - Coveralls |
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
ehanson8
left a comment
There was a problem hiding this comment.
Looking good and works as expected, just a few questions!
|
@jonavellecuerdo , @ehanson8 - I've stumbled on kind of an interesting wrinkle / edge case with this approach. @ehanson8, in a way, it's related to your comment about My preference would be to keep this PR as-is, which establishes some flags and methods that get us very close. In fact, it would work as needed for TIM to refresh a source as long as TIM was careful with how it uses this new functionality. But, I think there is room for The very short example of this edge case:
While those I would propose that when
|
I largely agree but did wonder if this could complicate troubleshooting if we can't find the ID of a particular record in the loaded records since it was deleted. However, I imagine |
Yeah, it's interesting. I know that I probably overuse "ergonomics" but I think this is a really good example of it. Functionality wise it's all pretty simple, but it's about matching public interfaces, internal arguments and naming conventions, and behavior with a user's expectations. My hope is that we could nudge any concept of "current records" towards 100% matching what we find in Opensearch. So if
I think the implementation details for this have a little complexity, but I think the more intuitive ergonomics of it will be worth it. |
|
@jonavellecuerdo, @ehanson8 - building on my comment above about an edge case, if interested, here is a unit test I'm working on locally that gets at it. My goal is to work through a solution for this test, which currently fails, and have that be the basis of a followup PR. def test_dataset_current_records_accurate_with_additional_filters(new_local_dataset):
"""Simulate ETL runs with the following flow:
1. full run-1 establishes 5 records
- current state = 5 to-index
2. daily run-2 updates the first 5 and adds 5 new ones
- current state = 10 to-index
3. daily run-3 has 3 deletes
a. current state = 7 to-index, 3 to-delete
The interesting records here are alma:0|1|2 where the most recent version is
action="delete". If during a read method we filtered action="index", we would expect
that we don't see alma:0|2 with action="delete". However, we should *also* not see
alma:0|1|2 with action="index" from run-2, as these are not "current" versions of the
record.
This test ensures that even if additional filtering is applied after
load(current_records=True) is performed, we do not encounter non-current forms of a
record.
"""
run_params = [
(5, "alma", "2025-01-01", "full", "index", "run-1"),
(10, "alma", "2025-01-02", "daily", "index", "run-2"),
(3, "alma", "2025-01-03", "daily", "delete", "run-3"),
]
for params in run_params:
num_records, source, run_date, run_type, action, run_id = params
records = generate_sample_records(
num_records,
timdex_record_id_prefix=source,
source=source,
run_date=run_date,
run_type=run_type,
action=action,
run_id=run_id,
)
new_local_dataset.write(records)
# without any additional filtering, confirm current records are accurate
new_local_dataset.load(current_records=True)
assert new_local_dataset.read_dataframe().action.value_counts().to_dict() == {
"index": 7, # run-1 and run-2 established 10 records
"delete": 3, # run-3 had 3 deletes
}
# apply action="index" filtering on read, assert that alma:0|1|2 are not present
new_local_dataset.load(current_records=True)
current_to_index = new_local_dataset.read_dataframe(action="index")
assert len(current_to_index) == 7
for timdex_record_id in ["alma:0", "alma:1", "alma:2"]:
assert timdex_record_id not in list(current_to_index.timdex_record_id) |
|
Makes sense to me! |
jonavellecuerdo
left a comment
There was a problem hiding this comment.
One small request that will likely be addressed in the follow-up PR!
| *, | ||
| current_records: bool = False, | ||
| **filters: Unpack[DatasetFilters], | ||
| ) -> None: |
There was a problem hiding this comment.
Can you update the docstring for TIMDEXDataset.load to include a description for the new current_records: bool keyword arg?
There was a problem hiding this comment.
It is coming in the followup PR! Good catch.
Purpose and background context
This PR marks a somewhat important milestone in this library: support for reading only "current" records from the parquet dataset in a performant and memory-efficient manner.
This builds on PR #143, which introduced the
TIMDEXRunManagerwhich provided explicit lists of parquet files based on run ETL metadata. With that in hand,TIMDEXDatasetwas updated in two key ways.First, the
.load()method was updated to include acurrent_records:boolkeyword argument, which when True, will utilize theTIMDEXRunManagerto set an explicit list of parquet files for any future reading.Second, when
current_recordsare requested a private attribute is setTIMDEXDataset._dedupe_on_read = True. This informs all read methods to perform record deduping on read, which they can confidently and easily do with the dataset parquet files explicitly set in reverse chronological order.The majority of the work was not getting this functional, but keeping the API minimal, intuitive, and non-surprising where possible. For example, to read only current
dspacerecords from the dataset, only the following is required:The first anticipated use case is for TIM, where a new CLI command along the lines of
reindex-sourcewould utilize this new.load()+ read technique to yield all and only current records for a source purely from dataset data. No re-harvests would be required. This is useful for development work, and could be a very useful option in disaster recovery situations.How can a reviewer manually see the effects of these changes?
1- Set Dev Admin AWS credentials
2- Set following env vars (if not already):
3- Start Ipython
4- Load the dataset for
libguidescurrent records only5- Given the small number of records for this source, load them all into a dataframe (takes ~5 seconds):
6- Inspecting the dataframe to confirm that
timdex_record_idis unique:7- Observe the ETL runs present in the records:
run_type="full"has the bulk of the records, andrun_type="daily"runs are just incrementally updating8- Lastly, optional and just for kicks, an more full fledged example showing looping through all current records for
dspace:run_type="full"where you get lots of recordsbtop, note that memory usage remains lowIncludes new or updated dependencies?
NO
Changes expectations for external applications?
YES: applications like TIM may utilize new TDA functionality to read only current records
What are the relevant tickets?
Developer
Code Reviewer(s)