This repository has no description
0

Configure Feed

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

temp fix to collectionItem concurrent backfill issue

+800 -7
+768
.agent/logs/20260203_collection_backfill_race_conditioon.md
··· 1 + # Collection Backfill Race Condition Analysis 2 + 3 + **Date:** 2026-02-03 4 + **Issue:** Lost updates during concurrent collection item processing 5 + **Status:** Root cause identified 6 + 7 + --- 8 + 9 + ## Executive Summary 10 + 11 + During account data sync, when multiple collection items are added to the same collection concurrently, only the last item appears in the final result. This is caused by a **classic lost update race condition** in the collection repository's save method, which uses a DELETE-all-then-INSERT-all pattern that causes concurrent transactions to overwrite each other's changes. 12 + 13 + **Expected:** Collection A has 3 items, Collection B has 2 items 14 + **Actual:** Collection A has 1 item, Collection B has 1 item 15 + **Data Lost:** 3 out of 5 collection items 16 + 17 + --- 18 + 19 + ## Problem Symptoms 20 + 21 + From production logs, we can see: 22 + 23 + ``` 24 + [SYNC] [SYNC] Retrieved 5 records from at.margin.collectionItem (page 1) 25 + [SYNC] [SYNC] Processing batch 1 (5 records) from page 1... 26 + 27 + # All 5 items resolve successfully 28 + [SYNC] [FirehoseWorker] Resolved Margin collection item - collectionId: 63d0727a..., cardId: 7c7ae767..., itemUri: ...3mdlhi4wewh2h 29 + [SYNC] [FirehoseWorker] Resolved Margin collection item - collectionId: ce2891f7..., cardId: eca2df55..., itemUri: ...3mdykmh3pv22x 30 + [SYNC] [FirehoseWorker] Resolved Margin collection item - collectionId: ce2891f7..., cardId: 0a23c9b0..., itemUri: ...3mdyknieydy2a 31 + [SYNC] [FirehoseWorker] Resolved Margin collection item - collectionId: 63d0727a..., cardId: e8f84f9f..., itemUri: ...3mdvzqmvl6o2x 32 + [SYNC] [FirehoseWorker] Resolved Margin collection item - collectionId: 63d0727a..., cardId: 9fd4381a..., itemUri: ...3mdykgokcyp2m 33 + 34 + # All 5 successfully added 35 + [SYNC] [FirehoseWorker] Successfully added Margin annotation to collection - user: did:plc:rlknsba2qldjkicxsmni3vyn, cardId: 7c7ae767..., collectionId: 63d0727a... 36 + [SYNC] [FirehoseWorker] Successfully added Margin annotation to collection - user: did:plc:rlknsba2qldjkicxsmni3vyn, cardId: eca2df55..., collectionId: ce2891f7... 37 + [SYNC] [FirehoseWorker] Successfully added Margin annotation to collection - user: did:plc:rlknsba2qldjkicxsmni3vyn, cardId: e8f84f9f..., collectionId: 63d0727a... 38 + [SYNC] [FirehoseWorker] Successfully added Margin annotation to collection - user: did:plc:rlknsba2qldjkicxsmni3vyn, cardId: 0a23c9b0..., collectionId: ce2891f7... 39 + [SYNC] [FirehoseWorker] Successfully added Margin annotation to collection - user: did:plc:rlknsba2qldjkicxsmni3vyn, cardId: 9fd4381a..., collectionId: 63d0727a... 40 + 41 + [SYNC] [SYNC] Batch 1: processed 5/5 records 42 + [SYNC] [SYNC] Processed 5 collection items 43 + ``` 44 + 45 + **Expected Distribution:** 46 + 47 + - Collection `63d0727a`: 3 items (cards: 7c7ae767, e8f84f9f, 9fd4381a) 48 + - Collection `ce2891f7`: 2 items (cards: eca2df55, 0a23c9b0) 49 + 50 + **Actual Result:** Only 1 item in each collection (database query would show this) 51 + 52 + --- 53 + 54 + ## Root Cause: Lost Update Race Condition 55 + 56 + The issue occurs because of concurrent processing combined with a problematic database update pattern. 57 + 58 + ### Concurrent Processing 59 + 60 + **File:** `src/modules/sync/application/useCases/SyncAccountDataUseCase.ts` 61 + 62 + **Lines 231-245:** Collection items are processed in batches with concurrent execution: 63 + 64 + ```typescript 65 + // Process in batches of CONCURRENT_BATCH_SIZE 66 + for (let i = 0; i < records.length; i += CONCURRENT_BATCH_SIZE) { 67 + const batch = records.slice(i, i + CONCURRENT_BATCH_SIZE); 68 + const batchNumber = Math.floor(i / CONCURRENT_BATCH_SIZE) + 1; 69 + 70 + console.log( 71 + `[SYNC] Processing batch ${batchNumber} (${batch.length} records) from page ${pageCount}...`, 72 + ); 73 + 74 + // Process all records in this batch concurrently 75 + const results = await Promise.allSettled( 76 + batch.map((record) => 77 + this.processRecord(record, useCase, curatorId, collectionType), 78 + ), 79 + ); 80 + // ... 81 + } 82 + ``` 83 + 84 + **Key Point:** `CONCURRENT_BATCH_SIZE = 10` (line 208), so up to 10 collection items process simultaneously. 85 + 86 + --- 87 + 88 + ### The Problematic Save Pattern 89 + 90 + **File:** `src/modules/cards/infrastructure/repositories/DrizzleCollectionRepository.ts` 91 + 92 + **Lines 504-529:** The save method uses a DELETE-all-then-INSERT-all pattern: 93 + 94 + ```typescript 95 + async save(collection: Collection): Promise<Result<void, Error>> { 96 + // ... (earlier code preparing data) 97 + 98 + await this.db.transaction(async (tx) => { 99 + // ... (upsert collection record) 100 + 101 + // Delete existing collaborators and card links 102 + await tx 103 + .delete(collectionCollaborators) 104 + .where(eq(collectionCollaborators.collectionId, collectionData.id)); 105 + 106 + await tx 107 + .delete(collectionCards) 108 + .where(eq(collectionCards.collectionId, collectionData.id)); 109 + 110 + // Insert new collaborators 111 + if (collaborators.length > 0) { 112 + await tx.insert(collectionCollaborators).values(collaborators); 113 + } 114 + 115 + // Insert new card links with mapped published record IDs 116 + if (cardLinks.length > 0) { 117 + const cardLinksWithMappedRecords = cardLinks.map((link) => ({ 118 + ...link, 119 + publishedRecordId: link.publishedRecordId 120 + ? linkPublishedRecordMap.get(link.publishedRecordId) || 121 + link.publishedRecordId 122 + : undefined, 123 + })); 124 + 125 + await tx.insert(collectionCards).values(cardLinksWithMappedRecords); 126 + } 127 + }); 128 + } 129 + ``` 130 + 131 + **The Problem:** This pattern completely replaces all collection cards based on the in-memory collection state, regardless of what other concurrent transactions might be doing. 132 + 133 + --- 134 + 135 + ## Complete Code Flow 136 + 137 + Here's the full path from sync to database for adding a collection item: 138 + 139 + ### 1. Sync Initiation 140 + 141 + **File:** `src/modules/sync/application/useCases/SyncAccountDataUseCase.ts` 142 + 143 + **Line 184-187:** Sync collection items (third phase after bookmarks and collections): 144 + 145 + ```typescript 146 + // 3. Sync collection items (links cards to collections) 147 + console.log(`[SYNC] Syncing collection items for ${curatorId}...`); 148 + const itemsProcessed = await this.syncCollection( 149 + curatorId, 150 + ATPROTO_NSID.MARGIN.COLLECTION_ITEM, 151 + this.processMarginCollectionItemUseCase, 152 + ); 153 + ``` 154 + 155 + ### 2. Process Individual Collection Item 156 + 157 + **File:** `src/modules/atproto/application/useCases/ProcessMarginCollectionItemFirehoseEventUseCase.ts` 158 + 159 + **Lines 152-162:** Call UpdateUrlCardAssociationsUseCase: 160 + 161 + ```typescript 162 + const result = await this.updateUrlCardAssociationsUseCase.execute({ 163 + cardId: cardId.value.getStringValue(), 164 + curatorId: curatorDid, 165 + addToCollections: [collectionId.value.getStringValue()], 166 + context: OperationContext.FIREHOSE_EVENT, 167 + publishedRecordIds: { 168 + collectionLinks: collectionLinkMap, 169 + }, 170 + viaCardId: undefined, 171 + timestamp: timestamp, 172 + }); 173 + ``` 174 + 175 + ### 3. Update URL Card Associations 176 + 177 + **File:** `src/modules/cards/application/useCases/commands/UpdateUrlCardAssociationsUseCase.ts` 178 + 179 + **Lines 314-321:** Call CardCollectionService to add card to collections: 180 + 181 + ```typescript 182 + const addToCollectionsResult = 183 + await this.cardCollectionService.addCardToCollections( 184 + urlCard, 185 + collectionIds, 186 + curatorId, 187 + viaCardId, 188 + collectionOptions, 189 + ); 190 + ``` 191 + 192 + ### 4. Card Collection Service 193 + 194 + **File:** `src/modules/cards/domain/services/CardCollectionService.ts` 195 + 196 + **Lines 144-174:** Loop through collections and add card to each: 197 + 198 + ```typescript 199 + async addCardToCollections( 200 + card: Card, 201 + collectionIds: CollectionId[], 202 + addedBy: string, 203 + viaCard?: CardId, 204 + options?: CollectionCardOptions, 205 + ): Promise<Result<Collection[], Error>> { 206 + const updatedCollections: Collection[] = []; 207 + 208 + for (const collectionId of collectionIds) { 209 + const result = await this.addCardToCollection( 210 + collectionId, 211 + card, 212 + addedBy, 213 + viaCard, 214 + options, 215 + ); 216 + 217 + if (result.isErr()) { 218 + return err(result.error); 219 + } 220 + 221 + updatedCollections.push(result.value); 222 + } 223 + 224 + return ok(updatedCollections); 225 + } 226 + ``` 227 + 228 + ### 5. Add Card to Single Collection 229 + 230 + **File:** `src/modules/cards/domain/services/CardCollectionService.ts` 231 + 232 + **Lines 38-142:** The critical read-modify-write sequence: 233 + 234 + ```typescript 235 + async addCardToCollection( 236 + collectionId: CollectionId, 237 + card: Card, 238 + addedBy: string, 239 + viaCard?: CardId, 240 + options?: CollectionCardOptions, 241 + ): Promise<Result<Collection, Error>> { 242 + // ... validation ... 243 + 244 + // 📖 READ: Fetch collection from database 245 + const collectionResult = 246 + await this.collectionRepository.findById(collectionId); 247 + if (collectionResult.isErr()) { 248 + return err(collectionResult.error); 249 + } 250 + const collection = collectionResult.value; 251 + if (!collection) { 252 + return err(new Error(`Collection with id ${collectionId} not found`)); 253 + } 254 + 255 + // ... more validation ... 256 + 257 + // ✏️ MODIFY: Add card to in-memory collection 258 + const addCardResult = collection.addCard( 259 + card.id, 260 + addedBy, 261 + viaCard, 262 + options?.publishedRecordId, 263 + options?.timestamp, 264 + ); 265 + 266 + if (addCardResult.isErr()) { 267 + return err(addCardResult.error); 268 + } 269 + 270 + // ... increment library count ... 271 + 272 + // 💾 WRITE: Save collection back to database 273 + const saveCollectionResult = 274 + await this.collectionRepository.save(collection); 275 + if (saveCollectionResult.isErr()) { 276 + return err(saveCollectionResult.error); 277 + } 278 + 279 + return ok(collection); 280 + } 281 + ``` 282 + 283 + **Critical Issue:** This is a classic **Read-Modify-Write** pattern without any concurrency control! 284 + 285 + ### 6. Repository Save (The Problem!) 286 + 287 + **File:** `src/modules/cards/infrastructure/repositories/DrizzleCollectionRepository.ts` 288 + 289 + **Lines 504-511:** DELETE all existing cards: 290 + 291 + ```typescript 292 + // Delete existing collaborators and card links 293 + await tx 294 + .delete(collectionCollaborators) 295 + .where(eq(collectionCollaborators.collectionId, collectionData.id)); 296 + 297 + await tx 298 + .delete(collectionCards) 299 + .where(eq(collectionCards.collectionId, collectionData.id)); 300 + ``` 301 + 302 + **Lines 514-529:** INSERT only what's in memory: 303 + 304 + ```typescript 305 + // Insert new collaborators 306 + if (collaborators.length > 0) { 307 + await tx.insert(collectionCollaborators).values(collaborators); 308 + } 309 + 310 + // Insert new card links with mapped published record IDs 311 + if (cardLinks.length > 0) { 312 + const cardLinksWithMappedRecords = cardLinks.map((link) => ({ 313 + ...link, 314 + publishedRecordId: link.publishedRecordId 315 + ? linkPublishedRecordMap.get(link.publishedRecordId) || 316 + link.publishedRecordId 317 + : undefined, 318 + })); 319 + 320 + await tx.insert(collectionCards).values(cardLinksWithMappedRecords); 321 + } 322 + ``` 323 + 324 + --- 325 + 326 + ## Race Condition Timeline 327 + 328 + Let's trace what happens when 3 concurrent transactions try to add different cards to Collection X: 329 + 330 + ### Initial State 331 + 332 + - Collection X exists in database 333 + - Collection X has 0 cards 334 + - 3 collection items need to be added: Card-A, Card-B, Card-C 335 + 336 + ### Time T0: All Transactions Start 337 + 338 + **Transaction 1:** Add Card-A to Collection X 339 + **Transaction 2:** Add Card-B to Collection X 340 + **Transaction 3:** Add Card-C to Collection X 341 + 342 + All start concurrently via `Promise.allSettled()`. 343 + 344 + ### Time T1: READ Phase 345 + 346 + **Transaction 1:** 347 + 348 + ```typescript 349 + // CardCollectionService.ts line 54-58 350 + const collectionResult = await this.collectionRepository.findById(collectionId); 351 + // Returns: Collection X with cardLinks = [] 352 + ``` 353 + 354 + **Transaction 2:** 355 + 356 + ```typescript 357 + // CardCollectionService.ts line 54-58 358 + const collectionResult = await this.collectionRepository.findById(collectionId); 359 + // Returns: Collection X with cardLinks = [] 360 + ``` 361 + 362 + **Transaction 3:** 363 + 364 + ```typescript 365 + // CardCollectionService.ts line 54-58 366 + const collectionResult = await this.collectionRepository.findById(collectionId); 367 + // Returns: Collection X with cardLinks = [] 368 + ``` 369 + 370 + **Result:** All 3 transactions see the same initial state (0 cards). 371 + 372 + ### Time T2: MODIFY Phase (In-Memory) 373 + 374 + **Transaction 1:** 375 + 376 + ```typescript 377 + // CardCollectionService.ts line 70-82 378 + collection.addCard(cardA.id, ...); 379 + // In-memory state: collection.cardLinks = [Card-A] 380 + ``` 381 + 382 + **Transaction 2:** 383 + 384 + ```typescript 385 + // CardCollectionService.ts line 70-82 386 + collection.addCard(cardB.id, ...); 387 + // In-memory state: collection.cardLinks = [Card-B] 388 + ``` 389 + 390 + **Transaction 3:** 391 + 392 + ```typescript 393 + // CardCollectionService.ts line 70-82 394 + collection.addCard(cardC.id, ...); 395 + // In-memory state: collection.cardLinks = [Card-C] 396 + ``` 397 + 398 + **Result:** Each transaction has modified its own in-memory copy independently. 399 + 400 + ### Time T3: WRITE Phase (Database Commits) 401 + 402 + **Transaction 1 Commits First:** 403 + 404 + ```typescript 405 + // DrizzleCollectionRepository.ts lines 504-529 406 + await tx.delete(collectionCards).where(eq(collectionCards.collectionId, 'X')); 407 + // Deletes: 0 rows 408 + await tx.insert(collectionCards).values([Card - A]); 409 + // Inserts: Card-A 410 + ``` 411 + 412 + **Database State:** Collection X has 1 card: [Card-A] 413 + 414 + **Transaction 2 Commits Second:** 415 + 416 + ```typescript 417 + // DrizzleCollectionRepository.ts lines 504-529 418 + await tx.delete(collectionCards).where(eq(collectionCards.collectionId, 'X')); 419 + // Deletes: 1 row (Card-A is deleted!) ⚠️ 420 + await tx.insert(collectionCards).values([Card - B]); 421 + // Inserts: Card-B 422 + ``` 423 + 424 + **Database State:** Collection X has 1 card: [Card-B] (Card-A was lost!) 425 + 426 + **Transaction 3 Commits Last:** 427 + 428 + ```typescript 429 + // DrizzleCollectionRepository.ts lines 504-529 430 + await tx.delete(collectionCards).where(eq(collectionCards.collectionId, 'X')); 431 + // Deletes: 1 row (Card-B is deleted!) ⚠️ 432 + await tx.insert(collectionCards).values([Card - C]); 433 + // Inserts: Card-C 434 + ``` 435 + 436 + **Database State:** Collection X has 1 card: [Card-C] (Card-A and Card-B were lost!) 437 + 438 + ### Final Result 439 + 440 + - **Expected:** Collection X has 3 cards: [Card-A, Card-B, Card-C] 441 + - **Actual:** Collection X has 1 card: [Card-C] 442 + - **Lost:** Card-A and Card-B were successfully added in memory but deleted by subsequent transactions 443 + 444 + ### Why This Happens 445 + 446 + The DELETE-all-then-INSERT-all pattern means: 447 + 448 + 1. Each transaction only knows about cards it added in memory 449 + 2. Each transaction deletes ALL cards currently in the database 450 + 3. Each transaction inserts only the cards from its in-memory state 451 + 4. Later commits overwrite earlier commits completely 452 + 453 + --- 454 + 455 + ## Missing Safeguards 456 + 457 + ### 1. No Database-Level Unique Constraint 458 + 459 + **File:** `drizzle/0000_shocking_fenris.sql` 460 + 461 + The migration file does NOT include a `UNIQUE(collection_id, card_id)` constraint on the `collection_cards` table, even though the test schema suggests one was intended. 462 + 463 + **File:** `src/modules/cards/tests/test-utils/createTestSchema.ts` (line 49) 464 + 465 + ```typescript 466 + sql`CREATE TABLE IF NOT EXISTS collection_cards ( 467 + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), 468 + collection_id UUID NOT NULL REFERENCES collections(id) ON DELETE CASCADE, 469 + card_id UUID NOT NULL REFERENCES cards(id) ON DELETE CASCADE, 470 + added_by TEXT NOT NULL, 471 + added_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), 472 + via_card_id UUID REFERENCES cards(id), 473 + published_record_id UUID REFERENCES published_records(id), 474 + UNIQUE(collection_id, card_id) 475 + )`, 476 + ``` 477 + 478 + **Issue:** The production schema doesn't enforce uniqueness, allowing duplicates if the code doesn't prevent them. 479 + 480 + ### 2. No Row-Level Locking 481 + 482 + There's no `SELECT FOR UPDATE` in the repository's `findById` method to prevent concurrent reads of the same collection. 483 + 484 + **File:** `src/modules/cards/infrastructure/repositories/DrizzleCollectionRepository.ts` 485 + 486 + **Line 55-94:** Standard SELECT without locking: 487 + 488 + ```typescript 489 + async findById( 490 + collectionId: CollectionId, 491 + ): Promise<Result<Collection | null, Error>> { 492 + try { 493 + const collectionResult = await this.db 494 + .select({ 495 + id: collections.id, 496 + authorId: collections.authorId, 497 + // ... more fields 498 + }) 499 + .from(collections) 500 + .where(eq(collections.id, collectionId.getStringValue())) 501 + .limit(1); 502 + 503 + // No .for('update') clause! 504 + // ... 505 + } 506 + } 507 + ``` 508 + 509 + ### 3. No Optimistic Concurrency Control 510 + 511 + The `collections` table has no version column to detect concurrent modifications: 512 + 513 + **File:** `src/modules/cards/infrastructure/repositories/schema/collection.sql.ts` 514 + 515 + ```typescript 516 + export const collections = pgTable('collections', { 517 + id: uuid('id').defaultRandom().primaryKey(), 518 + authorId: text('author_id').notNull(), 519 + name: text('name').notNull(), 520 + description: text('description'), 521 + accessType: text('access_type').notNull(), 522 + cardCount: integer('card_count').notNull().default(0), 523 + createdAt: timestamp('created_at', { withTimezone: true }) 524 + .notNull() 525 + .defaultNow(), 526 + updatedAt: timestamp('updated_at', { withTimezone: true }) 527 + .notNull() 528 + .defaultNow(), 529 + publishedRecordId: uuid('published_record_id').references( 530 + () => publishedRecords.id, 531 + ), 532 + // No version column! 533 + }); 534 + ``` 535 + 536 + ### 4. No Idempotent Inserts 537 + 538 + The code doesn't use `INSERT ... ON CONFLICT` to make inserts idempotent and safe for concurrent execution. 539 + 540 + --- 541 + 542 + ## Solution Options 543 + 544 + ### Option 1: INSERT with ON CONFLICT (Recommended for Concurrency) 545 + 546 + **Pros:** 547 + 548 + - ✅ Allows true concurrent operations without blocking 549 + - ✅ Database-enforced correctness via unique constraint 550 + - ✅ Naturally idempotent - safe to retry 551 + - ✅ Best performance for high-concurrency scenarios 552 + 553 + **Cons:** 554 + 555 + - ⚠️ Requires schema migration (add unique constraint) 556 + - ⚠️ More complex repository logic to track additions vs. removals 557 + 558 + **Implementation:** 559 + 560 + 1. **Add unique constraint to schema:** 561 + 562 + **File:** `src/modules/cards/infrastructure/repositories/schema/collection.sql.ts` 563 + 564 + ```typescript 565 + export const collectionCards = pgTable( 566 + 'collection_cards', 567 + { 568 + // ... existing columns ... 569 + }, 570 + (table) => ({ 571 + uniqueCollectionCard: unique().on(table.collectionId, table.cardId), 572 + }), 573 + ); 574 + ``` 575 + 576 + 2. **Generate migration:** `npm run db:generate` 577 + 578 + 3. **Change repository save method:** 579 + 580 + **File:** `src/modules/cards/infrastructure/repositories/DrizzleCollectionRepository.ts` 581 + 582 + ```typescript 583 + // Instead of DELETE + INSERT, use INSERT ON CONFLICT 584 + if (cardLinks.length > 0) { 585 + await tx 586 + .insert(collectionCards) 587 + .values(cardLinksWithMappedRecords) 588 + .onConflictDoUpdate({ 589 + target: [collectionCards.collectionId, collectionCards.cardId], 590 + set: { 591 + publishedRecordId: sql`EXCLUDED.published_record_id`, 592 + addedAt: sql`EXCLUDED.added_at`, 593 + addedBy: sql`EXCLUDED.added_by`, 594 + viaCardId: sql`EXCLUDED.via_card_id`, 595 + }, 596 + }); 597 + } 598 + ``` 599 + 600 + 4. **Track and delete removed cards separately** (if needed for card removal functionality) 601 + 602 + --- 603 + 604 + ### Option 2: SELECT FOR UPDATE Locking (Simplest but Serializes) 605 + 606 + **Pros:** 607 + 608 + - ✅ Simple to implement 609 + - ✅ No schema changes needed 610 + - ✅ Guarantees serial access to collections 611 + 612 + **Cons:** 613 + 614 + - ⚠️ Serializes all updates to the same collection (slower) 615 + - ⚠️ Potential for deadlocks if not careful 616 + - ⚠️ Reduced concurrency 617 + 618 + **Implementation:** 619 + 620 + **File:** `src/modules/cards/infrastructure/repositories/DrizzleCollectionRepository.ts` 621 + 622 + In `findById` method, add row-level locking: 623 + 624 + ```typescript 625 + async findById( 626 + collectionId: CollectionId, 627 + ): Promise<Result<Collection | null, Error>> { 628 + try { 629 + const collectionResult = await this.db 630 + .select({...}) 631 + .from(collections) 632 + .where(eq(collections.id, collectionId.getStringValue())) 633 + .for('update') // ← Add this line 634 + .limit(1); 635 + // ... 636 + } 637 + } 638 + ``` 639 + 640 + This forces concurrent transactions to wait in line, preventing the race condition but reducing throughput. 641 + 642 + --- 643 + 644 + ### Option 3: Application-Level Grouping (No DB Changes) 645 + 646 + **Pros:** 647 + 648 + - ✅ No database schema changes 649 + - ✅ No repository changes 650 + 651 + **Cons:** 652 + 653 + - ⚠️ Requires changes in sync use case 654 + - ⚠️ More complex application logic 655 + - ⚠️ Doesn't prevent races from other code paths (e.g., firehose) 656 + 657 + **Implementation:** 658 + 659 + **File:** `src/modules/sync/application/useCases/SyncAccountDataUseCase.ts` 660 + 661 + Group collection items by collection before processing: 662 + 663 + ```typescript 664 + private async syncCollection(...): Promise<number> { 665 + // ... fetch records ... 666 + 667 + // Group items by collection 668 + const itemsByCollection = new Map<string, any[]>(); 669 + for (const record of records) { 670 + const collectionId = extractCollectionId(record); // Helper function 671 + if (!itemsByCollection.has(collectionId)) { 672 + itemsByCollection.set(collectionId, []); 673 + } 674 + itemsByCollection.get(collectionId)!.push(record); 675 + } 676 + 677 + // Process each collection's items sequentially 678 + for (const [collectionId, items] of itemsByCollection) { 679 + for (const item of items) { 680 + await this.processRecord(item, useCase, curatorId, collectionType); 681 + } 682 + } 683 + } 684 + ``` 685 + 686 + --- 687 + 688 + ### Option 4: Optimistic Concurrency Control (Version Column) 689 + 690 + **Pros:** 691 + 692 + - ✅ Detects conflicts explicitly 693 + - ✅ No blocking/locking 694 + 695 + **Cons:** 696 + 697 + - ⚠️ Requires retry logic and error handling 698 + - ⚠️ Schema changes needed (version column) 699 + - ⚠️ More complex to implement correctly 700 + 701 + **Implementation:** Add a `version` column to collections table and check it during updates. 702 + 703 + --- 704 + 705 + ## Recommended Approach 706 + 707 + **Use Option 1: INSERT with ON CONFLICT** 708 + 709 + This is the most robust solution for a concurrent, distributed system: 710 + 711 + 1. **Add UNIQUE constraint** to `collection_cards` table 712 + 2. **Change save method** to use `INSERT ... ON CONFLICT` 713 + 3. **Maintain concurrent processing** in sync use case 714 + 4. **Ensure idempotency** across all code paths 715 + 716 + **Why this is best:** 717 + 718 + - Handles concurrent operations from both sync AND firehose 719 + - Database enforces correctness (no silent data loss) 720 + - Scales well with high concurrency 721 + - Aligns with the domain model (Collection already prevents duplicate cards) 722 + 723 + --- 724 + 725 + ## Additional Notes 726 + 727 + ### Why Tests Didn't Catch This 728 + 729 + **File:** `src/modules/cards/tests/test-utils/createTestSchema.ts` (line 49) 730 + 731 + The test schema DOES include `UNIQUE(collection_id, card_id)`, which would have helped catch this issue: 732 + 733 + ```typescript 734 + UNIQUE(collection_id, card_id); 735 + ``` 736 + 737 + However, the production migration doesn't have this constraint, creating a gap between test and production schemas. 738 + 739 + ### Schema Drift 740 + 741 + There's a discrepancy between: 742 + 743 + - **Production schema:** `drizzle/0000_shocking_fenris.sql` (no unique constraint) 744 + - **Test schema:** `createTestSchema.ts` (has unique constraint) 745 + 746 + This drift should be resolved to ensure test environments match production. 747 + 748 + --- 749 + 750 + ## Conclusion 751 + 752 + The root cause is a **lost update race condition** caused by: 753 + 754 + 1. Concurrent processing of collection items 755 + 2. Read-Modify-Write pattern without concurrency control 756 + 3. DELETE-all-then-INSERT-all repository save pattern 757 + 758 + The recommended fix is to use **INSERT with ON CONFLICT**, which provides: 759 + 760 + - Database-level concurrency safety 761 + - Idempotent operations 762 + - Maximum throughput for concurrent operations 763 + 764 + Implementation requires: 765 + 766 + - Schema migration to add unique constraint 767 + - Repository method refactoring to use ON CONFLICT 768 + - Alignment of test and production schemas
+32 -7
src/modules/sync/application/useCases/SyncAccountDataUseCase.ts
··· 180 180 console.log(`[SYNC] Processed ${collectionsProcessed} collections`); 181 181 182 182 // 3. Sync collection items (links cards to collections) 183 + // NOTE: Process sequentially to prevent race conditions when multiple items 184 + // are added to the same collection concurrently 183 185 console.log(`[SYNC] Syncing collection items for ${curatorId}...`); 184 186 const itemsProcessed = await this.syncCollection( 185 187 curatorId, 186 188 ATPROTO_NSID.MARGIN.COLLECTION_ITEM, 187 189 this.processMarginCollectionItemUseCase, 190 + true, // sequential = true 188 191 ); 189 192 totalProcessed += itemsProcessed; 190 193 console.log(`[SYNC] Processed ${itemsProcessed} collection items`); ··· 194 197 195 198 /** 196 199 * Sync a specific collection type with batched concurrent processing 200 + * @param sequential - If true, process records sequentially instead of concurrently (prevents race conditions) 197 201 */ 198 202 private async syncCollection( 199 203 curatorId: string, ··· 202 206 | ProcessMarginBookmarkFirehoseEventUseCase 203 207 | ProcessMarginCollectionFirehoseEventUseCase 204 208 | ProcessMarginCollectionItemFirehoseEventUseCase, 209 + sequential: boolean = false, 205 210 ): Promise<number> { 206 211 let totalProcessed = 0; 207 212 let pageCount = 0; ··· 234 239 const batchNumber = Math.floor(i / CONCURRENT_BATCH_SIZE) + 1; 235 240 236 241 console.log( 237 - `[SYNC] Processing batch ${batchNumber} (${batch.length} records) from page ${pageCount}...`, 242 + `[SYNC] Processing batch ${batchNumber} (${batch.length} records) from page ${pageCount}${sequential ? ' [SEQUENTIAL]' : ''}...`, 238 243 ); 239 244 240 - // Process all records in this batch concurrently 241 - const results = await Promise.allSettled( 242 - batch.map((record) => 243 - this.processRecord(record, useCase, curatorId, collectionType), 244 - ), 245 - ); 245 + let results: PromiseSettledResult<boolean>[]; 246 + 247 + if (sequential) { 248 + // Process records one at a time to prevent race conditions 249 + results = []; 250 + for (const record of batch) { 251 + try { 252 + const result = await this.processRecord( 253 + record, 254 + useCase, 255 + curatorId, 256 + collectionType, 257 + ); 258 + results.push({ status: 'fulfilled', value: result }); 259 + } catch (error) { 260 + results.push({ status: 'rejected', reason: error }); 261 + } 262 + } 263 + } else { 264 + // Process all records in this batch concurrently 265 + results = await Promise.allSettled( 266 + batch.map((record) => 267 + this.processRecord(record, useCase, curatorId, collectionType), 268 + ), 269 + ); 270 + } 246 271 247 272 // Count successful processes 248 273 const successCount = results.filter(