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}