@@ -448,4 +448,185 @@ void updateProductType_WithValidChanges_ShouldUpdateProductTypeCorrectly() {
448448 assertThat (fetchedProductType .getName ()).isEqualTo (updatedProductType .getName ());
449449 assertThat (fetchedProductType .getAttributes ()).isEqualTo (updatedProductType .getAttributes ());
450450 }
451+
452+ /*
453+ * This test verifies the cache stampede fix by making concurrent calls
454+ and ensuring the cache is populated correctly without race conditions.
455+
456+ What this test verifies:
457+ 1. All concurrent calls complete successfully (no race conditions)
458+ 2. All calls return the same cached data (cache consistency)
459+ 3. No exceptions occur during concurrent access
460+
461+ NOTE: The tests were execute with logs and it can be seen that only one query is executed.
462+ */
463+ @ Test
464+ void
465+ fetchCachedProductAttributeMetaDataMap_WithConcurrentCalls_ShouldHandleCacheStampedeCorrectly ()
466+ throws Exception {
467+
468+ // preparation - create a product type with attributes
469+ final ProductTypeDraft productTypeDraft =
470+ ProductTypeDraftBuilder .of ()
471+ .key ("cache-stampede-test-type" )
472+ .name ("Cache Stampede Test Type" )
473+ .description ("Test product type for cache stampede fix" )
474+ .attributes (ATTRIBUTE_DEFINITION_DRAFT_1 )
475+ .build ();
476+
477+ final ProductType createdProductType =
478+ CTP_TARGET_CLIENT .productTypes ().post (productTypeDraft ).execute ().join ().getBody ();
479+
480+ final ProductTypeSyncOptions productTypeSyncOptions =
481+ ProductTypeSyncOptionsBuilder .of (CTP_TARGET_CLIENT ).build ();
482+ final ProductTypeService productTypeService =
483+ new ProductTypeServiceImpl (productTypeSyncOptions );
484+
485+ // test - make 10 concurrent calls to fetchCachedProductAttributeMetaDataMap
486+ // Used a CountDownLatch to ensure all threads start at approximately the same time
487+ final int numberOfConcurrentCalls = 10 ;
488+ final java .util .concurrent .CountDownLatch startLatch =
489+ new java .util .concurrent .CountDownLatch (1 );
490+ final java .util .concurrent .CountDownLatch readyLatch =
491+ new java .util .concurrent .CountDownLatch (numberOfConcurrentCalls );
492+ final java .util .concurrent .ExecutorService executorService =
493+ java .util .concurrent .Executors .newFixedThreadPool (numberOfConcurrentCalls );
494+ final java .util .List <
495+ java .util .concurrent .CompletableFuture <Optional <Map <String , AttributeMetaData >>>>
496+ futures = new java .util .ArrayList <>();
497+
498+ for (int i = 0 ; i < numberOfConcurrentCalls ; i ++) {
499+ final java .util .concurrent .CompletableFuture <Optional <Map <String , AttributeMetaData >>>
500+ future =
501+ java .util .concurrent .CompletableFuture .supplyAsync (
502+ () -> {
503+ try {
504+ readyLatch .countDown ();
505+ startLatch .await (); // Wait for all threads to be ready
506+ return productTypeService
507+ .fetchCachedProductAttributeMetaDataMap (createdProductType .getId ())
508+ .toCompletableFuture ()
509+ .join ();
510+ } catch (InterruptedException e ) {
511+ Thread .currentThread ().interrupt ();
512+ throw new RuntimeException (e );
513+ }
514+ },
515+ executorService );
516+ futures .add (future );
517+ }
518+
519+ final boolean allThreadsReady = readyLatch .await (5 , java .util .concurrent .TimeUnit .SECONDS );
520+ assertThat (allThreadsReady ).as ("All threads should be ready within timeout" ).isTrue ();
521+
522+ // Start all threads at once
523+ startLatch .countDown ();
524+
525+ // Wait for all futures to complete
526+ java .util .concurrent .CompletableFuture .allOf (
527+ futures .toArray (new java .util .concurrent .CompletableFuture [0 ]))
528+ .join ();
529+
530+ executorService .shutdown ();
531+ final boolean executorTerminated =
532+ executorService .awaitTermination (10 , java .util .concurrent .TimeUnit .SECONDS );
533+ assertThat (executorTerminated ).as ("Executor service should terminate within timeout" ).isTrue ();
534+
535+ // assertions - all calls should return the same result
536+ final Optional <Map <String , AttributeMetaData >> firstResult = futures .get (0 ).join ();
537+ assertThat (firstResult ).isPresent ();
538+
539+ for (java .util .concurrent .CompletableFuture <Optional <Map <String , AttributeMetaData >>> future :
540+ futures ) {
541+ assertThat (future ).isCompleted ();
542+ final Optional <Map <String , AttributeMetaData >> result = future .join ();
543+ assertThat (result ).isPresent ();
544+ assertThat (result .get ()).containsKey (ATTRIBUTE_DEFINITION_DRAFT_1 .getName ());
545+ // Verify all results are identical (same cached instance)
546+ assertThat (result .get ()).isEqualTo (firstResult .get ());
547+ }
548+
549+ // cleanup
550+ CTP_TARGET_CLIENT
551+ .productTypes ()
552+ .withId (createdProductType .getId ())
553+ .delete ()
554+ .withVersion (createdProductType .getVersion ())
555+ .execute ()
556+ .join ();
557+ }
558+
559+ @ Test
560+ void fetchCachedProductAttributeMetaDataMap_WithPopulatedCache_ShouldReturnCachedData () {
561+ // This test verifies that after the first call, subsequent calls use the cache
562+
563+ // preparation - create a product type
564+ final ProductTypeDraft productTypeDraft =
565+ ProductTypeDraftBuilder .of ()
566+ .key ("cache-reuse-test-type" )
567+ .name ("Cache Reuse Test Type" )
568+ .description ("Test product type for cache reuse" )
569+ .attributes (ATTRIBUTE_DEFINITION_DRAFT_1 , ATTRIBUTE_DEFINITION_DRAFT_2 )
570+ .build ();
571+
572+ final ProductType createdProductType =
573+ CTP_TARGET_CLIENT .productTypes ().post (productTypeDraft ).execute ().join ().getBody ();
574+
575+ final ProductTypeSyncOptions productTypeSyncOptions =
576+ ProductTypeSyncOptionsBuilder .of (CTP_TARGET_CLIENT ).build ();
577+ final ProductTypeService productTypeService =
578+ new ProductTypeServiceImpl (productTypeSyncOptions );
579+
580+ // test - first call to populate cache
581+ final Optional <Map <String , AttributeMetaData >> firstResult =
582+ productTypeService
583+ .fetchCachedProductAttributeMetaDataMap (createdProductType .getId ())
584+ .toCompletableFuture ()
585+ .join ();
586+
587+ // test - second call should use cache
588+ final Optional <Map <String , AttributeMetaData >> secondResult =
589+ productTypeService
590+ .fetchCachedProductAttributeMetaDataMap (createdProductType .getId ())
591+ .toCompletableFuture ()
592+ .join ();
593+
594+ // assertions
595+ assertThat (firstResult ).isPresent ();
596+ assertThat (secondResult ).isPresent ();
597+ assertThat (firstResult .get ()).isEqualTo (secondResult .get ());
598+ assertThat (firstResult .get ()).hasSize (2 );
599+ assertThat (firstResult .get ())
600+ .containsKeys (
601+ ATTRIBUTE_DEFINITION_DRAFT_1 .getName (), ATTRIBUTE_DEFINITION_DRAFT_2 .getName ());
602+
603+ // cleanup
604+ CTP_TARGET_CLIENT
605+ .productTypes ()
606+ .withId (createdProductType .getId ())
607+ .delete ()
608+ .withVersion (createdProductType .getVersion ())
609+ .execute ()
610+ .join ();
611+ }
612+
613+ @ Test
614+ void
615+ fetchCachedProductAttributeMetaDataMap_WithNonExistentProductType_ShouldReturnEmptyOptional () {
616+ // preparation
617+ final ProductTypeSyncOptions productTypeSyncOptions =
618+ ProductTypeSyncOptionsBuilder .of (CTP_TARGET_CLIENT ).build ();
619+ final ProductTypeService productTypeService =
620+ new ProductTypeServiceImpl (productTypeSyncOptions );
621+
622+ // test - query for non-existent product type ID
623+ final Optional <Map <String , AttributeMetaData >> result =
624+ productTypeService
625+ .fetchCachedProductAttributeMetaDataMap ("non-existent-id-12345" )
626+ .toCompletableFuture ()
627+ .join ();
628+
629+ // assertions
630+ assertThat (result ).isEmpty ();
631+ }
451632}
0 commit comments