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}