diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2bf8131 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,35 @@ +name: Distributed Systems CI + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + # 1. Checkout the code + - uses: actions/checkout@v3 + + # 2. Set up JDK 21 + - name: Set up JDK 21 + uses: actions/setup-java@v3 + with: + java-version: '21' + distribution: 'temurin' + cache: maven + + # 3. Install Root POM + - name: Install Root POM + run: mvn clean install -DskipTests -f java/pom.xml + + # 4. Build & Install 'dsl-common' + - name: Build & Test Common Module + run: mvn clean install -f java/dsl-common/pom.xml + + # 5. Build & Test 'url-shortener' + - name: Build & Test URL Shortener + run: mvn clean package -f java/url-shortener/pom.xml \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..510c22e --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# IDE files +.idea/ + +# Build output +build/ +out/ +target/ + +# Log files +*.log + +# Dependency caches +.mvn/ + +# OS-specific files +.DS_Store diff --git a/README.md b/README.md index 07ad648..eb24f63 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,18 @@ -# distributed-systems-lab -High-performance distributed system components implemented from scratch, focusing on concurrency, availability, and low-latency architecture. +# Distributed Systems Lab + +A collection of **production-ready** distributed services implemented from scratch. + +This repository moves beyond the theoretical "boxes and arrows" of system design interviews to provide **working, end-to-end implementations** of complex distributed challenges. It bridges the gap between high-level architecture diagrams and the low-level engineering reality. + +Instead of assuming a database scales, we **choose the DB**, implement the schema and prove it with **load tests** and **observability** metrics. + +>**Core Philosophy:** It's not a valid design until it runs in Docker, passes CI/CD, and handles real traffic. + +## Modules & Status + +| Module | Description | Key Tech | Status | +|:-----------------------------------------------|:--------------------------------------------------|:--------------------------|:-------------------| +| **[Common Libraries](java/dsl-common)** (Java) | Shared utility libraries for distributed systems. | Distributed ID Generator | ✅ **Completed** | +| **[URL Shortener](java/url-shortener)** | Distributed URL Shortener service | Netty, ScyllaDB | ✅ **Completed** | +| **[Rate Limiter](java/rate-limiter)** | Distributed sliding window rate limiter. | Redis (Lua), Token Bucket | 🚧 **In Progress** | + diff --git a/docs/assets/snowflake_bit_layout.png b/docs/assets/snowflake_bit_layout.png new file mode 100644 index 0000000..2cfd07a Binary files /dev/null and b/docs/assets/snowflake_bit_layout.png differ diff --git a/docs/assets/url-shortener/architecture.png b/docs/assets/url-shortener/architecture.png new file mode 100644 index 0000000..9e3428d Binary files /dev/null and b/docs/assets/url-shortener/architecture.png differ diff --git a/docs/assets/url-shortener/grafana_dashboard.png b/docs/assets/url-shortener/grafana_dashboard.png new file mode 100644 index 0000000..8346bf9 Binary files /dev/null and b/docs/assets/url-shortener/grafana_dashboard.png differ diff --git a/docs/assets/url-shortener/load_test.png b/docs/assets/url-shortener/load_test.png new file mode 100644 index 0000000..a40f059 Binary files /dev/null and b/docs/assets/url-shortener/load_test.png differ diff --git a/java/dsl-common/README.md b/java/dsl-common/README.md new file mode 100644 index 0000000..d9b99df --- /dev/null +++ b/java/dsl-common/README.md @@ -0,0 +1,54 @@ +# DSL Common Library + +Core distributed system utilities shared across the `distributed-systems-lab` microservices. + +## 1. Snowflake ID Generator +A distributed unique ID generator inspired by Twitter's Snowflake algorithm. + +### Why Snowflake? + +| Feature | **Snowflake ID** | **UUID (v4)** | **DB Auto-Increment** | **Redis `INCR`** | +|:----------------------|:----------------------------|:-------------------------|:------------------------|:-----------------------| +| **Generation** | Local (No Network) | Local (No Network) | Central (Network Call) | Central (Network Call) | +| **Sortable?** | Yes (Time-ordered) | No (Random) | Yes | Yes | +| **Size** | 64-bit (Small) | 128-bit (Large) | 64-bit (Small) | 64-bit (Small) | +| **Index Performance** | **Excellent** (Append-only) | **Poor** (Fragmentation) | Excellent | Excellent | +| **Coordination** | Low (Node ID config) | None | High (Locks) | High (Single Thread) | +| **Collision Risk** | Zero (if clock stable) | Near Zero | Zero | Risk if data lost | + +### Bit Layout (64-bit *long*) +The ID is composed of 64 bits, allowing for time-sorting and distributed generation. + +![snowflake_bit_layout.png](../../docs/assets/snowflake_bit_layout.png) + +### Handling Clock Drift (NTP) +Distributed systems rely on NTP, which can sometimes move the system clock backwards to sync with the global time. This creates a risk of generating duplicate IDs. + +#### Resolution Strategy (Patient Wait): +If the drift is small, the thread pauses (sleeps) until the clock catches up to the last generated timestamp. This prevents service outages during minor NTP adjustments. + +### Code Usage +```java +// Initialize with Node ID (must be unique per server instance) +SnowflakeIdGenerator generator = new SnowflakeIdGenerator(1); + +// Generate ID +long id = generator.nextId(); +// Output: 839281920192 +``` + +## 2. Base62 Encoder +A high-performance utility for converting unique numeric IDs into short strings with only `[0-9, a-z, A-Z]`. + +### The Base Conversion Math: +Direct mathematical conversion between Base10 (Decimal) and Base62. +* **Encode:** ID $\rightarrow$ String (e.g., `1024` $\rightarrow$ `"g8"`) +* **Decode:** String $\rightarrow$ ID (e.g., `"g8"` $\rightarrow$ `1024`) + +### Usage: +```java +String shortUrl = Base62Encoder.encode(178263819283046400L); +// Output: "darUvlwGyI" + +long originalId = Base62Encoder.decode("darUvlwGyI"); +// Output: 178263819283046400 diff --git a/java/dsl-common/pom.xml b/java/dsl-common/pom.xml new file mode 100644 index 0000000..0e296a0 --- /dev/null +++ b/java/dsl-common/pom.xml @@ -0,0 +1,14 @@ + + + 4.0.0 + + + distributed-systems-lab + com.dsl + 1.0-SNAPSHOT + + + dsl-common + \ No newline at end of file diff --git a/java/dsl-common/src/main/java/com/dsl/common/baseencoder/Base62Encoder.java b/java/dsl-common/src/main/java/com/dsl/common/baseencoder/Base62Encoder.java new file mode 100644 index 0000000..247a376 --- /dev/null +++ b/java/dsl-common/src/main/java/com/dsl/common/baseencoder/Base62Encoder.java @@ -0,0 +1,68 @@ +package com.dsl.common.baseencoder; + +import java.util.Arrays; + +/** + * Utility to convert between unique Long IDs and short Base62 Strings. + *

+ * Alphabet: [0-9, a-z, A-Z] + */ +public class Base62Encoder { + + private static final String ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; + private static final int BASE = ALPHABET.length(); // 62 + + // Pre-computed map for fast (char -> value) decoding + private static final int[] INDEX_MAP = new int[128]; + + static { + Arrays.fill(INDEX_MAP, -1); + for (int i = 0; i < BASE; i++) { + INDEX_MAP[ALPHABET.charAt(i)] = i; + } + } + + /** + * Encodes a numeric ID into a Base62 string. + * Example: 1024 -> "g8" + */ + public static String encode(long id) { + if (id < 0) { + throw new IllegalArgumentException("ID must be non-negative"); + } + if (id == 0) { + return String.valueOf(ALPHABET.charAt(0)); + } + + StringBuilder sb = new StringBuilder(); + while (id > 0) { + int remainder = (int) (id % BASE); + sb.append(ALPHABET.charAt(remainder)); + id /= BASE; + } + return sb.reverse().toString(); + } + + /** + * Decodes a Base62 string back into a numeric ID. + * Example: "g8" -> 1024 + */ + public static long decode(String str) { + if (str == null || str.isEmpty()) { + throw new IllegalArgumentException("String cannot be null or empty"); + } + + long id = 0; + for (int i = 0; i < str.length(); i++) { + char c = str.charAt(i); + int val = (c < INDEX_MAP.length) ? INDEX_MAP[c] : -1; + + if (val == -1) { + throw new IllegalArgumentException("Invalid character in Base62 string: " + c); + } + + id = id * BASE + val; + } + return id; + } +} \ No newline at end of file diff --git a/java/dsl-common/src/main/java/com/dsl/common/idgenerator/SnowflakeIdGenerator.java b/java/dsl-common/src/main/java/com/dsl/common/idgenerator/SnowflakeIdGenerator.java new file mode 100644 index 0000000..4f11433 --- /dev/null +++ b/java/dsl-common/src/main/java/com/dsl/common/idgenerator/SnowflakeIdGenerator.java @@ -0,0 +1,80 @@ +package com.dsl.common.idgenerator; + +import java.time.Instant; + +/** + * A distributed unique ID generator inspired by Twitter's Snowflake. + *

+ * Structure (64 bits): + * 1 bit: Unused (sign bit) + * 41 bits: Timestamp (milliseconds since custom epoch) -> 2,199,023,255,552 milliseconds or ~69 years + * 10 bits: Node ID (configured per server) -> 1,023 servers + * 12 bits: Sequence number (for IDs generated within the same millisecond) -> 4,096 IDs per millisecond + */ +public class SnowflakeIdGenerator { + + // Custom Epoch (e.g., Jan 1st, 2025) - Allows for ~69 years of IDs + private static final long CUSTOM_EPOCH = 1735689600000L; + + private static final long NODE_ID_BITS = 10L; + private static final long SEQUENCE_BITS = 12L; + + private static final long MAX_NODE_ID = (1L << NODE_ID_BITS) - 1; + private static final long MAX_SEQUENCE = (1L << SEQUENCE_BITS) - 1; + + // Bit shifts + private static final long NODE_ID_SHIFT = SEQUENCE_BITS; + private static final long TIMESTAMP_SHIFT = SEQUENCE_BITS + NODE_ID_BITS; + + private final long nodeId; + private long lastTimestamp = -1L; + private long sequence = 0L; + + /** + * @param nodeId Unique ID for this server/process (0 - 1023) + */ + public SnowflakeIdGenerator(long nodeId) { + if (nodeId < 0 || nodeId > MAX_NODE_ID) { + throw new IllegalArgumentException(String.format("Node ID must be between 0 and %d", MAX_NODE_ID)); + } + this.nodeId = nodeId; + } + + public synchronized long nextId() { + long currentTimestamp = timestamp(); + + if (currentTimestamp < lastTimestamp) { + throw new IllegalStateException("Clock moved backwards. Refusing to generate ID."); + } + + if (currentTimestamp == lastTimestamp) { + // Same millisecond: increment sequence + sequence = (sequence + 1) & MAX_SEQUENCE; + if (sequence == 0) { + // Sequence exhausted, wait for next millisecond + currentTimestamp = waitNextMillis(currentTimestamp); + } + } else { + // New millisecond: reset sequence + sequence = 0L; + } + + lastTimestamp = currentTimestamp; + + // Bitwise OR to combine parts + return ((currentTimestamp - CUSTOM_EPOCH) << TIMESTAMP_SHIFT) + | (nodeId << NODE_ID_SHIFT) + | sequence; + } + + private long waitNextMillis(long currentTimestamp) { + while (currentTimestamp <= lastTimestamp) { + currentTimestamp = timestamp(); + } + return currentTimestamp; + } + + private long timestamp() { + return Instant.now().toEpochMilli(); + } +} \ No newline at end of file diff --git a/java/dsl-common/src/test/java/com/dsl/common/baseencoder/Base62EncoderTest.java b/java/dsl-common/src/test/java/com/dsl/common/baseencoder/Base62EncoderTest.java new file mode 100644 index 0000000..3f0bc57 --- /dev/null +++ b/java/dsl-common/src/test/java/com/dsl/common/baseencoder/Base62EncoderTest.java @@ -0,0 +1,42 @@ +package com.dsl.common.baseencoder; + +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class Base62EncoderTest { + + @Test + void testEncodeDecodeRoundTrip() { + long originalId = 178263819283046400L; + + String shortUrl = Base62Encoder.encode(originalId); + long decodedId = Base62Encoder.decode(shortUrl); + + System.out.println("Original: " + originalId); + System.out.println("Encoded: " + shortUrl); + System.out.println("Decoded: " + decodedId); + + assertEquals(originalId, decodedId, "Decoded ID must match the original ID"); + } + + @Test + void testSimpleValues() { + // 0 -> "0" + assertEquals("0", Base62Encoder.encode(0)); + assertEquals(0, Base62Encoder.decode("0")); + + // 61 -> "Z" + assertEquals("Z", Base62Encoder.encode(61)); + assertEquals(61, Base62Encoder.decode("Z")); + + // 62 -> "10" + assertEquals("10", Base62Encoder.encode(62)); + assertEquals(62, Base62Encoder.decode("10")); + } + + @Test + void testInvalidCharacters() { + assertThrows(IllegalArgumentException.class, () -> Base62Encoder.decode("abc$")); + assertThrows(IllegalArgumentException.class, () -> Base62Encoder.decode("")); + } +} \ No newline at end of file diff --git a/java/dsl-common/src/test/java/com/dsl/common/idgenerator/SnowflakeIdGeneratorTest.java b/java/dsl-common/src/test/java/com/dsl/common/idgenerator/SnowflakeIdGeneratorTest.java new file mode 100644 index 0000000..23a5aeb --- /dev/null +++ b/java/dsl-common/src/test/java/com/dsl/common/idgenerator/SnowflakeIdGeneratorTest.java @@ -0,0 +1,86 @@ +package com.dsl.common.idgenerator; + +import org.junit.jupiter.api.Test; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.*; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class SnowflakeIdGeneratorTest { + + @Test + void testUniqueIdsSingleThread() { + SnowflakeIdGenerator generator = new SnowflakeIdGenerator(1); + Set ids = new HashSet<>(); + int iterations = 100_000; + + for (int i = 0; i < iterations; i++) { + long id = generator.nextId(); + ids.add(id); + } + + assertEquals(iterations, ids.size(), "All IDs should be unique"); + } + + @Test + void testUniqueIdsMultiThreaded() throws InterruptedException, ExecutionException { + int threadCount = 50; + int requestsPerThread = 1_000; + SnowflakeIdGenerator generator = new SnowflakeIdGenerator(1); + + // A thread-safe Set to store IDs from all threads + Set ids = Collections.synchronizedSet(new HashSet<>()); + try (ExecutorService executor = Executors.newFixedThreadPool(threadCount)) { + List> tasks = new ArrayList<>(); + + for (int i = 0; i < threadCount; i++) { + tasks.add(() -> { + for (int j = 0; j < requestsPerThread; j++) { + ids.add(generator.nextId()); + } + return null; + }); + } + + // Run all threads + List> futures = executor.invokeAll(tasks); + + // Wait for completion + for (Future future : futures) { + future.get(); + } + + executor.shutdown(); + } + + assertEquals(threadCount * requestsPerThread, ids.size(), + "Every ID generated across all threads should be unique"); + } + + @Test + void testIdsAreSortableByTime() throws InterruptedException { + SnowflakeIdGenerator generator = new SnowflakeIdGenerator(1); + long id1 = generator.nextId(); + + Thread.sleep(1); + + long id2 = generator.nextId(); + + assertTrue(id2 > id1, "Later ID should be larger than earlier ID"); + } + + @Test + void testNodeIdBits() { + SnowflakeIdGenerator node1 = new SnowflakeIdGenerator(1); + SnowflakeIdGenerator node2 = new SnowflakeIdGenerator(2); + + long id1 = node1.nextId(); + long id2 = node2.nextId(); + + assertNotEquals(id1, id2); + } +} \ No newline at end of file diff --git a/java/pom.xml b/java/pom.xml new file mode 100644 index 0000000..8e13dcd --- /dev/null +++ b/java/pom.xml @@ -0,0 +1,45 @@ + + 4.0.0 + + com.dsl + distributed-systems-lab + 1.0-SNAPSHOT + pom + + + dsl-common + url-shortener + + + + 21 + 21 + UTF-8 + + + + + org.junit.jupiter + junit-jupiter + 5.14.0 + test + + + + org.mockito + mockito-core + 5.20.0 + test + + + + org.mockito + mockito-junit-jupiter + 5.20.0 + test + + + + \ No newline at end of file diff --git a/java/url-shortener/Dockerfile b/java/url-shortener/Dockerfile new file mode 100644 index 0000000..55118fa --- /dev/null +++ b/java/url-shortener/Dockerfile @@ -0,0 +1,23 @@ +# --- Build the JAR --- +FROM maven:3.9.6-eclipse-temurin-21 AS build +WORKDIR /app + +COPY pom.xml . + +COPY dsl-common/pom.xml dsl-common/pom.xml +COPY dsl-common/src dsl-common/src + +COPY url-shortener/pom.xml url-shortener/pom.xml +COPY url-shortener/src url-shortener/src + +RUN mvn clean package -DskipTests + +# --- Runtime Stage --- +FROM eclipse-temurin:21-jre-alpine +WORKDIR /app + +COPY --from=build /app/url-shortener/target/url-shortener-1.0-SNAPSHOT.jar app.jar +COPY --from=build /app/url-shortener/target/lib lib + +# Run it +ENTRYPOINT ["java", "-cp", "app.jar:lib/*", "com.dsl.urlshortener.Application"] \ No newline at end of file diff --git a/java/url-shortener/README.md b/java/url-shortener/README.md new file mode 100644 index 0000000..7d6ee59 --- /dev/null +++ b/java/url-shortener/README.md @@ -0,0 +1,90 @@ +# High-Performance Distributed URL Shortener + +A high-throughput, low-latency URL Shortener service built from scratch in Java. It bypasses high-level frameworks (Spring Boot) in favor of **Raw Netty** and **ScyllaDB** to achieve maximum concurrency and minimal resource overhead. + +## Functional Requirements +* **Shorten URL:** The system must accept a valid long URL and return a unique shortened alias. +* **Redirection:** Accessing the shortened alias must redirect the user to the original long URL via HTTP 302. +* **Uniqueness:** Every generated Short URL must be globally unique. +* **Idempotency:** *Design Choice: Submitting the same URL twice results in two different short links.* +* **Base62 Encoding:** Short URLs must use alphanumeric characters `[a-z, A-Z, 0-9]` for URL safety. + +## Non-Functional Requirements +* **Low Latency:** + * **Write (Shorten):** < 10ms (ID generation is local; DB write is async/fast). + * **Read (Redirect):** < 10ms (P99). +* **High Throughput:** The system should handle thousands of requests per second (RPS) on a single node. +* **Scalability:** + * **Application:** Stateless design allows horizontal scaling behind a load balancer. + * **Database:** ScyllaDB allows horizontal scaling by adding nodes to the cluster. +* **Durability:** Data is persisted to disk immediately; no in-memory data loss risks. + +## Key Features + +* **Non-Blocking I/O:** Built on **Netty 4** using the Event Loop model (Reactor Pattern) for handling thousands of concurrent connections. +* **Distributed ID Generation:** Uses a custom **Snowflake ID Generator** (no coordination required between nodes). +* **High-Performance Storage:** Uses **ScyllaDB** for scalable, key-value persistence. +* **Observability:** Built-in **Prometheus** metrics and **Grafana** dashboards ([RED Method](https://grafana.com/blog/2018/08/02/the-red-method-how-to-instrument-your-services/)). + +## Tech Stack + +* **Language:** Java 21 +* **Networking:** Netty (Raw NIO) +* **Database:** ScyllaDB (NoSQL) +* **Observability:** Micrometer, Prometheus, Grafana +* **Testing:** JUnit 5, Mockito, k6 (Load Testing) +* **Containerization:** Docker, Docker Compose + +--- + +## Architecture Design + +### 1. Write Path (`POST /shorten`) +1. **Request:** Client sends `original_url`. +2. **ID Generation:** Server generates a unique 64-bit ID using the **Snowflake Algorithm** (Local CPU operation, 0ms latency). +3. **Encoding:** ID is converted to a Base62 String (e.g., `17826...` $\rightarrow$ `7Xw9b2`). +4. **Persistence:** The mapping (`short_url` $\rightarrow$ `original_url`) is persisted in **ScyllaDB**. +5. **Response:** Client receives the short URL immediately. + +### 2. Read Path (`GET /{shortUrl}`) +1. **Request:** Client hits the short link. +2. **Lookup:** Server queries ScyllaDB by Partition Key (`short_url`). +3. **Redirect:** Server returns a `302 Found` with the `Location` header. + +![URL Shortener Architecture](../../docs/assets/url-shortener/architecture.png) + +--- + +## Getting Started + +### Prerequisites +* Docker & Docker Compose +* Java 21 (Optional, if running outside Docker) + +### Run the Full Stack +This spins up the App, ScyllaDB, Prometheus, and Grafana as docker containers. + +```bash + cd java/url-shortener + docker-compose up --build +``` + +### API Endpoints +The API Endpoints are available here for [test](src/main/resources/test.http). + +## Observability & Load Testing +### Run a Load Test (k6) +We use **_k6_** to simulate high traffic. The script handles both Write (Shorten) and Read (Redirect) traffic. +Load test script folder location: [load-test](load-test) + +![load_test.png](../../docs/assets/url-shortener/load_test.png) + +### View Metrics (Grafana) +* URL: http://localhost:3000 +* Credentials: admin / admin +* Dashboard Setup: + * Add Data Source → Prometheus (http://prometheus:9090) + * Import Dashboard from [grafana-dashboard.json](grafana-dashboard.json). + +![grafana_dashboard.png](../../docs/assets/url-shortener/grafana_dashboard.png) + diff --git a/java/url-shortener/docker-compose.yml b/java/url-shortener/docker-compose.yml new file mode 100644 index 0000000..a84abe3 --- /dev/null +++ b/java/url-shortener/docker-compose.yml @@ -0,0 +1,57 @@ +version: '3.8' + +services: + scylla: + image: scylladb/scylla:5.2 + container_name: scylla-node1 + # SAFETY LIMITS FOR LAPTOP DEV: + # --smp 1: Use only 1 CPU core + # --memory 750M: Use only 750MB RAM + command: --smp 1 --memory 750M --overprovisioned 1 --api-address 0.0.0.0 + ports: + - "9042:9042" + volumes: + - scylla-data:/var/lib/scylla + healthcheck: + test: ["CMD-SHELL", "cqlsh -e 'DESCRIBE KEYSPACES'"] + interval: 15s + timeout: 10s + retries: 10 + + url-shortener: + build: + context: .. + dockerfile: url-shortener/Dockerfile + container_name: url-shortener + ports: + - "8080:8080" + environment: + - SCYLLA_HOST=scylla + - JAVA_TOOL_OPTIONS="-Xmx512m" + - NODE_ID=1 + depends_on: + scylla: + condition: service_healthy + + prometheus: + image: prom/prometheus:latest + container_name: shortener-prom + volumes: + - ./prometheus.yml:/etc/prometheus/prometheus.yml + ports: + - "9090:9090" + depends_on: + - url-shortener + + grafana: + image: grafana/grafana:latest + container_name: shortener-grafana + ports: + - "3000:3000" + environment: + - GF_SECURITY_ADMIN_PASSWORD=admin + depends_on: + - prometheus + +volumes: + scylla-data: \ No newline at end of file diff --git a/java/url-shortener/grafana-dashboard.json b/java/url-shortener/grafana-dashboard.json new file mode 100644 index 0000000..63e2f48 --- /dev/null +++ b/java/url-shortener/grafana-dashboard.json @@ -0,0 +1,326 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": 0, + "links": [], + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "df6epxq9f9wjke" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 11, + "x": 0, + "y": 0 + }, + "id": 2, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "editorMode": "builder", + "expr": "http_requests_seconds_max", + "legendFormat": "__auto", + "range": true, + "refId": "A" + } + ], + "title": "Request Max Time", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "df6epxq9f9wjke" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 9, + "x": 11, + "y": 0 + }, + "id": 1, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "df6epxq9f9wjke" + }, + "editorMode": "builder", + "expr": "http_requests_seconds_count", + "legendFormat": "__auto", + "range": true, + "refId": "A" + } + ], + "title": "Request count", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "df6epxq9f9wjke" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 11, + "x": 0, + "y": 9 + }, + "id": 3, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "editorMode": "builder", + "expr": "rate(http_requests_seconds_count[1m])", + "legendFormat": "__auto", + "range": true, + "refId": "A" + } + ], + "title": "Request Rate", + "type": "timeseries" + } + ], + "preload": false, + "schemaVersion": 42, + "tags": [], + "templating": { + "list": [] + }, + "time": { + "from": "now-5m", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "URL Shortener", + "uid": "adrntlm", + "version": 11 +} \ No newline at end of file diff --git a/java/url-shortener/load-test/load-test.js b/java/url-shortener/load-test/load-test.js new file mode 100644 index 0000000..72723c0 --- /dev/null +++ b/java/url-shortener/load-test/load-test.js @@ -0,0 +1,44 @@ +import http from 'k6/http'; +import { check, sleep } from 'k6'; + +export const options = { + // Ramp up to 50 virtual users over 10 seconds, then hold for 20s + stages: [ + { duration: '10s', target: 100 }, + { duration: '20s', target: 100 }, + { duration: '5s', target: 0 }, + ], +}; + +const BASE_URL = 'http://localhost:8080'; + +export default function () { + const randomId = Math.random().toString(36).substring(7); + const payload = `https://google.com/search?q=${randomId}`; + + const params = { + headers: { + 'Content-Type': 'text/plain', + } + }; + + const res = http.post(`${BASE_URL}/shorten`, payload, params); + + + check(res, { + 'is status 200': (r) => r.status === 200, + 'latency < 100ms': (r) => r.timings.duration < 100, + }); + + if (res.status === 200) { + const shortUrl = res.body; + + const readRes = http.get(shortUrl, { redirects: 0 }); // Don't follow the redirect, just check the 302 + + check(readRes, { + 'is redirect 302': (r) => r.status === 302, + }); + } + + sleep(0.1); +} \ No newline at end of file diff --git a/java/url-shortener/pom.xml b/java/url-shortener/pom.xml new file mode 100644 index 0000000..f0c94b2 --- /dev/null +++ b/java/url-shortener/pom.xml @@ -0,0 +1,93 @@ + + + 4.0.0 + + + + com.dsl + distributed-systems-lab + 1.0-SNAPSHOT + + + com.dsl.urlshortener + url-shortener + 1.0-SNAPSHOT + + + 4.2.7.Final + 4.17.0 + 2.0.12 + 1.16.0 + + + + + com.dsl + dsl-common + 1.0-SNAPSHOT + + + + io.netty + netty-codec-http + ${netty.version} + + + + com.datastax.oss + java-driver-core + ${datastax.version} + + + + org.slf4j + slf4j-simple + ${slf4j.version} + + + + io.micrometer + micrometer-registry-prometheus + ${micrometer-prometheus.version} + + + + + + + org.apache.maven.plugins + maven-dependency-plugin + 3.6.1 + + + copy-dependencies + package + + copy-dependencies + + + ${project.build.directory}/lib + + + + + + org.apache.maven.plugins + maven-jar-plugin + 3.3.0 + + + + true + lib/ + com.dsl.urlshortener.Application + + + + + + + + \ No newline at end of file diff --git a/java/url-shortener/prometheus.yml b/java/url-shortener/prometheus.yml new file mode 100644 index 0000000..1406f0a --- /dev/null +++ b/java/url-shortener/prometheus.yml @@ -0,0 +1,8 @@ +global: + scrape_interval: 5s + +scrape_configs: + - job_name: 'url-shortener' + metrics_path: '/metrics' + static_configs: + - targets: ['url-shortener:8080'] \ No newline at end of file diff --git a/java/url-shortener/src/main/java/com/dsl/urlshortener/Application.java b/java/url-shortener/src/main/java/com/dsl/urlshortener/Application.java new file mode 100644 index 0000000..c78ead4 --- /dev/null +++ b/java/url-shortener/src/main/java/com/dsl/urlshortener/Application.java @@ -0,0 +1,62 @@ +package com.dsl.urlshortener; + +import com.datastax.oss.driver.api.core.CqlSession; +import com.dsl.common.idgenerator.SnowflakeIdGenerator; +import com.dsl.urlshortener.handler.ShortenerHandler; +import com.dsl.urlshortener.repository.ScyllaUrlRepository; +import com.dsl.urlshortener.repository.UrlRepository; +import com.dsl.urlshortener.server.NettyServer; +import io.micrometer.prometheusmetrics.PrometheusConfig; +import io.micrometer.prometheusmetrics.PrometheusMeterRegistry; + +import java.net.InetSocketAddress; + +public class Application { + public static void main(String[] args) throws Exception { + int port = 8080; + String host = "http://localhost:" + port + "/"; + + String scyllaHost = System.getenv("SCYLLA_HOST"); + if (scyllaHost == null) { + scyllaHost = "127.0.0.1"; + } + + int nodeId = Integer.parseInt(System.getenv("NODE_ID")); + + System.out.println("Connecting to ScyllaDB at " + scyllaHost + "..."); + + CqlSession session = CqlSession.builder() + .addContactPoint(new InetSocketAddress(scyllaHost, 9042)) + .withLocalDatacenter("datacenter1") + .build(); + + initializeSchema(session); + + System.out.println("Initializing system components..."); + + SnowflakeIdGenerator idGenerator = new SnowflakeIdGenerator(nodeId); + + UrlRepository repository = new ScyllaUrlRepository(session); + + PrometheusMeterRegistry meterRegistry = new PrometheusMeterRegistry(PrometheusConfig.DEFAULT); + + ShortenerHandler shortenerHandler = new ShortenerHandler(idGenerator, repository, host, meterRegistry); + + NettyServer server = new NettyServer<>(port, shortenerHandler); + server.start(); + } + + private static void initializeSchema(CqlSession session) { + session.execute("CREATE KEYSPACE IF NOT EXISTS shortener " + + "WITH replication = {'class': 'SimpleStrategy', 'replication_factor': 1}"); + + session.execute("CREATE TABLE IF NOT EXISTS shortener.urls (" + + "short_url text PRIMARY KEY, " + + "original_url text, " + + "id bigint, " + + "created_at timestamp)"); + + System.out.println("Schema initialized."); + } +} + diff --git a/java/url-shortener/src/main/java/com/dsl/urlshortener/handler/ShortenerHandler.java b/java/url-shortener/src/main/java/com/dsl/urlshortener/handler/ShortenerHandler.java new file mode 100644 index 0000000..393efde --- /dev/null +++ b/java/url-shortener/src/main/java/com/dsl/urlshortener/handler/ShortenerHandler.java @@ -0,0 +1,99 @@ +package com.dsl.urlshortener.handler; + +import com.dsl.common.baseencoder.Base62Encoder; +import com.dsl.common.idgenerator.SnowflakeIdGenerator; +import com.dsl.urlshortener.repository.UrlRepository; +import io.micrometer.core.instrument.Timer; +import io.micrometer.prometheusmetrics.PrometheusMeterRegistry; +import io.netty.buffer.Unpooled; +import io.netty.channel.ChannelFutureListener; +import io.netty.channel.ChannelHandler; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.SimpleChannelInboundHandler; +import io.netty.handler.codec.http.*; +import io.netty.util.CharsetUtil; + +import java.time.Duration; + +@ChannelHandler.Sharable +public class ShortenerHandler extends SimpleChannelInboundHandler { + + private final SnowflakeIdGenerator idGenerator; + private final UrlRepository repository; + private final String domain; + private final PrometheusMeterRegistry meterRegistry; + private final Timer shortenTimer; + private final Timer redirectTimer; + + public ShortenerHandler(SnowflakeIdGenerator idGenerator, UrlRepository repository, String domain, PrometheusMeterRegistry meterRegistry) { + this.idGenerator = idGenerator; + this.repository = repository; + this.domain = domain; + this.meterRegistry = meterRegistry; + this.shortenTimer = meterRegistry.timer("http_requests", "endpoint", "shorten"); + this.redirectTimer = meterRegistry.timer("http_requests", "endpoint", "redirect"); + } + + @Override + protected void channelRead0(ChannelHandlerContext handlerContext, FullHttpRequest request) { + HttpMethod method = request.method(); + String uri = request.uri(); + + if ("/metrics".equals(uri)) { + sendResponse(handlerContext, HttpResponseStatus.OK, meterRegistry.scrape()); + return; + } + + long startTime = System.nanoTime(); + + if (HttpMethod.POST.equals(method) && "/shorten".equals(uri)) { + handleShortenRequest(handlerContext, request); + shortenTimer.record(Duration.ofNanos(System.nanoTime() - startTime)); + } else if (HttpMethod.GET.equals(method) && uri.length() > 1) { + handleRedirectRequest(handlerContext, uri.substring(1)); // Remove leading '/' + redirectTimer.record(Duration.ofNanos(System.nanoTime() - startTime)); + } else { + sendResponse(handlerContext, HttpResponseStatus.NOT_FOUND, "Endpoint not found"); + } + } + + private void handleShortenRequest(ChannelHandlerContext handlerContext, FullHttpRequest request) { + String originalUrl = request.content().toString(CharsetUtil.UTF_8).trim(); + + if (originalUrl.isEmpty()) { + sendResponse(handlerContext, HttpResponseStatus.BAD_REQUEST, "Body cannot be empty"); + return; + } + + // Core Logic: ID -> Base62 -> Store + long id = idGenerator.nextId(); + String shortUrl = Base62Encoder.encode(id); + repository.save(id, shortUrl, originalUrl); + + String responseBody = domain + shortUrl; + sendResponse(handlerContext, HttpResponseStatus.OK, responseBody); + } + + private void handleRedirectRequest(ChannelHandlerContext handlerContext, String shortUrl) { + String originalUrl = repository.getOriginalUrl(shortUrl); + + if (originalUrl == null) { + sendResponse(handlerContext, HttpResponseStatus.NOT_FOUND, "Short URL not found"); + return; + } + + // 302 Redirect + FullHttpResponse response = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.FOUND); + response.headers().set(HttpHeaderNames.LOCATION, originalUrl); + + handlerContext.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE); + } + + private void sendResponse(ChannelHandlerContext handlerContext, HttpResponseStatus status, String content) { + FullHttpResponse response = new DefaultFullHttpResponse( + HttpVersion.HTTP_1_1, status, Unpooled.copiedBuffer(content, CharsetUtil.UTF_8)); + response.headers().set(HttpHeaderNames.CONTENT_TYPE, "text/plain"); + response.headers().set(HttpHeaderNames.CONTENT_LENGTH, response.content().readableBytes()); + handlerContext.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE); + } +} \ No newline at end of file diff --git a/java/url-shortener/src/main/java/com/dsl/urlshortener/repository/InMemoryUrlRepository.java b/java/url-shortener/src/main/java/com/dsl/urlshortener/repository/InMemoryUrlRepository.java new file mode 100644 index 0000000..6995590 --- /dev/null +++ b/java/url-shortener/src/main/java/com/dsl/urlshortener/repository/InMemoryUrlRepository.java @@ -0,0 +1,19 @@ +package com.dsl.urlshortener.repository; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +public class InMemoryUrlRepository implements UrlRepository { + + private final Map storage = new ConcurrentHashMap<>(); + + @Override + public void save(long id, String shortUrl, String originalUrl) { + storage.put(shortUrl, originalUrl); + } + + @Override + public String getOriginalUrl(String shortUrl) { + return storage.get(shortUrl); + } +} \ No newline at end of file diff --git a/java/url-shortener/src/main/java/com/dsl/urlshortener/repository/ScyllaUrlRepository.java b/java/url-shortener/src/main/java/com/dsl/urlshortener/repository/ScyllaUrlRepository.java new file mode 100644 index 0000000..d91bc88 --- /dev/null +++ b/java/url-shortener/src/main/java/com/dsl/urlshortener/repository/ScyllaUrlRepository.java @@ -0,0 +1,40 @@ +package com.dsl.urlshortener.repository; + +import com.datastax.oss.driver.api.core.CqlSession; +import com.datastax.oss.driver.api.core.cql.PreparedStatement; +import com.datastax.oss.driver.api.core.cql.ResultSet; +import com.datastax.oss.driver.api.core.cql.Row; + +import java.time.Instant; + +public class ScyllaUrlRepository implements UrlRepository { + + private final CqlSession session; + private final PreparedStatement insertStmt; + private final PreparedStatement selectStmt; + + public ScyllaUrlRepository(CqlSession session) { + this.session = session; + + // Prepare statements once (Performance Best Practice) + this.insertStmt = session.prepare( + "INSERT INTO shortener.urls (short_url, original_url, id, created_at) VALUES (?, ?, ?, ?)" + ); + + this.selectStmt = session.prepare( + "SELECT original_url FROM shortener.urls WHERE short_url = ?" + ); + } + + @Override + public void save(long id, String shortUrl, String originalUrl) { + session.execute(insertStmt.bind(shortUrl, originalUrl, id, Instant.now())); + } + + @Override + public String getOriginalUrl(String shortUrl) { + ResultSet rs = session.execute(selectStmt.bind(shortUrl)); + Row row = rs.one(); + return (row != null) ? row.getString("original_url") : null; + } +} diff --git a/java/url-shortener/src/main/java/com/dsl/urlshortener/repository/UrlRepository.java b/java/url-shortener/src/main/java/com/dsl/urlshortener/repository/UrlRepository.java new file mode 100644 index 0000000..ad49e4c --- /dev/null +++ b/java/url-shortener/src/main/java/com/dsl/urlshortener/repository/UrlRepository.java @@ -0,0 +1,7 @@ +package com.dsl.urlshortener.repository; + +public interface UrlRepository { + void save(long id, String shortUrl, String originalUrl); + + String getOriginalUrl(String shortUrl); +} \ No newline at end of file diff --git a/java/url-shortener/src/main/java/com/dsl/urlshortener/server/NettyServer.java b/java/url-shortener/src/main/java/com/dsl/urlshortener/server/NettyServer.java new file mode 100644 index 0000000..a2bdb82 --- /dev/null +++ b/java/url-shortener/src/main/java/com/dsl/urlshortener/server/NettyServer.java @@ -0,0 +1,46 @@ +package com.dsl.urlshortener.server; + +import io.netty.bootstrap.ServerBootstrap; +import io.netty.channel.*; +import io.netty.channel.nio.NioIoHandler; +import io.netty.channel.socket.SocketChannel; +import io.netty.channel.socket.nio.NioServerSocketChannel; +import io.netty.handler.codec.http.HttpObjectAggregator; +import io.netty.handler.codec.http.HttpServerCodec; + +public class NettyServer> { + + private final int port; + private final T handler; + + public NettyServer(int port, T handler) { + this.port = port; + this.handler = handler; + } + + public void start() throws Exception { + + try (EventLoopGroup bossGroup = new MultiThreadIoEventLoopGroup(1, NioIoHandler.newFactory()); + EventLoopGroup workerGroup = new MultiThreadIoEventLoopGroup(NioIoHandler.newFactory()) + ) { + ServerBootstrap b = new ServerBootstrap(); + b.group(bossGroup, workerGroup) + .channel(NioServerSocketChannel.class) + .childHandler(new ChannelInitializer() { + @Override + public void initChannel(SocketChannel ch) { + ch.pipeline().addLast(new HttpServerCodec()); + ch.pipeline().addLast(new HttpObjectAggregator(64 * 1024)); // 64KB max content length + + // Pass the handler to the pipeline + ch.pipeline().addLast(handler); + } + }); + + System.out.println("Server started on port " + port); + ChannelFuture f = b.bind(port).sync(); + f.channel().closeFuture().sync(); + } + } + +} diff --git a/java/url-shortener/src/main/resources/test.http b/java/url-shortener/src/main/resources/test.http new file mode 100644 index 0000000..428292b --- /dev/null +++ b/java/url-shortener/src/main/resources/test.http @@ -0,0 +1,9 @@ +### Shorten a URL +POST http://localhost:8080/shorten +Content-Type: text/plain + +www.google.com + +### Redirect to the original URL +# Replace {shortUrl} with the value from the previous request +GET http://localhost:8080/{shortUrl} diff --git a/java/url-shortener/src/test/java/com/dsl/urlshortener/handler/ShortenerHandlerTest.java b/java/url-shortener/src/test/java/com/dsl/urlshortener/handler/ShortenerHandlerTest.java new file mode 100644 index 0000000..23afab7 --- /dev/null +++ b/java/url-shortener/src/test/java/com/dsl/urlshortener/handler/ShortenerHandlerTest.java @@ -0,0 +1,126 @@ +package com.dsl.urlshortener.handler; + +import com.dsl.common.idgenerator.SnowflakeIdGenerator; +import com.dsl.urlshortener.repository.UrlRepository; +import io.micrometer.core.instrument.Timer; +import io.micrometer.prometheusmetrics.PrometheusMeterRegistry; +import io.netty.buffer.Unpooled; +import io.netty.channel.embedded.EmbeddedChannel; +import io.netty.handler.codec.http.*; +import io.netty.util.CharsetUtil; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class ShortenerHandlerTest { + + @Mock + private SnowflakeIdGenerator idGenerator; + + @Mock + private UrlRepository repository; + + @Mock + private PrometheusMeterRegistry meterRegistry; + + @Mock + private Timer shortenTimer; + + @Mock + private Timer redirectTimer; + + private EmbeddedChannel channel; + + @BeforeEach + void setUp() { + when(meterRegistry.timer("http_requests", "endpoint", "shorten")).thenReturn(shortenTimer); + when(meterRegistry.timer("http_requests", "endpoint", "redirect")).thenReturn(redirectTimer); + ShortenerHandler handler = new ShortenerHandler(idGenerator, repository, "http://localhost:8080/", meterRegistry); + // EmbeddedChannel to test Netty handlers + channel = new EmbeddedChannel(handler); + } + + @Test + void testShortenUrlSuccess() { + long mockId = 12345L; + String expectedShortCode = "3d7"; // Base62 of 12345 + when(idGenerator.nextId()).thenReturn(mockId); + + FullHttpRequest request = new DefaultFullHttpRequest( + HttpVersion.HTTP_1_1, + HttpMethod.POST, + "/shorten", + Unpooled.copiedBuffer("https://google.com", CharsetUtil.UTF_8) + ); + channel.writeInbound(request); + + FullHttpResponse response = channel.readOutbound(); + + assertNotNull(response); + assertEquals(HttpResponseStatus.OK, response.status()); + + String body = response.content().toString(CharsetUtil.UTF_8); + assertEquals("http://localhost:8080/" + expectedShortCode, body); + + verify(repository).save(mockId, expectedShortCode, "https://google.com"); + verify(shortenTimer).record(any(java.time.Duration.class)); + } + + @Test + void testShortenEmptyBodyFails() { + FullHttpRequest request = new DefaultFullHttpRequest( + HttpVersion.HTTP_1_1, + HttpMethod.POST, + "/shorten", + Unpooled.copiedBuffer("", CharsetUtil.UTF_8) + ); + + channel.writeInbound(request); + FullHttpResponse response = channel.readOutbound(); + + assertEquals(HttpResponseStatus.BAD_REQUEST, response.status()); + verifyNoInteractions(idGenerator); // Shouldn't burn an ID for invalid request + } + + @Test + void testRedirectNotFound() { + // 1. SETUP: Repo returns null + when(repository.getOriginalUrl("unknown")).thenReturn(null); + + // 2. EXECUTE + FullHttpRequest request = new DefaultFullHttpRequest( + HttpVersion.HTTP_1_1, + HttpMethod.GET, + "/unknown" + ); + channel.writeInbound(request); + + // 3. VERIFY + FullHttpResponse response = channel.readOutbound(); + assertEquals(HttpResponseStatus.NOT_FOUND, response.status()); + verify(redirectTimer).record(any(java.time.Duration.class)); + } + + @Test + void testMetricsEndpoint() { + when(meterRegistry.scrape()).thenReturn("http_requests"); + FullHttpRequest request = new DefaultFullHttpRequest( + HttpVersion.HTTP_1_1, + HttpMethod.GET, + "/metrics" + ); + channel.writeInbound(request); + + FullHttpResponse response = channel.readOutbound(); + assertEquals(HttpResponseStatus.OK, response.status()); + assertTrue(response.content().toString(CharsetUtil.UTF_8).contains("http_requests")); + verifyNoInteractions(shortenTimer); + verifyNoInteractions(redirectTimer); + } +}