1/*
2 * Copyright (C) 2012 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package com.android.internal.util;
18
19import android.os.FileUtils;
20import android.util.Slog;
21
22import java.io.BufferedInputStream;
23import java.io.BufferedOutputStream;
24import java.io.File;
25import java.io.FileInputStream;
26import java.io.FileOutputStream;
27import java.io.IOException;
28import java.io.InputStream;
29import java.io.OutputStream;
30import java.util.zip.ZipEntry;
31import java.util.zip.ZipOutputStream;
32
33import libcore.io.IoUtils;
34import libcore.io.Streams;
35
36/**
37 * Utility that rotates files over time, similar to {@code logrotate}. There is
38 * a single "active" file, which is periodically rotated into historical files,
39 * and eventually deleted entirely. Files are stored under a specific directory
40 * with a well-known prefix.
41 * <p>
42 * Instead of manipulating files directly, users implement interfaces that
43 * perform operations on {@link InputStream} and {@link OutputStream}. This
44 * enables atomic rewriting of file contents in
45 * {@link #rewriteActive(Rewriter, long)}.
46 * <p>
47 * Users must periodically call {@link #maybeRotate(long)} to perform actual
48 * rotation. Not inherently thread safe.
49 */
50public class FileRotator {
51    private static final String TAG = "FileRotator";
52    private static final boolean LOGD = false;
53
54    private final File mBasePath;
55    private final String mPrefix;
56    private final long mRotateAgeMillis;
57    private final long mDeleteAgeMillis;
58
59    private static final String SUFFIX_BACKUP = ".backup";
60    private static final String SUFFIX_NO_BACKUP = ".no_backup";
61
62    // TODO: provide method to append to active file
63
64    /**
65     * External class that reads data from a given {@link InputStream}. May be
66     * called multiple times when reading rotated data.
67     */
68    public interface Reader {
69        public void read(InputStream in) throws IOException;
70    }
71
72    /**
73     * External class that writes data to a given {@link OutputStream}.
74     */
75    public interface Writer {
76        public void write(OutputStream out) throws IOException;
77    }
78
79    /**
80     * External class that reads existing data from given {@link InputStream},
81     * then writes any modified data to {@link OutputStream}.
82     */
83    public interface Rewriter extends Reader, Writer {
84        public void reset();
85        public boolean shouldWrite();
86    }
87
88    /**
89     * Create a file rotator.
90     *
91     * @param basePath Directory under which all files will be placed.
92     * @param prefix Filename prefix used to identify this rotator.
93     * @param rotateAgeMillis Age in milliseconds beyond which an active file
94     *            may be rotated into a historical file.
95     * @param deleteAgeMillis Age in milliseconds beyond which a rotated file
96     *            may be deleted.
97     */
98    public FileRotator(File basePath, String prefix, long rotateAgeMillis, long deleteAgeMillis) {
99        mBasePath = Preconditions.checkNotNull(basePath);
100        mPrefix = Preconditions.checkNotNull(prefix);
101        mRotateAgeMillis = rotateAgeMillis;
102        mDeleteAgeMillis = deleteAgeMillis;
103
104        // ensure that base path exists
105        mBasePath.mkdirs();
106
107        // recover any backup files
108        for (String name : mBasePath.list()) {
109            if (!name.startsWith(mPrefix)) continue;
110
111            if (name.endsWith(SUFFIX_BACKUP)) {
112                if (LOGD) Slog.d(TAG, "recovering " + name);
113
114                final File backupFile = new File(mBasePath, name);
115                final File file = new File(
116                        mBasePath, name.substring(0, name.length() - SUFFIX_BACKUP.length()));
117
118                // write failed with backup; recover last file
119                backupFile.renameTo(file);
120
121            } else if (name.endsWith(SUFFIX_NO_BACKUP)) {
122                if (LOGD) Slog.d(TAG, "recovering " + name);
123
124                final File noBackupFile = new File(mBasePath, name);
125                final File file = new File(
126                        mBasePath, name.substring(0, name.length() - SUFFIX_NO_BACKUP.length()));
127
128                // write failed without backup; delete both
129                noBackupFile.delete();
130                file.delete();
131            }
132        }
133    }
134
135    /**
136     * Delete all files managed by this rotator.
137     */
138    public void deleteAll() {
139        final FileInfo info = new FileInfo(mPrefix);
140        for (String name : mBasePath.list()) {
141            if (info.parse(name)) {
142                // delete each file that matches parser
143                new File(mBasePath, name).delete();
144            }
145        }
146    }
147
148    /**
149     * Dump all files managed by this rotator for debugging purposes.
150     */
151    public void dumpAll(OutputStream os) throws IOException {
152        final ZipOutputStream zos = new ZipOutputStream(os);
153        try {
154            final FileInfo info = new FileInfo(mPrefix);
155            for (String name : mBasePath.list()) {
156                if (info.parse(name)) {
157                    final ZipEntry entry = new ZipEntry(name);
158                    zos.putNextEntry(entry);
159
160                    final File file = new File(mBasePath, name);
161                    final FileInputStream is = new FileInputStream(file);
162                    try {
163                        Streams.copy(is, zos);
164                    } finally {
165                        IoUtils.closeQuietly(is);
166                    }
167
168                    zos.closeEntry();
169                }
170            }
171        } finally {
172            IoUtils.closeQuietly(zos);
173        }
174    }
175
176    /**
177     * Process currently active file, first reading any existing data, then
178     * writing modified data. Maintains a backup during write, which is restored
179     * if the write fails.
180     */
181    public void rewriteActive(Rewriter rewriter, long currentTimeMillis)
182            throws IOException {
183        final String activeName = getActiveName(currentTimeMillis);
184        rewriteSingle(rewriter, activeName);
185    }
186
187    @Deprecated
188    public void combineActive(final Reader reader, final Writer writer, long currentTimeMillis)
189            throws IOException {
190        rewriteActive(new Rewriter() {
191            @Override
192            public void reset() {
193                // ignored
194            }
195
196            @Override
197            public void read(InputStream in) throws IOException {
198                reader.read(in);
199            }
200
201            @Override
202            public boolean shouldWrite() {
203                return true;
204            }
205
206            @Override
207            public void write(OutputStream out) throws IOException {
208                writer.write(out);
209            }
210        }, currentTimeMillis);
211    }
212
213    /**
214     * Process all files managed by this rotator, usually to rewrite historical
215     * data. Each file is processed atomically.
216     */
217    public void rewriteAll(Rewriter rewriter) throws IOException {
218        final FileInfo info = new FileInfo(mPrefix);
219        for (String name : mBasePath.list()) {
220            if (!info.parse(name)) continue;
221
222            // process each file that matches parser
223            rewriteSingle(rewriter, name);
224        }
225    }
226
227    /**
228     * Process a single file atomically, first reading any existing data, then
229     * writing modified data. Maintains a backup during write, which is restored
230     * if the write fails.
231     */
232    private void rewriteSingle(Rewriter rewriter, String name) throws IOException {
233        if (LOGD) Slog.d(TAG, "rewriting " + name);
234
235        final File file = new File(mBasePath, name);
236        final File backupFile;
237
238        rewriter.reset();
239
240        if (file.exists()) {
241            // read existing data
242            readFile(file, rewriter);
243
244            // skip when rewriter has nothing to write
245            if (!rewriter.shouldWrite()) return;
246
247            // backup existing data during write
248            backupFile = new File(mBasePath, name + SUFFIX_BACKUP);
249            file.renameTo(backupFile);
250
251            try {
252                writeFile(file, rewriter);
253
254                // write success, delete backup
255                backupFile.delete();
256            } catch (Throwable t) {
257                // write failed, delete file and restore backup
258                file.delete();
259                backupFile.renameTo(file);
260                throw rethrowAsIoException(t);
261            }
262
263        } else {
264            // create empty backup during write
265            backupFile = new File(mBasePath, name + SUFFIX_NO_BACKUP);
266            backupFile.createNewFile();
267
268            try {
269                writeFile(file, rewriter);
270
271                // write success, delete empty backup
272                backupFile.delete();
273            } catch (Throwable t) {
274                // write failed, delete file and empty backup
275                file.delete();
276                backupFile.delete();
277                throw rethrowAsIoException(t);
278            }
279        }
280    }
281
282    /**
283     * Read any rotated data that overlap the requested time range.
284     */
285    public void readMatching(Reader reader, long matchStartMillis, long matchEndMillis)
286            throws IOException {
287        final FileInfo info = new FileInfo(mPrefix);
288        for (String name : mBasePath.list()) {
289            if (!info.parse(name)) continue;
290
291            // read file when it overlaps
292            if (info.startMillis <= matchEndMillis && matchStartMillis <= info.endMillis) {
293                if (LOGD) Slog.d(TAG, "reading matching " + name);
294
295                final File file = new File(mBasePath, name);
296                readFile(file, reader);
297            }
298        }
299    }
300
301    /**
302     * Return the currently active file, which may not exist yet.
303     */
304    private String getActiveName(long currentTimeMillis) {
305        String oldestActiveName = null;
306        long oldestActiveStart = Long.MAX_VALUE;
307
308        final FileInfo info = new FileInfo(mPrefix);
309        for (String name : mBasePath.list()) {
310            if (!info.parse(name)) continue;
311
312            // pick the oldest active file which covers current time
313            if (info.isActive() && info.startMillis < currentTimeMillis
314                    && info.startMillis < oldestActiveStart) {
315                oldestActiveName = name;
316                oldestActiveStart = info.startMillis;
317            }
318        }
319
320        if (oldestActiveName != null) {
321            return oldestActiveName;
322        } else {
323            // no active file found above; create one starting now
324            info.startMillis = currentTimeMillis;
325            info.endMillis = Long.MAX_VALUE;
326            return info.build();
327        }
328    }
329
330    /**
331     * Examine all files managed by this rotator, renaming or deleting if their
332     * age matches the configured thresholds.
333     */
334    public void maybeRotate(long currentTimeMillis) {
335        final long rotateBefore = currentTimeMillis - mRotateAgeMillis;
336        final long deleteBefore = currentTimeMillis - mDeleteAgeMillis;
337
338        final FileInfo info = new FileInfo(mPrefix);
339        for (String name : mBasePath.list()) {
340            if (!info.parse(name)) continue;
341
342            if (info.isActive()) {
343                if (info.startMillis <= rotateBefore) {
344                    // found active file; rotate if old enough
345                    if (LOGD) Slog.d(TAG, "rotating " + name);
346
347                    info.endMillis = currentTimeMillis;
348
349                    final File file = new File(mBasePath, name);
350                    final File destFile = new File(mBasePath, info.build());
351                    file.renameTo(destFile);
352                }
353            } else if (info.endMillis <= deleteBefore) {
354                // found rotated file; delete if old enough
355                if (LOGD) Slog.d(TAG, "deleting " + name);
356
357                final File file = new File(mBasePath, name);
358                file.delete();
359            }
360        }
361    }
362
363    private static void readFile(File file, Reader reader) throws IOException {
364        final FileInputStream fis = new FileInputStream(file);
365        final BufferedInputStream bis = new BufferedInputStream(fis);
366        try {
367            reader.read(bis);
368        } finally {
369            IoUtils.closeQuietly(bis);
370        }
371    }
372
373    private static void writeFile(File file, Writer writer) throws IOException {
374        final FileOutputStream fos = new FileOutputStream(file);
375        final BufferedOutputStream bos = new BufferedOutputStream(fos);
376        try {
377            writer.write(bos);
378            bos.flush();
379        } finally {
380            FileUtils.sync(fos);
381            IoUtils.closeQuietly(bos);
382        }
383    }
384
385    private static IOException rethrowAsIoException(Throwable t) throws IOException {
386        if (t instanceof IOException) {
387            throw (IOException) t;
388        } else {
389            throw new IOException(t.getMessage(), t);
390        }
391    }
392
393    /**
394     * Details for a rotated file, either parsed from an existing filename, or
395     * ready to be built into a new filename.
396     */
397    private static class FileInfo {
398        public final String prefix;
399
400        public long startMillis;
401        public long endMillis;
402
403        public FileInfo(String prefix) {
404            this.prefix = Preconditions.checkNotNull(prefix);
405        }
406
407        /**
408         * Attempt parsing the given filename.
409         *
410         * @return Whether parsing was successful.
411         */
412        public boolean parse(String name) {
413            startMillis = endMillis = -1;
414
415            final int dotIndex = name.lastIndexOf('.');
416            final int dashIndex = name.lastIndexOf('-');
417
418            // skip when missing time section
419            if (dotIndex == -1 || dashIndex == -1) return false;
420
421            // skip when prefix doesn't match
422            if (!prefix.equals(name.substring(0, dotIndex))) return false;
423
424            try {
425                startMillis = Long.parseLong(name.substring(dotIndex + 1, dashIndex));
426
427                if (name.length() - dashIndex == 1) {
428                    endMillis = Long.MAX_VALUE;
429                } else {
430                    endMillis = Long.parseLong(name.substring(dashIndex + 1));
431                }
432
433                return true;
434            } catch (NumberFormatException e) {
435                return false;
436            }
437        }
438
439        /**
440         * Build current state into filename.
441         */
442        public String build() {
443            final StringBuilder name = new StringBuilder();
444            name.append(prefix).append('.').append(startMillis).append('-');
445            if (endMillis != Long.MAX_VALUE) {
446                name.append(endMillis);
447            }
448            return name.toString();
449        }
450
451        /**
452         * Test if current file is active (no end timestamp).
453         */
454        public boolean isActive() {
455            return endMillis == Long.MAX_VALUE;
456        }
457    }
458}
459