FileRotator.java revision a27a3e8ad7d20dea63ef2d5cb8b6ec7e56c20a89
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;
20
21import com.android.internal.util.FileRotator.Reader;
22import com.android.internal.util.FileRotator.Writer;
23
24import java.io.BufferedInputStream;
25import java.io.BufferedOutputStream;
26import java.io.File;
27import java.io.FileInputStream;
28import java.io.FileOutputStream;
29import java.io.IOException;
30import java.io.InputStream;
31import java.io.OutputStream;
32
33import libcore.io.IoUtils;
34
35/**
36 * Utility that rotates files over time, similar to {@code logrotate}. There is
37 * a single "active" file, which is periodically rotated into historical files,
38 * and eventually deleted entirely. Files are stored under a specific directory
39 * with a well-known prefix.
40 * <p>
41 * Instead of manipulating files directly, users implement interfaces that
42 * perform operations on {@link InputStream} and {@link OutputStream}. This
43 * enables atomic rewriting of file contents in
44 * {@link #combineActive(Reader, Writer, long)}.
45 * <p>
46 * Users must periodically call {@link #maybeRotate(long)} to perform actual
47 * rotation. Not inherently thread safe.
48 */
49public class FileRotator {
50    private final File mBasePath;
51    private final String mPrefix;
52    private final long mRotateAgeMillis;
53    private final long mDeleteAgeMillis;
54
55    private static final String SUFFIX_BACKUP = ".backup";
56    private static final String SUFFIX_NO_BACKUP = ".no_backup";
57
58    // TODO: provide method to append to active file
59
60    /**
61     * External class that reads data from a given {@link InputStream}. May be
62     * called multiple times when reading rotated data.
63     */
64    public interface Reader {
65        public void read(InputStream in) throws IOException;
66    }
67
68    /**
69     * External class that writes data to a given {@link OutputStream}.
70     */
71    public interface Writer {
72        public void write(OutputStream out) throws IOException;
73    }
74
75    /**
76     * Create a file rotator.
77     *
78     * @param basePath Directory under which all files will be placed.
79     * @param prefix Filename prefix used to identify this rotator.
80     * @param rotateAgeMillis Age in milliseconds beyond which an active file
81     *            may be rotated into a historical file.
82     * @param deleteAgeMillis Age in milliseconds beyond which a rotated file
83     *            may be deleted.
84     */
85    public FileRotator(File basePath, String prefix, long rotateAgeMillis, long deleteAgeMillis) {
86        mBasePath = Preconditions.checkNotNull(basePath);
87        mPrefix = Preconditions.checkNotNull(prefix);
88        mRotateAgeMillis = rotateAgeMillis;
89        mDeleteAgeMillis = deleteAgeMillis;
90
91        // ensure that base path exists
92        mBasePath.mkdirs();
93
94        // recover any backup files
95        for (String name : mBasePath.list()) {
96            if (!name.startsWith(mPrefix)) continue;
97
98            if (name.endsWith(SUFFIX_BACKUP)) {
99                final File backupFile = new File(mBasePath, name);
100                final File file = new File(
101                        mBasePath, name.substring(0, name.length() - SUFFIX_BACKUP.length()));
102
103                // write failed with backup; recover last file
104                backupFile.renameTo(file);
105
106            } else if (name.endsWith(SUFFIX_NO_BACKUP)) {
107                final File noBackupFile = new File(mBasePath, name);
108                final File file = new File(
109                        mBasePath, name.substring(0, name.length() - SUFFIX_NO_BACKUP.length()));
110
111                // write failed without backup; delete both
112                noBackupFile.delete();
113                file.delete();
114            }
115        }
116    }
117
118    /**
119     * Atomically combine data with existing data in currently active file.
120     * Maintains a backup during write, which is restored if the write fails.
121     */
122    public void combineActive(Reader reader, Writer writer, long currentTimeMillis)
123            throws IOException {
124        final String activeName = getActiveName(currentTimeMillis);
125
126        final File file = new File(mBasePath, activeName);
127        final File backupFile;
128
129        if (file.exists()) {
130            // read existing data
131            readFile(file, reader);
132
133            // backup existing data during write
134            backupFile = new File(mBasePath, activeName + SUFFIX_BACKUP);
135            file.renameTo(backupFile);
136
137            try {
138                writeFile(file, writer);
139
140                // write success, delete backup
141                backupFile.delete();
142            } catch (IOException e) {
143                // write failed, delete file and restore backup
144                file.delete();
145                backupFile.renameTo(file);
146                throw e;
147            }
148
149        } else {
150            // create empty backup during write
151            backupFile = new File(mBasePath, activeName + SUFFIX_NO_BACKUP);
152            backupFile.createNewFile();
153
154            try {
155                writeFile(file, writer);
156
157                // write success, delete empty backup
158                backupFile.delete();
159            } catch (IOException e) {
160                // write failed, delete file and empty backup
161                file.delete();
162                backupFile.delete();
163                throw e;
164            }
165        }
166    }
167
168    /**
169     * Read any rotated data that overlap the requested time range.
170     */
171    public void readMatching(Reader reader, long matchStartMillis, long matchEndMillis)
172            throws IOException {
173        final FileInfo info = new FileInfo(mPrefix);
174        for (String name : mBasePath.list()) {
175            if (!info.parse(name)) continue;
176
177            // read file when it overlaps
178            if (info.startMillis <= matchEndMillis && matchStartMillis <= info.endMillis) {
179                final File file = new File(mBasePath, name);
180                readFile(file, reader);
181            }
182        }
183    }
184
185    /**
186     * Return the currently active file, which may not exist yet.
187     */
188    private String getActiveName(long currentTimeMillis) {
189        String oldestActiveName = null;
190        long oldestActiveStart = Long.MAX_VALUE;
191
192        final FileInfo info = new FileInfo(mPrefix);
193        for (String name : mBasePath.list()) {
194            if (!info.parse(name)) continue;
195
196            // pick the oldest active file which covers current time
197            if (info.isActive() && info.startMillis < currentTimeMillis
198                    && info.startMillis < oldestActiveStart) {
199                oldestActiveName = name;
200                oldestActiveStart = info.startMillis;
201            }
202        }
203
204        if (oldestActiveName != null) {
205            return oldestActiveName;
206        } else {
207            // no active file found above; create one starting now
208            info.startMillis = currentTimeMillis;
209            info.endMillis = Long.MAX_VALUE;
210            return info.build();
211        }
212    }
213
214    /**
215     * Examine all files managed by this rotator, renaming or deleting if their
216     * age matches the configured thresholds.
217     */
218    public void maybeRotate(long currentTimeMillis) {
219        final long rotateBefore = currentTimeMillis - mRotateAgeMillis;
220        final long deleteBefore = currentTimeMillis - mDeleteAgeMillis;
221
222        final FileInfo info = new FileInfo(mPrefix);
223        for (String name : mBasePath.list()) {
224            if (!info.parse(name)) continue;
225
226            if (info.isActive()) {
227                // found active file; rotate if old enough
228                if (info.startMillis < rotateBefore) {
229                    info.endMillis = currentTimeMillis;
230
231                    final File file = new File(mBasePath, name);
232                    final File destFile = new File(mBasePath, info.build());
233                    file.renameTo(destFile);
234                }
235            } else if (info.endMillis < deleteBefore) {
236                // found rotated file; delete if old enough
237                final File file = new File(mBasePath, name);
238                file.delete();
239            }
240        }
241    }
242
243    private static void readFile(File file, Reader reader) throws IOException {
244        final FileInputStream fis = new FileInputStream(file);
245        final BufferedInputStream bis = new BufferedInputStream(fis);
246        try {
247            reader.read(bis);
248        } finally {
249            IoUtils.closeQuietly(bis);
250        }
251    }
252
253    private static void writeFile(File file, Writer writer) throws IOException {
254        final FileOutputStream fos = new FileOutputStream(file);
255        final BufferedOutputStream bos = new BufferedOutputStream(fos);
256        try {
257            writer.write(bos);
258            bos.flush();
259        } finally {
260            FileUtils.sync(fos);
261            IoUtils.closeQuietly(bos);
262        }
263    }
264
265    /**
266     * Details for a rotated file, either parsed from an existing filename, or
267     * ready to be built into a new filename.
268     */
269    private static class FileInfo {
270        public final String prefix;
271
272        public long startMillis;
273        public long endMillis;
274
275        public FileInfo(String prefix) {
276            this.prefix = Preconditions.checkNotNull(prefix);
277        }
278
279        /**
280         * Attempt parsing the given filename.
281         *
282         * @return Whether parsing was successful.
283         */
284        public boolean parse(String name) {
285            startMillis = endMillis = -1;
286
287            final int dotIndex = name.lastIndexOf('.');
288            final int dashIndex = name.lastIndexOf('-');
289
290            // skip when missing time section
291            if (dotIndex == -1 || dashIndex == -1) return false;
292
293            // skip when prefix doesn't match
294            if (!prefix.equals(name.substring(0, dotIndex))) return false;
295
296            try {
297                startMillis = Long.parseLong(name.substring(dotIndex + 1, dashIndex));
298
299                if (name.length() - dashIndex == 1) {
300                    endMillis = Long.MAX_VALUE;
301                } else {
302                    endMillis = Long.parseLong(name.substring(dashIndex + 1));
303                }
304
305                return true;
306            } catch (NumberFormatException e) {
307                return false;
308            }
309        }
310
311        /**
312         * Build current state into filename.
313         */
314        public String build() {
315            final StringBuilder name = new StringBuilder();
316            name.append(prefix).append('.').append(startMillis).append('-');
317            if (endMillis != Long.MAX_VALUE) {
318                name.append(endMillis);
319            }
320            return name.toString();
321        }
322
323        /**
324         * Test if current file is active (no end timestamp).
325         */
326        public boolean isActive() {
327            return endMillis == Long.MAX_VALUE;
328        }
329    }
330}
331