Skip to content

Commit 69bb51d

Browse files
committed
Support hosted Parquet stores and raw Parquet uploads
1 parent b0797dc commit 69bb51d

8 files changed

Lines changed: 286 additions & 36 deletions

File tree

‎caterva2/services/remote_store.py‎

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,12 @@ def inspect(path):
4747

4848
def validate_manifest(manifest):
4949
blosc2.RemoteStore._validate_artifact_manifest(manifest)
50-
if set(manifest["source"]) != {"urlpath", "dataset", "kind"}:
50+
allowed_source = (
51+
{"urlpath", "dataset", "kind", "options"}
52+
if manifest["source"]["kind"] == "parquet"
53+
else {"urlpath", "dataset", "kind"}
54+
)
55+
if set(manifest["source"]) != allowed_source:
5156
raise remote_proxy.RemoteArrayDenied("RemoteStore source contains unsupported fields")
5257
if len(msgpack_packb(manifest)) > remote_proxy.policy.max_metadata_bytes:
5358
raise remote_proxy.RemoteArrayDenied("RemoteStore metadata exceeds the configured limit")
@@ -118,7 +123,7 @@ def filesystem_for_url(url):
118123

119124
def cold_export(manifest, destination):
120125
"""Write a descriptor-only archive without resolving its source."""
121-
manifest = dict(manifest, caches=[], batch_caches=[], linked={})
126+
manifest = dict(manifest, caches=[], batch_caches=[], parquet_caches=[], linked={})
122127
storage = blosc2.Storage(contiguous=True)
123128
storage.meta = {"b2tree": {"version": 1}, "b2remote_store": {"version": 1}}
124129
embed = blosc2.SChunk(chunksize=8192, data=None, storage=storage)
@@ -149,13 +154,24 @@ def authorized_filesystem(url):
149154
source = self.manifest["source"]
150155
fs = authorized_filesystem(source["urlpath"])
151156
options = {
157+
"_source_format": source["kind"],
152158
"_filesystem": fs,
153159
"_filesystem_resolver": authorized_filesystem,
154160
"_source_validator": validate_array,
155161
"_batch_validator": validate_batch,
156162
"_manifest_validator": validate_manifest,
157163
"_max_nodes": remote_proxy.policy.max_nodes,
158164
}
165+
if source["kind"] == "parquet":
166+
from blosc2.ctable import NullPolicy
167+
from blosc2.remote_parquet import _restore_compression_options
168+
169+
conversion = dict(self.manifest["metadata"]["parquet"]["conversion"])
170+
conversion["_effective_null_policy"] = NullPolicy(
171+
**self.manifest["metadata"]["parquet"]["discovery"]["options"]["null_policy"]
172+
)
173+
_restore_compression_options(conversion)
174+
options["_parquet_conversion"] = conversion
159175
try:
160176
if runtime is not None:
161177
store = blosc2.RemoteStore.with_sparse_cache(
@@ -172,7 +188,13 @@ def authorized_filesystem(url):
172188
source["urlpath"],
173189
dataset=source.get("dataset"),
174190
cache_policy=blosc2.CachePolicy.NONE,
175-
_manifest=dict(copy.deepcopy(self.manifest), caches=[], batch_caches=[], linked={}),
191+
_manifest=dict(
192+
copy.deepcopy(self.manifest),
193+
caches=[],
194+
batch_caches=[],
195+
parquet_caches=[],
196+
linked={},
197+
),
176198
allow_table_root=True,
177199
**options,
178200
)
@@ -192,7 +214,7 @@ def operation(self, callback, *, cached=None):
192214
return quota_coordinator().remote.store_operation(self, callback, cached=cached)
193215
except remote_proxy.RemoteArrayDenied as exc:
194216
raise fastapi.HTTPException(status_code=403, detail=str(exc)) from exc
195-
except ValueError as exc:
217+
except (KeyError, TypeError, ValueError) as exc:
196218
raise fastapi.HTTPException(status_code=400, detail=str(exc)) from exc
197219
except (OSError, zipfile.BadZipFile) as exc:
198220
raise fastapi.HTTPException(status_code=502, detail=str(exc)) from exc

‎caterva2/services/server.py‎

Lines changed: 29 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -957,11 +957,15 @@ def get_abspath(
957957
elif (cachedir / filepath).is_dir():
958958
return cachedir / filepath
959959

960-
# HDF5 files cannot be compressed, as they are supported natively
961-
if (
962-
filepath.suffix not in srv_utils.BLOSC2_NATIVE_SUFFIXES | srv_utils.HDF5_SUFFIXES
963-
and not may_not_exist
964-
):
960+
# Preserve access to Parquet files uploaded before the compression exemption.
961+
if filepath.suffix == ".parquet" and not filepath.exists() and not may_not_exist:
962+
compressed = filepath.with_suffix(".parquet.b2")
963+
if compressed.is_file():
964+
filepath = compressed
965+
966+
# HDF5 files cannot be compressed, as they are supported natively.
967+
# Parquet also needs its original bytes for HTTP range reads.
968+
if filepath.suffix not in srv_utils.NO_COMPRESSION_SUFFIXES and not may_not_exist:
965969
if filepath.is_file():
966970
srv_utils.compress_file(filepath)
967971
filepath = f"{filepath}.b2"
@@ -1533,16 +1537,16 @@ async def __call__(self, scope, receive, send):
15331537
await concurrency.run_in_threadpool(self.cleanup)
15341538

15351539

1536-
@app.get("/api/download/{path:path}")
1540+
@app.api_route("/api/download/{path:path}", methods=["GET", "HEAD"])
15371541
async def download_data(
15381542
path: pathlib.Path,
15391543
user: db.User = Depends(optional_user),
15401544
include_cache: bool = True,
15411545
accept_encoding: str | None = fastapi.Header(None),
15421546
range_header: str | None = fastapi.Header(None, alias="Range"),
15431547
):
1544-
# This one always streams, decompressing on the way out more often than not,
1545-
# so it never serves ranges; api/fetch on a stored dataset is what does. The
1548+
# Regular files stream, often decompressing on the way out, and refuse ranges.
1549+
# Stored Parquet files use FileResponse below to support range reads. The
15461550
# refusal comes after the path is resolved, so a path that does not exist is
15471551
# still a 404 rather than a 416 about a file nobody has.
15481552
provider = providers.provider_for(path.parts[0])
@@ -1570,6 +1574,13 @@ async def download_data(
15701574
from caterva2.services import remote_store
15711575

15721576
abspath = get_abspath(path, user)
1577+
if abspath.suffix == ".parquet":
1578+
return FileResponse(
1579+
abspath,
1580+
filename=path.name,
1581+
media_type="application/vnd.apache.parquet",
1582+
headers=with_etag(abspath),
1583+
)
15731584
manifest = remote_store.inspect(abspath)
15741585
if manifest is not None and (remote_proxy.policy.enabled or not include_cache):
15751586
artifact, etag, cleanup = await concurrency.run_in_threadpool(
@@ -2588,13 +2599,11 @@ async def upload_file(
25882599
# Check quota
25892600
# TODO To be fair we should check quota later (after compression, zip unpacking etc.)
25902601
data = await file.read()
2591-
if abspath.suffix not in srv_utils.BLOSC2_NATIVE_SUFFIXES:
2592-
schunk = blosc2.SChunk(data=data)
25932602

25942603
# If regular file, compress it
25952604
abspath.parent.mkdir(exist_ok=True, parents=True)
2596-
if abspath.suffix not in srv_utils.BLOSC2_NATIVE_SUFFIXES | {".h5", ".hdf5"}:
2597-
data = schunk.to_cframe()
2605+
if abspath.suffix not in srv_utils.NO_COMPRESSION_SUFFIXES:
2606+
data = blosc2.SChunk(data=data).to_cframe()
25982607
abspath = abspath.with_suffix(abspath.suffix + ".b2")
25992608

26002609
# Write the file
@@ -2687,13 +2696,10 @@ async def load_from_url(
26872696
response.raise_for_status()
26882697
data = response.content
26892698

2690-
if abspath.suffix not in srv_utils.BLOSC2_NATIVE_SUFFIXES:
2691-
schunk = blosc2.SChunk(data=data)
2692-
26932699
# If regular file, compress it
26942700
abspath.parent.mkdir(exist_ok=True, parents=True)
2695-
if abspath.suffix not in srv_utils.BLOSC2_NATIVE_SUFFIXES | {".h5", ".hdf5"}:
2696-
data = schunk.to_cframe()
2701+
if abspath.suffix not in srv_utils.NO_COMPRESSION_SUFFIXES:
2702+
data = blosc2.SChunk(data=data).to_cframe()
26972703
abspath = abspath.with_suffix(abspath.suffix + ".b2")
26982704

26992705
# Write the file
@@ -3362,7 +3368,7 @@ def add_dataset(path, abspath, mountable=False, size=None):
33623368
else:
33633369
relpath = pathlib.Path(*segments[2:])
33643370
abspath = rootdir / relpath
3365-
if abspath.suffix not in srv_utils.BLOSC2_NATIVE_SUFFIXES:
3371+
if abspath.suffix not in srv_utils.NO_COMPRESSION_SUFFIXES:
33663372
abspath = pathlib.Path(f"{abspath}.b2")
33673373

33683374
with contextlib.suppress(FileNotFoundError, NotADirectoryError):
@@ -4225,7 +4231,7 @@ def store_member(name, body):
42254231
raise ValueError("archive member escapes its destination")
42264232
if any(p.startswith((".", "__MACOSX")) for p in member.parts):
42274233
return
4228-
if member.suffix not in srv_utils.BLOSC2_NATIVE_SUFFIXES:
4234+
if member.suffix not in srv_utils.NO_COMPRESSION_SUFFIXES:
42294235
body = blosc2.SChunk(data=body).to_cframe()
42304236
member = member.with_suffix(member.suffix + ".b2")
42314237
write_dataset(path / member, body)
@@ -4282,19 +4288,17 @@ def store_member(name, body):
42824288
new_members = [
42834289
member
42844290
for member in members
4285-
if not (path / member).is_dir() and member.suffix not in srv_utils.BLOSC2_NATIVE_SUFFIXES
4291+
if not (path / member).is_dir() and member.suffix not in srv_utils.NO_COMPRESSION_SUFFIXES
42864292
]
42874293
for member in new_members:
42884294
srv_utils.compress_file(path / member)
42894295

42904296
# We are done, redirect to home, and show the new files, starting with the first one
4291-
first_member = next((m for m in new_members), None)
4297+
first_member = next((m for m in members if (path / m).is_file() or m in new_members), None)
42924298
path = f"{name}/{first_member}"
42934299
return htmx_redirect(hx_current_url, make_url(request, "html_home", path=path), root=name)
42944300

4295-
if suffix in [".h5", ".hdf5"]:
4296-
pass
4297-
elif filename.suffix not in srv_utils.BLOSC2_NATIVE_SUFFIXES:
4301+
if filename.suffix not in srv_utils.NO_COMPRESSION_SUFFIXES:
42984302
schunk = blosc2.SChunk(data=data)
42994303
data = schunk.to_cframe()
43004304
filename = f"{filename}.b2"
@@ -4338,9 +4342,7 @@ async def htmx_delete(
43384342
abspath = settings.public / path
43394343

43404344
# Remove
4341-
if abspath.suffix in [".h5", ".hdf5"]:
4342-
pass
4343-
elif abspath.suffix not in {".b2frame", ".b2nd"}:
4345+
if abspath.suffix not in srv_utils.NO_COMPRESSION_SUFFIXES:
43444346
abspath = abspath.with_suffix(abspath.suffix + ".b2")
43454347
if not abspath.exists():
43464348
return fastapi.HTTPException(status_code=404)

‎caterva2/services/sparse_cache.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -478,14 +478,17 @@ def execute(path=None, operation=callback):
478478
store.manifest["caches"]
479479
or store.manifest.get("batch_caches")
480480
or store.manifest.get("linked")
481+
or store.manifest.get("parquet_caches")
481482
):
482483
self._intent(gid, "coldify")
483484
cold = io.BytesIO()
484485
remote_store.cold_export(store.manifest, cold)
485486
new_sig = self.q.publish_locked(
486487
rel, cold.getvalue(), expected=sig, preserve_remote=True
487488
)
488-
store.manifest = dict(store.manifest, caches=[], batch_caches=[], linked={})
489+
store.manifest = dict(
490+
store.manifest, caches=[], batch_caches=[], parquet_caches=[], linked={}
491+
)
489492
store.carrier_generation = new_sig
490493
spec = hashlib.sha256(msgpack_packb(store.manifest)).hexdigest()
491494
with self.q.transaction() as db:

‎caterva2/services/srv_utils.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@
4040
BLOSC2_NATIVE_SUFFIXES = BLOSC2_ARRAY_SUFFIXES | BLOSC2_TABLE_SUFFIXES | BLOSC2_FRAME_SUFFIXES
4141

4242
HDF5_SUFFIXES = {".h5", ".hdf5"}
43+
# Preserve these formats byte-for-byte when storing uploaded files.
44+
NO_COMPRESSION_SUFFIXES = BLOSC2_NATIVE_SUFFIXES | HDF5_SUFFIXES | {".parquet"}
4345

4446
# Container suffixes whose paths may descend into internal (virtual) members.
4547
BLOSC2_CONTAINER_SUFFIXES = {".b2z"} | HDF5_SUFFIXES
@@ -524,6 +526,9 @@ def read_metadata(obj, mtime=None):
524526
finally:
525527
container.close()
526528

529+
if path.suffix == ".parquet":
530+
return models.File(mtime=mtime, size=size)
531+
527532
assert path.suffix in BLOSC2_NATIVE_SUFFIXES
528533
try:
529534
reference = remote_proxy.inspect(path)

‎caterva2/tests/test_remote_store.py‎

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,141 @@ class RichRow:
202202
assert list(blosc2.ctable_from_cframe(projected).text[:]) == ["one", None, "three"]
203203

204204

205+
@pytest.fixture(params=["taxi.parquet", "taxi-data"])
206+
def parquet_runtime(table_runtime, tmp_path, request):
207+
import pyarrow as pa
208+
import pyarrow.parquet as pq
209+
210+
fs = fsspec.filesystem("memory")
211+
url = f"https://data.example/{request.param}"
212+
source = tmp_path / "taxi.parquet"
213+
pq.write_table(pa.table({"fare": [10, 20, 30], "cab": ["a", "b", "c"]}), source, row_group_size=2)
214+
fs.pipe_file(url, source.read_bytes())
215+
path = table_runtime.with_name("parquet-reference.b2z")
216+
with blosc2.RemoteCTable(
217+
url,
218+
source_format="parquet",
219+
_filesystem=fs,
220+
cache_dir=tmp_path / "parquet-cache",
221+
columns=["fare", "cab"],
222+
blosc2_batch_size=2,
223+
) as remote:
224+
assert list(remote.slice(0, 2).fare[:]) == [10, 20]
225+
remote.save(path)
226+
return path
227+
228+
229+
def test_remote_parquet_reference(parquet_runtime, tmp_path, monkeypatch):
230+
import blosc2.remote_parquet as parquet
231+
import pyarrow as pa
232+
import pyarrow.parquet as pq
233+
234+
from caterva2.services import server
235+
236+
path = parquet_runtime
237+
manifest = remote_store.inspect(path)
238+
assert manifest["parquet_caches"]
239+
assert remote_store.root_kind(manifest) == "ctable"
240+
q = server.quota_coordinator()
241+
with monkeypatch.context() as patch:
242+
patch.setattr(
243+
parquet, "_open_source_handle", lambda *args, **kwargs: pytest.fail("warm Parquet row fetched")
244+
)
245+
assert srv_utils.read_metadata(path).kind == "ctable"
246+
table = server.open_b2(path, "@public/parquet-reference.b2z")
247+
assert table.nrows == 3
248+
assert list(blosc2.ctable_from_cframe(table.fetch(slice_=slice(0, 2))).fare[:]) == [10, 20]
249+
assert q.usage()["remote_cache_used"] > 0
250+
assert remote_store.inspect(path)["parquet_caches"] == []
251+
used = q.usage()["used"]
252+
with q.transaction() as db:
253+
db.execute("UPDATE account SET cache_fill_suspended=1,quota=?", (used,))
254+
assert list(blosc2.ctable_from_cframe(table.fetch(slice_=slice(0, 2))).fare[:]) == [10, 20]
255+
with q.transaction() as db:
256+
db.execute("UPDATE account SET cache_fill_suspended=0,quota=0")
257+
for include_cache in (True, False):
258+
export, _, cleanup = q.remote.export_store(table.store, include_cache=include_cache)
259+
try:
260+
exported = remote_store.inspect(export)
261+
assert exported["source"] == manifest["source"]
262+
assert bool(exported["parquet_caches"]) == include_cache
263+
finally:
264+
cleanup()
265+
266+
source = tmp_path / "taxi.parquet"
267+
pq.write_table(pa.table({"fare": [40, 50, 60, 70], "cab": ["d", "e", "f", "g"]}), source)
268+
fsspec.filesystem("memory").pipe_file(manifest["source"]["urlpath"], source.read_bytes())
269+
assert list(blosc2.ctable_from_cframe(table.fetch(slice_=slice(0, 2))).fare[:]) == [10, 20]
270+
path.write_bytes(table.store.refreshed_bytes())
271+
updated = server.open_b2(path, "@public/parquet-reference.b2z")
272+
assert list(blosc2.ctable_from_cframe(updated.fetch(slice_=slice(0, 2))).fare[:]) == [40, 50]
273+
274+
275+
@pytest.mark.parametrize("policy", ["none", "memory"])
276+
def test_remote_parquet_without_retention(parquet_runtime, policy):
277+
from caterva2.services import server
278+
279+
manifest = dict(
280+
remote_store.inspect(parquet_runtime),
281+
cache_policy=policy,
282+
max_cache_bytes=None if policy == "none" else 1 << 20,
283+
)
284+
path = parquet_runtime.with_name("uncached.b2z")
285+
remote_store.cold_export(manifest, path)
286+
table = server.open_b2(path, "@public/uncached.b2z")
287+
result = blosc2.ctable_from_cframe(table.fetch(slice_=slice(1, 3), field="cab"))
288+
assert list(result.cab[:]) == ["b", "c"]
289+
assert server.quota_coordinator().usage()["remote_cache_used"] == 0
290+
path.write_bytes(table.store.refreshed_bytes())
291+
assert remote_store.inspect(path)["cache_policy"] == policy
292+
293+
294+
@pytest.mark.asyncio
295+
async def test_remote_parquet_api(parquet_runtime, monkeypatch):
296+
import httpx
297+
298+
from caterva2.services import server
299+
300+
path = parquet_runtime
301+
monkeypatch.setattr(server.settings, "statedir", path.parents[1])
302+
monkeypatch.setattr(server.settings, "public", path.parent)
303+
monkeypatch.setitem(server.app.dependency_overrides, server.optional_user, lambda: None)
304+
async with httpx.AsyncClient(
305+
transport=httpx.ASGITransport(app=server.app), base_url="http://test"
306+
) as client:
307+
response = await client.get("/api/info/@public/parquet-reference.b2z")
308+
assert response.status_code == 200, response.text
309+
assert response.json()["kind"] == "ctable"
310+
response = await client.get(
311+
"/api/fetch/@public/parquet-reference.b2z",
312+
params={"filter": "fare >= 20", "field": "cab", "slice_": "0:1"},
313+
)
314+
assert response.status_code == 200, response.text
315+
assert list(blosc2.ctable_from_cframe(response.content).cab[:]) == ["b"]
316+
response = await client.post(
317+
"/htmx/path-view/@public/parquet-reference.b2z", data={"sortby": "fare"}
318+
)
319+
assert response.status_code == 200, response.text
320+
response = await client.get(
321+
"/api/download/@public/parquet-reference.b2z", params={"include_cache": "false"}
322+
)
323+
assert response.status_code == 200, response.text
324+
exported = path.with_name("downloaded.b2z")
325+
exported.write_bytes(response.content)
326+
assert remote_store.inspect(exported)["parquet_caches"] == []
327+
328+
329+
def test_remote_parquet_resource_limit(parquet_runtime, monkeypatch):
330+
import fastapi
331+
332+
from caterva2.services import server
333+
334+
monkeypatch.setattr(remote_proxy, "policy", dataclasses.replace(remote_proxy.policy, max_nbytes=1))
335+
with pytest.raises(fastapi.HTTPException) as exc:
336+
server.open_b2(parquet_runtime, "@public/parquet-reference.b2z")
337+
assert exc.value.status_code == 403
338+
339+
205340
@pytest.mark.asyncio
206341
async def test_standalone_remote_ctable_api(table_runtime, monkeypatch):
207342
import httpx

0 commit comments

Comments
 (0)