package gov.nysenate.openleg.service.bill.data; import com.google.common.collect.Range; import com.google.common.eventbus.EventBus; import com.google.common.eventbus.Subscribe; import gov.nysenate.openleg.dao.base.LimitOffset; import gov.nysenate.openleg.dao.base.SortOrder; import gov.nysenate.openleg.dao.bill.data.BillDao; import gov.nysenate.openleg.model.base.SessionYear; import gov.nysenate.openleg.model.base.Version; import gov.nysenate.openleg.model.bill.BaseBillId; import gov.nysenate.openleg.model.bill.Bill; import gov.nysenate.openleg.model.bill.BillId; import gov.nysenate.openleg.model.bill.BillInfo; import gov.nysenate.openleg.model.cache.CacheEvictIdEvent; import gov.nysenate.openleg.model.sobi.SobiFragment; import gov.nysenate.openleg.model.cache.CacheEvictEvent; import gov.nysenate.openleg.model.cache.CacheWarmEvent; import gov.nysenate.openleg.service.base.data.CachingService; import gov.nysenate.openleg.model.cache.ContentCache; import gov.nysenate.openleg.service.bill.event.BillUpdateEvent; import gov.nysenate.openleg.util.OutputUtils; import net.sf.ehcache.*; import net.sf.ehcache.Cache; import net.sf.ehcache.CacheManager; import net.sf.ehcache.config.CacheConfiguration; import net.sf.ehcache.config.MemoryUnit; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.dao.DataAccessException; import org.springframework.dao.EmptyResultDataAccessException; import org.springframework.stereotype.Service; import javax.annotation.PostConstruct; import javax.annotation.PreDestroy; import java.time.LocalDateTime; import java.util.Arrays; import java.util.List; import java.util.Optional; /** * Data service layer for retrieving and updating bill data. This implementation makes use of * in-memory caches to reduce the number of database queries involved in retrieving bill data. */ @Service public class CachedBillDataService implements BillDataService, CachingService<BaseBillId> { private static final Logger logger = LoggerFactory.getLogger(CachedBillDataService.class); @Autowired private CacheManager cacheManager; @Autowired private BillDao billDao; @Autowired private EventBus eventBus; @Value("${bill.cache.size}") private long billCacheSizeMb; @Value("${bill-info.cache.size}") private long billInfoCacheSizeMb; private Cache billCache; private Cache billInfoCache; @PostConstruct private void init() { setupCaches(); eventBus.register(this); } @PreDestroy private void cleanUp() { evictCaches(); cacheManager.removeCache(ContentCache.BILL.name()); cacheManager.removeCache(ContentCache.BILL_INFO.name()); } /** --- CachingService implementation --- */ /** {@inheritDoc} */ @Override public List<Ehcache> getCaches() { return Arrays.asList(billCache, billInfoCache); } /** {@inheritDoc} */ @Override public void setupCaches() { // Partial bill cache will store Bill instances with the full text fields stripped to save space. this.billCache = new Cache(new CacheConfiguration().name(ContentCache.BILL.name()) .eternal(true) .maxBytesLocalHeap(billCacheSizeMb, MemoryUnit.MEGABYTES) .sizeOfPolicy(defaultSizeOfPolicy())); cacheManager.addCache(this.billCache); // This can only be called after the cache is added to the cache manager. this.billCache.setMemoryStoreEvictionPolicy(new BillCacheEvictionPolicy()); // Bill Info cache will store BillInfo instances to speed up search and listings. // If a bill is already stored in the billCache, it's BillInfo does not need to be stored here. this.billInfoCache = new Cache(new CacheConfiguration().name(ContentCache.BILL_INFO.name()) .eternal(true) .maxBytesLocalHeap(billInfoCacheSizeMb, MemoryUnit.MEGABYTES) .sizeOfPolicy(defaultSizeOfPolicy())); cacheManager.addCache(this.billInfoCache); } /** * Pre-load the bill caches by clearing out each of their contents and then loading: * Bill Cache - Current session year bills only * Bill Info Cache - Bill Infos from all available session years. */ public void warmCaches() { evictCaches(); logger.info("Warming up bill cache."); Optional<Range<SessionYear>> sessionRange = activeSessionRange(); if (sessionRange.isPresent()) { SessionYear sessionYear = sessionRange.get().lowerEndpoint(); while (sessionYear.compareTo(sessionRange.get().upperEndpoint()) <= 0) { if (sessionYear.equals(SessionYear.current())) { logger.info("Caching Bill instances for current session year: {}", sessionYear); getBillIds(sessionYear, LimitOffset.ALL).forEach(id -> getBill(id)); } else { logger.info("Caching Bill Info instances for session year: {}", sessionYear); getBillIds(sessionYear, LimitOffset.ALL).forEach(id -> getBillInfo(id)); } sessionYear = sessionYear.next(); } } logger.info("Done warming up bill cache."); } /** {@inheritDoc} */ @Override @Subscribe public synchronized void handleCacheEvictEvent(CacheEvictEvent evictEvent) { if (evictEvent.affects(ContentCache.BILL) || evictEvent.affects(ContentCache.BILL_INFO)) { evictCaches(); } } /** {@inheritDoc} */ @Subscribe @Override public void handleCacheEvictIdEvent(CacheEvictIdEvent<BaseBillId> evictIdEvent) { if (evictIdEvent.affects(ContentCache.BILL) || evictIdEvent.affects(ContentCache.BILL_INFO)) { evictContent(evictIdEvent.getContentId()); } } /** {@inheritDoc} */ @Override public void evictContent(BaseBillId baseBillId) { logger.debug("evicting {}", baseBillId); billInfoCache.remove(baseBillId); billCache.remove(baseBillId); } /** {@inheritDoc} */ @Subscribe public synchronized void handleCacheWarmEvent(CacheWarmEvent warmEvent) { if (warmEvent.affects(ContentCache.BILL) || warmEvent.affects(ContentCache.BILL_INFO)) { warmCaches(); } } /** --- BillDataService implementation --- */ /** {@inheritDoc} */ @Override public Bill getBill(BaseBillId billId) throws BillNotFoundEx { if (billId == null) { throw new IllegalArgumentException("BillId cannot be null"); } try { Bill bill; if (billCache.get(billId) != null) { bill = constructBillFromCache(billId); logger.debug("Cache hit for bill {}", bill); } else { logger.debug("Fetching bill {}..", billId); bill = billDao.getBill(billId); putStrippedBillInCache(bill); } return bill; } catch (EmptyResultDataAccessException ex) { throw new BillNotFoundEx(billId, ex); } catch (CloneNotSupportedException e) { throw new CacheException("Failed to cache retrieved Bill: " + e.getMessage()); } } /** {@inheritDoc} */ @Override public BillInfo getBillInfo(BaseBillId billId) throws BillNotFoundEx { logger.debug("Fetching bill info {}..", billId); if (billId == null) { throw new IllegalArgumentException("BillId cannot be null"); } if (billCache.get(billId) != null) { return new BillInfo((Bill) billCache.get(billId).getObjectValue()); } if (billInfoCache.get(billId) != null) { return (BillInfo) billInfoCache.get(billId).getObjectValue(); } try { BillInfo billInfo = billDao.getBillInfo(billId); billInfoCache.put(new Element(billId, billInfo)); return billInfo; } catch (EmptyResultDataAccessException ex) { throw new BillNotFoundEx(billId, ex); } } /** {@inheritDoc} */ @Override public BillInfo getBillInfoSafe(BaseBillId billId) { try { return getBillInfo(billId); } catch (BillNotFoundEx ex) { BillInfo dummyInfo = new BillInfo(); dummyInfo.setBillId(billId); String message = "Data is currently not available for this bill"; dummyInfo.setTitle(message); dummyInfo.setSummary(message); return dummyInfo; } } /** {@inheritDoc} */ @Override public List<BaseBillId> getBillIds(SessionYear sessionYear, LimitOffset limitOffset) { if (sessionYear == null) { throw new IllegalArgumentException("SessionYear cannot be null"); } if (limitOffset == null) { limitOffset = LimitOffset.ALL; } return billDao.getBillIds(sessionYear, limitOffset, SortOrder.ASC); } /** {@inheritDoc} */ @Override public synchronized int getBillCount(SessionYear sessionYear) { if (sessionYear == null) { throw new IllegalArgumentException("SessionYear cannot be null"); } return billDao.getBillCount(sessionYear); } /** {@inheritDoc} */ @Override public synchronized void saveBill(Bill bill, SobiFragment fragment, boolean postUpdateEvent) { logger.debug("Persisting bill {}", bill); billDao.updateBill(bill, fragment); putStrippedBillInCache(bill); if (postUpdateEvent) { eventBus.post(new BillUpdateEvent(bill, LocalDateTime.now())); } } /** {@inheritDoc} */ @Override public Optional<Range<SessionYear>> activeSessionRange() { try { return Optional.of(billDao.activeSessionRange()); } catch (DataAccessException ex) { return Optional.empty(); } } /** {@inheritDoc} */ @Override public Optional<String> getAlternateBillPdfUrl(BillId billId) { try { return Optional.of(billDao.getAlternateBillPdfUrl(billId)); } catch (DataAccessException ex) { return Optional.empty(); } } /** --- Internal Methods --- */ /** * Retrieves the bill from the cache. You must check that the bill exists prior to calling this * method. The fulltext and memo are put back into a copy of the cached bill. * * @param billId BaseBillId * @return Bill * @throws CloneNotSupportedException */ private Bill constructBillFromCache(BaseBillId billId) throws CloneNotSupportedException { Bill cachedBill = (Bill) billCache.get(billId).getObjectValue(); cachedBill = cachedBill.shallowClone(); billDao.applyText(cachedBill); return cachedBill; } /** * In order to cache bills effectively, we strip out the memos and full text from the bill first * to save some heap space. * @param bill Bill */ private void putStrippedBillInCache(final Bill bill) { if (bill != null) { try { Bill cacheBill = bill.shallowClone(); cacheBill.getAmendmentList().stream().forEach(ba -> { ba.setMemo(""); ba.setFullText(""); }); this.billCache.put(new Element(cacheBill.getBaseBillId(), cacheBill)); // Remove entry from the bill info cache if it exists this.billInfoCache.remove(cacheBill.getBaseBillId()); } catch (CloneNotSupportedException e) { logger.error("Failed to cache bill!", e); } } } }