Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 66 additions & 7 deletions API.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ token (except under `anonymous`).
| POST | `/api/3/action/datastore_delete` | Delete rows, drop columns, or drop the table |
| GET | `/api/3/action/datastore_search` | Search a resource (streaming) |
| GET | `/api/3/action/datastore_search_sql` | Run a read-only SQL `SELECT` (streaming) |
| GET | `/datastore/dump/query` | Download the result of a SQL `SELECT` as a file |
| GET | `/api/3/action/datastore_info` | Schema + row stats for a resource |
| GET | `/datastore/dump/{resource_id}` | Download a whole resource (CSV/NDJSON/Parquet) |
| GET | `/` · `/health` · `/ready` | Welcome / liveness / readiness |
Expand Down Expand Up @@ -306,6 +307,7 @@ GET /api/3/action/datastore_search
Run a single read-only `SELECT` / `WITH` statement and stream the result. Tables
are referenced by `resource_id`; each is authorized individually, and functions
are checked against the engine's allow-list. Include a `LIMIT` (required).
To export the result as a file instead, use [`GET /datastore/dump/query`](#get-datastoredumpquery).

### Query parameters

Expand Down Expand Up @@ -380,17 +382,74 @@ row stats — a column-level metadata catalog without a side store.
## `GET /datastore/dump/{resource_id}`

Download an entire resource. Pick the format with `?format=csv` (default),
`ndjson`, or `parquet`.

- **Small export (single shard):** `302` redirect to a signed GCS URL (bytes go
straight from storage to the client).
- **Large export (multiple shards, CSV/NDJSON):** a streamed body
(`200`, ~constant memory).
- Parquet over the single-shard limit returns `413` — switch to CSV/NDJSON.
`gzip`, `ndjson`, or `parquet`.

- **csv / gzip / ndjson** — `302` redirect to a signed GCS URL, at any size.
Shards from a large export are stitched into one object server-side, so the
bytes go straight from storage to the client (resumable, no server
bandwidth, no server CPU). `gzip` is compressed by BigQuery at export time.
- **parquet** — `302` when the export is a single file. Parquet shards can't be
merged (footer + magic bytes), so a sharded export instead returns `200` with
a **zip** of the parts (`Content-Type: application/zip`, members named
`<resource_id>_NN.parquet`). Entries are stored, not deflated — parquet is
already compressed. This is the one download the server streams itself, so
it has no `Content-Length` and can't be resumed; unzip before querying.

Requires `read` permission on the resource and a configured export bucket
(`BIGQUERY_EXPORT_BUCKET`).

`query` is a **reserved name** on this route — `/datastore/dump/query` is the SQL
download endpoint below, so a resource literally named `query` can't be dumped
by this URL.

---

## `GET /datastore/dump/query`

Download the result of a **SQL `SELECT`** as a single file — filtered
downloads at any size. Same validation as `datastore_search_sql` (single
`SELECT`/`WITH`, per-table auth, function allow-list); the response is the
file itself, not the CKAN envelope.

### Query parameters

| Name | Type | Notes |
|---|---|---|
| `sql` | string | A single `SELECT`/`WITH`; no multi-statement, no DML/DDL. `LIMIT` optional. |
| `format` | string | `csv` (default) \| `gzip` (gzipped CSV) \| `ndjson` \| `parquet`. |

### Example

```http
GET /datastore/dump/query
?sql=SELECT * FROM "c6153a74-43cb-4edf-8bdf-bb664feca937" WHERE accepted = true
&format=csv
```

### Response

Identical to `/datastore/dump/{resource_id}` above:

- **csv / gzip / ndjson** — `302` to a signed GCS URL at any size (shards are
composed into one object). The URL expires after
`BIGQUERY_EXPORT_URL_EXPIRY_HOURS` (default 1h).
- **parquet** — `302` for one file; a sharded export returns `200` with a
streamed zip of the parts, members named `query_NN.parquet`.

### Rules (vs the JSON API)

- `LIMIT` is **optional** — absent exports the full result set; present it is
honored as written, and the `SEARCH_RESULT_ROWS_MAX` cap does not apply
(`OFFSET` without `LIMIT` is rejected).
- Repeated downloads of the same SQL are served from a GCS cache until any
referenced table changes; SQL calling non-deterministic functions
(`now()`, `current_date`, …) re-exports on every request.
- Row order in the file follows the SQL's `ORDER BY` when it sorts on output
columns (BigQuery preserves it across shards, which are composed in order). Without one, results fall
back to `_id` order when the query outputs `_id` (e.g. `SELECT *`) — the
same order the JSON API pages in; otherwise order is undefined.
- Requires a configured export bucket (`BIGQUERY_EXPORT_BUCKET`).

---

## Health
Expand Down
67 changes: 48 additions & 19 deletions CLAUDE.md

Large diffs are not rendered by default.

10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,18 @@ Copy [.env.example](.env.example) and fill it in. The essentials:
| `AUTH_TYPE` | `ckan` | Auth provider: `ckan` · `jwt` · `anonymous` |
| `CKAN_URL` | — | CKAN base URL (required when `AUTH_TYPE=ckan`) |
| `BIGQUERY_PROJECT` / `BIGQUERY_DATASET` | — | Required when `DATASTORE_ENGINE=bigquery` |
| `BIGQUERY_EXPORT_BUCKET` | — | GCS bucket for downloads (`/datastore/dump/{resource_id}`, `/datastore/dump/query`) |
| `REDIS_URL` | — | Cache backend; empty → in-process cache |

**Note on the export bucket:** everything the service writes lives under
a single `dumps/` prefix — `dumps/<resource_id>/…` for whole-table downloads
and `dumps/<query-hash>/…` for SQL downloads — so one lifecycle rule covers
everything. Configure a ~24h rule on
`BIGQUERY_EXPORT_BUCKET`: the engine garbage-collects superseded export
revisions itself, but abandoned query-download prefixes (queries never
re-issued) and files stranded by a crashed export are only ever cleaned by
the lifecycle rule.

## Documentation

- **[API.md](API.md)** — full API reference (endpoints, request/response, examples).
Expand Down
3 changes: 2 additions & 1 deletion datastore/api/endpoints/datastore.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,8 @@ async def datastore_search_sql(
params: Annotated[DatastoreSearchSQLRequest, Query()],
):
"""`GET /api/3/datastore_search_sql` — execute a raw SQL SELECT and stream.
Accepts a single `sql` query parameter;
Accepts a single `sql` query parameter. To export the result as a
file instead, use `GET /datastore/dump/query`.
"""
for resource_id in params.resource_ids:
await context.authorize(resource_id=resource_id, permission="read")
Expand Down
163 changes: 102 additions & 61 deletions datastore/api/endpoints/dump.py
Original file line number Diff line number Diff line change
@@ -1,94 +1,135 @@
"""`GET /datastore/dump/{resource_id}` — single download for a table.
"""Download endpoints: `/datastore/dump/{resource_id}` + `/datastore/dump/query`.

Behaviour by shard count (decided by BigQuery from the export size):

- **1 shard** (≤ 1 GB, including Parquet): 302 redirect to the
GCS signed URL. Zero server bandwidth — bytes go GCS → client.
- **N shards** (>1 GB CSV/NDJSON): `StreamingResponse` over
`services.dump.stream_*_shards`, which pulls each shard from GCS
via async httpx and byte-forwards (CSV header-dedup; NDJSON pure
concat). Memory ≈ one chunk in flight; no threadpool consumption.

Multi-shard Parquet is refused with 413 (parquet shards can't be
byte-concatenated). Caller picks CSV/NDJSON.
csv / gzip / ndjson shards are composed into one GCS object, so those
always redirect — the server never touches the bytes. Parquet can't be
composed (footer + magic bytes), so a parquet export that shards is
zipped on the way out: the API fetches the parts and streams one
archive, which keeps every download a single file at one URL.
"""

from __future__ import annotations

from typing import Annotated, Literal
from typing import Annotated

from fastapi import APIRouter, Query
from starlette.requests import Request
from starlette.responses import RedirectResponse, StreamingResponse

from datastore.api.context import Context
from datastore.api.responses import ERROR_RESPONSES
from datastore.core.constants import DUMP_EXTENSIONS, DumpFormat
from datastore.core.exceptions import ServerError
from datastore.infrastructure.engines import get_datastore_engine
from datastore.services.dump import (
stream_csv_shards,
stream_gzip_csv_shards,
stream_ndjson_shards,
)
from datastore.schemas.request import DatastoreDumpSQLRequest
from datastore.services.read import dump_sql_datastore
from datastore.services.streaming import zip_archive_writer

DumpFormat = Literal["csv", "gzip", "ndjson", "parquet"]
router = APIRouter(tags=["Datastore Download"], responses=ERROR_RESPONSES)

_MEDIA_TYPE: dict[str, str] = {
"csv": "text/csv",
"gzip": "application/gzip",
"ndjson": "application/x-ndjson",
"parquet": "application/vnd.apache.parquet",
}

_DOWNLOAD_EXT: dict[str, str] = {
"csv": "csv",
"gzip": "csv.gz",
"ndjson": "ndjson",
"parquet": "parquet",
}
def download_response(
request: Request,
urls: list[str],
fmt: DumpFormat,
filename_base: str,
) -> RedirectResponse | StreamingResponse:
"""Shape the engine's signed URL(s) into a response:

- one file → 302 to the signed URL; bytes go GCS → client and
the download is resumable
- many files → 200 streaming one zip of the parts (parquet only —
every other format composes to a single object)
- none → 500; the engine isn't configured

The zip is the only path where the server carries the bytes, and it
exists so a caller always gets one file from one URL. `Content-
Length` is unknowable mid-stream, so the response is chunked: no
progress bar, and no resuming a dropped connection.
"""
if not urls:
raise ServerError(
"export produced no downloadable files "
"(datastore engine is not configured)"
)
if len(urls) == 1:
return RedirectResponse(url=urls[0], status_code=302)

router = APIRouter(tags=["Datastore Download"], responses=ERROR_RESPONSES)
ext = DUMP_EXTENSIONS[fmt]
members = [
(f"{filename_base}_{i + 1:02d}.{ext}", url)
for i, url in enumerate(urls)
]
return StreamingResponse(
zip_archive_writer(request.app.state.http, members),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should probably remove this for the dumps query, they are either redirects or in the one case they are not (parquet) they are already zipped, could make things faster

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@luccasmmg I didn't quite understand you comment. this streaming response only needed for Parquet files when the export is split into more than one sharded Parquet file?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nevermind

media_type="application/zip",
headers={
"Content-Disposition": (
f'attachment; filename="{filename_base}.zip"'
),
},
)


@router.get(
"/datastore/dump/query",
summary="Download the result of a SQL SELECT (CSV / gzip CSV / NDJSON / Parquet)",
responses={
302: {"description": "Redirect to the signed download URL."},
200: {
"description": (
"Sharded parquet export — one streamed zip of the parts."
),
"content": {"application/zip": {}},
},
},
)
async def dump_sql(
request: Request,
context: Context,
params: Annotated[DatastoreDumpSQLRequest, Query()],
):
"""Download a vetted SQL SELECT's result as one file (no envelope).

Same validation as `datastore_search_sql`; `LIMIT` optional and
uncapped. Declared before `/{resource_id}` → `query` is reserved.
"""
for resource_id in params.resource_ids:
await context.authorize(resource_id=resource_id, permission="read")

urls = await dump_sql_datastore(
context,
{
"sql": params.sql,
"fmt": params.format,
"resource_ids": params.resource_ids,
"function_names": params.function_names,
},
)
return download_response(request, urls, params.format, "query")


@router.get(
"/datastore/dump/{resource_id}",
summary="Download an entire table (CSV / gzip CSV / NDJSON / Parquet)",
responses={
302: {"description": "Single-shard export — redirect to a signed GCS URL."},
200: {"description": "Multi-shard export — streamed CSV / NDJSON body."},
302: {"description": "Redirect to the signed Download URL."},
200: {
"description": (
"Multi-file parquet export — one streamed zip."
),
"content": {"application/zip": {}},
},
},
)
async def dump(
request: Request,
context: Context,
resource_id: str,
fmt: Annotated[DumpFormat, Query(alias="format")] = "csv",
):
"""Download an entire resource as `csv` (default), `gzip`, `ndjson`, or `parquet`.

Small exports redirect (302) straight to a signed GCS URL; large ones
stream a concatenated body. Select the format with `?format=`.
"""
"""Download an entire resource; pick the format with `?format=`."""
await context.authorize(resource_id=resource_id, permission="read")
engine = get_datastore_engine(context, mode="ro")

urls = await engine.dump(resource_id, fmt)

if len(urls) == 1:
return RedirectResponse(url=urls[0], status_code=302)

if fmt == "csv":
body = stream_csv_shards(urls)
elif fmt == "gzip":
body = stream_gzip_csv_shards(urls)
elif fmt == "ndjson":
body = stream_ndjson_shards(urls)
else: # pragma: no cover — the engine rejects multi-shard Parquet
raise RuntimeError(f"unexpected multi-shard format: {fmt}")

return StreamingResponse(
body,
media_type=_MEDIA_TYPE[fmt],
headers={
"Content-Disposition": (
f'attachment; filename="{resource_id}.{_DOWNLOAD_EXT[fmt]}"'
),
},
)
return download_response(request, urls, fmt, resource_id)
18 changes: 18 additions & 0 deletions datastore/core/constants.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,23 @@
from __future__ import annotations

from typing import Literal

# Download formats served by the export pipeline (`/datastore/dump/…` and
# `datastore_search_sql?download=…`). Lives here — not in `api/` — because
# both the request schemas (pydantic layer) and the endpoints (starlette
# layer) need it, and `schemas/` must not import from `api/`.
DUMP_FORMATS: tuple[str, ...] = ("csv", "gzip", "ndjson", "parquet")
DumpFormat = Literal["csv", "gzip", "ndjson", "parquet"]

# File extension per dump format — names the download and, for a
# multi-file export, each member inside the zip.
DUMP_EXTENSIONS: dict[str, str] = {
"csv": "csv",
"gzip": "csv.gz",
"ndjson": "json",
"parquet": "parquet",
}

POSTGRES_TYPES: dict[str, str] = {
# integer
"int2": "int2",
Expand Down
22 changes: 21 additions & 1 deletion datastore/infrastructure/engines/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,27 @@ def info(self, resource_id: str) -> InfoResult:

@abstractmethod
async def dump(self, resource_id: str, fmt: str) -> list[str]:
"""Download a table as CSV/NDJSON/Parquet.
"""Download a table as CSV/NDJSON/Parquet.
"""

@abstractmethod
async def dump_sql(
self,
sql: str,
fmt: str,
*,
resource_ids: list[str],
function_names: list[str],
) -> list[str]:
"""Export the result of a vetted SELECT as `fmt`; return signed
URLs — normally one (engines may merge shards into a single
object), more only for formats that cannot be merged.

`resource_ids` / `function_names` are the names already parsed
(and authorized / allow-listed) by the schema + service layers —
passed in so engines don't re-parse the SQL. Engines may use
`function_names` to decide result cacheability (non-deterministic
functions make a cached export stale by definition).
"""

@abstractmethod
Expand Down
Loading
Loading