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}