Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ frontend/*.local



modules/module-04/*

modules/module-05/*
modules/module-06/*
modules/module-07/*
Expand Down
55 changes: 55 additions & 0 deletions modules/module-04/REFLECTION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Module 4 — Reflection

**Team name**: _______________
**Branch**: `module-04/<team-name>`
**Submitted**: before Module 5 lesson

---

Answer the three questions below. There are no right or wrong answers — we are looking for your reasoning, not a textbook definition. A few honest sentences are worth more than a long generic paragraph.

---

## 1. The "why"

In Module 3, services called each other directly over HTTP. Now activity-service drops a message into a broker and moves on — it never waits for a reply.

**What does the activity-service gain by not waiting? And what does the notification-service gain by consuming at its own pace?**

Think about what happens under load, or when notification-service is temporarily down.

> *Your answer:* By not waiting for notification-service, activity-service can save the activity and respond to the user immediately. This makes the system faster and prevents activity creation from failing just because notifications are slow or temporarily unavailable.

Notification-service also benefits because it can process messages at its own pace. If there is a spike in traffic, messages can stay in RabbitMQ until the service is ready to consume them. If notification-service goes down for a short time, the messages remain in the queue and can be processed when the service comes back online.

---

## 2. Your choice

In Module 3 you already knew how to call another service directly over HTTP — you did it for user validation and game enrichment.

**Why not use the same approach for notifications? What does introducing a broker give you that a direct HTTP call doesn't?**

Think about what happens if notification-service is slow, or crashes mid-message.

> *Your answer:* Using a broker is more reliable than making a direct HTTP call for notifications. With HTTP, activity-service would have to wait for notification-service to respond, and activity creation could fail if notification-service was slow or offline.

RabbitMQ acts as a buffer between the services. Activity-service only needs to publish a message and continue. If notification-service crashes or becomes overloaded, the messages remain in the queue instead of being lost. This reduces coupling between the services and improves resilience.

---

## 3. The tradeoff

With synchronous REST, you get an immediate answer: success or failure. With async messaging, the activity is saved and the message is sent — but you have no idea if the notification was ever delivered.

**How would a user know if their notification was never sent? How would you know as a developer?**

What visibility do you lose when you go async?

> *Your answer:* With asynchronous messaging, users do not get immediate confirmation that a notification was actually delivered. They only know that the activity was created successfully. If the notification is never processed, the user may not notice until they realize they never received it.

As a developer, I also lose immediate visibility because there is no direct success or failure response from notification-service. Instead, I have to rely on monitoring tools, logs, queue metrics, and dead-letter queues to detect problems. The tradeoff is better scalability and reliability, but less immediate feedback about whether downstream processing succeeded.

---

*Keep this file. You will refer back to it during the oral presentation.*
9 changes: 9 additions & 0 deletions modules/module-04/docker-compose.override.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Module 4: Messaging infrastructure only (RabbitMQ)
# Services still run locally with uvicorn + SQLite.
# RabbitMQ is provided by docker-compose.infra.yml.
#
# Start with:
# docker compose -f docker-compose.infra.yml up -d rabbitmq
#
# No service containers in this module.
version: "3.9"
101 changes: 101 additions & 0 deletions modules/module-04/exercise.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# Module 4 — Asynchronous Messaging

**Duration**: 2h in class
**Branch to submit**: `module-04/<team-name>`

---

## Objective

Until now, services communicated synchronously — one service called another and waited for a reply. This module introduces asynchronous messaging: a service drops a message into a broker and moves on, without waiting for any response.

You will wire one messaging flow into the system: when an activity is logged, `activity-service` publishes a message to RabbitMQ and `notification-service` consumes it.

---

## Before you start

Your module-03 work must be in place: gateway running on port 8000, `activity-service` working, seeded users and games available.

Start the RabbitMQ broker:

```bash
docker compose -f docker-compose.infra.yml up -d rabbitmq
```

Confirm it's up:
- RabbitMQ management UI: http://localhost:15672 (guest / guest)

Start `notification-service` in a separate terminal:

```bash
cd services/notification-service
npm install
npm run dev
```

Confirm it's up:
- http://localhost:8004/v1/notifications should return an empty list `[]`

---

## What's provided

- RabbitMQ is running via Docker. You do not need to configure it.
- `rabbitmq_publisher.py` is scaffolded in `services/activity-service/app/infrastructure/` — the connection and publish logic is done, you fill in the call site.
- `notification-service` is already built (Node.js) — you started it above.

Install the new dependency in `activity-service`:

```bash
cd services/activity-service
pip install -r requirements.txt
```

---

## Part A — Wire the RabbitMQ publisher *(~50 min)*

When a user logs an activity, a notification should be sent to `notification-service` via RabbitMQ.

Open `services/activity-service/app/infrastructure/rabbitmq_publisher.py` — the publisher is already implemented. Your job is to **call it** from the right place in `create_activity`.

After wiring it up, verify the full flow:

1. Log an activity through the gateway:
```bash
curl -X POST http://localhost:8000/v1/activities \
-H "Content-Type: application/json" \
-d '{"user_id": "YOUR_USER_ID", "game_id": "YOUR_GAME_ID", "action": "started"}'
```
2. Open the RabbitMQ UI at http://localhost:15672 — go to the **Queues** tab and confirm messages appeared in `gamehub.notifications` and `gamehub.logs`
3. Check the `notification-service` logs — a notification should appear

---

## Part B — Register notification-service in the gateway *(~20 min)*

`notification-service` is now part of the system. Add it to the gateway's routing table.

Open `gateway/app/config.py` and `gateway/app/main.py` — the lines for `notification-service` are already there, commented out with `# Added in Module 4`. Uncomment them.

Verify:
```bash
curl http://localhost:8000/v1/notifications
```

---

## Discussion *(~15 min)*

- What happens to the activity request if `notification-service` is down when the message is published? Should the activity creation fail?
- In Module 3, you called `game-service` directly over HTTP to enrich the response. Why not do the same for notifications — why introduce a broker at all?
- The activity is saved and the message is sent — but you have no confirmation the notification was delivered. What visibility do you lose compared to a synchronous call?

---

## Minimum to submit this branch

- [ ] Activity creation publishes a RabbitMQ message — visible in the management UI
- [ ] `notification-service` registered in the gateway and reachable via port 8000
- [ ] `REFLECTION.md` completed and committed
Binary file added services/activity-service/activities.db
Binary file not shown.
71 changes: 45 additions & 26 deletions services/activity-service/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import httpx
from fastapi import Depends, FastAPI, HTTPException
from sqlalchemy.orm import Session
from app.infrastructure.rabbitmq_publisher import publish_activity_event

from app.config import settings
from app.database import Base, engine, get_db
Expand All @@ -24,40 +25,48 @@
# ---------------------------------------------------------------------------

async def validate_user(user_id: str) -> None:
"""
Verify that the user exists in user-service before logging an activity.
retries = 2

Call: GET {settings.user_service_url}/v1/users/{user_id}
for attempt in range(retries):
try:
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.get(
f"{settings.user_service_url}/v1/users/{user_id}"
)

Behaviour:
- 200 → user exists, return normally (None)
- 404 → raise HTTPException(status_code=404, detail="User not found")
- Network error (httpx.RequestError) → retry the call once, then raise
HTTPException(status_code=503, detail="user-service unavailable")
- Any other non-2xx status → raise HTTPException(status_code=503, ...)
if response.status_code == 200:
return

Use `async with httpx.AsyncClient(timeout=5.0) as client:` for HTTP calls.
This call is CRITICAL — the request must not proceed if validation fails.
"""
raise NotImplementedError
if response.status_code == 404:
raise HTTPException(status_code=404, detail="User not found")

raise HTTPException(
status_code=503,
detail="user-service unavailable"
)

except httpx.RequestError:
if attempt == retries - 1:
raise HTTPException(
status_code=503,
detail="user-service unavailable"
)

async def fetch_game(game_id: str) -> dict | None:
"""
Fetch game data from game-service to enrich the activity response.

Call: GET {settings.game_service_url}/v1/games/{game_id}
async def fetch_game(game_id: str) -> dict | None:
try:
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.get(
f"{settings.game_service_url}/v1/games/{game_id}"
)

Behaviour:
- 200 → return the response JSON as a dict
- Any non-2xx status OR network error → return None (do NOT raise)
if response.status_code == 200:
return response.json()

This call is OPTIONAL — the activity is saved regardless of the result.
Graceful degradation is the goal: the response will include "game": null
when game-service is unreachable.
"""
raise NotImplementedError
except httpx.RequestError:
pass

return None

# ---------------------------------------------------------------------------
# Endpoints — pre-written, they call your two functions above
Expand All @@ -71,8 +80,19 @@ def health():
@app.post("/v1/activities", response_model=schemas.ActivityOut, status_code=201)
async def create_activity(data: schemas.ActivityCreate, db: Session = Depends(get_db)):
await validate_user(data.user_id)

activity = repository.create_activity(db, data)
game_data = await fetch_game(activity.game_id)

game_title = game_data["title"] if game_data else None

await publish_activity_event(
user_id=activity.user_id,
game_id=activity.game_id,
action=activity.action,
game_title=game_title,
)

return {
"id": activity.id,
"user_id": activity.user_id,
Expand All @@ -82,7 +102,6 @@ async def create_activity(data: schemas.ActivityCreate, db: Session = Depends(ge
"game": game_data,
}


@app.get("/v1/activities", response_model=schemas.ActivityList)
async def list_activities(limit: int = 20, offset: int = 0, db: Session = Depends(get_db)):
activities, total = repository.list_activities(db, limit=limit, offset=offset)
Expand Down
36 changes: 26 additions & 10 deletions services/game-service/app/database.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,26 @@
# Infrastructure layer — database connection.
#
# Replicate the same structure as user-service/app/database.py.
# The only difference: the default DATABASE_URL points to games.db.
#
# This file should provide:
# - engine — SQLAlchemy engine built from DATABASE_URL
# - SessionLocal — session factory bound to the engine
# - Base — DeclarativeBase that all ORM models inherit from
# - get_db() — FastAPI dependency: yields a session, closes it after the request
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, DeclarativeBase
import os

DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./games.db")

engine = create_engine(
DATABASE_URL,
connect_args={"check_same_thread": False}
)

SessionLocal = sessionmaker(
autocommit=False,
autoflush=False,
bind=engine
)

class Base(DeclarativeBase):
pass

def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
18 changes: 9 additions & 9 deletions services/game-service/app/main.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
# Entry point — FastAPI application.
#
# Create the FastAPI app instance and register the router from app.routes.
# Keep it minimal: no business logic, no endpoints defined here.
#
# To run the service locally:
# uvicorn app.main:app --reload --port 8002
#
# Then open: http://localhost:8002/docs
from fastapi import FastAPI
from app.routes import router
from app.database import Base, engine
from app.models import Game

Base.metadata.create_all(bind=engine)

app = FastAPI(title="game-service")
app.include_router(router)
28 changes: 14 additions & 14 deletions services/game-service/app/models.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
# Infrastructure layer — ORM model.
#
# Define the `games` table. This is the only file that knows about columns.
#
# The Game model should have these columns:
# - id String, primary key, UUID generated by default
# - title String, not nullable
# - genre String, not nullable
# - platform String, not nullable
# - release_year Integer, nullable
# - cover_url String, nullable
# - created_at DateTime, defaults to now (UTC)
#
# Import Base from app.database — do not redefine it here.
from sqlalchemy import Column, String
import uuid

from app.database import Base


class Game(Base):
__tablename__ = "games"

id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
title = Column(String, nullable=False)
genre = Column(String, nullable=False)
platform = Column(String, nullable=False)
cover_url = Column(String, nullable=False)
Loading