feat: complete phase one and two of task implementation
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
# Server Configuration
|
||||
PORT=3049
|
||||
NODE_ENV=development
|
||||
|
||||
# Redis Configuration
|
||||
REDIS_URL=redis://localhost:6379
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
|
||||
# Event Configuration
|
||||
DEFAULT_TICKETS_PER_EVENT=10000
|
||||
|
||||
# Logging Configuration
|
||||
LOG_LEVEL=info
|
||||
LOG_FILE=logs/app.log
|
||||
|
||||
# PDF Configuration
|
||||
PDF_OUTPUT_DIR=./tickets
|
||||
|
||||
# Metrics Configuration
|
||||
METRICS_PORT=9090
|
||||
@@ -0,0 +1,21 @@
|
||||
# Server Configuration
|
||||
PORT=3049
|
||||
NODE_ENV=development
|
||||
|
||||
# Redis Configuration
|
||||
REDIS_URL=redis://localhost:6379
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
|
||||
# Event Configuration
|
||||
DEFAULT_TICKETS_PER_EVENT=10000
|
||||
|
||||
# Logging Configuration
|
||||
LOG_LEVEL=info
|
||||
LOG_FILE=logs/app.log
|
||||
|
||||
# PDF Configuration
|
||||
PDF_OUTPUT_DIR=./tickets
|
||||
|
||||
# Metrics Configuration
|
||||
METRICS_PORT=9090
|
||||
+18
-2
@@ -5,13 +5,29 @@
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"seed": "node seed.js"
|
||||
"dev": "nodemon server.js",
|
||||
"seed": "node seed.js",
|
||||
"test": "jest",
|
||||
"test:load": "node tests/load-test.js",
|
||||
"docker:up": "docker-compose up -d",
|
||||
"docker:down": "docker-compose down"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"express": "^4.21.2",
|
||||
"redis": "^4.7.0"
|
||||
"redis": "^4.7.0",
|
||||
"pdfkit": "^0.15.0",
|
||||
"winston": "^3.11.0",
|
||||
"prom-client": "^15.1.0",
|
||||
"uuid": "^9.0.1",
|
||||
"dotenv": "^16.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"jest": "^29.7.0",
|
||||
"supertest": "^6.3.3",
|
||||
"autocannon": "^7.12.0",
|
||||
"nodemon": "^3.0.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,63 @@
|
||||
const redis = require("redis");
|
||||
require('dotenv').config();
|
||||
|
||||
// Number of tickets to seed; default is 100000, or can be passed as a command line argument
|
||||
const numTickets = parseInt(process.argv[2]) || 100000;
|
||||
// Configuration for seeding
|
||||
const config = {
|
||||
numEvents: parseInt(process.argv[2]) || 5, // Number of events to create
|
||||
ticketsPerEvent: parseInt(process.argv[3]) || 10000, // Tickets per event
|
||||
redisUrl: process.env.REDIS_URL || "redis://localhost:6379"
|
||||
};
|
||||
|
||||
// Use environment variable for Redis URL if provided; otherwise default to localhost
|
||||
const redisUrl = process.env.REDIS_URL || "redis://localhost:6379";
|
||||
const client = redis.createClient({ url: redisUrl });
|
||||
const client = redis.createClient({ url: config.redisUrl });
|
||||
|
||||
async function seedTickets() {
|
||||
try {
|
||||
await client.connect();
|
||||
// Clear existing tickets if any
|
||||
await client.del("tickets");
|
||||
console.log(`Seeding ${numTickets} tickets into Redis...`);
|
||||
let tickets = [];
|
||||
for (let i = 1; i <= numTickets; i++) {
|
||||
tickets.push(`ticket-${i}`);
|
||||
console.log(`Seeding ${config.numEvents} events with ${config.ticketsPerEvent} tickets each...`);
|
||||
|
||||
// Clear existing event data
|
||||
const existingKeys = await client.keys('event:*');
|
||||
if (existingKeys.length > 0) {
|
||||
await client.del(existingKeys);
|
||||
console.log(`Cleared ${existingKeys.length} existing event keys.`);
|
||||
}
|
||||
// Push all tickets into the 'tickets' list
|
||||
await client.rPush("tickets", tickets);
|
||||
console.log(`Seeded ${numTickets} tickets.`);
|
||||
|
||||
// Seed multiple events
|
||||
for (let eventId = 1; eventId <= config.numEvents; eventId++) {
|
||||
const eventKey = `event:${eventId}:tickets`;
|
||||
const metaKey = `event:${eventId}:meta`;
|
||||
|
||||
// Generate tickets for this event
|
||||
const tickets = [];
|
||||
for (let i = 1; i <= config.ticketsPerEvent; i++) {
|
||||
tickets.push(`ticket-${eventId}-${i}`);
|
||||
}
|
||||
|
||||
// Store tickets in Redis list
|
||||
await client.rPush(eventKey, tickets);
|
||||
|
||||
// Store event metadata
|
||||
await client.hSet(metaKey, {
|
||||
eventId: eventId,
|
||||
totalTickets: config.ticketsPerEvent,
|
||||
soldTickets: 0,
|
||||
createdAt: new Date().toISOString(),
|
||||
name: `Event ${eventId}`,
|
||||
description: `Sample event ${eventId} for load testing`
|
||||
});
|
||||
|
||||
console.log(`✓ Event ${eventId}: ${config.ticketsPerEvent} tickets seeded`);
|
||||
}
|
||||
|
||||
// Store global stats
|
||||
await client.hSet('global:stats', {
|
||||
totalEvents: config.numEvents,
|
||||
totalTickets: config.numEvents * config.ticketsPerEvent,
|
||||
totalSold: 0,
|
||||
lastSeeded: new Date().toISOString()
|
||||
});
|
||||
|
||||
console.log(`\n🎉 Successfully seeded ${config.numEvents} events with ${config.numEvents * config.ticketsPerEvent} total tickets!`);
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
console.error("Error during seed:", err);
|
||||
|
||||
@@ -1,35 +1,486 @@
|
||||
const express = require("express");
|
||||
const redis = require("redis");
|
||||
const express = require('express');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
require('dotenv').config();
|
||||
|
||||
// Import utilities
|
||||
const redisClient = require('./src/utils/redis-client');
|
||||
const fallbackStore = require('./src/utils/fallback-store');
|
||||
const logger = require('./src/utils/logger');
|
||||
const pdfGenerator = require('./src/utils/pdf-generator');
|
||||
|
||||
const app = express();
|
||||
const port = process.env.PORT || 3049;
|
||||
|
||||
// Middleware
|
||||
app.use(express.json());
|
||||
app.use(express.static('public'));
|
||||
|
||||
// Create Redis client and connect
|
||||
const client = redis.createClient();
|
||||
client.connect().catch(console.error);
|
||||
// Request logging middleware
|
||||
app.use((req, res, next) => {
|
||||
const start = Date.now();
|
||||
res.on('finish', () => {
|
||||
const responseTime = Date.now() - start;
|
||||
logger.logRequest(req, res, responseTime);
|
||||
});
|
||||
next();
|
||||
});
|
||||
|
||||
// Purchase ticket endpoint
|
||||
app.post("/buy", async (req, res) => {
|
||||
// Global error handler
|
||||
app.use((err, req, res, next) => {
|
||||
logger.error('Unhandled error:', err);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Internal Server Error',
|
||||
error: process.env.NODE_ENV === 'development' ? err.message : undefined
|
||||
});
|
||||
});
|
||||
|
||||
// Health check endpoint
|
||||
app.get('/health', async (req, res) => {
|
||||
const redisHealthy = redisClient.isHealthy();
|
||||
const fallbackActive = fallbackStore.isActive;
|
||||
|
||||
res.json({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
redis: {
|
||||
connected: redisHealthy,
|
||||
fallbackActive: fallbackActive
|
||||
},
|
||||
uptime: process.uptime()
|
||||
});
|
||||
});
|
||||
|
||||
// Get all events endpoint
|
||||
app.get('/events', async (req, res) => {
|
||||
try {
|
||||
// Atomically pop a ticket from the 'tickets' list
|
||||
const ticket = await client.lPop("tickets");
|
||||
if (ticket) {
|
||||
res.json({
|
||||
success: true,
|
||||
ticket,
|
||||
message: "Ticket purchased successfully!",
|
||||
});
|
||||
let events;
|
||||
|
||||
if (redisClient.isHealthy()) {
|
||||
events = await redisClient.getAllEvents();
|
||||
} else {
|
||||
res.status(404).json({ success: false, message: "No tickets available" });
|
||||
events = fallbackStore.getAllEvents();
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
events,
|
||||
usingFallback: fallbackStore.isActive
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error during ticket purchase:", error);
|
||||
res.status(500).json({ success: false, message: "Internal Server Error" });
|
||||
logger.error('Error fetching events:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to fetch events'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.listen(port, () => {
|
||||
console.log(`Server is running on port ${port}`);
|
||||
// Get specific event stats
|
||||
app.get('/events/:eventId', async (req, res) => {
|
||||
try {
|
||||
const eventId = req.params.eventId;
|
||||
let eventStats;
|
||||
|
||||
if (redisClient.isHealthy()) {
|
||||
eventStats = await redisClient.getEventStats(eventId);
|
||||
} else {
|
||||
eventStats = fallbackStore.getEventStats(eventId);
|
||||
}
|
||||
|
||||
if (!eventStats) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Event not found'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
event: eventStats,
|
||||
usingFallback: fallbackStore.isActive
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error fetching event stats:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to fetch event stats'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Purchase ticket endpoint (multi-event)
|
||||
app.post('/buy/:eventId', async (req, res) => {
|
||||
const startTime = Date.now();
|
||||
const eventId = req.params.eventId;
|
||||
const purchaseId = uuidv4();
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
try {
|
||||
let result;
|
||||
|
||||
// Try Redis first
|
||||
if (redisClient.isHealthy()) {
|
||||
try {
|
||||
const luaResult = await redisClient.purchaseTicket(eventId, purchaseId, timestamp);
|
||||
|
||||
if (luaResult[0]) {
|
||||
// Success - generate PDF ticket
|
||||
try {
|
||||
// Get event details for PDF
|
||||
const eventStats = await redisClient.getEventStats(eventId);
|
||||
|
||||
const pdfResult = await pdfGenerator.generateTicketPDF({
|
||||
ticketId: luaResult[0],
|
||||
eventId,
|
||||
purchaseId,
|
||||
eventName: eventStats?.name || `Event ${eventId}`,
|
||||
eventDescription: eventStats?.description || 'Event description not available',
|
||||
timestamp,
|
||||
soldCount: luaResult[2]
|
||||
});
|
||||
|
||||
result = {
|
||||
success: true,
|
||||
ticket: luaResult[0],
|
||||
purchaseId,
|
||||
eventId,
|
||||
soldCount: luaResult[2],
|
||||
message: 'Ticket purchased successfully!',
|
||||
usingFallback: false,
|
||||
pdf: {
|
||||
generated: pdfResult.success,
|
||||
filename: pdfResult.filename,
|
||||
downloadUrl: `/tickets/${purchaseId}`
|
||||
}
|
||||
};
|
||||
|
||||
logger.logPurchase(eventId, luaResult[0], purchaseId, true);
|
||||
logger.info(`PDF ticket generated for purchase ${purchaseId}`);
|
||||
|
||||
} catch (pdfError) {
|
||||
logger.error('PDF generation failed:', pdfError);
|
||||
|
||||
// Still return success for ticket purchase, but note PDF failure
|
||||
result = {
|
||||
success: true,
|
||||
ticket: luaResult[0],
|
||||
purchaseId,
|
||||
eventId,
|
||||
soldCount: luaResult[2],
|
||||
message: 'Ticket purchased successfully! (PDF generation failed)',
|
||||
usingFallback: false,
|
||||
pdf: {
|
||||
generated: false,
|
||||
error: 'PDF generation failed'
|
||||
}
|
||||
};
|
||||
|
||||
logger.logPurchase(eventId, luaResult[0], purchaseId, true);
|
||||
}
|
||||
} else {
|
||||
// Failed - handle specific error
|
||||
const errorCode = luaResult[1];
|
||||
let statusCode = 400;
|
||||
let message = 'Purchase failed';
|
||||
|
||||
switch (errorCode) {
|
||||
case 'EVENT_NOT_FOUND':
|
||||
statusCode = 404;
|
||||
message = 'Event not found';
|
||||
break;
|
||||
case 'NO_TICKETS_AVAILABLE':
|
||||
statusCode = 409;
|
||||
message = 'No tickets available for this event';
|
||||
break;
|
||||
}
|
||||
|
||||
logger.logPurchase(eventId, null, purchaseId, false, errorCode);
|
||||
return res.status(statusCode).json({
|
||||
success: false,
|
||||
message,
|
||||
errorCode,
|
||||
eventId,
|
||||
purchaseId
|
||||
});
|
||||
}
|
||||
} catch (redisError) {
|
||||
logger.error('Redis purchase failed, attempting fallback:', redisError);
|
||||
// Activate fallback if not already active
|
||||
if (!fallbackStore.isActive) {
|
||||
fallbackStore.activate('Redis purchase operation failed');
|
||||
}
|
||||
throw redisError; // Will be caught by outer try-catch for fallback
|
||||
}
|
||||
} else {
|
||||
throw new Error('Redis not available');
|
||||
}
|
||||
|
||||
const responseTime = Date.now() - startTime;
|
||||
result.responseTime = `${responseTime}ms`;
|
||||
|
||||
res.json(result);
|
||||
|
||||
} catch (error) {
|
||||
// Fallback to in-memory store
|
||||
try {
|
||||
if (!fallbackStore.isActive) {
|
||||
fallbackStore.activate('Redis connection failed during purchase');
|
||||
}
|
||||
|
||||
const fallbackResult = fallbackStore.purchaseTicket(eventId, purchaseId);
|
||||
|
||||
if (fallbackResult.success) {
|
||||
// Generate PDF for fallback purchase
|
||||
try {
|
||||
const eventStats = fallbackStore.getEventStats(eventId);
|
||||
|
||||
const pdfResult = await pdfGenerator.generateTicketPDF({
|
||||
ticketId: fallbackResult.ticket,
|
||||
eventId,
|
||||
purchaseId,
|
||||
eventName: eventStats?.name || `Event ${eventId}`,
|
||||
eventDescription: eventStats?.description || 'Event description not available',
|
||||
timestamp,
|
||||
soldCount: fallbackResult.soldCount
|
||||
});
|
||||
|
||||
const responseTime = Date.now() - startTime;
|
||||
res.json({
|
||||
success: true,
|
||||
ticket: fallbackResult.ticket,
|
||||
purchaseId,
|
||||
eventId,
|
||||
soldCount: fallbackResult.soldCount,
|
||||
message: 'Ticket purchased successfully (fallback mode)!',
|
||||
usingFallback: true,
|
||||
responseTime: `${responseTime}ms`,
|
||||
pdf: {
|
||||
generated: pdfResult.success,
|
||||
filename: pdfResult.filename,
|
||||
downloadUrl: `/tickets/${purchaseId}`
|
||||
}
|
||||
});
|
||||
|
||||
logger.info(`PDF ticket generated for fallback purchase ${purchaseId}`);
|
||||
|
||||
} catch (pdfError) {
|
||||
logger.error('PDF generation failed in fallback mode:', pdfError);
|
||||
|
||||
const responseTime = Date.now() - startTime;
|
||||
res.json({
|
||||
success: true,
|
||||
ticket: fallbackResult.ticket,
|
||||
purchaseId,
|
||||
eventId,
|
||||
soldCount: fallbackResult.soldCount,
|
||||
message: 'Ticket purchased successfully (fallback mode, PDF generation failed)!',
|
||||
usingFallback: true,
|
||||
responseTime: `${responseTime}ms`,
|
||||
pdf: {
|
||||
generated: false,
|
||||
error: 'PDF generation failed'
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
let statusCode = 400;
|
||||
let message = 'Purchase failed';
|
||||
|
||||
switch (fallbackResult.error) {
|
||||
case 'EVENT_NOT_FOUND':
|
||||
statusCode = 404;
|
||||
message = 'Event not found';
|
||||
break;
|
||||
case 'NO_TICKETS_AVAILABLE':
|
||||
statusCode = 409;
|
||||
message = 'No tickets available for this event';
|
||||
break;
|
||||
}
|
||||
|
||||
logger.logPurchase(eventId, null, purchaseId, false, fallbackResult.error);
|
||||
res.status(statusCode).json({
|
||||
success: false,
|
||||
message,
|
||||
errorCode: fallbackResult.error,
|
||||
eventId,
|
||||
purchaseId,
|
||||
usingFallback: true
|
||||
});
|
||||
}
|
||||
} catch (fallbackError) {
|
||||
logger.error('Both Redis and fallback failed:', fallbackError);
|
||||
logger.logPurchase(eventId, null, purchaseId, false, fallbackError);
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'System temporarily unavailable',
|
||||
eventId,
|
||||
purchaseId
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Download ticket PDF endpoint
|
||||
app.get('/tickets/:purchaseId', async (req, res) => {
|
||||
try {
|
||||
const purchaseId = req.params.purchaseId;
|
||||
|
||||
if (!pdfGenerator.ticketExists(purchaseId)) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Ticket not found'
|
||||
});
|
||||
}
|
||||
|
||||
const filepath = pdfGenerator.getTicketPath(purchaseId);
|
||||
const filename = `ticket-${purchaseId}.pdf`;
|
||||
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
|
||||
const fileStream = require('fs').createReadStream(filepath);
|
||||
fileStream.pipe(res);
|
||||
|
||||
logger.info(`PDF ticket downloaded: ${purchaseId}`);
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Error downloading ticket:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to download ticket'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// PDF management endpoint
|
||||
app.get('/admin/pdf-stats', async (req, res) => {
|
||||
try {
|
||||
const stats = pdfGenerator.getStats();
|
||||
res.json({
|
||||
success: true,
|
||||
stats
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error getting PDF stats:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to get PDF statistics'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Cleanup old tickets endpoint
|
||||
app.post('/admin/cleanup-tickets', async (req, res) => {
|
||||
try {
|
||||
const maxAgeHours = req.body.maxAgeHours || 24;
|
||||
const deletedCount = await pdfGenerator.cleanupOldTickets(maxAgeHours);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Cleaned up ${deletedCount} old tickets`,
|
||||
deletedCount
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error cleaning up tickets:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to cleanup tickets'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Metrics endpoint (Prometheus compatible)
|
||||
app.get('/metrics', async (req, res) => {
|
||||
try {
|
||||
let globalStats, events;
|
||||
|
||||
if (redisClient.isHealthy()) {
|
||||
globalStats = await redisClient.getGlobalStats();
|
||||
events = await redisClient.getAllEvents();
|
||||
} else {
|
||||
globalStats = fallbackStore.getGlobalStats();
|
||||
events = fallbackStore.getAllEvents();
|
||||
}
|
||||
|
||||
// Get PDF stats
|
||||
const pdfStats = pdfGenerator.getStats();
|
||||
|
||||
// Calculate metrics
|
||||
const metrics = {
|
||||
timestamp: new Date().toISOString(),
|
||||
global: globalStats,
|
||||
events: events,
|
||||
system: {
|
||||
usingFallback: fallbackStore.isActive,
|
||||
redisConnected: redisClient.isHealthy(),
|
||||
uptime: process.uptime(),
|
||||
memoryUsage: process.memoryUsage()
|
||||
},
|
||||
pdf: pdfStats
|
||||
};
|
||||
|
||||
res.json(metrics);
|
||||
} catch (error) {
|
||||
logger.error('Error generating metrics:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to generate metrics'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize server
|
||||
async function initializeServer() {
|
||||
try {
|
||||
// Connect to Redis
|
||||
await redisClient.connect();
|
||||
logger.info('Redis connected successfully');
|
||||
|
||||
// Start server
|
||||
app.listen(port, () => {
|
||||
logger.info(`🚀 Ticket Microservice running on port ${port}`);
|
||||
logger.info(`📊 Health check: http://localhost:${port}/health`);
|
||||
logger.info(`📈 Metrics: http://localhost:${port}/metrics`);
|
||||
logger.info(`🎫 Events: http://localhost:${port}/events`);
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Failed to initialize server:', error);
|
||||
logger.warn('Starting server with fallback store only');
|
||||
|
||||
fallbackStore.activate('Redis connection failed at startup');
|
||||
|
||||
app.listen(port, () => {
|
||||
logger.warn(`⚠️ Server running in FALLBACK MODE on port ${port}`);
|
||||
logger.warn('Redis connection failed - using in-memory store');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Graceful shutdown
|
||||
process.on('SIGINT', async () => {
|
||||
logger.info('Received SIGINT, shutting down gracefully...');
|
||||
try {
|
||||
await redisClient.disconnect();
|
||||
logger.info('Redis disconnected');
|
||||
} catch (error) {
|
||||
logger.error('Error disconnecting Redis:', error);
|
||||
}
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGTERM', async () => {
|
||||
logger.info('Received SIGTERM, shutting down gracefully...');
|
||||
try {
|
||||
await redisClient.disconnect();
|
||||
logger.info('Redis disconnected');
|
||||
} catch (error) {
|
||||
logger.error('Error disconnecting Redis:', error);
|
||||
}
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// Start the server
|
||||
initializeServer();
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
-- Get event statistics script
|
||||
-- KEYS[1]: event metadata key (e.g., "event:1:meta")
|
||||
-- KEYS[2]: event tickets key (e.g., "event:1:tickets")
|
||||
|
||||
local metaKey = KEYS[1]
|
||||
local ticketKey = KEYS[2]
|
||||
|
||||
-- Check if event exists
|
||||
if redis.call('EXISTS', metaKey) == 0 then
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Get event metadata
|
||||
local eventData = redis.call('HGETALL', metaKey)
|
||||
local eventInfo = {}
|
||||
for i = 1, #eventData, 2 do
|
||||
eventInfo[eventData[i]] = eventData[i + 1]
|
||||
end
|
||||
|
||||
-- Get remaining tickets count
|
||||
local remainingTickets = redis.call('LLEN', ticketKey)
|
||||
|
||||
-- Return combined stats
|
||||
return {
|
||||
eventInfo.eventId,
|
||||
eventInfo.name,
|
||||
eventInfo.description,
|
||||
eventInfo.totalTickets,
|
||||
eventInfo.soldTickets,
|
||||
remainingTickets,
|
||||
eventInfo.createdAt,
|
||||
eventInfo.lastSoldAt or "never"
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
-- Atomic ticket purchase script
|
||||
-- KEYS[1]: event tickets key (e.g., "event:1:tickets")
|
||||
-- KEYS[2]: event metadata key (e.g., "event:1:meta")
|
||||
-- KEYS[3]: global stats key ("global:stats")
|
||||
-- ARGV[1]: timestamp
|
||||
-- ARGV[2]: purchase ID (UUID)
|
||||
|
||||
local ticketKey = KEYS[1]
|
||||
local metaKey = KEYS[2]
|
||||
local globalKey = KEYS[3]
|
||||
local timestamp = ARGV[1]
|
||||
local purchaseId = ARGV[2]
|
||||
|
||||
-- Check if event exists
|
||||
if redis.call('EXISTS', metaKey) == 0 then
|
||||
return {nil, "EVENT_NOT_FOUND"}
|
||||
end
|
||||
|
||||
-- Atomically pop a ticket from the list
|
||||
local ticket = redis.call('LPOP', ticketKey)
|
||||
|
||||
if not ticket then
|
||||
return {nil, "NO_TICKETS_AVAILABLE"}
|
||||
end
|
||||
|
||||
-- Update event metadata
|
||||
local soldCount = redis.call('HINCRBY', metaKey, 'soldTickets', 1)
|
||||
redis.call('HSET', metaKey, 'lastSoldAt', timestamp)
|
||||
|
||||
-- Update global stats
|
||||
redis.call('HINCRBY', globalKey, 'totalSold', 1)
|
||||
|
||||
-- Store purchase record
|
||||
local purchaseKey = 'purchase:' .. purchaseId
|
||||
redis.call('HSET', purchaseKey, {
|
||||
'ticketId', ticket,
|
||||
'eventId', string.match(ticketKey, 'event:(%d+):tickets'),
|
||||
'purchaseId', purchaseId,
|
||||
'timestamp', timestamp,
|
||||
'status', 'completed'
|
||||
})
|
||||
|
||||
-- Set expiration for purchase record (24 hours)
|
||||
redis.call('EXPIRE', purchaseKey, 86400)
|
||||
|
||||
return {ticket, "SUCCESS", soldCount}
|
||||
@@ -0,0 +1,131 @@
|
||||
const logger = require('./logger');
|
||||
|
||||
class FallbackStore {
|
||||
constructor() {
|
||||
this.events = new Map();
|
||||
this.globalStats = {
|
||||
totalEvents: 0,
|
||||
totalTickets: 0,
|
||||
totalSold: 0,
|
||||
lastSeeded: null
|
||||
};
|
||||
this.isActive = false;
|
||||
}
|
||||
|
||||
activate(reason) {
|
||||
this.isActive = true;
|
||||
logger.logFallback('In-Memory Store Activated', reason);
|
||||
logger.warn('⚠️ FALLBACK MODE: Using in-memory store - data will not persist!');
|
||||
}
|
||||
|
||||
deactivate() {
|
||||
this.isActive = false;
|
||||
logger.info('In-Memory Store Deactivated - Redis connection restored');
|
||||
}
|
||||
|
||||
seedEvent(eventId, tickets, metadata) {
|
||||
if (!this.isActive) return false;
|
||||
|
||||
this.events.set(eventId, {
|
||||
tickets: [...tickets], // Create a copy
|
||||
metadata: { ...metadata },
|
||||
soldTickets: 0
|
||||
});
|
||||
|
||||
this.globalStats.totalEvents++;
|
||||
this.globalStats.totalTickets += tickets.length;
|
||||
|
||||
logger.info(`Fallback: Seeded event ${eventId} with ${tickets.length} tickets`);
|
||||
return true;
|
||||
}
|
||||
|
||||
purchaseTicket(eventId, purchaseId) {
|
||||
if (!this.isActive) return null;
|
||||
|
||||
const event = this.events.get(eventId);
|
||||
if (!event) {
|
||||
return { success: false, error: 'EVENT_NOT_FOUND' };
|
||||
}
|
||||
|
||||
if (event.tickets.length === 0) {
|
||||
return { success: false, error: 'NO_TICKETS_AVAILABLE' };
|
||||
}
|
||||
|
||||
// Atomically remove a ticket
|
||||
const ticket = event.tickets.shift();
|
||||
event.soldTickets++;
|
||||
event.metadata.lastSoldAt = new Date().toISOString();
|
||||
|
||||
this.globalStats.totalSold++;
|
||||
|
||||
logger.logPurchase(eventId, ticket, purchaseId, true);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
ticket,
|
||||
soldCount: event.soldTickets
|
||||
};
|
||||
}
|
||||
|
||||
getEventStats(eventId) {
|
||||
if (!this.isActive) return null;
|
||||
|
||||
const event = this.events.get(eventId);
|
||||
if (!event) return null;
|
||||
|
||||
return {
|
||||
eventId: eventId,
|
||||
name: event.metadata.name,
|
||||
description: event.metadata.description,
|
||||
totalTickets: parseInt(event.metadata.totalTickets),
|
||||
soldTickets: event.soldTickets,
|
||||
remainingTickets: event.tickets.length,
|
||||
createdAt: event.metadata.createdAt,
|
||||
lastSoldAt: event.metadata.lastSoldAt || 'never'
|
||||
};
|
||||
}
|
||||
|
||||
getAllEvents() {
|
||||
if (!this.isActive) return [];
|
||||
|
||||
const events = [];
|
||||
for (const [eventId, _] of this.events) {
|
||||
const stats = this.getEventStats(eventId);
|
||||
if (stats) {
|
||||
events.push(stats);
|
||||
}
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
getGlobalStats() {
|
||||
if (!this.isActive) return null;
|
||||
return { ...this.globalStats };
|
||||
}
|
||||
|
||||
isHealthy() {
|
||||
return this.isActive;
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.events.clear();
|
||||
this.globalStats = {
|
||||
totalEvents: 0,
|
||||
totalTickets: 0,
|
||||
totalSold: 0,
|
||||
lastSeeded: null
|
||||
};
|
||||
logger.info('Fallback store cleared');
|
||||
}
|
||||
|
||||
getStatus() {
|
||||
return {
|
||||
active: this.isActive,
|
||||
eventsCount: this.events.size,
|
||||
totalTickets: this.globalStats.totalTickets,
|
||||
totalSold: this.globalStats.totalSold
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new FallbackStore();
|
||||
@@ -0,0 +1,105 @@
|
||||
const winston = require('winston');
|
||||
const path = require('path');
|
||||
|
||||
// Create logs directory if it doesn't exist
|
||||
const fs = require('fs');
|
||||
const logsDir = path.join(process.cwd(), 'logs');
|
||||
if (!fs.existsSync(logsDir)) {
|
||||
fs.mkdirSync(logsDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Custom format for console output
|
||||
const consoleFormat = winston.format.combine(
|
||||
winston.format.colorize(),
|
||||
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
|
||||
winston.format.printf(({ timestamp, level, message, ...meta }) => {
|
||||
let metaStr = Object.keys(meta).length ? JSON.stringify(meta, null, 2) : '';
|
||||
return `${timestamp} [${level}]: ${message} ${metaStr}`;
|
||||
})
|
||||
);
|
||||
|
||||
// Custom format for file output
|
||||
const fileFormat = winston.format.combine(
|
||||
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
|
||||
winston.format.errors({ stack: true }),
|
||||
winston.format.json()
|
||||
);
|
||||
|
||||
// Create logger instance
|
||||
const logger = winston.createLogger({
|
||||
level: process.env.LOG_LEVEL || 'info',
|
||||
defaultMeta: { service: 'ticket-microservice' },
|
||||
transports: [
|
||||
// Console transport
|
||||
new winston.transports.Console({
|
||||
format: consoleFormat
|
||||
}),
|
||||
|
||||
// File transport for all logs
|
||||
new winston.transports.File({
|
||||
filename: path.join(logsDir, 'app.log'),
|
||||
format: fileFormat,
|
||||
maxsize: 5242880, // 5MB
|
||||
maxFiles: 5
|
||||
}),
|
||||
|
||||
// File transport for errors only
|
||||
new winston.transports.File({
|
||||
filename: path.join(logsDir, 'error.log'),
|
||||
level: 'error',
|
||||
format: fileFormat,
|
||||
maxsize: 5242880, // 5MB
|
||||
maxFiles: 5
|
||||
})
|
||||
]
|
||||
});
|
||||
|
||||
// Add request logging helper
|
||||
logger.logRequest = (req, res, responseTime) => {
|
||||
const logData = {
|
||||
method: req.method,
|
||||
url: req.url,
|
||||
statusCode: res.statusCode,
|
||||
responseTime: `${responseTime}ms`,
|
||||
userAgent: req.get('User-Agent'),
|
||||
ip: req.ip || req.connection.remoteAddress
|
||||
};
|
||||
|
||||
if (res.statusCode >= 400) {
|
||||
logger.warn('HTTP Request', logData);
|
||||
} else {
|
||||
logger.info('HTTP Request', logData);
|
||||
}
|
||||
};
|
||||
|
||||
// Add purchase logging helper
|
||||
logger.logPurchase = (eventId, ticketId, purchaseId, success, error = null) => {
|
||||
const logData = {
|
||||
eventId,
|
||||
ticketId,
|
||||
purchaseId,
|
||||
success,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
if (error) {
|
||||
logData.error = error.message || error;
|
||||
}
|
||||
|
||||
if (success) {
|
||||
logger.info('Ticket Purchase', logData);
|
||||
} else {
|
||||
logger.error('Ticket Purchase Failed', logData);
|
||||
}
|
||||
};
|
||||
|
||||
// Add fallback logging helper
|
||||
logger.logFallback = (operation, reason) => {
|
||||
logger.warn('Fallback Activated', {
|
||||
operation,
|
||||
reason,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = logger;
|
||||
@@ -0,0 +1,247 @@
|
||||
const PDFDocument = require('pdfkit');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const logger = require('./logger');
|
||||
|
||||
class PDFGenerator {
|
||||
constructor() {
|
||||
this.outputDir = process.env.PDF_OUTPUT_DIR || './tickets';
|
||||
this.ensureOutputDirectory();
|
||||
}
|
||||
|
||||
ensureOutputDirectory() {
|
||||
if (!fs.existsSync(this.outputDir)) {
|
||||
fs.mkdirSync(this.outputDir, { recursive: true });
|
||||
logger.info(`Created PDF output directory: ${this.outputDir}`);
|
||||
}
|
||||
}
|
||||
|
||||
async generateTicketPDF(ticketData) {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const {
|
||||
ticketId,
|
||||
eventId,
|
||||
purchaseId,
|
||||
eventName,
|
||||
eventDescription,
|
||||
timestamp,
|
||||
soldCount
|
||||
} = ticketData;
|
||||
|
||||
// Create PDF document
|
||||
const doc = new PDFDocument({
|
||||
size: 'A4',
|
||||
margin: 50
|
||||
});
|
||||
|
||||
// Generate filename
|
||||
const filename = `ticket-${purchaseId}.pdf`;
|
||||
const filepath = path.join(this.outputDir, filename);
|
||||
|
||||
// Pipe to file
|
||||
const stream = fs.createWriteStream(filepath);
|
||||
doc.pipe(stream);
|
||||
|
||||
// Header
|
||||
doc.fontSize(24)
|
||||
.fillColor('#2c3e50')
|
||||
.text('🎫 TICKET RECEIPT', 50, 50, { align: 'center' });
|
||||
|
||||
// Divider line
|
||||
doc.moveTo(50, 90)
|
||||
.lineTo(545, 90)
|
||||
.strokeColor('#3498db')
|
||||
.lineWidth(2)
|
||||
.stroke();
|
||||
|
||||
// Event Information
|
||||
doc.fontSize(18)
|
||||
.fillColor('#2c3e50')
|
||||
.text('Event Information', 50, 120);
|
||||
|
||||
doc.fontSize(12)
|
||||
.fillColor('#34495e')
|
||||
.text(`Event Name: ${eventName || `Event ${eventId}`}`, 50, 150)
|
||||
.text(`Event ID: ${eventId}`, 50, 170)
|
||||
.text(`Description: ${eventDescription || 'No description available'}`, 50, 190);
|
||||
|
||||
// Ticket Information
|
||||
doc.fontSize(18)
|
||||
.fillColor('#2c3e50')
|
||||
.text('Ticket Details', 50, 230);
|
||||
|
||||
doc.fontSize(12)
|
||||
.fillColor('#34495e')
|
||||
.text(`Ticket ID: ${ticketId}`, 50, 260)
|
||||
.text(`Purchase ID: ${purchaseId}`, 50, 280)
|
||||
.text(`Purchase Date: ${new Date(timestamp).toLocaleString()}`, 50, 300)
|
||||
.text(`Ticket Number: #${soldCount}`, 50, 320);
|
||||
|
||||
// QR Code placeholder (you could integrate a QR code library here)
|
||||
doc.rect(400, 250, 100, 100)
|
||||
.strokeColor('#bdc3c7')
|
||||
.lineWidth(1)
|
||||
.stroke();
|
||||
|
||||
doc.fontSize(10)
|
||||
.fillColor('#7f8c8d')
|
||||
.text('QR Code', 430, 305, { align: 'center' });
|
||||
|
||||
// Terms and Conditions
|
||||
doc.fontSize(14)
|
||||
.fillColor('#2c3e50')
|
||||
.text('Terms & Conditions', 50, 380);
|
||||
|
||||
doc.fontSize(10)
|
||||
.fillColor('#7f8c8d')
|
||||
.text('• This ticket is non-refundable and non-transferable', 50, 410)
|
||||
.text('• Please arrive 30 minutes before the event starts', 50, 425)
|
||||
.text('• Valid photo ID required for entry', 50, 440)
|
||||
.text('• This ticket is valid only for the specified event and date', 50, 455);
|
||||
|
||||
// Footer
|
||||
doc.fontSize(8)
|
||||
.fillColor('#95a5a6')
|
||||
.text(`Generated on ${new Date().toLocaleString()}`, 50, 520)
|
||||
.text('Powered by Ticket Microservice', 50, 535)
|
||||
.text(`System ID: ${process.env.NODE_ENV || 'development'}`, 50, 550);
|
||||
|
||||
// Security watermark
|
||||
doc.fontSize(60)
|
||||
.fillColor('#ecf0f1')
|
||||
.text('VALID', 200, 300, {
|
||||
rotate: -45,
|
||||
opacity: 0.1
|
||||
});
|
||||
|
||||
// Finalize PDF
|
||||
doc.end();
|
||||
|
||||
stream.on('finish', () => {
|
||||
logger.info(`PDF ticket generated: ${filename}`);
|
||||
resolve({
|
||||
success: true,
|
||||
filename,
|
||||
filepath,
|
||||
size: fs.statSync(filepath).size
|
||||
});
|
||||
});
|
||||
|
||||
stream.on('error', (error) => {
|
||||
logger.error('Error writing PDF file:', error);
|
||||
reject(error);
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Error generating PDF:', error);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async generateBulkTicketsPDF(tickets) {
|
||||
const results = [];
|
||||
|
||||
for (const ticket of tickets) {
|
||||
try {
|
||||
const result = await this.generateTicketPDF(ticket);
|
||||
results.push(result);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to generate PDF for ticket ${ticket.ticketId}:`, error);
|
||||
results.push({
|
||||
success: false,
|
||||
ticketId: ticket.ticketId,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
getTicketPath(purchaseId) {
|
||||
return path.join(this.outputDir, `ticket-${purchaseId}.pdf`);
|
||||
}
|
||||
|
||||
ticketExists(purchaseId) {
|
||||
const filepath = this.getTicketPath(purchaseId);
|
||||
return fs.existsSync(filepath);
|
||||
}
|
||||
|
||||
async deleteTicket(purchaseId) {
|
||||
try {
|
||||
const filepath = this.getTicketPath(purchaseId);
|
||||
if (fs.existsSync(filepath)) {
|
||||
fs.unlinkSync(filepath);
|
||||
logger.info(`Deleted ticket PDF: ${purchaseId}`);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (error) {
|
||||
logger.error(`Error deleting ticket PDF ${purchaseId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async cleanupOldTickets(maxAgeHours = 24) {
|
||||
try {
|
||||
const files = fs.readdirSync(this.outputDir);
|
||||
const now = Date.now();
|
||||
let deletedCount = 0;
|
||||
|
||||
for (const file of files) {
|
||||
if (file.endsWith('.pdf')) {
|
||||
const filepath = path.join(this.outputDir, file);
|
||||
const stats = fs.statSync(filepath);
|
||||
const ageHours = (now - stats.mtime.getTime()) / (1000 * 60 * 60);
|
||||
|
||||
if (ageHours > maxAgeHours) {
|
||||
fs.unlinkSync(filepath);
|
||||
deletedCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (deletedCount > 0) {
|
||||
logger.info(`Cleaned up ${deletedCount} old ticket PDFs`);
|
||||
}
|
||||
|
||||
return deletedCount;
|
||||
} catch (error) {
|
||||
logger.error('Error cleaning up old tickets:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
getStats() {
|
||||
try {
|
||||
const files = fs.readdirSync(this.outputDir);
|
||||
const pdfFiles = files.filter(f => f.endsWith('.pdf'));
|
||||
|
||||
let totalSize = 0;
|
||||
for (const file of pdfFiles) {
|
||||
const filepath = path.join(this.outputDir, file);
|
||||
totalSize += fs.statSync(filepath).size;
|
||||
}
|
||||
|
||||
return {
|
||||
totalTickets: pdfFiles.length,
|
||||
totalSizeBytes: totalSize,
|
||||
totalSizeMB: (totalSize / (1024 * 1024)).toFixed(2),
|
||||
outputDirectory: this.outputDir
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Error getting PDF stats:', error);
|
||||
return {
|
||||
totalTickets: 0,
|
||||
totalSizeBytes: 0,
|
||||
totalSizeMB: '0.00',
|
||||
outputDirectory: this.outputDir,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new PDFGenerator();
|
||||
@@ -0,0 +1,185 @@
|
||||
const redis = require('redis');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const logger = require('./logger');
|
||||
|
||||
class RedisClient {
|
||||
constructor() {
|
||||
this.client = null;
|
||||
this.isConnected = false;
|
||||
this.luaScripts = {};
|
||||
this.loadLuaScripts();
|
||||
}
|
||||
|
||||
async connect() {
|
||||
try {
|
||||
const redisUrl = process.env.REDIS_URL || 'redis://localhost:6379';
|
||||
this.client = redis.createClient({ url: redisUrl });
|
||||
|
||||
this.client.on('error', (err) => {
|
||||
logger.error('Redis Client Error:', err);
|
||||
this.isConnected = false;
|
||||
});
|
||||
|
||||
this.client.on('connect', () => {
|
||||
logger.info('Redis client connected');
|
||||
this.isConnected = true;
|
||||
});
|
||||
|
||||
this.client.on('disconnect', () => {
|
||||
logger.warn('Redis client disconnected');
|
||||
this.isConnected = false;
|
||||
});
|
||||
|
||||
await this.client.connect();
|
||||
return this.client;
|
||||
} catch (error) {
|
||||
logger.error('Failed to connect to Redis:', error);
|
||||
this.isConnected = false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
loadLuaScripts() {
|
||||
try {
|
||||
const luaDir = path.join(__dirname, '../lua');
|
||||
|
||||
// Load purchase ticket script
|
||||
const purchaseScript = fs.readFileSync(
|
||||
path.join(luaDir, 'purchase-ticket.lua'),
|
||||
'utf8'
|
||||
);
|
||||
this.luaScripts.purchaseTicket = purchaseScript;
|
||||
|
||||
// Load event stats script
|
||||
const statsScript = fs.readFileSync(
|
||||
path.join(luaDir, 'get-event-stats.lua'),
|
||||
'utf8'
|
||||
);
|
||||
this.luaScripts.getEventStats = statsScript;
|
||||
|
||||
logger.info('Lua scripts loaded successfully');
|
||||
} catch (error) {
|
||||
logger.error('Failed to load Lua scripts:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async purchaseTicket(eventId, purchaseId, timestamp) {
|
||||
if (!this.isConnected) {
|
||||
throw new Error('Redis not connected');
|
||||
}
|
||||
|
||||
const keys = [
|
||||
`event:${eventId}:tickets`,
|
||||
`event:${eventId}:meta`,
|
||||
'global:stats'
|
||||
];
|
||||
const args = [timestamp, purchaseId];
|
||||
|
||||
try {
|
||||
const result = await this.client.eval(
|
||||
this.luaScripts.purchaseTicket,
|
||||
{ keys, arguments: args }
|
||||
);
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error('Error executing purchase ticket script:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getEventStats(eventId) {
|
||||
if (!this.isConnected) {
|
||||
throw new Error('Redis not connected');
|
||||
}
|
||||
|
||||
const keys = [
|
||||
`event:${eventId}:meta`,
|
||||
`event:${eventId}:tickets`
|
||||
];
|
||||
|
||||
try {
|
||||
const result = await this.client.eval(
|
||||
this.luaScripts.getEventStats,
|
||||
{ keys }
|
||||
);
|
||||
|
||||
if (!result) return null;
|
||||
|
||||
return {
|
||||
eventId: result[0],
|
||||
name: result[1],
|
||||
description: result[2],
|
||||
totalTickets: parseInt(result[3]),
|
||||
soldTickets: parseInt(result[4]),
|
||||
remainingTickets: parseInt(result[5]),
|
||||
createdAt: result[6],
|
||||
lastSoldAt: result[7]
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Error executing get event stats script:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getAllEvents() {
|
||||
if (!this.isConnected) {
|
||||
throw new Error('Redis not connected');
|
||||
}
|
||||
|
||||
try {
|
||||
const eventKeys = await this.client.keys('event:*:meta');
|
||||
const events = [];
|
||||
|
||||
for (const key of eventKeys) {
|
||||
const eventId = key.match(/event:(\d+):meta/)[1];
|
||||
const stats = await this.getEventStats(eventId);
|
||||
if (stats) {
|
||||
events.push(stats);
|
||||
}
|
||||
}
|
||||
|
||||
return events;
|
||||
} catch (error) {
|
||||
logger.error('Error getting all events:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getGlobalStats() {
|
||||
if (!this.isConnected) {
|
||||
throw new Error('Redis not connected');
|
||||
}
|
||||
|
||||
try {
|
||||
const stats = await this.client.hGetAll('global:stats');
|
||||
return {
|
||||
totalEvents: parseInt(stats.totalEvents) || 0,
|
||||
totalTickets: parseInt(stats.totalTickets) || 0,
|
||||
totalSold: parseInt(stats.totalSold) || 0,
|
||||
lastSeeded: stats.lastSeeded || null
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Error getting global stats:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
getClient() {
|
||||
return this.client;
|
||||
}
|
||||
|
||||
isHealthy() {
|
||||
return this.isConnected && this.client && this.client.isReady;
|
||||
}
|
||||
|
||||
async disconnect() {
|
||||
if (this.client) {
|
||||
await this.client.disconnect();
|
||||
this.isConnected = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new RedisClient();
|
||||
@@ -0,0 +1,255 @@
|
||||
const autocannon = require('autocannon');
|
||||
const { performance } = require('perf_hooks');
|
||||
|
||||
class LoadTester {
|
||||
constructor() {
|
||||
this.baseUrl = process.env.TEST_URL || 'http://localhost:3049';
|
||||
this.results = [];
|
||||
}
|
||||
|
||||
async runPurchaseLoadTest(eventId = 1, options = {}) {
|
||||
const defaultOptions = {
|
||||
url: `${this.baseUrl}/buy/${eventId}`,
|
||||
connections: 5000,
|
||||
duration: 30,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
...options
|
||||
};
|
||||
|
||||
console.log(`\n🚀 Starting load test for Event ${eventId}`);
|
||||
console.log(`📊 Connections: ${defaultOptions.connections}`);
|
||||
console.log(`⏱️ Duration: ${defaultOptions.duration}s`);
|
||||
console.log(`🎯 Target: ${defaultOptions.url}\n`);
|
||||
|
||||
const startTime = performance.now();
|
||||
|
||||
try {
|
||||
const result = await autocannon(defaultOptions);
|
||||
const endTime = performance.now();
|
||||
|
||||
const testResult = {
|
||||
eventId,
|
||||
timestamp: new Date().toISOString(),
|
||||
duration: endTime - startTime,
|
||||
...result
|
||||
};
|
||||
|
||||
this.results.push(testResult);
|
||||
this.printResults(testResult);
|
||||
|
||||
return testResult;
|
||||
} catch (error) {
|
||||
console.error('❌ Load test failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async runMultiEventLoadTest(eventIds = [1, 2, 3], options = {}) {
|
||||
console.log(`\n🎯 Running multi-event load test for events: ${eventIds.join(', ')}`);
|
||||
|
||||
const promises = eventIds.map(eventId =>
|
||||
this.runPurchaseLoadTest(eventId, {
|
||||
connections: Math.floor((options.connections || 5000) / eventIds.length),
|
||||
duration: options.duration || 30
|
||||
})
|
||||
);
|
||||
|
||||
try {
|
||||
const results = await Promise.all(promises);
|
||||
this.printSummary(results);
|
||||
return results;
|
||||
} catch (error) {
|
||||
console.error('❌ Multi-event load test failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async runHealthCheckTest(options = {}) {
|
||||
const defaultOptions = {
|
||||
url: `${this.baseUrl}/health`,
|
||||
connections: 100,
|
||||
duration: 10,
|
||||
...options
|
||||
};
|
||||
|
||||
console.log('\n🏥 Running health check load test...');
|
||||
|
||||
try {
|
||||
const result = await autocannon(defaultOptions);
|
||||
console.log(`✅ Health check test completed`);
|
||||
console.log(`📈 RPS: ${result.requests.average}`);
|
||||
console.log(`⚡ Latency: ${result.latency.average}ms`);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('❌ Health check test failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async runMetricsTest(options = {}) {
|
||||
const defaultOptions = {
|
||||
url: `${this.baseUrl}/metrics`,
|
||||
connections: 50,
|
||||
duration: 10,
|
||||
...options
|
||||
};
|
||||
|
||||
console.log('\n📊 Running metrics endpoint test...');
|
||||
|
||||
try {
|
||||
const result = await autocannon(defaultOptions);
|
||||
console.log(`✅ Metrics test completed`);
|
||||
console.log(`📈 RPS: ${result.requests.average}`);
|
||||
console.log(`⚡ Latency: ${result.latency.average}ms`);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('❌ Metrics test failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
printResults(result) {
|
||||
console.log('📋 LOAD TEST RESULTS');
|
||||
console.log('═'.repeat(50));
|
||||
console.log(`🎯 Event ID: ${result.eventId}`);
|
||||
console.log(`⏱️ Duration: ${(result.duration / 1000).toFixed(2)}s`);
|
||||
console.log(`📊 Total Requests: ${result.requests.total}`);
|
||||
console.log(`📈 Requests/sec: ${result.requests.average.toFixed(2)}`);
|
||||
console.log(`⚡ Avg Latency: ${result.latency.average.toFixed(2)}ms`);
|
||||
console.log(`🔥 Max Latency: ${result.latency.max}ms`);
|
||||
console.log(`✅ Success Rate: ${((result.requests.total - result.non2xx) / result.requests.total * 100).toFixed(2)}%`);
|
||||
console.log(`❌ Errors: ${result.non2xx}`);
|
||||
console.log(`🔗 Connections: ${result.connections}`);
|
||||
console.log(`📦 Throughput: ${(result.throughput.average / 1024 / 1024).toFixed(2)} MB/s`);
|
||||
console.log('═'.repeat(50));
|
||||
}
|
||||
|
||||
printSummary(results) {
|
||||
console.log('\n📊 MULTI-EVENT TEST SUMMARY');
|
||||
console.log('═'.repeat(60));
|
||||
|
||||
const totalRequests = results.reduce((sum, r) => sum + r.requests.total, 0);
|
||||
const avgRPS = results.reduce((sum, r) => sum + r.requests.average, 0);
|
||||
const avgLatency = results.reduce((sum, r) => sum + r.latency.average, 0) / results.length;
|
||||
const totalErrors = results.reduce((sum, r) => sum + r.non2xx, 0);
|
||||
|
||||
console.log(`🎯 Events Tested: ${results.length}`);
|
||||
console.log(`📊 Total Requests: ${totalRequests}`);
|
||||
console.log(`📈 Combined RPS: ${avgRPS.toFixed(2)}`);
|
||||
console.log(`⚡ Avg Latency: ${avgLatency.toFixed(2)}ms`);
|
||||
console.log(`✅ Overall Success Rate: ${((totalRequests - totalErrors) / totalRequests * 100).toFixed(2)}%`);
|
||||
console.log(`❌ Total Errors: ${totalErrors}`);
|
||||
console.log('═'.repeat(60));
|
||||
|
||||
// Individual event breakdown
|
||||
results.forEach((result, index) => {
|
||||
console.log(`\n📋 Event ${result.eventId}:`);
|
||||
console.log(` 📈 RPS: ${result.requests.average.toFixed(2)}`);
|
||||
console.log(` ⚡ Latency: ${result.latency.average.toFixed(2)}ms`);
|
||||
console.log(` ✅ Success: ${((result.requests.total - result.non2xx) / result.requests.total * 100).toFixed(2)}%`);
|
||||
});
|
||||
}
|
||||
|
||||
async runFullTestSuite() {
|
||||
console.log('🚀 STARTING FULL LOAD TEST SUITE');
|
||||
console.log('═'.repeat(60));
|
||||
|
||||
try {
|
||||
// 1. Health check test
|
||||
await this.runHealthCheckTest();
|
||||
|
||||
// 2. Metrics test
|
||||
await this.runMetricsTest();
|
||||
|
||||
// 3. Single event high-load test
|
||||
await this.runPurchaseLoadTest(1, {
|
||||
connections: 5000,
|
||||
duration: 30
|
||||
});
|
||||
|
||||
// 4. Multi-event test
|
||||
await this.runMultiEventLoadTest([1, 2, 3], {
|
||||
connections: 6000,
|
||||
duration: 30
|
||||
});
|
||||
|
||||
console.log('\n🎉 FULL TEST SUITE COMPLETED!');
|
||||
console.log(`📊 Total tests run: ${this.results.length + 2}`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Test suite failed:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
getResults() {
|
||||
return this.results;
|
||||
}
|
||||
|
||||
exportResults(filename = 'load-test-results.json') {
|
||||
const fs = require('fs');
|
||||
const data = {
|
||||
timestamp: new Date().toISOString(),
|
||||
testSuite: 'Ticket Microservice Load Test',
|
||||
results: this.results
|
||||
};
|
||||
|
||||
fs.writeFileSync(filename, JSON.stringify(data, null, 2));
|
||||
console.log(`📄 Results exported to: ${filename}`);
|
||||
}
|
||||
}
|
||||
|
||||
// CLI execution
|
||||
if (require.main === module) {
|
||||
const args = process.argv.slice(2);
|
||||
const tester = new LoadTester();
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
if (args.includes('--full')) {
|
||||
await tester.runFullTestSuite();
|
||||
} else if (args.includes('--event')) {
|
||||
const eventId = parseInt(args[args.indexOf('--event') + 1]) || 1;
|
||||
const connections = parseInt(args[args.indexOf('--connections') + 1]) || 5000;
|
||||
const duration = parseInt(args[args.indexOf('--duration') + 1]) || 30;
|
||||
|
||||
await tester.runPurchaseLoadTest(eventId, { connections, duration });
|
||||
} else if (args.includes('--multi')) {
|
||||
const eventIds = args.includes('--events')
|
||||
? args[args.indexOf('--events') + 1].split(',').map(Number)
|
||||
: [1, 2, 3];
|
||||
const connections = parseInt(args[args.indexOf('--connections') + 1]) || 6000;
|
||||
const duration = parseInt(args[args.indexOf('--duration') + 1]) || 30;
|
||||
|
||||
await tester.runMultiEventLoadTest(eventIds, { connections, duration });
|
||||
} else {
|
||||
console.log('🎯 Ticket Microservice Load Tester');
|
||||
console.log('Usage:');
|
||||
console.log(' node tests/load-test.js --full # Run full test suite');
|
||||
console.log(' node tests/load-test.js --event 1 --connections 5000 --duration 30');
|
||||
console.log(' node tests/load-test.js --multi --events 1,2,3 --connections 6000');
|
||||
console.log('');
|
||||
console.log('Running default single event test...');
|
||||
await tester.runPurchaseLoadTest(1);
|
||||
}
|
||||
|
||||
if (args.includes('--export')) {
|
||||
tester.exportResults();
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Test execution failed:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
}
|
||||
|
||||
module.exports = LoadTester;
|
||||
Reference in New Issue
Block a user