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.
+
+
+
+### 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.
+
+
+
+---
+
+## 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)
+
+
+
+### 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).
+
+
+
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);
+ }
+}