Conversation
Added PageLocation, OffsetIndex and ColumnIndex structures to the parquet.thrift file in order to support secondary indexes in parquet files.
This commit moves the design doc from [1] to a .md file. It removes the actual thrift definitions, which are added to parquet.thrift. [1] https://docs.google.com/document/d/1sBACp8Lbutuj1Zxdowvsrlm8ku4BFxf8U_Do5K2wSO4/edit#
| * value that does not exist on that page. For ordered columns, these values | ||
| * must be lower and upper bounds for consecutive pages respectively. | ||
| */ | ||
| 2: required list<binary> min_values |
There was a problem hiding this comment.
I like where you're going with the description, but I was initially confused by it. How about giving a concrete example?
It may also be good to note that descending order still stores "min" values here, meaning the values that come first in the ordering.
There was a problem hiding this comment.
The guarantee for ordered columns is only required if max_values is omitted. The reason is that it's hard to come up with a value between one page's max and the next's min when truncating. Imagine I have a page that ends with "aaaaaaaaaaaaaaaaa" and the next one starts with "aaaaaaaaaaaaaaaaaaaab". If we're truncating strings to save space, we don't want to add logic to come up with a value between the two for the min. It's better in that case to truncate both and also write max_values.
Should we also clarify that the guarantee is that x >= lower and x <= upper for all values x in a page? I think we should be explicit that bounds could be equal.
There was a problem hiding this comment.
Also we should clarify where we imply strictly lower vs lower. The current definition of min and max always implies "or equal" which means that the boundary values in min_values here can be in both pages. It is not min included and max excluded as you would expect for boundary values (unless we add the constraint of not starting a new page as long as the same value is repeated). This lowers the value of making max_values optional. I think it general everything is simpler if we keep max_values. Is it really worth making optional?
julienledem
left a comment
There was a problem hiding this comment.
Thanks for posting!
Please see my comments.
| struct OffsetIndex { | ||
| /** | ||
| * PageLocations, ordered by increasing PageLocation.offset. It is required | ||
| * that page_locations[i].first_row_index > page_locations[i+1].first_row_index. |
There was a problem hiding this comment.
did you mean "<" ?
first_row_index should be increasing as well
| * value that does not exist on that page. For ordered columns, these values | ||
| * must be lower and upper bounds for consecutive pages respectively. | ||
| */ | ||
| 2: required list<binary> min_values |
There was a problem hiding this comment.
Also we should clarify where we imply strictly lower vs lower. The current definition of min and max always implies "or equal" which means that the boundary values in min_values here can be in both pages. It is not min included and max excluded as you would expect for boundary values (unless we add the constraint of not starting a new page as long as the same value is repeated). This lowers the value of making max_values optional. I think it general everything is simpler if we keep max_values. Is it really worth making optional?
|
pleas add "closes #63" in the description since it supersedes the previous PR. |
|
also mention @poojanilangekar so that this is attributed appropriately when we merge. |
| * Consider the following values: | ||
| * | 37 31 29 23 | 19 17 13 11 | 7 5 3 2 | | ||
| * ^ ^ ^ ^ | ||
| * Examples for valid lists of minimum values are [23 11 2] or [ 20 8 0 ]. |
There was a problem hiding this comment.
I think I disagree? Min should respect the sort order, so a descending order produces 23 < 11 => true. Valid mins for this example are [37, 19, 7] or [38, 20, 8] or [37, 23, 11]
There was a problem hiding this comment.
On second thought, I don't think it is a good idea to do this. The mins should always be the min defined by the type's order. Descending order shouldn't change that, but it does make the logic more confusing when trying to use it as both min and max.
When mins are min and max, descending order changes the page for which the min of P_i min is a max. For asecending order:
For descending:
That's annoying. Maybe we should just always require max_values as Julien suggests. That makes it easy to produce smaller bounds anyway by simplifying the constraints for truncation.
There was a problem hiding this comment.
I'm ok with making max_values required, too. However, if min_values is ordered and partitions the values, then one can use binary search to find matching pages. To enable that, we would need some boolean that writers can set to indicate this property.
@julienledem I couldn't reply to your comment above for some Github reason. :(
| * ^ ^ ^ ^ | ||
| * Examples for valid lists of minimum values are [23 11 2] or [ 20 8 0 ]. | ||
| * | ||
| * If max_values is set, then pairs of min and max values may be overlapping. |
There was a problem hiding this comment.
Change "may be overlapping" to "may overlap".
|
The test failure is that PageIndex.md doesn't have a license header. You can use the one from LogicalTypes.md. |
Added license to PageIndex.md
|
I addressed the comments from today's sync meeting. @julienledem @mkornacker @rdblue @zivanfi - Please have a look. |
rdblue
left a comment
There was a problem hiding this comment.
A couple of minor comments.
| DATA_PAGE_V2 = 3; | ||
| } | ||
|
|
||
| enum SortOrder { |
There was a problem hiding this comment.
Could this enum have a more specific name? Maybe BoundaryOrder?
| * entries in min_values and max_values should be ignored. If false, the | ||
| * corresponding entries must be valid. | ||
| */ | ||
| 1: required list<bool> null_pages |
There was a problem hiding this comment.
We should specify that the min and max values for a page of only null values cannot be omitted from the min_values and max_values lists. Instead it should be byte[0] (because thrift doesn't allow null in the lists). This preserves the property that for page i in a column, its min is min_values[i] max is max_values[i], etc.
| } | ||
|
|
||
| enum SortOrder { | ||
| enum BoundayOrder { |
There was a problem hiding this comment.
Should be "BoundaryOrder". Missing the "r".
|
+1 |
| only reason to write page-level statistics when writing ColumnIndex structs | ||
| is to support older readers (not recommended). | ||
|
|
||
| This allows a reader to find matching pages by performing a binary search in |
There was a problem hiding this comment.
I would add "For ordered columns" to the beginning of this sentence.
| - under the License. | ||
| --> | ||
|
|
||
| # ColumnIndex Layout to Support Page Skipping |
There was a problem hiding this comment.
I would suggest linking to this page from the main documentation page (README.md) so that it becomes discoverable by readers.
| 1: required list<bool> null_pages | ||
|
|
||
| /** | ||
| * Two lists containing lower and upper bounds for the values of each page. |
There was a problem hiding this comment.
No strong opinion here, just asking: Wouldn't it be nicer to have a single list of elements containing both a min and a max field?
There was a problem hiding this comment.
I think the property that for page i, relevant information is stored at min_values[i], max_values[i], null_pages[i], etc. is convenient enough. Plus, it simplifies binary search and makes the search more cache-friendly.
There was a problem hiding this comment.
Also, if min_/max_values were fields in a struct, the resulting serialized byte sequence would be less compact (I think).
| /** | ||
| * Stores whether both min_values and max_values are orderd and if so, in | ||
| * which order. This allows readers to perform binary searches in both lists. | ||
| * Readers cannot assume that max_values[i] <= min_values[i+1], even if the |
There was a problem hiding this comment.
I agree with this design decision and understand the reason behind it, but it may be hard for readers to understand why this is good (actual_max[i]="Blart Versenwald III", actual_min[i+1]="Blart Versenwald IV", stored_max[i]="C", stored_min[i+1]="B"). I would suggest elaborating a bit on this in the .md file and maybe also putting a note in the comment of this struct to refer to the documentation for details.
There was a problem hiding this comment.
Do you have a suggestion what to add so it becomes more clear?
There was a problem hiding this comment.
Is this so you can truncate very large values (e.g., strings)? I think it would be helpful to point that out in the .md (ie, something along the lines that the writer can/should enforce some reasonable bound on the size of the index structures).
| /** | ||
| * Two lists containing lower and upper bounds for the values of each page. | ||
| * These may be the actual minimum and maximum values found on a page, but can | ||
| * also be (more compact) values that does not exist on a page. Readers must |
There was a problem hiding this comment.
I agree with this design decision and understand the reason behind it (instead of storing "Blart Versenwald III" you can store "C" as the max value), but it may be hard for readers to understand why this is good. I would suggest elaborating a bit on this in the .md file and maybe also putting a note in the comment of this struct to refer to the documentation for details.
There was a problem hiding this comment.
I thought that "more compact" was the main reason here. I added your example to make it more clear.
|
After we now expanded several comments in parquet.thrift I'm inclined to remove the PageIndex.md from the commit altogether. It seems to me that the parquet.thrift documents the structures well enough for an unambiguous implementation. The intent behind adding the indexes is still visible in the original design proposal, which is linked in the JIRA itself. What does everyone think on removing it? |
|
I removed the .md doc, let me know what you think. |
|
I liked having the proposal and the picture. I think it's good to have the intent and more discussion documented. |
This reverts commit 5df2bbc.
|
@julienledem, @mkornacker - Can you have a final look at this? Thanks! |
mkornacker
left a comment
There was a problem hiding this comment.
Mostly minor clarification.
| ## Goals | ||
| 1. Make both range scans and point lookups I/O efficient by allowing direct | ||
| access to pages based on their min and max values. In particular: | ||
| 1. A single-row lookup in a rowgroup based on the sort column of that rowgroup |
There was a problem hiding this comment.
The markup here looks wrong.
There was a problem hiding this comment.
Markdown translates this into an enumerated list, but using the actual numbers does indeed make more sense. Fixed.
| DATA_PAGE_V2 = 3; | ||
| } | ||
|
|
||
| enum BoundaryOrder { |
| **/ | ||
| 3: optional ColumnMetaData meta_data | ||
|
|
||
| /** File offset of this column's OffsetIndex **/ |
There was a problem hiding this comment.
"of this ColumnChunk's" (these are column chunk, not column, specific)
| 1: required list<bool> null_pages | ||
|
|
||
| /** | ||
| * Two lists containing lower and upper bounds for the values of each page. |
There was a problem hiding this comment.
Also, if min_/max_values were fields in a struct, the resulting serialized byte sequence would be less compact (I think).
| /** | ||
| * Two lists containing lower and upper bounds for the values of each page. | ||
| * These may be the actual minimum and maximum values found on a page, but | ||
| * can also be (more compact) values that does not exist on a page. For |
|
|
||
| /** | ||
| * Stores whether both min_values and max_values are orderd and if so, in | ||
| * which order. This allows readers to perform binary searches in both lists. |
There was a problem hiding this comment.
I added a bullet point to the .md file.
| /** | ||
| * Stores whether both min_values and max_values are orderd and if so, in | ||
| * which order. This allows readers to perform binary searches in both lists. | ||
| * Readers cannot assume that max_values[i] <= min_values[i+1], even if the |
There was a problem hiding this comment.
Is this so you can truncate very large values (e.g., strings)? I think it would be helpful to point that out in the .md (ie, something along the lines that the writer can/should enforce some reasonable bound on the size of the index structures).
| 4: required BoundaryOrder boundary_order | ||
|
|
||
| /** A list containing the number of null values for each page **/ | ||
| 5: optional list<i64> null_counts |
There was a problem hiding this comment.
What happened to distinct_counts?
There was a problem hiding this comment.
In one of the recent parquet syncs we discussed that having a distinct_count per page seemed of little use, because they cannot be aggregated across pages. Additionally it looks like no writer currently populates the field (parquet-mr and Impala both don't). We could still add it back in a future commit if we think it's necessary.
There was a problem hiding this comment.
Ah right, good point. Why don't we mark Statistics.distinct_count as deprecated then?
There was a problem hiding this comment.
I don't think we should deprecate the distinct count until we know we won't use it in the future. Lets just leave it as is for now.
|
I also prefer having a separate documentation page for the feature. In general I think that it is easier to grasp the logic behind different Parquet mechanisms if they are documented per feature rather than scattered around in different data structures. It's like reading a book vs. trying to read an encyclopedia end-to-end. |
|
I still see a few corner cases that should be explicitly dealt with in my opinion. They are related to the feature that allows the min and max values to not necessarily be present in the data. Implementations can also pick other values as long as all data falls between min and max, with the goal of allowing long data to be truncated. For example instead of "Blart Versenwald III", one can use "B" as min and "C" as max. The corner cases are the following: According to the (already existing) specification, implementations must use unsigned byte-wise comparison for UTF-8 data. As result, the single byte 0xFF is a valid max value for any valid UTF-8 string. It is, however, not a valid UTF-8 string. We should either disallow this, or explicitly call out in the docs that implementations must not rely on min and max being valid values of the logical type. Values of the decimal type, however, must be compared using the represented value. In this case, min and max must be valid values of the logical type. The last corner case involves non-truncatable BLOBs. Suppose that the largest value in a page is a several-megabytes-large BLOB, consisting of the 0xFF byte repeated over and over. Since there is no shorter value that is larger in byte-wise comparison order and the specification does not allow omitting a single max value without omitting all of them, it means that either all max values must be omitted or the whole several-megabytes-large BLOB must be repeated for the max value. |
| } | ||
|
|
||
| /** | ||
| * Enum to annotate whether lists of min/max elements inside column indexes |
There was a problem hiding this comment.
I'd refer to the actual struct.
There was a problem hiding this comment.
Change the comment.
I thought that Statistics.distinct_count still may be helpful in row groups, for example a reader may decide whether to read the data into dictionaries on the fly. I think @rdblue wanted to double check that no writer currently uses distinct count in pages and then we would deprecate the page statistics in a subsequent change.
I'm also happy to do either of those in the current PR. @rdblue, @julienledem - what do you think.
There was a problem hiding this comment.
Good point.
Probably best to leave stats-related changes to a separate PR and get this in.
| 4: required BoundaryOrder boundary_order | ||
|
|
||
| /** A list containing the number of null values for each page **/ | ||
| 5: optional list<i64> null_counts |
There was a problem hiding this comment.
Ah right, good point. Why don't we mark Statistics.distinct_count as deprecated then?
|
@zivanfi - I expanded the comment before Regarding the maximum strings, I thought the conclusion of the weekly sync meeting was that the writer of such data will either have to omit the index altogether, or will have to accept the overhead by storing the full value within the index. Would you like us to state this in the comment explicitly, or do you suggest to change the structures to allow a more concise representation in such cases? |
If I remember correctly,
In the sync-up, the conclusion was that this is an extremely rare case that we don't need to worry about. |
|
+1 |
|
Awesome @mkornacker @lekv @poojanilangekar !! I'm working on a branch of parquetjs replicating the structure. The PageIndex diagram suggests storing ColumnIndex array and OffsetIndex array separately in sequence. This makes it hard to read both (for a single page) in a single read, let alone reading all of them for a single column (across all pages). As the location of each struct is up to the user, would it be considered a bad implementation of the spec to:
Also do you have any test parquet files available that follow this spec and include ColumnIndex and OffsetIndex or a link to a reference implementation? Really appreciate the help! |
|
Hi @lekv @poojanilangekar @mkornacker |

I moved the design doc to a .md file and addressed the first round of review comments.
closes #63
This is based on work done by @mkornacker and @lekv who wrote the initial proposal and @poojanilangekar who evolved the design, wrote a prototypical implementation, and evaluated its performance.