2020
2121import dataclasses
2222import json
23+ import os
24+ import sys
2325from dataclasses import dataclass
2426from enum import Enum
2527from typing import TYPE_CHECKING , Any
2628
2729import pyarrow as pa
2830import pyarrow .compute as pc
31+ import structlog
2932
3033if 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