Skip to content

Commit 267c5fb

Browse files
rustyconoverclaude
andcommitted
fix(filter_v2): measure view-typed payloads on pyarrow < 19
The payload size limit read RecordBatch.nbytes, which pyarrow < 19 does not implement for view types (string_view, binary_view, list_view): a filter carrying one failed with a raw ArrowTypeError instead of being parsed, or rejected as FilterV2Error. Fall back to get_total_buffer_size(), which handles every layout and can only overcount, so the limit still holds; if neither works, raise FilterV2Error. Verified on pyarrow 18.1.0, where nbytes raises on both view types and the fallback measures them. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 22cfe08 commit 267c5fb

2 files changed

Lines changed: 63 additions & 1 deletion

File tree

‎tests/test_filter_v2.py‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
FilterVersionError,
2323
RuntimeFilterAlgorithmCapability,
2424
deserialize_filters,
25+
filter_v2,
2526
)
2627

2728
_METADATA: dict[bytes, bytes] = {
@@ -767,3 +768,36 @@ def _connect(**_kw: object) -> _Connection:
767768
with pytest.raises(filter_v2.FilterV2Error, match=f"found {engine} {re.escape(version)}"):
768769
filter_v2._get_evaluation_connection(context)
769770
assert opened == []
771+
772+
773+
class _NoNbytes:
774+
"""Stands in for a view-typed batch under pyarrow < 19, where ``nbytes`` raises."""
775+
776+
def __init__(self, total: int | None) -> None:
777+
self._total = total
778+
779+
@property
780+
def nbytes(self) -> int:
781+
raise pa.ArrowTypeError("not implemented for string_view")
782+
783+
def get_total_buffer_size(self) -> int:
784+
if self._total is None:
785+
raise pa.ArrowNotImplementedError("not implemented")
786+
return self._total
787+
788+
789+
def test_payload_size_falls_back_when_nbytes_is_unavailable() -> None:
790+
"""A view-typed payload is measured by total buffer size instead of raising ArrowTypeError."""
791+
assert filter_v2._payload_nbytes(_NoNbytes(123)) == 123 # type: ignore[arg-type]
792+
793+
794+
def test_payload_size_unmeasurable_is_a_filter_error() -> None:
795+
"""When no measurement works, the caller sees FilterV2Error, not a raw Arrow error."""
796+
with pytest.raises(filter_v2.FilterV2Error, match="cannot measure filter payload size"):
797+
filter_v2._payload_nbytes(_NoNbytes(None)) # type: ignore[arg-type]
798+
799+
800+
def test_payload_size_of_a_view_typed_batch() -> None:
801+
"""A real string_view payload is measured without error."""
802+
batch = pa.record_batch([pa.array(["a", "bc"], type=pa.string_view())], names=["needles"])
803+
assert filter_v2._payload_nbytes(batch) > 0

‎vgi/filter_v2.py‎

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,34 @@ class FilterV2Error(ValueError):
117117
"""A Filter Encoding v2 document is malformed or cannot be evaluated."""
118118

119119

120+
def _payload_nbytes(batch: pa.RecordBatch) -> int:
121+
"""Size of a filter payload batch, for the payload limit.
122+
123+
``RecordBatch.nbytes`` is not implemented for view types (``string_view``,
124+
``binary_view``, ``list_view``) before pyarrow 19 and raises a raw
125+
``ArrowTypeError``. Fall back to the total buffer size there, which walks
126+
every buffer generically and can only overcount (shared buffers), never
127+
undercount, so the limit still holds.
128+
129+
Args:
130+
batch: The filter payload batch.
131+
132+
Returns:
133+
Its size in bytes, for comparison with ``MAX_PAYLOAD_BYTES``.
134+
135+
Raises:
136+
FilterV2Error: If neither measurement is available for the batch.
137+
"""
138+
try:
139+
return int(batch.nbytes)
140+
except (pa.ArrowTypeError, pa.ArrowNotImplementedError):
141+
pass
142+
try:
143+
return int(batch.get_total_buffer_size())
144+
except (pa.ArrowTypeError, pa.ArrowNotImplementedError) as exc:
145+
raise FilterV2Error(f"cannot measure filter payload size: {exc}") from exc
146+
147+
120148
class PredicateMode(StrEnum):
121149
"""Whether a predicate must be applied exactly or is only a pruning hint."""
122150

@@ -592,7 +620,7 @@ def _validate_batch(self) -> EvaluationContext:
592620
raise FilterV2Error(f"noncanonical payload field name {name!r}")
593621
if name.startswith("type_") and self.batch.column(i)[0].is_valid:
594622
raise FilterV2Error(f"{name} must contain a NULL value")
595-
if self.batch.nbytes > MAX_PAYLOAD_BYTES:
623+
if _payload_nbytes(self.batch) > MAX_PAYLOAD_BYTES:
596624
raise FilterV2Error("filter payload exceeds 16 MiB")
597625
return parse_evaluation_context(self.batch.schema)
598626

0 commit comments

Comments
 (0)