View Javadoc
1   /*
2    * Copyright (C) 2010, Google Inc. and others
3    *
4    * This program and the accompanying materials are made available under the
5    * terms of the Eclipse Distribution License v. 1.0 which is available at
6    * https://www.eclipse.org/org/documents/edl-v10.php.
7    *
8    * SPDX-License-Identifier: BSD-3-Clause
9    */
10  
11  package org.eclipse.jgit.internal.storage.file;
12  
13  import static org.eclipse.jgit.util.FS.FileStoreAttributes.FALLBACK_FILESTORE_ATTRIBUTES;
14  import static org.eclipse.jgit.util.FS.FileStoreAttributes.FALLBACK_TIMESTAMP_RESOLUTION;
15  
16  import java.io.File;
17  import java.io.IOException;
18  import java.nio.file.NoSuchFileException;
19  import java.nio.file.attribute.BasicFileAttributes;
20  import java.time.Duration;
21  import java.time.Instant;
22  import java.time.ZoneId;
23  import java.time.format.DateTimeFormatter;
24  import java.util.Locale;
25  import java.util.Objects;
26  import java.util.concurrent.TimeUnit;
27  
28  import org.eclipse.jgit.annotations.NonNull;
29  import org.eclipse.jgit.util.FS;
30  import org.eclipse.jgit.util.FS.FileStoreAttributes;
31  import org.slf4j.Logger;
32  import org.slf4j.LoggerFactory;
33  
34  /**
35   * Caches when a file was last read, making it possible to detect future edits.
36   * <p>
37   * This object tracks the last modified time of a file. Later during an
38   * invocation of {@link #isModified(File)} the object will return true if the
39   * file may have been modified and should be re-read from disk.
40   * <p>
41   * A snapshot does not "live update" when the underlying filesystem changes.
42   * Callers must poll for updates by periodically invoking
43   * {@link #isModified(File)}.
44   * <p>
45   * To work around the "racy git" problem (where a file may be modified multiple
46   * times within the granularity of the filesystem modification clock) this class
47   * may return true from isModified(File) if the last modification time of the
48   * file is less than 3 seconds ago.
49   */
50  public class FileSnapshot {
51  	private static final Logger LOG = LoggerFactory
52  			.getLogger(FileSnapshot.class);
53  	/**
54  	 * An unknown file size.
55  	 *
56  	 * This value is used when a comparison needs to happen purely on the lastUpdate.
57  	 */
58  	public static final long UNKNOWN_SIZE = -1;
59  
60  	private static final Instant UNKNOWN_TIME = Instant.ofEpochMilli(-1);
61  
62  	private static final Object MISSING_FILEKEY = new Object();
63  
64  	private static final DateTimeFormatter dateFmt = DateTimeFormatter
65  			.ofPattern("yyyy-MM-dd HH:mm:ss.nnnnnnnnn") //$NON-NLS-1$
66  			.withLocale(Locale.getDefault()).withZone(ZoneId.systemDefault());
67  
68  	/**
69  	 * A FileSnapshot that is considered to always be modified.
70  	 * <p>
71  	 * This instance is useful for application code that wants to lazily read a
72  	 * file, but only after {@link #isModified(File)} gets invoked. The returned
73  	 * snapshot contains only invalid status information.
74  	 */
75  	public static final FileSnapshot DIRTY = new FileSnapshot(UNKNOWN_TIME,
76  			UNKNOWN_TIME, UNKNOWN_SIZE, Duration.ZERO, MISSING_FILEKEY);
77  
78  	/**
79  	 * A FileSnapshot that is clean if the file does not exist.
80  	 * <p>
81  	 * This instance is useful if the application wants to consider a missing
82  	 * file to be clean. {@link #isModified(File)} will return false if the file
83  	 * path does not exist.
84  	 */
85  	public static final FileSnapshot MISSING_FILE = new FileSnapshot(
86  			Instant.EPOCH, Instant.EPOCH, 0, Duration.ZERO, MISSING_FILEKEY) {
87  		@Override
88  		public boolean isModified(File path) {
89  			return FS.DETECTED.exists(path);
90  		}
91  	};
92  
93  	/**
94  	 * Record a snapshot for a specific file path.
95  	 * <p>
96  	 * This method should be invoked before the file is accessed.
97  	 *
98  	 * @param path
99  	 *            the path to later remember. The path's current status
100 	 *            information is saved.
101 	 * @return the snapshot.
102 	 */
103 	public static FileSnapshot save(File path) {
104 		return new FileSnapshot(path);
105 	}
106 
107 	/**
108 	 * Record a snapshot for a specific file path without using config file to
109 	 * get filesystem timestamp resolution.
110 	 * <p>
111 	 * This method should be invoked before the file is accessed. It is used by
112 	 * FileBasedConfig to avoid endless recursion.
113 	 *
114 	 * @param path
115 	 *            the path to later remember. The path's current status
116 	 *            information is saved.
117 	 * @return the snapshot.
118 	 */
119 	public static FileSnapshot saveNoConfig(File path) {
120 		return new FileSnapshot(path, false);
121 	}
122 
123 	private static Object getFileKey(BasicFileAttributes fileAttributes) {
124 		Object fileKey = fileAttributes.fileKey();
125 		return fileKey == null ? MISSING_FILEKEY : fileKey;
126 	}
127 
128 	/**
129 	 * Record a snapshot for a file for which the last modification time is
130 	 * already known.
131 	 * <p>
132 	 * This method should be invoked before the file is accessed.
133 	 * <p>
134 	 * Note that this method cannot rely on measuring file timestamp resolution
135 	 * to avoid racy git issues caused by finite file timestamp resolution since
136 	 * it's unknown in which filesystem the file is located. Hence the worst
137 	 * case fallback for timestamp resolution is used.
138 	 *
139 	 * @param modified
140 	 *            the last modification time of the file
141 	 * @return the snapshot.
142 	 * @deprecated use {@link #save(Instant)} instead.
143 	 */
144 	@Deprecated
145 	public static FileSnapshot save(long modified) {
146 		final Instant read = Instant.now();
147 		return new FileSnapshot(read, Instant.ofEpochMilli(modified),
148 				UNKNOWN_SIZE, FALLBACK_TIMESTAMP_RESOLUTION, MISSING_FILEKEY);
149 	}
150 
151 	/**
152 	 * Record a snapshot for a file for which the last modification time is
153 	 * already known.
154 	 * <p>
155 	 * This method should be invoked before the file is accessed.
156 	 * <p>
157 	 * Note that this method cannot rely on measuring file timestamp resolution
158 	 * to avoid racy git issues caused by finite file timestamp resolution since
159 	 * it's unknown in which filesystem the file is located. Hence the worst
160 	 * case fallback for timestamp resolution is used.
161 	 *
162 	 * @param modified
163 	 *            the last modification time of the file
164 	 * @return the snapshot.
165 	 */
166 	public static FileSnapshot save(Instant modified) {
167 		final Instant read = Instant.now();
168 		return new FileSnapshot(read, modified, UNKNOWN_SIZE,
169 				FALLBACK_TIMESTAMP_RESOLUTION, MISSING_FILEKEY);
170 	}
171 
172 	/** Last observed modification time of the path. */
173 	private final Instant lastModified;
174 
175 	/** Last wall-clock time the path was read. */
176 	private volatile Instant lastRead;
177 
178 	/** True once {@link #lastRead} is far later than {@link #lastModified}. */
179 	private boolean cannotBeRacilyClean;
180 
181 	/** Underlying file-system size in bytes.
182 	 *
183 	 * When set to {@link #UNKNOWN_SIZE} the size is not considered for modification checks. */
184 	private final long size;
185 
186 	/** measured FileStore attributes */
187 	private FileStoreAttributes fileStoreAttributeCache;
188 
189 	/**
190 	 * if {@code true} read filesystem time resolution from configuration file
191 	 * otherwise use fallback resolution
192 	 */
193 	private boolean useConfig;
194 
195 	/**
196 	 * Object that uniquely identifies the given file, or {@code
197 	 * null} if a file key is not available
198 	 */
199 	private final Object fileKey;
200 
201 	private final File file;
202 
203 	/**
204 	 * Record a snapshot for a specific file path.
205 	 * <p>
206 	 * This method should be invoked before the file is accessed.
207 	 *
208 	 * @param file
209 	 *            the path to remember meta data for. The path's current status
210 	 *            information is saved.
211 	 */
212 	protected FileSnapshot(File file) {
213 		this(file, true);
214 	}
215 
216 	/**
217 	 * Record a snapshot for a specific file path.
218 	 * <p>
219 	 * This method should be invoked before the file is accessed.
220 	 *
221 	 * @param file
222 	 *            the path to remember meta data for. The path's current status
223 	 *            information is saved.
224 	 * @param useConfig
225 	 *            if {@code true} read filesystem time resolution from
226 	 *            configuration file otherwise use fallback resolution
227 	 */
228 	protected FileSnapshot(File file, boolean useConfig) {
229 		this.file = file;
230 		this.lastRead = Instant.now();
231 		this.useConfig = useConfig;
232 		BasicFileAttributes fileAttributes = null;
233 		try {
234 			fileAttributes = FS.DETECTED.fileAttributes(file);
235 		} catch (NoSuchFileException e) {
236 			this.lastModified = Instant.EPOCH;
237 			this.size = 0L;
238 			this.fileKey = MISSING_FILEKEY;
239 			return;
240 		} catch (IOException e) {
241 			LOG.error(e.getMessage(), e);
242 			this.lastModified = Instant.EPOCH;
243 			this.size = 0L;
244 			this.fileKey = MISSING_FILEKEY;
245 			return;
246 		}
247 		this.lastModified = fileAttributes.lastModifiedTime().toInstant();
248 		this.size = fileAttributes.size();
249 		this.fileKey = getFileKey(fileAttributes);
250 		if (LOG.isDebugEnabled()) {
251 			LOG.debug("file={}, create new FileSnapshot: lastRead={}, lastModified={}, size={}, fileKey={}", //$NON-NLS-1$
252 					file, dateFmt.format(lastRead),
253 					dateFmt.format(lastModified), Long.valueOf(size),
254 					fileKey.toString());
255 		}
256 	}
257 
258 	private boolean sizeChanged;
259 
260 	private boolean fileKeyChanged;
261 
262 	private boolean lastModifiedChanged;
263 
264 	private boolean wasRacyClean;
265 
266 	private long delta;
267 
268 	private long racyThreshold;
269 
270 	private FileSnapshot(Instant read, Instant modified, long size,
271 			@NonNull Duration fsTimestampResolution, @NonNull Object fileKey) {
272 		this.file = null;
273 		this.lastRead = read;
274 		this.lastModified = modified;
275 		this.fileStoreAttributeCache = new FileStoreAttributes(
276 				fsTimestampResolution);
277 		this.size = size;
278 		this.fileKey = fileKey;
279 	}
280 
281 	/**
282 	 * Get time of last snapshot update
283 	 *
284 	 * @return time of last snapshot update
285 	 * @deprecated use {@link #lastModifiedInstant()} instead
286 	 */
287 	@Deprecated
288 	public long lastModified() {
289 		return lastModified.toEpochMilli();
290 	}
291 
292 	/**
293 	 * Get time of last snapshot update
294 	 *
295 	 * @return time of last snapshot update
296 	 */
297 	public Instant lastModifiedInstant() {
298 		return lastModified;
299 	}
300 
301 	/**
302 	 * @return file size in bytes of last snapshot update
303 	 */
304 	public long size() {
305 		return size;
306 	}
307 
308 	/**
309 	 * Check if the path may have been modified since the snapshot was saved.
310 	 *
311 	 * @param path
312 	 *            the path the snapshot describes.
313 	 * @return true if the path needs to be read again.
314 	 */
315 	public boolean isModified(File path) {
316 		Instant currLastModified;
317 		long currSize;
318 		Object currFileKey;
319 		try {
320 			BasicFileAttributes fileAttributes = FS.DETECTED.fileAttributes(path);
321 			currLastModified = fileAttributes.lastModifiedTime().toInstant();
322 			currSize = fileAttributes.size();
323 			currFileKey = getFileKey(fileAttributes);
324 		} catch (NoSuchFileException e) {
325 			currLastModified = Instant.EPOCH;
326 			currSize = 0L;
327 			currFileKey = MISSING_FILEKEY;
328 		} catch (IOException e) {
329 			LOG.error(e.getMessage(), e);
330 			currLastModified = Instant.EPOCH;
331 			currSize = 0L;
332 			currFileKey = MISSING_FILEKEY;
333 		}
334 		sizeChanged = isSizeChanged(currSize);
335 		if (sizeChanged) {
336 			return true;
337 		}
338 		fileKeyChanged = isFileKeyChanged(currFileKey);
339 		if (fileKeyChanged) {
340 			return true;
341 		}
342 		lastModifiedChanged = isModified(currLastModified);
343 		if (lastModifiedChanged) {
344 			return true;
345 		}
346 		return false;
347 	}
348 
349 	/**
350 	 * Update this snapshot when the content hasn't changed.
351 	 * <p>
352 	 * If the caller gets true from {@link #isModified(File)}, re-reads the
353 	 * content, discovers the content is identical, and
354 	 * {@link #equals(FileSnapshot)} is true, it can use
355 	 * {@link #setClean(FileSnapshot)} to make a future
356 	 * {@link #isModified(File)} return false. The logic goes something like
357 	 * this:
358 	 *
359 	 * <pre>
360 	 * if (snapshot.isModified(path)) {
361 	 *  FileSnapshot other = FileSnapshot.save(path);
362 	 *  Content newContent = ...;
363 	 *  if (oldContent.equals(newContent) &amp;&amp; snapshot.equals(other))
364 	 *      snapshot.setClean(other);
365 	 * }
366 	 * </pre>
367 	 *
368 	 * @param other
369 	 *            the other snapshot.
370 	 */
371 	public void setClean(FileSnapshot other) {
372 		final Instant now = other.lastRead;
373 		if (!isRacyClean(now)) {
374 			cannotBeRacilyClean = true;
375 		}
376 		lastRead = now;
377 	}
378 
379 	/**
380 	 * Wait until this snapshot's file can't be racy anymore
381 	 *
382 	 * @throws InterruptedException
383 	 *             if sleep was interrupted
384 	 */
385 	public void waitUntilNotRacy() throws InterruptedException {
386 		long timestampResolution = fileStoreAttributeCache()
387 				.getFsTimestampResolution().toNanos();
388 		while (isRacyClean(Instant.now())) {
389 			TimeUnit.NANOSECONDS.sleep(timestampResolution);
390 		}
391 	}
392 
393 	/**
394 	 * Compare two snapshots to see if they cache the same information.
395 	 *
396 	 * @param other
397 	 *            the other snapshot.
398 	 * @return true if the two snapshots share the same information.
399 	 */
400 	@SuppressWarnings("NonOverridingEquals")
401 	public boolean equals(FileSnapshot other) {
402 		boolean sizeEq = size == UNKNOWN_SIZE || other.size == UNKNOWN_SIZE || size == other.size;
403 		return lastModified.equals(other.lastModified) && sizeEq
404 				&& Objects.equals(fileKey, other.fileKey);
405 	}
406 
407 	/** {@inheritDoc} */
408 	@Override
409 	public boolean equals(Object obj) {
410 		if (this == obj) {
411 			return true;
412 		}
413 		if (obj == null) {
414 			return false;
415 		}
416 		if (!(obj instanceof FileSnapshot)) {
417 			return false;
418 		}
419 		FileSnapshot other = (FileSnapshot) obj;
420 		return equals(other);
421 	}
422 
423 	/** {@inheritDoc} */
424 	@Override
425 	public int hashCode() {
426 		return Objects.hash(lastModified, Long.valueOf(size), fileKey);
427 	}
428 
429 	/**
430 	 * @return {@code true} if FileSnapshot.isModified(File) found the file size
431 	 *         changed
432 	 */
433 	boolean wasSizeChanged() {
434 		return sizeChanged;
435 	}
436 
437 	/**
438 	 * @return {@code true} if FileSnapshot.isModified(File) found the file key
439 	 *         changed
440 	 */
441 	boolean wasFileKeyChanged() {
442 		return fileKeyChanged;
443 	}
444 
445 	/**
446 	 * @return {@code true} if FileSnapshot.isModified(File) found the file's
447 	 *         lastModified changed
448 	 */
449 	boolean wasLastModifiedChanged() {
450 		return lastModifiedChanged;
451 	}
452 
453 	/**
454 	 * @return {@code true} if FileSnapshot.isModified(File) detected that
455 	 *         lastModified is racily clean
456 	 */
457 	boolean wasLastModifiedRacilyClean() {
458 		return wasRacyClean;
459 	}
460 
461 	/**
462 	 * @return the delta in nanoseconds between lastModified and lastRead during
463 	 *         last racy check
464 	 */
465 	public long lastDelta() {
466 		return delta;
467 	}
468 
469 	/**
470 	 * @return the racyLimitNanos threshold in nanoseconds during last racy
471 	 *         check
472 	 */
473 	public long lastRacyThreshold() {
474 		return racyThreshold;
475 	}
476 
477 	/** {@inheritDoc} */
478 	@SuppressWarnings({ "nls", "ReferenceEquality" })
479 	@Override
480 	public String toString() {
481 		if (this == DIRTY) {
482 			return "DIRTY";
483 		}
484 		if (this == MISSING_FILE) {
485 			return "MISSING_FILE";
486 		}
487 		return "FileSnapshot[modified: " + dateFmt.format(lastModified)
488 				+ ", read: " + dateFmt.format(lastRead) + ", size:" + size
489 				+ ", fileKey: " + fileKey + "]";
490 	}
491 
492 	private boolean isRacyClean(Instant read) {
493 		racyThreshold = getEffectiveRacyThreshold();
494 		delta = Duration.between(lastModified, read).toNanos();
495 		wasRacyClean = delta <= racyThreshold;
496 		if (LOG.isDebugEnabled()) {
497 			LOG.debug(
498 					"file={}, isRacyClean={}, read={}, lastModified={}, delta={} ns, racy<={} ns", //$NON-NLS-1$
499 					file, Boolean.valueOf(wasRacyClean), dateFmt.format(read),
500 					dateFmt.format(lastModified), Long.valueOf(delta),
501 					Long.valueOf(racyThreshold));
502 		}
503 		return wasRacyClean;
504 	}
505 
506 	private long getEffectiveRacyThreshold() {
507 		long timestampResolution = fileStoreAttributeCache()
508 				.getFsTimestampResolution().toNanos();
509 		long minRacyInterval = fileStoreAttributeCache()
510 				.getMinimalRacyInterval().toNanos();
511 		long max = Math.max(timestampResolution, minRacyInterval);
512 		// safety margin: factor 2.5 below 100ms otherwise 1.25
513 		return max < 100_000_000L ? max * 5 / 2 : max * 5 / 4;
514 	}
515 
516 	private boolean isModified(Instant currLastModified) {
517 		// Any difference indicates the path was modified.
518 
519 		lastModifiedChanged = !lastModified.equals(currLastModified);
520 		if (lastModifiedChanged) {
521 			if (LOG.isDebugEnabled()) {
522 				LOG.debug(
523 						"file={}, lastModified changed from {} to {}", //$NON-NLS-1$
524 						file, dateFmt.format(lastModified),
525 						dateFmt.format(currLastModified));
526 			}
527 			return true;
528 		}
529 
530 		// We have already determined the last read was far enough
531 		// after the last modification that any new modifications
532 		// are certain to change the last modified time.
533 		if (cannotBeRacilyClean) {
534 			LOG.debug("file={}, cannot be racily clean", file); //$NON-NLS-1$
535 			return false;
536 		}
537 		if (!isRacyClean(lastRead)) {
538 			// Our last read should have marked cannotBeRacilyClean,
539 			// but this thread may not have seen the change. The read
540 			// of the volatile field lastRead should have fixed that.
541 			LOG.debug("file={}, is unmodified", file); //$NON-NLS-1$
542 			return false;
543 		}
544 
545 		// We last read this path too close to its last observed
546 		// modification time. We may have missed a modification.
547 		// Scan again, to ensure we still see the same state.
548 		LOG.debug("file={}, is racily clean", file); //$NON-NLS-1$
549 		return true;
550 	}
551 
552 	private boolean isFileKeyChanged(Object currFileKey) {
553 		boolean changed = currFileKey != MISSING_FILEKEY
554 				&& !currFileKey.equals(fileKey);
555 		if (changed) {
556 			LOG.debug("file={}, FileKey changed from {} to {}", //$NON-NLS-1$
557 					file, fileKey, currFileKey);
558 		}
559 		return changed;
560 	}
561 
562 	private boolean isSizeChanged(long currSize) {
563 		boolean changed = (currSize != UNKNOWN_SIZE) && (currSize != size);
564 		if (changed) {
565 			LOG.debug("file={}, size changed from {} to {} bytes", //$NON-NLS-1$
566 					file, Long.valueOf(size), Long.valueOf(currSize));
567 		}
568 		return changed;
569 	}
570 
571 	private FileStoreAttributes fileStoreAttributeCache() {
572 		if (fileStoreAttributeCache == null) {
573 			fileStoreAttributeCache = useConfig
574 					? FS.getFileStoreAttributes(file.toPath().getParent())
575 					: FALLBACK_FILESTORE_ATTRIBUTES;
576 		}
577 		return fileStoreAttributeCache;
578 	}
579 }