-
Notifications
You must be signed in to change notification settings - Fork 248
Expand file tree
/
Copy pathFileDistributedLock.cs
More file actions
171 lines (147 loc) · 7.8 KB
/
Copy pathFileDistributedLock.cs
File metadata and controls
171 lines (147 loc) · 7.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
using Medallion.Threading.Internal;
namespace Medallion.Threading.FileSystem;
/// <summary>
/// A distributed lock based on holding an exclusive handle to a lock file. The file will be deleted when the lock is released.
/// </summary>
public sealed partial class FileDistributedLock : IInternalDistributedLock<FileDistributedLockHandle>
{
/// <summary>
/// Since <see cref="UnauthorizedAccessException"/> can be thrown EITHER transiently or for permissions issues, we retry up to this many times
/// before we assume that the issue is non-transient. Empirically I've found this value to be reliable both locally and on AppVeyor (if there
/// IS a problem there's little risk to trying more times because we'll eventually be failing hard).
/// </summary>
private const int MaxUnauthorizedAccessExceptionRetries = 1600;
// These are not configurable currently because in the future we may want to change the implementation of FileDistributedLock
// to leverage native methods which may allow for actual blocking. The values here reflect the idea that we expect file locks
// to be used in cases where contention is rare
private static readonly TimeoutValue MinBusyWaitSleepTime = TimeSpan.FromMilliseconds(50),
MaxBusyWaitSleepTime = TimeSpan.FromSeconds(1);
private string? _cachedDirectory;
/// <summary>
/// Constructs a lock which uses the provided <paramref name="lockFile"/> as the exact file name.
///
/// Upon acquiring the lock, the file's directory will be created automatically if it does not already exist. The file
/// will similarly be created if it does not already exist, and will be deleted when the lock is released.
/// </summary>
public FileDistributedLock(FileInfo lockFile)
{
this.Name = (lockFile ?? throw new ArgumentNullException(nameof(lockFile))).FullName;
if (lockFile.Name.Length == 0) { throw new FormatException($"{nameof(lockFile)}: may not have an empty file name"); }
}
/// <summary>
/// Constructs a lock which will place a lock file in <paramref name="lockFileDirectory"/>. The file's name
/// will be based on <paramref name="name"/>, but with proper escaping/hashing to ensure that a valid file name is produced.
///
/// Upon acquiring the lock, the file's directory will be created automatically if it does not already exist. The file
/// will similarly be created if it does not already exist, and will be deleted when the lock is released.
/// </summary>
public FileDistributedLock(DirectoryInfo lockFileDirectory, string name)
{
this.Name = FileNameValidationHelper.GetLockFileName(lockFileDirectory, name);
}
/// <summary>
/// Implements <see cref="IDistributedLock.Name"/>
/// </summary>
public string Name { get; }
private string Directory => this._cachedDirectory ??= Path.GetDirectoryName(this.Name);
ValueTask<FileDistributedLockHandle?> IInternalDistributedLock<FileDistributedLockHandle>.InternalTryAcquireAsync(TimeoutValue timeout, CancellationToken cancellationToken) =>
BusyWaitHelper.WaitAsync(
state: this,
tryGetValue: (@this, token) => @this.TryAcquire(token).AsValueTask(),
timeout: timeout,
minSleepTime: MinBusyWaitSleepTime,
maxSleepTime: MaxBusyWaitSleepTime,
cancellationToken
);
private FileDistributedLockHandle? TryAcquire(CancellationToken cancellationToken)
{
var retryCount = 0;
while (true)
{
cancellationToken.ThrowIfCancellationRequested();
this.EnsureDirectoryExists();
FileStream lockFileStream;
try
{
// key arguments:
// OpenOrCreate to be robust to the file existing or not
// None to take an exclusive lock
// DeleteOnClose to clean up after ourselves
lockFileStream = new FileStream(this.Name, FileMode.OpenOrCreate, FileAccess.Read, FileShare.None, bufferSize: 1, FileOptions.DeleteOnClose);
}
catch (DirectoryNotFoundException)
{
// this should almost never happen because we just created the directory but in a race condition it could. Just retry
continue;
}
catch (UnauthorizedAccessException)
{
// This can happen in few cases:
// The path is already directory, so we'll never be able to open a handle of it as a file
if (System.IO.Directory.Exists(this.Name))
{
throw new InvalidOperationException($"Failed to create lock file '{this.Name}' because it is already the name of a directory");
}
// The file exists and is read-only
FileAttributes attributes;
try { attributes = File.GetAttributes(this.Name); }
catch { attributes = FileAttributes.Normal; } // e. g. could fail with FileNotFoundException
if (attributes.HasFlag(FileAttributes.ReadOnly))
{
// We could support this by eschewing DeleteOnClose once we detect that a file is read-only,
// but absent interest or a use-case we'll just throw for now
throw new NotSupportedException($"Locking on read-only file '{this.Name}' is not supported");
}
// Frustratingly, this error can be thrown transiently due to concurrent creation/deletion. Initially assume
// that it is transient and just retry
if (CanRetryTransientFileSystemError(ref retryCount))
{
continue;
}
// If we get here, we've exhausted our retries: assume that it is a legitimate permissions issue
throw;
}
// this should never happen because we validate. However if it does (e. g. due to some system configuration change?), throw so that
// this doesn't end up in the IOException block (PathTooLongException is IOException)
catch (PathTooLongException) { throw; }
catch (IOException)
{
// the hope is that if we get here the only failure reason would be that the file is locked
return null;
}
return new FileDistributedLockHandle(lockFileStream);
}
}
private void EnsureDirectoryExists()
{
var retryCount = 0;
while (true)
{
try
{
System.IO.Directory.CreateDirectory(this.Directory);
return;
}
catch (Exception ex)
{
// This can indicate either a transient failure during concurrent creation/deletion or a permissions issue.
// If we encounter it, assume it is transient unless it persists.
// For a long time, I just checked for UnauthorizedAccessException here. However, recent tests on Linux have
// shown that in race conditions we can see IOException as well, presumably because there is some period during
// directory creation where it presents as a file.
if (ex is UnauthorizedAccessException or IOException
&& CanRetryTransientFileSystemError(ref retryCount))
{
continue;
}
throw new InvalidOperationException($"Failed to ensure that lock file directory {this.Directory} exists", ex);
}
}
}
private static bool CanRetryTransientFileSystemError(ref int retryCount)
{
if (retryCount >= MaxUnauthorizedAccessExceptionRetries) { return false; }
++retryCount;
return true;
}
}