/* * Copyright (C) 2010, Google Inc. and others * * This program and the accompanying materials are made available under the * terms of the Eclipse Distribution License v. 1.0 which is available at * https://www.eclipse.org/org/documents/edl-v10.php. * * SPDX-License-Identifier: BSD-3-Clause */ package org.eclipse.jgit.internal.storage.file; import static org.eclipse.jgit.util.FS.FileStoreAttributes.FALLBACK_FILESTORE_ATTRIBUTES; import static org.eclipse.jgit.util.FS.FileStoreAttributes.FALLBACK_TIMESTAMP_RESOLUTION; import java.io.File; import java.io.IOException; import java.nio.file.NoSuchFileException; import java.nio.file.attribute.BasicFileAttributes; import java.time.Duration; import java.time.Instant; import java.time.ZoneId; import java.time.format.DateTimeFormatter; import java.util.Locale; import java.util.Objects; import java.util.concurrent.TimeUnit; import org.eclipse.jgit.annotations.NonNull; import org.eclipse.jgit.util.FS; import org.eclipse.jgit.util.FS.FileStoreAttributes; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** * Caches when a file was last read, making it possible to detect future edits. *

* This object tracks the last modified time of a file. Later during an * invocation of {@link #isModified(File)} the object will return true if the * file may have been modified and should be re-read from disk. *

* A snapshot does not "live update" when the underlying filesystem changes. * Callers must poll for updates by periodically invoking * {@link #isModified(File)}. *

* To work around the "racy git" problem (where a file may be modified multiple * times within the granularity of the filesystem modification clock) this class * may return true from isModified(File) if the last modification time of the * file is less than 3 seconds ago. */ public class FileSnapshot { private static final Logger LOG = LoggerFactory .getLogger(FileSnapshot.class); /** * An unknown file size. * * This value is used when a comparison needs to happen purely on the lastUpdate. */ public static final long UNKNOWN_SIZE = -1; private static final Instant UNKNOWN_TIME = Instant.ofEpochMilli(-1); private static final Object MISSING_FILEKEY = new Object(); private static final DateTimeFormatter dateFmt = DateTimeFormatter .ofPattern("yyyy-MM-dd HH:mm:ss.nnnnnnnnn") //$NON-NLS-1$ .withLocale(Locale.getDefault()).withZone(ZoneId.systemDefault()); /** * A FileSnapshot that is considered to always be modified. *

* This instance is useful for application code that wants to lazily read a * file, but only after {@link #isModified(File)} gets invoked. The returned * snapshot contains only invalid status information. */ public static final FileSnapshot DIRTY = new FileSnapshot(UNKNOWN_TIME, UNKNOWN_TIME, UNKNOWN_SIZE, Duration.ZERO, MISSING_FILEKEY); /** * A FileSnapshot that is clean if the file does not exist. *

* This instance is useful if the application wants to consider a missing * file to be clean. {@link #isModified(File)} will return false if the file * path does not exist. */ public static final FileSnapshot MISSING_FILE = new FileSnapshot( Instant.EPOCH, Instant.EPOCH, 0, Duration.ZERO, MISSING_FILEKEY) { @Override public boolean isModified(File path) { return FS.DETECTED.exists(path); } }; /** * Record a snapshot for a specific file path. *

* This method should be invoked before the file is accessed. * * @param path * the path to later remember. The path's current status * information is saved. * @return the snapshot. */ public static FileSnapshot save(File path) { return new FileSnapshot(path); } /** * Record a snapshot for a specific file path without using config file to * get filesystem timestamp resolution. *

* This method should be invoked before the file is accessed. It is used by * FileBasedConfig to avoid endless recursion. * * @param path * the path to later remember. The path's current status * information is saved. * @return the snapshot. */ public static FileSnapshot saveNoConfig(File path) { return new FileSnapshot(path, false); } private static Object getFileKey(BasicFileAttributes fileAttributes) { Object fileKey = fileAttributes.fileKey(); return fileKey == null ? MISSING_FILEKEY : fileKey; } /** * Record a snapshot for a file for which the last modification time is * already known. *

* This method should be invoked before the file is accessed. *

* Note that this method cannot rely on measuring file timestamp resolution * to avoid racy git issues caused by finite file timestamp resolution since * it's unknown in which filesystem the file is located. Hence the worst * case fallback for timestamp resolution is used. * * @param modified * the last modification time of the file * @return the snapshot. * @deprecated use {@link #save(Instant)} instead. */ @Deprecated public static FileSnapshot save(long modified) { final Instant read = Instant.now(); return new FileSnapshot(read, Instant.ofEpochMilli(modified), UNKNOWN_SIZE, FALLBACK_TIMESTAMP_RESOLUTION, MISSING_FILEKEY); } /** * Record a snapshot for a file for which the last modification time is * already known. *

* This method should be invoked before the file is accessed. *

* Note that this method cannot rely on measuring file timestamp resolution * to avoid racy git issues caused by finite file timestamp resolution since * it's unknown in which filesystem the file is located. Hence the worst * case fallback for timestamp resolution is used. * * @param modified * the last modification time of the file * @return the snapshot. */ public static FileSnapshot save(Instant modified) { final Instant read = Instant.now(); return new FileSnapshot(read, modified, UNKNOWN_SIZE, FALLBACK_TIMESTAMP_RESOLUTION, MISSING_FILEKEY); } /** Last observed modification time of the path. */ private final Instant lastModified; /** Last wall-clock time the path was read. */ private volatile Instant lastRead; /** True once {@link #lastRead} is far later than {@link #lastModified}. */ private boolean cannotBeRacilyClean; /** Underlying file-system size in bytes. * * When set to {@link #UNKNOWN_SIZE} the size is not considered for modification checks. */ private final long size; /** measured FileStore attributes */ private FileStoreAttributes fileStoreAttributeCache; /** * Object that uniquely identifies the given file, or {@code * null} if a file key is not available */ private final Object fileKey; private final File file; /** * Record a snapshot for a specific file path. *

* This method should be invoked before the file is accessed. * * @param file * the path to remember meta data for. The path's current status * information is saved. */ protected FileSnapshot(File file) { this(file, true); } /** * Record a snapshot for a specific file path. *

* This method should be invoked before the file is accessed. * * @param file * the path to remember meta data for. The path's current status * information is saved. * @param useConfig * if {@code true} read filesystem time resolution from * configuration file otherwise use fallback resolution */ protected FileSnapshot(File file, boolean useConfig) { this.file = file; this.lastRead = Instant.now(); this.fileStoreAttributeCache = useConfig ? FS.getFileStoreAttributes(file.toPath()) : FALLBACK_FILESTORE_ATTRIBUTES; BasicFileAttributes fileAttributes = null; try { fileAttributes = FS.DETECTED.fileAttributes(file); } catch (NoSuchFileException e) { this.lastModified = Instant.EPOCH; this.size = 0L; this.fileKey = MISSING_FILEKEY; return; } catch (IOException e) { LOG.error(e.getMessage(), e); this.lastModified = Instant.EPOCH; this.size = 0L; this.fileKey = MISSING_FILEKEY; return; } this.lastModified = fileAttributes.lastModifiedTime().toInstant(); this.size = fileAttributes.size(); this.fileKey = getFileKey(fileAttributes); if (LOG.isDebugEnabled()) { LOG.debug("file={}, create new FileSnapshot: lastRead={}, lastModified={}, size={}, fileKey={}", //$NON-NLS-1$ file, dateFmt.format(lastRead), dateFmt.format(lastModified), Long.valueOf(size), fileKey.toString()); } } private boolean sizeChanged; private boolean fileKeyChanged; private boolean lastModifiedChanged; private boolean wasRacyClean; private long delta; private long racyThreshold; private FileSnapshot(Instant read, Instant modified, long size, @NonNull Duration fsTimestampResolution, @NonNull Object fileKey) { this.file = null; this.lastRead = read; this.lastModified = modified; this.fileStoreAttributeCache = new FileStoreAttributes( fsTimestampResolution); this.size = size; this.fileKey = fileKey; } /** * Get time of last snapshot update * * @return time of last snapshot update * @deprecated use {@link #lastModifiedInstant()} instead */ @Deprecated public long lastModified() { return lastModified.toEpochMilli(); } /** * Get time of last snapshot update * * @return time of last snapshot update */ public Instant lastModifiedInstant() { return lastModified; } /** * @return file size in bytes of last snapshot update */ public long size() { return size; } /** * Check if the path may have been modified since the snapshot was saved. * * @param path * the path the snapshot describes. * @return true if the path needs to be read again. */ public boolean isModified(File path) { Instant currLastModified; long currSize; Object currFileKey; try { BasicFileAttributes fileAttributes = FS.DETECTED.fileAttributes(path); currLastModified = fileAttributes.lastModifiedTime().toInstant(); currSize = fileAttributes.size(); currFileKey = getFileKey(fileAttributes); } catch (NoSuchFileException e) { currLastModified = Instant.EPOCH; currSize = 0L; currFileKey = MISSING_FILEKEY; } catch (IOException e) { LOG.error(e.getMessage(), e); currLastModified = Instant.EPOCH; currSize = 0L; currFileKey = MISSING_FILEKEY; } sizeChanged = isSizeChanged(currSize); if (sizeChanged) { return true; } fileKeyChanged = isFileKeyChanged(currFileKey); if (fileKeyChanged) { return true; } lastModifiedChanged = isModified(currLastModified); if (lastModifiedChanged) { return true; } return false; } /** * Update this snapshot when the content hasn't changed. *

* If the caller gets true from {@link #isModified(File)}, re-reads the * content, discovers the content is identical, and * {@link #equals(FileSnapshot)} is true, it can use * {@link #setClean(FileSnapshot)} to make a future * {@link #isModified(File)} return false. The logic goes something like * this: * *

	 * if (snapshot.isModified(path)) {
	 *  FileSnapshot other = FileSnapshot.save(path);
	 *  Content newContent = ...;
	 *  if (oldContent.equals(newContent) && snapshot.equals(other))
	 *      snapshot.setClean(other);
	 * }
	 * 
* * @param other * the other snapshot. */ public void setClean(FileSnapshot other) { final Instant now = other.lastRead; if (!isRacyClean(now)) { cannotBeRacilyClean = true; } lastRead = now; } /** * Wait until this snapshot's file can't be racy anymore * * @throws InterruptedException * if sleep was interrupted */ public void waitUntilNotRacy() throws InterruptedException { long timestampResolution = fileStoreAttributeCache .getFsTimestampResolution().toNanos(); while (isRacyClean(Instant.now())) { TimeUnit.NANOSECONDS.sleep(timestampResolution); } } /** * Compare two snapshots to see if they cache the same information. * * @param other * the other snapshot. * @return true if the two snapshots share the same information. */ @SuppressWarnings("NonOverridingEquals") public boolean equals(FileSnapshot other) { boolean sizeEq = size == UNKNOWN_SIZE || other.size == UNKNOWN_SIZE || size == other.size; return lastModified.equals(other.lastModified) && sizeEq && Objects.equals(fileKey, other.fileKey); } /** {@inheritDoc} */ @Override public boolean equals(Object obj) { if (this == obj) { return true; } if (obj == null) { return false; } if (!(obj instanceof FileSnapshot)) { return false; } FileSnapshot other = (FileSnapshot) obj; return equals(other); } /** {@inheritDoc} */ @Override public int hashCode() { return Objects.hash(lastModified, Long.valueOf(size), fileKey); } /** * @return {@code true} if FileSnapshot.isModified(File) found the file size * changed */ boolean wasSizeChanged() { return sizeChanged; } /** * @return {@code true} if FileSnapshot.isModified(File) found the file key * changed */ boolean wasFileKeyChanged() { return fileKeyChanged; } /** * @return {@code true} if FileSnapshot.isModified(File) found the file's * lastModified changed */ boolean wasLastModifiedChanged() { return lastModifiedChanged; } /** * @return {@code true} if FileSnapshot.isModified(File) detected that * lastModified is racily clean */ boolean wasLastModifiedRacilyClean() { return wasRacyClean; } /** * @return the delta in nanoseconds between lastModified and lastRead during * last racy check */ public long lastDelta() { return delta; } /** * @return the racyLimitNanos threshold in nanoseconds during last racy * check */ public long lastRacyThreshold() { return racyThreshold; } /** {@inheritDoc} */ @SuppressWarnings({ "nls", "ReferenceEquality" }) @Override public String toString() { if (this == DIRTY) { return "DIRTY"; } if (this == MISSING_FILE) { return "MISSING_FILE"; } return "FileSnapshot[modified: " + dateFmt.format(lastModified) + ", read: " + dateFmt.format(lastRead) + ", size:" + size + ", fileKey: " + fileKey + "]"; } private boolean isRacyClean(Instant read) { racyThreshold = getEffectiveRacyThreshold(); delta = Duration.between(lastModified, read).toNanos(); wasRacyClean = delta <= racyThreshold; if (LOG.isDebugEnabled()) { LOG.debug( "file={}, isRacyClean={}, read={}, lastModified={}, delta={} ns, racy<={} ns", //$NON-NLS-1$ file, Boolean.valueOf(wasRacyClean), dateFmt.format(read), dateFmt.format(lastModified), Long.valueOf(delta), Long.valueOf(racyThreshold)); } return wasRacyClean; } private long getEffectiveRacyThreshold() { long timestampResolution = fileStoreAttributeCache .getFsTimestampResolution().toNanos(); long minRacyInterval = fileStoreAttributeCache.getMinimalRacyInterval() .toNanos(); long max = Math.max(timestampResolution, minRacyInterval); // safety margin: factor 2.5 below 100ms otherwise 1.25 return max < 100_000_000L ? max * 5 / 2 : max * 5 / 4; } private boolean isModified(Instant currLastModified) { // Any difference indicates the path was modified. lastModifiedChanged = !lastModified.equals(currLastModified); if (lastModifiedChanged) { if (LOG.isDebugEnabled()) { LOG.debug( "file={}, lastModified changed from {} to {}", //$NON-NLS-1$ file, dateFmt.format(lastModified), dateFmt.format(currLastModified)); } return true; } // We have already determined the last read was far enough // after the last modification that any new modifications // are certain to change the last modified time. if (cannotBeRacilyClean) { LOG.debug("file={}, cannot be racily clean", file); //$NON-NLS-1$ return false; } if (!isRacyClean(lastRead)) { // Our last read should have marked cannotBeRacilyClean, // but this thread may not have seen the change. The read // of the volatile field lastRead should have fixed that. LOG.debug("file={}, is unmodified", file); //$NON-NLS-1$ return false; } // We last read this path too close to its last observed // modification time. We may have missed a modification. // Scan again, to ensure we still see the same state. LOG.debug("file={}, is racily clean", file); //$NON-NLS-1$ return true; } private boolean isFileKeyChanged(Object currFileKey) { boolean changed = currFileKey != MISSING_FILEKEY && !currFileKey.equals(fileKey); if (changed) { LOG.debug("file={}, FileKey changed from {} to {}", //$NON-NLS-1$ file, fileKey, currFileKey); } return changed; } private boolean isSizeChanged(long currSize) { boolean changed = (currSize != UNKNOWN_SIZE) && (currSize != size); if (changed) { LOG.debug("file={}, size changed from {} to {} bytes", //$NON-NLS-1$ file, Long.valueOf(size), Long.valueOf(currSize)); } return changed; } }