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 java.time.Duration; 016import java.time.Instant; 017import java.util.ArrayList; 018import java.util.List; 019 020/** 021 * Streams timestamp-based image cache cleanup through bounded delete batches.<p> 022 */ 023public final class CmsImageCacheMaintenanceCleaner { 024 025 /** Receives progress after each delete batch. */ 026 @FunctionalInterface 027 public interface I_ProgressListener { 028 029 /** 030 * Receives cleanup progress.<p> 031 * 032 * @param result the accumulated cleanup result 033 * @param batchResult the latest delete batch result 034 * @throws Exception if progress processing fails 035 */ 036 void onProgress(Result result, CmsImageCacheMaintenanceResult batchResult) throws Exception; 037 } 038 039 /** Accumulated cleanup counters. */ 040 public static final class Result { 041 042 /** Number of failed deletes. */ 043 private long m_failed; 044 045 /** Whether the configured delete limit stopped this run. */ 046 private boolean m_deleteLimitReached; 047 048 /** Number of entries matching the time criterion. */ 049 private long m_matched; 050 051 /** Number of scanned entries. */ 052 private long m_scanned; 053 054 /** Number of skipped deletes. */ 055 private long m_skipped; 056 057 /** Number of successful deletes. */ 058 private long m_succeeded; 059 060 /** Whether the configured runtime limit stopped this run. */ 061 private boolean m_runtimeLimitReached; 062 063 /** Returns the number of failed deletes. */ 064 public long getFailed() { 065 066 return m_failed; 067 } 068 069 /** Returns the number of entries matching the time criterion. */ 070 public long getMatched() { 071 072 return m_matched; 073 } 074 075 /** Returns the number of scanned entries. */ 076 public long getScanned() { 077 078 return m_scanned; 079 } 080 081 /** Returns the number of skipped deletes. */ 082 public long getSkipped() { 083 084 return m_skipped; 085 } 086 087 /** Returns the number of successful deletes. */ 088 public long getSucceeded() { 089 090 return m_succeeded; 091 } 092 093 /** Returns whether the configured delete limit stopped this run. */ 094 public boolean isDeleteLimitReached() { 095 096 return m_deleteLimitReached; 097 } 098 099 /** Returns whether the configured runtime limit stopped this run. */ 100 public boolean isRuntimeLimitReached() { 101 102 return m_runtimeLimitReached; 103 } 104 } 105 106 /** Internal signal used to stop a streaming backend listing after a configured limit was reached. */ 107 private static final class CleanupStoppedException extends Exception { 108 109 /** Serial version id. */ 110 private static final long serialVersionUID = 1L; 111 } 112 113 /** Utility class. */ 114 private CmsImageCacheMaintenanceCleaner() { 115 116 // no instances 117 } 118 119 /** 120 * Deletes entries whose last-modified timestamp is before the cutoff.<p> 121 * 122 * A {@code null} cutoff selects all entries. Entries without a timestamp are not selected by a time-based 123 * cleanup.<p> 124 * 125 * @param service the maintenance service 126 * @param cutoff the exclusive last-modified cutoff, or {@code null} for all entries 127 * @param batchSize the maximum number of entries per delete request 128 * @param listener the optional progress listener 129 * @return the accumulated cleanup result 130 * @throws Exception if listing, deletion or progress processing fails 131 */ 132 public static Result delete( 133 CmsImageCacheMaintenanceService service, 134 Instant cutoff, 135 int batchSize, 136 I_ProgressListener listener) 137 throws Exception { 138 139 return delete(service, cutoff, batchSize, Integer.MAX_VALUE, null, listener); 140 } 141 142 /** 143 * Deletes entries whose last-modified timestamp is before the cutoff, subject to per-run limits.<p> 144 * 145 * The runtime limit is soft: an already started backend delete request is allowed to finish before the run is 146 * stopped.<p> 147 * 148 * @param service the maintenance service 149 * @param cutoff the exclusive last-modified cutoff, or {@code null} for all entries 150 * @param batchSize the maximum number of entries per delete request 151 * @param maxDeletes the maximum number of delete attempts in this run 152 * @param maxRuntime the maximum runtime, or {@code null} for no runtime limit 153 * @param listener the optional progress listener 154 * @return the accumulated cleanup result 155 * @throws Exception if listing, deletion or progress processing fails 156 */ 157 public static Result delete( 158 CmsImageCacheMaintenanceService service, 159 Instant cutoff, 160 int batchSize, 161 int maxDeletes, 162 Duration maxRuntime, 163 I_ProgressListener listener) 164 throws Exception { 165 166 if (batchSize < 1) { 167 throw new IllegalArgumentException("Image cache cleanup batch size must be positive."); 168 } 169 if (maxDeletes < 1) { 170 throw new IllegalArgumentException("Image cache cleanup delete limit must be positive."); 171 } 172 if ((maxRuntime != null) && (maxRuntime.isNegative() || maxRuntime.isZero())) { 173 throw new IllegalArgumentException("Image cache cleanup runtime limit must be positive."); 174 } 175 long startNanos = System.nanoTime(); 176 long maxRuntimeNanos = toNanosSaturated(maxRuntime); 177 Result result = new Result(); 178 List<CmsImageCacheEntry> batch = new ArrayList<CmsImageCacheEntry>(Math.min(batchSize, maxDeletes)); 179 try { 180 service.visitEntries(entry -> { 181 if (isRuntimeLimitReached(startNanos, maxRuntimeNanos)) { 182 deleteBatch(service, batch, result, listener); 183 result.m_runtimeLimitReached = true; 184 throw new CleanupStoppedException(); 185 } 186 result.m_scanned += 1; 187 if ((cutoff == null) 188 || ((entry.getLastModified() != null) && entry.getLastModified().isBefore(cutoff))) { 189 result.m_matched += 1; 190 batch.add(entry); 191 if ((batch.size() == batchSize) || (result.m_matched == maxDeletes)) { 192 deleteBatch(service, batch, result, listener); 193 } 194 if (result.m_matched == maxDeletes) { 195 result.m_deleteLimitReached = true; 196 throw new CleanupStoppedException(); 197 } 198 } 199 if (isRuntimeLimitReached(startNanos, maxRuntimeNanos)) { 200 deleteBatch(service, batch, result, listener); 201 result.m_runtimeLimitReached = true; 202 throw new CleanupStoppedException(); 203 } 204 }); 205 } catch (CleanupStoppedException e) { 206 // Expected control flow after a configured per-run limit was reached. 207 } 208 if (!batch.isEmpty()) { 209 deleteBatch(service, batch, result, listener); 210 } 211 return result; 212 } 213 214 /** Executes and reports one delete batch. */ 215 private static void deleteBatch( 216 CmsImageCacheMaintenanceService service, 217 List<CmsImageCacheEntry> batch, 218 Result result, 219 I_ProgressListener listener) 220 throws Exception { 221 222 if (batch.isEmpty()) { 223 return; 224 } 225 CmsImageCacheMaintenanceResult batchResult = service.execute( 226 CmsImageCacheMaintenanceRequest.delete(new ArrayList<CmsImageCacheEntry>(batch))); 227 batch.clear(); 228 result.m_succeeded += batchResult.getSucceeded(); 229 result.m_skipped += batchResult.getSkipped(); 230 result.m_failed += batchResult.getFailed(); 231 if (listener != null) { 232 listener.onProgress(result, batchResult); 233 } 234 } 235 236 /** Returns whether the soft runtime limit has been reached. */ 237 private static boolean isRuntimeLimitReached(long startNanos, long maxRuntimeNanos) { 238 239 return (maxRuntimeNanos != Long.MAX_VALUE) && ((System.nanoTime() - startNanos) >= maxRuntimeNanos); 240 } 241 242 /** Converts a duration to nanoseconds, saturating at {@link Long#MAX_VALUE}. */ 243 private static long toNanosSaturated(Duration duration) { 244 245 if (duration == null) { 246 return Long.MAX_VALUE; 247 } 248 try { 249 return duration.toNanos(); 250 } catch (ArithmeticException e) { 251 return Long.MAX_VALUE; 252 } 253 } 254}