001/* 002 * This library is part of OpenCms - 003 * The Open Source Content Management System 004 * 005 * Copyright (c) Alkacon Software GmbH & Co. KG (https://www.alkacon.com) 006 * 007 * This library is free software; you can redistribute it and/or 008 * modify it under the terms of the GNU Lesser General Public 009 * License as published by the Free Software Foundation; either 010 * version 2.1 of the License, or (at your option) any later version. 011 */ 012 013package org.opencms.loader.imagecache; 014 015import org.opencms.configuration.CmsImageCacheConfiguration; 016import org.opencms.configuration.CmsImageCacheConfiguration.RetentionMode; 017import org.opencms.loader.CmsFsImageCache; 018import org.opencms.loader.CmsS3ImageCache; 019import org.opencms.loader.I_CmsImageCache; 020import org.opencms.main.CmsLog; 021 022import java.nio.ByteBuffer; 023import java.time.Duration; 024import java.time.Instant; 025import java.util.Collections; 026import java.util.Set; 027import java.util.concurrent.ArrayBlockingQueue; 028import java.util.concurrent.ConcurrentHashMap; 029import java.util.concurrent.RejectedExecutionException; 030import java.util.concurrent.ThreadFactory; 031import java.util.concurrent.ThreadPoolExecutor; 032import java.util.concurrent.TimeUnit; 033import java.util.concurrent.atomic.AtomicInteger; 034import java.util.concurrent.atomic.AtomicLong; 035 036import org.apache.commons.codec.digest.DigestUtils; 037import org.apache.commons.logging.Log; 038 039import com.google.common.cache.Cache; 040import com.google.common.cache.CacheBuilder; 041 042/** 043 * Evaluates successful image cache accesses and renews eligible entries asynchronously.<p> 044 */ 045public final class CmsImageCacheAccessRenewal implements AutoCloseable { 046 047 /** Maximum number of locally remembered entry timestamps. */ 048 private static final int METADATA_CACHE_MAX_SIZE = 10000; 049 050 /** Number of minutes after which unused local metadata expires. */ 051 private static final int METADATA_CACHE_EXPIRY_MINUTES = 10; 052 053 /** The logger. */ 054 private static final Log LOG = CmsLog.getLog(CmsImageCacheAccessRenewal.class); 055 056 /** The configured image cache. */ 057 private final I_CmsImageCache m_cache; 058 059 /** The bounded background executor. */ 060 private final ThreadPoolExecutor m_executor; 061 062 /** Locally remembered entry metadata. */ 063 private final Cache<String, CmsImageCacheEntry> m_metadata = CacheBuilder.newBuilder().maximumSize( 064 METADATA_CACHE_MAX_SIZE).expireAfterAccess(METADATA_CACHE_EXPIRY_MINUTES, TimeUnit.MINUTES).build(); 065 066 /** Keys with queued or running renewal evaluation. */ 067 private final Set<String> m_pending = Collections.newSetFromMap(new ConcurrentHashMap<String, Boolean>()); 068 069 /** Number of rejected access events. */ 070 private final AtomicLong m_rejected = new AtomicLong(); 071 072 /** The configured renewal jitter in milliseconds. */ 073 private final long m_renewalJitterMillis; 074 075 /** The age at which the renewal window starts, before jitter. */ 076 private final long m_renewalWindowStartMillis; 077 078 /** The maintenance service. */ 079 private final CmsImageCacheMaintenanceService m_service; 080 081 /** 082 * Creates an access renewal coordinator.<p> 083 * 084 * @param cache the image cache 085 * @param configuration the image cache configuration 086 * @param concurrency the background worker count 087 * @param queueCapacity the maximum number of queued keys 088 */ 089 private CmsImageCacheAccessRenewal( 090 I_CmsImageCache cache, 091 CmsImageCacheConfiguration configuration, 092 int concurrency, 093 int queueCapacity) { 094 095 m_cache = cache; 096 m_service = CmsImageCacheMaintenanceService.create(cache, configuration); 097 m_renewalWindowStartMillis = configuration.getMaxAge().minus(configuration.getRenewalWindow()).toMillis(); 098 m_renewalJitterMillis = configuration.getRenewalJitter().toMillis(); 099 AtomicInteger threadCounter = new AtomicInteger(); 100 ThreadFactory threadFactory = runnable -> { 101 Thread thread = new Thread( 102 runnable, 103 "OpenCms image cache access renewal " + threadCounter.incrementAndGet()); 104 thread.setDaemon(true); 105 return thread; 106 }; 107 m_executor = new ThreadPoolExecutor( 108 concurrency, 109 concurrency, 110 0L, 111 TimeUnit.MILLISECONDS, 112 new ArrayBlockingQueue<Runnable>(queueCapacity), 113 threadFactory, 114 new ThreadPoolExecutor.AbortPolicy()); 115 } 116 117 /** 118 * Creates a coordinator when access-triggered renewal is enabled for the cache.<p> 119 * 120 * @param cache the configured image cache 121 * @param configuration the image cache configuration 122 * @return the coordinator, or {@code null} when access renewal is disabled or unsupported 123 */ 124 public static CmsImageCacheAccessRenewal create(I_CmsImageCache cache, CmsImageCacheConfiguration configuration) { 125 126 if ((cache == null) 127 || (configuration == null) 128 || !configuration.isConfigured() 129 || (configuration.getRetentionMode() != RetentionMode.renewOnUse)) { 130 return null; 131 } 132 if (cache instanceof CmsS3ImageCache) { 133 return new CmsImageCacheAccessRenewal( 134 cache, 135 configuration, 136 configuration.getS3CopyConcurrency(), 137 configuration.getS3RenewalQueueCapacity()); 138 } 139 if (cache instanceof CmsFsImageCache) { 140 return new CmsImageCacheAccessRenewal( 141 cache, 142 configuration, 143 configuration.getFsTouchConcurrency(), 144 CmsImageCacheConfiguration.DEFAULT_S3_RENEWAL_QUEUE_CAPACITY); 145 } 146 return null; 147 } 148 149 /** Shuts down the background executor. */ 150 @Override 151 public void close() { 152 153 m_executor.shutdownNow(); 154 m_pending.clear(); 155 m_metadata.invalidateAll(); 156 } 157 158 /** 159 * Records a successful content delivery or HTTP 304 response.<p> 160 * 161 * The method never accesses the backend and never blocks on renewal work.<p> 162 * 163 * @param key the image cache key 164 */ 165 public void recordAccess(String key) { 166 167 String normalizedKey = normalizeKey(key); 168 CmsImageCacheEntry observed = null; 169 if (m_cache instanceof I_CmsImageCacheAccessMetadataProvider) { 170 observed = ((I_CmsImageCacheAccessMetadataProvider)m_cache).getRecentAccessMetadata(normalizedKey); 171 if (observed != null) { 172 m_metadata.put(normalizedKey, observed); 173 } 174 } 175 CmsImageCacheEntry known = observed == null ? m_metadata.getIfPresent(normalizedKey) : observed; 176 Instant now = Instant.now(); 177 if ((known != null) && !isRenewalDue(normalizedKey, known.getLastModified(), now)) { 178 return; 179 } 180 if (!m_pending.add(normalizedKey)) { 181 return; 182 } 183 final CmsImageCacheEntry candidate = known; 184 try { 185 m_executor.execute(() -> processAccess(normalizedKey, candidate)); 186 } catch (RejectedExecutionException e) { 187 m_pending.remove(normalizedKey); 188 long rejected = m_rejected.incrementAndGet(); 189 if ((rejected == 1) || ((rejected % 1000) == 0)) { 190 LOG.warn("Image cache access renewal queue is full; rejected accesses: " + rejected); 191 } 192 } 193 } 194 195 /** Returns whether an entry is old enough for renewal. */ 196 boolean isRenewalDue(String key, Instant lastModified, Instant now) { 197 198 if ((lastModified == null) || lastModified.isAfter(now)) { 199 return false; 200 } 201 long jitter = 0; 202 if (m_renewalJitterMillis > 0) { 203 long hash = ByteBuffer.wrap(DigestUtils.sha256(key)).getLong(); 204 jitter = Long.remainderUnsigned(hash, m_renewalJitterMillis + 1); 205 } 206 Duration age = Duration.between(lastModified, now); 207 return age.compareTo(Duration.ofMillis(m_renewalWindowStartMillis + jitter)) >= 0; 208 } 209 210 /** Normalizes a cache key. */ 211 private String normalizeKey(String key) { 212 213 String result = key; 214 while (result.startsWith("/")) { 215 result = result.substring(1); 216 } 217 return result; 218 } 219 220 /** Evaluates and, if necessary, renews one entry. */ 221 private void processAccess(String key, CmsImageCacheEntry candidate) { 222 223 try { 224 CmsImageCacheEntry entry = candidate; 225 if (entry == null) { 226 entry = m_service.getEntry(key); 227 if (entry == null) { 228 m_metadata.invalidate(key); 229 return; 230 } 231 m_metadata.put(key, entry); 232 } 233 Instant now = Instant.now(); 234 if (!isRenewalDue(key, entry.getLastModified(), now)) { 235 return; 236 } 237 CmsImageCacheMaintenanceResult result = m_service.execute( 238 CmsImageCacheMaintenanceRequest.renew(Collections.singletonList(entry), now)); 239 m_metadata.invalidate(key); 240 if (result.getFailed() > 0) { 241 LOG.warn("Unable to renew image cache entry after access: " + key); 242 for (Exception failure : result.getFailures().values()) { 243 LOG.warn("Image cache access renewal failure for " + key, failure); 244 } 245 } 246 } catch (Exception e) { 247 m_metadata.invalidate(key); 248 LOG.warn("Unable to evaluate image cache access renewal for " + key, e); 249 } finally { 250 m_pending.remove(key); 251 } 252 } 253}