Skip to content

Add SQL download endpoint; compose downloads to a single file, zip sharded parquet - #14

Merged
luccasmmg merged 5 commits into
mainfrom
experiment/compose-single-url
Jul 29, 2026
Merged

Add SQL download endpoint; compose downloads to a single file, zip sharded parquet#14
luccasmmg merged 5 commits into
mainfrom
experiment/compose-single-url

Conversation

@sagargg

@sagargg sagargg commented Jul 27, 2026

Copy link
Copy Markdown
Member

Issue: #15

What changed

  • Added GET /datastore/dump/query — download a filtered result set by SQL. Same validation and per-table auth as datastore_search_sql, but LIMIT is optional and uncapped.

  • Cached until a referenced table changes — first request exports + composes in single file then later requests are a signed redirect with no BigQuery job. Cache key includes every referenced table's modified, so any table change invalidates it. Non-deterministic SQL (now(), …) always re-exports.

  • Downloads are a GCS redirect (302)EXPORT DATA shards are composed server-side into one object for ndjson / csv / gzip csv, so bytes go storage → client and the API stays out of the byte path.

  • csv / gzip get one header row — shards export header-less and a synthesized header is composed in front.

  • Parquet: single file when the export doesn't shard; zip when it does. Parquet shards can't be composed (footer + magic bytes), and EXPORT DATA gives no control over file count — it shards by write parallelism, not size, so a ~40 MB result can land as 7 files. When sharded, the API fetches the parts and streams one zip (stored, not deflated — parquet is already compressed). No way found to merge parquet shards without reading and rewriting the data.

  • Moved the download pipeline out of backend.py / services/dump.py into infrastructure/engines/bigquery/export.py.

  • Background cleanup — compose sources and stale revisions are deleted off the response path; unusable cache entries self-heal by rebuilding.

Overview of download flow

flowchart TD
    Req["GET /datastore/dump/{rid}<br/>GET /datastore/dump/query?sql=…"] --> Cache{"cached for this<br/>table version?"}

    Cache -- hit --> Sign["sign V4 URL(s)"]
    Cache -- miss --> Export["EXPORT DATA → shards in GCS<br/>(uri must be a wildcard)"]

    Export --> Fmt{"format"}
    Fmt -- "csv · gzip · ndjson" --> Compose["GCS compose → ONE object<br/>(+ synthesized header for csv/gzip)"]
    Fmt -- "parquet" --> Keep["shards stay as-is<br/>(can't be composed)"]

    Compose --> Sign
    Keep --> Sign
    Compose -.-> GC["background: delete sources<br/>+ sweep old revisions"]

    Sign --> Count{"how many files?"}
    Count -- "1" --> Redirect["302 → signed GCS URL<br/>bytes: GCS → client (resumable)"]
    Count -- "N" --> Zip["200 streamed zip of the parts<br/>bytes: GCS → API → client"]

    classDef out fill:#ecfdf5,stroke:#059669,color:#064e3b
    classDef bg fill:#f5f5f5,stroke:#9ca3af,color:#374151
    class Redirect,Zip out
    class GC bg
Loading

Summary by CodeRabbit

  • New Features

    • Added SQL query exports through a new download endpoint.
    • Added support for CSV, gzip, NDJSON, and Parquet downloads.
    • Multi-part exports are delivered as streamed ZIP archives.
    • Added export caching and clearer file-download behavior, including redirects for single-file exports.
  • Documentation

    • Documented query exports, supported formats, configuration, caching, and export lifecycle requirements.
    • Clarified SQL LIMIT rules and file-export usage.

sagargg added 3 commits July 25, 2026 10:10
GET /datastore/dump/sql?sql=<SELECT>&format=csv|gzip|ndjson|parquet exports
the result of a vetted read-only SELECT as a file through the BigQuery
EXPORT DATA -> GCS pipeline, reusing the /datastore/dump/{resource_id}
response shaping (302 single shard / stream-concat multi-shard / 413
multi-shard parquet). The CKAN action API (datastore_search_sql) stays
pure JSON; sql is a reserved resource name on the dump route.

- DatastoreDumpSQLRequest (subclasses the search-SQL request): LIMIT is
  optional and uncapped in download mode; OFFSET-without-LIMIT rejected.
  parse_sql_pagination gains a require_limit flag.
- Engine dump_sql: GCS cache keyed on sql_dumps/<qhash>/<fmt>/<rev>
  (qhash = sha256 of qualified SQL, rev = sha256 over every referenced
  table's modified). Non-deterministic SQL bypasses the cache (fresh uuid
  rev). RO dry-run preflight yields the output schema + clean 400s; the
  user ORDER BY is preserved across shards; superseded revisions are GC'd
  immediately (age-gated only for the non-cacheable path).
- Refactor dump() + dump_sql() over one shared _cached_export workflow
  plus helpers (_get_table, _run_export_job, _gc_stale_blobs, _sign_blobs).
- dump_sql_datastore service shares the SQL function allow-list gate with
  search_sql_datastore.
- Shared DumpFormat in core/constants.
- Engine/endpoint/service tests and docs (API.md, CLAUDE.md, README).
…dump/query

Download logic (cache -> export -> compose -> sign) moves out of
backend.py and services/dump.py into a dedicated engine module.
EXPORT DATA requires a wildcard URI and shards by write parallelism,
so parquet can come back as several files that can't be composed. The
endpoint now fetches the parts and streams one zip (stored, not
deflated) instead of returning a URL list; single-file exports still
302 to the signed URL.
@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds GET /datastore/dump/query for SQL result downloads, optional SQL LIMIT handling, centralized redirect/ZIP responses, and a BigQuery cache, compose, cleanup, and signed-URL export pipeline.

Changes

Datastore export flow

Layer / File(s) Summary
SQL export contracts and request flow
datastore/core/constants.py, datastore/schemas/*, datastore/services/read.py, datastore/api/endpoints/dump.py
Adds SQL dump request validation, optional LIMIT, format selection, function authorization, and backend export wiring.
BigQuery cache, export, and composition pipeline
datastore/infrastructure/engines/*, README.md, CLAUDE.md
Moves export logic into a shared BigQuery pipeline with cache revisions, dry runs, shard composition, cleanup, format-specific SQL, and signed URLs.
Redirect and streamed ZIP responses
datastore/api/endpoints/dump.py, datastore/services/streaming.py
Returns redirects for single files and streams multi-part exports as stored ZIP archives, including sharded Parquet.
Validation and export coverage
tests/test_datastore_dump*.py, tests/test_datastore_search_sql.py, tests/test_read_service.py
Adds coverage for SQL validation, authorization, caching, composition, ordering, error mapping, redirects, and ZIP downloads.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant DumpEndpoint
  participant ReadService
  participant BigQueryExport
  participant GCS
  Client->>DumpEndpoint: GET /datastore/dump/query
  DumpEndpoint->>ReadService: validate and authorize SQL references
  ReadService->>BigQueryExport: export vetted SQL
  BigQueryExport->>GCS: read cache or export and compose objects
  GCS-->>BigQueryExport: return signed URLs
  BigQueryExport-->>DumpEndpoint: return URLs
  DumpEndpoint-->>Client: redirect or streamed ZIP response
Loading

Possibly related PRs

  • datopian/datastore#1: Earlier implementation of the BigQuery-backed resource dump flow refactored by this change.
  • datopian/datastore#2: Previous multi-shard dump response behavior replaced by the shared redirect and ZIP pipeline.
  • datopian/datastore#13: Earlier Parquet export handling superseded by the new multi-shard ZIP behavior.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 69.86% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly captures the main changes: a new SQL download endpoint, single-file composition, and zipped sharded Parquet downloads.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch experiment/compose-single-url

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🧹 Nitpick comments (3)
tests/test_datastore_dump_sql.py (1)

1-15: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚖️ Poor tradeoff

Engine-level dump_sql tests could live under tests/engines/bigquery/.

Lines 108-730 are BigQuery-engine tests (mocked client.query, list_blobs, _delete_old_cache); lines 733-970 are endpoint tests. CLAUDE.md's tree reserves tests/engines/bigquery/test_*.py for the former. Splitting is optional, but worth considering before this file grows further.

As per coding guidelines: "auth and engine tests should mirror the corresponding production directory structure".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_datastore_dump_sql.py` around lines 1 - 15, Split
tests/test_datastore_dump_sql.py according to test scope: move the BigQuery
engine tests, including mocked client.query, list_blobs, and _delete_old_cache
cases, into tests/engines/bigquery/test_*.py, while keeping the endpoint tests
in their current test module. Preserve shared fixtures and all existing test
behavior.

Source: Coding guidelines

datastore/infrastructure/engines/bigquery/export.py (2)

265-267: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Build the rw BigQuery client lazily.

_build_bq_client("rw") loads credentials and constructs a bigquery.Client synchronously on the request path for every call, including pure cache hits where it's never used — the path the docstring advertises as "no BigQuery". Only _run_export_job needs it.

♻️ Suggested change
-    rw_bq = backend._build_bq_client("rw")
     ro_gcs = backend._build_storage_client("ro").bucket(bucket)
     rw_gcs = backend._build_storage_client("rw").bucket(bucket)

and inside the miss branch:

         export_sql, header_bytes = await build_export_sql()
-        await _run_export_job(rw_bq, export_sql, what=what, fmt=fmt)
+        rw_bq = backend._build_bq_client("rw")
+        await _run_export_job(rw_bq, export_sql, what=what, fmt=fmt)

Note tests/test_datastore_dump.py::_engine_with_storage stubs _build_bq_client unconditionally, so this stays test-compatible.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@datastore/infrastructure/engines/bigquery/export.py` around lines 265 - 267,
Move the _build_bq_client("rw") call out of the request setup and into the
cache-miss branch immediately before the _run_export_job invocation. Keep
storage clients initialized as currently, and ensure cache-hit paths never
construct the BigQuery client while preserving the existing test stub
compatibility.

375-390: 🩺 Stability & Availability | 🔵 Trivial

job.result() on the default executor can saturate it under concurrent dumps.

asyncio.to_thread uses the interpreter's default ThreadPoolExecutor (min(32, cpu+4) workers), shared with every other to_thread call in the app (signing, listing, deletes). An EXPORT DATA can hold a worker for minutes, so a handful of concurrent large dumps can stall unrelated offloaded work. Consider a dedicated bounded executor for export waits, or polling job.done() with asyncio.sleep instead of blocking a worker.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@datastore/infrastructure/engines/bigquery/export.py` around lines 375 - 390,
The _run_export_job function blocks the shared default executor while waiting on
job.result(), allowing concurrent exports to starve unrelated to_thread work.
Move the blocking export wait to a dedicated bounded executor, or replace it
with nonblocking job.done() polling and asyncio.sleep; keep the existing query
submission and completion/error behavior intact.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@CLAUDE.md`:
- Line 489: Update the SQL download description in CLAUDE.md to match
download_response: state that composed outputs, including gzip, are returned via
a 302 redirect, and multi-file parquet is streamed as a zip archive. Remove the
incorrect claim about streamed gzip and JSON URL lists, aligning the wording
with §5.3 step 7 and the table entry.

In `@datastore/api/endpoints/dump.py`:
- Around line 124-135: Update the dump endpoint to delegate resource dumping
through a new dump_datastore service function, mirroring dump_sql_datastore in
the read service. Move get_datastore_engine and engine.dump handling into
dump_datastore, while keeping authorization, request parsing, and
download_response construction in dump.

In `@datastore/infrastructure/engines/bigquery/export.py`:
- Around line 538-556: The /datastore/dump/{resource_id} flow must validate
resource_id before _signed_urls uses it in the Content-Disposition filename.
Reject non-ASCII characters and quotes, carriage returns, or line feeds with the
route’s existing invalid-input response, while preserving signing for valid
identifiers.
- Around line 99-137: Update the dump flow around the _prepare_download call to
set cacheable based on whether table.modified is present, matching dump_sql’s
handling: disable caching when table.modified is None and retain caching for
modified tables. Keep the existing revision and export behavior unchanged.
- Around line 291-293: Remove the `or rw_blobs` fallback in the re-list flow
around `_serve_with_cached`: only assign `blobs` to the validated servable
result. If validation returns no blobs, route through the existing
export/rebuild branch rather than signing raw `rw_blobs`, restructuring the
surrounding `if blobs` flow as needed.

---

Nitpick comments:
In `@datastore/infrastructure/engines/bigquery/export.py`:
- Around line 265-267: Move the _build_bq_client("rw") call out of the request
setup and into the cache-miss branch immediately before the _run_export_job
invocation. Keep storage clients initialized as currently, and ensure cache-hit
paths never construct the BigQuery client while preserving the existing test
stub compatibility.
- Around line 375-390: The _run_export_job function blocks the shared default
executor while waiting on job.result(), allowing concurrent exports to starve
unrelated to_thread work. Move the blocking export wait to a dedicated bounded
executor, or replace it with nonblocking job.done() polling and asyncio.sleep;
keep the existing query submission and completion/error behavior intact.

In `@tests/test_datastore_dump_sql.py`:
- Around line 1-15: Split tests/test_datastore_dump_sql.py according to test
scope: move the BigQuery engine tests, including mocked client.query,
list_blobs, and _delete_old_cache cases, into tests/engines/bigquery/test_*.py,
while keeping the endpoint tests in their current test module. Preserve shared
fixtures and all existing test behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: cf5e61c4-b1c3-434c-bc34-2cf9c8fdb453

📥 Commits

Reviewing files that changed from the base of the PR and between daf5825 and 641c543.

📒 Files selected for processing (18)
  • API.md
  • CLAUDE.md
  • README.md
  • datastore/api/endpoints/datastore.py
  • datastore/api/endpoints/dump.py
  • datastore/core/constants.py
  • datastore/infrastructure/engines/base.py
  • datastore/infrastructure/engines/bigquery/backend.py
  • datastore/infrastructure/engines/bigquery/export.py
  • datastore/schemas/request.py
  • datastore/schemas/validators.py
  • datastore/services/dump.py
  • datastore/services/read.py
  • datastore/services/streaming.py
  • tests/test_datastore_dump.py
  • tests/test_datastore_dump_sql.py
  • tests/test_datastore_search_sql.py
  • tests/test_read_service.py
💤 Files with no reviewable changes (1)
  • datastore/services/dump.py

@sagargg
sagargg force-pushed the experiment/compose-single-url branch from d11c71d to ae56c33 Compare July 27, 2026 10:34

@luccasmmg luccasmmg left a comment

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.

Feel free to ignore this

rw_gcs = backend._build_storage_client("rw").bucket(bucket)

# 1. cache lookup — and self-heal an unusable entry.
blobs = await _list_files_sorted(ro_gcs, prefix) if cacheable else []

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.

If individual A calls

select * from x

And then individual B calls

select * from x

Because of this line, the request made by individual B, will clear the cache, so by the time that individual A receives the GCS url, the files will be either incomplete, or invalid (Might end up mixed with individual B request)

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 this is fixed now so nothing deletes another request's work any more.
A and B each export into their own attempt directory, so their EXPORT DATA
jobs can't touch each other's files, and an entry that doesn't validate is
just skipped instead of wiped.

The cleanup sweep is also age-gated now — it used to delete superseded
revisions immediately, which is the same bug from the other side: a client
who started downloading a minute ago still holds a signed URL pointing at
those objects. Nothing is deleted until it's older than the URL expiry
(BIGQUERY_EXPORT_URL_EXPIRY_HOURS, default 1h).

if fmt == "ndjson" and len(blobs) == 1:
return blobs
return None
return blobs

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.

If individual A calls

select * from x

And then individual B calls

select * from x

I think if Individual B requests with format=parquet, due to the fact that we dont check the composed section, he will receive incomplete data?

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.

An attempt is now only servable once it has a _SUCCESS marker, and that marker is written after the export and the compose. So a listing taken mid-export finds nothing to serve and the reader exports its own attempt
instead of reading half a dataset.

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

Concurrent exports for the same cache key no longer clear each other's
files or serve a half-written one. Also folds sql_dumps/ into dumps/.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
tests/test_datastore_dump_sql.py (1)

292-332: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider driving both min_age branches through dump_sql instead of calling _delete_old_cache directly.

These two tests reach into a private engine helper, while test_gc_spares_young_attempts_and_reaps_old_revisions already covers the age-gated branch end-to-end. Keeping the min_age=None case at the same layer (cacheable dump → sweep) would keep the suite on the public surface and stop these from pinning the helper signature.

As per coding guidelines: "Tests must use public APIs and layer-appropriate interfaces; fixtures must not reach into production internals through back doors."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_datastore_dump_sql.py` around lines 292 - 332, The tests
test_gc_stale_blobs_no_age_gate_deletes_all_non_current and
test_gc_stale_blobs_age_gate_keeps_young call the private _delete_old_cache
helper directly. Rewrite them to exercise the public dump_sql flow, configuring
cacheable and non-cacheable dumps to cover min_age=None and the age-gated
behavior, while preserving the existing assertions about which blobs are deleted
and the current revision being retained.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@tests/test_datastore_dump_sql.py`:
- Around line 292-332: The tests
test_gc_stale_blobs_no_age_gate_deletes_all_non_current and
test_gc_stale_blobs_age_gate_keeps_young call the private _delete_old_cache
helper directly. Rewrite them to exercise the public dump_sql flow, configuring
cacheable and non-cacheable dumps to cover min_age=None and the age-gated
behavior, while preserving the existing assertions about which blobs are deleted
and the current revision being retained.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: d44daac8-d912-4b3b-81ea-bc79ec36e368

📥 Commits

Reviewing files that changed from the base of the PR and between 641c543 and f0e98e5.

📒 Files selected for processing (6)
  • CLAUDE.md
  • README.md
  • datastore/infrastructure/engines/bigquery/export.py
  • tests/test_cors.py
  • tests/test_datastore_dump.py
  • tests/test_datastore_dump_sql.py
🚧 Files skipped from review as they are similar to previous changes (3)
  • README.md
  • datastore/infrastructure/engines/bigquery/export.py
  • tests/test_datastore_dump.py

@datopian datopian deleted a comment from coderabbitai Bot Jul 29, 2026
@datopian datopian deleted a comment from coderabbitai Bot Jul 29, 2026
@datopian datopian deleted a comment from coderabbitai Bot Jul 29, 2026
@datopian datopian deleted a comment from coderabbitai Bot Jul 29, 2026
@datopian datopian deleted a comment from coderabbitai Bot Jul 29, 2026
@luccasmmg
luccasmmg merged commit f734403 into main Jul 29, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants