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}