This repository has no description
0

Configure Feed

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

semble / src / modules / feeds / application / sagas / CardCollectionSaga.ts
7.4 kB 218 lines
1import { Result, ok, err } from '../../../../shared/core/Result'; 2import { CardAddedToLibraryEvent } from '../../../cards/domain/events/CardAddedToLibraryEvent'; 3import { CardAddedToCollectionEvent } from '../../../cards/domain/events/CardAddedToCollectionEvent'; 4import { 5 AddActivityToFeedUseCase, 6 AddCardCollectedActivityDTO, 7} from '../useCases/commands/AddActivityToFeedUseCase'; 8import { ActivityTypeEnum } from '../../domain/value-objects/ActivityType'; 9import { ISagaStateStore } from './ISagaStateStore'; 10 11interface PendingCardActivity { 12 cardId: string; 13 actorId: string; 14 collectionIds: string[]; 15 timestamp: Date; 16 hasLibraryEvent: boolean; 17 hasCollectionEvents: boolean; 18} 19 20export class CardCollectionSaga { 21 private readonly AGGREGATION_WINDOW_MS = 3000; 22 private readonly REDIS_KEY_PREFIX = 'saga:feed'; 23 24 constructor( 25 private addActivityToFeedUseCase: AddActivityToFeedUseCase, 26 private stateStore: ISagaStateStore, 27 ) {} 28 29 async handleCardEvent( 30 event: CardAddedToLibraryEvent | CardAddedToCollectionEvent, 31 ): Promise<Result<void>> { 32 const aggregationKey = this.createKey(event); 33 34 // Retry lock acquisition with longer delays and more attempts for high concurrency 35 const maxRetries = 15; // Increased from 5 36 const baseDelay = 100; // Increased from 50ms 37 const maxDelay = 2000; // Cap the maximum delay 38 39 for (let attempt = 0; attempt < maxRetries; attempt++) { 40 const lockAcquired = await this.acquireLock(aggregationKey); 41 42 if (lockAcquired) { 43 try { 44 const existing = await this.getPendingActivity(aggregationKey); 45 46 if (existing && this.isWithinWindow(existing)) { 47 this.mergeActivity(existing, event); 48 await this.setPendingActivity(aggregationKey, existing); 49 } else { 50 const newActivity = this.createNewPendingActivity(event); 51 await this.setPendingActivity(aggregationKey, newActivity); 52 await this.scheduleFlush(aggregationKey); 53 } 54 55 return ok(undefined); 56 } finally { 57 await this.releaseLock(aggregationKey); 58 } 59 } 60 61 // Lock not acquired, wait and retry 62 if (attempt < maxRetries - 1) { 63 // Use exponential backoff with jitter and cap at maxDelay 64 const exponentialDelay = baseDelay * Math.pow(1.5, attempt); 65 const jitter = Math.random() * 50; // Add randomness to prevent thundering herd 66 const delay = Math.min(exponentialDelay + jitter, maxDelay); 67 68 console.log( 69 `Lock acquisition failed for ${aggregationKey}, retrying in ${Math.round(delay)}ms (attempt ${attempt + 1}/${maxRetries})`, 70 ); 71 await new Promise((resolve) => setTimeout(resolve, delay)); 72 } 73 } 74 75 // All retries failed - this shouldn't happen often 76 console.warn( 77 `Failed to acquire lock after ${maxRetries} attempts for ${aggregationKey}`, 78 ); 79 return ok(undefined); 80 } 81 82 // Key helpers 83 private getPendingKey(aggregationKey: string): string { 84 return `${this.REDIS_KEY_PREFIX}:pending:${aggregationKey}`; 85 } 86 87 private getLockKey(aggregationKey: string): string { 88 return `${this.REDIS_KEY_PREFIX}:lock:${aggregationKey}`; 89 } 90 91 // State management 92 private async getPendingActivity( 93 aggregationKey: string, 94 ): Promise<PendingCardActivity | null> { 95 const data = await this.stateStore.get(this.getPendingKey(aggregationKey)); 96 if (!data) return null; 97 98 const parsed = JSON.parse(data); 99 // Convert timestamp string back to Date object 100 parsed.timestamp = new Date(parsed.timestamp); 101 return parsed; 102 } 103 104 private async setPendingActivity( 105 aggregationKey: string, 106 activity: PendingCardActivity, 107 ): Promise<void> { 108 const key = this.getPendingKey(aggregationKey); 109 const ttlSeconds = Math.ceil(this.AGGREGATION_WINDOW_MS / 1000) + 5; 110 await this.stateStore.setex(key, ttlSeconds, JSON.stringify(activity)); 111 } 112 113 private async deletePendingActivity(aggregationKey: string): Promise<void> { 114 await this.stateStore.del(this.getPendingKey(aggregationKey)); 115 } 116 117 // Distributed locking 118 private async acquireLock(aggregationKey: string): Promise<boolean> { 119 const lockKey = this.getLockKey(aggregationKey); 120 // Reduced lock TTL to allow faster recovery in high concurrency scenarios 121 const lockTtl = Math.ceil(this.AGGREGATION_WINDOW_MS / 1000) + 5; // Reduced from +10 to +5 122 const result = await this.stateStore.set(lockKey, '1', 'EX', lockTtl, 'NX'); 123 return result === 'OK'; 124 } 125 126 private async releaseLock(aggregationKey: string): Promise<void> { 127 await this.stateStore.del(this.getLockKey(aggregationKey)); 128 } 129 130 private createKey( 131 event: CardAddedToLibraryEvent | CardAddedToCollectionEvent, 132 ): string { 133 const cardId = event.cardId.getStringValue(); 134 const actorId = this.getActorId(event); 135 return `${cardId}-${actorId}`; 136 } 137 138 private getActorId( 139 event: CardAddedToLibraryEvent | CardAddedToCollectionEvent, 140 ): string { 141 if ('curatorId' in event) { 142 return event.curatorId.value; // CardAddedToLibraryEvent 143 } else { 144 return event.addedBy.value; // CardAddedToCollectionEvent 145 } 146 } 147 148 private isWithinWindow(pending: PendingCardActivity): boolean { 149 const now = new Date(); 150 const timeDiff = now.getTime() - pending.timestamp.getTime(); 151 return timeDiff <= this.AGGREGATION_WINDOW_MS; 152 } 153 154 private createNewPendingActivity( 155 event: CardAddedToLibraryEvent | CardAddedToCollectionEvent, 156 ): PendingCardActivity { 157 const cardId = event.cardId.getStringValue(); 158 const actorId = this.getActorId(event); 159 160 const pending: PendingCardActivity = { 161 cardId, 162 actorId, 163 collectionIds: [], 164 timestamp: new Date(), 165 hasLibraryEvent: false, 166 hasCollectionEvents: false, 167 }; 168 169 this.mergeActivity(pending, event); 170 return pending; 171 } 172 173 private mergeActivity( 174 existing: PendingCardActivity, 175 event: CardAddedToLibraryEvent | CardAddedToCollectionEvent, 176 ): void { 177 if ('curatorId' in event) { 178 // CardAddedToLibraryEvent 179 existing.hasLibraryEvent = true; 180 } else { 181 // CardAddedToCollectionEvent 182 existing.hasCollectionEvents = true; 183 const collectionId = event.collectionId.getStringValue(); 184 if (!existing.collectionIds.includes(collectionId)) { 185 existing.collectionIds.push(collectionId); 186 } 187 } 188 } 189 190 private async scheduleFlush(aggregationKey: string): Promise<void> { 191 setTimeout(async () => { 192 await this.flushActivity(aggregationKey); 193 }, this.AGGREGATION_WINDOW_MS); 194 } 195 196 private async flushActivity(aggregationKey: string): Promise<void> { 197 const lockAcquired = await this.acquireLock(aggregationKey); 198 if (!lockAcquired) return; 199 200 try { 201 const pending = await this.getPendingActivity(aggregationKey); 202 if (!pending) return; 203 204 const request: AddCardCollectedActivityDTO = { 205 type: ActivityTypeEnum.CARD_COLLECTED, 206 actorId: pending.actorId, 207 cardId: pending.cardId, 208 collectionIds: 209 pending.collectionIds.length > 0 ? pending.collectionIds : undefined, 210 }; 211 212 await this.addActivityToFeedUseCase.execute(request); 213 } finally { 214 await this.deletePendingActivity(aggregationKey); 215 await this.releaseLock(aggregationKey); 216 } 217 } 218}