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