This repository contains a runnable demo that ingests a subset of Norwegian GBIF datasets with an Airflow-orchestrated pipeline, validates / enriches the records in a Java Spark job, stores canonical results in Postgres + MinIO, indexes a search view in Elasticsearch, and exposes a FastAPI service over the processed data.
┌────────────────┐
│ GBIF IPT (DwC) │
└──────┬─────────┘
│ HTTP download
┌──────────────┐ ▼
│ Airflow │ fetch/upload ┌────────┐
│ (scheduler) │──────────────► MinIO │ raw, processed Parquet
└────┬─────────┘ └──┬─────┘
│SparkSubmit │
▼ │
┌──────────────┐ validations │s3a://processed
│ Spark (Java) │───────────────────┘
└────┬─────────┘
│ pandas/s3fs
▼
┌──────────────┐ ┌────────────────────┐
│ Postgres │◄──────►│ FastAPI + Elastic │
│ ingest schema│ meta └────────────────────┘
└──────────────┘
- Airflow DAG (
airflow/dags/mini_gbif_ingest.py) orchestrates dataset fetch, Spark transform, Postgres load, and Elasticsearch indexing tasks for each configured dataset. - Object storage first using MinIO buckets (
raw,processed) with checksum-tracked objects for raw DwC-A payloads and canonical Parquet snapshots. - Java Spark transform (
spark-java/) enforces schema, normalizes values, applies TG2 validations (VALIDATION_COUNTRYCODE_STANDARD,VALIDATION_DECIMALLATITUDE_INRANGE), and writes valid/invalid splits plus summary stats. - Relational store (Postgres schema
ingest) keeps run metadata and a serving table (occurrence_valid) with indexes for queries / API usage. - Search layer + API: Elasticsearch index
occurrencebackstopsFastAPIendpoints for faceted search and record retrieval. - Docker Compose environment brings up Airflow, Postgres, MinIO, Elasticsearch, and the FastAPI app locally.
-
Build the Spark jar (requires Maven + Java 17):
./scripts/build_spark.sh
-
Bring everything up:
docker compose up --build
- Airflow UI:
http://localhost:8080(user/pass:airflow/airflow) - MinIO console:
http://localhost:9001(user/pass:minio/minio123) - API service:
http://localhost:8000/docs
- Airflow UI:
-
Trigger the DAG inside the Airflow UI (
mini_gbif_ingest), or via CLI once the containers are running:docker compose exec airflow-scheduler airflow dags trigger mini_gbif_ingest -
Explore outputs:
- Postgres (
postgresql://pipeline:pipeline@localhost:5432/pipeline), schemaingest - Elasticsearch index
occurrenceviahttp://localhost:9200/occurrence/_search - API endpoints, e.g.
GET /occurrence/search?countryCode=NO&basisOfRecord=PRESERVED_SPECIMEN
- Postgres (
Datasets live in config/datasets.yaml. Each entry supplies:
- dataset_key: <GBIF dataset UUID>
dwca_url: <DwC-A download URL>
publisher: <text>
label: <human readable name>Airflow mounts the file and creates per-dataset tasks automatically. Adjust, add, or remove datasets and re-trigger the DAG.
-
Fetch + land raw data
Downloads each DwC archive, extractsoccurrence.txt, computes SHA-256, and stores it in MinIO underraw/datasetKey=<id>/ingestDate=<date>/. Metadata (bucket key, checksum, ingest date) rides through XCom to downstream tasks. -
Spark transform (Java)
Spark 3.5.1 job (MiniGbifTransform) runs onspark-submit --master local[*]inside the Airflow image with Hadoop S3A configured for MinIO.- Normalizes columns (
countryCode,basisOfRecord, coordinates) - Validates ISO country codes (
VALIDATION_COUNTRYCODE_STANDARD) + latitude range (VALIDATION_DECIMALLATITUDE_INRANGE) - Adds derived fields (
event_year,lat_bucket,lon_bucket,taxon_key_hash) - Splits datasets into
valid/invalidParquet +statsJSON ats3a://processed/datasetKey=<id>/run_id=<ts>/
- Normalizes columns (
-
Load to Postgres
pandas+sqlalchemyread the processed Parquet, insert run metadata intoingest.ingest_run, and bulk load valid rows intoingest.occurrence_valid(with indexes on(dataset_key, event_year)and(country_code, basis_of_record)). -
Index Elasticsearch
Each run re-indexes the dataset into theoccurrenceindex with a simple mapping (text search onscientific_name, keyword facets, geo point from lat/lon). -
API Service (
api/app/main.py)
FastAPI exposes:GET /healthGET /occurrence/{gbif_id}(Postgres-backed lookup)GET /occurrence/search(query/filters/bbox via Elasticsearch)GET /datasets/{dataset_key}/runs(run history from Postgres)
VALIDATION_COUNTRYCODE_STANDARD: enforce ISO 3166-1 alpha-2 (plus GBIF’sXZ,ZZ) via Spark UDF and route failures to the invalid Parquet with explicit issue codes.VALIDATION_DECIMALLATITUDE_INRANGE: ensuresdecimalLatitude∈ [-90, 90]; a longitude sanity check is included as a bonus.- Derived fields:
event_year, coordinate buckets, SHA-256 hash over the scientific name to mimic stable partition keys.
- Add GitHub Actions to run
mvn -q -pl spark-java test packageand lint the Python pieces (ruff,pytest) to signal engineering rigor. - Docker Compose keeps everything local, but
infra/k8s/could host manifests or Helm charts translating these services to AKS/EKS/GKE once ready (explain in your application how you would externalize MinIO → S3 and Postgres → managed service).
- Swap MinIO with managed S3/GCS and wire S3 notifications → Airflow datasets for incremental triggers.
- Extend the Spark job with additional TG2 validations (e.g., longitude range,
month/daynormalization) and partitioned Parquet layout for Trino/Arrow. - Add Observability: structured logs (already in Airflow), metrics via Prometheus, dashboards showing run stats from
ingest.ingest_run. - Package the API + Elasticsearch as a tiny DigitalOcean deployment (docker compose →
doctl apps) pointing to the remote Postgres read replica to mimic the target architecture.