001/*
002 * Licensed to the Apache Software Foundation (ASF) under one or more
003 * contributor license agreements.  See the NOTICE file distributed with
004 * this work for additional information regarding copyright ownership.
005 * The ASF licenses this file to You under the Apache License, Version 2.0
006 * (the "License"); you may not use this file except in compliance with
007 * the License.  You may obtain a copy of the License at
008 *
009 *      http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017package org.apache.juneau.console.views;
018
019import static java.nio.charset.StandardCharsets.*;
020import static org.apache.juneau.commons.utils.Shorts.*;
021
022import java.io.*;
023import java.nio.*;
024import java.nio.channels.*;
025import java.nio.file.*;
026import java.nio.file.attribute.*;
027import java.util.*;
028import java.util.concurrent.atomic.*;
029import java.util.concurrent.locks.*;
030import java.util.zip.*;
031
032/**
033 * A sparse, bounded index from 1-based line numbers to byte offsets in one UTF-8 text file that may keep growing
034 * while it is read.
035 *
036 * <p>
037 * The index keeps one offset per {@code stride} lines. When {@code maxEntries} offsets are held, the stride doubles
038 * and every other entry is dropped, so memory stays at most {@code maxEntries} longs whatever the file size. A
039 * {@link #seek(long)} scans at most {@code stride - 1} lines from the nearest entry. {@link #refresh()} reads only
040 * bytes appended since the previous call.
041 *
042 * <p>
043 * The index resets (new {@link Snapshot#epoch() epoch}) when the file key changes (replaced or rotated by rename),
044 * when the file shrinks below the indexed size (truncated), or when its first bytes change (rewritten in place).
045 * A copy-truncate that regrows past the old indexed size with byte-identical first 64 bytes before the next
046 * {@code refresh()} cannot be detected; rotate logs by rename instead.
047 *
048 * <p>
049 * Thread-safe. Keep one instance per log file and share it across requests; otherwise every request re-scans the
050 * file from byte 0.
051 *
052 * <h5 class='section'>Example:</h5>
053 * <p class='bjava'>
054 *    FileLineIndex <jv>index</jv> = FileLineIndex.<jsm>of</jsm>(<jv>logFile</jv>);
055 *    FileLineIndex.Snapshot <jv>s</jv> = <jv>index</jv>.refresh();
056 *    <jk>long</jk> <jv>offset</jv> = <jv>index</jv>.seek(<jv>s</jv>.indexedLines());
057 * </p>
058 *
059 * @since 10.0.0
060 */
061public final class FileLineIndex {
062
063   /** Default lines per index entry. */
064   public static final int DEFAULT_STRIDE = 1000;
065
066   /** Default maximum number of index entries. */
067   public static final int DEFAULT_MAX_ENTRIES = 4096;
068
069   /** Size of the head fingerprint in bytes. */
070   public static final int FINGERPRINT_BYTES = 64;
071
072   static final int BLOCK_BYTES = 65_536;
073
074   private static final long EPOCH_SPACE = 2_176_782_336L; // 36^6
075   private static final byte[] EMPTY = {};
076
077   /**
078    * The result of a {@link #refresh()}.
079    *
080    * @param epoch Six base36 characters identifying this file generation.
081    * @param indexedBytes Offset just past the last {@code \n}.
082    * @param indexedLines Number of complete lines in {@code [0, indexedBytes)}.
083    * @param fileSize File size at the time of the refresh.
084    */
085   public record Snapshot(String epoch, long indexedBytes, long indexedLines, long fileSize) {}
086
087   /**
088    * Builder for {@link FileLineIndex}.
089    */
090   public static final class Builder {
091      private final Path file;
092      private int stride = DEFAULT_STRIDE;
093      private int maxEntries = DEFAULT_MAX_ENTRIES;
094
095      Builder(Path file) {
096         this.file = Objects.requireNonNull(file, "file");
097      }
098
099      /**
100       * Sets the initial lines per index entry.
101       *
102       * @param value At least 1.
103       * @return This object.
104       */
105      public Builder stride(int value) {
106         if (value < 1)
107            throw iaex("FileLineIndex stride must be >= 1; got %s.", value);
108         stride = value;
109         return this;
110      }
111
112      /**
113       * Sets the maximum number of index entries.
114       *
115       * @param value At least 2.
116       * @return This object.
117       */
118      public Builder maxEntries(int value) {
119         if (value < 2)
120            throw iaex("FileLineIndex maxEntries must be >= 2; got %s.", value);
121         maxEntries = value;
122         return this;
123      }
124
125      /**
126       * Builds the index. No I/O happens until {@link FileLineIndex#refresh()}.
127       *
128       * @return A new index.
129       */
130      public FileLineIndex build() {
131         return new FileLineIndex(this);
132      }
133   }
134
135   private final Path file;
136   private final int initialStride;
137   private final int maxEntries;
138   private final ReentrantLock lock = new ReentrantLock();
139   private final AtomicReference<Snapshot> snapshot = new AtomicReference<>();
140   private volatile String earlyEpoch;
141
142   // Guarded by lock.
143   private Object fileKey;
144   private byte[] head = EMPTY;
145   private long indexedBytes;
146   private long indexedLines;
147   private long scannedBytes;
148   private long[] entries;
149   private int count;
150   private int stride;
151
152   private FileLineIndex(Builder b) {
153      file = b.file;
154      initialStride = b.stride;
155      maxEntries = b.maxEntries;
156      reset(null);
157      snapshot.set(new Snapshot(epoch(null, EMPTY), 0, 0, 0));
158   }
159
160   /**
161    * Creates an index with the default stride and entry cap.
162    *
163    * @param file The file. Supplied by the application, never taken from a request.
164    * @return A new index.
165    */
166   public static FileLineIndex of(Path file) {
167      return create(file).build();
168   }
169
170   /**
171    * Creates a builder.
172    *
173    * @param file The file. Supplied by the application, never taken from a request.
174    * @return A new builder.
175    */
176   public static Builder create(Path file) {
177      return new Builder(file);
178   }
179
180   /**
181    * The indexed file.
182    *
183    * @return The path given to the builder.
184    */
185   public Path file() {
186      return file;
187   }
188
189   /**
190    * The result of the last {@link #refresh()}, without I/O.
191    *
192    * @return The snapshot.
193    */
194   public Snapshot snapshot() {
195      return snapshot.get();
196   }
197
198   /**
199    * Stats the file and indexes only the bytes appended since the previous call.
200    *
201    * @return The new snapshot. A missing file gives an empty snapshot.
202    * @throws IOException If reading fails.
203    */
204   public Snapshot refresh() throws IOException {
205      lock.lock();
206      try {
207         BasicFileAttributes attrs;
208         try {
209            attrs = Files.readAttributes(file, BasicFileAttributes.class);
210         } catch (NoSuchFileException e) {
211            return missing();
212         }
213         var key = attrs.fileKey();
214         var size = attrs.size();
215         try (var ch = FileChannel.open(file, StandardOpenOption.READ)) {
216            if (neq(key, fileKey) || size < indexedBytes || headChanged(ch, size))
217               reset(key);
218            readHead(ch, size);
219            scan(ch, size);
220         } catch (NoSuchFileException e) {
221            return missing();
222         }
223         return publish(size);
224      } finally {
225         lock.unlock();
226      }
227   }
228
229   /**
230    * Returns the byte offset of the start of line {@code n}.
231    *
232    * @param n The 1-based line number.
233    * @return The offset; {@code indexedBytes} for {@code n = indexedLines + 1}; {@code -1} outside
234    *    {@code [1, indexedLines + 1]} or when the file no longer has that line.
235    * @throws IOException If reading fails.
236    */
237   public long seek(long n) throws IOException {
238      lock.lock();
239      try {
240         if (n < 1 || n > indexedLines + 1)
241            return -1;
242         var i = (int)Math.min((n - 1) / stride, count - 1L);
243         var line = 1 + (long)i * stride;
244         var pos = entries[i];
245         if (line == n)
246            return pos;
247         try (var ch = FileChannel.open(file, StandardOpenOption.READ)) {
248            var buf = ByteBuffer.allocate(BLOCK_BYTES);
249            while (pos < indexedBytes) {
250               buf.clear();
251               if (indexedBytes - pos < BLOCK_BYTES)
252                  buf.limit((int)(indexedBytes - pos));
253               var r = ch.read(buf, pos);
254               if (r <= 0)
255                  return -1;
256               for (var k = 0; k < r; k++)
257                  if (buf.get(k) == '\n' && ++line == n)
258                     return pos + k + 1;
259               pos += r;
260            }
261         } catch (NoSuchFileException e) {
262            return -1;
263         }
264         return -1;
265      } finally {
266         lock.unlock();
267      }
268   }
269
270   /**
271    * Whether a token epoch belongs to the current file generation.
272    *
273    * <p>
274    * Accepts the current epoch and, when this instance published one, the generation's epoch from before the
275    * 64-byte head fingerprint existed.
276    *
277    * @param epoch The epoch from a token.
278    * @return <jk>true</jk> if tokens with this epoch are still valid.
279    */
280   boolean acceptsEpoch(String epoch) {
281      return epoch != null && (epoch.equals(snapshot.get().epoch()) || epoch.equals(earlyEpoch));
282   }
283
284   /** Current stride (grows when the entry cap is reached). */
285   int stride() {
286      lock.lock();
287      try {
288         return stride;
289      } finally {
290         lock.unlock();
291      }
292   }
293
294   /** Current number of index entries. */
295   int entryCount() {
296      lock.lock();
297      try {
298         return count;
299      } finally {
300         lock.unlock();
301      }
302   }
303
304   private Snapshot missing() {
305      if (fileKey != null || head.length > 0 || indexedBytes > 0 || scannedBytes > 0)
306         reset(null);
307      return publish(0);
308   }
309
310   private void reset(Object key) {
311      fileKey = key;
312      head = EMPTY;
313      earlyEpoch = null;
314      indexedBytes = 0;
315      indexedLines = 0;
316      scannedBytes = 0;
317      stride = initialStride;
318      entries = new long[Math.min(16, maxEntries)];
319      entries[0] = 0;
320      count = 1;
321   }
322
323   private boolean headChanged(FileChannel ch, long size) throws IOException {
324      if (head.length == 0)
325         return false;
326      if (size < head.length)
327         return true;
328      return ! Arrays.equals(head, read(ch, head.length));
329   }
330
331   private void readHead(FileChannel ch, long size) throws IOException {
332      if (head.length >= FINGERPRINT_BYTES || size <= head.length)
333         return;
334      head = read(ch, (int)Math.min(FINGERPRINT_BYTES, size));
335   }
336
337   private static byte[] read(FileChannel ch, int len) throws IOException {
338      var buf = ByteBuffer.allocate(len);
339      while (buf.hasRemaining()) {
340         if (ch.read(buf, buf.position()) <= 0)
341            break;
342      }
343      return Arrays.copyOf(buf.array(), buf.position());
344   }
345
346   private void scan(FileChannel ch, long size) throws IOException {
347      var buf = ByteBuffer.allocate(BLOCK_BYTES);
348      var pos = scannedBytes;
349      while (pos < size) {
350         buf.clear();
351         if (size - pos < BLOCK_BYTES)
352            buf.limit((int)(size - pos));
353         var r = ch.read(buf, pos);
354         if (r <= 0)
355            break;
356         for (var k = 0; k < r; k++) {
357            if (buf.get(k) == '\n') {
358               indexedLines++;
359               indexedBytes = pos + k + 1;
360               recordLine(indexedLines + 1, indexedBytes);
361            }
362         }
363         pos += r;
364      }
365      scannedBytes = pos;
366   }
367
368   private void recordLine(long line, long offset) {
369      if ((line - 1) % stride != 0)
370         return;
371      if (count == maxEntries) {
372         var kept = 0;
373         for (var i = 0; i < count; i += 2)
374            entries[kept++] = entries[i];
375         count = kept;
376         stride *= 2;
377         if ((line - 1) % stride != 0)
378            return;
379      }
380      if (count == entries.length)
381         entries = Arrays.copyOf(entries, Math.min(maxEntries, entries.length * 2));
382      entries[count++] = offset;
383   }
384
385   private Snapshot publish(long size) {
386      var full = head.length >= FINGERPRINT_BYTES;
387      var epoch = epoch(fileKey, full ? head : EMPTY);
388      if (! full)
389         earlyEpoch = epoch;
390      var s = new Snapshot(epoch, indexedBytes, indexedLines, size);
391      snapshot.set(s);
392      return s;
393   }
394
395   private static String epoch(Object key, byte[] fingerprint) {
396      var crc = new CRC32();
397      crc.update(String.valueOf(key).getBytes(UTF_8));
398      crc.update(0);
399      crc.update(fingerprint);
400      var s = Long.toString(crc.getValue() % EPOCH_SPACE, 36);
401      return "0".repeat(6 - s.length()) + s;
402   }
403}