This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

semble / docs / event-system-fly.md
36 kB 1265 lines
1# Distributed Event System Implementation with BullMQ on Fly.io 2 3This document provides a comprehensive guide for implementing a distributed event system using BullMQ and Redis/Valkey on Fly.io, specifically designed for high-frequency social features like notifications and activity feeds. 4 5## Overview 6 7Our current in-memory domain event system works well for single-instance deployments, but as we scale to multiple regions and instances on Fly.io, we need a distributed approach. For applications with frequent URL additions that trigger notifications and social activity feeds, **BullMQ with Redis/Valkey is the recommended solution**. 8 9## Why BullMQ + Redis/Valkey? 10 11### Perfect for Social Features 12 13- **High Throughput**: Handle thousands of events per second for URL additions 14- **Real-time Processing**: Near-instant notifications and feed updates 15- **Reliable Delivery**: Built-in retry logic ensures notifications aren't lost 16- **Rate Limiting**: Prevent overwhelming external services (email, push notifications) 17- **Priority Queues**: Process notifications faster than feed updates 18 19### Technical Advantages 20 21- **Excellent TypeScript Support**: First-class TypeScript integration 22- **Rich Feature Set**: Delays, retries, rate limiting, job scheduling 23- **Great Monitoring**: Built-in dashboard and metrics 24- **Battle-tested**: Used in production by many high-scale applications 25- **Flexible Scaling**: Scale workers independently from web servers 26 27## Architecture Overview 28 29``` 30┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ 31│ Web Server │ │ Redis/Valkey │ │ Worker Nodes │ 32│ │ │ │ │ │ 33│ Domain Events ──┼───▶│ BullMQ Queues ├───▶│ Event Handlers │ 34│ │ │ │ │ │ 35│ - Card Added │ │ - Notifications │ │ - Send Emails │ 36│ - URL Shared │ │ - Feeds │ │ - Update Feeds │ 37│ - Collection │ │ - Analytics │ │ - Track Events │ 38└─────────────────┘ └─────────────────┘ └─────────────────┘ 39``` 40 41## Process Flow: How Events Get Triggered 42 43Understanding how events flow from HTTP requests to worker processes is crucial for debugging and scaling: 44 45### 1. Web Process (HTTP Request Triggered) 46 47``` 48HTTP Request → Use Case → Domain Logic → Event Publishing → HTTP Response 49 ↓ ↓ ↓ ↓ ↓ 50POST /cards AddCardToLib card.addTo() publishEvents() 200 OK 51``` 52 53**What happens:** 54 55- User makes HTTP request to your API 56- Express/Fastify routes to use case 57- Use case executes domain logic (adds domain events to aggregate) 58- Use case saves to database 59- Use case publishes events to Redis queue via `BullMQEventPublisher` 60- HTTP response returned immediately (don't wait for event processing) 61 62### 2. Redis Queue (Event Storage) 63 64``` 65BullMQEventPublisher → Redis Queue → Job Storage 66 ↓ ↓ ↓ 67 Serialize Event Store in Queue Wait for Worker 68``` 69 70**What happens:** 71 72- Events are serialized to JSON 73- Stored in Redis with retry/priority configuration 74- BullMQ manages job lifecycle, retries, and failure handling 75 76### 3. Worker Process (Polling Triggered) 77 78``` 79Worker Polling → Job Found → Event Handler → Job Complete 80 ↓ ↓ ↓ ↓ 81 redis.poll() processJob() handler.handle() ack/nack 82``` 83 84**What happens:** 85 86- Worker processes continuously poll Redis for new jobs 87- When job found, BullMQ calls your `processJob()` method 88- Event is reconstructed from serialized data 89- Your event handler is called with the reconstructed event 90- Job marked as complete or failed based on handler result 91 92### 4. Key Differences from Web Process 93 94| Aspect | Web Process | Worker Process | 95| ---------------- | -------------------- | ----------------------- | 96| **Trigger** | HTTP Request | Redis Job Available | 97| **Lifecycle** | Request/Response | Long-running polling | 98| **Scaling** | Scale with traffic | Scale with queue depth | 99| **Failure** | Return error to user | Retry job automatically | 100| **Dependencies** | Database, Redis | Database, External APIs | 101 102## Implementation Guide 103 104### Step 1: Install Dependencies 105 106```bash 107npm install bullmq ioredis 108npm install --save-dev @types/ioredis 109``` 110 111### Step 2: Infrastructure Layer - Event Publisher 112 113Create the BullMQ event publisher that implements the `IEventPublisher` interface: 114 115```typescript 116// src/shared/infrastructure/events/BullMQEventPublisher.ts 117import { Queue } from 'bullmq'; 118import Redis from 'ioredis'; 119import { IEventPublisher } from '../../application/events/IEventPublisher'; 120import { IDomainEvent } from '../../domain/events/IDomainEvent'; 121import { Result, ok, err } from '../../core/Result'; 122 123export class BullMQEventPublisher implements IEventPublisher { 124 private queues: Map<string, Queue> = new Map(); 125 126 constructor(private redisConnection: Redis) {} 127 128 async publishEvents(events: IDomainEvent[]): Promise<Result<void>> { 129 try { 130 for (const event of events) { 131 await this.publishSingleEvent(event); 132 } 133 return ok(undefined); 134 } catch (error) { 135 return err(error as Error); 136 } 137 } 138 139 private async publishSingleEvent(event: IDomainEvent): Promise<void> { 140 const queueConfig = this.getQueueConfig(event); 141 142 if (!this.queues.has(queueConfig.name)) { 143 this.queues.set( 144 queueConfig.name, 145 new Queue(queueConfig.name, { 146 connection: this.redisConnection, 147 defaultJobOptions: queueConfig.options, 148 }), 149 ); 150 } 151 152 const queue = this.queues.get(queueConfig.name)!; 153 await queue.add(event.constructor.name, { 154 eventType: event.constructor.name, 155 aggregateId: event.getAggregateId().toString(), 156 dateTimeOccurred: event.dateTimeOccurred.toISOString(), 157 // Serialize the event data 158 cardId: (event as any).cardId?.getValue?.()?.toString(), 159 curatorId: (event as any).curatorId?.value, 160 }); 161 } 162 163 private getQueueConfig(event: IDomainEvent) { 164 // Route events to appropriate queues 165 if (event.constructor.name === 'CardAddedToLibraryEvent') { 166 return { 167 name: 'notifications', 168 options: { 169 priority: 1, 170 attempts: 5, 171 backoff: { type: 'exponential' as const, delay: 1000 }, 172 removeOnComplete: 100, 173 removeOnFail: 50, 174 }, 175 }; 176 } 177 178 return { 179 name: 'events', 180 options: { 181 priority: 2, 182 attempts: 3, 183 backoff: { type: 'exponential' as const, delay: 2000 }, 184 removeOnComplete: 50, 185 removeOnFail: 25, 186 }, 187 }; 188 } 189 190 async close(): Promise<void> { 191 await Promise.all( 192 Array.from(this.queues.values()).map((queue) => queue.close()), 193 ); 194 } 195} 196``` 197 198### Step 3: Infrastructure Layer - Event Subscriber 199 200Create the BullMQ event subscriber that implements the `IEventSubscriber` interface: 201 202```typescript 203// src/shared/infrastructure/events/BullMQEventSubscriber.ts 204import { Worker, Job } from 'bullmq'; 205import Redis from 'ioredis'; 206import { 207 IEventSubscriber, 208 IEventHandler, 209} from '../../application/events/IEventSubscriber'; 210import { IDomainEvent } from '../../domain/events/IDomainEvent'; 211import { CardAddedToLibraryEvent } from '../../../modules/cards/domain/events/CardAddedToLibraryEvent'; 212import { CardId } from '../../../modules/cards/domain/value-objects/CardId'; 213import { CuratorId } from '../../../modules/cards/domain/value-objects/CuratorId'; 214 215export class BullMQEventSubscriber implements IEventSubscriber { 216 private workers: Worker[] = []; 217 private handlers: Map<string, IEventHandler<any>> = new Map(); 218 219 constructor(private redisConnection: Redis) {} 220 221 async subscribe<T extends IDomainEvent>( 222 eventType: string, 223 handler: IEventHandler<T>, 224 ): Promise<void> { 225 this.handlers.set(eventType, handler); 226 } 227 228 async start(): Promise<void> { 229 // Start workers for different queues 230 const queues = ['notifications', 'events']; 231 232 for (const queueName of queues) { 233 const worker = new Worker( 234 queueName, 235 async (job: Job) => { 236 await this.processJob(job); 237 }, 238 { 239 connection: this.redisConnection, 240 concurrency: queueName === 'notifications' ? 5 : 15, 241 }, 242 ); 243 244 worker.on('completed', (job) => { 245 console.log(`Job ${job.id} completed successfully`); 246 }); 247 248 worker.on('failed', (job, err) => { 249 console.error(`Job ${job?.id} failed:`, err); 250 }); 251 252 worker.on('error', (err) => { 253 console.error('Worker error:', err); 254 }); 255 256 this.workers.push(worker); 257 } 258 } 259 260 async stop(): Promise<void> { 261 await Promise.all(this.workers.map((worker) => worker.close())); 262 this.workers = []; 263 } 264 265 private async processJob(job: Job): Promise<void> { 266 const eventData = job.data; 267 const eventType = eventData.eventType; 268 269 const handler = this.handlers.get(eventType); 270 if (!handler) { 271 console.warn(`No handler registered for event type: ${eventType}`); 272 return; 273 } 274 275 const event = this.reconstructEvent(eventData); 276 const result = await handler.handle(event); 277 278 if (result.isErr()) { 279 throw result.error; 280 } 281 } 282 283 private reconstructEvent(eventData: any): IDomainEvent { 284 if (eventData.eventType === 'CardAddedToLibraryEvent') { 285 const cardId = CardId.create(eventData.cardId).unwrap(); 286 const curatorId = CuratorId.create(eventData.curatorId).unwrap(); 287 288 const event = new CardAddedToLibraryEvent(cardId, curatorId); 289 (event as any).dateTimeOccurred = new Date(eventData.dateTimeOccurred); 290 291 return event; 292 } 293 294 throw new Error(`Unknown event type: ${eventData.eventType}`); 295 } 296} 297``` 298 299### Step 4: Update Event Handler Registry 300 301Update your existing registry to publish events to the distributed system: 302 303```typescript 304// src/shared/infrastructure/events/EventHandlerRegistry.ts 305import { DomainEvents } from '../../domain/events/DomainEvents'; 306import { CardAddedToLibraryEvent } from '../../../modules/cards/domain/events/CardAddedToLibraryEvent'; 307import { IEventPublisher } from '../../application/events/IEventPublisher'; 308 309export class EventHandlerRegistry { 310 constructor(private eventPublisher: IEventPublisher) {} 311 312 registerAllHandlers(): void { 313 // Register distributed event publishing 314 DomainEvents.register(async (event: CardAddedToLibraryEvent) => { 315 try { 316 await this.eventPublisher.publishEvents([event]); 317 } catch (error) { 318 console.error('Error publishing event to BullMQ:', error); 319 // Don't fail the main operation if event publishing fails 320 } 321 }, CardAddedToLibraryEvent.name); 322 } 323 324 clearAllHandlers(): void { 325 DomainEvents.clearHandlers(); 326 } 327} 328``` 329 330### Step 5: Fly.io Infrastructure Setup 331 332#### Set up Redis with Fly 333 334```bash 335# Create an Upstash Redis database via Fly 336fly redis create --name myapp-redis --region ord 337 338# For production, add replica regions for better performance 339fly redis create --name myapp-redis --region ord --replica-regions fra,iad 340 341# Check available plans if you need more capacity 342fly redis plans 343 344# View your Redis databases 345fly redis list 346``` 347 348After creation, Fly will provide you with connection details. You can also check the status: 349 350```bash 351# Check Redis status and get connection info 352fly redis status myapp-redis 353 354# Connect to Redis for testing 355fly redis connect myapp-redis 356``` 357 358#### Configure fly.toml for Worker Processes 359 360Fly.io supports multiple process types in a single app. Here's how to configure your `fly.toml` to run both web servers and worker processes: 361 362```toml 363# fly.toml 364app = "myapp" 365primary_region = "sea" 366 367[build] 368 dockerfile = "Dockerfile" 369 370# Define different process types 371[processes] 372 web = "npm start" 373 notification-worker = "npm run worker:notifications" 374 feed-worker = "npm run worker:feeds" 375 376[env] 377 NODE_ENV = "production" 378 # Redis URL will be automatically set by Fly when you attach the Redis database 379 380# HTTP service configuration - ONLY applies to web processes 381[http_service] 382 internal_port = 3000 383 force_https = true 384 auto_stop_machines = 'stop' 385 auto_start_machines = true 386 min_machines_running = 0 387 processes = ['web'] # Only web processes handle HTTP traffic 388 389# Default VM configuration for all processes 390[[vm]] 391 memory = '1gb' 392 cpu_kind = 'shared' 393 cpus = 1 394 395# Override VM settings for specific process types 396[[vm]] 397 processes = ['notification-worker'] 398 memory = '512mb' # Workers typically need less memory 399 cpu_kind = 'shared' 400 cpus = 1 401 402[[vm]] 403 processes = ['feed-worker'] 404 memory = '512mb' 405 cpu_kind = 'shared' 406 cpus = 1 407 408[deploy] 409 strategy = "rolling" 410``` 411 412**Key Configuration Points:** 413 4141. **Process Types**: Define each worker as a separate process type 4152. **HTTP Service**: Only applies to web processes (workers don't need HTTP) 4163. **VM Configuration**: Can be customized per process type 4174. **Auto-scaling**: Workers can have different scaling rules than web processes 418 419#### Update package.json Scripts 420 421Add worker scripts to your `package.json`: 422 423```json 424// package.json 425{ 426 "scripts": { 427 "start": "node dist/index.js", 428 "worker:notifications": "node dist/workers/notification-worker.js", 429 "worker:feeds": "node dist/workers/feed-worker.js", 430 "build": "tsup", 431 "dev": "concurrently \"tsc --watch\" \"nodemon dist/index.js\"" 432 } 433} 434``` 435 436**Important Notes:** 437 438- Workers and web processes use the same build output (`dist/`) 439- All processes are built together with `npm run build` 440- Each process type runs a different entry point 441 442```typescript 443// src/workers/notification-worker.ts 444import Redis from 'ioredis'; 445import { BullMQEventSubscriber } from '../shared/infrastructure/events/BullMQEventSubscriber'; 446import { CardAddedToLibraryEventHandler } from '../modules/notifications/application/eventHandlers/CardAddedToLibraryEventHandler'; 447import { EnvironmentConfigService } from '../shared/infrastructure/config/EnvironmentConfigService'; 448 449async function startNotificationWorker() { 450 console.log('Starting notification worker...'); 451 452 const configService = new EnvironmentConfigService(); 453 454 // Connect to Redis 455 const redisUrl = configService.get('REDIS_URL'); 456 if (!redisUrl) { 457 throw new Error('REDIS_URL environment variable is required'); 458 } 459 460 const redis = new Redis(redisUrl, { 461 maxRetriesPerRequest: 3, 462 retryDelayOnFailover: 100, 463 lazyConnect: true, 464 }); 465 466 // Test Redis connection 467 try { 468 await redis.ping(); 469 console.log('Connected to Redis successfully'); 470 } catch (error) { 471 console.error('Failed to connect to Redis:', error); 472 process.exit(1); 473 } 474 475 // Create subscriber 476 const eventSubscriber = new BullMQEventSubscriber(redis); 477 478 // Create event handlers (wire up your services here) 479 const notificationHandler = new CardAddedToLibraryEventHandler( 480 notificationService, 481 ); 482 483 // Register handlers 484 await eventSubscriber.subscribe( 485 'CardAddedToLibraryEvent', 486 notificationHandler, 487 ); 488 489 // Start the worker - THIS IS WHAT TRIGGERS YOUR HANDLERS! 490 await eventSubscriber.start(); 491 492 console.log('Notification worker started and listening for events...'); 493 494 // Graceful shutdown 495 process.on('SIGTERM', async () => { 496 console.log('Shutting down notification worker...'); 497 await eventSubscriber.stop(); 498 await redis.quit(); 499 process.exit(0); 500 }); 501} 502 503startNotificationWorker().catch(console.error); 504``` 505 506### Step 5: Service Factory Integration 507 508Update your service factory to use the distributed event system: 509 510```typescript 511// In your ServiceFactory 512export class ServiceFactory { 513 static create( 514 configService: EnvironmentConfigService, 515 repositories: Repositories, 516 ): Services { 517 // ... existing services 518 519 // Redis connection - Fly Redis provides a full connection URL 520 const redisUrl = configService.get('REDIS_URL'); 521 if (!redisUrl) { 522 throw new Error('REDIS_URL environment variable is required'); 523 } 524 525 const redis = new Redis(redisUrl, { 526 maxRetriesPerRequest: 3, 527 retryDelayOnFailover: 100, 528 lazyConnect: true, 529 }); 530 531 // Event publisher 532 const eventPublisher = new BullMQEventPublisher(redis); 533 534 // Event handler registry 535 const eventHandlerRegistry = new EventHandlerRegistry(eventPublisher); 536 eventHandlerRegistry.registerAllHandlers(); 537 538 return { 539 // ... existing services 540 eventHandlerRegistry, 541 eventPublisher, 542 }; 543 } 544} 545``` 546 547### Step 6: Create Use Cases with Event Publishing 548 549Update your use cases to extend `BaseUseCase` and publish events: 550 551```typescript 552// src/modules/cards/application/use-cases/AddCardToLibraryUseCase.ts 553import { BaseUseCase } from '../../../../shared/core/UseCase'; 554import { Result, ok, err } from '../../../../shared/core/Result'; 555import { IEventPublisher } from '../../../../shared/application/events/IEventPublisher'; 556import { ICardRepository } from '../../domain/ICardRepository'; 557 558interface AddCardToLibraryRequest { 559 cardId: string; 560 userId: string; 561} 562 563export class AddCardToLibraryUseCase extends BaseUseCase< 564 AddCardToLibraryRequest, 565 Result<void> 566> { 567 constructor( 568 private cardRepository: ICardRepository, 569 eventPublisher: IEventPublisher, 570 ) { 571 super(eventPublisher); 572 } 573 574 async execute(request: AddCardToLibraryRequest): Promise<Result<void>> { 575 // 1. Get the card 576 const cardResult = await this.cardRepository.findById( 577 CardId.create(request.cardId).unwrap(), 578 ); 579 if (cardResult.isErr()) return err(cardResult.error); 580 581 const card = cardResult.value; 582 if (!card) return err(new Error('Card not found')); 583 584 // 2. Execute domain logic (adds events to aggregate) 585 const curatorId = CuratorId.create(request.userId).unwrap(); 586 const addResult = card.addToLibrary(curatorId); 587 if (addResult.isErr()) return err(addResult.error); 588 589 // 3. Save to repository 590 const saveResult = await this.cardRepository.save(card); 591 if (saveResult.isErr()) return err(saveResult.error); 592 593 // 4. Publish events after successful save 594 const publishResult = await this.publishEventsForAggregate(card); 595 if (publishResult.isErr()) { 596 console.error('Failed to publish events:', publishResult.error); 597 // Don't fail the operation if event publishing fails 598 } 599 600 return ok(undefined); 601 } 602} 603``` 604 605## Deployment Strategy 606 607### Step-by-Step Deployment Process 608 609#### 1. Create and Configure Redis 610 611```bash 612# Create Redis database with replicas for better performance 613fly redis create --name myapp-redis --region ord --replica-regions fra,iad 614 615# Attach Redis to your app (sets REDIS_URL environment variable) 616fly redis attach myapp-redis 617 618# Verify Redis connection details 619fly redis status myapp-redis 620``` 621 622#### 2. Update Your Application Configuration 623 624Ensure your `fly.toml` includes worker processes: 625 626```toml 627[processes] 628 web = "npm start" 629 notification-worker = "npm run worker:notifications" 630 feed-worker = "npm run worker:feeds" 631 632[http_service] 633 processes = ['web'] # Only web processes handle HTTP 634``` 635 636#### 3. Deploy All Processes 637 638```bash 639# Deploy the entire application (web + workers) 640fly deploy 641 642# Check deployment status 643fly status 644 645# View all running processes 646fly ps 647``` 648 649#### 4. Scale Worker Processes 650 651```bash 652# Scale different process types independently 653fly scale count web=2 notification-worker=2 feed-worker=3 654 655# Scale to specific regions 656fly scale count web=2 --region ord 657fly scale count notification-worker=1 --region ord 658fly scale count notification-worker=1 --region fra 659fly scale count feed-worker=2 --region ord 660fly scale count feed-worker=1 --region fra 661 662# Check current scaling 663fly scale show 664``` 665 666#### 5. Monitor Worker Health 667 668```bash 669# Monitor worker logs 670fly logs --process notification-worker 671fly logs --process feed-worker 672 673# Check specific worker instances 674fly logs --process notification-worker --region ord 675 676# Monitor Redis connectivity 677fly redis connect myapp-redis 678``` 679 680### Understanding Fly.io Worker Deployment 681 682#### How Fly.io Handles Multiple Process Types 683 684**Single App, Multiple Process Types:** 685 686- All processes (web, workers) are part of the same Fly app 687- They share the same Docker image and environment variables 688- Each process type can be scaled independently 689- Workers run as long-lived background processes 690 691**Deployment Flow:** 692 6931. `fly deploy` builds one Docker image 6942. Fly creates different machine types based on `[processes]` config 6953. Each machine runs the specified command for its process type 6964. HTTP service only applies to processes listed in `[http_service].processes` 697 698#### Process Type Differences 699 700| Aspect | Web Process | Worker Processes | 701| ------------------- | --------------------------- | ------------------------------ | 702| **Command** | `npm start` | `npm run worker:notifications` | 703| **HTTP Traffic** | ✅ Receives HTTP requests | ❌ No HTTP traffic | 704| **Load Balancer** | ✅ Behind Fly proxy | ❌ Direct process | 705| **Health Checks** | HTTP endpoint | Process running | 706| **Scaling Trigger** | HTTP traffic | Queue depth | 707| **Auto-stop** | Can auto-stop when idle | Should run continuously | 708| **Regions** | Scale based on user traffic | Scale based on workload | 709 710#### Worker-Specific Configuration 711 712**Prevent Auto-stopping Workers:** 713 714```toml 715# In fly.toml - workers should not auto-stop 716[http_service] 717 auto_stop_machines = 'stop' 718 processes = ['web'] # Only web processes auto-stop 719 720# Or disable auto-stop entirely for workers 721[[vm]] 722 processes = ['notification-worker', 'feed-worker'] 723 auto_stop_machines = false 724``` 725 726**Worker Health Monitoring:** 727 728```bash 729# Workers don't have HTTP health checks 730# Monitor via logs and process status 731fly logs --process notification-worker --follow 732 733# Check if worker processes are running 734fly ps | grep worker 735 736# SSH into worker for debugging 737fly ssh console --process notification-worker 738``` 739 740#### Environment Variables and Secrets 741 742**Shared Environment:** 743 744- All process types share the same environment variables 745- Redis URL, database URL, etc. are available to all processes 746- Secrets are shared across all process types 747 748```bash 749# Set environment variables for all processes 750fly secrets set DATABASE_URL="postgresql://..." 751fly secrets set REDIS_URL="redis://..." 752 753# Check environment in worker 754fly ssh console --process notification-worker 755env | grep REDIS_URL 756``` 757 758#### Deployment Dependencies and Order 759 760**Critical Deployment Order:** 761 7621. **Redis First**: Must exist before workers can start 7632. **Database**: Must be accessible to both web and workers 7643. **Deploy**: All processes deploy together 7654. **Scale**: Scale workers after successful deployment 766 767```bash 768# 1. Ensure Redis exists 769fly redis status myapp-redis || fly redis create --name myapp-redis 770 771# 2. Ensure Redis is attached 772fly redis attach myapp-redis 773 774# 3. Deploy all processes 775fly deploy 776 777# 4. Verify all process types started 778fly ps 779 780# 5. Scale workers as needed 781fly scale count notification-worker=2 feed-worker=3 782``` 783 784**Common Deployment Issues:** 785 7861. **Workers Exit Immediately** 787 788 ```bash 789 # Check worker logs for startup errors 790 fly logs --process notification-worker 791 792 # Common causes: 793 # - Missing REDIS_URL 794 # - Redis connection failed 795 # - Missing dependencies in package.json 796 ``` 797 7982. **Workers Can't Connect to Redis** 799 800 ```bash 801 # Verify Redis attachment 802 fly redis status myapp-redis 803 804 # Test Redis connection from worker 805 fly ssh console --process notification-worker 806 node -e "const Redis = require('ioredis'); const r = new Redis(process.env.REDIS_URL); r.ping().then(console.log)" 807 ``` 808 8093. **Workers Not Processing Jobs** 810 811 ```bash 812 # Check if jobs are being queued 813 fly redis connect myapp-redis 814 > KEYS * 815 > LLEN bull:notifications:waiting 816 817 # Check worker logs for processing 818 fly logs --process notification-worker --follow 819 ``` 820 821### Worker Scaling Guidelines 822 823#### Notification Workers (External API Calls) 824 825```bash 826# Conservative scaling for external APIs 827fly scale count notification-worker=2 --region ord 828fly scale count notification-worker=1 --region fra 829 830# Monitor and adjust based on: 831# - External API rate limits 832# - Queue depth 833# - Processing time 834``` 835 836**Configuration:** 837 838- **Concurrency**: 5-10 jobs per worker (respect API limits) 839- **Regions**: 2-3 regions max (avoid hitting API limits from too many IPs) 840- **Memory**: 512MB usually sufficient 841- **Scaling Trigger**: Queue depth > 100 jobs 842 843#### Feed Workers (Database Operations) 844 845```bash 846# More aggressive scaling for database operations 847fly scale count feed-worker=3 --region ord 848fly scale count feed-worker=2 --region fra 849 850# Scale based on: 851# - Database connection limits 852# - Queue processing speed 853# - Regional user distribution 854``` 855 856**Configuration:** 857 858- **Concurrency**: 10-20 jobs per worker (database can handle more) 859- **Regions**: Match your user distribution 860- **Memory**: 512MB-1GB depending on data processing 861- **Scaling Trigger**: Queue depth > 50 jobs 862 863#### Scaling Commands Reference 864 865```bash 866# View current scaling 867fly scale show 868 869# Scale specific process types 870fly scale count web=2 notification-worker=2 feed-worker=3 871 872# Scale to specific regions 873fly scale count notification-worker=1 --region ord 874fly scale count notification-worker=1 --region fra 875 876# Scale with memory adjustments 877fly scale memory 1gb --process feed-worker 878fly scale memory 512mb --process notification-worker 879 880# Auto-scaling (if using Fly's auto-scaling features) 881fly autoscale set min=1 max=5 --process notification-worker 882fly autoscale set min=2 max=8 --process feed-worker 883``` 884 885#### Monitoring Scaling Effectiveness 886 887```bash 888# Monitor queue depths 889fly redis connect myapp-redis 890> LLEN bull:notifications:waiting 891> LLEN bull:feeds:waiting 892 893# Monitor worker performance 894fly logs --process notification-worker | grep "Job.*completed" 895fly logs --process feed-worker | grep "processing time" 896 897# Check resource usage 898fly metrics --process notification-worker 899fly metrics --process feed-worker 900``` 901 902### Monitoring and Observability 903 904#### BullMQ Dashboard 905 906```typescript 907// Optional: Add Bull Dashboard for monitoring 908import { createBullBoard } from '@bull-board/api'; 909import { BullMQAdapter } from '@bull-board/api/bullMQAdapter'; 910import { ExpressAdapter } from '@bull-board/express'; 911 912const serverAdapter = new ExpressAdapter(); 913serverAdapter.setBasePath('/admin/queues'); 914 915const { addQueue } = createBullBoard({ 916 queues: [new BullMQAdapter(notificationQueue), new BullMQAdapter(feedQueue)], 917 serverAdapter: serverAdapter, 918}); 919 920app.use('/admin/queues', serverAdapter.getRouter()); 921``` 922 923#### Custom Metrics 924 925```typescript 926// Track event processing metrics 927export class EventMetrics { 928 private static eventCounts = new Map<string, number>(); 929 private static processingTimes = new Map<string, number[]>(); 930 931 static recordEvent(eventType: string, processingTime: number): void { 932 // Increment count 933 const count = this.eventCounts.get(eventType) || 0; 934 this.eventCounts.set(eventType, count + 1); 935 936 // Track processing time 937 const times = this.processingTimes.get(eventType) || []; 938 times.push(processingTime); 939 if (times.length > 100) times.shift(); // Keep last 100 940 this.processingTimes.set(eventType, times); 941 } 942 943 static getMetrics() { 944 const metrics: any = {}; 945 946 for (const [eventType, count] of this.eventCounts) { 947 const times = this.processingTimes.get(eventType) || []; 948 const avgTime = 949 times.length > 0 ? times.reduce((a, b) => a + b, 0) / times.length : 0; 950 951 metrics[eventType] = { 952 count, 953 averageProcessingTime: avgTime, 954 recentProcessingTimes: times.slice(-10), 955 }; 956 } 957 958 return metrics; 959 } 960} 961``` 962 963## Testing Strategy 964 965### Unit Tests 966 967```typescript 968// Test event publishing 969describe('BullMQEventPublisher', () => { 970 it('should publish CardAddedToLibraryEvent to notifications queue', async () => { 971 const mockQueue = { 972 add: jest.fn().mockResolvedValue({}), 973 }; 974 975 const publisher = new BullMQEventPublisher(redisConnection); 976 (publisher as any).queues.set('notifications', mockQueue); 977 978 const event = new CardAddedToLibraryEvent( 979 cardId, 980 curatorId, 981 CardTypeEnum.URL, 982 ); 983 await publisher.publish(event); 984 985 expect(mockQueue.add).toHaveBeenCalledWith( 986 'CardAddedToLibraryEvent', 987 expect.objectContaining({ 988 cardId, 989 curatorId, 990 cardType: CardTypeEnum.URL, 991 }), 992 ); 993 }); 994}); 995``` 996 997### Integration Tests 998 999```typescript 1000// Test end-to-end event flow 1001describe('Event Processing Integration', () => { 1002 it('should process CardAddedToLibraryEvent through the queue', async () => { 1003 const mockNotificationHandler = { 1004 handle: jest.fn().mockResolvedValue({}), 1005 }; 1006 1007 // Publish event 1008 const event = new CardAddedToLibraryEvent( 1009 cardId, 1010 curatorId, 1011 CardTypeEnum.URL, 1012 ); 1013 await eventPublisher.publish(event); 1014 1015 // Wait for processing 1016 await new Promise((resolve) => setTimeout(resolve, 1000)); 1017 1018 // Verify handler was called 1019 expect(mockNotificationHandler.handle).toHaveBeenCalledWith( 1020 expect.objectContaining({ 1021 cardId, 1022 curatorId, 1023 cardType: CardTypeEnum.URL, 1024 }), 1025 ); 1026 }); 1027}); 1028``` 1029 1030## Performance Optimization 1031 1032### Queue Configuration 1033 1034```typescript 1035// Optimize for your specific use case 1036const queueOptions = { 1037 // For high-frequency events 1038 defaultJobOptions: { 1039 removeOnComplete: 100, // Keep successful jobs for debugging 1040 removeOnFail: 50, // Keep failed jobs for analysis 1041 attempts: 3, // Retry failed jobs 1042 backoff: { 1043 type: 'exponential', 1044 delay: 2000, // Start with 2s delay 1045 }, 1046 }, 1047 1048 // Connection pooling 1049 connection: { 1050 ...redisConnection, 1051 maxRetriesPerRequest: 3, 1052 retryDelayOnFailover: 100, 1053 lazyConnect: true, 1054 }, 1055}; 1056``` 1057 1058### Worker Optimization 1059 1060```typescript 1061// Optimize worker settings 1062const workerOptions = { 1063 concurrency: process.env.NODE_ENV === 'production' ? 10 : 5, 1064 1065 // Batch processing for better performance 1066 stalledInterval: 30000, 1067 maxStalledCount: 1, 1068 1069 // Memory management 1070 removeOnComplete: 100, 1071 removeOnFail: 50, 1072}; 1073``` 1074 1075## Troubleshooting 1076 1077### Common Issues 1078 10791. **Events not processing**: Check Redis connection and worker status 10802. **High memory usage**: Adjust `removeOnComplete` and `removeOnFail` settings 10813. **Slow processing**: Increase worker concurrency or add more workers 10824. **Failed jobs**: Check error logs and adjust retry settings 1083 1084### Debugging Commands 1085 1086```bash 1087# Check worker status 1088fly logs --app myapp --process notification-worker 1089 1090# Monitor Redis 1091fly redis connect myapp-redis 1092> MONITOR 1093 1094# Check Redis status and connection info 1095fly redis status myapp-redis 1096 1097# Proxy to Redis for local debugging 1098fly redis proxy myapp-redis 1099 1100# Check queue status 1101fly ssh console --app myapp 1102> npm run queue:status 1103``` 1104 1105## Complete Deployment Checklist 1106 1107### Phase 1: Infrastructure Setup 1108 11091. **Create Redis Database** 1110 1111 ```bash 1112 fly redis create --name myapp-redis --region ord --replica-regions fra,iad 1113 fly redis attach myapp-redis 1114 fly redis status myapp-redis # Verify creation 1115 ``` 1116 11172. **Update Application Configuration** 1118 - ✅ Add worker processes to `fly.toml` 1119 - ✅ Add worker scripts to `package.json` 1120 - ✅ Configure HTTP service for web processes only 1121 - ✅ Set appropriate VM resources for workers 1122 1123### Phase 2: Code Implementation 1124 11253. **Implement Event System** 1126 - ✅ Create `BullMQEventPublisher` 1127 - ✅ Create `BullMQEventSubscriber` 1128 - ✅ Update service factory to use distributed events 1129 - ✅ Create worker entry points 1130 11314. **Create Worker Files** 1132 ```bash 1133 # Ensure these files exist: 1134 ls src/workers/notification-worker.ts 1135 ls src/workers/feed-worker.ts 1136 ``` 1137 1138### Phase 3: Deployment 1139 11405. **Deploy Application** 1141 1142 ```bash 1143 # Build and deploy all processes 1144 fly deploy 1145 1146 # Verify all process types are running 1147 fly ps 1148 fly status 1149 ``` 1150 11516. **Verify Worker Startup** 1152 1153 ```bash 1154 # Check worker logs for successful startup 1155 fly logs --process notification-worker 1156 fly logs --process feed-worker 1157 1158 # Look for these messages: 1159 # "Connected to Redis successfully" 1160 # "Worker started and listening for events..." 1161 ``` 1162 1163### Phase 4: Testing and Scaling 1164 11657. **Test Event Flow** 1166 1167 ```bash 1168 # Test Redis connection from workers 1169 fly ssh console --process notification-worker 1170 node -e "const Redis = require('ioredis'); const r = new Redis(process.env.REDIS_URL); r.ping().then(console.log)" 1171 1172 # Monitor Redis for job activity 1173 fly redis connect myapp-redis 1174 > MONITOR 1175 ``` 1176 11778. **Scale Workers Based on Load** 1178 1179 ```bash 1180 # Start conservative 1181 fly scale count notification-worker=1 feed-worker=2 1182 1183 # Monitor and scale up as needed 1184 fly scale count notification-worker=2 feed-worker=3 1185 1186 # Add regional distribution 1187 fly scale count notification-worker=1 --region ord 1188 fly scale count notification-worker=1 --region fra 1189 ``` 1190 1191### Phase 5: Monitoring and Optimization 1192 11939. **Set Up Monitoring** 1194 1195 ```bash 1196 # Monitor worker health 1197 fly logs --process notification-worker --follow 1198 fly logs --process feed-worker --follow 1199 1200 # Check Redis performance 1201 fly redis status myapp-redis 1202 fly redis dashboard myapp-redis 1203 ``` 1204 120510. **Performance Optimization** 1206 1207 ```bash 1208 # Monitor queue depths 1209 fly redis connect myapp-redis 1210 > LLEN bull:notifications:waiting 1211 > LLEN bull:feeds:waiting 1212 1213 # Adjust scaling based on metrics 1214 fly scale count notification-worker=3 # If queue depth consistently high 1215 fly scale memory 1gb --process feed-worker # If memory usage high 1216 ``` 1217 1218### Troubleshooting Common Issues 1219 1220**Workers Not Starting:** 1221 1222```bash 1223# Check for missing dependencies 1224fly logs --process notification-worker | grep "Error" 1225 1226# Verify Redis connection 1227fly ssh console --process notification-worker 1228echo $REDIS_URL 1229``` 1230 1231**Jobs Not Processing:** 1232 1233```bash 1234# Check if jobs are being queued 1235fly redis connect myapp-redis 1236> KEYS bull:* 1237> LLEN bull:notifications:waiting 1238 1239# Verify worker registration 1240fly logs --process notification-worker | grep "subscribe" 1241``` 1242 1243**High Memory Usage:** 1244 1245```bash 1246# Monitor resource usage 1247fly metrics --process notification-worker 1248fly metrics --process feed-worker 1249 1250# Scale memory if needed 1251fly scale memory 1gb --process feed-worker 1252``` 1253 1254### Production Readiness Checklist 1255 1256- ✅ Redis database with replicas in multiple regions 1257- ✅ Worker processes configured in `fly.toml` 1258- ✅ Environment variables properly set 1259- ✅ Workers successfully connecting to Redis 1260- ✅ Event handlers processing jobs 1261- ✅ Monitoring and logging in place 1262- ✅ Scaling strategy defined 1263- ✅ Error handling and retry logic tested 1264 1265This comprehensive deployment guide ensures your distributed event system is properly configured and running on Fly.io with robust worker processes handling your social features at scale.