<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Madhav's Blogs]]></title><description><![CDATA[Madhav's Blogs]]></description><link>https://blog.madhav.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/622ac3bee552e99d82e59b17/d0eb2e7c-2156-46d8-9b80-afc801dfdab5.png</url><title>Madhav&apos;s Blogs</title><link>https://blog.madhav.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Sat, 12 Sep 2026 01:25:24 GMT</lastBuildDate><atom:link href="https://blog.madhav.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Managing State, Secrets, and Environments in Terraform]]></title><description><![CDATA[The engineer who set up the Terraform project had left the company three months earlier. Nobody had thought to ask where the state file lived.
It lived on their laptop. Which IT had wiped.
Re-importin]]></description><link>https://blog.madhav.dev/managing-state-secrets-and-environments-in-terraform</link><guid isPermaLink="true">https://blog.madhav.dev/managing-state-secrets-and-environments-in-terraform</guid><category><![CDATA[Terraform]]></category><category><![CDATA[Infrastructure as code]]></category><category><![CDATA[GCP]]></category><category><![CDATA[Devops]]></category><category><![CDATA[Cloud]]></category><category><![CDATA[secrets management]]></category><dc:creator><![CDATA[Madhav Bhasin]]></dc:creator><pubDate>Fri, 11 Sep 2026 04:13:30 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/622ac3bee552e99d82e59b17/a2ade129-1572-44b7-bf2f-042738c4972c.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>The engineer who set up the Terraform project had left the company three months earlier. Nobody had thought to ask where the state file lived.</p>
<p>It lived on their laptop. Which IT had wiped.</p>
<p>Re-importing 23 GCP resources manually — Cloud Run services, Pub/Sub topics, Cloud SQL instances, IAM bindings — from a project with no consistent naming convention or resource tags took two days. Two days of <code>terraform import</code>, <code>gcloud</code> commands, and guessing which resource in the console matched which block in the config.</p>
<p>State, secrets, and environment configuration are the parts of Terraform that feel like infrastructure concerns and get deferred. They're the decisions that quietly determine whether your setup survives the team scaling, an engineer leaving, or a CI pipeline running on the wrong environment.</p>
<hr />
<h2>State</h2>
<p>Terraform state is the source of truth for what exists. It maps your HCL config to real cloud resources. Without it, Terraform doesn't know what's deployed — it can't plan changes, detect drift, or destroy resources cleanly.</p>
<p>Local state (<code>terraform.tfstate</code> in your project directory) is a single point of failure. It can't be shared across a team. It disappears when a laptop dies or gets wiped. Never use it for anything beyond a personal experiment.</p>
<p>Remote state in GCS is the minimum viable setup:</p>
<pre><code class="language-hcl"># versions.tf
terraform {
  backend "gcs" {
    bucket = "pulsecart-terraform-state"
    prefix = "terraform/state"
  }
}
</code></pre>
<p>Create the bucket before running <code>terraform init</code>:</p>
<pre><code class="language-bash">gsutil mb -l us-central1 gs://pulsecart-terraform-state
gsutil versioning set on gs://pulsecart-terraform-state
</code></pre>
<p>Versioning is what gives you state history. If a bad <code>terraform apply</code> corrupts state, you can restore a previous version from the bucket. Without versioning, that option doesn't exist.</p>
<p>GCS also handles state locking natively — when one engineer or CI job is running <code>terraform apply</code>, the state file is locked and other applies are blocked. No two applies can corrupt state simultaneously.</p>
<p><strong>The three commands you'll need when something goes wrong:</strong></p>
<pre><code class="language-bash"># List all resources Terraform knows about
terraform state list

# Inspect a specific resource's state
terraform state show google_cloud_run_v2_service.producer

# Import an existing resource into state (when recovering from lost state)
terraform import google_cloud_run_v2_service.producer \
  projects/your-project/locations/us-central1/services/pulsecart-producer
</code></pre>
<p><code>terraform import</code> is how you recover from the laptop situation — but it requires knowing the exact resource ID for every resource, which is why tagging and consistent naming conventions are worth enforcing from day one.</p>
<hr />
<h2>Secrets</h2>
<p>Terraform state stores resource attributes in plaintext JSON. If you pass a database password as a variable and Terraform writes it to a resource, it's in your state file. In plaintext. In a GCS bucket that whoever has GCS access can read.</p>
<p>Two rules:</p>
<p><strong>Never put secrets in tfvars files.</strong> <code>prod.tfvars</code> gets committed to Git. Even if it's in <code>.gitignore</code> today, it gets committed by accident eventually.</p>
<p><strong>Read secrets from GCP Secret Manager at apply time:</strong></p>
<pre><code class="language-hcl"># Read the DB password from Secret Manager during terraform apply
data "google_secret_manager_secret_version" "db_password" {
  secret  = "pulsecart-db-password"
  version = "latest"
}

resource "google_sql_database_instance" "pulsecart" {
  name             = "pulsecart-postgres"
  database_version = "POSTGRES_15"
  region           = var.gcp_region

  settings {
    tier = var.db_tier
  }
}

resource "google_sql_user" "app" {
  name     = "pulsecart_app"
  instance = google_sql_database_instance.pulsecart.name
  password = data.google_secret_manager_secret_version.db_password.secret_data
}
</code></pre>
<p>The secret value is fetched at apply time from Secret Manager — it never lives in your tfvars, your repo, or your CI environment variables. The only thing in Git is the secret name.</p>
<p>For variables that contain sensitive values, mark them explicitly:</p>
<pre><code class="language-hcl"># variables.tf
variable "db_password" {
  type      = string
  sensitive = true   # redacted from plan output and logs
}
</code></pre>
<p><code>sensitive = true</code> prevents the value from appearing in <code>terraform plan</code> output or in CI logs. It doesn't prevent the value from being stored in state — which is why reading from Secret Manager rather than passing as a variable is the better pattern for anything genuinely sensitive.</p>
<hr />
<h2>Environments</h2>
<p>Terraform Workspaces are the built-in answer to environments. They feel right — one codebase, multiple workspaces, each with isolated state. In practice they have a footgun: all workspaces share the same backend bucket prefix by default, just with a workspace name injected. It's easy to accidentally run <code>terraform apply</code> against the wrong workspace, especially in CI where the workspace is set by an environment variable.</p>
<p><strong>Use tfvars files per environment instead:</strong></p>
<pre><code class="language-plaintext">environments/
├── dev.tfvars
├── staging.tfvars
└── prod.tfvars
</code></pre>
<pre><code class="language-hcl"># environments/dev.tfvars
gcp_project_id        = "pulsecart-dev"
environment           = "dev"
db_tier               = "db-g1-small"
db_availability_type  = "ZONAL"
min_instances         = 0
max_instances         = 5
deletion_protection   = false

# environments/prod.tfvars
gcp_project_id        = "pulsecart-prod"
environment           = "prod"
db_tier               = "db-custom-4-15360"
db_availability_type  = "REGIONAL"
min_instances         = 1
max_instances         = 20
deletion_protection   = true
</code></pre>
<p>Apply explicitly with the right var file:</p>
<pre><code class="language-bash"># Dev
terraform apply -var-file="environments/dev.tfvars"

# Prod
terraform apply -var-file="environments/prod.tfvars"
</code></pre>
<p>In CI, the var file is determined by the branch or environment the pipeline runs against — explicit, auditable, hard to accidentally misapply.</p>
<p>Use workspaces when you have genuinely identical environments that differ only by region or account — multi-region deployments, for example. For dev/staging/prod, tfvars is clearer and safer.</p>
<hr />
<h2>Variable Validation</h2>
<p>Catch misconfiguration at plan time, not after a broken apply:</p>
<pre><code class="language-hcl"># variables.tf
variable "environment" {
  type = string
  validation {
    condition     = contains(["dev", "staging", "prod"], var.environment)
    error_message = "environment must be one of: dev, staging, prod"
  }
}

variable "db_tier" {
  type = string
  validation {
    condition     = can(regex("^db-", var.db_tier))
    error_message = "db_tier must be a valid Cloud SQL tier starting with 'db-'"
  }
}

variable "min_instances" {
  type = number
  validation {
    condition     = var.min_instances &gt;= 0 &amp;&amp; var.min_instances &lt;= var.max_instances
    error_message = "min_instances must be &gt;= 0 and &lt;= max_instances"
  }
}
</code></pre>
<p><code>terraform plan</code> fails with a clear error message if validation fails. No apply, no partial deployment, no guessing what went wrong. One line of validation per constraint costs nothing and prevents a category of misconfiguration that's otherwise only caught in production.</p>
<hr />
<p>The laptop incident was avoidable at every step — remote state from day one, a tagging policy, a runbook for what to do when someone leaves the team. None of it is complex. It's just the kind of setup that gets skipped when you're moving fast and the project is small.</p>
<p>It stops being small eventually.</p>
]]></content:encoded></item><item><title><![CDATA[Structuring OpenAI and Gemini Calls in FastAPI: Prompts, Fallbacks, and Retries]]></title><description><![CDATA[The fallback was supposed to be temporary. When the OpenAI API was unavailable, the service would return the last cached result for that user rather than failing the request. Clean degraded experience]]></description><link>https://blog.madhav.dev/structuring-openai-and-gemini-calls-in-fastapi-prompts-fallbacks-and-retries</link><guid isPermaLink="true">https://blog.madhav.dev/structuring-openai-and-gemini-calls-in-fastapi-prompts-fallbacks-and-retries</guid><category><![CDATA[openai]]></category><category><![CDATA[FastAPI]]></category><category><![CDATA[Python]]></category><category><![CDATA[llm]]></category><category><![CDATA[production]]></category><category><![CDATA[Backend Engineering]]></category><dc:creator><![CDATA[Madhav Bhasin]]></dc:creator><pubDate>Tue, 08 Sep 2026 02:42:23 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/622ac3bee552e99d82e59b17/11f4c29f-2dab-4083-8e61-f55bcde12e20.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>The fallback was supposed to be temporary. When the OpenAI API was unavailable, the service would return the last cached result for that user rather than failing the request. Clean degraded experience, no errors surfaced to the client.</p>
<p>What nobody noticed: the cache TTL was set to 7 days. When the API came back up, the service kept serving cached results for users whose inputs had changed — because the cache key was too coarse and the invalidation logic had a bug. No errors, no alerts. Clients were seeing stale AI-generated content for days.</p>
<p>The fallback worked exactly as coded. The design was wrong.</p>
<p>Building LLM integrations that hold up in production means getting three things right: how you call the model, how you retry when it fails, and what you actually do when it's unavailable. All three are worth more thought than they usually get.</p>
<hr />
<h2>Structuring the LLM Client</h2>
<p>Don't scatter <code>openai.chat.completions.create()</code> calls across your service layer. Wrap the client once, in one place, with all the configuration and error handling it needs:</p>
<pre><code class="language-python"># services/llm_client.py
from openai import AsyncOpenAI
from config import settings
import logging

logger = logging.getLogger(__name__)

client = AsyncOpenAI(
    api_key=settings.openai_api_key,
    timeout=30.0,        # hard timeout — don't let slow responses hang indefinitely
    max_retries=0,       # handle retries yourself, not via the SDK's built-in retry
)

async def call_model(
    prompt: str,
    system: str,
    model: str = "gpt-4o-mini",
    max_tokens: int = 1000,
    temperature: float = 0.2,
) -&gt; str:
    response = await client.chat.completions.create(
        model=model,
        messages=[
            {"role": "system", "content": system},
            {"role": "user", "content": prompt},
        ],
        max_tokens=max_tokens,
        temperature=temperature,
    )
    content = response.choices[0].message.content
    logger.info(
        "LLM call complete",
        extra={
            "model": model,
            "prompt_tokens": response.usage.prompt_tokens,
            "completion_tokens": response.usage.completion_tokens,
            "total_tokens": response.usage.total_tokens,
        }
    )
    return content
</code></pre>
<p><code>max_retries=0</code> is intentional — the SDK's built-in retry is a black box. You want explicit control over when, how many times, and with what backoff you retry. More on that below.</p>
<p><code>timeout=30.0</code> is a hard limit on how long a single API call can take. Without it, a slow model response holds your Cloud Run instance's concurrency slot indefinitely.</p>
<p>For Gemini, the structure is the same pattern with a different client:</p>
<pre><code class="language-python">import google.generativeai as genai
from config import settings

genai.configure(api_key=settings.gemini_api_key)
gemini_model = genai.GenerativeModel("gemini-1.5-flash")

async def call_gemini(prompt: str, system: str) -&gt; str:
    response = await gemini_model.generate_content_async(
        f"{system}\n\n{prompt}"
    )
    return response.text
</code></pre>
<p>The key difference: Gemini doesn't have a separate system role in the same way — you prepend system instructions to the user prompt. If you're abstracting across both providers, account for this in your prompt builder.</p>
<hr />
<h2>Prompt Management</h2>
<p>Prompts hardcoded as string constants in service files are a maintenance problem. They can't be iterated without a code deployment. They can't be versioned or rolled back independently. They can't be tested in isolation.</p>
<p>Store prompts as versioned config, loaded at startup:</p>
<pre><code class="language-python"># prompts/store.py
import json
from pathlib import Path
from config import settings

_prompts: dict = {}

def load_prompts():
    prompt_file = Path(f"prompts/{settings.environment}.json")
    with open(prompt_file) as f:
        global _prompts
        _prompts = json.load(f)

def get_prompt(key: str, version: str = "latest") -&gt; dict:
    prompt_versions = _prompts.get(key, {})
    if version == "latest":
        version = max(prompt_versions.keys())
    return prompt_versions[version]
</code></pre>
<pre><code class="language-json">// prompts/prod.json
{
  "document_extraction": {
    "v1": {
      "system": "You are a document extraction assistant. Extract structured data from the provided document. Return only valid JSON matching the schema provided.",
      "user_template": "Extract the following fields from this document:\n{fields}\n\nDocument:\n{document}"
    },
    "v2": {
      "system": "You are a precise document extraction assistant. Extract only the explicitly stated fields. If a field is not present, return null. Return only valid JSON.",
      "user_template": "Extract these fields: {fields}\n\nDocument content:\n{document}\n\nReturn JSON only, no explanation."
    }
  }
}
</code></pre>
<pre><code class="language-python"># Usage in service
from prompts.store import get_prompt

prompt_config = get_prompt("document_extraction", version="v2")
system = prompt_config["system"]
user = prompt_config["user_template"].format(
    fields=", ".join(required_fields),
    document=document_text
)
result = await call_model(prompt=user, system=system)
</code></pre>
<p>Rolling back a bad prompt is now a config change, not a code deployment. A/B testing two prompt versions is straightforward. The service code doesn't change when prompts do.</p>
<hr />
<h2>Output Validation</h2>
<p>Model output is untrusted input. Validate it before using it:</p>
<pre><code class="language-python"># services/extraction.py
from pydantic import BaseModel, ValidationError
from services.llm_client import call_model
from prompts.store import get_prompt
import json
import logging

logger = logging.getLogger(__name__)

class ExtractionResult(BaseModel):
    invoice_number: str
    total_amount: float
    vendor_name: str
    date: str
    line_items: list[dict]

async def extract_document(document_text: str) -&gt; ExtractionResult:
    prompt_config = get_prompt("document_extraction")
    raw = await call_model(
        prompt=prompt_config["user_template"].format(document=document_text),
        system=prompt_config["system"],
    )

    # Strip markdown fences if model wraps output in ```json ... ```
    clean = raw.strip().removeprefix("```json").removesuffix("```").strip()

    try:
        data = json.loads(clean)
        return ExtractionResult(**data)
    except (json.JSONDecodeError, ValidationError) as e:
        logger.error("Output validation failed", extra={"raw_output": raw, "error": str(e)})
        raise ValueError(f"Model returned unparseable output: {e}")
</code></pre>
<p>The <code>removeprefix/removesuffix</code> for markdown fences is not optional — models frequently wrap JSON output in code fences even when instructed not to. Strip them before parsing.</p>
<hr />
<h2>Retry Logic</h2>
<p>Retries on LLM APIs need explicit backoff. The failure modes worth retrying are rate limit errors (429) and server errors (500, 503). Don't retry on invalid request errors (400) or auth errors (401) — those won't resolve with a retry.</p>
<pre><code class="language-python"># services/llm_client.py (extended)
import asyncio
from openai import RateLimitError, APIStatusError

async def call_model_with_retry(
    prompt: str,
    system: str,
    max_attempts: int = 3,
    base_delay: float = 1.0,
    **kwargs,
) -&gt; str:
    last_error = None

    for attempt in range(max_attempts):
        try:
            return await call_model(prompt, system, **kwargs)

        except RateLimitError as e:
            last_error = e
            delay = base_delay * (2 ** attempt)   # exponential backoff
            logger.warning(f"Rate limited, retrying in {delay}s (attempt {attempt + 1}/{max_attempts})")
            await asyncio.sleep(delay)

        except APIStatusError as e:
            if e.status_code in (500, 503):
                last_error = e
                delay = base_delay * (2 ** attempt)
                logger.warning(f"API error {e.status_code}, retrying in {delay}s")
                await asyncio.sleep(delay)
            else:
                raise   # don't retry 400, 401, 422

    raise last_error
</code></pre>
<p>Cap retries at 3. Add jitter if you're making many concurrent calls (multiply <code>delay</code> by <code>random.uniform(0.8, 1.2)</code>) to avoid thundering herd when multiple instances hit a rate limit simultaneously.</p>
<hr />
<h2>Fallbacks That Don't Silently Mislead</h2>
<p>The opening story failed because the fallback served data that was no longer valid without telling anyone. A good fallback is explicit about what it's doing:</p>
<pre><code class="language-python"># services/extraction.py (with fallback)
from db.queries.extractions import get_cached_extraction
from datetime import datetime, timedelta

MAX_CACHE_AGE = timedelta(hours=1)   # not 7 days

async def extract_document_with_fallback(
    document_id: str,
    document_text: str,
) -&gt; tuple[ExtractionResult, bool]:
    """Returns (result, is_fresh) — callers decide what to do with stale results."""

    try:
        result = await call_model_with_retry(
            prompt=..., system=...
        )
        fresh_result = ExtractionResult(**json.loads(result))
        await cache_extraction(document_id, fresh_result)
        return fresh_result, True

    except Exception as e:
        logger.error(f"LLM call failed, attempting cache fallback: {e}")
        cached = await get_cached_extraction(document_id)

        if cached and (datetime.utcnow() - cached.created_at) &lt; MAX_CACHE_AGE:
            return cached.result, False   # is_fresh=False signals to the caller

        raise   # no usable cache — fail explicitly
</code></pre>
<p>The caller receives <code>is_fresh=False</code> and decides what to do — surface a "results may be outdated" indicator to the user, skip the result entirely, or queue a refresh job. The fallback doesn't hide that it's a fallback.</p>
<hr />
<h2>Wiring It Into FastAPI</h2>
<pre><code class="language-python"># routers/documents.py
@router.post("/documents/{document_id}/extract")
async def extract(document_id: str, request: Request):
    document = await get_document(document_id)

    result, is_fresh = await extract_document_with_fallback(
        document_id=document_id,
        document_text=document.text,
    )

    return {
        "result": result.model_dump(),
        "fresh": is_fresh,
        "extracted_at": datetime.utcnow().isoformat(),
    }
</code></pre>
<p><code>fresh</code> in the response gives the client full information. A dashboard can show "results from 47 minutes ago" instead of presenting stale data as current.</p>
]]></content:encoded></item><item><title><![CDATA[PostgreSQL Connection Pooling on Cloud Run: The Problem Nobody Warns You About]]></title><description><![CDATA[Cloud Run scales horizontally. That's the point. Under load, it spins up new instances automatically — each one handling concurrent requests, each one maintaining its own connection pool to your Cloud]]></description><link>https://blog.madhav.dev/postgresql-connection-pooling-on-cloud-run-the-problem-nobody-warns-you-about</link><guid isPermaLink="true">https://blog.madhav.dev/postgresql-connection-pooling-on-cloud-run-the-problem-nobody-warns-you-about</guid><category><![CDATA[PostgreSQL]]></category><category><![CDATA[cloud run]]></category><category><![CDATA[connection pooling]]></category><category><![CDATA[Backend Engineering]]></category><category><![CDATA[sqlalchemy]]></category><category><![CDATA[PgBouncer]]></category><dc:creator><![CDATA[Madhav Bhasin]]></dc:creator><pubDate>Tue, 01 Sep 2026 01:08:31 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/622ac3bee552e99d82e59b17/2a253c57-aa79-4f30-8bd8-fdcfb8bf60cd.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Cloud Run scales horizontally. That's the point. Under load, it spins up new instances automatically — each one handling concurrent requests, each one maintaining its own connection pool to your Cloud SQL PostgreSQL instance.</p>
<p>Here's what that looks like in numbers.</p>
<p>You configure your SQLAlchemy pool with <code>pool_size=5</code> and <code>max_overflow=5</code> — ten connections per instance. Sounds reasonable. At 10 Cloud Run instances under moderate load, that's 100 connections. At 50 instances during a traffic spike, that's 500. PostgreSQL's default <code>max_connections</code> is 100. At 20 instances you've already exceeded it. New connections get rejected. Requests fail.</p>
<p>This isn't a Cloud Run bug. It's the expected behaviour of two systems — one that scales horizontally without limit, one that has a hard connection ceiling — combined without accounting for the interaction.</p>
<hr />
<h2>Why It's Worse Than It Looks</h2>
<p>The math above assumes every instance is using its full pool. In practice it's more unpredictable. Cloud Run instances don't scale down immediately. Idle instances sit around holding open connections they're not using. Your effective connection count at any given moment is <code>max_connections_per_instance × live_instances</code> — and live instances includes the idle ones.</p>
<p>Cloud SQL's connection limits vary by machine tier. A <code>db-g1-small</code> (the default for dev environments that get accidentally promoted to prod) caps at around 25 connections. A <code>db-custom-4-15360</code> caps at around 4,000. If you're not sure what tier your instance is, you're probably not sure what your connection ceiling is.</p>
<pre><code class="language-sql">-- Check your current connection usage and limit
SELECT
  count(*) as active_connections,
  max_conn as connection_limit
FROM pg_stat_activity, (SELECT setting::int AS max_conn FROM pg_settings WHERE name = 'max_connections') AS limits
GROUP BY max_conn;
</code></pre>
<p>Run this during a load test. The number is usually higher than expected.</p>
<hr />
<h2>The Fix: Pool Sizing That Accounts for Horizontal Scale</h2>
<p>The formula isn't <code>pool_size = how_many_feels_right</code>. It's:</p>
<pre><code class="language-plaintext">max_pool_size_per_instance = floor(db_max_connections / max_cloud_run_instances)
</code></pre>
<p>Leave headroom for Cloud SQL's internal processes (~3 connections) and for admin access during incidents. A practical formula:</p>
<pre><code class="language-plaintext">usable_connections = db_max_connections - 5
max_pool_per_instance = floor(usable_connections / max_cloud_run_instances)
</code></pre>
<p>For a <code>db-custom-2-7680</code> with 500 max connections and 20 max Cloud Run instances:</p>
<pre><code class="language-plaintext">usable_connections = 495
max_pool_per_instance = floor(495 / 20) = 24
</code></pre>
<p>Set <code>pool_size</code> below that ceiling with headroom for overflow:</p>
<pre><code class="language-python"># config.py
from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    database_url: str
    db_pool_size: int = 5
    db_max_overflow: int = 10
    db_pool_timeout: int = 30
    db_pool_recycle: int = 1800   # recycle connections every 30 minutes

settings = Settings()
</code></pre>
<pre><code class="language-python"># db/pool.py
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from config import settings

engine = create_async_engine(
    settings.database_url,
    pool_size=settings.db_pool_size,
    max_overflow=settings.db_max_overflow,
    pool_timeout=settings.db_pool_timeout,
    pool_recycle=settings.db_pool_recycle,
    pool_pre_ping=True,   # verify connections before using them
)

AsyncSessionLocal = async_sessionmaker(
    engine,
    class_=AsyncSession,
    expire_on_commit=False,
)
</code></pre>
<pre><code class="language-python"># dependencies.py
from db.pool import AsyncSessionLocal
from typing import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncSession

async def get_db() -&gt; AsyncGenerator[AsyncSession, None]:
    async with AsyncSessionLocal() as session:
        yield session
</code></pre>
<p><code>pool_pre_ping=True</code> adds a lightweight <code>SELECT 1</code> before each connection is used. It catches stale connections that Cloud SQL dropped on its end — without it, a connection that's been idle for Cloud SQL's timeout period returns an error on the first query instead of reconnecting cleanly.</p>
<p><code>pool_recycle=1800</code> proactively replaces connections older than 30 minutes. Cloud SQL drops idle connections after a configurable timeout (default 10 minutes). Recycling before that point prevents the errors that <code>pool_pre_ping</code> catches — it's the belt to <code>pre_ping</code>'s suspenders.</p>
<hr />
<h2>The Better Fix: PgBouncer</h2>
<p>Pool sizing helps. It doesn't solve the fundamental problem — Cloud Run's horizontal scaling means your connection count grows with your instance count, and at high scale, even a small pool per instance adds up.</p>
<p>PgBouncer sits between your application and Cloud SQL and maintains a fixed pool of server-side connections regardless of how many application instances connect to it. A hundred Cloud Run instances can each open 10 connections to PgBouncer; PgBouncer maintains 50 actual connections to PostgreSQL.</p>
<p>On GCP, the cleanest way to run PgBouncer is as a Cloud Run sidecar or a dedicated Cloud Run service:</p>
<pre><code class="language-yaml"># cloud-run-service.yaml (simplified)
apiVersion: serving.knative.dev/v1
kind: Service
metadata:
  name: pulsecart-api
spec:
  template:
    spec:
      containers:
        - image: gcr.io/your-project/pulsecart-api
          env:
            - name: DATABASE_URL
              value: postgresql+asyncpg://user:pass@localhost:5432/pulsecart
        - image: edoburu/pgbouncer
          env:
            - name: DATABASE_URL
              value: postgresql://user:pass@/cloudsql/your-project:region:instance/pulsecart
            - name: POOL_MODE
              value: transaction
            - name: MAX_CLIENT_CONN
              value: "1000"
            - name: DEFAULT_POOL_SIZE
              value: "50"
</code></pre>
<p><code>POOL_MODE=transaction</code> is the right mode for stateless APIs — PgBouncer assigns a server connection for the duration of a transaction only, not the entire client session. This gives the highest multiplexing efficiency for request-per-transaction workloads like FastAPI.</p>
<p>The tradeoff: <code>transaction</code> mode doesn't support session-level features like <code>SET LOCAL</code>, advisory locks, or <code>LISTEN/NOTIFY</code>. If you use any of these, use <code>session</code> mode instead and accept lower multiplexing efficiency.</p>
<hr />
<h2>Cloud SQL Auth Proxy</h2>
<p>If you're connecting to Cloud SQL via the Auth Proxy (which you should be — it handles IAM auth and TLS termination), the proxy itself doesn't pool connections. It's a pass-through. All the pooling logic above still applies; the proxy just sits between your application and Cloud SQL handling the authentication layer.</p>
<p>Run the Auth Proxy as a sidecar alongside your application container, not as a separate service — the connection overhead between containers on the same Cloud Run instance is negligible, and it avoids the network hop of a separate service.</p>
<hr />
<h2>The Monitoring You Need</h2>
<p>Set this up before you need it:</p>
<pre><code class="language-python"># Add to your /health endpoint
from sqlalchemy import text

@app.get("/health")
async def health(db: AsyncSession = Depends(get_db)):
    try:
        result = await db.execute(text(
            "SELECT count(*) FROM pg_stat_activity WHERE datname = current_database()"
        ))
        active_connections = result.scalar()
        return {"status": "ok", "db_connections": active_connections}
    except Exception:
        return JSONResponse(status_code=503, content={"status": "degraded"})
</code></pre>
<p>Expose connection count in your health endpoint. Set a Cloud Monitoring alert when it approaches 80% of your <code>max_connections</code>. At 80% you have time to react. At 100% you're already dropping requests.</p>
<hr />
<h2>Summary</h2>
<p>Cloud Run + PostgreSQL without connection management is a scaling time bomb. The failure doesn't happen at low traffic — it happens exactly when you can least afford it.</p>
<p>Size your pool to account for horizontal scaling. Add <code>pool_pre_ping</code> and <code>pool_recycle</code>. Monitor connection count as a first-class metric. Add PgBouncer when the math stops working in your favour.</p>
<p>The problem is predictable. The fix is straightforward. The only reason it surprises teams is that nobody mentions it until production is on fire.</p>
]]></content:encoded></item><item><title><![CDATA[Building AI-Powered Backend Services: Why Most Integrations Break in Production]]></title><description><![CDATA[The integration looked solid. In testing, the AI-powered document extraction service worked exactly as designed — pull a PDF, send it to the model, parse the structured output, write to the database. ]]></description><link>https://blog.madhav.dev/building-ai-powered-backend-services-why-most-integrations-break-in-production</link><guid isPermaLink="true">https://blog.madhav.dev/building-ai-powered-backend-services-why-most-integrations-break-in-production</guid><category><![CDATA[AI]]></category><category><![CDATA[llm]]></category><category><![CDATA[FastAPI]]></category><category><![CDATA[production]]></category><category><![CDATA[Python]]></category><category><![CDATA[Backend Engineering]]></category><dc:creator><![CDATA[Madhav Bhasin]]></dc:creator><pubDate>Sat, 22 Aug 2026 02:21:29 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/622ac3bee552e99d82e59b17/64a6df2a-4ba6-4879-b0b0-07033fd9c98e.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>The integration looked solid. In testing, the AI-powered document extraction service worked exactly as designed — pull a PDF, send it to the model, parse the structured output, write to the database. Latency was acceptable. Outputs were clean. We shipped it.</p>
<p>Three days after going live, client reports started coming in. Extracted data was missing fields. Some records had partial output. A handful had nothing at all. The service wasn't throwing errors. Logs showed successful API calls. The model was returning responses — they just weren't what we expected.</p>
<p>The root cause: in testing, we'd used short, simple documents. In production, clients uploaded complex multi-page PDFs. The model's output structure drifted — fields appeared in different orders, optional sections were omitted, the JSON we were parsing sometimes contained prose where we expected a value. We'd built the parsing layer assuming the model always returned a consistent structure. It didn't.</p>
<p>No error. No alert. Silent data loss.</p>
<hr />
<h2>Why AI Integrations Are Different</h2>
<p>A REST API call either succeeds or fails in a way you can detect. Status codes, error bodies, timeouts — the failure modes are well-defined. You write a retry, you write an error handler, and you know what you're dealing with.</p>
<p>LLM API calls fail differently. The HTTP request can return 200 with a well-formed response body that contains output you can't use. The model might:</p>
<ul>
<li><p>Return valid JSON that doesn't match your expected schema</p>
</li>
<li><p>Truncate output mid-sentence when it hits a token limit</p>
</li>
<li><p>Interpret an ambiguous prompt differently than it did in testing</p>
</li>
<li><p>Produce structurally correct output with factually wrong values</p>
</li>
<li><p>Respond slowly enough to exceed your downstream timeout, but fast enough that you don't configure a sensible one</p>
</li>
</ul>
<p>None of these show up as errors in your API client. They show up as data quality issues, silent failures, or confused end users — often days after the fact.</p>
<hr />
<h2>The Five Reasons Most Integrations Break</h2>
<p><strong>1. No output validation</strong></p>
<p>The most common failure. You call the model, get a response, and pass it downstream without checking whether it matches what you need. In testing, the model is consistent enough that this works. In production, edge cases in real data expose inconsistencies you didn't anticipate.</p>
<p>The fix isn't clever prompt engineering — it's treating model output like untrusted external input and validating it the same way you'd validate an API response from a third party. Pydantic, schema validation, explicit checks before any downstream write.</p>
<p><strong>2. Prompts treated as static strings</strong></p>
<p>A prompt that works today may not work after a model update, a context window change, or a shift in the input data. Teams that hardcode prompts as string literals in their service code have no way to iterate, version, or roll back a prompt change without a code deployment.</p>
<p>Prompts are configuration, not code. They belong in a versioned store, not string constants.</p>
<p><strong>3. No fallback when the model fails</strong></p>
<p>LLM APIs have rate limits, occasional downtime, and elevated latency during high-demand periods. Services that make synchronous blocking calls to an LLM API in the request path are one OpenAI outage away from a full service degradation.</p>
<p>Every AI integration needs a defined behaviour for when the model is unavailable: queue the work and process it later, return a graceful degraded response, or fail fast with a meaningful error rather than hanging.</p>
<p><strong>4. Token limits treated as someone else's problem</strong></p>
<p>Context windows have limits. Large inputs get truncated, sometimes silently, sometimes with a clear error, depending on how you're calling the API. Teams that don't explicitly manage token counts end up with truncated outputs that look complete until someone looks closely.</p>
<p>This is especially common with document processing — a service built on short test documents works fine until a client uploads a 40-page PDF.</p>
<p><strong>5. Cost left unmonitored until the bill arrives</strong></p>
<p>LLM API costs scale with usage in ways that aren't always intuitive. A feature that costs pennies in testing can cost hundreds of dollars in production if usage is higher than expected, if prompts are larger than necessary, or if a retry loop is accidentally calling the API in a tight loop.</p>
<p>Without per-endpoint cost tracking and usage alerts, the first signal is a monthly bill that's ten times what you expected.  </p>
<p>The document extraction service wasn't badly built. It just treated the LLM as a reliable structured data source rather than a probabilistic system that needs the same defensive patterns you'd apply to any external dependency.</p>
]]></content:encoded></item><item><title><![CDATA[How I Structure a FastAPI Project for a Team of 5]]></title><description><![CDATA[The first FastAPI project I led for a team had everything in two files. main.py with the routes. models.py with the Pydantic schemas. It worked fine until a second engineer joined and we started stepp]]></description><link>https://blog.madhav.dev/how-i-structure-a-fastapi-project-for-a-team-of-5</link><guid isPermaLink="true">https://blog.madhav.dev/how-i-structure-a-fastapi-project-for-a-team-of-5</guid><category><![CDATA[FastAPI]]></category><category><![CDATA[Python]]></category><category><![CDATA[Backend Engineering]]></category><category><![CDATA[team]]></category><category><![CDATA[clean code]]></category><category><![CDATA[software architecture]]></category><dc:creator><![CDATA[Madhav Bhasin]]></dc:creator><pubDate>Tue, 18 Aug 2026 08:00:33 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/622ac3bee552e99d82e59b17/f3c5a341-03d7-4566-8dd0-ab697d4ed7c3.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>The first FastAPI project I led for a team had everything in two files. <code>main.py</code> with the routes. <code>models.py</code> with the Pydantic schemas. It worked fine until a second engineer joined and we started stepping on each other constantly — merge conflicts on <code>main.py</code> every other day, no clear ownership of anything, and a growing pile of utility functions that lived wherever they'd been written first.</p>
<p>We restructured twice before landing on something that actually scaled with the team. Here's what we learned.</p>
<hr />
<h2>The Mistakes</h2>
<p><strong>Everything in main.py.</strong> Feels clean at the start. Becomes a 1,500-line file that nobody wants to open. Every feature touches it. Every PR conflicts with the one before it.</p>
<p><strong>Models mixed with business logic.</strong> Pydantic schemas sitting next to database query functions sitting next to helper utilities. No clear layer boundaries. A junior engineer joining the team has no idea where to put a new thing.</p>
<p><strong>No separation between internal and external schemas.</strong> Using the same Pydantic model for API input, database writes, and inter-service events. One schema change breaks three things at once.</p>
<p><strong>Config scattered across files.</strong> Environment variables read directly in route handlers, service files, and utility modules. No single place to look at what the application needs to run.</p>
<hr />
<h2>The Structure That Fixed It</h2>
<pre><code class="language-plaintext">pulsecart-api/
├── main.py                  # app factory only — no routes, no logic
├── config.py                # all env vars in one place
├── dependencies.py          # shared FastAPI dependencies (db pool, auth, etc.)
│
├── routers/
│   ├── __init__.py
│   ├── events.py            # POST /events/ingest
│   ├── orders.py            # GET/POST /orders
│   └── health.py            # GET /health, GET /ready
│
├── models/
│   ├── __init__.py
│   ├── base.py              # PulseCartEvent base schema
│   ├── commerce.py          # OrderPlaced, CartAbandoned, etc.
│   └── internal.py          # schemas for inter-service communication
│
├── services/
│   ├── __init__.py
│   ├── publisher.py         # Pub/Sub publishing logic
│   ├── idempotency.py       # Redis deduplication
│   └── messaging.py         # personalized message triggering
│
├── db/
│   ├── __init__.py
│   ├── pool.py              # connection pool setup
│   └── queries/
│       ├── orders.py        # SQL for order operations
│       └── events.py        # SQL for event logging
│
└── tests/
    ├── conftest.py
    ├── unit/
    └── integration/
</code></pre>
<p>Five folders, clear ownership. A new engineer can look at this and know exactly where to find something and where to put something new.</p>
<hr />
<h2>main.py — App Factory Only</h2>
<pre><code class="language-python"># main.py
from contextlib import asynccontextmanager
from fastapi import FastAPI
from routers import events, orders, health
from db.pool import init_pool, close_pool
from config import settings
import logging

@asynccontextmanager
async def lifespan(app: FastAPI):
    app.state.db = await init_pool(settings.database_url)
    yield
    await close_pool(app.state.db)

app = FastAPI(
    title="PulseCart API",
    version="1.0.0",
    lifespan=lifespan
)

app.include_router(events.router)
app.include_router(orders.router)
app.include_router(health.router)
</code></pre>
<p><code>main.py</code> does three things: lifespan management, router registration, app config. Nothing else. If you're adding business logic here, it belongs in <code>services/</code>.</p>
<hr />
<h2>config.py — All Env Vars in One Place</h2>
<pre><code class="language-python"># config.py
from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    database_url: str
    redis_url: str
    gcp_project_id: str
    pubsub_topic_commerce: str = "pulsecart.commerce-events"
    environment: str = "dev"
    log_level: str = "INFO"

    class Config:
        env_file = ".env"

settings = Settings()
</code></pre>
<p>Every environment variable the application needs is declared here with a type and an optional default. If a required variable is missing, the app fails at startup with a clear error — not at runtime when the first request hits the code path that needs it.</p>
<p>No <code>os.getenv()</code> anywhere else in the codebase. If an engineer needs a new env var, it goes in <code>config.py</code> first.</p>
<hr />
<h2>dependencies.py — Shared FastAPI Dependencies</h2>
<pre><code class="language-python"># dependencies.py
from fastapi import Request, HTTPException, status
from typing import AsyncGenerator
import asyncpg

async def get_db(request: Request) -&gt; AsyncGenerator[asyncpg.Connection, None]:
    async with request.app.state.db.acquire() as conn:
        yield conn

async def get_current_service(request: Request) -&gt; str:
    api_key = request.headers.get("X-Service-Key")
    if not api_key or api_key not in VALID_SERVICE_KEYS:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Invalid or missing service key"
        )
    return api_key
</code></pre>
<p>Anything shared across routes — database connections, auth checks, request context — lives here and gets injected via FastAPI's dependency system. Routes stay clean. Tests swap these out via <code>dependency_overrides</code>.</p>
<hr />
<h2>routers/ — One File Per Domain</h2>
<pre><code class="language-python"># routers/events.py
from fastapi import APIRouter, Depends, status
from models.commerce import EVENT_REGISTRY
from services.publisher import publish_event
from dependencies import get_db
import asyncpg

router = APIRouter(prefix="/events", tags=["events"])

@router.post("/ingest", status_code=status.HTTP_202_ACCEPTED)
async def ingest_event(
    raw_event: dict,
    db: asyncpg.Connection = Depends(get_db)
):
    event_type = raw_event.get("event_type")
    model_class = EVENT_REGISTRY.get(event_type)
    event = model_class(**raw_event)
    message_id = await publish_event(event.model_dump())
    return {"status": "accepted", "event_id": event.event_id, "message_id": message_id}
</code></pre>
<p>Each router owns one domain. <code>events.py</code> owns event ingestion. <code>orders.py</code> owns order operations. When an engineer is working on orders, they touch <code>routers/orders.py</code>, <code>services/</code> if needed, and <code>db/queries/orders.py</code>. They don't touch anything else.</p>
<hr />
<h2>services/ — Business Logic, No HTTP</h2>
<p>Services contain the actual work. No <code>Request</code> objects, no <code>Response</code> objects, no FastAPI imports. Pure Python functions that take inputs and return outputs.</p>
<p>This matters for testing — services can be tested directly without spinning up an HTTP server, and without mocking the FastAPI layer.</p>
<pre><code class="language-python"># services/publisher.py
from google.cloud import pubsub_v1
from config import settings
import json
import logging

logger = logging.getLogger(__name__)
publisher = pubsub_v1.PublisherClient()

async def publish_event(event: dict) -&gt; str:
    topic_path = resolve_topic(event["event_type"])
    future = publisher.publish(
        topic_path,
        data=json.dumps(event, default=str).encode(),
        ordering_key=event["user_id"],
        event_type=event["event_type"],
    )
    return future.result(timeout=10)
</code></pre>
<hr />
<h2>The Rules the Team Follows</h2>
<p><strong>Routes call services. Services call db queries. Services don't call routes.</strong> The dependency direction is one-way. A router can call a service. A service can call a db query. Nothing calls back up the chain.</p>
<p><strong>No business logic in routes.</strong> A route handler should be readable in 10 lines. If it's longer, something belongs in <code>services/</code>.</p>
<p><strong>No direct DB access outside</strong> <code>db/</code><strong>.</strong> Every SQL query lives in <code>db/queries/</code>. Routes and services never construct SQL strings directly.</p>
<p><strong>One model per concern.</strong> API input schema, database write schema, and event payload schema are separate models — even if they look similar today. They'll diverge, and when they do, having them separate costs nothing. Having them merged costs a refactor.</p>
<hr />
<p>The structure above isn't the only valid one. It's the one that reduced merge conflicts, made onboarding faster, and gave every engineer on a five-person team a clear answer to "where does this go?"</p>
<p>That's the bar worth optimising for.</p>
]]></content:encoded></item><item><title><![CDATA[Terraform Modules That Don't Break When Your Team Grows]]></title><description><![CDATA[Six months into a project, our main.tf was 800 lines long.
Every resource — Cloud Run services, Pub/Sub topics, Cloud SQL, Redis, IAM bindings, secrets — lived in one file. Nobody wanted to touch it. ]]></description><link>https://blog.madhav.dev/terraform-modules-that-don-t-break-when-your-team-grows</link><guid isPermaLink="true">https://blog.madhav.dev/terraform-modules-that-don-t-break-when-your-team-grows</guid><category><![CDATA[Terraform]]></category><category><![CDATA[Infrastructure as code]]></category><category><![CDATA[GCP]]></category><category><![CDATA[Devops]]></category><category><![CDATA[modules]]></category><category><![CDATA[#HCL]]></category><category><![CDATA[Backend Engineering]]></category><dc:creator><![CDATA[Madhav Bhasin]]></dc:creator><pubDate>Tue, 11 Aug 2026 10:52:58 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/622ac3bee552e99d82e59b17/3631ce4a-b708-43d1-a8b1-f530122a4e0b.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Six months into a project, our <code>main.tf</code> was 800 lines long.</p>
<p>Every resource — Cloud Run services, Pub/Sub topics, Cloud SQL, Redis, IAM bindings, secrets — lived in one file. Nobody wanted to touch it. Adding a new Cloud Run service meant scrolling through hundreds of lines to find the right place, hoping you didn't accidentally modify something adjacent. Reviewing a PR meant reading a diff that changed lines 47, 312, and 651 with no obvious relationship between them.</p>
<p>The file worked. It was just impossible to reason about.</p>
<p>Modules are the fix. Not because they're the Terraform-approved way to do things, but because they enforce the same principle that makes application code maintainable: one thing, one place, clear boundaries.</p>
<hr />
<h2>What a Module Is</h2>
<p>A module is a directory with Terraform files. That's it. You call it with <code>module</code> block, pass in variables, and get outputs back. It's a function for infrastructure.</p>
<pre><code class="language-hcl"># Without modules — everything inline in main.tf
resource "google_cloud_run_v2_service" "producer" { ... }
resource "google_cloud_run_v2_service" "consumer" { ... }
resource "google_pubsub_topic" "commerce_events" { ... }
resource "google_pubsub_subscription" "realtime" { ... }
resource "google_sql_database_instance" "main" { ... }
# ... 750 more lines

# With modules — main.tf becomes a composition
module "pubsub" {
  source         = "./modules/pubsub"
  project_id     = var.gcp_project_id
  environment    = var.environment
}

module "cloud_run" {
  source               = "./modules/cloud_run"
  project_id           = var.gcp_project_id
  producer_image       = var.producer_image
  consumer_image       = var.consumer_image
  consumer_url         = module.pubsub.consumer_push_url
}

module "database" {
  source      = "./modules/database"
  project_id  = var.gcp_project_id
  environment = var.environment
  tier        = var.db_tier
}
</code></pre>
<p>The top-level <code>main.tf</code> is now a readable description of what the system is. The details live in the modules.</p>
<hr />
<h2>Module Structure That Scales</h2>
<pre><code class="language-plaintext">infra/
├── main.tf           # module composition only
├── variables.tf      # top-level inputs
├── outputs.tf        # top-level outputs
├── versions.tf       # provider + backend config
├── environments/
│   ├── dev.tfvars
│   └── prod.tfvars
└── modules/
    ├── pubsub/
    │   ├── main.tf
    │   ├── variables.tf
    │   └── outputs.tf
    ├── cloud_run/
    │   ├── main.tf
    │   ├── variables.tf
    │   └── outputs.tf
    ├── database/
    │   ├── main.tf
    │   ├── variables.tf
    │   └── outputs.tf
    └── redis/
        ├── main.tf
        ├── variables.tf
        └── outputs.tf
</code></pre>
<p>Every module has three files. <code>main.tf</code> declares resources. <code>variables.tf</code> declares inputs. <code>outputs.tf</code> declares what the module exposes to the outside. Nothing else.</p>
<p>The rule: if a resource belongs to a specific service or concern, it lives in that module. If you're not sure which module a resource belongs to, that's a signal the module boundaries need rethinking.</p>
<hr />
<h2>Writing a Module That's Actually Reusable</h2>
<p>A module that works only for one exact configuration isn't a module — it's just a file split. The variables interface is what makes a module reusable.</p>
<pre><code class="language-hcl"># modules/cloud_run/variables.tf
variable "project_id" {
  type        = string
  description = "GCP project ID"
}

variable "region" {
  type        = string
  default     = "us-central1"
}

variable "producer_image" {
  type        = string
  description = "Docker image URI for the producer service"
}

variable "min_instances" {
  type        = number
  default     = 0
  description = "Minimum Cloud Run instances. Set to 1+ in prod to avoid cold starts."
}

variable "max_instances" {
  type        = number
  default     = 10
}

variable "service_account_email" {
  type        = string
}
</code></pre>
<pre><code class="language-hcl"># modules/cloud_run/main.tf
resource "google_cloud_run_v2_service" "producer" {
  name     = "pulsecart-producer"
  location = var.region

  template {
    scaling {
      min_instance_count = var.min_instances
      max_instance_count = var.max_instances
    }

    containers {
      image = var.producer_image
    }

    service_account = var.service_account_email
  }
}
</code></pre>
<pre><code class="language-hcl"># modules/cloud_run/outputs.tf
output "producer_url" {
  value       = google_cloud_run_v2_service.producer.uri
  description = "URL of the deployed producer service"
}
</code></pre>
<pre><code class="language-hcl"># environments/prod.tfvars
min_instances = 1
max_instances = 20
db_tier       = "db-custom-4-15360"

# environments/dev.tfvars
min_instances = 0
max_instances = 5
db_tier       = "db-g1-small"
</code></pre>
<p>The same module, two environments, zero duplication. Changing the prod scaling config is one line in <code>prod.tfvars</code> — not a search through 800 lines of <code>main.tf</code>.</p>
<hr />
<h2>Module Outputs and Cross-Module Dependencies</h2>
<p>Modules need to talk to each other. The Cloud Run module needs the Pub/Sub push endpoint. The Pub/Sub module needs the Cloud Run service URL for the push subscription. Outputs wire them together cleanly:</p>
<pre><code class="language-hcl"># main.tf — passing outputs between modules
module "cloud_run" {
  source    = "./modules/cloud_run"
  # Pass the consumer URL to Pub/Sub so it knows where to push
  consumer_url = module.cloud_run.consumer_url  # ← circular?
}

module "pubsub" {
  source         = "./modules/pubsub"
  consumer_push_url = module.cloud_run.consumer_url  # ← resolved at plan time
}
</code></pre>
<p>Terraform resolves inter-module dependencies automatically at plan time — it builds a dependency graph and applies resources in the right order. You don't need to manage this manually.</p>
<p>Watch for genuine circular dependencies (module A needs an output from module B, which needs an output from module A). These require restructuring — usually by extracting the shared resource into a third module that both depend on.</p>
<hr />
<h2>The One Rule</h2>
<p><strong>Each module owns exactly one concern.</strong> Pub/Sub owns topics and subscriptions. Cloud Run owns services and revisions. Database owns the Cloud SQL instance and its config. When a module starts owning two unrelated things, split it.</p>
<p>This rule is what keeps modules reviewable. A PR that touches only <code>modules/pubsub/</code> tells a reviewer exactly what changed and why. A PR that touches <code>main.tf</code> line 47, 312, and 651 tells them nothing.</p>
]]></content:encoded></item><item><title><![CDATA[Terraform for Application Engineers: Why You Should Own Your Infrastructure]]></title><description><![CDATA[At 11pm on a Tuesday, a Cloud Run service stopped accepting traffic. No deployment had happened. No code had changed. The service was running — Cloud Run showed healthy instances — but requests were t]]></description><link>https://blog.madhav.dev/terraform-for-application-engineers-why-you-should-own-your-infrastructure</link><guid isPermaLink="true">https://blog.madhav.dev/terraform-for-application-engineers-why-you-should-own-your-infrastructure</guid><category><![CDATA[Terraform]]></category><category><![CDATA[Infrastructure as code]]></category><category><![CDATA[GCP]]></category><category><![CDATA[Devops]]></category><category><![CDATA[cloud run]]></category><category><![CDATA[Backend Engineering]]></category><dc:creator><![CDATA[Madhav Bhasin]]></dc:creator><pubDate>Sun, 09 Aug 2026 08:35:53 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/622ac3bee552e99d82e59b17/d2f72ac9-b83e-4be2-a42e-6df7d4b4140f.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>At 11pm on a Tuesday, a Cloud Run service stopped accepting traffic. No deployment had happened. No code had changed. The service was running — Cloud Run showed healthy instances — but requests were timing out at the load balancer.</p>
<p>It took two hours to find the problem. Someone had manually updated a firewall rule in the GCP Console three days earlier to test something locally. The change had never been reverted. It had been sitting there silently breaking a specific traffic path until load shifted to expose it.</p>
<p>Nobody on the team knew the rule existed. It wasn't in code. It wasn't in version control. It wasn't in any PR or deployment log. It was a click in a console that lived only in one person's memory — and that person was asleep.</p>
<p>This is what infrastructure without ownership looks like.</p>
<hr />
<h2>The "Ops Team Handles It" Assumption</h2>
<p>There's a common assumption in engineering teams — especially small ones — that infrastructure is someone else's problem. Developers write the application. Someone else (an ops engineer, a DevOps team, a more senior engineer) handles the cloud config.</p>
<p>This assumption has a cost that's invisible until it isn't.</p>
<p>When infrastructure lives outside the application team's ownership, nobody knows what's actually running. Resources get created manually and never documented. Configuration drifts from what was intended. When something breaks at 11pm, the engineers who are on-call don't have the context to debug it because they never owned the thing that broke.</p>
<p>At a small team of four or five engineers — where there is no separate ops function — this problem is worse. If the lead engineer is the only one who knows how the infrastructure is configured, the team has a single point of failure that isn't visible in any architecture diagram.</p>
<p>Ownership doesn't mean everyone needs to be an infrastructure expert. It means the infrastructure is legible to the people who depend on it.</p>
<hr />
<h2>What "Owning" Infrastructure Actually Means</h2>
<p>Owning your infrastructure doesn't mean clicking around in the GCP or AWS Console. It means the infrastructure is defined as code, version-controlled alongside the application, reviewable in a PR, and reproducible from scratch.</p>
<p>When infrastructure is code:</p>
<p><strong>It's auditable.</strong> The firewall rule incident above couldn't happen with Terraform. Any change to infrastructure goes through a PR. The PR shows exactly what changes, who approved it, and why. The history is in Git, not in someone's memory.</p>
<p><strong>It's reproducible.</strong> Spinning up a staging environment means running <code>terraform apply</code> against a different variable file. It doesn't mean spending two days manually recreating what you think production looks like.</p>
<p><strong>It's shared context.</strong> A new engineer who joins the team can read the Terraform code and understand exactly what's running — what services exist, how they're connected, what the scaling config is. They don't need to ask.</p>
<p><strong>It fails loudly.</strong> <code>terraform plan</code> shows you what will change before anything changes. The console doesn't.</p>
<hr />
<h2>Why Application Engineers Specifically</h2>
<p>Infrastructure-as-code tools like Terraform are often positioned as a DevOps or platform engineering concern. That framing is part of the problem.</p>
<p>Application engineers are the ones who understand what the application needs — how it scales, what it connects to, what its failure modes are. A DevOps engineer who writes Terraform for a service they don't understand will provision something technically correct but architecturally wrong. They'll set a max instance count that's too low. They'll miss a dependency. They'll configure a health check that doesn't reflect what the service actually needs to be healthy.</p>
<p>The engineer who built the Cloud Run service is the right person to write the Terraform for it. Not because DevOps engineers aren't capable, but because application context and infrastructure config belong together.</p>
<p>On PulseCart — the event-driven pipeline built across this blog — every Terraform resource was written by the same engineers who wrote the FastAPI services and the Pub/Sub consumers. The Cloud Run autoscaling config reflected what we knew about traffic patterns. The dead-letter topic retry policy reflected what we knew about consumer failure modes. The infrastructure made sense because the people who wrote it understood what it was for.</p>
<hr />
<h2>The Objection: "I Don't Have Time to Learn Terraform"</h2>
<p>Fair. Terraform has a learning curve. The HCL syntax is unfamiliar at first. State management has rough edges. Debugging a plan that's doing something unexpected takes practice.</p>
<p>But the alternative isn't "no infrastructure cost." The alternative is debugging production incidents caused by config nobody understands, rebuilding environments from memory when something goes wrong, and onboarding new engineers into a system that exists only in the console and in the heads of whoever set it up.</p>
<p>The time you spend learning Terraform is paid back the first time you spin up a staging environment in 20 minutes instead of two days. It's paid back the second time you can point at a PR and say "that's the change that caused it." It's paid back every time you hand a new engineer a codebase that includes the infrastructure and they don't need to ask where anything is.</p>
<hr />
<h2>Start Small, Start Now</h2>
<p>You don't need to Terraform everything at once. Pick one service — the one you understand best — and write the infrastructure for it. Get it into version control. Run <code>terraform plan</code> before you apply anything. See what it feels like to have a change history for your infrastructure the way you have one for your code.</p>
<p>The firewall rule that broke our service at 11pm would have been a three-line PR. Someone would have asked why it was needed. It would have been reverted when the test was done, or at minimum documented when it wasn't.</p>
<p>That's the whole argument. Infrastructure in code is infrastructure you can reason about.</p>
]]></content:encoded></item><item><title><![CDATA[What Leading a 4-Person Engineering Team Actually Looks Like]]></title><description><![CDATA[I became an engineering lead without a transition plan. One week I was an individual contributor shipping features. The next I was responsible for four engineers, client deadlines, sprint planning, co]]></description><link>https://blog.madhav.dev/what-leading-a-4-person-engineering-team-actually-looks-like</link><guid isPermaLink="true">https://blog.madhav.dev/what-leading-a-4-person-engineering-team-actually-looks-like</guid><category><![CDATA[engineering leadership]]></category><category><![CDATA[Software Engineering]]></category><category><![CDATA[Team Management]]></category><category><![CDATA[Engineering culture]]></category><category><![CDATA[tech leadership]]></category><dc:creator><![CDATA[Madhav Bhasin]]></dc:creator><pubDate>Fri, 07 Aug 2026 08:56:13 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/622ac3bee552e99d82e59b17/714b5823-09f8-44e7-93d8-442298f43d82.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>I became an engineering lead without a transition plan. One week I was an individual contributor shipping features. The next I was responsible for four engineers, client deadlines, sprint planning, code reviews, and making sure nothing fell through the cracks. Nobody handed me a playbook. I figured it out by making mistakes, some of which cost us real time and real credibility.</p>
<p>This is what I wish I'd known in the first six months.</p>
<hr />
<h2>Mistake 1: I Kept Writing Code Like an IC</h2>
<p>The first instinct when you become a lead is to keep doing what made you good — writing code. It feels productive. It's measurable. And for the first few weeks, it's fine.</p>
<p>Then sprint planning gets skipped because you're heads-down on a feature. A junior engineer is blocked for two days because you're in flow and didn't check in. A client requirement gets misinterpreted because nobody translated it properly into a ticket, and you weren't in the room when it was discussed.</p>
<p>I was individually productive and collectively a bottleneck.</p>
<p>The mindset shift that fixed it: your output is no longer lines of code. It's the velocity and quality of the team. A day spent unblocking three engineers, clarifying requirements, and reviewing PRs carefully is a more productive day than a day spent shipping a feature yourself — even if it doesn't feel that way.</p>
<p>I still write code. I own specific modules, handle architectural spikes, and stay close enough to the codebase to do meaningful reviews. But I no longer treat it as the primary metric of a good day.</p>
<hr />
<h2>Mistake 2: I Assumed Requirements Were Clear When They Weren't</h2>
<p>Client requirements arrive in many forms — a Notion doc, a Slack message, a 45-minute call, a Figma file with comments. Early on, I'd take whatever arrived, mentally fill in the gaps, and write tickets based on my interpretation.</p>
<p>Engineers would build against those tickets. Then the client would see the result and say "that's not quite what we meant." Not because the engineers built it wrong — they built exactly what the ticket said. The ticket was wrong.</p>
<p>The gap between what a client says and what they mean is almost always larger than it looks. Now before any ticket gets written, I ask three questions: What does success look like for the user? What's explicitly out of scope? What's the edge case nobody's thought about yet? The answers to those three questions change almost every requirement I've ever received.</p>
<p>Writing tickets well is an underrated engineering skill. A well-written ticket — clear acceptance criteria, explicit non-goals, a definition of done — reduces back-and-forth, reduces misbuilds, and makes code review faster because the reviewer knows exactly what to evaluate against.</p>
<hr />
<h2>Mistake 3: I Gave Feedback Only in Code Review</h2>
<p>For the first few months, my feedback to engineers lived almost entirely in PR comments. Inline notes on code quality, architecture choices, naming, test coverage. Detailed, sometimes lengthy, delivered asynchronously after the fact.</p>
<p>This is a bad feedback loop. By the time someone gets a PR comment, they've already spent hours building in the wrong direction. Fixing it means rework, which is demoralising and wastes time. And async text-based feedback strips all the nuance — what reads as a sharp critique in a comment lands completely differently in a five-minute conversation.</p>
<p>Two things changed this. First, I started having brief design conversations before implementation, not after. Five minutes discussing the approach before someone writes 200 lines of code saves hours of rework. Second, I made feedback in code review additive rather than corrective — pointing out what's working as explicitly as what isn't, so engineers understood their own strengths and weren't only receiving criticism.</p>
<p>PR reviews got shorter. Rework dropped significantly. The quality of the code that arrived for review improved because engineers were better calibrated before they started.</p>
<hr />
<h2>Mistake 4: I Didn't Protect the Team's Focus</h2>
<p>Clients communicate directly with engineers in Slack. A question here, a small change request there, a "quick clarification" that turns into a 30-minute tangent. Each one feels minor. Collectively they destroy deep work.</p>
<p>I underestimated how much this mattered early on. An engineer context-switching three times in a morning has effectively lost that morning. The cost isn't the interruption — it's the recovery time after each one.</p>
<p>Now client-facing communication routes through me first. Engineers aren't hidden from clients — they're in calls, they demo features, they answer technical questions. But they're not on the receiving end of every ad-hoc request. I take the interrupt, translate it into a ticket if it needs work, and the engineer gets a clean, scoped ask instead of a half-formed request with missing context.</p>
<p>This also benefits clients. They get faster, clearer responses than they did when questions landed in a channel and waited for whoever was least busy to notice.</p>
<hr />
<h2>Mistake 5: I Waited Too Long to Have Hard Conversations</h2>
<p>Two situations in the first six months where I noticed a problem — a deadline at risk, a quality issue on a specific engineer's output, a communication pattern that was creating friction — and said nothing for too long. I told myself I was gathering more data. I was avoiding discomfort.</p>
<p>Both situations got worse before I addressed them. The conversations I eventually had were harder because the issues had compounded. And both engineers later told me, separately, that they'd have preferred to hear it earlier.</p>
<p>Feedback given early is a gift. Feedback withheld until it becomes an incident is a problem. I learned to say something within a day or two of noticing an issue, directly and without softening it into ambiguity. "I noticed the last three PRs have had recurring issues with error handling — let's talk about it" is better than waiting until a client flags a bug.</p>
<p>The conversation doesn't have to be long. It doesn't have to be formal. It just has to happen.</p>
<hr />
<h2>Mistake 6: I Didn't Invest in Onboarding</h2>
<p>When a new engineer joined the team mid-year, I handed them a GitHub repo link, a Notion doc with some context, and told them to ping me with questions. I was in the middle of a delivery crunch and didn't have time to do it properly.</p>
<p>They were unproductive for three weeks. Three weeks of context-gathering through trial and error, confusion about conventions, and questions that interrupted everyone else on the team.</p>
<p>A proper onboarding doc — architecture overview, environment setup, coding conventions, how we write tickets, what good looks like on this team — takes half a day to write and pays back immediately. Every engineer who joins after spends less time confused and less time interrupting others. I wrote one after that experience. It's now the first thing a new engineer gets.</p>
<hr />
<h2>What the Job Actually Is</h2>
<p>Engineering leadership at a small team isn't management in the traditional sense. There's no org chart to navigate, no performance review cycles, no HR processes. It's closer to: make sure the right things get built correctly, by people who have what they need to do their best work.</p>
<p>The mistakes above all share a root cause — I was optimising for my own productivity and comfort rather than the team's. Staying in code because it felt familiar. Avoiding hard conversations because they were uncomfortable. Skipping onboarding because I was busy.</p>
<p>The job got easier when I accepted that my discomfort is often the signal that I'm doing it right.</p>
<hr />
<p>Six months in, I was a better lead than I was at the start. Twelve months in, I was significantly better than six months. The gap between where I started and where I am now is almost entirely made up of mistakes I don't make twice.</p>
<p>That's probably how it works for everyone.</p>
]]></content:encoded></item><item><title><![CDATA[FastAPI Background Tasks vs Pub/Sub vs Cloud Tasks: When to Use What]]></title><description><![CDATA[The queue had been growing for six days before anyone noticed. A Cloud Tasks queue processing cart abandonment reminders had started backing up after a handler deployment introduced a subtle bug — an ]]></description><link>https://blog.madhav.dev/fastapi-background-tasks-vs-pub-sub-vs-cloud-tasks-when-to-use-what</link><guid isPermaLink="true">https://blog.madhav.dev/fastapi-background-tasks-vs-pub-sub-vs-cloud-tasks-when-to-use-what</guid><category><![CDATA[FastAPI]]></category><category><![CDATA[Python]]></category><category><![CDATA[cloud tasks]]></category><category><![CDATA[GCP]]></category><category><![CDATA[Backend Engineering]]></category><dc:creator><![CDATA[Madhav Bhasin]]></dc:creator><pubDate>Tue, 04 Aug 2026 07:05:29 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/622ac3bee552e99d82e59b17/96105c49-80e1-40c4-b881-c61189bbe2d2.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>The queue had been growing for six days before anyone noticed. A Cloud Tasks queue processing cart abandonment reminders had started backing up after a handler deployment introduced a subtle bug — an unhandled edge case that caused tasks to fail silently and retry indefinitely. No alert, no dashboard, no on-call ping. Just 140,000 tasks sitting in a queue, retrying every 5 minutes, hammering a downstream email service.</p>
<p>The bug was a one-line fix. The 140,000 retries took two days to drain.</p>
<p>The real problem wasn't the bug. It was that nobody had set up queue depth alerting, because nobody had explicitly decided that Cloud Tasks was the right tool and what "healthy" looked like for it. The choice had been made by default.</p>
<p>Don't choose your async pattern by default.</p>
<hr />
<h2>The Decision Framework</h2>
<p>Three questions determine which tool to use:</p>
<p><strong>1. Can you afford to lose this work if the process restarts?</strong> If yes → FastAPI background tasks. If no → Pub/Sub or Cloud Tasks.</p>
<p><strong>2. Does the work need to happen at a specific time in the future?</strong> If yes → Cloud Tasks. If no → Pub/Sub.</p>
<p><strong>3. Do multiple independent consumers need to react to the same event?</strong> If yes → Pub/Sub. If no → Cloud Tasks or background tasks.</p>
<table>
<thead>
<tr>
<th></th>
<th>Background Tasks</th>
<th>Pub/Sub</th>
<th>Cloud Tasks</th>
</tr>
</thead>
<tbody><tr>
<td>Survives process restart</td>
<td>❌</td>
<td>✅</td>
<td>✅</td>
</tr>
<tr>
<td>Delayed / scheduled execution</td>
<td>❌</td>
<td>❌</td>
<td>✅</td>
</tr>
<tr>
<td>Multiple consumers</td>
<td>❌</td>
<td>✅</td>
<td>❌</td>
</tr>
<tr>
<td>Built-in retry</td>
<td>❌</td>
<td>✅</td>
<td>✅</td>
</tr>
<tr>
<td>Deduplication</td>
<td>❌</td>
<td>Manual (Redis)</td>
<td>✅ (task name)</td>
</tr>
<tr>
<td>Observability</td>
<td>❌</td>
<td>Cloud Monitoring</td>
<td>Cloud Monitoring</td>
</tr>
</tbody></table>
<hr />
<h2>FastAPI Background Tasks — Use Sparingly</h2>
<p>FastAPI's <code>BackgroundTasks</code> runs work in the same process as the request, after the response is sent. It's the simplest option and the most dangerous one to overuse.</p>
<pre><code class="language-python">from fastapi import BackgroundTasks

def send_welcome_email(user_id: str):
    # runs after response is returned
    email_service.send(user_id, template="welcome")

@app.post("/users", status_code=201)
async def create_user(payload: UserCreate, background_tasks: BackgroundTasks):
    user = await db.create_user(payload)
    background_tasks.add_task(send_welcome_email, user.id)
    return user
</code></pre>
<p><strong>What it's good for:</strong> fire-and-forget work where loss is acceptable — sending a welcome email, logging an analytics event, invalidating a cache. Low volume, low stakes.</p>
<p><strong>Where it breaks:</strong> Cloud Run scales to zero. When an instance shuts down, any in-flight background tasks die with it. There's no queue, no retry, no visibility. At moderate traffic, you won't notice. At scale, you will.</p>
<p>Never use background tasks for anything that must complete — payment confirmations, order receipts, inventory updates.</p>
<hr />
<h2>Pub/Sub — Use for Event-Driven Fan-Out</h2>
<p>Pub/Sub is the right choice when an event needs to reach multiple independent consumers, or when you want the producer completely decoupled from what happens next.</p>
<pre><code class="language-python"># Producer — doesn't know or care who's consuming
async def publish_order_event(order: Order):
    await publisher.publish(
        topic_path,
        data=order.model_dump_json().encode(),
        event_type="order.placed",
        ordering_key=order.user_id,
    )

# Consumer 1 — sends confirmation email
# Consumer 2 — updates inventory
# Consumer 3 — feeds personalization score
# All subscribe independently, all receive the same message
</code></pre>
<p><strong>What it's good for:</strong> anything event-driven where multiple things need to react — the PulseCart pipeline is built entirely on this. Also the right choice when consumers are owned by different teams and should be independently deployable.</p>
<p><strong>Where it breaks:</strong> Pub/Sub is not a scheduler. You can't say "deliver this message in 2 hours." You can't guarantee exactly-once delivery without a Redis deduplication layer (covered in PulseCart Day 4). And dead-letter queues need explicit setup — messages don't automatically surface when they fail.</p>
<hr />
<h2>Cloud Tasks — Use for Delayed and Retryable Work</h2>
<p>Cloud Tasks is the right choice when work needs to happen at a specific time, needs explicit retry control, or needs to check state at execution time rather than at event time.</p>
<pre><code class="language-python">from google.cloud import tasks_v2
from google.protobuf import timestamp_pb2
import datetime

async def schedule_abandonment_reminder(user_id: str, cart_id: str, event_id: str):
    scheduled_time = datetime.datetime.utcnow() + datetime.timedelta(hours=2)

    timestamp = timestamp_pb2.Timestamp()
    timestamp.FromDatetime(scheduled_time)

    task = {
        "http_request": {
            "http_method": tasks_v2.HttpMethod.POST,
            "url": f"{HANDLER_URL}/tasks/cart-reminder",
            "body": json.dumps({
                "user_id": user_id,
                "cart_id": cart_id,
                "event_id": event_id,
            }).encode(),
            "oidc_token": {"service_account_email": SERVICE_ACCOUNT},
        },
        "schedule_time": timestamp,
        "name": f"{QUEUE_PATH}/tasks/reminder-{event_id}",  # deterministic name = free dedup
    }

    tasks_client.create_task(request={"parent": QUEUE_PATH, "task": task})
</code></pre>
<p>The handler re-validates state at execution time — if the cart was purchased in the two hours since scheduling, the task exits cleanly without sending anything. This is the pattern you can't replicate with Pub/Sub alone.</p>
<p><strong>What it's good for:</strong> delayed workflows (cart reminders, payment retry after N minutes), work that needs idempotency without a Redis layer, and any job where you want per-task retry configuration rather than subscription-level policy.</p>
<p><strong>Where it breaks:</strong> Cloud Tasks is one consumer per task. If you need fan-out, it's the wrong tool. And as the story at the top shows — queue depth alerting is not optional. Set it up before the queue goes live, not after.</p>
<hr />
<h2>The Alerting You Need for Cloud Tasks</h2>
<p>The lesson from the opening story:</p>
<pre><code class="language-hcl"># Terraform — Cloud Tasks queue depth alert
resource "google_monitoring_alert_policy" "tasks_queue_depth" {
  display_name = "Cart Reminders Queue Depth High"

  conditions {
    display_name = "Queue depth &gt; 5000 for 10 minutes"
    condition_threshold {
      filter     = "resource.type=\"cloudtasks.googleapis.com/Queue\" AND metric.type=\"cloudtasks.googleapis.com/queue/depth\""
      threshold_value = 5000
      duration        = "600s"
      comparison      = "COMPARISON_GT"
    }
  }

  notification_channels = [var.alert_channel_id]
}
</code></pre>
<p>Add this at provisioning time, not after an incident.</p>
<hr />
<h2>The One-Line Summary</h2>
<p>Use <strong>background tasks</strong> for low-stakes fire-and-forget. Use <strong>Pub/Sub</strong> when multiple consumers need the same event. Use <strong>Cloud Tasks</strong> when timing, retries, or state re-validation matter.</p>
<p>When in doubt, Cloud Tasks over background tasks — the observability alone is worth it.</p>
]]></content:encoded></item><item><title><![CDATA[I Migrated a Product from AWS to GCP — Here's What I'd Do Differently]]></title><description><![CDATA[Last year we moved a production platform off AWS and onto GCP. Full migration — compute, database, storage, CDN, CI/CD, the works. The product was live, clients were active, and we had no meaningful d]]></description><link>https://blog.madhav.dev/i-migrated-a-product-from-aws-to-gcp-here-s-what-i-d-do-differently</link><guid isPermaLink="true">https://blog.madhav.dev/i-migrated-a-product-from-aws-to-gcp-here-s-what-i-d-do-differently</guid><category><![CDATA[GCP]]></category><category><![CDATA[AWS]]></category><category><![CDATA[Cloud Migration]]></category><category><![CDATA[Devops]]></category><category><![CDATA[Backend Engineering]]></category><dc:creator><![CDATA[Madhav Bhasin]]></dc:creator><pubDate>Wed, 29 Jul 2026 01:11:22 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/622ac3bee552e99d82e59b17/e58b083a-2dce-48e2-9797-14d844ddd16f.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Last year we moved a production platform off AWS and onto GCP. Full migration — compute, database, storage, CDN, CI/CD, the works. The product was live, clients were active, and we had no meaningful downtime budget.</p>
<p>It went well enough. But "well enough" means we made mistakes we didn't have to make. Here's what actually happened, in order, and what I'd do differently.</p>
<hr />
<h2>Step 1: We Audited the AWS Stack First</h2>
<p>What we did: before touching GCP, we spent a week mapping every AWS service in use — ECS Fargate, RDS Postgres, S3, CloudFront, SES, Secrets Manager, Route 53 — and found the GCP equivalent for each.</p>
<p>This was the right call. The mapping exercise surfaced two services we'd forgotten were even running (a legacy SQS queue and a Lambda that ran once a week), and forced us to make explicit decisions about what to migrate vs. what to retire.</p>
<p><strong>What I'd do the same:</strong> the audit. Non-negotiable. You will find things you forgot existed.</p>
<p><strong>What I'd do differently:</strong> involve the whole team earlier. I did most of the audit myself, which meant others were context-free when implementation started. A shared doc with the mapping, built collaboratively, would have halved the onboarding time later.</p>
<hr />
<h2>Step 2: We Wrote Terraform Before Touching the Console</h2>
<p>What we did: we committed to 100% Infrastructure as Code from day one. No GCP Console clicks that weren't codified in Terraform. Every resource — Cloud Run services, Cloud SQL instance, Redis Memorystore, Pub/Sub topics, Cloud Tasks queues — was provisioned via Terraform with remote state in GCS.</p>
<p>This was the best decision we made. When we needed to spin up a staging environment mid-migration, it took 20 minutes, not two days.</p>
<p><strong>What I'd do the same:</strong> everything here. IaC from day one, no exceptions.</p>
<p><strong>What I'd do differently:</strong> set up the Terraform module structure earlier. We started with a flat <code>main.tf</code> and refactored into modules halfway through when it got unwieldy. Starting modular costs nothing and saves a painful refactor.</p>
<hr />
<h2>Step 3: We Migrated Compute Before Data</h2>
<p>What we did: we deployed the application services to Cloud Run first, kept them pointed at the existing AWS RDS Postgres instance, and validated the full stack was working before touching the database.</p>
<p>In theory this was smart — decouple the compute migration from the data migration. In practice it meant our Cloud Run services in GCP were making cross-cloud calls to RDS in AWS for three weeks, adding ~40ms of latency on every database call. Clients noticed.</p>
<p><strong>What I'd do differently:</strong> plan the database migration timeline before starting compute. Three weeks of cross-cloud latency was avoidable. A tighter migration window — compute and database moved within the same week — would have been uncomfortable but faster.</p>
<hr />
<h2>Step 4: The Database Migration Was the Hardest Part</h2>
<p>What we did: we used AWS DMS (Database Migration Service) to stream changes from RDS Postgres to Cloud SQL during a live migration window. The plan was to run both databases in sync, then do a cutover with a short maintenance window.</p>
<p>DMS had opinions about our schema we hadn't anticipated. Several constraints didn't replicate cleanly, and we spent two days debugging replication lag before the cutover. The actual maintenance window was 47 minutes — longer than the 15 we'd planned.</p>
<p><strong>What I'd do differently:</strong> test the full DMS pipeline on a production-sized data copy at least two weeks before the actual cutover. We tested on a small subset, which didn't surface the schema issues. Test at scale, with real data volume.</p>
<p>Also: <code>pg_dump</code> / <code>pg_restore</code> for smaller databases (under ~50GB) is less exciting than DMS but dramatically less surprising. We overcomplicated it.</p>
<hr />
<h2>Step 5: We Underestimated Secret Migration</h2>
<p>What we did: we had ~30 secrets in AWS Secrets Manager — API keys, DB credentials, service tokens. We manually recreated them in GCP Secret Manager. It took half a day and introduced two errors we caught in staging (wrong value copy-pasted, one secret missed entirely).</p>
<p><strong>What I'd do differently:</strong> script it. Even a simple Python script that reads from AWS Secrets Manager and writes to GCP Secret Manager would have been faster, auditable, and error-free. Manual secret migration at any meaningful scale is asking for problems.</p>
<hr />
<h2>Step 6: CI/CD Was the Easiest Part</h2>
<p>What we did: replaced GitLab CI pipelines pointing at ECS Fargate with GitHub Actions pipelines pointing at Cloud Run. The logic was near-identical — build Docker image, push to registry, deploy.</p>
<p>Switching to Workload Identity Federation instead of long-lived service account keys added a day of setup but was worth it. No credentials to rotate, no keys to accidentally commit.</p>
<p><strong>What I'd do the same:</strong> Workload Identity Federation from the start. Not an afterthought.</p>
<hr />
<h2>What I'd Tell Myself Before Starting</h2>
<p><strong>Audit everything, including the things you think you know.</strong> You will find forgotten services.</p>
<p><strong>IaC from day one, modular from day one.</strong> Refactoring Terraform mid-migration is pain you don't need.</p>
<p><strong>Test the database migration at production scale, weeks early.</strong> Schema surprises at cutover time are expensive.</p>
<p><strong>Script the boring parts.</strong> Secret migration, DNS cutover checklists, smoke test sequences — anything manual and repetitive should be a script.</p>
<p><strong>Set a tighter timeline for cross-cloud transitional states.</strong> Three weeks of cross-cloud DB latency was a self-inflicted wound.</p>
<p>The migration took eight weeks end to end. With these changes, I think six was achievable — and the last two weeks would have been less stressful.</p>
]]></content:encoded></item><item><title><![CDATA[Testing FastAPI Services: Unit, Integration, and Contract Tests]]></title><description><![CDATA[The test suite was green. All 47 tests passing, CI clean, PR merged. Two hours later someone flagged that the staging database had 3,000 rows of test order data in the orders table.
A test that was su]]></description><link>https://blog.madhav.dev/testing-fastapi-services-unit-integration-and-contract-tests</link><guid isPermaLink="true">https://blog.madhav.dev/testing-fastapi-services-unit-integration-and-contract-tests</guid><category><![CDATA[FastAPI]]></category><category><![CDATA[pytest]]></category><category><![CDATA[Python]]></category><category><![CDATA[pydantic]]></category><category><![CDATA[ci-cd]]></category><category><![CDATA[Backend Engineering]]></category><dc:creator><![CDATA[Madhav Bhasin]]></dc:creator><pubDate>Fri, 24 Jul 2026 08:33:58 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/622ac3bee552e99d82e59b17/a4170dfd-5645-4a56-afca-2fb4396ed5e3.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>The test suite was green. All 47 tests passing, CI clean, PR merged. Two hours later someone flagged that the staging database had 3,000 rows of test order data in the <code>orders</code> table.</p>
<p>A test that was supposed to mock the database wasn't. The mock was patched at the wrong path — the module where the function was defined, not where it was imported and used. Real DB calls went through. Real rows got written. Green tests, real side effects.</p>
<p>This is the most common testing mistake in FastAPI services, and it's entirely avoidable.</p>
<hr />
<h2>Project Setup</h2>
<pre><code class="language-plaintext">tests/
├── conftest.py          # shared fixtures: app, client, db, mocks
├── unit/
│   └── test_models.py   # Pydantic validation, pure logic
├── integration/
│   └── test_routes.py   # full request → response against real dependencies
└── contract/
    └── test_contracts.py # schema compatibility between producer and consumer
</code></pre>
<p>One <code>conftest.py</code> at the root. Fixtures flow down — integration tests use the same client fixture as unit tests, just with different dependency overrides.</p>
<hr />
<h2>The Mock Path Problem</h2>
<p>The bug from the story above:</p>
<pre><code class="language-python"># services/orders.py
from db import get_pool  # defined here

async def create_order(payload): ...

# ❌ Wrong — patches where it's defined, not where it's used
@patch("db.get_pool")
async def test_create_order(mock_pool): ...

# ✅ Correct — patch where the function is imported and called
@patch("services.orders.get_pool")
async def test_create_order(mock_pool): ...
</code></pre>
<p>Always patch at the import site, not the definition site. If <code>services/orders.py</code> imports <code>get_pool</code> from <code>db</code>, patch <code>services.orders.get_pool</code>.</p>
<p>FastAPI's dependency injection makes this cleaner — use <code>app.dependency_overrides</code> instead of <code>@patch</code> wherever possible:</p>
<pre><code class="language-python"># conftest.py
import pytest
from httpx import AsyncClient, ASGITransport
from main import app
from db import get_db

async def override_get_db():
    # return a test session or mock
    yield MockDB()

@pytest.fixture
def client():
    app.dependency_overrides[get_db] = override_get_db
    yield  # tests run here
    app.dependency_overrides.clear()  # always clean up

@pytest.fixture
async def async_client(client):
    async with AsyncClient(
        transport=ASGITransport(app=app),
        base_url="http://test"
    ) as ac:
        yield ac
</code></pre>
<p><code>dependency_overrides</code> swaps the real dependency for the test version at the FastAPI layer — no patching required, no wrong-path bugs.</p>
<hr />
<h2>Unit Tests — Pure Logic Only</h2>
<p>Unit tests should have zero I/O. No DB, no HTTP, no Redis. Test Pydantic models, business logic, and utility functions:</p>
<pre><code class="language-python"># tests/unit/test_models.py
import pytest
from models.commerce import OrderPlacedEvent

def test_order_event_requires_amount():
    with pytest.raises(ValueError):
        OrderPlacedEvent(order_id="ord_1", user_id="usr_1", payload={})
        # amount is required — missing field raises

def test_order_event_legacy_field_accepted():
    event = OrderPlacedEvent(
        order_id="ord_1",
        user_id="usr_1",
        payload={"total_amount": 99.99}  # old field name
    )
    assert event.payload["amount"] == 99.99  # transition validator fired
</code></pre>
<p>Fast, no fixtures, no setup. These should run in milliseconds.</p>
<hr />
<h2>Integration Tests — Full Request Stack</h2>
<p>Integration tests hit the actual FastAPI routes with real request/response cycles, but with controlled dependencies:</p>
<pre><code class="language-python"># tests/integration/test_routes.py
import pytest
from httpx import AsyncClient

@pytest.mark.anyio
async def test_ingest_valid_event(async_client: AsyncClient, mock_publisher):
    response = await async_client.post("/events/ingest", json={
        "event_type": "order.placed",
        "user_id": "usr_8821",
        "order_id": "ord_77234",
        "payload": {"amount": 89.99, "currency": "USD"}
    })

    assert response.status_code == 202
    assert "event_id" in response.json()
    mock_publisher.assert_called_once()

@pytest.mark.anyio
async def test_ingest_unknown_event_type(async_client: AsyncClient):
    response = await async_client.post("/events/ingest", json={
        "event_type": "nonexistent.event",
        "user_id": "usr_8821",
        "payload": {}
    })

    assert response.status_code == 422

@pytest.mark.anyio
async def test_health_check_degraded_when_db_down(async_client: AsyncClient, broken_db):
    response = await async_client.get("/health")
    assert response.status_code == 503
</code></pre>
<p>The <code>mock_publisher</code> and <code>broken_db</code> fixtures live in <code>conftest.py</code> and swap real dependencies via <code>dependency_overrides</code>. The routes, validation, exception handlers, and response shapes all get exercised — just not the real infrastructure.</p>
<p>Add <code>anyio</code> mode to <code>pytest.ini</code> or <code>pyproject.toml</code>:</p>
<pre><code class="language-toml">[tool.pytest.ini_options]
anyio_mode = "auto"
</code></pre>
<hr />
<h2>Contract Tests — Schema Compatibility</h2>
<p>Contract tests answer one question: if the producer changes its schema, does the consumer still work?</p>
<pre><code class="language-python"># tests/contract/test_contracts.py
from models.commerce import OrderPlacedEvent   # producer model
from consumer.models import IncomingOrderEvent  # consumer model
import json

def test_producer_output_satisfies_consumer_contract():
    # Simulate what the producer publishes
    producer_event = OrderPlacedEvent(
        order_id="ord_77234",
        user_id="usr_8821",
        payload={"amount": 89.99, "currency": "USD"}
    )

    # Serialize as the producer would (to Pub/Sub)
    published = json.loads(producer_event.model_dump_json())

    # Deserialize as the consumer would
    try:
        IncomingOrderEvent(**published)
    except Exception as e:
        pytest.fail(f"Consumer cannot parse producer output: {e}")
</code></pre>
<p>This test runs in CI on every PR. If a producer field rename would break the consumer, this catches it before merge — not after 3,000 rows of bad data.</p>
<p>For teams using a shared models package (covered in S01E02), this test is even simpler — the same class is imported on both sides, and the contract is enforced by the package version.</p>
<hr />
<h2>CI Configuration</h2>
<pre><code class="language-yaml"># .github/workflows/test.yml
name: Test

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest

    services:
      postgres:
        image: postgres:15
        env:
          POSTGRES_PASSWORD: test
          POSTGRES_DB: pulsecart_test
        options: &gt;-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"

      - run: pip install -r requirements.txt

      - run: pytest tests/ -v --tb=short
        env:
          DATABASE_URL: postgresql://postgres:test@localhost/pulsecart_test
          ENVIRONMENT: test
</code></pre>
<p>Real Postgres in CI via GitHub Actions services — no mocking the DB layer in integration tests, just a throwaway test database that gets created fresh on every run. Unit and contract tests run against it too, but don't touch it.</p>
<hr />
<h2>The Three Rules</h2>
<p><strong>Mock at the import site, not the definition site.</strong> Or better — use <code>dependency_overrides</code> and skip <code>@patch</code> entirely.</p>
<p><strong>Unit tests have zero I/O.</strong> If a unit test needs a database or an HTTP call, it's an integration test.</p>
<p><strong>Contract tests run in CI on every PR.</strong> Schema mismatches are a merge-time problem, not a production problem.</p>
<p><strong>Next: S01E04 — FastAPI Background Tasks vs Pub/Sub vs Cloud Tasks: when each one breaks.</strong></p>
]]></content:encoded></item><item><title><![CDATA[FastAPI + Pydantic in Production: Contracts, Validation, and Versioning]]></title><description><![CDATA[Two FastAPI services. One producing order events, one consuming them. Both running fine in staging. In production, the consumer started silently dropping records.
No exception. No 5xx. Just missing da]]></description><link>https://blog.madhav.dev/fastapi-pydantic-in-production-contracts-validation-and-versioning</link><guid isPermaLink="true">https://blog.madhav.dev/fastapi-pydantic-in-production-contracts-validation-and-versioning</guid><category><![CDATA[FastAPI]]></category><category><![CDATA[pydantic]]></category><category><![CDATA[GCP]]></category><category><![CDATA[backend]]></category><category><![CDATA[Backend Engineering]]></category><category><![CDATA[System Design]]></category><dc:creator><![CDATA[Madhav Bhasin]]></dc:creator><pubDate>Tue, 21 Jul 2026 12:02:07 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/622ac3bee552e99d82e59b17/a4ff24e4-7d44-43bd-af1b-0e253db271a5.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Two FastAPI services. One producing order events, one consuming them. Both running fine in staging. In production, the consumer started silently dropping records.</p>
<p>No exception. No 5xx. Just missing data.</p>
<p>The producer had shipped a field rename — <code>total_amount</code> to <code>amount</code> — in a patch release. The consumer's Pydantic model still expected <code>total_amount</code>. FastAPI validated the incoming payload against the consumer's model, found no <code>total_amount</code>, set it to <code>None</code> (it was <code>Optional</code>), and continued. No error. No alert. Downstream, every order record had a null value where revenue should be.</p>
<p>This is what a schema mismatch looks like in production — not a crash, but silent data corruption.</p>
<hr />
<h2>Pydantic as a Contract, Not Just a Validator</h2>
<p>Most engineers use Pydantic to validate inputs. That's half its value. The other half is using it as an explicit contract between services — a shared definition of what data looks like, enforced at the boundary.</p>
<p>The mistake in the story above was <code>Optional</code> without a default that makes the problem visible. Here's the pattern that would have caught it:</p>
<pre><code class="language-python"># ❌ Silent failure — None slips through
class OrderEvent(BaseModel):
    order_id: str
    user_id: str
    total_amount: Optional[float] = None  # missing field → None, no error

# ✅ Loud failure — missing field raises immediately
class OrderEvent(BaseModel):
    order_id: str
    user_id: str
    amount: float  # required, no default — missing field → ValidationError
</code></pre>
<p>Required fields with no default make schema mismatches loud. If a field is genuinely optional in your domain, make it optional. If it's required, don't give it a default just to suppress validation errors.</p>
<hr />
<h2>Strict Mode for Cross-Service Boundaries</h2>
<p>At internal service boundaries, you want Pydantic to be strict about what it accepts. Extra fields should be rejected, not silently ignored:</p>
<pre><code class="language-python">from pydantic import BaseModel, ConfigDict

class OrderEvent(BaseModel):
    model_config = ConfigDict(extra="forbid")  # reject unknown fields

    order_id: str
    user_id: str
    amount: float
</code></pre>
<p>With <code>extra="forbid"</code>, if the producer sends a field the consumer doesn't recognise, it raises immediately — forcing the teams to align rather than silently diverging. Use this on consumer models at service boundaries. Don't use it on models that accept user input, where extra fields should be ignored cleanly.</p>
<hr />
<h2>Backward-Compatible Schema Changes</h2>
<p>Schema changes are inevitable. The question is whether they're breaking or backward-compatible. Here's the decision tree:</p>
<p><strong>Adding a new optional field</strong> — backward-compatible. Existing consumers ignore it, new consumers can use it.</p>
<pre><code class="language-python"># v1
class OrderEvent(BaseModel):
    order_id: str
    amount: float

# v2 — safe, existing consumers unaffected
class OrderEvent(BaseModel):
    order_id: str
    amount: float
    currency: str = "USD"  # optional with default
</code></pre>
<p><strong>Renaming a field</strong> — breaking. Handle it with a validator that accepts both during a transition window:</p>
<pre><code class="language-python">from pydantic import model_validator

class OrderEvent(BaseModel):
    order_id: str
    amount: float

    @model_validator(mode="before")
    @classmethod
    def handle_legacy_field(cls, data):
        # Accept old field name during transition
        if "total_amount" in data and "amount" not in data:
            data["amount"] = data.pop("total_amount")
        return data
</code></pre>
<p>Once all producers are on the new field name, remove the validator. This gives you a controlled migration window without a hard cutover.</p>
<p><strong>Removing a field</strong> — deprecate first, remove later. Mark it in the model with a comment and log a warning when it's present, so you can see when producers stop sending it before you drop it.</p>
<hr />
<h2>API Versioning Strategy</h2>
<p>Pydantic handles schema evolution within a version. For breaking changes that can't be backward-compatible, you need versioning at the API level.</p>
<p>Two approaches worth knowing:</p>
<p><strong>URL prefix versioning</strong> — explicit, simple, the default choice for most teams:</p>
<pre><code class="language-python">from fastapi import APIRouter

v1_router = APIRouter(prefix="/v1")
v2_router = APIRouter(prefix="/v2")

@v1_router.post("/orders")
async def create_order_v1(payload: OrderEventV1): ...

@v2_router.post("/orders")
async def create_order_v2(payload: OrderEventV2): ...

app.include_router(v1_router)
app.include_router(v2_router)
</code></pre>
<p>Run both versions simultaneously during the transition. Deprecate v1 with a response header (<code>Deprecation: true</code>, <code>Sunset: &lt;date&gt;</code>) so consumers know the clock is ticking.</p>
<p><strong>Header-based versioning</strong> — cleaner URLs, slightly more complex routing:</p>
<pre><code class="language-python">from fastapi import Header, HTTPException

@app.post("/orders")
async def create_order(
    payload: dict,
    api_version: str = Header(default="1", alias="X-API-Version")
):
    if api_version == "2":
        event = OrderEventV2(**payload)
    else:
        event = OrderEventV1(**payload)
    ...
</code></pre>
<p>URL versioning is easier to reason about, easier to document, and easier to deprecate. Use header versioning only if you have a specific reason — cleaner client URLs, or a gateway that routes by header. Otherwise, URL prefix is the default.</p>
<hr />
<h2>Shared Models Across Services</h2>
<p>If you control both the producer and consumer (common in a small team), consider a shared models package rather than duplicating Pydantic schemas:</p>
<pre><code class="language-plaintext">pulsecart-shared/
├── pyproject.toml
└── pulsecart_shared/
    └── models/
        ├── __init__.py
        └── events.py   # OrderEvent, CartAbandonedEvent, etc.
</code></pre>
<p>Both services install <code>pulsecart-shared</code> as a dependency. Schema changes happen in one place, and a version bump forces consumers to update explicitly. For a team of 4–5 engineers on a shared codebase, this is worth the overhead. For teams with independent deployment cycles, it can create coupling — weigh it against your release cadence.</p>
<hr />
<h2>The Rule</h2>
<p>Make schema mismatches loud at the boundary, not silent in the data. Required fields stay required. Extra fields get rejected at service boundaries. Breaking changes get a transition window with a validator, not a hard cutover.</p>
<p><strong>Next: S01E03 — Testing FastAPI Services: pytest setup, mocking dependencies, and integration tests in CI.</strong></p>
]]></content:encoded></item><item><title><![CDATA[What the FastAPI Docs Don't Tell You About Production]]></title><description><![CDATA[The service had been live for weeks. Locally, staging, early production — fast, clean, no issues. Then a new client onboarded, traffic doubled overnight, and P99 latency climbed from under 100ms to ov]]></description><link>https://blog.madhav.dev/what-the-fastapi-docs-don-t-tell-you-about-production</link><guid isPermaLink="true">https://blog.madhav.dev/what-the-fastapi-docs-don-t-tell-you-about-production</guid><category><![CDATA[FastAPI]]></category><category><![CDATA[Backend Engineering]]></category><category><![CDATA[#cloudrun]]></category><category><![CDATA[System Design]]></category><category><![CDATA[GCP]]></category><dc:creator><![CDATA[Madhav Bhasin]]></dc:creator><pubDate>Fri, 17 Jul 2026 08:37:56 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/622ac3bee552e99d82e59b17/ea1ce1fc-df72-4f1c-8a4f-527f575d747d.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>The service had been live for weeks. Locally, staging, early production — fast, clean, no issues. Then a new client onboarded, traffic doubled overnight, and P99 latency climbed from under 100ms to over 4 seconds.</p>
<p>No 5xx spike. No memory pressure. No CPU ceiling. Just slow.</p>
<p>Two things were wrong. Both came directly from patterns copied out of the FastAPI docs.</p>
<hr />
<h2>Problem 1: A New DB Connection on Every Request</h2>
<p>The FastAPI quickstart doesn't show you how to manage shared resources. So most services end up doing this:</p>
<pre><code class="language-python"># ❌ What most people write first
@app.get("/orders/{order_id}")
async def get_order(order_id: str):
    conn = await asyncpg.connect(DATABASE_URL)  # new connection every request
    order = await conn.fetchrow("SELECT * FROM orders WHERE id = $1", order_id)
    await conn.close()
    return order
</code></pre>
<p>Fine locally. Under 50 concurrent requests on Cloud Run hitting Cloud SQL, this means 50 simultaneous TCP handshakes + authentication attempts. PostgreSQL has a finite connection limit. Requests queue waiting for a slot. Latency compounds.</p>
<p>The fix is a connection pool initialised once at startup via FastAPI's lifespan hook — not per-request, not as a module-level global:</p>
<pre><code class="language-python"># ✅ The right way
from contextlib import asynccontextmanager
from fastapi import FastAPI
import asyncpg

@asynccontextmanager
async def lifespan(app: FastAPI):
    app.state.db = await asyncpg.create_pool(DATABASE_URL, min_size=2, max_size=10)
    yield
    await app.state.db.close()

app = FastAPI(lifespan=lifespan)

@app.get("/orders/{order_id}")
async def get_order(order_id: str, request: Request):
    async with request.app.state.db.acquire() as conn:
        return await conn.fetchrow("SELECT * FROM orders WHERE id = $1", order_id)
</code></pre>
<p>Same pattern applies to Redis clients, HTTP sessions, and any SDK that's expensive to initialise. If it's shared, it belongs in lifespan.</p>
<hr />
<h2>Problem 2: Stack Traces Leaking to Clients</h2>
<p>While diagnosing the latency issue, we found something else in Cloud Logging. Clients were receiving this:</p>
<pre><code class="language-json">{
  "detail": "500: Internal Server Error\nTraceback (most recent call last):\n  File \"/app/routers/orders.py\", line 34...\nasyncpg.exceptions.TooManyConnectionsError: ..."
}
</code></pre>
<p>Internal file paths. Dependency names. Exception types. Leaking silently for weeks.</p>
<p>FastAPI's default behaviour on an unhandled exception exposes more than you want in production. The fix is a single catch-all exception handler:</p>
<pre><code class="language-python"># ✅ Global exception handler
import logging
from fastapi import Request
from fastapi.responses import JSONResponse

logger = logging.getLogger(__name__)

@app.exception_handler(Exception)
async def unhandled_exception_handler(request: Request, exc: Exception):
    logger.exception(
        "Unhandled exception",
        extra={"path": request.url.path, "method": request.method}
    )
    return JSONResponse(
        status_code=500,
        content={"error": "internal_server_error", "message": "Something went wrong."}
    )
</code></pre>
<p>Full traceback goes to Cloud Logging — where only your team sees it. Clients get a safe, consistent error shape. One file, set it once, never think about it again.</p>
<hr />
<h2>Three More Things the Docs Skip</h2>
<p><strong>1. Structured logging</strong></p>
<p>Default uvicorn logs are human-readable text. Cloud Logging expects JSON. Replace the default logger before your first deploy:</p>
<pre><code class="language-python">import logging, json, sys

class JSONFormatter(logging.Formatter):
    def format(self, record):
        return json.dumps({
            "severity": record.levelname,
            "message": record.getMessage(),
            "logger": record.name,
        })

handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(JSONFormatter())
logging.root.handlers = [handler]
logging.root.setLevel(logging.INFO)
</code></pre>
<p><strong>2. Uvicorn config for Cloud Run</strong></p>
<p>Don't copy <code>--reload</code> from the quickstart into your Dockerfile. For Cloud Run:</p>
<pre><code class="language-dockerfile">CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080", "--workers", "1", "--loop", "uvloop", "--timeout-keep-alive", "30"]
</code></pre>
<p>Single worker per instance (Cloud Run scales horizontally, not vertically), uvloop for async performance, no <code>--reload</code>.</p>
<p><strong>3. A health check that actually checks something</strong></p>
<pre><code class="language-python">@app.get("/health")
async def health(request: Request):
    try:
        async with request.app.state.db.acquire() as conn:
            await conn.fetchval("SELECT 1")
        return {"status": "ok"}
    except Exception:
        return JSONResponse(status_code=503, content={"status": "degraded"})
</code></pre>
<p>A health endpoint that just returns <code>{"status": "ok"}</code> is a lie. Cloud Run routes traffic based on this endpoint — a lying health check sends requests to a broken instance.</p>
<p>The framework didn't fail. The configuration did.</p>
<p><strong>Next: S01E02 — FastAPI + Pydantic in Production: Contracts, Validation, and Versioning.</strong></p>
]]></content:encoded></item><item><title><![CDATA[Observability and Scaling: Monitoring Event Lag and Handling Failure]]></title><description><![CDATA[A distributed event-driven pipeline fails differently from a monolith. When a single service goes down, you get an error page. When an event-driven pipeline degrades, it's often invisible at first — m]]></description><link>https://blog.madhav.dev/observability-and-scaling-monitoring-event-lag-and-handling-failure</link><guid isPermaLink="true">https://blog.madhav.dev/observability-and-scaling-monitoring-event-lag-and-handling-failure</guid><category><![CDATA[GCP]]></category><category><![CDATA[observability]]></category><category><![CDATA[event-driven-architecture]]></category><category><![CDATA[Backend Engineering]]></category><dc:creator><![CDATA[Madhav Bhasin]]></dc:creator><pubDate>Mon, 13 Jul 2026 03:20:17 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/622ac3bee552e99d82e59b17/5a134e85-8de4-48cd-939f-f49001b01a34.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>A distributed event-driven pipeline fails differently from a monolith. When a single service goes down, you get an error page. When an event-driven pipeline degrades, it's often invisible at first — messages pile up in a subscription backlog, a DAG silently misses its SLA, a dead-letter queue grows without triggering any alert. By the time a user notices, the system has been degraded for hours.</p>
<p>Observability in an event-driven system isn't optional, and it isn't just application logging. It's knowing the health of every layer — the broker, the consumers, the task queues, and the orchestration layer — before users are affected. This final post covers how PulseCart monitors each layer, what alerts matter, and what actually breaks at 10x current scale.</p>
<hr />
<h2>Layer 1: Pub/Sub Subscription Backlog</h2>
<p>The most important metric in a Pub/Sub-based pipeline is <code>subscription/num_undelivered_messages</code> — the number of messages waiting to be delivered on a subscription. A healthy consumer processes messages as fast as they arrive. A growing backlog means either the consumer is too slow, crashing, or not running at all.</p>
<h3>Setting Up a Backlog Alert</h3>
<pre><code class="language-python"># monitoring/alerts.py — create alerts programmatically via Cloud Monitoring API
from google.cloud import monitoring_v3
from google.protobuf.duration_pb2 import Duration

client = monitoring_v3.AlertPolicyServiceClient()
project_name = f"projects/your-gcp-project-id"

backlog_alert = monitoring_v3.AlertPolicy(
    display_name="PulseCart: Commerce Events Backlog High",
    conditions=[
        monitoring_v3.AlertPolicy.Condition(
            display_name="Undelivered messages &gt; 1000",
            condition_threshold=monitoring_v3.AlertPolicy.Condition.MetricThreshold(
                filter='resource.type="pubsub_subscription" '
                       'AND resource.labels.subscription_id="sub-realtime-consumer" '
                       'AND metric.type="pubsub.googleapis.com/subscription/num_undelivered_messages"',
                comparison=monitoring_v3.ComparisonType.COMPARISON_GT,
                threshold_value=1000,
                duration=Duration(seconds=300),   # sustained for 5 minutes before alerting
                aggregations=[
                    monitoring_v3.Aggregation(
                        alignment_period=Duration(seconds=60),
                        per_series_aligner=monitoring_v3.Aggregation.Aligner.ALIGN_MAX,
                    )
                ],
            ),
        )
    ],
    notification_channels=["projects/your-gcp-project-id/notificationChannels/YOUR_CHANNEL_ID"],
    alert_strategy=monitoring_v3.AlertPolicy.AlertStrategy(
        auto_close=Duration(seconds=1800)
    ),
)

client.create_alert_policy(name=project_name, alert_policy=backlog_alert)
</code></pre>
<p>The 5-minute <code>duration</code> window prevents alert noise from brief spikes — a surge of <code>order.placed</code> events during a flash sale will temporarily spike the backlog, but if the consumer is healthy it clears within minutes. You want alerts for sustained backlogs, not momentary ones.</p>
<p>Track these metrics per subscription, not per topic:</p>
<table>
<thead>
<tr>
<th>Metric</th>
<th>Alert Threshold</th>
<th>What It Means</th>
</tr>
</thead>
<tbody><tr>
<td><code>num_undelivered_messages</code></td>
<td>&gt; 1000 for 5 min</td>
<td>Consumer falling behind</td>
</tr>
<tr>
<td><code>oldest_unacked_message_age</code></td>
<td>&gt; 10 min</td>
<td>Messages stuck — consumer may be crashing</td>
</tr>
<tr>
<td><code>num_undelivered_messages</code> on dead-letter</td>
<td>&gt; 0</td>
<td>Failed messages accumulating</td>
</tr>
</tbody></table>
<p><code>oldest_unacked_message_age</code> is often more useful than raw backlog size. A backlog of 5000 messages being processed quickly is fine. A backlog of 50 messages where the oldest is 30 minutes old means something is stuck.</p>
<hr />
<h2>Layer 2: Cloud Run Consumer Health</h2>
<p>Cloud Run surfaces two categories of metrics that matter for PulseCart's consumers:</p>
<p><strong>Request metrics</strong> — latency, error rate, request count. These tell you how the consumer is performing per invocation.</p>
<p><strong>Instance metrics</strong> — active instances, startup latency. These tell you whether autoscaling is keeping up with load.</p>
<pre><code class="language-python"># Cloud Monitoring dashboard config (as code via Terraform)
# modules/monitoring/main.tf

resource "google_monitoring_dashboard" "pulsecart" {
  dashboard_json = jsonencode({
    displayName = "PulseCart Pipeline Health"
    gridLayout = {
      columns = 2
      widgets = [
        {
          title = "Consumer Error Rate"
          xyChart = {
            dataSets = [{
              timeSeriesQuery = {
                timeSeriesFilter = {
                  filter = join(" AND ", [
                    "resource.type=\"cloud_run_revision\"",
                    "resource.labels.service_name=\"pulsecart-consumer\"",
                    "metric.type=\"run.googleapis.com/request_count\"",
                    "metric.labels.response_code_class!=\"2xx\""
                  ])
                }
              }
            }]
          }
        },
        {
          title = "Consumer P99 Latency"
          xyChart = {
            dataSets = [{
              timeSeriesQuery = {
                timeSeriesFilter = {
                  filter = join(" AND ", [
                    "resource.type=\"cloud_run_revision\"",
                    "resource.labels.service_name=\"pulsecart-consumer\"",
                    "metric.type=\"run.googleapis.com/request_latencies\""
                  ])
                }
              }
            }]
          }
        }
      ]
    }
  })
}
</code></pre>
<p><strong>Alert thresholds for Cloud Run:</strong></p>
<table>
<thead>
<tr>
<th>Metric</th>
<th>Alert Threshold</th>
<th>Action</th>
</tr>
</thead>
<tbody><tr>
<td>Error rate</td>
<td>&gt; 5% for 3 min</td>
<td>Page on-call, check consumer logs</td>
</tr>
<tr>
<td>P99 latency</td>
<td>&gt; 25s (of 30s ack deadline)</td>
<td>Consumer processing too slowly</td>
</tr>
<tr>
<td>Max instances reached</td>
<td>Sustained at ceiling</td>
<td>Scale up <code>max_instance_count</code></td>
</tr>
</tbody></table>
<p>P99 latency approaching the Pub/Sub ack deadline is a leading indicator of trouble. If the consumer takes 28 seconds on a 30-second deadline, any variance causes missed acks and redelivery — which compounds the backlog problem.</p>
<hr />
<h2>Layer 3: Cloud Tasks Queue Depth</h2>
<p>Cloud Tasks exposes queue-level metrics that tell you whether delayed work is executing on schedule or backing up.</p>
<pre><code class="language-yaml"># Cloud Monitoring alert for Cloud Tasks — in Terraform alerting config
filter: &gt;
  resource.type="cloudtasks.googleapis.com/Queue"
  AND resource.labels.queue_id="pulsecart-cart-reminders"
  AND metric.type="cloudtasks.googleapis.com/queue/depth"
threshold_value: 10000
duration: 600s  # 10 minutes
</code></pre>
<p>For PulseCart's cart reminder queue, a depth of 10,000 tasks sustained for 10 minutes means either the handler is failing (Cloud Tasks retrying everything) or there was a genuine spike in cart abandonments. Check the task dispatch error rate alongside queue depth to distinguish between the two.</p>
<p>Key Cloud Tasks metrics:</p>
<table>
<thead>
<tr>
<th>Metric</th>
<th>What to Watch</th>
</tr>
</thead>
<tbody><tr>
<td><code>queue/depth</code></td>
<td>Total tasks waiting — alert if sustained high</td>
</tr>
<tr>
<td><code>queue/task_attempt_failures</code></td>
<td>Tasks failing on dispatch — indicates handler errors</td>
</tr>
<tr>
<td><code>queue/task_attempt_count</code></td>
<td>Retry rate — high retries signal handler instability</td>
</tr>
</tbody></table>
<hr />
<h2>Layer 4: Airflow DAG Monitoring</h2>
<p>Cloud Composer exposes Airflow metrics via Cloud Monitoring. The ones that matter for PulseCart:</p>
<pre><code class="language-python"># dags/monitoring_check.py — a lightweight DAG that validates upstream DAGs ran successfully

from airflow import DAG
from airflow.operators.python import PythonOperator
from airflow.models import DagRun
from airflow.utils.state import State
from datetime import datetime, timedelta
import pendulum
import logging

logger = logging.getLogger(__name__)

def check_critical_dags(**context):
    critical_dags = [
        "pulsecart_nightly_aggregation",
        "pulsecart_dead_letter_reconciliation",
    ]

    execution_date = context["data_interval_start"]
    lookback = execution_date - timedelta(hours=25)  # give 1-hour buffer

    failed = []
    for dag_id in critical_dags:
        runs = DagRun.find(
            dag_id=dag_id,
            execution_start_date=lookback,
            execution_end_date=execution_date,
        )
        if not runs:
            failed.append(f"{dag_id}: no run found in last 25 hours")
        elif runs[-1].state != State.SUCCESS:
            failed.append(f"{dag_id}: last run state={runs[-1].state}")

    if failed:
        raise ValueError(f"Critical DAG failures detected:\n" + "\n".join(failed))

    logger.info("All critical DAGs ran successfully.")


with DAG(
    dag_id="pulsecart_dag_health_check",
    schedule_interval="0 6 * * *",   # runs at 06:00 UTC, after nightly DAGs complete
    start_date=pendulum.datetime(2025, 1, 1, tz="UTC"),
    catchup=False,
    tags=["pulsecart", "monitoring"],
) as dag:

    PythonOperator(
        task_id="check_critical_dags",
        python_callable=check_critical_dags,
    )
</code></pre>
<p>This DAG runs at 06:00 UTC — after the nightly aggregation and metrics DAGs should have completed — and raises if either didn't succeed. Airflow's built-in alerting fires on DAG failure, which triggers the notification channel.</p>
<p>Beyond this, configure SLA misses directly on your critical DAGs:</p>
<pre><code class="language-python"># Add to nightly_aggregation DAG definition
with DAG(
    dag_id="pulsecart_nightly_aggregation",
    sla_miss_callback=notify_sla_miss,   # custom callback that pages on-call
    ...
) as dag:
</code></pre>
<hr />
<h2>Layer 5: Structured Logging Across the Pipeline</h2>
<p>Individual metric alerts tell you something is wrong. Structured logs tell you why. Every service in PulseCart logs in JSON with consistent fields so Cloud Logging can correlate events across the pipeline by <code>event_id</code>.</p>
<pre><code class="language-python"># services/logging.py
import logging
import json
import sys

class StructuredLogger:
    def __init__(self, service_name: str):
        self.service_name = service_name
        self.logger = logging.getLogger(service_name)

    def info(self, message: str, **kwargs):
        self._log("INFO", message, **kwargs)

    def error(self, message: str, **kwargs):
        self._log("ERROR", message, **kwargs)

    def warning(self, message: str, **kwargs):
        self._log("WARNING", message, **kwargs)

    def _log(self, severity: str, message: str, **kwargs):
        entry = {
            "severity": severity,
            "message": message,
            "service": self.service_name,
            **kwargs
        }
        print(json.dumps(entry), file=sys.stdout)


# Usage in consumer
logger = StructuredLogger("pulsecart-consumer")

logger.info(
    "Event processed",
    event_id="evt_a3f9b21c4d8e",
    event_type="order.placed",
    user_id="usr_8821",
    latency_ms=42,
)
</code></pre>
<p>With <code>event_id</code> on every log entry, a Cloud Logging query like <code>jsonPayload.event_id="evt_a3f9b21c4d8e"</code> traces a single event from ingestion through the producer, across Pub/Sub, into the consumer, and through any downstream Cloud Tasks — across services, without distributed tracing infrastructure.</p>
<hr />
<h2>What Breaks at 10x Scale</h2>
<p>PulseCart currently processes roughly 1 million events per day across all topics. At 10x — 10 million events — here's what changes:</p>
<p><strong>Pub/Sub ordering keys become a bottleneck.</strong> Ordering keys force sequential delivery per key value within a subscription. At 10x user volume, popular users (or bots) generate enough events to create ordering-key hot spots that slow delivery for the entire subscription. The fix is to shard high-volume subscriptions or relax ordering requirements on non-critical event types.</p>
<p><strong>The Redis idempotency store needs sharding.</strong> A single Redis instance has throughput limits. At 10x event volume, the idempotency check (<code>GET processed:{event_id}</code>) becomes a bottleneck. Cloud Memorystore for Redis Cluster (available in GCP) handles this via automatic sharding, or you can partition the key space across multiple Redis instances at the application layer.</p>
<p><strong>Cloud SQL connections exhaust.</strong> Cloud Run scales to many instances under load, each holding a connection pool. At 10x scale, the total connection count across all consumer instances can exceed PostgreSQL's <code>max_connections</code>. The fix is PgBouncer as a connection pooler in front of Cloud SQL, or Cloud SQL's built-in connection pooling via the Auth Proxy with a <code>--max-connections</code> flag.</p>
<p><strong>Airflow worker concurrency caps out.</strong> The dead-letter reconciliation DAG pulls up to 500 messages per run. At 10x dead-letter volume, a single worker takes too long per topic. The fix is dynamic task mapping — Airflow 2.3+ lets you generate task instances at runtime, so each dead-letter topic gets its own worker in parallel rather than sequential processing.</p>
<p><strong>Cloud Composer cost grows non-linearly.</strong> At 10x DAG runs with more complex dependencies, Composer's GKE cluster costs scale with you. At that point, re-evaluate whether managed Airflow is the right fit or whether a lighter orchestrator (Prefect, Dagster, or a self-hosted Airflow on GKE Autopilot) makes more economic sense.</p>
<hr />
<h2>The Observability Stack, Summarised</h2>
<table>
<thead>
<tr>
<th>Layer</th>
<th>Primary Metric</th>
<th>Alert Condition</th>
<th>Tool</th>
</tr>
</thead>
<tbody><tr>
<td>Pub/Sub</td>
<td><code>oldest_unacked_message_age</code></td>
<td>&gt; 10 minutes</td>
<td>Cloud Monitoring</td>
</tr>
<tr>
<td>Cloud Run</td>
<td>Error rate, P99 latency</td>
<td>&gt; 5% errors, P99 &gt; 25s</td>
<td>Cloud Monitoring</td>
</tr>
<tr>
<td>Cloud Tasks</td>
<td>Queue depth, failure rate</td>
<td>Depth &gt; 10k sustained</td>
<td>Cloud Monitoring</td>
</tr>
<tr>
<td>Airflow</td>
<td>DAG run state, SLA miss</td>
<td>Any critical DAG failure</td>
<td>Airflow + Cloud Monitoring</td>
</tr>
<tr>
<td>Cross-service</td>
<td><code>event_id</code> trace</td>
<td>N/A — for debugging</td>
<td>Cloud Logging</td>
</tr>
</tbody></table>
<hr />
<h2>Closing Thoughts</h2>
<p>This series set out to build PulseCart the way a production system actually gets built — not a hello-world demo, but a full pipeline with real tradeoffs at every layer. Here's what we covered across the eight posts:</p>
<ul>
<li><p><strong>Day 0</strong> — What PulseCart is and what to expect</p>
</li>
<li><p><strong>Day 1</strong> — Why request-response breaks at scale and how to design an event taxonomy</p>
</li>
<li><p><strong>Day 2</strong> — Pub/Sub topics, subscriptions, ordering keys, and dead-letter topics</p>
</li>
<li><p><strong>Day 3</strong> — FastAPI producer with Pydantic validation and at-least-once delivery semantics</p>
</li>
<li><p><strong>Day 4</strong> — Cloud Run consumers, Redis idempotency, and Cloud Tasks for delayed workflows</p>
</li>
<li><p><strong>Day 5</strong> — Airflow DAGs for batch aggregation, dead-letter reconciliation, and metrics</p>
</li>
<li><p><strong>Day 6</strong> — Full Terraform infrastructure and zero-downtime GitHub Actions CI/CD</p>
</li>
<li><p><strong>Day 7</strong> — Observability, alerting, and what breaks at 10x scale</p>
</li>
</ul>
<p>The GCP-native stack — Pub/Sub, Cloud Tasks, Cloud Run, Cloud Composer — won't be the right fit for every team. If you're already running Kafka, the producer/consumer patterns from Days 3 and 4 translate directly. If you're on AWS, SNS/SQS maps closely to what Pub/Sub does here. The infrastructure changes; the architecture doesn't.</p>
<p>Thanks for following along. If you found something useful, wrong, or worth pushing back on — the comments are open.</p>
]]></content:encoded></item><item><title><![CDATA[Production Infrastructure: Terraform, Autoscaling, and Zero-Downtime Deploys]]></title><description><![CDATA[By Day 5, every layer of PulseCart's event-driven pipeline exists as application code. The FastAPI producer, Cloud Run consumers, Cloud Tasks queues, and Airflow DAGs are all written and tested. What ]]></description><link>https://blog.madhav.dev/production-infrastructure-terraform-autoscaling-and-zero-downtime-deploys</link><guid isPermaLink="true">https://blog.madhav.dev/production-infrastructure-terraform-autoscaling-and-zero-downtime-deploys</guid><category><![CDATA[GitHub Actions]]></category><category><![CDATA[GCP]]></category><category><![CDATA[#cloudrun]]></category><category><![CDATA[Terraform]]></category><dc:creator><![CDATA[Madhav Bhasin]]></dc:creator><pubDate>Sat, 11 Jul 2026 09:39:59 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/622ac3bee552e99d82e59b17/9661882a-676d-459a-8f67-2dda0561a329.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>By Day 5, every layer of PulseCart's event-driven pipeline exists as application code. The FastAPI producer, Cloud Run consumers, Cloud Tasks queues, and Airflow DAGs are all written and tested. What doesn't exist yet is the infrastructure that runs them — and right now, that infrastructure lives only in someone's head or a series of manual GCP Console clicks.</p>
<p>Manual infrastructure is a liability. It can't be reviewed, versioned, or reproduced reliably. When you need to spin up a staging environment or recover from a misconfiguration, you're guessing. Terraform eliminates the guesswork — every resource is declared in code, version-controlled alongside the application, and reproducible across environments.</p>
<p>This post provisions PulseCart's complete infrastructure and wires it to a GitHub Actions pipeline for zero-downtime deploys.</p>
<hr />
<h2>Project Structure</h2>
<pre><code class="language-plaintext">pulsecart-infra/
├── main.tf
├── variables.tf
├── outputs.tf
├── versions.tf
├── modules/
│   ├── pubsub/
│   │   ├── main.tf
│   │   └── variables.tf
│   ├── cloud_run/
│   │   ├── main.tf
│   │   └── variables.tf
│   ├── cloud_tasks/
│   │   ├── main.tf
│   │   └── variables.tf
│   ├── cloud_sql/
│   │   ├── main.tf
│   │   └── variables.tf
│   ├── redis/
│   │   ├── main.tf
│   │   └── variables.tf
│   └── composer/
│       ├── main.tf
│       └── variables.tf
└── environments/
    ├── dev.tfvars
    └── prod.tfvars
</code></pre>
<p>Splitting into modules keeps each service's resources self-contained and reusable across environments. The <code>environments/</code> directory holds variable overrides — dev uses smaller machine types and lower min-instance counts; prod uses production-grade sizing.</p>
<hr />
<h2>versions.tf</h2>
<pre><code class="language-hcl">terraform {
  required_version = "&gt;= 1.5"

  required_providers {
    google = {
      source  = "hashicorp/google"
      version = "~&gt; 5.0"
    }
  }

  backend "gcs" {
    bucket = "pulsecart-terraform-state"
    prefix = "terraform/state"
  }
}

provider "google" {
  project = var.gcp_project_id
  region  = var.gcp_region
}
</code></pre>
<p>Remote state in GCS means the state file is shared across the team and won't get lost when someone's laptop dies. Enable state locking (GCS does this automatically via object versioning) to prevent concurrent applies from corrupting state.</p>
<hr />
<h2>Module: Pub/Sub</h2>
<pre><code class="language-hcl"># modules/pubsub/main.tf

locals {
  topics = ["commerce-events", "user-actions", "system-events"]
}

resource "google_pubsub_topic" "topics" {
  for_each = toset(local.topics)
  name     = "pulsecart.${each.key}"

  message_retention_duration = "604800s"  # 7 days
}

resource "google_pubsub_topic" "dead_letter_topics" {
  for_each = toset(local.topics)
  name     = "pulsecart.${each.key}.dead-letter"
}

resource "google_pubsub_subscription" "realtime_consumer" {
  name  = "sub-realtime-consumer"
  topic = google_pubsub_topic.topics["commerce-events"].name

  ack_deadline_seconds    = 30
  enable_message_ordering = true

  push_config {
    push_endpoint = var.cloud_run_consumer_url

    oidc_token {
      service_account_email = var.service_account_email
    }
  }

  dead_letter_policy {
    dead_letter_topic     = google_pubsub_topic.dead_letter_topics["commerce-events"].id
    max_delivery_attempts = 5
  }

  retry_policy {
    minimum_backoff = "10s"
    maximum_backoff = "300s"
  }

  depends_on = [google_pubsub_topic.topics, google_pubsub_topic.dead_letter_topics]
}

resource "google_pubsub_subscription" "airflow_ingestion" {
  name  = "sub-airflow-ingestion"
  topic = google_pubsub_topic.topics["commerce-events"].name

  ack_deadline_seconds    = 60
  enable_message_ordering = true

  dead_letter_policy {
    dead_letter_topic     = google_pubsub_topic.dead_letter_topics["commerce-events"].id
    max_delivery_attempts = 5
  }
}
</code></pre>
<p>The <code>for_each</code> loop over <code>local.topics</code> creates all three main topics and their dead-letter counterparts without repetition. Adding a new topic means adding one string to the <code>locals</code> block.</p>
<hr />
<h2>Module: Cloud Run</h2>
<pre><code class="language-hcl"># modules/cloud_run/main.tf

resource "google_cloud_run_v2_service" "producer" {
  name     = "pulsecart-producer"
  location = var.gcp_region

  template {
    scaling {
      min_instance_count = var.producer_min_instances
      max_instance_count = 10
    }

    containers {
      image = var.producer_image

      resources {
        limits = {
          cpu    = "1"
          memory = "512Mi"
        }
      }

      env {
        name  = "GCP_PROJECT_ID"
        value = var.gcp_project_id
      }

      env {
        name = "REDIS_URL"
        value_source {
          secret_key_ref {
            secret  = google_secret_manager_secret.redis_url.secret_id
            version = "latest"
          }
        }
      }
    }

    service_account = var.service_account_email
  }

  traffic {
    type    = "TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST"
    percent = 100
  }
}

resource "google_cloud_run_v2_service" "consumer" {
  name     = "pulsecart-consumer"
  location = var.gcp_region

  template {
    scaling {
      min_instance_count = var.consumer_min_instances
      max_instance_count = 20
    }

    containers {
      image = var.consumer_image

      resources {
        limits = {
          cpu    = "2"
          memory = "1Gi"
        }
      }

      env {
        name  = "GCP_PROJECT_ID"
        value = var.gcp_project_id
      }

      env {
        name = "REDIS_URL"
        value_source {
          secret_key_ref {
            secret  = google_secret_manager_secret.redis_url.secret_id
            version = "latest"
          }
        }
      }
    }

    service_account = var.service_account_email
  }

  traffic {
    type    = "TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST"
    percent = 100
  }
}
</code></pre>
<p>The <code>traffic</code> block set to <code>TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST</code> at 100% is what makes Cloud Run deployments zero-downtime by default — new revisions receive traffic only after they pass health checks. If a new revision fails its health check, traffic stays on the previous revision automatically.</p>
<p>For the consumer, <code>min_instance_count</code> is set higher in prod than dev (typically 2–3 for the consumer) to avoid cold starts on push subscription deliveries.</p>
<hr />
<h2>Module: Cloud Tasks</h2>
<pre><code class="language-hcl"># modules/cloud_tasks/main.tf

resource "google_cloud_tasks_queue" "cart_reminders" {
  name     = "pulsecart-cart-reminders"
  location = var.gcp_region

  rate_limits {
    max_concurrent_dispatches = 100
    max_dispatches_per_second = 50
  }

  retry_config {
    max_attempts       = 5
    max_retry_duration = "3600s"
    min_backoff        = "10s"
    max_backoff        = "300s"
    max_doublings      = 4
  }

  stackdriver_logging_config {
    sampling_ratio = 1.0
  }
}
</code></pre>
<p><code>max_concurrent_dispatches</code> prevents the cart reminder handler from being overwhelmed during a backlog catch-up. <code>stackdriver_logging_config</code> at <code>1.0</code> logs every task dispatch — useful for debugging, though you'd lower this in prod once things are stable to reduce logging costs.</p>
<hr />
<h2>Module: Cloud SQL</h2>
<pre><code class="language-hcl"># modules/cloud_sql/main.tf

resource "google_sql_database_instance" "pulsecart" {
  name             = "pulsecart-postgres"
  database_version = "POSTGRES_15"
  region           = var.gcp_region

  settings {
    tier              = var.db_tier   # db-g1-small for dev, db-custom-4-15360 for prod
    availability_type = var.db_availability_type  # ZONAL for dev, REGIONAL for prod

    backup_configuration {
      enabled                        = true
      start_time                     = "03:00"
      point_in_time_recovery_enabled = true
      transaction_log_retention_days = 7
    }

    ip_configuration {
      ipv4_enabled    = false
      private_network = var.vpc_network_id
    }

    database_flags {
      name  = "max_connections"
      value = "200"
    }
  }

  deletion_protection = var.deletion_protection  # true in prod, false in dev
}

resource "google_sql_database" "pulsecart" {
  name     = "pulsecart"
  instance = google_sql_database_instance.pulsecart.name
}
</code></pre>
<p><code>REGIONAL</code> availability type in prod means Cloud SQL automatically fails over to a standby instance in another zone if the primary goes down. For PulseCart's commerce-events pipeline, this matters — a database outage that takes down the consumer would cause messages to pile up in Pub/Sub until the subscription's retry window expires.</p>
<p><code>deletion_protection = true</code> in prod is non-negotiable. It prevents <code>terraform destroy</code> from accidentally dropping your production database.</p>
<hr />
<h2>Module: Redis (Memorystore)</h2>
<pre><code class="language-hcl"># modules/redis/main.tf

resource "google_redis_instance" "pulsecart" {
  name           = "pulsecart-redis"
  tier           = var.redis_tier   # BASIC for dev, STANDARD_HA for prod
  memory_size_gb = var.redis_memory_gb
  region         = var.gcp_region

  redis_version      = "REDIS_7_0"
  authorized_network = var.vpc_network_id

  redis_configs = {
    maxmemory-policy = "allkeys-lru"
  }
}
</code></pre>
<p><code>STANDARD_HA</code> in prod gives Redis a read replica and automatic failover. The idempotency layer we built in Day 4 depends on Redis being available — if Redis is down, the <code>is_duplicate</code> check fails and the consumer falls back to processing without deduplication. Whether that's acceptable depends on your tolerance for occasional duplicate sends; for PulseCart's transactional emails, we'd rather fail closed and have the consumer return a 5xx (triggering Pub/Sub retry) than silently process without idempotency.</p>
<hr />
<h2>Module: Cloud Composer</h2>
<pre><code class="language-hcl"># modules/composer/main.tf

resource "google_composer_environment" "pulsecart" {
  name   = "pulsecart-composer"
  region = var.gcp_region

  config {
    software_config {
      image_version = "composer-2-airflow-2"

      pypi_packages = {
        "apache-airflow-providers-google" = "&gt;=10.0.0"
        "apache-airflow-providers-postgres" = "&gt;=5.0.0"
      }

      env_variables = {
        PULSECART_PROJECT_ID = var.gcp_project_id
        PULSECART_ENV        = var.environment
      }
    }

    workloads_config {
      scheduler {
        cpu        = 0.5
        memory_gb  = 1.875
        storage_gb = 1
        count      = 1
      }
      web_server {
        cpu       = 0.5
        memory_gb = 1.875
      }
      worker {
        cpu        = 2
        memory_gb  = 7.5
        storage_gb = 10
        min_count  = 1
        max_count  = 4
      }
    }

    node_config {
      service_account = var.service_account_email
      network         = var.vpc_network_id
    }
  }
}
</code></pre>
<p>Worker autoscaling (<code>min_count = 1</code>, <code>max_count = 4</code>) means Composer adds workers during DAG runs and scales back down when idle. For PulseCart's three DAGs running nightly, this keeps costs reasonable without under-provisioning during peak DAG execution.</p>
<hr />
<h2>GitHub Actions CI/CD Pipeline</h2>
<p>Two workflows: one for the application (build, push Docker image, deploy to Cloud Run), one for infrastructure (terraform plan on PR, terraform apply on merge).</p>
<h3>Application Deploy</h3>
<pre><code class="language-yaml"># .github/workflows/deploy.yml
name: Deploy to Cloud Run

on:
  push:
    branches: [main]
    paths:
      - 'pulsecart-producer/**'
      - 'pulsecart-consumer/**'

jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write   # required for Workload Identity Federation

    steps:
      - uses: actions/checkout@v4

      - name: Authenticate to GCP
        uses: google-github-actions/auth@v2
        with:
          workload_identity_provider: ${{ secrets.WIF_PROVIDER }}
          service_account: ${{ secrets.SERVICE_ACCOUNT }}

      - name: Set up Cloud SDK
        uses: google-github-actions/setup-gcloud@v2

      - name: Build and push producer image
        run: |
          docker build -t gcr.io/${{ secrets.GCP_PROJECT_ID }}/pulsecart-producer:${{ github.sha }} \
            ./pulsecart-producer
          docker push gcr.io/${{ secrets.GCP_PROJECT_ID }}/pulsecart-producer:${{ github.sha }}

      - name: Deploy producer to Cloud Run
        run: |
          gcloud run deploy pulsecart-producer \
            --image gcr.io/${{ secrets.GCP_PROJECT_ID }}/pulsecart-producer:${{ github.sha }} \
            --region ${{ secrets.GCP_REGION }} \
            --platform managed \
            --no-traffic   # deploy revision without routing traffic yet

      - name: Run smoke tests against new revision
        run: |
          NEW_URL=$(gcloud run revisions list \
            --service pulsecart-producer \
            --region ${{ secrets.GCP_REGION }} \
            --format 'value(status.url)' \
            --limit 1)
          curl --fail "$NEW_URL/health"

      - name: Shift traffic to new revision
        run: |
          gcloud run services update-traffic pulsecart-producer \
            --to-latest \
            --region ${{ secrets.GCP_REGION }}
</code></pre>
<p>The <code>--no-traffic</code> flag deploys the new revision without routing any traffic to it. Smoke tests run against the new revision's URL directly. Only if those pass does the final step shift traffic. If smoke tests fail, the pipeline stops and the previous revision continues serving 100% of traffic — this is the zero-downtime guarantee.</p>
<p>Workload Identity Federation (<code>id-token: write</code>) replaces long-lived service account keys in GitHub secrets. GCP verifies the GitHub Actions OIDC token directly, eliminating a credential rotation concern entirely.</p>
<h3>Infrastructure Plan and Apply</h3>
<pre><code class="language-yaml"># .github/workflows/terraform.yml
name: Terraform

on:
  pull_request:
    paths: ['pulsecart-infra/**']
  push:
    branches: [main]
    paths: ['pulsecart-infra/**']

jobs:
  terraform:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write
      pull-requests: write

    steps:
      - uses: actions/checkout@v4

      - name: Authenticate to GCP
        uses: google-github-actions/auth@v2
        with:
          workload_identity_provider: ${{ secrets.WIF_PROVIDER }}
          service_account: ${{ secrets.SERVICE_ACCOUNT }}

      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: "1.5.7"

      - name: Terraform Init
        working-directory: pulsecart-infra
        run: terraform init

      - name: Terraform Plan
        working-directory: pulsecart-infra
        run: |
          terraform plan \
            -var-file="environments/${{ github.ref == 'refs/heads/main' &amp;&amp; 'prod' || 'dev' }}.tfvars" \
            -out=tfplan

      - name: Post plan to PR
        if: github.event_name == 'pull_request'
        uses: actions/github-script@v7
        with:
          script: |
            const plan = require('fs').readFileSync('pulsecart-infra/tfplan.txt', 'utf8');
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: '```terraform\n' + plan + '\n```'
            });

      - name: Terraform Apply
        if: github.ref == 'refs/heads/main' &amp;&amp; github.event_name == 'push'
        working-directory: pulsecart-infra
        run: terraform apply -auto-approve tfplan
</code></pre>
<p>Terraform plan output is posted as a PR comment — reviewers see exactly what infrastructure changes the PR introduces before it merges. Apply runs only on merge to main. This pattern prevents infrastructure drift and keeps changes reviewable.</p>
<hr />
<h2>Environment Variable Matrix</h2>
<table>
<thead>
<tr>
<th>Variable</th>
<th>Dev</th>
<th>Prod</th>
</tr>
</thead>
<tbody><tr>
<td><code>producer_min_instances</code></td>
<td>0</td>
<td>1</td>
</tr>
<tr>
<td><code>consumer_min_instances</code></td>
<td>0</td>
<td>2</td>
</tr>
<tr>
<td><code>db_tier</code></td>
<td><code>db-g1-small</code></td>
<td><code>db-custom-4-15360</code></td>
</tr>
<tr>
<td><code>db_availability_type</code></td>
<td><code>ZONAL</code></td>
<td><code>REGIONAL</code></td>
</tr>
<tr>
<td><code>redis_tier</code></td>
<td><code>BASIC</code></td>
<td><code>STANDARD_HA</code></td>
</tr>
<tr>
<td><code>deletion_protection</code></td>
<td><code>false</code></td>
<td><code>true</code></td>
</tr>
</tbody></table>
<p>Dev can scale to zero to keep costs low during development. Prod keeps minimum instances alive to eliminate cold starts on critical paths.</p>
<hr />
<h2>What's Next</h2>
<p>Day 7 closes the series with observability — monitoring Pub/Sub subscription backlog, Cloud Tasks queue depth, Airflow DAG failures, and what actually breaks when PulseCart processes at 10x its current scale.</p>
]]></content:encoded></item><item><title><![CDATA[Orchestrating Batch and Scheduled Workflows with Airflow]]></title><description><![CDATA[By Day 4, PulseCart's reactive layer is fully operational. Events flow from the FastAPI producer into Pub/Sub, Cloud Run consumers react in real time, and Cloud Tasks handles delayed work like cart ab]]></description><link>https://blog.madhav.dev/orchestrating-batch-and-scheduled-workflows-with-airflow</link><guid isPermaLink="true">https://blog.madhav.dev/orchestrating-batch-and-scheduled-workflows-with-airflow</guid><category><![CDATA[airflow]]></category><category><![CDATA[GCP]]></category><category><![CDATA[event-driven-architecture]]></category><category><![CDATA[data-engineering]]></category><category><![CDATA[cloud composer]]></category><dc:creator><![CDATA[Madhav Bhasin]]></dc:creator><pubDate>Sun, 05 Jul 2026 12:57:08 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/622ac3bee552e99d82e59b17/2345f166-260c-420a-a2b5-6fb34d116f55.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>By Day 4, PulseCart's reactive layer is fully operational. Events flow from the FastAPI producer into Pub/Sub, Cloud Run consumers react in real time, and Cloud Tasks handles delayed work like cart abandonment reminders. The system responds to what's happening right now.</p>
<p>But not everything in PulseCart is reactive. Some work is inherently scheduled — it doesn't make sense to trigger it per event. Nightly aggregation of user behavior into personalization scores, reconciling messages that landed in dead-letter topics, computing daily business metrics — these are batch jobs. They run on a schedule, depend on a window of data being complete, and often have multi-step dependencies between tasks.</p>
<p>This is Airflow's domain. And it's explicitly not a duplication of what Pub/Sub and Cloud Tasks do — it's the complement.</p>
<hr />
<h2>Why Airflow, Not More Cloud Tasks</h2>
<p>Cloud Tasks is excellent for individual delayed tasks. It's not designed for multi-step workflows with dependencies, branching logic, retries at the DAG level, or visibility into which step failed and why.</p>
<p>Airflow gives you:</p>
<ul>
<li><p><strong>DAGs</strong> (Directed Acyclic Graphs) — workflows defined as code, with explicit task dependencies</p>
</li>
<li><p><strong>Scheduling</strong> — cron-based or data-interval-driven execution</p>
</li>
<li><p><strong>Observability</strong> — a UI showing every DAG run, task status, logs, and retry history</p>
</li>
<li><p><strong>Backfill</strong> — re-run historical DAG runs if something failed or data was corrected</p>
</li>
<li><p><strong>Sensors</strong> — tasks that wait for an external condition before proceeding</p>
</li>
</ul>
<p>For PulseCart's nightly batch jobs, none of this is achievable cleanly with Cloud Tasks alone.</p>
<hr />
<h2>Cloud Composer vs Self-Hosted Airflow</h2>
<p>Cloud Composer is GCP's managed Airflow service. It handles the scheduler, webserver, worker pool, and underlying GKE infrastructure. You deploy DAGs by uploading Python files to a GCS bucket — no Airflow internals to manage.</p>
<p>The tradeoff is cost. Cloud Composer is significantly more expensive than a self-hosted Airflow instance on a single VM or a small GKE cluster. For PulseCart at production scale — where DAG failures have real business consequences and on-call engineers shouldn't be debugging Airflow infrastructure at 2am — Cloud Composer is worth it. For a smaller team or lower stakes, self-hosted on Cloud Run or GKE is a legitimate alternative.</p>
<p>This series uses Cloud Composer, but the DAG code is identical either way.</p>
<hr />
<h2>PulseCart's Airflow DAGs</h2>
<p>PulseCart runs three scheduled DAGs:</p>
<table>
<thead>
<tr>
<th>DAG</th>
<th>Schedule</th>
<th>Purpose</th>
</tr>
</thead>
<tbody><tr>
<td><code>pulsecart_nightly_aggregation</code></td>
<td>Daily at 02:00 UTC</td>
<td>Aggregate prior day's events into personalization scores</td>
</tr>
<tr>
<td><code>pulsecart_dead_letter_reconciliation</code></td>
<td>Every 6 hours</td>
<td>Pull from dead-letter topics, triage, and reprocess eligible messages</td>
</tr>
<tr>
<td><code>pulsecart_daily_metrics</code></td>
<td>Daily at 03:00 UTC</td>
<td>Compute business metrics (conversion rate, abandonment rate, revenue) into Cloud SQL</td>
</tr>
</tbody></table>
<hr />
<h2>DAG 1: Nightly Aggregation</h2>
<p>This DAG pulls the prior day's events from Cloud SQL (written by the Cloud Run consumer as it processes Pub/Sub messages), aggregates them into per-user behavior signals, and writes the results back as personalization scores.</p>
<pre><code class="language-python"># dags/nightly_aggregation.py
from airflow import DAG
from airflow.providers.google.cloud.operators.cloud_sql import CloudSQLExecuteQueryOperator
from airflow.providers.google.cloud.transfers.sql_to_gcs import CloudSQLToGCSOperator
from airflow.operators.python import PythonOperator
from airflow.utils.dates import days_ago
from datetime import datetime, timedelta
import pendulum

default_args = {
    "owner": "pulsecart",
    "retries": 2,
    "retry_delay": timedelta(minutes=5),
    "email_on_failure": True,
    "email": ["engineering@pulsecart.io"],
}

with DAG(
    dag_id="pulsecart_nightly_aggregation",
    default_args=default_args,
    description="Aggregate prior day events into personalization scores",
    schedule_interval="0 2 * * *",   # 02:00 UTC daily
    start_date=pendulum.datetime(2025, 1, 1, tz="UTC"),
    catchup=False,
    tags=["pulsecart", "personalization"],
) as dag:

    extract_events = CloudSQLExecuteQueryOperator(
        task_id="extract_prior_day_events",
        gcp_cloudsql_conn_id="pulsecart_cloudsql",
        sql="""
            INSERT INTO event_aggregates (user_id, date, event_type, event_count)
            SELECT
                user_id,
                DATE(timestamp) AS date,
                event_type,
                COUNT(*) AS event_count
            FROM events
            WHERE DATE(timestamp) = CURRENT_DATE - INTERVAL '1 day'
            GROUP BY user_id, DATE(timestamp), event_type
            ON CONFLICT (user_id, date, event_type)
            DO UPDATE SET event_count = EXCLUDED.event_count;
        """,
    )

    compute_scores = CloudSQLExecuteQueryOperator(
        task_id="compute_personalization_scores",
        gcp_cloudsql_conn_id="pulsecart_cloudsql",
        sql="""
            INSERT INTO personalization_scores (user_id, score, computed_at)
            SELECT
                user_id,
                -- Weighted score: recency, frequency, purchase intent
                (
                    SUM(CASE WHEN event_type = 'order.placed'   THEN event_count * 10 ELSE 0 END) +
                    SUM(CASE WHEN event_type = 'cart.item_added' THEN event_count * 3  ELSE 0 END) +
                    SUM(CASE WHEN event_type = 'product.viewed'  THEN event_count * 1  ELSE 0 END)
                ) AS score,
                NOW() AS computed_at
            FROM event_aggregates
            WHERE date &gt;= CURRENT_DATE - INTERVAL '7 days'
            GROUP BY user_id
            ON CONFLICT (user_id)
            DO UPDATE SET score = EXCLUDED.score, computed_at = EXCLUDED.computed_at;
        """,
    )

    extract_events &gt;&gt; compute_scores
</code></pre>
<p>The task dependency (<code>extract_events &gt;&gt; compute_scores</code>) ensures scores are never computed against stale data. If <code>extract_events</code> fails, <code>compute_scores</code> never runs — and Airflow retries the whole DAG run up to 2 times before alerting.</p>
<hr />
<h2>DAG 2: Dead-Letter Reconciliation</h2>
<p>Every Pub/Sub subscription has a dead-letter topic (configured in Day 2). Messages end up there when they exceed the maximum delivery attempt count — typically due to a consumer bug, a malformed payload, or a dependency outage during the retry window.</p>
<p>Without reconciliation, dead-lettered messages just sit there. With this DAG, they're triaged every 6 hours: reprocessable messages are re-published to the original topic, and genuinely malformed ones are logged for manual review.</p>
<pre><code class="language-python"># dags/dead_letter_reconciliation.py
from airflow import DAG
from airflow.operators.python import PythonOperator
from airflow.utils.dates import days_ago
from datetime import timedelta
from google.cloud import pubsub_v1
import json
import logging
import pendulum

logger = logging.getLogger(__name__)

DEAD_LETTER_TOPICS = [
    ("pulsecart.commerce-events.dead-letter", "pulsecart.commerce-events"),
    ("pulsecart.user-actions.dead-letter",    "pulsecart.user-actions"),
    ("pulsecart.system-events.dead-letter",   "pulsecart.system-events"),
]

PROJECT_ID = "your-gcp-project-id"
MAX_MESSAGES_PER_RUN = 500


def reconcile_dead_letters(dead_letter_topic: str, original_topic: str, **context):
    subscriber = pubsub_v1.SubscriberClient()
    publisher = pubsub_v1.PublisherClient()

    subscription_path = subscriber.subscription_path(
        PROJECT_ID, f"sub-{dead_letter_topic}-reconciler"
    )
    original_topic_path = publisher.topic_path(PROJECT_ID, original_topic)

    reprocessed = 0
    skipped = 0

    response = subscriber.pull(
        request={"subscription": subscription_path, "max_messages": MAX_MESSAGES_PER_RUN}
    )

    for msg in response.received_messages:
        try:
            event = json.loads(msg.message.data.decode("utf-8"))
            event_type = event.get("event_type")

            if not event_type:
                logger.warning(f"Dead-lettered message has no event_type, skipping: {msg.message.message_id}")
                skipped += 1
            else:
                publisher.publish(
                    original_topic_path,
                    data=msg.message.data,
                    ordering_key=event.get("user_id", ""),
                    event_type=event_type,
                    event_id=event.get("event_id", ""),
                    reconciled="true",        # attribute flag so consumers can log it
                )
                reprocessed += 1

            subscriber.acknowledge(
                request={"subscription": subscription_path, "ack_ids": [msg.ack_id]}
            )

        except Exception as e:
            logger.error(f"Failed to reconcile message {msg.message.message_id}: {e}")

    logger.info(f"{dead_letter_topic}: reprocessed={reprocessed}, skipped={skipped}")


default_args = {
    "owner": "pulsecart",
    "retries": 1,
    "retry_delay": timedelta(minutes=2),
}

with DAG(
    dag_id="pulsecart_dead_letter_reconciliation",
    default_args=default_args,
    description="Triage and reprocess dead-lettered Pub/Sub messages",
    schedule_interval="0 */6 * * *",   # every 6 hours
    start_date=pendulum.datetime(2025, 1, 1, tz="UTC"),
    catchup=False,
    tags=["pulsecart", "reliability"],
) as dag:

    for dead_letter_topic, original_topic in DEAD_LETTER_TOPICS:
        PythonOperator(
            task_id=f"reconcile_{dead_letter_topic.replace('.', '_')}",
            python_callable=reconcile_dead_letters,
            op_kwargs={
                "dead_letter_topic": dead_letter_topic,
                "original_topic": original_topic,
            },
        )
</code></pre>
<p>The three reconciliation tasks run in parallel — each topic's dead-letter queue is independent. Note the <code>reconciled="true"</code> message attribute: downstream consumers can detect and log reconciled messages separately, which is useful for tracking how often dead-lettered messages are genuinely recoverable vs. indicative of a deeper bug.</p>
<hr />
<h2>DAG 3: Daily Metrics</h2>
<p>A simpler DAG that computes the previous day's business metrics into a <code>daily_metrics</code> table in Cloud SQL. Downstream, a dashboard reads from this table.</p>
<pre><code class="language-python"># dags/daily_metrics.py
from airflow import DAG
from airflow.providers.google.cloud.operators.cloud_sql import CloudSQLExecuteQueryOperator
from datetime import timedelta
import pendulum

default_args = {
    "owner": "pulsecart",
    "retries": 2,
    "retry_delay": timedelta(minutes=5),
}

with DAG(
    dag_id="pulsecart_daily_metrics",
    default_args=default_args,
    description="Compute prior day business metrics",
    schedule_interval="0 3 * * *",   # 03:00 UTC daily
    start_date=pendulum.datetime(2025, 1, 1, tz="UTC"),
    catchup=False,
    tags=["pulsecart", "metrics"],
) as dag:

    compute_metrics = CloudSQLExecuteQueryOperator(
        task_id="compute_daily_metrics",
        gcp_cloudsql_conn_id="pulsecart_cloudsql",
        sql="""
            INSERT INTO daily_metrics (date, orders_placed, carts_abandoned, abandonment_rate, revenue)
            SELECT
                CURRENT_DATE - INTERVAL '1 day'                                    AS date,
                COUNT(*) FILTER (WHERE event_type = 'order.placed')                AS orders_placed,
                COUNT(*) FILTER (WHERE event_type = 'cart.abandoned')              AS carts_abandoned,
                ROUND(
                    COUNT(*) FILTER (WHERE event_type = 'cart.abandoned')::numeric /
                    NULLIF(COUNT(*) FILTER (WHERE event_type = 'cart.item_added'), 0) * 100,
                    2
                )                                                                   AS abandonment_rate,
                SUM((payload-&gt;&gt;'total')::numeric) FILTER (WHERE event_type = 'order.placed') AS revenue
            FROM events
            WHERE DATE(timestamp) = CURRENT_DATE - INTERVAL '1 day'
            ON CONFLICT (date)
            DO UPDATE SET
                orders_placed    = EXCLUDED.orders_placed,
                carts_abandoned  = EXCLUDED.carts_abandoned,
                abandonment_rate = EXCLUDED.abandonment_rate,
                revenue          = EXCLUDED.revenue;
        """,
    )
</code></pre>
<hr />
<h2>How Airflow Complements the Reactive Layer</h2>
<p>The distinction is worth being explicit about:</p>
<table>
<thead>
<tr>
<th>Concern</th>
<th>Pub/Sub + Cloud Run/Tasks</th>
<th>Airflow</th>
</tr>
</thead>
<tbody><tr>
<td>Trigger</td>
<td>An event happened</td>
<td>Time elapsed / schedule</td>
</tr>
<tr>
<td>Granularity</td>
<td>Per message</td>
<td>Per data window (day, hour)</td>
</tr>
<tr>
<td>Dependencies</td>
<td>None — consumers are independent</td>
<td>Explicit task dependencies in DAG</td>
</tr>
<tr>
<td>Observability</td>
<td>Cloud Logging per message</td>
<td>DAG UI, per-task logs, SLA tracking</td>
</tr>
<tr>
<td>Failure scope</td>
<td>Per message retry</td>
<td>Per DAG run retry, backfill support</td>
</tr>
<tr>
<td>Use case</td>
<td>React now</td>
<td>Aggregate, reconcile, report</td>
</tr>
</tbody></table>
<p>These two layers don't overlap in PulseCart — they handle genuinely different categories of work. Trying to do nightly aggregation with Cloud Tasks, or trying to do per-event reactions with Airflow, would be fighting the tool.</p>
<hr />
<h2>Deploying DAGs to Cloud Composer</h2>
<p>Cloud Composer exposes a GCS bucket for DAG storage. Deploying is straightforward:</p>
<pre><code class="language-bash"># Upload a DAG file to the Composer environment's DAG bucket
gsutil cp dags/nightly_aggregation.py \
  gs://your-composer-bucket/dags/nightly_aggregation.py
</code></pre>
<p>In practice, this step is part of the GitHub Actions CI/CD pipeline (Day 6) — DAG files are synced to the GCS bucket on every merge to main, so the Airflow scheduler picks up changes automatically within a few minutes.</p>
<hr />
<h2>What's Next</h2>
<p>Day 6 covers the full production infrastructure: Terraform modules for every service we've built across this series — Pub/Sub topics, Cloud Run, Cloud Tasks queues, Cloud SQL, Redis Memorystore, and Cloud Composer — plus GitHub Actions CI/CD for zero-downtime deploys.</p>
]]></content:encoded></item><item><title><![CDATA[Cloud Run Consumers and Cloud Tasks: Reactive and Delayed Workflows]]></title><description><![CDATA[In Day 3 we built the FastAPI producer that publishes events to Pub/Sub. Now we build what consumes them. This is where the event-driven model pays off — multiple independent services reacting to the ]]></description><link>https://blog.madhav.dev/cloud-run-consumers-and-cloud-tasks-reactive-and-delayed-workflows</link><guid isPermaLink="true">https://blog.madhav.dev/cloud-run-consumers-and-cloud-tasks-reactive-and-delayed-workflows</guid><category><![CDATA[GCP]]></category><category><![CDATA[#cloudrun]]></category><category><![CDATA[cloud tasks]]></category><category><![CDATA[event-driven-architecture]]></category><category><![CDATA[Python]]></category><dc:creator><![CDATA[Madhav Bhasin]]></dc:creator><pubDate>Wed, 01 Jul 2026 17:35:41 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/622ac3bee552e99d82e59b17/cd0dc5d7-5923-4bb7-ba50-d9ff740a62f4.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>In Day 3 we built the FastAPI producer that publishes events to Pub/Sub. Now we build what consumes them. This is where the event-driven model pays off — multiple independent services reacting to the same events, each doing exactly one job, without knowing anything about each other.</p>
<p>Day 4 covers two distinct patterns that serve different needs in PulseCart:</p>
<ul>
<li><p><strong>Cloud Run + Pub/Sub push</strong> for immediate, reactive work</p>
</li>
<li><p><strong>Cloud Tasks</strong> for delayed, scheduled, and explicitly retryable work</p>
</li>
</ul>
<p>Understanding when to use each — and why they're complementary rather than interchangeable — is one of the more practical decisions in this stack.</p>
<hr />
<h2>Pattern 1: Cloud Run Consumer via Pub/Sub Push</h2>
<p>When an <code>order.placed</code> event hits <code>pulsecart.commerce-events</code>, we want to react immediately — trigger a confirmation message, update the user's order history, notify the inventory service. This is Cloud Run's job.</p>
<p>A push subscription delivers each Pub/Sub message as an HTTP POST to your Cloud Run service URL. The service processes it and returns a 2xx to acknowledge. No polling, no ack_id management — Pub/Sub handles delivery, Cloud Run handles processing.</p>
<h3>Decoding the Push Payload</h3>
<p>Pub/Sub wraps the message in an envelope before posting it:</p>
<pre><code class="language-python"># models/pubsub.py
from pydantic import BaseModel
from typing import Dict, Optional
import base64
import json

class PubSubMessage(BaseModel):
    data: str                          # base64-encoded event JSON
    messageId: str
    publishTime: str
    attributes: Optional[Dict[str, str]] = {}

class PubSubPushEnvelope(BaseModel):
    message: PubSubMessage
    subscription: str

    def decode_event(self) -&gt; dict:
        decoded = base64.b64decode(self.message.data).decode("utf-8")
        return json.loads(decoded)
</code></pre>
<h3>The Consumer Service</h3>
<pre><code class="language-python"># routers/consumer.py
from fastapi import APIRouter, HTTPException, status, Request
from models.pubsub import PubSubPushEnvelope
from services.idempotency import is_duplicate, mark_processed
from services.tasks import schedule_cart_reminder
from services.messaging import trigger_personalized_message
import logging

logger = logging.getLogger(__name__)
router = APIRouter(prefix="/consumer", tags=["consumer"])


@router.post("/push", status_code=status.HTTP_204_NO_CONTENT)
async def handle_push(envelope: PubSubPushEnvelope):
    event = envelope.decode_event()
    event_id = event.get("event_id")
    event_type = event.get("event_type")

    if not event_id or not event_type:
        # Malformed message — ack it anyway to avoid infinite retry
        logger.error(f"Malformed event, missing event_id or event_type: {event}")
        return

    # Idempotency check — deduplicate before any processing
    if await is_duplicate(event_id):
        logger.info(f"Duplicate event skipped: {event_id}")
        return

    try:
        if event_type == "order.placed":
            await trigger_personalized_message(event, template="order_confirmation")

        elif event_type == "cart.abandoned":
            # Don't send immediately — schedule a delayed reminder via Cloud Tasks
            await schedule_cart_reminder(event, delay_seconds=7200)  # 2 hours

        elif event_type == "payment.failed":
            await trigger_personalized_message(event, template="payment_retry")

        # Mark as processed only after successful handling
        await mark_processed(event_id)

    except Exception as e:
        logger.error(f"Processing failed for {event_id}: {e}")
        # Raise so Pub/Sub retries delivery (don't return 2xx on failure)
        raise HTTPException(
            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
            detail=f"Processing failed: {str(e)}"
        )
</code></pre>
<p>Two things to notice here: the <code>cart.abandoned</code> case doesn't process immediately — it hands off to Cloud Tasks for a delayed reminder. And any unhandled exception returns a 5xx, which tells Pub/Sub to retry. Returning a 2xx on failure would silently ack and drop the message.</p>
<hr />
<h2>Idempotency with Redis</h2>
<p>Pub/Sub guarantees at-least-once delivery. In practice, duplicate delivery is rare but real — network hiccups, consumer restarts, or Pub/Sub's own retry logic can cause the same message to arrive twice. Without idempotency, a user could receive two order confirmation emails for one purchase.</p>
<p>PulseCart uses Redis (Cloud Memorystore) as the deduplication store. The pattern is simple: before processing any event, check if its <code>event_id</code> exists in Redis. If it does, skip. If it doesn't, process and then write the key with a TTL.</p>
<pre><code class="language-python"># services/idempotency.py
import aioredis
from config import settings

redis = aioredis.from_url(settings.redis_url, decode_responses=True)

IDEMPOTENCY_TTL = 86400  # 24 hours — longer than Pub/Sub's max retry window


async def is_duplicate(event_id: str) -&gt; bool:
    result = await redis.get(f"processed:{event_id}")
    return result is not None


async def mark_processed(event_id: str) -&gt; None:
    await redis.set(
        f"processed:{event_id}",
        "1",
        ex=IDEMPOTENCY_TTL
    )
</code></pre>
<p>The TTL matters. You need it to be longer than Pub/Sub's maximum retry window for a given subscription (configurable, but defaults to 7 days with exponential backoff). 24 hours is conservative for most cases — tune it to your subscription's actual retry policy.</p>
<p>One subtlety: <code>mark_processed</code> is called <strong>after</strong> successful processing, not before. If processing fails halfway through, the event_id stays unset in Redis and Pub/Sub will redeliver. This means your downstream operations (sending a message, writing to Cloud SQL) also need to be idempotent — or wrapped in a transaction that either fully succeeds or fully rolls back. For PulseCart's use case, the messaging service handles this by checking for an existing <code>message_sent</code> record before sending.</p>
<hr />
<h2>Pattern 2: Cloud Tasks for Delayed and Retryable Work</h2>
<p>Not everything should happen immediately. PulseCart's cart abandonment reminder shouldn't fire the moment someone leaves the site — that would be jarring and almost certainly wrong (they might just be switching tabs). The right behavior is to wait two hours, check if the cart is still abandoned, and only then send a reminder.</p>
<p>This is exactly what Cloud Tasks is built for: scheduling work to happen at a specific time in the future, with explicit retry control.</p>
<h3>Creating a Cloud Tasks Task</h3>
<pre><code class="language-python"># services/tasks.py
from google.cloud import tasks_v2
from google.protobuf import timestamp_pb2
from config import settings
import json
import datetime

tasks_client = tasks_v2.CloudTasksClient()

QUEUE_PATH = tasks_client.queue_path(
    settings.gcp_project_id,
    settings.gcp_region,
    "pulsecart-cart-reminders"
)

HANDLER_URL = f"https://{settings.cloud_run_consumer_url}/tasks/cart-reminder"


async def schedule_cart_reminder(event: dict, delay_seconds: int = 7200):
    scheduled_time = datetime.datetime.utcnow() + datetime.timedelta(seconds=delay_seconds)

    timestamp = timestamp_pb2.Timestamp()
    timestamp.FromDatetime(scheduled_time)

    task = {
        "http_request": {
            "http_method": tasks_v2.HttpMethod.POST,
            "url": HANDLER_URL,
            "headers": {"Content-Type": "application/json"},
            "body": json.dumps({
                "event_id": event["event_id"],
                "user_id": event["user_id"],
                "cart_id": event["payload"].get("cart_id"),
                "total": event["payload"].get("total"),
            }).encode(),
            "oidc_token": {
                "service_account_email": settings.service_account_email
            }
        },
        "schedule_time": timestamp,
        "name": f"{QUEUE_PATH}/tasks/cart-reminder-{event['event_id']}"
    }

    response = tasks_client.create_task(
        request={"parent": QUEUE_PATH, "task": task}
    )
    return response.name
</code></pre>
<p>The task name (<code>cart-reminder-{event_id}</code>) is deterministic. Cloud Tasks will reject a duplicate task creation with the same name within the deduplication window — this gives you free idempotency on task scheduling. If <code>schedule_cart_reminder</code> is called twice for the same <code>cart.abandoned</code> event (because Pub/Sub re-delivered it), only one task gets created.</p>
<h3>The Cart Reminder Handler</h3>
<pre><code class="language-python"># routers/tasks.py
from fastapi import APIRouter, HTTPException, status
from services.cart import get_cart_status
from services.messaging import trigger_personalized_message
import logging

logger = logging.getLogger(__name__)
router = APIRouter(prefix="/tasks", tags=["tasks"])


@router.post("/cart-reminder", status_code=status.HTTP_204_NO_CONTENT)
async def handle_cart_reminder(payload: dict):
    cart_id = payload.get("cart_id")
    user_id = payload.get("user_id")
    event_id = payload.get("event_id")

    # Re-check cart status at execution time — it may have been purchased
    cart = await get_cart_status(cart_id)

    if cart is None or cart.status == "purchased":
        logger.info(f"Cart {cart_id} no longer abandoned, skipping reminder")
        return

    await trigger_personalized_message(
        {"user_id": user_id, "event_id": event_id, "payload": payload},
        template="cart_abandonment_reminder"
    )
</code></pre>
<p>This is the critical check that justifies using Cloud Tasks over a simple <code>asyncio.sleep</code>. Two hours after the event was published, the handler re-validates that the cart is still abandoned before sending anything. If the user purchased in the meantime, the task exits cleanly. You can't do this reliably in memory — if your Cloud Run instance restarts during the wait, the reminder is lost.</p>
<hr />
<h2>Push vs Cloud Tasks: When to Use Each</h2>
<table>
<thead>
<tr>
<th>Concern</th>
<th>Pub/Sub Push (Cloud Run)</th>
<th>Cloud Tasks</th>
</tr>
</thead>
<tbody><tr>
<td>Timing</td>
<td>Immediate</td>
<td>Delayed / scheduled</td>
</tr>
<tr>
<td>Retry control</td>
<td>Subscription-level policy</td>
<td>Per-task, explicit</td>
</tr>
<tr>
<td>Deduplication</td>
<td>Manual (Redis)</td>
<td>Built-in (task name)</td>
</tr>
<tr>
<td>State at execution</td>
<td>Event payload only</td>
<td>Can re-query state</td>
</tr>
<tr>
<td>Cost model</td>
<td>Per message delivered</td>
<td>Per task created</td>
</tr>
</tbody></table>
<p>The decision is straightforward in practice: if the work needs to happen now, use a push subscription into Cloud Run. If the work needs to happen later, or if you need to re-validate state before acting, use Cloud Tasks.</p>
<hr />
<h2>Wiring It Together in main.py</h2>
<pre><code class="language-python"># main.py
from fastapi import FastAPI
from routers.consumer import router as consumer_router
from routers.tasks import router as tasks_router
import logging

logging.basicConfig(level=logging.INFO)

app = FastAPI(
    title="PulseCart Consumer",
    version="1.0.0"
)

app.include_router(consumer_router)
app.include_router(tasks_router)

@app.get("/health")
async def health():
    return {"status": "ok"}
</code></pre>
<hr />
<h2>Cloud Run Configuration Notes</h2>
<p>A few Cloud Run settings that matter for consumer reliability:</p>
<p><strong>Concurrency</strong>: Cloud Run defaults to 80 concurrent requests per instance. For a consumer doing lightweight work (validate, route, trigger), this is fine. If your handlers do heavy I/O, lower this to prevent one slow request from starving others.</p>
<p><strong>Min instances</strong>: Set to at least 1 for the commerce-events consumer. Cold starts on a push subscription mean Pub/Sub retries while your instance is spinning up, which adds unnecessary latency on high-priority events.</p>
<p><strong>Timeout</strong>: Default is 300 seconds. For push subscription handlers that should be fast, set this lower (30–60 seconds) so slow requests fail loudly rather than silently consuming your concurrency budget.</p>
<hr />
<h2>What's Next</h2>
<p>Day 5 brings in Airflow — specifically Cloud Composer — to handle PulseCart's batch and scheduled workflows: the nightly aggregation jobs, dead-letter reconciliation, and how the orchestrated layer complements rather than duplicates the reactive one we've built here.</p>
]]></content:encoded></item><item><title><![CDATA[FastAPI Producer: Publishing Commerce Events to Pub/Sub]]></title><description><![CDATA[We have a taxonomy (Day 1) and a Pub/Sub backbone (Day 2). Now we build the service that connects them: the FastAPI ingestion service that receives commerce events, validates them, and publishes them ]]></description><link>https://blog.madhav.dev/fastapi-producer-publishing-commerce-events-to-pub-sub</link><guid isPermaLink="true">https://blog.madhav.dev/fastapi-producer-publishing-commerce-events-to-pub-sub</guid><category><![CDATA[FastAPI]]></category><category><![CDATA[GCP]]></category><category><![CDATA[Python]]></category><category><![CDATA[event-driven-architecture]]></category><dc:creator><![CDATA[Madhav Bhasin]]></dc:creator><pubDate>Sun, 28 Jun 2026 12:14:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/622ac3bee552e99d82e59b17/a8d56f0d-1da0-4bc3-9a53-460e55eeac8d.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>We have a taxonomy (Day 1) and a Pub/Sub backbone (Day 2). Now we build the service that connects them: the FastAPI ingestion service that receives commerce events, validates them, and publishes them to the right Pub/Sub topic.</p>
<p>This service is the entry point to PulseCart's entire event pipeline. Everything downstream — Cloud Run consumers, Cloud Tasks, Airflow DAGs — depends on what comes out of this producer. Getting the validation, routing, and delivery semantics right here saves pain at every layer below.</p>
<hr />
<h2>What the Producer Needs to Do</h2>
<p>The producer has four responsibilities, in order:</p>
<ol>
<li><p><strong>Receive</strong> an event via HTTP POST from internal services (checkout, cart, user signup)</p>
</li>
<li><p><strong>Validate</strong> the event against the Pydantic schema for that event type</p>
</li>
<li><p><strong>Enrich</strong> it with a <code>event_id</code> and <code>timestamp</code> if not already present</p>
</li>
<li><p><strong>Publish</strong> it to the correct Pub/Sub topic with the right message attributes</p>
</li>
</ol>
<p>It deliberately does nothing else. No business logic, no database writes, no downstream calls. Its job is ingestion and publishing — fast, reliable, and stateless.</p>
<hr />
<h2>Project Structure</h2>
<pre><code class="language-plaintext">pulsecart-producer/
├── main.py
├── models/
│   ├── __init__.py
│   ├── base.py          # PulseCartEvent base schema
│   └── commerce.py      # CartItemAdded, OrderPlaced, CartAbandoned, etc.
├── services/
│   ├── __init__.py
│   └── publisher.py     # Pub/Sub publishing logic
├── routers/
│   ├── __init__.py
│   └── events.py        # FastAPI route handlers
├── config.py            # GCP project ID, topic names, env config
└── requirements.txt
</code></pre>
<hr />
<h2>The Event Models</h2>
<p>We introduced <code>PulseCartEvent</code> in Day 1. Here's the full set of models for PulseCart's commerce events:</p>
<pre><code class="language-python"># models/base.py
from pydantic import BaseModel, Field
from typing import Any, Dict
from datetime import datetime
import uuid

class PulseCartEvent(BaseModel):
    event_type: str
    event_id: str = Field(default_factory=lambda: f"evt_{uuid.uuid4().hex[:12]}")
    timestamp: datetime = Field(default_factory=datetime.utcnow)
    user_id: str
    payload: Dict[str, Any]
</code></pre>
<pre><code class="language-python"># models/commerce.py
from .base import PulseCartEvent
from typing import List, Optional

class CartItemAddedEvent(PulseCartEvent):
    event_type: str = "cart.item_added"
    session_id: str

class OrderPlacedEvent(PulseCartEvent):
    event_type: str = "order.placed"
    order_id: str

class CartAbandonedEvent(PulseCartEvent):
    event_type: str = "cart.abandoned"
    session_id: str

class PaymentFailedEvent(PulseCartEvent):
    event_type: str = "payment.failed"
    order_id: str

# Registry: maps event_type string to its model class
EVENT_REGISTRY = {
    "cart.item_added": CartItemAddedEvent,
    "order.placed": OrderPlacedEvent,
    "cart.abandoned": CartAbandonedEvent,
    "payment.failed": PaymentFailedEvent,
}
</code></pre>
<p>The <code>EVENT_REGISTRY</code> dict is the key pattern here — it lets the router dynamically resolve the correct model for any incoming event type without a chain of <code>if/elif</code> blocks.</p>
<hr />
<h2>The Publisher Service</h2>
<pre><code class="language-python"># services/publisher.py
from google.cloud import pubsub_v1
from google.api_core.exceptions import GoogleAPICallError
from config import settings
import json
import logging

logger = logging.getLogger(__name__)

publisher = pubsub_v1.PublisherClient()

TOPIC_MAP = {
    "user-actions":    ["cart.item_added", "product.viewed", "search.performed"],
    "commerce-events": ["order.placed", "cart.abandoned", "payment.failed"],
    "system-events":   ["message.personalized_send", "inventory.low_stock_alert"],
}

def resolve_topic(event_type: str) -&gt; str:
    for topic_suffix, event_types in TOPIC_MAP.items():
        if event_type in event_types:
            topic_name = f"pulsecart.{topic_suffix}"
            return publisher.topic_path(settings.gcp_project_id, topic_name)
    raise ValueError(f"No topic mapping found for event_type: {event_type}")


async def publish_event(event: dict) -&gt; str:
    topic_path = resolve_topic(event["event_type"])

    message_data = json.dumps(event, default=str).encode("utf-8")

    try:
        future = publisher.publish(
            topic_path,
            data=message_data,
            ordering_key=event["user_id"],
            event_type=event["event_type"],       # message attribute for filtering
            event_id=event["event_id"],           # attribute for deduplication downstream
        )
        message_id = future.result(timeout=10)
        logger.info(f"Published {event['event_type']} | event_id={event['event_id']} | msg_id={message_id}")
        return message_id

    except GoogleAPICallError as e:
        logger.error(f"Pub/Sub publish failed for {event['event_type']}: {e}")
        raise
</code></pre>
<p>A few things worth noting here:</p>
<p><code>ordering_key=event["user_id"]</code> — as established in Day 2, user-scoped ordering ensures events for a given user arrive in sequence at the consumer.</p>
<p><strong>Message attributes</strong> — <code>event_type</code> and <code>event_id</code> are published as Pub/Sub message attributes (not inside the payload). This lets consumers filter by <code>event_type</code> at the subscription level without deserializing the message body, which is faster and cleaner.</p>
<p><code>future.result(timeout=10)</code> — this blocks until Pub/Sub confirms the message was received. For a synchronous HTTP endpoint this is acceptable; the publish call typically resolves in under 100ms. If you need higher throughput, you'd batch publishes or use async publishing with callbacks instead.</p>
<hr />
<h2>The FastAPI Router</h2>
<pre><code class="language-python"># routers/events.py
from fastapi import APIRouter, HTTPException, status
from models.commerce import EVENT_REGISTRY
from models.base import PulseCartEvent
from services.publisher import publish_event
from pydantic import ValidationError
import logging

logger = logging.getLogger(__name__)
router = APIRouter(prefix="/events", tags=["events"])


@router.post("/ingest", status_code=status.HTTP_202_ACCEPTED)
async def ingest_event(raw_event: dict):
    event_type = raw_event.get("event_type")

    if not event_type:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="Missing required field: event_type"
        )

    model_class = EVENT_REGISTRY.get(event_type)
    if not model_class:
        raise HTTPException(
            status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
            detail=f"Unknown event_type: {event_type}"
        )

    try:
        event = model_class(**raw_event)
    except ValidationError as e:
        logger.warning(f"Validation failed for {event_type}: {e}")
        raise HTTPException(
            status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
            detail=e.errors()
        )

    message_id = await publish_event(event.model_dump())

    return {
        "status": "accepted",
        "event_id": event.event_id,
        "message_id": message_id
    }
</code></pre>
<p>The endpoint returns <code>202 Accepted</code>, not <code>200 OK</code>. This is intentional: the event has been accepted and published, but processing happens downstream asynchronously. A <code>200</code> would imply the work is done, which it isn't.</p>
<p>The response includes both <code>event_id</code> (generated by the Pydantic model) and <code>message_id</code> (returned by Pub/Sub). Callers can use <code>event_id</code> to trace the event through the pipeline — it travels with the message as an attribute all the way to the consumer.</p>
<hr />
<h2>The Main App</h2>
<pre><code class="language-python"># main.py
from fastapi import FastAPI
from routers.events import router as events_router
import logging

logging.basicConfig(level=logging.INFO)

app = FastAPI(
    title="PulseCart Event Producer",
    description="Ingestion service for PulseCart commerce events",
    version="1.0.0"
)

app.include_router(events_router)

@app.get("/health")
async def health():
    return {"status": "ok"}
</code></pre>
<hr />
<h2>Config and Environment</h2>
<pre><code class="language-python"># config.py
from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    gcp_project_id: str
    environment: str = "dev"

    class Config:
        env_file = ".env"

settings = Settings()
</code></pre>
<pre><code class="language-bash"># .env (never commit this)
GCP_PROJECT_ID=your-gcp-project-id
ENVIRONMENT=dev
</code></pre>
<p>Pub/Sub authentication uses Application Default Credentials — on Cloud Run, the service account attached to the Cloud Run service handles this automatically. Locally, you run <code>gcloud auth application-default login</code>.</p>
<hr />
<h2>At-Least-Once Delivery and What It Means for Producers</h2>
<p>Pub/Sub guarantees at-least-once delivery — every message will be delivered to every subscription at least once, but potentially more than once. This is not a producer concern; it's a consumer concern. The producer's job is simply to publish reliably.</p>
<p>What the producer does own is making sure every event has a stable, unique <code>event_id</code>. This is what consumers use to deduplicate — if the same <code>event_id</code> arrives twice, the consumer can detect and discard the duplicate. We'll cover the consumer-side deduplication pattern with Redis in Day 4.</p>
<hr />
<h2>Batching for High-Throughput Scenarios</h2>
<p>For most PulseCart events, single-message publishing is fine. But user action events (<code>product.viewed</code>, <code>search.performed</code>) can arrive in high bursts. For those, batching reduces Pub/Sub API calls and lowers cost:</p>
<pre><code class="language-python"># Batch settings on the PublisherClient
from google.cloud.pubsub_v1.types import BatchSettings

batch_settings = pubsub_v1.types.BatchSettings(
    max_messages=100,
    max_bytes=1 * 1024 * 1024,  # 1MB
    max_latency=0.05,           # 50ms max wait before flushing
)

publisher = pubsub_v1.PublisherClient(batch_settings=batch_settings)
</code></pre>
<p>With these settings, the client accumulates up to 100 messages or 1MB (whichever comes first) and flushes within 50ms. For the <code>pulsecart.user-actions</code> topic, this is a sensible default. For <code>pulsecart.commerce-events</code>, where latency matters more than throughput efficiency, keep single-message publishing.</p>
<hr />
<h2>Testing the Producer Locally</h2>
<pre><code class="language-bash"># Run the service
uvicorn main:app --reload --port 8000

# Publish a test cart.abandoned event
curl -X POST http://localhost:8000/events/ingest \
  -H "Content-Type: application/json" \
  -d '{
    "event_type": "cart.abandoned",
    "user_id": "usr_8821",
    "session_id": "sess_44fa9b",
    "payload": {
      "cart_id": "cart_99012",
      "items": [{"product_id": "prod_991", "quantity": 1}],
      "total": 89.99
    }
  }'

# Expected response
{
  "status": "accepted",
  "event_id": "evt_a3f9b21c4d8e",
  "message_id": "136969346945"
}
</code></pre>
<p>For local testing against real Pub/Sub, use a dedicated <code>dev</code> project or the Pub/Sub emulator (<code>gcloud beta emulators pubsub start</code>) to avoid polluting production topics.</p>
<hr />
<h2>What's Next</h2>
<p>Day 4 builds the Cloud Run consumer that receives these events via Pub/Sub push subscriptions — including how to handle at-least-once delivery with Redis-based idempotency, and where Cloud Tasks fits in for delayed workflows like cart abandonment reminders.</p>
]]></content:encoded></item><item><title><![CDATA[Pub/Sub Topics and Subscriptions: Designing PulseCart's Event Backbone]]></title><description><![CDATA[In Day 1 we defined PulseCart's event taxonomy — what events exist, what they mean, and what triggers them. Now we need somewhere to send them. That's Pub/Sub's job: a fully managed message broker tha]]></description><link>https://blog.madhav.dev/pub-sub-topics-and-subscriptions-designing-pulsecart-s-event-backbone</link><guid isPermaLink="true">https://blog.madhav.dev/pub-sub-topics-and-subscriptions-designing-pulsecart-s-event-backbone</guid><category><![CDATA[GCP]]></category><category><![CDATA[PubSub]]></category><category><![CDATA[event-driven-architecture]]></category><category><![CDATA[backend]]></category><category><![CDATA[deadletter-queue]]></category><dc:creator><![CDATA[Madhav Bhasin]]></dc:creator><pubDate>Mon, 22 Jun 2026 17:50:58 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/622ac3bee552e99d82e59b17/b9810583-267a-4a5b-8dd7-54bd8e697c50.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>In Day 1 we defined PulseCart's event taxonomy — what events exist, what they mean, and what triggers them. Now we need somewhere to send them. That's Pub/Sub's job: a fully managed message broker that decouples producers from consumers, handles delivery guarantees, and scales without you managing a single broker node.</p>
<p>Before writing any producer code, it's worth spending time on Pub/Sub's design — specifically topics, subscriptions, and the decisions around them. Getting this right early saves you from painful restructuring once consumers are in production.</p>
<hr />
<h2>How Pub/Sub Works</h2>
<p>The model is straightforward:</p>
<ul>
<li><p>A <strong>topic</strong> is a named channel. Producers publish messages to a topic.</p>
</li>
<li><p>A <strong>subscription</strong> is an attachment to a topic. Consumers read from subscriptions, not directly from topics.</p>
</li>
<li><p>One topic can have multiple subscriptions. Each subscription gets its own independent copy of every message published to that topic.</p>
</li>
</ul>
<p>This means you can add a new consumer (a new subscription) without changing the producer or any existing consumers. The producer doesn't know or care who's listening.</p>
<pre><code class="language-plaintext">Producer (FastAPI)
      │
      ▼
  [Topic: pulsecart.commerce.events]
      │
      ├──▶ [Subscription: sub-realtime-consumer]   → Cloud Run
      ├──▶ [Subscription: sub-cloud-tasks-router]  → Cloud Tasks
      └──▶ [Subscription: sub-airflow-ingestion]   → Cloud Composer DAG
</code></pre>
<hr />
<h2>PulseCart's Topic Structure</h2>
<p>A common mistake is creating one topic for everything. It feels simpler upfront but creates problems: consumers that only care about <code>order.placed</code> still receive every <code>product.viewed</code> event and have to filter client-side. Routing gets messy. Access control becomes coarse.</p>
<p>PulseCart uses three topics, aligned to the event categories from Day 1:</p>
<table>
<thead>
<tr>
<th>Topic</th>
<th>Event Types</th>
<th>Purpose</th>
</tr>
</thead>
<tbody><tr>
<td><code>pulsecart.user-actions</code></td>
<td><code>cart.item_added</code>, <code>product.viewed</code>, <code>search.performed</code></td>
<td>High-volume, low-criticality user signals</td>
</tr>
<tr>
<td><code>pulsecart.commerce-events</code></td>
<td><code>order.placed</code>, <code>cart.abandoned</code>, <code>payment.failed</code></td>
<td>Business-critical state changes</td>
</tr>
<tr>
<td><code>pulsecart.system-events</code></td>
<td><code>message.personalized_send</code>, <code>inventory.low_stock_alert</code></td>
<td>Internal automation triggers</td>
</tr>
</tbody></table>
<p>Separating <code>commerce-events</code> from <code>user-actions</code> is particularly important. Commerce events are higher-stakes — they need tighter retry policies, stricter monitoring, and potentially different IAM permissions. Mixing them with high-volume user action events makes both harder to manage.</p>
<hr />
<h2>Push vs Pull Subscriptions</h2>
<p>This is the decision that shapes how your consumers are built. Pub/Sub supports two delivery modes:</p>
<h3>Pull Subscriptions</h3>
<p>Your consumer service calls Pub/Sub's API to fetch messages. It controls the polling rate and acknowledges each message after processing.</p>
<pre><code class="language-python">from google.cloud import pubsub_v1

subscriber = pubsub_v1.SubscriberClient()
subscription_path = subscriber.subscription_path(
    "your-project-id", "sub-airflow-ingestion"
)

response = subscriber.pull(
    request={"subscription": subscription_path, "max_messages": 100}
)

for msg in response.received_messages:
    print(f"Received: {msg.message.data.decode()}")
    subscriber.acknowledge(
        request={
            "subscription": subscription_path,
            "ack_ids": [msg.ack_id]
        }
    )
</code></pre>
<p><strong>Use pull when:</strong> the consumer controls its own processing pace (Airflow DAGs pulling batches on a schedule), or when you're processing large volumes and want to batch messages explicitly.</p>
<h3>Push Subscriptions</h3>
<p>Pub/Sub delivers messages to an HTTPS endpoint — your Cloud Run service URL. No polling needed; messages arrive as HTTP POST requests.</p>
<pre><code class="language-json">{
  "subscription": "projects/your-project/subscriptions/sub-realtime-consumer",
  "message": {
    "data": "eyJldmVudF90eXBlIjogIm9yZGVyLnBsYWNlZCIsIC4uLn0=",
    "messageId": "136969346945",
    "publishTime": "2025-06-18T10:31:05Z",
    "attributes": {
      "event_type": "order.placed"
    }
  }
}
</code></pre>
<p>Your Cloud Run service receives this, processes it, and returns a 2xx to acknowledge. No ack_id management needed.</p>
<p><strong>Use push when:</strong> you have a serverless consumer (Cloud Run) that should react immediately to each message, and you want Pub/Sub to handle the delivery rather than polling.</p>
<h3>PulseCart's Subscription Breakdown</h3>
<table>
<thead>
<tr>
<th>Subscription</th>
<th>Mode</th>
<th>Consumer</th>
<th>Reasoning</th>
</tr>
</thead>
<tbody><tr>
<td><code>sub-realtime-consumer</code></td>
<td>Push</td>
<td>Cloud Run</td>
<td>Immediate reaction to commerce events</td>
</tr>
<tr>
<td><code>sub-cloud-tasks-router</code></td>
<td>Push</td>
<td>Cloud Run (router service)</td>
<td>Creates Cloud Tasks for delayed work</td>
</tr>
<tr>
<td><code>sub-airflow-ingestion</code></td>
<td>Pull</td>
<td>Cloud Composer</td>
<td>DAGs pull batches on their own schedule</td>
</tr>
</tbody></table>
<hr />
<h2>Message Ordering</h2>
<p>By default, Pub/Sub does not guarantee message ordering across a subscription. For most of PulseCart's events, this is fine — a <code>product.viewed</code> event being delivered slightly out of order has no consequence.</p>
<p>But for commerce events, order can matter. A <code>cart.abandoned</code> event being processed before <code>cart.item_added</code> would trigger an abandonment email for a cart the system doesn't know exists yet.</p>
<p>Pub/Sub handles this with <strong>ordering keys</strong>. Messages published with the same ordering key are delivered in order to a given subscriber.</p>
<pre><code class="language-python">from google.cloud import pubsub_v1
import json

publisher = pubsub_v1.PublisherClient()
topic_path = publisher.topic_path("your-project-id", "pulsecart.commerce-events")

event = {
    "event_type": "cart.abandoned",
    "user_id": "usr_8821",
    "payload": { "cart_id": "cart_99012", "total": 89.99 }
}

future = publisher.publish(
    topic_path,
    data=json.dumps(event).encode("utf-8"),
    ordering_key="usr_8821",       # ordered per user
    event_type="cart.abandoned"    # message attribute for filtering
)
</code></pre>
<p>Using <code>user_id</code> as the ordering key ensures all events for a given user are delivered in publish order. This is the right scope for PulseCart — user-level ordering, not global ordering (which would be a performance bottleneck at scale).</p>
<p>Note: ordering keys require the subscription to have message ordering enabled. You can't enable it retroactively on a live subscription without downtime.</p>
<hr />
<h2>Dead-Letter Topics</h2>
<p>Even with retries, some messages will never be successfully processed — a malformed payload, a consumer bug, a dependency that's permanently unavailable. Without a dead-letter topic, those messages either retry indefinitely (blocking the subscription) or get dropped silently.</p>
<p>PulseCart uses a dead-letter topic per main topic:</p>
<pre><code class="language-plaintext">pulsecart.commerce-events       →  pulsecart.commerce-events.dead-letter
pulsecart.user-actions          →  pulsecart.user-actions.dead-letter
pulsecart.system-events         →  pulsecart.system-events.dead-letter
</code></pre>
<p>When a message exceeds its maximum delivery attempt count (we use 5), Pub/Sub automatically forwards it to the dead-letter topic. A separate monitoring subscription on each dead-letter topic triggers an alert, and a periodic Airflow DAG (Day 5) reconciles and reprocesses eligible dead-lettered messages.</p>
<p>Configuring this in Terraform looks like this:</p>
<pre><code class="language-hcl">resource "google_pubsub_subscription" "realtime_consumer" {
  name  = "sub-realtime-consumer"
  topic = google_pubsub_topic.commerce_events.name

  ack_deadline_seconds    = 30
  enable_message_ordering = true

  dead_letter_policy {
    dead_letter_topic     = google_pubsub_topic.commerce_events_dead_letter.id
    max_delivery_attempts = 5
  }

  retry_policy {
    minimum_backoff = "10s"
    maximum_backoff = "300s"
  }
}
</code></pre>
<p>The exponential backoff retry policy (<code>10s</code> to <code>300s</code>) prevents a struggling consumer from hammering a downstream dependency in a tight retry loop.</p>
<hr />
<h2>Acknowledgement Deadlines</h2>
<p>Every Pub/Sub message has an acknowledgement deadline — the window within which your consumer must ack the message before Pub/Sub considers it undelivered and retries. The default is 10 seconds.</p>
<p>For Cloud Run consumers doing lightweight work (validate, route, trigger), 10–30 seconds is fine. For consumers doing heavier processing (calling an external API, writing to Cloud SQL), you'll want to either extend the deadline or design the consumer to ack fast and handle the heavy work asynchronously via Cloud Tasks (exactly the pattern we cover in Day 4).</p>
<hr />
<h2>What's Next</h2>
<p>Day 3 builds the FastAPI producer service that validates events against our Pydantic schemas and publishes them to the Pub/Sub topics we've just designed — including how to handle batching, message attributes, and at-least-once delivery in practice.</p>
]]></content:encoded></item><item><title><![CDATA[Event-Driven Fundamentals: Why PulseCart Moved Off Request-Response]]></title><description><![CDATA[Every system starts synchronously. A user clicks "Add to Cart", your API handles it, writes to the database, and returns a 200. Simple, predictable, easy to reason about. Then traffic grows, and the c]]></description><link>https://blog.madhav.dev/event-driven-fundamentals-why-pulsecart-moved-off-request-response</link><guid isPermaLink="true">https://blog.madhav.dev/event-driven-fundamentals-why-pulsecart-moved-off-request-response</guid><category><![CDATA[event-driven-architecture]]></category><category><![CDATA[GCP]]></category><category><![CDATA[GCP DevOps]]></category><category><![CDATA[PubSub]]></category><category><![CDATA[FastAPI]]></category><category><![CDATA[System Design]]></category><dc:creator><![CDATA[Madhav Bhasin]]></dc:creator><pubDate>Sat, 20 Jun 2026 04:48:19 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/622ac3bee552e99d82e59b17/adce9ae0-4a89-4eb2-8389-106ae9b123c3.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Every system starts synchronously. A user clicks "Add to Cart", your API handles it, writes to the database, and returns a 200. Simple, predictable, easy to reason about. Then traffic grows, and the cracks start showing.</p>
<p>PulseCart's first version worked exactly like this. A single checkout endpoint did everything: validated the cart, charged the payment, updated inventory, triggered a confirmation email, and queued a personalization signal — all in one request. At low traffic, it was fine. At scale, it became a liability. One slow email provider call could time out the entire checkout. A spike in orders backed up inventory updates. The system was only as fast as its slowest step.</p>
<p>This is the core problem that event-driven architecture solves — and it's the reason PulseCart's pipeline looks nothing like that original design.</p>
<hr />
<h2>The Problem With Request-Response at Scale</h2>
<p>Synchronous request-response couples the caller to every downstream operation. When your checkout endpoint calls the email service, it's blocked until the email service responds. If inventory updates are slow, checkout is slow. If the personalization service is down, checkout fails.</p>
<p>This creates three concrete problems:</p>
<p><strong>Latency compounds.</strong> Each synchronous step adds to the total response time. Three services averaging 100ms each means your user waits 300ms minimum — before accounting for retries, timeouts, or database contention.</p>
<p><strong>Failures cascade.</strong> A transient failure in one downstream service propagates up to the caller. Your checkout endpoint shouldn't fail because an email provider returned a 503.</p>
<p><strong>Scaling is uneven.</strong> Checkout traffic spikes at different times than, say, personalization scoring. Coupling them in a synchronous chain means you scale everything together, whether or not every service actually needs it.</p>
<hr />
<h2>The Event-Driven Alternative</h2>
<p>Instead of one endpoint doing everything, PulseCart decouples each action into a discrete event. The checkout endpoint does exactly one thing: persist the order and publish an <code>order.placed</code> event. Everything else — email, inventory, personalization — reacts to that event independently and asynchronously.</p>
<p>The caller gets a fast response. Downstream services process at their own pace. A failure in email doesn't affect inventory. Each service scales independently.</p>
<p>This is the shift: from <strong>orchestration</strong> (one caller tells everyone what to do, in order) to <strong>choreography</strong> (each service reacts to what happened, when it's ready).</p>
<hr />
<h2>PulseCart's Event Taxonomy</h2>
<p>Before writing any code, you need to define what events exist in your system, what they mean, and what triggers them. Getting this wrong early is expensive — a vague event schema leads to consumers that guess at intent and producers that overload single event types with unrelated concerns.</p>
<p>PulseCart organizes events into three categories:</p>
<h3>1. User Action Events</h3>
<p>Things a user explicitly does. These are the raw inputs to the system.</p>
<pre><code class="language-json">{
  "event_type": "cart.item_added",
  "event_id": "evt_01HZ9K2MNP3Q",
  "timestamp": "2025-06-18T10:23:41Z",
  "user_id": "usr_8821",
  "session_id": "sess_44fa9b",
  "payload": {
    "product_id": "prod_991",
    "product_name": "Wireless Headphones",
    "quantity": 1,
    "unit_price": 89.99
  }
}
</code></pre>
<p>Other user action events: <code>cart.item_removed</code>, <code>product.viewed</code>, <code>search.performed</code>, <code>user.signed_up</code></p>
<h3>2. Commerce Events</h3>
<p>System-confirmed state changes. These represent something that actually happened in the platform — not just an intent.</p>
<pre><code class="language-json">{
  "event_type": "order.placed",
  "event_id": "evt_01HZ9K7TRP2W",
  "timestamp": "2025-06-18T10:31:05Z",
  "user_id": "usr_8821",
  "order_id": "ord_77234",
  "payload": {
    "items": [
      { "product_id": "prod_991", "quantity": 1, "unit_price": 89.99 }
    ],
    "total": 89.99,
    "currency": "USD",
    "payment_status": "confirmed"
  }
}
</code></pre>
<p>Other commerce events: <code>order.cancelled</code>, <code>cart.abandoned</code>, <code>payment.failed</code>, <code>refund.issued</code></p>
<h3>3. System-Triggered Events</h3>
<p>Events generated by internal services, not by user actions. These drive automation and personalization.</p>
<pre><code class="language-json">{
  "event_type": "message.personalized_send",
  "event_id": "evt_01HZ9M1KQP9A",
  "timestamp": "2025-06-18T12:31:05Z",
  "user_id": "usr_8821",
  "payload": {
    "trigger": "cart.abandoned",
    "channel": "email",
    "template_id": "tpl_abandon_v3",
    "scheduled_at": "2025-06-18T14:31:05Z"
  }
}
</code></pre>
<p>Other system events: <code>recommendation.updated</code>, <code>inventory.low_stock_alert</code>, <code>user.segment_changed</code></p>
<hr />
<h2>Defining the Schema in Python</h2>
<p>In PulseCart, every event is validated against a Pydantic schema before it hits the pipeline. This isn't optional — it's the contract between producers and consumers. A consumer that receives a malformed event and silently ignores it is worse than one that rejects it loudly.</p>
<pre><code class="language-python">from pydantic import BaseModel, Field
from typing import Any, Dict
from datetime import datetime
import uuid

class PulseCartEvent(BaseModel):
    event_type: str
    event_id: str = Field(default_factory=lambda: f"evt_{uuid.uuid4().hex[:12]}")
    timestamp: datetime = Field(default_factory=datetime.utcnow)
    user_id: str
    payload: Dict[str, Any]

class CartItemAddedEvent(PulseCartEvent):
    event_type: str = "cart.item_added"
    session_id: str

    class Config:
        json_schema_extra = {
            "example": {
                "event_type": "cart.item_added",
                "user_id": "usr_8821",
                "session_id": "sess_44fa9b",
                "payload": {
                    "product_id": "prod_991",
                    "quantity": 1,
                    "unit_price": 89.99
                }
            }
        }
</code></pre>
<p>The base <code>PulseCartEvent</code> establishes the envelope — <code>event_type</code>, <code>event_id</code>, <code>timestamp</code>, <code>user_id</code>, <code>payload</code>. Specific event types extend it and add their own fields. Every event goes through this validation before being published to Pub/Sub.</p>
<hr />
<h2>What Makes a Good Event</h2>
<p>A few principles that hold in production:</p>
<p><strong>Events describe what happened, not what to do.</strong> <code>order.placed</code> is a good event. <code>send_confirmation_email</code> is a command masquerading as an event. Consumers decide what they do with an event — that decision doesn't belong in the event name.</p>
<p><strong>Events are immutable facts.</strong> Once an event is published, it happened. You don't update events; you publish new ones. This matters especially for audit trails and replay scenarios.</p>
<p><strong>Include enough context to be useful.</strong> A consumer shouldn't need to make 3 more API calls to figure out what to do with an event. If a <code>cart.abandoned</code> event doesn't include the user's email or at least their <code>user_id</code>, the email consumer is stuck.</p>
<p><strong>Keep payloads focused.</strong> Don't dump the entire user profile into every event. Include what's relevant to that specific state change, and let consumers fetch additional context if they need it.</p>
<hr />
<h2>PulseCart's Event Flow, End to End</h2>
<p>Here's the full picture of what we're building:</p>
<pre><code class="language-plaintext">User Action
    │
    ▼
FastAPI Ingestion Service
    │  (validates with Pydantic, assigns event_id + timestamp)
    ▼
Pub/Sub Topic (e.g. pulsecart.commerce.events)
    │
    ├──▶ Cloud Run Consumer (real-time reactions: trigger personalized message)
    │
    ├──▶ Cloud Tasks (delayed work: cart abandonment reminder in 2 hours)
    │
    └──▶ Airflow DAG (nightly: aggregate, reconcile, update recommendation scores)
</code></pre>
<p>Each layer has a specific job. The ingestion service doesn't know or care what happens downstream. Pub/Sub delivers to whoever is subscribed. Consumers are independent and can fail without affecting each other or the producer.</p>
<p>We'll build each piece in the posts ahead.</p>
<hr />
<h2>What's Next</h2>
<p>Day 2 covers Pub/Sub topics and subscriptions in depth — how to structure PulseCart's event backbone, when to use push vs pull subscriptions, and how dead-letter topics save you when things go wrong downstream.</p>
]]></content:encoded></item></channel></rss>