Skip to content

Commit 202bad3

Browse files
rustyconoverclaude
andcommitted
Add VGI_FILTER_DEBUG environment variable for filter pushdown diagnostics
Enable with VGI_FILTER_DEBUG=1 to trace: - Filter deserialization (byte size, specs, version) - Filter parsing (type, column, operator, values) - Filter evaluation (rows passing per filter, total filtered) - Auto-apply logic (input/output rows, rows removed) Uses structlog for consistent formatting with other VGI debug logging. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 3a26789 commit 202bad3

3 files changed

Lines changed: 242 additions & 9 deletions

File tree

CLAUDE.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ vgi-client --input data.parquet --function sum_all_columns --worker vgi-example-
124124
|----------|-------------|
125125
| `VGI_IPC_DEBUG=1` | Enable Arrow IPC debug logging (see below) |
126126
| `VGI_IPC_STATS=1` | Enable IPC stream statistics logging (see below) |
127+
| `VGI_FILTER_DEBUG=1` | Enable filter pushdown debug logging (see below) |
127128
| `VGI_QUIET=1` | Suppress worker startup logging |
128129

129130
### IPC Stream Statistics
@@ -174,6 +175,38 @@ ipc_read num_rows=1 schema={'sum': 'int64'} metadata={'vgi.status': 'FINISHED'
174175

175176
**Performance:** Zero overhead when disabled (just a boolean check).
176177

178+
### Filter Pushdown Debug Logging
179+
180+
Enable `VGI_FILTER_DEBUG=1` to trace filter pushdown deserialization, parsing, and evaluation. Useful for debugging filter pushdown issues and understanding how filters are applied.
181+
182+
```bash
183+
VGI_FILTER_DEBUG=1 vgi-example-worker
184+
```
185+
186+
**Key events logged:**
187+
- `deserialize_start` - When filter bytes are received (with byte size)
188+
- `deserialize_specs` - Parsed filter specifications from JSON
189+
- `parse_filter_*` - Individual filter parsing (constant, in, is_null, and, or, struct)
190+
- `pushdown_filters_ready` - Deserialized filters summary
191+
- `evaluate_start` - Beginning filter evaluation against a batch
192+
- `evaluate_filter` - Each filter's result (rows passing)
193+
- `evaluate_complete` - Final evaluation result (input rows, rows passing, rows filtered)
194+
- `auto_apply_start/complete` - When auto_apply_filters triggers
195+
196+
**Example output:**
197+
```
198+
deserialize_start ipc_bytes_size=600
199+
deserialize_specs num_filters=1 specs=[{'column_name': 'n', 'type': 'constant', 'op': 'ge', 'value_ref': 0}]
200+
parse_filter_constant column=n op=ge value=5 value_type=int64
201+
pushdown_filters_ready function=SequenceFunction num_filters=1 filter_summary=['ConstantFilter(n >= 5)']
202+
evaluate_start columns=['n'] input_rows=100 num_filters=1
203+
evaluate_filter filter_index=0 filter_repr='ConstantFilter(n >= 5)' rows_passing=50
204+
evaluate_complete input_rows=100 rows_passing=50 rows_filtered=50
205+
auto_apply_complete function=SequenceFunction input_rows=100 output_rows=50 rows_removed=50
206+
```
207+
208+
**Performance:** Zero overhead when disabled (just a boolean check).
209+
177210
## Creating a Scalar Function (Per-Row Transform)
178211

179212
Use `Annotated[T, Param(...)]`, `Annotated[T, ConstParam(...)]`, and `Annotated[T, Returns(...)]` on the `compute()` method. **Arrow types are inferred from array classes** for concise declarations:

vgi/table_filter_pushdown.py

Lines changed: 162 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,16 +20,52 @@
2020

2121
import dataclasses
2222
import json
23+
import os
24+
import sys
2325
from dataclasses import dataclass
2426
from enum import Enum
2527
from typing import TYPE_CHECKING, Any
2628

2729
import pyarrow as pa
2830
import pyarrow.compute as pc
31+
import structlog
2932

3033
if TYPE_CHECKING:
3134
from collections.abc import Callable, Iterator
3235

36+
# =============================================================================
37+
# Debug Logging
38+
# =============================================================================
39+
40+
# Enable with VGI_FILTER_DEBUG=1 for detailed filter pushdown diagnostics
41+
_FILTER_DEBUG = os.environ.get("VGI_FILTER_DEBUG", "").lower() in ("1", "true", "yes")
42+
_filter_log: structlog.stdlib.BoundLogger | None = None
43+
44+
45+
def _get_filter_log() -> structlog.stdlib.BoundLogger:
46+
"""Get or create the filter debug logger, configured to write to stderr."""
47+
global _filter_log
48+
if _filter_log is None:
49+
# Configure structlog to write to stderr for filter debugging
50+
structlog.configure(
51+
processors=[
52+
structlog.processors.add_log_level,
53+
structlog.processors.TimeStamper(fmt="iso"),
54+
structlog.dev.ConsoleRenderer(),
55+
],
56+
wrapper_class=structlog.make_filtering_bound_logger(0),
57+
logger_factory=structlog.PrintLoggerFactory(file=sys.stderr),
58+
)
59+
_filter_log = structlog.get_logger().bind(component="filter_pushdown")
60+
return _filter_log
61+
62+
63+
def _log_debug(event: str, **kwargs: Any) -> None:
64+
"""Log a debug message if VGI_FILTER_DEBUG is enabled."""
65+
if _FILTER_DEBUG:
66+
_get_filter_log().debug(event, **kwargs)
67+
68+
3369
__all__ = [
3470
"AndFilter",
3571
"ColumnBounds",
@@ -404,11 +440,46 @@ def evaluate(self, batch: pa.RecordBatch) -> pa.BooleanArray:
404440
Boolean array with True for rows that pass all filters.
405441
406442
"""
443+
_log_debug(
444+
"evaluate_start",
445+
num_filters=len(self.filters),
446+
input_rows=batch.num_rows,
447+
columns=[f.column_name for f in self.filters],
448+
)
449+
407450
if not self.filters:
451+
_log_debug("evaluate_no_filters", input_rows=batch.num_rows)
408452
return pa.array([True] * batch.num_rows, type=pa.bool_())
453+
409454
result = self.filters[0].evaluate(batch)
410-
for f in self.filters[1:]:
455+
# pc.sum works on BooleanArray (counts True values) but stubs don't reflect this
456+
true_count: int | None = pc.sum(result).as_py() # type: ignore[type-var]
457+
_log_debug(
458+
"evaluate_filter",
459+
filter_index=0,
460+
filter_type=type(self.filters[0]).__name__,
461+
filter_repr=repr(self.filters[0]),
462+
rows_passing=true_count,
463+
)
464+
465+
for i, f in enumerate(self.filters[1:], start=1):
411466
result = pc.and_(result, f.evaluate(batch))
467+
true_count = pc.sum(result).as_py() # type: ignore[type-var]
468+
_log_debug(
469+
"evaluate_filter",
470+
filter_index=i,
471+
filter_type=type(f).__name__,
472+
filter_repr=repr(f),
473+
rows_passing=true_count,
474+
)
475+
476+
final_count: int | None = pc.sum(result).as_py() # type: ignore[type-var]
477+
_log_debug(
478+
"evaluate_complete",
479+
input_rows=batch.num_rows,
480+
rows_passing=final_count,
481+
rows_filtered=batch.num_rows - (final_count or 0),
482+
)
412483
return result
413484

414485
def apply(self, batch: pa.RecordBatch) -> pa.RecordBatch:
@@ -421,9 +492,17 @@ def apply(self, batch: pa.RecordBatch) -> pa.RecordBatch:
421492
Filtered RecordBatch containing only rows that pass all filters.
422493
423494
"""
495+
_log_debug("apply_start", input_rows=batch.num_rows)
424496
mask = self.evaluate(batch)
425497
# pc.filter supports RecordBatch but pyarrow-stubs don't have the overload
426-
return pc.filter(batch, mask) # type: ignore[call-overload,no-any-return]
498+
filtered: pa.RecordBatch = pc.filter(batch, mask) # type: ignore[call-overload]
499+
_log_debug(
500+
"apply_complete",
501+
input_rows=batch.num_rows,
502+
output_rows=filtered.num_rows,
503+
rows_removed=batch.num_rows - filtered.num_rows,
504+
)
505+
return filtered
427506

428507
# =========================================================================
429508
# Column Query Helpers
@@ -781,12 +860,21 @@ def deserialize_filters(ipc_bytes: bytes) -> PushdownFilters:
781860
FilterVersionError: If version is unsupported.
782861
783862
"""
863+
_log_debug("deserialize_start", ipc_bytes_size=len(ipc_bytes))
864+
784865
try:
785866
reader = pa.ipc.open_stream(ipc_bytes)
786867
batch = reader.read_next_batch()
787868
except Exception as e:
869+
_log_debug("deserialize_ipc_error", error=str(e))
788870
raise FilterDeserializationError(f"Failed to read IPC stream: {e}") from e
789871

872+
_log_debug(
873+
"deserialize_ipc_read",
874+
num_columns=batch.num_columns,
875+
schema=[f.name for f in batch.schema],
876+
)
877+
790878
# Validate version
791879
metadata = batch.schema.field(0).metadata
792880
if metadata is None:
@@ -795,22 +883,43 @@ def deserialize_filters(ipc_bytes: bytes) -> PushdownFilters:
795883
if version != "1":
796884
raise FilterVersionError(f"Unsupported filter version: {version!r}")
797885

886+
_log_debug("deserialize_version", version=version)
887+
798888
# Parse JSON spec
799889
try:
800890
filter_specs = json.loads(batch.column(0)[0].as_py())
801891
except Exception as e:
892+
_log_debug("deserialize_json_error", error=str(e))
802893
raise FilterDeserializationError(f"Failed to parse filter JSON: {e}") from e
803894

895+
_log_debug("deserialize_specs", num_filters=len(filter_specs), specs=filter_specs)
896+
804897
# Value resolver - returns scalar for value_ref N from column N+1
805898
def get_value(ref: int) -> pa.Scalar[Any]:
806-
return batch.column(ref + 1)[0] # type: ignore[no-any-return]
899+
value = batch.column(ref + 1)[0]
900+
_log_debug(
901+
"deserialize_value_ref",
902+
ref=ref,
903+
column_index=ref + 1,
904+
value_type=str(value.type),
905+
value=str(value),
906+
)
907+
return value # type: ignore[no-any-return]
807908

808909
# Parse filters
809910
try:
810911
filters = tuple(_parse_filter(spec, get_value) for spec in filter_specs)
811912
except Exception as e:
913+
_log_debug("deserialize_parse_error", error=str(e))
812914
raise FilterDeserializationError(f"Failed to parse filters: {e}") from e
813915

916+
_log_debug(
917+
"deserialize_complete",
918+
num_filters=len(filters),
919+
filter_types=[type(f).__name__ for f in filters],
920+
columns=[f.column_name for f in filters],
921+
)
922+
814923
return PushdownFilters(filters=filters, version=version)
815924

816925

@@ -834,18 +943,37 @@ def _parse_filter(
834943
column_index = spec["column_index"]
835944
filter_type = spec["type"]
836945

946+
_log_debug(
947+
"parse_filter_start",
948+
filter_type=filter_type,
949+
column_name=column_name,
950+
column_index=column_index,
951+
)
952+
837953
if filter_type == "constant":
838-
return ConstantFilter(
954+
op = ComparisonOp(spec["op"])
955+
value = get_value(spec["value_ref"])
956+
result = ConstantFilter(
839957
column_name=column_name,
840958
column_index=column_index,
841-
op=ComparisonOp(spec["op"]),
842-
value=get_value(spec["value_ref"]),
959+
op=op,
960+
value=value,
961+
)
962+
_log_debug(
963+
"parse_filter_constant",
964+
column=column_name,
965+
op=op.value,
966+
value=str(value),
967+
value_type=str(value.type),
843968
)
969+
return result
844970

845971
elif filter_type == "is_null":
972+
_log_debug("parse_filter_is_null", column=column_name)
846973
return IsNullFilter(column_name=column_name, column_index=column_index)
847974

848975
elif filter_type == "is_not_null":
976+
_log_debug("parse_filter_is_not_null", column=column_name)
849977
return IsNotNullFilter(column_name=column_name, column_index=column_index)
850978

851979
elif filter_type == "in":
@@ -854,37 +982,64 @@ def _parse_filter(
854982
# ListScalar.values gives us the underlying array
855983
# pyarrow-stubs doesn't type ListScalar.values correctly
856984
values_array: pa.Array[Any] = list_scalar.values # type: ignore[attr-defined]
985+
_log_debug(
986+
"parse_filter_in",
987+
column=column_name,
988+
num_values=len(values_array),
989+
values=values_array.to_pylist(),
990+
value_type=str(values_array.type),
991+
)
857992
return InFilter(
858993
column_name=column_name,
859994
column_index=column_index,
860995
values=values_array,
861996
)
862997

863998
elif filter_type == "and":
999+
_log_debug(
1000+
"parse_filter_and_start",
1001+
column=column_name,
1002+
num_children=len(spec["children"]),
1003+
)
8641004
children = tuple(_parse_filter(c, get_value) for c in spec["children"])
1005+
_log_debug("parse_filter_and_complete", column=column_name)
8651006
return AndFilter(
8661007
column_name=column_name,
8671008
column_index=column_index,
8681009
children=children,
8691010
)
8701011

8711012
elif filter_type == "or":
1013+
_log_debug(
1014+
"parse_filter_or_start",
1015+
column=column_name,
1016+
num_children=len(spec["children"]),
1017+
)
8721018
children = tuple(_parse_filter(c, get_value) for c in spec["children"])
1019+
_log_debug("parse_filter_or_complete", column=column_name)
8731020
return OrFilter(
8741021
column_name=column_name,
8751022
column_index=column_index,
8761023
children=children,
8771024
)
8781025

8791026
elif filter_type == "struct":
1027+
child_name = spec["child_name"]
1028+
_log_debug(
1029+
"parse_filter_struct_start",
1030+
column=column_name,
1031+
child_name=child_name,
1032+
)
8801033
child_filter = _parse_filter(spec["child_filter"], get_value)
1034+
_log_debug("parse_filter_struct_complete", column=column_name)
8811035
return StructFilter(
8821036
column_name=column_name,
8831037
column_index=column_index,
8841038
child_index=spec["child_index"],
885-
child_name=spec["child_name"],
1039+
child_name=child_name,
8861040
child_filter=child_filter,
8871041
)
8881042

8891043
else:
1044+
_log_debug("parse_filter_unknown", filter_type=filter_type)
8901045
raise FilterDeserializationError(f"Unknown filter type: {filter_type}")

0 commit comments

Comments
 (0)