001/**
002 * Logback: the reliable, generic, fast and flexible logging framework. Copyright (C) 1999-2015, QOS.ch. All rights
003 * reserved.
004 *
005 * This program and the accompanying materials are dual-licensed under either the terms of the Eclipse Public License
006 * v1.0 as published by the Eclipse Foundation
007 *
008 * or (per the licensee's choosing)
009 *
010 * under the terms of the GNU Lesser General Public License version 2.1 as published by the Free Software Foundation.
011 */
012package ch.qos.logback.core.rolling.helper;
013
014import static ch.qos.logback.core.CoreConstants.UNBOUNDED_TOTAL_SIZE_CAP;
015
016import java.io.File;
017import java.time.Instant;
018import java.util.concurrent.ExecutorService;
019import java.util.concurrent.Future;
020
021import ch.qos.logback.core.CoreConstants;
022import ch.qos.logback.core.pattern.Converter;
023import ch.qos.logback.core.pattern.LiteralConverter;
024import ch.qos.logback.core.spi.ContextAwareBase;
025import ch.qos.logback.core.util.FileSize;
026
027public class TimeBasedArchiveRemover extends ContextAwareBase implements ArchiveRemover {
028
029    static protected final long UNINITIALIZED = -1;
030    // aim for 32 days, except in case of hourly rollover, see
031    // MAX_VALUE_FOR_INACTIVITY_PERIODS
032    static protected final long INACTIVITY_TOLERANCE_IN_MILLIS = 32L * (long) CoreConstants.MILLIS_IN_ONE_DAY;
033    static final int MAX_VALUE_FOR_INACTIVITY_PERIODS = 14 * 24; // 14 days in case of hourly rollover
034
035    final FileNamePattern fileNamePattern;
036    // Pattern of archives before compression. Null if there is no compression. Used
037    // to find files left uncompressed, e.g. when the application was down at rollover.
038    final FileNamePattern fileNamePatternWithoutCompSuffix;
039    final RollingCalendar rc;
040    private int maxHistory = CoreConstants.UNBOUNDED_HISTORY;
041    private long totalSizeCap = CoreConstants.UNBOUNDED_TOTAL_SIZE_CAP;
042    final boolean parentClean;
043    long lastHeartBeat = UNINITIALIZED;
044
045    public TimeBasedArchiveRemover(FileNamePattern fileNamePattern, RollingCalendar rc) {
046        this(fileNamePattern, null, rc);
047    }
048
049    /**
050     * @param fileNamePattern                  the pattern of archived files
051     * @param fileNamePatternWithoutCompSuffix the same pattern without the compression suffix, may be null.
052     *                                         Uncompressed files matching this pattern are removed
053     *                                         along with archived files.
054     * @param rc                               the rolling calendar
055     * @since 1.7.0
056     */
057    public TimeBasedArchiveRemover(FileNamePattern fileNamePattern, FileNamePattern fileNamePatternWithoutCompSuffix,
058            RollingCalendar rc) {
059        this.fileNamePattern = fileNamePattern;
060        this.fileNamePatternWithoutCompSuffix = differentOrNull(fileNamePattern, fileNamePatternWithoutCompSuffix);
061        this.rc = rc;
062        this.parentClean = computeParentCleaningFlag(fileNamePattern);
063    }
064
065    // Without compression both patterns are the same. Keeping only one avoids finding the same file twice.
066    private static FileNamePattern differentOrNull(FileNamePattern fileNamePattern,
067            FileNamePattern fileNamePatternWithoutCompSuffix) {
068        if (fileNamePatternWithoutCompSuffix == null) {
069            return null;
070        }
071        if (fileNamePatternWithoutCompSuffix.getPattern().equals(fileNamePattern.getPattern())) {
072            return null;
073        }
074        return fileNamePatternWithoutCompSuffix;
075    }
076
077    int callCount = 0;
078
079    public Future<?> cleanAsynchronously(Instant now) {
080        ArchiveRemoverRunnable runnable = new ArchiveRemoverRunnable(now);
081        ExecutorService alternateExecutorService = context.getAlternateExecutorService();
082        Future<?> future = alternateExecutorService.submit(runnable);
083        return future;
084    }
085
086    /**
087     * Called from the cleaning thread.
088     *
089     * @param now
090     */
091    @Override
092    public void clean(Instant now) {
093
094        long nowInMillis = now.toEpochMilli();
095        // for a live appender periodsElapsed is expected to be 1
096        int periodsElapsed = computeElapsedPeriodsSinceLastClean(nowInMillis);
097        lastHeartBeat = nowInMillis;
098        if (periodsElapsed > 1) {
099            addInfo("Multiple periods, i.e. " + periodsElapsed
100                    + " periods, seem to have elapsed. This can happen at application start.");
101        }
102        for (int i = 0; i < periodsElapsed; i++) {
103            int offset = getPeriodOffsetForDeletionTarget() - i;
104            Instant instantOfPeriodToClean = rc.getEndOfNextNthPeriod(now, offset);
105            cleanPeriod(instantOfPeriodToClean);
106        }
107    }
108
109    protected File[] getFilesInPeriod(Instant instantOfPeriodToClean) {
110        String filenameToDelete = fileNamePattern.convert(instantOfPeriodToClean);
111        File file2Delete = new File(filenameToDelete);
112
113        if (fileExistsAndIsFile(file2Delete)) {
114            return new File[] { file2Delete };
115        } else {
116            return new File[0];
117        }
118    }
119
120    /**
121     * Returns files of the given period which were not compressed, e.g. because the
122     * application was down at rollover time. Returns an empty array if there is no
123     * compression.
124     *
125     * <p>Only called for periods older than maxHistory. Such files are neither the active
126     * file nor being compressed.</p>
127     *
128     * @since 1.7.0
129     */
130    protected File[] getUncompressedFilesInPeriod(Instant instantOfPeriodToClean) {
131        if (fileNamePatternWithoutCompSuffix == null) {
132            return new File[0];
133        }
134        File file2Delete = new File(fileNamePatternWithoutCompSuffix.convert(instantOfPeriodToClean));
135        if (fileExistsAndIsFile(file2Delete)) {
136            return new File[] { file2Delete };
137        } else {
138            return new File[0];
139        }
140    }
141
142    private boolean fileExistsAndIsFile(File file2Delete) {
143        return file2Delete.exists() && file2Delete.isFile();
144    }
145
146    public void cleanPeriod(Instant instantOfPeriodToClean) {
147        File[] matchingFileArray = getFilesInPeriod(instantOfPeriodToClean);
148
149        File[] uncompressedFileArray = getUncompressedFilesInPeriod(instantOfPeriodToClean);
150
151        for (File f : matchingFileArray) {
152            addInfo("deleting historically stale " + f);
153            checkAndDeleteFile(f);
154        }
155        for (File f : uncompressedFileArray) {
156            addInfo("deleting historically stale uncompressed file " + f);
157            checkAndDeleteFile(f);
158        }
159
160        if (parentClean) {
161            File anyFile = matchingFileArray.length > 0 ? matchingFileArray[0]
162                    : (uncompressedFileArray.length > 0 ? uncompressedFileArray[0] : null);
163            if (anyFile != null) {
164                removeFolderIfEmpty(getParentDir(anyFile));
165            }
166        }
167    }
168
169    private boolean checkAndDeleteFile(File f) {
170
171        if (f == null) {
172            addWarn("Cannot delete empty file");
173            return false;
174        } else if (!f.exists()) {
175            addWarn("Cannot delete non existent file");
176            return false;
177        }
178
179        boolean result = f.delete();
180        if (!result) {
181            addWarn("Failed to delete file " + f.toString());
182        }
183        return result;
184    }
185
186    void capTotalSize(Instant now) {
187        long totalSize = 0;
188        long totalRemoved = 0;
189        int successfulDeletions = 0;
190            int failedDeletions = 0;
191
192        for (int offset = 0; offset < maxHistory; offset++) {
193            Instant instant = rc.getEndOfNextNthPeriod(now, -offset);
194            File[] matchingFileArray = getFilesInPeriod(instant);
195            descendingSort(matchingFileArray, instant);
196            for (File f : matchingFileArray) {
197                long size = f.length();
198                //System.out.println("File: " + f + " size=" + size);
199                totalSize += size;
200                if (totalSize > totalSizeCap) {
201                    //addInfo("Deleting [" + f + "]" + " of size " + new FileSize(size) + " on account of totalSizeCap " + totalSizeCap);
202                    addInfo("Deleting [" + f + "]" + " of size " + size + " on account of totalSizeCap " + totalSizeCap);
203
204                    boolean success = checkAndDeleteFile(f);
205
206                    if (success) {
207                        successfulDeletions++;
208                        totalRemoved += size;
209                    } else {
210                        failedDeletions++;
211                    }
212                }
213            }
214        }
215        if ((successfulDeletions + failedDeletions) == 0) {
216            addInfo("No removal attempts were made on account of totalSizeCap="+totalSizeCap);
217        } else {
218            addInfo("Removed  " + new FileSize(totalRemoved) + " of files in " + successfulDeletions + " files on account of totalSizeCap=" + totalSizeCap);
219            if (failedDeletions != 0) {
220                addInfo("There were " + failedDeletions + " failed deletion attempts.");
221            }
222        }
223    }
224
225    protected void descendingSort(File[] matchingFileArray, Instant instant) {
226        // nothing to do in super class
227    }
228
229    File getParentDir(File file) {
230        File absolute = file.getAbsoluteFile();
231        File parentDir = absolute.getParentFile();
232        return parentDir;
233    }
234
235    int computeElapsedPeriodsSinceLastClean(long nowInMillis) {
236        long periodsElapsed = 0;
237        if (lastHeartBeat == UNINITIALIZED) {
238            addInfo("first clean up after appender initialization");
239            periodsElapsed = rc.periodBarriersCrossed(nowInMillis, nowInMillis + INACTIVITY_TOLERANCE_IN_MILLIS);
240            periodsElapsed = Math.min(periodsElapsed, MAX_VALUE_FOR_INACTIVITY_PERIODS);
241        } else {
242            periodsElapsed = rc.periodBarriersCrossed(lastHeartBeat, nowInMillis);
243            // periodsElapsed of zero is possible for size and time based policies
244        }
245        return (int) periodsElapsed;
246    }
247
248    /**
249     * Computes whether the fileNamePattern may create sub-folders.
250     *
251     * @param fileNamePattern
252     * @return
253     */
254    boolean computeParentCleaningFlag(FileNamePattern fileNamePattern) {
255        DateTokenConverter<Object> dtc = fileNamePattern.getPrimaryDateTokenConverter();
256        // if the date pattern has a /, then we need parent cleaning
257        if (dtc.getDatePattern().indexOf('/') != -1) {
258            return true;
259        }
260        // if the literal string after the dtc contains a /, we also
261        // need parent cleaning
262
263        Converter<Object> p = fileNamePattern.headTokenConverter;
264
265        // find the date converter
266        while (p != null) {
267            if (p instanceof DateTokenConverter) {
268                break;
269            }
270            p = p.getNext();
271        }
272
273        while (p != null) {
274            if (p instanceof LiteralConverter) {
275                String s = p.convert(null);
276                if (s.indexOf('/') != -1) {
277                    return true;
278                }
279            }
280            p = p.getNext();
281        }
282
283        // no '/', so we don't need parent cleaning
284        return false;
285    }
286
287    void removeFolderIfEmpty(File dir) {
288        removeFolderIfEmpty(dir, 0);
289    }
290
291    /**
292     * Will remove the directory passed as parameter if empty. After that, if the parent is also becomes empty, remove
293     * the parent dir as well but at most 3 times.
294     *
295     * @param dir
296     * @param depth
297     */
298    private void removeFolderIfEmpty(File dir, int depth) {
299        // we should never go more than 3 levels higher
300        if (depth >= 3) {
301            return;
302        }
303        if (dir.isDirectory() && FileFilterUtil.isEmptyDirectory(dir)) {
304            addInfo("deleting folder [" + dir + "]");
305            checkAndDeleteFile(dir);
306            removeFolderIfEmpty(dir.getParentFile(), depth + 1);
307        }
308    }
309
310    public void setMaxHistory(int maxHistory) {
311        this.maxHistory = maxHistory;
312    }
313
314    protected int getPeriodOffsetForDeletionTarget() {
315        return -maxHistory - 1;
316    }
317
318    public void setTotalSizeCap(long totalSizeCap) {
319        this.totalSizeCap = totalSizeCap;
320    }
321
322    public String toString() {
323        return "c.q.l.core.rolling.helper.TimeBasedArchiveRemover";
324    }
325
326    public class ArchiveRemoverRunnable implements Runnable {
327        Instant now;
328
329        ArchiveRemoverRunnable(Instant now) {
330            this.now = now;
331        }
332
333        @Override
334        public void run() {
335            clean(now);
336            if (totalSizeCap != UNBOUNDED_TOTAL_SIZE_CAP && totalSizeCap > 0) {
337                capTotalSize(now);
338            }
339        }
340    }
341
342}