(feature): thread safe file and json file logging, deep clone and compare of objects
This commit is contained in:
parent
1fba73f690
commit
28a698a03b
137
README.md
137
README.md
@ -13,6 +13,10 @@
|
||||
- [DataTable Extensions](#datatable-extensions)
|
||||
- [Guid Extensions](#guid-extensions)
|
||||
- [Logging](#logging)
|
||||
- [File Logger](#file-logger)
|
||||
- [JSON File Logger](#json-file-logger)
|
||||
- [Threading](#threading)
|
||||
- [Lock Manager](#lock-manager)
|
||||
- [Networking](#networking)
|
||||
- [Network Connection](#network-connection)
|
||||
- [Ping Port](#ping-port)
|
||||
@ -511,33 +515,75 @@ string result = "example".Left(3); // "exa"
|
||||
|
||||
#### Object Extensions
|
||||
|
||||
The `ObjectExtensions` class provides methods for serializing objects to JSON strings and deserializing JSON strings back to objects.
|
||||
The `ObjectExtensions` class provides advanced methods for working with objects, including serialization, deep cloning, and structural equality comparison.
|
||||
|
||||
---
|
||||
|
||||
#### Features
|
||||
|
||||
1. **JSON Serialization**:
|
||||
- Convert objects to JSON strings.
|
||||
- Convert objects to JSON strings with optional custom converters.
|
||||
|
||||
2. **JSON Deserialization**:
|
||||
- Convert JSON strings back to objects.
|
||||
2. **Deep Cloning**:
|
||||
- Create a deep clone of an object, preserving reference identity and supporting cycles.
|
||||
|
||||
3. **Structural Equality**:
|
||||
- Compare two objects deeply for structural equality, including private fields.
|
||||
|
||||
4. **Snapshot Reversion**:
|
||||
- Revert an object to a previous state by copying all fields from a snapshot.
|
||||
|
||||
---
|
||||
|
||||
#### Example Usage
|
||||
|
||||
##### Serialization
|
||||
##### JSON Serialization
|
||||
```csharp
|
||||
var person = new { Name = "John", Age = 30 };
|
||||
string json = person.ToJson();
|
||||
|
||||
// With custom converters
|
||||
var converters = new List<JsonConverter> { new CustomConverter() };
|
||||
string jsonWithConverters = person.ToJson(converters);
|
||||
```
|
||||
|
||||
##### Deserialization
|
||||
##### Deep Cloning
|
||||
```csharp
|
||||
var person = json.ToObject<Person>();
|
||||
var original = new Person { Name = "John", Age = 30 };
|
||||
var clone = original.DeepClone();
|
||||
```
|
||||
|
||||
##### Structural Equality
|
||||
```csharp
|
||||
var person1 = new Person { Name = "John", Age = 30 };
|
||||
var person2 = new Person { Name = "John", Age = 30 };
|
||||
|
||||
bool areEqual = person1.DeepEqual(person2); // True
|
||||
```
|
||||
|
||||
##### Snapshot Reversion
|
||||
```csharp
|
||||
var snapshot = new Person { Name = "John", Age = 30 };
|
||||
var current = new Person { Name = "Doe", Age = 25 };
|
||||
|
||||
current.RevertFrom(snapshot);
|
||||
// current.Name is now "John"
|
||||
// current.Age is now 30
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### Best Practices
|
||||
|
||||
1. **Use Deep Cloning for Complex Objects**:
|
||||
- Ensure objects are deeply cloned when working with mutable reference types.
|
||||
|
||||
2. **Validate Structural Equality**:
|
||||
- Use `DeepEqual` for scenarios requiring precise object comparisons.
|
||||
|
||||
3. **Revert State Safely**:
|
||||
- Use `RevertFrom` to safely restore object states in tracked entities.
|
||||
|
||||
---
|
||||
|
||||
#### DataTable Extensions
|
||||
@ -599,24 +645,26 @@ The `Logging` namespace provides a custom file-based logging implementation that
|
||||
|
||||
---
|
||||
|
||||
### File Logger
|
||||
|
||||
The `FileLogger` class in the `MaksIT.Core.Logging` namespace provides a simple and efficient way to log messages to plain text files. It supports log retention policies and ensures thread-safe writes using the `LockManager`.
|
||||
|
||||
#### Features
|
||||
|
||||
1. **File-Based Logging**:
|
||||
- Log messages to a specified file.
|
||||
1. **Plain Text Logging**:
|
||||
- Logs messages in a human-readable plain text format.
|
||||
|
||||
2. **Log Levels**:
|
||||
- Supports all standard log levels.
|
||||
2. **Log Retention**:
|
||||
- Automatically deletes old log files based on a configurable retention period.
|
||||
|
||||
3. **Thread Safety**:
|
||||
- Ensures thread-safe writes to the log file.
|
||||
|
||||
---
|
||||
- Ensures safe concurrent writes to the log file using the `LockManager`.
|
||||
|
||||
#### Example Usage
|
||||
|
||||
```csharp
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging(builder => builder.AddFile("logs.txt"));
|
||||
services.AddLogging(builder => builder.AddFileLogger("logs", TimeSpan.FromDays(7)));
|
||||
|
||||
var logger = services.BuildServiceProvider().GetRequiredService<ILogger<FileLogger>>();
|
||||
logger.LogInformation("Logging to file!");
|
||||
@ -624,6 +672,65 @@ logger.LogInformation("Logging to file!");
|
||||
|
||||
---
|
||||
|
||||
### JSON File Logger
|
||||
|
||||
The `JsonFileLogger` class in the `MaksIT.Core.Logging` namespace provides structured logging in JSON format. It is ideal for machine-readable logs and integrates seamlessly with log aggregation tools.
|
||||
|
||||
#### Features
|
||||
|
||||
1. **JSON Logging**:
|
||||
- Logs messages in structured JSON format, including timestamps, log levels, and exceptions.
|
||||
|
||||
2. **Log Retention**:
|
||||
- Automatically deletes old log files based on a configurable retention period.
|
||||
|
||||
3. **Thread Safety**:
|
||||
- Ensures safe concurrent writes to the log file using the `LockManager`.
|
||||
|
||||
#### Example Usage
|
||||
|
||||
```csharp
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging(builder => builder.AddJsonFileLogger("logs", TimeSpan.FromDays(7)));
|
||||
|
||||
var logger = services.BuildServiceProvider().GetRequiredService<ILogger<JsonFileLogger>>();
|
||||
logger.LogInformation("Logging to JSON file!");
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Threading
|
||||
|
||||
### Lock Manager
|
||||
|
||||
The `LockManager` class in the `MaksIT.Core.Threading` namespace provides a robust solution for managing concurrency and rate limiting. It ensures safe access to shared resources in multi-threaded or multi-process environments.
|
||||
|
||||
#### Features
|
||||
|
||||
1. **Thread Safety**:
|
||||
- Ensures mutual exclusion using a semaphore.
|
||||
|
||||
2. **Rate Limiting**:
|
||||
- Limits the frequency of access to shared resources using a token bucket rate limiter.
|
||||
|
||||
3. **Reentrant Locks**:
|
||||
- Supports reentrant locks for the same thread.
|
||||
|
||||
#### Example Usage
|
||||
|
||||
```csharp
|
||||
var lockManager = new LockManager();
|
||||
|
||||
await lockManager.ExecuteWithLockAsync(async () => {
|
||||
// Critical section
|
||||
Console.WriteLine("Executing safely");
|
||||
});
|
||||
|
||||
lockManager.Dispose();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Networking
|
||||
|
||||
### Network Connection
|
||||
|
||||
@ -118,5 +118,199 @@ namespace MaksIT.Core.Tests.Extensions {
|
||||
// Assert
|
||||
Assert.Equal("{}", result);
|
||||
}
|
||||
|
||||
// ------- DeepClone / DeepEqual / RevertFrom tests below -------
|
||||
|
||||
private class Person {
|
||||
public string Name = "";
|
||||
public int Age;
|
||||
private string _secret = "xyz";
|
||||
public string Secret => _secret;
|
||||
public void SetSecret(string s) { _secret = s; }
|
||||
public Address? Addr;
|
||||
}
|
||||
|
||||
private class Address {
|
||||
public string City = "";
|
||||
public Person? Owner; // cycle back to person
|
||||
}
|
||||
|
||||
private struct Score {
|
||||
public int A;
|
||||
public List<Person>? People; // ref-type field inside struct
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeepClone_WithSimpleGraph_ShouldProduceIndependentCopy() {
|
||||
// Arrange
|
||||
var p = new Person { Name = "Alice", Age = 25, Addr = new Address { City = "Rome" } };
|
||||
|
||||
// Act
|
||||
var clone = p.DeepClone();
|
||||
|
||||
// Assert
|
||||
Assert.NotSame(p, clone);
|
||||
Assert.Equal("Alice", clone.Name);
|
||||
Assert.Equal(25, clone.Age);
|
||||
Assert.NotSame(p.Addr, clone.Addr);
|
||||
Assert.Equal("Rome", clone.Addr!.City);
|
||||
|
||||
// Mutate clone should not affect original
|
||||
clone.Name = "Bob";
|
||||
clone.Addr.City = "Milan";
|
||||
clone.SetSecret("new");
|
||||
Assert.Equal("Alice", p.Name);
|
||||
Assert.Equal("Rome", p.Addr!.City);
|
||||
Assert.Equal("xyz", p.Secret);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeepClone_ShouldPreserveCyclesAndReferenceIdentity() {
|
||||
// Arrange
|
||||
var p = new Person { Name = "Root" };
|
||||
var a = new Address { City = "Naples" };
|
||||
p.Addr = a;
|
||||
a.Owner = p; // create cycle
|
||||
|
||||
// Act
|
||||
var clone = p.DeepClone();
|
||||
|
||||
// Assert
|
||||
Assert.NotSame(p, clone);
|
||||
Assert.NotSame(p.Addr, clone.Addr);
|
||||
Assert.Same(clone, clone.Addr!.Owner); // cycle preserved in clone
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeepClone_ShouldHandleStructsWithReferenceFields() {
|
||||
// Arrange
|
||||
var s = new Score {
|
||||
A = 7,
|
||||
People = new List<Person> { new Person { Name = "P1" } }
|
||||
};
|
||||
|
||||
// Act
|
||||
var sClone = s.DeepClone();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(7, sClone.A);
|
||||
Assert.NotSame(s.People, sClone.People);
|
||||
Assert.NotSame(s.People![0], sClone.People![0]);
|
||||
Assert.Equal("P1", sClone.People[0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeepClone_ShouldHandleArraysAndMultiDimensional() {
|
||||
// Arrange
|
||||
var arr = new[] { new Person { Name = "A" }, new Person { Name = "B" } };
|
||||
var md = (Person[,])Array.CreateInstance(typeof(Person), new[] { 1, 2 }, new[] { 1, 1 });
|
||||
md[1, 1] = arr[0];
|
||||
md[1, 2] = arr[1];
|
||||
|
||||
// Act
|
||||
var arrClone = arr.DeepClone();
|
||||
var mdClone = md.DeepClone();
|
||||
|
||||
// Assert
|
||||
Assert.NotSame(arr, arrClone);
|
||||
Assert.NotSame(arr[0], arrClone[0]);
|
||||
Assert.Equal("A", arrClone[0].Name);
|
||||
|
||||
Assert.NotSame(md, mdClone);
|
||||
Assert.Equal(md.GetLowerBound(0), mdClone.GetLowerBound(0));
|
||||
Assert.Equal(md.GetLowerBound(1), mdClone.GetLowerBound(1));
|
||||
Assert.NotSame(md[1, 1], mdClone[1, 1]);
|
||||
Assert.Equal("A", mdClone[1, 1].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeepClone_ShouldReturnSameReferenceForImmutable() {
|
||||
// Arrange
|
||||
var s = "hello";
|
||||
|
||||
// Act
|
||||
var s2 = s.DeepClone();
|
||||
|
||||
// Assert
|
||||
Assert.Same(s, s2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeepEqual_ShouldReturnTrue_ForEqualGraphs() {
|
||||
// Arrange
|
||||
var p1 = new Person { Name = "Alice", Age = 30, Addr = new Address { City = "Turin" } };
|
||||
var p2 = new Person { Name = "Alice", Age = 30, Addr = new Address { City = "Turin" } };
|
||||
|
||||
// Act
|
||||
var equal = p1.DeepEqual(p2);
|
||||
|
||||
// Assert
|
||||
Assert.True(equal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeepEqual_ShouldReturnFalse_WhenAnyFieldDiffers() {
|
||||
// Arrange
|
||||
var p1 = new Person { Name = "Alice", Age = 30, Addr = new Address { City = "Turin" } };
|
||||
var p2 = new Person { Name = "Alice", Age = 31, Addr = new Address { City = "Turin" } };
|
||||
|
||||
// Act
|
||||
var equal = p1.DeepEqual(p2);
|
||||
|
||||
// Assert
|
||||
Assert.False(equal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeepEqual_ShouldHandleCycles() {
|
||||
// Arrange
|
||||
var p1 = new Person { Name = "R", Addr = new Address { City = "X" } };
|
||||
p1.Addr!.Owner = p1;
|
||||
var p2 = new Person { Name = "R", Addr = new Address { City = "X" } };
|
||||
p2.Addr!.Owner = p2;
|
||||
|
||||
// Act
|
||||
var equal = p1.DeepEqual(p2);
|
||||
|
||||
// Assert
|
||||
Assert.True(equal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RevertFrom_ShouldCopyStateBackOntoExistingInstance() {
|
||||
// Arrange
|
||||
var original = new Person { Name = "Alice", Age = 20, Addr = new Address { City = "Parma" } };
|
||||
var snapshot = original.DeepClone();
|
||||
|
||||
// Mutate original
|
||||
original.Name = "Changed";
|
||||
original.Age = 99;
|
||||
original.Addr!.City = "ChangedCity";
|
||||
original.SetSecret("changed-secret");
|
||||
|
||||
// Act
|
||||
original.RevertFrom(snapshot);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Alice", original.Name);
|
||||
Assert.Equal(20, original.Age);
|
||||
Assert.Equal("Parma", original.Addr!.City);
|
||||
Assert.Equal("xyz", original.Secret);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeepEqual_NullsAndTypeMismatch_ShouldBehaveCorrectly() {
|
||||
// Arrange
|
||||
Person? a = null;
|
||||
Person? b = null;
|
||||
var c = new Person();
|
||||
var d = new TestObject { Name = "x", Age = 1 };
|
||||
|
||||
// Act / Assert
|
||||
Assert.True(a.DeepEqual(b));
|
||||
Assert.False(a.DeepEqual(c));
|
||||
// Different runtime types must be false
|
||||
Assert.False(c.DeepEqual((object)d));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -30,9 +30,8 @@ public static class LoggerHelper
|
||||
|
||||
serviceCollection.AddLogging(builder =>
|
||||
{
|
||||
var env = serviceCollection.BuildServiceProvider().GetRequiredService<IHostEnvironment>();
|
||||
builder.ClearProviders();
|
||||
builder.AddConsole(env);
|
||||
builder.AddConsoleLogger();
|
||||
});
|
||||
|
||||
var provider = serviceCollection.BuildServiceProvider();
|
||||
|
||||
@ -28,7 +28,7 @@ public class FileLoggerTests {
|
||||
ContentRootPath = Directory.GetCurrentDirectory()
|
||||
});
|
||||
|
||||
serviceCollection.AddLogging(builder => builder.AddFile(_testFolderPath, TimeSpan.FromDays(7)));
|
||||
serviceCollection.AddLogging(builder => builder.AddFileLogger(_testFolderPath, TimeSpan.FromDays(7)));
|
||||
|
||||
var provider = serviceCollection.BuildServiceProvider();
|
||||
var logger = provider.GetRequiredService<ILogger<FileLoggerTests>>();
|
||||
@ -55,7 +55,7 @@ public class FileLoggerTests {
|
||||
ContentRootPath = Directory.GetCurrentDirectory()
|
||||
});
|
||||
|
||||
serviceCollection.AddLogging(builder => builder.AddFile(_testFolderPath, retentionPeriod));
|
||||
serviceCollection.AddLogging(builder => builder.AddFileLogger(_testFolderPath, retentionPeriod));
|
||||
|
||||
var provider = serviceCollection.BuildServiceProvider();
|
||||
var logger = provider.GetRequiredService<ILogger<FileLoggerTests>>();
|
||||
@ -86,7 +86,7 @@ public class FileLoggerTests {
|
||||
ContentRootPath = Directory.GetCurrentDirectory()
|
||||
});
|
||||
|
||||
serviceCollection.AddLogging(builder => builder.AddFile(_testFolderPath));
|
||||
serviceCollection.AddLogging(builder => builder.AddFileLogger(_testFolderPath));
|
||||
|
||||
var provider = serviceCollection.BuildServiceProvider();
|
||||
var logger = provider.GetRequiredService<ILogger<FileLoggerTests>>();
|
||||
|
||||
143
src/MaksIT.Core.Tests/Logging/JsonFileLoggerTests.cs
Normal file
143
src/MaksIT.Core.Tests/Logging/JsonFileLoggerTests.cs
Normal file
@ -0,0 +1,143 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MaksIT.Core.Logging;
|
||||
|
||||
|
||||
namespace MaksIT.Core.Tests.Logging;
|
||||
|
||||
public class JsonFileLoggerTests {
|
||||
private readonly string _testFolderPath;
|
||||
|
||||
public JsonFileLoggerTests() {
|
||||
_testFolderPath = Path.Combine(Path.GetTempPath(), "JsonFileLoggerTests");
|
||||
if (Directory.Exists(_testFolderPath)) {
|
||||
Directory.Delete(_testFolderPath, true);
|
||||
}
|
||||
Directory.CreateDirectory(_testFolderPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShouldWriteLogsInJsonFormat() {
|
||||
// Arrange
|
||||
var serviceCollection = new ServiceCollection();
|
||||
serviceCollection.AddSingleton<IHostEnvironment>(sp =>
|
||||
new TestHostEnvironment {
|
||||
EnvironmentName = Environments.Development,
|
||||
ApplicationName = "TestApp",
|
||||
ContentRootPath = Directory.GetCurrentDirectory()
|
||||
});
|
||||
|
||||
serviceCollection.AddLogging(builder => builder.AddJsonFileLogger(_testFolderPath, TimeSpan.FromDays(7)));
|
||||
|
||||
var provider = serviceCollection.BuildServiceProvider();
|
||||
var logger = provider.GetRequiredService<ILogger<JsonFileLoggerTests>>();
|
||||
|
||||
// Act
|
||||
logger.LogInformation("Test JSON log message");
|
||||
|
||||
// Assert
|
||||
var logFile = Directory.GetFiles(_testFolderPath, "log_*.json").FirstOrDefault();
|
||||
Assert.NotNull(logFile);
|
||||
var logContent = File.ReadAllText(logFile);
|
||||
Assert.Contains("Test JSON log message", logContent);
|
||||
|
||||
var logEntry = JsonSerializer.Deserialize<JsonElement>(logContent.TrimEnd(','));
|
||||
Assert.Equal("Information", logEntry.GetProperty("LogLevel").GetString());
|
||||
Assert.Equal("Test JSON log message", logEntry.GetProperty("Message").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShouldDeleteOldJsonLogsBasedOnRetention() {
|
||||
// Arrange
|
||||
var retentionPeriod = TimeSpan.FromDays(1);
|
||||
var serviceCollection = new ServiceCollection();
|
||||
serviceCollection.AddSingleton<IHostEnvironment>(sp =>
|
||||
new TestHostEnvironment {
|
||||
EnvironmentName = Environments.Development,
|
||||
ApplicationName = "TestApp",
|
||||
ContentRootPath = Directory.GetCurrentDirectory()
|
||||
});
|
||||
|
||||
serviceCollection.AddLogging(builder => builder.AddJsonFileLogger(_testFolderPath, retentionPeriod));
|
||||
|
||||
var provider = serviceCollection.BuildServiceProvider();
|
||||
var logger = provider.GetRequiredService<ILogger<JsonFileLoggerTests>>();
|
||||
|
||||
// Create an old log file
|
||||
var oldLogFile = Path.Combine(_testFolderPath, $"log_{DateTime.Now.AddDays(-2):yyyy-MM-dd}.json");
|
||||
File.WriteAllText(oldLogFile, "{\"Message\":\"Old log\"}");
|
||||
|
||||
// Act
|
||||
logger.LogInformation("New JSON log message");
|
||||
|
||||
// Assert
|
||||
Assert.False(File.Exists(oldLogFile), "Old JSON log file should have been deleted.");
|
||||
var logFile = Directory.GetFiles(_testFolderPath, "log_*.json").FirstOrDefault();
|
||||
Assert.NotNull(logFile);
|
||||
var logContent = File.ReadAllText(logFile);
|
||||
Assert.Contains("New JSON log message", logContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShouldLogExceptionsInJsonFormat() {
|
||||
// Arrange
|
||||
var serviceCollection = new ServiceCollection();
|
||||
serviceCollection.AddSingleton<IHostEnvironment>(sp =>
|
||||
new TestHostEnvironment {
|
||||
EnvironmentName = Environments.Development,
|
||||
ApplicationName = "TestApp",
|
||||
ContentRootPath = Directory.GetCurrentDirectory()
|
||||
});
|
||||
|
||||
serviceCollection.AddLogging(builder => builder.AddJsonFileLogger(_testFolderPath, TimeSpan.FromDays(7)));
|
||||
|
||||
var provider = serviceCollection.BuildServiceProvider();
|
||||
var logger = provider.GetRequiredService<ILogger<JsonFileLoggerTests>>();
|
||||
|
||||
// Act
|
||||
logger.LogError(new InvalidOperationException("Test exception"), "An error occurred");
|
||||
|
||||
// Assert
|
||||
var logFile = Directory.GetFiles(_testFolderPath, "log_*.json").FirstOrDefault();
|
||||
Assert.NotNull(logFile);
|
||||
var logContent = File.ReadAllText(logFile);
|
||||
Assert.Contains("An error occurred", logContent);
|
||||
Assert.Contains("Test exception", logContent);
|
||||
|
||||
var logEntry = JsonSerializer.Deserialize<JsonElement>(logContent.TrimEnd(','));
|
||||
Assert.Equal("Error", logEntry.GetProperty("LogLevel").GetString());
|
||||
Assert.Equal("An error occurred", logEntry.GetProperty("Message").GetString());
|
||||
Assert.Contains("Test exception", logEntry.GetProperty("Exception").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShouldWorkWithConsoleLogger() {
|
||||
// Arrange
|
||||
var serviceCollection = new ServiceCollection();
|
||||
serviceCollection.AddSingleton<IHostEnvironment>(sp =>
|
||||
new TestHostEnvironment {
|
||||
EnvironmentName = Environments.Development,
|
||||
ApplicationName = "TestApp",
|
||||
ContentRootPath = Directory.GetCurrentDirectory()
|
||||
});
|
||||
|
||||
serviceCollection.AddLogging(builder => {
|
||||
builder.AddJsonFileLogger(_testFolderPath, TimeSpan.FromDays(7));
|
||||
builder.AddSimpleConsoleLogger();
|
||||
});
|
||||
|
||||
var provider = serviceCollection.BuildServiceProvider();
|
||||
var logger = provider.GetRequiredService<ILogger<JsonFileLoggerTests>>();
|
||||
|
||||
// Act
|
||||
logger.LogInformation("Test combined logging");
|
||||
|
||||
// Assert
|
||||
var logFile = Directory.GetFiles(_testFolderPath, "log_*.json").FirstOrDefault();
|
||||
Assert.NotNull(logFile);
|
||||
var logContent = File.ReadAllText(logFile);
|
||||
Assert.Contains("Test combined logging", logContent);
|
||||
}
|
||||
}
|
||||
105
src/MaksIT.Core.Tests/Threading/LockManagerTests.cs
Normal file
105
src/MaksIT.Core.Tests/Threading/LockManagerTests.cs
Normal file
@ -0,0 +1,105 @@
|
||||
using System.Diagnostics;
|
||||
using MaksIT.Core.Threading;
|
||||
|
||||
namespace MaksIT.Core.Tests.Threading;
|
||||
|
||||
public class LockManagerTests {
|
||||
[Fact]
|
||||
public async Task ShouldEnsureThreadSafety() {
|
||||
// Arrange
|
||||
var lockManager = new LockManager();
|
||||
int counter = 0;
|
||||
|
||||
// Act
|
||||
var tasks = Enumerable.Range(0, 10).Select(_ => lockManager.ExecuteWithLockAsync(async () => {
|
||||
int temp = counter;
|
||||
await Task.Delay(10); // Simulate work
|
||||
counter = temp + 1;
|
||||
}));
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(10, counter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ShouldEnforceRateLimiting() {
|
||||
// Arrange
|
||||
var lockManager = new LockManager();
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
|
||||
// Act
|
||||
var tasks = Enumerable.Range(0, 10).Select(_ => lockManager.ExecuteWithLockAsync(async () => {
|
||||
await Task.Delay(10); // Simulate work
|
||||
}));
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
stopwatch.Stop();
|
||||
|
||||
// With 1 token and 200ms replenishment:
|
||||
// first task starts immediately, remaining 9 wait ~9 * 200ms = ~1800ms + overhead.
|
||||
// Allow some jitter on CI.
|
||||
Assert.InRange(stopwatch.ElapsedMilliseconds, 1700, 6000);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ShouldAllowReentrantLocks() {
|
||||
// Arrange
|
||||
var lockManager = new LockManager();
|
||||
int counter = 0;
|
||||
|
||||
// Act
|
||||
await lockManager.ExecuteWithLockAsync(async () => {
|
||||
await lockManager.ExecuteWithLockAsync(() => {
|
||||
counter++;
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.Equal(1, counter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ShouldHandleExceptionsGracefully() {
|
||||
// Arrange
|
||||
var lockManager = new LockManager();
|
||||
int counter = 0;
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(async () => {
|
||||
await lockManager.ExecuteWithLockAsync(async () => {
|
||||
counter++;
|
||||
throw new InvalidOperationException("Test exception");
|
||||
});
|
||||
});
|
||||
|
||||
// Ensure lock is not in an inconsistent state
|
||||
await lockManager.ExecuteWithLockAsync(() => {
|
||||
counter++;
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
Assert.Equal(2, counter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ShouldSupportConcurrentAccess() {
|
||||
// Arrange
|
||||
var lockManager = new LockManager();
|
||||
int counter = 0;
|
||||
|
||||
// Act
|
||||
var tasks = Enumerable.Range(0, 100).Select(_ => lockManager.ExecuteWithLockAsync(async () => {
|
||||
int temp = counter;
|
||||
await Task.Delay(1); // Simulate work
|
||||
counter = temp + 1;
|
||||
}));
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(100, counter);
|
||||
}
|
||||
}
|
||||
@ -1,7 +1,11 @@
|
||||
using System.Text.Json;
|
||||
using System.Reflection;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.Serialization;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace MaksIT.Core.Extensions;
|
||||
|
||||
namespace MaksIT.Core.Extensions;
|
||||
|
||||
public static class ObjectExtensions {
|
||||
|
||||
@ -33,4 +37,232 @@ public static class ObjectExtensions {
|
||||
|
||||
return JsonSerializer.Serialize(obj, options);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a deep clone of the object, preserving reference identity and supporting cycles.
|
||||
/// </summary>
|
||||
public static T DeepClone<T>(this T source) {
|
||||
return (T)DeepCloneInternal(source, new Dictionary<object, object>(ReferenceEqualityComparer.Instance));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deeply compares two objects for structural equality (fields, including private ones).
|
||||
/// </summary>
|
||||
public static bool DeepEqual<T>(this T a, T b) {
|
||||
return DeepEqualInternal(a, b, new HashSet<(object, object)>(ReferencePairComparer.Instance));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Copies all fields from the snapshot into the current target object (useful with tracked entities).
|
||||
/// </summary>
|
||||
public static void RevertFrom<T>(this T target, T snapshot) {
|
||||
if (ReferenceEquals(target, snapshot) || target == null || snapshot == null) return;
|
||||
var visited = new Dictionary<object, object>(ReferenceEqualityComparer.Instance);
|
||||
CopyAllFields(snapshot!, target!, snapshot!.GetType(), visited);
|
||||
}
|
||||
|
||||
#region Internal Cloner
|
||||
|
||||
private static object DeepCloneInternal(object source, Dictionary<object, object> visited) {
|
||||
if (source == null) return null!;
|
||||
|
||||
var type = source.GetType();
|
||||
|
||||
// Fast-path for immutable/primitive-ish types
|
||||
if (IsImmutable(type)) return source;
|
||||
|
||||
// Already cloned?
|
||||
if (!type.IsValueType && visited.TryGetValue(source, out var existing))
|
||||
return existing;
|
||||
|
||||
// Arrays
|
||||
if (type.IsArray)
|
||||
return CloneArray((Array)source, visited);
|
||||
|
||||
// Value types (structs): shallow copy via boxing + clone ref-type fields
|
||||
if (type.IsValueType)
|
||||
return CloneStruct(source, type, visited);
|
||||
|
||||
// Reference type: allocate uninitialized object, then copy fields
|
||||
var clone = FormatterServices.GetUninitializedObject(type);
|
||||
visited[source] = clone;
|
||||
CopyAllFields(source, clone, type, visited);
|
||||
return clone;
|
||||
}
|
||||
|
||||
private static bool IsImmutable(Type t) {
|
||||
if (t.IsPrimitive || t.IsEnum) return true;
|
||||
|
||||
// Common immutable BCL types
|
||||
if (t == typeof(string) ||
|
||||
t == typeof(decimal) ||
|
||||
t == typeof(DateTime) ||
|
||||
t == typeof(DateTimeOffset) ||
|
||||
t == typeof(TimeSpan) ||
|
||||
t == typeof(Guid) ||
|
||||
t == typeof(Uri))
|
||||
return true;
|
||||
|
||||
// Nullable<T> of immutable underlying
|
||||
if (Nullable.GetUnderlyingType(t) is Type nt)
|
||||
return IsImmutable(nt);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static Array CloneArray(Array source, Dictionary<object, object> visited) {
|
||||
var elemType = source.GetType().GetElementType()!;
|
||||
var rank = source.Rank;
|
||||
|
||||
var lengths = new int[rank];
|
||||
var lowers = new int[rank];
|
||||
for (int d = 0; d < rank; d++) {
|
||||
lengths[d] = source.GetLength(d);
|
||||
lowers[d] = source.GetLowerBound(d);
|
||||
}
|
||||
|
||||
var clone = Array.CreateInstance(elemType, lengths, lowers);
|
||||
visited[source] = clone;
|
||||
|
||||
var indices = new int[rank];
|
||||
CopyArrayRecursive(source, clone, 0, indices, lowers, lengths, visited);
|
||||
return clone;
|
||||
}
|
||||
|
||||
private static void CopyArrayRecursive(
|
||||
Array source,
|
||||
Array target,
|
||||
int dim,
|
||||
int[] indices,
|
||||
int[] lowers,
|
||||
int[] lengths,
|
||||
Dictionary<object, object> visited) {
|
||||
if (dim == source.Rank) {
|
||||
var value = source.GetValue(indices);
|
||||
var cloned = DeepCloneInternal(value!, visited);
|
||||
target.SetValue(cloned, indices);
|
||||
return;
|
||||
}
|
||||
|
||||
int start = lowers[dim];
|
||||
int end = lowers[dim] + lengths[dim];
|
||||
for (int i = start; i < end; i++) {
|
||||
indices[dim] = i;
|
||||
CopyArrayRecursive(source, target, dim + 1, indices, lowers, lengths, visited);
|
||||
}
|
||||
}
|
||||
|
||||
private static object CloneStruct(object source, Type type, Dictionary<object, object> visited) {
|
||||
// Boxed copy is already a shallow copy of the struct
|
||||
object boxed = source;
|
||||
CopyAllFields(boxed, boxed, type, visited, skipVisitedRegistration: true);
|
||||
return boxed;
|
||||
}
|
||||
|
||||
private static void CopyAllFields(object source, object target, Type type, Dictionary<object, object> visited, bool skipVisitedRegistration = false) {
|
||||
for (Type t = type; t != null; t = t.BaseType!) {
|
||||
var fields = t.GetFields(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.DeclaredOnly);
|
||||
foreach (var f in fields) {
|
||||
var value = f.GetValue(source);
|
||||
var cloned = DeepCloneInternal(value!, visited);
|
||||
f.SetValue(target, cloned);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Internal Deep-Equal
|
||||
|
||||
private static bool DeepEqualInternal(object a, object b, HashSet<(object, object)> visited) {
|
||||
if (ReferenceEquals(a, b))
|
||||
return true;
|
||||
if (a == null || b == null)
|
||||
return false;
|
||||
|
||||
var type = a.GetType();
|
||||
if (type != b.GetType())
|
||||
return false;
|
||||
|
||||
// Fast path for immutables
|
||||
if (IsImmutable(type))
|
||||
return a.Equals(b);
|
||||
|
||||
// Prevent infinite recursion on cycles
|
||||
var pair = (a, b);
|
||||
if (visited.Contains(pair))
|
||||
return true;
|
||||
visited.Add(pair);
|
||||
|
||||
// Arrays
|
||||
if (type.IsArray)
|
||||
return ArraysEqual((Array)a, (Array)b, visited);
|
||||
|
||||
// Value or reference types: compare fields recursively
|
||||
return FieldsEqual(a, b, type, visited);
|
||||
}
|
||||
|
||||
private static bool ArraysEqual(Array a, Array b, HashSet<(object, object)> visited) {
|
||||
if (a.Rank != b.Rank) return false;
|
||||
for (int d = 0; d < a.Rank; d++) {
|
||||
if (a.GetLength(d) != b.GetLength(d) || a.GetLowerBound(d) != b.GetLowerBound(d))
|
||||
return false;
|
||||
}
|
||||
|
||||
var indices = new int[a.Rank];
|
||||
return CompareArrayRecursive(a, b, 0, indices, visited);
|
||||
}
|
||||
|
||||
private static bool CompareArrayRecursive(Array a, Array b, int dim, int[] indices, HashSet<(object, object)> visited) {
|
||||
if (dim == a.Rank) {
|
||||
var va = a.GetValue(indices);
|
||||
var vb = b.GetValue(indices);
|
||||
return DeepEqualInternal(va!, vb!, visited);
|
||||
}
|
||||
|
||||
int start = a.GetLowerBound(dim);
|
||||
int end = start + a.GetLength(dim);
|
||||
for (int i = start; i < end; i++) {
|
||||
indices[dim] = i;
|
||||
if (!CompareArrayRecursive(a, b, dim + 1, indices, visited))
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool FieldsEqual(object a, object b, Type type, HashSet<(object, object)> visited) {
|
||||
for (Type t = type; t != null; t = t.BaseType!) {
|
||||
var fields = t.GetFields(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.DeclaredOnly);
|
||||
foreach (var f in fields) {
|
||||
var va = f.GetValue(a);
|
||||
var vb = f.GetValue(b);
|
||||
if (!DeepEqualInternal(va!, vb!, visited))
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
|
||||
private sealed class ReferenceEqualityComparer : IEqualityComparer<object> {
|
||||
public static readonly ReferenceEqualityComparer Instance = new ReferenceEqualityComparer();
|
||||
public new bool Equals(object x, object y) => ReferenceEquals(x, y);
|
||||
public int GetHashCode(object obj) => RuntimeHelpers.GetHashCode(obj);
|
||||
}
|
||||
|
||||
private sealed class ReferencePairComparer : IEqualityComparer<(object, object)> {
|
||||
public static readonly ReferencePairComparer Instance = new ReferencePairComparer();
|
||||
public bool Equals((object, object) x, (object, object) y)
|
||||
=> ReferenceEquals(x.Item1, y.Item1) && ReferenceEquals(x.Item2, y.Item2);
|
||||
public int GetHashCode((object, object) obj) {
|
||||
unchecked {
|
||||
return (RuntimeHelpers.GetHashCode(obj.Item1) * 397) ^ RuntimeHelpers.GetHashCode(obj.Item2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
57
src/MaksIT.Core/Logging/BaseFileLogger.cs
Normal file
57
src/MaksIT.Core/Logging/BaseFileLogger.cs
Normal file
@ -0,0 +1,57 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using MaksIT.Core.Threading;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace MaksIT.Core.Logging;
|
||||
|
||||
public abstract class BaseFileLogger : ILogger, IDisposable {
|
||||
private readonly LockManager _lockManager = new LockManager();
|
||||
private readonly string _folderPath;
|
||||
private readonly TimeSpan _retentionPeriod;
|
||||
|
||||
protected BaseFileLogger(string folderPath, TimeSpan retentionPeriod) {
|
||||
_folderPath = folderPath;
|
||||
_retentionPeriod = retentionPeriod;
|
||||
Directory.CreateDirectory(_folderPath); // Ensure the folder exists
|
||||
}
|
||||
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||
|
||||
public bool IsEnabled(LogLevel logLevel) {
|
||||
return logLevel != LogLevel.None;
|
||||
}
|
||||
|
||||
public abstract void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter);
|
||||
|
||||
protected string GenerateLogFileName(string extension) {
|
||||
return Path.Combine(_folderPath, $"log_{DateTime.UtcNow:yyyy-MM-dd}.{extension}");
|
||||
}
|
||||
|
||||
protected void AppendToLogFile(string logFileName, string content) {
|
||||
_lockManager.ExecuteWithLockAsync(async () => {
|
||||
await File.AppendAllTextAsync(logFileName, content);
|
||||
RemoveExpiredLogFiles(Path.GetExtension(logFileName));
|
||||
}).Wait();
|
||||
}
|
||||
|
||||
private void RemoveExpiredLogFiles(string extension) {
|
||||
var filePattern = $"log_*.{extension.TrimStart('.')}";
|
||||
|
||||
var logFiles = Directory.GetFiles(_folderPath, filePattern);
|
||||
var expirationDate = DateTime.UtcNow - _retentionPeriod;
|
||||
|
||||
foreach (var logFile in logFiles) {
|
||||
var fileName = Path.GetFileNameWithoutExtension(logFile);
|
||||
if (DateTime.TryParseExact(fileName.Substring(4), "yyyy-MM-dd", null, System.Globalization.DateTimeStyles.None, out var logDate)) {
|
||||
if (logDate < expirationDate) {
|
||||
File.Delete(logFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() {
|
||||
_lockManager.Dispose();
|
||||
}
|
||||
}
|
||||
@ -1,26 +1,11 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.IO;
|
||||
|
||||
namespace MaksIT.Core.Logging;
|
||||
|
||||
public class FileLogger : ILogger {
|
||||
private readonly string _folderPath;
|
||||
private readonly object _lock = new object();
|
||||
private readonly TimeSpan _retentionPeriod;
|
||||
public class FileLogger : BaseFileLogger {
|
||||
public FileLogger(string folderPath, TimeSpan retentionPeriod) : base(folderPath, retentionPeriod) { }
|
||||
|
||||
public FileLogger(string folderPath, TimeSpan retentionPeriod) {
|
||||
_folderPath = folderPath;
|
||||
_retentionPeriod = retentionPeriod;
|
||||
Directory.CreateDirectory(_folderPath); // Ensure the folder exists
|
||||
}
|
||||
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||
|
||||
public bool IsEnabled(LogLevel logLevel) {
|
||||
return logLevel != LogLevel.None;
|
||||
}
|
||||
|
||||
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter) {
|
||||
public override void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter) {
|
||||
if (!IsEnabled(logLevel))
|
||||
return;
|
||||
|
||||
@ -28,30 +13,12 @@ public class FileLogger : ILogger {
|
||||
if (string.IsNullOrEmpty(message))
|
||||
return;
|
||||
|
||||
var logRecord = $"{DateTime.Now:yyyy-MM-dd HH:mm:ss} [{logLevel}] {message}";
|
||||
var logRecord = $"{DateTime.UtcNow.ToString("o")} [{logLevel}] {message}";
|
||||
if (exception != null) {
|
||||
logRecord += Environment.NewLine + exception;
|
||||
}
|
||||
|
||||
var logFileName = Path.Combine(_folderPath, $"log_{DateTime.Now:yyyy-MM-dd}.txt"); // Generate log file name by date
|
||||
|
||||
lock (_lock) {
|
||||
File.AppendAllText(logFileName, logRecord + Environment.NewLine);
|
||||
CleanUpOldLogs();
|
||||
}
|
||||
}
|
||||
|
||||
private void CleanUpOldLogs() {
|
||||
var logFiles = Directory.GetFiles(_folderPath, "log_*.txt");
|
||||
var expirationDate = DateTime.Now - _retentionPeriod;
|
||||
|
||||
foreach (var logFile in logFiles) {
|
||||
var fileName = Path.GetFileNameWithoutExtension(logFile);
|
||||
if (DateTime.TryParseExact(fileName.Substring(4), "yyyy-MM-dd", null, System.Globalization.DateTimeStyles.None, out var logDate)) {
|
||||
if (logDate < expirationDate) {
|
||||
File.Delete(logFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
var logFileName = GenerateLogFileName("txt");
|
||||
AppendToLogFile(logFileName, logRecord + Environment.NewLine);
|
||||
}
|
||||
}
|
||||
|
||||
23
src/MaksIT.Core/Logging/JsonFileLogger.cs
Normal file
23
src/MaksIT.Core/Logging/JsonFileLogger.cs
Normal file
@ -0,0 +1,23 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace MaksIT.Core.Logging;
|
||||
|
||||
public class JsonFileLogger : BaseFileLogger {
|
||||
public JsonFileLogger(string folderPath, TimeSpan retentionPeriod) : base(folderPath, retentionPeriod) { }
|
||||
|
||||
public override void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter) {
|
||||
if (!IsEnabled(logLevel))
|
||||
return;
|
||||
|
||||
var logEntry = new {
|
||||
Timestamp = DateTime.UtcNow.ToString("o"),
|
||||
LogLevel = logLevel.ToString(),
|
||||
Message = formatter(state, exception),
|
||||
Exception = exception?.ToString()
|
||||
};
|
||||
|
||||
var logFileName = GenerateLogFileName("json");
|
||||
AppendToLogFile(logFileName, JsonSerializer.Serialize(logEntry) + Environment.NewLine);
|
||||
}
|
||||
}
|
||||
20
src/MaksIT.Core/Logging/JsonFileLoggerProvider.cs
Normal file
20
src/MaksIT.Core/Logging/JsonFileLoggerProvider.cs
Normal file
@ -0,0 +1,20 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace MaksIT.Core.Logging;
|
||||
|
||||
[ProviderAlias("JsonFileLogger")]
|
||||
public class JsonFileLoggerProvider : ILoggerProvider {
|
||||
private readonly string _folderPath;
|
||||
private readonly TimeSpan _retentionPeriod;
|
||||
|
||||
public JsonFileLoggerProvider(string folderPath, TimeSpan? retentionPeriod = null) {
|
||||
_folderPath = folderPath ?? throw new ArgumentNullException(nameof(folderPath));
|
||||
_retentionPeriod = retentionPeriod ?? TimeSpan.FromDays(7); // Default retention period is 7 days
|
||||
}
|
||||
|
||||
public ILogger CreateLogger(string categoryName) {
|
||||
return new JsonFileLogger(_folderPath, _retentionPeriod);
|
||||
}
|
||||
|
||||
public void Dispose() { }
|
||||
}
|
||||
@ -5,25 +5,48 @@ using Microsoft.Extensions.Hosting;
|
||||
namespace MaksIT.Core.Logging;
|
||||
|
||||
public static class LoggingBuilderExtensions {
|
||||
public static ILoggingBuilder AddFile(this ILoggingBuilder builder, string folderPath, TimeSpan? retentionPeriod = null) {
|
||||
builder.Services.AddSingleton<ILoggerProvider>(new FileLoggerProvider(folderPath, retentionPeriod));
|
||||
return builder;
|
||||
public static ILoggingBuilder AddFileLogger(this ILoggingBuilder logging, string folderPath, TimeSpan? retentionPeriod = null) {
|
||||
logging.Services.AddSingleton<ILoggerProvider>(new FileLoggerProvider(folderPath, retentionPeriod));
|
||||
return logging;
|
||||
}
|
||||
public static ILoggingBuilder AddConsole(this ILoggingBuilder logging, IHostEnvironment env) {
|
||||
|
||||
public static ILoggingBuilder AddJsonFileLogger(this ILoggingBuilder logging, string folderPath, TimeSpan? retentionPeriod = null) {
|
||||
logging.Services.AddSingleton<ILoggerProvider>(new JsonFileLoggerProvider(folderPath, retentionPeriod));
|
||||
return logging;
|
||||
}
|
||||
|
||||
public static ILoggingBuilder AddSimpleConsoleLogger(this ILoggingBuilder logging) {
|
||||
logging.AddSimpleConsole(options => {
|
||||
options.IncludeScopes = true;
|
||||
options.SingleLine = false;
|
||||
options.TimestampFormat = "yyyy-MM-ddTHH:mm:ss.fffZ";
|
||||
});
|
||||
return logging;
|
||||
}
|
||||
|
||||
public static ILoggingBuilder AddJsonConsoleLogger(this ILoggingBuilder logging) {
|
||||
logging.AddJsonConsole(options => {
|
||||
options.IncludeScopes = true;
|
||||
options.TimestampFormat = "yyyy-MM-ddTHH:mm:ss.fffZ";
|
||||
});
|
||||
return logging;
|
||||
}
|
||||
|
||||
public static ILoggingBuilder AddConsoleLogger(this ILoggingBuilder logging, string? fileLoggerPath = null) {
|
||||
logging.ClearProviders();
|
||||
if (env.IsDevelopment()) {
|
||||
logging.AddSimpleConsole(options => {
|
||||
options.IncludeScopes = true;
|
||||
options.SingleLine = false;
|
||||
options.TimestampFormat = "hh:mm:ss ";
|
||||
});
|
||||
}
|
||||
else {
|
||||
logging.AddJsonConsole(options => {
|
||||
options.IncludeScopes = true;
|
||||
options.TimestampFormat = "yyyy-MM-ddTHH:mm:ss.fffZ";
|
||||
});
|
||||
}
|
||||
logging.AddSimpleConsoleLogger();
|
||||
|
||||
if (fileLoggerPath != null)
|
||||
logging.AddFileLogger(fileLoggerPath);
|
||||
|
||||
return logging;
|
||||
}
|
||||
|
||||
public static ILoggingBuilder AddJsonConsoleLogger(this ILoggingBuilder logging, string? fileLoggerPath = null) {
|
||||
logging.ClearProviders();
|
||||
logging.AddJsonConsoleLogger();
|
||||
if (fileLoggerPath != null)
|
||||
logging.AddJsonFileLogger(fileLoggerPath);
|
||||
return logging;
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
|
||||
<!-- NuGet package metadata -->
|
||||
<PackageId>MaksIT.Core</PackageId>
|
||||
<Version>1.5.0</Version>
|
||||
<Version>1.5.1</Version>
|
||||
<Authors>Maksym Sadovnychyy</Authors>
|
||||
<Company>MAKS-IT</Company>
|
||||
<Product>MaksIT.Core</Product>
|
||||
@ -35,5 +35,6 @@
|
||||
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.14.0" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.14.0" />
|
||||
<PackageReference Include="System.Linq.Dynamic.Core" Version="1.6.9" />
|
||||
<PackageReference Include="System.Threading.RateLimiting" Version="9.0.10" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
74
src/MaksIT.Core/Threading/LockManager.cs
Normal file
74
src/MaksIT.Core/Threading/LockManager.cs
Normal file
@ -0,0 +1,74 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Threading.RateLimiting;
|
||||
|
||||
namespace MaksIT.Core.Threading;
|
||||
|
||||
public class LockManager : IDisposable {
|
||||
private readonly SemaphoreSlim _semaphore = new SemaphoreSlim(1, 1);
|
||||
|
||||
// Use AsyncLocal to track reentrancy in the same async flow
|
||||
private static readonly AsyncLocal<int> _reentrancyDepth = new AsyncLocal<int>();
|
||||
|
||||
// Strict limiter: allow 1 token, replenish 1 every 200ms
|
||||
private readonly TokenBucketRateLimiter _rateLimiter = new TokenBucketRateLimiter(new TokenBucketRateLimiterOptions {
|
||||
TokenLimit = 1, // Single concurrent entry
|
||||
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
|
||||
QueueLimit = 1_000,
|
||||
ReplenishmentPeriod = TimeSpan.FromMilliseconds(200), // 1 token every 200ms
|
||||
TokensPerPeriod = 1,
|
||||
AutoReplenishment = true
|
||||
});
|
||||
|
||||
public async Task<T> ExecuteWithLockAsync<T>(Func<Task<T>> action) {
|
||||
var lease = await _rateLimiter.AcquireAsync(1);
|
||||
if (!lease.IsAcquired) throw new InvalidOperationException("Rate limit exceeded");
|
||||
|
||||
// Determine if this is the first entry for the current async flow
|
||||
bool isFirstEntry = false;
|
||||
if (_reentrancyDepth.Value == 0) {
|
||||
isFirstEntry = true;
|
||||
_reentrancyDepth.Value = 1;
|
||||
}
|
||||
else {
|
||||
_reentrancyDepth.Value = _reentrancyDepth.Value + 1;
|
||||
}
|
||||
|
||||
if (isFirstEntry) await _semaphore.WaitAsync();
|
||||
try {
|
||||
return await action();
|
||||
}
|
||||
finally {
|
||||
// Decrement reentrancy; release semaphore only when depth reaches zero
|
||||
var newDepth = _reentrancyDepth.Value - 1;
|
||||
_reentrancyDepth.Value = newDepth < 0 ? 0 : newDepth;
|
||||
|
||||
if (isFirstEntry) _semaphore.Release();
|
||||
|
||||
// Dispose the lease to complete the rate-limited window
|
||||
lease.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task ExecuteWithLockAsync(Func<Task> action) {
|
||||
await ExecuteWithLockAsync(async () => {
|
||||
await action();
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<T> ExecuteWithLockAsync<T>(Func<T> action) {
|
||||
return await ExecuteWithLockAsync(() => Task.FromResult(action()));
|
||||
}
|
||||
|
||||
public async Task ExecuteWithLockAsync(Action action) {
|
||||
await ExecuteWithLockAsync(() => {
|
||||
action();
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
}
|
||||
|
||||
public void Dispose() {
|
||||
_semaphore.Dispose();
|
||||
_rateLimiter.Dispose();
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user