| /* |
| * Copyright (C) 2012 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| package com.android.internal.util; |
| |
| import android.os.FileUtils; |
| import android.util.Slog; |
| |
| import java.io.BufferedInputStream; |
| import java.io.BufferedOutputStream; |
| import java.io.File; |
| import java.io.FileInputStream; |
| import java.io.FileOutputStream; |
| import java.io.IOException; |
| import java.io.InputStream; |
| import java.io.OutputStream; |
| import java.util.zip.ZipEntry; |
| import java.util.zip.ZipOutputStream; |
| |
| import libcore.io.IoUtils; |
| import libcore.io.Streams; |
| |
| /** |
| * Utility that rotates files over time, similar to {@code logrotate}. There is |
| * a single "active" file, which is periodically rotated into historical files, |
| * and eventually deleted entirely. Files are stored under a specific directory |
| * with a well-known prefix. |
| * <p> |
| * Instead of manipulating files directly, users implement interfaces that |
| * perform operations on {@link InputStream} and {@link OutputStream}. This |
| * enables atomic rewriting of file contents in |
| * {@link #rewriteActive(Rewriter, long)}. |
| * <p> |
| * Users must periodically call {@link #maybeRotate(long)} to perform actual |
| * rotation. Not inherently thread safe. |
| */ |
| public class FileRotator { |
| private static final String TAG = "FileRotator"; |
| private static final boolean LOGD = false; |
| |
| private final File mBasePath; |
| private final String mPrefix; |
| private final long mRotateAgeMillis; |
| private final long mDeleteAgeMillis; |
| |
| private static final String SUFFIX_BACKUP = ".backup"; |
| private static final String SUFFIX_NO_BACKUP = ".no_backup"; |
| |
| // TODO: provide method to append to active file |
| |
| /** |
| * External class that reads data from a given {@link InputStream}. May be |
| * called multiple times when reading rotated data. |
| */ |
| public interface Reader { |
| public void read(InputStream in) throws IOException; |
| } |
| |
| /** |
| * External class that writes data to a given {@link OutputStream}. |
| */ |
| public interface Writer { |
| public void write(OutputStream out) throws IOException; |
| } |
| |
| /** |
| * External class that reads existing data from given {@link InputStream}, |
| * then writes any modified data to {@link OutputStream}. |
| */ |
| public interface Rewriter extends Reader, Writer { |
| public void reset(); |
| public boolean shouldWrite(); |
| } |
| |
| /** |
| * Create a file rotator. |
| * |
| * @param basePath Directory under which all files will be placed. |
| * @param prefix Filename prefix used to identify this rotator. |
| * @param rotateAgeMillis Age in milliseconds beyond which an active file |
| * may be rotated into a historical file. |
| * @param deleteAgeMillis Age in milliseconds beyond which a rotated file |
| * may be deleted. |
| */ |
| public FileRotator(File basePath, String prefix, long rotateAgeMillis, long deleteAgeMillis) { |
| mBasePath = Preconditions.checkNotNull(basePath); |
| mPrefix = Preconditions.checkNotNull(prefix); |
| mRotateAgeMillis = rotateAgeMillis; |
| mDeleteAgeMillis = deleteAgeMillis; |
| |
| // ensure that base path exists |
| mBasePath.mkdirs(); |
| |
| // recover any backup files |
| for (String name : mBasePath.list()) { |
| if (!name.startsWith(mPrefix)) continue; |
| |
| if (name.endsWith(SUFFIX_BACKUP)) { |
| if (LOGD) Slog.d(TAG, "recovering " + name); |
| |
| final File backupFile = new File(mBasePath, name); |
| final File file = new File( |
| mBasePath, name.substring(0, name.length() - SUFFIX_BACKUP.length())); |
| |
| // write failed with backup; recover last file |
| backupFile.renameTo(file); |
| |
| } else if (name.endsWith(SUFFIX_NO_BACKUP)) { |
| if (LOGD) Slog.d(TAG, "recovering " + name); |
| |
| final File noBackupFile = new File(mBasePath, name); |
| final File file = new File( |
| mBasePath, name.substring(0, name.length() - SUFFIX_NO_BACKUP.length())); |
| |
| // write failed without backup; delete both |
| noBackupFile.delete(); |
| file.delete(); |
| } |
| } |
| } |
| |
| /** |
| * Delete all files managed by this rotator. |
| */ |
| public void deleteAll() { |
| final FileInfo info = new FileInfo(mPrefix); |
| for (String name : mBasePath.list()) { |
| if (info.parse(name)) { |
| // delete each file that matches parser |
| new File(mBasePath, name).delete(); |
| } |
| } |
| } |
| |
| /** |
| * Dump all files managed by this rotator for debugging purposes. |
| */ |
| public void dumpAll(OutputStream os) throws IOException { |
| final ZipOutputStream zos = new ZipOutputStream(os); |
| try { |
| final FileInfo info = new FileInfo(mPrefix); |
| for (String name : mBasePath.list()) { |
| if (info.parse(name)) { |
| final ZipEntry entry = new ZipEntry(name); |
| zos.putNextEntry(entry); |
| |
| final File file = new File(mBasePath, name); |
| final FileInputStream is = new FileInputStream(file); |
| try { |
| Streams.copy(is, zos); |
| } finally { |
| IoUtils.closeQuietly(is); |
| } |
| |
| zos.closeEntry(); |
| } |
| } |
| } finally { |
| IoUtils.closeQuietly(zos); |
| } |
| } |
| |
| /** |
| * Process currently active file, first reading any existing data, then |
| * writing modified data. Maintains a backup during write, which is restored |
| * if the write fails. |
| */ |
| public void rewriteActive(Rewriter rewriter, long currentTimeMillis) |
| throws IOException { |
| final String activeName = getActiveName(currentTimeMillis); |
| rewriteSingle(rewriter, activeName); |
| } |
| |
| @Deprecated |
| public void combineActive(final Reader reader, final Writer writer, long currentTimeMillis) |
| throws IOException { |
| rewriteActive(new Rewriter() { |
| @Override |
| public void reset() { |
| // ignored |
| } |
| |
| @Override |
| public void read(InputStream in) throws IOException { |
| reader.read(in); |
| } |
| |
| @Override |
| public boolean shouldWrite() { |
| return true; |
| } |
| |
| @Override |
| public void write(OutputStream out) throws IOException { |
| writer.write(out); |
| } |
| }, currentTimeMillis); |
| } |
| |
| /** |
| * Process all files managed by this rotator, usually to rewrite historical |
| * data. Each file is processed atomically. |
| */ |
| public void rewriteAll(Rewriter rewriter) throws IOException { |
| final FileInfo info = new FileInfo(mPrefix); |
| for (String name : mBasePath.list()) { |
| if (!info.parse(name)) continue; |
| |
| // process each file that matches parser |
| rewriteSingle(rewriter, name); |
| } |
| } |
| |
| /** |
| * Process a single file atomically, first reading any existing data, then |
| * writing modified data. Maintains a backup during write, which is restored |
| * if the write fails. |
| */ |
| private void rewriteSingle(Rewriter rewriter, String name) throws IOException { |
| if (LOGD) Slog.d(TAG, "rewriting " + name); |
| |
| final File file = new File(mBasePath, name); |
| final File backupFile; |
| |
| rewriter.reset(); |
| |
| if (file.exists()) { |
| // read existing data |
| readFile(file, rewriter); |
| |
| // skip when rewriter has nothing to write |
| if (!rewriter.shouldWrite()) return; |
| |
| // backup existing data during write |
| backupFile = new File(mBasePath, name + SUFFIX_BACKUP); |
| file.renameTo(backupFile); |
| |
| try { |
| writeFile(file, rewriter); |
| |
| // write success, delete backup |
| backupFile.delete(); |
| } catch (Throwable t) { |
| // write failed, delete file and restore backup |
| file.delete(); |
| backupFile.renameTo(file); |
| throw rethrowAsIoException(t); |
| } |
| |
| } else { |
| // create empty backup during write |
| backupFile = new File(mBasePath, name + SUFFIX_NO_BACKUP); |
| backupFile.createNewFile(); |
| |
| try { |
| writeFile(file, rewriter); |
| |
| // write success, delete empty backup |
| backupFile.delete(); |
| } catch (Throwable t) { |
| // write failed, delete file and empty backup |
| file.delete(); |
| backupFile.delete(); |
| throw rethrowAsIoException(t); |
| } |
| } |
| } |
| |
| /** |
| * Read any rotated data that overlap the requested time range. |
| */ |
| public void readMatching(Reader reader, long matchStartMillis, long matchEndMillis) |
| throws IOException { |
| final FileInfo info = new FileInfo(mPrefix); |
| for (String name : mBasePath.list()) { |
| if (!info.parse(name)) continue; |
| |
| // read file when it overlaps |
| if (info.startMillis <= matchEndMillis && matchStartMillis <= info.endMillis) { |
| if (LOGD) Slog.d(TAG, "reading matching " + name); |
| |
| final File file = new File(mBasePath, name); |
| readFile(file, reader); |
| } |
| } |
| } |
| |
| /** |
| * Return the currently active file, which may not exist yet. |
| */ |
| private String getActiveName(long currentTimeMillis) { |
| String oldestActiveName = null; |
| long oldestActiveStart = Long.MAX_VALUE; |
| |
| final FileInfo info = new FileInfo(mPrefix); |
| for (String name : mBasePath.list()) { |
| if (!info.parse(name)) continue; |
| |
| // pick the oldest active file which covers current time |
| if (info.isActive() && info.startMillis < currentTimeMillis |
| && info.startMillis < oldestActiveStart) { |
| oldestActiveName = name; |
| oldestActiveStart = info.startMillis; |
| } |
| } |
| |
| if (oldestActiveName != null) { |
| return oldestActiveName; |
| } else { |
| // no active file found above; create one starting now |
| info.startMillis = currentTimeMillis; |
| info.endMillis = Long.MAX_VALUE; |
| return info.build(); |
| } |
| } |
| |
| /** |
| * Examine all files managed by this rotator, renaming or deleting if their |
| * age matches the configured thresholds. |
| */ |
| public void maybeRotate(long currentTimeMillis) { |
| final long rotateBefore = currentTimeMillis - mRotateAgeMillis; |
| final long deleteBefore = currentTimeMillis - mDeleteAgeMillis; |
| |
| final FileInfo info = new FileInfo(mPrefix); |
| String[] baseFiles = mBasePath.list(); |
| if (baseFiles == null) { |
| return; |
| } |
| |
| for (String name : baseFiles) { |
| if (!info.parse(name)) continue; |
| |
| if (info.isActive()) { |
| if (info.startMillis <= rotateBefore) { |
| // found active file; rotate if old enough |
| if (LOGD) Slog.d(TAG, "rotating " + name); |
| |
| info.endMillis = currentTimeMillis; |
| |
| final File file = new File(mBasePath, name); |
| final File destFile = new File(mBasePath, info.build()); |
| file.renameTo(destFile); |
| } |
| } else if (info.endMillis <= deleteBefore) { |
| // found rotated file; delete if old enough |
| if (LOGD) Slog.d(TAG, "deleting " + name); |
| |
| final File file = new File(mBasePath, name); |
| file.delete(); |
| } |
| } |
| } |
| |
| private static void readFile(File file, Reader reader) throws IOException { |
| final FileInputStream fis = new FileInputStream(file); |
| final BufferedInputStream bis = new BufferedInputStream(fis); |
| try { |
| reader.read(bis); |
| } finally { |
| IoUtils.closeQuietly(bis); |
| } |
| } |
| |
| private static void writeFile(File file, Writer writer) throws IOException { |
| final FileOutputStream fos = new FileOutputStream(file); |
| final BufferedOutputStream bos = new BufferedOutputStream(fos); |
| try { |
| writer.write(bos); |
| bos.flush(); |
| } finally { |
| FileUtils.sync(fos); |
| IoUtils.closeQuietly(bos); |
| } |
| } |
| |
| private static IOException rethrowAsIoException(Throwable t) throws IOException { |
| if (t instanceof IOException) { |
| throw (IOException) t; |
| } else { |
| throw new IOException(t.getMessage(), t); |
| } |
| } |
| |
| /** |
| * Details for a rotated file, either parsed from an existing filename, or |
| * ready to be built into a new filename. |
| */ |
| private static class FileInfo { |
| public final String prefix; |
| |
| public long startMillis; |
| public long endMillis; |
| |
| public FileInfo(String prefix) { |
| this.prefix = Preconditions.checkNotNull(prefix); |
| } |
| |
| /** |
| * Attempt parsing the given filename. |
| * |
| * @return Whether parsing was successful. |
| */ |
| public boolean parse(String name) { |
| startMillis = endMillis = -1; |
| |
| final int dotIndex = name.lastIndexOf('.'); |
| final int dashIndex = name.lastIndexOf('-'); |
| |
| // skip when missing time section |
| if (dotIndex == -1 || dashIndex == -1) return false; |
| |
| // skip when prefix doesn't match |
| if (!prefix.equals(name.substring(0, dotIndex))) return false; |
| |
| try { |
| startMillis = Long.parseLong(name.substring(dotIndex + 1, dashIndex)); |
| |
| if (name.length() - dashIndex == 1) { |
| endMillis = Long.MAX_VALUE; |
| } else { |
| endMillis = Long.parseLong(name.substring(dashIndex + 1)); |
| } |
| |
| return true; |
| } catch (NumberFormatException e) { |
| return false; |
| } |
| } |
| |
| /** |
| * Build current state into filename. |
| */ |
| public String build() { |
| final StringBuilder name = new StringBuilder(); |
| name.append(prefix).append('.').append(startMillis).append('-'); |
| if (endMillis != Long.MAX_VALUE) { |
| name.append(endMillis); |
| } |
| return name.toString(); |
| } |
| |
| /** |
| * Test if current file is active (no end timestamp). |
| */ |
| public boolean isActive() { |
| return endMillis == Long.MAX_VALUE; |
| } |
| } |
| } |