Skip to content

Commit 8e32bb8

Browse files
committed
Use parquet stats for pruning reads #2687
1 parent 0be6499 commit 8e32bb8

8 files changed

Lines changed: 270 additions & 3 deletions

File tree

opteryx/__version__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
__build__ = 1367
1+
__build__ = 1370
22

33
# Licensed under the Apache License, Version 2.0 (the "License");
44
# you may not use this file except in compliance with the License.

opteryx/config.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,9 @@ def get(key: str, default: Optional[typing.Any] = None) -> Optional[typing.Any]:
169169
MAX_READ_BUFFER_CAPACITY: int = memory_allocation_calculation(float(get("MAX_READ_BUFFER_CAPACITY", 0.1)))
170170
"""Read buffer pool size in either bytes or fraction of system memory."""
171171

172+
MAX_STATISTICS_CACHE_ITEMS: int = get("MAX_STATISTICS_CACHE_ITEMS", 10_000)
173+
"""The number of .parquet files we cache the statistics for."""
174+
172175
CONCURRENT_READS: int = int(get("CONCURRENT_READS", max(system_gigabytes(), 2)))
173176
"""Number of read workers per data source."""
174177

opteryx/connectors/capabilities/statistics.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,110 @@
44
# Distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND.
55

66

7+
from typing import Any
8+
from typing import Dict
79
from typing import Optional
810

911
from orso.schema import RelationSchema
1012

13+
from opteryx.managers.expression import NodeType
1114
from opteryx.models import RelationStatistics
15+
from opteryx.shared.stats_cache import StatsCache
16+
from opteryx.third_party.cyan4973.xxhash import hash_bytes
1217

1318

1419
class Statistics:
1520
def __init__(self, statistics: dict, **kwargs):
21+
self.stats_cache = StatsCache()
1622
self.relation_statistics = RelationStatistics()
1723

24+
def read_blob_statistics(
25+
self, blob_name: str, blob_bytes: bytes = None, decoder=None
26+
) -> Optional[Dict[str, Any]]:
27+
key = hex(hash_bytes(blob_name.encode())).encode()
28+
cached_stats = self.stats_cache.get(key)
29+
if cached_stats is not None:
30+
# If statistics are cached, return them
31+
return cached_stats
32+
33+
cached_stats = decoder(blob_bytes, just_statistics=True)
34+
self.stats_cache.set(key, cached_stats)
35+
return cached_stats
36+
37+
def prefilter_blobs(self, blob_names: list[str], query_statistics, selection) -> list[str]:
38+
new_blob_names = []
39+
for blob_name in blob_names:
40+
key = hex(hash_bytes(blob_name.encode())).encode()
41+
cached_stats = self.stats_cache.get(key)
42+
if cached_stats is None:
43+
# we have no stats so we can't make a decision
44+
new_blob_names.append(blob_name)
45+
query_statistics.no_stats += 1
46+
continue
47+
48+
skip_blob = False
49+
50+
for condition in selection:
51+
if condition.left.node_type != NodeType.IDENTIFIER:
52+
continue
53+
if condition.right.node_type != NodeType.LITERAL:
54+
continue
55+
56+
column_name = condition.left.source_column
57+
literal_value = condition.right.value
58+
max_value = cached_stats.upper_bounds.get(column_name, None)
59+
min_value = cached_stats.lower_bounds.get(column_name, None)
60+
61+
if max_value is None or min_value is None:
62+
continue
63+
64+
if condition.value == "Eq": # noqa: SIM102
65+
# value must be within [min, max]
66+
if literal_value < min_value or literal_value > max_value:
67+
query_statistics.blobs_pruned += 1
68+
skip_blob = True
69+
break
70+
71+
elif condition.value == "NotEq": # noqa: SIM102
72+
# only prune if min == max == literal (i.e., column only contains this value)
73+
if min_value == max_value == literal_value:
74+
query_statistics.blobs_pruned += 1
75+
skip_blob = True
76+
break
77+
78+
elif condition.value == "Gt": # noqa: SIM102
79+
# value must be less than max to potentially match
80+
if max_value <= literal_value:
81+
query_statistics.blobs_pruned += 1
82+
skip_blob = True
83+
break
84+
85+
elif condition.value == "GtEq": # noqa: SIM102
86+
# value must be less than or equal to max to potentially match
87+
if max_value < literal_value:
88+
query_statistics.blobs_pruned += 1
89+
skip_blob = True
90+
break
91+
92+
elif condition.value == "Lt": # noqa: SIM102
93+
# value must be greater than min to potentially match
94+
if min_value >= literal_value:
95+
query_statistics.blobs_pruned += 1
96+
skip_blob = True
97+
break
98+
99+
elif condition.value == "LtEq": # noqa: SIM102
100+
# value must be greater than or equal to min to potentially match
101+
if min_value > literal_value:
102+
query_statistics.blobs_pruned += 1
103+
skip_blob = True
104+
break
105+
106+
if not skip_blob:
107+
new_blob_names.append(blob_name)
108+
109+
return new_blob_names
110+
18111
def map_statistics(
19112
self, statistics: Optional[RelationStatistics], schema: RelationSchema
20113
) -> RelationSchema:

opteryx/connectors/gcp_cloudstorage_connector.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -245,8 +245,11 @@ def read_dataset(
245245
selection=predicates,
246246
just_schema=just_schema,
247247
)
248+
stats = self.read_blob_statistics(
249+
blob_name=blob_name, blob_bytes=blob_bytes, decoder=decoder
250+
)
248251
if len(blob_names) == 1:
249-
self.relation_statistics = decoder(blob_bytes, just_statistics=True)
252+
self.relation_statistics = stats
250253
except Exception as err:
251254
raise DatasetReadError(f"Unable to read file {blob_name} ({err})") from err
252255

opteryx/models/relation_statistics.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
1+
# Licensed under the Apache License, Version 2.0 (the "License");
2+
# you may not use this file except in compliance with the License.
3+
# See the License at http://www.apache.org/licenses/LICENSE-2.0
4+
# Distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND.
5+
16
from typing import Any
27
from typing import Dict
38
from typing import List
49
from typing import Optional
510
from typing import Tuple
611

12+
import orjson
13+
714

815
class RelationStatistics:
916
"""
@@ -57,3 +64,31 @@ def set_cardinality_estimate(self, column: str, cardinality: int):
5764
if self.cardinality_estimate is None:
5865
self.cardinality_estimate = {}
5966
self.cardinality_estimate[column] = cardinality
67+
68+
def to_bytes(self) -> bytes:
69+
"""Serialize the RelationStatistics object to bytes using JSON."""
70+
# Convert all attributes to a serializable dict
71+
data = {
72+
"record_count": self.record_count,
73+
"record_count_estimate": self.record_count_estimate,
74+
"null_count": self.null_count,
75+
"lower_bounds": self.lower_bounds,
76+
"upper_bounds": self.upper_bounds,
77+
"cardinality_estimate": self.cardinality_estimate,
78+
"raw_distribution_data": self.raw_distribution_data,
79+
}
80+
return orjson.dumps(data, default=str)
81+
82+
@classmethod
83+
def from_bytes(cls, data: bytes) -> "RelationStatistics":
84+
"""Deserialize bytes to a RelationStatistics object using JSON."""
85+
obj = cls()
86+
loaded = orjson.loads(data)
87+
obj.record_count = loaded.get("record_count", 0)
88+
obj.record_count_estimate = loaded.get("record_count_estimate", 0)
89+
obj.null_count = loaded.get("null_count", None)
90+
obj.lower_bounds = loaded.get("lower_bounds", {})
91+
obj.upper_bounds = loaded.get("upper_bounds", {})
92+
obj.cardinality_estimate = loaded.get("cardinality_estimate", None)
93+
obj.raw_distribution_data = loaded.get("raw_distribution_data", [])
94+
return obj

opteryx/operators/async_read_node.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,12 @@ def execute(self, morsel, **kwargs) -> Generator:
106106
predicates=self.predicates,
107107
)
108108

109+
if self.predicates and hasattr(reader, "prefilter_blobs"):
110+
# if we're capturing statistics, we can prefilter the blobs
111+
blob_names = reader.prefilter_blobs(
112+
blob_names=blob_names, query_statistics=self.statistics, selection=self.predicates
113+
)
114+
109115
if len(blob_names) == 0:
110116
# if we don't have any matching blobs, create an empty dataset
111117
from orso import DataFrame
@@ -166,6 +172,14 @@ def execute(self, morsel, **kwargs) -> Generator:
166172
decoded = decoder(
167173
blob_memory_view, projection=self.columns, selection=self.predicates
168174
)
175+
176+
# We read the statisics from the blob, we can use this for
177+
# prefiltering the files next time we read them.
178+
if hasattr(reader, "read_blob_statistics"):
179+
reader.read_blob_statistics(
180+
blob_name=blob_name, blob_bytes=blob_memory_view, decoder=decoder
181+
)
182+
169183
self.pool.release(reference) # release also unlatches the segment
170184
except Exception as err:
171185
from pyarrow import ArrowInvalid
@@ -197,7 +211,7 @@ def execute(self, morsel, **kwargs) -> Generator:
197211

198212
yield morsel
199213
except Exception as err:
200-
self.statistics.add_message(f"failed to read {blob_name}")
214+
self.statistics.add_message(f"failed to read {blob_name} ({err.__type__.__name__})")
201215
self.statistics.failed_reads += 1
202216
import warnings
203217

opteryx/shared/stats_cache.py

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
# Licensed under the Apache License, Version 2.0 (the "License");
2+
# you may not use this file except in compliance with the License.
3+
# See the License at http://www.apache.org/licenses/LICENSE-2.0
4+
# Distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND.
5+
6+
"""
7+
Global Blob Statistics Cache.
8+
9+
This module implements a global statistics cache using an LRU-K2 policy to determine
10+
which items to keep and which to evict. The cache is used to store and retrieve
11+
table or query statistics efficiently, and is shared across all connections and cursors.
12+
13+
The cache is limited by the number of items (MAX_STATISTICS_CACHE_ITEMS), not by memory volume.
14+
Eviction occurs when the item count exceeds the configured maximum.
15+
"""
16+
17+
from typing import Optional
18+
19+
from opteryx.config import MAX_STATISTICS_CACHE_ITEMS
20+
from opteryx.models import RelationStatistics
21+
from opteryx.utils.lru_2 import LRU2
22+
23+
24+
class _StatsCache:
25+
"""
26+
Implements a statistics cache using an LRU-K2 eviction policy.
27+
Stores serialized statistics objects, keyed by bytes.
28+
"""
29+
30+
slots = "_lru"
31+
32+
def __init__(self):
33+
self._lru = LRU2()
34+
35+
def get(self, key: bytes) -> Optional[RelationStatistics]:
36+
"""
37+
Retrieve a statistics object from the cache by key.
38+
Returns the deserialized object if found, or None if not present.
39+
"""
40+
cached_stats = self._lru.get(key)
41+
if cached_stats is not None:
42+
return RelationStatistics.from_bytes(cached_stats)
43+
return None
44+
45+
def delete(self, key: bytes):
46+
"""
47+
Remove a statistics object from the cache by key.
48+
"""
49+
self._lru.delete(key)
50+
51+
def set(self, key: bytes, value: RelationStatistics) -> Optional[str]:
52+
"""
53+
Store a statistics object in the cache, serializing it to bytes.
54+
If the cache exceeds the maximum allowed items, evict the least recently used item.
55+
56+
Args:
57+
key: The key associated with the statistics object.
58+
value: The statistics object to store (will be serialized).
59+
60+
Returns:
61+
The key of the evicted item if eviction occurred, otherwise None.
62+
"""
63+
cached_stats = value.to_bytes()
64+
65+
# Update LRU cache with the new key and memory pool key if commit succeeds
66+
self._lru.set(key, cached_stats)
67+
if self._lru.size > MAX_STATISTICS_CACHE_ITEMS:
68+
# If the cache size exceeds the limit, evict the least recently used item
69+
evicted_key = self._lru.evict()
70+
if evicted_key:
71+
self.delete(evicted_key)
72+
73+
# Return the evicted key if an eviction occurred, otherwise return None
74+
return evicted_key if "evicted_key" in locals() else None
75+
76+
@property
77+
def stats(self) -> tuple:
78+
"""
79+
Return the hit, miss, and eviction statistics for the cache.
80+
"""
81+
return self._lru.stats
82+
83+
def __del__(self):
84+
pass
85+
# DEBUG: print(f"Statistics Cache <hits={self.stats[0]}, misses={self.stats[1]}, evictions={self.stats[2]}, inserts={self.stats[3]}>")
86+
87+
88+
class StatsCache(_StatsCache):
89+
"""
90+
Singleton wrapper for the _StatsCache class.
91+
Ensures only one global statistics cache instance exists.
92+
"""
93+
94+
_instance = None
95+
96+
def __new__(cls):
97+
if cls._instance is None:
98+
cls._instance = cls._create_instance()
99+
return cls._instance
100+
101+
@classmethod
102+
def _create_instance(cls):
103+
"""
104+
Create a new instance of the underlying _StatsCache.
105+
"""
106+
return _StatsCache()
107+
108+
@classmethod
109+
def reset(cls):
110+
"""
111+
Reset the StatsCache singleton instance. This is useful when the configuration changes.
112+
"""
113+
cls._instance = None
114+
cls._instance = cls._create_instance()

opteryx/utils/lru_2.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ def __init__(self, k=2):
4848
self.evictions = 0
4949
self.inserts = 0
5050

51+
self.size = 0
52+
5153
def __len__(self):
5254
return len(self.slots)
5355

@@ -62,6 +64,7 @@ def get(self, key: bytes):
6264

6365
def set(self, key: bytes, value):
6466
self.inserts += 1
67+
self.size += 1
6568
self.slots[key] = value
6669
self._update_access_history(key)
6770
return None
@@ -93,6 +96,7 @@ def evict(self, details=False):
9396
continue
9497
value = self.slots.pop(oldest_key)
9598
self.access_history.pop(oldest_key)
99+
self.size -= 1
96100
self.evictions += 1
97101
if details: # pragma: no cover
98102
return oldest_key, value
@@ -110,6 +114,7 @@ def delete(self, key: bytes):
110114
self.slots.pop(key, None)
111115
self.access_history.pop(key, None)
112116
self.evictions += 1
117+
self.size -= 1
113118
return True
114119
return False
115120

0 commit comments

Comments
 (0)