hotdata-materialized 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hotdata_materialized-0.1.0/PKG-INFO +227 -0
- hotdata_materialized-0.1.0/README.md +202 -0
- hotdata_materialized-0.1.0/hotdata_materialized/__init__.py +44 -0
- hotdata_materialized-0.1.0/hotdata_materialized/_arrow.py +44 -0
- hotdata_materialized-0.1.0/hotdata_materialized/_sql.py +42 -0
- hotdata_materialized-0.1.0/hotdata_materialized/client.py +50 -0
- hotdata_materialized-0.1.0/hotdata_materialized/conf.py +70 -0
- hotdata_materialized-0.1.0/hotdata_materialized/decorator.py +237 -0
- hotdata_materialized-0.1.0/hotdata_materialized/exceptions.py +22 -0
- hotdata_materialized-0.1.0/hotdata_materialized/fingerprint.py +77 -0
- hotdata_materialized-0.1.0/hotdata_materialized/indexes.py +33 -0
- hotdata_materialized-0.1.0/hotdata_materialized/py.typed +0 -0
- hotdata_materialized-0.1.0/hotdata_materialized/registry.py +246 -0
- hotdata_materialized-0.1.0/hotdata_materialized/store.py +405 -0
- hotdata_materialized-0.1.0/hotdata_materialized.egg-info/PKG-INFO +227 -0
- hotdata_materialized-0.1.0/hotdata_materialized.egg-info/SOURCES.txt +28 -0
- hotdata_materialized-0.1.0/hotdata_materialized.egg-info/dependency_links.txt +1 -0
- hotdata_materialized-0.1.0/hotdata_materialized.egg-info/requires.txt +13 -0
- hotdata_materialized-0.1.0/hotdata_materialized.egg-info/top_level.txt +1 -0
- hotdata_materialized-0.1.0/pyproject.toml +60 -0
- hotdata_materialized-0.1.0/setup.cfg +7 -0
- hotdata_materialized-0.1.0/tests/test_conf_and_client.py +28 -0
- hotdata_materialized-0.1.0/tests/test_decorator.py +249 -0
- hotdata_materialized-0.1.0/tests/test_fingerprint.py +103 -0
- hotdata_materialized-0.1.0/tests/test_package.py +67 -0
- hotdata_materialized-0.1.0/tests/test_registry.py +134 -0
- hotdata_materialized-0.1.0/tests/test_registry_heal.py +34 -0
- hotdata_materialized-0.1.0/tests/test_sql.py +47 -0
- hotdata_materialized-0.1.0/tests/test_store.py +274 -0
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hotdata-materialized
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Materialize expensive Django query results into Hotdata: miss runs locally, hit returns a queryable frame.
|
|
5
|
+
Author: Hotdata
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://hotdata.dev
|
|
8
|
+
Keywords: django,hotdata,cache,materialized,analytics,parquet
|
|
9
|
+
Classifier: Framework :: Django
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Requires-Python: >=3.9
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
Requires-Dist: Django>=3.2
|
|
15
|
+
Requires-Dist: hotdata[arrow]>=0.8
|
|
16
|
+
Requires-Dist: pyarrow>=14.0.1
|
|
17
|
+
Provides-Extra: test
|
|
18
|
+
Requires-Dist: pytest>=7.0; extra == "test"
|
|
19
|
+
Requires-Dist: duckdb>=1.0; extra == "test"
|
|
20
|
+
Requires-Dist: flake8>=4.0; extra == "test"
|
|
21
|
+
Requires-Dist: mypy>=1.5; extra == "test"
|
|
22
|
+
Requires-Dist: build>=1.0; extra == "test"
|
|
23
|
+
Provides-Extra: demo
|
|
24
|
+
Requires-Dist: psycopg[binary]>=3.1; extra == "demo"
|
|
25
|
+
|
|
26
|
+
# hotdata-materialized
|
|
27
|
+
|
|
28
|
+
Materialize expensive Django query results into [Hotdata](https://hotdata.dev).
|
|
29
|
+
On a cache **miss** your code runs in your environment as usual and the result
|
|
30
|
+
is captured into Hotdata as parquet — in the background, after your response
|
|
31
|
+
has already returned. On a **hit** your database is never touched: the data
|
|
32
|
+
comes back as a `pyarrow.Table` you can turn into a dataframe, or query with
|
|
33
|
+
SQL server-side.
|
|
34
|
+
|
|
35
|
+
Think materialized views, not Redis: entries are snapshots of expensive
|
|
36
|
+
analytical results, with a TTL lifecycle, each living in its own Hotdata
|
|
37
|
+
managed database. The host application needs **no migrations and no Redis** —
|
|
38
|
+
entry metadata lives in a small Hotdata managed database (the registry),
|
|
39
|
+
created on first use.
|
|
40
|
+
|
|
41
|
+
Measured against TPC-H on a Neon Postgres (see [demo/](demo/)):
|
|
42
|
+
|
|
43
|
+
| | Direct on Postgres | Miss (perceived) | Hit |
|
|
44
|
+
|---|---|---|---|
|
|
45
|
+
| Q1 pricing summary (full scan, 4 rows) | ~1.4 s | ~1.4 s | **~80 ms** |
|
|
46
|
+
| Revenue by part (50,000 rows) | ~2.1 s | ~2.0 s | **~260 ms** |
|
|
47
|
+
|
|
48
|
+
A miss costs the same as not caching (the persist runs write-behind); a hit
|
|
49
|
+
is 8–18× faster with zero load on your database.
|
|
50
|
+
|
|
51
|
+
## Install
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
pip install hotdata-materialized
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
# settings.py
|
|
59
|
+
HOTDATA_MATERIALIZED = {
|
|
60
|
+
"API_KEY": env("HOTDATA_API_KEY"), # hd_... key from app.hotdata.dev
|
|
61
|
+
"WORKSPACE_ID": "work...",
|
|
62
|
+
# optional:
|
|
63
|
+
# "API_URL": "https://api.hotdata.dev",
|
|
64
|
+
# "REGISTRY_DATABASE": "materialized_registry",
|
|
65
|
+
# "INLINE_THRESHOLD_BYTES": 2 * 1024 * 1024,
|
|
66
|
+
# "BACKGROUND": True, # write-behind persists (default)
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Using it in a Django view
|
|
71
|
+
|
|
72
|
+
Wrap the expensive part in `@materialize`; call it from your view:
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
# reports.py
|
|
76
|
+
from django.db.models import Count, Sum
|
|
77
|
+
from hotdata_materialized import materialize
|
|
78
|
+
|
|
79
|
+
from .models import Order
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@materialize(ttl=3600)
|
|
83
|
+
def revenue_by_region():
|
|
84
|
+
return (
|
|
85
|
+
Order.objects
|
|
86
|
+
.filter(status="complete")
|
|
87
|
+
.values("region")
|
|
88
|
+
.annotate(orders=Count("id"), revenue=Sum("total"))
|
|
89
|
+
.order_by("-revenue")
|
|
90
|
+
)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
# views.py
|
|
95
|
+
from django.http import JsonResponse
|
|
96
|
+
from .reports import revenue_by_region
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def revenue_view(request):
|
|
100
|
+
frame = revenue_by_region()
|
|
101
|
+
return JsonResponse({"rows": frame.to_pylist(), "cached": frame.cached})
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
On a **hit** the function never runs and your database is never touched. On a
|
|
105
|
+
**miss** the function runs exactly as it would uncached, the caller gets the
|
|
106
|
+
result immediately, and the persist happens in a background thread. Either
|
|
107
|
+
way you get a `MaterializedFrame`:
|
|
108
|
+
|
|
109
|
+
- `frame.arrow()` — the data as a `pyarrow.Table` (`frame.df()` for pandas,
|
|
110
|
+
`frame.to_pylist()` for dicts, `len(frame)` for the row count)
|
|
111
|
+
- `frame.sql("SELECT region, revenue FROM this ORDER BY revenue DESC LIMIT 5")`
|
|
112
|
+
— SQL runs server-side against the cached entry; `this` names the data.
|
|
113
|
+
Needs a persisted entry: available on hits (on a fresh miss the
|
|
114
|
+
write-behind persist has to land first; `background=False` avoids that)
|
|
115
|
+
- `frame.cached` — which path served it; `frame.entry` — the registry record
|
|
116
|
+
|
|
117
|
+
The wrapped function can return an iterable of dicts (a `.values()` queryset),
|
|
118
|
+
a `pyarrow.Table`, or a pandas DataFrame. Decorator knobs: `ttl=` (seconds,
|
|
119
|
+
`None` = never expires), `version=` (bump to bust the cache on code changes),
|
|
120
|
+
`key=` (human-readable label), `key_fn=` (stable identity for arguments that
|
|
121
|
+
aren't JSON-serializable), `background=False` (block until persisted).
|
|
122
|
+
Fail-open by design: if Hotdata is unreachable, the function runs and its
|
|
123
|
+
result is served uncached — the cache degrades to "no cache," never to
|
|
124
|
+
"no page."
|
|
125
|
+
|
|
126
|
+
## Search
|
|
127
|
+
|
|
128
|
+
Declare a search index on the decorator and the entry gets it when it
|
|
129
|
+
persists (builds run server-side; a freshly missed entry may error on search
|
|
130
|
+
for a few seconds until its index is ready):
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
from hotdata_materialized import BM25, Vector, materialize
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@materialize(ttl=3600, index=BM25("notes"))
|
|
137
|
+
def incidents():
|
|
138
|
+
return Incident.objects.values("id", "notes", "severity")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
incidents().search("checkout errors outage", column="notes", limit=5)
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Vector (semantic) search embeds your text server-side via a workspace
|
|
145
|
+
embedding provider — pass the indexed source column and a natural-language
|
|
146
|
+
query:
|
|
147
|
+
|
|
148
|
+
```python
|
|
149
|
+
@materialize(ttl=3600, index=Vector("notes", provider="emb_...", metric="cosine"))
|
|
150
|
+
def incidents_semantic():
|
|
151
|
+
return Incident.objects.values("id", "notes")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
incidents_semantic().vector_search("the site was down", column="notes", limit=5)
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Both return the matching rows as a `pyarrow.Table` with a relevance column
|
|
158
|
+
(`score` / `distance`). One platform rule to know: an embedding-backed vector
|
|
159
|
+
index cannot share an entry with other indexes — the decorator rejects that
|
|
160
|
+
combination up front.
|
|
161
|
+
|
|
162
|
+
## Evicting entries
|
|
163
|
+
|
|
164
|
+
The decorator composes public primitives (`fingerprint_call` /
|
|
165
|
+
`fingerprint_queryset`, `Registry`, `EntryStore`) that you can use directly —
|
|
166
|
+
for example to drop an entry explicitly:
|
|
167
|
+
|
|
168
|
+
```python
|
|
169
|
+
from hotdata_materialized import fingerprint_call
|
|
170
|
+
from hotdata_materialized.decorator import get_runtime
|
|
171
|
+
|
|
172
|
+
registry, store = get_runtime()
|
|
173
|
+
store.evict(fingerprint_call(revenue_by_region))
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Expired entries stop being served immediately (the `is_expired` check) and
|
|
177
|
+
their databases carry a server-side expiry backstop; a sweep command that
|
|
178
|
+
deletes them proactively is on the roadmap below.
|
|
179
|
+
|
|
180
|
+
## How it works
|
|
181
|
+
|
|
182
|
+
One managed database (the **registry**) holds one row per entry: fingerprint,
|
|
183
|
+
status, TTL, the entry's database id, and — for small results — the data
|
|
184
|
+
itself as an inline Arrow payload, so a chart-sized hit is served in a single
|
|
185
|
+
API round trip. Larger results live in a per-entry managed database and are
|
|
186
|
+
fetched as an Arrow IPC stream via a result id minted at persist time (no
|
|
187
|
+
re-query on hits). Failures are loud and fail toward "no cache": a failed
|
|
188
|
+
persist leaves no registry row, and the next request simply misses again.
|
|
189
|
+
|
|
190
|
+
See [DESIGN.md](DESIGN.md) for the architecture and the accepted
|
|
191
|
+
trade-offs.
|
|
192
|
+
|
|
193
|
+
## Roadmap
|
|
194
|
+
|
|
195
|
+
- [x] Content-addressed fingerprinting (querysets and function calls)
|
|
196
|
+
- [x] Remote registry — no migrations or local state in the host app
|
|
197
|
+
- [x] Entry store with write-behind persists
|
|
198
|
+
- [x] Arrow-native reads (inline payloads, persisted-result fetch)
|
|
199
|
+
- [x] `@materialize` decorator and `MaterializedFrame`
|
|
200
|
+
(`.arrow()/.df()/.to_pylist()/.sql()`)
|
|
201
|
+
- [x] TPC-H demo and benchmark
|
|
202
|
+
- [x] CI: pytest, mypy, flake8
|
|
203
|
+
- [ ] Stale-while-revalidate refresh (rebuild protocol)
|
|
204
|
+
- [ ] Sweep command for expired entries
|
|
205
|
+
- [ ] Chainable queryset facade on the frame (`.filter()/.order_by()`)
|
|
206
|
+
- [x] Vector/BM25 index declarations, `frame.search()` and
|
|
207
|
+
`frame.vector_search()`
|
|
208
|
+
- [ ] Async view support (`await frame.aarrow()`)
|
|
209
|
+
- [ ] PyPI release
|
|
210
|
+
|
|
211
|
+
## Demo
|
|
212
|
+
|
|
213
|
+
[demo/](demo/) is a small Django project that benchmarks TPC-H queries on a
|
|
214
|
+
Neon Postgres directly vs. through the cache:
|
|
215
|
+
|
|
216
|
+
```bash
|
|
217
|
+
cd demo
|
|
218
|
+
python manage.py compare # Q1: tiny result, inline hit
|
|
219
|
+
python manage.py compare --scenario parts # 50k rows: remote Arrow hit
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
## Development
|
|
223
|
+
|
|
224
|
+
```bash
|
|
225
|
+
uv venv && uv pip install -e '.[test]'
|
|
226
|
+
uv run pytest
|
|
227
|
+
```
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
# hotdata-materialized
|
|
2
|
+
|
|
3
|
+
Materialize expensive Django query results into [Hotdata](https://hotdata.dev).
|
|
4
|
+
On a cache **miss** your code runs in your environment as usual and the result
|
|
5
|
+
is captured into Hotdata as parquet — in the background, after your response
|
|
6
|
+
has already returned. On a **hit** your database is never touched: the data
|
|
7
|
+
comes back as a `pyarrow.Table` you can turn into a dataframe, or query with
|
|
8
|
+
SQL server-side.
|
|
9
|
+
|
|
10
|
+
Think materialized views, not Redis: entries are snapshots of expensive
|
|
11
|
+
analytical results, with a TTL lifecycle, each living in its own Hotdata
|
|
12
|
+
managed database. The host application needs **no migrations and no Redis** —
|
|
13
|
+
entry metadata lives in a small Hotdata managed database (the registry),
|
|
14
|
+
created on first use.
|
|
15
|
+
|
|
16
|
+
Measured against TPC-H on a Neon Postgres (see [demo/](demo/)):
|
|
17
|
+
|
|
18
|
+
| | Direct on Postgres | Miss (perceived) | Hit |
|
|
19
|
+
|---|---|---|---|
|
|
20
|
+
| Q1 pricing summary (full scan, 4 rows) | ~1.4 s | ~1.4 s | **~80 ms** |
|
|
21
|
+
| Revenue by part (50,000 rows) | ~2.1 s | ~2.0 s | **~260 ms** |
|
|
22
|
+
|
|
23
|
+
A miss costs the same as not caching (the persist runs write-behind); a hit
|
|
24
|
+
is 8–18× faster with zero load on your database.
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install hotdata-materialized
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
# settings.py
|
|
34
|
+
HOTDATA_MATERIALIZED = {
|
|
35
|
+
"API_KEY": env("HOTDATA_API_KEY"), # hd_... key from app.hotdata.dev
|
|
36
|
+
"WORKSPACE_ID": "work...",
|
|
37
|
+
# optional:
|
|
38
|
+
# "API_URL": "https://api.hotdata.dev",
|
|
39
|
+
# "REGISTRY_DATABASE": "materialized_registry",
|
|
40
|
+
# "INLINE_THRESHOLD_BYTES": 2 * 1024 * 1024,
|
|
41
|
+
# "BACKGROUND": True, # write-behind persists (default)
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Using it in a Django view
|
|
46
|
+
|
|
47
|
+
Wrap the expensive part in `@materialize`; call it from your view:
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
# reports.py
|
|
51
|
+
from django.db.models import Count, Sum
|
|
52
|
+
from hotdata_materialized import materialize
|
|
53
|
+
|
|
54
|
+
from .models import Order
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@materialize(ttl=3600)
|
|
58
|
+
def revenue_by_region():
|
|
59
|
+
return (
|
|
60
|
+
Order.objects
|
|
61
|
+
.filter(status="complete")
|
|
62
|
+
.values("region")
|
|
63
|
+
.annotate(orders=Count("id"), revenue=Sum("total"))
|
|
64
|
+
.order_by("-revenue")
|
|
65
|
+
)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
# views.py
|
|
70
|
+
from django.http import JsonResponse
|
|
71
|
+
from .reports import revenue_by_region
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def revenue_view(request):
|
|
75
|
+
frame = revenue_by_region()
|
|
76
|
+
return JsonResponse({"rows": frame.to_pylist(), "cached": frame.cached})
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
On a **hit** the function never runs and your database is never touched. On a
|
|
80
|
+
**miss** the function runs exactly as it would uncached, the caller gets the
|
|
81
|
+
result immediately, and the persist happens in a background thread. Either
|
|
82
|
+
way you get a `MaterializedFrame`:
|
|
83
|
+
|
|
84
|
+
- `frame.arrow()` — the data as a `pyarrow.Table` (`frame.df()` for pandas,
|
|
85
|
+
`frame.to_pylist()` for dicts, `len(frame)` for the row count)
|
|
86
|
+
- `frame.sql("SELECT region, revenue FROM this ORDER BY revenue DESC LIMIT 5")`
|
|
87
|
+
— SQL runs server-side against the cached entry; `this` names the data.
|
|
88
|
+
Needs a persisted entry: available on hits (on a fresh miss the
|
|
89
|
+
write-behind persist has to land first; `background=False` avoids that)
|
|
90
|
+
- `frame.cached` — which path served it; `frame.entry` — the registry record
|
|
91
|
+
|
|
92
|
+
The wrapped function can return an iterable of dicts (a `.values()` queryset),
|
|
93
|
+
a `pyarrow.Table`, or a pandas DataFrame. Decorator knobs: `ttl=` (seconds,
|
|
94
|
+
`None` = never expires), `version=` (bump to bust the cache on code changes),
|
|
95
|
+
`key=` (human-readable label), `key_fn=` (stable identity for arguments that
|
|
96
|
+
aren't JSON-serializable), `background=False` (block until persisted).
|
|
97
|
+
Fail-open by design: if Hotdata is unreachable, the function runs and its
|
|
98
|
+
result is served uncached — the cache degrades to "no cache," never to
|
|
99
|
+
"no page."
|
|
100
|
+
|
|
101
|
+
## Search
|
|
102
|
+
|
|
103
|
+
Declare a search index on the decorator and the entry gets it when it
|
|
104
|
+
persists (builds run server-side; a freshly missed entry may error on search
|
|
105
|
+
for a few seconds until its index is ready):
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
from hotdata_materialized import BM25, Vector, materialize
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@materialize(ttl=3600, index=BM25("notes"))
|
|
112
|
+
def incidents():
|
|
113
|
+
return Incident.objects.values("id", "notes", "severity")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
incidents().search("checkout errors outage", column="notes", limit=5)
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Vector (semantic) search embeds your text server-side via a workspace
|
|
120
|
+
embedding provider — pass the indexed source column and a natural-language
|
|
121
|
+
query:
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
@materialize(ttl=3600, index=Vector("notes", provider="emb_...", metric="cosine"))
|
|
125
|
+
def incidents_semantic():
|
|
126
|
+
return Incident.objects.values("id", "notes")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
incidents_semantic().vector_search("the site was down", column="notes", limit=5)
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Both return the matching rows as a `pyarrow.Table` with a relevance column
|
|
133
|
+
(`score` / `distance`). One platform rule to know: an embedding-backed vector
|
|
134
|
+
index cannot share an entry with other indexes — the decorator rejects that
|
|
135
|
+
combination up front.
|
|
136
|
+
|
|
137
|
+
## Evicting entries
|
|
138
|
+
|
|
139
|
+
The decorator composes public primitives (`fingerprint_call` /
|
|
140
|
+
`fingerprint_queryset`, `Registry`, `EntryStore`) that you can use directly —
|
|
141
|
+
for example to drop an entry explicitly:
|
|
142
|
+
|
|
143
|
+
```python
|
|
144
|
+
from hotdata_materialized import fingerprint_call
|
|
145
|
+
from hotdata_materialized.decorator import get_runtime
|
|
146
|
+
|
|
147
|
+
registry, store = get_runtime()
|
|
148
|
+
store.evict(fingerprint_call(revenue_by_region))
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Expired entries stop being served immediately (the `is_expired` check) and
|
|
152
|
+
their databases carry a server-side expiry backstop; a sweep command that
|
|
153
|
+
deletes them proactively is on the roadmap below.
|
|
154
|
+
|
|
155
|
+
## How it works
|
|
156
|
+
|
|
157
|
+
One managed database (the **registry**) holds one row per entry: fingerprint,
|
|
158
|
+
status, TTL, the entry's database id, and — for small results — the data
|
|
159
|
+
itself as an inline Arrow payload, so a chart-sized hit is served in a single
|
|
160
|
+
API round trip. Larger results live in a per-entry managed database and are
|
|
161
|
+
fetched as an Arrow IPC stream via a result id minted at persist time (no
|
|
162
|
+
re-query on hits). Failures are loud and fail toward "no cache": a failed
|
|
163
|
+
persist leaves no registry row, and the next request simply misses again.
|
|
164
|
+
|
|
165
|
+
See [DESIGN.md](DESIGN.md) for the architecture and the accepted
|
|
166
|
+
trade-offs.
|
|
167
|
+
|
|
168
|
+
## Roadmap
|
|
169
|
+
|
|
170
|
+
- [x] Content-addressed fingerprinting (querysets and function calls)
|
|
171
|
+
- [x] Remote registry — no migrations or local state in the host app
|
|
172
|
+
- [x] Entry store with write-behind persists
|
|
173
|
+
- [x] Arrow-native reads (inline payloads, persisted-result fetch)
|
|
174
|
+
- [x] `@materialize` decorator and `MaterializedFrame`
|
|
175
|
+
(`.arrow()/.df()/.to_pylist()/.sql()`)
|
|
176
|
+
- [x] TPC-H demo and benchmark
|
|
177
|
+
- [x] CI: pytest, mypy, flake8
|
|
178
|
+
- [ ] Stale-while-revalidate refresh (rebuild protocol)
|
|
179
|
+
- [ ] Sweep command for expired entries
|
|
180
|
+
- [ ] Chainable queryset facade on the frame (`.filter()/.order_by()`)
|
|
181
|
+
- [x] Vector/BM25 index declarations, `frame.search()` and
|
|
182
|
+
`frame.vector_search()`
|
|
183
|
+
- [ ] Async view support (`await frame.aarrow()`)
|
|
184
|
+
- [ ] PyPI release
|
|
185
|
+
|
|
186
|
+
## Demo
|
|
187
|
+
|
|
188
|
+
[demo/](demo/) is a small Django project that benchmarks TPC-H queries on a
|
|
189
|
+
Neon Postgres directly vs. through the cache:
|
|
190
|
+
|
|
191
|
+
```bash
|
|
192
|
+
cd demo
|
|
193
|
+
python manage.py compare # Q1: tiny result, inline hit
|
|
194
|
+
python manage.py compare --scenario parts # 50k rows: remote Arrow hit
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
## Development
|
|
198
|
+
|
|
199
|
+
```bash
|
|
200
|
+
uv venv && uv pip install -e '.[test]'
|
|
201
|
+
uv run pytest
|
|
202
|
+
```
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""hotdata-materialized: offload expensive Django query results to Hotdata.
|
|
2
|
+
|
|
3
|
+
Public surface: the @materialize decorator and MaterializedFrame handle,
|
|
4
|
+
built on fingerprinting, the remote registry, and the entry store.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .conf import Config
|
|
8
|
+
from .client import HotdataClients, get_clients, reset_clients
|
|
9
|
+
from .decorator import MaterializedFrame, materialize
|
|
10
|
+
from .exceptions import (
|
|
11
|
+
ConfigurationError,
|
|
12
|
+
FingerprintError,
|
|
13
|
+
MaterializedError,
|
|
14
|
+
RegistryError,
|
|
15
|
+
StoreError,
|
|
16
|
+
)
|
|
17
|
+
from .fingerprint import fingerprint_call, fingerprint_queryset
|
|
18
|
+
from .indexes import BM25, Vector
|
|
19
|
+
from .registry import Registry, RegistryEntry
|
|
20
|
+
from .store import EntryStore
|
|
21
|
+
|
|
22
|
+
__version__ = "0.1.0"
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"materialize",
|
|
26
|
+
"MaterializedFrame",
|
|
27
|
+
"BM25",
|
|
28
|
+
"Vector",
|
|
29
|
+
"Config",
|
|
30
|
+
"HotdataClients",
|
|
31
|
+
"get_clients",
|
|
32
|
+
"reset_clients",
|
|
33
|
+
"MaterializedError",
|
|
34
|
+
"ConfigurationError",
|
|
35
|
+
"FingerprintError",
|
|
36
|
+
"RegistryError",
|
|
37
|
+
"StoreError",
|
|
38
|
+
"fingerprint_call",
|
|
39
|
+
"fingerprint_queryset",
|
|
40
|
+
"Registry",
|
|
41
|
+
"RegistryEntry",
|
|
42
|
+
"EntryStore",
|
|
43
|
+
"__version__",
|
|
44
|
+
]
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Arrow/parquet encoding helpers shared by the registry and the entry store."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import io
|
|
7
|
+
import json
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
import pyarrow as pa
|
|
11
|
+
import pyarrow.ipc as pa_ipc
|
|
12
|
+
import pyarrow.parquet as pq
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def table_to_parquet_bytes(table: pa.Table) -> bytes:
|
|
16
|
+
buffer = io.BytesIO()
|
|
17
|
+
pq.write_table(table, buffer)
|
|
18
|
+
return buffer.getvalue()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def table_to_ipc_bytes(table: pa.Table) -> bytes:
|
|
22
|
+
sink = io.BytesIO()
|
|
23
|
+
with pa_ipc.new_stream(sink, table.schema) as writer:
|
|
24
|
+
writer.write_table(table)
|
|
25
|
+
return sink.getvalue()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def encode_inline_payload(table: pa.Table, threshold_bytes: int) -> Optional[str]:
|
|
29
|
+
# threshold bounds the payload as stored (post-base64), not the raw IPC
|
|
30
|
+
payload = base64.b64encode(table_to_ipc_bytes(table)).decode("ascii")
|
|
31
|
+
if len(payload) > threshold_bytes:
|
|
32
|
+
return None
|
|
33
|
+
return payload
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def decode_inline_payload(payload: str) -> pa.Table:
|
|
37
|
+
with pa_ipc.open_stream(io.BytesIO(base64.b64decode(payload))) as reader:
|
|
38
|
+
return reader.read_all()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def schema_to_json(table: pa.Table) -> str:
|
|
42
|
+
return json.dumps(
|
|
43
|
+
[{"name": field.name, "type": str(field.type)} for field in table.schema]
|
|
44
|
+
)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""SQL literal and identifier encoding.
|
|
2
|
+
|
|
3
|
+
POST /v1/queries takes SQL text only — there are no bind parameters — so
|
|
4
|
+
every value written into SQL must go through quote_literal(), and every
|
|
5
|
+
caller-supplied identifier (column names in search) through quote_ident().
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import datetime as _dt
|
|
11
|
+
import math
|
|
12
|
+
import re
|
|
13
|
+
|
|
14
|
+
from .exceptions import RegistryError
|
|
15
|
+
|
|
16
|
+
_IDENT = re.compile(r"[A-Za-z_][A-Za-z0-9_]*\Z")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def quote_ident(name: str) -> str:
|
|
20
|
+
if not _IDENT.match(name):
|
|
21
|
+
raise ValueError(f"invalid SQL identifier: {name!r}")
|
|
22
|
+
return name
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def quote_literal(value) -> str:
|
|
26
|
+
if value is None:
|
|
27
|
+
return "NULL"
|
|
28
|
+
if isinstance(value, bool):
|
|
29
|
+
return "TRUE" if value else "FALSE"
|
|
30
|
+
if isinstance(value, int):
|
|
31
|
+
return str(value)
|
|
32
|
+
if isinstance(value, float):
|
|
33
|
+
if math.isnan(value) or math.isinf(value):
|
|
34
|
+
raise RegistryError(f"cannot encode non-finite float: {value!r}")
|
|
35
|
+
return repr(value)
|
|
36
|
+
if isinstance(value, (_dt.datetime, _dt.date)):
|
|
37
|
+
return quote_literal(value.isoformat())
|
|
38
|
+
if isinstance(value, str):
|
|
39
|
+
if "\x00" in value:
|
|
40
|
+
raise RegistryError("cannot encode string containing NUL byte")
|
|
41
|
+
return "'" + value.replace("'", "''") + "'"
|
|
42
|
+
raise RegistryError(f"cannot encode SQL literal of type {type(value).__name__}")
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Hotdata SDK client construction.
|
|
2
|
+
|
|
3
|
+
One ApiClient (and its connection pool) per process, shared by the query,
|
|
4
|
+
databases, and uploads resource APIs.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import threading
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
import hotdata
|
|
13
|
+
from hotdata.arrow import ResultsApi
|
|
14
|
+
from hotdata.query import QueryApi
|
|
15
|
+
from hotdata.uploads import UploadsApi
|
|
16
|
+
|
|
17
|
+
from .conf import Config
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class HotdataClients:
|
|
21
|
+
def __init__(self, config: Config):
|
|
22
|
+
sdk_config = hotdata.Configuration(
|
|
23
|
+
host=config.api_url,
|
|
24
|
+
api_key=config.api_key,
|
|
25
|
+
workspace_id=config.workspace_id,
|
|
26
|
+
)
|
|
27
|
+
self.api_client = hotdata.ApiClient(sdk_config)
|
|
28
|
+
self.query = QueryApi(self.api_client)
|
|
29
|
+
self.databases = hotdata.DatabasesApi(self.api_client)
|
|
30
|
+
self.uploads = UploadsApi(self.api_client)
|
|
31
|
+
self.results = ResultsApi(self.api_client)
|
|
32
|
+
self.indexes = hotdata.IndexesApi(self.api_client)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
_lock = threading.Lock()
|
|
36
|
+
_clients: Optional[HotdataClients] = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def get_clients(config: Optional[Config] = None) -> HotdataClients:
|
|
40
|
+
global _clients
|
|
41
|
+
with _lock:
|
|
42
|
+
if _clients is None:
|
|
43
|
+
_clients = HotdataClients(config or Config.from_django())
|
|
44
|
+
return _clients
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def reset_clients() -> None:
|
|
48
|
+
global _clients
|
|
49
|
+
with _lock:
|
|
50
|
+
_clients = None
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Configuration, loaded from the HOTDATA_MATERIALIZED dict in Django settings.
|
|
2
|
+
|
|
3
|
+
The Config dataclass is plain and injectable so the library (and its tests)
|
|
4
|
+
can run without a configured Django project.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Any, Dict
|
|
11
|
+
|
|
12
|
+
from .exceptions import ConfigurationError
|
|
13
|
+
|
|
14
|
+
DEFAULT_API_URL = "https://api.hotdata.dev"
|
|
15
|
+
|
|
16
|
+
_REQUIRED_KEYS = ("API_KEY", "WORKSPACE_ID")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class Config:
|
|
21
|
+
api_key: str
|
|
22
|
+
workspace_id: str
|
|
23
|
+
api_url: str = DEFAULT_API_URL
|
|
24
|
+
registry_database: str = "materialized_registry"
|
|
25
|
+
inline_threshold_bytes: int = 2 * 1024 * 1024
|
|
26
|
+
entry_prefix: str = "mz_"
|
|
27
|
+
# Server-side entry-database expiry is set to ttl + grace: a safety net
|
|
28
|
+
# behind the sweep, never the primary eviction path.
|
|
29
|
+
entry_expiry_grace_seconds: int = 24 * 3600
|
|
30
|
+
# Write-behind is the default: a miss returns as soon as the caller's data
|
|
31
|
+
# is in hand and the persist runs on a small background pool.
|
|
32
|
+
background: bool = True
|
|
33
|
+
background_max_workers: int = 2
|
|
34
|
+
|
|
35
|
+
@classmethod
|
|
36
|
+
def from_dict(cls, raw: Dict[str, Any]) -> "Config":
|
|
37
|
+
missing = [key for key in _REQUIRED_KEYS if not raw.get(key)]
|
|
38
|
+
if missing:
|
|
39
|
+
raise ConfigurationError(
|
|
40
|
+
"HOTDATA_MATERIALIZED is missing required keys: " + ", ".join(missing)
|
|
41
|
+
)
|
|
42
|
+
return cls(
|
|
43
|
+
api_key=raw["API_KEY"],
|
|
44
|
+
workspace_id=raw["WORKSPACE_ID"],
|
|
45
|
+
api_url=raw.get("API_URL", DEFAULT_API_URL),
|
|
46
|
+
registry_database=raw.get("REGISTRY_DATABASE", cls.registry_database),
|
|
47
|
+
inline_threshold_bytes=raw.get(
|
|
48
|
+
"INLINE_THRESHOLD_BYTES", cls.inline_threshold_bytes
|
|
49
|
+
),
|
|
50
|
+
entry_prefix=raw.get("ENTRY_PREFIX", cls.entry_prefix),
|
|
51
|
+
entry_expiry_grace_seconds=raw.get(
|
|
52
|
+
"ENTRY_EXPIRY_GRACE_SECONDS", cls.entry_expiry_grace_seconds
|
|
53
|
+
),
|
|
54
|
+
background=raw.get("BACKGROUND", cls.background),
|
|
55
|
+
background_max_workers=raw.get(
|
|
56
|
+
"BACKGROUND_MAX_WORKERS", cls.background_max_workers
|
|
57
|
+
),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
@classmethod
|
|
61
|
+
def from_django(cls) -> "Config":
|
|
62
|
+
from django.conf import settings
|
|
63
|
+
|
|
64
|
+
raw = getattr(settings, "HOTDATA_MATERIALIZED", None)
|
|
65
|
+
if not isinstance(raw, dict):
|
|
66
|
+
raise ConfigurationError(
|
|
67
|
+
"Define a HOTDATA_MATERIALIZED dict in Django settings "
|
|
68
|
+
"(required keys: API_KEY, WORKSPACE_ID)."
|
|
69
|
+
)
|
|
70
|
+
return cls.from_dict(raw)
|