@@ -448,4 +448,175 @@ 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 = new ProductTypeServiceImpl (productTypeSyncOptions );
483+
484+ // test - make 10 concurrent calls to fetchCachedProductAttributeMetaDataMap
485+ // Used a CountDownLatch to ensure all threads start at approximately the same time
486+ final int numberOfConcurrentCalls = 10 ;
487+ final java .util .concurrent .CountDownLatch startLatch =
488+ new java .util .concurrent .CountDownLatch (1 );
489+ final java .util .concurrent .CountDownLatch readyLatch =
490+ new java .util .concurrent .CountDownLatch (numberOfConcurrentCalls );
491+ final java .util .concurrent .ExecutorService executorService =
492+ java .util .concurrent .Executors .newFixedThreadPool (numberOfConcurrentCalls );
493+ final java .util .List <java .util .concurrent .CompletableFuture <Optional <Map <String , AttributeMetaData >>>>
494+ futures = new java .util .ArrayList <>();
495+
496+ for (int i = 0 ; i < numberOfConcurrentCalls ; i ++) {
497+ final java .util .concurrent .CompletableFuture <Optional <Map <String , AttributeMetaData >>> future =
498+ java .util .concurrent .CompletableFuture .supplyAsync (
499+ () -> {
500+ try {
501+ readyLatch .countDown ();
502+ startLatch .await (); // Wait for all threads to be ready
503+ return productTypeService
504+ .fetchCachedProductAttributeMetaDataMap (createdProductType .getId ())
505+ .toCompletableFuture ()
506+ .join ();
507+ } catch (InterruptedException e ) {
508+ Thread .currentThread ().interrupt ();
509+ throw new RuntimeException (e );
510+ }
511+ },
512+ executorService );
513+ futures .add (future );
514+ }
515+
516+ readyLatch .await (5 , java .util .concurrent .TimeUnit .SECONDS );
517+
518+ // Start all threads at once
519+ startLatch .countDown ();
520+
521+ // Wait for all futures to complete
522+ java .util .concurrent .CompletableFuture .allOf (futures .toArray (new java .util .concurrent .CompletableFuture [0 ]))
523+ .join ();
524+
525+ executorService .shutdown ();
526+ executorService .awaitTermination (10 , java .util .concurrent .TimeUnit .SECONDS );
527+
528+ // assertions - all calls should return the same result
529+ final Optional <Map <String , AttributeMetaData >> firstResult = futures .get (0 ).join ();
530+ assertThat (firstResult ).isPresent ();
531+
532+ for (java .util .concurrent .CompletableFuture <Optional <Map <String , AttributeMetaData >>> future :
533+ futures ) {
534+ assertThat (future ).isCompleted ();
535+ final Optional <Map <String , AttributeMetaData >> result = future .join ();
536+ assertThat (result ).isPresent ();
537+ assertThat (result .get ()).containsKey (ATTRIBUTE_DEFINITION_DRAFT_1 .getName ());
538+ // Verify all results are identical (same cached instance)
539+ assertThat (result .get ()).isEqualTo (firstResult .get ());
540+ }
541+
542+ // cleanup
543+ CTP_TARGET_CLIENT
544+ .productTypes ()
545+ .withId (createdProductType .getId ())
546+ .delete ()
547+ .withVersion (createdProductType .getVersion ())
548+ .execute ()
549+ .join ();
550+ }
551+
552+ @ Test
553+ void fetchCachedProductAttributeMetaDataMap_WithPopulatedCache_ShouldReturnCachedData () {
554+ // This test verifies that after the first call, subsequent calls use the cache
555+
556+ // preparation - create a product type
557+ final ProductTypeDraft productTypeDraft =
558+ ProductTypeDraftBuilder .of ()
559+ .key ("cache-reuse-test-type" )
560+ .name ("Cache Reuse Test Type" )
561+ .description ("Test product type for cache reuse" )
562+ .attributes (ATTRIBUTE_DEFINITION_DRAFT_1 , ATTRIBUTE_DEFINITION_DRAFT_2 )
563+ .build ();
564+
565+ final ProductType createdProductType =
566+ CTP_TARGET_CLIENT .productTypes ().post (productTypeDraft ).execute ().join ().getBody ();
567+
568+ final ProductTypeSyncOptions productTypeSyncOptions =
569+ ProductTypeSyncOptionsBuilder .of (CTP_TARGET_CLIENT ).build ();
570+ final ProductTypeService productTypeService = new ProductTypeServiceImpl (productTypeSyncOptions );
571+
572+ // test - first call to populate cache
573+ final Optional <Map <String , AttributeMetaData >> firstResult =
574+ productTypeService
575+ .fetchCachedProductAttributeMetaDataMap (createdProductType .getId ())
576+ .toCompletableFuture ()
577+ .join ();
578+
579+ // test - second call should use cache
580+ final Optional <Map <String , AttributeMetaData >> secondResult =
581+ productTypeService
582+ .fetchCachedProductAttributeMetaDataMap (createdProductType .getId ())
583+ .toCompletableFuture ()
584+ .join ();
585+
586+ // assertions
587+ assertThat (firstResult ).isPresent ();
588+ assertThat (secondResult ).isPresent ();
589+ assertThat (firstResult .get ()).isEqualTo (secondResult .get ());
590+ assertThat (firstResult .get ()).hasSize (2 );
591+ assertThat (firstResult .get ()).containsKeys (
592+ ATTRIBUTE_DEFINITION_DRAFT_1 .getName (), ATTRIBUTE_DEFINITION_DRAFT_2 .getName ());
593+
594+ // cleanup
595+ CTP_TARGET_CLIENT
596+ .productTypes ()
597+ .withId (createdProductType .getId ())
598+ .delete ()
599+ .withVersion (createdProductType .getVersion ())
600+ .execute ()
601+ .join ();
602+ }
603+
604+ @ Test
605+ void
606+ fetchCachedProductAttributeMetaDataMap_WithNonExistentProductType_ShouldReturnEmptyOptional () {
607+ // preparation
608+ final ProductTypeSyncOptions productTypeSyncOptions =
609+ ProductTypeSyncOptionsBuilder .of (CTP_TARGET_CLIENT ).build ();
610+ final ProductTypeService productTypeService = new ProductTypeServiceImpl (productTypeSyncOptions );
611+
612+ // test - query for non-existent product type ID
613+ final Optional <Map <String , AttributeMetaData >> result =
614+ productTypeService
615+ .fetchCachedProductAttributeMetaDataMap ("non-existent-id-12345" )
616+ .toCompletableFuture ()
617+ .join ();
618+
619+ // assertions
620+ assertThat (result ).isEmpty ();
621+ }
451622}
0 commit comments