View Javadoc
1   package emissary.util.roll;
2   
3   import emissary.roll.Rollable;
4   import emissary.util.io.FileNameGenerator;
5   
6   import jakarta.annotation.Nullable;
7   import org.slf4j.Logger;
8   import org.slf4j.LoggerFactory;
9   
10  import java.io.Closeable;
11  import java.io.File;
12  import java.io.FileOutputStream;
13  import java.io.FilenameFilter;
14  import java.io.IOException;
15  import java.io.OutputStream;
16  import java.nio.file.Files;
17  import java.util.UUID;
18  import java.util.concurrent.atomic.AtomicLong;
19  import java.util.concurrent.locks.ReentrantLock;
20  
21  /**
22   * Allows for use within the Emissary Rolling framework. Keeps track of bytes written and is thread safe.
23   */
24  public class RollableFileOutputStream extends OutputStream implements Rollable {
25      private static final Logger LOG = LoggerFactory.getLogger(RollableFileOutputStream.class);
26      /** Locks for protecting writes to underlying stream */
27      final ReentrantLock lock = new ReentrantLock();
28      /** Flag to let callers know if this class is currently rolling */
29      volatile boolean rolling;
30      /** Current output stream we're writing to */
31      @Nullable
32      FileOutputStream fileOutputStream;
33      /** Current File we're writing to */
34      @Nullable
35      File currentFile;
36      /** File Name Generator for creating unique file names */
37      FileNameGenerator namegen;
38      /** Directory we're writing to */
39      private final File dir;
40      /** Number of bytes written to file */
41      long bytesWritten;
42      /** Whether to delete a zero byte file */
43      boolean deleteZeroByteFiles = true;
44      /**
45       * internal sequencer in case FileNameGenerator does not obey contract. Present for defense only
46       */
47      private final AtomicLong seq = new AtomicLong();
48  
49      public RollableFileOutputStream(FileNameGenerator namegen, File dir) throws IOException {
50          if (dir == null || !dir.exists() || !dir.isDirectory()) {
51              throw new IllegalArgumentException("Directory is invalid: " + dir);
52          }
53          this.namegen = namegen;
54          this.dir = dir;
55          handleOrphanedFiles();
56          open();
57      }
58  
59      public RollableFileOutputStream(FileNameGenerator namegen) throws IOException {
60          this(namegen, new File("."));
61      }
62  
63      private void handleOrphanedFiles() {
64          // Create FilenameFilter
65          FilenameFilter filter = (directory, name) -> name.startsWith(".");
66  
67          // Look for any dot files in directory
68          for (File file : this.dir.listFiles(filter)) {
69              if (file.isFile()) {
70                  LOG.info("Renaming orphaned file, {}, to non-dot file.", file.getName());
71                  rename(file);
72              }
73          }
74      }
75  
76      private void open() throws IOException {
77          File newFile = getNewFile();
78          currentFile = newFile;
79          fileOutputStream = new FileOutputStream(newFile, true);
80      }
81  
82      private File getNewFile() {
83          String newName = namegen.nextFileName();
84          String dotFile = "." + newName;
85          String seqFname = "." + seq.get() + "_" + newName;
86          if (currentFile != null && (dotFile.equals(currentFile.getName()) || seqFname.equals(currentFile.getName()))) {
87              LOG.warn("Duplicate file name returned from {}. Using internal sequencer to uniquify.", namegen.getClass());
88              dotFile = "." + seq.getAndIncrement() + "_" + newName;
89          }
90          return new File(dir, dotFile);
91      }
92  
93      private void closeAndRename() throws IOException {
94          fileOutputStream.flush();
95          if (!internalClose(fileOutputStream)) {
96              LOG.error("Error closing file {}", currentFile.getAbsolutePath());
97          }
98          rename(currentFile);
99          bytesWritten = 0L;
100     }
101 
102     private void rename(File f) {
103         if (f.length() == 0L && deleteZeroByteFiles) {
104             try {
105                 LOG.debug("Deleting Zero Byte File {}", f.getAbsolutePath());
106                 Files.delete(f.toPath());
107             } catch (IOException e) {
108                 LOG.error("Failed to delete zero byte file {}", f.getAbsolutePath(), e);
109             }
110             return;
111         }
112         // drop the dot...
113         String nonDot = f.getName().substring(1);
114         File nd = new File(dir, nonDot);
115         // This shouldn't happen
116         if (nd.exists()) {
117             LOG.error("Non dot file {} already exists. Forcing unique name.", nd.getAbsolutePath());
118             nd = new File(dir, nonDot + UUID.randomUUID());
119         }
120         if (!f.renameTo(nd)) {
121             LOG.error("Rename from {} to {} failed.", f.getAbsolutePath(), nd.getAbsolutePath());
122         }
123     }
124 
125     /**
126      * Rolls current file. Exact workflow is closing of the underlying output, renaming the file to it's final name, and
127      * opening a new file.
128      */
129     @Override
130     public void roll() {
131         lock.lock();
132         try {
133             rolling = true;
134             closeAndRename();
135 
136             open();
137         } catch (IOException e) {
138             LOG.error("Exception during roll of " + currentFile, e);
139         } finally {
140             rolling = false;
141             lock.unlock();
142         }
143     }
144 
145     /**
146      * True is this object is in the middle of a roll.
147      * 
148      * @return true if rolling
149      */
150     @Override
151     public boolean isRolling() {
152         return rolling;
153     }
154 
155     private static boolean internalClose(@Nullable Closeable c) {
156         try {
157             if (c != null) {
158                 c.close();
159             }
160         } catch (Exception e) {
161             LOG.warn("Error occurred while closing file", e);
162             return false;
163         }
164         return true;
165     }
166 
167     /**
168      * Closes the underlying outputs and renames the current file to its final name. A new file is NOT opened. Further use
169      * of the instance of this class is not guaranteed to function after calling this method.
170      */
171     @Override
172     public void close() throws IOException {
173         lock.lock();
174         try {
175             closeAndRename();
176             fileOutputStream = null;
177             currentFile = null;
178         } finally {
179             lock.unlock();
180         }
181     }
182 
183     /**
184      * Thread safe write of a byte
185      * 
186      * @param b byte to write
187      */
188     @Override
189     public void write(int b) throws IOException {
190         lock.lock();
191         try {
192             fileOutputStream.write(b);
193             bytesWritten++;
194         } finally {
195             lock.unlock();
196         }
197 
198     }
199 
200     /**
201      * Thread safe write of byte array.
202      * 
203      * @param b the data
204      * @param off the start offset of the data
205      * @param len the number of bytes to write
206      */
207     @Override
208     public void write(byte[] b, int off, int len) throws IOException {
209         lock.lock();
210         try {
211             fileOutputStream.write(b, off, len);
212             bytesWritten += len;
213         } finally {
214             lock.unlock();
215         }
216     }
217 
218     /**
219      * Number of bytes written to current output file. This value is reset once roll() is called.
220      * 
221      * @return the number of bytes written
222      */
223     public long getBytesWritten() {
224         return bytesWritten;
225     }
226 
227     /**
228      * Whether zero byte files will be deleted.
229      * 
230      * @return true is zero byte files will be deleted
231      */
232     public boolean isDeleteZeroByteFiles() {
233         return deleteZeroByteFiles;
234     }
235 
236     /**
237      * Determines whether to delete zero byte files on a roll. If true, both bytes written and the size of the output file
238      * are checked. If both are zero, and deleteZeroByteFiles is true, the file is deleted.
239      */
240     public void setDeleteZeroByteFiles(boolean deleteZeroByteFiles) {
241         this.deleteZeroByteFiles = deleteZeroByteFiles;
242     }
243 
244 }