diff --git a/src/Infrastructure.Persistence.Azure/ApplicationServices/AzureSqlServerStore.IDataStore.cs b/src/Infrastructure.Persistence.Azure/ApplicationServices/AzureSqlServerStore.IDataStore.cs index 99958ccc..5448cfb6 100644 --- a/src/Infrastructure.Persistence.Azure/ApplicationServices/AzureSqlServerStore.IDataStore.cs +++ b/src/Infrastructure.Persistence.Azure/ApplicationServices/AzureSqlServerStore.IDataStore.cs @@ -150,6 +150,18 @@ public async Task, Error>> RetrieveAsync(string c return Optional.None; } + + private async Task> AddExclusiveAsync(string containerName, Dictionary wheres, + CommandEntity entity, + CancellationToken cancellationToken) + { + containerName.ThrowIfNotValuedParameter(nameof(containerName), + Resources.AnyStore_MissingContainerName); + ArgumentNullException.ThrowIfNull(entity); + + return await ExecuteSqlInsertExclusiveCommandAsync(containerName, wheres, entity.ToTableEntity(), + cancellationToken); + } } internal static class SqlServerQueryBuilderExtensions diff --git a/src/Infrastructure.Persistence.Azure/ApplicationServices/AzureSqlServerStore.IEventStore.cs b/src/Infrastructure.Persistence.Azure/ApplicationServices/AzureSqlServerStore.IEventStore.cs index 5693d4fa..687388b6 100644 --- a/src/Infrastructure.Persistence.Azure/ApplicationServices/AzureSqlServerStore.IEventStore.cs +++ b/src/Infrastructure.Persistence.Azure/ApplicationServices/AzureSqlServerStore.IEventStore.cs @@ -30,7 +30,7 @@ public async Task> AddEventsAsync(string entityName, strin var latestStoredEventVersion = latest.Value.HasValue ? latest.Value.Value.Version.ToOptional() : Optional.None; - var @checked = this.VerifyConcurrencyCheck(streamName, latestStoredEventVersion, events.First().Version); + var @checked = this.VerifyContiguousCheck(streamName, latestStoredEventVersion, events.First().Version); if (@checked.IsFailure) { return @checked.Error; @@ -38,10 +38,23 @@ public async Task> AddEventsAsync(string entityName, strin foreach (var @event in events) { - var added = await AddAsync(DetermineEventStoreContainerName(), + var version = @event.Version; + var wheres = new Dictionary + { + { nameof(EventStoreEntity.Version), version }, + { nameof(EventStoreEntity.StreamName), streamName } + }; + var added = await AddExclusiveAsync(DetermineEventStoreContainerName(), wheres, CommandEntity.FromDto(@event.ToTabulated(entityName, streamName)), cancellationToken); if (added.IsFailure) { + if (added.Error.Is(ErrorCode.EntityExists)) + { + return Error.EntityExists( + Common.Resources.EventStore_ConcurrencyVerificationFailed_StreamAlreadyUpdated.Format( + streamName, version)); + } + return added.Error; } } diff --git a/src/Infrastructure.Persistence.Azure/ApplicationServices/AzureSqlServerStore.cs b/src/Infrastructure.Persistence.Azure/ApplicationServices/AzureSqlServerStore.cs index 784ea597..5e46c022 100644 --- a/src/Infrastructure.Persistence.Azure/ApplicationServices/AzureSqlServerStore.cs +++ b/src/Infrastructure.Persistence.Azure/ApplicationServices/AzureSqlServerStore.cs @@ -70,6 +70,9 @@ private async Task> ExecuteSqlUpdateCommandAsync(string tableName, } } + /// + /// Inserts the entity into the table whether it exists or not. + /// private async Task> ExecuteSqlInsertCommandAsync(string tableName, Dictionary parameters, CancellationToken cancellationToken) { @@ -109,6 +112,80 @@ private async Task> ExecuteSqlInsertCommandAsync(string tableName, } } + /// + /// Inserts the entity into the table, as long as the entity does not already exist for the specified + /// , otherwise return + /// + private async Task> ExecuteSqlInsertExclusiveCommandAsync(string tableName, + Dictionary wheresParameter, + Dictionary parameters, CancellationToken cancellationToken) + { + const int whereParameterOffset = 500; // Arbitrary offset to avoid parameter collision + var columnNames = string.Join(',', parameters.Select(p => p.Key.ToColumnName())); + var columnIndex = 1; + var columnValuePlaceholders = string.Join(',', parameters.Select(_ => $"@{columnIndex++}")); + var existsIndex = whereParameterOffset; + var existsColumnNames = string.Join(' ', wheresParameter.Select(where => + $"{(existsIndex++ > whereParameterOffset ? "AND " : "")}{where.Key.ToColumnName()} = @{existsIndex - 1}")); + var commandText = $""" + SET TRANSACTION ISOLATION LEVEL SERIALIZABLE + BEGIN TRANSACTION + IF NOT EXISTS ( + SELECT [Id] + FROM {tableName.ToTableName()} + WHERE {existsColumnNames} + ) + BEGIN + INSERT INTO {tableName.ToTableName()} ({columnNames}) + VALUES ({columnValuePlaceholders}) + END + COMMIT TRANSACTION + """; + + await using var connection = new SqlConnection(_connectionOptions.ConnectionString); + { + try + { + await connection.OpenAsync(cancellationToken); + int numRecords; + await using (var command = new SqlCommand(commandText, connection)) + { + var whereParameterIndex = whereParameterOffset; + foreach (var whereParameter in wheresParameter) + { + command.Parameters.AddWithValue($"@{whereParameterIndex++}", whereParameter.Value); + } + + var parameterIndex = 1; + foreach (var parameter in parameters) + { + command.Parameters.AddWithValue($"@{parameterIndex++}", parameter.Value); + } + + numRecords = await command.ExecuteNonQueryAsync(cancellationToken); + } + + await connection.CloseAsync(); + if (numRecords == -1) + { + _recorder.TraceWarning(null, "SQLServer executed SQL {Command}, but found existing record", + commandText); + return Error.EntityExists(); + } + + _recorder.TraceInformation(null, "SQLServer executed SQL {Command}, affecting {Affecting} records", + commandText, numRecords); + + return Result.Ok; + } + catch (Exception ex) + { + _recorder.TraceError(null, ex, "SQLServer failed executing SQL {Command}", commandText); + return ex.ToError(ErrorCode.Unexpected); + } + } + } + private async Task> ExecuteSqlDeleteCommandAsync(string tableName, KeyValuePair? whereParameter, CancellationToken cancellationToken) { @@ -350,7 +427,7 @@ public static Dictionary ToTableEntity(this CommandEntity entity var targetPropertyType = entity.GetPropertyType(key); properties.Add(key, ToTableEntityProperty(value, targetPropertyType)); } - + properties[nameof(CommandEntity.LastPersistedAtUtc)] = DateTime.UtcNow; return properties; diff --git a/src/Infrastructure.Persistence.Common.UnitTests/Extensions/EventStoreExtensionsSpec.cs b/src/Infrastructure.Persistence.Common.UnitTests/Extensions/EventStoreExtensionsSpec.cs index 91e8767e..ed1df4ba 100644 --- a/src/Infrastructure.Persistence.Common.UnitTests/Extensions/EventStoreExtensionsSpec.cs +++ b/src/Infrastructure.Persistence.Common.UnitTests/Extensions/EventStoreExtensionsSpec.cs @@ -17,7 +17,7 @@ public class EventStoreExtensionsSpec [Fact] public void WhenVerifyConcurrencyCheckAndNothingStoredAndFirstVersionIsNotFirst_TheReturnsError() { - var result = _eventStore.Object.VerifyConcurrencyCheck("astreamname", Optional.None, 10); + var result = _eventStore.Object.VerifyContiguousCheck("astreamname", Optional.None, 10); result.Should().BeError(ErrorCode.EntityExists, Resources.EventStore_ConcurrencyVerificationFailed_StreamReset.Format("astreamname")); @@ -27,7 +27,7 @@ public void WhenVerifyConcurrencyCheckAndNothingStoredAndFirstVersionIsNotFirst_ public void WhenVerifyConcurrencyCheckAndNothingStoredAndFirstVersionIsFirst_ThenPasses() { var result = - _eventStore.Object.VerifyConcurrencyCheck("astreamname", Optional.None, EventStream.FirstVersion); + _eventStore.Object.VerifyContiguousCheck("astreamname", Optional.None, EventStream.FirstVersion); result.Should().BeSuccess(); } @@ -35,7 +35,7 @@ public void WhenVerifyConcurrencyCheckAndNothingStoredAndFirstVersionIsFirst_The [Fact] public void WhenVerifyConcurrencyCheckAndFirstVersionIsSameAsStored_TheReturnsError() { - var result = _eventStore.Object.VerifyConcurrencyCheck("astreamname", 2, 2); + var result = _eventStore.Object.VerifyContiguousCheck("astreamname", 2, 2); result.Should().BeError(ErrorCode.EntityExists, Resources.EventStore_ConcurrencyVerificationFailed_StreamAlreadyUpdated.Format("astreamname", 2)); @@ -44,7 +44,7 @@ public void WhenVerifyConcurrencyCheckAndFirstVersionIsSameAsStored_TheReturnsEr [Fact] public void WhenVerifyConcurrencyCheckAndFirstVersionIsBeforeStored_TheReturnsError() { - var result = _eventStore.Object.VerifyConcurrencyCheck("astreamname", 2, 1); + var result = _eventStore.Object.VerifyContiguousCheck("astreamname", 2, 1); result.Should().BeError(ErrorCode.EntityExists, Resources.EventStore_ConcurrencyVerificationFailed_StreamAlreadyUpdated.Format("astreamname", 1)); @@ -53,7 +53,7 @@ public void WhenVerifyConcurrencyCheckAndFirstVersionIsBeforeStored_TheReturnsEr [Fact] public void WhenVerifyConcurrencyCheckAndFirstVersionIsAfterStoredButNotContiguous_TheReturnsError() { - var result = _eventStore.Object.VerifyConcurrencyCheck("astreamname", 1, 3); + var result = _eventStore.Object.VerifyContiguousCheck("astreamname", 1, 3); result.Should().BeError(ErrorCode.EntityExists, Resources.EventStore_ConcurrencyVerificationFailed_MissingUpdates.Format("astreamname", 2, 3)); @@ -62,7 +62,7 @@ public void WhenVerifyConcurrencyCheckAndFirstVersionIsAfterStoredButNotContiguo [Fact] public void WhenVerifyConcurrencyCheckAndFirstVersionIsNextAfterStored_ThenPasses() { - var result = _eventStore.Object.VerifyConcurrencyCheck("astreamname", 1, 2); + var result = _eventStore.Object.VerifyContiguousCheck("astreamname", 1, 2); result.Should().BeSuccess(); } diff --git a/src/Infrastructure.Persistence.Common/Extensions/EventStoreExtensions.cs b/src/Infrastructure.Persistence.Common/Extensions/EventStoreExtensions.cs index 567c09d3..edf60811 100644 --- a/src/Infrastructure.Persistence.Common/Extensions/EventStoreExtensions.cs +++ b/src/Infrastructure.Persistence.Common/Extensions/EventStoreExtensions.cs @@ -9,11 +9,9 @@ public static class EventStoreExtensions { /// /// Verifies that the version of the latest event produced by the aggregate is the next event in the stream of events - /// from the store. - /// In other words, that the event stream of the aggregate in the store has not been updated while the - /// aggregate has been changed in memory. + /// from the store, with no version gaps between them. IN other words, they are contiguous /// - public static Result VerifyConcurrencyCheck(this IEventStore eventStore, string streamName, + public static Result VerifyContiguousCheck(this IEventStore eventStore, string streamName, Optional latestStoredEventVersion, int nextEventVersion) { if (!latestStoredEventVersion.HasValue) @@ -27,13 +25,6 @@ public static Result VerifyConcurrencyCheck(this IEventStore eventStore, return Result.Ok; } - if (nextEventVersion <= latestStoredEventVersion) - { - return Error.EntityExists( - Resources.EventStore_ConcurrencyVerificationFailed_StreamAlreadyUpdated.Format(streamName, - nextEventVersion)); - } - var expectedNextVersion = latestStoredEventVersion + 1; if (nextEventVersion > expectedNextVersion) { diff --git a/src/Infrastructure.Persistence.Common/Infrastructure.Persistence.Common.csproj b/src/Infrastructure.Persistence.Common/Infrastructure.Persistence.Common.csproj index 0ecb7c59..9e3041d1 100644 --- a/src/Infrastructure.Persistence.Common/Infrastructure.Persistence.Common.csproj +++ b/src/Infrastructure.Persistence.Common/Infrastructure.Persistence.Common.csproj @@ -22,9 +22,15 @@ <_Parameter1>Infrastructure.Persistence.Shared.IntegrationTests + + <_Parameter1>Infrastructure.Persistence.Shared + <_Parameter1>Infrastructure.Persistence.Kurrent + + <_Parameter1>Infrastructure.Persistence.Azure + diff --git a/src/Infrastructure.Persistence.Shared.IntegrationTests/AnyEventStoreBaseSpec.cs b/src/Infrastructure.Persistence.Shared.IntegrationTests/AnyEventStoreBaseSpec.cs index 8d84eb1a..fc4e34ad 100644 --- a/src/Infrastructure.Persistence.Shared.IntegrationTests/AnyEventStoreBaseSpec.cs +++ b/src/Infrastructure.Persistence.Shared.IntegrationTests/AnyEventStoreBaseSpec.cs @@ -4,12 +4,12 @@ using Domain.Common.ValueObjects; using Domain.Interfaces.Entities; using FluentAssertions; -using Infrastructure.Persistence.Common; using Infrastructure.Persistence.Interfaces; using QueryAny; using UnitTesting.Common; using UnitTesting.Common.Validation; using Xunit; +using Resources = Infrastructure.Persistence.Common.Resources; using Task = System.Threading.Tasks.Task; namespace Infrastructure.Persistence.Shared.IntegrationTests; @@ -154,7 +154,7 @@ await _setup.Store } [Fact] - public async Task WhenAddEvents_ThenEventsAdded() + public async Task WhenAddEventsWithFirstEvent_ThenEventsAdded() { var entityId = GetNextEntityId(); await _setup.Store.AddEventsAsync(_setup.ContainerName, entityId, @@ -168,6 +168,27 @@ await _setup.Store.GetEventStreamAsync(_setup.ContainerName, entityId, result.Value.Last().Id.Should().Be("anid_v1"); } + [Fact] + public async Task WhenAddEventsAtSameTime_ThenReturnsConcurrencyError() + { + var entityId = GetNextEntityId(); + var sameEvent = CreateEvent(1); + var add1 = _setup.Store.AddEventsAsync(_setup.ContainerName, entityId, + [sameEvent], CancellationToken.None); + var add2 = _setup.Store.AddEventsAsync(_setup.ContainerName, entityId, + [sameEvent], CancellationToken.None); + var add3 = _setup.Store.AddEventsAsync(_setup.ContainerName, entityId, + [sameEvent], CancellationToken.None); + + var result = await Task.WhenAll(add1, add2, add3); + + result.Length.Should().Be(3); + result.Count(x => x.IsSuccessful).Should().Be(1); + result.Count(x => x is { IsFailure: true, Error.Code: ErrorCode.EntityExists } && x.Error.Message == + Resources.EventStore_ConcurrencyVerificationFailed_StreamAlreadyUpdated + .Format($"testentities_{entityId}", 1)).Should().Be(2); + } + [Fact] public async Task WhenAddEventsAndStreamCleared_ThenReturnsError() { @@ -213,7 +234,7 @@ await _setup.Store.AddEventsAsync(_setup.ContainerName, entityId, var result = await _setup.Store.AddEventsAsync(_setup.ContainerName, entityId, [CreateEvent(10)], CancellationToken.None); - + result.Should().BeError(ErrorCode.EntityExists, Resources.EventStore_ConcurrencyVerificationFailed_MissingUpdates.Format( $"testentities_{entityId}", 4, 10)); diff --git a/src/Infrastructure.Persistence.Shared/ApplicationServices/InProcessInMemStore.IEventStore.cs b/src/Infrastructure.Persistence.Shared/ApplicationServices/InProcessInMemStore.IEventStore.cs index 7ae8ad7c..9568f458 100644 --- a/src/Infrastructure.Persistence.Shared/ApplicationServices/InProcessInMemStore.IEventStore.cs +++ b/src/Infrastructure.Persistence.Shared/ApplicationServices/InProcessInMemStore.IEventStore.cs @@ -27,23 +27,35 @@ public async Task> AddEventsAsync(string entityName, strin var latestStoredEventVersion = latestStoredEvent.HasValue ? latestStoredEvent.Value.Version.ToOptional() : Optional.None; - var concurrencyCheck = - this.VerifyConcurrencyCheck(streamName, latestStoredEventVersion, Enumerable.First(events).Version); - if (concurrencyCheck.IsFailure) + var @checked = + this.VerifyContiguousCheck(streamName, latestStoredEventVersion, Enumerable.First(events).Version); + if (@checked.IsFailure) { - return concurrencyCheck.Error; + return @checked.Error; } - events.ForEach(@event => + foreach (var @event in events) { var entity = CommandEntity.FromDto(@event.ToTabulated(entityName, streamName)); + var version = @event.Version; + if (!_events.ContainsKey(entityName)) { _events.Add(entityName, new Dictionary()); } - _events[entityName].Add(entity.Id, entity.ToHydrationProperties()); - }); + try + { + var stream = _events[entityName]; + stream.Add(entity.Id, entity.ToHydrationProperties()); + } + catch (ArgumentException) + { + return Error.EntityExists( + Common.Resources.EventStore_ConcurrencyVerificationFailed_StreamAlreadyUpdated.Format( + streamName, version)); + } + } return streamName; } diff --git a/src/Infrastructure.Persistence.Shared/ApplicationServices/LocalMachineJsonFileStore.IEventStore.cs b/src/Infrastructure.Persistence.Shared/ApplicationServices/LocalMachineJsonFileStore.IEventStore.cs index 72168351..787ba2e7 100644 --- a/src/Infrastructure.Persistence.Shared/ApplicationServices/LocalMachineJsonFileStore.IEventStore.cs +++ b/src/Infrastructure.Persistence.Shared/ApplicationServices/LocalMachineJsonFileStore.IEventStore.cs @@ -27,11 +27,11 @@ public async Task> AddEventsAsync(string entityName, strin var latestStoredEventVersion = latestStoredEvent.HasValue ? latestStoredEvent.Value.Version.ToOptional() : Optional.None; - var concurrencyCheck = - this.VerifyConcurrencyCheck(streamName, latestStoredEventVersion, Enumerable.First(events).Version); - if (concurrencyCheck.IsFailure) + var @checked = + this.VerifyContiguousCheck(streamName, latestStoredEventVersion, Enumerable.First(events).Version); + if (@checked.IsFailure) { - return concurrencyCheck.Error; + return @checked.Error; } foreach (var @event in events) @@ -39,7 +39,20 @@ public async Task> AddEventsAsync(string entityName, strin var entity = CommandEntity.FromDto(@event.ToTabulated(entityName, streamName)); var container = EnsureContainer(GetEventStoreContainerPath(entityName, entityId)); - await container.WriteAsync(entity.Id, entity.ToFileProperties(), cancellationToken); + var version = @event.Version; + var filename = $"version_{version:D3}"; + var added = await container.WriteExclusiveAsync(filename, entity.ToFileProperties(), cancellationToken); + if (added.IsFailure) + { + if (added.Error.Is(ErrorCode.EntityExists)) + { + return Error.EntityExists( + Common.Resources.EventStore_ConcurrencyVerificationFailed_StreamAlreadyUpdated.Format( + streamName, version)); + } + + return added.Error; + } } return streamName; diff --git a/src/Infrastructure.Persistence.Shared/ApplicationServices/LocalMachineJsonFileStore.cs b/src/Infrastructure.Persistence.Shared/ApplicationServices/LocalMachineJsonFileStore.cs index 7e423e19..21a1bfa9 100644 --- a/src/Infrastructure.Persistence.Shared/ApplicationServices/LocalMachineJsonFileStore.cs +++ b/src/Infrastructure.Persistence.Shared/ApplicationServices/LocalMachineJsonFileStore.cs @@ -1,5 +1,6 @@ #if TESTINGONLY using System.Diagnostics.CodeAnalysis; +using System.Text; using Common; using Common.Configuration; using Common.Extensions; @@ -196,6 +197,8 @@ private static async Task GetEntityFromFileAsync(FileContai private sealed class FileContainer { internal const string FileExtension = "json"; + + private static readonly Encoding DefaultSystemTextEncoding = new UTF8Encoding(false, true); private readonly string _dirPath; private readonly string _rootPath; @@ -248,7 +251,7 @@ public void Erase() public bool Exists(string entityId) { - var filename = GetFullFilePathFromId(entityId); + var filename = GetFullFilePathFromName(entityId); return File.Exists(filename); } @@ -307,7 +310,7 @@ public async Task>> ReadAsync(strin { if (Exists(entityId)) { - var filename = GetFullFilePathFromId(entityId); + var filename = GetFullFilePathFromName(entityId); var content = await File.ReadAllTextAsync(filename, cancellationToken); return (content.FromJson>() ?? new Dictionary()) @@ -321,27 +324,28 @@ public void Remove(string entityId) { if (Exists(entityId)) { - var filename = GetFullFilePathFromId(entityId); + var filename = GetFullFilePathFromName(entityId); File.Delete(filename); } } /// - /// Writes the specified to JSON to a file on the disk + /// Writes the specified to JSON to a file on the disk. + /// This version of the method will overwrite the file if it already exists. /// - public async Task WriteAsync(string entityId, IReadOnlyDictionary> properties, + public async Task WriteAsync(string filename, IReadOnlyDictionary> properties, CancellationToken cancellationToken) { var retryPolicy = Policy.Handle() .WaitAndRetryAsync(3, _ => TimeSpan.FromMilliseconds(300)); - var filename = GetFullFilePathFromId(entityId); + var fullFilename = GetFullFilePathFromName(filename); await retryPolicy.ExecuteAsync(SaveFileAsync); return; async Task SaveFileAsync() { - await using var file = File.CreateText(filename); + await using var file = File.CreateText(fullFilename); var json = properties .Where(pair => pair.Value.HasValue) .ToDictionary(pair => pair.Key, pair => pair.Value.ValueOrDefault) @@ -351,6 +355,60 @@ async Task SaveFileAsync() } } + /// + /// Writes the specified to JSON to a file on the disk. + /// This version of the method will return an error if the file already exists. + /// + public async Task> WriteExclusiveAsync(string filename, + IReadOnlyDictionary> properties, + CancellationToken cancellationToken) + { + var retryPolicy = Policy.Handle() + .WaitAndRetryAsync(3, _ => TimeSpan.FromMilliseconds(300)); + + var fullFilename = GetFullFilePathFromName(filename); + return await retryPolicy.ExecuteAsync(CreateFileExclusiveAsync); + + async Task> CreateFileExclusiveAsync() + { + var json = properties + .Where(pair => pair.Value.HasValue) + .ToDictionary(pair => pair.Key, pair => pair.Value.ValueOrDefault) + .ToJson()!; + + var content = DefaultSystemTextEncoding.GetBytes(json); + + FileStream? file = null; + try + { + // Try to open the file for writing, but fail if it already exists, or if someone else is writing + // or reading it at the same time (essentially locking the file). + file = File.Open(fullFilename, FileMode.CreateNew, FileAccess.Write, FileShare.None); + } + catch (IOException) + { + if (file.Exists()) + { + file.Close(); + } + + return Error.EntityExists(); + } + + try + { + await file.WriteAsync(content, cancellationToken); + await file.FlushAsync(cancellationToken); + + return Result.Ok; + } + finally + { + file.Close(); + } + } + } + private static string CleanDirectoryPath(string path) { return string.Join("", path.Split(Path.GetInvalidPathChars())); @@ -361,10 +419,10 @@ private static string CleanFileName(string fileName) return string.Join("", fileName.Split(Path.GetInvalidFileNameChars())); } - private string GetFullFilePathFromId(string entityId) + private string GetFullFilePathFromName(string filename) { - var filename = $"{CleanFileName(entityId)}.{FileExtension}"; - return Path.Combine(_dirPath, filename); + var fullFilename = $"{CleanFileName(filename)}.{FileExtension}"; + return Path.Combine(_dirPath, fullFilename); } private static string GetIdFromFullFilePath(string path) diff --git a/src/Infrastructure.Persistence.Shared/Resources.resx b/src/Infrastructure.Persistence.Shared/Resources.resx index 1b401dcc..3bd0bc3e 100644 --- a/src/Infrastructure.Persistence.Shared/Resources.resx +++ b/src/Infrastructure.Persistence.Shared/Resources.resx @@ -57,5 +57,4 @@ The specified root path: {0}, is not a valid path or for some reason cannot be created on this local machine. - \ No newline at end of file