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        String[] baseFiles = mBasePath.list();
340        if (baseFiles == null) {
341            return;
342        }
343
344        for (String name : baseFiles) {
345            if (!info.parse(name)) continue;
346
347            if (info.isActive()) {
348                if (info.startMillis <= rotateBefore) {
349                    // found active file; rotate if old enough
350                    if (LOGD) Slog.d(TAG, "rotating " + name);
351
352                    info.endMillis = currentTimeMillis;
353
354                    final File file = new File(mBasePath, name);
355                    final File destFile = new File(mBasePath, info.build());
356                    file.renameTo(destFile);
357                }
358            } else if (info.endMillis <= deleteBefore) {
359                // found rotated file; delete if old enough
360                if (LOGD) Slog.d(TAG, "deleting " + name);
361
362                final File file = new File(mBasePath, name);
363                file.delete();
364            }
365        }
366    }
367
368    private static void readFile(File file, Reader reader) throws IOException {
369        final FileInputStream fis = new FileInputStream(file);
370        final BufferedInputStream bis = new BufferedInputStream(fis);
371        try {
372            reader.read(bis);
373        } finally {
374            IoUtils.closeQuietly(bis);
375        }
376    }
377
378    private static void writeFile(File file, Writer writer) throws IOException {
379        final FileOutputStream fos = new FileOutputStream(file);
380        final BufferedOutputStream bos = new BufferedOutputStream(fos);
381        try {
382            writer.write(bos);
383            bos.flush();
384        } finally {
385            FileUtils.sync(fos);
386            IoUtils.closeQuietly(bos);
387        }
388    }
389
390    private static IOException rethrowAsIoException(Throwable t) throws IOException {
391        if (t instanceof IOException) {
392            throw (IOException) t;
393        } else {
394            throw new IOException(t.getMessage(), t);
395        }
396    }
397
398    /**
399     * Details for a rotated file, either parsed from an existing filename, or
400     * ready to be built into a new filename.
401     */
402    private static class FileInfo {
403        public final String prefix;
404
405        public long startMillis;
406        public long endMillis;
407
408        public FileInfo(String prefix) {
409            this.prefix = Preconditions.checkNotNull(prefix);
410        }
411
412        /**
413         * Attempt parsing the given filename.
414         *
415         * @return Whether parsing was successful.
416         */
417        public boolean parse(String name) {
418            startMillis = endMillis = -1;
419
420            final int dotIndex = name.lastIndexOf('.');
421            final int dashIndex = name.lastIndexOf('-');
422
423            // skip when missing time section
424            if (dotIndex == -1 || dashIndex == -1) return false;
425
426            // skip when prefix doesn't match
427            if (!prefix.equals(name.substring(0, dotIndex))) return false;
428
429            try {
430                startMillis = Long.parseLong(name.substring(dotIndex + 1, dashIndex));
431
432                if (name.length() - dashIndex == 1) {
433                    endMillis = Long.MAX_VALUE;
434                } else {
435                    endMillis = Long.parseLong(name.substring(dashIndex + 1));
436                }
437
438                return true;
439            } catch (NumberFormatException e) {
440                return false;
441            }
442        }
443
444        /**
445         * Build current state into filename.
446         */
447        public String build() {
448            final StringBuilder name = new StringBuilder();
449            name.append(prefix).append('.').append(startMillis).append('-');
450            if (endMillis != Long.MAX_VALUE) {
451                name.append(endMillis);
452            }
453            return name.toString();
454        }
455
456        /**
457         * Test if current file is active (no end timestamp).
458         */
459        public boolean isActive() {
460            return endMillis == Long.MAX_VALUE;
461        }
462    }
463}
464