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 
17 package com.android.internal.util;
18 
19 import android.os.FileUtils;
20 import android.util.Slog;
21 
22 import java.io.BufferedInputStream;
23 import java.io.BufferedOutputStream;
24 import java.io.File;
25 import java.io.FileInputStream;
26 import java.io.FileOutputStream;
27 import java.io.IOException;
28 import java.io.InputStream;
29 import java.io.OutputStream;
30 import java.util.zip.ZipEntry;
31 import java.util.zip.ZipOutputStream;
32 
33 import libcore.io.IoUtils;
34 import 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  */
50 public 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 {
read(InputStream in)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 {
write(OutputStream out)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 {
reset()84         public void reset();
shouldWrite()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      */
FileRotator(File basePath, String prefix, long rotateAgeMillis, long deleteAgeMillis)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      */
deleteAll()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      */
dumpAll(OutputStream os)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                         FileUtils.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      */
rewriteActive(Rewriter rewriter, long currentTimeMillis)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
combineActive(final Reader reader, final Writer writer, long currentTimeMillis)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      */
rewriteAll(Rewriter rewriter)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      */
rewriteSingle(Rewriter rewriter, String name)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      */
readMatching(Reader reader, long matchStartMillis, long matchEndMillis)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      */
getActiveName(long currentTimeMillis)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      */
maybeRotate(long currentTimeMillis)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 
readFile(File file, Reader reader)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 
writeFile(File file, Writer writer)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 
rethrowAsIoException(Throwable t)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 
FileInfo(String prefix)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          */
parse(String name)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          */
build()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          */
isActive()459         public boolean isActive() {
460             return endMillis == Long.MAX_VALUE;
461         }
462     }
463 }
464