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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,17 @@ services:
ports:
- "9090:9090"

rabbitmq:
image: rabbitmq:3-management
ports:
- "5672:5672"
- "15672:15672"
environment:
- RABBITMQ_DEFAULT_USER=user
- RABBITMQ_DEFAULT_PASS=password
volumes:
- rabbitmq-data:/var/lib/rabbitmq

grafana:
image: grafana/grafana
ports:
Expand All @@ -95,4 +106,5 @@ services:
volumes:
order-db-data:
inventory-db-data:
rabbitmq-data:
grafana-storage:
2 changes: 2 additions & 0 deletions services/api-gateway/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ const proxyOptions = {
'^/api/products': '/products',
'^/api/inventory': '/inventory',
},
timeout: 10000,
proxyTimeout: 10000
};

// Routes
Expand Down
100 changes: 82 additions & 18 deletions services/frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@ import { useState, useEffect } from 'react'
import axios from 'axios'
import './index.css'

const ORDER_SERVICE_URL = import.meta.env.VITE_ORDER_SERVICE_URL || 'http://localhost:3001';
const INVENTORY_SERVICE_URL = import.meta.env.VITE_INVENTORY_SERVICE_URL || 'http://localhost:3002';
const API_GATEWAY_URL = import.meta.env.VITE_API_GATEWAY_URL || 'http://localhost:8080/api';

interface Product {
id: string;
Expand All @@ -23,18 +22,18 @@ function App() {

const checkHealth = async () => {
try {
await axios.get(`${ORDER_SERVICE_URL}/health`);
await axios.get(`${API_GATEWAY_URL}/health/orders`);
setHealth({ order: 'UP' });
} catch (e) {
setHealth({ order: 'DOWN' });
}
};

const fetchProducts = async () => {
const fetchProducts = async (initializeSelection = false) => {
try {
const res = await axios.get(`${INVENTORY_SERVICE_URL}/products`);
const res = await axios.get(`${API_GATEWAY_URL}/products`);
setProducts(res.data);
if (res.data.length > 0 && !selectedProduct) {
if (initializeSelection && res.data.length > 0 && !selectedProduct) {
setSelectedProduct(res.data[0].id);
}
} catch (e) {
Expand All @@ -44,14 +43,51 @@ function App() {

useEffect(() => {
checkHealth();
fetchProducts();
fetchProducts(true);
const interval = setInterval(() => {
checkHealth();
fetchProducts(); // Refresh stock levels
fetchProducts(false); // Refresh stock levels without resetting selection
}, 5000);
return () => clearInterval(interval);
}, []);

const [queuedOrderIds, setQueuedOrderIds] = useState<string[]>([]);

useEffect(() => {
if (queuedOrderIds.length === 0) return;

const pollInterval = setInterval(async () => {
try {
const res = await axios.get(`${API_GATEWAY_URL}/orders`);
const orders = res.data;

// Check status of all queued orders
const remainingQueuedIds = queuedOrderIds.filter(id => {
const order = orders.find((o: any) => o.id === id);
if (order && order.status === 'COMPLETED') {
addLog(`✅ Async Order Completed! ID: ${id}`);
fetchProducts(); // Refresh stock
return false; // Remove from queued list
}
if (order && order.status === 'FAILED') {
addLog(`❌ Async Order Failed! ID: ${id}`);
return false; // Remove from queued list
}
return true; // Keep polling
});

if (remainingQueuedIds.length !== queuedOrderIds.length) {
setQueuedOrderIds(remainingQueuedIds);
}

} catch (e) {
console.error("Polling error", e);
}
}, 2000);

return () => clearInterval(pollInterval);
}, [queuedOrderIds]);

const placeOrder = async (isGremlin: boolean) => {
if (!selectedProduct) {
addLog("⚠️ No product selected!");
Expand All @@ -63,18 +99,44 @@ function App() {
addLog(`Initiating Order... (Product: ${products.find(p => p.id === selectedProduct)?.name}, Gremlin: ${isGremlin ? 'ON' : 'OFF'})`);

try {
// Use quantity=13 to trigger Gremlin Latency in Inventory Service
const quantity = isGremlin ? 3 : 1;
const response = await axios.post(`${ORDER_SERVICE_URL}/orders`, {
// Send 'gremlin' flag to trigger latency in Inventory Service
const quantity = 1;
const response = await axios.post(`${API_GATEWAY_URL}/orders`, {
productId: selectedProduct,
quantity
quantity,
gremlin: isGremlin
});

const end = performance.now();
const dur = Math.round(end - start);
setLatency(dur);
addLog(`✅ Order Success! ID: ${response.data.id}. Duration: ${dur}ms`);
fetchProducts(); // Update stock immediately

if (response.status === 202) {
// QUEUED
addLog(`⚠️ Order Queued: ${response.data.message}. Duration: ${dur}ms`);

// Poll for completion
const orderId = response.data.id;
const pollInterval = setInterval(async () => {
try {
const pollRes = await axios.get(`${API_GATEWAY_URL}/orders`);
// Ideally we'd have a specific GET /orders/:id endpoint, but filtering list works for demo
const myOrder = pollRes.data.find((o: any) => o.id === orderId);
if (myOrder && myOrder.status === 'COMPLETED') {
addLog(`✅ Async Order Completed! ID: ${orderId}`);
clearInterval(pollInterval);
fetchProducts();
}
} catch (e) {
console.error("Polling error", e);
}
}, 2000);

} else {
// SUCCESS
addLog(`✅ Order Success! ID: ${response.data.id}. Duration: ${dur}ms`);
fetchProducts(); // Update stock immediately
}

} catch (error: any) {
const end = performance.now();
Expand All @@ -90,7 +152,7 @@ function App() {

return (
<>
<h1>Valerix Resilient Platform</h1>
<h1>Valerix</h1>

<div style={{ display: 'flex', justifyContent: 'center', gap: '1rem', marginBottom: '2rem' }}>
<div className={`status-badge ${health.order === 'UP' ? 'CONFIRMED' : 'FAILED'}`}>
Expand Down Expand Up @@ -131,10 +193,12 @@ function App() {
fontSize: '2rem',
fontWeight: 'bold',
marginBottom: '1rem',
color: latency !== null ? (latency > 1500 ? 'var(--danger)' : 'var(--success)') : 'inherit'
color: latency !== null ? (latency > 2000 ? '#e3b341' : latency > 1500 ? 'var(--danger)' : 'var(--success)') : 'inherit'
}}>
{latency !== null ? `${latency}ms` : '---'}
<div style={{ fontSize: '0.8rem', color: '#8b949e', fontWeight: 'normal' }}>Last Request Latency</div>
{latency !== null ? (latency > 2000 ? 'QUEUED' : `${latency}ms`) : '---'}
<div style={{ fontSize: '0.8rem', color: '#8b949e', fontWeight: 'normal' }}>
{latency !== null && latency > 2000 ? 'Processed in Background' : 'Last Request Latency'}
</div>
</div>

<div style={{ display: 'flex', justifyContent: 'center', flexWrap: 'wrap' }}>
Expand Down
3 changes: 2 additions & 1 deletion services/inventory-service/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
"@prisma/client": "^5.10.2",
"cors": "^2.8.5",
"prom-client": "^15.1.0",
"dotenv": "^16.4.5"
"dotenv": "^16.4.5",
"amqplib": "^0.10.3"
},
"devDependencies": {
"typescript": "^5.3.3",
Expand Down
128 changes: 86 additions & 42 deletions services/inventory-service/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,61 +62,104 @@ app.get('/products', async (req, res) => {
});

// Deduct Inventory (with Idempotency + Gremlin Latency)
app.post('/inventory/deduct', async (req: Request, res: Response) => {
const { productId, quantity, orderId } = req.body;

if (!productId || !quantity || !orderId) {
res.status(400).json({ error: 'Missing productId, quantity, or orderId' });
return;
const RABBITMQ_URL = process.env.RABBITMQ_URL || 'amqp://user:password@rabbitmq:5672';
let channel: any;

// Connect to RabbitMQ
// Connect to RabbitMQ
async function connectToRabbit() {
const amqp = require('amqplib');
while (true) {
try {
const connection = await amqp.connect(RABBITMQ_URL);
channel = await connection.createChannel();
await channel.assertQueue('inventory_queue');
await channel.assertQueue('order_completion_queue');

console.log("Connected to RabbitMQ & listening on inventory_queue");

channel.consume('inventory_queue', async (msg: any) => {
if (msg !== null) {
const data = JSON.parse(msg.content.toString());
console.log("Received Async Order via RabbitMQ:", data);

try {
await deductInventory(data.productId, data.quantity, data.orderId);

// Send Completion Event
const completionMsg = JSON.stringify({
orderId: data.orderId,
status: 'COMPLETED',
message: 'Inventory deducted successfully (Async)'
});
channel.sendToQueue('order_completion_queue', Buffer.from(completionMsg));
console.log("Sent completion event for:", data.orderId);

channel.ack(msg);
} catch (e: any) {
console.error("Async Processing Failed:", e.message);
channel.ack(msg);
}
}
});
break; // Success
} catch (e) {
console.error("RabbitMQ Connection Failed, retrying in 5s...", e);
await new Promise(resolve => setTimeout(resolve, 5000));
}
}
}

try {
// 1. Check Idempotency
const existingLog = await prisma.idempotencyLog.findUnique({
where: { orderId }
});
// Logic: Deduct Inventory
async function deductInventory(productId: string, quantity: number, orderId: string) {
const existingLog = await prisma.idempotencyLog.findUnique({
where: { orderId }
});

if (existingLog) {
console.log(`Idempotency check: Order ${orderId} already processed.`);
// Return previous success immediately (skip Gremlin this time?)
// If we want to simulate "Vanishing Response" persisting, we might sleep again,
// but to solve the issue, we usually return success fast on retry.
res.status(200).json({ message: 'Stock already deducted (Idempotent)', success: true });
return;
}
if (existingLog) {
console.log(`Idempotency check: Order ${orderId} already processed.`);
return { message: 'Stock already deducted (Idempotent)', success: true };
}

// 2. Transaction: Deduct Stock + Log Idempotency
await prisma.$transaction(async (tx) => {
const product = await tx.product.findUnique({ where: { id: productId } });
if (!product || product.stock < quantity) {
throw new Error('Insufficient stock or product not found');
}
await prisma.$transaction(async (tx) => {
const product = await tx.product.findUnique({ where: { id: productId } });
if (!product || product.stock < quantity) {
throw new Error('Insufficient stock or product not found');
}

await tx.product.update({
where: { id: productId },
data: { stock: product.stock - quantity }
});
await tx.product.update({
where: { id: productId },
data: { stock: product.stock - quantity }
});

await tx.idempotencyLog.create({
data: { orderId }
});
await tx.idempotencyLog.create({
data: { orderId }
});
});

// 3. Gremlin Latency (The Vanishing Response)
// Deterministic delay: response delays by 5 seconds if orderId ends with 'DELAY' or basically always to force timeout demonstration.
// The requirement says "deterministic pattern". Let's say if quantity is > 5, or just always for now to verify observability.
// Let's make it deterministic based on orderId hash/char.
// If orderId starts with 'GREMLIN', we delay.
// Or simpler: Just delay 3s (Order timeout is 2s).
// But then *all* orders fail.
// Let's only delay if the 'quantity' is 13 (unlucky number).
return { message: 'Stock deducted', success: true };
}

// Deduct Inventory Endpoint
app.post('/inventory/deduct', async (req: Request, res: Response) => {
const { productId, quantity, orderId } = req.body;

if (quantity === 13) {
if (!productId || !quantity || !orderId) {
res.status(400).json({ error: 'Missing productId, quantity, or orderId' });
return;
}

try {
// Gremlin Latency: Simulate "Not Responding" / High Latency
// This will cause the synchronous caller (Order Service) to timeout.
// Gremlin Latency: Simulate "Not Responding" / High Latency
if (req.body.gremlin === true) {
console.log("Gremlin Triggered: Delaying response...");
await new Promise(resolve => setTimeout(resolve, 5000));
}

res.status(200).json({ message: 'Stock deducted', success: true });
const result = await deductInventory(productId, quantity, orderId);
res.status(200).json(result);

} catch (error: any) {
console.error("Inventory Error:", error.message);
Expand All @@ -127,4 +170,5 @@ app.post('/inventory/deduct', async (req: Request, res: Response) => {
app.listen(PORT, async () => {
console.log(`Inventory Service running on port ${PORT}`);
await seedProducts();
await connectToRabbit();
});
3 changes: 2 additions & 1 deletion services/order-service/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
"cors": "^2.8.5",
"axios": "^1.6.7",
"prom-client": "^15.1.0",
"dotenv": "^16.4.5"
"dotenv": "^16.4.5",
"amqplib": "^0.10.3"
},
"devDependencies": {
"typescript": "^5.3.3",
Expand Down
Loading