Skip to content

add nested nullable field support for CachedArrayReader - #9066

Closed
adriangb wants to merge 9 commits into
apache:mainfrom
pydantic:fix-cache-nested
Closed

add nested nullable field support for CachedArrayReader#9066
adriangb wants to merge 9 commits into
apache:mainfrom
pydantic:fix-cache-nested

Conversation

@adriangb

@adriangb adriangb commented Dec 30, 2025

Copy link
Copy Markdown
Contributor

Needed to unblock apache/datafusion#19556

This was mostly written by Claude with guidance on to fix the following MRE (reproducible on the branch for apache/datafusion#19556):

COPY (
  SELECT *
  FROM VALUES ({field: [{nested: 1}]}, 1), ({field: [{nested: 2}]}, 2) AS t(struct_col, id)
)
TO 'test.parquet';

set datafusion.execution.parquet.pushdown_filters = true;

CREATE EXTERNAL TABLE t1 STORED AS PARQUET LOCATION 'test.parquet';

-- Works, no issues
select struct_col['field'][1]['nested']
from t1
where id = 1;

-- Works, no issues
select id, struct_col
from t1
where struct_col['field'][1]['nested']  = 1;

-- Error
select id, struct_col['field'][1]['nested']
from t1
where struct_col['field'][1]['nested'] = 1;

@github-actions github-actions Bot added the parquet Changes to the parquet crate label Dec 30, 2025

@Weijun-H Weijun-H left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, Thanks @adriangb

better add this test

    #[test]
    fn test_level_propagation_empty_after_skip() {
        let metrics = ArrowReaderMetrics::disabled();
        let cache = Arc::new(Mutex::new(RowGroupCache::new(4, usize::MAX)));

        // Producer populates cache with levels
        let data = vec![1, 2, 3, 4];
        let def_levels = vec![1, 0, 1, 1];
        let rep_levels = vec![0, 1, 1, 0];
        let mock_reader =
            MockArrayReaderWithLevels::new(data, def_levels.clone(), rep_levels.clone());
        let mut producer = CachedArrayReader::new(
            Box::new(mock_reader),
            cache.clone(),
            0,
            CacheRole::Producer,
            metrics.clone(),
        );

        producer.read_records(4).unwrap();
        producer.consume_batch().unwrap();

        // Consumer skips all rows, resulting in an empty output batch
        let mock_reader2 = MockArrayReaderWithLevels::new(
            vec![10, 20, 30, 40],
            vec![0, 0, 0, 0],
            vec![0, 0, 0, 0],
        );
        let mut consumer = CachedArrayReader::new(
            Box::new(mock_reader2),
            cache,
            0,
            CacheRole::Consumer,
            metrics,
        );

        let skipped = consumer.skip_records(4).unwrap();
        assert_eq!(skipped, 4);

        let array = consumer.consume_batch().unwrap();
        assert_eq!(array.len(), 0);

        assert_eq!(consumer.get_def_levels().unwrap(), &[]);
        assert_eq!(consumer.get_rep_levels().unwrap(), &[]);
    }

@adriangb

adriangb commented Jan 4, 2026

Copy link
Copy Markdown
Contributor Author

Added in a495d4d

@adriangb

adriangb commented Jan 7, 2026

Copy link
Copy Markdown
Contributor Author

@XiangpengHao any chance you could take a look at this?

@XiangpengHao

Copy link
Copy Markdown
Contributor

Looks good to me, thank you @adriangb !

@adriangb

adriangb commented Jan 8, 2026

Copy link
Copy Markdown
Contributor Author

Looks good to me, thank you @adriangb !

Thank you for reviewing! I think this needs approval from someone with write permissions and for them to hit merge, I don't have these permissions in this repo.

@XiangpengHao

Copy link
Copy Markdown
Contributor

me neither, asking for @alamb's help!

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

Thanks -- the high level idea of this looks good. However, I think we need some end to end tests prior to merging this

Specifically an end to end test that actually reads a StructAray that uses repetition and definition levels, and then a row filter that filters out some of the rows

I looked around in

builder = builder.set_statistics_truncate_length(DEFAULT_COLUMN_INDEX_TRUNCATE_LENGTH);
and didn't find any clear example

I am imagining something like

let builder = builder
.with_projection(ProjectionMask::columns(&schema_descr, ["a", "b"]))
.with_row_filter(filter_b_false(&schema_descr));
where we setup a reader and read the results back

let array = self.inner.consume_batch()?;

// Capture definition and repetition levels from inner reader before they are cleared
let def_levels = self.inner.get_def_levels().map(|l| l.to_vec());

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.

this copies the levels, right? It seems like we should:

  1. Only copy the levels when needed (caching nested types)
  2. Look into potentially being able to take the levels rather than having to copy them

@adriangb adriangb left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@alamb I've reduced some allocations and added pretty extensive integration tests

Comment on lines +175 to +179
let def_levels = if self.needs_def_levels {
self.inner.get_def_levels().map(|l| l.to_vec())
} else {
None
};

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@alamb this was the best I could easily do: get_def_levels() returns an Option<&[u16]> and we're storing this on structures that can't easily have a lifetime (without a major refactor at least) so we have to allocate an owned Vec

col_idx,
cache_options.role,
self.metrics.clone(), // cheap clone
field.def_level > 0, // needs_def_levels: true if has nullable ancestors

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alternatively could pass field: &ParquetField or def_level: i16 and have CachedArrayReader::new figure out how to use that

@adriangb

Copy link
Copy Markdown
Contributor Author

@alamb sorry for the ping!

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

Sorry -- I am underwater with so many reviews.

I don't understand the nesting / reptition levels enough to review this code realistically as written.

Is there any chance you can write some "end to end" tests that show what this is doing from an end API perspective? Specifically, in https://github.com/apache/arrow-rs/blob/main/parquet/tests/arrow_reader/predicate_cache.rs

  1. Create a file with nested structures / nulls
  2. Create a read test with RowFilters on it
  3. Use the cache statistics to show what is cached / not cached

Maybe you can get claude to help translate the datafusion repro into a arrow-rs only repo?

@adriangb

adriangb commented Jan 26, 2026

Copy link
Copy Markdown
Contributor Author

Sorry for putting more on your plate…

I think the tests I added to parquet/tests/arrow_reader/predicate_cache.rs are exactly what you’re after?

adriangb and others added 2 commits March 27, 2026 08:33
Add tests demonstrating that nested fields inside List structures
(with rep_level > 0) are NOT cached, while top-level columns work
correctly:

- test_list_struct_fields_not_cached_filter_on_id: filter on id
  (rep_level=0), shows id is cached but list struct fields are not
- test_list_struct_fields_not_cached_filter_on_struct_field: filter
  on struct_field_b inside the list, shows 0 cache reads for all
  projections since filter mask leaves have rep_level > 0

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@adriangb

Copy link
Copy Markdown
Contributor Author

We've been able to work around this. @XiangpengHao @alamb do you think this is still valuable in its own right or should I close this?

@alamb

alamb commented Mar 31, 2026

Copy link
Copy Markdown
Contributor

I am sorry -- this pr is so large I am having trouble finding time to review it reasonably. Is it possibly to slim it down somehow? Perhaps breaking out tests (and/or reducing boiler plate, etc)?

@github-actions

Copy link
Copy Markdown

Thank you for your contribution. Unfortunately, this pull request is stale because it has been open 60 days with no activity. Please remove the stale label or comment or this will be closed in 7 days.

@github-actions github-actions Bot added the Stale label Jun 25, 2026
@github-actions github-actions Bot closed this Jul 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

parquet Changes to the parquet crate Stale

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants