diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..207a13c --- /dev/null +++ b/.env.example @@ -0,0 +1,34 @@ +# Environment Configuration for Ticket Microservice + +# Server Configuration +PORT=3049 +NODE_ENV=development + +# Redis Configuration +REDIS_URL=redis://localhost:6379 + +# Logging Configuration +LOG_LEVEL=info +LOG_FILE=logs/app.log + +# PDF Configuration +PDF_OUTPUT_DIR=tickets +PDF_CLEANUP_MAX_AGE_HOURS=24 + +# Load Testing Configuration +TEST_URL=http://localhost:3049 + +# Prometheus Configuration (if using monitoring profile) +PROMETHEUS_PORT=9090 +GRAFANA_PORT=3000 + +# Optional: Custom Redis Configuration +# REDIS_HOST=localhost +# REDIS_PORT=6379 +# REDIS_PASSWORD= +# REDIS_DB=0 + +# Optional: Performance Tuning +# MAX_CONCURRENT_REQUESTS=1000 +# REQUEST_TIMEOUT_MS=30000 +# PDF_GENERATION_TIMEOUT_MS=10000 diff --git a/README.md b/README.md index 92ed2b3..26c0356 100644 --- a/README.md +++ b/README.md @@ -47,39 +47,65 @@ Your task is to extract the high-throughput ticket purchasing component and exte - Docker and Docker Compose - Git +### Environment Variables + +The following environment variables can be configured in your `.env` file: + +| Variable | Default | Description | +| --------------------------- | ------------------------ | ---------------------------------------- | +| `PORT` | `3049` | Server port number | +| `NODE_ENV` | `development` | Environment mode | +| `REDIS_URL` | `redis://localhost:6379` | Redis connection string | +| `LOG_LEVEL` | `info` | Logging level (error, warn, info, debug) | +| `LOG_FILE` | `logs/app.log` | Log file path | +| `PDF_OUTPUT_DIR` | `tickets` | Directory for generated PDF tickets | +| `PDF_CLEANUP_MAX_AGE_HOURS` | `24` | Maximum age for PDF cleanup | +| `TEST_URL` | `http://localhost:3049` | Base URL for load testing | + ### Quick Start 1. **Clone the repository** + ```bash git clone cd module4_backend_project ``` 2. **Install dependencies** + ```bash npm install ``` 3. **Set up environment** + ```bash - cp .env.example .env + cp env.example .env # Edit .env file if needed ``` + > **Note**: The `env.example` file contains default configuration values. Copy it to `.env` and modify as needed for your environment. + + > **Important**: Create the `logs` directory if you want to use file logging: `mkdir logs` + 4. **Start with Docker (Recommended)** + ```bash # Start core services (Redis + App) docker-compose up -d - + # Or start with monitoring (Prometheus + Grafana) docker-compose --profile monitoring up -d ``` + > **Note**: For Docker deployment, you can also set environment variables directly in `docker-compose.yml` or use the `.env` file for local development. + 5. **Seed the database** + ```bash # Seed 5 events with 10,000 tickets each npm run seed - + # Custom seeding: 3 events with 5,000 tickets each npm run seed 3 5000 ``` @@ -88,12 +114,19 @@ Your task is to extract the high-throughput ticket purchasing component and exte If you prefer to run components separately: -1. **Start Redis** +1. **Create necessary directories** + + ```bash + mkdir -p logs tickets + ``` + +2. **Start Redis** + ```bash docker-compose up -d redis ``` -2. **Start the application** +3. **Start the application** ```bash npm run dev # Development with auto-reload # or @@ -104,22 +137,23 @@ If you prefer to run components separately: Once running, the following endpoints are available: -| Method | Endpoint | Description | -|--------|----------|-------------| -| GET | `/health` | System health check | -| GET | `/events` | List all events with statistics | -| GET | `/events/:eventId` | Get specific event details | -| POST | `/buy/:eventId` | Purchase a ticket for an event | -| GET | `/tickets/:purchaseId` | Download ticket PDF | -| GET | `/metrics` | Real-time system metrics | -| GET | `/admin/pdf-stats` | PDF management statistics | -| POST | `/admin/cleanup-tickets` | Cleanup old ticket files | +| Method | Endpoint | Description | +| ------ | ------------------------ | --------------------------------------- | +| GET | `/health` | System health check | +| GET | `/events` | List all events with statistics | +| GET | `/events/:eventId` | Get specific event details | +| POST | `/buy/:eventId` | Purchase a ticket for an event | +| GET | `/tickets/:purchaseId` | Download ticket PDF | +| GET | `/metrics` | Real-time system metrics | +| GET | `/admin/pdf-stats` | PDF management statistics | +| POST | `/admin/cleanup-tickets` | Cleanup old ticket files | +| POST | `/admin/seed-fallback` | Manually seed fallback store from Redis | ### Load Testing The system includes a comprehensive load testing framework: -```bash +````bash # Run full test suite (5000+ concurrent connections) npm run test:load -- --full @@ -131,7 +165,9 @@ npm run test:load -- --multi --events 1,2,3 --connections 6000 # Custom load test node tests/load-test.js --event 2 --connections 1000 --duration 10 -``` + +# Test fallback store functionality +npm run test:fallback ### Monitoring & Metrics @@ -146,6 +182,22 @@ Grafana dashboard: http://localhost:3000 - Username: `admin` - Password: `admin` +### Fallback Store Management + +The system includes a robust fallback mechanism that automatically activates when Redis is unavailable: + +- **Automatic Seeding**: The fallback store is automatically seeded during server startup and when activated +- **Data Synchronization**: When Redis becomes available again, the fallback store can be manually synced +- **Manual Seeding**: Use `/admin/seed-fallback` to manually populate the fallback store from Redis data + +```bash +# Manually seed fallback store from Redis +curl -X POST http://localhost:3049/admin/seed-fallback + +# Check fallback store status +curl http://localhost:3049/health +```` + ### Docker Commands ```bash @@ -174,6 +226,42 @@ docker-compose up -d --build - **Logging & Metrics:** Proper logging of operations and a functional metrics endpoint suitable for Prometheus scraping. - **Design Rationale:** The design document (`design.md`) should clearly articulate your architectural decisions, potential bottlenecks, and design solutions. +## Troubleshooting + +### Common Issues + +1. **Redis Connection Failed** + + - Ensure Redis is running: `docker-compose up -d redis` + - Check Redis URL in `.env` file + - Verify Redis port (default: 6379) is not blocked + +2. **Port Already in Use** + + - Change `PORT` in `.env` file + - Kill process using the port: `lsof -ti:3049 | xargs kill -9` + +3. **Missing Dependencies** + + - Run `npm install` to install all dependencies + - Ensure Node.js version is 18+ (check with `node --version`) + +4. **Permission Denied for Logs/Tickets** + + - Create directories with proper permissions: `mkdir -p logs tickets` + - Check file permissions: `chmod 755 logs tickets` + +5. **Fallback Store Not Working** + - Ensure Redis is seeded: `npm run seed` + - Check fallback store status: `npm run test:fallback` + - Manually seed fallback: `curl -X POST http://localhost:3049/admin/seed-fallback` + +### Getting Help + +- Check application logs in the `logs` directory +- Verify Redis connection: `curl http://localhost:3049/health` +- Test fallback store: `npm run test:fallback` + ## Final Challenges - Enhance your docker-compose setup to include a Prometheus container for live monitoring. diff --git a/design.md b/design.md index 8013461..77bc28b 100644 --- a/design.md +++ b/design.md @@ -1,6 +1,7 @@ # Ticket Scaling Microservice - Design Document ## Table of Contents + 1. [Architecture Overview](#architecture-overview) 2. [System Components](#system-components) 3. [Scalability Strategies](#scalability-strategies) @@ -15,6 +16,7 @@ ## Architecture Overview ### High-Level Architecture + ``` ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ Load Balancer │ │ Prometheus │ │ Grafana │ @@ -38,6 +40,7 @@ ``` ### Design Principles + 1. **High Availability**: Fallback mechanisms ensure service continuity 2. **Atomic Operations**: Redis Lua scripts prevent race conditions 3. **Horizontal Scalability**: Stateless design enables easy scaling @@ -47,6 +50,7 @@ ## System Components ### 1. Core Application (server.js) + - **Technology**: Node.js with Express framework - **Responsibilities**: - HTTP request handling @@ -55,6 +59,7 @@ - PDF generation coordination ### 2. Redis Client (redis-client.js) + - **Technology**: Redis with Lua scripting - **Responsibilities**: - Atomic ticket operations @@ -63,6 +68,7 @@ - Script execution ### 3. Fallback Store (fallback-store.js) + - **Technology**: In-memory JavaScript Map - **Responsibilities**: - Emergency ticket storage @@ -70,6 +76,7 @@ - Graceful degradation ### 4. PDF Generator (pdf-generator.js) + - **Technology**: PDFKit library - **Responsibilities**: - Professional ticket generation @@ -77,6 +84,7 @@ - Cleanup operations ### 5. Logging System (logger.js) + - **Technology**: Winston logging framework - **Responsibilities**: - Structured logging @@ -87,6 +95,7 @@ ## Scalability Strategies ### Horizontal Scaling + ``` ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ Instance 1 │ │ Instance 2 │ │ Instance N │ @@ -102,17 +111,20 @@ ``` **Key Features**: + - Stateless application design - Shared Redis backend - Load balancer distribution - Independent scaling ### Vertical Scaling + - **CPU**: Multi-core utilization through Node.js cluster mode - **Memory**: Configurable heap sizes for high-throughput - **I/O**: Async operations prevent blocking ### Database Scaling + ``` ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ Redis Master │ │ Redis Replica │ │ Redis Replica │ @@ -121,6 +133,7 @@ ``` **Strategies**: + - Redis clustering for horizontal scaling - Read replicas for metrics/stats queries - Sharding by event ID for massive scale @@ -128,6 +141,7 @@ ## Atomic Operations ### Lua Script Design + Our core purchase operation uses a Redis Lua script to ensure atomicity: ```lua @@ -145,12 +159,14 @@ local globalKey = KEYS[3] -- global:stats ``` **Benefits**: + - **Race Condition Prevention**: All operations execute atomically - **Consistency**: No partial state updates - **Performance**: Single round-trip to Redis - **Reliability**: All-or-nothing execution ### Concurrency Handling + - **Optimistic Locking**: Lua scripts handle concurrent access - **Queue Management**: Redis lists provide FIFO ticket distribution - **Connection Pooling**: Efficient Redis connection reuse @@ -158,11 +174,13 @@ local globalKey = KEYS[3] -- global:stats ## Fallback Mechanisms ### Activation Triggers + 1. **Redis Connection Failure**: Network issues or Redis downtime 2. **Script Execution Errors**: Lua script failures 3. **Timeout Scenarios**: Slow Redis responses ### Fallback Architecture + ``` ┌─────────────────┐ │ Request Comes │ @@ -181,8 +199,16 @@ local globalKey = KEYS[3] -- global:stats └─────────────────┘ └─────────────────┘ ``` +### Fallback Store Improvements + +- **Automatic Seeding**: Fallback store is seeded during server startup and when activated +- **Data Synchronization**: Automatic attempt to sync with Redis data when activated +- **Manual Seeding**: Admin endpoint to manually populate fallback store from Redis +- **Resilient Operation**: Continues functioning even when Redis is completely unavailable + ### Fallback Limitations -- **Non-Persistent**: Data lost on restart + +- **Non-Persistent**: Data lost on restart (mitigated by automatic reseeding) - **Single Instance**: No cross-instance synchronization - **Capacity Limited**: Memory constraints - **Warning Logs**: Clear indication of degraded mode @@ -190,18 +216,21 @@ local globalKey = KEYS[3] -- global:stats ## Performance Optimizations ### Application Level + 1. **Async Operations**: Non-blocking I/O throughout 2. **Connection Pooling**: Reuse Redis connections 3. **Batch Operations**: Bulk ticket seeding 4. **Caching**: Event metadata caching ### Redis Optimizations + 1. **Lua Scripts**: Reduced network round-trips 2. **Pipeline Operations**: Batch commands 3. **Memory Management**: Efficient data structures 4. **Persistence**: AOF for durability ### PDF Generation + 1. **Async Generation**: Non-blocking PDF creation 2. **Stream Processing**: Memory-efficient file handling 3. **Cleanup Jobs**: Automatic old file removal @@ -210,6 +239,7 @@ local globalKey = KEYS[3] -- global:stats ## Monitoring & Observability ### Metrics Collection + ```json { "global": { @@ -238,12 +268,14 @@ local globalKey = KEYS[3] -- global:stats ``` ### Logging Strategy + - **Structured Logging**: JSON format for parsing - **Request Tracking**: Unique IDs for tracing - **Performance Metrics**: Response times and throughput - **Error Categorization**: Different log levels ### Health Checks + - **Application Health**: `/health` endpoint - **Redis Connectivity**: Connection status - **Fallback Status**: Degraded mode indication @@ -252,16 +284,19 @@ local globalKey = KEYS[3] -- global:stats ## Security Considerations ### Input Validation + - **Event ID Validation**: Numeric constraints - **Request Rate Limiting**: DDoS protection - **Parameter Sanitization**: Injection prevention ### Container Security + - **Non-Root User**: Principle of least privilege - **Minimal Base Image**: Alpine Linux for smaller attack surface - **Health Checks**: Container monitoring ### Data Protection + - **No Sensitive Data**: Tickets are identifiers only - **Audit Logging**: Purchase tracking - **Secure Defaults**: Production-ready configuration @@ -269,6 +304,7 @@ local globalKey = KEYS[3] -- global:stats ## Deployment Strategy ### Development Environment + ```bash # Local development npm install @@ -278,6 +314,7 @@ npm run dev # Start with nodemon ``` ### Production Environment + ```bash # Docker deployment docker-compose up -d # Core services @@ -285,6 +322,7 @@ docker-compose --profile monitoring up # With monitoring ``` ### Container Orchestration + - **Docker Compose**: Local and small deployments - **Kubernetes**: Large-scale deployments - **Health Checks**: Automatic restart on failure @@ -293,24 +331,28 @@ docker-compose --profile monitoring up # With monitoring ## Future Enhancements ### Performance Improvements + 1. **Redis Clustering**: Horizontal database scaling 2. **CDN Integration**: PDF delivery optimization 3. **Caching Layer**: Application-level caching 4. **Connection Optimization**: Advanced pooling ### Feature Additions + 1. **QR Code Generation**: Enhanced ticket security 2. **Email Integration**: Automatic ticket delivery 3. **Payment Processing**: Complete purchase flow 4. **Event Management**: Dynamic event creation ### Monitoring Enhancements + 1. **Distributed Tracing**: Request flow tracking 2. **Custom Dashboards**: Business metrics visualization 3. **Alerting**: Proactive issue detection 4. **Performance Profiling**: Bottleneck identification ### Security Hardening + 1. **Authentication**: API key management 2. **Rate Limiting**: Advanced throttling 3. **Encryption**: Data in transit protection diff --git a/package.json b/package.json index e68c3a9..681596d 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "seed": "node seed.js", "test": "jest", "test:load": "node tests/load-test.js", + "test:fallback": "node test-fallback.js", "docker:up": "docker-compose up -d", "docker:down": "docker-compose down" }, diff --git a/seed.js b/seed.js index dd152fe..66df489 100644 --- a/seed.js +++ b/seed.js @@ -1,11 +1,14 @@ const redis = require("redis"); -require('dotenv').config(); +require("dotenv").config(); + +// Import fallback store for seeding +const fallbackStore = require("./src/utils/fallback-store"); // 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" + redisUrl: process.env.REDIS_URL || "redis://localhost:6379", }; const client = redis.createClient({ url: config.redisUrl }); @@ -13,51 +16,65 @@ const client = redis.createClient({ url: config.redisUrl }); async function seedTickets() { try { await client.connect(); - console.log(`Seeding ${config.numEvents} events with ${config.ticketsPerEvent} tickets each...`); - + console.log( + `Seeding ${config.numEvents} events with ${config.ticketsPerEvent} tickets each...` + ); + // Clear existing event data - const existingKeys = await client.keys('event:*'); + const existingKeys = await client.keys("event:*"); if (existingKeys.length > 0) { await client.del(existingKeys); console.log(`Cleared ${existingKeys.length} existing event keys.`); } - + // 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, { + const metadata = { 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`); + description: `Sample event ${eventId} for load testing`, + }; + + await client.hSet(metaKey, metadata); + + console.log( + `✓ Event ${eventId}: ${config.ticketsPerEvent} tickets seeded` + ); } - + // Store global stats - await client.hSet('global:stats', { + await client.hSet("global:stats", { totalEvents: config.numEvents, totalTickets: config.numEvents * config.ticketsPerEvent, totalSold: 0, - lastSeeded: new Date().toISOString() + lastSeeded: new Date().toISOString(), }); - - console.log(`\n🎉 Successfully seeded ${config.numEvents} events with ${config.numEvents * config.ticketsPerEvent} total tickets!`); + + // Also seed the fallback store + await seedFallbackStore(); + + console.log( + `\n🎉 Successfully seeded ${config.numEvents} events with ${ + config.numEvents * config.ticketsPerEvent + } total tickets!` + ); + console.log(`📦 Fallback store also seeded for resilience`); process.exit(0); } catch (err) { console.error("Error during seed:", err); @@ -65,4 +82,43 @@ async function seedTickets() { } } +async function seedFallbackStore() { + try { + console.log("\n🌐 Seeding fallback store..."); + + // Activate fallback store temporarily for seeding + fallbackStore.activate("Seeding fallback store during initialization"); + + // Seed the same events in fallback store + for (let eventId = 1; eventId <= config.numEvents; eventId++) { + const tickets = []; + for (let i = 1; i <= config.ticketsPerEvent; i++) { + tickets.push(`ticket-${eventId}-${i}`); + } + + const metadata = { + eventId: eventId, + totalTickets: config.ticketsPerEvent, + soldTickets: 0, + createdAt: new Date().toISOString(), + name: `Event ${eventId}`, + description: `Sample event ${eventId} for load testing`, + }; + + fallbackStore.seedEvent(eventId, tickets, metadata); + } + + // Update global stats in fallback store + fallbackStore.globalStats.lastSeeded = new Date().toISOString(); + + console.log(`✓ Fallback store seeded with ${config.numEvents} events`); + + // Deactivate fallback store after seeding (will be activated when needed) + fallbackStore.deactivate(); + } catch (error) { + console.error("Error seeding fallback store:", error); + // Don't fail the entire seeding process if fallback seeding fails + } +} + seedTickets(); diff --git a/server.js b/server.js index b4d7549..43b5b94 100644 --- a/server.js +++ b/server.js @@ -1,166 +1,184 @@ -const express = require('express'); -const { v4: uuidv4 } = require('uuid'); -require('dotenv').config(); +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 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 metrics = require("./src/utils/metrics"); const app = express(); const port = process.env.PORT || 3049; // Middleware app.use(express.json()); -app.use(express.static('public')); +app.use(express.static("public")); // Request logging middleware app.use((req, res, next) => { const start = Date.now(); - res.on('finish', () => { + res.on("finish", () => { const responseTime = Date.now() - start; logger.logRequest(req, res, responseTime); }); next(); }); -// 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 - }); -}); +// Prometheus metrics middleware +app.use(metrics.metricsMiddleware); // Health check endpoint -app.get('/health', async (req, res) => { +app.get("/health", async (req, res) => { const redisHealthy = redisClient.isHealthy(); const fallbackActive = fallbackStore.isActive; - + + // Update metrics + metrics.updateRedisStatus(redisHealthy); + metrics.updateFallbackStatus(fallbackActive); + res.json({ - status: 'ok', + status: "ok", timestamp: new Date().toISOString(), redis: { connected: redisHealthy, - fallbackActive: fallbackActive + fallbackActive: fallbackActive, }, - uptime: process.uptime() + uptime: process.uptime(), }); }); // Get all events endpoint -app.get('/events', async (req, res) => { +app.get("/events", async (req, res) => { try { let events; - + if (redisClient.isHealthy()) { events = await redisClient.getAllEvents(); } else { events = fallbackStore.getAllEvents(); } - + res.json({ success: true, events, - usingFallback: fallbackStore.isActive + usingFallback: fallbackStore.isActive, }); } catch (error) { - logger.error('Error fetching events:', error); - res.status(500).json({ - success: false, - message: 'Failed to fetch events' + logger.error("Error fetching events:", error); + res.status(500).json({ + success: false, + message: "Failed to fetch events", }); } }); // Get specific event stats -app.get('/events/:eventId', async (req, res) => { +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' + return res.status(404).json({ + success: false, + message: "Event not found", }); } - + res.json({ success: true, event: eventStats, - usingFallback: fallbackStore.isActive + usingFallback: fallbackStore.isActive, }); } catch (error) { - logger.error('Error fetching event stats:', error); - res.status(500).json({ - success: false, - message: 'Failed to fetch event stats' + 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) => { +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); - + 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 pdfStartTime = Date.now(); const pdfResult = await pdfGenerator.generateTicketPDF({ ticketId: luaResult[0], eventId, purchaseId, eventName: eventStats?.name || `Event ${eventId}`, - eventDescription: eventStats?.description || 'Event description not available', + eventDescription: + eventStats?.description || "Event description not available", timestamp, - soldCount: luaResult[2] + soldCount: luaResult[2], }); - + + // Record PDF generation metrics + const pdfDuration = (Date.now() - pdfStartTime) / 1000; + metrics.recordPDFGeneration( + pdfResult.success ? "success" : "failed", + pdfDuration + ); + result = { success: true, ticket: luaResult[0], purchaseId, eventId, soldCount: luaResult[2], - message: 'Ticket purchased successfully!', + message: "Ticket purchased successfully!", usingFallback: false, pdf: { generated: pdfResult.success, filename: pdfResult.filename, - downloadUrl: `/tickets/${purchaseId}` - } + downloadUrl: `/tickets/${purchaseId}`, + }, }; - + + // Record metrics for successful purchase + metrics.recordTicketSale(eventId, "success"); + metrics.updateTicketMetrics( + eventId, + luaResult[2], + luaResult[3] || 0 + ); + logger.logPurchase(eventId, luaResult[0], purchaseId, true); logger.info(`PDF ticket generated for purchase ${purchaseId}`); - } catch (pdfError) { - logger.error('PDF generation failed:', pdfError); - + logger.error("PDF generation failed:", pdfError); + // Still return success for ticket purchase, but note PDF failure result = { success: true, @@ -168,83 +186,106 @@ app.post('/buy/:eventId', async (req, res) => { purchaseId, eventId, soldCount: luaResult[2], - message: 'Ticket purchased successfully! (PDF generation failed)', + message: "Ticket purchased successfully! (PDF generation failed)", usingFallback: false, pdf: { generated: false, - error: 'PDF generation failed' - } + error: "PDF generation failed", + }, }; - + + // Record metrics for successful purchase (even with PDF failure) + metrics.recordTicketSale(eventId, "success"); + metrics.updateTicketMetrics( + eventId, + luaResult[2], + luaResult[3] || 0 + ); + logger.logPurchase(eventId, luaResult[0], purchaseId, true); } } else { // Failed - handle specific error const errorCode = luaResult[1]; let statusCode = 400; - let message = 'Purchase failed'; - + let message = "Purchase failed"; + switch (errorCode) { - case 'EVENT_NOT_FOUND': + case "EVENT_NOT_FOUND": statusCode = 404; - message = 'Event not found'; + message = "Event not found"; break; - case 'NO_TICKETS_AVAILABLE': + case "NO_TICKETS_AVAILABLE": statusCode = 409; - message = 'No tickets available for this event'; + message = "No tickets available for this event"; break; } - + + // Record metrics for failed purchase + metrics.recordTicketSale(eventId, "failed"); + logger.logPurchase(eventId, null, purchaseId, false, errorCode); - return res.status(statusCode).json({ - success: false, + return res.status(statusCode).json({ + success: false, message, errorCode, eventId, - purchaseId + purchaseId, }); } } catch (redisError) { - logger.error('Redis purchase failed, attempting fallback:', redisError); + logger.error("Redis purchase failed, attempting fallback:", redisError); // Activate fallback if not already active if (!fallbackStore.isActive) { - fallbackStore.activate('Redis purchase operation failed'); + fallbackStore.activate("Redis purchase operation failed"); + // Try to sync with Redis data if possible + setTimeout(() => { + if (fallbackStore.isActive && fallbackStore.events.size === 0) { + fallbackStore.attemptReseed(); + } + }, 100); } throw redisError; // Will be caught by outer try-catch for fallback } } else { - throw new Error('Redis not available'); + 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'); + fallbackStore.activate("Redis connection failed during purchase"); + // Try to sync with Redis data if possible + setTimeout(() => { + if (fallbackStore.isActive && fallbackStore.events.size === 0) { + fallbackStore.attemptReseed(); + } + }, 100); } - + 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', + eventDescription: + eventStats?.description || "Event description not available", timestamp, - soldCount: fallbackResult.soldCount + soldCount: fallbackResult.soldCount, }); - + const responseTime = Date.now() - startTime; res.json({ success: true, @@ -252,21 +293,30 @@ app.post('/buy/:eventId', async (req, res) => { purchaseId, eventId, soldCount: fallbackResult.soldCount, - message: 'Ticket purchased successfully (fallback mode)!', + message: "Ticket purchased successfully (fallback mode)!", usingFallback: true, responseTime: `${responseTime}ms`, pdf: { generated: pdfResult.success, filename: pdfResult.filename, - downloadUrl: `/tickets/${purchaseId}` - } + downloadUrl: `/tickets/${purchaseId}`, + }, }); - - logger.info(`PDF ticket generated for fallback purchase ${purchaseId}`); - + + // Record metrics for successful fallback purchase + metrics.recordTicketSale(eventId, "success_fallback"); + metrics.updateTicketMetrics( + eventId, + fallbackResult.soldCount, + fallbackResult.remainingTickets || 0 + ); + + logger.info( + `PDF ticket generated for fallback purchase ${purchaseId}` + ); } catch (pdfError) { - logger.error('PDF generation failed in fallback mode:', pdfError); - + logger.error("PDF generation failed in fallback mode:", pdfError); + const responseTime = Date.now() - startTime; res.json({ success: true, @@ -274,128 +324,218 @@ app.post('/buy/:eventId', async (req, res) => { purchaseId, eventId, soldCount: fallbackResult.soldCount, - message: 'Ticket purchased successfully (fallback mode, PDF generation failed)!', + message: + "Ticket purchased successfully (fallback mode, PDF generation failed)!", usingFallback: true, responseTime: `${responseTime}ms`, pdf: { generated: false, - error: 'PDF generation failed' - } + error: "PDF generation failed", + }, }); + + // Record metrics for successful fallback purchase (even with PDF failure) + metrics.recordTicketSale(eventId, "success_fallback"); + metrics.updateTicketMetrics( + eventId, + fallbackResult.soldCount, + fallbackResult.remainingTickets || 0 + ); } } else { let statusCode = 400; - let message = 'Purchase failed'; - + let message = "Purchase failed"; + switch (fallbackResult.error) { - case 'EVENT_NOT_FOUND': + case "EVENT_NOT_FOUND": statusCode = 404; - message = 'Event not found'; + message = "Event not found"; break; - case 'NO_TICKETS_AVAILABLE': + case "NO_TICKETS_AVAILABLE": statusCode = 409; - message = 'No tickets available for this event'; + message = "No tickets available for this event"; break; } - - logger.logPurchase(eventId, null, purchaseId, false, fallbackResult.error); - res.status(statusCode).json({ - success: false, + + // Record metrics for failed fallback purchase + metrics.recordTicketSale(eventId, "failed_fallback"); + + logger.logPurchase( + eventId, + null, + purchaseId, + false, + fallbackResult.error + ); + res.status(statusCode).json({ + success: false, message, errorCode: fallbackResult.error, eventId, purchaseId, - usingFallback: true + usingFallback: true, }); } } catch (fallbackError) { - logger.error('Both Redis and fallback failed:', fallbackError); + logger.error("Both Redis and fallback failed:", fallbackError); + + // Record metrics for system failure + metrics.recordTicketSale(eventId, "system_error"); + logger.logPurchase(eventId, null, purchaseId, false, fallbackError); - - res.status(500).json({ - success: false, - message: 'System temporarily unavailable', + + res.status(500).json({ + success: false, + message: "System temporarily unavailable", eventId, - purchaseId + purchaseId, }); } } }); // Download ticket PDF endpoint -app.get('/tickets/:purchaseId', async (req, res) => { +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' + 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); + + 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); + logger.error("Error downloading ticket:", error); res.status(500).json({ success: false, - message: 'Failed to download ticket' + message: "Failed to download ticket", }); } }); // PDF management endpoint -app.get('/admin/pdf-stats', async (req, res) => { +app.get("/admin/pdf-stats", async (req, res) => { try { const stats = pdfGenerator.getStats(); res.json({ success: true, - stats + stats, }); } catch (error) { - logger.error('Error getting PDF stats:', error); + logger.error("Error getting PDF stats:", error); res.status(500).json({ success: false, - message: 'Failed to get PDF statistics' + message: "Failed to get PDF statistics", }); } }); // Cleanup old tickets endpoint -app.post('/admin/cleanup-tickets', async (req, res) => { +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 + deletedCount, }); } catch (error) { - logger.error('Error cleaning up tickets:', error); + logger.error("Error cleaning up tickets:", error); res.status(500).json({ success: false, - message: 'Failed to cleanup tickets' + message: "Failed to cleanup tickets", + }); + } +}); + +// Seed fallback store endpoint +app.post("/admin/seed-fallback", async (req, res) => { + try { + if (redisClient.isHealthy()) { + // Activate fallback store temporarily for seeding + fallbackStore.activate("Manual seeding from admin endpoint"); + + // Get all events from Redis and seed fallback store + const events = await redisClient.getAllEvents(); + const globalStats = await redisClient.getGlobalStats(); + + for (const event of events) { + // Get remaining tickets for this event + const remainingTickets = await redisClient.getRemainingTickets( + event.eventId + ); + + // Create metadata object + const metadata = { + eventId: event.eventId, + totalTickets: event.totalTickets, + soldTickets: event.soldTickets, + createdAt: event.createdAt, + name: event.name, + description: event.description, + lastSoldAt: event.lastSoldAt, + }; + + // Seed the event in fallback store + fallbackStore.seedEvent(event.eventId, remainingTickets, metadata); + + // Update sold tickets count + const fallbackEvent = fallbackStore.events.get(event.eventId); + if (fallbackEvent) { + fallbackEvent.soldTickets = event.soldTickets; + } + } + + // Update global stats + if (globalStats) { + fallbackStore.globalStats.totalSold = globalStats.totalSold; + fallbackStore.globalStats.lastSeeded = new Date().toISOString(); + } + + // Deactivate fallback store (will be activated when needed) + fallbackStore.deactivate(); + + res.json({ + success: true, + message: `Fallback store seeded with ${events.length} events`, + eventsCount: events.length, + totalTickets: globalStats?.totalTickets || 0, + totalSold: globalStats?.totalSold || 0, + }); + } else { + res.status(503).json({ + success: false, + message: "Redis not available - cannot seed fallback store", + }); + } + } catch (error) { + logger.error("Error seeding fallback store:", error); + res.status(500).json({ + success: false, + message: "Failed to seed fallback store", }); } }); // Metrics endpoint (Prometheus compatible) -app.get('/metrics', async (req, res) => { +app.get("/metrics", async (req, res) => { try { let globalStats, events; - + if (redisClient.isHealthy()) { globalStats = await redisClient.getGlobalStats(); events = await redisClient.getAllEvents(); @@ -403,10 +543,10 @@ app.get('/metrics', async (req, res) => { globalStats = fallbackStore.getGlobalStats(); events = fallbackStore.getAllEvents(); } - + // Get PDF stats const pdfStats = pdfGenerator.getStats(); - + // Calculate metrics const metrics = { timestamp: new Date().toISOString(), @@ -416,17 +556,17 @@ app.get('/metrics', async (req, res) => { usingFallback: fallbackStore.isActive, redisConnected: redisClient.isHealthy(), uptime: process.uptime(), - memoryUsage: process.memoryUsage() + memoryUsage: process.memoryUsage(), }, - pdf: pdfStats + pdf: pdfStats, }; - + res.json(metrics); } catch (error) { - logger.error('Error generating metrics:', error); - res.status(500).json({ - success: false, - message: 'Failed to generate metrics' + logger.error("Error generating metrics:", error); + res.status(500).json({ + success: false, + message: "Failed to generate metrics", }); } }); @@ -436,8 +576,11 @@ async function initializeServer() { try { // Connect to Redis await redisClient.connect(); - logger.info('Redis connected successfully'); - + logger.info("Redis connected successfully"); + + // Ensure fallback store is seeded with current Redis data + await ensureFallbackStoreSeeded(); + // Start server app.listen(port, () => { logger.info(`🚀 Ticket Microservice running on port ${port}`); @@ -445,39 +588,108 @@ async function initializeServer() { 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'); - + 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'); + logger.warn("Redis connection failed - using in-memory store"); }); } } +// Ensure fallback store is seeded with current Redis data +async function ensureFallbackStoreSeeded() { + try { + // Check if fallback store needs seeding + if (fallbackStore.events.size === 0) { + logger.info( + "Fallback store is empty, seeding with current Redis data..." + ); + + // Temporarily activate fallback store for seeding + fallbackStore.activate("Seeding during server initialization"); + + // Get all events from Redis and seed fallback store + const events = await redisClient.getAllEvents(); + const globalStats = await redisClient.getGlobalStats(); + + for (const event of events) { + // Get remaining tickets for this event + const remainingTickets = await redisClient.getRemainingTickets( + event.eventId + ); + + // Create metadata object + const metadata = { + eventId: event.eventId, + totalTickets: event.totalTickets, + soldTickets: event.soldTickets, + createdAt: event.createdAt, + name: event.name, + description: event.description, + lastSoldAt: event.lastSoldAt, + }; + + // Seed the event in fallback store + fallbackStore.seedEvent(event.eventId, remainingTickets, metadata); + + // Update sold tickets count + const fallbackEvent = fallbackStore.events.get(event.eventId); + if (fallbackEvent) { + fallbackEvent.soldTickets = event.soldTickets; + } + } + + // Update global stats + if (globalStats) { + fallbackStore.globalStats.totalSold = globalStats.totalSold; + fallbackStore.globalStats.lastSeeded = new Date().toISOString(); + } + + logger.info(`Fallback store seeded with ${events.length} events`); + + // Deactivate fallback store (will be activated when needed) + fallbackStore.deactivate(); + } + } catch (error) { + logger.error("Error seeding fallback store during initialization:", error); + // Don't fail server startup if fallback seeding fails + } +} + +// 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, + }); +}); + // Graceful shutdown -process.on('SIGINT', async () => { - logger.info('Received SIGINT, shutting down gracefully...'); +process.on("SIGINT", async () => { + logger.info("Received SIGINT, shutting down gracefully..."); try { await redisClient.disconnect(); - logger.info('Redis disconnected'); + logger.info("Redis disconnected"); } catch (error) { - logger.error('Error disconnecting Redis:', error); + logger.error("Error disconnecting Redis:", error); } process.exit(0); }); -process.on('SIGTERM', async () => { - logger.info('Received SIGTERM, shutting down gracefully...'); +process.on("SIGTERM", async () => { + logger.info("Received SIGTERM, shutting down gracefully..."); try { await redisClient.disconnect(); - logger.info('Redis disconnected'); + logger.info("Redis disconnected"); } catch (error) { - logger.error('Error disconnecting Redis:', error); + logger.error("Error disconnecting Redis:", error); } process.exit(0); }); diff --git a/src/utils/fallback-store.js b/src/utils/fallback-store.js index ccda022..6f54c43 100644 --- a/src/utils/fallback-store.js +++ b/src/utils/fallback-store.js @@ -1,4 +1,4 @@ -const logger = require('./logger'); +const logger = require("./logger"); class FallbackStore { constructor() { @@ -7,20 +7,30 @@ class FallbackStore { totalEvents: 0, totalTickets: 0, totalSold: 0, - lastSeeded: null + 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!'); + logger.logFallback("In-Memory Store Activated", reason); + logger.warn( + "⚠️ FALLBACK MODE: Using in-memory store - data will not persist!" + ); + + // Check if fallback store needs seeding + if (this.events.size === 0) { + logger.warn( + "⚠️ Fallback store is empty - attempting to seed from Redis..." + ); + this.attemptReseed(); + } } deactivate() { this.isActive = false; - logger.info('In-Memory Store Deactivated - Redis connection restored'); + logger.info("In-Memory Store Deactivated - Redis connection restored"); } seedEvent(eventId, tickets, metadata) { @@ -29,13 +39,15 @@ class FallbackStore { this.events.set(eventId, { tickets: [...tickets], // Create a copy metadata: { ...metadata }, - soldTickets: 0 + soldTickets: 0, }); this.globalStats.totalEvents++; this.globalStats.totalTickets += tickets.length; - - logger.info(`Fallback: Seeded event ${eventId} with ${tickets.length} tickets`); + + logger.info( + `Fallback: Seeded event ${eventId} with ${tickets.length} tickets` + ); return true; } @@ -44,26 +56,26 @@ class FallbackStore { const event = this.events.get(eventId); if (!event) { - return { success: false, error: 'EVENT_NOT_FOUND' }; + return { success: false, error: "EVENT_NOT_FOUND" }; } if (event.tickets.length === 0) { - return { success: false, error: 'NO_TICKETS_AVAILABLE' }; + 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 + soldCount: event.soldTickets, }; } @@ -81,7 +93,7 @@ class FallbackStore { soldTickets: event.soldTickets, remainingTickets: event.tickets.length, createdAt: event.metadata.createdAt, - lastSoldAt: event.metadata.lastSoldAt || 'never' + lastSoldAt: event.metadata.lastSoldAt || "never", }; } @@ -113,9 +125,9 @@ class FallbackStore { totalEvents: 0, totalTickets: 0, totalSold: 0, - lastSeeded: null + lastSeeded: null, }; - logger.info('Fallback store cleared'); + logger.info("Fallback store cleared"); } getStatus() { @@ -123,9 +135,70 @@ class FallbackStore { active: this.isActive, eventsCount: this.events.size, totalTickets: this.globalStats.totalTickets, - totalSold: this.globalStats.totalSold + totalSold: this.globalStats.totalSold, }; } + + async attemptReseed() { + try { + logger.info("Attempting to reseed fallback store from Redis..."); + + // Try to connect to Redis temporarily to get data + const redis = require("redis"); + const client = redis.createClient({ + url: process.env.REDIS_URL || "redis://localhost:6379", + }); + + await client.connect(); + + // Get all event keys + const eventKeys = await client.keys("event:*:meta"); + + for (const metaKey of eventKeys) { + const eventId = metaKey.split(":")[1]; + const ticketKey = `event:${eventId}:tickets`; + + try { + // Get event metadata + const metadata = await client.hGetAll(metaKey); + if (!metadata.eventId) continue; + + // Get remaining tickets + const remainingTickets = await client.lRange(ticketKey, 0, -1); + + // Get sold tickets count + const soldTickets = parseInt(metadata.soldTickets) || 0; + + // Seed the event in fallback store + this.seedEvent(eventId, remainingTickets, metadata); + + // Update sold tickets count + const event = this.events.get(eventId); + if (event) { + event.soldTickets = soldTickets; + this.globalStats.totalSold += soldTickets; + } + + logger.info( + `Fallback: Reseeded event ${eventId} with ${remainingTickets.length} tickets (${soldTickets} already sold)` + ); + } catch (eventError) { + logger.error(`Error reseeding event ${eventId}:`, eventError); + } + } + + // Update global stats + this.globalStats.lastSeeded = new Date().toISOString(); + + await client.disconnect(); + logger.info("Fallback store reseed completed"); + } catch (error) { + logger.error("Failed to reseed fallback store from Redis:", error); + logger.warn( + "Fallback store will operate with existing data or empty state" + ); + } + } } module.exports = new FallbackStore(); diff --git a/src/utils/metrics.js b/src/utils/metrics.js new file mode 100644 index 0000000..933faec --- /dev/null +++ b/src/utils/metrics.js @@ -0,0 +1,134 @@ +const promClient = require("prom-client"); + +// Create a Registry to register the metrics +const register = new promClient.Registry(); + +// Enable the collection of default metrics +promClient.collectDefaultMetrics({ register }); + +// Custom metrics for the ticket service +const httpRequestDurationMicroseconds = new promClient.Histogram({ + name: "http_request_duration_seconds", + help: "Duration of HTTP requests in seconds", + labelNames: ["method", "route", "status_code"], + buckets: [0.1, 0.5, 1, 2, 5], +}); + +const httpRequestsTotal = new promClient.Counter({ + name: "http_requests_total", + help: "Total number of HTTP requests", + labelNames: ["method", "route", "status_code"], +}); + +const ticketsSoldTotal = new promClient.Counter({ + name: "tickets_sold_total", + help: "Total number of tickets sold", + labelNames: ["event_id", "status"], +}); + +const ticketsAvailable = new promClient.Gauge({ + name: "tickets_available", + help: "Number of tickets available per event", + labelNames: ["event_id"], +}); + +const redisConnectionStatus = new promClient.Gauge({ + name: "redis_connection_status", + help: "Redis connection status (1 = connected, 0 = disconnected)", +}); + +const fallbackStoreActive = new promClient.Gauge({ + name: "fallback_store_active", + help: "Fallback store activation status (1 = active, 0 = inactive)", +}); + +const pdfGenerationTotal = new promClient.Counter({ + name: "pdf_generation_total", + help: "Total number of PDFs generated", + labelNames: ["status"], +}); + +const pdfGenerationDuration = new promClient.Histogram({ + name: "pdf_generation_duration_seconds", + help: "Duration of PDF generation in seconds", + buckets: [0.1, 0.5, 1, 2, 5], +}); + +// Register all metrics +register.registerMetric(httpRequestDurationMicroseconds); +register.registerMetric(httpRequestsTotal); +register.registerMetric(ticketsSoldTotal); +register.registerMetric(ticketsAvailable); +register.registerMetric(redisConnectionStatus); +register.registerMetric(fallbackStoreActive); +register.registerMetric(pdfGenerationTotal); +register.registerMetric(pdfGenerationDuration); + +// Middleware to collect HTTP metrics +const metricsMiddleware = (req, res, next) => { + const start = Date.now(); + + // Override res.end to capture response status + const originalEnd = res.end; + res.end = function (...args) { + const duration = (Date.now() - start) / 1000; // Convert to seconds + const route = req.route ? req.route.path : req.path; + + // Record metrics + httpRequestDurationMicroseconds + .labels(req.method, route, res.statusCode.toString()) + .observe(duration); + + httpRequestsTotal + .labels(req.method, route, res.statusCode.toString()) + .inc(); + + originalEnd.apply(this, args); + }; + + next(); +}; + +// Function to update ticket metrics +const updateTicketMetrics = (eventId, soldTickets, remainingTickets) => { + ticketsAvailable.labels(eventId.toString()).set(remainingTickets); +}; + +// Function to record ticket sale +const recordTicketSale = (eventId, status = "success") => { + ticketsSoldTotal.labels(eventId.toString(), status).inc(); +}; + +// Function to update Redis connection status +const updateRedisStatus = (isConnected) => { + redisConnectionStatus.set(isConnected ? 1 : 0); +}; + +// Function to update fallback store status +const updateFallbackStatus = (isActive) => { + fallbackStoreActive.set(isActive ? 1 : 0); +}; + +// Function to record PDF generation +const recordPDFGeneration = (status = "success", duration = null) => { + pdfGenerationTotal.labels(status).inc(); + if (duration !== null) { + pdfGenerationDuration.observe(duration); + } +}; + +// Function to get metrics in Prometheus format +const getMetrics = async () => { + return await register.metrics(); +}; + +module.exports = { + register, + metricsMiddleware, + updateTicketMetrics, + recordTicketSale, + updateRedisStatus, + updateFallbackStatus, + recordPDFGeneration, + getMetrics, +}; diff --git a/src/utils/redis-client.js b/src/utils/redis-client.js index 1a4af98..ab23cd1 100644 --- a/src/utils/redis-client.js +++ b/src/utils/redis-client.js @@ -1,7 +1,7 @@ -const redis = require('redis'); -const fs = require('fs'); -const path = require('path'); -const logger = require('./logger'); +const redis = require("redis"); +const fs = require("fs"); +const path = require("path"); +const logger = require("./logger"); class RedisClient { constructor() { @@ -13,28 +13,28 @@ class RedisClient { async connect() { try { - const redisUrl = process.env.REDIS_URL || 'redis://localhost:6379'; + 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.client.on("error", (err) => { + logger.error("Redis Client Error:", err); this.isConnected = false; }); - this.client.on('connect', () => { - logger.info('Redis client connected'); + this.client.on("connect", () => { + logger.info("Redis client connected"); this.isConnected = true; }); - this.client.on('disconnect', () => { - logger.warn('Redis client disconnected'); + 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); + logger.error("Failed to connect to Redis:", error); this.isConnected = false; throw error; } @@ -42,79 +42,75 @@ class RedisClient { loadLuaScripts() { try { - const luaDir = path.join(__dirname, '../lua'); - + const luaDir = path.join(__dirname, "../lua"); + // Load purchase ticket script const purchaseScript = fs.readFileSync( - path.join(luaDir, 'purchase-ticket.lua'), - 'utf8' + 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' + path.join(luaDir, "get-event-stats.lua"), + "utf8" ); this.luaScripts.getEventStats = statsScript; - logger.info('Lua scripts loaded successfully'); + logger.info("Lua scripts loaded successfully"); } catch (error) { - logger.error('Failed to load Lua scripts:', 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'); + throw new Error("Redis not connected"); } // Validate event exists before attempting purchase const eventExists = await this.client.exists(`event:${eventId}:meta`); if (!eventExists) { logger.warn(`Event ${eventId} does not exist`); - return [null, 'EVENT_NOT_FOUND']; + return [null, "EVENT_NOT_FOUND"]; } const keys = [ `event:${eventId}:tickets`, `event:${eventId}:meta`, - 'global:stats' + "global:stats", ]; // Ensure all arguments are strings as required by Redis Lua const args = [String(timestamp), String(purchaseId)]; try { - const result = await this.client.eval( - this.luaScripts.purchaseTicket, - { keys, arguments: args } - ); + const result = await this.client.eval(this.luaScripts.purchaseTicket, { + keys, + arguments: args, + }); return result; } catch (error) { - logger.error('Error executing purchase ticket script:', error); - logger.error('Script keys:', keys); - logger.error('Script args:', args); + logger.error("Error executing purchase ticket script:", error); + logger.error("Script keys:", keys); + logger.error("Script args:", args); throw error; } } async getEventStats(eventId) { if (!this.isConnected) { - throw new Error('Redis not connected'); + throw new Error("Redis not connected"); } - const keys = [ - `event:${eventId}:meta`, - `event:${eventId}:tickets` - ]; + const keys = [`event:${eventId}:meta`, `event:${eventId}:tickets`]; try { - const result = await this.client.eval( - this.luaScripts.getEventStats, - { keys } - ); - + const result = await this.client.eval(this.luaScripts.getEventStats, { + keys, + }); + if (!result) return null; return { @@ -125,21 +121,21 @@ class RedisClient { soldTickets: parseInt(result[4]), remainingTickets: parseInt(result[5]), createdAt: result[6], - lastSoldAt: result[7] + lastSoldAt: result[7], }; } catch (error) { - logger.error('Error executing get event stats script:', error); + logger.error("Error executing get event stats script:", error); throw error; } } async getAllEvents() { if (!this.isConnected) { - throw new Error('Redis not connected'); + throw new Error("Redis not connected"); } try { - const eventKeys = await this.client.keys('event:*:meta'); + const eventKeys = await this.client.keys("event:*:meta"); const events = []; for (const key of eventKeys) { @@ -152,30 +148,48 @@ class RedisClient { return events; } catch (error) { - logger.error('Error getting all events:', error); + logger.error("Error getting all events:", error); throw error; } } async getGlobalStats() { if (!this.isConnected) { - throw new Error('Redis not connected'); + throw new Error("Redis not connected"); } try { - const stats = await this.client.hGetAll('global:stats'); + 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 + lastSeeded: stats.lastSeeded || null, }; } catch (error) { - logger.error('Error getting global stats:', error); + logger.error("Error getting global stats:", error); throw error; } } + async getRemainingTickets(eventId) { + if (!this.isConnected) { + throw new Error("Redis not connected"); + } + + try { + const ticketKey = `event:${eventId}:tickets`; + const remainingTickets = await this.client.lRange(ticketKey, 0, -1); + return remainingTickets; + } catch (error) { + logger.error( + `Error getting remaining tickets for event ${eventId}:`, + error + ); + return []; + } + } + getClient() { return this.client; } diff --git a/test-fallback.js b/test-fallback.js new file mode 100644 index 0000000..119199b --- /dev/null +++ b/test-fallback.js @@ -0,0 +1,65 @@ +const fallbackStore = require("./src/utils/fallback-store"); + +async function testFallbackStore() { + console.log("🧪 Testing Fallback Store Functionality\n"); + + // Test 1: Check initial state + console.log("1. Initial State:"); + console.log(` - Active: ${fallbackStore.isActive}`); + console.log(` - Events Count: ${fallbackStore.events.size}`); + console.log(` - Total Tickets: ${fallbackStore.globalStats.totalTickets}`); + console.log(` - Total Sold: ${fallbackStore.globalStats.totalSold}\n`); + + // Test 2: Activate fallback store + console.log("2. Activating Fallback Store..."); + fallbackStore.activate("Test activation"); + console.log(` - Active: ${fallbackStore.isActive}\n`); + + // Test 3: Check if seeding is needed + console.log("3. Checking Seeding Status:"); + if (fallbackStore.events.size === 0) { + console.log(" - Fallback store is empty, attempting to seed..."); + await fallbackStore.attemptReseed(); + } else { + console.log(" - Fallback store already has data"); + } + console.log(` - Events Count: ${fallbackStore.events.size}\n`); + + // Test 4: Test ticket purchase if events exist + if (fallbackStore.events.size > 0) { + console.log("4. Testing Ticket Purchase:"); + const eventId = "1"; + const purchaseId = "test-purchase-123"; + + const result = fallbackStore.purchaseTicket(eventId, purchaseId); + if (result.success) { + console.log(` - Purchase successful: ${result.ticket}`); + console.log(` - Sold count: ${result.soldCount}`); + } else { + console.log(` - Purchase failed: ${result.error}`); + } + console.log( + ` - Remaining tickets: ${ + fallbackStore.getEventStats(eventId)?.remainingTickets || 0 + }\n` + ); + } + + // Test 5: Get status + console.log("5. Fallback Store Status:"); + const status = fallbackStore.getStatus(); + console.log(` - Active: ${status.active}`); + console.log(` - Events: ${status.eventsCount}`); + console.log(` - Total Tickets: ${status.totalTickets}`); + console.log(` - Total Sold: ${status.totalSold}\n`); + + // Test 6: Deactivate + console.log("6. Deactivating Fallback Store..."); + fallbackStore.deactivate(); + console.log(` - Active: ${fallbackStore.isActive}\n`); + + console.log("✅ Fallback Store Test Completed!"); +} + +// Run the test +testFallbackStore().catch(console.error);