Skip to content

Commit cd2db7a

Browse files
authored
Merge pull request #2693 from mabel-dev/#2692
Enable stats and pushdowns on S3 #2692
2 parents 2f6aae7 + a817935 commit cd2db7a

13 files changed

Lines changed: 428 additions & 323 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__ = 1377
1+
__build__ = 1381
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.
Lines changed: 59 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
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.
1+
# distutils: language = c++
2+
# cython: language_level=3
3+
# cython: nonecheck=False
4+
# cython: cdivision=True
5+
# cython: initializedcheck=False
6+
# cython: infer_types=True
7+
# cython: wraparound=False
8+
# cython: boundscheck=False
59

610
"""
711
LRU-K evicts the morsel whose K-th most recent access is furthest in the past. Note, the
@@ -30,13 +34,30 @@
3034
the BufferPool implements limit to only evict up to 32 items per 'transaction'
3135
"""
3236

33-
import heapq
34-
import time
37+
import heapq as py_heapq
38+
39+
from libc.stdint cimport int64_t
3540
from collections import defaultdict
41+
from time import monotonic_ns
42+
43+
cdef class LRU_K:
44+
45+
__slots__ = ("k", "slots", "access_history", "removed", "heap",
46+
"hits", "misses", "evictions", "inserts", "size")
3647

48+
cdef public int64_t k
49+
cdef dict slots
50+
cdef object access_history
51+
cdef set removed
52+
cdef list heap
3753

38-
class LRU2:
39-
def __init__(self, k=2):
54+
cdef int64_t hits
55+
cdef int64_t misses
56+
cdef int64_t evictions
57+
cdef int64_t inserts
58+
cdef public int64_t size
59+
60+
def __cinit__(self, int64_t k=2):
4061
self.k = k
4162
self.slots = {}
4263
self.access_history = defaultdict(list)
@@ -47,70 +68,72 @@ def __init__(self, k=2):
4768
self.misses = 0
4869
self.evictions = 0
4970
self.inserts = 0
50-
5171
self.size = 0
5272

5373
def __len__(self):
5474
return len(self.slots)
5575

56-
def get(self, key: bytes):
57-
value = self.slots.get(key)
76+
def get(self, bytes key) -> Optional[bytes]:
77+
cdef object value = self.slots.get(key)
5878
if value is not None:
5979
self.hits += 1
6080
self._update_access_history(key)
6181
else:
6282
self.misses += 1
6383
return value
6484

65-
def set(self, key: bytes, value):
85+
def set(self, bytes key, bytes value):
6686
self.inserts += 1
6787
if key not in self.slots:
6888
self.size += 1
6989
self.slots[key] = value
7090
self._update_access_history(key)
7191
return None
7292

73-
def _update_access_history(self, key: bytes):
74-
access_time = time.monotonic_ns()
75-
if len(self.access_history[key]) == self.k:
76-
old_entry = self.access_history[key].pop(0)
93+
cdef void _update_access_history(self, bytes key):
94+
cdef int64_t access_time = monotonic_ns()
95+
cdef list history = self.access_history[key]
96+
if len(history) == self.k:
97+
old_entry = history.pop(0)
7798
self.removed.add(old_entry)
78-
self.access_history[key].append((access_time, key))
79-
heapq.heappush(self.heap, (access_time, key))
80-
81-
def evict(self, details=False):
99+
history.append((access_time, key))
100+
py_heapq.heappush(self.heap, (access_time, key))
101+
102+
def evict(self, bint details=False):
103+
cdef int64_t _oldest_access_time
104+
cdef bytes oldest_key
105+
cdef int64_t new_access_time
106+
cdef tuple popped
82107
while self.heap:
83-
oldest_access_time, oldest_key = heapq.heappop(self.heap)
84-
if (oldest_access_time, oldest_key) in self.removed:
85-
self.removed.remove((oldest_access_time, oldest_key))
108+
popped = py_heapq.heappop(self.heap)
109+
_oldest_access_time, oldest_key = popped
110+
if popped in self.removed:
111+
self.removed.remove(popped)
86112
continue
87113

88114
if len(self.access_history[oldest_key]) == 1:
89115
# Synthetic access to give a grace period
90-
new_access_time = time.monotonic_ns()
116+
new_access_time = monotonic_ns()
91117
self.access_history[oldest_key].append((new_access_time, oldest_key))
92-
heapq.heappush(self.heap, (new_access_time, oldest_key))
118+
py_heapq.heappush(self.heap, (new_access_time, oldest_key))
93119
continue
94120

95-
# Evict the key with the oldest k-th access
96121
if oldest_key not in self.slots:
97122
continue
123+
98124
value = self.slots.pop(oldest_key)
99125
self.access_history.pop(oldest_key)
100126
self.size -= 1
101127
self.evictions += 1
102-
if details: # pragma: no cover
128+
if details:
103129
return oldest_key, value
104130
return oldest_key
105131

106-
if details: # pragma: no cover
107-
return None, None # No item was evicted
132+
if details:
133+
return None, None
108134
return None
109135

110-
def delete(self, key: bytes):
111-
"""
112-
Delete an item from the cache.
113-
"""
136+
def delete(self, bytes key):
114137
if key in self.slots:
115138
self.slots.pop(key, None)
116139
self.access_history.pop(key, None)
@@ -127,11 +150,11 @@ def keys(self):
127150
def stats(self):
128151
return self.hits, self.misses, self.evictions, self.inserts
129152

130-
def reset(self, reset_stats=False):
131-
self.slots = {}
153+
def reset(self, bint reset_stats=False):
154+
self.slots.clear()
132155
self.access_history.clear()
133156
self.removed.clear()
134-
self.heap = []
157+
self.heap.clear()
135158
if reset_stats:
136159
self.hits = 0
137160
self.misses = 0

opteryx/connectors/aws_s3_connector.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,20 @@
99

1010
import asyncio
1111
import os
12+
from typing import Dict
1213
from typing import List
1314

1415
import pyarrow
1516
from orso.schema import RelationSchema
1617
from orso.tools import single_item_cache
18+
from orso.types import OrsoTypes
1719

1820
from opteryx.connectors.base.base_connector import BaseConnector
1921
from opteryx.connectors.capabilities import Asynchronous
2022
from opteryx.connectors.capabilities import Cacheable
2123
from opteryx.connectors.capabilities import Partitionable
24+
from opteryx.connectors.capabilities import PredicatePushable
25+
from opteryx.connectors.capabilities import Statistics
2226
from opteryx.exceptions import DataError
2327
from opteryx.exceptions import DatasetNotFoundError
2428
from opteryx.exceptions import MissingDependencyError
@@ -31,10 +35,31 @@
3135
OS_SEP = os.sep
3236

3337

34-
class AwsS3Connector(BaseConnector, Cacheable, Partitionable, Asynchronous):
38+
class AwsS3Connector(
39+
BaseConnector, Cacheable, Partitionable, PredicatePushable, Asynchronous, Statistics
40+
):
3541
__mode__ = "Blob"
3642
__type__ = "S3"
3743

44+
PUSHABLE_OPS: Dict[str, bool] = {
45+
"Eq": True,
46+
"NotEq": True,
47+
"Gt": True,
48+
"GtEq": True,
49+
"Lt": True,
50+
"LtEq": True,
51+
}
52+
53+
PUSHABLE_TYPES = {
54+
OrsoTypes.BLOB,
55+
OrsoTypes.BOOLEAN,
56+
OrsoTypes.DOUBLE,
57+
OrsoTypes.INTEGER,
58+
OrsoTypes.VARCHAR,
59+
OrsoTypes.TIMESTAMP,
60+
OrsoTypes.DATE,
61+
}
62+
3863
def __init__(self, credentials=None, **kwargs):
3964
try:
4065
from minio import Minio # type:ignore
@@ -44,7 +69,9 @@ def __init__(self, credentials=None, **kwargs):
4469
BaseConnector.__init__(self, **kwargs)
4570
Partitionable.__init__(self, **kwargs)
4671
Cacheable.__init__(self, **kwargs)
72+
PredicatePushable.__init__(self, **kwargs)
4773
Asynchronous.__init__(self, **kwargs)
74+
Statistics.__init__(self, **kwargs)
4875

4976
# fmt:off
5077
end_point = kwargs.get("S3_END_POINT", os.environ.get("MINIO_END_POINT"))
@@ -89,6 +116,13 @@ def read_dataset(
89116
blob_bytes = self.read_blob(blob_name=blob_name, statistics=self.statistics)
90117
try:
91118
decoded = decoder(blob_bytes, projection=columns, just_schema=just_schema)
119+
120+
stats = self.read_blob_statistics(
121+
blob_name=blob_name, blob_bytes=blob_bytes, decoder=decoder
122+
)
123+
if len(blob_names) == 1:
124+
self.relation_statistics = stats
125+
92126
except Exception as err:
93127
raise DataError(f"Unable to read file {blob_name} ({err})") from err
94128
if not just_schema:

opteryx/connectors/capabilities/cacheable.py

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from opteryx.config import MAX_CACHEABLE_ITEM_SIZE
1212
from opteryx.third_party.cyan4973.xxhash import hash_bytes
1313

14-
__all__ = ("Cacheable", "async_read_thru_cache")
14+
__all__ = ("Cacheable", "async_read_thru_cache", "read_thru_cache")
1515

1616
SOURCE_NOT_FOUND = 0
1717
SOURCE_BUFFER_POOL = 1
@@ -58,6 +58,75 @@ def purge_blob(self, blob_name: str):
5858
return None
5959

6060

61+
def read_thru_cache(func):
62+
"""
63+
Decorator to implement a read-thru cache.
64+
65+
It intercepts requests to read blobs and first looks them up in the in-memory
66+
cache (BufferPool) and optionally in a secondary cache (like MemcacheD or Redis).
67+
"""
68+
69+
# Capture the max_evictions value at decoration time
70+
from opteryx import get_cache_manager
71+
from opteryx.managers.cache import NullCache
72+
from opteryx.shared import BufferPool
73+
74+
cache_manager = get_cache_manager()
75+
max_evictions = MAX_CACHE_EVICTIONS_PER_QUERY
76+
remote_cache = cache_manager.cache_backend
77+
if not remote_cache:
78+
# rather than make decisions - just use a dummy
79+
remote_cache = NullCache()
80+
81+
buffer_pool = BufferPool()
82+
83+
my_keys = set()
84+
85+
@wraps(func)
86+
def wrapper(blob_name, statistics, **kwargs):
87+
nonlocal max_evictions
88+
89+
key = hex(hash_bytes(blob_name.encode())).encode()
90+
my_keys.add(key)
91+
92+
# try the buffer pool first
93+
result = buffer_pool.get(key)
94+
if result is not None:
95+
statistics.bufferpool_hits += 1
96+
return result
97+
98+
# try the remote cache next
99+
result = remote_cache.get(key)
100+
if result is not None:
101+
statistics.remote_cache_hits += 1
102+
return result
103+
104+
# Key is not in cache, execute the function and store the result in cache
105+
result = func(blob_name=blob_name, **kwargs)
106+
107+
# Write the result to caches
108+
if max_evictions:
109+
# we set a per-query eviction limit
110+
if len(result) < MAX_CACHEABLE_ITEM_SIZE:
111+
evicted = buffer_pool.set(key, result)
112+
remote_cache.set(key, result)
113+
if evicted:
114+
# if we're evicting items we're putting into the cache
115+
if evicted in my_keys:
116+
max_evictions = 0
117+
else:
118+
max_evictions -= 1
119+
statistics.cache_evictions += 1
120+
else:
121+
statistics.cache_oversize += 1
122+
123+
statistics.cache_misses += 1
124+
125+
return result
126+
127+
return wrapper
128+
129+
61130
def async_read_thru_cache(func):
62131
"""
63132
This is added to the reader by the binder.

0 commit comments

Comments
 (0)