Data ingestion pipeline for OpenAQ that processes air quality measurements from various providers into the OpenAQ database.
The ingestor handles three main types of data:
- LCS Pipeline Data (lcs-etl-pipeline/) - Low-cost sensor measurements and metadata
- Realtime Data (realtime-gzipped/) - Real-time air quality measurements
- Metadata (lcs-etl-pipeline/stations/) - Sensor station metadata
The ingestor runs as an AWS Lambda function triggered by:
- S3 Events via SNS (production) - Automatically processes new files uploaded to S3
- EventBridge/CloudWatch Events (cron) - Scheduled batch processing
- Direct invocation (manual) - For debugging or reprocessing specific files
S3 Upload → SNS Topic → Lambda Handler → Load to Staging → ETL to Permanent Tables
↓
fetchlogs tracking
The lcsV2 module is the primary ingestion pipeline for low-cost sensor data. It processes JSON, NDJSON, and CSV files containing sensor metadata and measurements.
- Function:
utils.load_fetchlogs(pattern, limit, ascending) - Purpose: Selects files from
fetchlogstable for processing - Filters:
- Pattern match (regex)
init_datetime IS NOT NULL(initialized records only)completed_datetime IS NULL(unprocessed)has_error = false(no previous errors)- Not loaded in last 30 minutes (prevents duplicate processing)
- Updates: Sets
loaded_datetime, incrementsjobscounter, assignsbatch_uuid
- Purpose: Reads file from S3 or local filesystem and parses data
- Supported Formats:
- JSON - Structured metadata and measurements
- NDJSON - Newline-delimited JSON (realtime data)
- CSV - Tabular sensor data
- Populates:
self.nodes- Sensor locations/stationsself.systems- Sensor systems/instrumentsself.sensors- Individual sensorsself.measurements- Measurement valuesself.flags- Data quality flags
- SQL File:
temp_locations_dump.sql - Purpose: Creates staging tables and writes location data
- Tables Created:
staging_keys- Tracks files being processedstaging_sensornodes- Node/location data with unique constraints oningest_idand(source_name, source_id)staging_sensorsystems- System/instrument data linked to nodesstaging_sensors- Sensor data linked to systemsstaging_flags- Data quality flags
- Updates: Sets
fetchlogs.loaded_datetime = clock_timestamp()andlast_message = 'load_data'
- SQL File:
temp_measurements_dump.sql - Purpose: Creates staging table and writes measurement data
- Table Created:
staging_measurements- Raw measurement values with sensor references
- Data Transfer: Uses PostgreSQL
COPYfor efficient bulk loading
- SQL File:
etl_process_nodes.sql - Purpose: Moves data from staging tables to permanent tables
- Operations:
- Matches nodes to existing
sensor_nodesrecords or creates new ones - Links systems to nodes and creates
sensor_systemsrecords - Links sensors to systems and creates
sensorsrecords - Handles geometry, timezones, countries lookups
- Resolves measurands and units
- Merges metadata JSON fields
- Matches nodes to existing
- Tables Updated:
sensor_nodessensor_systemssensors
- SQL File:
etl_process_measurements.sql - Purpose: Processes measurements into permanent storage
- Operations:
- Links measurements to sensors via ingest_id or (source_name, source_id)
- Validates and filters measurements
- Inserts into
measurementstable - Handles duplicates and rejects
- Creates rollup data for aggregations
- Tables Updated:
measurementsrejects(invalid measurements)
- Updates: Sets
fetchlogs.completed_datetime = clock_timestamp()andlast_message = NULL - Cleanup: Drops staging tables (if using TEMP TABLEs)
| File | Purpose | When Used | Key Operations |
|---|---|---|---|
temp_locations_dump.sql |
Create staging tables for locations | dump_locations() start |
CREATE [TEMP] TABLE for nodes, systems, sensors, flags |
etl_process_nodes.sql |
ETL staging → permanent tables | dump_locations() end |
INSERT/UPDATE sensor_nodes, sensor_systems, sensors |
temp_measurements_dump.sql |
Create staging table for measurements | dump_measurements() start |
CREATE [TEMP] TABLE staging_measurements |
etl_process_measurements.sql |
ETL measurements → permanent table | dump_measurements() end |
INSERT measurements, handle rejects, create rollups |
The realtime process (fetch.py) handles high-frequency measurement data in NDJSON format.
| File | Purpose | When Used |
|---|---|---|
fetch_staging.sql |
Create staging table for realtime data | Start of realtime processing |
fetch_copy.sql |
Copy data into staging | After data loaded |
fetch_ingest_full.sql |
Process staging → measurements table | Final ETL step |
Triggered by EventBridge on a schedule (e.g., every 5 minutes):
def cronhandler(event, context):
# Process metadata (stations)
load_metadata_db(limit=250, ascending=False)
# Process realtime measurements
load_db(limit=50, ascending=False)
# Process pipeline measurements
load_measurements_db(limit=250, ascending=False)Each function:
- Calls
load_fetchlogs()with specific pattern - Processes returned records
- Returns count of processed files
For debugging or reprocessing specific files:
# Process specific file by ID
load_measurements_file(fetchlogs_id=123)
# Process by key pattern
load_measurements_pattern(pattern='lcs-etl-pipeline/measures/2024%')
# Process batch by UUID
load_measurements_batch(batch_uuid='abc123')Tracks all files processed through the system:
| Column | Type | Purpose |
|---|---|---|
fetchlogs_id |
int | Primary key (auto-generated) |
key |
text | S3 object key (unique) |
last_modified |
timestamptz | S3 object last modified time |
init_datetime |
timestamptz | When record was initialized (required for automated processing) |
loaded_datetime |
timestamptz | When file was last loaded for processing |
completed_datetime |
timestamptz | When processing completed successfully |
has_error |
boolean | Whether file has processing errors |
last_message |
text | Status message or error details |
jobs |
int | Number of times file has been processed |
batch_uuid |
text | UUID linking files processed together |
file_size |
bigint | File size in bytes |
Created per-session during processing:
staging_keys- Files being processedstaging_sensornodes- Node locationsstaging_sensorsystems- Sensor systemsstaging_sensors- Individual sensorsstaging_flags- Quality flagsstaging_measurements- Raw measurements
Final destination for processed data:
sensor_nodes- Sensor locations/stationssensor_systems- Sensor system/instrument metadatasensors- Individual sensor configurationsmeasurements- Time-series measurement datarejects- Invalid/rejected measurements for review
Configured via .env.local (local) or environment (production):
# Database
DATABASE_WRITE_URL=postgresql://user:pass@host:port/database
# S3
FETCH_BUCKET=openaq-fetches
# Processing
USE_TEMP_TABLES=true # Use temp tables for staging (production=true)
INGEST_TIMEOUT=300 # Timeout for cron processing (seconds)
PAUSE_INGESTING=false # Emergency pause flag
# Limits (records per batch)
PIPELINE_LIMIT=250 # LCS pipeline measurements
REALTIME_LIMIT=50 # Realtime measurements
METADATA_LIMIT=250 # Station metadata
# Order
FETCH_ASCENDING=false # Process newest files first (false) or oldest first (true)See ingest/settings.py for full configuration options.
poetry run pytest tests/ -v
## increase the log level
poetry run pytest tests/ --log-cli-level=DEBUGHandler Tests (test_handler_integration.py):
- SNS-wrapped S3 events (production path)
- Direct S3 events (manual invocations)
- EventBridge cron triggers
Cronhandler Tests (test_cronhandler_unit.py):
- Loader orchestration
- Timeout handling
- Error resilience
- Event parameter overrides
Load Fetchlogs Tests (test_load_fetchlogs.py):
- Pattern matching
- Ordering (ascending/descending)
- 30-minute loaded_datetime logic
- Duplicate prevention
lcsV2 Integration Tests (test_lcsv2_integration.py):
- End-to-end data loading
- Staging table verification
- Data integrity checks
- Multiple data formats (JSON, NDJSON, CSV)
poetry run pytest tests/ --cov=ingest --cov-report=term-missingpoetry run pytest tests/test_handler_integration.py::TestHandlerSNSEvents::test_handler_sns_event_single_file -vA single command-line tool for loading, checking, and downloading fetchlog files. Replaces the older local.py, check.py, and test_ingest.py scripts.
./cli.py [SELECTOR] [ACTION] [OPTIONS]You must provide one key selector. By default the tool processes each key through the ingest client and rolls back — pass --commit to persist.
| Flag | Purpose |
|---|---|
--id ID |
single fetchlog id |
--batch UUID |
all keys in a batch |
--pattern REGEX |
query fetchlogs by PostgreSQL regex |
--s3-prefix PREFIX |
list S3 directly under a prefix |
--from-file PATH |
read keys from a file (one per line, # for comments) |
KEY [KEY ...] |
positional S3 keys or local paths |
Selectors are mutually exclusive. Positional keys are upserted into fetchlogs on the target DB.
Exactly one of these determines what happens to each key:
| Flag | Behavior |
|---|---|
| (default) | parse, dump to staging, run ETL, report |
--preview |
parse only; no DB writes at all |
--dry-run |
resolve keys and print them; no processing |
--download [PATH] |
download files locally; no DB interaction |
| Flag | Purpose |
|---|---|
--stage-only |
dump to staging tables, skip the ETL load step |
--keep-staging |
use permanent staging tables (overrides USE_TEMP_TABLES) so you can inspect state after a run |
--source-db DSN |
alternate DB connection string for fetchlog lookups (e.g. query production, load into dev) |
--bucket NAME |
override FETCH_BUCKET |
--limit N |
cap on keys processed (default 300) |
| Flag | Purpose |
|---|---|
| (default) | prints a per-key summary table with matched/added counts |
--no-report |
suppress the summary table |
--csv PATH |
write per-key stats to CSV |
--diagnose QUERY[,QUERY,...] |
run named diagnostic queries; use list to see available names, all to run every one |
--diagnose-dir DIR |
write diagnostic query results to CSV files in this directory |
Diagnostic queries run inside the same transaction as the ingest, so they see uncommitted staging data even without --keep-staging.
| Flag | Purpose |
|---|---|
| (default) | rollback all changes |
--commit |
persist changes to the target DB |
# Look at a file's parsed contents without touching the DB
./cli.py --key measures/2024-03/foo.json.gz --preview
# See which keys a pattern resolves to, don't process
./cli.py --pattern 'measures/2024-03/.*' --dry-run --limit 50
# Download a production file for local inspection
./cli.py --key measures/2024-03/foo.json.gz --download ~/inspect/
# Load a batch into dev with rollback (default) and full report
./cli.py --batch abc-123
# Same, but persist
./cli.py --batch abc-123 --commit
# Stage-only load with CSV report
./cli.py --from-file keys.txt --stage-only --csv report.csv
# Cross-DB: query prod fetchlogs, load into local dev
./cli.py --pattern 'measures/2024.*' \
--source-db postgresql://user@prod-host/openaq \
--commit
# Debug a matching issue with diagnostic queries
./cli.py --key measures/foo.json.gz \
--diagnose new-nodes-with-nearby,rejects \
--diagnose-dir ./diag/
# Full inspection: keep staging around, run all diagnostics
./cli.py --key measures/foo.json.gz \
--keep-staging --diagnose all --diagnose-dir ./diag/Because rollback is the default, a common pattern is:
# First run: inspect what would happen
./cli.py --key foo --diagnose summary
# Second run: same command with --commit
./cli.py --key foo --diagnose summary --commitRun ./cli.py --diagnose list to see the full list. Queries are grouped into packs for convenience:
summary— quick health check (staged counts, sources, units, rejects)matching— node matching analysis (unmatched, nearby existing, cross-provider)rejects— reject breakdown by causeunits— sensor/measurement unit reconciliationquality— flagged values, suspicious timestamps, stuck sensorsspatial— geographic proximity and collision detection
Use pack names anywhere a query name is expected:
./cli.py --key foo --diagnose matching,rejects- Place test file in the ingest directory or provide path
- Run with Python:
from ingest.lcsV2 import IngestClient
from datetime import date
client = IngestClient()
client.load_key('path/to/test.json', fetchlogs_id=1, last_modified=str(date.today()))
client.dump(load=True)from ingest.utils import get_logs_from_ids
# Get error details
logs = get_logs_from_ids([fetchlogs_id])
print(logs)
# Reprocess with error handling
from ingest.lcsV2 import load_measurements_file
load_measurements_file(fetchlogs_id)from ingest.utils import load_errors_summary, load_errors_list
# Summary of recent errors
errors = load_errors_summary(days=7)
# List of recent error details
error_list = load_errors_list(limit=10)The ingestor runs as an AWS Lambda function deployed via CDK (see ../cdk/lambda_ingest_stack.py).
- Trigger: SNS topic subscribed to S3 bucket notifications
- Schedule: EventBridge rule for cron processing
- Timeout: Configured based on processing requirements
- Memory: Sufficient for large file processing
- VPC: Access to RDS PostgreSQL database
cd ../cdk
cdk deploy lambda-ingest-stackCustom metrics published:
- Files processed per run
- Processing duration
- Error counts
- Record counts
Check CloudWatch Logs for:
- File processing details
- SQL execution notices
- Error tracebacks
- Performance metrics
File not processing:
- Check
fetchlogs.init_datetime IS NOT NULL - Verify
has_error = false - Check if
completed_datetime IS NULL - Ensure not loaded in last 30 minutes
Staging table not found:
- Verify
USE_TEMP_TABLESsetting - Check if processing completed (temp tables dropped)
- For debugging, set
USE_TEMP_TABLES=false
Duplicate records:
- Check unique constraints on staging tables
- Verify
batch_uuidtracking - Review 30-minute duplicate prevention logic
Memory issues:
- Reduce batch limits (PIPELINE_LIMIT, etc.)
- Increase Lambda memory allocation
- Process files individually with
load_measurements_file()
For issues or questions:
- Check test files for examples
- Review SQL files for ETL logic
- See function docstrings for parameter details