-
Notifications
You must be signed in to change notification settings - Fork 675
DYN-9707: Harden graph locking against corrupt lock files and improve Save As safety #17197
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
1512227
d039d15
aa2db99
0d1d43d
024074c
af1abec
a880bd1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -15,8 +15,9 @@ namespace Dynamo.Graph.Workspaces.Locking | |
| /// </summary> | ||
| internal sealed class GraphLockManager : IDisposable | ||
| { | ||
| internal const int DefaultHeartbeatMilliseconds = 30000; | ||
| internal const int DefaultHeartbeatMilliseconds = 15000; | ||
| private const int StaleFactor = 3; | ||
| private const int MaxConsecutiveReadFailures = 3; | ||
|
|
||
| private readonly DynamoModel dynamoModel; | ||
| private readonly StringComparer pathComparer; | ||
|
|
@@ -37,6 +38,7 @@ private sealed class OwnedLock | |
| internal string SidecarPath { get; set; } | ||
| internal GraphLockInfo Info { get; set; } | ||
| internal WorkspaceModel Workspace { get; set; } | ||
| internal int ConsecutiveReadFailures { get; set; } | ||
| } | ||
|
|
||
| /// <summary> | ||
|
|
@@ -192,22 +194,28 @@ private GraphLockAcquireResult AcquireLockInternal(string normalizedPath, bool a | |
| } | ||
|
|
||
| // A lock file already exists: read it to find out who owns it | ||
| GraphLockInfo existingLock; | ||
| var readable = GraphLockFile.TryRead(sidecarPath, out existingLock); | ||
| var readResult = GraphLockFile.TryRead(sidecarPath, out var existingLock); | ||
|
|
||
| var lockResult = TryAcquireReadableLock(normalizedPath, sidecarPath, info, workspace, readable, existingLock); | ||
| if (readResult == GraphLockReadResult.NotFound) | ||
| { | ||
| // The sidecar was deleted between TryCreateNewLockFile failing and TryRead. | ||
| // Retry the loop — TryCreateNewLockFile will succeed on the next attempt. | ||
| continue; | ||
| } | ||
|
|
||
| var lockResult = TryAcquireReadableLock(normalizedPath, sidecarPath, info, workspace, readResult, existingLock); | ||
| if (lockResult != null) | ||
| { | ||
| return lockResult; | ||
| } | ||
|
|
||
| // A live (or currently unreadable) lock owns the path. | ||
| // ExistingLock is Ok and live — a real conflict with another session. | ||
| return ResolveLockConflict(normalizedPath, allowPromptUI, workspace, existingLock); | ||
| } | ||
| catch (Exception ex) when (ex is UnauthorizedAccessException || ex is SecurityException || ex is IOException) | ||
| { | ||
| dynamoModel.Logger?.Log("GraphLock unavailable: " + ex.Message); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| return GraphLockAcquireResult.Unavailable(normalizedPath); | ||
|
|
@@ -218,26 +226,34 @@ private GraphLockAcquireResult TryAcquireReadableLock( | |
| string sidecarPath, | ||
| GraphLockInfo info, | ||
| WorkspaceModel workspace, | ||
| bool readable, | ||
| GraphLockReadResult readResult, | ||
| GraphLockInfo existingLock) | ||
| { | ||
| if (!readable) | ||
| if (readResult == GraphLockReadResult.Corrupt) | ||
| { | ||
| return null; | ||
| // Definitively unreadable — not evidence of a live owner. | ||
| // Safe to overwrite and take ownership. | ||
| GraphLockFile.WriteHeartbeat(sidecarPath, info); | ||
| RegisterOwnedLock(normalizedPath, sidecarPath, info, workspace); | ||
| return GraphLockAcquireResult.Acquired(normalizedPath); | ||
| } | ||
|
|
||
| if (readResult == GraphLockReadResult.TransientFailure) | ||
| { | ||
| // Could not read due to IO/permissions — a live owner may exist. | ||
| // Do not steal the lock. Redirect to copy to be safe. | ||
| dynamoModel.Logger?.Log( "GraphLock: could not read sidecar due to transient failure, redirecting to copy: " + sidecarPath); | ||
| return GraphLockAcquireResult.Unavailable(normalizedPath); | ||
| } | ||
|
|
||
| // It is our own lock (same machine + process): reuse it | ||
| if (IsOwnedByThisSession(existingLock)) | ||
| { | ||
| RegisterOwnedLock(normalizedPath, sidecarPath, existingLock, workspace); | ||
| return GraphLockAcquireResult.Acquired(normalizedPath); | ||
| } | ||
|
|
||
| // The lock is expired (no recent heartbeat) or owned by a process on this machine | ||
| // that is no longer running. In these cases the previous owner is gone, so we | ||
| // silently take the lock over. A present but unreadable lock is not reclaimed here, | ||
| // it is treated as a real conflict below, so that a transient read failure cannot | ||
| // make us steal a lock that a live instance still owns. | ||
| // that is no longer running. Silently take ownership. | ||
| if (IsStale(existingLock) || IsDeadLocalProcess(existingLock)) | ||
| { | ||
| GraphLockFile.WriteHeartbeat(sidecarPath, info); | ||
|
|
@@ -297,13 +313,13 @@ private GraphLockAcquireResult CreateAndOpenCopy( string sourcePath, string save | |
| else | ||
| { | ||
| GraphLockInfo saveAsLock; | ||
| var readable = GraphLockFile.TryRead(sidecarPath, out saveAsLock); | ||
| if (readable && IsOwnedByThisSession(saveAsLock)) | ||
| var saveAsReadResult = GraphLockFile.TryRead(sidecarPath, out saveAsLock); | ||
| if (saveAsReadResult == GraphLockReadResult.Ok && IsOwnedByThisSession(saveAsLock)) | ||
| { | ||
| info = saveAsLock; | ||
| ownsSaveAsLock = true; | ||
| } | ||
| else if (!readable || IsStale(saveAsLock) || IsDeadLocalProcess(saveAsLock)) | ||
| else if (saveAsReadResult == GraphLockReadResult.Corrupt || IsStale(saveAsLock) || IsDeadLocalProcess(saveAsLock)) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. So here, you might want to check for the TransientFailure as well? |
||
| { | ||
| GraphLockFile.WriteHeartbeat(sidecarPath, info); | ||
| ownsSaveAsLock = true; | ||
|
|
@@ -341,17 +357,42 @@ private void RegisterOwnedLock(string normalizedPath, string sidecarPath, GraphL | |
| }; | ||
| } | ||
|
|
||
| private void RefreshHeartbeats(object state) | ||
| internal void RefreshHeartbeats(object state = null) | ||
| { | ||
| foreach (var pair in locks.ToList()) | ||
| { | ||
| var owned = pair.Value; | ||
| try | ||
| { | ||
| GraphLockInfo current; | ||
| if (!GraphLockFile.TryRead(owned.SidecarPath, out current) || | ||
| current.SessionId != owned.Info.SessionId) | ||
| if (GraphLockFile.TryRead(owned.SidecarPath, out current) != GraphLockReadResult.Ok) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If TryRead() returns NotFound ... it's pretty definitive that it does not exist and the retries are unnecessary. It's probably best to break out of the retries right away as it might allow another session to claim the path.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Similarly returning Corrupt would also retry, but the file would not suddenly uncorrupt itself ... Elsewhere in AcquireLockInternal() treats Corrupt as something that can be immediately reclaimed. This might be a window where two sessions claim the same path. |
||
| { | ||
| // Transient IO failure (antivirus, NAS hiccup) — don't drop the lock immediately. | ||
| // Only abandon after MaxConsecutiveReadFailures consecutive misses. | ||
| owned.ConsecutiveReadFailures++; | ||
|
|
||
| if (owned.ConsecutiveReadFailures >= MaxConsecutiveReadFailures) | ||
| { | ||
| dynamoModel.Logger?.Log( | ||
| $"GraphLock heartbeat read failed {MaxConsecutiveReadFailures} consecutive times, " + | ||
| $"dropping lock: {owned.SidecarPath}"); | ||
| locks.TryRemove(pair.Key, out _); | ||
| } | ||
| else | ||
| { | ||
| dynamoModel.Logger?.Log( | ||
| $"GraphLock heartbeat read failed (attempt {owned.ConsecutiveReadFailures}), " + | ||
| $"will retry: {owned.SidecarPath}"); | ||
| } | ||
| continue; | ||
| } | ||
|
|
||
| // Successful read — reset the failure counter | ||
| owned.ConsecutiveReadFailures = 0; | ||
|
|
||
| if (current.SessionId != owned.Info.SessionId) | ||
| { | ||
| // Lock was genuinely stolen by another instance — stop tracking | ||
| locks.TryRemove(pair.Key, out _); | ||
| continue; | ||
| } | ||
|
|
@@ -371,7 +412,7 @@ private void ReleaseOwnedLock(string normalizedPath, OwnedLock owned) | |
| try | ||
| { | ||
| GraphLockInfo current; | ||
| if (GraphLockFile.TryRead(owned.SidecarPath, out current) && | ||
| if (GraphLockFile.TryRead(owned.SidecarPath, out current) == GraphLockReadResult.Ok && | ||
| current.SessionId == owned.Info.SessionId) | ||
| { | ||
| GraphLockFile.TryDelete(owned.SidecarPath); | ||
|
|
||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This fix addressing the TransierntFailure should probably be applied below as well, look at line 322