Event-Driven Fan-Out: S3 to Lambda to EventBridge to dbt
Architectural Overview
Decoupling a multi-source ingestion pipeline into independent Lambda stages using native S3 and EventBridge event triggers instead of a Step Functions orchestrator or a polling coordinator — so a failure in one processor never blocks the others, and a completion signal from any of three independent sources fans into one shared transform-integrity-test-live-health checkpoint.
Three independent external sources (Places, ER-wait, Open Data) each need fetch, S3 landing, process/upsert, and shared dbt transformation before the data is usable for triage routing. Coordinating three sources that get added, disabled, or moved independently over time (ER-wait was later moved to a Railway worker, Sprint 12) is a different problem than coordinating a single fixed pipeline.
The Problem
A central orchestrator has to know about every source up front — adding or retiring a source means editing shared orchestration state, not just adding or removing an independent resource. And a failure in one source's processor shouldn't block or freeze the sources that are healthy.
A Step Functions state machine (or any central coordinator) has to know about every source up front; adding a 4th source means editing the orchestrator's definition, not just adding an independent stack resource.
If one source's processor starts failing, that can't block a healthy source's processor from running or the shared dbt-runner from firing on the sources that did succeed.
The Architecture Strategy
S3 Object Created events, filtered by key prefix (raw/places/, raw/er-wait/, raw/open-data/), trigger the matching processor Lambda directly — no orchestrator in between. Each processor publishes a custom ProcessorComplete event (status: SUCCESS, source medicoord.pipeline) to the default EventBridge bus after a successful Supabase upsert. One DbtRunnerRule matches that event pattern regardless of which of the three processors fired it, fanning all three into a single shared dbt-runner that never needs to know which source triggered it. Once triggered, dbt-runner runs three phases, not one: Phase 1 dbt run (transforms facilities_clean), Phase 2 dbt test (13 automated data-quality tests, 13/13 passing — an integrity gate, not just a transform), Phase 3 the medi_db_health_check Supabase RPC (dead tuples, long-running queries, deadlocks — a live database health check, not a pipeline-internal check). IAM backs the whole chain with three least-privilege roles: ingestion (SSM read + S3 write + logs), processor (S3 read + logs + EventBridge publish scoped to the default bus), dbt-runner (logs only — Supabase access is HTTPS with env-injected credentials, no AWS resource access needed). The pipeline is defined across two separate CloudFormation stacks, not one. s3-buckets.yaml (plain CloudFormation) defines the landing bucket with native EventBridgeConfiguration (S3-to-EventBridge without the manual console toggle that was tried first and abandoned), a 30-day lifecycle expiry (raw files are transient — cost hygiene, not an afterthought), and a full PublicAccessBlockConfiguration (this bucket is internal-only, stated explicitly rather than left to default). template.yaml (AWS SAM) defines the compute layer: 3 IAM roles, 4 processing/ingestion Lambdas plus dbt-runner, EventBridge rules. The S3 stack exports BucketName/BucketArn via CloudFormation Export; the compute stack consumes them via !ImportValue in every IAM policy and EventBridge rule that touches the bucket — one resource, one source of truth, zero hardcoded ARNs or duplicated bucket names across stacks.
Outputs:
BucketName:
Description: Name of the raw ingestion bucket
Value: !Ref MedicoordRawBucket
Export:
Name: medicoord-ingestion-bucket-name
BucketArn:
Description: ARN of the raw ingestion bucket
Value: !GetAtt MedicoordRawBucket.Arn
Export:
Name: medicoord-ingestion-bucket-arn# One rule covers all three processors — fires when any processor
# publishes ProcessorComplete with status SUCCESS. dbt-runner never
# knows which source triggered it.
DbtRunnerRule:
Type: AWS::Events::Rule
Properties:
Name: medicoord-dbt-runner-rule
EventPattern:
source:
- medicoord.pipeline
detail-type:
- ProcessorComplete
detail:
status:
- SUCCESS
State: ENABLED
Targets:
- Id: DbtRunnerTarget
Arn: !GetAtt DbtRunner.Arn
# Cross-stack reference — no hardcoded bucket ARN
S3Write:
PolicyDocument:
Statement:
- Effect: Allow
Action: s3:PutObject
Resource: !Sub
- '${BucketArn}/*'
- BucketArn: !ImportValue medicoord-ingestion-bucket-arnlogger.info("Phase 1 — dbt run")
# ... run facilities_clean transform ...
logger.info("Phase 2 — dbt test")
# ... 13 automated data-quality tests, fails loudly on bad data ...
logger.info("Phase 3 — DB health checks (RPC)")
# ... medi_db_health_check: dead tuples, long-running queries, deadlocks ...Alternatives Considered
Its centralized definition doesn't match how sources actually get added or retired over time (ad hoc, sometimes disabled entirely, as ER-wait was). Editing a state machine to add or remove a branch is more coupling than the problem needs.
A periodic Lambda or status-table poll adds latency and a dedicated moving part solely for coordination that S3/EventBridge already provides natively, at no extra cost.

Lessons Learned
places-processor 404'd on its Supabase upsert on roughly 48% of invocations (15 of 31) over a 90-day historical window, with zero downstream impact: dbt-runner kept running clean on whichever processors succeeded, and no other source was blocked. The event-driven design's resilience — failures don't cascade — is also exactly why the bug went unnoticed for so long. Decoupling contains a fault; it doesn't surface it. The fix has held for the most recent 21 days (10 invocations, 0 errors) as of this write-up.
Only 1 of 3 ingestion sources is actually active in this pipeline today — er-wait-scraper and open-data-sync's EventBridge Schedule rules are disabled; the team moved ER-wait ingestion to a Railway background worker instead during Sprint 12, to avoid near-real-time Lambda cost overhead. This is architecture built for three sources, one active — stated directly rather than implied by omission. Separately: because the DbtRunnerRule pattern matches ProcessorComplete from any processor, two processors completing within the same window could trigger dbt-runner twice back-to-back. Not a correctness problem — the dbt models and health-check RPC are idempotent/read-only — but worth knowing if duplicate CloudWatch log entries for dbt-runner show up close together.
- 31 places-processor invocations over 90 days; 0 errors in the most recent 21 days (10 invocations) — a historical Supabase-upsert 404 bug is confirmed fixed.
- 12 dbt-runner invocations over 90 days, 0 errors — the shared fan-in/verification gate holds regardless of which processor triggers it.
- CloudWatch GetMetricStatistics (AWS/Lambda, Invocations + Errors), 90-day window, weekly buckets, per function, live production AWS account.
- CloudWatch Logs filter-log-events on places-processor's log group confirmed the 404 root cause: a urllib.request.urlopen call inside upsert_facilities(), not a third-party API key issue.
- A most-recent-21-days re-check (3 weekly buckets) confirmed the fix held: 10 invocations, 0 errors.