MediCoord AI Engineering
Back to Technical Index

Event-Driven Fan-Out: S3 to Lambda to EventBridge to dbt

Published: June 16, 2026Updated: July 14, 2026Written by MediCoord Core Platform Team7 Min Read

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.

Central Orchestrator = Central Coupling

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.

One Failure Shouldn't Freeze the Rest

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.

YAML Sample
s3-buckets.yaml
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
YAML Sample
template.yaml
# 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-arn
PYTHON Sample
dbt-runner/handler.py
logger.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

Step Functions state machine

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.

Polling/cron-based coordinator

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.

System Flow
Diagram of the event-driven pipeline: scheduled ingestion writes to S3, an S3 event triggers the matching processor, the processor signals completion on EventBridge, and a shared rule fans all three sources into one dbt-runner verification gate
FIG 4.1: EVENT-DRIVEN FAN-OUT PIPELINE
01
Scheduled Ingestion
An EventBridge Schedule rule fires an ingestion Lambda on a 7-day cadence, writes raw JSON to S3 under a source-specific prefix.
02
S3 Event Trigger
S3 Object Created, filtered by key prefix, directly invokes the matching processor Lambda. No polling, no orchestrator.
03
Processor Fan-In Signal
After a successful Supabase upsert, the processor publishes a ProcessorComplete/SUCCESS event to the default EventBridge bus.
04
Shared Verification Gate
One rule matches ProcessorComplete from any of the three processors and invokes dbt-runner once: transform, then 13 data-quality tests, then a live Supabase health check.

Lessons Learned

A stale bug stayed invisible for months because nothing was watching

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.

Tradeoff

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.

Result
Measured: Simulated Load
  • 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.
Methodology
  1. CloudWatch GetMetricStatistics (AWS/Lambda, Invocations + Errors), 90-day window, weekly buckets, per function, live production AWS account.
  2. 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.
  3. A most-recent-21-days re-check (3 weekly buckets) confirmed the fix held: 10 invocations, 0 errors.