import { Result, ok, err } from '../../../../shared/core/Result'; import { CardAddedToLibraryEvent } from '../../../cards/domain/events/CardAddedToLibraryEvent'; import { CardAddedToCollectionEvent } from '../../../cards/domain/events/CardAddedToCollectionEvent'; import { AddActivityToFeedUseCase, AddCardCollectedActivityDTO, } from '../useCases/commands/AddActivityToFeedUseCase'; import { ActivityTypeEnum } from '../../domain/value-objects/ActivityType'; import { ISagaStateStore } from './ISagaStateStore'; interface PendingCardActivity { cardId: string; actorId: string; collectionIds: string[]; timestamp: Date; hasLibraryEvent: boolean; hasCollectionEvents: boolean; } export class CardCollectionSaga { private readonly AGGREGATION_WINDOW_MS = 3000; private readonly REDIS_KEY_PREFIX = 'saga:feed'; constructor( private addActivityToFeedUseCase: AddActivityToFeedUseCase, private stateStore: ISagaStateStore, ) {} async handleCardEvent( event: CardAddedToLibraryEvent | CardAddedToCollectionEvent, ): Promise> { const aggregationKey = this.createKey(event); // Retry lock acquisition with longer delays and more attempts for high concurrency const maxRetries = 15; // Increased from 5 const baseDelay = 100; // Increased from 50ms const maxDelay = 2000; // Cap the maximum delay for (let attempt = 0; attempt < maxRetries; attempt++) { const lockAcquired = await this.acquireLock(aggregationKey); if (lockAcquired) { try { const existing = await this.getPendingActivity(aggregationKey); if (existing && this.isWithinWindow(existing)) { this.mergeActivity(existing, event); await this.setPendingActivity(aggregationKey, existing); } else { const newActivity = this.createNewPendingActivity(event); await this.setPendingActivity(aggregationKey, newActivity); await this.scheduleFlush(aggregationKey); } return ok(undefined); } finally { await this.releaseLock(aggregationKey); } } // Lock not acquired, wait and retry if (attempt < maxRetries - 1) { // Use exponential backoff with jitter and cap at maxDelay const exponentialDelay = baseDelay * Math.pow(1.5, attempt); const jitter = Math.random() * 50; // Add randomness to prevent thundering herd const delay = Math.min(exponentialDelay + jitter, maxDelay); console.log( `Lock acquisition failed for ${aggregationKey}, retrying in ${Math.round(delay)}ms (attempt ${attempt + 1}/${maxRetries})`, ); await new Promise((resolve) => setTimeout(resolve, delay)); } } // All retries failed - this shouldn't happen often console.warn( `Failed to acquire lock after ${maxRetries} attempts for ${aggregationKey}`, ); return ok(undefined); } // Key helpers private getPendingKey(aggregationKey: string): string { return `${this.REDIS_KEY_PREFIX}:pending:${aggregationKey}`; } private getLockKey(aggregationKey: string): string { return `${this.REDIS_KEY_PREFIX}:lock:${aggregationKey}`; } // State management private async getPendingActivity( aggregationKey: string, ): Promise { const data = await this.stateStore.get(this.getPendingKey(aggregationKey)); if (!data) return null; const parsed = JSON.parse(data); // Convert timestamp string back to Date object parsed.timestamp = new Date(parsed.timestamp); return parsed; } private async setPendingActivity( aggregationKey: string, activity: PendingCardActivity, ): Promise { const key = this.getPendingKey(aggregationKey); const ttlSeconds = Math.ceil(this.AGGREGATION_WINDOW_MS / 1000) + 5; await this.stateStore.setex(key, ttlSeconds, JSON.stringify(activity)); } private async deletePendingActivity(aggregationKey: string): Promise { await this.stateStore.del(this.getPendingKey(aggregationKey)); } // Distributed locking private async acquireLock(aggregationKey: string): Promise { const lockKey = this.getLockKey(aggregationKey); // Reduced lock TTL to allow faster recovery in high concurrency scenarios const lockTtl = Math.ceil(this.AGGREGATION_WINDOW_MS / 1000) + 5; // Reduced from +10 to +5 const result = await this.stateStore.set(lockKey, '1', 'EX', lockTtl, 'NX'); return result === 'OK'; } private async releaseLock(aggregationKey: string): Promise { await this.stateStore.del(this.getLockKey(aggregationKey)); } private createKey( event: CardAddedToLibraryEvent | CardAddedToCollectionEvent, ): string { const cardId = event.cardId.getStringValue(); const actorId = this.getActorId(event); return `${cardId}-${actorId}`; } private getActorId( event: CardAddedToLibraryEvent | CardAddedToCollectionEvent, ): string { if ('curatorId' in event) { return event.curatorId.value; // CardAddedToLibraryEvent } else { return event.addedBy.value; // CardAddedToCollectionEvent } } private isWithinWindow(pending: PendingCardActivity): boolean { const now = new Date(); const timeDiff = now.getTime() - pending.timestamp.getTime(); return timeDiff <= this.AGGREGATION_WINDOW_MS; } private createNewPendingActivity( event: CardAddedToLibraryEvent | CardAddedToCollectionEvent, ): PendingCardActivity { const cardId = event.cardId.getStringValue(); const actorId = this.getActorId(event); const pending: PendingCardActivity = { cardId, actorId, collectionIds: [], timestamp: new Date(), hasLibraryEvent: false, hasCollectionEvents: false, }; this.mergeActivity(pending, event); return pending; } private mergeActivity( existing: PendingCardActivity, event: CardAddedToLibraryEvent | CardAddedToCollectionEvent, ): void { if ('curatorId' in event) { // CardAddedToLibraryEvent existing.hasLibraryEvent = true; } else { // CardAddedToCollectionEvent existing.hasCollectionEvents = true; const collectionId = event.collectionId.getStringValue(); if (!existing.collectionIds.includes(collectionId)) { existing.collectionIds.push(collectionId); } } } private async scheduleFlush(aggregationKey: string): Promise { setTimeout(async () => { await this.flushActivity(aggregationKey); }, this.AGGREGATION_WINDOW_MS); } private async flushActivity(aggregationKey: string): Promise { const lockAcquired = await this.acquireLock(aggregationKey); if (!lockAcquired) return; try { const pending = await this.getPendingActivity(aggregationKey); if (!pending) return; const request: AddCardCollectedActivityDTO = { type: ActivityTypeEnum.CARD_COLLECTED, actorId: pending.actorId, cardId: pending.cardId, collectionIds: pending.collectionIds.length > 0 ? pending.collectionIds : undefined, }; await this.addActivityToFeedUseCase.execute(request); } finally { await this.deletePendingActivity(aggregationKey); await this.releaseLock(aggregationKey); } } }