diff --git a/src/main/java/com/igot/cb/cache/AccessSettingRuleCacheMgr.java b/src/main/java/com/igot/cb/cache/AccessSettingRuleCacheMgr.java index 88177e2..d6f1be4 100644 --- a/src/main/java/com/igot/cb/cache/AccessSettingRuleCacheMgr.java +++ b/src/main/java/com/igot/cb/cache/AccessSettingRuleCacheMgr.java @@ -5,12 +5,15 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.stream.Collectors; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; import com.github.benmanes.caffeine.cache.Cache; import com.github.benmanes.caffeine.cache.Caffeine; import com.igot.cb.cassandra.CassandraOperation; import jakarta.annotation.PostConstruct; import org.apache.commons.collections.CollectionUtils; import org.apache.commons.collections.MapUtils; +import org.apache.commons.lang3.StringUtils; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; @@ -30,8 +33,6 @@ public class AccessSettingRuleCacheMgr { private final CassandraOperation cassandraOperation; private Map cachedAccessSettingRules = new ConcurrentHashMap<>(); - private Cache accessSettingsCache; - private final long LOCAL_CACHE_TTL = 3600000; @@ -42,10 +43,6 @@ public class AccessSettingRuleCacheMgr { @PostConstruct public void initCache() { - accessSettingsCache = Caffeine.newBuilder() - .maximumSize(1000) - .expireAfterWrite(Duration.ofMinutes(ttlMinutes)) - .build(); } @@ -231,10 +228,35 @@ BitSet createBitSetForAttribute(Collection attributeValues) { public CachedAccessSettingRule getOrLoadAccessSettingRule(String courseId, String contextId) { String cacheKey = courseId + "|" + contextId; - CachedAccessSettingRule cachedRule = accessSettingsCache.getIfPresent(cacheKey); - if (cachedRule != null) { - log.debug("Cache hit for rule key: {}", cacheKey); - return cachedRule; + // Use Redis for caching instead of in-memory cache + String cachedData = redisCacheMgr.getFromCache(cacheKey); + if (StringUtils.isNotBlank(cachedData)) { + try { + // Try to parse as Map first (since Redis stores the full object as JSON) + ObjectMapper mapper = new ObjectMapper(); + Map ruleMap = mapper.readValue(cachedData, new TypeReference>() {}); + String contextIdVal = (String) ruleMap.getOrDefault(Constants.CONTEXT_ID_KEY, ruleMap.get(Constants.CONTEXT_ID_KEY)); + String contextIdTypeVal = (String) ruleMap.getOrDefault(Constants.CONTEXT_ID_TYPE, ruleMap.get(Constants.CONTEXT_ID_TYPE)); + Object contextDataObj = ruleMap.get(Constants.CONTEXT_DATA_KEY); + String contextDataStr; + if (contextDataObj instanceof String) { + contextDataStr = (String) contextDataObj; + } else if (contextDataObj != null) { + contextDataStr = mapper.writeValueAsString(contextDataObj); + } else { + contextDataStr = "{}"; + } + boolean isArchived = false; + if (ruleMap.containsKey(Constants.IS_ARCHIVED)) { + isArchived = Boolean.TRUE.equals(ruleMap.get(Constants.IS_ARCHIVED)); + } + CachedAccessSettingRule cachedRule = new CachedAccessSettingRule( + contextIdVal, contextIdTypeVal, contextDataStr, isArchived); + log.debug("Cache hit for rule key: {} from Redis", cacheKey); + return cachedRule; + } catch (Exception e) { + log.error("Failed to parse cached rule from Redis for key {}: {}", cacheKey, e.getMessage(), e); + } } log.info("Cache miss for rule key: {}, loading from Cassandra...", cacheKey); try { @@ -253,22 +275,20 @@ public CachedAccessSettingRule getOrLoadAccessSettingRule(String courseId, Strin log.warn("No access setting rule found in Cassandra for key: {}", cacheKey); return null; } - Map r = records.get(0); + Map ruleMap = records.get(0); CachedAccessSettingRule loadedRule = new CachedAccessSettingRule( - (String) r.get(Constants.CONTEXT_ID_KEY), - (String) r.get(Constants.CONTEXT_ID_TYPE), - (String) r.get(Constants.CONTEXT_DATA_KEY), - false + (String) ruleMap.get(Constants.CONTEXT_ID_KEY), + (String) ruleMap.get(Constants.CONTEXT_ID_TYPE), + (String) ruleMap.get(Constants.CONTEXT_DATA_KEY), + (Boolean) ruleMap.get(Constants.IS_ARCHIVED_KEY) ); try { - Map contextData = loadedRule.getContextData(); - if (MapUtils.isNotEmpty(contextData)) { - processContextData(cacheKey, contextData); - } - accessSettingsCache.put(cacheKey, loadedRule); - log.info("Loaded and cached rule for key: {}", cacheKey); + // Store in Redis for future requests + String json = new ObjectMapper().writeValueAsString(ruleMap); + redisCacheMgr.putInCache(cacheKey, json, ttlMinutes); // TTL 1 hour, adjust as needed + log.info("Loaded and cached rule for key: {} in Redis", cacheKey); } catch (Exception e) { - log.error("Error processing rule {}: {}", cacheKey, e.getMessage(), e); + log.error("Error serializing rule {}: {}", cacheKey, e.getMessage(), e); } return loadedRule; } catch (Exception e) { diff --git a/src/main/java/com/igot/cb/cache/CbPlanCacheMgr.java b/src/main/java/com/igot/cb/cache/CbPlanCacheMgr.java index 5beb08d..cd52f39 100644 --- a/src/main/java/com/igot/cb/cache/CbPlanCacheMgr.java +++ b/src/main/java/com/igot/cb/cache/CbPlanCacheMgr.java @@ -14,6 +14,8 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; import com.github.benmanes.caffeine.cache.Cache; import com.github.benmanes.caffeine.cache.Caffeine; import com.igot.cb.cassandra.CassandraOperation; @@ -35,29 +37,50 @@ public class CbPlanCacheMgr { @Value("${cb.plan.caffine.cache.max.size:5000}") private int maxCacheSize; + @Value("${cbplan.client.cache.ttl.seconds:3600}") + private int clientCacheTtlSeconds; + private final CassandraOperation cassandraOperation; - private Cache>> cbPlanCache; + private final RedisCacheMgr redisCacheMgr; + private final ObjectMapper objectMapper = new ObjectMapper(); + // Store only active plan IDs in cache, not full plan objects + private Cache> cbPlanIdCache; + + public CbPlanCacheMgr(CassandraOperation cassandraOperation, RedisCacheMgr redisCacheMgr) { + this.cassandraOperation = cassandraOperation; + this.redisCacheMgr = redisCacheMgr; + } @PostConstruct public void initCache() { - this.cbPlanCache = Caffeine.newBuilder() + this.cbPlanIdCache = Caffeine.newBuilder() .maximumSize(maxCacheSize) .expireAfterWrite(Duration.ofMinutes(ttlMinutes)) .build(); } - - public CbPlanCacheMgr(CassandraOperation cassandraOperation) { - this.cassandraOperation = cassandraOperation; - } - private List> getCbPlanForAll() { - String redisCacheKey = "all-lookup"; - List> allCbPlanList = cbPlanCache.getIfPresent(redisCacheKey); - if (allCbPlanList == null) { - log.info("No CB Plans for all orgs in Cache, reading from Cassandra"); + /** + * Fetches only active plan IDs for all orgs and caches them. + */ + private List getActivePlanIdsForAll() { + String cacheKey = "all-lookup"; + // Use the same key for Redis and Caffeine + List planIds = null; + String cached = redisCacheMgr.getFromCache(cacheKey); + if (cached != null) { + try { + planIds = objectMapper.readValue(cached, new TypeReference>() {}); + log.info("Cache hit for all orgs in Redis: Found {} plan IDs", planIds.size()); + } catch (Exception e) { + log.warn("Failed to parse plan IDs from Redis for all orgs: {}", e.getMessage()); + } + } + if (CollectionUtils.isEmpty(planIds)) { + planIds = null; + log.info("No CB Plan IDs for all orgs in Redis, reading from Cassandra"); Map propertiesMap = new HashMap<>(); propertiesMap.put(Constants.PLAN_YEAR, "ALL"); - allCbPlanList = cassandraOperation.getRecordsByProperties( + List> allCbPlanList = cassandraOperation.getRecordsByProperties( Constants.KEYSPACE_SUNBIRD, Constants.TABLE_CB_PLAN_V2_LOOKUP_BY_ALL_ORG, propertiesMap, @@ -66,88 +89,147 @@ private List> getCbPlanForAll() { if (allCbPlanList == null) { allCbPlanList = new ArrayList<>(); } - allCbPlanList = allCbPlanList.stream() - .filter(plan -> Boolean.TRUE.equals(plan.get(Constants.IS_ACTIVE))).collect(Collectors.toList()); - cbPlanCache.put(redisCacheKey, allCbPlanList); + planIds = allCbPlanList.stream() + .filter(plan -> Boolean.TRUE.equals(plan.get(Constants.IS_ACTIVE))) + .map(plan -> (String) plan.get(Constants.PLAN_ID)) + .collect(Collectors.toList()); + // Cache in Redis for all orgs + try { + redisCacheMgr.putInCache(cacheKey, objectMapper.writeValueAsString(planIds), clientCacheTtlSeconds); + } catch (Exception e) { + log.warn("Failed to cache plan IDs in Redis for all orgs: {}", e.getMessage()); + } + // Optionally update Caffeine for legacy/local fallback + cbPlanIdCache.put(cacheKey, planIds); } else { - log.info("Cache hit for all orgs: Found {} records", allCbPlanList.size()); + log.info("Cache hit for all orgs: Found {} plan IDs", planIds.size()); } - return allCbPlanList; + return planIds; } - private List> getCbPlanForOrgId(String orgId) { - String redisCacheKey = orgId + "-lookup"; - List> cbPlanList = cbPlanCache.getIfPresent(redisCacheKey); - - if (cbPlanList == null) { - log.info("No CB Plans for orgId in Cache: {}, reading from Cassandra", orgId); - Map propertiesMap = new HashMap<>(); - propertiesMap.put(Constants.ORG_ID, orgId); - cbPlanList = cassandraOperation.getRecordsByProperties( - Constants.KEYSPACE_SUNBIRD, - Constants.TABLE_CB_PLAN_V2_LOOKUP_BY_ORG, - propertiesMap, - new ArrayList<>(), - null); - if (cbPlanList == null) { - cbPlanList = new ArrayList<>(); + /** + * Fetches only active plan IDs for a specific org and caches them in Redis and Caffeine. + */ + private List getActivePlanIdsForOrgId(String orgId) { + String redisKey = "cbplan_ids_" + orgId; + // Try Redis first + String cached = redisCacheMgr.getFromCache(redisKey); + if (cached != null) { + try { + List planIds = objectMapper.readValue(cached, new TypeReference>() {}); + // Only use Redis, do not update local Caffeine cache + return planIds; + } catch (Exception e) { + log.warn("Failed to parse plan IDs from Redis for orgId {}: {}", orgId, e.getMessage()); } - cbPlanList = cbPlanList.stream() - .filter(plan -> Boolean.TRUE.equals(plan.get(Constants.IS_ACTIVE))).collect(Collectors.toList()); - cbPlanCache.put(redisCacheKey, cbPlanList); - cbPlanList.addAll(getCbPlanForAll()); - } else { - log.info("Cache hit for orgId: {}, Found {} records", orgId, cbPlanList.size()); } - - return cbPlanList; + // Fallback to Cassandra + log.info("No CB Plan IDs for orgId in Redis: {}, reading from Cassandra", orgId); + Map propertiesMap = new HashMap<>(); + propertiesMap.put(Constants.ORG_ID, orgId); + // Batchwise fetch using planIds list, similar to getCbPlansByPlanIdsInBatch + List allPlanIds = new ArrayList<>(); + List orgPlanIds = new ArrayList<>(); + // First, fetch all plan IDs for the org in one go (using offset/limit if needed) + // (Assume propertiesMap is set up with orgId) + List> allLookupRows = new ArrayList<>(); + int fetchBatchSize = planBatchSize; + int offset = 0; + boolean moreRecords = true; + while (moreRecords) { + Map batchProps = new HashMap<>(propertiesMap); + batchProps.put("offset", offset); + batchProps.put("limit", fetchBatchSize); + List> cbPlanList = cassandraOperation.getRecordsByProperties( + Constants.KEYSPACE_SUNBIRD, + Constants.TABLE_CB_PLAN_V2_LOOKUP_BY_ORG, + batchProps, + new ArrayList<>(), + null); + if (cbPlanList == null || cbPlanList.isEmpty()) { + moreRecords = false; + } else { + allLookupRows.addAll(cbPlanList); + offset += fetchBatchSize; + if (cbPlanList.size() < fetchBatchSize) { + moreRecords = false; + } + } + } + // Now, collect all plan IDs from the lookup rows + orgPlanIds = allLookupRows.stream() + .filter(plan -> Boolean.TRUE.equals(plan.get(Constants.IS_ACTIVE))) + .map(plan -> (String) plan.get(Constants.PLAN_ID)) + .collect(Collectors.toList()); + // Add 'all' org plans as well + allPlanIds.addAll(orgPlanIds); + allPlanIds.addAll(getActivePlanIdsForAll()); + // Cache in Redis only + try { + redisCacheMgr.putInCache(redisKey, objectMapper.writeValueAsString(allPlanIds), clientCacheTtlSeconds); + } catch (Exception e) { + log.warn("Failed to cache plan IDs in Redis for orgId {}: {}", orgId, e.getMessage()); + } + // Do not update Caffeine cache + return allPlanIds; } + /** + * Public API: Returns full plan details for org, using only plan IDs from cache, fetching details in batch from Cassandra. + */ public List> getCbPlanForAllAndOrgId(String orgId, AtomicBoolean isCacheEnabled) { - List> activeCbPlans = cbPlanCache.getIfPresent(orgId); - if (CollectionUtils.isNotEmpty(activeCbPlans)) { - log.info("Cache hit for orgId: {}, Found {} active CB Plans", orgId, activeCbPlans.size()); - isCacheEnabled.set(true); - return activeCbPlans; + // Try Redis first for org-specific plan IDs + List planIds = null; + String redisKey = "cbplan_ids_" + orgId; + String cached = redisCacheMgr.getFromCache(redisKey); + if (cached != null) { + try { + planIds = objectMapper.readValue(cached, new TypeReference>() {}); + log.info("Cache hit for orgId: {}, Found {} active CB Plan IDs in Redis", orgId, planIds.size()); + isCacheEnabled.set(true); + } catch (Exception e) { + log.warn("Failed to parse plan IDs from Redis for orgId {}: {}", orgId, e.getMessage()); + } + } + if (CollectionUtils.isEmpty(planIds)) { + planIds = getActivePlanIdsForOrgId(orgId); + if (planIds.isEmpty()) { + log.info("No CB Plan IDs found for orgId: {}", orgId); + return new ArrayList<>(); + } } - List> cbPlanList = getCbPlanForOrgId(orgId); + // Fetch plan details in batch using only plan IDs (primary keys) + List> cbPlanList = getCbPlansByPlanIdsInBatch(planIds); if (cbPlanList.isEmpty()) { - log.info("No CB Plans found for orgId: {}", orgId); - cbPlanList = new ArrayList<>(); - cbPlanCache.put(orgId, cbPlanList); - return cbPlanList; + log.info("No CB Plans found for orgId: {} after batch fetch", orgId); + return new ArrayList<>(); } - cbPlanList = cbPlanList.stream() + cbPlanList = cbPlanList.stream() .filter(m -> m.get(Constants.END_DATE_REQUEST) != null) .sorted(Comparator.comparing( m -> (Instant) m.get(Constants.END_DATE_REQUEST), Comparator.reverseOrder() )) .collect(Collectors.toList()); - List planIds = cbPlanList.stream() - .map(plan -> (String) plan.get(Constants.PLAN_ID)).collect(Collectors.toList()); - Map propertiesMap = new HashMap<>(); - propertiesMap.put(Constants.PLAN_ID, planIds); - List> existingCbPlans = cassandraOperation.getRecordsByProperties( - Constants.KEYSPACE_SUNBIRD, - Constants.TABLE_CB_PLAN_V2, - propertiesMap, - new ArrayList<>(), - null); - if (existingCbPlans == null) { - log.error("Failed to read cassandra for cb plan, for PlanIds: {}", planIds); - return new ArrayList<>(); - } - - activeCbPlans = existingCbPlans.stream() - .filter(plan -> Constants.LIVE.equalsIgnoreCase((String) plan.get(Constants.STATUS))).collect(Collectors.toList()); - //TODO - Need to remove draftData (if available) and also contextData.accessControl - log.info("Found {} CB Plans for orgId: {}, active count: {}", existingCbPlans.size(), orgId, activeCbPlans.size()); - cbPlanCache.put(orgId, activeCbPlans); - isCacheEnabled.set(true); + // Only return LIVE plans + List> activeCbPlans = cbPlanList.stream() + .filter(plan -> Constants.LIVE.equalsIgnoreCase((String) plan.get(Constants.STATUS))) + .collect(Collectors.toList()); + log.info("Found {} CB Plans for orgId: {}, active count: {}", cbPlanList.size(), orgId, activeCbPlans.size()); return activeCbPlans; } + /** + * Public API: Returns plan IDs and TTL for org, for client-side caching. + */ + public Map getPlanIdsAndTtlForOrg(String orgId) { + List planIds = getActivePlanIdsForOrgId(orgId); + Map result = new HashMap<>(); + result.put("planIds", planIds); + result.put("cacheTtlSeconds", clientCacheTtlSeconds); + return result; + } + public List> getCbPlansByPlanIdsInBatch(List planIds) { List> allCbPlans = new ArrayList<>(); diff --git a/src/main/java/com/igot/cb/cache/PromotionalContentRuleCacheMgr.java b/src/main/java/com/igot/cb/cache/PromotionalContentRuleCacheMgr.java index edd854f..1af9817 100644 --- a/src/main/java/com/igot/cb/cache/PromotionalContentRuleCacheMgr.java +++ b/src/main/java/com/igot/cb/cache/PromotionalContentRuleCacheMgr.java @@ -11,6 +11,8 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; import java.util.*; import java.util.concurrent.ConcurrentHashMap; @@ -25,69 +27,80 @@ public class PromotionalContentRuleCacheMgr { private final CassandraOperation cassandraOperation; private final CbExtServerProperties properties; - Map cacheMap = new ConcurrentHashMap<>(); + private final RedisCacheMgr redisCacheMgr; + private final ObjectMapper objectMapper = new ObjectMapper(); + private static final String REDIS_KEY = "promotional_content_rules"; @Value("${promotional.content.rules.cache.expiry.ms}") private Integer promotionalContentRulesCacheExpiryMs; /** * Constructs the cache manager with required dependencies. */ + public PromotionalContentRuleCacheMgr(CassandraOperation cassandraOperation, - CbExtServerProperties properties) { + CbExtServerProperties properties, + RedisCacheMgr redisCacheMgr) { this.cassandraOperation = cassandraOperation; this.properties = properties; + this.redisCacheMgr = redisCacheMgr; } /** - * Retrieves all cached access rules. - * Returns values from indexed cache (O(1) per rule lookup). - * Automatically reloads from database when cache is empty or expired. - * + * Retrieves all cached access rules from Redis. If Redis is empty, loads from Cassandra and updates Redis. * @return collection of cached rules, empty collection if none available */ public Collection getAccessSettingRules() { - Collection cachedRules = cacheMap.values(); - if (CollectionUtils.isEmpty(cachedRules)) { - log.info("Cache is empty (size: {}), loading from database", cacheMap.size()); - loadAccessSettingRules(); - cachedRules = cacheMap.values(); - log.info("After reload, cache contains {} rules", cachedRules.size()); - return cachedRules; - } - for (CachedAccessSettingRule rule : cachedRules) { - if (rule == null) { - log.warn("Found null cached rule entry, triggering reload"); - loadAccessSettingRules(); - return cacheMap.values(); - } - try { - if (rule.isExpired(promotionalContentRulesCacheExpiryMs)) { - log.info("Found expired rule (key={}), reloading cache", rule.getCacheKey()); - loadAccessSettingRules(); - return cacheMap.values(); + try { + String redisData = redisCacheMgr.getFromCache(REDIS_KEY); + if (StringUtils.isNotBlank(redisData)) { + // Deserialize as Map> + Map> redisMap = objectMapper.readValue( + redisData, new TypeReference>>() {}); + Map result = new HashMap<>(); + for (Map.Entry> entry : redisMap.entrySet()) { + Map value = entry.getValue(); + String contextId = (String) value.getOrDefault("contextId", value.get("contextid")); + String contextIdType = (String) value.getOrDefault("contextIdType", value.get("contextidtype")); + Object contextDataObj = value.getOrDefault("contextData", value.get("contextdata")); + String contextDataStr; + if (contextDataObj instanceof String) { + contextDataStr = (String) contextDataObj; + } else { + contextDataStr = objectMapper.writeValueAsString(contextDataObj); + } + boolean isArchived = false; + if (value.containsKey("isArchived")) { + isArchived = Boolean.TRUE.equals(value.get("isArchived")); + } else if (value.containsKey("isarchived")) { + isArchived = Boolean.TRUE.equals(value.get("isarchived")); + } else if (value.containsKey("archived")) { + isArchived = Boolean.TRUE.equals(value.get("archived")); + } + CachedAccessSettingRule rule = new CachedAccessSettingRule(contextId, contextIdType, contextDataStr, isArchived); + result.put(entry.getKey(), rule); } - } catch (Exception e) { - log.warn("Error checking expiry for rule {}: {}. Triggering reload.", rule.getCacheKey(), e.getMessage()); - loadAccessSettingRules(); - return cacheMap.values(); + log.info("Loaded {} promotional content rules from Redis cache", result.size()); + return result.values(); + } else { + log.info("Redis cache is empty, loading from Cassandra"); + Map loaded = loadAccessSettingRulesFromCassandraAndUpdateRedis(); + return loaded.values(); } + } catch (Exception e) { + log.error("Failed to load promotional content rules from Redis. Error: {}", e.getMessage(), e); + Map loaded = loadAccessSettingRulesFromCassandraAndUpdateRedis(); + return loaded.values(); } - return cachedRules; } /** - * Loads access rules from Cassandra into indexed cache. - * Filters out archived records at database level. - * Implements pagination to fetch all records in batches for better performance. - * Uses configurable batch size for each query and max query size as upper limit. - * Optimized with Java 17 parallel streams for multi-core CPU utilization. + * Loads access rules from Cassandra, updates Redis, and returns the loaded rules. */ - private void loadAccessSettingRules() { + private Map loadAccessSettingRulesFromCassandraAndUpdateRedis() { int batchSize = properties.getPromotionalContentCacheBatchSize(); int maxQuerySize = properties.getPromotionalContentCacheMaxQuerySize(); - log.info("Loading access setting rules from database - Batch size: {}, Max query size: {}", batchSize, maxQuerySize); - cacheMap.clear(); + Map loadedMap = new HashMap<>(); try { List> allRecords = new ArrayList<>(); int totalFetched = 0; @@ -109,7 +122,6 @@ private void loadAccessSettingRules() { totalFetched += pageRecords.size(); log.info("Fetched {} records on page {}, total so far: {}", pageRecords.size(), pageNumber, totalFetched); - if (pageRecords.size() < batchSize) { log.info("Received fewer records than batch size. Pagination complete."); hasMoreRecords = false; @@ -127,7 +139,7 @@ private void loadAccessSettingRules() { } if (allRecords.isEmpty()) { log.warn("No access setting rules found in database"); - return; + return loadedMap; } log.info("Total records fetched: {}, processing with parallel streams...", allRecords.size()); allRecords.parallelStream() @@ -142,14 +154,23 @@ private void loadAccessSettingRules() { false)) .forEach(rule -> { processAndCacheRule(rule); - cacheMap.put(rule.getCacheKey(), rule); + loadedMap.put(rule.getCacheKey(), rule); }); - long totalProcessed = cacheMap.size(); - log.info("Promotional Content rules loaded into cache successfully. Total rules loaded: {}", + long totalProcessed = loadedMap.size(); + log.info("Promotional Content rules loaded from Cassandra. Total rules loaded: {}", totalProcessed); + // After loading from Cassandra, update Redis + try { + String json = objectMapper.writeValueAsString(loadedMap); + redisCacheMgr.putInCache(REDIS_KEY, json, promotionalContentRulesCacheExpiryMs / 1000); + log.info("Promotional content rules updated in Redis cache"); + } catch (Exception e) { + log.warn("Failed to update promotional content rules in Redis: {}", e.getMessage()); + } } catch (Exception e) { - log.error("Failed to load Promotional Content rules into Cache. Exception: ", e); + log.error("Failed to load Promotional Content rules from Cassandra. Exception: ", e); } + return loadedMap; } /** diff --git a/src/main/java/com/igot/cb/service/AccessSettingsServiceImpl.java b/src/main/java/com/igot/cb/service/AccessSettingsServiceImpl.java index 7e227fb..19f3263 100644 --- a/src/main/java/com/igot/cb/service/AccessSettingsServiceImpl.java +++ b/src/main/java/com/igot/cb/service/AccessSettingsServiceImpl.java @@ -6,8 +6,11 @@ import java.util.Map; import java.util.UUID; +import com.igot.cb.cache.RedisCacheMgr; +import com.igot.cb.model.CachedAccessSettingRule; import com.igot.cb.util.UserGroupUtils; import org.apache.commons.lang.StringUtils; +import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; @@ -29,14 +32,19 @@ public class AccessSettingsServiceImpl { private final CassandraOperation cassandraOperation; private final AccessSettingMigrationServiceImpl accessSettingMigrationService; private final ObjectMapper objectMapper = new ObjectMapper(); + private final RedisCacheMgr redisCacheMgr; public AccessSettingsServiceImpl(CassandraOperation cassandraOperation, PayloadValidation payloadValidation, - AccessSettingMigrationServiceImpl accessSettingMigrationService) { + AccessSettingMigrationServiceImpl accessSettingMigrationService, RedisCacheMgr redisCacheMgr) { this.cassandraOperation = cassandraOperation; this.payloadValidation = payloadValidation; this.accessSettingMigrationService = accessSettingMigrationService; + this.redisCacheMgr = redisCacheMgr; } + @Value("${access.rule.ttl.minutes}") + private int ttlMinutes; + public ApiResponse upsert(Map userGroupDetails, String authToken) { log.info("AccessSettingsService::create:inside"); ApiResponse response = ApiResponse.createDefaultResponse(Constants.ACCESS_SETTINGS_CREATE_API); @@ -59,6 +67,19 @@ public ApiResponse upsert(Map userGroupDetails, String authToken if (accessSettingMigrationService.processAccessSettingRule(accessRuleData)) { cassandraOperation.insertRecord(Constants.KEYSPACE_SUNBIRD_COURSE, Constants.ACCESS_SETTINGS_RULES_TABLE_V2, accessRuleData); + // Redis cache logic + String contextId = String.valueOf(userGroupDetails.get(Constants.CONTENT_ID)); + String contextIdType = userGroupDetails.getOrDefault(Constants.CONTEXT_ID_TYPE, accessRuleData.getOrDefault(Constants.CONTEXT_ID_TYPE, "")).toString(); + String contextDataStr = (String) accessRuleData.get(Constants.CONTEXT_DATA_KEY); + CachedAccessSettingRule loadedRule = new CachedAccessSettingRule( + contextId, + contextIdType, + contextDataStr, + false + ); + String redisKey = contextId + "|" + contextIdType; + String json = objectMapper.writeValueAsString(loadedRule); + redisCacheMgr.putInCache(redisKey, json, ttlMinutes); // TTL 1 hour, adjust as needed response.getResult().put(Constants.MSG, Constants.CREATED_RULES); // Remove all other keys, and put a single object after message Map payload = new HashMap<>(); diff --git a/src/main/java/com/igot/cb/service/CourseAccessServiceImpl.java b/src/main/java/com/igot/cb/service/CourseAccessServiceImpl.java index fc3ba94..66a567c 100644 --- a/src/main/java/com/igot/cb/service/CourseAccessServiceImpl.java +++ b/src/main/java/com/igot/cb/service/CourseAccessServiceImpl.java @@ -217,7 +217,27 @@ private boolean evaluateAccessSettingRule(Map accessSettingIdMap } for (Map criteria : criteriaList) { String criteriaKey = criteria.get(Constants.CRITERIA_KEY).toString().toLowerCase(); - BitSet criteriaValue = (BitSet) criteria.get(Constants.CRITERIA_VALUE); + Object value = criteria.get(Constants.CRITERIA_VALUE); + BitSet criteriaValue; + if (value instanceof BitSet) { + criteriaValue = (BitSet) value; + } else if (value instanceof List) { + criteriaValue = new BitSet(); + List list = (List) value; + for (Object obj : list) { + if (obj instanceof Number) { + criteriaValue.set(((Number) obj).intValue()); + } else if (obj instanceof String) { + try { + criteriaValue.set(Integer.parseInt((String) obj)); + } catch (NumberFormatException e) { + // Optionally log or handle error + } + } + } + } else { + criteriaValue = new BitSet(); // or handle as error + } Integer userCriteriaValue = userProfile.get(criteriaKey); if (userCriteriaValue == null || !criteriaValue.get(userCriteriaValue)) { log.info("User profile does not contain criteria key: {} in userGroup: {}", criteriaKey, diff --git a/src/test/java/com/igot/cb/cache/AccessSettingRuleCacheMgrTest.java b/src/test/java/com/igot/cb/cache/AccessSettingRuleCacheMgrTest.java index 0223850..9f5038d 100644 --- a/src/test/java/com/igot/cb/cache/AccessSettingRuleCacheMgrTest.java +++ b/src/test/java/com/igot/cb/cache/AccessSettingRuleCacheMgrTest.java @@ -244,24 +244,7 @@ void testProcessContextData_invalidCriteriaValues() { Map recordMap = Map.of( "contextId", "do_123", "contextIdType", "Course", - "contextData", """ - { - "accessControlId": { - "userGroups": [ - { - "userGroupId": "group-123", - "userGroupName": "Test Group", - "userGroupCriteriaList": [ - { - "criteriaKey": "designation", - "criteriaValue": ["invalid", "2"] - } - ] - } - ] - } - } - """ + "contextData", "{\"accessControlId\": {\"userGroups\": [{\"userGroupId\": \"group-123\", \"userGroupName\": \"Test Group\", \"userGroupCriteriaList\": [{\"criteriaKey\": \"designation\", \"criteriaValue\": [\"invalid\", \"2\"]}]}]}}" ); when(cassandraOperation.getRecordsByProperties(anyString(), anyString(), isNull(), isNull(), isNull())) .thenReturn(List.of(recordMap)); @@ -286,24 +269,28 @@ void testCreateBitSetForAttribute() { @Test void testGetOrLoadAccessSettingRule_cacheHit() { - // Use reflection to insert an entry into the Caffeine cache - CachedAccessSettingRule rule = new CachedAccessSettingRule("do_123", "Course", "{}", false); - - try { - Field field = AccessSettingRuleCacheMgr.class.getDeclaredField("accessSettingsCache"); - field.setAccessible(true); - Cache cache = - (Cache) field.get(cacheMgr); - cache.put("do_123|Course", rule); - } catch (Exception e) { - fail("Reflection failed: " + e.getMessage()); - } + // Prepare a rule and its JSON representation + String contextId = "do_123"; + String contextIdType = "Course"; + String contextDataStr = "{\"foo\":\"bar\"}"; + boolean isArchived = false; + CachedAccessSettingRule rule = new CachedAccessSettingRule(contextId, contextIdType, contextDataStr, isArchived); + + // Mock Redis to return the JSON for this rule + String cacheKey = contextId + "|" + contextIdType; + when(redisCacheMgr.getFromCache(cacheKey)).thenReturn( + String.format("{\"contextId\":\"%s\",\"contextIdType\":\"%s\",\"contextData\":%s,\"isArchived\":%s}", + contextId, contextIdType, contextDataStr, isArchived) + ); - CachedAccessSettingRule result = cacheMgr.getOrLoadAccessSettingRule("do_123", "Course"); + CachedAccessSettingRule result = cacheMgr.getOrLoadAccessSettingRule(contextId, contextIdType); assertNotNull(result); - assertEquals("do_123", result.getContextId()); - assertEquals("Course", result.getContextIdType()); + assertEquals(contextId, result.getContextId()); + assertEquals(contextIdType, result.getContextIdType()); + assertEquals(isArchived, result.isArchived()); + assertNotNull(result.getContextData()); + assertEquals("bar", result.getContextData().get("foo")); verifyNoInteractions(cassandraOperation); } @@ -312,7 +299,10 @@ void testGetOrLoadAccessSettingRule_cacheMiss_loadsFromCassandra() { Map cassRecord = new HashMap<>(); cassRecord.put(Constants.CONTEXT_ID_KEY, "do_123"); cassRecord.put(Constants.CONTEXT_ID_TYPE, "Course"); + // contextData should be a JSON string, to match what the service expects cassRecord.put(Constants.CONTEXT_DATA_KEY, "{\"sample\":true}"); + // Add isArchived field to match what the service expects + cassRecord.put("isArchived", false); when(cassandraOperation.getRecordsByProperties( anyString(), anyString(), any(), any(), any())) .thenReturn(List.of(cassRecord)); diff --git a/src/test/java/com/igot/cb/cache/CbPlanCacheMgrTest.java b/src/test/java/com/igot/cb/cache/CbPlanCacheMgrTest.java index 37ba562..30853b6 100644 --- a/src/test/java/com/igot/cb/cache/CbPlanCacheMgrTest.java +++ b/src/test/java/com/igot/cb/cache/CbPlanCacheMgrTest.java @@ -3,10 +3,13 @@ import com.github.benmanes.caffeine.cache.Cache; import com.igot.cb.cassandra.CassandraOperation; import com.igot.cb.util.Constants; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; import org.mockito.Mock; +import org.mockito.MockitoAnnotations; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.test.util.ReflectionTestUtils; @@ -23,31 +26,44 @@ class CbPlanCacheMgrTest { @Mock private CassandraOperation cassandraOperation; + @Mock + private RedisCacheMgr redisCacheMgr; private CbPlanCacheMgr cbPlanCacheMgr; + private AutoCloseable mocks; @BeforeEach void setUp() { - cbPlanCacheMgr = new CbPlanCacheMgr(cassandraOperation); - // Set the cache TTL and batch size for testing + mocks = MockitoAnnotations.openMocks(this); + cbPlanCacheMgr = new CbPlanCacheMgr(cassandraOperation, redisCacheMgr); ReflectionTestUtils.setField(cbPlanCacheMgr, "ttlMinutes", 60); ReflectionTestUtils.setField(cbPlanCacheMgr, "planBatchSize", 5); ReflectionTestUtils.setField(cbPlanCacheMgr, "maxCacheSize", 5000); + ReflectionTestUtils.setField(cbPlanCacheMgr, "clientCacheTtlSeconds", 3600); cbPlanCacheMgr.initCache(); } + @AfterEach + void tearDown() throws Exception { + if (mocks != null) { + mocks.close(); + } + } + @Test void testGetCbPlanForAllAndOrgId_CacheHit() { // Arrange String orgId = "org123"; AtomicBoolean isCacheEnabled = new AtomicBoolean(false); - - List> cachedPlans = createMockCbPlans(2, Constants.LIVE); - - // Pre-populate cache using reflection - Cache>> cache = (Cache>>) ReflectionTestUtils - .getField(cbPlanCacheMgr, "cbPlanCache"); - cache.put(orgId, cachedPlans); + List planIds = Arrays.asList("plan1", "plan2"); + // Simulate Redis cache hit + when(redisCacheMgr.getFromCache("cbplan_ids_" + orgId)).thenReturn("[\"plan1\",\"plan2\"]"); + List> cbPlans = createMockCbPlans(2, Constants.LIVE); + when(cassandraOperation.getRecordsByProperties( + eq(Constants.KEYSPACE_SUNBIRD), + eq(Constants.TABLE_CB_PLAN_V2), + anyMap(), anyList(), any())) + .thenReturn(cbPlans); // Act List> result = cbPlanCacheMgr.getCbPlanForAllAndOrgId(orgId, isCacheEnabled); @@ -55,7 +71,11 @@ void testGetCbPlanForAllAndOrgId_CacheHit() { // Assert assertNotNull(result); assertEquals(2, result.size()); - verify(cassandraOperation, never()).getRecordsByProperties(anyString(), anyString(), anyMap(), anyList(), any()); + verify(redisCacheMgr, times(1)).getFromCache("cbplan_ids_" + orgId); + verify(cassandraOperation, times(1)).getRecordsByProperties( + eq(Constants.KEYSPACE_SUNBIRD), + eq(Constants.TABLE_CB_PLAN_V2), + anyMap(), anyList(), any()); } @Test @@ -101,7 +121,7 @@ void testGetCbPlanForAllAndOrgId_CacheMiss_Success() { // Assert assertNotNull(result); assertEquals(5, result.size()); - assertTrue(isCacheEnabled.get()); + assertFalse(isCacheEnabled.get()); // Verify all plans are LIVE result.forEach(plan -> assertEquals(Constants.LIVE, plan.get(Constants.STATUS))); @@ -450,7 +470,7 @@ void testGetCbPlanForAllAndOrgId_ReturnsLivePlans() { assertNotNull(result); assertEquals(2, result.size()); - assertTrue(isCacheEnabled.get()); + assertFalse(isCacheEnabled.get()); result.forEach(plan -> assertEquals(Constants.LIVE, plan.get(Constants.STATUS))); } diff --git a/src/test/java/com/igot/cb/cache/PromotionalContentRuleCacheMgrTest.java b/src/test/java/com/igot/cb/cache/PromotionalContentRuleCacheMgrTest.java index f9a0cc1..ed2d494 100644 --- a/src/test/java/com/igot/cb/cache/PromotionalContentRuleCacheMgrTest.java +++ b/src/test/java/com/igot/cb/cache/PromotionalContentRuleCacheMgrTest.java @@ -5,6 +5,7 @@ import java.util.*; +import com.fasterxml.jackson.databind.ObjectMapper; import com.igot.cb.cassandra.CassandraOperation; import com.igot.cb.model.CachedAccessSettingRule; import com.igot.cb.util.CbExtServerProperties; @@ -25,6 +26,9 @@ class PromotionalContentRuleCacheMgrTest { @Mock private CbExtServerProperties properties; + @Mock + private RedisCacheMgr redisCacheMgr; + private PromotionalContentRuleCacheMgr cacheMgr; @BeforeEach @@ -33,12 +37,14 @@ void setup() { lenient().when(properties.isPromotionalContentCacheWarmingEnabled()).thenReturn(false); lenient().when(properties.getPromotionalContentCacheBatchSize()).thenReturn(500); lenient().when(properties.getPromotionalContentCacheMaxQuerySize()).thenReturn(5000); - cacheMgr = new PromotionalContentRuleCacheMgr(cassandraOperation, properties); + cacheMgr = new PromotionalContentRuleCacheMgr(cassandraOperation, properties, redisCacheMgr); ReflectionTestUtils.setField(cacheMgr, "promotionalContentRulesCacheExpiryMs", 3600000); } @Test void testGetAccessSettingRules_EmptyCache_LoadsFromCassandra() { + // Redis miss, Cassandra returns data + when(redisCacheMgr.getFromCache(anyString())).thenReturn(null); List> cassandraRecords = createCassandraRecords(3); when(cassandraOperation.getRecordsByProperties( Constants.KEYSPACE_SUNBIRD_COURSE, @@ -52,6 +58,7 @@ void testGetAccessSettingRules_EmptyCache_LoadsFromCassandra() { @Test void testGetAccessSettingRules_ReturnsEmptyCollection_WhenNoDataAvailable() { + when(redisCacheMgr.getFromCache(anyString())).thenReturn(null); when(cassandraOperation.getRecordsByProperties( anyString(), anyString(), any(), any(), anyInt() )).thenReturn(List.of()); @@ -61,14 +68,30 @@ void testGetAccessSettingRules_ReturnsEmptyCollection_WhenNoDataAvailable() { } @Test - void testGetAccessSettingRules_ReturnsCachedData_OnSubsequentCalls() { + void testGetAccessSettingRules_ReturnsCachedData_OnSubsequentCalls() throws Exception { + // Prepare a rule and its JSON List> cassandraRecords = createCassandraRecords(2); - when(cassandraOperation.getRecordsByProperties( - anyString(), anyString(), any(), any(), anyInt() - )).thenReturn(cassandraRecords); + Map> ruleMap = new HashMap<>(); + ObjectMapper mapper = new ObjectMapper(); + for (Map rec : cassandraRecords) { + Map ruleJson = new HashMap<>(); + ruleJson.put("contextId", rec.get("contextId")); + ruleJson.put("contextIdType", rec.get("contextIdType")); + // contextData must be a JSON string for the constructor, but in Redis it's stored as a Map + Object contextData = rec.get("contextData"); + if (contextData instanceof String) { + // Try to parse it to Map for Redis simulation + contextData = mapper.readValue((String) contextData, Map.class); + } + ruleJson.put("contextData", contextData); + ruleJson.put("isArchived", false); // Use the correct key as per POJO + ruleMap.put(rec.get("contextId") + "|" + rec.get("contextIdType"), ruleJson); + } + String json = new ObjectMapper().writeValueAsString(ruleMap); + when(redisCacheMgr.getFromCache(anyString())).thenReturn(json); Collection result1 = cacheMgr.getAccessSettingRules(); assertEquals(2, result1.size()); - reset(cassandraOperation); + // On subsequent call, should still return from Redis Collection result2 = cacheMgr.getAccessSettingRules(); assertEquals(2, result2.size()); verify(cassandraOperation, never()).getRecordsByProperties( @@ -78,6 +101,7 @@ void testGetAccessSettingRules_ReturnsCachedData_OnSubsequentCalls() { @Test void testLoadAccessSettingRules_HandlesException() { + when(redisCacheMgr.getFromCache(anyString())).thenReturn(null); when(cassandraOperation.getRecordsByProperties( anyString(), anyString(), any(), any(), anyInt() )).thenThrow(new RuntimeException("Database error")); @@ -88,6 +112,7 @@ void testLoadAccessSettingRules_HandlesException() { @Test void testProcessAndCacheRule_WithValidContextData() { + when(redisCacheMgr.getFromCache(anyString())).thenReturn(null); List> cassandraRecords = List.of( createCassandraRecordWithFullData("do_test_123", "Course") ); @@ -114,6 +139,7 @@ void testProcessAndCacheRule_WithValidContextData() { @Test void testProcessCriteria_WithNonIntegerValues() { + when(redisCacheMgr.getFromCache(anyString())).thenReturn(null); String contextData = "{\"accessControlId\":{\"version\":1,\"userGroups\":[{\"userGroupId\":\"group-1\",\"userGroupName\":\"Group 1\",\"userGroupCriteriaList\":[{\"criteriaKey\":\"designation\",\"criteriaValue\":[\"1\",\"invalid\",\"3\",\"not-a-number\",\"5\"]}]}]}}"; Map nonIntegerRecord = createCassandraRecord("do_non_integer", "Course", contextData); when(cassandraOperation.getRecordsByProperties( @@ -137,7 +163,8 @@ void testProcessCriteria_WithNonIntegerValues() { @Test void testProcessContextData_WithNoAccessControl() { - Map noAccessControlRecord = createCassandraRecord("do_no_access", "Course", "{}"); + when(redisCacheMgr.getFromCache(anyString())).thenReturn(null); + Map noAccessControlRecord = createCassandraRecord("do_no_access", "Course", "{}" ); when(cassandraOperation.getRecordsByProperties( anyString(), anyString(), any(), any(), anyInt() )).thenReturn(List.of(noAccessControlRecord)); @@ -183,17 +210,38 @@ private List> createCassandraRecords(int count) { return records; } - private Map createCassandraRecord(String contextId, String contextIdType, String contextData) { + private Map createCassandraRecord(String contextId, String contextIdType, Object contextData) { Map createCassandraRecord = new HashMap<>(); createCassandraRecord.put("contextId", contextId); createCassandraRecord.put("contextIdType", contextIdType); - createCassandraRecord.put("contextData", contextData); + // Always store as JSON string for contextData + if (contextData instanceof String) { + createCassandraRecord.put("contextData", contextData); + } else { + try { + createCassandraRecord.put("contextData", new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(contextData)); + } catch (Exception e) { + throw new RuntimeException(e); + } + } createCassandraRecord.put("isArchived", false); return createCassandraRecord; } private Map createCassandraRecordWithFullData(String contextId, String contextIdType) { - String contextData = "{\"accessControlId\":{\"version\":1,\"userGroups\":[{\"userGroupId\":\"group-1\",\"userGroupName\":\"Group 1\",\"userGroupCriteriaList\":[{\"criteriaKey\":\"designation\",\"criteriaValue\":[\"1\",\"2\",\"3\"]}]}]}}"; + // Use a nested map for contextData + Map criteria = new HashMap<>(); + criteria.put("criteriaKey", "designation"); + criteria.put("criteriaValue", Arrays.asList("1", "2", "3")); + Map userGroup = new HashMap<>(); + userGroup.put("userGroupId", "group-1"); + userGroup.put("userGroupName", "Group 1"); + userGroup.put("userGroupCriteriaList", List.of(criteria)); + Map accessControlId = new HashMap<>(); + accessControlId.put("version", 1); + accessControlId.put("userGroups", List.of(userGroup)); + Map contextData = new HashMap<>(); + contextData.put("accessControlId", accessControlId); return createCassandraRecord(contextId, contextIdType, contextData); } } diff --git a/src/test/java/com/igot/cb/service/AccessSettingsServiceImplTest.java b/src/test/java/com/igot/cb/service/AccessSettingsServiceImplTest.java index d1a8dce..630f44f 100644 --- a/src/test/java/com/igot/cb/service/AccessSettingsServiceImplTest.java +++ b/src/test/java/com/igot/cb/service/AccessSettingsServiceImplTest.java @@ -1,5 +1,6 @@ package com.igot.cb.service; +import com.igot.cb.cache.RedisCacheMgr; import com.igot.cb.util.Constants; import com.igot.cb.cassandra.CassandraOperation; import com.igot.cb.model.ApiResponse; @@ -37,9 +38,12 @@ class AccessSettingsServiceImplTest { @Mock private AccessSettingMigrationServiceImpl accessSettingMigrationService; + @Mock + private RedisCacheMgr redisCacheMgr; + @BeforeEach void setUp() { - service = new AccessSettingsServiceImpl(cassandraOperation, payloadValidation, accessSettingMigrationService); + service = new AccessSettingsServiceImpl(cassandraOperation, payloadValidation, accessSettingMigrationService, redisCacheMgr); } @Test