Build Redis Cache-Aside for OrderFlow

Add Redis cache-aside to OrderFlow Catalog with resilient PostgreSQL fallback.

Introduction

30 Second Summary

A product page feels slow when every repeat visit makes you wait for the same information again. A faster response matters only if the price stays correct when something goes wrong.

In this project, you will extend OrderFlow's Catalog service with a Redis Open Source cache that speeds up repeated product lookups. PostgreSQL remains the source of truth, so the service keeps returning durable product data when Redis stops.

What You'll Build

You'll demo a product lookup whose X-Cache header reveals a slower MISS, a faster HIT, and a successful BYPASS during a cache outage.

By the end of this project, you'll have:

  • A cache-aside demo that shows TEE-001 arriving from PostgreSQL on a MISS before the same lookup returns faster as a HIT.
  • Fresh product prices that remain accurate after updates because stale cache entries are invalidated. Each entry also expires after 15 seconds.
  • Graceful degradation that keeps Catalog responding from PostgreSQL with X-Cache: BYPASS when Redis is unavailable.
  • Secret Mission: Detect a malformed cached product, remove it, and recover the correct data from PostgreSQL.

Are there any prerequisites?

You need the completed OrderFlow workspace from Projects 1 through 3 before you begin. You also need Docker Engine, Docker Compose, and Visual Studio Code working on Linux.

Before We Start

Before any hands-on work, commit to using Redis to accelerate repeat Catalog product reads. PostgreSQL remains the authoritative durable source so a Redis outage never blocks product retrieval.

Verify the Inherited Workspace

Before Redis joins OrderFlow, the inherited Catalog service needs a clean bill of health. A broken service or Compose model would make every later cache result misleading.

Here, you'll restore the completed OrderFlow baseline. You'll prove Docker Compose can resolve the stack before Catalog reads TEE-001 from PostgreSQL.

In this step, get ready to:
  • Open the completed persistent-checkout workspace.
  • Validate the inherited Docker Compose model.
  • Start the inherited stack so Catalog returns TEE-001 from PostgreSQL.
Open the required starting point

This project extends the workspace built across the first three OrderFlow projects. Those projects supply the existing services, databases, files, and network boundaries used here.

Good work. The inherited OrderFlow workspace is now open with the Catalog files this project extends.

Can't find the inherited files?

  • Check that you opened persistent-checkout instead of one of its parent folders.
  • Finish Projects 1 through 3 if compose.override.yaml or catalog-service/main.py is missing.

Still stuck? Help me find the inherited OrderFlow files.

Validate the merged Compose model

Docker Compose merges the inherited configuration files into one application model. Validating that model now catches missing services or invalid configuration before any containers start.

  • Select Terminal from the Visual Studio Code menu bar.
  • Select New Terminal.
  • Validate the merged service list by running this command:
docker compose config --services

What does this command do?

  • Docker Compose parses the project configuration files into one resolved model.
  • The --services flag prints the service names from that model.

You should see order-service, inventory-service, and catalog-service. You should also see their PostgreSQL services.

Does Compose reject the model?

  • Confirm the terminal is running from the open persistent-checkout workspace.
  • Check that both compose.yaml and compose.override.yaml appear in the Explorer.

Need another pair of eyes? Help me diagnose my merged Docker Compose model.

Start the inherited stack and verify Catalog

The validated model describes what Docker should run. Starting the stack proves those services can build together and reach a healthy state.

The first startup can take a few minutes while Docker builds the service images. The terminal remains busy until the health checks finish.

  • Build the images and start the inherited services by running:
docker compose up --build --wait

What does this command do?

  • The --build option rebuilds the service images from their current files.
  • The --wait option keeps the command active until the services are running or healthy.

The command finishes when Docker has started the inherited stack and completed its health checks.

Did the stack fail to start?

  • Read the final terminal lines to identify the service that failed its build or health check.
  • Resolve any unfinished work from Projects 1 through 3 before repeating the startup command.

Need help with the failure? Help me diagnose why my inherited OrderFlow stack did not start.

Before you check, which inherited services do you expect to see running? Hold that prediction in mind.

  • Inspect the running Compose services by running:
docker compose ps

What does this command show?

Docker Compose lists the containers in this application with their current status. It also shows any ports exposed to your computer.

You should see the inherited Order, Inventory, and Catalog services running. Their PostgreSQL services should also show a running or healthy state.

The final proof comes from the Catalog API itself. Swagger UI lets you execute the inherited product route from your browser.

Before you execute the request, do you expect Catalog to find the inherited product? Keep your prediction in mind.

  • Click Execute.

✔️ I see TEE-001

The response contains TEE-001 with its product name and positive price_cents value. This proves Catalog can read the durable product from PostgreSQL.

ⓧ I see a 404 response

The inherited database is running without the sample product. Create the missing record through the existing Catalog route.

  • Expand POST /products.
  • Click Try it out.
  • Set sku to TEE-001 in the request body.
  • Set name to OrderFlow Tee.
  • Set price_cents to 3000.
  • Click Execute.
  • Return to GET /products/{sku}.
  • Click Execute again.

You should now see a product response containing TEE-001. That result confirms the inherited Catalog service can retrieve durable data from PostgreSQL.

That's the baseline secured. Next, you'll expose the cost of sending every product lookup to PostgreSQL.

Reveal the Database Path

The inherited Catalog endpoint already returns TEE-001 from PostgreSQL. That proves the database path works.

Every repeated lookup still pays for a PostgreSQL query. A visible baseline gives the cache a fair comparison.

A one-second pause makes the database cost visible. The X-Cache header labels the response source as DATABASE.

In this step, get ready to:
  • Extract the product lookup into a reusable database helper.
  • Label PostgreSQL responses through the X-Cache header.
  • Measure two repeated database lookups.
Extract the database lookup

The database query currently lives inside the route. Moving it into read_product_from_database(sku) gives the slow source one clear boundary.

  • In the Explorer sidebar in Visual Studio Code, select catalog-service/main.py.
  • Add import time to the standard-library imports at the top of the file.
  • Place the database helper above @app.get("/health") by copying this code:
def read_product_from_database(sku: str):
    time.sleep(1)
    with get_connection() as connection:
        return connection.execute(
            "SELECT sku, name, price_cents FROM products WHERE sku = %s",
            (sku,),
        ).fetchone()

What does this helper do?

  • The time.sleep(1) call adds a deliberate one-second pause before the database query.
  • The parameterized %s placeholder keeps the SKU separate from the SQL text.
  • The helper returns the matching product row from PostgreSQL.
  • Inside the existing get_product route, replace the direct query with a call to read_product_from_database(sku).
  • Keep the existing unknown-SKU check immediately after the helper call.
Expose the database source

An HTTP response header can reveal which backend served a request. The product response body stays unchanged.

  • Extend the FastAPI import in catalog-service/main.py to include Response.
  • Change the route signature to get_product(sku: str, response: Response).
  • Set response.headers["X-Cache"] to DATABASE immediately before returning the product.
  • Save catalog-service/main.py.
  • Rebuild the Catalog service with Docker Compose by running this command:
docker compose up --build --wait catalog-service

What does this rebuild do?

Docker Compose rebuilds the Catalog image with your updated Python file. The --wait flag keeps the command running until the service is ready.

Before you send the lookup, do you expect the product body to change or only the response metadata?

  • Return to the Catalog Swagger UI from earlier.
  • Expand GET /products/{sku}.
  • Enter TEE-001 in the sku field.
  • Send the product lookup with the control beneath the endpoint.

You'll receive the same TEE-001 product after a noticeable one-second pause. The response headers include X-Cache: DATABASE.

Catalog service not returning the product?

  • Confirm that catalog-service/main.py was saved before the rebuild.
  • Check that get_product accepts response: Response.
  • Check the terminal output for a Python indentation or import problem.

Still stuck? Help me debug why the rebuilt Catalog service does not return TEE-001.

Measure the repeated delay

The timing probe prints the source header first. It also prints the elapsed time followed by the product body.

Before you run the probe twice, do you expect the second request to become faster?

  • Measure two consecutive lookups by running this probe twice:
docker compose exec catalog-service python -c "from time import perf_counter; from urllib.request import urlopen; start=perf_counter(); response=urlopen('http://127.0.0.1:8000/products/TEE-001'); print(response.headers['X-Cache'], round(perf_counter()-start, 2), response.read().decode())"

What do the two results prove?

  • Both results begin with DATABASE.
  • Both results include the TEE-001 product from PostgreSQL.
  • Both requests take about one second or longer.
  • The second lookup receives no speed advantage from the first lookup.

Probe not showing DATABASE?

  • Rebuild the Catalog service again if the probe shows an older response.
  • Confirm that the header name is exactly X-Cache.
  • Confirm that the header value is exactly DATABASE.
  • Check that the probe uses http://127.0.0.1:8000/products/TEE-001 inside the Catalog container.

Still stuck? Help me diagnose the missing DATABASE label or delay.

You now have a trustworthy baseline. Both requests reach PostgreSQL and pay the same deliberate delay.

The database path is now impossible to miss. Next, you'll add Redis and give repeated lookups something faster to use.

Add Redis Cache-Aside

Your timing baseline proved that every product lookup still reaches PostgreSQL. Each repeated request pays the same simulated delay.

A cache-aside read checks Redis before loading the durable product from PostgreSQL. This keeps the database authoritative while repeated reads gain a faster path.

In this step, get ready to:
  • Add a healthy Redis service to the inherited Compose model.
  • Implement cache-aside reads with a 15-second expiry.
  • Observe a slow cache miss become a faster cache hit.
Add the Redis runtime and Python client

The Catalog container needs redis-py `8.1.0` to communicate with Redis. The inherited Docker Compose model also needs a Redis `8.10.1` service that Catalog can reach by service name.

  • In catalog-service/requirements.txt, add the Redis client while preserving every inherited dependency pin:
fastapi==0.141.1
pydantic==2.13.5
uvicorn==0.52.4
psycopg[binary]==3.3.5
redis==8.1.0

Why add this dependency?

The Redis server runs in its own container. The Python client gives Catalog the connection and hash operations used by the cache-aside path.

  • Save catalog-service/requirements.txt.
  • Confirm the file now ends with redis==8.1.0.

Dependency line missing?

Check that redis==8.1.0 is on its own line below the inherited Psycopg dependency.

Still stuck? Help me check my Catalog requirements file.

  • In compose.override.yaml, add this service below the existing catalog-db service:
  catalog-cache:
    image: redis:8.10.1
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 5

What does the cache service do?

  • The redis:8.10.1 image runs the cache inside the Compose network.
  • The health check asks Redis to respond before Catalog starts.
  • The missing ports section keeps Redis unavailable from the host.
  • In the existing catalog-service definition, replace its current depends_on block with this version:
    depends_on:
      catalog-db:
        condition: service_healthy
      catalog-cache:
        condition: service_healthy

Why wait for both dependencies?

Catalog still needs its database for durable product data. Waiting for the cache health check also gives the Redis client a ready server during normal startup.

  • Save compose.override.yaml.
  • Validate the merged Compose model by running:
docker compose config

What does this check prove?

Compose parses the inherited files as one resolved model. A successful result confirms the new service and dependency structure are valid.

You should see the resolved Compose model without a configuration error.

Compose configuration failing?

  • Check that catalog-cache is aligned with catalog-db under services.
  • Check that both dependency names are nested beneath the existing depends_on key.

Still stuck? Help me find the YAML indentation problem.

✔️ I have both files

Your dependency file now includes the Redis client. Your Compose override now defines a healthy cache dependency for Catalog.

  • Keep both files saved before continuing.

ⓧ I'd like to double check the full code

  • Compare catalog-service/requirements.txt with this complete file:
fastapi==0.141.1
pydantic==2.13.5
uvicorn==0.52.4
psycopg[binary]==3.3.5
redis==8.1.0

What should match?

The four inherited dependency pins remain unchanged. The Redis client appears once on the final line.

  • Compare compose.override.yaml with this complete file:
services:
  catalog-db:
    image: postgres:18.6
    environment:
      POSTGRES_USER: catalog
      POSTGRES_PASSWORD: catalog
      POSTGRES_DB: catalog
    volumes:
      - catalog-data:/var/lib/postgresql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
      interval: 10s
      retries: 5
      start_period: 30s
      timeout: 10s

  catalog-cache:
    image: redis:8.10.1
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 5

  catalog-service:
    build: ./catalog-service
    ports:
      - "127.0.0.1:8002:8000"
    depends_on:
      catalog-db:
        condition: service_healthy
      catalog-cache:
        condition: service_healthy

volumes:
  catalog-data:
    name: orderflow-catalog-data

What should match here?

The cache has a health check without a host port. Catalog waits for healthy database and cache services.

Implement the cache-aside read path

A Redis client needs short timeouts because the database fallback should begin quickly when the cache is unavailable. Zero retries keep each request from waiting through repeated cache connection attempts.

  • In catalog-service/main.py, select the opening section from the first line through the current DSN declaration.
  • Replace the selected section with this cache client setup:
from contextlib import asynccontextmanager
import logging
import time

import psycopg
import redis
from fastapi import FastAPI, HTTPException, Response
from pydantic import BaseModel, PositiveInt
from psycopg.rows import dict_row
from redis.backoff import NoBackoff
from redis.retry import Retry

DSN = "dbname=catalog user=catalog password=catalog host=catalog-db"
CACHE_TTL_SECONDS = 15

logger = logging.getLogger(__name__)

cache_client = redis.Redis(
    host="catalog-cache",
    port=6379,
    db=0,
    decode_responses=True,
    socket_connect_timeout=1.0,
    socket_timeout=1.0,
    retry=Retry(NoBackoff(), 0),
)

What does this setup do?

  • The client reaches Redis through the Compose service hostname catalog-cache.
  • Decoded responses make cached hash fields available as Python strings.
  • The one-second connection and socket timeouts bound how long Catalog waits for Redis.
  • The CACHE_TTL_SECONDS constant gives every product entry a 15-second TTL.
  • Select the existing read_product_from_database(sku) function.
  • Replace the selection with this three-function section:
def product_cache_key(sku: str) -> str:
    return f"cache:catalog:product:{sku}"


def read_product_from_database(sku: str):
    time.sleep(1)
    with get_connection() as connection:
        return connection.execute(
            "SELECT sku, name, price_cents FROM products WHERE sku = %s",
            (sku,),
        ).fetchone()


def cache_product(product: dict):
    key = product_cache_key(product["sku"])
    with cache_client.pipeline() as pipeline:
        pipeline.delete(key)
        pipeline.hset(key, mapping=product)
        pipeline.expire(key, CACHE_TTL_SECONDS)
        pipeline.execute()

How do these helpers shape the cache?

  • The key helper places every product under the cache:catalog:product: namespace.
  • The database helper remains the authoritative loader with its simulated delay.
  • The cache helper replaces any old key with a Redis hash.
  • The pipeline applies the 15-second expiry with the hash write.
  • Save catalog-service/main.py.
  • Rebuild Catalog and start its cache dependency by running:
docker compose up --build --wait catalog-service catalog-cache

What happens during this rebuild?

Compose installs the new Python dependency into the Catalog image. It also waits for the database and cache health checks before completing.

The rebuild can pause while the Catalog image is created. You should see both requested services reach a running or healthy state.

Catalog failing during the rebuild?

  • Check that the Redis dependency line contains the exact version pin from the completed requirements file.
  • Check that every Redis import matches the setup block exactly.

Still stuck? Help me diagnose the Catalog rebuild.

The client and helpers are ready. The request handler now needs to choose between cached data, the database path, and graceful fallback.

  • Inside get_product(sku, response), insert this block before the existing call to read_product_from_database(sku).
    key = product_cache_key(sku)

    try:
        cached = cache_client.hgetall(key)
        cache_available = True
    except (redis.ConnectionError, redis.TimeoutError) as error:
        logger.warning("Cache read skipped for %s: %s", sku, error)
        cached = {}
        cache_available = False

    if cached:
        response.headers["X-Cache"] = "HIT"
        return {
            "sku": cached["sku"],
            "name": cached["name"],
            "price_cents": int(cached["price_cents"]),
        }

How does the read path work?

  • The handler checks the product hash before calling PostgreSQL.
  • A populated hash returns immediately with X-Cache: HIT.
  • A Redis connection or timeout failure records a warning.
  • The cache_available flag carries that failure into the database fallback.
  • Replace the current DATABASE header assignment plus the final product return with this cache write block:
    if cache_available:
        try:
            cache_product(product)
            response.headers["X-Cache"] = "MISS"
        except (redis.ConnectionError, redis.TimeoutError) as error:
            logger.warning("Cache write skipped for %s: %s", sku, error)
            response.headers["X-Cache"] = "BYPASS"
    else:
        response.headers["X-Cache"] = "BYPASS"

    return product

How does the fallback path work?

  • An available cache receives the PostgreSQL product after a miss.
  • That database-backed response carries X-Cache: MISS.
  • A failed Redis read or write leaves the PostgreSQL response intact.
  • That degraded response carries X-Cache: BYPASS.
  • Save catalog-service/main.py.

✔️ My cache path is complete

Your Catalog handler now supports cache hits, database-backed misses, and Redis bypass responses.

  • Keep catalog-service/main.py saved before rebuilding it.

ⓧ I'd like to double check the full code

  • Compare catalog-service/main.py with this complete file:
from contextlib import asynccontextmanager
import logging
import time

import psycopg
import redis
from fastapi import FastAPI, HTTPException, Response
from pydantic import BaseModel, PositiveInt
from psycopg.rows import dict_row
from redis.backoff import NoBackoff
from redis.retry import Retry

DSN = "dbname=catalog user=catalog password=catalog host=catalog-db"
CACHE_TTL_SECONDS = 15

logger = logging.getLogger(__name__)

cache_client = redis.Redis(
    host="catalog-cache",
    port=6379,
    db=0,
    decode_responses=True,
    socket_connect_timeout=1.0,
    socket_timeout=1.0,
    retry=Retry(NoBackoff(), 0),
)


def get_connection():
    return psycopg.connect(DSN, row_factory=dict_row)


def create_products_table():
    with get_connection() as connection:
        connection.execute(
            """
            CREATE TABLE IF NOT EXISTS products (
                sku TEXT PRIMARY KEY,
                name TEXT NOT NULL,
                price_cents INTEGER NOT NULL CHECK (price_cents > 0)
            )
            """
        )


@asynccontextmanager
async def lifespan(_app: FastAPI):
    create_products_table()
    yield


app = FastAPI(title="OrderFlow Catalog API", lifespan=lifespan)


class ProductCreate(BaseModel):
    sku: str
    name: str
    price_cents: PositiveInt


def product_cache_key(sku: str) -> str:
    return f"cache:catalog:product:{sku}"


def read_product_from_database(sku: str):
    time.sleep(1)
    with get_connection() as connection:
        return connection.execute(
            "SELECT sku, name, price_cents FROM products WHERE sku = %s",
            (sku,),
        ).fetchone()


def cache_product(product: dict):
    key = product_cache_key(product["sku"])
    with cache_client.pipeline() as pipeline:
        pipeline.delete(key)
        pipeline.hset(key, mapping=product)
        pipeline.expire(key, CACHE_TTL_SECONDS)
        pipeline.execute()


@app.get("/health")
def health():
    with get_connection() as connection:
        connection.execute("SELECT 1")
    return {"status": "ok", "service": "catalog"}


@app.get("/products")
def list_products():
    with get_connection() as connection:
        return connection.execute(
            "SELECT sku, name, price_cents FROM products ORDER BY sku"
        ).fetchall()


@app.get("/products/{sku}")
def get_product(sku: str, response: Response):
    key = product_cache_key(sku)

    try:
        cached = cache_client.hgetall(key)
        cache_available = True
    except (redis.ConnectionError, redis.TimeoutError) as error:
        logger.warning("Cache read skipped for %s: %s", sku, error)
        cached = {}
        cache_available = False

    if cached:
        response.headers["X-Cache"] = "HIT"
        return {
            "sku": cached["sku"],
            "name": cached["name"],
            "price_cents": int(cached["price_cents"]),
        }

    product = read_product_from_database(sku)

    if product is None:
        raise HTTPException(status_code=404, detail="Product not found")

    if cache_available:
        try:
            cache_product(product)
            response.headers["X-Cache"] = "MISS"
        except (redis.ConnectionError, redis.TimeoutError) as error:
            logger.warning("Cache write skipped for %s: %s", sku, error)
            response.headers["X-Cache"] = "BYPASS"
    else:
        response.headers["X-Cache"] = "BYPASS"

    return product


@app.post("/products", status_code=201)
def create_product(product: ProductCreate):
    with get_connection() as connection:
        return connection.execute(
            """
            INSERT INTO products (sku, name, price_cents)
            VALUES (%s, %s, %s)
            RETURNING sku, name, price_cents
            """,
            (product.sku, product.name, product.price_cents),
        ).fetchone()

What should match?

The full file preserves the inherited database routes. It adds the Redis client, cache helpers, observable headers, and graceful connection handling.

Observe a miss becoming a hit

An empty cache forces the first lookup through PostgreSQL. That request stores the product hash so the next lookup can return before the simulated database delay.

  • Rebuild Catalog with the completed cache path by running:
docker compose up --build --wait catalog-service catalog-cache

What does this launch?

Compose rebuilds the Catalog image with redis-py. It waits until both Catalog and Redis are ready for the timing check.

You should see the command complete after both requested services reach their ready state.

  • Confirm the Redis container is accepting commands by running:
docker compose exec catalog-cache redis-cli ping

What should Redis return?

You should see PONG. That response confirms the cache process is reachable inside its container.

  • Remove the test product key by running:
docker compose exec catalog-cache redis-cli DEL cache:catalog:product:TEE-001

Why clear this key?

Deleting the key gives both timing runs a known starting point. The first request must take the database path before the second can demonstrate a cache hit.

Before you run the probe twice, which request do you think pays the one-second database delay?

  • Run this timing probe twice:
docker compose exec catalog-service python -c "from time import perf_counter; from urllib.request import urlopen; start=perf_counter(); response=urlopen('http://127.0.0.1:8000/products/TEE-001'); print(response.headers['X-Cache'], round(perf_counter()-start, 2), response.read().decode())"

What should the two probes show?

The first request reports MISS and visibly pays the simulated database delay. It returns the PostgreSQL record for TEE-001.

The second request reports HIT with a lower elapsed time. It returns the same product data from Redis.

Not seeing MISS followed by HIT?

  • Confirm the Redis health command returned PONG.
  • Confirm the delete command targets cache:catalog:product:TEE-001.
  • Check that cache_product(product) runs before the handler assigns MISS.

Still stuck? Help me trace my MISS and HIT paths.

That is the cache-aside loop working. Catalog now accelerates repeated reads while PostgreSQL remains the durable source of product data.

Your Catalog now exposes the difference between a database-backed miss and a Redis hit. Next, you'll test what happens when a price changes while an older value remains cached.

Create a Stale Cache Failure

Your Catalog now reads quickly from Redis cache. That speed creates a consistency risk when the cached product outlives a newer PostgreSQL value.

This step adds a database price update while leaving the current cache behavior unchanged. You will create a stale read on purpose so the failure is visible before you repair it.

In this step, get ready to:
  • Add a validated endpoint that updates a product price in PostgreSQL.
  • Cache a known product price with a temporary 60-second TTL.
  • Update PostgreSQL and expose the stale value still held in Redis.
Add a database-only price update

The update endpoint needs a strict request shape before it can modify a product. A Pydantic model uses PositiveInt to accept prices above zero.

  • Return to catalog-service/main.py in the Visual Studio Code Explorer sidebar.
  • Find the ProductCreate model near the top of the file.
  • Add the price update model by copying this code directly below ProductCreate:
class PriceUpdate(BaseModel):
    price_cents: PositiveInt

What does this model do?

  • PriceUpdate defines the request body accepted by the new endpoint.
  • PositiveInt prevents zero or negative prices from reaching the database query.
  • Scroll to the end of catalog-service/main.py below the existing create_product() function.
  • Register the price update endpoint by copying this code below create_product():
@app.patch("/products/{sku}")
def update_price(sku: str, update: PriceUpdate):
    with get_connection() as connection:
        product = connection.execute("UPDATE products SET price_cents = %s WHERE sku = %s RETURNING sku, name, price_cents", (update.price_cents, sku)).fetchone()
    if product is None:
        raise HTTPException(status_code=404, detail="Product not found")
    return product

What does this endpoint do?

  • @app.patch("/products/{sku}") registers the price update route with FastAPI.
  • The parameterized query binds the requested price to the matching SKU.
  • RETURNING retrieves the updated product from PostgreSQL.
  • The function returns 404 when the requested product does not exist.
  • The endpoint makes no call to the existing Redis cache functions.
  • Save catalog-service/main.py.
  • Rebuild the Catalog service with the new endpoint by running:
docker compose up --build --wait catalog-service

What does this command do?

Docker Compose rebuilds the Catalog image from the updated source. It waits until the service is running before returning control to your terminal.

  • Return to the Catalog docs at http://127.0.0.1:8002/docs.
  • Confirm that PATCH /products/{sku} appears in the route list.

Good progress. The new route confirms that your rebuilt Catalog service can now change the authoritative database price.

Missing the PATCH route?

  • Check that PriceUpdate sits directly below ProductCreate.
  • Check that update_price() sits outside the existing create_product() function.
  • Run the rebuild command again after saving catalog-service/main.py.

Help me diagnose why the PATCH route is missing.

✔️ Awesome, I've got everything!

Your price update route is live. Double check that catalog-service/main.py is saved before continuing.

ⓧ I'd like to double check the full code

  • Compare your complete catalog-service/main.py file with this reference:
from contextlib import asynccontextmanager
import logging
import time

import psycopg
import redis
from fastapi import FastAPI, HTTPException, Response
from pydantic import BaseModel, PositiveInt
from psycopg.rows import dict_row
from redis.backoff import NoBackoff
from redis.retry import Retry

DSN = "dbname=catalog user=catalog password=catalog host=catalog-db"
CACHE_TTL_SECONDS = 15

logger = logging.getLogger(__name__)

cache_client = redis.Redis(
    host="catalog-cache",
    port=6379,
    db=0,
    decode_responses=True,
    socket_connect_timeout=1.0,
    socket_timeout=1.0,
    retry=Retry(NoBackoff(), 0),
)


def get_connection():
    return psycopg.connect(DSN, row_factory=dict_row)


def create_products_table():
    with get_connection() as connection:
        connection.execute("""CREATE TABLE IF NOT EXISTS products (sku TEXT PRIMARY KEY, name TEXT NOT NULL, price_cents INTEGER NOT NULL CHECK (price_cents > 0))""")


@asynccontextmanager
async def lifespan(_app: FastAPI):
    create_products_table()
    yield


app = FastAPI(title="OrderFlow Catalog API", lifespan=lifespan)


class ProductCreate(BaseModel):
    sku: str
    name: str
    price_cents: PositiveInt


class PriceUpdate(BaseModel):
    price_cents: PositiveInt


def product_cache_key(sku: str) -> str:
    return f"cache:catalog:product:{sku}"


def read_product_from_database(sku: str):
    time.sleep(1)
    with get_connection() as connection:
        return connection.execute("SELECT sku, name, price_cents FROM products WHERE sku = %s", (sku,)).fetchone()


def cache_product(product: dict):
    key = product_cache_key(product["sku"])
    with cache_client.pipeline() as pipeline:
        pipeline.delete(key)
        pipeline.hset(key, mapping=product)
        pipeline.expire(key, CACHE_TTL_SECONDS)
        pipeline.execute()


@app.get("/health")
def health():
    with get_connection() as connection:
        connection.execute("SELECT 1")
    return {"status": "ok", "service": "catalog"}


@app.get("/products")
def list_products():
    with get_connection() as connection:
        return connection.execute("SELECT sku, name, price_cents FROM products ORDER BY sku").fetchall()


@app.get("/products/{sku}")
def get_product(sku: str, response: Response):
    key = product_cache_key(sku)
    try:
        cached = cache_client.hgetall(key)
        cache_available = True
    except (redis.ConnectionError, redis.TimeoutError) as error:
        logger.warning("Cache read skipped for %s: %s", sku, error)
        cached = {}
        cache_available = False
    if cached:
        response.headers["X-Cache"] = "HIT"
        return {"sku": cached["sku"], "name": cached["name"], "price_cents": int(cached["price_cents"])}
    product = read_product_from_database(sku)
    if product is None:
        raise HTTPException(status_code=404, detail="Product not found")
    if cache_available:
        try:
            cache_product(product)
            response.headers["X-Cache"] = "MISS"
        except (redis.ConnectionError, redis.TimeoutError) as error:
            logger.warning("Cache write skipped for %s: %s", sku, error)
            response.headers["X-Cache"] = "BYPASS"
    else:
        response.headers["X-Cache"] = "BYPASS"
    return product


@app.post("/products", status_code=201)
def create_product(product: ProductCreate):
    with get_connection() as connection:
        return connection.execute("INSERT INTO products (sku, name, price_cents) VALUES (%s, %s, %s) RETURNING sku, name, price_cents", (product.sku, product.name, product.price_cents)).fetchone()


@app.patch("/products/{sku}")
def update_price(sku: str, update: PriceUpdate):
    with get_connection() as connection:
        product = connection.execute("UPDATE products SET price_cents = %s WHERE sku = %s RETURNING sku, name, price_cents", (update.price_cents, sku)).fetchone()
    if product is None:
        raise HTTPException(status_code=404, detail="Product not found")
    return product
Create a known cached value

A controlled stale-read test needs a known cached price. The temporary 60-second expiry keeps that value alive while you update PostgreSQL through the docs.

  • Delete the existing cached product by running:
docker compose exec catalog-cache redis-cli DEL cache:catalog:product:TEE-001

What does this command do?

DEL removes the Redis hash for TEE-001. The next product lookup must reload the authoritative row from PostgreSQL.

Redis prints a deletion count. The cache key is now ready for a clean test.

  • Return to the Catalog docs at http://127.0.0.1:8002/docs.
  • Expand PATCH /products/{sku}.
  • Enter TEE-001 as the route's sku value.
  • Set price_cents to 3000 in the request body.
  • Submit the price update from the route panel.

The response returns TEE-001 with price_cents set to 3000. PostgreSQL now holds the test price.

  • Populate Redis by running the timing probe from Step 2 once.

The probe reports MISS and returns price_cents as 3000. That response stores the product in Redis.

  • Extend the cached product's test TTL to 60 seconds by running:
docker compose exec catalog-cache redis-cli EXPIRE cache:catalog:product:TEE-001 60

Why extend the expiry?

EXPIRE gives the existing hash a fresh 60-second lifetime. This test-only window keeps the cached price available while you submit the next database update.

You should see (integer) 1. That result confirms Redis applied the new expiry to the existing key.

Did EXPIRE return a different result?

  • Run the timing probe again if the cached key expired before you extended it.
  • Run the EXPIRE command immediately after the probe recreates the key.
  • Check that catalog-cache is running if the command cannot connect.

Help me restore the cached test value.

Reproduce the stale read

The database update route changes PostgreSQL while the existing Redis hash stays in place. The next lookup reveals which copy the cache-aside read path chooses.

  • Return to PATCH /products/{sku} in the Catalog docs.
  • Enter TEE-001 as the route's sku value.
  • Set price_cents to 3100 in the request body.
  • Submit the price update from the route panel.

The PATCH response returns price_cents as 3100. This proves PostgreSQL accepted the newer value.

Before you run the probe, which price do you think the GET request chooses?

  • Run the timing probe from Step 2 immediately.

The probe reports HIT and returns price_cents as 3000. You found the consistency bug because Catalog served the older cached value after PostgreSQL changed.

Why is the cached price stale?

The PATCH request updates PostgreSQL without removing the Redis hash. The cache-aside read path sees that hash first.

A cache hit skips the database lookup. The old 3000 value survives until its TTL expires.

Did the probe return 3100?

  • Repeat the test from the deletion command if the 60-second key expired before the final probe.
  • Confirm that the first probe cached price_cents as 3000.
  • Confirm that the PATCH response returned 3100 before the final GET probe.

Help me reproduce the stale cache hit.

You have reproduced a stale read with a real cache hit. Next, you will invalidate the cached product after a successful database update and keep Catalog available when Redis stops.

Keep Data Fresh and Survive Redis Loss

The last test exposed the consistency gap in your faster read path. PostgreSQL stores the current price of 3100. Redis still serves the cached price of 3000.

In this step, you'll apply invalidate-on-write after successful price changes. You'll also prove that Catalog stays available when the cache expires or becomes unavailable.

In this step, get ready to:
  • Invalidate cached products after successful database updates.
  • Prove that the TTL limits how long each cached product survives.
  • Demonstrate graceful degradation while the Redis cache is unavailable.
Invalidate after a successful write

Cache invalidation removes a product's old Redis key after PostgreSQL accepts a price update. The next lookup must reload the current row from the authoritative database.

The invalidation helper catches cache connection failures. A successful database update therefore remains successful even when Redis cannot delete the old key.

  • In catalog-service/main.py, find the cache_product() function.
  • Add the invalidation helper directly below cache_product() by pasting this code:
def invalidate_product_cache(sku: str):
    try:
        cache_client.delete(product_cache_key(sku))
    except (redis.ConnectionError, redis.TimeoutError) as error:
        logger.warning("Cache invalidation skipped for %s: %s", sku, error)

What does this helper do?

  • The helper derives the product's Redis key through product_cache_key().
  • The try block deletes the cached product when Redis is available.
  • The exception handler logs connection or timeout failures without undoing a successful PostgreSQL update.
  • Save catalog-service/main.py.
  • Verify that the helper builds successfully by running this command:
docker compose up --build --wait catalog-service

What does this build verify?

Docker Compose rebuilds the Catalog image with your new helper. The wait completes when the service reaches its running state.

You'll see the Catalog service rebuild and start successfully. This confirms that the helper has valid Python syntax.

Catalog service not starting?

  • Check that invalidate_product_cache() begins at the left edge of the file.
  • Check that the except line aligns with the try line.

Still stuck? Help me debug the invalidation helper.

The update endpoint must call the helper after the PostgreSQL connection context exits successfully. This ordering keeps the database write authoritative.

  • In catalog-service/main.py, locate the update_price() function.
  • Replace the complete update_price() function with this version:
@app.patch("/products/{sku}")
def update_price(sku: str, update: PriceUpdate):
    with get_connection() as connection:
        product = connection.execute(
            """
            UPDATE products
            SET price_cents = %s
            WHERE sku = %s
            RETURNING sku, name, price_cents
            """,
            (update.price_cents, sku),
        ).fetchone()

    if product is None:
        raise HTTPException(status_code=404, detail="Product not found")

    invalidate_product_cache(sku)
    return product

Why invalidate here?

  • The PostgreSQL connection context finishes before invalidate_product_cache(sku) runs.
  • An unknown SKU still returns the existing 404 response before any cache deletion is attempted.
  • A successful update deletes the old key so the next read reloads the current price.
  • Save catalog-service/main.py.
  • Rebuild the updated Catalog service by running this command:
docker compose up --build --wait catalog-service

What happens during this rebuild?

The Catalog image now includes the invalidation helper and its call from update_price(). The running service is ready for a fresh consistency test.

  • Return to the Catalog API docs at http://127.0.0.1:8002/docs.
  • Expand PATCH /products/{sku}.
  • Enter TEE-001 for sku.
  • Set price_cents to 3200 in the request body.
  • Run the request from the expanded endpoint.

The response returns the updated TEE-001 record with price_cents set to 3200.

  • Measure two product lookups by running the timing probe twice:

Before you run the probes, which cache result do you expect from each request?

docker compose exec catalog-service python -c "from time import perf_counter; from urllib.request import urlopen; start=perf_counter(); response=urlopen('http://127.0.0.1:8000/products/TEE-001'); print(response.headers['X-Cache'], round(perf_counter()-start, 2), response.read().decode())"
docker compose exec catalog-service python -c "from time import perf_counter; from urllib.request import urlopen; start=perf_counter(); response=urlopen('http://127.0.0.1:8000/products/TEE-001'); print(response.headers['X-Cache'], round(perf_counter()-start, 2), response.read().decode())"

What do the two probes prove?

  • The first lookup reports MISS because the price update deleted the old Redis key.
  • The first lookup reloads 3200 from PostgreSQL before repopulating Redis.
  • The second lookup reports HIT because the current product is now cached.

That closes the stale-read gap. Your faster second lookup now returns the same current price as PostgreSQL.

Still seeing the old price?

  • Confirm that invalidate_product_cache(sku) appears after the unknown-product check.
  • Confirm that you rebuilt catalog-service after saving the file.
  • Confirm that the price update response contains 3200 before running the probes.

Need another pair of eyes? Help me trace why the stale Redis value survives my price update.

✔️ Awesome, I've got everything!

Great. Confirm that catalog-service/main.py is saved with the invalidation helper and the updated update_price() function.

ⓧ I'd like to double check the full code

  • Compare catalog-service/main.py with this cumulative reference:
from contextlib import asynccontextmanager
import logging
import time

import psycopg
import redis
from fastapi import FastAPI, HTTPException, Response
from pydantic import BaseModel, PositiveInt
from psycopg.rows import dict_row
from redis.backoff import NoBackoff
from redis.retry import Retry

DSN = "dbname=catalog user=catalog password=catalog host=catalog-db"
CACHE_TTL_SECONDS = 15

logger = logging.getLogger(__name__)

cache_client = redis.Redis(
    host="catalog-cache",
    port=6379,
    db=0,
    decode_responses=True,
    socket_connect_timeout=1.0,
    socket_timeout=1.0,
    retry=Retry(NoBackoff(), 0),
)


def get_connection():
    return psycopg.connect(DSN, row_factory=dict_row)


def create_products_table():
    with get_connection() as connection:
        connection.execute(
            """
            CREATE TABLE IF NOT EXISTS products (
                sku TEXT PRIMARY KEY,
                name TEXT NOT NULL,
                price_cents INTEGER NOT NULL CHECK (price_cents > 0)
            )
            """
        )


@asynccontextmanager
async def lifespan(_app: FastAPI):
    create_products_table()
    yield


app = FastAPI(title="OrderFlow Catalog API", lifespan=lifespan)


class ProductCreate(BaseModel):
    sku: str
    name: str
    price_cents: PositiveInt


class PriceUpdate(BaseModel):
    price_cents: PositiveInt


def product_cache_key(sku: str) -> str:
    return f"cache:catalog:product:{sku}"


def read_product_from_database(sku: str):
    time.sleep(1)
    with get_connection() as connection:
        return connection.execute(
            "SELECT sku, name, price_cents FROM products WHERE sku = %s",
            (sku,),
        ).fetchone()


def cache_product(product: dict):
    key = product_cache_key(product["sku"])
    with cache_client.pipeline() as pipeline:
        pipeline.delete(key)
        pipeline.hset(key, mapping=product)
        pipeline.expire(key, CACHE_TTL_SECONDS)
        pipeline.execute()


def invalidate_product_cache(sku: str):
    try:
        cache_client.delete(product_cache_key(sku))
    except (redis.ConnectionError, redis.TimeoutError) as error:
        logger.warning("Cache invalidation skipped for %s: %s", sku, error)


@app.get("/health")
def health():
    with get_connection() as connection:
        connection.execute("SELECT 1")
    return {"status": "ok", "service": "catalog"}


@app.get("/products")
def list_products():
    with get_connection() as connection:
        return connection.execute(
            "SELECT sku, name, price_cents FROM products ORDER BY sku"
        ).fetchall()


@app.get("/products/{sku}")
def get_product(sku: str, response: Response):
    key = product_cache_key(sku)

    try:
        cached = cache_client.hgetall(key)
        cache_available = True
    except (redis.ConnectionError, redis.TimeoutError) as error:
        logger.warning("Cache read skipped for %s: %s", sku, error)
        cached = {}
        cache_available = False

    if cached:
        response.headers["X-Cache"] = "HIT"
        return {
            "sku": cached["sku"],
            "name": cached["name"],
            "price_cents": int(cached["price_cents"]),
        }

    product = read_product_from_database(sku)

    if product is None:
        raise HTTPException(status_code=404, detail="Product not found")

    if cache_available:
        try:
            cache_product(product)
            response.headers["X-Cache"] = "MISS"
        except (redis.ConnectionError, redis.TimeoutError) as error:
            logger.warning("Cache write skipped for %s: %s", sku, error)
            response.headers["X-Cache"] = "BYPASS"
    else:
        response.headers["X-Cache"] = "BYPASS"

    return product


@app.post("/products", status_code=201)
def create_product(product: ProductCreate):
    with get_connection() as connection:
        return connection.execute(
            """
            INSERT INTO products (sku, name, price_cents)
            VALUES (%s, %s, %s)
            RETURNING sku, name, price_cents
            """,
            (product.sku, product.name, product.price_cents),
        ).fetchone()


@app.patch("/products/{sku}")
def update_price(sku: str, update: PriceUpdate):
    with get_connection() as connection:
        product = connection.execute(
            """
            UPDATE products
            SET price_cents = %s
            WHERE sku = %s
            RETURNING sku, name, price_cents
            """,
            (update.price_cents, sku),
        ).fetchone()

    if product is None:
        raise HTTPException(status_code=404, detail="Product not found")

    invalidate_product_cache(sku)
    return product
Prove the cache expires

Invalidation handles writes. Expiration handles cached products that receive no updates by deleting each key after the application-defined lifetime.

Your Catalog cache applies a 15-second lifetime whenever it stores a product. You'll reset the test key so the earlier 60-second test value cannot affect this check.

  • Delete the existing product key and create a fresh cached value by running these commands:
docker compose exec catalog-cache redis-cli DEL cache:catalog:product:TEE-001
docker compose exec catalog-service python -c "from time import perf_counter; from urllib.request import urlopen; start=perf_counter(); response=urlopen('http://127.0.0.1:8000/products/TEE-001'); print(response.headers['X-Cache'], round(perf_counter()-start, 2), response.read().decode())"

What do these commands reset?

  • The first command removes the existing cache:catalog:product:TEE-001 key.
  • The probe reloads the product from PostgreSQL and reports MISS.
  • The cache write applies CACHE_TTL_SECONDS to the new Redis hash.
  • Inspect the remaining lifetime of the fresh key by running this command:
docker compose exec catalog-cache redis-cli TTL cache:catalog:product:TEE-001

What does the TTL check show?

The command reads the number of seconds remaining before Redis deletes the key. A positive result confirms that the application applied an expiry.

You'll see a positive value no greater than 15. The value may already be a little lower because the countdown begins when Catalog writes the key.

  • Let the key expire and repeat the lookup twice by running these commands:

Before you run them, which result should appear immediately after expiration?

sleep 16
docker compose exec catalog-service python -c "from time import perf_counter; from urllib.request import urlopen; start=perf_counter(); response=urlopen('http://127.0.0.1:8000/products/TEE-001'); print(response.headers['X-Cache'], round(perf_counter()-start, 2), response.read().decode())"
docker compose exec catalog-service python -c "from time import perf_counter; from urllib.request import urlopen; start=perf_counter(); response=urlopen('http://127.0.0.1:8000/products/TEE-001'); print(response.headers['X-Cache'], round(perf_counter()-start, 2), response.read().decode())"

What does expiration change?

  • The 16-second wait passes the application's 15-second cache lifetime.
  • The first lookup reports MISS because Redis has deleted the expired key.
  • The second lookup reports HIT because the first lookup created a fresh hash.

Your cache now has a visible freshness boundary. An untouched product must return to PostgreSQL after 15 seconds.

TTL not behaving as expected?

  • Confirm that you deleted the key before creating the fresh cached value.
  • Confirm that CACHE_TTL_SECONDS remains set to 15 in catalog-service/main.py.
  • Confirm that cache_product() calls pipeline.expire(key, CACHE_TTL_SECONDS).

Still seeing an unexpected result? Help me diagnose the Redis TTL test.

Keep Catalog available without Redis

Redis accelerates product lookups. PostgreSQL still owns the durable product record, so a cache outage should only restore the slower database path.

The existing read path catches Redis connection and timeout failures. It marks the response as BYPASS before returning the database result.

  • Stop the Redis cache container by running this command:
docker compose stop catalog-cache

What does stopping the cache change?

The command stops catalog-cache without removing the container. Catalog remains available because its durable product data lives in PostgreSQL.

  • Test the product lookup while the cache is stopped by running the timing probe:

Before you run the probe, do you expect the product lookup to succeed or fail?

docker compose exec catalog-service python -c "from time import perf_counter; from urllib.request import urlopen; start=perf_counter(); response=urlopen('http://127.0.0.1:8000/products/TEE-001'); print(response.headers['X-Cache'], round(perf_counter()-start, 2), response.read().decode())"

What does BYPASS prove?

The probe reports BYPASS and returns the current TEE-001 record after the simulated database delay. Redis is an optional acceleration layer in this path.

You have proved the critical resilience behavior. A Redis outage makes Catalog slower while the correct product remains available.

  • Restore the cache and run the final recovery check with these commands:

Before you run them, what should an empty restarted cache report for its first product lookup?

docker compose start --wait catalog-cache
docker compose exec catalog-cache redis-cli ping
docker compose exec catalog-service python -c "from time import perf_counter; from urllib.request import urlopen; start=perf_counter(); response=urlopen('http://127.0.0.1:8000/products/TEE-001'); print(response.headers['X-Cache'], round(perf_counter()-start, 2), response.read().decode())"

What does the recovery check prove?

  • The start command waits for catalog-cache to return to its running state.
  • The Redis health check prints PONG.
  • The first lookup against the empty restarted cache reports MISS and returns the current product from PostgreSQL.

That's the resilience loop complete. Catalog now refreshes stale data, bounds cache lifetime, and keeps serving products through a Redis outage.

Recovery check not passing?

  • Confirm that the cache health check prints PONG before running the product probe.
  • Confirm that catalog-service is still running after the cache stop test.
  • Confirm that the response contains the current TEE-001 price of 3200.

Need help with the recovery sequence? Help me debug the BYPASS and restart checks.

Secret mission

Secret Mission: Recover from a Corrupted Cache Entry

A Redis hash can exist while carrying data the Catalog service cannot safely decode. Inject a malformed price, reproduce the failure, then make the cache delete the bad entry and recover from PostgreSQL.

Clean Up Your Resources

Clean Up Your Resources

Choose whether to keep your local services running, pause them for later, or remove the cache container. Your Docker Compose resources run locally, so there are no cloud charges.

The rebuilt Catalog image stays with the inherited OrderFlow workspace. The PostgreSQL containers and named volume also stay protected.

Resources you used:

  • One local catalog-cache container running Redis Open Source from redis:8.10.1. Its cache entries are disposable.

Keep everything running

No action is needed. Choose this option if you are still testing cache hits, expiration, invalidation, or malformed-entry recovery.

  • Leave catalog-cache running to keep the cache available.
  • Keep the inherited PostgreSQL containers to preserve durable product data.
  • Keep the rebuilt Catalog image for future OrderFlow experiments.

The running containers continue to use local CPU and memory.

Pause - I'll come back to this later

Stop the running processes to free local CPU and memory. Every container and volume remains available for your return.

  • Return to the terminal at the persistent-checkout workspace root from earlier.
  • Pause the local stack by running this command:
docker compose stop

What does pausing do?

  • The stop operation halts the running service containers.
  • It preserves the containers and their stored configuration.

You'll see each running service report that it has stopped.

Services not stopping?

Confirm that your terminal is inside the persistent-checkout workspace. Check that Docker Engine is running.

Help me troubleshoot the local Compose stop.

  • Restore the saved containers later by running this command:
docker compose start --wait

What does restoring do?

  • The start operation brings back the existing containers.
  • The wait option holds the terminal until the services are running.

You'll see the preserved services start without rebuilding the OrderFlow workspace.

Services not starting?

Check that Docker Engine has enough local memory. Confirm that the terminal still points to the persistent-checkout workspace.

Help me troubleshoot the local Compose start.

Delete - I don't want to use this again

Remove only the cache container added in this project. Its data is safe to discard because PostgreSQL owns the durable product records.

Protect the inherited database

The inherited PostgreSQL containers and named volume remain outside this cleanup. Removing Compose volumes would erase data that earlier OrderFlow projects depend on.

  • Return to the terminal at the persistent-checkout workspace root from earlier.
  • Stop only the cache container by running this command:
docker compose stop catalog-cache

What does this stop?

  • This operation stops only the local cache container.
  • The Catalog service and inherited database resources remain untouched.

You'll see catalog-cache report that it has stopped.

Cache container not stopping?

Confirm that the service name is catalog-cache. Check that your terminal is at the workspace root.

Help me stop the cache container safely.

  • Remove the stopped cache container by running this command:
docker compose rm -f catalog-cache

What does removal delete?

  • The removal operation deletes the stopped cache container without asking for confirmation.
  • Its temporary Redis entries disappear with the container.
  • The Compose configuration can recreate the cache during a future build.

You'll see confirmation that the catalog-cache container has been removed.

Cache container not removed?

Confirm that the cache container stopped before removal. Verify that the service name matches catalog-cache.

Help me remove the cache container safely.

  • Verify the cache container is gone by running this status check:
docker compose ps

What does the status check show?

  • The status check lists the service containers that still exist in the Compose application.
  • The removed cache container no longer appears among active containers.

You should no longer see catalog-cache in the active container list.

Still see the cache container?

Check whether the listed container is running. Repeat the stop operation before retrying removal.

Help me verify the cache cleanup.

Nice Work!

Nice Work!

Strong finish! You built a resilient cache-aside layer for the OrderFlow Catalog service.

Redis now accelerates repeated product lookups. PostgreSQL remains the durable source of truth.

You've learned how to:

  • Built a Redis-backed cache-aside read path around the Catalog service. PostgreSQL remains authoritative for every durable product record.
  • Bounded cache lifetime with a 15-second TTL. Added invalidate-on-write after successful price updates to prevent stale values from surviving.
  • Kept Catalog available during Redis outages through graceful degradation. The X-Cache response header makes each read path visible. A cold cache reports MISS. A warm cache reports HIT. An unavailable cache reports BYPASS.
  • Completed the optional Secret Mission by detecting malformed cached product hashes with decode_cached_product(). Catalog now deletes corrupt entries before reloading the product from PostgreSQL.

Ready to quiz yourself?