-
Notifications
You must be signed in to change notification settings - Fork 53
pgstac-rs: Rust engine for pgstac (search, ingest, dump/export) #458
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
bitner
wants to merge
18
commits into
main
Choose a base branch
from
v010io
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 6 commits
Commits
Show all changes
18 commits
Select commit
Hold shift + click to select a range
f3830b5
feat: v0.10 export/streaming engine + configurable parquet row-groups…
bitner ca5d249
refactor(pgstac-rs): drop profiling artifacts; fix 3D bbox footprint
bitner a56ca19
feat(pgstac-rs): implement stac::api client traits on PgstacPool
bitner fa7cedc
ci: enable the rust-crate test job
bitner 8487acf
feat(pgstac): error on items with a nonexistent collection in SQL ingest
bitner ae4483c
refactor(pgstac-rs): reorganize modules; drop dev artifacts and dead …
bitner 4400bfd
feat(pgstac-rs): add --skip-unchanged precheck to load; id-only for i…
bitner 0b58b2d
fix(pgstac-rs): gate precheck behind `pool`; add feature-matrix CI
bitner ca69b75
docs(pgstac-rs): fix broken rustdoc links + features list; check docs…
bitner 8532eb2
docs(pgstac): correct the jsonb_canonical "RFC 8785" comments
bitner daf5c16
refactor(pgstac-rs): dedup the CLI policy enum; erroring int cast; na…
bitner 77899d9
feat(pgstac): finish indexes_pending — defer queryable indexes to an …
bitner c484710
fix(pgstac-rs): set search_path on cloned test databases
bitner bf48b97
refactor(pgstac-rs): delete the Pgstac trait; one Client surface (#458)
bitner bbe13ff
fix(pgstac-rs): an explicit --dsn overrides ambient PG* env
bitner be02b89
address issues from reviews
bitner 1fc2fbc
fix issue in item hash parity between postgres/rust
bitner 1723348
fix tests
bitner File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,160 @@ | ||
| # Rust client & CLI (`pgstac-rs`) | ||
|
|
||
| `src/pgstac-rs` is a Rust crate (`pgstac`) and a command-line tool (`pgstac`) that talk to a PgSTAC | ||
| database. It provides: | ||
|
|
||
| - a **read engine** that drives `search_plan` / `collection_search_plan` and does the band stepping, | ||
| hydration (EWKB→GeoJSON, fragment merge), keyset token minting, and the STAC `fields` include/exclude | ||
| projection **in Rust** — page-equivalent to SQL `search()` but with flat memory under streaming and | ||
| the CPU moved off the database. When a search's `fields` need no shared fragment, `search_plan` nulls | ||
| `fragment_id` in the projection it returns, so the engine skips the per-row `item_fragments` lookup; | ||
| - a **connection pool** (`PgstacPool`, the `pool` feature) with the async read API; | ||
| - a **dump/export** library + the `pgstac` **CLI** (`export` / `cli` features); | ||
| - a **Python wheel** (`pypgstac_rs`, the `python` feature, built with [maturin](https://github.com/PyO3/maturin)). | ||
|
|
||
| ## Cargo features | ||
|
|
||
| | Feature | Adds | | ||
| | ----------- | ---- | | ||
| | *(default)* | The `Pgstac` trait over any `tokio_postgres::GenericClient`. | | ||
| | `pool` | `PgstacPool`: pooled async read API (rustls TLS, PgBouncer-safe). | | ||
| | `export` | The dump library (scan partitions → stac-geoparquet + manifest). | | ||
| | `cli` | The `pgstac` binary (implies `export`). | | ||
| | `python` | The `pypgstac_rs` pyo3 extension module. | | ||
|
|
||
| ## Environment variables | ||
|
|
||
| The library and CLI resolve a connection from a single DSN if one is given, otherwise from the standard | ||
| **libpq** environment variables. The connection `search_path` is **always** set to `pgstac, public`. | ||
|
|
||
| ### Connection | ||
|
|
||
| | Variable | Purpose | | ||
| | -------- | ------- | | ||
| | `PGSTAC_DSN` | Full Postgres connection string (URL or key/value). The CLI `--dsn` flag defaults to this. | | ||
| | `DATABASE_URL` | Fallback DSN if `PGSTAC_DSN` is unset. | | ||
| | `PGHOST` | Server host. | | ||
| | `PGPORT` | Server port. | | ||
| | `PGDATABASE` | Database name. | | ||
| | `PGUSER` | Username. | | ||
| | `PGPASSWORD` | Password. | | ||
| | `PGOPTIONS` | Extra startup options. | | ||
| | `PGAPPNAME` | `application_name` for the connection. | | ||
| | `PGCONNECT_TIMEOUT` | Connection timeout, in seconds. | | ||
| | `PGSSLMODE` | TLS mode (`disable`, `prefer`, `require`, `verify-ca`, `verify-full`). | | ||
| | `PGSSLROOTCERT` | Path to the CA certificate. | | ||
| | `PGSSLCERT` | Path to the client certificate. | | ||
| | `PGSSLKEY` | Path to the client key. | | ||
|
|
||
| A DSN (`PGSTAC_DSN` / `--dsn`) takes precedence; the individual `PG*` variables fill in any field the | ||
| DSN does not set. | ||
|
|
||
| ### Test fixtures | ||
|
|
||
| The integration tests skip (rather than fail) when their database is unreachable. Point them at your | ||
| fixtures with: | ||
|
|
||
| | Variable | Default | Used by | | ||
| | -------- | ------- | ------- | | ||
| | `PGSTAC_RS_TEST_DB` | `postgresql://username:password@localhost:5439/postgis` | clean-template tests | | ||
| | `PGSTAC_RS_TEST_RICH_DB` | `…/pgstac_rs_test_rich` | search/stream/hydration/collection parity tests | | ||
| | `PGSTAC_RS_TEST_TEMPLATE` | `pgstac_rs_test_template` | pool clone-from-template tests | | ||
| | `PGSTAC_PARITY_DB_010` | `…/a_parity010` | 0.10 dump end-to-end tests | | ||
| | `PGSTAC_PARITY_DB_0911` | `…/a_parity0911` | 0.9.11 dump end-to-end tests | | ||
|
|
||
| ## Library | ||
|
|
||
| ```rust,no_run | ||
| use pgstac::{ConnectConfig, PgstacPool}; | ||
| use futures::StreamExt; | ||
| use serde_json::json; | ||
|
|
||
| # tokio_test::block_on(async { | ||
| // Pool from the environment (PGSTAC_DSN or the PG* vars). Create once at startup. | ||
| let pool = PgstacPool::connect(ConnectConfig::from_env()).await.unwrap(); | ||
|
|
||
| // A bounded page, with keyset next/prev tokens. | ||
| let page = pool.search_page(&json!({"collections": ["landsat-c2-l2"]}), None, 10).await.unwrap(); | ||
|
|
||
| // Stream every match with flat memory. | ||
| let mut items = Box::pin(pool.search_items(json!({"collections": ["landsat-c2-l2"]}), None, None)); | ||
| while let Some(item) = items.next().await { let _ = item.unwrap(); } | ||
| # }) | ||
| ``` | ||
|
|
||
| Pool methods: `search_page`, `search_items` (stream), `search_collect_items`, `stream_ndjson`, | ||
| `search_matched`, `collection_search`, `get_item`, `get_collection`, `get_queryables`. | ||
|
|
||
| ## CLI | ||
|
|
||
| ```text | ||
| pgstac <COMMAND> | ||
| dump Dump a pgstac instance (or a subset) to a directory, tar, S3, or stdout | ||
| search Stream a search/CQL2 result as NDJSON / ItemCollection / geoparquet (0.10 only) | ||
| ``` | ||
|
|
||
| Both subcommands read `--dsn` from `$PGSTAC_DSN` when the flag is omitted. | ||
|
|
||
| ### `pgstac dump` | ||
|
|
||
| Writes fully-hydrated items as stac-geoparquet (one file per partition, ordered by `datetime, id`) plus | ||
| `collection.json` / `queryables.json` / `settings.json` and a sha256'd `manifest.json` (written last — | ||
| its presence marks a complete dump). | ||
|
|
||
| | Option | Default | Description | | ||
| | ------ | ------- | ----------- | | ||
| | `--dsn <DSN>` | `$PGSTAC_DSN` / local dev | Postgres connection string. | | ||
| | `-o, --out <OUT>` | *(required)* | Destination: a directory, `s3://bucket/prefix` (or other object-store URL), a `*.tar` / `*.tar.zst` file, or `-` for stdout. | | ||
| | `-c, --collection <ID>` | *(all)* | Restrict to these collection ids (repeatable). | | ||
| | `--datetime-start <RFC3339>` | — | Inclusive datetime prefilter start (requires `--datetime-end`). | | ||
| | `--datetime-end <RFC3339>` | — | Exclusive datetime prefilter end (requires `--datetime-start`). | | ||
| | `--bbox <W> <S> <E> <N>` | — | Bbox prefilter (EPSG:4326). | | ||
| | `--compression <CODEC>` | `zstd` | Parquet codec: `zstd`, `snappy`, `uncompressed`. | | ||
| | `--skip-errors` | off | Continue past per-item errors, recording skips in the report. | | ||
| | `--memory-budget <BYTES>` | ~25% RAM | Memory budget for the buffered geoparquet path. | | ||
| | `--concurrency <N>` | `1` | Max partitions dumped concurrently (>1 opens one extra connection per worker). | | ||
| | `--consistent` | off | Dump the whole instance under one repeatable-read snapshot (implies a parallel run). | | ||
| | `--tar-zstd` | off | Write a compressed `.tar.zst` when `--out` is a `.tar` path. | | ||
| | `--dry-run` | off | Report the plan without writing data. | | ||
|
|
||
| ### `pgstac search` | ||
|
|
||
| Streams a 0.10 search off the keyset engine. | ||
|
|
||
| | Option | Default | Description | | ||
| | ------ | ------- | ----------- | | ||
| | `--dsn <DSN>` | `$PGSTAC_DSN` / local dev | Postgres connection string. | | ||
| | `-o, --out <OUT>` | `-` (stdout) | A file path or `-` for stdout. | | ||
| | `--format <FORMAT>` | `ndjson` | `ndjson` (streams all pages), `itemcollection` (one SQL-faithful page with next/prev + context), or `geoparquet`. | | ||
| | `-c, --collection <ID>` | *(all)* | Restrict to these collection ids (repeatable). | | ||
| | `--id <ID>` | — | Restrict to these item ids (repeatable). | | ||
| | `--bbox <W> <S> <E> <N>` | — | Bbox (EPSG:4326). | | ||
| | `--datetime <DT>` | — | Datetime / interval (STAC datetime syntax). | | ||
| | `--filter <CQL2>` | — | CQL2-JSON filter (a JSON object). | | ||
| | `--limit <N>` | server default | Page size (the keyset limit). | | ||
| | `--token <TOKEN>` | — | Continuation token (`next:…` / `prev:…`). With `itemcollection`, returns exactly that one page. | | ||
| | `--max-items <N>` | *(unbounded)* | Cap total items streamed (`ndjson` / `geoparquet`). | | ||
| | `--compression <CODEC>` | `zstd` | Parquet codec (`geoparquet` only). | | ||
|
|
||
| ## Python wheel | ||
|
|
||
| ```sh | ||
| maturin build -m src/pgstac-rs/Cargo.toml # produces the pypgstac_rs wheel | ||
| ``` | ||
|
|
||
| ```python | ||
| import asyncio, orjson, pypgstac_rs | ||
|
|
||
| async def main(): | ||
| pool = await pypgstac_rs.Pgstac.connect() # libpq env, or pass a dsn string | ||
| body = orjson.dumps({"collections": ["landsat-c2-l2"], "limit": 10}).decode() | ||
| fc = orjson.loads(await pool.search(body)) | ||
| print(fc["numberReturned"]) | ||
|
|
||
| asyncio.run(main()) | ||
| ``` | ||
|
|
||
| `Pgstac` methods (each `async`, returning a JSON string): `connect(dsn=None)`, `search(search, | ||
| token=None, limit=10)`, `search_collect(search, max_items=None)`, `search_matched(search)`, | ||
| `collection_search(search, token=None)`, `get_item(collection_id, item_id)`, | ||
| `get_collection(collection_id)`, `get_queryables(collection_id=None)`. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| #!/bin/bash | ||
| # Build the clean, empty pgstac install used as the clone template by the pgstac-rs | ||
| # (`src/pgstac-rs`) Rust tests. Each Rust test clones a fresh database from this template, so it | ||
| # must be a pristine install (zero collections/items). Run this once against a running dev database | ||
| # (see `scripts/server`); re-run after schema changes to refresh the template. | ||
| # | ||
| # Usage: scripts/pgstac-rs-test-db [TEMPLATE_NAME] | ||
| # TEMPLATE_NAME defaults to pgstac_rs_test_template (matches PGSTAC_RS_TEST_TEMPLATE in the tests). | ||
| SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) | ||
| source "$SCRIPT_DIR/pgstacenv" # cd to repo root, ensure .env | ||
|
|
||
| # Connection defaults (overridable via env / .env) | ||
| [[ -f .env ]] && set -a && source .env && set +a | ||
| PGHOST=${PGHOST:-localhost} | ||
| PGPORT=${PGPORT:-5439} | ||
| PGUSER=${PGUSER:-username} | ||
| export PGPASSWORD=${PGPASSWORD:-password} | ||
|
|
||
| TEMPLATE=${1:-${PGSTAC_RS_TEST_TEMPLATE:-pgstac_rs_test_template}} | ||
| PGSTAC_SQL="src/pgstac/pgstac.sql" | ||
|
|
||
| if [[ ! -f "$PGSTAC_SQL" ]]; then | ||
| echo "ERROR: $PGSTAC_SQL not found (run from a repo checkout)." >&2 | ||
| exit 1 | ||
| fi | ||
|
|
||
| echo "Building clean pgstac template '$TEMPLATE' on $PGHOST:$PGPORT ..." | ||
|
|
||
| # (Re)create the template database from a maintenance connection (postgres/postgis), then load the | ||
| # assembled pgstac.sql into it. Terminating sessions first lets a re-run drop an in-use template. | ||
| psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d postgres -v ON_ERROR_STOP=1 -q <<SQL | ||
| SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '$TEMPLATE'; | ||
| DROP DATABASE IF EXISTS $TEMPLATE; | ||
| CREATE DATABASE $TEMPLATE; | ||
| ALTER DATABASE $TEMPLATE SET search_path TO pgstac, public; | ||
| ALTER DATABASE $TEMPLATE SET client_min_messages TO WARNING; | ||
| SQL | ||
|
|
||
| psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$TEMPLATE" -v ON_ERROR_STOP=1 -q -f "$PGSTAC_SQL" | ||
|
|
||
| # Verify a pristine install. | ||
| psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$TEMPLATE" -tAc \ | ||
| "SET search_path=pgstac,public; SELECT format('pgstac %s ready: %s collections, %s items', get_version(), (SELECT count(*) FROM collections), (SELECT count(*) FROM items));" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,75 @@ | ||
| #!/bin/bash | ||
| # Build the RICH pgstac test template used by the pgstac-rs hydration/search tests: real | ||
| # landsat-c2-l2 / sentinel-2-l2a / naip collections (many assets + properties) with `fragment_config` | ||
| # set, plus 1000 items each dehydrated into `item_fragments`. This exercises the fragment-hydration | ||
| # path, which is one of the largest bottlenecks. Re-run after schema changes. | ||
| # | ||
| # Usage: scripts/pgstac-rs-test-rich-db [TEMPLATE_NAME] (default pgstac_rs_test_rich) | ||
| SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) | ||
| source "$SCRIPT_DIR/pgstacenv" # cd to repo root, ensure .env | ||
|
|
||
| [[ -f .env ]] && set -a && source .env && set +a | ||
| PGHOST=${PGHOST:-localhost} | ||
| PGPORT=${PGPORT:-5439} | ||
| PGUSER=${PGUSER:-username} | ||
| export PGPASSWORD=${PGPASSWORD:-password} | ||
|
|
||
| TEMPLATE=${1:-${PGSTAC_RS_TEST_RICH_TEMPLATE:-pgstac_rs_test_rich}} | ||
| PGSTAC_SQL="src/pgstac/pgstac.sql" | ||
| FIXROOT="src/pgstac/tests/testdata/planetary-computer" | ||
| FIXTURES=(landsat-c2-l2 sentinel-2-l2a naip) | ||
|
|
||
| for required in "$PGSTAC_SQL" "$FIXROOT/collections.ndjson"; do | ||
| [[ -f "$required" ]] || { echo "ERROR: $required not found (run from a repo checkout)." >&2; exit 1; } | ||
| done | ||
|
|
||
| psql() { command psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" "$@"; } | ||
|
|
||
| echo "Building rich pgstac template '$TEMPLATE' on $PGHOST:$PGPORT ..." | ||
|
|
||
| psql -d postgres -v ON_ERROR_STOP=1 -q <<SQL | ||
| SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '$TEMPLATE'; | ||
| DROP DATABASE IF EXISTS $TEMPLATE; | ||
| CREATE DATABASE $TEMPLATE; | ||
| ALTER DATABASE $TEMPLATE SET search_path TO pgstac, public; | ||
| ALTER DATABASE $TEMPLATE SET client_min_messages TO WARNING; | ||
| SQL | ||
|
|
||
| psql -d "$TEMPLATE" -v ON_ERROR_STOP=1 -q -f "$PGSTAC_SQL" | ||
|
|
||
| # Load raw NDJSON into jsonb columns via CSV format with control-char quote/delimiter (chosen so they | ||
| # never appear in the data): this reads each line verbatim as one field, avoiding COPY text-format | ||
| # backslash de-escaping that would corrupt JSON escapes like \n. | ||
| JSON_COPY="WITH (FORMAT csv, DELIMITER E'\\x02', QUOTE E'\\x01')" | ||
|
|
||
| # Load collections, then BEFORE loading items: derive fragment_config from each collection's | ||
| # item_assets (so items dehydrate into shared fragments) and set partition_trunc=month (so items land | ||
| # in datetime sub-partitions and the search() band engine has a real histogram — matching how the | ||
| # synth/landsat test collections are configured). | ||
| psql -d "$TEMPLATE" -v ON_ERROR_STOP=1 -q <<SQL | ||
| \copy collections (content) FROM '$FIXROOT/collections.ndjson' $JSON_COPY | ||
| UPDATE collections | ||
| SET fragment_config = collection_fragment_config_default(content), | ||
| partition_trunc = 'month' | ||
| WHERE fragment_config IS NULL; | ||
| SQL | ||
|
|
||
| for fixture in "${FIXTURES[@]}"; do | ||
| items="$FIXROOT/data/$fixture/items.ndjson" | ||
| [[ -f "$items" ]] || { echo "ERROR: $items not found." >&2; exit 1; } | ||
| psql -d "$TEMPLATE" -v ON_ERROR_STOP=1 -q \ | ||
| -c "\\copy items_staging (content) FROM '$items' $JSON_COPY" | ||
| done | ||
|
|
||
| # The bulk items_staging path does not populate partition_stats; analyze_items() fills the per-month | ||
| # histogram so the search() band engine and partition_bounds work. (analyze_items is a procedure that | ||
| # commits internally, so it must run on its own outside a transaction block.) | ||
| psql -d "$TEMPLATE" -v ON_ERROR_STOP=1 -q -c "CALL analyze_items();" | ||
|
|
||
| psql -d "$TEMPLATE" -tAc "SET search_path=pgstac,public; | ||
| SELECT format( | ||
| 'rich template ready: %s collections (%s with fragment_config), %s items, %s fragments', | ||
| (SELECT count(*) FROM collections), | ||
| (SELECT count(*) FROM collections WHERE fragment_config IS NOT NULL), | ||
| (SELECT count(*) FROM items), | ||
| (SELECT count(*) FROM item_fragments));" |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.