The Data Hub Lambda function preprocesses raw instrument data uploaded to S3 and builds run-archive zips on demand for the web app's "Download all" actions. It can be triggered automatically by S3 events or manually via Function URL invocations from the web app. It runs an instrument-specific preprocessing pipeline (or the archive builder) and reports results back through the API.
The Lambda has three invocation paths:
- An S3
ObjectCreated:*event on the raw bucket triggers the Lambda (catch-all; no per-instrument prefix/suffix filters). - The handler parses the S3 key to extract the instrument ID, run ID, and filename. The expected key layout is
{instrument_id}/{run_id}/{filename}. - For S3-triggered events, a cheap union of processor filename gates runs first. Non-matching files no-op without an API call.
- The handler fetches the instrument via
GET /instruments/:idand looks up a processor byinstrument_typeindata_hub_lambda.processors. Unmapped types (includinggeneric) and per-type gate failures no-op. - The processor downloads the raw file from S3, preprocesses it, and creates/updates the run and files via the Data Hub API using the event's
instrument_id.
Slack notifications are sent by the web app, not the Lambda — see Slack notifications below.
When a file fails processing (or needs to be re-run), users can trigger reprocessing from the run detail page in the web app. This invokes the Lambda's Function URL instead of going through S3:
- The user clicks Reprocess on an uploaded, failed, or completed file in the web dashboard.
- The web app's
POST /api/v1/files/:fileId/reprocessendpoint transitions the file toprocessingstatus, clears any previous error, and sends a POST request to the Lambda Function URL. - The Function URL is configured with
AuthType: AWS_IAM, so the web app SigV4-signs the request using credentials it gets via Vercel OIDC federation (theWebAppS3RoleIAM role, which haslambda:InvokeFunctionUrlon this function's ARN). The body is a JSON payload containing a synthetic S3 event. - The Lambda handler detects the Function URL invocation (via
requestContextin the event) and parses the S3 event from the request body. Inbound auth is enforced by AWS itself in front of the function — the handler never sees an unauthenticated request. - From here, processing follows the same type-based dispatch as the S3 trigger path, except filename gates are skipped — a user clicking Reprocess has stated intent, so the handler must not leave the file stranded in
processing.
The web app's GET /api/v1/instruments/:instrumentId/runs/:runId/download-archive route delegates zip building to the Lambda so download bytes never traverse Vercel. This shares the Function URL with manual reprocessing and is distinguished by a type: "build_archive" field in the request body:
- The web app POSTs
{ type: "build_archive", instrument_id, run_id, destination_bucket, destination_key, files: [{ key, name, source_bucket }, ...], job_id? }to the Function URL using the same SigV4-signed flow as reprocessing. Each file carries its ownsource_bucketso a single archive can zip files that live in the raw bucket and the processed bucket (the common case for instruments that produce processed artifacts — SpectraMax CSVs, Hina JPGs, Azure 600 PNGs). For backward compat with pre-migration callers, a top-levelsource_bucketis still accepted as a fallback for files that omit the per-entry field. - The handler dispatches on the
typediscriminator and callsarchive_builder.build_run_archive. Two security checks fire on every payload: (a) every inputkeymust live under{instrument_id}/{run_id}/so a misconfigured caller can't exfiltrate cross-run/cross-tenant data, and (b) everysource_bucketmust be on the Lambda's allow-list —AWS_S3_RAW_DATA_BUCKETandAWS_S3_PROCESSED_DATA_BUCKET— so the builder can't be redirected at an arbitrary bucket the Lambda role might happen to have GetObject on. - The builder streams each S3 object through
zipfile.ZipFileinto an_MultipartUploadStreamthat buffers writes into ~16 MB parts and flushes each viaUploadPart. Memory stays bounded regardless of total archive size, so a 200+ GB run zips inside the Lambda's standard memory budget. Entries are writtenZIP_STORED(no compression — instrument output rarely compresses) withforce_zip64=True(so individual entries ≥ 4 GB don't blow up the writer). - On success, the Lambda returns
{ archive_bucket, archive_key, size_bytes }. Ifjob_idwas supplied, it also PATCHes/api/v1/archive-jobs/:job_idwith the same fields andstatus: "ready"; on failure it PATCHesstatus: "failed"witherror_message. The PATCH callback authenticates withAuthorization: Bearer <DATA_HUB_API_KEY>— the same PAT the Lambda uses for every other Lambda → API call. The PATCH primarily serves to record terminal state for diagnostics and to surfacefailedquickly — the UI's polling target is the/download-archiveroute itself (whose first action is an S3 HEAD against the canonical archive key), so a finished build is downloadable the moment the multipart upload completes regardless of whether this PATCH lands.
See Run archives for the full flow, S3 bucket layout, cache semantics, and operator runbook.
Dispatch is by instrument_type (Postgres/TS enum), not instrument ID. The registry lives in lambda/src/data_hub_lambda/processors.py; the web reprocess gate mirrors the same keys in web/lib/instruments/processable-types.ts.
instrument_type |
Module | Filename gate (S3 events only) |
|---|---|---|
tape_station |
agilent_4150_tapestation |
.pdf |
fplc |
akta_fplc |
.pdf |
gel_doc |
azure_600_gel_doc |
.tif / .tiff |
qpcr |
azure_cielo_qpcr |
ends with _cq values.csv |
epson_v700_scanner |
epson_v700_scanner |
.tif / .tiff |
hina_microscope |
hina_microscope |
.nd2 |
plate_reader |
spectramax_plate_reader |
.xls |
generic, instant_raman |
— | — |
One type = one vendor's output format. Names like qpcr and fplc sound generic, but the parsers behind them are vendor-specific (Azure Cielo, ÄKTA, …). Adding a second vendor under an existing type requires splitting the type, not reusing it.
Seeded jolene-fplc stays generic until an operator confirms its PDFs match the ÄKTA processor and edits the type to fplc. Typing an unknown FPLC as fplc would feed non-ÄKTA files into that parser.
Each processor module exposes process_file(instrument_id, run_id, filename) and reports progress through the Data Hub API.
Slack channel notifications are sent by the web app (web/lib/slack.ts), not the Lambda. When the Lambda's process_file calls POST /api/v1/instruments/:instrumentId/runs to register a newly-detected run, that endpoint posts a single message per run to the incoming webhook URL configured in Settings > Notifications > Slack channel (workspace admins only). Subsequent files for the same run do not re-notify because the upsert is idempotent on (instrument_id, run_id). File-level failures remain visible in the web app via the file row's status='failed' and error_message fields.
-
Add or reuse an
instrument_type. If this is a new vendor format, extendinstrumentTypeEnuminweb/lib/db/schema.tsand generate anALTER TYPE ... ADD VALUEmigration. Create the instrument row in the web app with that type (or edit an existing row). The sharedInstrumentenum inpackages/sharedis optional — only needed for watcher/CLI display naming, not for Lambda dispatch. -
Create a processor module under
lambda/src/data_hub_lambda/that exposes:def process_file(instrument_id: str, run_id: str, filename: str) -> None: """Process a file, reporting progress via the Data Hub API.""" ...
-
Register it in
lambda/src/data_hub_lambda/processors.py(type →process_file+matches_filename) and add the same type string toPROCESSABLE_INSTRUMENT_TYPESinweb/lib/instruments/processable-types.ts. -
Add tests for the processor and for the new registry gate.
-
Deploy the Lambda image. The raw bucket already notifies on all
ObjectCreated:*events — no new S3 trigger entry is required.
The data-hub-process CLI lets you exercise instrument-specific parsing and processing locally. Most subcommands run a single processor in isolation against a file on disk and print the result; the handler subcommand drives lambda_handler end-to-end against a local S3 mirror and the dev API.
uv run data-hub-process <command> [args]Available commands:
| Command | Description |
|---|---|
epson-scanner |
Process an Epson V700 Scanner TIFF (resized JPEG preview + metadata) |
gel-doc |
Process an Azure 600 Gel Doc TIFF (contrast-enhanced PNG + metadata) |
hina |
Convert a Hina microscope ND2 file to a JPG overlay + metadata |
qpcr |
Parse dye channels from an Azure Cielo qPCR Cq Values CSV |
spectramax |
Parse metadata and raw well data from a SpectraMax .xls export |
tapestation |
Extract the tape type from a TapeStation CSV filename |
handler |
Stage a file into a local S3 mirror and invoke lambda_handler against the local dev API. See Testing the Lambda end-to-end for the workflow. |
The first six subcommands need no S3 or API access — they call into the same parsing/processing utilities the lambda uses, but stop short of the network. handler is different: it expects a running dev API and a LOCAL_S3_MIRROR directory, and uses the same dispatch path production uses.
Examples:
uv run data-hub-process gel-doc path/to/image.tif --output-dir out/
uv run data-hub-process spectramax path/to/plate.xls
uv run data-hub-process handler azure-cielo-qpcr Experiment_20260101 cq.csv --source ~/Downloads/cq.csvThe Lambda function is packaged as a container image:
make docker-build-lambdaThe Dockerfile is a multi-stage build:
- Builder stage: Uses
uvto export and install third-party dependencies into the Lambda task root. - Final stage: Copies the installed dependencies plus the
data_hub_sharedanddata_hub_lambdasource packages.
The entry point is data_hub_lambda.handler.lambda_handler.
The Lambda function depends on a scientific Python stack:
click— CLI frameworkpandas— data manipulationmatplotlib— plottingscikit-image— image processingtifffile— TIFF file readingarcadia-microscopy-tools— ND2 reading, channel handling, and multi-channel compositing for the Hina microscopepydantic— data validationrequests— HTTP client for the Data Hub APIaws-lambda-typing— type stubs for Lambda events/contextdata-hub-shared— shared utilities (S3, enums)