Skip to content

Commit bf74991

Browse files
authored
Experimental image: raster + zarr DuckDB extensions on a separate tag and endpoint (#358)
* experimental image: raster + zarr extensions on a separate tag and endpoint Adds a second image (Dockerfile.experimental -> :experimental) layered on the default one, carrying the duckdb raster and zarr community extensions, plus a testing-only endpoint at experimental-duckdb-mcp.nrp-nautilus.io. raster gives RT_ReadCells/RT_Read (GDAL) so a small GeoTIFF can be joined to catalog GeoParquet directly, without a hex ingest first; zarr gives read_zarr. Both stay off the default endpoint: raster pulls GDAL into the query path, and RT_ReadCells materializes one row per pixel, which is only safe for small rasters. One codebase serves both tags. server.py LOADs whatever EXTRA_DUCKDB_EXTENSIONS names per connection (unset on the default image, so behaviour is unchanged) and injects experimental-extensions.md into the query tool description only where the extras are actually loaded. Extension names are restricted to identifier characters since LOAD takes no parameters. The guidance carries the two traps found while testing: RT_ReadCells emits GEOMETRY('EPSG:4326') while catalog GeoParquet is GEOMETRY('OGC:CRS84'), so the join needs ST_SetCRS; and unfamiliar rasters need a cols*rows check before read, with the hex asset as the answer for anything large. * fix(experimental): point GDAL at the Debian CA bundle The raster extension vendors a GDAL whose libcurl is built with the RedHat CA path (/etc/pki/tls/certs/ca-bundle.crt), which does not exist on the python:slim Debian base. Every /vsicurl/ read failed with 'CURL error: error setting certificate file' while RT_* functions themselves loaded fine, so the extension looked healthy right up until it touched the network. * fix(experimental): correct the raster area guidance The area advice was wrong in the direction that matters. It told models to assign each pixel an H3 cell and sum h3_cell_area(), which charges every pixel a whole hex's area rather than the pixel's own — inflating the test fire by ~29% (110.5 ha against a true 85.9). h3_cell_area is for hex rows, not raster rows. Replaced with the pixel size from the raster's own geotransform, including the element order, since $.transform is [originX, dx, rotX, originY, rotY, dy] and the pixel size is at indices 1 and 5 — indexing 0 and 4 silently yields zero area. Found by running the documented recipe against the Eureka fire on the live endpoint and reconciling with the published R analysis (87.16 ha).
1 parent c6affb3 commit bf74991

7 files changed

Lines changed: 359 additions & 0 deletions
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
name: Build experimental Docker image
2+
3+
# Builds ghcr.io/boettiger-lab/mcp-data-server:experimental from
4+
# Dockerfile.experimental — the default image plus the `raster` and `zarr`
5+
# community extensions (issue #354).
6+
#
7+
# Deliberately NOT triggered by pushes to main. This tag backs a single testing
8+
# endpoint (experimental-duckdb-mcp) and is meant to move only when someone is
9+
# actually exercising it; an automatic rebuild on every main push would swap the
10+
# image under a live test session.
11+
#
12+
# BASE_TAG selects which default image to layer on. It defaults to :main, so the
13+
# experimental build inherits main's dependency and stock-extension layers while
14+
# `COPY . /app` overlays the code from *this* ref — that is what lets the tag carry
15+
# server changes before they merge. When main's deps move, re-run this workflow.
16+
on:
17+
push:
18+
branches: ['experimental/**']
19+
workflow_dispatch:
20+
inputs:
21+
base_tag:
22+
description: 'Default image tag to layer on (e.g. main, v0.8.12, or a sha)'
23+
required: false
24+
default: 'main'
25+
26+
jobs:
27+
build:
28+
runs-on: ubuntu-latest
29+
permissions:
30+
contents: read
31+
packages: write
32+
steps:
33+
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
34+
35+
- name: Log in to GHCR
36+
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
37+
with:
38+
registry: ghcr.io
39+
username: ${{ github.actor }}
40+
password: ${{ secrets.GITHUB_TOKEN }}
41+
42+
- name: Set up Buildx
43+
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
44+
45+
- name: Build and push
46+
id: build
47+
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
48+
with:
49+
context: .
50+
file: Dockerfile.experimental
51+
push: true
52+
tags: ghcr.io/boettiger-lab/mcp-data-server:experimental
53+
build-args: |
54+
BASE_TAG=${{ github.event.inputs.base_tag || 'main' }}
55+
APP_VERSION=experimental
56+
GIT_SHA=${{ github.sha }}
57+
# Always pull, so a moving BASE_TAG (:main) is actually re-resolved
58+
# rather than served from a stale local base.
59+
pull: true
60+
cache-from: type=gha
61+
cache-to: type=gha,mode=max
62+
63+
- name: Smoke-test image
64+
run: |
65+
image="ghcr.io/boettiger-lab/mcp-data-server@${{ steps.build.outputs.digest }}"
66+
docker pull "$image"
67+
# Stock extensions must still work, and both extras must load and expose
68+
# their entry points — a silently-missing extension would otherwise only
69+
# surface as a confusing "function does not exist" at query time.
70+
docker run --rm "$image" python -c "
71+
import duckdb
72+
c = duckdb.connect()
73+
for e in ('httpfs', 'spatial', 'h3', 'raster', 'zarr'):
74+
c.sql(f'LOAD {e}')
75+
c.sql('SELECT h3_latlng_to_cell(37.8, -122.4, 5)')
76+
c.sql('SELECT ST_Point(0, 0)')
77+
for fn in ('RT_ReadCells', 'read_zarr'):
78+
n = c.sql(f\"SELECT count(*) FROM duckdb_functions() WHERE function_name ILIKE '{fn}'\").fetchone()[0]
79+
assert n, f'{fn} missing'
80+
print('OK: httpfs/spatial/h3 + raster/zarr loaded and functional')
81+
"
82+
83+
- name: Report image digest
84+
run: |
85+
{
86+
echo '### Experimental image digest'
87+
echo '```'
88+
echo "${{ steps.build.outputs.digest }}"
89+
echo '```'
90+
} >> "$GITHUB_STEP_SUMMARY"

Dockerfile.experimental

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# Experimental image: the default server plus the `raster` and `zarr` community
2+
# extensions. Published as :experimental, deployed to experimental-duckdb-mcp
3+
# (k8s/experimental-*.yaml). Never promoted to the default endpoint — issue #354.
4+
#
5+
# Layered on the default image rather than duplicating it, so the Python deps and
6+
# the stock httpfs/spatial/h3 extension layer are shared and cannot drift. CI passes
7+
# BASE_TAG=<sha> so the experimental image is pinned to the exact base build from
8+
# the same run, not to whatever :main happens to be at pull time.
9+
ARG BASE_TAG=main
10+
FROM ghcr.io/boettiger-lab/mcp-data-server:${BASE_TAG}
11+
12+
# Community extensions. These pull GDAL into the query path (raster) and add a
13+
# chunked-array reader (zarr) — the reason this image is kept separate from the
14+
# default one rather than gated behind an env var alone.
15+
RUN python -c "import duckdb; c = duckdb.connect(); c.sql('INSTALL raster FROM community; INSTALL zarr FROM community')"
16+
17+
# Overlay this branch's code on the base image's dependency layers, so the
18+
# experimental tag can carry server changes that have not landed on main yet.
19+
COPY . /app
20+
21+
# Read by server.py: LOADs these per connection and injects
22+
# experimental-extensions.md into the query tool description. Unset on the
23+
# default image, which therefore behaves exactly as before.
24+
ENV EXTRA_DUCKDB_EXTENSIONS=raster,zarr
25+
26+
# The raster extension ships its own GDAL, whose libcurl is compiled with the
27+
# RedHat CA path (/etc/pki/tls/certs/ca-bundle.crt). The base image is Debian, so
28+
# every /vsicurl/ read fails with "CURL error: error setting certificate file"
29+
# until GDAL is pointed at the Debian bundle. Both names are set because which one
30+
# a given GDAL build honours depends on how it was compiled.
31+
ENV GDAL_HTTP_CAINFO=/etc/ssl/certs/ca-certificates.crt \
32+
CURL_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt
33+
34+
ARG APP_VERSION=experimental
35+
ARG GIT_SHA=unknown
36+
ENV APP_VERSION=$APP_VERSION GIT_SHA=$GIT_SHA
37+
38+
CMD ["python", "server.py"]

experimental-extensions.md

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
2+
### 🧪 EXPERIMENTAL EXTENSIONS (this deployment only)
3+
4+
This endpoint loads two community extensions beyond the stock set. They are **not**
5+
available on the default endpoint — do not assume them elsewhere.
6+
7+
#### `raster` — read GeoTIFF/COG directly
8+
9+
`RT_ReadCells(path)` returns **one row per pixel**: `(id, x, y, geometry, col, row, band_1, …)`.
10+
`RT_Read(path)` returns one row per tile with an `RT_DATACUBE` band, for band algebra
11+
(`RT_CubeStats`, `RT_CubeClip`, `RT_CubeBurn`). Paths may be S3/HTTP via GDAL's
12+
`/vsicurl/` or `/vsis3/` prefixes.
13+
14+
**Size guard — `RT_ReadCells` materializes every pixel.** A 92×81 fire-severity
15+
raster is 7,452 rows and returns in seconds; a continental COG is billions and will
16+
exhaust the pod. Before reading an unfamiliar raster, check its dimensions with
17+
`SELECT cols, rows FROM RT_Read(path)` and stop if `cols * rows` exceeds ~10 million.
18+
For anything larger, use the dataset's `…/hex/h0=*/…` asset — every raster collection
19+
in the catalog is already aggregated to H3 by the ingest pipeline, and that path is
20+
partition-pruned and far cheaper. `RT_ReadCells` is for small rasters not yet ingested.
21+
22+
⚠️ **CRS type mismatch.** `RT_ReadCells` emits `GEOMETRY('EPSG:4326')`; catalog
23+
GeoParquet is `GEOMETRY('OGC:CRS84')`. Both hold lon/lat, but DuckDB rejects the join
24+
on the type difference. Re-tag the raster side — no reprojection is involved:
25+
26+
```sql
27+
WITH px AS (
28+
SELECT band_1 AS value, ST_SetCRS(geometry, 'OGC:CRS84') AS g
29+
FROM RT_ReadCells('/vsicurl/https://example.org/severity.tif')
30+
WHERE band_1 > 0 -- drop nodata BEFORE the join
31+
)
32+
SELECT f.name, count(*) AS n_px, round(avg(px.value), 3) AS mean_value
33+
FROM px LEFT JOIN read_parquet('<geoparquet>') f ON f.geom.ST_Contains(px.g)
34+
GROUP BY 1;
35+
```
36+
37+
Filter the raster's nodata sentinel in the CTE (see §7) — it is usually a large
38+
negative value like `-9999` that will wreck any average.
39+
40+
⚠️ **Area: take the pixel size from the raster's own transform.** Do not sum
41+
`h3_cell_area()` over pixel rows — that charges each pixel a whole H3 cell's area,
42+
which is a different quantity and overcounts badly (it inflated this fire by ~29%).
43+
H3 cell area is the right tool for *hex* rows, not raster rows. Do not use a
44+
hardcoded constant either: in a geographic CRS, pixel ground area varies with
45+
latitude.
46+
47+
`RT_Read(path).metadata` is JSON; `$.transform` is a **6-element GDAL geotransform
48+
in the order `[originX, dx, rotX, originY, rotY, dy]`** — the pixel size lives at
49+
indices **1 and 5**, not 0 and 4:
50+
51+
```sql
52+
WITH hdr AS (
53+
SELECT abs(CAST(metadata->'$.transform'->>1 AS DOUBLE)) AS dlon,
54+
abs(CAST(metadata->'$.transform'->>5 AS DOUBLE)) AS dlat
55+
FROM RT_Read('<path>')
56+
), px AS (
57+
SELECT band_1 AS value,
58+
ST_SetCRS(geometry, 'OGC:CRS84') AS g,
59+
h.dlon * 111320.0 * cos(radians(ST_Y(geometry))) -- metres per degree lon
60+
* h.dlat * 110574.0 -- metres per degree lat
61+
AS px_m2
62+
FROM RT_ReadCells('<path>'), hdr h
63+
WHERE band_1 > 0
64+
)
65+
SELECT round(sum(px_m2) / 10000, 2) AS hectares FROM px;
66+
```
67+
68+
Other useful `metadata` keys: `crs`, `bounds`, `width`, `height`, `bands`. Note
69+
`RT_Envelope` takes an `RT_DATACUBE`, not the `bbox` column — use `$.bounds`.
70+
71+
#### `zarr` — read Zarr stores
72+
73+
`read_zarr(path)`, plus `read_zarr_groups(path)` and `read_zarr_metadata(path)` for
74+
discovery. Call the metadata/groups functions first to learn array names, shapes and
75+
chunking; the same size discipline as `RT_ReadCells` applies — a Zarr array is
76+
typically a full datacube, so slice on its coordinate columns before aggregating.
77+
78+
**Report which extension produced a number** when you use either one, so the user
79+
knows the result came from the experimental path.

k8s/experimental-deployment.yaml

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
# Testing endpoint for the :experimental image (raster + zarr extensions, issue #354).
2+
# Not part of the prod or dev rotation — nothing should depend on this being up.
3+
apiVersion: apps/v1
4+
kind: Deployment
5+
metadata:
6+
name: experimental-duckdb-mcp
7+
namespace: biodiversity
8+
spec:
9+
# Single replica on purpose: a testing endpoint on a shared cluster, and a
10+
# single pod keeps a test session pinned to one DuckDB process.
11+
replicas: 1
12+
selector:
13+
matchLabels:
14+
app: experimental-duckdb-mcp
15+
template:
16+
metadata:
17+
labels:
18+
app: experimental-duckdb-mcp
19+
spec:
20+
affinity:
21+
nodeAffinity:
22+
requiredDuringSchedulingIgnoredDuringExecution:
23+
nodeSelectorTerms:
24+
- matchExpressions:
25+
- key: topology.kubernetes.io/region
26+
operator: In
27+
values: ["us-west"]
28+
containers:
29+
- name: server
30+
image: ghcr.io/boettiger-lab/mcp-data-server:experimental
31+
imagePullPolicy: Always
32+
env:
33+
- name: STAC_CATALOG_URL
34+
value: "https://s3-west.nrp-nautilus.io/public-data/stac/catalog.json"
35+
- name: MCP_PUBLIC_BASE_URL
36+
value: "https://experimental-duckdb-mcp.nrp-nautilus.io"
37+
- name: STAC_ALLOW_DEGRADED_START
38+
value: "true"
39+
# Also baked into the image; set here too so `kubectl describe` shows which
40+
# extras a running pod believes it has without cracking open the image.
41+
- name: EXTRA_DUCKDB_EXTENSIONS
42+
value: "raster,zarr"
43+
# GDAL (vendored by the raster extension) looks for the RedHat CA path;
44+
# the image is Debian. Without this every /vsicurl/ read fails TLS. Also
45+
# set in the image — repeated here so it is visible and overridable.
46+
- name: GDAL_HTTP_CAINFO
47+
value: "/etc/ssl/certs/ca-certificates.crt"
48+
- name: CURL_CA_BUNDLE
49+
value: "/etc/ssl/certs/ca-certificates.crt"
50+
- name: POD_MEMORY_LIMIT
51+
valueFrom:
52+
resourceFieldRef:
53+
containerName: server
54+
resource: limits.memory
55+
resources:
56+
# Small requests (NRP's "Ignored" utilization bucket), and limits well
57+
# below prod's 160Gi/64: this endpoint is for small rasters and Zarr
58+
# slices. RT_ReadCells materializes every pixel, so a modest ceiling is
59+
# a feature — an over-large read fails here instead of evicting
60+
# co-tenants on the node.
61+
requests:
62+
memory: 2Gi
63+
cpu: 250m
64+
limits:
65+
memory: 32Gi
66+
cpu: 16
67+
ports:
68+
- containerPort: 8000
69+
readinessProbe:
70+
httpGet:
71+
path: /healthz
72+
port: 8000
73+
initialDelaySeconds: 15
74+
periodSeconds: 5
75+
timeoutSeconds: 2
76+
failureThreshold: 3
77+
livenessProbe:
78+
httpGet:
79+
path: /healthz
80+
port: 8000
81+
initialDelaySeconds: 30
82+
periodSeconds: 30
83+
timeoutSeconds: 10
84+
failureThreshold: 6

k8s/experimental-ingress.yaml

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
apiVersion: networking.k8s.io/v1
2+
kind: Ingress
3+
metadata:
4+
name: experimental-mcp-server-duckdb-ingress
5+
namespace: biodiversity
6+
annotations:
7+
haproxy-ingress.github.io/cors-enable: "true"
8+
haproxy-ingress.github.io/cors-allow-origin: "*"
9+
haproxy-ingress.github.io/cors-allow-methods: "GET, POST, OPTIONS"
10+
haproxy-ingress.github.io/cors-allow-headers: "DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization,mcp-session-id"
11+
haproxy-ingress.github.io/cors-allow-credentials: "true"
12+
haproxy-ingress.github.io/cors-max-age: "86400"
13+
haproxy-ingress.github.io/balance-algorithm: leastconn
14+
haproxy-ingress.github.io/timeout-client: "600s"
15+
haproxy-ingress.github.io/timeout-server: "600s"
16+
haproxy-ingress.github.io/timeout-tunnel: "3600s"
17+
spec:
18+
ingressClassName: haproxy
19+
tls:
20+
- hosts:
21+
- experimental-duckdb-mcp.nrp-nautilus.io
22+
rules:
23+
- host: experimental-duckdb-mcp.nrp-nautilus.io
24+
http:
25+
paths:
26+
- path: /
27+
pathType: Prefix
28+
backend:
29+
service:
30+
name: experimental-mcp-server-duckdb
31+
port:
32+
number: 8000

k8s/experimental-service.yaml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
apiVersion: v1
2+
kind: Service
3+
metadata:
4+
name: experimental-mcp-server-duckdb
5+
namespace: biodiversity
6+
spec:
7+
selector:
8+
app: experimental-duckdb-mcp
9+
ports:
10+
- protocol: TCP
11+
port: 8000
12+
targetPort: 8000

server.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,21 @@ def parse_setup_sql(content):
7777
H3_RAW = load_text_file("h3-guide.md")
7878
ROLE_RAW = load_text_file("assistant-role.md")
7979

80+
# Opt-in DuckDB extensions beyond the stock httpfs/spatial/h3 set (issue #354).
81+
# The experimental image (Dockerfile.experimental) installs raster + zarr and sets
82+
# EXTRA_DUCKDB_EXTENSIONS; the default image leaves it unset and behaves exactly as
83+
# before. LOAD is per-connection, like every statement in SETUP_SQL, so this is a
84+
# deployment knob rather than a code fork. Names are restricted to identifier
85+
# characters — they are interpolated into LOAD, which takes no parameters.
86+
_EXT_NAME_RE = re.compile(r"^[A-Za-z0-9_]+$")
87+
EXTRA_EXTENSIONS = [
88+
e for e in (s.strip() for s in os.environ.get("EXTRA_DUCKDB_EXTENSIONS", "").split(","))
89+
if e and _EXT_NAME_RE.match(e)
90+
]
91+
# Guidance for the extras is injected only where they are actually loaded, so the
92+
# default deployment's tool description is unchanged.
93+
EXTRAS_RAW = load_text_file("experimental-extensions.md") if EXTRA_EXTENSIONS else ""
94+
8095
# -------------------------------------------------------------------------
8196
# 3. CONTEXT INJECTION (PROMPT ENGINEERING)
8297
# -------------------------------------------------------------------------
@@ -111,6 +126,7 @@ def parse_setup_sql(content):
111126
112127
### 📐 H3 SPATIAL MATH
113128
{H3_RAW}
129+
{EXTRAS_RAW}
114130
---
115131
"""
116132

@@ -154,6 +170,14 @@ def get_isolated_db(s3_key: str = None, s3_secret: str = None, s3_endpoint: str
154170
conn.sql(stmt)
155171
except Exception as e:
156172
print(f"⚠️ Setup statement skipped: {stmt!r}: {e}", file=sys.stderr)
173+
# Opt-in extras (experimental image only — empty list on the default tag).
174+
# Warn rather than raise: a missing extension should degrade this connection's
175+
# capability, not fail every query the replica serves.
176+
for ext in EXTRA_EXTENSIONS:
177+
try:
178+
conn.sql(f"LOAD {ext}")
179+
except Exception as e:
180+
print(f"⚠️ Extra extension {ext!r} not loaded: {e}", file=sys.stderr)
157181
# Bound memory to the pod so a big aggregate spills instead of OOM-killing
158182
# the replica (#270). Not part of SETUP_SQL: the value is deployment-derived
159183
# (dbconfig reads POD_MEMORY_LIMIT/DUCKDB_MEMORY_LIMIT), not model guidance.

0 commit comments

Comments
 (0)