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 * This library is distributed in the hope that it will be useful,
013 * but WITHOUT ANY WARRANTY; without even the implied warranty of
014 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
015 * Lesser General Public License for more details.
016 *
017 * For further information about Alkacon Software GmbH & Co. KG, please see the
018 * company website: https://www.alkacon.com
019 *
020 * For further information about OpenCms, please see the
021 * project website: https://www.opencms.org
022 *
023 * You should have received a copy of the GNU Lesser General Public
024 * License along with this library; if not, write to the Free Software
025 * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
026 */
027
028package org.opencms.loader.imagecache;
029
030import org.opencms.configuration.CmsImageCacheConfiguration;
031import org.opencms.loader.CmsFsImageCache;
032import org.opencms.loader.imagecache.CmsImageCacheCapabilities.Capability;
033
034import java.io.IOException;
035import java.nio.file.FileVisitResult;
036import java.nio.file.Files;
037import java.nio.file.LinkOption;
038import java.nio.file.Path;
039import java.nio.file.SimpleFileVisitor;
040import java.nio.file.attribute.BasicFileAttributes;
041import java.nio.file.attribute.FileTime;
042import java.time.Duration;
043import java.time.Instant;
044import java.util.ArrayList;
045import java.util.List;
046import java.util.concurrent.ExecutionException;
047import java.util.concurrent.ExecutorService;
048import java.util.concurrent.Executors;
049import java.util.concurrent.Future;
050
051/**
052 * Maintenance adapter for the shared file system image cache.<p>
053 */
054public class CmsFsImageCacheMaintenance extends CmsRfsImageCacheMaintenance {
055
056    /** Exception wrapper used to transport visitor exceptions through the file visitor API. */
057    private static final class EntryVisitorException extends IOException {
058
059        /** Serial version id. */
060        private static final long serialVersionUID = 1L;
061
062        /** Creates an exception wrapper. */
063        EntryVisitorException(Exception cause) {
064
065            super(cause);
066        }
067    }
068
069    /** Result for one renewal. */
070    private static final class RenewalResult {
071
072        /** The renewed entry. */
073        private final CmsImageCacheEntry m_entry;
074
075        /** The renewal failure. */
076        private final Exception m_failure;
077
078        /** Whether renewal was skipped. */
079        private final boolean m_skipped;
080
081        /** Creates a renewal result. */
082        RenewalResult(CmsImageCacheEntry entry, boolean skipped, Exception failure) {
083
084            m_entry = entry;
085            m_skipped = skipped;
086            m_failure = failure;
087        }
088    }
089
090    /** Capabilities without optional FS touching. */
091    private static final CmsImageCacheCapabilities CAPABILITIES = CmsImageCacheCapabilities.of(
092        Capability.LIST_ENTRIES,
093        Capability.ENTRY_TIMESTAMPS,
094        Capability.DELETE_ENTRIES,
095        Capability.CLEAR);
096
097    /** Capabilities with optional FS touching. */
098    private static final CmsImageCacheCapabilities CAPABILITIES_WITH_RENEWAL = CmsImageCacheCapabilities.of(
099        Capability.LIST_ENTRIES,
100        Capability.ENTRY_TIMESTAMPS,
101        Capability.DELETE_ENTRIES,
102        Capability.CLEAR,
103        Capability.RENEW_ENTRIES);
104
105    /** The shared file system cache. */
106    private final CmsFsImageCache m_cache;
107
108    /** Whether touching cache files is enabled. */
109    private final boolean m_touchEnabled;
110
111    /** The maximum number of concurrent touch operations. */
112    private final int m_touchConcurrency;
113
114    /**
115     * Creates a maintenance adapter.<p>
116     *
117     * @param cache the shared file system cache
118     */
119    public CmsFsImageCacheMaintenance(CmsFsImageCache cache) {
120
121        this(cache, false, CmsImageCacheConfiguration.DEFAULT_FS_TOUCH_CONCURRENCY);
122    }
123
124    /**
125     * Creates a maintenance adapter.<p>
126     *
127     * @param cache the shared file system cache
128     * @param touchEnabled whether touching cache entries is enabled
129     * @param touchConcurrency the maximum number of concurrent touch operations
130     */
131    public CmsFsImageCacheMaintenance(CmsFsImageCache cache, boolean touchEnabled, int touchConcurrency) {
132
133        super(cache);
134        if (touchConcurrency < 1) {
135            throw new IllegalArgumentException("FS touch concurrency must be positive.");
136        }
137        m_cache = cache;
138        m_touchEnabled = touchEnabled;
139        m_touchConcurrency = touchConcurrency;
140    }
141
142    /**
143     * @see org.opencms.loader.imagecache.CmsRfsImageCacheMaintenance#execute(org.opencms.loader.imagecache.CmsImageCacheMaintenanceRequest)
144     */
145    @Override
146    public CmsImageCacheMaintenanceResult execute(CmsImageCacheMaintenanceRequest request) throws Exception {
147
148        if (request.getOperation() != CmsImageCacheMaintenanceRequest.Operation.RENEW) {
149            return super.execute(request);
150        }
151        if (!m_touchEnabled) {
152            throw new UnsupportedOperationException("Shared-FS image cache touching is disabled.");
153        }
154        long start = System.nanoTime();
155        CmsImageCacheMaintenanceResult.Builder result = new CmsImageCacheMaintenanceResult.Builder(
156            request.getOperation(),
157            request.getEntries().size());
158        renewEntries(request.getEntries(), request.getRenewalTime(), result);
159        return result.build(Duration.ofNanos(System.nanoTime() - start));
160    }
161
162    /**
163     * @see org.opencms.loader.imagecache.CmsRfsImageCacheMaintenance#getBackendId()
164     */
165    @Override
166    public String getBackendId() {
167
168        return "shared-fs";
169    }
170
171    /**
172     * @see org.opencms.loader.imagecache.CmsRfsImageCacheMaintenance#getCapabilities()
173     */
174    @Override
175    public CmsImageCacheCapabilities getCapabilities() {
176
177        return m_touchEnabled ? CAPABILITIES_WITH_RENEWAL : CAPABILITIES;
178    }
179
180    /**
181     * @see org.opencms.loader.imagecache.I_CmsImageCacheMaintenance#getEntry(java.lang.String)
182     */
183    @Override
184    public CmsImageCacheEntry getEntry(String key) throws Exception {
185
186        Path path = toPath(key);
187        BasicFileAttributes attributes;
188        try {
189            attributes = Files.readAttributes(path, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS);
190        } catch (java.nio.file.NoSuchFileException e) {
191            return null;
192        }
193        return attributes.isRegularFile() ? toEntry(path, attributes) : null;
194    }
195
196    /**
197     * @see org.opencms.loader.imagecache.CmsRfsImageCacheMaintenance#visitEntries(org.opencms.loader.imagecache.I_CmsImageCacheMaintenanceEntryVisitor)
198     */
199    @Override
200    public void visitEntries(I_CmsImageCacheMaintenanceEntryVisitor visitor) throws Exception {
201
202        try {
203            Files.walkFileTree(getRepository(), new SimpleFileVisitor<Path>() {
204
205                @Override
206                public FileVisitResult visitFile(Path path, BasicFileAttributes attributes) throws IOException {
207
208                    if (attributes.isRegularFile()) {
209                        try {
210                            visitor.visit(toEntry(path, attributes));
211                        } catch (Exception e) {
212                            throw new EntryVisitorException(e);
213                        }
214                    }
215                    return FileVisitResult.CONTINUE;
216                }
217            });
218        } catch (EntryVisitorException e) {
219            throw (Exception)e.getCause();
220        }
221    }
222
223    /**
224     * @see org.opencms.loader.imagecache.CmsRfsImageCacheMaintenance#afterDelete(java.lang.String)
225     */
226    @Override
227    protected void afterDelete(String key) throws Exception {
228
229        m_cache.invalidateLocalMetadata(key);
230    }
231
232    /**
233     * Shared-FS deletes deliberately leave empty directories in place.<p>
234     *
235     * Traversing the complete shared repository after every delete request would turn deletion of one image into a
236     * full NFS metadata scan. The directories are harmless and are removed by a complete cache clear.<p>
237     */
238    @Override
239    protected void removeEmptyDirectories() {
240
241        // no-op
242    }
243
244    /** Adds one renewal outcome to the maintenance metrics. */
245    private void addRenewalResult(RenewalResult renewal, CmsImageCacheMaintenanceResult.Builder result) {
246
247        if (renewal.m_failure != null) {
248            result.addFailure(renewal.m_entry.getKey(), renewal.m_failure);
249        } else if (renewal.m_skipped) {
250            result.addSkipped();
251        } else {
252            result.addSuccess();
253        }
254    }
255
256    /** Creates the opaque revision used to detect entries changed since the scan. */
257    private String createRevision(BasicFileAttributes attributes) {
258
259        Object fileKey = attributes.fileKey();
260        return attributes.lastModifiedTime().toMillis()
261            + ":"
262            + attributes.size()
263            + ":"
264            + (fileKey == null ? "" : fileKey.toString());
265    }
266
267    /** Renews selected entries with bounded parallelism. */
268    private void renewEntries(
269        List<CmsImageCacheEntry> entries,
270        Instant renewalTime,
271        CmsImageCacheMaintenanceResult.Builder result)
272    throws Exception {
273
274        if ((m_touchConcurrency == 1) || (entries.size() < 2)) {
275            for (CmsImageCacheEntry entry : entries) {
276                addRenewalResult(renewEntry(entry, renewalTime), result);
277            }
278            return;
279        }
280        ExecutorService executor = Executors.newFixedThreadPool(Math.min(m_touchConcurrency, entries.size()));
281        try {
282            List<Future<RenewalResult>> futures = new ArrayList<Future<RenewalResult>>(entries.size());
283            for (CmsImageCacheEntry entry : entries) {
284                futures.add(executor.submit(() -> renewEntry(entry, renewalTime)));
285            }
286            for (Future<RenewalResult> future : futures) {
287                try {
288                    addRenewalResult(future.get(), result);
289                } catch (ExecutionException e) {
290                    Throwable cause = e.getCause();
291                    if (cause instanceof Exception) {
292                        throw (Exception)cause;
293                    }
294                    throw e;
295                }
296            }
297        } catch (InterruptedException e) {
298            Thread.currentThread().interrupt();
299            throw e;
300        } finally {
301            executor.shutdownNow();
302        }
303    }
304
305    /**
306     * Renews one entry if its revision still matches the preceding scan.<p>
307     *
308     * File systems do not provide an atomic compare-and-touch operation. The check therefore prevents stale planned
309     * work in the normal case, but can not eliminate a replacement in the narrow interval before setting the file
310     * timestamp.<p>
311     */
312    private RenewalResult renewEntry(CmsImageCacheEntry entry, Instant renewalTime) {
313
314        if ((entry.getRevision() == null) || entry.getRevision().trim().isEmpty()) {
315            return new RenewalResult(entry, true, null);
316        }
317        try {
318            Path path = toPath(entry.getKey());
319            BasicFileAttributes attributes;
320            try {
321                attributes = Files.readAttributes(path, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS);
322            } catch (java.nio.file.NoSuchFileException e) {
323                return new RenewalResult(entry, true, null);
324            }
325            if (!attributes.isRegularFile() || !entry.getRevision().equals(createRevision(attributes))) {
326                return new RenewalResult(entry, true, null);
327            }
328            Files.setLastModifiedTime(path, FileTime.from(renewalTime));
329            return new RenewalResult(entry, false, null);
330        } catch (Exception e) {
331            return new RenewalResult(entry, false, e);
332        }
333    }
334
335    /** Creates a maintenance entry without an additional file-system metadata lookup. */
336    private CmsImageCacheEntry toEntry(Path path, BasicFileAttributes attributes) {
337
338        String key = getRepository().relativize(path).toString().replace(path.getFileSystem().getSeparator(), "/");
339        return new CmsImageCacheEntry(
340            key,
341            attributes.size(),
342            attributes.lastModifiedTime().toInstant(),
343            createRevision(attributes));
344    }
345}