Support deleted rows in SAS data files - #366
Conversation
189d873 to
1cbd51e
Compare
|
I've rebased on the latest commits in
If there is anything else that should be changed, please let me know. The existing unit tests for the reading of SAS data files all rely on the write feature of ReadStat to write the files first. Would it be okay to add a unit test that uses the example file from #284? It's quite small so commiting it to the repository seems okay to me. Or should I add a feature to the write API to write deleted rows? |
|
I've tested this PR with SAS data files from SAS 9.4 and the file from the linked issue, which has been created with SAS 8.0202M0. And it works fine for those as far as I can tell. However, the PR doesn't work for the SAS file from #379, which has been generated with SAS 7. The SAS 7 file is also treated differently by SAS 9.4 clients. Usually, the number of deleted rows is visible in the GUI of a local SAS installation but not for this one. While the incremental row number in the data view is presented correctly for the (only) non-deleted row, the total number of rows in the meta-data is presented as just 1. There are two reasons why the PR doesn't work for the SAS 7 file:
Both problems are fixable:
If there is interest in an adaptation of this PR for SAS 7 files, please let me know. |
|
As far as I am concerned (but I am just a biased user) support for SAS8+ files with deleted rows would already be very nice. I mean, support for SAS7 files would be even nicer but not if that delays merging the original PR. |
|
I would not worry about dealing with SAS datasets generated by SAS version 7. That version was not very stable and very few people ever used it. |
|
Hi, I asked Fable to review, can you look over the comments and make adjustments if necessary? (Sorry it somewhat annoyingly decided to use "maintainer voice", but here goes:) Bugs: interaction with user-set row limit / row offsetThe premise that
A related semantic wrinkle even once those are fixed: deleted rows consume the user's row budget ( Design: hard failure on count mismatchToday, files with deleted rows parse "successfully" (deleted rows come through as live). With this PR, any file where the bitmap and counters don't reconcile — the SAS 7 file in #379, or Your SAS 7 analysis also suggests the more robust bitmap location is "immediately before the first subheader" rather than computed forward from the row area — worth considering here even independent of SAS 7 support. Minor
SummaryTo get this merged: drop |
|
Thanks for the review! The observations about I'll fix this but it will take some time as I won't be able to work on it next week. There are two aspects in the review that I'd like to have your short personal take on:
|
|
I've fixed the interaction with the row limit/offset functionality, rebased on dev, and added a test with the example SAS 8 data file from the issue #284. The test is integrated into I've manually tested the change also against SAS 9.4 data files (Linux 64-bit and Windows 32-bit and each with both compressed and uncompressed rows). The proposed change doesn't work for the SAS 7 file from #379 due to the reasons given above. If #379 is merged, then this change will not change how that file is processed: Only the first row, which is soft-deleted, is read while the expectation would be that only the single non-deleted row should be read. My idea to derive the position of the bitmap from the end of the page instead of the the front, didn't work out in practice. Please have another look and let me know whether the PR needs further work. |
|
@evanmiller Please have another look. If there is anything that needs work, please let me know. Another review by AI would also be appreciated. |
7bfb6e7 to
bc45f0b
Compare
|
I've rebased on the latest commit on As far as I can tell, I've taken all feedback from the previous PR review into account in my latest changes. Please let me know whether there is anything to be changed in this PR. The PR proposes a new kind of test based on committed SAS files (as also suggested by Fable). If this proposition needs further work, please let me know and consider it done. |
|
Great thank you! |
|
Thanks! |
Follow-up to #366: - The row-offset guard in the row-size subheader compared the offset against the total row count while subtracting it from the live row count. A row offset between the two underflowed row_limit, reporting a garbage metadata row count and streaming rows read from page free space to the value handler. Compare against the live count instead. A regression test arg (row_offset=4) exercises this window against resources/datetime.sas7bdat (5 physical rows, 2 deleted). - Widen the deleted-row bitmap offset multiplication to 64 bits so a corrupt page_row_count/row_length pair can't wrap the bounds check. - buffer_read_from_resource: reset the buffer before reading (repeated test-arg iterations previously parsed the file concatenated with itself), stop freeing the caller-owned buffer on error paths, and honor the srcdir environment variable so VPATH builds can find the resource files. - Distribute resources/datetime.sas7bdat via EXTRA_DIST so make distcheck passes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…em as live data SAS deletes a row by tombstoning it. The row stays on the page, stays counted by the header, and a reader that does not look for the mark returns it as data. We did, silently, and so does ReadStat 1.1.9 -- which is what `haven` and `pyreadstat` ship -- and so does the C# reader whose expected-output sidecars sit next to these fixtures. Only ReadStat after WizardMac/ReadStat#366 gets it right. Measured against ReadStat built at `da9fcaa`, on fixtures already in the corpus: all_rand_normal_with_deleted we said 37, correct is 36 all_rand_normal_with_deleted2 we said 37, correct is 32 data_page_with_deleted we said 998, correct is 997 pandas/load_log we said 2097, correct is 2088 comp_deleted failed to open at all Two representations, so two mechanisms. An uncompressed page carries a trailing bitmap; a compressed row is tombstoned by its subheader pointer's compression code, 0x05. The last file above is the compressed case and used to fail with "unsupported subheader compression mode 5"; the four before it read cleanly and were quietly wrong, which is the worse failure. Both are gated on a count read once per file from the ROW_SIZE subheader, so a file that declares no deletions does one extra u64 read at metadata time and nothing per page. That gate is why this does not touch the fast path: a page with tombstones cannot stay on `FusedContiguousUncompressed`, which addresses rows as `data_start + i * row_len` and has nowhere to put a hole, so such a page is demoted to explicit spans over the live rows. Only pages that need it pay. There are two fused paths, not one. Patching only `classify_indexed_descriptor` left `data_page_with_deleted` still reporting 998, which is how `classify_descriptor` was found. `load_log.sas7bdat` was not known to carry deletions. It turned up by re-converting the whole corpus and diffing row counts against the previous run, which is worth repeating for the rest of this work. `info` now leads with the live count and shows the header's as the qualifier, because printing 4 rows for a file `convert` writes 3 of is its own small lie. The span-per-live-row representation here is exactly what PERFORMANCE.md item 7 objects to. It is acceptable because of the file-level gate, and it should be deleted rather than fixed when the contiguous-tail descriptor lands.
Resolves #284.
Introduction
This PR adds support for deleted rows in SAS data files, which may be compressed or uncompressed.
The handling of deleted uncompressed rows follows the description of the sas7bdat file format here: https://github.com/FredHutch/sas7bdat-specification/blob/master/sas7bdat.rst
The handling of deleted compressed rows is not described in the document linked above. However, it is much simpler as it doesn't involve a dedicated bitmap but only the compression type
0x05, which marks compressed data rows as deleted. The compression type0x05seems to be the combination of0x01(meaning the data can be skipped) and0x04(indicating a compressed data row), which fits well with my theory put forth in #365 that the compression type is actually a bitmap.Row limits and counters
Currently, the
row_limitis not only an upper limit on the number of rows but also the exact number of rows that are expected to be parsed. As therow_limitalso includes the deleted rows, the corresponding counterparsed_row_countis now also increased when encountering a deleted row. This allows for the validation checks againstrow_limitto remain unchanged.The two new variables
deleted_row_limitandparsed_deleted_row_countserve a corresponding purpose but count only deleted rows.The row count in the meta data is computed as
row_limit - deleted_row_limit. Accordingly, the row id that is passed to the value handler and is used in error messages is now computed asparsed_row_count - parsed_deleted_row_count.Implementation alternative
To the user of the library, deleted rows are transparent with the proposed change, which is a bit different in SAS itself. There, the number of deleted rows is visible in the metadata and the GUI viewer indicates positions of deleted rows by non-subsequent row ids.
One could consider adding the number of deleted rows to the meta data. And one could also consider marking rows as deleted either by explicitly passing the information to the value handler or by implicitly not passing data for deleted row ids to it. This would require a breaking change in the API, I think. It's mostly a discussion on what the information
row_countin the meta data should exactly refer to in the case of deleted rows.Validation
The code works as expected with the example file from #284, which uses a single page and contains only 5 rows (of which 2 are deleted).
I've also tested it successfully against data files written on both Windows and Linux that contain multiple pages and have deleted rows across them.
As generating generic test data is quite easy in this case, I can generate test files if needed.