diff --git a/README.md b/README.md index 5bfa2c8c..3b420dd6 100644 --- a/README.md +++ b/README.md @@ -70,9 +70,13 @@ block until cancellation for long-running hosts. |---------|-------------|-------| | `SquidStd.Core` | Foundational contracts & utilities (config, event bus, jobs, metrics, serialization, YAML/JSON, Serilog sink). | [![readme](https://img.shields.io/badge/readme-1390A3.svg)](src/SquidStd.Core/README.md) · [![NuGet](https://img.shields.io/nuget/v/SquidStd.Core.svg)](https://www.nuget.org/packages/SquidStd.Core/) | | `SquidStd.Abstractions` | DI registration plumbing (`ISquidStdService`, `RegisterStdService`, `RegisterConfigSection`). | [![readme](https://img.shields.io/badge/readme-1390A3.svg)](src/SquidStd.Abstractions/README.md) · [![NuGet](https://img.shields.io/nuget/v/SquidStd.Abstractions.svg)](https://www.nuget.org/packages/SquidStd.Abstractions/) | +| `SquidStd.Generators` | Roslyn source generators for event listener, service, config, worker, and Lua registration helpers. | [![readme](https://img.shields.io/badge/readme-1390A3.svg)](src/SquidStd.Generators/README.md) · [![NuGet](https://img.shields.io/nuget/v/SquidStd.Generators.svg)](https://www.nuget.org/packages/SquidStd.Generators/) | | `SquidStd.Services.Core` | Concrete services: config, event bus, jobs, timer/cron scheduler, dispatcher, metrics, health checks, secrets. | [![readme](https://img.shields.io/badge/readme-1390A3.svg)](src/SquidStd.Services.Core/README.md) · [![NuGet](https://img.shields.io/nuget/v/SquidStd.Services.Core.svg)](https://www.nuget.org/packages/SquidStd.Services.Core/) | | `SquidStd.AspNetCore` | ASP.NET Core host integration for the SquidStd service stack. | [![readme](https://img.shields.io/badge/readme-1390A3.svg)](src/SquidStd.AspNetCore/README.md) · [![NuGet](https://img.shields.io/nuget/v/SquidStd.AspNetCore.svg)](https://www.nuget.org/packages/SquidStd.AspNetCore/) | | `SquidStd.Network` | TCP/UDP servers & clients, sessions, framing/middleware pipeline, span readers/writers. | [![readme](https://img.shields.io/badge/readme-1390A3.svg)](src/SquidStd.Network/README.md) · [![NuGet](https://img.shields.io/nuget/v/SquidStd.Network.svg)](https://www.nuget.org/packages/SquidStd.Network/) | +| `SquidStd.Persistence.Abstractions` | Binary-persistence contracts (`IEntityStore`, `IPersistenceService`, journal/snapshot/registry, `PersistenceConfig`). | [![readme](https://img.shields.io/badge/readme-1390A3.svg)](src/SquidStd.Persistence.Abstractions/README.md) · [![NuGet](https://img.shields.io/nuget/v/SquidStd.Persistence.Abstractions.svg)](https://www.nuget.org/packages/SquidStd.Persistence.Abstractions/) | +| `SquidStd.Persistence` | In-memory entity store with durable binary snapshot + journal (WAL); serializer-agnostic engine. | [![readme](https://img.shields.io/badge/readme-1390A3.svg)](src/SquidStd.Persistence/README.md) · [![NuGet](https://img.shields.io/nuget/v/SquidStd.Persistence.svg)](https://www.nuget.org/packages/SquidStd.Persistence/) | +| `SquidStd.Persistence.MessagePack` | MessagePack-backed binary `IDataSerializer` for SquidStd.Persistence (recommended default). | [![readme](https://img.shields.io/badge/readme-1390A3.svg)](src/SquidStd.Persistence.MessagePack/README.md) · [![NuGet](https://img.shields.io/nuget/v/SquidStd.Persistence.MessagePack.svg)](https://www.nuget.org/packages/SquidStd.Persistence.MessagePack/) | | `SquidStd.Plugin.Abstractions` | Plugin contracts (`ISquidStdPlugin`, metadata, context). | [![readme](https://img.shields.io/badge/readme-1390A3.svg)](src/SquidStd.Plugin.Abstractions/README.md) · [![NuGet](https://img.shields.io/nuget/v/SquidStd.Plugin.Abstractions.svg)](https://www.nuget.org/packages/SquidStd.Plugin.Abstractions/) | | `SquidStd.Database.Abstractions` | Provider-agnostic data-access contracts (`IDataAccess`, `BaseEntity`, `PagedResultData`). | [![readme](https://img.shields.io/badge/readme-1390A3.svg)](src/SquidStd.Database.Abstractions/README.md) · [![NuGet](https://img.shields.io/nuget/v/SquidStd.Database.Abstractions.svg)](https://www.nuget.org/packages/SquidStd.Database.Abstractions/) | | `SquidStd.Database` | FreeSql-backed data access (CRUD/bulk/paging, URI connection strings, ZLinq helpers). | [![readme](https://img.shields.io/badge/readme-1390A3.svg)](src/SquidStd.Database/README.md) · [![NuGet](https://img.shields.io/nuget/v/SquidStd.Database.svg)](https://www.nuget.org/packages/SquidStd.Database/) | diff --git a/SquidStd.slnx b/SquidStd.slnx index 5ebd7a16..08f1b455 100644 --- a/SquidStd.slnx +++ b/SquidStd.slnx @@ -1,6 +1,7 @@ + @@ -16,6 +17,9 @@ + + + @@ -41,6 +45,7 @@ + diff --git a/docs/articles/source-generators.md b/docs/articles/source-generators.md new file mode 100644 index 00000000..20aba3ce --- /dev/null +++ b/docs/articles/source-generators.md @@ -0,0 +1,122 @@ +# SquidStd.Generators + +`SquidStd.Generators` contains Roslyn source generators that create compile-time registration helpers for SquidStd projects. + +Generators are opt-in per registration surface. Mark the type you want registered and call the generated extension method while configuring the DryIoc container: + +```csharp +container.RegisterGeneratedEventListeners(); +container.RegisterGeneratedStdServices(); +container.RegisterGeneratedConfigSections(); +container.RegisterGeneratedJobHandlers(); +container.RegisterGeneratedScriptModules(); +``` + +Each generated method uses the same runtime path as manual registration. For example, the event listener generator emits: + +```csharp +container.RegisterEventListener(); +``` + +This keeps startup behavior compatible with `EventListenerActivator` and the normal `SquidStdBootstrap` lifecycle. + +## Usage + +Add the generator package: + +```bash +dotnet add package SquidStd.Generators +``` + +Call the generated extension while configuring the DryIoc container: + +```csharp +using SquidStd.Abstractions.Attributes; +using SquidStd.Core.Interfaces.Events; +using SquidStd.Generators.Events; + +[RegisterEventListener] +public sealed class PingListener : IEventListener +{ + public Task HandleAsync(PingEvent eventData, CancellationToken cancellationToken = default) + { + Console.WriteLine(eventData.Message); + + return Task.CompletedTask; + } +} + +bootstrap.ConfigureServices(container => container.RegisterGeneratedEventListeners()); +``` + +## Generated registration families + +### Event listeners + +Use `[RegisterEventListener]` on a concrete `IEventListener` implementation and call: + +```csharp +using SquidStd.Generators.Events; + +container.RegisterGeneratedEventListeners(); +``` + +### Standard services + +Use `[RegisterStdService(typeof(IMyService), Priority = 10)]` on the implementation and call: + +```csharp +using SquidStd.Generators.Services; + +container.RegisterGeneratedStdServices(); +``` + +The generator emits `RegisterStdService(container, 10)`. + +### Config sections + +Use `[RegisterConfigSection("workers", Priority = -50)]` on the config type and call: + +```csharp +using SquidStd.Generators.Config; + +container.RegisterGeneratedConfigSections(); +``` + +The generator emits `RegisterConfigSection(container, "workers", priority: -50)`. + +### Job handlers + +Use `[RegisterJobHandler]` on an `IJobHandler` implementation and call: + +```csharp +using SquidStd.Generators.Workers; + +container.RegisterGeneratedJobHandlers(); +``` + +The generator emits `AddJobHandler(container)`. + +### Lua script modules + +Use `[RegisterScriptModule]` together with the runtime `[ScriptModule("name")]` metadata and call: + +```csharp +using SquidStd.Generators.Scripting.Lua; + +container.RegisterGeneratedScriptModules(); +``` + +The generator emits `RegisterScriptModule(container)`. + +## Supported shapes and diagnostics + +Generated source can only reference public or internal non-generic concrete classes. Unsupported types are skipped and reported as warnings. + +| Diagnostic | Registration family | +|------------|---------------------| +| `SQDGEN001` | Event listener cannot be generated. | +| `SQDGEN002` | Standard service cannot be generated. | +| `SQDGEN003` | Config section cannot be generated. | +| `SQDGEN004` | Job handler cannot be generated. | +| `SQDGEN005` | Script module cannot be generated. | diff --git a/docs/articles/toc.yml b/docs/articles/toc.yml index c531a44f..2669ef5b 100644 --- a/docs/articles/toc.yml +++ b/docs/articles/toc.yml @@ -4,6 +4,8 @@ href: core.md - name: SquidStd.Abstractions href: abstractions.md +- name: SquidStd.Generators + href: source-generators.md - name: SquidStd.Services.Core href: services-core.md - name: SquidStd.AspNetCore diff --git a/docs/docfx.json b/docs/docfx.json index 26c8aa44..f7308d34 100644 --- a/docs/docfx.json +++ b/docs/docfx.json @@ -5,7 +5,7 @@ { "src": "../", "files": [ "src/**/*.csproj" ], - "exclude": [ "**/bin/**", "**/obj/**" ] + "exclude": [ "**/bin/**", "**/obj/**", "src/SquidStd.Generators/**" ] } ], "dest": "api", diff --git a/docs/tutorials/scripting-lua.md b/docs/tutorials/scripting-lua.md index 5b231783..8c1f968e 100644 --- a/docs/tutorials/scripting-lua.md +++ b/docs/tutorials/scripting-lua.md @@ -12,13 +12,15 @@ bootstrap, runs a small Lua script, and prints values evaluated by the engine. - .NET 10 SDK - `dotnet add package SquidStd.Scripting.Lua` - `dotnet add package SquidStd.Services.Core` +- `dotnet add package SquidStd.Generators` ## Steps ### 1. Register the Lua engine The engine needs a `LuaEngineConfig` (it watches a scripts directory) and is started by the bootstrap as a -SquidStd service. The `DirectoriesConfig` it depends on is already registered by the core services. +SquidStd service. The `DirectoriesConfig` it depends on is already registered by the core services. The generated +script-module registration call adds any `[RegisterScriptModule]` modules before startup. [!code-csharp[](../../samples/SquidStd.Samples.ScriptingLua/Program.cs#step-1)] @@ -29,6 +31,13 @@ SquidStd service. The `DirectoriesConfig` it depends on is already registered by [!code-csharp[](../../samples/SquidStd.Samples.ScriptingLua/Program.cs#step-2)] +### 3. Define a generated module + +`[RegisterScriptModule]` opts the type into source generation. `[ScriptModule("sample")]` is still the runtime Lua +metadata used as the module name. + +[!code-csharp[](../../samples/SquidStd.Samples.ScriptingLua/Program.cs#step-3)] + ## Run it ```bash @@ -38,6 +47,7 @@ dotnet run --project samples/SquidStd.Samples.ScriptingLua Prints: ``` +lua modules = 1 3 + 4 = 7 result = hello from C# and lua ``` @@ -52,3 +62,4 @@ configured scripts directory. Globals you register become Lua variables, and `Ex ## See also - [SquidStd.Scripting.Lua reference](../articles/scripting-lua.md) +- [Generated registrations](source-generators-registration.md) diff --git a/docs/tutorials/source-generators-event-listeners.md b/docs/tutorials/source-generators-event-listeners.md new file mode 100644 index 00000000..204eb25e --- /dev/null +++ b/docs/tutorials/source-generators-event-listeners.md @@ -0,0 +1,53 @@ +# Generated event listener registration + +This tutorial shows how to use `SquidStd.Generators` to register event listeners at compile time. + +## Add the generator + +```bash +dotnet add package SquidStd.Generators +``` + +## Create an event and listener + +```csharp +using SquidStd.Abstractions.Attributes; +using SquidStd.Core.Interfaces.Events; + +public sealed record PingEvent(string Message) : IEvent; + +[RegisterEventListener] +public sealed class PingListener : IEventListener +{ + public Task HandleAsync(PingEvent eventData, CancellationToken cancellationToken = default) + { + Console.WriteLine(eventData.Message); + + return Task.CompletedTask; + } +} +``` + +## Register generated listeners + +```csharp +using SquidStd.Generators.Events; +using SquidStd.Services.Core.Services.Bootstrap; + +var bootstrap = SquidStdBootstrap.Create() + .ConfigureServices(container => container.RegisterGeneratedEventListeners()); + +await bootstrap.StartAsync(); +``` + +At build time the generator emits a method that calls: + +```csharp +container.RegisterEventListener(); +``` + +The listener is then subscribed during the normal SquidStd service startup. + +## See also + +- [Generated registrations](source-generators-registration.md) diff --git a/docs/tutorials/source-generators-registration.md b/docs/tutorials/source-generators-registration.md new file mode 100644 index 00000000..4f037bc2 --- /dev/null +++ b/docs/tutorials/source-generators-registration.md @@ -0,0 +1,84 @@ +# Generated registrations + +Use `SquidStd.Generators` to replace repetitive manual registration calls with compile-time generated DryIoc extensions. + +## Add the generator + +```bash +dotnet add package SquidStd.Generators +``` + +## Standard services + +```csharp +using SquidStd.Abstractions.Attributes; +using SquidStd.Abstractions.Interfaces.Services; +using SquidStd.Generators.Services; + +public interface IGreetingService : ISquidStdService { } + +[RegisterStdService(typeof(IGreetingService), Priority = 10)] +public sealed class GreetingService : IGreetingService +{ + public ValueTask StartAsync(CancellationToken cancellationToken = default) => ValueTask.CompletedTask; + + public ValueTask StopAsync(CancellationToken cancellationToken = default) => ValueTask.CompletedTask; +} + +container.RegisterGeneratedStdServices(); +``` + +## Config sections + +```csharp +using SquidStd.Abstractions.Attributes; +using SquidStd.Generators.Config; + +[RegisterConfigSection("greeting", Priority = -10)] +public sealed class GreetingConfig +{ + public string Message { get; set; } = "hello"; +} + +container.RegisterGeneratedConfigSections(); +``` + +## Job handlers + +```csharp +using SquidStd.Generators.Workers; +using SquidStd.Workers.Abstractions.Data; +using SquidStd.Workers.Attributes; +using SquidStd.Workers.Interfaces; + +[RegisterJobHandler] +public sealed class GreetingJobHandler : IJobHandler +{ + public string JobName => "greet"; + + public Task HandleAsync(JobRequest job, CancellationToken cancellationToken) + { + Console.WriteLine(job.JobName); + + return Task.CompletedTask; + } +} + +container.RegisterGeneratedJobHandlers(); +``` + +## Lua script modules + +```csharp +using SquidStd.Generators.Scripting.Lua; +using SquidStd.Scripting.Lua.Attributes; +using SquidStd.Scripting.Lua.Attributes.Scripts; + +[RegisterScriptModule] +[ScriptModule("greeting")] +public sealed class GreetingScriptModule { } + +container.RegisterGeneratedScriptModules(); +``` + +The Lua generator only emits `RegisterScriptModule()`; `[ScriptModule("name")]` is still required because the Lua runtime uses it as the module name. diff --git a/docs/tutorials/toc.yml b/docs/tutorials/toc.yml index f6942f09..5696494d 100644 --- a/docs/tutorials/toc.yml +++ b/docs/tutorials/toc.yml @@ -4,6 +4,10 @@ href: getting-started.md - name: Events, jobs and scheduling href: events-jobs-scheduling.md +- name: Generated event listener registration + href: source-generators-event-listeners.md +- name: Generated registrations + href: source-generators-registration.md - name: Caching href: caching.md - name: Messaging diff --git a/docs/tutorials/worker-system.md b/docs/tutorials/worker-system.md index b66d2577..5c84f68b 100644 --- a/docs/tutorials/worker-system.md +++ b/docs/tutorials/worker-system.md @@ -15,20 +15,22 @@ and prints a greeting. - `dotnet add package SquidStd.Workers.Manager` - `dotnet add package SquidStd.Messaging` - `dotnet add package SquidStd.Services.Core` +- `dotnet add package SquidStd.Generators` ## Steps ### 1. Register messaging, workers and the manager -`ConfigureServices` runs against the bootstrap's DryIoc container. Add the in-memory queue, the worker runtime, a -job handler, and the manager (which provides the job scheduler). +`ConfigureServices` runs against the bootstrap's DryIoc container. Add the in-memory queue, the worker runtime, +generated job handler registrations, and the manager (which provides the job scheduler). [!code-csharp[](../../samples/SquidStd.Samples.WorkerSystem/Program.cs#step-1)] ### 2. Implement the job handler A handler implements `IJobHandler`: it declares the `JobName` it processes and does the work in `HandleAsync`, -reading any string parameters off the `JobRequest`. +reading any string parameters off the `JobRequest`. `[RegisterJobHandler]` opts the handler into generated +registration. [!code-csharp[](../../samples/SquidStd.Samples.WorkerSystem/Program.cs#step-2)] @@ -62,4 +64,5 @@ DryIoc container. - [SquidStd.Workers reference](../articles/workers.md) - [SquidStd.Workers.Manager reference](../articles/workers-manager.md) - [SquidStd.Workers.Abstractions reference](../articles/workers-abstractions.md) +- [Generated registrations](source-generators-registration.md) - Previous: [Events, jobs and scheduling](events-jobs-scheduling.md) diff --git a/samples/SquidStd.Samples.Caching/Program.cs b/samples/SquidStd.Samples.Caching/Program.cs index 59a0227e..2caf671f 100644 --- a/samples/SquidStd.Samples.Caching/Program.cs +++ b/samples/SquidStd.Samples.Caching/Program.cs @@ -1,9 +1,10 @@ using SquidStd.Caching.Abstractions.Interfaces; using SquidStd.Caching.Extensions; +using SquidStd.Core.Data.Bootstrap; using SquidStd.Services.Core.Services.Bootstrap; var bootstrap = SquidStdBootstrap.Create( - new() + new SquidStdOptions { ConfigName = "squidstd", RootDirectory = AppContext.BaseDirectory diff --git a/samples/SquidStd.Samples.Database/Program.cs b/samples/SquidStd.Samples.Database/Program.cs index 97c8c65a..08b45ace 100644 --- a/samples/SquidStd.Samples.Database/Program.cs +++ b/samples/SquidStd.Samples.Database/Program.cs @@ -1,10 +1,11 @@ +using SquidStd.Core.Data.Bootstrap; using SquidStd.Database.Abstractions.Data.Entities; using SquidStd.Database.Abstractions.Interfaces.Data; using SquidStd.Database.Extensions; using SquidStd.Services.Core.Services.Bootstrap; var bootstrap = SquidStdBootstrap.Create( - new() + new SquidStdOptions { ConfigName = "squidstd", RootDirectory = AppContext.BaseDirectory @@ -23,14 +24,14 @@ var products = bootstrap.Resolve>(); -await products.InsertAsync(new() { Name = "Squid Plushie", Price = 19.99m }); -await products.InsertAsync(new() { Name = "Kraken Mug", Price = 12.50m }); +await products.InsertAsync(new Product { Name = "Squid Plushie", Price = 19.99m }); +await products.InsertAsync(new Product { Name = "Kraken Mug", Price = 12.50m }); var page = await products.GetPagedAsync( - 1, - 10, - orderBy: product => product.Price - ); + 1, + 10, + orderBy: product => product.Price +); Console.WriteLine($"Found {page.TotalCount} product(s) on page {page.Page}/{page.TotalPages}:"); diff --git a/samples/SquidStd.Samples.Email/Program.cs b/samples/SquidStd.Samples.Email/Program.cs index 2b910174..0ca1f7ab 100644 --- a/samples/SquidStd.Samples.Email/Program.cs +++ b/samples/SquidStd.Samples.Email/Program.cs @@ -1,5 +1,7 @@ +using SquidStd.Core.Data.Bootstrap; using SquidStd.Core.Interfaces.Events; using SquidStd.Mail.Abstractions.Data; +using SquidStd.Mail.Abstractions.Data.Config; using SquidStd.Mail.Abstractions.Data.Events; using SquidStd.Mail.Abstractions.Interfaces; using SquidStd.Mail.Abstractions.Types.Mail; @@ -10,7 +12,7 @@ using SquidStd.Services.Core.Services.Bootstrap; var bootstrap = SquidStdBootstrap.Create( - new() + new SquidStdOptions { ConfigName = "squidstd", RootDirectory = AppContext.BaseDirectory @@ -19,9 +21,8 @@ #region step-1 -bootstrap.ConfigureServices( - container => container.AddMail( - new() +bootstrap.ConfigureServices(container => container.AddMail( + new MailOptions { Protocol = MailProtocolType.Imap, Host = "imap.example.com", @@ -36,9 +37,8 @@ #region step-2 -bootstrap.ConfigureServices( - container => container.AddMailSender( - new() +bootstrap.ConfigureServices(container => container.AddMailSender( + new SmtpOptions { Host = "smtp.example.com", Port = 587 @@ -50,10 +50,9 @@ #region step-3 -bootstrap.ConfigureServices( - container => container - .AddInMemoryMessaging() - .AddMailQueue() +bootstrap.ConfigureServices(container => container + .AddInMemoryMessaging() + .AddMailQueue() ); #endregion @@ -62,11 +61,11 @@ // Inbound: react to each received email on the event bus. var eventBus = bootstrap.Resolve(); -eventBus.RegisterAsyncListener(new MailReceivedLogger()); +eventBus.RegisterListener(new MailReceivedLogger()); var outgoing = new OutgoingMailMessage { - To = [new("Bob", "bob@example.com")], + To = [new MailAddress("Bob", "bob@example.com")], Subject = "Hi", HtmlBody = "

Hi

" }; @@ -85,7 +84,7 @@ await bootstrap.StopAsync(); /// Logs every received email as it arrives on the event bus. -public sealed class MailReceivedLogger : IAsyncEventListener +public sealed class MailReceivedLogger : IEventListener { /// Handles a received-mail event. public Task HandleAsync(MailReceivedEvent eventData, CancellationToken cancellationToken) diff --git a/samples/SquidStd.Samples.EventsJobsScheduling/Program.cs b/samples/SquidStd.Samples.EventsJobsScheduling/Program.cs index 49c82498..332014d0 100644 --- a/samples/SquidStd.Samples.EventsJobsScheduling/Program.cs +++ b/samples/SquidStd.Samples.EventsJobsScheduling/Program.cs @@ -1,11 +1,14 @@ +using SquidStd.Abstractions.Attributes; +using SquidStd.Core.Data.Bootstrap; using SquidStd.Core.Interfaces.Events; using SquidStd.Core.Interfaces.Jobs; using SquidStd.Core.Interfaces.Scheduling; +using SquidStd.Generators.Events; using SquidStd.Services.Core.Extensions; using SquidStd.Services.Core.Services.Bootstrap; var bootstrap = SquidStdBootstrap.Create( - new() + new SquidStdOptions { ConfigName = "squidstd", RootDirectory = AppContext.BaseDirectory @@ -13,14 +16,16 @@ ); // The cron scheduler and timer wheel are opt-in. -bootstrap.ConfigureServices(container => container.RegisterSchedulerServices()); +bootstrap.ConfigureServices(container => container + .RegisterSchedulerServices() + .RegisterGeneratedEventListeners() +); await bootstrap.StartAsync(); #region step-1 var eventBus = bootstrap.Resolve(); -eventBus.RegisterAsyncListener(new PingListener()); await eventBus.PublishAsync(new PingEvent("hello"), CancellationToken.None); #endregion @@ -54,7 +59,8 @@ public sealed record PingEvent(string Message) : IEvent; /// Handles . -public sealed class PingListener : IAsyncEventListener +[RegisterEventListener] +public sealed class PingListener : IEventListener { public Task HandleAsync(PingEvent eventData, CancellationToken cancellationToken) { diff --git a/samples/SquidStd.Samples.EventsJobsScheduling/SquidStd.Samples.EventsJobsScheduling.csproj b/samples/SquidStd.Samples.EventsJobsScheduling/SquidStd.Samples.EventsJobsScheduling.csproj index e3b27fc2..7366b1fa 100644 --- a/samples/SquidStd.Samples.EventsJobsScheduling/SquidStd.Samples.EventsJobsScheduling.csproj +++ b/samples/SquidStd.Samples.EventsJobsScheduling/SquidStd.Samples.EventsJobsScheduling.csproj @@ -10,6 +10,7 @@ +
diff --git a/samples/SquidStd.Samples.GettingStarted/Program.cs b/samples/SquidStd.Samples.GettingStarted/Program.cs index c6dcb6cc..e9a6a176 100644 --- a/samples/SquidStd.Samples.GettingStarted/Program.cs +++ b/samples/SquidStd.Samples.GettingStarted/Program.cs @@ -1,9 +1,10 @@ +using SquidStd.Core.Data.Bootstrap; using SquidStd.Services.Core.Services.Bootstrap; #region step-1 var bootstrap = SquidStdBootstrap.Create( - new() + new SquidStdOptions { ConfigName = "squidstd", RootDirectory = AppContext.BaseDirectory diff --git a/samples/SquidStd.Samples.Messaging/Program.cs b/samples/SquidStd.Samples.Messaging/Program.cs index 32489792..75202d9e 100644 --- a/samples/SquidStd.Samples.Messaging/Program.cs +++ b/samples/SquidStd.Samples.Messaging/Program.cs @@ -1,9 +1,10 @@ +using SquidStd.Core.Data.Bootstrap; using SquidStd.Messaging.Abstractions.Interfaces; using SquidStd.Messaging.Extensions; using SquidStd.Services.Core.Services.Bootstrap; var bootstrap = SquidStdBootstrap.Create( - new() + new SquidStdOptions { ConfigName = "squidstd", RootDirectory = AppContext.BaseDirectory diff --git a/samples/SquidStd.Samples.Networking/Program.cs b/samples/SquidStd.Samples.Networking/Program.cs index 8afd2de5..948269b4 100644 --- a/samples/SquidStd.Samples.Networking/Program.cs +++ b/samples/SquidStd.Samples.Networking/Program.cs @@ -9,12 +9,12 @@ var server = new SquidTcpServer(endPoint); server.OnClientConnect += (_, args) => - Console.WriteLine($"Client connected: session {args.Client.SessionId}"); + Console.WriteLine($"Client connected: session {args.Client.SessionId}"); server.OnDataReceived += (_, args) => - Console.WriteLine( - $"Received {args.Data.Length} byte(s): {Encoding.UTF8.GetString(args.Data.Span)}" - ); + Console.WriteLine( + $"Received {args.Data.Length} byte(s): {Encoding.UTF8.GetString(args.Data.Span)}" + ); #endregion diff --git a/samples/SquidStd.Samples.Persistence/Program.cs b/samples/SquidStd.Samples.Persistence/Program.cs new file mode 100644 index 00000000..a2bfdc1a --- /dev/null +++ b/samples/SquidStd.Samples.Persistence/Program.cs @@ -0,0 +1,72 @@ +using SquidStd.Persistence.Abstractions.Data; +using SquidStd.Persistence.Abstractions.Interfaces.Persistence; +using SquidStd.Persistence.Data; +using SquidStd.Persistence.MessagePack; +using SquidStd.Persistence.Services; + +// A standalone demo of SquidStd.Persistence: an in-memory entity store backed by a durable +// binary snapshot + journal. Run it twice — the second run reloads the state saved by the first. + +var saveDir = Path.Combine(AppContext.BaseDirectory, "save"); + +IPersistenceService BuildPersistence() +{ + var serializer = new MessagePackDataSerializer(); + + var registry = new PersistenceEntityRegistry(); + registry.Register( + new PersistenceEntityDescriptor( + serializer, + serializer, + typeId: 1, + typeName: "Player", + schemaVersion: 1, + keySelector: player => player.Id + ) + ); + + var config = new PersistenceConfig { SaveDirectory = saveDir, AutosaveInterval = TimeSpan.FromMinutes(5) }; + var journal = new BinaryJournalService(Path.Combine(saveDir, config.JournalFileName)); + var snapshot = new SnapshotService(saveDir, config.SnapshotFileSuffix); + + return new PersistenceService(registry, journal, snapshot, config); +} + +#region step-1: load existing state (snapshot + journal replay) + +var persistence = BuildPersistence(); +await persistence.InitializeAsync(); + +var players = persistence.GetStore(); + +Console.WriteLine($"Loaded {await players.CountAsync()} player(s) from {saveDir}"); + +foreach (var existing in await players.GetAllAsync()) +{ + Console.WriteLine($" - #{existing.Id} {existing.Name} (level {existing.Level})"); +} + +#endregion + +#region step-2: mutate — every upsert/remove is appended to the journal + +var nextId = (await players.CountAsync()) + 1; +await players.UpsertAsync(new Player { Id = nextId, Name = $"Hero-{nextId}", Level = nextId * 10 }); + +Console.WriteLine($"Added player #{nextId}; store now holds {await players.CountAsync()} player(s)"); + +#endregion + +#region step-3: snapshot — capture full state and trim the journal + +await persistence.SaveSnapshotAsync(); +Console.WriteLine("Snapshot saved. Re-run this sample to see the state reload."); + +#endregion + +public sealed class Player +{ + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public int Level { get; set; } +} diff --git a/samples/SquidStd.Samples.Persistence/SquidStd.Samples.Persistence.csproj b/samples/SquidStd.Samples.Persistence/SquidStd.Samples.Persistence.csproj new file mode 100644 index 00000000..8d71e5c6 --- /dev/null +++ b/samples/SquidStd.Samples.Persistence/SquidStd.Samples.Persistence.csproj @@ -0,0 +1,16 @@ + + + + Exe + net10.0 + enable + enable + false + + + + + + + + diff --git a/samples/SquidStd.Samples.Plugins/Program.cs b/samples/SquidStd.Samples.Plugins/Program.cs index 5822c920..266558f6 100644 --- a/samples/SquidStd.Samples.Plugins/Program.cs +++ b/samples/SquidStd.Samples.Plugins/Program.cs @@ -14,7 +14,9 @@ public interface IGreeter public sealed class WeatherGreeter : IGreeter { public string Greet(string name) - => $"Hello {name}, the weather plugin is online."; + { + return $"Hello {name}, the weather plugin is online."; + } } public sealed class WeatherPlugin : ISquidStdPlugin @@ -23,14 +25,16 @@ public sealed class WeatherPlugin : ISquidStdPlugin { Id = "squidstd.weather", Name = "Weather Plugin", - Version = new(1, 0, 0), + Version = new Version(1, 0, 0), Author = "SquidStd Samples", Description = "Registers a greeter service.", Dependencies = [] }; public void Configure(IContainer container, PluginContext context) - => container.Register(Reuse.Singleton); + { + container.Register(Reuse.Singleton); + } } #endregion @@ -39,7 +43,7 @@ internal static class Program { private static void Main() { - #region step-2 + #region step-2 var container = new Container(); var context = new PluginContext(); @@ -53,6 +57,6 @@ private static void Main() var greeter = container.Resolve(); Console.WriteLine(greeter.Greet("squid")); - #endregion + #endregion } } diff --git a/samples/SquidStd.Samples.ScriptingLua/Program.cs b/samples/SquidStd.Samples.ScriptingLua/Program.cs index cbbfc3da..94115310 100644 --- a/samples/SquidStd.Samples.ScriptingLua/Program.cs +++ b/samples/SquidStd.Samples.ScriptingLua/Program.cs @@ -1,12 +1,16 @@ using DryIoc; using SquidStd.Abstractions.Extensions.Services; +using SquidStd.Core.Data.Bootstrap; +using SquidStd.Generators.Scripting.Lua; +using SquidStd.Scripting.Lua.Attributes; +using SquidStd.Scripting.Lua.Attributes.Scripts; using SquidStd.Scripting.Lua.Data.Config; using SquidStd.Scripting.Lua.Interfaces.Scripts; using SquidStd.Scripting.Lua.Services; using SquidStd.Services.Core.Services.Bootstrap; var bootstrap = SquidStdBootstrap.Create( - new() + new SquidStdOptions { ConfigName = "squidstd", RootDirectory = AppContext.BaseDirectory @@ -18,8 +22,7 @@ var scriptsDirectory = Path.Combine(AppContext.BaseDirectory, "scripts"); Directory.CreateDirectory(scriptsDirectory); -bootstrap.ConfigureServices( - container => +bootstrap.ConfigureServices(container => { var engineConfig = new LuaEngineConfig( AppContext.BaseDirectory, @@ -30,6 +33,7 @@ container.RegisterInstance(engineConfig); container.RegisterStdService(); + container.RegisterGeneratedScriptModules(); return container; } @@ -42,6 +46,7 @@ #region step-2 var engine = bootstrap.Resolve(); +var stats = ((LuaScriptEngineService)engine).GetStats(); engine.RegisterGlobal("greeting", "hello from C#"); engine.ExecuteScript("result = greeting .. ' and lua'"); @@ -49,9 +54,20 @@ var sum = engine.ExecuteFunction("3 + 4"); var message = engine.ExecuteFunction("result"); +Console.WriteLine($"lua modules = {stats.ModuleCount}"); Console.WriteLine($"3 + 4 = {sum.Data}"); Console.WriteLine($"result = {message.Data}"); #endregion await bootstrap.StopAsync(); + +#region step-3 + +[RegisterScriptModule] +[ScriptModule("sample")] +internal sealed class SampleLuaModule +{ +} + +#endregion diff --git a/samples/SquidStd.Samples.ScriptingLua/SquidStd.Samples.ScriptingLua.csproj b/samples/SquidStd.Samples.ScriptingLua/SquidStd.Samples.ScriptingLua.csproj index 1da66757..8000da19 100644 --- a/samples/SquidStd.Samples.ScriptingLua/SquidStd.Samples.ScriptingLua.csproj +++ b/samples/SquidStd.Samples.ScriptingLua/SquidStd.Samples.ScriptingLua.csproj @@ -11,6 +11,7 @@ +
diff --git a/samples/SquidStd.Samples.Search/Program.cs b/samples/SquidStd.Samples.Search/Program.cs index 25b5594d..905455ae 100644 --- a/samples/SquidStd.Samples.Search/Program.cs +++ b/samples/SquidStd.Samples.Search/Program.cs @@ -1,11 +1,13 @@ +using SquidStd.Core.Data.Bootstrap; using SquidStd.Search.Abstractions.Attributes; using SquidStd.Search.Abstractions.Interfaces; +using SquidStd.Search.Elasticsearch.Data.Config; using SquidStd.Search.Elasticsearch.Extensions; using SquidStd.Search.Elasticsearch.Linq; using SquidStd.Services.Core.Services.Bootstrap; var bootstrap = SquidStdBootstrap.Create( - new() + new SquidStdOptions { ConfigName = "squidstd", RootDirectory = AppContext.BaseDirectory @@ -14,11 +16,10 @@ #region step-1 -bootstrap.ConfigureServices( - container => container.AddElasticsearch( - new() +bootstrap.ConfigureServices(container => container.AddElasticsearch( + new ElasticsearchOptions { - Uri = new("http://localhost:9200") + Uri = new Uri("http://localhost:9200") } ) ); @@ -34,8 +35,8 @@ await search.IndexAsync(new Order("1", "open", 150), true); var open = await search.Query() - .Where(o => o.Status == "open") - .ToListAsync(); + .Where(o => o.Status == "open") + .ToListAsync(); Console.WriteLine($"found {open.Count} open order(s)"); diff --git a/samples/SquidStd.Samples.Storage/Program.cs b/samples/SquidStd.Samples.Storage/Program.cs index ddaf9985..cde19fa4 100644 --- a/samples/SquidStd.Samples.Storage/Program.cs +++ b/samples/SquidStd.Samples.Storage/Program.cs @@ -1,9 +1,10 @@ +using SquidStd.Core.Data.Bootstrap; using SquidStd.Services.Core.Services.Bootstrap; using SquidStd.Storage.Abstractions.Interfaces; using SquidStd.Storage.Extensions; var bootstrap = SquidStdBootstrap.Create( - new() + new SquidStdOptions { ConfigName = "squidstd", RootDirectory = AppContext.BaseDirectory diff --git a/samples/SquidStd.Samples.Templating/Program.cs b/samples/SquidStd.Samples.Templating/Program.cs index ad2b0e49..d46884f6 100644 --- a/samples/SquidStd.Samples.Templating/Program.cs +++ b/samples/SquidStd.Samples.Templating/Program.cs @@ -1,9 +1,10 @@ +using SquidStd.Core.Data.Bootstrap; using SquidStd.Services.Core.Services.Bootstrap; using SquidStd.Templating.Extensions; using SquidStd.Templating.Interfaces; var bootstrap = SquidStdBootstrap.Create( - new() + new SquidStdOptions { ConfigName = "squidstd", RootDirectory = AppContext.BaseDirectory diff --git a/samples/SquidStd.Samples.WorkerSystem/Program.cs b/samples/SquidStd.Samples.WorkerSystem/Program.cs index 66e0debb..0f220543 100644 --- a/samples/SquidStd.Samples.WorkerSystem/Program.cs +++ b/samples/SquidStd.Samples.WorkerSystem/Program.cs @@ -1,13 +1,16 @@ +using SquidStd.Core.Data.Bootstrap; +using SquidStd.Generators.Workers; using SquidStd.Messaging.Extensions; using SquidStd.Services.Core.Services.Bootstrap; using SquidStd.Workers.Abstractions.Data; +using SquidStd.Workers.Attributes; using SquidStd.Workers.Extensions; using SquidStd.Workers.Interfaces; using SquidStd.Workers.Manager.Extensions; using SquidStd.Workers.Manager.Interfaces; var bootstrap = SquidStdBootstrap.Create( - new() + new SquidStdOptions { ConfigName = "squidstd", RootDirectory = AppContext.BaseDirectory @@ -16,12 +19,11 @@ #region step-1 -bootstrap.ConfigureServices( - c => +bootstrap.ConfigureServices(c => { c.AddInMemoryMessaging(); c.AddWorkers(); - c.AddJobHandler(); + c.RegisterGeneratedJobHandlers(); c.AddWorkerManager(); return c; @@ -44,6 +46,7 @@ #region step-2 +[RegisterJobHandler] internal sealed class GreetJobHandler : IJobHandler { public string JobName => "greet"; diff --git a/samples/SquidStd.Samples.WorkerSystem/SquidStd.Samples.WorkerSystem.csproj b/samples/SquidStd.Samples.WorkerSystem/SquidStd.Samples.WorkerSystem.csproj index c203cc17..ad514b65 100644 --- a/samples/SquidStd.Samples.WorkerSystem/SquidStd.Samples.WorkerSystem.csproj +++ b/samples/SquidStd.Samples.WorkerSystem/SquidStd.Samples.WorkerSystem.csproj @@ -13,6 +13,7 @@ +
diff --git a/src/SquidStd.Abstractions/Attributes/RegisterConfigSectionAttribute.cs b/src/SquidStd.Abstractions/Attributes/RegisterConfigSectionAttribute.cs new file mode 100644 index 00000000..c59fb658 --- /dev/null +++ b/src/SquidStd.Abstractions/Attributes/RegisterConfigSectionAttribute.cs @@ -0,0 +1,27 @@ +namespace SquidStd.Abstractions.Attributes; + +/// +/// Marks a configuration type for generated config-section registration. +/// +[AttributeUsage(AttributeTargets.Class, Inherited = false)] +public sealed class RegisterConfigSectionAttribute : Attribute +{ + /// + /// Initializes a new instance of the attribute. + /// + /// The configuration section name. + public RegisterConfigSectionAttribute(string? sectionName = null) + { + SectionName = sectionName; + } + + /// + /// Gets the configuration section name. + /// + public string? SectionName { get; } + + /// + /// Gets or sets the config loading priority. + /// + public int Priority { get; set; } +} diff --git a/src/SquidStd.Abstractions/Attributes/RegisterEventListenerAttribute.cs b/src/SquidStd.Abstractions/Attributes/RegisterEventListenerAttribute.cs new file mode 100644 index 00000000..a77c6dbe --- /dev/null +++ b/src/SquidStd.Abstractions/Attributes/RegisterEventListenerAttribute.cs @@ -0,0 +1,9 @@ +namespace SquidStd.Abstractions.Attributes; + +/// +/// Marks an event listener for generated registration. +/// +[AttributeUsage(AttributeTargets.Class, Inherited = false)] +public sealed class RegisterEventListenerAttribute : Attribute +{ +} diff --git a/src/SquidStd.Abstractions/Attributes/RegisterStdServiceAttribute.cs b/src/SquidStd.Abstractions/Attributes/RegisterStdServiceAttribute.cs new file mode 100644 index 00000000..fc0d4b20 --- /dev/null +++ b/src/SquidStd.Abstractions/Attributes/RegisterStdServiceAttribute.cs @@ -0,0 +1,27 @@ +namespace SquidStd.Abstractions.Attributes; + +/// +/// Marks a service implementation for generated SquidStd lifecycle registration. +/// +[AttributeUsage(AttributeTargets.Class, Inherited = false)] +public sealed class RegisterStdServiceAttribute : Attribute +{ + /// + /// Initializes a new instance of the attribute. + /// + /// The service contract registered for the annotated implementation. + public RegisterStdServiceAttribute(Type? serviceType = null) + { + ServiceType = serviceType; + } + + /// + /// Gets the service contract registered for the annotated implementation. + /// + public Type? ServiceType { get; } + + /// + /// Gets or sets the lifecycle start priority. + /// + public int Priority { get; set; } +} diff --git a/src/SquidStd.Abstractions/Data/Internal/Config/ConfigRegistrationData.cs b/src/SquidStd.Abstractions/Data/Internal/Config/ConfigRegistrationData.cs index c9ea6476..b2bfc02c 100644 --- a/src/SquidStd.Abstractions/Data/Internal/Config/ConfigRegistrationData.cs +++ b/src/SquidStd.Abstractions/Data/Internal/Config/ConfigRegistrationData.cs @@ -23,12 +23,12 @@ public ConfigRegistrationData( Priority = priority; } + public int Priority { get; } + public string SectionName { get; } public Type ConfigType { get; } - public int Priority { get; } - public object CreateDefault() { var config = _createDefault(); diff --git a/src/SquidStd.Abstractions/Data/Internal/Events/EventListenerRegistration.cs b/src/SquidStd.Abstractions/Data/Internal/Events/EventListenerRegistration.cs new file mode 100644 index 00000000..98b7ffbb --- /dev/null +++ b/src/SquidStd.Abstractions/Data/Internal/Events/EventListenerRegistration.cs @@ -0,0 +1,13 @@ +using DryIoc; +using SquidStd.Core.Interfaces.Events; + +namespace SquidStd.Abstractions.Data.Internal.Events; + +/// +/// A declarative event-listener registration consumed by the bootstrap activator. +/// The closure captures the concrete event and listener types at +/// registration time, so subscription needs no reflection. +/// +/// The concrete listener implementation type. +/// Resolves the listener and subscribes it to the bus. +public sealed record EventListenerRegistration(Type ListenerType, Action Subscribe); diff --git a/src/SquidStd.Abstractions/Extensions/Config/RegisterConfigSectionExtension.cs b/src/SquidStd.Abstractions/Extensions/Config/RegisterConfigSectionExtension.cs index 98326a6f..79872dbd 100644 --- a/src/SquidStd.Abstractions/Extensions/Config/RegisterConfigSectionExtension.cs +++ b/src/SquidStd.Abstractions/Extensions/Config/RegisterConfigSectionExtension.cs @@ -22,8 +22,11 @@ public static IContainer RegisterConfigSection( if (container.IsRegistered>()) { var entries = container.Resolve>(); - var sameSection = entries.FirstOrDefault( - entry => string.Equals(entry.SectionName, sectionName, StringComparison.Ordinal) + var sameSection = entries.FirstOrDefault(entry => string.Equals( + entry.SectionName, + sectionName, + StringComparison.Ordinal + ) ); if (sameSection is not null) @@ -42,7 +45,7 @@ public static IContainer RegisterConfigSection( } } - var factory = createDefault ?? (() => new()); + var factory = createDefault ?? (() => new TConfig()); container.AddToRegisterTypedList(new ConfigRegistrationData(sectionName, configType, () => factory(), priority)); return container; diff --git a/src/SquidStd.Abstractions/Extensions/Container/AddTypedListMethodExtension.cs b/src/SquidStd.Abstractions/Extensions/Container/AddTypedListMethodExtension.cs index a06d0aaa..b5f0a041 100644 --- a/src/SquidStd.Abstractions/Extensions/Container/AddTypedListMethodExtension.cs +++ b/src/SquidStd.Abstractions/Extensions/Container/AddTypedListMethodExtension.cs @@ -3,13 +3,13 @@ namespace SquidStd.Abstractions.Extensions.Container; /// -/// Extension methods for registering typed lists in the dependency injection container. +/// Extension methods for registering typed lists in the dependency injection container. /// public static class AddTypedListMethodExtension { /// - /// Adds an entity to a typed list in the DryIoc container. - /// If the list doesn't exist, it creates and registers a new one. + /// Adds an entity to a typed list in the DryIoc container. + /// If the list doesn't exist, it creates and registers a new one. /// /// The type of entities in the list. /// The DryIoc container. diff --git a/src/SquidStd.Abstractions/Extensions/Events/RegisterEventListenerExtension.cs b/src/SquidStd.Abstractions/Extensions/Events/RegisterEventListenerExtension.cs new file mode 100644 index 00000000..7522e6b8 --- /dev/null +++ b/src/SquidStd.Abstractions/Extensions/Events/RegisterEventListenerExtension.cs @@ -0,0 +1,34 @@ +using DryIoc; +using SquidStd.Abstractions.Data.Internal.Events; +using SquidStd.Abstractions.Extensions.Container; +using SquidStd.Core.Interfaces.Events; + +namespace SquidStd.Abstractions.Extensions.Events; + +/// +/// Registers event listeners for DI-native auto-subscription at bootstrap. +/// +public static class RegisterEventListenerExtension +{ + /// + /// Registers a listener implementation as a singleton and records it for auto-subscription. + /// + /// The event type the listener handles. + /// The listener implementation type. + /// The DryIoc container. + /// The same container for chaining. + public static IContainer RegisterEventListener(this IContainer container) + where TEvent : IEvent + where TListener : class, IEventListener + { + container.Register(Reuse.Singleton); + container.AddToRegisterTypedList( + new EventListenerRegistration( + typeof(TListener), + (bus, resolver) => bus.RegisterListener(resolver.Resolve()) + ) + ); + + return container; + } +} diff --git a/src/SquidStd.Abstractions/Interfaces/Services/ISquidStdService.cs b/src/SquidStd.Abstractions/Interfaces/Services/ISquidStdService.cs index 4e9b5066..dbc57ec2 100644 --- a/src/SquidStd.Abstractions/Interfaces/Services/ISquidStdService.cs +++ b/src/SquidStd.Abstractions/Interfaces/Services/ISquidStdService.cs @@ -1,7 +1,7 @@ namespace SquidStd.Abstractions.Interfaces.Services; /// -/// Defines lifecycle operations for a SquidStd service. +/// Defines lifecycle operations for a SquidStd service. /// public interface ISquidStdService { diff --git a/src/SquidStd.Abstractions/README.md b/src/SquidStd.Abstractions/README.md index f41238e9..3a83a2d9 100644 --- a/src/SquidStd.Abstractions/README.md +++ b/src/SquidStd.Abstractions/README.md @@ -24,6 +24,9 @@ dotnet add package SquidStd.Abstractions ## Features - `ISquidStdService` — a `StartAsync`/`StopAsync` lifecycle contract for managed services. +- `RegisterEventListenerAttribute` — mark `IEventListener` classes for generated registration. +- `RegisterStdServiceAttribute` — mark service implementations for generated lifecycle registration. +- `RegisterConfigSectionAttribute` — mark config models for generated config-section registration. - `RegisterStdService()` — register a singleton service and record it in the ordered service list (with optional priority). - `RegisterConfigSection(sectionName)` — register a YAML config section for the config manager. @@ -33,6 +36,7 @@ dotnet add package SquidStd.Abstractions ```csharp using DryIoc; +using SquidStd.Abstractions.Attributes; using SquidStd.Abstractions.Extensions.Config; using SquidStd.Abstractions.Extensions.Services; @@ -47,10 +51,13 @@ container.RegisterConfigSection("my"); | Type | Purpose | |----------------------------------|--------------------------------------------------| | `ISquidStdService` | Async start/stop lifecycle for managed services. | -| `RegisterStdServiceExtension` | `RegisterStdService<,>` container extension. | -| `RegisterConfigSectionExtension` | `RegisterConfigSection<>` container extension. | -| `ServiceRegistrationData` | Ordered service registration record. | -| `ConfigRegistrationData` | Config section registration record. | +| `RegisterEventListenerAttribute` | Marks event listeners for generated registration. | +| `RegisterStdServiceAttribute` | Marks services for generated registration. | +| `RegisterConfigSectionAttribute` | Marks config sections for generated registration. | +| `RegisterStdServiceExtension` | `RegisterStdService<,>` container extension. | +| `RegisterConfigSectionExtension` | `RegisterConfigSection<>` container extension. | +| `ServiceRegistrationData` | Ordered service registration record. | +| `ConfigRegistrationData` | Config section registration record. | ## License diff --git a/src/SquidStd.AspNetCore/Extensions/SquidStdAspNetCoreBuilderExtensions.cs b/src/SquidStd.AspNetCore/Extensions/SquidStdAspNetCoreBuilderExtensions.cs index 927e4dc1..c44fd3c9 100644 --- a/src/SquidStd.AspNetCore/Extensions/SquidStdAspNetCoreBuilderExtensions.cs +++ b/src/SquidStd.AspNetCore/Extensions/SquidStdAspNetCoreBuilderExtensions.cs @@ -9,23 +9,34 @@ namespace SquidStd.AspNetCore.Extensions; /// -/// Extension methods for connecting SquidStd to ASP.NET Core Minimal API applications. +/// Extension methods for connecting SquidStd to ASP.NET Core Minimal API applications. /// public static class SquidStdAspNetCoreBuilderExtensions { + internal const string ContainerPropertyKey = "SquidStd:Container"; + + private static void ValidateOptions(SquidStdOptions options) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentException.ThrowIfNullOrWhiteSpace(options.ConfigName); + ArgumentException.ThrowIfNullOrWhiteSpace(options.RootDirectory); + } + /// ASP.NET Core application builder. extension(WebApplicationBuilder builder) { /// - /// Registers SquidStd using DryIoc as the ASP.NET Core service provider. + /// Registers SquidStd using DryIoc as the ASP.NET Core service provider. /// /// Optional SquidStd bootstrap options callback. /// The same builder for chaining. public WebApplicationBuilder UseSquidStd(Action? configureOptions = null) - => builder.UseSquidStd(configureOptions, null); + { + return builder.UseSquidStd(configureOptions, null); + } /// - /// Registers SquidStd using DryIoc as the ASP.NET Core service provider. + /// Registers SquidStd using DryIoc as the ASP.NET Core service provider. /// /// Optional SquidStd bootstrap options callback. /// Optional DryIoc registration callback. @@ -61,13 +72,4 @@ public WebApplicationBuilder UseSquidStd( return builder; } } - - internal const string ContainerPropertyKey = "SquidStd:Container"; - - private static void ValidateOptions(SquidStdOptions options) - { - ArgumentNullException.ThrowIfNull(options); - ArgumentException.ThrowIfNullOrWhiteSpace(options.ConfigName); - ArgumentException.ThrowIfNullOrWhiteSpace(options.RootDirectory); - } } diff --git a/src/SquidStd.AspNetCore/Extensions/SquidStdHealthChecksExtensions.cs b/src/SquidStd.AspNetCore/Extensions/SquidStdHealthChecksExtensions.cs index ab54d1ff..6ff1155b 100644 --- a/src/SquidStd.AspNetCore/Extensions/SquidStdHealthChecksExtensions.cs +++ b/src/SquidStd.AspNetCore/Extensions/SquidStdHealthChecksExtensions.cs @@ -8,7 +8,7 @@ namespace SquidStd.AspNetCore.Extensions; /// -/// Extension methods that bridge SquidStd health checks into the standard ASP.NET Core health-check system. +/// Extension methods that bridge SquidStd health checks into the standard ASP.NET Core health-check system. /// public static class SquidStdHealthChecksExtensions { @@ -16,9 +16,9 @@ public static class SquidStdHealthChecksExtensions extension(WebApplicationBuilder builder) { /// - /// Registers each SquidStd IHealthCheck as a standard ASP.NET Core health check (one entry - /// per check, same name). Call after UseSquidStd; expose them with the standard - /// app.MapHealthChecks(...). Check names must be unique. + /// Registers each SquidStd IHealthCheck as a standard ASP.NET Core health check (one entry + /// per check, same name). Call after UseSquidStd; expose them with the standard + /// app.MapHealthChecks(...). Check names must be unique. /// /// The same builder for chaining. public WebApplicationBuilder AddSquidStdHealthChecks() @@ -40,7 +40,7 @@ out var value foreach (var check in checks) { healthChecks.Add( - new( + new HealthCheckRegistration( check.Name, _ => new SquidStdHealthCheckAdapter(check), HealthStatus.Unhealthy, diff --git a/src/SquidStd.AspNetCore/Services/SquidStdHealthCheckAdapter.cs b/src/SquidStd.AspNetCore/Services/SquidStdHealthCheckAdapter.cs index a9b64d07..037ed264 100644 --- a/src/SquidStd.AspNetCore/Services/SquidStdHealthCheckAdapter.cs +++ b/src/SquidStd.AspNetCore/Services/SquidStdHealthCheckAdapter.cs @@ -5,8 +5,8 @@ namespace SquidStd.AspNetCore.Services; /// -/// Adapts a SquidStd to the standard ASP.NET Core -/// contract. +/// Adapts a SquidStd to the standard ASP.NET Core +/// contract. /// internal sealed class SquidStdHealthCheckAdapter : IHealthCheck { @@ -25,7 +25,7 @@ public async Task CheckHealthAsync( var result = await _check.CheckAsync(cancellationToken); return result.Status == SquidHealthStatus.Unhealthy - ? HealthCheckResult.Unhealthy(result.Description, result.Exception) - : HealthCheckResult.Healthy(result.Description); + ? HealthCheckResult.Unhealthy(result.Description, result.Exception) + : HealthCheckResult.Healthy(result.Description); } } diff --git a/src/SquidStd.AspNetCore/Services/SquidStdHostedService.cs b/src/SquidStd.AspNetCore/Services/SquidStdHostedService.cs index b279eb7f..c2f65da8 100644 --- a/src/SquidStd.AspNetCore/Services/SquidStdHostedService.cs +++ b/src/SquidStd.AspNetCore/Services/SquidStdHostedService.cs @@ -4,14 +4,14 @@ namespace SquidStd.AspNetCore.Services; /// -/// Bridges the ASP.NET Core host lifecycle to the SquidStd bootstrap lifecycle. +/// Bridges the ASP.NET Core host lifecycle to the SquidStd bootstrap lifecycle. /// internal sealed class SquidStdHostedService : IHostedService { private readonly ISquidStdBootstrap _bootstrap; /// - /// Initializes the hosted service. + /// Initializes the hosted service. /// /// SquidStd bootstrap instance started with the ASP.NET host. public SquidStdHostedService(ISquidStdBootstrap bootstrap) @@ -21,9 +21,13 @@ public SquidStdHostedService(ISquidStdBootstrap bootstrap) /// public async Task StartAsync(CancellationToken cancellationToken) - => await _bootstrap.StartAsync(cancellationToken); + { + await _bootstrap.StartAsync(cancellationToken); + } /// public async Task StopAsync(CancellationToken cancellationToken) - => await _bootstrap.StopAsync(cancellationToken); + { + await _bootstrap.StopAsync(cancellationToken); + } } diff --git a/src/SquidStd.Aws.Abstractions/Data/Config/AwsConfigEntry.cs b/src/SquidStd.Aws.Abstractions/Data/Config/AwsConfigEntry.cs index fe5e1f43..80cc661e 100644 --- a/src/SquidStd.Aws.Abstractions/Data/Config/AwsConfigEntry.cs +++ b/src/SquidStd.Aws.Abstractions/Data/Config/AwsConfigEntry.cs @@ -1,8 +1,8 @@ namespace SquidStd.Aws.Abstractions.Data.Config; /// -/// Shared connection configuration for AWS-style services (region, credentials, endpoint override). -/// A dependency-free POCO: each provider maps it to its own SDK (AWS SDK, MinIO SDK, ...). +/// Shared connection configuration for AWS-style services (region, credentials, endpoint override). +/// A dependency-free POCO: each provider maps it to its own SDK (AWS SDK, MinIO SDK, ...). /// public sealed class AwsConfigEntry { diff --git a/src/SquidStd.Caching.Abstractions/Data/Config/CacheConnectionString.cs b/src/SquidStd.Caching.Abstractions/Data/Config/CacheConnectionString.cs index 8d124dec..d75e724c 100644 --- a/src/SquidStd.Caching.Abstractions/Data/Config/CacheConnectionString.cs +++ b/src/SquidStd.Caching.Abstractions/Data/Config/CacheConnectionString.cs @@ -4,7 +4,7 @@ namespace SquidStd.Caching.Abstractions.Data.Config; /// -/// Parsed cache connection string of the form scheme://[user:pass@]host[:port][?params]. +/// Parsed cache connection string of the form scheme://[user:pass@]host[:port][?params]. /// public sealed class CacheConnectionString { @@ -62,14 +62,14 @@ public static CacheConnectionString Parse(string connectionString) var query = HttpUtility.ParseQueryString(uri.Query); var parameters = query.AllKeys - .Where(static key => key is not null) - .ToFrozenDictionary( - key => key!, - key => query[key] ?? string.Empty, - StringComparer.OrdinalIgnoreCase - ); + .Where(static key => key is not null) + .ToFrozenDictionary( + key => key!, + key => query[key] ?? string.Empty, + StringComparer.OrdinalIgnoreCase + ); - return new( + return new CacheConnectionString( uri.Scheme, uri.Host, uri.Port > 0 ? uri.Port : null, @@ -81,11 +81,13 @@ public static CacheConnectionString Parse(string connectionString) /// Builds from the query parameters. public CacheOptions ToCacheOptions() - => new() + { + return new CacheOptions { DefaultTtl = Parameters.TryGetValue("defaultTtlSeconds", out var ttl) && int.TryParse(ttl, out var seconds) - ? TimeSpan.FromSeconds(seconds) - : null, + ? TimeSpan.FromSeconds(seconds) + : null, KeyPrefix = Parameters.TryGetValue("keyPrefix", out var prefix) ? prefix : string.Empty }; + } } diff --git a/src/SquidStd.Caching.Abstractions/Data/Config/CacheOptions.cs b/src/SquidStd.Caching.Abstractions/Data/Config/CacheOptions.cs index 68fda982..0f633d4c 100644 --- a/src/SquidStd.Caching.Abstractions/Data/Config/CacheOptions.cs +++ b/src/SquidStd.Caching.Abstractions/Data/Config/CacheOptions.cs @@ -1,7 +1,7 @@ namespace SquidStd.Caching.Abstractions.Data.Config; /// -/// Options shared by all cache providers. +/// Options shared by all cache providers. /// public sealed class CacheOptions { diff --git a/src/SquidStd.Caching.Abstractions/Interfaces/ICacheMetrics.cs b/src/SquidStd.Caching.Abstractions/Interfaces/ICacheMetrics.cs index bb966b18..c88d4bf2 100644 --- a/src/SquidStd.Caching.Abstractions/Interfaces/ICacheMetrics.cs +++ b/src/SquidStd.Caching.Abstractions/Interfaces/ICacheMetrics.cs @@ -1,7 +1,7 @@ namespace SquidStd.Caching.Abstractions.Interfaces; /// -/// Sink for cache instrumentation events. +/// Sink for cache instrumentation events. /// public interface ICacheMetrics { diff --git a/src/SquidStd.Caching.Abstractions/Interfaces/ICacheProvider.cs b/src/SquidStd.Caching.Abstractions/Interfaces/ICacheProvider.cs index de8a47ae..dd061219 100644 --- a/src/SquidStd.Caching.Abstractions/Interfaces/ICacheProvider.cs +++ b/src/SquidStd.Caching.Abstractions/Interfaces/ICacheProvider.cs @@ -3,7 +3,7 @@ namespace SquidStd.Caching.Abstractions.Interfaces; /// -/// Byte-level cache backend. Implemented by each provider (in-memory, Redis, ...). +/// Byte-level cache backend. Implemented by each provider (in-memory, Redis, ...). /// public interface ICacheProvider : ISquidStdService { diff --git a/src/SquidStd.Caching.Abstractions/Interfaces/ICacheService.cs b/src/SquidStd.Caching.Abstractions/Interfaces/ICacheService.cs index db57782e..46cf2db6 100644 --- a/src/SquidStd.Caching.Abstractions/Interfaces/ICacheService.cs +++ b/src/SquidStd.Caching.Abstractions/Interfaces/ICacheService.cs @@ -1,7 +1,7 @@ namespace SquidStd.Caching.Abstractions.Interfaces; /// -/// Typed cache-aside facade over an . +/// Typed cache-aside facade over an . /// public interface ICacheService { diff --git a/src/SquidStd.Caching.Abstractions/Services/CacheMetricsProvider.cs b/src/SquidStd.Caching.Abstractions/Services/CacheMetricsProvider.cs index 4835ad1f..4677398e 100644 --- a/src/SquidStd.Caching.Abstractions/Services/CacheMetricsProvider.cs +++ b/src/SquidStd.Caching.Abstractions/Services/CacheMetricsProvider.cs @@ -6,14 +6,38 @@ namespace SquidStd.Caching.Abstractions.Services; /// -/// Accumulates aggregate cache metrics and exposes them to the metrics collection system. +/// Accumulates aggregate cache metrics and exposes them to the metrics collection system. /// public sealed class CacheMetricsProvider : ICacheMetrics, IMetricProvider { private long _hits; private long _misses; - private long _sets; private long _removes; + private long _sets; + + /// + public void OnHit(string key) + { + Interlocked.Increment(ref _hits); + } + + /// + public void OnMiss(string key) + { + Interlocked.Increment(ref _misses); + } + + /// + public void OnRemove(string key) + { + Interlocked.Increment(ref _removes); + } + + /// + public void OnSet(string key) + { + Interlocked.Increment(ref _sets); + } /// public string ProviderName => "cache"; @@ -37,20 +61,4 @@ public ValueTask> CollectAsync(CancellationToken can return ValueTask.FromResult>(samples); } - - /// - public void OnHit(string key) - => Interlocked.Increment(ref _hits); - - /// - public void OnMiss(string key) - => Interlocked.Increment(ref _misses); - - /// - public void OnRemove(string key) - => Interlocked.Increment(ref _removes); - - /// - public void OnSet(string key) - => Interlocked.Increment(ref _sets); } diff --git a/src/SquidStd.Caching.Abstractions/Services/CacheService.cs b/src/SquidStd.Caching.Abstractions/Services/CacheService.cs index 4cb661d8..e21e6cbb 100644 --- a/src/SquidStd.Caching.Abstractions/Services/CacheService.cs +++ b/src/SquidStd.Caching.Abstractions/Services/CacheService.cs @@ -5,17 +5,17 @@ namespace SquidStd.Caching.Abstractions.Services; /// -/// Typed cache-aside facade: serializes values, applies the key prefix and default TTL, and -/// implements once over any . +/// Typed cache-aside facade: serializes values, applies the key prefix and default TTL, and +/// implements once over any . /// public sealed class CacheService : ICacheService { - private readonly ICacheProvider _provider; - private readonly IDataSerializer _serializer; - private readonly IDataDeserializer _deserializer; - private readonly ICacheMetrics _metrics; private readonly TimeSpan? _defaultTtl; + private readonly IDataDeserializer _deserializer; private readonly string _keyPrefix; + private readonly ICacheMetrics _metrics; + private readonly ICacheProvider _provider; + private readonly IDataSerializer _serializer; public CacheService( ICacheProvider provider, @@ -35,7 +35,9 @@ public CacheService( /// public Task ExistsAsync(string key, CancellationToken cancellationToken = default) - => _provider.ExistsAsync(Prefixed(key), cancellationToken); + { + return _provider.ExistsAsync(Prefixed(key), cancellationToken); + } /// public async Task GetAsync(string key, CancellationToken cancellationToken = default) @@ -102,5 +104,7 @@ public async Task SetAsync(string key, T value, TimeSpan? ttl = null, Cancell } private string Prefixed(string key) - => _keyPrefix.Length == 0 ? key : _keyPrefix + key; + { + return _keyPrefix.Length == 0 ? key : _keyPrefix + key; + } } diff --git a/src/SquidStd.Caching.Abstractions/Services/NoOpCacheMetrics.cs b/src/SquidStd.Caching.Abstractions/Services/NoOpCacheMetrics.cs index 285be016..4d210305 100644 --- a/src/SquidStd.Caching.Abstractions/Services/NoOpCacheMetrics.cs +++ b/src/SquidStd.Caching.Abstractions/Services/NoOpCacheMetrics.cs @@ -3,18 +3,26 @@ namespace SquidStd.Caching.Abstractions.Services; /// -/// Metrics sink that ignores all events. Used when no metrics are configured. +/// Metrics sink that ignores all events. Used when no metrics are configured. /// public sealed class NoOpCacheMetrics : ICacheMetrics { /// Shared instance. public static NoOpCacheMetrics Instance { get; } = new(); - public void OnHit(string key) { } + public void OnHit(string key) + { + } - public void OnMiss(string key) { } + public void OnMiss(string key) + { + } - public void OnRemove(string key) { } + public void OnRemove(string key) + { + } - public void OnSet(string key) { } + public void OnSet(string key) + { + } } diff --git a/src/SquidStd.Caching.Redis/Data/Config/RedisCacheOptions.cs b/src/SquidStd.Caching.Redis/Data/Config/RedisCacheOptions.cs index 30dd72d4..82bf02f9 100644 --- a/src/SquidStd.Caching.Redis/Data/Config/RedisCacheOptions.cs +++ b/src/SquidStd.Caching.Redis/Data/Config/RedisCacheOptions.cs @@ -1,7 +1,7 @@ namespace SquidStd.Caching.Redis.Data.Config; /// -/// Connection options for the Redis cache provider. +/// Connection options for the Redis cache provider. /// public sealed class RedisCacheOptions { diff --git a/src/SquidStd.Caching.Redis/Extensions/RedisCacheRegistrationExtensions.cs b/src/SquidStd.Caching.Redis/Extensions/RedisCacheRegistrationExtensions.cs index 24eda31a..68aecd6b 100644 --- a/src/SquidStd.Caching.Redis/Extensions/RedisCacheRegistrationExtensions.cs +++ b/src/SquidStd.Caching.Redis/Extensions/RedisCacheRegistrationExtensions.cs @@ -11,7 +11,7 @@ namespace SquidStd.Caching.Redis.Extensions; /// -/// DryIoc registration helpers for the Redis cache provider. +/// DryIoc registration helpers for the Redis cache provider. /// public static class RedisCacheRegistrationExtensions { diff --git a/src/SquidStd.Caching.Redis/Services/RedisCacheProvider.cs b/src/SquidStd.Caching.Redis/Services/RedisCacheProvider.cs index 75221a88..7fe28890 100644 --- a/src/SquidStd.Caching.Redis/Services/RedisCacheProvider.cs +++ b/src/SquidStd.Caching.Redis/Services/RedisCacheProvider.cs @@ -5,7 +5,7 @@ namespace SquidStd.Caching.Redis.Services; /// -/// Redis backed by a StackExchange.Redis connection multiplexer. +/// Redis backed by a StackExchange.Redis connection multiplexer. /// public sealed class RedisCacheProvider : ICacheProvider, IAsyncDisposable { @@ -38,7 +38,9 @@ public async ValueTask DisposeAsync() /// public Task ExistsAsync(string key, CancellationToken cancellationToken = default) - => Database.KeyExistsAsync(key); + { + return Database.KeyExistsAsync(key); + } /// public async Task?> GetAsync(string key, CancellationToken cancellationToken = default) @@ -55,7 +57,9 @@ public Task ExistsAsync(string key, CancellationToken cancellationToken = /// public Task RemoveAsync(string key, CancellationToken cancellationToken = default) - => Database.KeyDeleteAsync(key); + { + return Database.KeyDeleteAsync(key); + } /// public async Task SetAsync( @@ -65,15 +69,19 @@ public async Task SetAsync( CancellationToken cancellationToken = default ) { - var expiry = ttl is null ? Expiration.Default : new(ttl.Value); + var expiry = ttl is null ? Expiration.Default : new Expiration(ttl.Value); await Database.StringSetAsync(key, value.ToArray(), expiry); } /// public async ValueTask StartAsync(CancellationToken cancellationToken = default) - => _connection = await ConnectionMultiplexer.ConnectAsync(_options.Configuration); + { + _connection = await ConnectionMultiplexer.ConnectAsync(_options.Configuration); + } /// public ValueTask StopAsync(CancellationToken cancellationToken = default) - => DisposeAsync(); + { + return DisposeAsync(); + } } diff --git a/src/SquidStd.Caching/Extensions/CacheRegistrationExtensions.cs b/src/SquidStd.Caching/Extensions/CacheRegistrationExtensions.cs index 06875df5..ad7a9662 100644 --- a/src/SquidStd.Caching/Extensions/CacheRegistrationExtensions.cs +++ b/src/SquidStd.Caching/Extensions/CacheRegistrationExtensions.cs @@ -11,7 +11,7 @@ namespace SquidStd.Caching.Extensions; /// -/// DryIoc registration helpers for the in-memory cache. +/// DryIoc registration helpers for the in-memory cache. /// public static class CacheRegistrationExtensions { diff --git a/src/SquidStd.Caching/Services/InMemoryCacheProvider.cs b/src/SquidStd.Caching/Services/InMemoryCacheProvider.cs index d63c2879..c7b93ccf 100644 --- a/src/SquidStd.Caching/Services/InMemoryCacheProvider.cs +++ b/src/SquidStd.Caching/Services/InMemoryCacheProvider.cs @@ -4,7 +4,7 @@ namespace SquidStd.Caching.Services; /// -/// In-memory backed by . +/// In-memory backed by . /// public sealed class InMemoryCacheProvider : ICacheProvider { @@ -17,7 +17,9 @@ public InMemoryCacheProvider(IMemoryCache cache) /// public Task ExistsAsync(string key, CancellationToken cancellationToken = default) - => Task.FromResult(_cache.TryGetValue(key, out _)); + { + return Task.FromResult(_cache.TryGetValue(key, out _)); + } /// public Task?> GetAsync(string key, CancellationToken cancellationToken = default) @@ -61,9 +63,13 @@ public Task SetAsync( /// public ValueTask StartAsync(CancellationToken cancellationToken = default) - => ValueTask.CompletedTask; + { + return ValueTask.CompletedTask; + } /// public ValueTask StopAsync(CancellationToken cancellationToken = default) - => ValueTask.CompletedTask; + { + return ValueTask.CompletedTask; + } } diff --git a/src/SquidStd.Core/Data/Bootstrap/SquidStdLoggerOptions.cs b/src/SquidStd.Core/Data/Bootstrap/SquidStdLoggerOptions.cs index e361a730..bd0dd582 100644 --- a/src/SquidStd.Core/Data/Bootstrap/SquidStdLoggerOptions.cs +++ b/src/SquidStd.Core/Data/Bootstrap/SquidStdLoggerOptions.cs @@ -3,37 +3,37 @@ namespace SquidStd.Core.Data.Bootstrap; /// -/// Defines YAML-backed logger options for SquidStd bootstrap. +/// Defines YAML-backed logger options for SquidStd bootstrap. /// public sealed class SquidStdLoggerOptions { /// - /// Gets or sets the minimum logger level. + /// Gets or sets the minimum logger level. /// public LogLevelType MinimumLevel { get; set; } = LogLevelType.Information; /// - /// Gets or sets whether console logging is enabled. + /// Gets or sets whether console logging is enabled. /// public bool EnableConsole { get; set; } = true; /// - /// Gets or sets whether file logging is enabled. + /// Gets or sets whether file logging is enabled. /// public bool EnableFile { get; set; } /// - /// Gets or sets the file log directory. Relative paths are resolved from the SquidStd root directory. + /// Gets or sets the file log directory. Relative paths are resolved from the SquidStd root directory. /// public string LogDirectory { get; set; } = "logs"; /// - /// Gets or sets the file log name or rolling file pattern. + /// Gets or sets the file log name or rolling file pattern. /// public string FileName { get; set; } = "squidstd-.log"; /// - /// Gets or sets the file log rolling interval. + /// Gets or sets the file log rolling interval. /// public SquidStdLogRollingIntervalType RollingInterval { get; set; } = SquidStdLogRollingIntervalType.Day; } diff --git a/src/SquidStd.Core/Data/Bootstrap/SquidStdOptions.cs b/src/SquidStd.Core/Data/Bootstrap/SquidStdOptions.cs index 614508d6..24885cde 100644 --- a/src/SquidStd.Core/Data/Bootstrap/SquidStdOptions.cs +++ b/src/SquidStd.Core/Data/Bootstrap/SquidStdOptions.cs @@ -1,17 +1,17 @@ namespace SquidStd.Core.Data.Bootstrap; /// -/// Defines bootstrap-only options used to locate SquidStd runtime resources. +/// Defines bootstrap-only options used to locate SquidStd runtime resources. /// public sealed class SquidStdOptions { /// - /// Gets or sets the root directory for configuration, logs, and runtime data. + /// Gets or sets the root directory for configuration, logs, and runtime data. /// public string RootDirectory { get; set; } = Directory.GetCurrentDirectory(); /// - /// Gets or sets the logical configuration name or YAML file name. + /// Gets or sets the logical configuration name or YAML file name. /// public string ConfigName { get; set; } = "squidstd"; } diff --git a/src/SquidStd.Core/Data/Config/HealthCheckOptions.cs b/src/SquidStd.Core/Data/Config/HealthCheckOptions.cs index b344fbe1..f578f535 100644 --- a/src/SquidStd.Core/Data/Config/HealthCheckOptions.cs +++ b/src/SquidStd.Core/Data/Config/HealthCheckOptions.cs @@ -1,7 +1,7 @@ namespace SquidStd.Core.Data.Config; /// -/// Options for the health-check aggregator. +/// Options for the health-check aggregator. /// public sealed class HealthCheckOptions { diff --git a/src/SquidStd.Core/Data/Events/EventBusOptions.cs b/src/SquidStd.Core/Data/Events/EventBusOptions.cs new file mode 100644 index 00000000..7893a1dd --- /dev/null +++ b/src/SquidStd.Core/Data/Events/EventBusOptions.cs @@ -0,0 +1,12 @@ +namespace SquidStd.Core.Data.Events; + +/// +/// Options controlling event bus dispatch behavior. +/// +public sealed record EventBusOptions +{ + /// + /// Listeners whose handling exceeds this duration are logged as slow. Defaults to 100ms. + /// + public TimeSpan SlowListenerThreshold { get; init; } = TimeSpan.FromMilliseconds(100); +} diff --git a/src/SquidStd.Core/Data/Health/HealthCheckResult.cs b/src/SquidStd.Core/Data/Health/HealthCheckResult.cs index 344b315c..76bba2b0 100644 --- a/src/SquidStd.Core/Data/Health/HealthCheckResult.cs +++ b/src/SquidStd.Core/Data/Health/HealthCheckResult.cs @@ -3,7 +3,7 @@ namespace SquidStd.Core.Data.Health; /// -/// Result of a single health check. is stamped by the aggregator. +/// Result of a single health check. is stamped by the aggregator. /// public sealed record HealthCheckResult { @@ -21,9 +21,13 @@ public sealed record HealthCheckResult /// Creates a healthy result. public static HealthCheckResult Healthy(string? description = null) - => new() { Status = HealthStatus.Healthy, Description = description }; + { + return new HealthCheckResult { Status = HealthStatus.Healthy, Description = description }; + } /// Creates an unhealthy result. public static HealthCheckResult Unhealthy(string? description = null, Exception? exception = null) - => new() { Status = HealthStatus.Unhealthy, Description = description, Exception = exception }; + { + return new HealthCheckResult { Status = HealthStatus.Unhealthy, Description = description, Exception = exception }; + } } diff --git a/src/SquidStd.Core/Data/Health/HealthReport.cs b/src/SquidStd.Core/Data/Health/HealthReport.cs index 93c05f8c..e9a8a56f 100644 --- a/src/SquidStd.Core/Data/Health/HealthReport.cs +++ b/src/SquidStd.Core/Data/Health/HealthReport.cs @@ -3,7 +3,7 @@ namespace SquidStd.Core.Data.Health; /// -/// Aggregated result of running every registered health check. +/// Aggregated result of running every registered health check. /// public sealed record HealthReport { diff --git a/src/SquidStd.Core/Data/Jobs/JobsConfig.cs b/src/SquidStd.Core/Data/Jobs/JobsConfig.cs index 7f91275b..c83ae3a7 100644 --- a/src/SquidStd.Core/Data/Jobs/JobsConfig.cs +++ b/src/SquidStd.Core/Data/Jobs/JobsConfig.cs @@ -1,17 +1,17 @@ namespace SquidStd.Core.Data.Jobs; /// -/// Configuration for the background job system. +/// Configuration for the background job system. /// public sealed class JobsConfig { /// - /// Gets or sets the number of worker threads. + /// Gets or sets the number of worker threads. /// public int WorkerThreadCount { get; set; } /// - /// Gets or sets the seconds to wait for worker shutdown. + /// Gets or sets the seconds to wait for worker shutdown. /// public double ShutdownTimeoutSeconds { get; set; } = 1.0; } diff --git a/src/SquidStd.Core/Data/Metrics/MetricSample.cs b/src/SquidStd.Core/Data/Metrics/MetricSample.cs index b4612060..69ba580c 100644 --- a/src/SquidStd.Core/Data/Metrics/MetricSample.cs +++ b/src/SquidStd.Core/Data/Metrics/MetricSample.cs @@ -3,7 +3,7 @@ namespace SquidStd.Core.Data.Metrics; /// -/// Represents one collected metric value. +/// Represents one collected metric value. /// /// Metric name emitted by the provider. /// Metric numeric value. diff --git a/src/SquidStd.Core/Data/Metrics/MetricsCollectedEvent.cs b/src/SquidStd.Core/Data/Metrics/MetricsCollectedEvent.cs index 854a3f2b..38efc0ff 100644 --- a/src/SquidStd.Core/Data/Metrics/MetricsCollectedEvent.cs +++ b/src/SquidStd.Core/Data/Metrics/MetricsCollectedEvent.cs @@ -3,7 +3,7 @@ namespace SquidStd.Core.Data.Metrics; /// -/// Event published after a metrics snapshot has been collected. +/// Event published after a metrics snapshot has been collected. /// /// The collected metrics snapshot. public sealed record MetricsCollectedEvent(MetricsSnapshot Snapshot) : IEvent; diff --git a/src/SquidStd.Core/Data/Metrics/MetricsConfig.cs b/src/SquidStd.Core/Data/Metrics/MetricsConfig.cs index 531ce7f7..8c9424eb 100644 --- a/src/SquidStd.Core/Data/Metrics/MetricsConfig.cs +++ b/src/SquidStd.Core/Data/Metrics/MetricsConfig.cs @@ -4,27 +4,27 @@ namespace SquidStd.Core.Data.Metrics; /// -/// Configuration for periodic metrics collection. +/// Configuration for periodic metrics collection. /// public sealed class MetricsConfig : IConfigEntry { /// - /// Gets or sets whether metrics collection is enabled. + /// Gets or sets whether metrics collection is enabled. /// public bool Enabled { get; set; } = true; /// - /// Gets or sets the collection interval in milliseconds. + /// Gets or sets the collection interval in milliseconds. /// public int IntervalMilliseconds { get; set; } = 1000; /// - /// Gets or sets whether each provider collection is logged. + /// Gets or sets whether each provider collection is logged. /// public bool LogEnabled { get; set; } = true; /// - /// Gets or sets the log level used for metrics collection logs. + /// Gets or sets the log level used for metrics collection logs. /// public LogLevelType LogLevel { get; set; } = LogLevelType.Trace; @@ -33,5 +33,7 @@ public sealed class MetricsConfig : IConfigEntry Type IConfigEntry.ConfigType => typeof(MetricsConfig); object IConfigEntry.CreateDefault() - => new MetricsConfig(); + { + return new MetricsConfig(); + } } diff --git a/src/SquidStd.Core/Data/Metrics/MetricsSnapshot.cs b/src/SquidStd.Core/Data/Metrics/MetricsSnapshot.cs index f0343dba..d4174bff 100644 --- a/src/SquidStd.Core/Data/Metrics/MetricsSnapshot.cs +++ b/src/SquidStd.Core/Data/Metrics/MetricsSnapshot.cs @@ -1,12 +1,12 @@ namespace SquidStd.Core.Data.Metrics; /// -/// Stores the last collected metrics batch. +/// Stores the last collected metrics batch. /// public sealed class MetricsSnapshot { /// - /// Initializes a metrics snapshot. + /// Initializes a metrics snapshot. /// /// Timestamp when the snapshot was collected. /// Metrics keyed by their flattened metric name. @@ -17,12 +17,12 @@ public MetricsSnapshot(DateTimeOffset collectedAt, IReadOnlyDictionary - /// Gets the timestamp when the snapshot was collected. + /// Gets the timestamp when the snapshot was collected. /// public DateTimeOffset CollectedAt { get; } /// - /// Gets metrics keyed by their flattened metric name. + /// Gets metrics keyed by their flattened metric name. /// public IReadOnlyDictionary Metrics { get; } } diff --git a/src/SquidStd.Core/Data/Scheduling/CronJobInfo.cs b/src/SquidStd.Core/Data/Scheduling/CronJobInfo.cs index 860db3fd..bf45a4b6 100644 --- a/src/SquidStd.Core/Data/Scheduling/CronJobInfo.cs +++ b/src/SquidStd.Core/Data/Scheduling/CronJobInfo.cs @@ -1,7 +1,7 @@ namespace SquidStd.Core.Data.Scheduling; /// -/// Immutable snapshot describing a registered cron job. +/// Immutable snapshot describing a registered cron job. /// public sealed class CronJobInfo { diff --git a/src/SquidStd.Core/Data/Storage/SecretsConfig.cs b/src/SquidStd.Core/Data/Storage/SecretsConfig.cs index ee471b1a..1708dc4e 100644 --- a/src/SquidStd.Core/Data/Storage/SecretsConfig.cs +++ b/src/SquidStd.Core/Data/Storage/SecretsConfig.cs @@ -3,17 +3,17 @@ namespace SquidStd.Core.Data.Storage; /// -/// Configuration for encrypted local secret storage. +/// Configuration for encrypted local secret storage. /// public sealed class SecretsConfig : IConfigEntry { /// - /// Gets or sets the root directory used by local secret storage. + /// Gets or sets the root directory used by local secret storage. /// public string RootDirectory { get; set; } = "secrets"; /// - /// Gets or sets the environment variable that contains the base64 AES key. + /// Gets or sets the environment variable that contains the base64 AES key. /// public string KeyEnvironmentVariable { get; set; } = "SQUIDSTD_SECRETS_KEY"; @@ -22,5 +22,7 @@ public sealed class SecretsConfig : IConfigEntry Type IConfigEntry.ConfigType => typeof(SecretsConfig); object IConfigEntry.CreateDefault() - => new SecretsConfig(); + { + return new SecretsConfig(); + } } diff --git a/src/SquidStd.Core/Data/Timing/TimerWheelConfig.cs b/src/SquidStd.Core/Data/Timing/TimerWheelConfig.cs index a92aa79d..064129b1 100644 --- a/src/SquidStd.Core/Data/Timing/TimerWheelConfig.cs +++ b/src/SquidStd.Core/Data/Timing/TimerWheelConfig.cs @@ -1,17 +1,17 @@ namespace SquidStd.Core.Data.Timing; /// -/// Configuration for the hashed timer wheel. +/// Configuration for the hashed timer wheel. /// public sealed class TimerWheelConfig { /// - /// Gets or sets the timer wheel granularity. + /// Gets or sets the timer wheel granularity. /// public TimeSpan TickDuration { get; set; } = TimeSpan.FromMilliseconds(8); /// - /// Gets or sets the number of slots in the wheel. + /// Gets or sets the number of slots in the wheel. /// public int WheelSize { get; set; } = 512; } diff --git a/src/SquidStd.Core/Data/Timing/TimerWheelPumpConfig.cs b/src/SquidStd.Core/Data/Timing/TimerWheelPumpConfig.cs index c106b8f8..6c12fadb 100644 --- a/src/SquidStd.Core/Data/Timing/TimerWheelPumpConfig.cs +++ b/src/SquidStd.Core/Data/Timing/TimerWheelPumpConfig.cs @@ -1,12 +1,12 @@ namespace SquidStd.Core.Data.Timing; /// -/// Configuration for the timer wheel pump service. +/// Configuration for the timer wheel pump service. /// public sealed class TimerWheelPumpConfig { /// - /// Gets or sets how often the pump advances the timer wheel. + /// Gets or sets how often the pump advances the timer wheel. /// public TimeSpan PumpInterval { get; set; } = TimeSpan.FromMilliseconds(250); } diff --git a/src/SquidStd.Core/Directories/DirectoriesConfig.cs b/src/SquidStd.Core/Directories/DirectoriesConfig.cs index c7bb0a17..f069b0ca 100644 --- a/src/SquidStd.Core/Directories/DirectoriesConfig.cs +++ b/src/SquidStd.Core/Directories/DirectoriesConfig.cs @@ -3,14 +3,14 @@ namespace SquidStd.Core.Directories; /// -/// Configuration for managing directory structures with automatic creation and path resolution +/// Configuration for managing directory structures with automatic creation and path resolution /// public class DirectoriesConfig { private readonly string[] _directories; /// - /// Initializes a new instance of the DirectoriesConfig class. + /// Initializes a new instance of the DirectoriesConfig class. /// /// The root directory path. /// The array of directory types. @@ -23,34 +23,36 @@ public DirectoriesConfig(string rootDirectory, string[] directories) } /// - /// Gets the root directory path. + /// Gets the root directory path. /// public string Root { get; } /// - /// Gets the path for the specified directory type. + /// Gets the path for the specified directory type. /// /// The directory type as string. /// The path for the directory type. public string this[string directoryType] => GetPath(directoryType); /// - /// Gets the path for the specified directory type enum. + /// Gets the path for the specified directory type enum. /// /// The directory type enum. /// The path for the directory type. public string this[Enum directoryType] => GetPath(directoryType.ToString()); /// - /// Gets the path for the specified directory type enum. + /// Gets the path for the specified directory type enum. /// /// The directory type enum value. /// The path for the directory type. public string GetPath(TEnum value) where TEnum : struct, Enum - => GetPath(Enum.GetName(value)); + { + return GetPath(Enum.GetName(value)); + } /// - /// Gets the path for the specified directory type string. + /// Gets the path for the specified directory type string. /// /// The directory type as string. /// The path for the directory type. @@ -67,14 +69,16 @@ public string GetPath(string directoryType) } /// - /// Returns a string representation of the root directory. + /// Returns a string representation of the root directory. /// /// The root directory path. public override string ToString() - => Root; + { + return Root; + } /// - /// Initializes the directories configuration. + /// Initializes the directories configuration. /// private void Init() { @@ -86,7 +90,7 @@ private void Init() var directoryTypes = _directories.ToList(); foreach (var path in directoryTypes.Select(GetPath) - .Where(path => !Directory.Exists(path))) + .Where(path => !Directory.Exists(path))) { Directory.CreateDirectory(path); } diff --git a/src/SquidStd.Core/Extensions/Directories/DirectoriesExtension.cs b/src/SquidStd.Core/Extensions/Directories/DirectoriesExtension.cs index 69214c52..5e9016bb 100644 --- a/src/SquidStd.Core/Extensions/Directories/DirectoriesExtension.cs +++ b/src/SquidStd.Core/Extensions/Directories/DirectoriesExtension.cs @@ -3,12 +3,12 @@ namespace SquidStd.Core.Extensions.Directories; /// -/// Provides extension methods for directory path resolution and environment variable expansion +/// Provides extension methods for directory path resolution and environment variable expansion /// public static class DirectoriesExtension { /// - /// Resolves path by expanding tilde (~) to user home directory and expanding environment variables + /// Resolves path by expanding tilde (~) to user home directory and expanding environment variables /// /// The path to resolve /// The fully resolved path with expanded environment variables diff --git a/src/SquidStd.Core/Extensions/Env/EnvExtensions.cs b/src/SquidStd.Core/Extensions/Env/EnvExtensions.cs index 7b1ded91..00b602f7 100644 --- a/src/SquidStd.Core/Extensions/Env/EnvExtensions.cs +++ b/src/SquidStd.Core/Extensions/Env/EnvExtensions.cs @@ -4,7 +4,7 @@ namespace SquidStd.Core.Extensions.Env; /// -/// Provides extension methods for expanding environment variables in strings +/// Provides extension methods for expanding environment variables in strings /// public static class EnvExtensions { @@ -14,7 +14,7 @@ public static class EnvExtensions ); /// - /// Expands environment variables in a string using custom $VARIABLE syntax + /// Expands environment variables in a string using custom $VARIABLE syntax /// /// The input string containing environment variable references /// The string with environment variables expanded to their values @@ -36,8 +36,8 @@ public static string ExpandEnvironmentVariables(this string input) } /// - /// Replaces "$VAR" tokens with the matching environment variable value. Unknown variables are - /// left unchanged. + /// Replaces "$VAR" tokens with the matching environment variable value. Unknown variables are + /// left unchanged. /// /// The input string. /// The string with known $VAR tokens substituted. diff --git a/src/SquidStd.Core/Extensions/Logger/LogLevelExtensions.cs b/src/SquidStd.Core/Extensions/Logger/LogLevelExtensions.cs index 4f79d5e5..e313fce1 100644 --- a/src/SquidStd.Core/Extensions/Logger/LogLevelExtensions.cs +++ b/src/SquidStd.Core/Extensions/Logger/LogLevelExtensions.cs @@ -4,17 +4,18 @@ namespace SquidStd.Core.Extensions.Logger; /// -/// Extension methods for converting log levels between different logging frameworks. +/// Extension methods for converting log levels between different logging frameworks. /// public static class LogLevelExtensions { /// - /// Converts a LogLevelType to a Serilog LogEventLevel. + /// Converts a LogLevelType to a Serilog LogEventLevel. /// /// The log level to convert. /// The corresponding Serilog log event level. public static LogEventLevel ToSerilogLogLevel(this LogLevelType logLevel) - => logLevel switch + { + return logLevel switch { LogLevelType.Trace => LogEventLevel.Verbose, LogLevelType.Debug => LogEventLevel.Debug, @@ -24,4 +25,5 @@ public static LogEventLevel ToSerilogLogLevel(this LogLevelType logLevel) LogLevelType.Critical => LogEventLevel.Fatal, _ => LogEventLevel.Information }; + } } diff --git a/src/SquidStd.Core/Extensions/Strings/StringMethodExtension.cs b/src/SquidStd.Core/Extensions/Strings/StringMethodExtension.cs index 75a1ad81..5c9f42d7 100644 --- a/src/SquidStd.Core/Extensions/Strings/StringMethodExtension.cs +++ b/src/SquidStd.Core/Extensions/Strings/StringMethodExtension.cs @@ -3,87 +3,107 @@ namespace SquidStd.Core.Extensions.Strings; /// -/// Provides extension methods for string operations, particularly for case conversions. +/// Provides extension methods for string operations, particularly for case conversions. /// public static class StringMethodExtension { /// - /// Converts a string to camelCase. + /// Converts a string to camelCase. /// /// The string to convert. /// A camelCase version of the input string. public static string ToCamelCase(this string text) - => StringUtils.ToCamelCase(text); + { + return StringUtils.ToCamelCase(text); + } /// - /// Converts a string to Dot Case. + /// Converts a string to Dot Case. /// /// The string to convert. /// A Dot Case version of the input string. public static string ToDotCase(this string text) - => StringUtils.ToDotCase(text); + { + return StringUtils.ToDotCase(text); + } /// - /// Converts a string to kebab-case. + /// Converts a string to kebab-case. /// /// The string to convert. /// A kebab-case version of the input string. public static string ToKebabCase(this string text) - => StringUtils.ToKebabCase(text); + { + return StringUtils.ToKebabCase(text); + } /// - /// Converts a string to PascalCase. + /// Converts a string to PascalCase. /// /// The string to convert. /// A PascalCase version of the input string. public static string ToPascalCase(this string text) - => StringUtils.ToPascalCase(text); + { + return StringUtils.ToPascalCase(text); + } /// - /// Converts a string to Path Case. + /// Converts a string to Path Case. /// /// The string to convert. /// A Path Case version of the input string. public static string ToPathCase(this string text) - => StringUtils.ToPathCase(text); + { + return StringUtils.ToPathCase(text); + } /// - /// Converts a string to Sentence Case. + /// Converts a string to Sentence Case. /// /// The string to convert. /// A Sentence Case version of the input string. public static string ToSentenceCase(this string text) - => StringUtils.ToSentenceCase(text); + { + return StringUtils.ToSentenceCase(text); + } /// - /// Converts a string to snake_case. + /// Converts a string to snake_case. /// /// The string to convert. /// A snake_case version of the input string. public static string ToSnakeCase(this string text) - => StringUtils.ToSnakeCase(text); + { + return StringUtils.ToSnakeCase(text); + } /// - /// Converts a string to UPPER_SNAKE_CASE. + /// Converts a string to UPPER_SNAKE_CASE. /// /// The string to convert. /// An UPPER_SNAKE_CASE version of the input string. public static string ToSnakeCaseUpper(this string text) - => StringUtils.ToUpperSnakeCase(text); + { + return StringUtils.ToUpperSnakeCase(text); + } /// - /// Converts a string to Title Case. + /// Converts a string to Title Case. /// /// The string to convert. /// A Title Case version of the input string. public static string ToTitleCase(this string text) - => StringUtils.ToTitleCase(text); + { + return StringUtils.ToTitleCase(text); + } /// - /// Converts a string to Train Case. + /// Converts a string to Train Case. /// /// The string to convert. /// A Train Case version of the input string. public static string ToTrainCase(this string text) - => StringUtils.ToTrainCase(text); + { + return StringUtils.ToTrainCase(text); + } } diff --git a/src/SquidStd.Core/Interfaces/Bootstrap/ISquidStdBootstrap.cs b/src/SquidStd.Core/Interfaces/Bootstrap/ISquidStdBootstrap.cs index 857851bf..b987f9f2 100644 --- a/src/SquidStd.Core/Interfaces/Bootstrap/ISquidStdBootstrap.cs +++ b/src/SquidStd.Core/Interfaces/Bootstrap/ISquidStdBootstrap.cs @@ -4,57 +4,57 @@ namespace SquidStd.Core.Interfaces.Bootstrap; /// -/// Coordinates SquidStd dependency registration, configuration loading, and service lifecycle. +/// Coordinates SquidStd dependency registration, configuration loading, and service lifecycle. /// public interface ISquidStdBootstrap : IAsyncDisposable { /// - /// Gets the bootstrap options used to configure runtime resources. + /// Gets the bootstrap options used to configure runtime resources. /// SquidStdOptions Options { get; } /// - /// Gets the owned dependency injection container. + /// Gets the owned dependency injection container. /// IContainer Container { get; } /// - /// Applies custom service registrations before the bootstrap lifecycle starts. + /// Applies custom service registrations before the bootstrap lifecycle starts. /// /// Callback that receives and returns the container. /// The same bootstrap instance for chaining. ISquidStdBootstrap ConfigureService(Func configure); /// - /// Applies custom service registrations before the bootstrap lifecycle starts. + /// Applies custom service registrations before the bootstrap lifecycle starts. /// /// Callback that receives and returns the container. /// The same bootstrap instance for chaining. ISquidStdBootstrap ConfigureServices(Func configure); /// - /// Resolves a service from the owned dependency injection container. + /// Resolves a service from the owned dependency injection container. /// /// The service type to resolve. /// The resolved service instance. TService Resolve(); /// - /// Starts services, waits until cancellation, and then stops services. + /// Starts services, waits until cancellation, and then stops services. /// /// Token that controls the run lifetime. /// A task that completes after services have stopped. Task RunAsync(CancellationToken cancellationToken = default); /// - /// Starts registered lifecycle services in priority order. + /// Starts registered lifecycle services in priority order. /// /// Token used to cancel the start operation. /// A task that represents the asynchronous start operation. ValueTask StartAsync(CancellationToken cancellationToken = default); /// - /// Stops started lifecycle services in reverse priority order. + /// Stops started lifecycle services in reverse priority order. /// /// Token used to cancel the stop operation. /// A task that represents the asynchronous stop operation. diff --git a/src/SquidStd.Core/Interfaces/Config/IConfigEntry.cs b/src/SquidStd.Core/Interfaces/Config/IConfigEntry.cs index 0b7a7ca7..c5fcdbd8 100644 --- a/src/SquidStd.Core/Interfaces/Config/IConfigEntry.cs +++ b/src/SquidStd.Core/Interfaces/Config/IConfigEntry.cs @@ -1,22 +1,22 @@ namespace SquidStd.Core.Interfaces.Config; /// -/// Describes a configuration section that can be composed into YAML and registered into DI. +/// Describes a configuration section that can be composed into YAML and registered into DI. /// public interface IConfigEntry { /// - /// Gets the top-level YAML section name. + /// Gets the top-level YAML section name. /// string SectionName { get; } /// - /// Gets the concrete configuration type for this section. + /// Gets the concrete configuration type for this section. /// Type ConfigType { get; } /// - /// Creates a default configuration value for this section. + /// Creates a default configuration value for this section. /// /// The default configuration object. object CreateDefault(); diff --git a/src/SquidStd.Core/Interfaces/Config/IConfigManagerService.cs b/src/SquidStd.Core/Interfaces/Config/IConfigManagerService.cs index 0ae4a387..626620ee 100644 --- a/src/SquidStd.Core/Interfaces/Config/IConfigManagerService.cs +++ b/src/SquidStd.Core/Interfaces/Config/IConfigManagerService.cs @@ -1,50 +1,50 @@ namespace SquidStd.Core.Interfaces.Config; /// -/// Manages the YAML configuration file and registers loaded sections into DI. +/// Manages the YAML configuration file and registers loaded sections into DI. /// public interface IConfigManagerService { /// - /// Gets the logical configuration name. + /// Gets the logical configuration name. /// string ConfigName { get; } /// - /// Gets the directory where the configuration file is searched. + /// Gets the directory where the configuration file is searched. /// string ConfigDirectory { get; } /// - /// Gets the resolved YAML configuration file path. + /// Gets the resolved YAML configuration file path. /// string ConfigPath { get; } /// - /// Gets the registered configuration entries. + /// Gets the registered configuration entries. /// IReadOnlyCollection Entries { get; } /// - /// Composes the currently loaded sections into YAML. + /// Composes the currently loaded sections into YAML. /// /// The composed YAML document. string Compose(); /// - /// Gets a loaded configuration section from DI. + /// Gets a loaded configuration section from DI. /// /// The configuration type. /// The loaded configuration section. TConfig GetConfig() where TConfig : class; /// - /// Loads or creates the configured YAML file and registers every section into DI. + /// Loads or creates the configured YAML file and registers every section into DI. /// void Load(); /// - /// Saves the currently loaded sections to the configured YAML file. + /// Saves the currently loaded sections to the configured YAML file. /// void Save(); } diff --git a/src/SquidStd.Core/Interfaces/Events/IEvent.cs b/src/SquidStd.Core/Interfaces/Events/IEvent.cs index 3b4e0027..9dc7bc9e 100644 --- a/src/SquidStd.Core/Interfaces/Events/IEvent.cs +++ b/src/SquidStd.Core/Interfaces/Events/IEvent.cs @@ -1,6 +1,8 @@ namespace SquidStd.Core.Interfaces.Events; /// -/// Marker contract for events dispatched through the SquidStd event bus. +/// Marker contract for events dispatched through the SquidStd event bus. /// -public interface IEvent { } +public interface IEvent +{ +} diff --git a/src/SquidStd.Core/Interfaces/Events/IEventBus.cs b/src/SquidStd.Core/Interfaces/Events/IEventBus.cs index 87b076e3..879e6344 100644 --- a/src/SquidStd.Core/Interfaces/Events/IEventBus.cs +++ b/src/SquidStd.Core/Interfaces/Events/IEventBus.cs @@ -1,37 +1,39 @@ namespace SquidStd.Core.Interfaces.Events; /// -/// Dispatches synchronous and asynchronous events to registered listeners. +/// In-process event bus that dispatches events to registered listeners in parallel. /// public interface IEventBus { /// - /// Dispatches an event to synchronous listeners and waits until every listener has completed. + /// Publishes an event and blocks until every listener has completed. /// /// The event payload. /// The event type. void Publish(TEvent eventData) where TEvent : IEvent; /// - /// Dispatches an event to asynchronous listeners and waits until every listener has completed. + /// Publishes an event and completes when every listener has finished. /// /// The event payload. /// The cancellation token. /// The event type. - /// A task that completes after all asynchronous listeners finish. - Task PublishAsync(TEvent eventData, CancellationToken cancellationToken) where TEvent : IEvent; + /// A task that completes after all listeners finish. + Task PublishAsync(TEvent eventData, CancellationToken cancellationToken = default) where TEvent : IEvent; /// - /// Registers an asynchronous listener for the specified event type. + /// Registers a listener for the specified event type. Dispose the returned token to unsubscribe. /// - /// Listener that handles published events asynchronously. + /// The listener to register. /// The event type. - void RegisterAsyncListener(IAsyncEventListener listener) where TEvent : IEvent; + /// A token that unsubscribes the listener when disposed. + IDisposable RegisterListener(IEventListener listener) where TEvent : IEvent; /// - /// Registers a synchronous listener for the specified event type. + /// Subscribes a delegate handler for the specified event type. Dispose the returned token to unsubscribe. /// - /// Listener that handles published events. + /// The handler invoked for each published event. /// The event type. - void RegisterListener(ISyncEventListener listener) where TEvent : IEvent; + /// A token that unsubscribes the handler when disposed. + IDisposable Subscribe(Func handler) where TEvent : IEvent; } diff --git a/src/SquidStd.Core/Interfaces/Events/IAsyncEventListener.cs b/src/SquidStd.Core/Interfaces/Events/IEventListener.cs similarity index 60% rename from src/SquidStd.Core/Interfaces/Events/IAsyncEventListener.cs rename to src/SquidStd.Core/Interfaces/Events/IEventListener.cs index f93b9d61..f3a7a3b5 100644 --- a/src/SquidStd.Core/Interfaces/Events/IAsyncEventListener.cs +++ b/src/SquidStd.Core/Interfaces/Events/IEventListener.cs @@ -1,16 +1,16 @@ namespace SquidStd.Core.Interfaces.Events; /// -/// Handles an event asynchronously. +/// Handles an event published on the SquidStd event bus. /// -/// The event type. -public interface IAsyncEventListener where TEvent : IEvent +/// The event type handled by the listener. +public interface IEventListener where TEvent : IEvent { /// - /// Handles the event. + /// Handles a published event. /// /// The event payload. /// The cancellation token. /// A task that completes when the listener finishes handling the event. - Task HandleAsync(TEvent eventData, CancellationToken cancellationToken); + Task HandleAsync(TEvent eventData, CancellationToken cancellationToken = default); } diff --git a/src/SquidStd.Core/Interfaces/Events/ISyncEventListener.cs b/src/SquidStd.Core/Interfaces/Events/ISyncEventListener.cs deleted file mode 100644 index cfb7f6f6..00000000 --- a/src/SquidStd.Core/Interfaces/Events/ISyncEventListener.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace SquidStd.Core.Interfaces.Events; - -/// -/// Handles an event synchronously. -/// -/// The event type. -public interface ISyncEventListener where TEvent : IEvent -{ - /// - /// Handles the event. - /// - /// The event payload. - void Handle(TEvent eventData); -} diff --git a/src/SquidStd.Core/Interfaces/Health/IHealthCheck.cs b/src/SquidStd.Core/Interfaces/Health/IHealthCheck.cs index 1555dad3..8acf796f 100644 --- a/src/SquidStd.Core/Interfaces/Health/IHealthCheck.cs +++ b/src/SquidStd.Core/Interfaces/Health/IHealthCheck.cs @@ -3,7 +3,7 @@ namespace SquidStd.Core.Interfaces.Health; /// -/// A single health check for one component. +/// A single health check for one component. /// public interface IHealthCheck { diff --git a/src/SquidStd.Core/Interfaces/Health/IHealthCheckService.cs b/src/SquidStd.Core/Interfaces/Health/IHealthCheckService.cs index c04b9042..9da3dea7 100644 --- a/src/SquidStd.Core/Interfaces/Health/IHealthCheckService.cs +++ b/src/SquidStd.Core/Interfaces/Health/IHealthCheckService.cs @@ -3,7 +3,7 @@ namespace SquidStd.Core.Interfaces.Health; /// -/// Runs every registered and aggregates the results. +/// Runs every registered and aggregates the results. /// public interface IHealthCheckService { diff --git a/src/SquidStd.Core/Interfaces/Jobs/IJobSystem.cs b/src/SquidStd.Core/Interfaces/Jobs/IJobSystem.cs index 79c5336e..2bdd1104 100644 --- a/src/SquidStd.Core/Interfaces/Jobs/IJobSystem.cs +++ b/src/SquidStd.Core/Interfaces/Jobs/IJobSystem.cs @@ -1,32 +1,32 @@ namespace SquidStd.Core.Interfaces.Jobs; /// -/// Schedules work on a fixed-size pool of worker threads. +/// Schedules work on a fixed-size pool of worker threads. /// public interface IJobSystem : IDisposable { /// - /// Gets the number of worker threads. + /// Gets the number of worker threads. /// int WorkerCount { get; } /// - /// Gets the number of jobs waiting in the queue. + /// Gets the number of jobs waiting in the queue. /// int PendingCount { get; } /// - /// Gets the number of jobs currently executing. + /// Gets the number of jobs currently executing. /// int ActiveCount { get; } /// - /// Gets the number of jobs that completed since startup. + /// Gets the number of jobs that completed since startup. /// long CompletedCount { get; } /// - /// Schedules work on a worker thread. + /// Schedules work on a worker thread. /// /// Work invoked on a worker thread. /// Token used to cancel the job before it starts. @@ -34,7 +34,7 @@ public interface IJobSystem : IDisposable Task ScheduleAsync(Action work, CancellationToken cancellationToken = default); /// - /// Schedules work on a worker thread and returns the result. + /// Schedules work on a worker thread and returns the result. /// /// Work invoked on a worker thread. /// Token used to cancel the job before it starts. diff --git a/src/SquidStd.Core/Interfaces/Metrics/IMetricProvider.cs b/src/SquidStd.Core/Interfaces/Metrics/IMetricProvider.cs index cd13980e..a5d02ade 100644 --- a/src/SquidStd.Core/Interfaces/Metrics/IMetricProvider.cs +++ b/src/SquidStd.Core/Interfaces/Metrics/IMetricProvider.cs @@ -3,17 +3,17 @@ namespace SquidStd.Core.Interfaces.Metrics; /// -/// Provides metric samples for one subsystem domain. +/// Provides metric samples for one subsystem domain. /// public interface IMetricProvider { /// - /// Gets the unique provider name used as metric name prefix. + /// Gets the unique provider name used as metric name prefix. /// string ProviderName { get; } /// - /// Collects the current metric samples for this provider. + /// Collects the current metric samples for this provider. /// /// Token used to cancel collection. /// The collected metric samples. diff --git a/src/SquidStd.Core/Interfaces/Metrics/IMetricsCollectionService.cs b/src/SquidStd.Core/Interfaces/Metrics/IMetricsCollectionService.cs index 37aef401..c0e0989b 100644 --- a/src/SquidStd.Core/Interfaces/Metrics/IMetricsCollectionService.cs +++ b/src/SquidStd.Core/Interfaces/Metrics/IMetricsCollectionService.cs @@ -3,24 +3,24 @@ namespace SquidStd.Core.Interfaces.Metrics; /// -/// Exposes the latest metrics snapshot collected from registered providers. +/// Exposes the latest metrics snapshot collected from registered providers. /// public interface IMetricsCollectionService { /// - /// Gets all metrics from the latest snapshot. + /// Gets all metrics from the latest snapshot. /// /// Metrics keyed by their flattened metric name. IReadOnlyDictionary GetAllMetrics(); /// - /// Gets the latest collected metrics snapshot. + /// Gets the latest collected metrics snapshot. /// /// The current metrics snapshot. MetricsSnapshot GetSnapshot(); /// - /// Gets the current metrics status. + /// Gets the current metrics status. /// /// The current metrics snapshot. MetricsSnapshot GetStatus(); diff --git a/src/SquidStd.Core/Interfaces/Scheduling/ICronScheduler.cs b/src/SquidStd.Core/Interfaces/Scheduling/ICronScheduler.cs index a3e6b1d5..255e6858 100644 --- a/src/SquidStd.Core/Interfaces/Scheduling/ICronScheduler.cs +++ b/src/SquidStd.Core/Interfaces/Scheduling/ICronScheduler.cs @@ -3,7 +3,7 @@ namespace SquidStd.Core.Interfaces.Scheduling; /// -/// Schedules asynchronous jobs on standard 5-field cron expressions (UTC). +/// Schedules asynchronous jobs on standard 5-field cron expressions (UTC). /// public interface ICronScheduler { @@ -11,7 +11,7 @@ public interface ICronScheduler IReadOnlyCollection Jobs { get; } /// - /// Registers a cron job. + /// Registers a cron job. /// /// Logical job name. /// Standard 5-field cron expression, evaluated in UTC. diff --git a/src/SquidStd.Core/Interfaces/Secrets/ISecretProtector.cs b/src/SquidStd.Core/Interfaces/Secrets/ISecretProtector.cs index 07fc86cb..382dea52 100644 --- a/src/SquidStd.Core/Interfaces/Secrets/ISecretProtector.cs +++ b/src/SquidStd.Core/Interfaces/Secrets/ISecretProtector.cs @@ -1,19 +1,19 @@ namespace SquidStd.Core.Interfaces.Secrets; /// -/// Protects and unprotects secret payloads. +/// Protects and unprotects secret payloads. /// public interface ISecretProtector { /// - /// Protects a plaintext payload. + /// Protects a plaintext payload. /// /// Plaintext bytes to protect. /// Protected payload bytes. byte[] Protect(byte[] plaintext); /// - /// Unprotects a protected payload. + /// Unprotects a protected payload. /// /// Protected payload bytes. /// Plaintext bytes. diff --git a/src/SquidStd.Core/Interfaces/Secrets/ISecretStore.cs b/src/SquidStd.Core/Interfaces/Secrets/ISecretStore.cs index a22b87a4..199f23ff 100644 --- a/src/SquidStd.Core/Interfaces/Secrets/ISecretStore.cs +++ b/src/SquidStd.Core/Interfaces/Secrets/ISecretStore.cs @@ -1,12 +1,12 @@ namespace SquidStd.Core.Interfaces.Secrets; /// -/// Stores encrypted secret values by logical name. +/// Stores encrypted secret values by logical name. /// public interface ISecretStore { /// - /// Deletes a secret. + /// Deletes a secret. /// /// The secret name. /// Token used to cancel the operation. @@ -14,7 +14,7 @@ public interface ISecretStore ValueTask DeleteAsync(string name, CancellationToken cancellationToken = default); /// - /// Checks whether a secret exists. + /// Checks whether a secret exists. /// /// The secret name. /// Token used to cancel the operation. @@ -22,7 +22,7 @@ public interface ISecretStore ValueTask ExistsAsync(string name, CancellationToken cancellationToken = default); /// - /// Gets a secret value. + /// Gets a secret value. /// /// The secret name. /// Token used to cancel the operation. @@ -30,7 +30,7 @@ public interface ISecretStore ValueTask GetAsync(string name, CancellationToken cancellationToken = default); /// - /// Sets a secret value. + /// Sets a secret value. /// /// The secret name. /// The secret value. diff --git a/src/SquidStd.Core/Interfaces/Serialization/IDataDeserializer.cs b/src/SquidStd.Core/Interfaces/Serialization/IDataDeserializer.cs index 0c649821..c8fa8416 100644 --- a/src/SquidStd.Core/Interfaces/Serialization/IDataDeserializer.cs +++ b/src/SquidStd.Core/Interfaces/Serialization/IDataDeserializer.cs @@ -1,7 +1,7 @@ namespace SquidStd.Core.Interfaces.Serialization; /// -/// Deserializes bytes to typed values. +/// Deserializes bytes to typed values. /// public interface IDataDeserializer { diff --git a/src/SquidStd.Core/Interfaces/Serialization/IDataSerializer.cs b/src/SquidStd.Core/Interfaces/Serialization/IDataSerializer.cs index 6b4b140c..a216e3be 100644 --- a/src/SquidStd.Core/Interfaces/Serialization/IDataSerializer.cs +++ b/src/SquidStd.Core/Interfaces/Serialization/IDataSerializer.cs @@ -1,7 +1,7 @@ namespace SquidStd.Core.Interfaces.Serialization; /// -/// Serializes typed values to bytes. +/// Serializes typed values to bytes. /// public interface IDataSerializer { diff --git a/src/SquidStd.Core/Interfaces/Threading/IMainThreadDispatcher.cs b/src/SquidStd.Core/Interfaces/Threading/IMainThreadDispatcher.cs index 82fde106..aa9202a1 100644 --- a/src/SquidStd.Core/Interfaces/Threading/IMainThreadDispatcher.cs +++ b/src/SquidStd.Core/Interfaces/Threading/IMainThreadDispatcher.cs @@ -1,24 +1,24 @@ namespace SquidStd.Core.Interfaces.Threading; /// -/// Queues callbacks for execution on the caller that drains the queue. +/// Queues callbacks for execution on the caller that drains the queue. /// public interface IMainThreadDispatcher { /// - /// Gets the number of callbacks waiting to be drained. + /// Gets the number of callbacks waiting to be drained. /// int PendingCount { get; } /// - /// Executes queued callbacks on the calling thread. + /// Executes queued callbacks on the calling thread. /// /// Optional wall-clock budget in milliseconds. /// The number of callbacks executed. int DrainPending(double? budgetMs = null); /// - /// Queues a callback for later execution. + /// Queues a callback for later execution. /// /// Callback to execute when the queue is drained. void Post(Action action); diff --git a/src/SquidStd.Core/Interfaces/Timing/ITimerService.cs b/src/SquidStd.Core/Interfaces/Timing/ITimerService.cs index 41941238..3e8d4a7e 100644 --- a/src/SquidStd.Core/Interfaces/Timing/ITimerService.cs +++ b/src/SquidStd.Core/Interfaces/Timing/ITimerService.cs @@ -1,12 +1,12 @@ namespace SquidStd.Core.Interfaces.Timing; /// -/// Schedules timer callbacks on a hashed timer wheel. +/// Schedules timer callbacks on a hashed timer wheel. /// public interface ITimerService { /// - /// Registers a timer. + /// Registers a timer. /// /// Logical timer name. /// Timer interval. @@ -23,26 +23,26 @@ string RegisterTimer( ); /// - /// Removes all registered timers. + /// Removes all registered timers. /// void UnregisterAllTimers(); /// - /// Removes a timer by id. + /// Removes a timer by id. /// /// Timer id to remove. /// true when a timer was removed; otherwise false. bool UnregisterTimer(string timerId); /// - /// Removes all timers with the specified name. + /// Removes all timers with the specified name. /// /// Timer name to remove. /// The number of removed timers. int UnregisterTimersByName(string name); /// - /// Advances the wheel using an absolute timestamp in milliseconds. + /// Advances the wheel using an absolute timestamp in milliseconds. /// /// Absolute monotonic timestamp in milliseconds. /// The number of processed wheel ticks. diff --git a/src/SquidStd.Core/Json/JsonContextTypeResolver.cs b/src/SquidStd.Core/Json/JsonContextTypeResolver.cs index cf2cf232..ff0214f6 100644 --- a/src/SquidStd.Core/Json/JsonContextTypeResolver.cs +++ b/src/SquidStd.Core/Json/JsonContextTypeResolver.cs @@ -5,12 +5,12 @@ namespace SquidStd.Core.Json; /// -/// Utility class to retrieve registered types from JsonSerializerContext at runtime. +/// Utility class to retrieve registered types from JsonSerializerContext at runtime. /// public static class JsonContextTypeResolver { /// - /// Gets all types registered in the specified JsonSerializerContext. + /// Gets all types registered in the specified JsonSerializerContext. /// /// The JsonSerializerContext to query. /// A collection of registered Type objects. @@ -18,11 +18,10 @@ public static IEnumerable GetRegisteredTypes(JsonSerializerContext context { // Get all JsonTypeInfo properties from the context var properties = GetContextType(context) - .GetProperties() - .Where( - p => p.PropertyType.IsGenericType && - p.PropertyType.GetGenericTypeDefinition() == typeof(JsonTypeInfo<>) - ); + .GetProperties() + .Where(p => p.PropertyType.IsGenericType && + p.PropertyType.GetGenericTypeDefinition() == typeof(JsonTypeInfo<>) + ); foreach (var property in properties) { @@ -34,17 +33,19 @@ public static IEnumerable GetRegisteredTypes(JsonSerializerContext context } /// - /// Gets all registered types that inherit from a specific base type. + /// Gets all registered types that inherit from a specific base type. /// /// The JsonSerializerContext to query. /// The base type to filter by. /// A collection of types that inherit from TBase. public static IEnumerable GetRegisteredTypes(JsonSerializerContext context) - => GetRegisteredTypes(context) + { + return GetRegisteredTypes(context) .Where(t => typeof(TBase).IsAssignableFrom(t)); + } /// - /// Gets all registered types with their corresponding JsonTypeInfo. + /// Gets all registered types with their corresponding JsonTypeInfo. /// /// The JsonSerializerContext to query. /// A dictionary mapping Type to JsonTypeInfo. @@ -53,11 +54,10 @@ public static Dictionary GetRegisteredTypesWithInfo(JsonSeri var result = new Dictionary(); var properties = GetContextType(context) - .GetProperties() - .Where( - p => p.PropertyType.IsGenericType && - p.PropertyType.GetGenericTypeDefinition() == typeof(JsonTypeInfo<>) - ); + .GetProperties() + .Where(p => p.PropertyType.IsGenericType && + p.PropertyType.GetGenericTypeDefinition() == typeof(JsonTypeInfo<>) + ); foreach (var property in properties) { @@ -74,7 +74,7 @@ public static Dictionary GetRegisteredTypesWithInfo(JsonSeri } /// - /// Gets the JsonTypeInfo for a specific type if registered. + /// Gets the JsonTypeInfo for a specific type if registered. /// /// The JsonSerializerContext to query. /// The type to get info for. @@ -90,13 +90,15 @@ public static Dictionary GetRegisteredTypesWithInfo(JsonSeri } /// - /// Checks if a specific type is registered in the context. + /// Checks if a specific type is registered in the context. /// /// The JsonSerializerContext to query. /// The type to check. /// True if the type is registered, false otherwise. public static bool IsTypeRegistered(JsonSerializerContext context, Type type) - => GetRegisteredTypes(context).Contains(type); + { + return GetRegisteredTypes(context).Contains(type); + } [UnconditionalSuppressMessage( "Trimming", @@ -105,5 +107,7 @@ public static bool IsTypeRegistered(JsonSerializerContext context, Type type) )] [return: DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] private static Type GetContextType(JsonSerializerContext context) - => context.GetType(); + { + return context.GetType(); + } } diff --git a/src/SquidStd.Core/Json/JsonDataSerializer.cs b/src/SquidStd.Core/Json/JsonDataSerializer.cs index c20a7576..3a2fd7a1 100644 --- a/src/SquidStd.Core/Json/JsonDataSerializer.cs +++ b/src/SquidStd.Core/Json/JsonDataSerializer.cs @@ -5,9 +5,9 @@ namespace SquidStd.Core.Json; /// -/// Default JSON data serializer based on Web defaults -/// (reflection-based, supports arbitrary types). Implements both -/// and . +/// Default JSON data serializer based on Web defaults +/// (reflection-based, supports arbitrary types). Implements both +/// and . /// public sealed class JsonDataSerializer : IDataSerializer, IDataDeserializer { @@ -15,17 +15,21 @@ public sealed class JsonDataSerializer : IDataSerializer, IDataDeserializer public JsonDataSerializer() { - _options = new(JsonSerializerDefaults.Web); + _options = new JsonSerializerOptions(JsonSerializerDefaults.Web); } - [RequiresUnreferencedCode("JSON deserialization may require types that cannot be statically analyzed."), - RequiresDynamicCode("JSON deserialization may require runtime code generation.")] + [RequiresUnreferencedCode("JSON deserialization may require types that cannot be statically analyzed.")] + [RequiresDynamicCode("JSON deserialization may require runtime code generation.")] public T Deserialize(ReadOnlyMemory data) - => JsonSerializer.Deserialize(data.Span, _options) ?? - throw new InvalidOperationException($"Deserialization returned null for type {typeof(T).Name}."); + { + return JsonSerializer.Deserialize(data.Span, _options) ?? + throw new InvalidOperationException($"Deserialization returned null for type {typeof(T).Name}."); + } - [RequiresUnreferencedCode("JSON serialization may require types that cannot be statically analyzed."), - RequiresDynamicCode("JSON serialization may require runtime code generation.")] + [RequiresUnreferencedCode("JSON serialization may require types that cannot be statically analyzed.")] + [RequiresDynamicCode("JSON serialization may require runtime code generation.")] public ReadOnlyMemory Serialize(T value) - => JsonSerializer.SerializeToUtf8Bytes(value, _options); + { + return JsonSerializer.SerializeToUtf8Bytes(value, _options); + } } diff --git a/src/SquidStd.Core/Json/JsonUtils.cs b/src/SquidStd.Core/Json/JsonUtils.cs index 847a038c..93795587 100644 --- a/src/SquidStd.Core/Json/JsonUtils.cs +++ b/src/SquidStd.Core/Json/JsonUtils.cs @@ -8,7 +8,7 @@ namespace SquidStd.Core.Json; /// -/// Provides utility methods for JSON serialization and deserialization. +/// Provides utility methods for JSON serialization and deserialization. /// public static class JsonUtils { @@ -27,7 +27,7 @@ static JsonUtils() } /// - /// Adds a JSON converter to the global converter list. Thread-safe. + /// Adds a JSON converter to the global converter list. Thread-safe. /// /// The converter to add. public static void AddJsonConverter(JsonConverter converter) @@ -47,18 +47,17 @@ public static void AddJsonConverter(JsonConverter converter) } /// - /// Deserializes a JSON string to an object using global options. + /// Deserializes a JSON string to an object using global options. /// /// The type to deserialize to. /// The JSON string to deserialize. /// Deserialized object. [RequiresUnreferencedCode( - "JSON deserialization may require types that cannot be statically analyzed. Use overload with JsonSerializerContext when possible." - ), - RequiresDynamicCode( - "JSON deserialization may require dynamic code generation. Use overload with JsonSerializerContext when possible." - )] - + "JSON deserialization may require types that cannot be statically analyzed. Use overload with JsonSerializerContext when possible." + )] + [RequiresDynamicCode( + "JSON deserialization may require dynamic code generation. Use overload with JsonSerializerContext when possible." + )] /// /// Deserializes a JSON string to an object using global options. /// @@ -81,7 +80,7 @@ public static void AddJsonConverter(JsonConverter converter) } /// - /// Deserializes a JSON string to an object using a JsonSerializerContext. + /// Deserializes a JSON string to an object using a JsonSerializerContext. /// /// The type to deserialize to. /// The JSON string to deserialize. @@ -106,18 +105,17 @@ public static T Deserialize(string json, JsonSerializerContext context) } /// - /// Deserializes an object from a JSON file. + /// Deserializes an object from a JSON file. /// /// The type to deserialize to. /// Path to the JSON file. /// Deserialized object. [RequiresUnreferencedCode( - "JSON deserialization may require types that cannot be statically analyzed. Use overload with JsonSerializerContext when possible." - ), - RequiresDynamicCode( - "JSON deserialization may require dynamic code generation. Use overload with JsonSerializerContext when possible." - )] - + "JSON deserialization may require types that cannot be statically analyzed. Use overload with JsonSerializerContext when possible." + )] + [RequiresDynamicCode( + "JSON deserialization may require dynamic code generation. Use overload with JsonSerializerContext when possible." + )] /// /// Deserializes an object from a JSON file. /// @@ -148,7 +146,7 @@ public static T DeserializeFromFile(string filePath) } /// - /// Deserializes an object from a JSON file using a JsonSerializerContext. + /// Deserializes an object from a JSON file using a JsonSerializerContext. /// /// The type to deserialize to. /// Path to the JSON file. @@ -171,8 +169,8 @@ public static T DeserializeFromFile(string filePath, JsonSerializerContext co var json = File.ReadAllText(normalizedPath); return JsonSerializer.Deserialize(json, context.GetTypeInfo(typeof(T))) is T typedResult - ? typedResult - : throw new JsonException($"Deserialization returned null for type {typeof(T).Name}"); + ? typedResult + : throw new JsonException($"Deserialization returned null for type {typeof(T).Name}"); } catch (JsonException ex) { @@ -188,19 +186,18 @@ public static T DeserializeFromFile(string filePath, JsonSerializerContext co } /// - /// Deserializes an object from a JSON file asynchronously. + /// Deserializes an object from a JSON file asynchronously. /// /// The type to deserialize to. /// Path to the JSON file. /// Cancellation token. /// Deserialized object. [RequiresUnreferencedCode( - "JSON deserialization may require types that cannot be statically analyzed. Use overload with JsonSerializerContext when possible." - ), - RequiresDynamicCode( - "JSON deserialization may require dynamic code generation. Use overload with JsonSerializerContext when possible." - )] - + "JSON deserialization may require types that cannot be statically analyzed. Use overload with JsonSerializerContext when possible." + )] + [RequiresDynamicCode( + "JSON deserialization may require dynamic code generation. Use overload with JsonSerializerContext when possible." + )] /// /// Deserializes an object from a JSON file asynchronously. /// @@ -232,19 +229,18 @@ public static async Task DeserializeFromFileAsync(string filePath, Cancell } /// - /// Deserializes an object from a stream asynchronously. + /// Deserializes an object from a stream asynchronously. /// /// The type to deserialize to. /// The stream containing JSON data. /// Cancellation token. /// Deserialized object. [RequiresUnreferencedCode( - "JSON deserialization may require types that cannot be statically analyzed. Use overload with JsonSerializerContext when possible." - ), - RequiresDynamicCode( - "JSON deserialization may require dynamic code generation. Use overload with JsonSerializerContext when possible." - )] - + "JSON deserialization may require types that cannot be statically analyzed. Use overload with JsonSerializerContext when possible." + )] + [RequiresDynamicCode( + "JSON deserialization may require dynamic code generation. Use overload with JsonSerializerContext when possible." + )] /// /// Deserializes an object from a stream asynchronously. /// @@ -259,7 +255,7 @@ public static async Task DeserializeFromStreamAsync(Stream stream, Cancell try { var result = await JsonSerializer.DeserializeAsync(stream, GetJsonSerializerOptions(), cancellationToken) - .ConfigureAwait(false); + .ConfigureAwait(false); return result ?? throw new JsonException($"Deserialization returned null for type {typeof(T).Name}"); } @@ -270,19 +266,18 @@ public static async Task DeserializeFromStreamAsync(Stream stream, Cancell } /// - /// Deserializes a JSON string to an object with fallback value. + /// Deserializes a JSON string to an object with fallback value. /// /// The type to deserialize to. /// The JSON string to deserialize. /// Default value if deserialization fails. /// Deserialized object or default value. [RequiresUnreferencedCode( - "JSON deserialization may require types that cannot be statically analyzed. Use overload with JsonSerializerContext when possible." - ), - RequiresDynamicCode( - "JSON deserialization may require dynamic code generation. Use overload with JsonSerializerContext when possible." - )] - + "JSON deserialization may require types that cannot be statically analyzed. Use overload with JsonSerializerContext when possible." + )] + [RequiresDynamicCode( + "JSON deserialization may require dynamic code generation. Use overload with JsonSerializerContext when possible." + )] /// /// Deserializes a JSON string to an object with fallback value. /// @@ -308,7 +303,7 @@ public static async Task DeserializeFromStreamAsync(Stream stream, Cancell } /// - /// Gets a read-only view of the current JSON converters. + /// Gets a read-only view of the current JSON converters. /// /// A read-only list of JSON converters. public static IReadOnlyList GetJsonConverters() @@ -321,13 +316,15 @@ public static IReadOnlyList GetJsonConverters() } /// - /// Gets the current JSON serializer options. Thread-safe. + /// Gets the current JSON serializer options. Thread-safe. /// public static JsonSerializerOptions GetJsonSerializerOptions() - => _jsonSerializerOptions ?? throw new InvalidOperationException("JsonSerializerOptions not initialized"); + { + return _jsonSerializerOptions ?? throw new InvalidOperationException("JsonSerializerOptions not initialized"); + } /// - /// Gets cached JSON serializer options combined with a context resolver. Thread-safe. + /// Gets cached JSON serializer options combined with a context resolver. Thread-safe. /// public static JsonSerializerOptions GetJsonSerializerOptions(JsonSerializerContext context) { @@ -339,7 +336,7 @@ public static JsonSerializerOptions GetJsonSerializerOptions(JsonSerializerConte { var baseOptions = GetJsonSerializerOptions(); - return new(baseOptions) + return new JsonSerializerOptions(baseOptions) { TypeInfoResolver = JsonTypeInfoResolver.Combine(context, baseOptions.TypeInfoResolver) }; @@ -348,7 +345,7 @@ public static JsonSerializerOptions GetJsonSerializerOptions(JsonSerializerConte } /// - /// Generates a schema file name for the given type using snake_case convention. + /// Generates a schema file name for the given type using snake_case convention. /// /// The type to generate a schema name for. /// A schema file name in the format "type_name.schema.json". @@ -371,7 +368,7 @@ public static string GetSchemaFileName(Type type) } /// - /// Determines if a JSON string represents an array. + /// Determines if a JSON string represents an array. /// /// The JSON string to analyze. /// True if the JSON represents an array, false otherwise. @@ -395,7 +392,7 @@ public static bool IsArray(string json) } /// - /// Determines if a JSON file contains an array. + /// Determines if a JSON file contains an array. /// /// Path to the JSON file to analyze. /// True if the JSON file contains an array, false otherwise. @@ -423,7 +420,7 @@ public static bool IsArrayFile(string filePath) } /// - /// Validates if a string is valid JSON without deserializing. + /// Validates if a string is valid JSON without deserializing. /// /// The JSON string to validate. /// True if valid JSON, false otherwise. @@ -447,7 +444,7 @@ public static bool IsValidJson(string json) } /// - /// Registers a JSON serializer context for source generation. Thread-safe. + /// Registers a JSON serializer context for source generation. Thread-safe. /// /// The context to register. public static void RegisterJsonContext(JsonSerializerContext context) @@ -459,7 +456,7 @@ public static void RegisterJsonContext(JsonSerializerContext context) } /// - /// Removes all converters of the specified type. Thread-safe. + /// Removes all converters of the specified type. Thread-safe. /// /// The converter type to remove. /// True if any converters were removed. @@ -497,18 +494,17 @@ public static bool RemoveJsonConverter() where T : JsonConverter } /// - /// Serializes an object to JSON string using global options. + /// Serializes an object to JSON string using global options. /// /// The type to serialize. /// The object to serialize. /// JSON string representation. [RequiresUnreferencedCode( - "JSON serialization may require types that cannot be statically analyzed. Use overload with JsonSerializerContext when possible." - ), - RequiresDynamicCode( - "JSON serialization may require dynamic code generation. Use overload with JsonSerializerContext when possible." - )] - + "JSON serialization may require types that cannot be statically analyzed. Use overload with JsonSerializerContext when possible." + )] + [RequiresDynamicCode( + "JSON serialization may require dynamic code generation. Use overload with JsonSerializerContext when possible." + )] /// /// Serializes an object to JSON string using global options. /// @@ -530,19 +526,18 @@ public static string Serialize(T obj) } /// - /// Serializes an object to JSON string using custom options. + /// Serializes an object to JSON string using custom options. /// /// The type to serialize. /// The object to serialize. /// Custom serialization options. /// JSON string representation. [RequiresUnreferencedCode( - "JSON serialization may require types that cannot be statically analyzed. Use overload with JsonSerializerContext when possible." - ), - RequiresDynamicCode( - "JSON serialization may require dynamic code generation. Use overload with JsonSerializerContext when possible." - )] - + "JSON serialization may require types that cannot be statically analyzed. Use overload with JsonSerializerContext when possible." + )] + [RequiresDynamicCode( + "JSON serialization may require dynamic code generation. Use overload with JsonSerializerContext when possible." + )] /// /// Serializes an object to JSON string using custom options. /// @@ -566,18 +561,17 @@ public static string Serialize(T obj, JsonSerializerOptions options) } /// - /// Serializes multiple objects to JSON files in a directory. + /// Serializes multiple objects to JSON files in a directory. /// /// The type to serialize. /// Dictionary of filename to object mappings. /// Target directory path. [RequiresUnreferencedCode( - "JSON serialization may require types that cannot be statically analyzed. Use overload with JsonSerializerContext when possible." - ), - RequiresDynamicCode( - "JSON serialization may require dynamic code generation. Use overload with JsonSerializerContext when possible." - )] - + "JSON serialization may require types that cannot be statically analyzed. Use overload with JsonSerializerContext when possible." + )] + [RequiresDynamicCode( + "JSON serialization may require dynamic code generation. Use overload with JsonSerializerContext when possible." + )] /// /// Serializes multiple objects to JSON files in a directory. /// @@ -610,18 +604,17 @@ public static void SerializeMultipleToDirectory(Dictionary objects } /// - /// Serializes an object to a JSON file with directory creation. + /// Serializes an object to a JSON file with directory creation. /// /// The type to serialize. /// The object to serialize. /// Path to the output JSON file. [RequiresUnreferencedCode( - "JSON serialization may require types that cannot be statically analyzed. Use overload with JsonSerializerContext when possible." - ), - RequiresDynamicCode( - "JSON serialization may require dynamic code generation. Use overload with JsonSerializerContext when possible." - )] - + "JSON serialization may require types that cannot be statically analyzed. Use overload with JsonSerializerContext when possible." + )] + [RequiresDynamicCode( + "JSON serialization may require dynamic code generation. Use overload with JsonSerializerContext when possible." + )] /// /// Serializes an object to a JSON file with directory creation. /// @@ -653,19 +646,18 @@ public static void SerializeToFile(T obj, string filePath) } /// - /// Serializes an object to a JSON file asynchronously with directory creation. + /// Serializes an object to a JSON file asynchronously with directory creation. /// /// The type to serialize. /// The object to serialize. /// Path to the output JSON file. /// Cancellation token. [RequiresUnreferencedCode( - "JSON serialization may require types that cannot be statically analyzed. Use overload with JsonSerializerContext when possible." - ), - RequiresDynamicCode( - "JSON serialization may require dynamic code generation. Use overload with JsonSerializerContext when possible." - )] - + "JSON serialization may require types that cannot be statically analyzed. Use overload with JsonSerializerContext when possible." + )] + [RequiresDynamicCode( + "JSON serialization may require dynamic code generation. Use overload with JsonSerializerContext when possible." + )] /// /// Serializes an object to a JSON file asynchronously with directory creation. /// diff --git a/src/SquidStd.Core/Logging/EventSink.cs b/src/SquidStd.Core/Logging/EventSink.cs index b38d85a1..88eb7d0e 100644 --- a/src/SquidStd.Core/Logging/EventSink.cs +++ b/src/SquidStd.Core/Logging/EventSink.cs @@ -4,25 +4,13 @@ namespace SquidStd.Core.Logging; /// -/// A Serilog sink that raises events when logs are received. -/// Subscribe to to receive log events. +/// A Serilog sink that raises events when logs are received. +/// Subscribe to to receive log events. /// public class EventSink : ILogEventSink { /// - /// Event raised when a log event is received. - /// - public static event EventHandler? OnLogReceived; - - /// - /// Clears all event subscribers. - /// Useful for cleanup or testing. - /// - public static void ClearSubscribers() - => OnLogReceived = null; - - /// - /// Emits a log event to all subscribers. + /// Emits a log event to all subscribers. /// /// The log event to emit. public void Emit(LogEvent logEvent) @@ -70,4 +58,18 @@ public void Emit(LogEvent logEvent) // Could log to Debug or another sink if needed } } + + /// + /// Event raised when a log event is received. + /// + public static event EventHandler? OnLogReceived; + + /// + /// Clears all event subscribers. + /// Useful for cleanup or testing. + /// + public static void ClearSubscribers() + { + OnLogReceived = null; + } } diff --git a/src/SquidStd.Core/Logging/EventSinkExtensions.cs b/src/SquidStd.Core/Logging/EventSinkExtensions.cs index b89f8244..fbdc1cf5 100644 --- a/src/SquidStd.Core/Logging/EventSinkExtensions.cs +++ b/src/SquidStd.Core/Logging/EventSinkExtensions.cs @@ -5,13 +5,13 @@ namespace SquidStd.Core.Logging; /// -/// Extension methods for configuring the EventSink. +/// Extension methods for configuring the EventSink. /// public static class EventSinkExtensions { /// - /// Adds the EventSink to the Serilog pipeline. - /// Subscribe to to receive log events. + /// Adds the EventSink to the Serilog pipeline. + /// Subscribe to to receive log events. /// /// The logger sink configuration. /// The minimum log level to capture. Defaults to Verbose (all logs). @@ -20,5 +20,7 @@ public static LoggerConfiguration EventSink( this LoggerSinkConfiguration sinkConfiguration, LogEventLevel restrictedToMinimumLevel = LogEventLevel.Verbose ) - => sinkConfiguration.Sink(new EventSink(), restrictedToMinimumLevel); + { + return sinkConfiguration.Sink(new EventSink(), restrictedToMinimumLevel); + } } diff --git a/src/SquidStd.Core/Logging/LogEventData.cs b/src/SquidStd.Core/Logging/LogEventData.cs index 97c25271..57d92a5f 100644 --- a/src/SquidStd.Core/Logging/LogEventData.cs +++ b/src/SquidStd.Core/Logging/LogEventData.cs @@ -3,37 +3,37 @@ namespace SquidStd.Core.Logging; /// -/// Contains data for a log event. +/// Contains data for a log event. /// public class LogEventData { /// - /// Gets the log level. + /// Gets the log level. /// public LogEventLevel Level { get; init; } /// - /// Gets the timestamp of the log event. + /// Gets the timestamp of the log event. /// public DateTimeOffset Timestamp { get; init; } /// - /// Gets the formatted log message. + /// Gets the formatted log message. /// public string Message { get; init; } = string.Empty; /// - /// Gets the exception associated with the log event, if any. + /// Gets the exception associated with the log event, if any. /// public Exception? Exception { get; init; } /// - /// Gets the properties associated with the log event. + /// Gets the properties associated with the log event. /// public IReadOnlyDictionary Properties { get; init; } = new Dictionary(); /// - /// Gets the source context (usually the logger name/class). + /// Gets the source context (usually the logger name/class). /// public string? SourceContext { get; init; } } diff --git a/src/SquidStd.Core/Types/Health/HealthStatus.cs b/src/SquidStd.Core/Types/Health/HealthStatus.cs index d005a10e..9dc66aed 100644 --- a/src/SquidStd.Core/Types/Health/HealthStatus.cs +++ b/src/SquidStd.Core/Types/Health/HealthStatus.cs @@ -1,7 +1,7 @@ namespace SquidStd.Core.Types.Health; /// -/// Overall health state of a check or report. +/// Overall health state of a check or report. /// public enum HealthStatus { diff --git a/src/SquidStd.Core/Types/LogLevelType.cs b/src/SquidStd.Core/Types/LogLevelType.cs index 43ef7f77..6133c86c 100644 --- a/src/SquidStd.Core/Types/LogLevelType.cs +++ b/src/SquidStd.Core/Types/LogLevelType.cs @@ -1,42 +1,42 @@ namespace SquidStd.Core.Types; /// -/// public enum LogLevelType : byte. +/// public enum LogLevelType : byte. /// public enum LogLevelType : byte { /// - /// No logging. + /// No logging. /// None = 0, /// - /// Trace level logging. + /// Trace level logging. /// Trace = 1, /// - /// Debug level logging. + /// Debug level logging. /// Debug = 2, /// - /// Information level logging. + /// Information level logging. /// Information = 3, /// - /// Warning level logging. + /// Warning level logging. /// Warning = 4, /// - /// Error level logging. + /// Error level logging. /// Error = 5, /// - /// Critical level logging. + /// Critical level logging. /// Critical = 6 } diff --git a/src/SquidStd.Core/Types/Metrics/MetricType.cs b/src/SquidStd.Core/Types/Metrics/MetricType.cs index f59560ff..6202e72a 100644 --- a/src/SquidStd.Core/Types/Metrics/MetricType.cs +++ b/src/SquidStd.Core/Types/Metrics/MetricType.cs @@ -1,22 +1,22 @@ namespace SquidStd.Core.Types.Metrics; /// -/// Defines the semantic type of a metric sample. +/// Defines the semantic type of a metric sample. /// public enum MetricType { /// - /// A value that can go up or down. + /// A value that can go up or down. /// Gauge, /// - /// A cumulative value that only increases. + /// A cumulative value that only increases. /// Counter, /// - /// A value that represents a distribution bucket or aggregate. + /// A value that represents a distribution bucket or aggregate. /// Histogram } diff --git a/src/SquidStd.Core/Types/PlatformType.cs b/src/SquidStd.Core/Types/PlatformType.cs index 21f3f66c..56571088 100644 --- a/src/SquidStd.Core/Types/PlatformType.cs +++ b/src/SquidStd.Core/Types/PlatformType.cs @@ -1,7 +1,7 @@ namespace SquidStd.Core.Types; /// -/// Enumerates the supported platform types. +/// Enumerates the supported platform types. /// public enum PlatformType : byte { diff --git a/src/SquidStd.Core/Types/SquidStdLogRollingIntervalType.cs b/src/SquidStd.Core/Types/SquidStdLogRollingIntervalType.cs index 9aa4830c..7584213a 100644 --- a/src/SquidStd.Core/Types/SquidStdLogRollingIntervalType.cs +++ b/src/SquidStd.Core/Types/SquidStdLogRollingIntervalType.cs @@ -1,37 +1,37 @@ namespace SquidStd.Core.Types; /// -/// Defines file log rolling intervals. +/// Defines file log rolling intervals. /// public enum SquidStdLogRollingIntervalType { /// - /// Does not roll log files automatically. + /// Does not roll log files automatically. /// Infinite = 0, /// - /// Rolls log files every year. + /// Rolls log files every year. /// Year = 1, /// - /// Rolls log files every month. + /// Rolls log files every month. /// Month = 2, /// - /// Rolls log files every day. + /// Rolls log files every day. /// Day = 3, /// - /// Rolls log files every hour. + /// Rolls log files every hour. /// Hour = 4, /// - /// Rolls log files every minute. + /// Rolls log files every minute. /// Minute = 5 } diff --git a/src/SquidStd.Core/Utils/ChecksumUtils.cs b/src/SquidStd.Core/Utils/ChecksumUtils.cs new file mode 100644 index 00000000..8d1fdb15 --- /dev/null +++ b/src/SquidStd.Core/Utils/ChecksumUtils.cs @@ -0,0 +1,24 @@ +namespace SquidStd.Core.Utils; + +/// +/// FNV-1a 32-bit non-cryptographic checksum used to validate persisted binary records. +/// +public static class ChecksumUtils +{ + private const uint FnvOffsetBasis = 2166136261; + private const uint FnvPrime = 16777619; + + /// Computes the FNV-1a 32-bit checksum of the given bytes. + public static uint Compute(ReadOnlySpan data) + { + var hash = FnvOffsetBasis; + + for (var i = 0; i < data.Length; i++) + { + hash ^= data[i]; + hash *= FnvPrime; + } + + return hash; + } +} diff --git a/src/SquidStd.Core/Utils/DirectoriesUtils.cs b/src/SquidStd.Core/Utils/DirectoriesUtils.cs index 7fd8e34a..ffd8612b 100644 --- a/src/SquidStd.Core/Utils/DirectoriesUtils.cs +++ b/src/SquidStd.Core/Utils/DirectoriesUtils.cs @@ -1,21 +1,23 @@ namespace SquidStd.Core.Utils; /// -/// Utility methods for working with directories and file system operations +/// Utility methods for working with directories and file system operations /// public class DirectoriesUtils { /// - /// Gets files from the specified path recursively with optional extension filtering + /// Gets files from the specified path recursively with optional extension filtering /// /// Directory path to search /// File extensions to filter by (e.g., "*.txt", "*.json") /// Array of file paths matching the criteria public static string[] GetFiles(string path, params string[] extensions) - => GetFiles(path, true, extensions); + { + return GetFiles(path, true, extensions); + } /// - /// Gets files from the specified path with configurable recursion and extension filtering + /// Gets files from the specified path with configurable recursion and extension filtering /// /// Directory path to search /// Whether to search subdirectories recursively diff --git a/src/SquidStd.Core/Utils/HashUtils.cs b/src/SquidStd.Core/Utils/HashUtils.cs index 7c63ae4e..786a1215 100644 --- a/src/SquidStd.Core/Utils/HashUtils.cs +++ b/src/SquidStd.Core/Utils/HashUtils.cs @@ -4,7 +4,7 @@ namespace SquidStd.Core.Utils; /// -/// Provides password hashing and verification helpers using PBKDF2-SHA256. +/// Provides password hashing and verification helpers using PBKDF2-SHA256. /// public static class HashUtils { @@ -14,7 +14,7 @@ public static class HashUtils private const int DefaultSaltSize = 16; /// - /// Hashes a password using PBKDF2-SHA256 and returns a serialized payload. + /// Hashes a password using PBKDF2-SHA256 and returns a serialized payload. /// /// Plain password. /// Serialized hash payload. @@ -41,7 +41,7 @@ public static string HashPassword(string password) } /// - /// Verifies a plain password against a serialized PBKDF2-SHA256 payload. + /// Verifies a plain password against a serialized PBKDF2-SHA256 payload. /// /// Plain password. /// Serialized hash payload. diff --git a/src/SquidStd.Core/Utils/NetworkUtils.cs b/src/SquidStd.Core/Utils/NetworkUtils.cs index e32c695e..7d0258b5 100644 --- a/src/SquidStd.Core/Utils/NetworkUtils.cs +++ b/src/SquidStd.Core/Utils/NetworkUtils.cs @@ -4,12 +4,12 @@ namespace SquidStd.Core.Utils; /// -/// Utility methods for network configuration parsing. +/// Utility methods for network configuration parsing. /// public static class NetworkUtils { /// - /// Enumerates local unicast endpoints matching the supplied endpoint address family. + /// Enumerates local unicast endpoints matching the supplied endpoint address family. /// /// The template endpoint supplying address family and port. /// The matching local endpoints. @@ -18,17 +18,16 @@ public static IEnumerable GetListeningAddresses(IPEndPoint endPoint) ArgumentNullException.ThrowIfNull(endPoint); return NetworkInterface.GetAllNetworkInterfaces() - .SelectMany( - adapter => - adapter.GetIPProperties() - .UnicastAddresses - .Where(unicast => endPoint.AddressFamily == unicast.Address.AddressFamily) - .Select(unicast => new IPEndPoint(unicast.Address, endPoint.Port)) - ); + .SelectMany(adapter => + adapter.GetIPProperties() + .UnicastAddresses + .Where(unicast => endPoint.AddressFamily == unicast.Address.AddressFamily) + .Select(unicast => new IPEndPoint(unicast.Address, endPoint.Port)) + ); } /// - /// Parses an IP address, treating "*" as every IPv4 interface. + /// Parses an IP address, treating "*" as every IPv4 interface. /// /// The IP address or "*". /// The parsed IP address. @@ -40,7 +39,7 @@ public static IPAddress ParseIpAddress(string ipAddress) } /// - /// Parses a comma-separated port list with optional ranges. + /// Parses a comma-separated port list with optional ranges. /// /// The ports string, such as "6666-6668,6669,8000". /// The parsed ports. diff --git a/src/SquidStd.Core/Utils/PlatformUtils.cs b/src/SquidStd.Core/Utils/PlatformUtils.cs index 29c7583c..2debaa05 100644 --- a/src/SquidStd.Core/Utils/PlatformUtils.cs +++ b/src/SquidStd.Core/Utils/PlatformUtils.cs @@ -3,12 +3,12 @@ namespace SquidStd.Core.Utils; /// -/// Provides utilities for detecting the current platform. +/// Provides utilities for detecting the current platform. /// public static class PlatformUtils { /// - /// Gets the current platform type. + /// Gets the current platform type. /// /// The detected platform type. public static PlatformType GetCurrentPlatform() @@ -27,23 +27,29 @@ public static PlatformType GetCurrentPlatform() } /// - /// Checks if the application is running on Linux. + /// Checks if the application is running on Linux. /// /// True if running on Linux, otherwise false. public static bool IsRunningOnLinux() - => OperatingSystem.IsLinux(); + { + return OperatingSystem.IsLinux(); + } /// - /// Checks if the application is running on macOS. + /// Checks if the application is running on macOS. /// /// True if running on macOS, otherwise false. public static bool IsRunningOnMacOS() - => OperatingSystem.IsMacOS(); + { + return OperatingSystem.IsMacOS(); + } /// - /// Checks if the application is running on Windows. + /// Checks if the application is running on Windows. /// /// True if running on Windows, otherwise false. public static bool IsRunningOnWindows() - => OperatingSystem.IsWindows(); + { + return OperatingSystem.IsWindows(); + } } diff --git a/src/SquidStd.Core/Utils/ResourceUtils.cs b/src/SquidStd.Core/Utils/ResourceUtils.cs index 2559ba47..55fb52f8 100644 --- a/src/SquidStd.Core/Utils/ResourceUtils.cs +++ b/src/SquidStd.Core/Utils/ResourceUtils.cs @@ -3,12 +3,12 @@ namespace SquidStd.Core.Utils; /// -/// Provides utilities for working with embedded resources. +/// Provides utilities for working with embedded resources. /// public static class ResourceUtils { /// - /// Converts a resource name to a file path format. + /// Converts a resource name to a file path format. /// /// The resource name to convert. /// The base namespace to remove. @@ -38,7 +38,7 @@ public static string ConvertResourceNameToPath(string resourceName, string baseN } /// - /// Copies all embedded resources from an assembly to a destination directory + /// Copies all embedded resources from an assembly to a destination directory /// /// The assembly containing the embedded resources /// The directory where resources will be copied @@ -72,7 +72,7 @@ public static void CopyEmbeddedToDirectory(Assembly assembly, string destination } /// - /// Converts an embedded resource name to a file path format + /// Converts an embedded resource name to a file path format /// /// The full embedded resource name /// The assembly prefix to remove from the resource name @@ -88,7 +88,7 @@ public static string EmbeddedNameToPath(string resourceName, string assemblyPref } /// - /// Converts an embedded resource name to a directory path structure + /// Converts an embedded resource name to a directory path structure /// /// The embedded resource name (e.g., "Assets.Fonts.DefaultUiFont.ttf") /// Optional base namespace to remove from the beginning @@ -128,7 +128,7 @@ public static string GetDirectoryPathFromResourceName(string resourceName, strin } /// - /// Gets an embedded resource as a byte array wrapped in Memory + /// Gets an embedded resource as a byte array wrapped in Memory /// /// The assembly containing the resource /// The full resource name @@ -145,8 +145,7 @@ public static Memory GetEmbeddedResourceByteArray(Assembly assembly, strin { // Try to find a partial match var resourceNames = assembly.GetManifestResourceNames(); - var matchingResource = resourceNames.FirstOrDefault( - n => n.EndsWith( + var matchingResource = resourceNames.FirstOrDefault(n => n.EndsWith( resourceName.Replace('/', '.').Replace('\\', '.'), StringComparison.Ordinal ) @@ -168,12 +167,12 @@ public static Memory GetEmbeddedResourceByteArray(Assembly assembly, strin using var memoryStream = new MemoryStream(); stream.CopyTo(memoryStream); - return new(memoryStream.ToArray()); + return new Memory(memoryStream.ToArray()); } } /// - /// Reads the content of an embedded resource as a byte array + /// Reads the content of an embedded resource as a byte array /// /// Resource path (e.g. "Assets/Templates/welcome.scriban") /// The assembly to search in (if null, uses current assembly) @@ -221,7 +220,7 @@ public static byte[] GetEmbeddedResourceContent(string resourcePath, Assembly as } /// - /// Gets a list of all files in a specific embedded directory + /// Gets a list of all files in a specific embedded directory /// /// The assembly to search in (if null, uses current assembly) /// Directory path to search (e.g. "Assets/Templates") @@ -256,7 +255,7 @@ public static List GetEmbeddedResourceFileNames( } /// - /// Gets a list of all embedded resources that match a given pattern + /// Gets a list of all embedded resources that match a given pattern /// /// The assembly to search in (if null, uses current assembly) /// Directory path to search (e.g. "Assets.Templates") @@ -289,17 +288,19 @@ public static string[] GetEmbeddedResourceNames(Assembly assembly = null, string } /// - /// Gets a stream for an embedded resource, inferring the assembly from . + /// Gets a stream for an embedded resource, inferring the assembly from . /// /// Any type defined in the target assembly. /// The full resource name or a path-style suffix. /// A stream for the resource. /// Thrown when the resource cannot be found. public static Stream GetEmbeddedResourceStream(string resourceName) - => GetEmbeddedResourceStream(typeof(TClass).Assembly, resourceName); + { + return GetEmbeddedResourceStream(typeof(TClass).Assembly, resourceName); + } /// - /// Gets a stream for an embedded resource. + /// Gets a stream for an embedded resource. /// /// The assembly containing the resource. /// The full resource name. @@ -316,8 +317,7 @@ public static Stream GetEmbeddedResourceStream(Assembly assembly, string resourc { // Try to find a partial match var resourceNames = assembly.GetManifestResourceNames(); - var matchingResource = resourceNames.FirstOrDefault( - n => n.EndsWith( + var matchingResource = resourceNames.FirstOrDefault(n => n.EndsWith( resourceName.Replace('/', '.').Replace('\\', '.'), StringComparison.Ordinal ) @@ -350,8 +350,8 @@ public static string GetEmbeddedResourceString(Assembly assembly, string resourc } /// - /// Converts an embedded resource name to a proper file name by extracting the last part after the final dot - /// and treating everything before it as directory structure + /// Converts an embedded resource name to a proper file name by extracting the last part after the final dot + /// and treating everything before it as directory structure /// /// The embedded resource name (e.g., "Assets.Fonts.DefaultUiFont.ttf") /// The file name with extension (e.g., "DefaultUiFont.ttf") @@ -380,7 +380,7 @@ public static string GetFileNameFromResourceName(string resourceName) } /// - /// Extracts the file name from an embedded resource path + /// Extracts the file name from an embedded resource path /// /// Full resource name /// File name without path @@ -395,23 +395,23 @@ public static string GetFileNameFromResourcePath(string resourceName) } /// - /// Reads the content of an embedded resource as a string. + /// Reads the content of an embedded resource as a string. /// /// The name of the resource to read. /// The assembly containing the resource. /// The content of the resource as a string. /// Thrown when the resource cannot be found in the specified assembly. /// - /// This method handles resource names that may contain either forward slashes (/) or - /// backslashes (\) by converting them to dots, which is the standard separator for - /// resource names in .NET assemblies. + /// This method handles resource names that may contain either forward slashes (/) or + /// backslashes (\) by converting them to dots, which is the standard separator for + /// resource names in .NET assemblies. /// public static string? ReadEmbeddedResource(string resourceName, Assembly assembly) { var resourcePath = resourceName.Replace('/', '.').Replace('\\', '.'); var fullResourceName = assembly.GetManifestResourceNames() - .FirstOrDefault(name => name.EndsWith(resourcePath, StringComparison.Ordinal)); + .FirstOrDefault(name => name.EndsWith(resourcePath, StringComparison.Ordinal)); if (fullResourceName == null) { diff --git a/src/SquidStd.Core/Utils/StringUtils.cs b/src/SquidStd.Core/Utils/StringUtils.cs index 33c29575..591f1d27 100644 --- a/src/SquidStd.Core/Utils/StringUtils.cs +++ b/src/SquidStd.Core/Utils/StringUtils.cs @@ -5,22 +5,22 @@ namespace SquidStd.Core.Utils; /// -/// Provides utility methods for string operations, including various case conversion methods. +/// Provides utility methods for string operations, including various case conversion methods. /// public static partial class StringUtils { private static readonly Regex WordSplitterRegex = WordSplitter(); /// - /// Converts a string to camelCase. + /// Converts a string to camelCase. /// /// The string to convert to camelCase. /// A camelCase version of the input string. /// Thrown when the input text is null or empty. /// - /// "HelloWorld" becomes "helloWorld" - /// "API_RESPONSE" becomes "apiResponse" - /// "user-id" becomes "userId" + /// "HelloWorld" becomes "helloWorld" + /// "API_RESPONSE" becomes "apiResponse" + /// "user-id" becomes "userId" /// public static string ToCamelCase(string text) { @@ -51,13 +51,13 @@ public static string ToCamelCase(string text) } /// - /// Converts a string to Dot Case. + /// Converts a string to Dot Case. /// /// The string to convert to Dot Case. /// A Dot Case version of the input string. /// - /// "HelloWorld" becomes "hello.world" - /// "API_RESPONSE" becomes "api.response" + /// "HelloWorld" becomes "hello.world" + /// "API_RESPONSE" becomes "api.response" /// public static string ToDotCase(string text) { @@ -96,15 +96,15 @@ public static string ToDotCase(string text) } /// - /// Converts a string to kebab-case. + /// Converts a string to kebab-case. /// /// The string to convert to kebab-case. /// A kebab-case version of the input string. /// Thrown when the input text is null or empty. /// - /// "HelloWorld" becomes "hello-world" - /// "API_RESPONSE" becomes "api-response" - /// "userId" becomes "user-id" + /// "HelloWorld" becomes "hello-world" + /// "API_RESPONSE" becomes "api-response" + /// "userId" becomes "user-id" /// public static string ToKebabCase(string text) { @@ -143,15 +143,15 @@ public static string ToKebabCase(string text) } /// - /// Converts a string to PascalCase. + /// Converts a string to PascalCase. /// /// The string to convert to PascalCase. /// A PascalCase version of the input string. /// Thrown when the input text is null or empty. /// - /// "hello_world" becomes "HelloWorld" - /// "api-response" becomes "ApiResponse" - /// "userId" becomes "UserId" + /// "hello_world" becomes "HelloWorld" + /// "api-response" becomes "ApiResponse" + /// "userId" becomes "UserId" /// public static string ToPascalCase(string text) { @@ -182,13 +182,13 @@ public static string ToPascalCase(string text) } /// - /// Converts a string to Path Case. + /// Converts a string to Path Case. /// /// The string to convert to Path Case. /// A Path Case version of the input string. /// - /// "HelloWorld" becomes "hello/world" - /// "API_RESPONSE" becomes "api/response" + /// "HelloWorld" becomes "hello/world" + /// "API_RESPONSE" becomes "api/response" /// public static string ToPathCase(string text) { @@ -227,13 +227,13 @@ public static string ToPathCase(string text) } /// - /// Converts a string to Sentence Case. + /// Converts a string to Sentence Case. /// /// The string to convert to Sentence Case. /// A Sentence Case version of the input string. /// - /// "hello world" becomes "Hello world" - /// "API_RESPONSE" becomes "Api response" + /// "hello world" becomes "Hello world" + /// "API_RESPONSE" becomes "Api response" /// public static string ToSentenceCase(string text) { @@ -284,15 +284,15 @@ public static string ToSentenceCase(string text) } /// - /// Converts a string from camelCase or PascalCase to snake_case. + /// Converts a string from camelCase or PascalCase to snake_case. /// /// The string to convert to snake_case. /// A snake_case version of the input string. /// Thrown when the input text is null or empty. /// - /// "HelloWorld" becomes "hello_world" - /// "APIResponse" becomes "api_response" - /// "userId" becomes "user_id" + /// "HelloWorld" becomes "hello_world" + /// "APIResponse" becomes "api_response" + /// "userId" becomes "user_id" /// public static string ToSnakeCase(string text) { @@ -331,15 +331,15 @@ public static string ToSnakeCase(string text) } /// - /// Converts a string to Title Case. + /// Converts a string to Title Case. /// /// The string to convert to Title Case. /// A Title Case version of the input string. /// Thrown when the input text is null or empty. /// - /// "hello_world" becomes "Hello World" - /// "API_RESPONSE" becomes "Api Response" - /// "user-id" becomes "User Id" + /// "hello_world" becomes "Hello World" + /// "API_RESPONSE" becomes "Api Response" + /// "user-id" becomes "User Id" /// public static string ToTitleCase(string text) { @@ -373,13 +373,13 @@ public static string ToTitleCase(string text) } /// - /// Converts a string to Train Case (Pascal Case with hyphens). + /// Converts a string to Train Case (Pascal Case with hyphens). /// /// The string to convert to Train Case. /// A Train Case version of the input string. /// - /// "hello_world" becomes "Hello-World" - /// "apiResponse" becomes "Api-Response" + /// "hello_world" becomes "Hello-World" + /// "apiResponse" becomes "Api-Response" /// public static string ToTrainCase(string text) { @@ -418,18 +418,20 @@ public static string ToTrainCase(string text) } /// - /// Converts a string to UPPER_SNAKE_CASE (screaming snake case). + /// Converts a string to UPPER_SNAKE_CASE (screaming snake case). /// /// The string to convert to UPPER_SNAKE_CASE. /// An UPPER_SNAKE_CASE version of the input string. /// Thrown when the input text is null or empty. /// - /// "HelloWorld" becomes "HELLO_WORLD" - /// "apiResponse" becomes "API_RESPONSE" - /// "user-id" becomes "USER_ID" + /// "HelloWorld" becomes "HELLO_WORLD" + /// "apiResponse" becomes "API_RESPONSE" + /// "user-id" becomes "USER_ID" /// public static string ToUpperSnakeCase(string text) - => ToSnakeCase(text).ToUpperInvariant(); + { + return ToSnakeCase(text).ToUpperInvariant(); + } [GeneratedRegex(@"[\s_-]|(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])", RegexOptions.Compiled)] private static partial Regex WordSplitter(); diff --git a/src/SquidStd.Core/Utils/VersionUtils.cs b/src/SquidStd.Core/Utils/VersionUtils.cs index 60af9eb1..45938b01 100644 --- a/src/SquidStd.Core/Utils/VersionUtils.cs +++ b/src/SquidStd.Core/Utils/VersionUtils.cs @@ -3,19 +3,21 @@ namespace SquidStd.Core.Utils; /// -/// Provides utility methods for reading assembly version metadata. +/// Provides utility methods for reading assembly version metadata. /// public static class VersionUtils { /// - /// Gets the informational version for the LyLy.Core assembly. + /// Gets the informational version for the LyLy.Core assembly. /// /// The package version declared for LyLy.Core. public static string GetVersion() - => GetVersion(typeof(VersionUtils).Assembly); + { + return GetVersion(typeof(VersionUtils).Assembly); + } /// - /// Gets the informational version for the specified assembly. + /// Gets the informational version for the specified assembly. /// /// The assembly to read version metadata from. /// The assembly informational version, or the assembly version when informational metadata is unavailable. @@ -24,7 +26,7 @@ public static string GetVersion(Assembly assembly) ArgumentNullException.ThrowIfNull(assembly); var informationalVersion = assembly.GetCustomAttribute() - ?.InformationalVersion; + ?.InformationalVersion; if (!string.IsNullOrWhiteSpace(informationalVersion)) { diff --git a/src/SquidStd.Core/Yaml/YamlUtils.cs b/src/SquidStd.Core/Yaml/YamlUtils.cs index 1e8dd7ca..7fd5e36f 100644 --- a/src/SquidStd.Core/Yaml/YamlUtils.cs +++ b/src/SquidStd.Core/Yaml/YamlUtils.cs @@ -3,21 +3,21 @@ namespace SquidStd.Core.Yaml; /// -/// Provides YAML serialization helpers. +/// Provides YAML serialization helpers. /// public static class YamlUtils { private static readonly ISerializer Serializer = new SerializerBuilder() - .DisableAliases() - .WithIndentedSequences() - .Build(); + .DisableAliases() + .WithIndentedSequences() + .Build(); private static readonly IDeserializer Deserializer = new DeserializerBuilder() - .IgnoreUnmatchedProperties() - .Build(); + .IgnoreUnmatchedProperties() + .Build(); /// - /// Deserializes YAML text using reflection-based metadata. + /// Deserializes YAML text using reflection-based metadata. /// /// The YAML text to deserialize. /// The target type. @@ -31,7 +31,7 @@ public static T Deserialize(string yaml) } /// - /// Deserializes YAML text to the specified runtime type. + /// Deserializes YAML text to the specified runtime type. /// /// The YAML text to deserialize. /// The target type. @@ -46,7 +46,7 @@ public static object Deserialize(string yaml, Type type) } /// - /// Deserializes YAML from a file using reflection-based metadata. + /// Deserializes YAML from a file using reflection-based metadata. /// /// The YAML file path. /// The target type. @@ -61,7 +61,7 @@ public static T DeserializeFromFile(string filePath) } /// - /// Deserializes a top-level YAML section to the specified runtime type. + /// Deserializes a top-level YAML section to the specified runtime type. /// /// The YAML document. /// The top-level section name. @@ -84,7 +84,7 @@ public static T DeserializeFromFile(string filePath) } /// - /// Serializes an object to YAML using reflection-based metadata. + /// Serializes an object to YAML using reflection-based metadata. /// /// The object to serialize. /// The source type. @@ -97,7 +97,7 @@ public static string Serialize(T obj) } /// - /// Serializes top-level YAML sections. + /// Serializes top-level YAML sections. /// /// The section map to serialize. /// The serialized YAML document. @@ -109,7 +109,7 @@ public static string SerializeSections(IReadOnlyDictionary secti } /// - /// Serializes an object to a YAML file using reflection-based metadata. + /// Serializes an object to a YAML file using reflection-based metadata. /// /// The object to serialize. /// The output YAML file path. diff --git a/src/SquidStd.Database.Abstractions/Data/Database/DatabaseConfig.cs b/src/SquidStd.Database.Abstractions/Data/Database/DatabaseConfig.cs index 39fdc203..d386fd1b 100644 --- a/src/SquidStd.Database.Abstractions/Data/Database/DatabaseConfig.cs +++ b/src/SquidStd.Database.Abstractions/Data/Database/DatabaseConfig.cs @@ -3,18 +3,18 @@ namespace SquidStd.Database.Abstractions.Data.Database; /// -/// Database connection configuration. +/// Database connection configuration. /// public sealed class DatabaseConfig : IConfigEntry { /// - /// Gets or sets the URI-style connection string (e.g. "sqlite://squidstd.db", - /// "postgres://user:pass@host:5432/db"). The scheme selects the provider. + /// Gets or sets the URI-style connection string (e.g. "sqlite://squidstd.db", + /// "postgres://user:pass@host:5432/db"). The scheme selects the provider. /// public string ConnectionString { get; set; } = "sqlite://squidstd.db"; /// - /// Gets or sets a value indicating whether the schema is auto-synchronized on startup. + /// Gets or sets a value indicating whether the schema is auto-synchronized on startup. /// public bool AutoMigrate { get; set; } = true; @@ -23,5 +23,7 @@ public sealed class DatabaseConfig : IConfigEntry Type IConfigEntry.ConfigType => typeof(DatabaseConfig); object IConfigEntry.CreateDefault() - => new DatabaseConfig(); + { + return new DatabaseConfig(); + } } diff --git a/src/SquidStd.Database.Abstractions/Data/Entities/BaseEntity.cs b/src/SquidStd.Database.Abstractions/Data/Entities/BaseEntity.cs index aee115e9..85dc2da7 100644 --- a/src/SquidStd.Database.Abstractions/Data/Entities/BaseEntity.cs +++ b/src/SquidStd.Database.Abstractions/Data/Entities/BaseEntity.cs @@ -1,7 +1,7 @@ namespace SquidStd.Database.Abstractions.Data.Entities; /// -/// Base class for all persisted entities: a Guid identity plus UTC create/update timestamps. +/// Base class for all persisted entities: a Guid identity plus UTC create/update timestamps. /// public abstract class BaseEntity { diff --git a/src/SquidStd.Database.Abstractions/Data/PagedResultData.cs b/src/SquidStd.Database.Abstractions/Data/PagedResultData.cs index a259ad19..cd16c889 100644 --- a/src/SquidStd.Database.Abstractions/Data/PagedResultData.cs +++ b/src/SquidStd.Database.Abstractions/Data/PagedResultData.cs @@ -1,7 +1,7 @@ namespace SquidStd.Database.Abstractions.Data; /// -/// A paginated result set with paging metadata. +/// A paginated result set with paging metadata. /// /// The item type. public sealed class PagedResultData @@ -28,7 +28,7 @@ public sealed class PagedResultData public bool HasPrevious => Page > 1 && TotalPages > 0; /// - /// Creates a paged result. + /// Creates a paged result. /// /// The current page items. /// The 1-based page number. @@ -36,11 +36,13 @@ public sealed class PagedResultData /// The total matching row count. /// The paged result. public static PagedResultData Create(IReadOnlyList items, int page, int pageSize, long totalCount) - => new() + { + return new PagedResultData { Items = items, Page = page, PageSize = pageSize, TotalCount = totalCount }; + } } diff --git a/src/SquidStd.Database.Abstractions/Interfaces/Data/IDataAccess.cs b/src/SquidStd.Database.Abstractions/Interfaces/Data/IDataAccess.cs index a5d8e020..8ba1f9ee 100644 --- a/src/SquidStd.Database.Abstractions/Interfaces/Data/IDataAccess.cs +++ b/src/SquidStd.Database.Abstractions/Interfaces/Data/IDataAccess.cs @@ -6,7 +6,7 @@ namespace SquidStd.Database.Abstractions.Interfaces.Data; /// -/// Generic data access for a type: CRUD, bulk, and querying. +/// Generic data access for a type: CRUD, bulk, and querying. /// /// The entity type. public interface IDataAccess diff --git a/src/SquidStd.Database.Abstractions/Types/Data/DatabaseProviderType.cs b/src/SquidStd.Database.Abstractions/Types/Data/DatabaseProviderType.cs index 81955055..530fe8ed 100644 --- a/src/SquidStd.Database.Abstractions/Types/Data/DatabaseProviderType.cs +++ b/src/SquidStd.Database.Abstractions/Types/Data/DatabaseProviderType.cs @@ -1,7 +1,7 @@ namespace SquidStd.Database.Abstractions.Types.Data; /// -/// Supported database providers. +/// Supported database providers. /// public enum DatabaseProviderType { diff --git a/src/SquidStd.Database/Connection/ConnectionStringParser.cs b/src/SquidStd.Database/Connection/ConnectionStringParser.cs index 1559f0c0..2eb883cc 100644 --- a/src/SquidStd.Database/Connection/ConnectionStringParser.cs +++ b/src/SquidStd.Database/Connection/ConnectionStringParser.cs @@ -4,12 +4,12 @@ namespace SquidStd.Database.Connection; /// -/// Parses URI-style connection strings ("scheme://...") into a provider and native connection string. +/// Parses URI-style connection strings ("scheme://...") into a provider and native connection string. /// public static class ConnectionStringParser { /// - /// Parses the given URI connection string. + /// Parses the given URI connection string. /// /// The URI connection string. /// The parsed provider and native connection string. @@ -29,10 +29,10 @@ public static ParsedConnection Parse(string connectionString) var provider = ResolveProvider(scheme); var native = provider == DatabaseProviderType.Sqlite - ? BuildSqlite(remainder) - : BuildServer(provider, remainder); + ? BuildSqlite(remainder) + : BuildServer(provider, remainder); - return new(provider, native); + return new ParsedConnection(provider, native); } private static string BuildServer(DatabaseProviderType provider, string remainder) @@ -87,7 +87,8 @@ private static string BuildSqlite(string remainder) } private static DatabaseProviderType ResolveProvider(string scheme) - => scheme switch + { + return scheme switch { "sqlite" => DatabaseProviderType.Sqlite, "postgres" or "postgresql" => DatabaseProviderType.Postgres, @@ -95,6 +96,7 @@ private static DatabaseProviderType ResolveProvider(string scheme) "mysql" => DatabaseProviderType.MySql, _ => throw new NotSupportedException($"Unsupported database scheme '{scheme}'.") }; + } private static (string User, string Password, string HostPort) SplitAuthority(string authority) { diff --git a/src/SquidStd.Database/Connection/ParsedConnection.cs b/src/SquidStd.Database/Connection/ParsedConnection.cs index b03c0d84..6603c3fd 100644 --- a/src/SquidStd.Database/Connection/ParsedConnection.cs +++ b/src/SquidStd.Database/Connection/ParsedConnection.cs @@ -3,7 +3,7 @@ namespace SquidStd.Database.Connection; /// -/// The result of parsing a URI connection string: the provider and the native connection string. +/// The result of parsing a URI connection string: the provider and the native connection string. /// /// The resolved database provider. /// The provider-native connection string for FreeSql. diff --git a/src/SquidStd.Database/Data/FreeSqlDataAccess.cs b/src/SquidStd.Database/Data/FreeSqlDataAccess.cs index b1fe240e..65a9dec0 100644 --- a/src/SquidStd.Database/Data/FreeSqlDataAccess.cs +++ b/src/SquidStd.Database/Data/FreeSqlDataAccess.cs @@ -10,7 +10,7 @@ namespace SquidStd.Database.Data; /// -/// FreeSql-backed . Writes run inside a unit of work with rollback. +/// FreeSql-backed . Writes run inside a unit of work with rollback. /// /// The entity type. public sealed class FreeSqlDataAccess : IDataAccess @@ -21,7 +21,7 @@ public sealed class FreeSqlDataAccess : IDataAccess private readonly IFreeSql _orm; /// - /// Initializes the data access over the shared FreeSql instance. + /// Initializes the data access over the shared FreeSql instance. /// /// The database service that owns the FreeSql instance. public FreeSqlDataAccess(IDatabaseService databaseService) @@ -34,15 +34,17 @@ public Task BulkDeleteAsync( Expression> predicate, CancellationToken cancellationToken = default ) - => RunInTransactionAsync( + { + return RunInTransactionAsync( transaction => _orm.Delete() - .Where(predicate) - .WithTransaction(transaction) - .ExecuteAffrowsAsync(cancellationToken), + .Where(predicate) + .WithTransaction(transaction) + .ExecuteAffrowsAsync(cancellationToken), "BulkDelete", null, cancellationToken ); + } /// public Task BulkInsertAsync(IEnumerable entities, CancellationToken cancellationToken = default) @@ -70,9 +72,9 @@ public Task BulkUpdateAsync(IEnumerable entities, CancellationToke return RunInTransactionAsync( transaction => _orm.Update() - .SetSource(list) - .WithTransaction(transaction) - .ExecuteAffrowsAsync(cancellationToken), + .SetSource(list) + .WithTransaction(transaction) + .ExecuteAffrowsAsync(cancellationToken), "BulkUpdate", list.Count, cancellationToken @@ -99,29 +101,35 @@ public Task CountAsync( public async Task DeleteAsync(Guid id, CancellationToken cancellationToken = default) { var affected = await RunInTransactionAsync( - transaction => _orm.Delete() - .Where(e => e.Id == id) - .WithTransaction(transaction) - .ExecuteAffrowsAsync(cancellationToken), - "Delete", - null, - cancellationToken - ); + transaction => _orm.Delete() + .Where(e => e.Id == id) + .WithTransaction(transaction) + .ExecuteAffrowsAsync(cancellationToken), + "Delete", + null, + cancellationToken + ); return affected > 0; } /// public Task DeleteAsync(TEntity entity, CancellationToken cancellationToken = default) - => DeleteAsync(entity.Id, cancellationToken); + { + return DeleteAsync(entity.Id, cancellationToken); + } /// public Task ExistsAsync(Expression> predicate, CancellationToken cancellationToken = default) - => _orm.Select().Where(predicate).AnyAsync(cancellationToken); + { + return _orm.Select().Where(predicate).AnyAsync(cancellationToken); + } /// public Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) - => _orm.Select().Where(e => e.Id == id).FirstAsync(cancellationToken)!; + { + return _orm.Select().Where(e => e.Id == id).FirstAsync(cancellationToken)!; + } /// public async Task> GetPagedAsync( @@ -177,7 +185,9 @@ await RunInTransactionAsync( /// public ISelect Query() - => _orm.Select(); + { + return _orm.Select(); + } /// public async Task> QueryAsync( @@ -202,9 +212,9 @@ public async Task UpdateAsync(TEntity entity, CancellationToken cancell await RunInTransactionAsync( transaction => _orm.Update() - .SetSource(entity) - .WithTransaction(transaction) - .ExecuteAffrowsAsync(cancellationToken), + .SetSource(entity) + .WithTransaction(transaction) + .ExecuteAffrowsAsync(cancellationToken), "Update", 1, cancellationToken diff --git a/src/SquidStd.Database/Extensions/RegisterDatabaseExtension.cs b/src/SquidStd.Database/Extensions/RegisterDatabaseExtension.cs index fa4f99bf..9f0d766b 100644 --- a/src/SquidStd.Database/Extensions/RegisterDatabaseExtension.cs +++ b/src/SquidStd.Database/Extensions/RegisterDatabaseExtension.cs @@ -10,12 +10,12 @@ namespace SquidStd.Database.Extensions; /// -/// DI registration for the database subsystem. +/// DI registration for the database subsystem. /// public static class RegisterDatabaseExtension { /// - /// Registers the database config section, the database service, and the open-generic data access. + /// Registers the database config section, the database service, and the open-generic data access. /// /// The DI container. /// The same container for chaining. diff --git a/src/SquidStd.Database/Extensions/ZLinqResultExtensions.cs b/src/SquidStd.Database/Extensions/ZLinqResultExtensions.cs index f5fe342d..0385b964 100644 --- a/src/SquidStd.Database/Extensions/ZLinqResultExtensions.cs +++ b/src/SquidStd.Database/Extensions/ZLinqResultExtensions.cs @@ -3,12 +3,12 @@ namespace SquidStd.Database.Extensions; /// -/// Zero-allocation, in-memory helpers (ZLinq) over already-materialized result lists. +/// Zero-allocation, in-memory helpers (ZLinq) over already-materialized result lists. /// public static class ZLinqResultExtensions { /// - /// Projects each materialized item to a new form using ZLinq, returning a list. + /// Projects each materialized item to a new form using ZLinq, returning a list. /// /// The source item type. /// The projected item type. @@ -19,10 +19,12 @@ public static List MapToList( this IReadOnlyList source, Func selector ) - => source.AsValueEnumerable().Select(selector).ToList(); + { + return source.AsValueEnumerable().Select(selector).ToList(); + } /// - /// Takes an in-memory page of a materialized list using ZLinq (no SQL involved). + /// Takes an in-memory page of a materialized list using ZLinq (no SQL involved). /// /// The item type. /// The materialized source items. diff --git a/src/SquidStd.Database/Interfaces/Services/IDatabaseService.cs b/src/SquidStd.Database/Interfaces/Services/IDatabaseService.cs index 0ce04e6d..1662dde7 100644 --- a/src/SquidStd.Database/Interfaces/Services/IDatabaseService.cs +++ b/src/SquidStd.Database/Interfaces/Services/IDatabaseService.cs @@ -3,7 +3,7 @@ namespace SquidStd.Database.Interfaces.Services; /// -/// Owns the application's singleton FreeSql instance and its lifecycle. +/// Owns the application's singleton FreeSql instance and its lifecycle. /// public interface IDatabaseService : ISquidStdService { diff --git a/src/SquidStd.Database/Services/DatabaseService.cs b/src/SquidStd.Database/Services/DatabaseService.cs index 7b606ea2..8ca4d6db 100644 --- a/src/SquidStd.Database/Services/DatabaseService.cs +++ b/src/SquidStd.Database/Services/DatabaseService.cs @@ -8,7 +8,7 @@ namespace SquidStd.Database.Services; /// -/// Builds and owns the singleton FreeSql instance, logging SQL and migrations verbosely. +/// Builds and owns the singleton FreeSql instance, logging SQL and migrations verbosely. /// public sealed class DatabaseService : IDatabaseService { @@ -18,11 +18,8 @@ public sealed class DatabaseService : IDatabaseService private IFreeSql? _orm; private int _started; - /// - public IFreeSql Orm => _orm ?? throw new InvalidOperationException("Database service is not started."); - /// - /// Initializes the database service. + /// Initializes the database service. /// /// The database configuration section. public DatabaseService(DatabaseConfig config) @@ -30,6 +27,9 @@ public DatabaseService(DatabaseConfig config) _config = config; } + /// + public IFreeSql Orm => _orm ?? throw new InvalidOperationException("Database service is not started."); + /// public ValueTask StartAsync(CancellationToken cancellationToken = default) { @@ -44,13 +44,13 @@ public ValueTask StartAsync(CancellationToken cancellationToken = default) Logger.Verbose("Building FreeSql for provider {Provider}", parsed.Provider); var builder = new FreeSqlBuilder() - .UseConnectionString(MapDataType(parsed.Provider), parsed.NativeConnectionString) - .UseAutoSyncStructure(_config.AutoMigrate) - .UseMonitorCommand(cmd => Logger.Verbose("SQL {Sql}", cmd.CommandText)); + .UseConnectionString(MapDataType(parsed.Provider), parsed.NativeConnectionString) + .UseAutoSyncStructure(_config.AutoMigrate) + .UseMonitorCommand(cmd => Logger.Verbose("SQL {Sql}", cmd.CommandText)); _orm = builder.Build(); _orm.Aop.SyncStructureAfter += (_, e) => - Logger.Verbose("Migrated {Entities} -> {Sql}", e.EntityTypes, e.Sql); + Logger.Verbose("Migrated {Entities} -> {Sql}", e.EntityTypes, e.Sql); Logger.Information( "Database service started ({Provider}, autoMigrate={AutoMigrate})", @@ -73,7 +73,8 @@ public ValueTask StopAsync(CancellationToken cancellationToken = default) } private static DataType MapDataType(DatabaseProviderType provider) - => provider switch + { + return provider switch { DatabaseProviderType.Sqlite => DataType.Sqlite, DatabaseProviderType.Postgres => DataType.PostgreSQL, @@ -81,4 +82,5 @@ private static DataType MapDataType(DatabaseProviderType provider) DatabaseProviderType.MySql => DataType.MySql, _ => throw new NotSupportedException($"Unsupported provider {provider}.") }; + } } diff --git a/src/SquidStd.Generators/AnalyzerReleases.Shipped.md b/src/SquidStd.Generators/AnalyzerReleases.Shipped.md new file mode 100644 index 00000000..f50bb1fe --- /dev/null +++ b/src/SquidStd.Generators/AnalyzerReleases.Shipped.md @@ -0,0 +1,2 @@ +; Shipped analyzer releases +; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md diff --git a/src/SquidStd.Generators/AnalyzerReleases.Unshipped.md b/src/SquidStd.Generators/AnalyzerReleases.Unshipped.md new file mode 100644 index 00000000..8dd9d9fb --- /dev/null +++ b/src/SquidStd.Generators/AnalyzerReleases.Unshipped.md @@ -0,0 +1,12 @@ +; Unshipped analyzer release +; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md + +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|------ +SQDGEN001 | SquidStd.Generators | Warning | Event listener cannot be generated +SQDGEN002 | SquidStd.Generators | Warning | Standard service cannot be generated +SQDGEN003 | SquidStd.Generators | Warning | Config section cannot be generated +SQDGEN004 | SquidStd.Generators | Warning | Job handler cannot be generated +SQDGEN005 | SquidStd.Generators | Warning | Script module cannot be generated diff --git a/src/SquidStd.Generators/Common/GeneratorSymbolHelpers.cs b/src/SquidStd.Generators/Common/GeneratorSymbolHelpers.cs new file mode 100644 index 00000000..6f10a228 --- /dev/null +++ b/src/SquidStd.Generators/Common/GeneratorSymbolHelpers.cs @@ -0,0 +1,82 @@ +using Microsoft.CodeAnalysis; + +namespace SquidStd.Generators.Common; + +internal static class GeneratorSymbolHelpers +{ + public static string FullyQualified(ITypeSymbol symbol) + { + return symbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + } + + public static string DisplayName(ISymbol symbol) + { + return symbol.ToDisplayString(SymbolDisplayFormat.CSharpErrorMessageFormat); + } + + public static Location? PrimaryLocation(ISymbol symbol) + { + return symbol.Locations.FirstOrDefault(); + } + + public static bool IsConcreteNonGenericClass(INamedTypeSymbol type) + { + return type.TypeKind == TypeKind.Class && !type.IsAbstract && !type.IsGenericType; + } + + public static bool IsAccessibleFromGeneratedSource(INamedTypeSymbol type) + { + var current = type; + + while (current is not null) + { + if (current.DeclaredAccessibility is not Accessibility.Public and not Accessibility.Internal) + { + return false; + } + + current = current.ContainingType; + } + + return true; + } + + public static bool ImplementsInterface(INamedTypeSymbol type, string metadataName, string namespaceName) + { + return type.AllInterfaces.Any(interfaceType => + { + var originalDefinition = interfaceType.OriginalDefinition; + + return originalDefinition.MetadataName == metadataName + && originalDefinition.ContainingNamespace.ToDisplayString() == namespaceName; + } + ); + } + + public static bool IsAssignableTo(INamedTypeSymbol implementationType, INamedTypeSymbol serviceType) + { + if (SymbolEqualityComparer.Default.Equals(implementationType, serviceType)) + { + return true; + } + + for (var current = implementationType.BaseType; current is not null; current = current.BaseType) + { + if (SymbolEqualityComparer.Default.Equals(current, serviceType)) + { + return true; + } + } + + return implementationType.AllInterfaces.Any(interfaceType => + SymbolEqualityComparer.Default.Equals(interfaceType, serviceType) + ); + } + + public static bool HasPublicParameterlessConstructor(INamedTypeSymbol type) + { + return type.InstanceConstructors.Any(constructor => + constructor.Parameters.Length == 0 && constructor.DeclaredAccessibility == Accessibility.Public + ); + } +} diff --git a/src/SquidStd.Generators/Config/ConfigSectionRegistrationCandidate.cs b/src/SquidStd.Generators/Config/ConfigSectionRegistrationCandidate.cs new file mode 100644 index 00000000..4a9533af --- /dev/null +++ b/src/SquidStd.Generators/Config/ConfigSectionRegistrationCandidate.cs @@ -0,0 +1,35 @@ +using Microsoft.CodeAnalysis; + +namespace SquidStd.Generators.Config; + +internal sealed class ConfigSectionRegistrationCandidate +{ + public ConfigSectionRegistrationCandidate( + string configTypeName, + string sectionName, + string displayName, + Location? location, + int priority, + bool isSupported + ) + { + ConfigTypeName = configTypeName; + SectionName = sectionName; + DisplayName = displayName; + Location = location; + Priority = priority; + IsSupported = isSupported; + } + + public string ConfigTypeName { get; } + + public string SectionName { get; } + + public string DisplayName { get; } + + public Location? Location { get; } + + public int Priority { get; } + + public bool IsSupported { get; } +} diff --git a/src/SquidStd.Generators/Config/ConfigSectionRegistrationGenerator.cs b/src/SquidStd.Generators/Config/ConfigSectionRegistrationGenerator.cs new file mode 100644 index 00000000..c45894b8 --- /dev/null +++ b/src/SquidStd.Generators/Config/ConfigSectionRegistrationGenerator.cs @@ -0,0 +1,118 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using SquidStd.Generators.Common; +using SquidStd.Generators.Diagnostics; + +namespace SquidStd.Generators.Config; + +[Generator(LanguageNames.CSharp)] +public sealed class ConfigSectionRegistrationGenerator : IIncrementalGenerator +{ + private const string AttributeName = "SquidStd.Abstractions.Attributes.RegisterConfigSectionAttribute"; + private const string GeneratedFileName = "SquidStd.GeneratedConfigSectionRegistration.g.cs"; + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var candidates = context.SyntaxProvider.ForAttributeWithMetadataName( + AttributeName, + static (node, _) => node is ClassDeclarationSyntax, + static (context, cancellationToken) => CreateCandidate(context, cancellationToken) + ); + + context.RegisterSourceOutput(candidates.Collect(), static (context, candidates) => Execute(context, candidates)); + } + + private static ConfigSectionRegistrationCandidate CreateCandidate( + GeneratorAttributeSyntaxContext context, + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + var configType = (INamedTypeSymbol)context.TargetSymbol; + var attribute = context.Attributes[0]; + var sectionName = GetSectionName(attribute); + var priority = GetIntNamedArgument(attribute, "Priority"); + + var isSupported = !string.IsNullOrWhiteSpace(sectionName) + && GeneratorSymbolHelpers.IsConcreteNonGenericClass(configType) + && GeneratorSymbolHelpers.IsAccessibleFromGeneratedSource(configType) + && GeneratorSymbolHelpers.HasPublicParameterlessConstructor(configType); + + return new ConfigSectionRegistrationCandidate( + GeneratorSymbolHelpers.FullyQualified(configType), + sectionName ?? string.Empty, + GeneratorSymbolHelpers.DisplayName(configType), + GeneratorSymbolHelpers.PrimaryLocation(configType), + priority, + isSupported + ); + } + + private static string? GetSectionName(AttributeData attribute) + { + if (attribute.ConstructorArguments.Length == 0) + { + return null; + } + + return attribute.ConstructorArguments[0].Value as string; + } + + private static int GetIntNamedArgument(AttributeData attribute, string name) + { + foreach (var argument in attribute.NamedArguments) + { + if (argument.Key == name && argument.Value.Value is int value) + { + return value; + } + } + + return 0; + } + + private static void Execute( + SourceProductionContext context, + ImmutableArray candidates + ) + { + var supported = new List(); + var seenKeys = new HashSet(StringComparer.Ordinal); + + foreach (var candidate in candidates) + { + if (!candidate.IsSupported) + { + context.ReportDiagnostic( + Diagnostic.Create( + SquidStdGeneratorDiagnostics.UnsupportedConfigSection, + candidate.Location, + candidate.DisplayName + ) + ); + + continue; + } + + var key = candidate.SectionName + "|" + candidate.ConfigTypeName; + if (seenKeys.Add(key)) + { + supported.Add(candidate); + } + } + + supported.Sort(static (left, right) => + { + var sectionComparison = string.Compare(left.SectionName, right.SectionName, StringComparison.Ordinal); + + return sectionComparison != 0 + ? sectionComparison + : string.Compare(left.ConfigTypeName, right.ConfigTypeName, StringComparison.Ordinal); + } + ); + + context.AddSource(GeneratedFileName, ConfigSectionSourceBuilder.Build(supported)); + } +} diff --git a/src/SquidStd.Generators/Config/ConfigSectionSourceBuilder.cs b/src/SquidStd.Generators/Config/ConfigSectionSourceBuilder.cs new file mode 100644 index 00000000..d145ddd8 --- /dev/null +++ b/src/SquidStd.Generators/Config/ConfigSectionSourceBuilder.cs @@ -0,0 +1,49 @@ +using System.Globalization; +using System.Text; + +namespace SquidStd.Generators.Config; + +internal static class ConfigSectionSourceBuilder +{ + public static string Build(IReadOnlyList candidates) + { + var builder = new StringBuilder(); + + builder.AppendLine("// "); + builder.AppendLine("#nullable enable"); + builder.AppendLine(); + builder.AppendLine("namespace SquidStd.Generators.Config;"); + builder.AppendLine(); + builder.AppendLine("public static class GeneratedConfigSectionRegistrationExtensions"); + builder.AppendLine("{"); + builder.AppendLine( + " public static global::DryIoc.IContainer RegisterGeneratedConfigSections(this global::DryIoc.IContainer container)" + ); + builder.AppendLine(" {"); + + for (var i = 0; i < candidates.Count; i++) + { + builder.Append( + " global::SquidStd.Abstractions.Extensions.Config.RegisterConfigSectionExtension.RegisterConfigSection<" + ); + builder.Append(candidates[i].ConfigTypeName); + builder.Append(">(container, "); + builder.Append(FormatStringLiteral(candidates[i].SectionName)); + builder.Append(", priority: "); + builder.Append(candidates[i].Priority.ToString(CultureInfo.InvariantCulture)); + builder.AppendLine(");"); + } + + builder.AppendLine(); + builder.AppendLine(" return container;"); + builder.AppendLine(" }"); + builder.AppendLine("}"); + + return builder.ToString(); + } + + private static string FormatStringLiteral(string value) + { + return "\"" + value.Replace("\\", "\\\\").Replace("\"", "\\\"") + "\""; + } +} diff --git a/src/SquidStd.Generators/Diagnostics/SquidStdGeneratorDiagnostics.cs b/src/SquidStd.Generators/Diagnostics/SquidStdGeneratorDiagnostics.cs new file mode 100644 index 00000000..cdb46649 --- /dev/null +++ b/src/SquidStd.Generators/Diagnostics/SquidStdGeneratorDiagnostics.cs @@ -0,0 +1,51 @@ +using Microsoft.CodeAnalysis; + +namespace SquidStd.Generators.Diagnostics; + +internal static class SquidStdGeneratorDiagnostics +{ + public static readonly DiagnosticDescriptor UnsupportedEventListener = new( + "SQDGEN001", + "Event listener cannot be generated", + "Event listener '{0}' must be a non-generic public or internal class with a public or internal event type", + "SquidStd.Generators", + DiagnosticSeverity.Warning, + true + ); + + public static readonly DiagnosticDescriptor UnsupportedStdService = new( + "SQDGEN002", + "Standard service cannot be generated", + "Standard service '{0}' must be a non-generic public or internal class assignable to a public or internal service contract", + "SquidStd.Generators", + DiagnosticSeverity.Warning, + true + ); + + public static readonly DiagnosticDescriptor UnsupportedConfigSection = new( + "SQDGEN003", + "Config section cannot be generated", + "Config section '{0}' must be a non-generic public or internal class with a public parameterless constructor and a non-empty section name", + "SquidStd.Generators", + DiagnosticSeverity.Warning, + true + ); + + public static readonly DiagnosticDescriptor UnsupportedJobHandler = new( + "SQDGEN004", + "Job handler cannot be generated", + "Job handler '{0}' must be a non-generic public or internal class implementing IJobHandler", + "SquidStd.Generators", + DiagnosticSeverity.Warning, + true + ); + + public static readonly DiagnosticDescriptor UnsupportedScriptModule = new( + "SQDGEN005", + "Script module cannot be generated", + "Script module '{0}' must be a non-generic public or internal class", + "SquidStd.Generators", + DiagnosticSeverity.Warning, + true + ); +} diff --git a/src/SquidStd.Generators/Events/EventListenerCandidate.cs b/src/SquidStd.Generators/Events/EventListenerCandidate.cs new file mode 100644 index 00000000..8c16b1b2 --- /dev/null +++ b/src/SquidStd.Generators/Events/EventListenerCandidate.cs @@ -0,0 +1,31 @@ +using Microsoft.CodeAnalysis; + +namespace SquidStd.Generators.Events; + +internal sealed class EventListenerCandidate +{ + public EventListenerCandidate( + string eventTypeName, + string listenerTypeName, + string displayName, + Location? location, + bool isSupported + ) + { + EventTypeName = eventTypeName; + ListenerTypeName = listenerTypeName; + DisplayName = displayName; + Location = location; + IsSupported = isSupported; + } + + public string EventTypeName { get; } + + public string ListenerTypeName { get; } + + public string DisplayName { get; } + + public Location? Location { get; } + + public bool IsSupported { get; } +} diff --git a/src/SquidStd.Generators/Events/EventListenerRegistrationGenerator.cs b/src/SquidStd.Generators/Events/EventListenerRegistrationGenerator.cs new file mode 100644 index 00000000..3556912a --- /dev/null +++ b/src/SquidStd.Generators/Events/EventListenerRegistrationGenerator.cs @@ -0,0 +1,153 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using SquidStd.Generators.Diagnostics; + +namespace SquidStd.Generators.Events; + +[Generator(LanguageNames.CSharp)] +public sealed class EventListenerRegistrationGenerator : IIncrementalGenerator +{ + private const string EventListenerMetadataName = "IEventListener`1"; + private const string EventListenerNamespace = "SquidStd.Core.Interfaces.Events"; + private const string GeneratedFileName = "SquidStd.GeneratedEventListenerRegistration.g.cs"; + + private const string RegisterEventListenerAttributeName = + "SquidStd.Abstractions.Attributes.RegisterEventListenerAttribute"; + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var candidateGroups = context.SyntaxProvider.ForAttributeWithMetadataName( + RegisterEventListenerAttributeName, + static (node, _) => node is ClassDeclarationSyntax, + static (context, cancellationToken) => CreateCandidates(context, cancellationToken) + ); + + context.RegisterSourceOutput( + candidateGroups.Collect(), + static (context, candidateGroups) => Execute(context, candidateGroups) + ); + } + + private static ImmutableArray CreateCandidates( + GeneratorAttributeSyntaxContext context, + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (context.TargetSymbol is not INamedTypeSymbol listenerType) + { + return ImmutableArray.Empty; + } + + if (listenerType.TypeKind != TypeKind.Class || listenerType.IsAbstract) + { + return ImmutableArray.Empty; + } + + var candidates = ImmutableArray.CreateBuilder(); + + for (var i = 0; i < listenerType.AllInterfaces.Length; i++) + { + var interfaceType = listenerType.AllInterfaces[i]; + if (!IsEventListenerInterface(interfaceType)) + { + continue; + } + + if (interfaceType.TypeArguments.Length != 1 || interfaceType.TypeArguments[0] is not INamedTypeSymbol eventType) + { + continue; + } + + var isSupported = !listenerType.IsGenericType + && IsAccessibleFromGeneratedSource(listenerType) + && IsAccessibleFromGeneratedSource(eventType); + + candidates.Add( + new EventListenerCandidate( + eventType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), + listenerType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), + listenerType.ToDisplayString(SymbolDisplayFormat.CSharpErrorMessageFormat), + listenerType.Locations.FirstOrDefault(), + isSupported + ) + ); + } + + return candidates.ToImmutable(); + } + + private static bool IsEventListenerInterface(INamedTypeSymbol interfaceType) + { + var originalDefinition = interfaceType.OriginalDefinition; + + return originalDefinition.MetadataName == EventListenerMetadataName + && originalDefinition.ContainingNamespace.ToDisplayString() == EventListenerNamespace; + } + + private static bool IsAccessibleFromGeneratedSource(INamedTypeSymbol type) + { + var current = type; + + while (current is not null) + { + if (current.DeclaredAccessibility is not Accessibility.Public and not Accessibility.Internal) + { + return false; + } + + current = current.ContainingType; + } + + return true; + } + + private static void Execute( + SourceProductionContext context, + ImmutableArray> candidateGroups + ) + { + var candidates = new List(); + var seenKeys = new HashSet(StringComparer.Ordinal); + + for (var i = 0; i < candidateGroups.Length; i++) + { + var group = candidateGroups[i]; + + for (var j = 0; j < group.Length; j++) + { + var candidate = group[j]; + + if (!candidate.IsSupported) + { + context.ReportDiagnostic( + Diagnostic.Create( + SquidStdGeneratorDiagnostics.UnsupportedEventListener, + candidate.Location, + candidate.DisplayName + ) + ); + + continue; + } + + var key = candidate.EventTypeName + "|" + candidate.ListenerTypeName; + if (seenKeys.Add(key)) + { + candidates.Add(candidate); + } + } + } + + candidates.Sort(static (left, right) => string.Compare( + left.ListenerTypeName, + right.ListenerTypeName, + StringComparison.Ordinal + ) + ); + + context.AddSource(GeneratedFileName, EventListenerSourceBuilder.Build(candidates)); + } +} diff --git a/src/SquidStd.Generators/Events/EventListenerSourceBuilder.cs b/src/SquidStd.Generators/Events/EventListenerSourceBuilder.cs new file mode 100644 index 00000000..c84ad831 --- /dev/null +++ b/src/SquidStd.Generators/Events/EventListenerSourceBuilder.cs @@ -0,0 +1,41 @@ +using System.Text; + +namespace SquidStd.Generators.Events; + +internal static class EventListenerSourceBuilder +{ + public static string Build(IReadOnlyList candidates) + { + var builder = new StringBuilder(); + + builder.AppendLine("// "); + builder.AppendLine("#nullable enable"); + builder.AppendLine(); + builder.AppendLine("namespace SquidStd.Generators.Events;"); + builder.AppendLine(); + builder.AppendLine("public static class GeneratedEventListenerRegistrationExtensions"); + builder.AppendLine("{"); + builder.AppendLine( + " public static global::DryIoc.IContainer RegisterGeneratedEventListeners(this global::DryIoc.IContainer container)" + ); + builder.AppendLine(" {"); + + for (var i = 0; i < candidates.Count; i++) + { + builder.Append( + " global::SquidStd.Abstractions.Extensions.Events.RegisterEventListenerExtension.RegisterEventListener<" + ); + builder.Append(candidates[i].EventTypeName); + builder.Append(", "); + builder.Append(candidates[i].ListenerTypeName); + builder.AppendLine(">(container);"); + } + + builder.AppendLine(); + builder.AppendLine(" return container;"); + builder.AppendLine(" }"); + builder.AppendLine("}"); + + return builder.ToString(); + } +} diff --git a/src/SquidStd.Generators/README.md b/src/SquidStd.Generators/README.md new file mode 100644 index 00000000..07567ef1 --- /dev/null +++ b/src/SquidStd.Generators/README.md @@ -0,0 +1,42 @@ +# SquidStd.Generators + +Roslyn source generators for SquidStd compile-time registration helpers. + +## Install + +```bash +dotnet add package SquidStd.Generators +``` + +## Event listeners + +The event listener generator discovers concrete `IEventListener` implementations marked with `[RegisterEventListener]` and generates a DryIoc registration extension: + +```csharp +using SquidStd.Abstractions.Attributes; +using SquidStd.Core.Interfaces.Events; + +[RegisterEventListener] +public sealed class PingListener : IEventListener +{ + public Task HandleAsync(PingEvent eventData, CancellationToken cancellationToken = default) + => Task.CompletedTask; +} + +container.RegisterGeneratedEventListeners(); +``` + +The generated method reuses the normal `RegisterEventListener()` runtime path, so listener activation stays compatible with `SquidStd.Services.Core`. + +## Other generated registrations + +Each registration family has its own marker attribute and generated extension method: + +| Marker attribute | Generated method | +|------------------|------------------| +| `[RegisterStdService(typeof(IMyService), Priority = 10)]` | `RegisterGeneratedStdServices()` | +| `[RegisterConfigSection("workers", Priority = -50)]` | `RegisterGeneratedConfigSections()` | +| `[RegisterJobHandler]` | `RegisterGeneratedJobHandlers()` | +| `[RegisterScriptModule]` with `[ScriptModule("name")]` | `RegisterGeneratedScriptModules()` | + +The generated methods call the same runtime APIs as manual registration: `RegisterStdService`, `RegisterConfigSection`, `AddJobHandler`, and `RegisterScriptModule`. diff --git a/src/SquidStd.Generators/Scripting/Lua/ScriptModuleRegistrationCandidate.cs b/src/SquidStd.Generators/Scripting/Lua/ScriptModuleRegistrationCandidate.cs new file mode 100644 index 00000000..4519da4f --- /dev/null +++ b/src/SquidStd.Generators/Scripting/Lua/ScriptModuleRegistrationCandidate.cs @@ -0,0 +1,27 @@ +using Microsoft.CodeAnalysis; + +namespace SquidStd.Generators.Scripting.Lua; + +internal sealed class ScriptModuleRegistrationCandidate +{ + public ScriptModuleRegistrationCandidate( + string scriptModuleTypeName, + string displayName, + Location? location, + bool isSupported + ) + { + ScriptModuleTypeName = scriptModuleTypeName; + DisplayName = displayName; + Location = location; + IsSupported = isSupported; + } + + public string ScriptModuleTypeName { get; } + + public string DisplayName { get; } + + public Location? Location { get; } + + public bool IsSupported { get; } +} diff --git a/src/SquidStd.Generators/Scripting/Lua/ScriptModuleRegistrationGenerator.cs b/src/SquidStd.Generators/Scripting/Lua/ScriptModuleRegistrationGenerator.cs new file mode 100644 index 00000000..5a4a0427 --- /dev/null +++ b/src/SquidStd.Generators/Scripting/Lua/ScriptModuleRegistrationGenerator.cs @@ -0,0 +1,100 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using SquidStd.Generators.Common; +using SquidStd.Generators.Diagnostics; + +namespace SquidStd.Generators.Scripting.Lua; + +[Generator(LanguageNames.CSharp)] +public sealed class ScriptModuleRegistrationGenerator : IIncrementalGenerator +{ + private const string AttributeName = "SquidStd.Scripting.Lua.Attributes.RegisterScriptModuleAttribute"; + private const string GeneratedFileName = "SquidStd.GeneratedScriptModuleRegistration.g.cs"; + private const string ScriptModuleAttributeMetadataName = "ScriptModuleAttribute"; + private const string ScriptModuleAttributeNamespace = "SquidStd.Scripting.Lua.Attributes.Scripts"; + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var candidates = context.SyntaxProvider.ForAttributeWithMetadataName( + AttributeName, + static (node, _) => node is ClassDeclarationSyntax, + static (context, cancellationToken) => CreateCandidate(context, cancellationToken) + ); + + context.RegisterSourceOutput(candidates.Collect(), static (context, candidates) => Execute(context, candidates)); + } + + private static ScriptModuleRegistrationCandidate CreateCandidate( + GeneratorAttributeSyntaxContext context, + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + var scriptModuleType = (INamedTypeSymbol)context.TargetSymbol; + var isSupported = GeneratorSymbolHelpers.IsConcreteNonGenericClass(scriptModuleType) + && GeneratorSymbolHelpers.IsAccessibleFromGeneratedSource(scriptModuleType) + && HasScriptModuleAttribute(scriptModuleType); + + return new ScriptModuleRegistrationCandidate( + GeneratorSymbolHelpers.FullyQualified(scriptModuleType), + GeneratorSymbolHelpers.DisplayName(scriptModuleType), + GeneratorSymbolHelpers.PrimaryLocation(scriptModuleType), + isSupported + ); + } + + private static bool HasScriptModuleAttribute(INamedTypeSymbol type) + { + return type.GetAttributes() + .Any(attribute => + { + var attributeClass = attribute.AttributeClass; + + return attributeClass is not null + && attributeClass.MetadataName == ScriptModuleAttributeMetadataName + && attributeClass.ContainingNamespace.ToDisplayString() == ScriptModuleAttributeNamespace; + } + ); + } + + private static void Execute( + SourceProductionContext context, + ImmutableArray candidates + ) + { + var supported = new List(); + var seenScriptModuleTypes = new HashSet(StringComparer.Ordinal); + + foreach (var candidate in candidates) + { + if (!candidate.IsSupported) + { + context.ReportDiagnostic( + Diagnostic.Create( + SquidStdGeneratorDiagnostics.UnsupportedScriptModule, + candidate.Location, + candidate.DisplayName + ) + ); + + continue; + } + + if (seenScriptModuleTypes.Add(candidate.ScriptModuleTypeName)) + { + supported.Add(candidate); + } + } + + supported.Sort(static (left, right) => string.Compare( + left.ScriptModuleTypeName, + right.ScriptModuleTypeName, + StringComparison.Ordinal + ) + ); + + context.AddSource(GeneratedFileName, ScriptModuleSourceBuilder.Build(supported)); + } +} diff --git a/src/SquidStd.Generators/Scripting/Lua/ScriptModuleSourceBuilder.cs b/src/SquidStd.Generators/Scripting/Lua/ScriptModuleSourceBuilder.cs new file mode 100644 index 00000000..7499a970 --- /dev/null +++ b/src/SquidStd.Generators/Scripting/Lua/ScriptModuleSourceBuilder.cs @@ -0,0 +1,39 @@ +using System.Text; + +namespace SquidStd.Generators.Scripting.Lua; + +internal static class ScriptModuleSourceBuilder +{ + public static string Build(IReadOnlyList candidates) + { + var builder = new StringBuilder(); + + builder.AppendLine("// "); + builder.AppendLine("#nullable enable"); + builder.AppendLine(); + builder.AppendLine("namespace SquidStd.Generators.Scripting.Lua;"); + builder.AppendLine(); + builder.AppendLine("public static class GeneratedScriptModuleRegistrationExtensions"); + builder.AppendLine("{"); + builder.AppendLine( + " public static global::DryIoc.IContainer RegisterGeneratedScriptModules(this global::DryIoc.IContainer container)" + ); + builder.AppendLine(" {"); + + for (var i = 0; i < candidates.Count; i++) + { + builder.Append( + " global::SquidStd.Scripting.Lua.Extensions.Scripts.AddScriptModuleExtension.RegisterScriptModule<" + ); + builder.Append(candidates[i].ScriptModuleTypeName); + builder.AppendLine(">(container);"); + } + + builder.AppendLine(); + builder.AppendLine(" return container;"); + builder.AppendLine(" }"); + builder.AppendLine("}"); + + return builder.ToString(); + } +} diff --git a/src/SquidStd.Generators/Services/StdServiceRegistrationCandidate.cs b/src/SquidStd.Generators/Services/StdServiceRegistrationCandidate.cs new file mode 100644 index 00000000..ff4aa4a8 --- /dev/null +++ b/src/SquidStd.Generators/Services/StdServiceRegistrationCandidate.cs @@ -0,0 +1,35 @@ +using Microsoft.CodeAnalysis; + +namespace SquidStd.Generators.Services; + +internal sealed class StdServiceRegistrationCandidate +{ + public StdServiceRegistrationCandidate( + string serviceTypeName, + string implementationTypeName, + string displayName, + Location? location, + int priority, + bool isSupported + ) + { + ServiceTypeName = serviceTypeName; + ImplementationTypeName = implementationTypeName; + DisplayName = displayName; + Location = location; + Priority = priority; + IsSupported = isSupported; + } + + public string ServiceTypeName { get; } + + public string ImplementationTypeName { get; } + + public string DisplayName { get; } + + public Location? Location { get; } + + public int Priority { get; } + + public bool IsSupported { get; } +} diff --git a/src/SquidStd.Generators/Services/StdServiceRegistrationGenerator.cs b/src/SquidStd.Generators/Services/StdServiceRegistrationGenerator.cs new file mode 100644 index 00000000..2fd7aae3 --- /dev/null +++ b/src/SquidStd.Generators/Services/StdServiceRegistrationGenerator.cs @@ -0,0 +1,113 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using SquidStd.Generators.Common; +using SquidStd.Generators.Diagnostics; + +namespace SquidStd.Generators.Services; + +[Generator(LanguageNames.CSharp)] +public sealed class StdServiceRegistrationGenerator : IIncrementalGenerator +{ + private const string AttributeName = "SquidStd.Abstractions.Attributes.RegisterStdServiceAttribute"; + private const string GeneratedFileName = "SquidStd.GeneratedStdServiceRegistration.g.cs"; + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var candidates = context.SyntaxProvider.ForAttributeWithMetadataName( + AttributeName, + static (node, _) => node is ClassDeclarationSyntax, + static (context, cancellationToken) => CreateCandidate(context, cancellationToken) + ); + + context.RegisterSourceOutput(candidates.Collect(), static (context, candidates) => Execute(context, candidates)); + } + + private static StdServiceRegistrationCandidate CreateCandidate( + GeneratorAttributeSyntaxContext context, + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + var implementationType = (INamedTypeSymbol)context.TargetSymbol; + var attribute = context.Attributes[0]; + var serviceType = GetServiceType(attribute); + var priority = GetIntNamedArgument(attribute, "Priority"); + + var isSupported = serviceType is not null + && GeneratorSymbolHelpers.IsConcreteNonGenericClass(implementationType) + && GeneratorSymbolHelpers.IsAccessibleFromGeneratedSource(implementationType) + && GeneratorSymbolHelpers.IsAccessibleFromGeneratedSource(serviceType) + && GeneratorSymbolHelpers.IsAssignableTo(implementationType, serviceType); + + return new StdServiceRegistrationCandidate( + serviceType is null ? string.Empty : GeneratorSymbolHelpers.FullyQualified(serviceType), + GeneratorSymbolHelpers.FullyQualified(implementationType), + GeneratorSymbolHelpers.DisplayName(implementationType), + GeneratorSymbolHelpers.PrimaryLocation(implementationType), + priority, + isSupported + ); + } + + private static INamedTypeSymbol? GetServiceType(AttributeData attribute) + { + if (attribute.ConstructorArguments.Length == 0) + { + return null; + } + + return attribute.ConstructorArguments[0].Value as INamedTypeSymbol; + } + + private static int GetIntNamedArgument(AttributeData attribute, string name) + { + foreach (var argument in attribute.NamedArguments) + { + if (argument.Key == name && argument.Value.Value is int value) + { + return value; + } + } + + return 0; + } + + private static void Execute(SourceProductionContext context, ImmutableArray candidates) + { + var supported = new List(); + var seenKeys = new HashSet(StringComparer.Ordinal); + + foreach (var candidate in candidates) + { + if (!candidate.IsSupported) + { + context.ReportDiagnostic( + Diagnostic.Create( + SquidStdGeneratorDiagnostics.UnsupportedStdService, + candidate.Location, + candidate.DisplayName + ) + ); + + continue; + } + + var key = candidate.ServiceTypeName + "|" + candidate.ImplementationTypeName; + if (seenKeys.Add(key)) + { + supported.Add(candidate); + } + } + + supported.Sort(static (left, right) => string.Compare( + left.ImplementationTypeName, + right.ImplementationTypeName, + StringComparison.Ordinal + ) + ); + + context.AddSource(GeneratedFileName, StdServiceSourceBuilder.Build(supported)); + } +} diff --git a/src/SquidStd.Generators/Services/StdServiceSourceBuilder.cs b/src/SquidStd.Generators/Services/StdServiceSourceBuilder.cs new file mode 100644 index 00000000..196721a0 --- /dev/null +++ b/src/SquidStd.Generators/Services/StdServiceSourceBuilder.cs @@ -0,0 +1,44 @@ +using System.Globalization; +using System.Text; + +namespace SquidStd.Generators.Services; + +internal static class StdServiceSourceBuilder +{ + public static string Build(IReadOnlyList candidates) + { + var builder = new StringBuilder(); + + builder.AppendLine("// "); + builder.AppendLine("#nullable enable"); + builder.AppendLine(); + builder.AppendLine("namespace SquidStd.Generators.Services;"); + builder.AppendLine(); + builder.AppendLine("public static class GeneratedStdServiceRegistrationExtensions"); + builder.AppendLine("{"); + builder.AppendLine( + " public static global::DryIoc.IContainer RegisterGeneratedStdServices(this global::DryIoc.IContainer container)" + ); + builder.AppendLine(" {"); + + for (var i = 0; i < candidates.Count; i++) + { + builder.Append( + " global::SquidStd.Abstractions.Extensions.Services.RegisterStdServiceExtension.RegisterStdService<" + ); + builder.Append(candidates[i].ServiceTypeName); + builder.Append(", "); + builder.Append(candidates[i].ImplementationTypeName); + builder.Append(">(container, "); + builder.Append(candidates[i].Priority.ToString(CultureInfo.InvariantCulture)); + builder.AppendLine(");"); + } + + builder.AppendLine(); + builder.AppendLine(" return container;"); + builder.AppendLine(" }"); + builder.AppendLine("}"); + + return builder.ToString(); + } +} diff --git a/src/SquidStd.Generators/SquidStd.Generators.csproj b/src/SquidStd.Generators/SquidStd.Generators.csproj new file mode 100644 index 00000000..e931f201 --- /dev/null +++ b/src/SquidStd.Generators/SquidStd.Generators.csproj @@ -0,0 +1,30 @@ + + + + true + netstandard2.0 + enable + enable + latest + true + true + false + true + false + + + + + + + + + + + + + + + + + diff --git a/src/SquidStd.Generators/Workers/JobHandlerRegistrationCandidate.cs b/src/SquidStd.Generators/Workers/JobHandlerRegistrationCandidate.cs new file mode 100644 index 00000000..3b3d3ffa --- /dev/null +++ b/src/SquidStd.Generators/Workers/JobHandlerRegistrationCandidate.cs @@ -0,0 +1,27 @@ +using Microsoft.CodeAnalysis; + +namespace SquidStd.Generators.Workers; + +internal sealed class JobHandlerRegistrationCandidate +{ + public JobHandlerRegistrationCandidate( + string handlerTypeName, + string displayName, + Location? location, + bool isSupported + ) + { + HandlerTypeName = handlerTypeName; + DisplayName = displayName; + Location = location; + IsSupported = isSupported; + } + + public string HandlerTypeName { get; } + + public string DisplayName { get; } + + public Location? Location { get; } + + public bool IsSupported { get; } +} diff --git a/src/SquidStd.Generators/Workers/JobHandlerRegistrationGenerator.cs b/src/SquidStd.Generators/Workers/JobHandlerRegistrationGenerator.cs new file mode 100644 index 00000000..59ccd643 --- /dev/null +++ b/src/SquidStd.Generators/Workers/JobHandlerRegistrationGenerator.cs @@ -0,0 +1,88 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using SquidStd.Generators.Common; +using SquidStd.Generators.Diagnostics; + +namespace SquidStd.Generators.Workers; + +[Generator(LanguageNames.CSharp)] +public sealed class JobHandlerRegistrationGenerator : IIncrementalGenerator +{ + private const string AttributeName = "SquidStd.Workers.Attributes.RegisterJobHandlerAttribute"; + private const string GeneratedFileName = "SquidStd.GeneratedJobHandlerRegistration.g.cs"; + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var candidates = context.SyntaxProvider.ForAttributeWithMetadataName( + AttributeName, + static (node, _) => node is ClassDeclarationSyntax, + static (context, cancellationToken) => CreateCandidate(context, cancellationToken) + ); + + context.RegisterSourceOutput(candidates.Collect(), static (context, candidates) => Execute(context, candidates)); + } + + private static JobHandlerRegistrationCandidate CreateCandidate( + GeneratorAttributeSyntaxContext context, + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + var handlerType = (INamedTypeSymbol)context.TargetSymbol; + var isSupported = GeneratorSymbolHelpers.IsConcreteNonGenericClass(handlerType) + && GeneratorSymbolHelpers.IsAccessibleFromGeneratedSource(handlerType) + && GeneratorSymbolHelpers.ImplementsInterface( + handlerType, + "IJobHandler", + "SquidStd.Workers.Interfaces" + ); + + return new JobHandlerRegistrationCandidate( + GeneratorSymbolHelpers.FullyQualified(handlerType), + GeneratorSymbolHelpers.DisplayName(handlerType), + GeneratorSymbolHelpers.PrimaryLocation(handlerType), + isSupported + ); + } + + private static void Execute( + SourceProductionContext context, + ImmutableArray candidates + ) + { + var supported = new List(); + var seenHandlerTypes = new HashSet(StringComparer.Ordinal); + + foreach (var candidate in candidates) + { + if (!candidate.IsSupported) + { + context.ReportDiagnostic( + Diagnostic.Create( + SquidStdGeneratorDiagnostics.UnsupportedJobHandler, + candidate.Location, + candidate.DisplayName + ) + ); + + continue; + } + + if (seenHandlerTypes.Add(candidate.HandlerTypeName)) + { + supported.Add(candidate); + } + } + + supported.Sort(static (left, right) => string.Compare( + left.HandlerTypeName, + right.HandlerTypeName, + StringComparison.Ordinal + ) + ); + + context.AddSource(GeneratedFileName, JobHandlerSourceBuilder.Build(supported)); + } +} diff --git a/src/SquidStd.Generators/Workers/JobHandlerSourceBuilder.cs b/src/SquidStd.Generators/Workers/JobHandlerSourceBuilder.cs new file mode 100644 index 00000000..22fcb6cb --- /dev/null +++ b/src/SquidStd.Generators/Workers/JobHandlerSourceBuilder.cs @@ -0,0 +1,37 @@ +using System.Text; + +namespace SquidStd.Generators.Workers; + +internal static class JobHandlerSourceBuilder +{ + public static string Build(IReadOnlyList candidates) + { + var builder = new StringBuilder(); + + builder.AppendLine("// "); + builder.AppendLine("#nullable enable"); + builder.AppendLine(); + builder.AppendLine("namespace SquidStd.Generators.Workers;"); + builder.AppendLine(); + builder.AppendLine("public static class GeneratedJobHandlerRegistrationExtensions"); + builder.AppendLine("{"); + builder.AppendLine( + " public static global::DryIoc.IContainer RegisterGeneratedJobHandlers(this global::DryIoc.IContainer container)" + ); + builder.AppendLine(" {"); + + for (var i = 0; i < candidates.Count; i++) + { + builder.Append(" global::SquidStd.Workers.Extensions.WorkersRegistrationExtensions.AddJobHandler<"); + builder.Append(candidates[i].HandlerTypeName); + builder.AppendLine(">(container);"); + } + + builder.AppendLine(); + builder.AppendLine(" return container;"); + builder.AppendLine(" }"); + builder.AppendLine("}"); + + return builder.ToString(); + } +} diff --git a/src/SquidStd.Mail.Abstractions/Exceptions/MailSendException.cs b/src/SquidStd.Mail.Abstractions/Exceptions/MailSendException.cs index d640d699..b7b4c6e7 100644 --- a/src/SquidStd.Mail.Abstractions/Exceptions/MailSendException.cs +++ b/src/SquidStd.Mail.Abstractions/Exceptions/MailSendException.cs @@ -5,5 +5,7 @@ public sealed class MailSendException : Exception { /// Initializes the exception with a message and the underlying cause. public MailSendException(string message, Exception innerException) - : base(message, innerException) { } + : base(message, innerException) + { + } } diff --git a/src/SquidStd.Mail.Abstractions/Interfaces/IMailReader.cs b/src/SquidStd.Mail.Abstractions/Interfaces/IMailReader.cs index 3038bfb5..4ba210f8 100644 --- a/src/SquidStd.Mail.Abstractions/Interfaces/IMailReader.cs +++ b/src/SquidStd.Mail.Abstractions/Interfaces/IMailReader.cs @@ -6,8 +6,8 @@ namespace SquidStd.Mail.Abstractions.Interfaces; public interface IMailReader { /// - /// Connects, fetches the new (unseen) messages, marks them seen / deletes them per options, disconnects, - /// and returns the parsed messages. + /// Connects, fetches the new (unseen) messages, marks them seen / deletes them per options, disconnects, + /// and returns the parsed messages. /// Task> FetchNewAsync(CancellationToken cancellationToken = default); } diff --git a/src/SquidStd.Mail.MailKit/Extensions/MailRegistrationExtensions.cs b/src/SquidStd.Mail.MailKit/Extensions/MailRegistrationExtensions.cs index 730dc8f3..5ef293ce 100644 --- a/src/SquidStd.Mail.MailKit/Extensions/MailRegistrationExtensions.cs +++ b/src/SquidStd.Mail.MailKit/Extensions/MailRegistrationExtensions.cs @@ -14,8 +14,8 @@ namespace SquidStd.Mail.MailKit.Extensions; public static class MailRegistrationExtensions { /// - /// Registers a single mailbox poller: the options, the protocol-specific , the - /// polling service, and the timer-wheel pump (only if not already registered). + /// Registers a single mailbox poller: the options, the protocol-specific , the + /// polling service, and the timer-wheel pump (only if not already registered). /// public static IContainer AddMail(this IContainer container, MailOptions options) { diff --git a/src/SquidStd.Mail.MailKit/Services/MailKitMailSender.cs b/src/SquidStd.Mail.MailKit/Services/MailKitMailSender.cs index 93f2d2c1..5fff176b 100644 --- a/src/SquidStd.Mail.MailKit/Services/MailKitMailSender.cs +++ b/src/SquidStd.Mail.MailKit/Services/MailKitMailSender.cs @@ -13,9 +13,9 @@ namespace SquidStd.Mail.MailKit.Services; /// MailKit : sends via SMTP and publishes send events. public sealed class MailKitMailSender : IMailSender { + private readonly IEventBus _eventBus; private readonly ILogger _logger = Log.ForContext(); private readonly SmtpOptions _options; - private readonly IEventBus _eventBus; public MailKitMailSender(SmtpOptions options, IEventBus eventBus) { diff --git a/src/SquidStd.Mail.MailKit/Services/MailPollingService.cs b/src/SquidStd.Mail.MailKit/Services/MailPollingService.cs index 89ec168a..02a11c04 100644 --- a/src/SquidStd.Mail.MailKit/Services/MailPollingService.cs +++ b/src/SquidStd.Mail.MailKit/Services/MailPollingService.cs @@ -12,13 +12,13 @@ namespace SquidStd.Mail.MailKit.Services; public sealed class MailPollingService : ISquidStdService, IDisposable { private const int DefaultIntervalSeconds = 60; + private readonly IEventBus _eventBus; + private readonly SemaphoreSlim _gate = new(1, 1); + private readonly TimeSpan _interval; private readonly ILogger _logger = Log.ForContext(); private readonly IMailReader _reader; - private readonly IEventBus _eventBus; private readonly ITimerService _timer; - private readonly SemaphoreSlim _gate = new(1, 1); - private readonly TimeSpan _interval; private string? _timerId; public MailPollingService(IMailReader reader, IEventBus eventBus, ITimerService timer, MailOptions options) @@ -43,7 +43,29 @@ public MailPollingService(IMailReader reader, IEventBus eventBus, ITimerService /// public void Dispose() - => _gate.Dispose(); + { + _gate.Dispose(); + } + + /// + public ValueTask StartAsync(CancellationToken cancellationToken = default) + { + _timerId = _timer.RegisterTimer("mail-poll", _interval, OnTick, _interval, true); + + return ValueTask.CompletedTask; + } + + /// + public ValueTask StopAsync(CancellationToken cancellationToken = default) + { + if (_timerId is not null) + { + _timer.UnregisterTimer(_timerId); + _timerId = null; + } + + return ValueTask.CompletedTask; + } /// Runs one poll and publishes events. Public so tests can drive it without the timer wheel. public async Task PollOnceAsync() @@ -72,26 +94,8 @@ public async Task PollOnceAsync() } } - /// - public ValueTask StartAsync(CancellationToken cancellationToken = default) - { - _timerId = _timer.RegisterTimer("mail-poll", _interval, OnTick, _interval, true); - - return ValueTask.CompletedTask; - } - - /// - public ValueTask StopAsync(CancellationToken cancellationToken = default) + private void OnTick() { - if (_timerId is not null) - { - _timer.UnregisterTimer(_timerId); - _timerId = null; - } - - return ValueTask.CompletedTask; + _ = PollOnceAsync(); } - - private void OnTick() - => _ = PollOnceAsync(); } diff --git a/src/SquidStd.Mail.MailKit/Services/MimeMessageMapper.cs b/src/SquidStd.Mail.MailKit/Services/MimeMessageMapper.cs index 52335d34..8d959901 100644 --- a/src/SquidStd.Mail.MailKit/Services/MimeMessageMapper.cs +++ b/src/SquidStd.Mail.MailKit/Services/MimeMessageMapper.cs @@ -15,9 +15,9 @@ public static MailMessage Map(MimeMessage message, bool includeAttachmentContent var to = message.To.Mailboxes.Select(ToAddress).ToArray(); var cc = message.Cc.Mailboxes.Select(ToAddress).ToArray(); var attachments = message.Attachments - .OfType() - .Select(part => ToAttachment(part, includeAttachmentContent)) - .ToArray(); + .OfType() + .Select(part => ToAttachment(part, includeAttachmentContent)) + .ToArray(); byte[]? rawEml = null; @@ -28,7 +28,7 @@ public static MailMessage Map(MimeMessage message, bool includeAttachmentContent rawEml = stream.ToArray(); } - return new( + return new MailMessage( from, to, cc, @@ -43,7 +43,9 @@ public static MailMessage Map(MimeMessage message, bool includeAttachmentContent } private static MailAddress ToAddress(MailboxAddress mailbox) - => new(mailbox.Name ?? string.Empty, mailbox.Address); + { + return new MailAddress(mailbox.Name ?? string.Empty, mailbox.Address); + } private static MailAttachment ToAttachment(MimePart part, bool includeContent) { @@ -63,6 +65,6 @@ private static MailAttachment ToAttachment(MimePart part, bool includeContent) var fileName = part.FileName ?? string.Empty; - return new(fileName, part.ContentType.MimeType, size, content); + return new MailAttachment(fileName, part.ContentType.MimeType, size, content); } } diff --git a/src/SquidStd.Mail.MailKit/Services/OutgoingMessageMapper.cs b/src/SquidStd.Mail.MailKit/Services/OutgoingMessageMapper.cs index 7ac2bd8b..c589197b 100644 --- a/src/SquidStd.Mail.MailKit/Services/OutgoingMessageMapper.cs +++ b/src/SquidStd.Mail.MailKit/Services/OutgoingMessageMapper.cs @@ -44,5 +44,7 @@ public static MimeMessage ToMimeMessage(OutgoingMailMessage message, SmtpOptions } private static MailboxAddress ToMailbox(MailAddress address) - => new(address.Name, address.Address); + { + return new MailboxAddress(address.Name, address.Address); + } } diff --git a/src/SquidStd.Mail.Queue/Extensions/MailQueueRegistrationExtensions.cs b/src/SquidStd.Mail.Queue/Extensions/MailQueueRegistrationExtensions.cs index ceb036e1..bfc29b29 100644 --- a/src/SquidStd.Mail.Queue/Extensions/MailQueueRegistrationExtensions.cs +++ b/src/SquidStd.Mail.Queue/Extensions/MailQueueRegistrationExtensions.cs @@ -10,8 +10,8 @@ namespace SquidStd.Mail.Queue.Extensions; public static class MailQueueRegistrationExtensions { /// - /// Registers the mail queue and its background consumer. Requires IMessageQueue (messaging) and - /// IMailSender (the SMTP sender) to be registered already. + /// Registers the mail queue and its background consumer. Requires IMessageQueue (messaging) and + /// IMailSender (the SMTP sender) to be registered already. /// public static IContainer AddMailQueue(this IContainer container, MailQueueOptions? options = null) { diff --git a/src/SquidStd.Mail.Queue/Services/MailSendConsumerService.cs b/src/SquidStd.Mail.Queue/Services/MailSendConsumerService.cs index b0760751..a5bdf7bc 100644 --- a/src/SquidStd.Mail.Queue/Services/MailSendConsumerService.cs +++ b/src/SquidStd.Mail.Queue/Services/MailSendConsumerService.cs @@ -8,15 +8,15 @@ namespace SquidStd.Mail.Queue.Services; /// -/// Consumes queued outbound messages and sends them via . Exceptions propagate so the -/// messaging layer retries / dead-letters. +/// Consumes queued outbound messages and sends them via . Exceptions propagate so the +/// messaging layer retries / dead-letters. /// public sealed class MailSendConsumerService : ISquidStdService, IQueueMessageListenerAsync { private readonly ILogger _logger = Log.ForContext(); private readonly IMessageQueue _queue; - private readonly IMailSender _sender; private readonly string _queueName; + private readonly IMailSender _sender; private IDisposable? _subscription; public MailSendConsumerService(IMessageQueue queue, IMailSender sender, MailQueueOptions options) @@ -28,7 +28,9 @@ public MailSendConsumerService(IMessageQueue queue, IMailSender sender, MailQueu /// public Task HandleAsync(OutgoingMailMessage message, CancellationToken cancellationToken) - => _sender.SendAsync(message, cancellationToken); + { + return _sender.SendAsync(message, cancellationToken); + } /// public ValueTask StartAsync(CancellationToken cancellationToken = default) diff --git a/src/SquidStd.Messaging.Abstractions/Data/Config/MessagingConnectionString.cs b/src/SquidStd.Messaging.Abstractions/Data/Config/MessagingConnectionString.cs index b54f257d..2ffacb4b 100644 --- a/src/SquidStd.Messaging.Abstractions/Data/Config/MessagingConnectionString.cs +++ b/src/SquidStd.Messaging.Abstractions/Data/Config/MessagingConnectionString.cs @@ -4,7 +4,7 @@ namespace SquidStd.Messaging.Abstractions.Data.Config; /// -/// Parsed messaging connection string of the form scheme://[user:pass@]host[:port][/vhost][?params]. +/// Parsed messaging connection string of the form scheme://[user:pass@]host[:port][/vhost][?params]. /// public sealed class MessagingConnectionString { @@ -68,14 +68,14 @@ public static MessagingConnectionString Parse(string connectionString) var virtualHost = uri.AbsolutePath.Trim('/'); var query = HttpUtility.ParseQueryString(uri.Query); var parameters = query.AllKeys - .Where(static key => key is not null) - .ToFrozenDictionary( - key => key!, - key => query[key] ?? string.Empty, - StringComparer.OrdinalIgnoreCase - ); - - return new( + .Where(static key => key is not null) + .ToFrozenDictionary( + key => key!, + key => query[key] ?? string.Empty, + StringComparer.OrdinalIgnoreCase + ); + + return new MessagingConnectionString( uri.Scheme, uri.Host, uri.Port > 0 ? uri.Port : null, @@ -88,15 +88,17 @@ public static MessagingConnectionString Parse(string connectionString) /// Builds from the query parameters. public MessagingOptions ToMessagingOptions() - => new() + { + return new MessagingOptions { MaxDeliveryAttempts = Parameters.TryGetValue("maxDeliveryAttempts", out var max) && int.TryParse(max, out var parsedMax) - ? parsedMax - : 3, + ? parsedMax + : 3, DeadLetterQueueSuffix = Parameters.TryGetValue("deadLetterSuffix", out var suffix) ? suffix : ".dlq", RetryDelay = Parameters.TryGetValue("retryDelayMs", out var delay) && int.TryParse(delay, out var parsedDelay) - ? TimeSpan.FromMilliseconds(parsedDelay) - : TimeSpan.Zero + ? TimeSpan.FromMilliseconds(parsedDelay) + : TimeSpan.Zero }; + } } diff --git a/src/SquidStd.Messaging.Abstractions/Data/Config/MessagingOptions.cs b/src/SquidStd.Messaging.Abstractions/Data/Config/MessagingOptions.cs index 97afa226..482f174b 100644 --- a/src/SquidStd.Messaging.Abstractions/Data/Config/MessagingOptions.cs +++ b/src/SquidStd.Messaging.Abstractions/Data/Config/MessagingOptions.cs @@ -1,7 +1,7 @@ namespace SquidStd.Messaging.Abstractions.Data.Config; /// -/// Configuration for the messaging system. +/// Configuration for the messaging system. /// public sealed class MessagingOptions { diff --git a/src/SquidStd.Messaging.Abstractions/Data/Events/TopicMessageEvent.cs b/src/SquidStd.Messaging.Abstractions/Data/Events/TopicMessageEvent.cs index b3a43554..b64fc0cd 100644 --- a/src/SquidStd.Messaging.Abstractions/Data/Events/TopicMessageEvent.cs +++ b/src/SquidStd.Messaging.Abstractions/Data/Events/TopicMessageEvent.cs @@ -3,7 +3,7 @@ namespace SquidStd.Messaging.Abstractions.Data.Events; /// -/// Event published on the in-process event bus when a topic message is bridged. is the -/// deserialized message. +/// Event published on the in-process event bus when a topic message is bridged. is the +/// deserialized message. /// public sealed record TopicMessageEvent(string Topic, object Data) : IEvent; diff --git a/src/SquidStd.Messaging.Abstractions/Interfaces/IMessageQueue.cs b/src/SquidStd.Messaging.Abstractions/Interfaces/IMessageQueue.cs index 32f7011e..0fd58df5 100644 --- a/src/SquidStd.Messaging.Abstractions/Interfaces/IMessageQueue.cs +++ b/src/SquidStd.Messaging.Abstractions/Interfaces/IMessageQueue.cs @@ -1,7 +1,7 @@ namespace SquidStd.Messaging.Abstractions.Interfaces; /// -/// Typed facade for publishing to and subscribing to named queues. +/// Typed facade for publishing to and subscribing to named queues. /// public interface IMessageQueue { diff --git a/src/SquidStd.Messaging.Abstractions/Interfaces/IMessageTopic.cs b/src/SquidStd.Messaging.Abstractions/Interfaces/IMessageTopic.cs index e1b1c2be..09872ecc 100644 --- a/src/SquidStd.Messaging.Abstractions/Interfaces/IMessageTopic.cs +++ b/src/SquidStd.Messaging.Abstractions/Interfaces/IMessageTopic.cs @@ -1,7 +1,7 @@ namespace SquidStd.Messaging.Abstractions.Interfaces; /// -/// Typed facade for publishing to and subscribing to topics (fan-out). +/// Typed facade for publishing to and subscribing to topics (fan-out). /// public interface IMessageTopic { diff --git a/src/SquidStd.Messaging.Abstractions/Interfaces/IMessagingMetrics.cs b/src/SquidStd.Messaging.Abstractions/Interfaces/IMessagingMetrics.cs index 4ebcdb4e..4b12e229 100644 --- a/src/SquidStd.Messaging.Abstractions/Interfaces/IMessagingMetrics.cs +++ b/src/SquidStd.Messaging.Abstractions/Interfaces/IMessagingMetrics.cs @@ -1,7 +1,7 @@ namespace SquidStd.Messaging.Abstractions.Interfaces; /// -/// Sink for messaging metric events. Implementations must be thread-safe. +/// Sink for messaging metric events. Implementations must be thread-safe. /// public interface IMessagingMetrics { diff --git a/src/SquidStd.Messaging.Abstractions/Interfaces/IQueueMessageListener.cs b/src/SquidStd.Messaging.Abstractions/Interfaces/IQueueMessageListener.cs index b384b5f1..a8d9d46a 100644 --- a/src/SquidStd.Messaging.Abstractions/Interfaces/IQueueMessageListener.cs +++ b/src/SquidStd.Messaging.Abstractions/Interfaces/IQueueMessageListener.cs @@ -1,7 +1,7 @@ namespace SquidStd.Messaging.Abstractions.Interfaces; /// -/// Handles a queue message synchronously. +/// Handles a queue message synchronously. /// /// The message payload type. public interface IQueueMessageListener diff --git a/src/SquidStd.Messaging.Abstractions/Interfaces/IQueueMessageListenerAsync.cs b/src/SquidStd.Messaging.Abstractions/Interfaces/IQueueMessageListenerAsync.cs index e7708457..90f18c18 100644 --- a/src/SquidStd.Messaging.Abstractions/Interfaces/IQueueMessageListenerAsync.cs +++ b/src/SquidStd.Messaging.Abstractions/Interfaces/IQueueMessageListenerAsync.cs @@ -1,7 +1,7 @@ namespace SquidStd.Messaging.Abstractions.Interfaces; /// -/// Handles a queue message asynchronously. +/// Handles a queue message asynchronously. /// /// The message payload type. public interface IQueueMessageListenerAsync diff --git a/src/SquidStd.Messaging.Abstractions/Interfaces/IQueueProvider.cs b/src/SquidStd.Messaging.Abstractions/Interfaces/IQueueProvider.cs index 18e26062..d7603f7e 100644 --- a/src/SquidStd.Messaging.Abstractions/Interfaces/IQueueProvider.cs +++ b/src/SquidStd.Messaging.Abstractions/Interfaces/IQueueProvider.cs @@ -3,7 +3,7 @@ namespace SquidStd.Messaging.Abstractions.Interfaces; /// -/// Byte-level queue transport owning buffering, round-robin delivery, retry and dead-lettering. +/// Byte-level queue transport owning buffering, round-robin delivery, retry and dead-lettering. /// public interface IQueueProvider : ISquidStdService, IAsyncDisposable { diff --git a/src/SquidStd.Messaging.Abstractions/Interfaces/ITopicEventBridge.cs b/src/SquidStd.Messaging.Abstractions/Interfaces/ITopicEventBridge.cs index 2ba8483c..9cf69bac 100644 --- a/src/SquidStd.Messaging.Abstractions/Interfaces/ITopicEventBridge.cs +++ b/src/SquidStd.Messaging.Abstractions/Interfaces/ITopicEventBridge.cs @@ -1,8 +1,8 @@ namespace SquidStd.Messaging.Abstractions.Interfaces; /// -/// Bridges a topic into the in-process event bus: each message of type T on the topic is republished -/// as a TopicMessageEvent. +/// Bridges a topic into the in-process event bus: each message of type T on the topic is republished +/// as a TopicMessageEvent. /// public interface ITopicEventBridge { diff --git a/src/SquidStd.Messaging.Abstractions/Interfaces/ITopicProvider.cs b/src/SquidStd.Messaging.Abstractions/Interfaces/ITopicProvider.cs index c5882dc4..3deffb58 100644 --- a/src/SquidStd.Messaging.Abstractions/Interfaces/ITopicProvider.cs +++ b/src/SquidStd.Messaging.Abstractions/Interfaces/ITopicProvider.cs @@ -3,8 +3,8 @@ namespace SquidStd.Messaging.Abstractions.Interfaces; /// -/// Byte-level publish/subscribe transport: every current subscriber of a topic receives every message -/// (transient, at-most-once fan-out). +/// Byte-level publish/subscribe transport: every current subscriber of a topic receives every message +/// (transient, at-most-once fan-out). /// public interface ITopicProvider : ISquidStdService, IAsyncDisposable { diff --git a/src/SquidStd.Messaging.Abstractions/Services/MessageQueue.cs b/src/SquidStd.Messaging.Abstractions/Services/MessageQueue.cs index 298f2747..b1f2581b 100644 --- a/src/SquidStd.Messaging.Abstractions/Services/MessageQueue.cs +++ b/src/SquidStd.Messaging.Abstractions/Services/MessageQueue.cs @@ -4,14 +4,14 @@ namespace SquidStd.Messaging.Abstractions.Services; /// -/// Typed facade over an : serializes outgoing messages and -/// deserializes incoming payloads before handing them to typed listeners. +/// Typed facade over an : serializes outgoing messages and +/// deserializes incoming payloads before handing them to typed listeners. /// public sealed class MessageQueue : IMessageQueue { + private readonly IDataDeserializer _deserializer; private readonly IQueueProvider _provider; private readonly IDataSerializer _serializer; - private readonly IDataDeserializer _deserializer; public MessageQueue(IQueueProvider provider, IDataSerializer serializer, IDataDeserializer deserializer) { @@ -22,7 +22,9 @@ public MessageQueue(IQueueProvider provider, IDataSerializer serializer, IDataDe /// public Task PublishAsync(string queueName, TMessage message, CancellationToken cancellationToken = default) - => _provider.PublishAsync(queueName, _serializer.Serialize(message), cancellationToken); + { + return _provider.PublishAsync(queueName, _serializer.Serialize(message), cancellationToken); + } /// public IDisposable Subscribe(string queueName, IQueueMessageListener listener) diff --git a/src/SquidStd.Messaging.Abstractions/Services/MessageTopic.cs b/src/SquidStd.Messaging.Abstractions/Services/MessageTopic.cs index 7677f737..84085782 100644 --- a/src/SquidStd.Messaging.Abstractions/Services/MessageTopic.cs +++ b/src/SquidStd.Messaging.Abstractions/Services/MessageTopic.cs @@ -4,14 +4,14 @@ namespace SquidStd.Messaging.Abstractions.Services; /// -/// Typed facade over an : serializes outgoing messages and deserializes -/// incoming payloads. +/// Typed facade over an : serializes outgoing messages and deserializes +/// incoming payloads. /// public sealed class MessageTopic : IMessageTopic { + private readonly IDataDeserializer _deserializer; private readonly ITopicProvider _provider; private readonly IDataSerializer _serializer; - private readonly IDataDeserializer _deserializer; public MessageTopic(ITopicProvider provider, IDataSerializer serializer, IDataDeserializer deserializer) { @@ -22,7 +22,9 @@ public MessageTopic(ITopicProvider provider, IDataSerializer serializer, IDataDe /// public Task PublishAsync(string topic, TMessage message, CancellationToken cancellationToken = default) - => _provider.PublishAsync(topic, _serializer.Serialize(message), cancellationToken); + { + return _provider.PublishAsync(topic, _serializer.Serialize(message), cancellationToken); + } /// public IDisposable Subscribe(string topic, Func handler) diff --git a/src/SquidStd.Messaging.Abstractions/Services/MessagingMetricsProvider.cs b/src/SquidStd.Messaging.Abstractions/Services/MessagingMetricsProvider.cs index 5d73279f..dd10dd31 100644 --- a/src/SquidStd.Messaging.Abstractions/Services/MessagingMetricsProvider.cs +++ b/src/SquidStd.Messaging.Abstractions/Services/MessagingMetricsProvider.cs @@ -7,77 +7,106 @@ namespace SquidStd.Messaging.Abstractions.Services; /// -/// Accumulates messaging metrics and exposes them to the metrics collection system. +/// Accumulates messaging metrics and exposes them to the metrics collection system. /// public sealed class MessagingMetricsProvider : IMessagingMetrics, IMetricProvider { private readonly ConcurrentDictionary _queues = new(StringComparer.Ordinal); /// - public string ProviderName => "messaging"; - - private sealed class QueueCounters + public void OnDeadLettered(string queueName) { - public long Published; - public long Delivered; - public long Failed; - public long Retried; - public long DeadLettered; - public int Depth; - public int Subscribers; + Interlocked.Increment(ref Counters(queueName).DeadLettered); } /// - public ValueTask> CollectAsync(CancellationToken cancellationToken = default) + public void OnDelivered(string queueName) { - var samples = new List(); - - foreach (var (queueName, counters) in _queues) - { - var tags = new Dictionary(StringComparer.Ordinal) { ["queue"] = queueName }; - - samples.Add(new("published", Interlocked.Read(ref counters.Published), Tags: tags, Type: MetricType.Counter)); - samples.Add(new("delivered", Interlocked.Read(ref counters.Delivered), Tags: tags, Type: MetricType.Counter)); - samples.Add(new("failed", Interlocked.Read(ref counters.Failed), Tags: tags, Type: MetricType.Counter)); - samples.Add(new("retried", Interlocked.Read(ref counters.Retried), Tags: tags, Type: MetricType.Counter)); - samples.Add( - new("dead_lettered", Interlocked.Read(ref counters.DeadLettered), Tags: tags, Type: MetricType.Counter) - ); - samples.Add(new("queue_depth", Volatile.Read(ref counters.Depth), Tags: tags)); - samples.Add(new("subscribers", Volatile.Read(ref counters.Subscribers), Tags: tags)); - } - - return ValueTask.FromResult>(samples); + Interlocked.Increment(ref Counters(queueName).Delivered); } - /// - public void OnDeadLettered(string queueName) - => Interlocked.Increment(ref Counters(queueName).DeadLettered); - - /// - public void OnDelivered(string queueName) - => Interlocked.Increment(ref Counters(queueName).Delivered); - /// public void OnFailed(string queueName) - => Interlocked.Increment(ref Counters(queueName).Failed); + { + Interlocked.Increment(ref Counters(queueName).Failed); + } /// public void OnPublished(string queueName) - => Interlocked.Increment(ref Counters(queueName).Published); + { + Interlocked.Increment(ref Counters(queueName).Published); + } /// public void OnRetried(string queueName) - => Interlocked.Increment(ref Counters(queueName).Retried); + { + Interlocked.Increment(ref Counters(queueName).Retried); + } /// public void SetQueueDepth(string queueName, int depth) - => Volatile.Write(ref Counters(queueName).Depth, depth); + { + Volatile.Write(ref Counters(queueName).Depth, depth); + } /// public void SetSubscriberCount(string queueName, int count) - => Volatile.Write(ref Counters(queueName).Subscribers, count); + { + Volatile.Write(ref Counters(queueName).Subscribers, count); + } + + /// + public string ProviderName => "messaging"; + + /// + public ValueTask> CollectAsync(CancellationToken cancellationToken = default) + { + var samples = new List(); + + foreach (var (queueName, counters) in _queues) + { + var tags = new Dictionary(StringComparer.Ordinal) { ["queue"] = queueName }; + + samples.Add( + new MetricSample("published", Interlocked.Read(ref counters.Published), Tags: tags, Type: MetricType.Counter) + ); + samples.Add( + new MetricSample("delivered", Interlocked.Read(ref counters.Delivered), Tags: tags, Type: MetricType.Counter) + ); + samples.Add( + new MetricSample("failed", Interlocked.Read(ref counters.Failed), Tags: tags, Type: MetricType.Counter) + ); + samples.Add( + new MetricSample("retried", Interlocked.Read(ref counters.Retried), Tags: tags, Type: MetricType.Counter) + ); + samples.Add( + new MetricSample( + "dead_lettered", + Interlocked.Read(ref counters.DeadLettered), + Tags: tags, + Type: MetricType.Counter + ) + ); + samples.Add(new MetricSample("queue_depth", Volatile.Read(ref counters.Depth), Tags: tags)); + samples.Add(new MetricSample("subscribers", Volatile.Read(ref counters.Subscribers), Tags: tags)); + } + + return ValueTask.FromResult>(samples); + } private QueueCounters Counters(string queueName) - => _queues.GetOrAdd(queueName, static _ => new()); + { + return _queues.GetOrAdd(queueName, static _ => new QueueCounters()); + } + + private sealed class QueueCounters + { + public long DeadLettered; + public long Delivered; + public int Depth; + public long Failed; + public long Published; + public long Retried; + public int Subscribers; + } } diff --git a/src/SquidStd.Messaging.Abstractions/Services/NoOpMessagingMetrics.cs b/src/SquidStd.Messaging.Abstractions/Services/NoOpMessagingMetrics.cs index 49fbfb26..a51d632d 100644 --- a/src/SquidStd.Messaging.Abstractions/Services/NoOpMessagingMetrics.cs +++ b/src/SquidStd.Messaging.Abstractions/Services/NoOpMessagingMetrics.cs @@ -3,24 +3,38 @@ namespace SquidStd.Messaging.Abstractions.Services; /// -/// Metrics sink that ignores all events. Used when no metrics are configured. +/// Metrics sink that ignores all events. Used when no metrics are configured. /// public sealed class NoOpMessagingMetrics : IMessagingMetrics { /// Shared instance. public static NoOpMessagingMetrics Instance { get; } = new(); - public void OnDeadLettered(string queueName) { } + public void OnDeadLettered(string queueName) + { + } - public void OnDelivered(string queueName) { } + public void OnDelivered(string queueName) + { + } - public void OnFailed(string queueName) { } + public void OnFailed(string queueName) + { + } - public void OnPublished(string queueName) { } + public void OnPublished(string queueName) + { + } - public void OnRetried(string queueName) { } + public void OnRetried(string queueName) + { + } - public void SetQueueDepth(string queueName, int depth) { } + public void SetQueueDepth(string queueName, int depth) + { + } - public void SetSubscriberCount(string queueName, int count) { } + public void SetSubscriberCount(string queueName, int count) + { + } } diff --git a/src/SquidStd.Messaging.Abstractions/Services/TopicEventBridge.cs b/src/SquidStd.Messaging.Abstractions/Services/TopicEventBridge.cs index 29481e60..03e84c44 100644 --- a/src/SquidStd.Messaging.Abstractions/Services/TopicEventBridge.cs +++ b/src/SquidStd.Messaging.Abstractions/Services/TopicEventBridge.cs @@ -5,13 +5,13 @@ namespace SquidStd.Messaging.Abstractions.Services; /// -/// One-way Topic → EventBus bridge. Subscribes a topic and republishes each message as a -/// on the . +/// One-way Topic → EventBus bridge. Subscribes a topic and republishes each message as a +/// on the . /// public sealed class TopicEventBridge : ITopicEventBridge { - private readonly IMessageTopic _topic; private readonly IEventBus _eventBus; + private readonly IMessageTopic _topic; public TopicEventBridge(IMessageTopic topic, IEventBus eventBus) { @@ -21,8 +21,10 @@ public TopicEventBridge(IMessageTopic topic, IEventBus eventBus) /// public IDisposable Bridge(string topic) - => _topic.Subscribe( + { + return _topic.Subscribe( topic, (data, cancellationToken) => _eventBus.PublishAsync(new TopicMessageEvent(topic, data!), cancellationToken) ); + } } diff --git a/src/SquidStd.Messaging.RabbitMq/Data/Config/RabbitMqOptions.cs b/src/SquidStd.Messaging.RabbitMq/Data/Config/RabbitMqOptions.cs index 4e939ab7..f4f90d1b 100644 --- a/src/SquidStd.Messaging.RabbitMq/Data/Config/RabbitMqOptions.cs +++ b/src/SquidStd.Messaging.RabbitMq/Data/Config/RabbitMqOptions.cs @@ -1,7 +1,7 @@ namespace SquidStd.Messaging.RabbitMq.Data.Config; /// -/// Connection options for the RabbitMQ queue provider. +/// Connection options for the RabbitMQ queue provider. /// public sealed class RabbitMqOptions { diff --git a/src/SquidStd.Messaging.RabbitMq/Extensions/RabbitMqMessagingRegistrationExtensions.cs b/src/SquidStd.Messaging.RabbitMq/Extensions/RabbitMqMessagingRegistrationExtensions.cs index 9391f499..048a122a 100644 --- a/src/SquidStd.Messaging.RabbitMq/Extensions/RabbitMqMessagingRegistrationExtensions.cs +++ b/src/SquidStd.Messaging.RabbitMq/Extensions/RabbitMqMessagingRegistrationExtensions.cs @@ -11,7 +11,7 @@ namespace SquidStd.Messaging.RabbitMq.Extensions; /// -/// DryIoc registration helpers for the RabbitMQ messaging provider. +/// DryIoc registration helpers for the RabbitMQ messaging provider. /// public static class RabbitMqMessagingRegistrationExtensions { @@ -79,8 +79,8 @@ public static IContainer AddRabbitMqMessaging(this IContainer container, string Password = cs.Password ?? "guest", PrefetchCount = cs.Parameters.TryGetValue("prefetch", out var prefetch) && ushort.TryParse(prefetch, out var parsed) - ? parsed - : (ushort)10 + ? parsed + : (ushort)10 }; return container.AddRabbitMqMessaging(options, cs.ToMessagingOptions()); diff --git a/src/SquidStd.Messaging.RabbitMq/Services/RabbitMqQueueProvider.cs b/src/SquidStd.Messaging.RabbitMq/Services/RabbitMqQueueProvider.cs index af2e97a7..0b860882 100644 --- a/src/SquidStd.Messaging.RabbitMq/Services/RabbitMqQueueProvider.cs +++ b/src/SquidStd.Messaging.RabbitMq/Services/RabbitMqQueueProvider.cs @@ -10,22 +10,22 @@ namespace SquidStd.Messaging.RabbitMq.Services; /// -/// RabbitMQ : named queues map to quorum queues with a delivery limit -/// and a dead-letter exchange; round-robin is the broker's native competing-consumers behaviour. +/// RabbitMQ : named queues map to quorum queues with a delivery limit +/// and a dead-letter exchange; round-robin is the broker's native competing-consumers behaviour. /// public sealed class RabbitMqQueueProvider : IQueueProvider { + private readonly HashSet _declared = new(StringComparer.Ordinal); private readonly ILogger _logger = Log.ForContext(); - private readonly RabbitMqOptions _options; private readonly MessagingOptions _messagingOptions; private readonly IMessagingMetrics _metrics; + private readonly RabbitMqOptions _options; private readonly SemaphoreSlim _publishLock = new(1, 1); - private readonly Lock _topologySync = new(); - private readonly HashSet _declared = new(StringComparer.Ordinal); private readonly ConcurrentDictionary _subscriberCounts = new(StringComparer.Ordinal); + private readonly Lock _topologySync = new(); private IConnection? _connection; - private IChannel? _publishChannel; private int _disposed; + private IChannel? _publishChannel; public RabbitMqQueueProvider( RabbitMqOptions options, @@ -38,94 +38,6 @@ public RabbitMqQueueProvider( _metrics = metrics ?? NoOpMessagingMetrics.Instance; } - private sealed class Subscription : IDisposable - { - private readonly RabbitMqQueueProvider _provider; - private readonly IConnection _connection; - private readonly string _queueName; - private readonly Func, CancellationToken, Task> _handler; - private IChannel? _channel; - private string? _consumerTag; - private int _disposed; - - public Subscription( - RabbitMqQueueProvider provider, - IConnection connection, - string queueName, - Func, CancellationToken, Task> handler - ) - { - _provider = provider; - _connection = connection; - _queueName = queueName; - _handler = handler; - } - - public void Dispose() - { - if (Interlocked.Exchange(ref _disposed, 1) != 0) - { - return; - } - - if (_channel is not null) - { - try - { - if (_consumerTag is not null) - { - _channel.BasicCancelAsync(_consumerTag).GetAwaiter().GetResult(); - } - - _channel.CloseAsync().GetAwaiter().GetResult(); - _channel.DisposeAsync().AsTask().GetAwaiter().GetResult(); - } - catch - { - // Best-effort teardown. - } - } - - _provider._metrics.SetSubscriberCount( - _queueName, - _provider._subscriberCounts.AddOrUpdate(_queueName, 0, static (_, count) => Math.Max(0, count - 1)) - ); - } - - public void Start() - => StartAsync().GetAwaiter().GetResult(); - - private async Task OnReceivedAsync(object sender, BasicDeliverEventArgs args) - { - var channel = _channel!; - - try - { - await _handler(args.Body, CancellationToken.None); - await channel.BasicAckAsync(args.DeliveryTag, false); - _provider._metrics.OnDelivered(_queueName); - } - catch (Exception ex) - { - _provider._logger.Warning(ex, "RabbitMq handler failed for {QueueName}", _queueName); - _provider._metrics.OnFailed(_queueName); - await channel.BasicNackAsync(args.DeliveryTag, false, true); - } - } - - private async Task StartAsync() - { - _channel = await _connection.CreateChannelAsync(); - await _provider.EnsureTopologyAsync(_channel, _queueName, CancellationToken.None); - await _channel.BasicQosAsync(0, _provider._options.PrefetchCount, false); - - var consumer = new AsyncEventingBasicConsumer(_channel); - consumer.ReceivedAsync += OnReceivedAsync; - - _consumerTag = await _channel.BasicConsumeAsync(_queueName, false, consumer); - } - } - /// public async ValueTask DisposeAsync() { @@ -208,7 +120,9 @@ public async ValueTask StartAsync(CancellationToken cancellationToken = default) /// public ValueTask StopAsync(CancellationToken cancellationToken = default) - => DisposeAsync(); + { + return DisposeAsync(); + } /// public IDisposable Subscribe(string queueName, Func, CancellationToken, Task> handler) @@ -275,4 +189,94 @@ await channel.QueueDeclareAsync( cancellationToken: cancellationToken ); } + + private sealed class Subscription : IDisposable + { + private readonly IConnection _connection; + private readonly Func, CancellationToken, Task> _handler; + private readonly RabbitMqQueueProvider _provider; + private readonly string _queueName; + private IChannel? _channel; + private string? _consumerTag; + private int _disposed; + + public Subscription( + RabbitMqQueueProvider provider, + IConnection connection, + string queueName, + Func, CancellationToken, Task> handler + ) + { + _provider = provider; + _connection = connection; + _queueName = queueName; + _handler = handler; + } + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + if (_channel is not null) + { + try + { + if (_consumerTag is not null) + { + _channel.BasicCancelAsync(_consumerTag).GetAwaiter().GetResult(); + } + + _channel.CloseAsync().GetAwaiter().GetResult(); + _channel.DisposeAsync().AsTask().GetAwaiter().GetResult(); + } + catch + { + // Best-effort teardown. + } + } + + _provider._metrics.SetSubscriberCount( + _queueName, + _provider._subscriberCounts.AddOrUpdate(_queueName, 0, static (_, count) => Math.Max(0, count - 1)) + ); + } + + public void Start() + { + StartAsync().GetAwaiter().GetResult(); + } + + private async Task OnReceivedAsync(object sender, BasicDeliverEventArgs args) + { + var channel = _channel!; + + try + { + await _handler(args.Body, CancellationToken.None); + await channel.BasicAckAsync(args.DeliveryTag, false); + _provider._metrics.OnDelivered(_queueName); + } + catch (Exception ex) + { + _provider._logger.Warning(ex, "RabbitMq handler failed for {QueueName}", _queueName); + _provider._metrics.OnFailed(_queueName); + await channel.BasicNackAsync(args.DeliveryTag, false, true); + } + } + + private async Task StartAsync() + { + _channel = await _connection.CreateChannelAsync(); + await _provider.EnsureTopologyAsync(_channel, _queueName, CancellationToken.None); + await _channel.BasicQosAsync(0, _provider._options.PrefetchCount, false); + + var consumer = new AsyncEventingBasicConsumer(_channel); + consumer.ReceivedAsync += OnReceivedAsync; + + _consumerTag = await _channel.BasicConsumeAsync(_queueName, false, consumer); + } + } } diff --git a/src/SquidStd.Messaging.RabbitMq/Services/RabbitMqTopicProvider.cs b/src/SquidStd.Messaging.RabbitMq/Services/RabbitMqTopicProvider.cs index e77d0055..300c5936 100644 --- a/src/SquidStd.Messaging.RabbitMq/Services/RabbitMqTopicProvider.cs +++ b/src/SquidStd.Messaging.RabbitMq/Services/RabbitMqTopicProvider.cs @@ -7,105 +7,25 @@ namespace SquidStd.Messaging.RabbitMq.Services; /// -/// RabbitMQ : topics map to fanout exchanges; each subscriber binds an -/// exclusive auto-delete queue and consumes with auto-ack (transient, at-most-once fan-out). +/// RabbitMQ : topics map to fanout exchanges; each subscriber binds an +/// exclusive auto-delete queue and consumes with auto-ack (transient, at-most-once fan-out). /// public sealed class RabbitMqTopicProvider : ITopicProvider { + private readonly HashSet _declared = new(StringComparer.Ordinal); + private readonly Lock _exchangeSync = new(); private readonly ILogger _logger = Log.ForContext(); private readonly RabbitMqOptions _options; private readonly SemaphoreSlim _publishLock = new(1, 1); - private readonly Lock _exchangeSync = new(); - private readonly HashSet _declared = new(StringComparer.Ordinal); private IConnection? _connection; - private IChannel? _publishChannel; private int _disposed; + private IChannel? _publishChannel; public RabbitMqTopicProvider(RabbitMqOptions options) { _options = options; } - private sealed class Subscription : IDisposable - { - private readonly RabbitMqTopicProvider _provider; - private readonly IConnection _connection; - private readonly string _topic; - private readonly Func, CancellationToken, Task> _handler; - private IChannel? _channel; - private string? _consumerTag; - private int _disposed; - - public Subscription( - RabbitMqTopicProvider provider, - IConnection connection, - string topic, - Func, CancellationToken, Task> handler - ) - { - _provider = provider; - _connection = connection; - _topic = topic; - _handler = handler; - } - - public void Dispose() - { - if (Interlocked.Exchange(ref _disposed, 1) != 0) - { - return; - } - - if (_channel is not null) - { - try - { - if (_consumerTag is not null) - { - _channel.BasicCancelAsync(_consumerTag).GetAwaiter().GetResult(); - } - - _channel.CloseAsync().GetAwaiter().GetResult(); - _channel.DisposeAsync().AsTask().GetAwaiter().GetResult(); - } - catch - { - // Best-effort teardown. - } - } - } - - public void Start() - => StartAsync().GetAwaiter().GetResult(); - - private async Task OnReceivedAsync(object sender, BasicDeliverEventArgs args) - { - try - { - await _handler(args.Body, CancellationToken.None); - } - catch (Exception ex) - { - _provider._logger.Warning(ex, "RabbitMq topic '{Topic}' handler failed", _topic); - } - } - - private async Task StartAsync() - { - _channel = await _connection.CreateChannelAsync(); - - await _channel.ExchangeDeclareAsync(_topic, ExchangeType.Fanout, false, false); - - var queue = await _channel.QueueDeclareAsync(string.Empty, false, true, true); - await _channel.QueueBindAsync(queue.QueueName, _topic, string.Empty); - - var consumer = new AsyncEventingBasicConsumer(_channel); - consumer.ReceivedAsync += OnReceivedAsync; - - _consumerTag = await _channel.BasicConsumeAsync(queue.QueueName, true, consumer); - } - } - /// public async ValueTask DisposeAsync() { @@ -182,7 +102,9 @@ public async ValueTask StartAsync(CancellationToken cancellationToken = default) /// public ValueTask StopAsync(CancellationToken cancellationToken = default) - => DisposeAsync(); + { + return DisposeAsync(); + } /// public IDisposable Subscribe(string topic, Func, CancellationToken, Task> handler) @@ -215,4 +137,86 @@ await channel.ExchangeDeclareAsync( cancellationToken: cancellationToken ); } + + private sealed class Subscription : IDisposable + { + private readonly IConnection _connection; + private readonly Func, CancellationToken, Task> _handler; + private readonly RabbitMqTopicProvider _provider; + private readonly string _topic; + private IChannel? _channel; + private string? _consumerTag; + private int _disposed; + + public Subscription( + RabbitMqTopicProvider provider, + IConnection connection, + string topic, + Func, CancellationToken, Task> handler + ) + { + _provider = provider; + _connection = connection; + _topic = topic; + _handler = handler; + } + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + if (_channel is not null) + { + try + { + if (_consumerTag is not null) + { + _channel.BasicCancelAsync(_consumerTag).GetAwaiter().GetResult(); + } + + _channel.CloseAsync().GetAwaiter().GetResult(); + _channel.DisposeAsync().AsTask().GetAwaiter().GetResult(); + } + catch + { + // Best-effort teardown. + } + } + } + + public void Start() + { + StartAsync().GetAwaiter().GetResult(); + } + + private async Task OnReceivedAsync(object sender, BasicDeliverEventArgs args) + { + try + { + await _handler(args.Body, CancellationToken.None); + } + catch (Exception ex) + { + _provider._logger.Warning(ex, "RabbitMq topic '{Topic}' handler failed", _topic); + } + } + + private async Task StartAsync() + { + _channel = await _connection.CreateChannelAsync(); + + await _channel.ExchangeDeclareAsync(_topic, ExchangeType.Fanout, false, false); + + var queue = await _channel.QueueDeclareAsync(string.Empty, false, true, true); + await _channel.QueueBindAsync(queue.QueueName, _topic, string.Empty); + + var consumer = new AsyncEventingBasicConsumer(_channel); + consumer.ReceivedAsync += OnReceivedAsync; + + _consumerTag = await _channel.BasicConsumeAsync(queue.QueueName, true, consumer); + } + } } diff --git a/src/SquidStd.Messaging.Sqs/Data/Config/SqsOptions.cs b/src/SquidStd.Messaging.Sqs/Data/Config/SqsOptions.cs index 2b2a14b1..7298d13a 100644 --- a/src/SquidStd.Messaging.Sqs/Data/Config/SqsOptions.cs +++ b/src/SquidStd.Messaging.Sqs/Data/Config/SqsOptions.cs @@ -3,8 +3,8 @@ namespace SquidStd.Messaging.Sqs.Data.Config; /// -/// Configuration for the SQS/SNS messaging provider. Connection details live in ; -/// the remaining knobs tune SQS receive behaviour. +/// Configuration for the SQS/SNS messaging provider. Connection details live in ; +/// the remaining knobs tune SQS receive behaviour. /// public sealed class SqsOptions { diff --git a/src/SquidStd.Messaging.Sqs/Extensions/SqsMessagingRegistrationExtensions.cs b/src/SquidStd.Messaging.Sqs/Extensions/SqsMessagingRegistrationExtensions.cs index 887504aa..1064ba1c 100644 --- a/src/SquidStd.Messaging.Sqs/Extensions/SqsMessagingRegistrationExtensions.cs +++ b/src/SquidStd.Messaging.Sqs/Extensions/SqsMessagingRegistrationExtensions.cs @@ -1,5 +1,6 @@ using System.Globalization; using DryIoc; +using SquidStd.Aws.Abstractions.Data.Config; using SquidStd.Core.Interfaces.Metrics; using SquidStd.Core.Interfaces.Serialization; using SquidStd.Core.Json; @@ -12,7 +13,7 @@ namespace SquidStd.Messaging.Sqs.Extensions; /// -/// DryIoc registration helpers for the AWS SQS/SNS messaging provider. +/// DryIoc registration helpers for the AWS SQS/SNS messaging provider. /// public static class SqsMessagingRegistrationExtensions { @@ -79,9 +80,9 @@ public static SqsOptions ParseOptions(string connectionString) ); } - return new() + return new SqsOptions { - Aws = new() + Aws = new AwsConfigEntry { Region = string.IsNullOrEmpty(cs.Host) ? "us-east-1" : cs.Host, AccessKey = cs.UserName, @@ -91,16 +92,16 @@ public static SqsOptions ParseOptions(string connectionString) }, MaxNumberOfMessages = cs.Parameters.TryGetValue("maxMessages", out var max) && int.TryParse(max, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedMax) - ? parsedMax - : 10, + ? parsedMax + : 10, VisibilityTimeout = cs.Parameters.TryGetValue("visibilityTimeoutSec", out var vis) && int.TryParse(vis, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedVis) - ? TimeSpan.FromSeconds(parsedVis) - : TimeSpan.FromSeconds(30), + ? TimeSpan.FromSeconds(parsedVis) + : TimeSpan.FromSeconds(30), WaitTimeSeconds = cs.Parameters.TryGetValue("waitTimeSec", out var wait) && int.TryParse(wait, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedWait) - ? parsedWait - : 20 + ? parsedWait + : 20 }; } } diff --git a/src/SquidStd.Messaging.Sqs/Internal/AwsClientFactory.cs b/src/SquidStd.Messaging.Sqs/Internal/AwsClientFactory.cs index a055526c..f7466d6b 100644 --- a/src/SquidStd.Messaging.Sqs/Internal/AwsClientFactory.cs +++ b/src/SquidStd.Messaging.Sqs/Internal/AwsClientFactory.cs @@ -7,7 +7,7 @@ namespace SquidStd.Messaging.Sqs.Internal; /// -/// Builds AWS SDK clients/credentials from a shared . +/// Builds AWS SDK clients/credentials from a shared . /// internal static class AwsClientFactory { @@ -16,8 +16,8 @@ public static AWSCredentials Credentials(AwsConfigEntry aws) if (!string.IsNullOrWhiteSpace(aws.AccessKey) && !string.IsNullOrWhiteSpace(aws.SecretKey)) { return string.IsNullOrWhiteSpace(aws.SessionToken) - ? new BasicAWSCredentials(aws.AccessKey, aws.SecretKey) - : new SessionAWSCredentials(aws.AccessKey, aws.SecretKey, aws.SessionToken); + ? new BasicAWSCredentials(aws.AccessKey, aws.SecretKey) + : new SessionAWSCredentials(aws.AccessKey, aws.SecretKey, aws.SessionToken); } return FallbackCredentialsFactory.GetCredentials(); diff --git a/src/SquidStd.Messaging.Sqs/Internal/SqsNames.cs b/src/SquidStd.Messaging.Sqs/Internal/SqsNames.cs index 8436dd48..15b6a275 100644 --- a/src/SquidStd.Messaging.Sqs/Internal/SqsNames.cs +++ b/src/SquidStd.Messaging.Sqs/Internal/SqsNames.cs @@ -1,7 +1,7 @@ namespace SquidStd.Messaging.Sqs.Internal; /// -/// Sanitizes queue/topic names to the SQS/SNS allowed alphabet (letters, digits, '-', '_'). +/// Sanitizes queue/topic names to the SQS/SNS allowed alphabet (letters, digits, '-', '_'). /// internal static class SqsNames { @@ -17,6 +17,6 @@ public static string Sanitize(string name) buffer[i] = char.IsAsciiLetterOrDigit(c) || c is '-' or '_' ? c : '-'; } - return new(buffer); + return new string(buffer); } } diff --git a/src/SquidStd.Messaging.Sqs/Services/SqsQueueProvider.cs b/src/SquidStd.Messaging.Sqs/Services/SqsQueueProvider.cs index 012a3b56..44065883 100644 --- a/src/SquidStd.Messaging.Sqs/Services/SqsQueueProvider.cs +++ b/src/SquidStd.Messaging.Sqs/Services/SqsQueueProvider.cs @@ -13,18 +13,18 @@ namespace SquidStd.Messaging.Sqs.Services; /// -/// AWS SQS : named queues are created with a redrive policy to a -/// "<queue><suffix>" dead-letter queue (maxReceiveCount = MaxDeliveryAttempts). Subscribers -/// long-poll; a handler that throws leaves the message un-acked so SQS redelivers and eventually -/// dead-letters it. Payloads travel base64-encoded in the message body. +/// AWS SQS : named queues are created with a redrive policy to a +/// "<queue><suffix>" dead-letter queue (maxReceiveCount = MaxDeliveryAttempts). Subscribers +/// long-poll; a handler that throws leaves the message un-acked so SQS redelivers and eventually +/// dead-letters it. Payloads travel base64-encoded in the message body. /// public sealed class SqsQueueProvider : IQueueProvider { + private readonly string _deadLetterSuffix; private readonly ILogger _logger = Log.ForContext(); - private readonly SqsOptions _options; private readonly MessagingOptions _messagingOptions; private readonly IMessagingMetrics _metrics; - private readonly string _deadLetterSuffix; + private readonly SqsOptions _options; private readonly ConcurrentDictionary _queueUrls = new(StringComparer.Ordinal); private readonly ConcurrentDictionary _subscriberCounts = new(StringComparer.Ordinal); private readonly SemaphoreSlim _topologyLock = new(1, 1); @@ -39,127 +39,6 @@ public SqsQueueProvider(SqsOptions options, MessagingOptions messagingOptions, I _deadLetterSuffix = SqsNames.Sanitize(messagingOptions.DeadLetterQueueSuffix); } - private sealed class Subscription : IDisposable - { - private readonly SqsQueueProvider _provider; - private readonly IAmazonSQS _client; - private readonly string _queueName; - private readonly Func, CancellationToken, Task> _handler; - private readonly CancellationTokenSource _cts = new(); - private Task? _loop; - private int _disposed; - - public Subscription( - SqsQueueProvider provider, - IAmazonSQS client, - string queueName, - Func, CancellationToken, Task> handler - ) - { - _provider = provider; - _client = client; - _queueName = queueName; - _handler = handler; - } - - public void Dispose() - { - if (Interlocked.Exchange(ref _disposed, 1) != 0) - { - return; - } - - _cts.Cancel(); - - try - { - _loop?.GetAwaiter().GetResult(); - } - catch - { - // Best-effort teardown. - } - - _cts.Dispose(); - _provider._metrics.SetSubscriberCount( - _queueName, - _provider._subscriberCounts.AddOrUpdate(_queueName, 0, static (_, count) => Math.Max(0, count - 1)) - ); - } - - public void Start() - => _loop = Task.Run(() => RunAsync(_cts.Token)); - - private async Task RunAsync(CancellationToken cancellationToken) - { - string url; - - try - { - url = await _provider.EnsureQueueAsync(_queueName, cancellationToken); - } - catch (OperationCanceledException) - { - return; - } - catch (Exception ex) - { - _provider._logger.Warning(ex, "SQS subscribe setup failed for {QueueName}", _queueName); - - return; - } - - while (!cancellationToken.IsCancellationRequested) - { - ReceiveMessageResponse response; - - try - { - response = await _client.ReceiveMessageAsync( - new ReceiveMessageRequest - { - QueueUrl = url, - MaxNumberOfMessages = _provider._options.MaxNumberOfMessages, - WaitTimeSeconds = _provider._options.WaitTimeSeconds, - VisibilityTimeout = (int)_provider._options.VisibilityTimeout.TotalSeconds - }, - cancellationToken - ); - } - catch (OperationCanceledException) - { - break; - } - catch (Exception ex) - { - _provider._logger.Warning(ex, "SQS receive failed for {QueueName}", _queueName); - - continue; - } - - foreach (var message in response.Messages ?? []) - { - try - { - var payload = Convert.FromBase64String(message.Body); - await _handler(payload, cancellationToken); - await _client.DeleteMessageAsync(url, message.ReceiptHandle, cancellationToken); - _provider._metrics.OnDelivered(_queueName); - } - catch (OperationCanceledException) - { - return; - } - catch (Exception ex) - { - _provider._logger.Warning(ex, "SQS handler failed for {QueueName}", _queueName); - _provider._metrics.OnFailed(_queueName); - } - } - } - } - } - /// public ValueTask DisposeAsync() { @@ -187,7 +66,7 @@ public async Task PublishAsync( var url = await EnsureQueueAsync(queueName, cancellationToken); await client.SendMessageAsync( - new() { QueueUrl = url, MessageBody = Convert.ToBase64String(payload.Span) }, + new SendMessageRequest { QueueUrl = url, MessageBody = Convert.ToBase64String(payload.Span) }, cancellationToken ); @@ -204,7 +83,9 @@ public ValueTask StartAsync(CancellationToken cancellationToken = default) /// public ValueTask StopAsync(CancellationToken cancellationToken = default) - => DisposeAsync(); + { + return DisposeAsync(); + } /// public IDisposable Subscribe(string queueName, Func, CancellationToken, Task> handler) @@ -251,9 +132,9 @@ private async Task EnsureQueueAsync(string queueName, CancellationToken { var dlqName = name + _deadLetterSuffix; var dlqUrl = (await client.CreateQueueAsync( - new CreateQueueRequest { QueueName = dlqName }, - cancellationToken - )).QueueUrl; + new CreateQueueRequest { QueueName = dlqName }, + cancellationToken + )).QueueUrl; var dlqArn = await GetQueueArnAsync(client, dlqUrl, cancellationToken); var redrivePolicy = JsonSerializer.Serialize( @@ -265,13 +146,13 @@ private async Task EnsureQueueAsync(string queueName, CancellationToken ); url = (await client.CreateQueueAsync( - new CreateQueueRequest - { - QueueName = name, - Attributes = new() { ["RedrivePolicy"] = redrivePolicy } - }, - cancellationToken - )).QueueUrl; + new CreateQueueRequest + { + QueueName = name, + Attributes = new Dictionary { ["RedrivePolicy"] = redrivePolicy } + }, + cancellationToken + )).QueueUrl; } _queueUrls[name] = url; @@ -291,10 +172,133 @@ CancellationToken cancellationToken ) { var response = await client.GetQueueAttributesAsync( - new() { QueueUrl = queueUrl, AttributeNames = ["QueueArn"] }, - cancellationToken - ); + new GetQueueAttributesRequest { QueueUrl = queueUrl, AttributeNames = ["QueueArn"] }, + cancellationToken + ); return response.Attributes["QueueArn"]; } + + private sealed class Subscription : IDisposable + { + private readonly IAmazonSQS _client; + private readonly CancellationTokenSource _cts = new(); + private readonly Func, CancellationToken, Task> _handler; + private readonly SqsQueueProvider _provider; + private readonly string _queueName; + private int _disposed; + private Task? _loop; + + public Subscription( + SqsQueueProvider provider, + IAmazonSQS client, + string queueName, + Func, CancellationToken, Task> handler + ) + { + _provider = provider; + _client = client; + _queueName = queueName; + _handler = handler; + } + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + _cts.Cancel(); + + try + { + _loop?.GetAwaiter().GetResult(); + } + catch + { + // Best-effort teardown. + } + + _cts.Dispose(); + _provider._metrics.SetSubscriberCount( + _queueName, + _provider._subscriberCounts.AddOrUpdate(_queueName, 0, static (_, count) => Math.Max(0, count - 1)) + ); + } + + public void Start() + { + _loop = Task.Run(() => RunAsync(_cts.Token)); + } + + private async Task RunAsync(CancellationToken cancellationToken) + { + string url; + + try + { + url = await _provider.EnsureQueueAsync(_queueName, cancellationToken); + } + catch (OperationCanceledException) + { + return; + } + catch (Exception ex) + { + _provider._logger.Warning(ex, "SQS subscribe setup failed for {QueueName}", _queueName); + + return; + } + + while (!cancellationToken.IsCancellationRequested) + { + ReceiveMessageResponse response; + + try + { + response = await _client.ReceiveMessageAsync( + new ReceiveMessageRequest + { + QueueUrl = url, + MaxNumberOfMessages = _provider._options.MaxNumberOfMessages, + WaitTimeSeconds = _provider._options.WaitTimeSeconds, + VisibilityTimeout = (int)_provider._options.VisibilityTimeout.TotalSeconds + }, + cancellationToken + ); + } + catch (OperationCanceledException) + { + break; + } + catch (Exception ex) + { + _provider._logger.Warning(ex, "SQS receive failed for {QueueName}", _queueName); + + continue; + } + + foreach (var message in response.Messages ?? []) + { + try + { + var payload = Convert.FromBase64String(message.Body); + await _handler(payload, cancellationToken); + await _client.DeleteMessageAsync(url, message.ReceiptHandle, cancellationToken); + _provider._metrics.OnDelivered(_queueName); + } + catch (OperationCanceledException) + { + return; + } + catch (Exception ex) + { + _provider._logger.Warning(ex, "SQS handler failed for {QueueName}", _queueName); + _provider._metrics.OnFailed(_queueName); + } + } + } + } + } } diff --git a/src/SquidStd.Messaging.Sqs/Services/SqsTopicProvider.cs b/src/SquidStd.Messaging.Sqs/Services/SqsTopicProvider.cs index aebbf95b..43aaceda 100644 --- a/src/SquidStd.Messaging.Sqs/Services/SqsTopicProvider.cs +++ b/src/SquidStd.Messaging.Sqs/Services/SqsTopicProvider.cs @@ -12,9 +12,9 @@ namespace SquidStd.Messaging.Sqs.Services; /// -/// SNS+SQS : a topic is an SNS topic; each subscriber gets a dedicated -/// ephemeral SQS queue subscribed to the topic with raw message delivery, long-polled and torn down -/// on dispose (transient, at-most-once fan-out). Payloads travel base64-encoded. +/// SNS+SQS : a topic is an SNS topic; each subscriber gets a dedicated +/// ephemeral SQS queue subscribed to the topic with raw message delivery, long-polled and torn down +/// on dispose (transient, at-most-once fan-out). Payloads travel base64-encoded. /// public sealed class SqsTopicProvider : ITopicProvider { @@ -22,27 +22,121 @@ public sealed class SqsTopicProvider : ITopicProvider private readonly SqsOptions _options; private readonly ConcurrentDictionary _topicArns = new(StringComparer.Ordinal); private readonly SemaphoreSlim _topicLock = new(1, 1); - private int _subscriberSeq; - private IAmazonSQS? _sqs; - private IAmazonSimpleNotificationService? _sns; private int _disposed; + private IAmazonSimpleNotificationService? _sns; + private IAmazonSQS? _sqs; + private int _subscriberSeq; public SqsTopicProvider(SqsOptions options) { _options = options; } + /// + public ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return ValueTask.CompletedTask; + } + + _sqs?.Dispose(); + _sns?.Dispose(); + _topicLock.Dispose(); + + return ValueTask.CompletedTask; + } + + /// + public async Task PublishAsync(string topic, ReadOnlyMemory payload, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(topic); + var sns = _sns ?? throw new InvalidOperationException("Provider not started."); + + var arn = await EnsureTopicAsync(topic, cancellationToken); + + await sns.PublishAsync( + new PublishRequest { TopicArn = arn, Message = Convert.ToBase64String(payload.Span) }, + cancellationToken + ); + } + + /// + public ValueTask StartAsync(CancellationToken cancellationToken = default) + { + var credentials = AwsClientFactory.Credentials(_options.Aws); + _sqs = new AmazonSQSClient(credentials, AwsClientFactory.SqsConfig(_options.Aws)); + _sns = new AmazonSimpleNotificationServiceClient(credentials, AwsClientFactory.SnsConfig(_options.Aws)); + + return ValueTask.CompletedTask; + } + + /// + public ValueTask StopAsync(CancellationToken cancellationToken = default) + { + return DisposeAsync(); + } + + /// + public IDisposable Subscribe(string topic, Func, CancellationToken, Task> handler) + { + ArgumentException.ThrowIfNullOrWhiteSpace(topic); + ArgumentNullException.ThrowIfNull(handler); + + if (_sqs is null || _sns is null) + { + throw new InvalidOperationException("Provider not started."); + } + + var index = Interlocked.Increment(ref _subscriberSeq); + var subscription = new Subscription(this, topic, index, handler); + subscription.Start(); + + return subscription; + } + + private async Task EnsureTopicAsync(string topic, CancellationToken cancellationToken) + { + var name = SqsNames.Sanitize(topic); + + if (_topicArns.TryGetValue(name, out var cached)) + { + return cached; + } + + var sns = _sns ?? throw new InvalidOperationException("Provider not started."); + + await _topicLock.WaitAsync(cancellationToken); + + try + { + if (_topicArns.TryGetValue(name, out cached)) + { + return cached; + } + + var arn = (await sns.CreateTopicAsync(new CreateTopicRequest { Name = name }, cancellationToken)).TopicArn; + _topicArns[name] = arn; + + return arn; + } + finally + { + _topicLock.Release(); + } + } + private sealed class Subscription : IDisposable { + private readonly CancellationTokenSource _cts = new(); + private readonly Func, CancellationToken, Task> _handler; + private readonly int _index; private readonly SqsTopicProvider _provider; private readonly string _topic; - private readonly int _index; - private readonly Func, CancellationToken, Task> _handler; - private readonly CancellationTokenSource _cts = new(); + private int _disposed; private Task? _loop; private string? _queueUrl; private string? _subscriptionArn; - private int _disposed; public Subscription( SqsTopicProvider provider, @@ -89,10 +183,13 @@ public void Dispose() } public void Start() - => _loop = Task.Run(() => RunAsync(_cts.Token)); + { + _loop = Task.Run(() => RunAsync(_cts.Token)); + } private static string BuildPolicy(string queueArn, string topicArn) - => JsonSerializer.Serialize( + { + return JsonSerializer.Serialize( new { Version = "2012-10-17", @@ -109,6 +206,7 @@ private static string BuildPolicy(string queueArn, string topicArn) } } ); + } private async Task RunAsync(CancellationToken cancellationToken) { @@ -119,37 +217,37 @@ private async Task RunAsync(CancellationToken cancellationToken) var topicArn = await _provider.EnsureTopicAsync(_topic, cancellationToken); var queueName = SqsNames.Sanitize(_topic) + "-sub-" + _index; queueUrl = (await _provider._sqs!.CreateQueueAsync( - new CreateQueueRequest { QueueName = queueName }, - cancellationToken - )).QueueUrl; + new CreateQueueRequest { QueueName = queueName }, + cancellationToken + )).QueueUrl; _queueUrl = queueUrl; var attributes = await _provider._sqs.GetQueueAttributesAsync( - new() { QueueUrl = queueUrl, AttributeNames = ["QueueArn"] }, - cancellationToken - ); + new GetQueueAttributesRequest { QueueUrl = queueUrl, AttributeNames = ["QueueArn"] }, + cancellationToken + ); var queueArn = attributes.Attributes["QueueArn"]; await _provider._sqs.SetQueueAttributesAsync( - new() + new SetQueueAttributesRequest { QueueUrl = queueUrl, - Attributes = new() { ["Policy"] = BuildPolicy(queueArn, topicArn) } + Attributes = new Dictionary { ["Policy"] = BuildPolicy(queueArn, topicArn) } }, cancellationToken ); _subscriptionArn = (await _provider._sns!.SubscribeAsync( - new() - { - TopicArn = topicArn, - Protocol = "sqs", - Endpoint = queueArn, - ReturnSubscriptionArn = true, - Attributes = new() { ["RawMessageDelivery"] = "true" } - }, - cancellationToken - )).SubscriptionArn; + new SubscribeRequest + { + TopicArn = topicArn, + Protocol = "sqs", + Endpoint = queueArn, + ReturnSubscriptionArn = true, + Attributes = new Dictionary { ["RawMessageDelivery"] = "true" } + }, + cancellationToken + )).SubscriptionArn; } catch (OperationCanceledException) { @@ -169,14 +267,14 @@ await _provider._sqs.SetQueueAttributesAsync( try { response = await _provider._sqs!.ReceiveMessageAsync( - new ReceiveMessageRequest - { - QueueUrl = queueUrl, - MaxNumberOfMessages = _provider._options.MaxNumberOfMessages, - WaitTimeSeconds = _provider._options.WaitTimeSeconds - }, - cancellationToken - ); + new ReceiveMessageRequest + { + QueueUrl = queueUrl, + MaxNumberOfMessages = _provider._options.MaxNumberOfMessages, + WaitTimeSeconds = _provider._options.WaitTimeSeconds + }, + cancellationToken + ); } catch (OperationCanceledException) { @@ -219,96 +317,4 @@ await _provider._sqs.SetQueueAttributesAsync( } } } - - /// - public ValueTask DisposeAsync() - { - if (Interlocked.Exchange(ref _disposed, 1) != 0) - { - return ValueTask.CompletedTask; - } - - _sqs?.Dispose(); - _sns?.Dispose(); - _topicLock.Dispose(); - - return ValueTask.CompletedTask; - } - - /// - public async Task PublishAsync(string topic, ReadOnlyMemory payload, CancellationToken cancellationToken = default) - { - ArgumentException.ThrowIfNullOrWhiteSpace(topic); - var sns = _sns ?? throw new InvalidOperationException("Provider not started."); - - var arn = await EnsureTopicAsync(topic, cancellationToken); - - await sns.PublishAsync( - new() { TopicArn = arn, Message = Convert.ToBase64String(payload.Span) }, - cancellationToken - ); - } - - /// - public ValueTask StartAsync(CancellationToken cancellationToken = default) - { - var credentials = AwsClientFactory.Credentials(_options.Aws); - _sqs = new AmazonSQSClient(credentials, AwsClientFactory.SqsConfig(_options.Aws)); - _sns = new AmazonSimpleNotificationServiceClient(credentials, AwsClientFactory.SnsConfig(_options.Aws)); - - return ValueTask.CompletedTask; - } - - /// - public ValueTask StopAsync(CancellationToken cancellationToken = default) - => DisposeAsync(); - - /// - public IDisposable Subscribe(string topic, Func, CancellationToken, Task> handler) - { - ArgumentException.ThrowIfNullOrWhiteSpace(topic); - ArgumentNullException.ThrowIfNull(handler); - - if (_sqs is null || _sns is null) - { - throw new InvalidOperationException("Provider not started."); - } - - var index = Interlocked.Increment(ref _subscriberSeq); - var subscription = new Subscription(this, topic, index, handler); - subscription.Start(); - - return subscription; - } - - private async Task EnsureTopicAsync(string topic, CancellationToken cancellationToken) - { - var name = SqsNames.Sanitize(topic); - - if (_topicArns.TryGetValue(name, out var cached)) - { - return cached; - } - - var sns = _sns ?? throw new InvalidOperationException("Provider not started."); - - await _topicLock.WaitAsync(cancellationToken); - - try - { - if (_topicArns.TryGetValue(name, out cached)) - { - return cached; - } - - var arn = (await sns.CreateTopicAsync(new CreateTopicRequest { Name = name }, cancellationToken)).TopicArn; - _topicArns[name] = arn; - - return arn; - } - finally - { - _topicLock.Release(); - } - } } diff --git a/src/SquidStd.Messaging/Extensions/MessagingRegistrationExtensions.cs b/src/SquidStd.Messaging/Extensions/MessagingRegistrationExtensions.cs index a2ca9fee..e2ea9d41 100644 --- a/src/SquidStd.Messaging/Extensions/MessagingRegistrationExtensions.cs +++ b/src/SquidStd.Messaging/Extensions/MessagingRegistrationExtensions.cs @@ -10,12 +10,12 @@ namespace SquidStd.Messaging.Extensions; /// -/// DryIoc registration helpers for the in-memory messaging system. +/// DryIoc registration helpers for the in-memory messaging system. /// public static class MessagingRegistrationExtensions { /// - /// Registers the in-memory messaging services (facade, provider, serializer, metrics). + /// Registers the in-memory messaging services (facade, provider, serializer, metrics). /// /// The container to register into. /// Optional messaging options; defaults are used when null. @@ -45,7 +45,7 @@ public static IContainer AddInMemoryMessaging(this IContainer container, Messagi } /// - /// Registers the in-memory messaging services from a connection string (scheme must be "memory"). + /// Registers the in-memory messaging services from a connection string (scheme must be "memory"). /// public static IContainer AddInMemoryMessaging(this IContainer container, string connectionString) { diff --git a/src/SquidStd.Messaging/Internal/InMemoryQueue.cs b/src/SquidStd.Messaging/Internal/InMemoryQueue.cs index ed926dae..c4f1f555 100644 --- a/src/SquidStd.Messaging/Internal/InMemoryQueue.cs +++ b/src/SquidStd.Messaging/Internal/InMemoryQueue.cs @@ -3,17 +3,17 @@ namespace SquidStd.Messaging.Internal; /// -/// Per-queue in-memory state: the buffer channel, the registered handlers, and the round-robin index. +/// Per-queue in-memory state: the buffer channel, the registered handlers, and the round-robin index. /// internal sealed class InMemoryQueue { private readonly Lock _handlerSync = new(); private readonly List, CancellationToken, Task>> _handlers = []; - private int _roundRobinIndex; private int _depth; + private int _roundRobinIndex; public Channel Channel { get; } = System.Threading.Channels.Channel.CreateUnbounded( - new() { SingleReader = true, SingleWriter = false } + new UnboundedChannelOptions { SingleReader = true, SingleWriter = false } ); public Task? ConsumerLoop { get; set; } @@ -39,11 +39,15 @@ public void AddHandler(Func, CancellationToken, Task> handl /// Decrements the buffered depth and returns the new value. public int DecrementDepth() - => Interlocked.Decrement(ref _depth); + { + return Interlocked.Decrement(ref _depth); + } /// Increments the buffered depth and returns the new value. public int IncrementDepth() - => Interlocked.Increment(ref _depth); + { + return Interlocked.Increment(ref _depth); + } /// Returns the next handler in round-robin order, or null when none are registered. public Func, CancellationToken, Task>? NextHandler() diff --git a/src/SquidStd.Messaging/Internal/QueuedMessage.cs b/src/SquidStd.Messaging/Internal/QueuedMessage.cs index 0ff2c295..6e46f970 100644 --- a/src/SquidStd.Messaging/Internal/QueuedMessage.cs +++ b/src/SquidStd.Messaging/Internal/QueuedMessage.cs @@ -1,7 +1,7 @@ namespace SquidStd.Messaging.Internal; /// -/// A buffered queue message with its delivery attempt count. +/// A buffered queue message with its delivery attempt count. /// /// The raw message payload. /// Number of delivery attempts already made (0 on first enqueue). diff --git a/src/SquidStd.Messaging/Services/InMemoryQueueProvider.cs b/src/SquidStd.Messaging/Services/InMemoryQueueProvider.cs index 2ca77098..21d4f14c 100644 --- a/src/SquidStd.Messaging/Services/InMemoryQueueProvider.cs +++ b/src/SquidStd.Messaging/Services/InMemoryQueueProvider.cs @@ -8,16 +8,16 @@ namespace SquidStd.Messaging.Services; /// -/// In-memory : one buffered channel + consumer loop per named queue, -/// round-robin delivery, retry and dead-lettering. +/// In-memory : one buffered channel + consumer loop per named queue, +/// round-robin delivery, retry and dead-lettering. /// public sealed class InMemoryQueueProvider : IQueueProvider { private readonly ILogger _logger = Log.ForContext(); + private readonly IMessagingMetrics _metrics; + private readonly MessagingOptions _options; private readonly ConcurrentDictionary _queues = new(StringComparer.Ordinal); private readonly CancellationTokenSource _shutdownCts = new(); - private readonly MessagingOptions _options; - private readonly IMessagingMetrics _metrics; private readonly TimeProvider _timeProvider; private int _disposed; @@ -34,39 +34,6 @@ public InMemoryQueueProvider( _timeProvider = timeProvider ?? TimeProvider.System; } - private sealed class Subscription : IDisposable - { - private readonly InMemoryQueueProvider _provider; - private readonly string _queueName; - private readonly InMemoryQueue _queue; - private readonly Func, CancellationToken, Task> _handler; - private int _disposed; - - public Subscription( - InMemoryQueueProvider provider, - string queueName, - InMemoryQueue queue, - Func, CancellationToken, Task> handler - ) - { - _provider = provider; - _queueName = queueName; - _queue = queue; - _handler = handler; - } - - public void Dispose() - { - if (Interlocked.Exchange(ref _disposed, 1) != 0) - { - return; - } - - _queue.RemoveHandler(_handler); - _provider._metrics.SetSubscriberCount(_queueName, _queue.HandlerCount); - } - } - /// public async ValueTask DisposeAsync() { @@ -106,7 +73,7 @@ public Task PublishAsync(string queueName, ReadOnlyMemory payload, Cancell ArgumentException.ThrowIfNullOrWhiteSpace(queueName); cancellationToken.ThrowIfCancellationRequested(); - Enqueue(queueName, new(payload, 0)); + Enqueue(queueName, new QueuedMessage(payload, 0)); _metrics.OnPublished(queueName); return Task.CompletedTask; @@ -114,11 +81,15 @@ public Task PublishAsync(string queueName, ReadOnlyMemory payload, Cancell /// public ValueTask StartAsync(CancellationToken cancellationToken = default) - => ValueTask.CompletedTask; + { + return ValueTask.CompletedTask; + } /// public ValueTask StopAsync(CancellationToken cancellationToken = default) - => DisposeAsync(); + { + return DisposeAsync(); + } /// public IDisposable Subscribe(string queueName, Func, CancellationToken, Task> handler) @@ -174,10 +145,13 @@ private async Task ConsumeLoopAsync(string queueName, InMemoryQueue queue, Cance } private void Enqueue(string queueName, QueuedMessage message) - => Write(GetOrCreate(queueName), queueName, message); + { + Write(GetOrCreate(queueName), queueName, message); + } private InMemoryQueue GetOrCreate(string queueName) - => _queues.GetOrAdd( + { + return _queues.GetOrAdd( queueName, name => { @@ -190,6 +164,7 @@ private InMemoryQueue GetOrCreate(string queueName) return queue; } ); + } private void HandleFailure(string queueName, InMemoryQueue queue, QueuedMessage message, Exception exception) { @@ -211,7 +186,7 @@ private void HandleFailure(string queueName, InMemoryQueue queue, QueuedMessage nextAttempt ); _metrics.OnDeadLettered(queueName); - Enqueue(queueName + _options.DeadLetterQueueSuffix, new(message.Payload, 0)); + Enqueue(queueName + _options.DeadLetterQueueSuffix, new QueuedMessage(message.Payload, 0)); } private async Task RequeueAsync(InMemoryQueue queue, string queueName, QueuedMessage message) @@ -264,4 +239,37 @@ private void Write(InMemoryQueue queue, string queueName, QueuedMessage message) queue.Channel.Writer.TryWrite(message); _metrics.SetQueueDepth(queueName, queue.IncrementDepth()); } + + private sealed class Subscription : IDisposable + { + private readonly Func, CancellationToken, Task> _handler; + private readonly InMemoryQueueProvider _provider; + private readonly InMemoryQueue _queue; + private readonly string _queueName; + private int _disposed; + + public Subscription( + InMemoryQueueProvider provider, + string queueName, + InMemoryQueue queue, + Func, CancellationToken, Task> handler + ) + { + _provider = provider; + _queueName = queueName; + _queue = queue; + _handler = handler; + } + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + _queue.RemoveHandler(_handler); + _provider._metrics.SetSubscriberCount(_queueName, _queue.HandlerCount); + } + } } diff --git a/src/SquidStd.Messaging/Services/InMemoryTopicProvider.cs b/src/SquidStd.Messaging/Services/InMemoryTopicProvider.cs index 455c3c87..0550ebbf 100644 --- a/src/SquidStd.Messaging/Services/InMemoryTopicProvider.cs +++ b/src/SquidStd.Messaging/Services/InMemoryTopicProvider.cs @@ -5,8 +5,8 @@ namespace SquidStd.Messaging.Services; /// -/// In-memory : fan-out delivery to all current subscribers of a topic. -/// Transient and at-most-once; exceptions in one subscriber are isolated. +/// In-memory : fan-out delivery to all current subscribers of a topic. +/// Transient and at-most-once; exceptions in one subscriber are isolated. /// public sealed class InMemoryTopicProvider : ITopicProvider { @@ -18,32 +18,6 @@ private readonly private int _disposed; - private sealed class Subscription : IDisposable - { - private readonly ConcurrentDictionary, CancellationToken, Task>> _handlers; - private readonly Guid _id; - private int _disposed; - - public Subscription( - ConcurrentDictionary, CancellationToken, Task>> handlers, - Guid id - ) - { - _handlers = handlers; - _id = id; - } - - public void Dispose() - { - if (Interlocked.Exchange(ref _disposed, 1) != 0) - { - return; - } - - _handlers.TryRemove(_id, out _); - } - } - /// public ValueTask DisposeAsync() { @@ -82,11 +56,15 @@ public async Task PublishAsync(string topic, ReadOnlyMemory payload, Cance /// public ValueTask StartAsync(CancellationToken cancellationToken = default) - => ValueTask.CompletedTask; + { + return ValueTask.CompletedTask; + } /// public ValueTask StopAsync(CancellationToken cancellationToken = default) - => DisposeAsync(); + { + return DisposeAsync(); + } /// public IDisposable Subscribe(string topic, Func, CancellationToken, Task> handler) @@ -94,10 +72,39 @@ public IDisposable Subscribe(string topic, Func, Cancellati ArgumentException.ThrowIfNullOrWhiteSpace(topic); ArgumentNullException.ThrowIfNull(handler); - var handlers = _topics.GetOrAdd(topic, static _ => new()); + var handlers = _topics.GetOrAdd( + topic, + static _ => new ConcurrentDictionary, CancellationToken, Task>>() + ); var id = Guid.NewGuid(); handlers[id] = handler; return new Subscription(handlers, id); } + + private sealed class Subscription : IDisposable + { + private readonly ConcurrentDictionary, CancellationToken, Task>> _handlers; + private readonly Guid _id; + private int _disposed; + + public Subscription( + ConcurrentDictionary, CancellationToken, Task>> handlers, + Guid id + ) + { + _handlers = handlers; + _id = id; + } + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + _handlers.TryRemove(_id, out _); + } + } } diff --git a/src/SquidStd.Network/Buffers/CircularBuffer.cs b/src/SquidStd.Network/Buffers/CircularBuffer.cs index 12594670..a4960c36 100644 --- a/src/SquidStd.Network/Buffers/CircularBuffer.cs +++ b/src/SquidStd.Network/Buffers/CircularBuffer.cs @@ -5,56 +5,107 @@ namespace SquidStd.Network.Buffers; /// /// -/// Circular buffer. -/// When writing to a full buffer: -/// PushBack -> removes this[0] / Front() -/// PushFront -> removes this[Size-1] / Back() -/// this implementation is inspired by -/// http://www.boost.org/doc/libs/1_53_0/libs/circular_buffer/doc/circular_buffer.html -/// because I liked their interface. +/// Circular buffer. +/// When writing to a full buffer: +/// PushBack -> removes this[0] / Front() +/// PushFront -> removes this[Size-1] / Back() +/// this implementation is inspired by +/// http://www.boost.org/doc/libs/1_53_0/libs/circular_buffer/doc/circular_buffer.html +/// because I liked their interface. /// public class CircularBuffer : IEnumerable { private readonly T[] _buffer; /// - /// The _end. Index after the last element in the buffer. + /// The _end. Index after the last element in the buffer. /// private int _end; /// - /// The _start. Index of the first element in buffer. + /// The _start. Index of the first element in buffer. /// private int _start; /// - /// Maximum capacity of the buffer. Elements pushed into the buffer after - /// maximum capacity is reached (IsFull = true), will remove an element. + /// Initializes a new instance of the class. + /// + /// + /// Buffer capacity. Must be positive. + /// + public CircularBuffer(int capacity) + : this(capacity, []) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// + /// Buffer capacity. Must be positive. + /// + /// + /// Items to fill buffer with. Items length must be less than capacity. + /// Suggestion: use Skip(x).Take(y).ToArray() to build this argument from + /// any enumerable. + /// + public CircularBuffer(int capacity, T[] items) + { + if (capacity < 1) + { + throw new ArgumentException( + "Circular buffer cannot have negative or zero capacity.", + nameof(capacity) + ); + } + + ArgumentNullException.ThrowIfNull(items); + + if (items.Length > capacity) + { + throw new ArgumentException( + "Too many items to fit circular buffer", + nameof(items) + ); + } + + _buffer = new T[capacity]; + + Array.Copy(items, _buffer, items.Length); + Size = items.Length; + + _start = 0; + _end = Size == capacity ? 0 : Size; + } + + /// + /// Maximum capacity of the buffer. Elements pushed into the buffer after + /// maximum capacity is reached (IsFull = true), will remove an element. /// public int Capacity => _buffer.Length; /// - /// Boolean indicating if Circular is at full capacity. - /// Adding more elements when the buffer is full will - /// cause elements to be removed from the other end - /// of the buffer. + /// Boolean indicating if Circular is at full capacity. + /// Adding more elements when the buffer is full will + /// cause elements to be removed from the other end + /// of the buffer. /// public bool IsFull => Size == Capacity; /// - /// True if has no elements. + /// True if has no elements. /// public bool IsEmpty => Size == 0; /// - /// Current buffer size (the number of elements that the buffer has). + /// Current buffer size (the number of elements that the buffer has). /// public int Size { get; private set; } /// - /// Index access to elements in buffer. - /// Index does not loop around like when adding elements, - /// valid interval is [0;Size[ + /// Index access to elements in buffer. + /// Index does not loop around like when adding elements, + /// valid interval is [0;Size[ /// /// Index of element to access. /// Thrown when index is outside of [; Size[ interval. @@ -93,57 +144,38 @@ public T this[int index] } } - /// - /// Initializes a new instance of the class. - /// - /// - /// Buffer capacity. Must be positive. - /// - public CircularBuffer(int capacity) - : this(capacity, []) { } + #region IEnumerable implementation /// - /// Initializes a new instance of the class. + /// Returns an enumerator that iterates through this buffer. /// - /// - /// Buffer capacity. Must be positive. - /// - /// - /// Items to fill buffer with. Items length must be less than capacity. - /// Suggestion: use Skip(x).Take(y).ToArray() to build this argument from - /// any enumerable. - /// - public CircularBuffer(int capacity, T[] items) + /// An enumerator that can be used to iterate this collection. + public IEnumerator GetEnumerator() { - if (capacity < 1) - { - throw new ArgumentException( - "Circular buffer cannot have negative or zero capacity.", - nameof(capacity) - ); - } - - ArgumentNullException.ThrowIfNull(items); + var segments = ToArraySegments(); - if (items.Length > capacity) + foreach (var segment in segments) { - throw new ArgumentException( - "Too many items to fit circular buffer", - nameof(items) - ); + for (var i = 0; i < segment.Count; i++) + { + yield return segment.Array[segment.Offset + i]; + } } + } - _buffer = new T[capacity]; + #endregion - Array.Copy(items, _buffer, items.Length); - Size = items.Length; + #region IEnumerable implementation - _start = 0; - _end = Size == capacity ? 0 : Size; + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); } + #endregion + /// - /// Element at the back of the buffer - this[Size - 1]. + /// Element at the back of the buffer - this[Size - 1]. /// /// The value of the element of type T at the back of the buffer. public T Back() @@ -154,7 +186,7 @@ public T Back() } /// - /// Clears the contents of the array. Size = 0, Capacity is unchanged. + /// Clears the contents of the array. Size = 0, Capacity is unchanged. /// /// public void Clear() @@ -167,7 +199,7 @@ public void Clear() } /// - /// Element at the front of the buffer - this[0]. + /// Element at the front of the buffer - this[0]. /// /// The value of the element of type T at the front of the buffer. public T Front() @@ -177,30 +209,9 @@ public T Front() return _buffer[_start]; } -#region IEnumerable implementation - /// - /// Returns an enumerator that iterates through this buffer. - /// - /// An enumerator that can be used to iterate this collection. - public IEnumerator GetEnumerator() - { - var segments = ToArraySegments(); - - foreach (var segment in segments) - { - for (var i = 0; i < segment.Count; i++) - { - yield return segment.Array[segment.Offset + i]; - } - } - } - -#endregion - - /// - /// Removes the element at the back of the buffer. Decreasing the - /// Buffer size by 1. + /// Removes the element at the back of the buffer. Decreasing the + /// Buffer size by 1. /// public void PopBack() { @@ -211,8 +222,8 @@ public void PopBack() } /// - /// Removes the element at the front of the buffer. Decreasing the - /// Buffer size by 1. + /// Removes the element at the front of the buffer. Decreasing the + /// Buffer size by 1. /// public void PopFront() { @@ -223,10 +234,10 @@ public void PopFront() } /// - /// Pushes a new element to the back of the buffer. Back()/this[Size-1] - /// will now return this element. - /// When the buffer is full, the element at Front()/this[0] will be - /// popped to allow for this new element to fit. + /// Pushes a new element to the back of the buffer. Back()/this[Size-1] + /// will now return this element. + /// When the buffer is full, the element at Front()/this[0] will be + /// popped to allow for this new element to fit. /// /// Item to push to the back of the buffer public void PushBack(T item) @@ -246,8 +257,8 @@ public void PushBack(T item) } /// - /// Pushes a contiguous range of elements to the back of the buffer in bulk. - /// When the buffer is full, the oldest elements are dropped to make room. + /// Pushes a contiguous range of elements to the back of the buffer in bulk. + /// When the buffer is full, the oldest elements are dropped to make room. /// /// Items to push. public void PushBackRange(ReadOnlySpan items) @@ -306,10 +317,10 @@ public void PushBackRange(ReadOnlySpan items) } /// - /// Pushes a new element to the front of the buffer. Front()/this[0] - /// will now return this element. - /// When the buffer is full, the element at Back()/this[Size-1] will be - /// popped to allow for this new element to fit. + /// Pushes a new element to the front of the buffer. Front()/this[0] + /// will now return this element. + /// When the buffer is full, the element at Back()/this[Size-1] will be + /// popped to allow for this new element to fit. /// /// Item to push to the front of the buffer public void PushFront(T item) @@ -329,9 +340,9 @@ public void PushFront(T item) } /// - /// Copies the buffer contents to an array, according to the logical - /// contents of the buffer (i.e. independent of the internal - /// order/contents) + /// Copies the buffer contents to an array, according to the logical + /// contents of the buffer (i.e. independent of the internal + /// order/contents) /// /// A new array with a copy of the buffer contents. public T[] ToArray() @@ -350,21 +361,23 @@ public T[] ToArray() } /// - /// Get the contents of the buffer as 2 ArraySegments. - /// Respects the logical contents of the buffer, where - /// each segment and items in each segment are ordered - /// according to insertion. - /// Fast: does not copy the array elements. - /// Useful for methods like Send(IList<ArraySegment<Byte>>). - /// Segments may be empty. + /// Get the contents of the buffer as 2 ArraySegments. + /// Respects the logical contents of the buffer, where + /// each segment and items in each segment are ordered + /// according to insertion. + /// Fast: does not copy the array elements. + /// Useful for methods like Send(IList<ArraySegment<Byte>>). + /// Segments may be empty. /// /// An IList with 2 segments corresponding to the buffer content. public IList> ToArraySegments() - => [ArrayOne(), ArrayTwo()]; + { + return [ArrayOne(), ArrayTwo()]; + } /// - /// Decrements the provided index variable by one, wrapping - /// around if necessary. + /// Decrements the provided index variable by one, wrapping + /// around if necessary. /// /// private void Decrement(ref int index) @@ -377,16 +390,9 @@ private void Decrement(ref int index) index--; } -#region IEnumerable implementation - - IEnumerator IEnumerable.GetEnumerator() - => GetEnumerator(); - -#endregion - /// - /// Increments the provided index variable by one, wrapping - /// around if necessary. + /// Increments the provided index variable by one, wrapping + /// around if necessary. /// /// private void Increment(ref int index) @@ -398,16 +404,18 @@ private void Increment(ref int index) } /// - /// Converts the index in the argument to an index in _buffer + /// Converts the index in the argument to an index in _buffer /// /// - /// The transformed index. + /// The transformed index. /// /// - /// External index. + /// External index. /// private int InternalIndex(int index) - => _start + (index < Capacity - _start ? index : index - Capacity); + { + return _start + (index < Capacity - _start ? index : index - Capacity); + } private void ThrowIfEmpty(string message = "Cannot access an empty buffer.") { @@ -422,7 +430,7 @@ private void ThrowIfEmpty(string message = "Cannot access an empty buffer.") // http://www.boost.org/doc/libs/1_37_0/libs/circular_buffer/doc/circular_buffer.html#classboost_1_1circular__buffer_1f5081a54afbc2dfc1a7fb20329df7d5b // should help a lot with the code. -#region Array items easy access. + #region Array items easy access. // The array is composed by at most two non-contiguous segments, // the next two methods allow easy access to those. @@ -431,31 +439,31 @@ private ArraySegment ArrayOne() { if (IsEmpty) { - return new([]); + return new ArraySegment([]); } if (_start < _end) { - return new(_buffer, _start, _end - _start); + return new ArraySegment(_buffer, _start, _end - _start); } - return new(_buffer, _start, _buffer.Length - _start); + return new ArraySegment(_buffer, _start, _buffer.Length - _start); } private ArraySegment ArrayTwo() { if (IsEmpty) { - return new([]); + return new ArraySegment([]); } if (_start < _end) { - return new(_buffer, _end, 0); + return new ArraySegment(_buffer, _end, 0); } - return new(_buffer, 0, _end); + return new ArraySegment(_buffer, 0, _end); } -#endregion + #endregion } diff --git a/src/SquidStd.Network/Client/SquidStdTcpClient.cs b/src/SquidStd.Network/Client/SquidStdTcpClient.cs index 411f2236..662ba7aa 100644 --- a/src/SquidStd.Network/Client/SquidStdTcpClient.cs +++ b/src/SquidStd.Network/Client/SquidStdTcpClient.cs @@ -5,6 +5,7 @@ using SquidStd.Network.Buffers; using SquidStd.Network.Data.Events; using SquidStd.Network.Interfaces.Client; +using SquidStd.Network.Interfaces.Codecs; using SquidStd.Network.Interfaces.Framing; using SquidStd.Network.Interfaces.Middleware; using SquidStd.Network.Pipeline; @@ -12,13 +13,14 @@ namespace SquidStd.Network.Client; /// -/// Represents a connected TCP client with async send/receive loops, -/// middleware processing, lifecycle events, and recent byte history. +/// Represents a connected TCP client with async send/receive loops, +/// middleware processing, lifecycle events, and recent byte history. /// public sealed class SquidStdTcpClient : INetworkConnection, IAsyncDisposable, IDisposable { private const int DefaultReceiveBufferSize = 8192; private const int DefaultHistoryBufferCapacity = 65536; + private static long _sessionIdSequence; private readonly INetFramer? _framer; private readonly CancellationTokenSource _internalCancellationTokenSource = new(); @@ -30,8 +32,8 @@ public sealed class SquidStdTcpClient : INetworkConnection, IAsyncDisposable, ID private readonly SemaphoreSlim _sendLock = new(1, 1); private readonly Socket _socket; private readonly Stream _stream; - private static long _sessionIdSequence; private int _closed; + private ITransportCodec? _codec; private CancellationTokenRegistration _externalCancellationTokenRegistration; private byte[]? _pendingBuffer; @@ -40,35 +42,68 @@ public sealed class SquidStdTcpClient : INetworkConnection, IAsyncDisposable, ID private int _started; /// - /// Unique session identifier for this client connection. + /// Creates a client wrapper for an accepted socket. /// - public long SessionId { get; } + /// Connected socket. + /// Optional middleware list. + /// + /// Optional framer. When supplied, the receive loop accumulates middleware output and + /// emits once per complete frame instead of once per socket read. + /// + /// Receive chunk size in bytes. + /// Max number of received bytes to keep in history. + public SquidStdTcpClient( + Socket socket, + IEnumerable? middlewares = null, + INetFramer? framer = null, + ITransportCodec? codec = null, + int receiveBufferSize = DefaultReceiveBufferSize, + int historyBufferCapacity = DefaultHistoryBufferCapacity + ) : this( + socket, + new NetworkStream(socket, false), + middlewares, + framer, + codec, + receiveBufferSize, + historyBufferCapacity + ) + { + } /// - /// Receives payload chunk size in bytes. + /// Creates a client wrapper for an accepted socket using the supplied transport stream. /// - public int ReceiveBufferSize { get; } + public SquidStdTcpClient( + Socket socket, + Stream stream, + IEnumerable? middlewares = null, + INetFramer? framer = null, + ITransportCodec? codec = null, + int receiveBufferSize = DefaultReceiveBufferSize, + int historyBufferCapacity = DefaultHistoryBufferCapacity + ) + { + ArgumentNullException.ThrowIfNull(socket); + ArgumentNullException.ThrowIfNull(stream); + + _socket = socket; + _stream = stream; + _middlewarePipeline = new NetMiddlewarePipeline(middlewares); + _framer = framer; + _codec = codec; + _receiveBuffer = new CircularBuffer(historyBufferCapacity); + ReceiveBufferSize = receiveBufferSize; + SessionId = Interlocked.Increment(ref _sessionIdSequence); + } /// - /// Client remote endpoint, when connected. + /// Receives payload chunk size in bytes. /// - public EndPoint? RemoteEndPoint - { - get - { - try - { - return _socket.RemoteEndPoint; - } - catch (ObjectDisposedException) - { - return null; - } - } - } + public int ReceiveBufferSize { get; } /// - /// Local endpoint used for this connection, when available. + /// Local endpoint used for this connection, when available. /// public EndPoint? LocalEndPoint { @@ -86,7 +121,7 @@ public EndPoint? LocalEndPoint } /// - /// Gets the number of bytes currently available in the receive circular buffer. + /// Gets the number of bytes currently available in the receive circular buffer. /// public int AvailableBytes { @@ -100,7 +135,7 @@ public int AvailableBytes } /// - /// Gets whether the receive circular buffer is full. + /// Gets whether the receive circular buffer is full. /// public bool IsReceiveBufferFull { @@ -113,93 +148,66 @@ public bool IsReceiveBufferFull } } - /// - /// True when the underlying socket is connected and client not closed. - /// - public bool IsConnected => _socket.Connected && Volatile.Read(ref _closed) == 0; - - /// - /// Raised when the client is fully connected and receive loop starts. - /// - public event EventHandler? OnConnected; + /// + public async ValueTask DisposeAsync() + { + await CloseAsync(CancellationToken.None); - /// - /// Raised when the client is disconnected. - /// - public event EventHandler? OnDisconnected; + // Drain the receive loop before disposing the resources it relies on. + if (_receiveLoopTask is not null) + { + try + { + await _receiveLoopTask; + } + catch + { + // Loop failures are already surfaced via OnException. + } + } - /// - /// Raised when data is received (after middleware pipeline). - /// - public event EventHandler? OnDataReceived; + await _stream.DisposeAsync(); + _sendLock.Dispose(); + _internalCancellationTokenSource.Dispose(); + _socket.Dispose(); + } - /// - /// Raised when receive/send loops throw an exception. - /// - public event EventHandler? OnException; + /// + public void Dispose() // Sync-over-async: best effort. Prefer DisposeAsync. + { + DisposeAsync().AsTask().GetAwaiter().GetResult(); + } /// - /// Creates a client wrapper for an accepted socket. + /// Unique session identifier for this client connection. /// - /// Connected socket. - /// Optional middleware list. - /// - /// Optional framer. When supplied, the receive loop accumulates middleware output and - /// emits once per complete frame instead of once per socket read. - /// - /// Receive chunk size in bytes. - /// Max number of received bytes to keep in history. - public SquidStdTcpClient( - Socket socket, - IEnumerable? middlewares = null, - INetFramer? framer = null, - int receiveBufferSize = DefaultReceiveBufferSize, - int historyBufferCapacity = DefaultHistoryBufferCapacity - ) : this( - socket, - new NetworkStream(socket, false), - middlewares, - framer, - receiveBufferSize, - historyBufferCapacity - ) { } + public long SessionId { get; } /// - /// Creates a client wrapper for an accepted socket using the supplied transport stream. + /// Client remote endpoint, when connected. /// - public SquidStdTcpClient( - Socket socket, - Stream stream, - IEnumerable? middlewares = null, - INetFramer? framer = null, - int receiveBufferSize = DefaultReceiveBufferSize, - int historyBufferCapacity = DefaultHistoryBufferCapacity - ) + public EndPoint? RemoteEndPoint { - ArgumentNullException.ThrowIfNull(socket); - ArgumentNullException.ThrowIfNull(stream); - - _socket = socket; - _stream = stream; - _middlewarePipeline = new(middlewares); - _framer = framer; - _receiveBuffer = new(historyBufferCapacity); - ReceiveBufferSize = receiveBufferSize; - SessionId = Interlocked.Increment(ref _sessionIdSequence); + get + { + try + { + return _socket.RemoteEndPoint; + } + catch (ObjectDisposedException) + { + return null; + } + } } /// - /// Adds a middleware component to this client pipeline. + /// True when the underlying socket is connected and client not closed. /// - public SquidStdTcpClient AddMiddleware(INetMiddleware middleware) - { - _middlewarePipeline.AddMiddleware(middleware); - - return this; - } + public bool IsConnected => _socket.Connected && Volatile.Read(ref _closed) == 0; /// - /// Closes the client connection and raises disconnect event once. + /// Closes the client connection and raises disconnect event once. /// public async Task CloseAsync(CancellationToken cancellationToken = default) { @@ -240,26 +248,113 @@ public async Task CloseAsync(CancellationToken cancellationToken = default) } /// - /// Creates an outbound client and connects to the specified endpoint. + /// Sends a payload to the connected socket. + /// + public async Task SendAsync(ReadOnlyMemory payload, CancellationToken cancellationToken) + { + if (payload.IsEmpty || !IsConnected) + { + return; + } + + var processedPayload = await _middlewarePipeline.ExecuteSendAsync(this, payload, cancellationToken); + + if (processedPayload.IsEmpty) + { + return; + } + + await _sendLock.WaitAsync(cancellationToken); + + try + { + var codec = Volatile.Read(ref _codec); + + if (codec is null) + { + await _stream.WriteAsync(processedPayload, cancellationToken); + } + else + { + var sendBuffer = ArrayPool.Shared.Rent(processedPayload.Length); + + try + { + processedPayload.Span.CopyTo(sendBuffer); + codec.Encode(sendBuffer.AsSpan(0, processedPayload.Length)); + await _stream.WriteAsync(sendBuffer.AsMemory(0, processedPayload.Length), cancellationToken); + } + finally + { + ArrayPool.Shared.Return(sendBuffer); + } + } + + await _stream.FlushAsync(cancellationToken); + } + catch (Exception ex) + { + RaiseException(ex); + await CloseAsync(CancellationToken.None); + } + finally + { + _sendLock.Release(); + } + } + + /// + /// Raised when the client is fully connected and receive loop starts. + /// + public event EventHandler? OnConnected; + + /// + /// Raised when the client is disconnected. + /// + public event EventHandler? OnDisconnected; + + /// + /// Raised when data is received (after middleware pipeline). + /// + public event EventHandler? OnDataReceived; + + /// + /// Raised when receive/send loops throw an exception. + /// + public event EventHandler? OnException; + + /// + /// Adds a middleware component to this client pipeline. + /// + public SquidStdTcpClient AddMiddleware(INetMiddleware middleware) + { + _middlewarePipeline.AddMiddleware(middleware); + + return this; + } + + /// + /// Creates an outbound client and connects to the specified endpoint. /// public static async Task ConnectAsync( IPEndPoint endPoint, IEnumerable? middlewares = null, INetFramer? framer = null, + ITransportCodec? codec = null, CancellationToken cancellationToken = default ) { var socket = new Socket(endPoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp); await socket.ConnectAsync(endPoint, cancellationToken); - var client = new SquidStdTcpClient(socket, middlewares, framer); + var client = new SquidStdTcpClient(socket, middlewares, framer, codec); await client.StartAsync(cancellationToken); return client; } /// - /// Consumes bytes from the front of the receive circular buffer. + /// Consumes bytes from the front of the receive circular buffer. /// public int ConsumeBytes(int count) { @@ -282,48 +377,24 @@ public int ConsumeBytes(int count) } /// - /// Checks whether this client pipeline contains at least one middleware instance of the specified type. + /// Checks whether this client pipeline contains at least one middleware instance of the specified type. /// public bool ContainsMiddleware() where TMiddleware : INetMiddleware - => _middlewarePipeline.ContainsMiddleware(); - - /// - public void Dispose() // Sync-over-async: best effort. Prefer DisposeAsync. - => DisposeAsync().AsTask().GetAwaiter().GetResult(); - - /// - public async ValueTask DisposeAsync() { - await CloseAsync(CancellationToken.None); - - // Drain the receive loop before disposing the resources it relies on. - if (_receiveLoopTask is not null) - { - try - { - await _receiveLoopTask; - } - catch - { - // Loop failures are already surfaced via OnException. - } - } - - await _stream.DisposeAsync(); - _sendLock.Dispose(); - _internalCancellationTokenSource.Dispose(); - _socket.Dispose(); + return _middlewarePipeline.ContainsMiddleware(); } /// - /// Returns a snapshot of recent received bytes from the circular history buffer. + /// Returns a snapshot of recent received bytes from the circular history buffer. /// public byte[] GetRecentReceivedBytes() - => PeekData(); + { + return PeekData(); + } /// - /// Peeks at data in the receive circular buffer without consuming it. + /// Peeks at data in the receive circular buffer without consuming it. /// public byte[] PeekData(int count = 0) { @@ -347,49 +418,26 @@ public byte[] PeekData(int count = 0) } /// - /// Removes all middleware components of the specified type from this client pipeline. + /// Atomically swaps the transport codec for this connection. The new codec takes effect from the next + /// socket read; the caller must trigger the swap at a read boundary (no old-regime bytes still pending). /// - public bool RemoveMiddleware() - where TMiddleware : INetMiddleware - => _middlewarePipeline.RemoveMiddleware(); + /// The new codec, or null to remove transport transformation. + public void SwapCodec(ITransportCodec? codec) + { + Volatile.Write(ref _codec, codec); + } /// - /// Sends a payload to the connected socket. + /// Removes all middleware components of the specified type from this client pipeline. /// - public async Task SendAsync(ReadOnlyMemory payload, CancellationToken cancellationToken) + public bool RemoveMiddleware() + where TMiddleware : INetMiddleware { - if (payload.IsEmpty || !IsConnected) - { - return; - } - - var processedPayload = await _middlewarePipeline.ExecuteSendAsync(this, payload, cancellationToken); - - if (processedPayload.IsEmpty) - { - return; - } - - await _sendLock.WaitAsync(cancellationToken); - - try - { - await _stream.WriteAsync(processedPayload, cancellationToken); - await _stream.FlushAsync(cancellationToken); - } - catch (Exception ex) - { - RaiseException(ex); - await CloseAsync(CancellationToken.None); - } - finally - { - _sendLock.Release(); - } + return _middlewarePipeline.RemoveMiddleware(); } /// - /// Starts the receive loop and raises connect event. + /// Starts the receive loop and raises connect event. /// public Task StartAsync(CancellationToken cancellationToken) { @@ -479,7 +527,7 @@ private void EmitFrames() ConsumePending(frameLength); - OnDataReceived?.Invoke(this, new(this, frame)); + OnDataReceived?.Invoke(this, new SquidStdTcpDataReceivedEventArgs(this, frame)); } } @@ -490,7 +538,7 @@ private void RaiseConnected() SessionId, RemoteEndPoint ); - OnConnected?.Invoke(this, new(this)); + OnConnected?.Invoke(this, new SquidStdTcpClientEventArgs(this)); } private void RaiseDisconnected() @@ -500,7 +548,7 @@ private void RaiseDisconnected() SessionId, RemoteEndPoint ); - OnDisconnected?.Invoke(this, new(this)); + OnDisconnected?.Invoke(this, new SquidStdTcpClientEventArgs(this)); } private void RaiseException(Exception exception) @@ -511,7 +559,7 @@ private void RaiseException(Exception exception) SessionId, RemoteEndPoint ); - OnException?.Invoke(this, new(exception, this)); + OnException?.Invoke(this, new SquidStdTcpExceptionEventArgs(exception, this)); } private async Task ReceiveLoopAsync() @@ -523,32 +571,34 @@ private async Task ReceiveLoopAsync() while (!_internalCancellationTokenSource.IsCancellationRequested && IsConnected) { var received = await _stream.ReadAsync( - buffer.AsMemory(0, ReceiveBufferSize), - _internalCancellationTokenSource.Token - ); + buffer.AsMemory(0, ReceiveBufferSize), + _internalCancellationTokenSource.Token + ); if (received <= 0) { break; } - lock (_receiveBufferSync) - { - _receiveBuffer.PushBackRange(buffer.AsSpan(0, received)); - } - var chunk = ArrayPool.Shared.Rent(received); try { buffer.AsSpan(0, received).CopyTo(chunk); + Volatile.Read(ref _codec)?.Decode(chunk.AsSpan(0, received)); + + lock (_receiveBufferSync) + { + _receiveBuffer.PushBackRange(chunk.AsSpan(0, received)); + } + var chunkMemory = new ReadOnlyMemory(chunk, 0, received); var processed = await _middlewarePipeline.ExecuteAsync( - this, - chunkMemory, - _internalCancellationTokenSource.Token - ); + this, + chunkMemory, + _internalCancellationTokenSource.Token + ); if (processed.IsEmpty) { @@ -560,7 +610,7 @@ private async Task ReceiveLoopAsync() // Fresh copy so the event handler can outlive the pooled chunk. var payload = new byte[processed.Length]; processed.CopyTo(payload); - OnDataReceived?.Invoke(this, new(this, payload)); + OnDataReceived?.Invoke(this, new SquidStdTcpDataReceivedEventArgs(this, payload)); } else { diff --git a/src/SquidStd.Network/Client/SquidStdUdpClient.cs b/src/SquidStd.Network/Client/SquidStdUdpClient.cs index 463298e0..2972f1ce 100644 --- a/src/SquidStd.Network/Client/SquidStdUdpClient.cs +++ b/src/SquidStd.Network/Client/SquidStdUdpClient.cs @@ -7,19 +7,19 @@ namespace SquidStd.Network.Client; /// -/// Connectionless UDP client that binds a local socket and surfaces inbound datagrams through an -/// async receive loop. Datagrams can be sent to any endpoint with , or to -/// an optional default remote endpoint via . Mirrors the lifecycle surface of -/// (session id, connect/disconnect/data/exception events, and -/// ). Supports Start once; recreate the instance to listen again. +/// Connectionless UDP client that binds a local socket and surfaces inbound datagrams through an +/// async receive loop. Datagrams can be sent to any endpoint with , or to +/// an optional default remote endpoint via . Mirrors the lifecycle surface of +/// (session id, connect/disconnect/data/exception events, and +/// ). Supports Start once; recreate the instance to listen again. /// public sealed class SquidStdUdpClient : INetworkConnection, IAsyncDisposable, IDisposable { + private static long _sessionIdSequence; private readonly IPEndPoint? _defaultRemoteEndPoint; private readonly CancellationTokenSource _internalCancellationTokenSource = new(); private readonly ILogger _logger = Log.ForContext(); private readonly UdpClient _udpClient; - private static long _sessionIdSequence; private int _closed; private CancellationTokenRegistration _externalCancellationTokenRegistration; @@ -27,17 +27,24 @@ public sealed class SquidStdUdpClient : INetworkConnection, IAsyncDisposable, ID private int _started; /// - /// Unique session identifier for this client. + /// Creates a UDP client bound to a local endpoint. /// - public long SessionId { get; } - - /// - /// Default remote endpoint used by , when configured. - /// - public EndPoint? RemoteEndPoint => _defaultRemoteEndPoint; + /// + /// Local endpoint to bind. When null, binds an ephemeral port on . + /// + /// + /// Optional default destination used by . When null, callers must use + /// with an explicit endpoint. + /// + public SquidStdUdpClient(IPEndPoint? localEndPoint = null, IPEndPoint? defaultRemoteEndPoint = null) + { + _udpClient = new UdpClient(localEndPoint ?? new IPEndPoint(IPAddress.Any, 0)); + _defaultRemoteEndPoint = defaultRemoteEndPoint; + SessionId = Interlocked.Increment(ref _sessionIdSequence); + } /// - /// Local endpoint the client is bound to, when available. + /// Local endpoint the client is bound to, when available. /// public EndPoint? LocalEndPoint { @@ -54,50 +61,51 @@ public EndPoint? LocalEndPoint } } - /// - /// True while the client is open (not closed). - /// - public bool IsConnected => Volatile.Read(ref _closed) == 0; + /// + public async ValueTask DisposeAsync() + { + await CloseAsync(CancellationToken.None); - /// - /// Raised when the client starts and the receive loop begins. - /// - public event EventHandler? OnConnected; + // Drain the receive loop before disposing the resources it relies on. + if (_receiveLoopTask is not null) + { + try + { + await _receiveLoopTask; + } + catch + { + // Loop failures are already surfaced via OnException. + } + } - /// - /// Raised when the client is closed. - /// - public event EventHandler? OnDisconnected; + _internalCancellationTokenSource.Dispose(); + _udpClient.Dispose(); + } + + /// + public void Dispose() // Sync-over-async: best effort. Prefer DisposeAsync. + { + DisposeAsync().AsTask().GetAwaiter().GetResult(); + } /// - /// Raised once per received datagram. + /// Unique session identifier for this client. /// - public event EventHandler? OnDataReceived; + public long SessionId { get; } /// - /// Raised when the receive/send paths throw an unexpected exception. + /// Default remote endpoint used by , when configured. /// - public event EventHandler? OnException; + public EndPoint? RemoteEndPoint => _defaultRemoteEndPoint; /// - /// Creates a UDP client bound to a local endpoint. + /// True while the client is open (not closed). /// - /// - /// Local endpoint to bind. When null, binds an ephemeral port on . - /// - /// - /// Optional default destination used by . When null, callers must use - /// with an explicit endpoint. - /// - public SquidStdUdpClient(IPEndPoint? localEndPoint = null, IPEndPoint? defaultRemoteEndPoint = null) - { - _udpClient = new(localEndPoint ?? new IPEndPoint(IPAddress.Any, 0)); - _defaultRemoteEndPoint = defaultRemoteEndPoint; - SessionId = Interlocked.Increment(ref _sessionIdSequence); - } + public bool IsConnected => Volatile.Read(ref _closed) == 0; /// - /// Closes the client and raises once. + /// Closes the client and raises once. /// public async Task CloseAsync(CancellationToken cancellationToken = default) { @@ -120,34 +128,8 @@ public async Task CloseAsync(CancellationToken cancellationToken = default) RaiseDisconnected(); } - /// - public void Dispose() // Sync-over-async: best effort. Prefer DisposeAsync. - => DisposeAsync().AsTask().GetAwaiter().GetResult(); - - /// - public async ValueTask DisposeAsync() - { - await CloseAsync(CancellationToken.None); - - // Drain the receive loop before disposing the resources it relies on. - if (_receiveLoopTask is not null) - { - try - { - await _receiveLoopTask; - } - catch - { - // Loop failures are already surfaced via OnException. - } - } - - _internalCancellationTokenSource.Dispose(); - _udpClient.Dispose(); - } - /// - /// Sends a datagram to the configured default remote endpoint. + /// Sends a datagram to the configured default remote endpoint. /// /// No default remote endpoint was configured. public Task SendAsync(ReadOnlyMemory payload, CancellationToken cancellationToken) @@ -163,7 +145,27 @@ public Task SendAsync(ReadOnlyMemory payload, CancellationToken cancellati } /// - /// Sends a datagram to an explicit endpoint. + /// Raised when the client starts and the receive loop begins. + /// + public event EventHandler? OnConnected; + + /// + /// Raised when the client is closed. + /// + public event EventHandler? OnDisconnected; + + /// + /// Raised once per received datagram. + /// + public event EventHandler? OnDataReceived; + + /// + /// Raised when the receive/send paths throw an unexpected exception. + /// + public event EventHandler? OnException; + + /// + /// Sends a datagram to an explicit endpoint. /// public async Task SendToAsync(ReadOnlyMemory payload, IPEndPoint endPoint, CancellationToken cancellationToken) { @@ -185,7 +187,7 @@ public async Task SendToAsync(ReadOnlyMemory payload, IPEndPoint endPoint, } /// - /// Starts the receive loop and raises . + /// Starts the receive loop and raises . /// public Task StartAsync(CancellationToken cancellationToken) { @@ -213,7 +215,7 @@ private void RaiseConnected() SessionId, LocalEndPoint ); - OnConnected?.Invoke(this, new(this)); + OnConnected?.Invoke(this, new SquidStdUdpClientEventArgs(this)); } private void RaiseDisconnected() @@ -223,13 +225,13 @@ private void RaiseDisconnected() SessionId, LocalEndPoint ); - OnDisconnected?.Invoke(this, new(this)); + OnDisconnected?.Invoke(this, new SquidStdUdpClientEventArgs(this)); } private void RaiseException(Exception exception) { _logger.Error(exception, "UDP client exception. SessionId={SessionId}", SessionId); - OnException?.Invoke(this, new(exception, this)); + OnException?.Invoke(this, new SquidStdUdpExceptionEventArgs(exception, this)); } private async Task ReceiveLoopAsync() @@ -241,7 +243,10 @@ private async Task ReceiveLoopAsync() var result = await _udpClient.ReceiveAsync(_internalCancellationTokenSource.Token); // UdpReceiveResult.Buffer is a fresh array per receive, so it is safe to hand off. - OnDataReceived?.Invoke(this, new(this, result.RemoteEndPoint, result.Buffer)); + OnDataReceived?.Invoke( + this, + new SquidStdUdpDataReceivedEventArgs(this, result.RemoteEndPoint, result.Buffer) + ); } } catch (OperationCanceledException) diff --git a/src/SquidStd.Network/Data/ConnectionPipeline.cs b/src/SquidStd.Network/Data/ConnectionPipeline.cs new file mode 100644 index 00000000..15b13650 --- /dev/null +++ b/src/SquidStd.Network/Data/ConnectionPipeline.cs @@ -0,0 +1,15 @@ +using SquidStd.Network.Interfaces.Codecs; +using SquidStd.Network.Interfaces.Framing; +using SquidStd.Network.Interfaces.Middleware; + +namespace SquidStd.Network.Data; + +/// +/// Per-connection transport configuration produced by a server factory on each accepted connection. +/// Any member left null falls back to the server's shared configuration. +/// +public sealed record ConnectionPipeline( + ITransportCodec? Codec = null, + IReadOnlyList? Middlewares = null, + INetFramer? Framer = null +); diff --git a/src/SquidStd.Network/Data/Events/SquidStdSessionDataEventArgs.cs b/src/SquidStd.Network/Data/Events/SquidStdSessionDataEventArgs.cs index 4c8273ef..21b8533f 100644 --- a/src/SquidStd.Network/Data/Events/SquidStdSessionDataEventArgs.cs +++ b/src/SquidStd.Network/Data/Events/SquidStdSessionDataEventArgs.cs @@ -3,19 +3,19 @@ namespace SquidStd.Network.Data.Events; /// -/// Event payload carrying a session and the data it received. +/// Event payload carrying a session and the data it received. /// public sealed class SquidStdSessionDataEventArgs : EventArgs { - /// The session that received the data. - public Session Session { get; } - - /// The received payload (already processed by the server pipeline). - public ReadOnlyMemory Data { get; } - public SquidStdSessionDataEventArgs(Session session, ReadOnlyMemory data) { Session = session; Data = data; } + + /// The session that received the data. + public Session Session { get; } + + /// The received payload (already processed by the server pipeline). + public ReadOnlyMemory Data { get; } } diff --git a/src/SquidStd.Network/Data/Events/SquidStdSessionEventArgs.cs b/src/SquidStd.Network/Data/Events/SquidStdSessionEventArgs.cs index 65fc0233..356464c0 100644 --- a/src/SquidStd.Network/Data/Events/SquidStdSessionEventArgs.cs +++ b/src/SquidStd.Network/Data/Events/SquidStdSessionEventArgs.cs @@ -3,15 +3,15 @@ namespace SquidStd.Network.Data.Events; /// -/// Event payload carrying a session (created or removed). +/// Event payload carrying a session (created or removed). /// public sealed class SquidStdSessionEventArgs : EventArgs { - /// The session associated with the event. - public Session Session { get; } - public SquidStdSessionEventArgs(Session session) { Session = session; } + + /// The session associated with the event. + public Session Session { get; } } diff --git a/src/SquidStd.Network/Data/Events/SquidStdTcpClientEventArgs.cs b/src/SquidStd.Network/Data/Events/SquidStdTcpClientEventArgs.cs index 31e1920f..9c0ade93 100644 --- a/src/SquidStd.Network/Data/Events/SquidStdTcpClientEventArgs.cs +++ b/src/SquidStd.Network/Data/Events/SquidStdTcpClientEventArgs.cs @@ -3,17 +3,17 @@ namespace SquidStd.Network.Data.Events; /// -/// Event payload containing a network client instance. +/// Event payload containing a network client instance. /// public sealed class SquidStdTcpClientEventArgs : EventArgs { - /// - /// Connected or disconnected client. - /// - public SquidStdTcpClient Client { get; } - public SquidStdTcpClientEventArgs(SquidStdTcpClient client) { Client = client; } + + /// + /// Connected or disconnected client. + /// + public SquidStdTcpClient Client { get; } } diff --git a/src/SquidStd.Network/Data/Events/SquidStdTcpDataReceivedEventArgs.cs b/src/SquidStd.Network/Data/Events/SquidStdTcpDataReceivedEventArgs.cs index 3fec6f48..cb579072 100644 --- a/src/SquidStd.Network/Data/Events/SquidStdTcpDataReceivedEventArgs.cs +++ b/src/SquidStd.Network/Data/Events/SquidStdTcpDataReceivedEventArgs.cs @@ -3,23 +3,23 @@ namespace SquidStd.Network.Data.Events; /// -/// Event payload containing data received from a network client. +/// Event payload containing data received from a network client. /// public sealed class SquidStdTcpDataReceivedEventArgs : EventArgs { + public SquidStdTcpDataReceivedEventArgs(SquidStdTcpClient client, ReadOnlyMemory data) + { + Client = client; + Data = data; + } + /// - /// Source client for the data payload. + /// Source client for the data payload. /// public SquidStdTcpClient Client { get; } /// - /// Received data payload. + /// Received data payload. /// public ReadOnlyMemory Data { get; } - - public SquidStdTcpDataReceivedEventArgs(SquidStdTcpClient client, ReadOnlyMemory data) - { - Client = client; - Data = data; - } } diff --git a/src/SquidStd.Network/Data/Events/SquidStdTcpExceptionEventArgs.cs b/src/SquidStd.Network/Data/Events/SquidStdTcpExceptionEventArgs.cs index 492be239..5530f8c4 100644 --- a/src/SquidStd.Network/Data/Events/SquidStdTcpExceptionEventArgs.cs +++ b/src/SquidStd.Network/Data/Events/SquidStdTcpExceptionEventArgs.cs @@ -3,23 +3,23 @@ namespace SquidStd.Network.Data.Events; /// -/// Event payload containing an exception raised by server or client network loops. +/// Event payload containing an exception raised by server or client network loops. /// public sealed class SquidStdTcpExceptionEventArgs : EventArgs { + public SquidStdTcpExceptionEventArgs(Exception exception, SquidStdTcpClient? client = null) + { + Exception = exception; + Client = client; + } + /// - /// Exception raised by the networking component. + /// Exception raised by the networking component. /// public Exception Exception { get; } /// - /// Client related to the exception, when available. + /// Client related to the exception, when available. /// public SquidStdTcpClient? Client { get; } - - public SquidStdTcpExceptionEventArgs(Exception exception, SquidStdTcpClient? client = null) - { - Exception = exception; - Client = client; - } } diff --git a/src/SquidStd.Network/Data/Events/SquidStdUdpClientEventArgs.cs b/src/SquidStd.Network/Data/Events/SquidStdUdpClientEventArgs.cs index 71dbc282..71a158bf 100644 --- a/src/SquidStd.Network/Data/Events/SquidStdUdpClientEventArgs.cs +++ b/src/SquidStd.Network/Data/Events/SquidStdUdpClientEventArgs.cs @@ -3,17 +3,17 @@ namespace SquidStd.Network.Data.Events; /// -/// Event payload containing a UDP client instance. +/// Event payload containing a UDP client instance. /// public sealed class SquidStdUdpClientEventArgs : EventArgs { - /// - /// Started or closed UDP client. - /// - public SquidStdUdpClient Client { get; } - public SquidStdUdpClientEventArgs(SquidStdUdpClient client) { Client = client; } + + /// + /// Started or closed UDP client. + /// + public SquidStdUdpClient Client { get; } } diff --git a/src/SquidStd.Network/Data/Events/SquidStdUdpDataReceivedEventArgs.cs b/src/SquidStd.Network/Data/Events/SquidStdUdpDataReceivedEventArgs.cs index b931359e..dc4a6982 100644 --- a/src/SquidStd.Network/Data/Events/SquidStdUdpDataReceivedEventArgs.cs +++ b/src/SquidStd.Network/Data/Events/SquidStdUdpDataReceivedEventArgs.cs @@ -4,29 +4,29 @@ namespace SquidStd.Network.Data.Events; /// -/// Event payload containing a datagram received from a UDP peer. +/// Event payload containing a datagram received from a UDP peer. /// public sealed class SquidStdUdpDataReceivedEventArgs : EventArgs { + public SquidStdUdpDataReceivedEventArgs(SquidStdUdpClient client, IPEndPoint remoteEndPoint, ReadOnlyMemory data) + { + Client = client; + RemoteEndPoint = remoteEndPoint; + Data = data; + } + /// - /// UDP client that received the datagram. + /// UDP client that received the datagram. /// public SquidStdUdpClient Client { get; } /// - /// Endpoint that sent the datagram. + /// Endpoint that sent the datagram. /// public IPEndPoint RemoteEndPoint { get; } /// - /// Received datagram payload. + /// Received datagram payload. /// public ReadOnlyMemory Data { get; } - - public SquidStdUdpDataReceivedEventArgs(SquidStdUdpClient client, IPEndPoint remoteEndPoint, ReadOnlyMemory data) - { - Client = client; - RemoteEndPoint = remoteEndPoint; - Data = data; - } } diff --git a/src/SquidStd.Network/Data/Events/SquidStdUdpDatagramReceivedEventArgs.cs b/src/SquidStd.Network/Data/Events/SquidStdUdpDatagramReceivedEventArgs.cs index 2cd200f2..2a5ebb2c 100644 --- a/src/SquidStd.Network/Data/Events/SquidStdUdpDatagramReceivedEventArgs.cs +++ b/src/SquidStd.Network/Data/Events/SquidStdUdpDatagramReceivedEventArgs.cs @@ -3,19 +3,19 @@ namespace SquidStd.Network.Data.Events; /// -/// Event payload for a datagram received by the UDP server, carrying the sender endpoint. +/// Event payload for a datagram received by the UDP server, carrying the sender endpoint. /// public sealed class SquidStdUdpDatagramReceivedEventArgs : EventArgs { - /// Endpoint that sent the datagram. - public IPEndPoint RemoteEndPoint { get; } - - /// Received datagram payload. - public ReadOnlyMemory Data { get; } - public SquidStdUdpDatagramReceivedEventArgs(IPEndPoint remoteEndPoint, ReadOnlyMemory data) { RemoteEndPoint = remoteEndPoint; Data = data; } + + /// Endpoint that sent the datagram. + public IPEndPoint RemoteEndPoint { get; } + + /// Received datagram payload. + public ReadOnlyMemory Data { get; } } diff --git a/src/SquidStd.Network/Data/Events/SquidStdUdpExceptionEventArgs.cs b/src/SquidStd.Network/Data/Events/SquidStdUdpExceptionEventArgs.cs index e22e173d..102b7aa5 100644 --- a/src/SquidStd.Network/Data/Events/SquidStdUdpExceptionEventArgs.cs +++ b/src/SquidStd.Network/Data/Events/SquidStdUdpExceptionEventArgs.cs @@ -3,23 +3,23 @@ namespace SquidStd.Network.Data.Events; /// -/// Event payload containing an exception raised by the UDP client receive/send paths. +/// Event payload containing an exception raised by the UDP client receive/send paths. /// public sealed class SquidStdUdpExceptionEventArgs : EventArgs { + public SquidStdUdpExceptionEventArgs(Exception exception, SquidStdUdpClient? client = null) + { + Exception = exception; + Client = client; + } + /// - /// Exception raised by the networking component. + /// Exception raised by the networking component. /// public Exception Exception { get; } /// - /// UDP client related to the exception, when available. + /// UDP client related to the exception, when available. /// public SquidStdUdpClient? Client { get; } - - public SquidStdUdpExceptionEventArgs(Exception exception, SquidStdUdpClient? client = null) - { - Exception = exception; - Client = client; - } } diff --git a/src/SquidStd.Network/Data/Options/SquidStdTcpServerTlsOptions.cs b/src/SquidStd.Network/Data/Options/SquidStdTcpServerTlsOptions.cs index 97d547e6..82f512c0 100644 --- a/src/SquidStd.Network/Data/Options/SquidStdTcpServerTlsOptions.cs +++ b/src/SquidStd.Network/Data/Options/SquidStdTcpServerTlsOptions.cs @@ -6,6 +6,13 @@ namespace SquidStd.Network.Data.Options; public sealed class SquidStdTcpServerTlsOptions { + public SquidStdTcpServerTlsOptions(X509Certificate2 serverCertificate) + { + ArgumentNullException.ThrowIfNull(serverCertificate); + + ServerCertificate = serverCertificate; + } + public X509Certificate2 ServerCertificate { get; } public bool ClientCertificateRequired { get; init; } @@ -14,21 +21,16 @@ public sealed class SquidStdTcpServerTlsOptions public SslProtocols EnabledSslProtocols { get; init; } = SslProtocols.None; - public SquidStdTcpServerTlsOptions(X509Certificate2 serverCertificate) - { - ArgumentNullException.ThrowIfNull(serverCertificate); - - ServerCertificate = serverCertificate; - } - internal SslServerAuthenticationOptions ToAuthenticationOptions() - => new() + { + return new SslServerAuthenticationOptions { ServerCertificate = ServerCertificate, ClientCertificateRequired = ClientCertificateRequired, CertificateRevocationCheckMode = CheckCertificateRevocation - ? X509RevocationMode.Online - : X509RevocationMode.NoCheck, + ? X509RevocationMode.Online + : X509RevocationMode.NoCheck, EnabledSslProtocols = EnabledSslProtocols }; + } } diff --git a/src/SquidStd.Network/Data/Options/SquidStdWebSocketServerTlsOptions.cs b/src/SquidStd.Network/Data/Options/SquidStdWebSocketServerTlsOptions.cs index 7df07fd1..69196a6a 100644 --- a/src/SquidStd.Network/Data/Options/SquidStdWebSocketServerTlsOptions.cs +++ b/src/SquidStd.Network/Data/Options/SquidStdWebSocketServerTlsOptions.cs @@ -4,12 +4,12 @@ namespace SquidStd.Network.Data.Options; public sealed class SquidStdWebSocketServerTlsOptions { - public X509Certificate2 ServerCertificate { get; } - public SquidStdWebSocketServerTlsOptions(X509Certificate2 serverCertificate) { ArgumentNullException.ThrowIfNull(serverCertificate); ServerCertificate = serverCertificate; } + + public X509Certificate2 ServerCertificate { get; } } diff --git a/src/SquidStd.Network/Exceptions/Buffers/CircularBufferEmptyException.cs b/src/SquidStd.Network/Exceptions/Buffers/CircularBufferEmptyException.cs index 22058ccd..1c50e2f3 100644 --- a/src/SquidStd.Network/Exceptions/Buffers/CircularBufferEmptyException.cs +++ b/src/SquidStd.Network/Exceptions/Buffers/CircularBufferEmptyException.cs @@ -1,10 +1,12 @@ namespace SquidStd.Network.Exceptions.Buffers; /// -/// Exception thrown when an operation requires at least one element in the circular buffer. +/// Exception thrown when an operation requires at least one element in the circular buffer. /// public sealed class CircularBufferEmptyException : InvalidOperationException { public CircularBufferEmptyException(string? message = null) - : base(message ?? "Circular buffer is empty.") { } + : base(message ?? "Circular buffer is empty.") + { + } } diff --git a/src/SquidStd.Network/Exceptions/Buffers/CircularBufferIndexOutOfRangeException.cs b/src/SquidStd.Network/Exceptions/Buffers/CircularBufferIndexOutOfRangeException.cs index f9ce6d6e..3df75eed 100644 --- a/src/SquidStd.Network/Exceptions/Buffers/CircularBufferIndexOutOfRangeException.cs +++ b/src/SquidStd.Network/Exceptions/Buffers/CircularBufferIndexOutOfRangeException.cs @@ -1,10 +1,12 @@ namespace SquidStd.Network.Exceptions.Buffers; /// -/// Exception thrown when an index is outside the valid circular buffer bounds. +/// Exception thrown when an index is outside the valid circular buffer bounds. /// public sealed class CircularBufferIndexOutOfRangeException : ArgumentOutOfRangeException { public CircularBufferIndexOutOfRangeException(string paramName, int index, int size) - : base(paramName, index, $"Cannot access index {index}. Buffer size is {size}.") { } + : base(paramName, index, $"Cannot access index {index}. Buffer size is {size}.") + { + } } diff --git a/src/SquidStd.Network/Extensions/PacketExtensions.cs b/src/SquidStd.Network/Extensions/PacketExtensions.cs index 95399336..aeb43133 100644 --- a/src/SquidStd.Network/Extensions/PacketExtensions.cs +++ b/src/SquidStd.Network/Extensions/PacketExtensions.cs @@ -3,5 +3,7 @@ namespace SquidStd.Network.Extensions; public static class PacketExtensions { public static string ToPacketString(this byte opCode) - => "0x" + opCode.ToString("X2"); + { + return "0x" + opCode.ToString("X2"); + } } diff --git a/src/SquidStd.Network/Interfaces/Client/INetworkConnection.cs b/src/SquidStd.Network/Interfaces/Client/INetworkConnection.cs index 2911ac6a..a0bb6858 100644 --- a/src/SquidStd.Network/Interfaces/Client/INetworkConnection.cs +++ b/src/SquidStd.Network/Interfaces/Client/INetworkConnection.cs @@ -3,34 +3,34 @@ namespace SquidStd.Network.Interfaces.Client; /// -/// Represents a connected network client independently from the underlying transport. +/// Represents a connected network client independently from the underlying transport. /// public interface INetworkConnection { /// - /// Unique connection identifier assigned by the transport. + /// Unique connection identifier assigned by the transport. /// long SessionId { get; } /// - /// Remote endpoint when the transport exposes one. + /// Remote endpoint when the transport exposes one. /// EndPoint? RemoteEndPoint { get; } /// - /// Indicates whether the connection is still open. + /// Indicates whether the connection is still open. /// bool IsConnected { get; } /// - /// Closes the connection. + /// Closes the connection. /// /// The cancellation token. /// A task that completes when the connection has closed. Task CloseAsync(CancellationToken cancellationToken = default); /// - /// Sends raw bytes to the connected client. + /// Sends raw bytes to the connected client. /// /// The payload bytes. /// The cancellation token. diff --git a/src/SquidStd.Network/Interfaces/Codecs/ITransportCodec.cs b/src/SquidStd.Network/Interfaces/Codecs/ITransportCodec.cs new file mode 100644 index 00000000..11386dd7 --- /dev/null +++ b/src/SquidStd.Network/Interfaces/Codecs/ITransportCodec.cs @@ -0,0 +1,21 @@ +namespace SquidStd.Network.Interfaces.Codecs; + +/// +/// An in-place, length-preserving transport transform applied at the wire — for example a stream cipher. +/// +/// +/// Implementations MUST preserve the buffer length and transform in place. is invoked +/// only from a connection's receive loop (serial), and only from its send path (serial +/// under the send lock). The two directions may run concurrently (one each), so an implementation MUST NOT +/// share mutable state between decode and encode (real ciphers keep separate receive/send positions). +/// +public interface ITransportCodec +{ + /// Transforms inbound bytes in place, before the middleware pipeline. + /// The bytes to transform in place. + void Decode(Span buffer); + + /// Transforms outbound bytes in place, after the middleware pipeline. + /// The bytes to transform in place. + void Encode(Span buffer); +} diff --git a/src/SquidStd.Network/Interfaces/Framing/INetFramer.cs b/src/SquidStd.Network/Interfaces/Framing/INetFramer.cs index ff90d6db..977b4583 100644 --- a/src/SquidStd.Network/Interfaces/Framing/INetFramer.cs +++ b/src/SquidStd.Network/Interfaces/Framing/INetFramer.cs @@ -1,29 +1,29 @@ namespace SquidStd.Network.Interfaces.Framing; /// -/// Extracts discrete frames from a continuous byte stream. +/// Extracts discrete frames from a continuous byte stream. /// /// -/// A framer is the optional bridge between the byte-oriented middleware pipeline -/// and a protocol-specific consumer. Implementations are typically stateless and -/// MUST be safe to call repeatedly: the same buffer may be inspected several times -/// as more bytes arrive across socket reads. Implementations holding per-connection -/// state must be supplied as a fresh instance per client. +/// A framer is the optional bridge between the byte-oriented middleware pipeline +/// and a protocol-specific consumer. Implementations are typically stateless and +/// MUST be safe to call repeatedly: the same buffer may be inspected several times +/// as more bytes arrive across socket reads. Implementations holding per-connection +/// state must be supplied as a fresh instance per client. /// public interface INetFramer { /// - /// Tries to read one complete frame from the start of . + /// Tries to read one complete frame from the start of . /// /// Accumulated bytes available for inspection. /// - /// The number of bytes to consume from the start of the buffer when a complete - /// frame is present. Undefined when the method returns false. + /// The number of bytes to consume from the start of the buffer when a complete + /// frame is present. Undefined when the method returns false. /// /// - /// true when starts with a complete frame and - /// has been written; false when more bytes - /// are required. + /// true when starts with a complete frame and + /// has been written; false when more bytes + /// are required. /// bool TryReadFrame(ReadOnlySpan buffer, out int frameLength); } diff --git a/src/SquidStd.Network/Interfaces/Middleware/INetMiddleware.cs b/src/SquidStd.Network/Interfaces/Middleware/INetMiddleware.cs index ffc715c2..d5a3a047 100644 --- a/src/SquidStd.Network/Interfaces/Middleware/INetMiddleware.cs +++ b/src/SquidStd.Network/Interfaces/Middleware/INetMiddleware.cs @@ -3,19 +3,19 @@ namespace SquidStd.Network.Interfaces.Middleware; /// -/// Transforms raw network bytes for a client connection. +/// Transforms raw network bytes for a client connection. /// /// -/// Middleware operates on raw bytes and MUST NOT assume any message, packet, or frame -/// semantics — framing and protocol parsing are the consumer's responsibility, applied -/// to the OnDataReceived output of the client. Returning -/// from either method drops the payload and -/// short-circuits the remaining pipeline. +/// Middleware operates on raw bytes and MUST NOT assume any message, packet, or frame +/// semantics — framing and protocol parsing are the consumer's responsibility, applied +/// to the OnDataReceived output of the client. Returning +/// from either method drops the payload and +/// short-circuits the remaining pipeline. /// public interface INetMiddleware { /// - /// Transforms an incoming payload before it is dispatched to consumers. + /// Transforms an incoming payload before it is dispatched to consumers. /// /// Client associated with the payload, if available. /// Incoming bytes. @@ -28,7 +28,7 @@ ValueTask> ProcessAsync( ); /// - /// Transforms an outgoing payload before it is written to the socket. + /// Transforms an outgoing payload before it is written to the socket. /// /// Client associated with the payload, if available. /// Outgoing bytes. @@ -39,5 +39,7 @@ ValueTask> ProcessSendAsync( ReadOnlyMemory data, CancellationToken cancellationToken = default ) - => ValueTask.FromResult(data); + { + return ValueTask.FromResult(data); + } } diff --git a/src/SquidStd.Network/Interfaces/Processing/IResultProcessor.cs b/src/SquidStd.Network/Interfaces/Processing/IResultProcessor.cs index 529b9411..5d5e4562 100644 --- a/src/SquidStd.Network/Interfaces/Processing/IResultProcessor.cs +++ b/src/SquidStd.Network/Interfaces/Processing/IResultProcessor.cs @@ -3,13 +3,13 @@ namespace SquidStd.Network.Interfaces.Processing; /// -/// Processes a framed network payload into a typed result. +/// Processes a framed network payload into a typed result. /// /// The processed result type. public interface IResultProcessor { /// - /// Processes one complete framed payload for a network connection. + /// Processes one complete framed payload for a network connection. /// /// The connection that produced the payload. /// The framed payload bytes. diff --git a/src/SquidStd.Network/Interfaces/Server/INetworkServer.cs b/src/SquidStd.Network/Interfaces/Server/INetworkServer.cs index 36fcea8c..75506f01 100644 --- a/src/SquidStd.Network/Interfaces/Server/INetworkServer.cs +++ b/src/SquidStd.Network/Interfaces/Server/INetworkServer.cs @@ -3,34 +3,34 @@ namespace SquidStd.Network.Interfaces.Server; /// -/// Represents a network listener with common lifecycle and metadata. +/// Represents a network listener with common lifecycle and metadata. /// public interface INetworkServer : IAsyncDisposable { /// - /// Transport type exposed by this server. + /// Transport type exposed by this server. /// ServerType ServerType { get; } /// - /// Current listening port. Returns 0 when no concrete port is bound. + /// Current listening port. Returns 0 when no concrete port is bound. /// int Port { get; } /// - /// Indicates whether the server is currently running. + /// Indicates whether the server is currently running. /// bool IsRunning { get; } /// - /// Starts the listener. + /// Starts the listener. /// /// The cancellation token. /// A task that completes when the listener has started. Task StartAsync(CancellationToken cancellationToken); /// - /// Stops the listener. + /// Stops the listener. /// /// The cancellation token. /// A task that completes when the listener has stopped. diff --git a/src/SquidStd.Network/Interfaces/Sessions/ISessionManager.cs b/src/SquidStd.Network/Interfaces/Sessions/ISessionManager.cs index cc41e88c..67b0fb9d 100644 --- a/src/SquidStd.Network/Interfaces/Sessions/ISessionManager.cs +++ b/src/SquidStd.Network/Interfaces/Sessions/ISessionManager.cs @@ -4,7 +4,7 @@ namespace SquidStd.Network.Interfaces.Sessions; /// -/// Tracks active connections and their application state. +/// Tracks active connections and their application state. /// /// Application-defined per-connection state. public interface ISessionManager diff --git a/src/SquidStd.Network/Pipeline/NetMiddlewarePipeline.cs b/src/SquidStd.Network/Pipeline/NetMiddlewarePipeline.cs index e47d9db6..55afa18a 100644 --- a/src/SquidStd.Network/Pipeline/NetMiddlewarePipeline.cs +++ b/src/SquidStd.Network/Pipeline/NetMiddlewarePipeline.cs @@ -4,14 +4,14 @@ namespace SquidStd.Network.Pipeline; /// -/// Executes components in registration order over a byte payload. +/// Executes components in registration order over a byte payload. /// /// -/// The pipeline is a byte transformer: each middleware sees a -/// of bytes and produces a of bytes. There is no concept of -/// message, packet, or frame at this layer — protocol-specific framing must happen on top of -/// the client's OnDataReceived output. Returning -/// from a middleware drops the payload and stops the chain. +/// The pipeline is a byte transformer: each middleware sees a +/// of bytes and produces a of bytes. There is no concept of +/// message, packet, or frame at this layer — protocol-specific framing must happen on top of +/// the client's OnDataReceived output. Returning +/// from a middleware drops the payload and stops the chain. /// public sealed class NetMiddlewarePipeline { @@ -19,7 +19,7 @@ public sealed class NetMiddlewarePipeline private INetMiddleware[] _middlewares; /// - /// Initializes the middleware pipeline. + /// Initializes the middleware pipeline. /// /// Optional initial middleware sequence. public NetMiddlewarePipeline(IEnumerable? middlewares = null) @@ -28,7 +28,7 @@ public NetMiddlewarePipeline(IEnumerable? middlewares = null) } /// - /// Adds a middleware component at the end of the execution chain. + /// Adds a middleware component at the end of the execution chain. /// /// Middleware to register. public void AddMiddleware(INetMiddleware middleware) @@ -40,7 +40,7 @@ public void AddMiddleware(INetMiddleware middleware) } /// - /// Checks whether at least one middleware component of the specified type is registered. + /// Checks whether at least one middleware component of the specified type is registered. /// /// Middleware type to check. /// true when a matching middleware is registered; otherwise false. @@ -54,7 +54,7 @@ public bool ContainsMiddleware() } /// - /// Processes the payload through all registered middleware components. + /// Processes the payload through all registered middleware components. /// /// Client associated with the payload, if available. /// Incoming payload. @@ -90,7 +90,7 @@ CancellationToken cancellationToken } /// - /// Processes the outgoing payload through all registered middleware components. + /// Processes the outgoing payload through all registered middleware components. /// /// Client associated with the payload, if available. /// Outgoing payload. @@ -126,7 +126,7 @@ CancellationToken cancellationToken } /// - /// Removes all middleware components of the specified type. + /// Removes all middleware components of the specified type. /// /// Middleware type to remove. /// true when at least one middleware was removed; otherwise false. diff --git a/src/SquidStd.Network/Server/SquidStdUdpServer.cs b/src/SquidStd.Network/Server/SquidStdUdpServer.cs index 801e2005..8cdf7a38 100644 --- a/src/SquidStd.Network/Server/SquidStdUdpServer.cs +++ b/src/SquidStd.Network/Server/SquidStdUdpServer.cs @@ -10,64 +10,49 @@ namespace SquidStd.Network.Server; /// -/// Connectionless UDP server that binds one socket per local interface address and processes -/// each received datagram. By default it echoes the payload back to the sender (the behaviour the -/// UO launcher expects from a shard ping server); supply to customise the -/// response. Supports Start/Stop/Start cycles by recreating the sockets on each Start. +/// Connectionless UDP server that binds one socket per local interface address and processes +/// each received datagram. By default it echoes the payload back to the sender (the behaviour the +/// UO launcher expects from a shard ping server); supply to customise the +/// response. Supports Start/Stop/Start cycles by recreating the sockets on each Start. /// public sealed class SquidStdUdpServer : INetworkServer, IAsyncDisposable, IDisposable { private readonly bool _bindAllInterfaces; private readonly IPEndPoint _endPoint; + private readonly ConcurrentDictionary _endpointListeners = new(); private readonly List _listeners = []; private readonly ILogger _logger = Log.ForContext(); private readonly List _receiveLoops = []; private readonly Lock _sync = new(); - private readonly ConcurrentDictionary _endpointListeners = new(); private CancellationTokenSource? _cancellationTokenSource; private int _started; /// - /// Transport type exposed by this server. + /// Initializes a UDP server bound to the given endpoint on every StartAsync. /// - public ServerType ServerType => ServerType.UDP; - - /// - /// Current listening port. Returns 0 when configured for an ephemeral port and stopped. - /// - public int Port + /// Endpoint supplying the port (and address when not binding all interfaces). + /// + /// When true (default), binds one socket per local unicast address matching the endpoint's + /// address family. When false, binds only . + /// + public SquidStdUdpServer(IPEndPoint endPoint, bool bindAllInterfaces = true) { - get - { - lock (_sync) - { - var listener = _listeners.FirstOrDefault(); - - if (listener?.Client.LocalEndPoint is IPEndPoint localEndPoint) - { - return localEndPoint.Port; - } + ArgumentNullException.ThrowIfNull(endPoint); - return _endPoint.Port; - } - } + _endPoint = endPoint; + _bindAllInterfaces = bindAllInterfaces; } /// - /// Optional response factory. Receives the datagram payload and the sender endpoint and returns - /// the bytes to send back; return to send no reply. - /// When null, the server echoes the payload unchanged. + /// Optional response factory. Receives the datagram payload and the sender endpoint and returns + /// the bytes to send back; return to send no reply. + /// When null, the server echoes the payload unchanged. /// public Func, IPEndPoint, ReadOnlyMemory>? OnDatagram { get; set; } /// - /// True when the server is currently listening. - /// - public bool IsRunning => Volatile.Read(ref _started) != 0; - - /// - /// Number of bound listening sockets. + /// Number of bound listening sockets. /// public int ListenerCount { @@ -80,80 +65,52 @@ public int ListenerCount } } - /// - /// Raised for every datagram received, carrying the sender endpoint. Always raised, regardless of - /// . - /// - public event EventHandler? OnDatagramReceived; - - /// - /// Raised when receive loops throw an unexpected exception. - /// - public event EventHandler? OnException; - - /// - /// Initializes a UDP server bound to the given endpoint on every StartAsync. - /// - /// Endpoint supplying the port (and address when not binding all interfaces). - /// - /// When true (default), binds one socket per local unicast address matching the endpoint's - /// address family. When false, binds only . - /// - public SquidStdUdpServer(IPEndPoint endPoint, bool bindAllInterfaces = true) - { - ArgumentNullException.ThrowIfNull(endPoint); - - _endPoint = endPoint; - _bindAllInterfaces = bindAllInterfaces; - } - /// public void Dispose() - => DisposeAsync().AsTask().GetAwaiter().GetResult(); + { + DisposeAsync().AsTask().GetAwaiter().GetResult(); + } - /// - public async ValueTask DisposeAsync() - => await StopAsync(CancellationToken.None); + /// + /// Transport type exposed by this server. + /// + public ServerType ServerType => ServerType.UDP; /// - /// Sends a datagram to a specific endpoint, using the listener that last received from it - /// (falling back to the first listener). No-op when no listener is available. + /// Current listening port. Returns 0 when configured for an ephemeral port and stopped. /// - public async Task SendToAsync( - IPEndPoint endPoint, - ReadOnlyMemory payload, - CancellationToken cancellationToken = default - ) + public int Port { - ArgumentNullException.ThrowIfNull(endPoint); - - if (!_endpointListeners.TryGetValue(endPoint, out var listener)) + get { lock (_sync) { - listener = _listeners.FirstOrDefault(); + var listener = _listeners.FirstOrDefault(); + + if (listener?.Client.LocalEndPoint is IPEndPoint localEndPoint) + { + return localEndPoint.Port; + } + + return _endPoint.Port; } } + } - if (listener is null) - { - return; - } + /// + /// True when the server is currently listening. + /// + public bool IsRunning => Volatile.Read(ref _started) != 0; - try - { - await listener.SendAsync(payload, endPoint, cancellationToken); - } - catch (Exception ex) - { - _logger.Warning(ex, "UDP SendToAsync failed for {EndPoint}", endPoint); - OnException?.Invoke(this, new(ex)); - } + /// + public async ValueTask DisposeAsync() + { + await StopAsync(CancellationToken.None); } /// - /// Starts listening, binding sockets and launching a receive loop per socket. Recreates the - /// sockets on every call, so Stop/Start cycles are supported. + /// Starts listening, binding sockets and launching a receive loop per socket. Recreates the + /// sockets on every call, so Stop/Start cycles are supported. /// public Task StartAsync(CancellationToken cancellationToken) { @@ -187,7 +144,7 @@ public Task StartAsync(CancellationToken cancellationToken) } /// - /// Stops listening, closing every socket and awaiting the receive loops. + /// Stops listening, closing every socket and awaiting the receive loops. /// public async Task StopAsync(CancellationToken cancellationToken) { @@ -243,6 +200,53 @@ public async Task StopAsync(CancellationToken cancellationToken) } } + /// + /// Raised for every datagram received, carrying the sender endpoint. Always raised, regardless of + /// . + /// + public event EventHandler? OnDatagramReceived; + + /// + /// Raised when receive loops throw an unexpected exception. + /// + public event EventHandler? OnException; + + /// + /// Sends a datagram to a specific endpoint, using the listener that last received from it + /// (falling back to the first listener). No-op when no listener is available. + /// + public async Task SendToAsync( + IPEndPoint endPoint, + ReadOnlyMemory payload, + CancellationToken cancellationToken = default + ) + { + ArgumentNullException.ThrowIfNull(endPoint); + + if (!_endpointListeners.TryGetValue(endPoint, out var listener)) + { + lock (_sync) + { + listener = _listeners.FirstOrDefault(); + } + } + + if (listener is null) + { + return; + } + + try + { + await listener.SendAsync(payload, endPoint, cancellationToken); + } + catch (Exception ex) + { + _logger.Warning(ex, "UDP SendToAsync failed for {EndPoint}", endPoint); + OnException?.Invoke(this, new SquidStdTcpExceptionEventArgs(ex)); + } + } + private UdpClient? CreateListener(IPEndPoint endPoint) { var socket = new Socket(endPoint.AddressFamily, SocketType.Dgram, ProtocolType.Udp) @@ -254,7 +258,7 @@ public async Task StopAsync(CancellationToken cancellationToken) { socket.Bind(endPoint); - return new() + return new UdpClient { Client = socket }; @@ -281,11 +285,14 @@ private async Task ReceiveLoopAsync(UdpClient listener, CancellationToken cancel { var result = await listener.ReceiveAsync(cancellationToken); _endpointListeners[result.RemoteEndPoint] = listener; - OnDatagramReceived?.Invoke(this, new(result.RemoteEndPoint, result.Buffer)); + OnDatagramReceived?.Invoke( + this, + new SquidStdUdpDatagramReceivedEventArgs(result.RemoteEndPoint, result.Buffer) + ); var response = OnDatagram is null - ? result.Buffer - : OnDatagram(result.Buffer, result.RemoteEndPoint); + ? result.Buffer + : OnDatagram(result.Buffer, result.RemoteEndPoint); if (!response.IsEmpty) { @@ -307,7 +314,7 @@ private async Task ReceiveLoopAsync(UdpClient listener, CancellationToken cancel catch (Exception ex) { _logger.Warning(ex, "UDP receive loop failed"); - OnException?.Invoke(this, new(ex)); + OnException?.Invoke(this, new SquidStdTcpExceptionEventArgs(ex)); } } } @@ -322,7 +329,7 @@ private IEnumerable ResolveBindEndPoints() return [ .. NetworkUtils.GetListeningAddresses(_endPoint) - .Select(address => new IPEndPoint(address.Address, _endPoint.Port)) + .Select(address => new IPEndPoint(address.Address, _endPoint.Port)) ]; } } diff --git a/src/SquidStd.Network/Server/SquidTcpServer.cs b/src/SquidStd.Network/Server/SquidTcpServer.cs index 731a49fa..60852b1f 100644 --- a/src/SquidStd.Network/Server/SquidTcpServer.cs +++ b/src/SquidStd.Network/Server/SquidTcpServer.cs @@ -4,6 +4,7 @@ using System.Net.Sockets; using Serilog; using SquidStd.Network.Client; +using SquidStd.Network.Data; using SquidStd.Network.Data.Events; using SquidStd.Network.Data.Options; using SquidStd.Network.Interfaces.Framing; @@ -14,21 +15,22 @@ namespace SquidStd.Network.Server; /// -/// High-throughput TCP server with client lifecycle events and middleware-enabled payload dispatch. -/// Supports Start/Stop/Start cycles by recreating the underlying socket on each Start. +/// High-throughput TCP server with client lifecycle events and middleware-enabled payload dispatch. +/// Supports Start/Stop/Start cycles by recreating the underlying socket on each Start. /// public sealed class SquidTcpServer : INetworkServer, IAsyncDisposable, IDisposable { private const int DefaultBacklog = 512; private readonly ConcurrentDictionary _clients = new(); + private readonly Func? _connectionPipelineFactory; private readonly IPEndPoint _endPoint; private readonly INetFramer? _framer; private readonly int _historyBufferCapacity; - private readonly SquidStdTcpServerTlsOptions? _tlsOptions; private readonly ILogger _logger = Log.ForContext(); private readonly Lock _middlewareSync = new(); private readonly int _receiveBufferSize; + private readonly SquidStdTcpServerTlsOptions? _tlsOptions; private Task? _acceptLoopTask; private CancellationTokenSource? _listenerCancellationTokenSource; @@ -37,56 +39,27 @@ public sealed class SquidTcpServer : INetworkServer, IAsyncDisposable, IDisposab private int _started; /// - /// Transport type exposed by this server. - /// - public ServerType ServerType => ServerType.TCP; - - /// - /// Current listening port. Returns 0 when the server is stopped. - /// - public int Port => ((IPEndPoint?)_serverSocket?.LocalEndPoint)?.Port ?? 0; - - /// - /// True when the server is currently accepting connections. - /// - public bool IsRunning => Volatile.Read(ref _started) != 0; - - /// - /// Raised when a client connects. - /// - public event EventHandler? OnClientConnect; - - /// - /// Raised when a client disconnects. - /// - public event EventHandler? OnClientDisconnect; - - /// - /// Raised when a client sends data after middleware processing. - /// - public event EventHandler? OnDataReceived; - - /// - /// Raised when an exception happens in accept loop or client loops. - /// - public event EventHandler? OnException; - - /// - /// Initializes a TCP server bound to the given endpoint. + /// Initializes a TCP server bound to the given endpoint. /// /// Endpoint to bind on every StartAsync. /// - /// Optional framer template. The same instance is shared by all accepted clients, - /// so implementations must be stateless or thread-safe. + /// Optional framer template. The same instance is shared by all accepted clients, + /// so implementations must be stateless or thread-safe. /// /// Per-client receive chunk size. /// Per-client history buffer capacity. + /// + /// Optional factory invoked once per accepted connection to produce its transport configuration. + /// It MUST return fresh per-connection state — in particular a new ITransportCodec instance per + /// call — because codecs are stateful and must not be shared across connections. + /// public SquidTcpServer( IPEndPoint endPoint, INetFramer? framer = null, int receiveBufferSize = 8192, int historyBufferCapacity = 65536, - SquidStdTcpServerTlsOptions? tlsOptions = null + SquidStdTcpServerTlsOptions? tlsOptions = null, + Func? connectionPipelineFactory = null ) { _endPoint = endPoint; @@ -94,32 +67,39 @@ public SquidTcpServer( _receiveBufferSize = receiveBufferSize; _historyBufferCapacity = historyBufferCapacity; _tlsOptions = tlsOptions; + _connectionPipelineFactory = connectionPipelineFactory; + } + + /// + public void Dispose() + { + DisposeAsync().AsTask().GetAwaiter().GetResult(); } /// - /// Registers middleware in execution order. + /// Transport type exposed by this server. /// - public SquidTcpServer AddMiddleware(INetMiddleware middleware) - { - lock (_middlewareSync) - { - _middlewares = [.. _middlewares, middleware]; - } + public ServerType ServerType => ServerType.TCP; - return this; - } + /// + /// Current listening port. Returns 0 when the server is stopped. + /// + public int Port => ((IPEndPoint?)_serverSocket?.LocalEndPoint)?.Port ?? 0; - /// - public void Dispose() - => DisposeAsync().AsTask().GetAwaiter().GetResult(); + /// + /// True when the server is currently accepting connections. + /// + public bool IsRunning => Volatile.Read(ref _started) != 0; /// public async ValueTask DisposeAsync() - => await StopAsync(CancellationToken.None); + { + await StopAsync(CancellationToken.None); + } /// - /// Starts accepting clients. Recreates the listening socket on every call, - /// so Stop/Start cycles are supported. + /// Starts accepting clients. Recreates the listening socket on every call, + /// so Stop/Start cycles are supported. /// public Task StartAsync(CancellationToken cancellationToken) { @@ -128,7 +108,7 @@ public Task StartAsync(CancellationToken cancellationToken) return Task.CompletedTask; } - _serverSocket = new(_endPoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp); + _serverSocket = new Socket(_endPoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp); _serverSocket.Bind(_endPoint); _serverSocket.Listen(DefaultBacklog); @@ -141,7 +121,7 @@ public Task StartAsync(CancellationToken cancellationToken) } /// - /// Stops accepting new clients and closes all active clients. + /// Stops accepting new clients and closes all active clients. /// public async Task StopAsync(CancellationToken cancellationToken) { @@ -195,6 +175,39 @@ public async Task StopAsync(CancellationToken cancellationToken) _acceptLoopTask = null; } + /// + /// Raised when a client connects. + /// + public event EventHandler? OnClientConnect; + + /// + /// Raised when a client disconnects. + /// + public event EventHandler? OnClientDisconnect; + + /// + /// Raised when a client sends data after middleware processing. + /// + public event EventHandler? OnDataReceived; + + /// + /// Raised when an exception happens in accept loop or client loops. + /// + public event EventHandler? OnException; + + /// + /// Registers middleware in execution order. + /// + public SquidTcpServer AddMiddleware(INetMiddleware middleware) + { + lock (_middlewareSync) + { + _middlewares = [.. _middlewares, middleware]; + } + + return this; + } + private async Task AcceptLoopAsync() { var cts = _listenerCancellationTokenSource; @@ -212,12 +225,16 @@ private async Task AcceptLoopAsync() var clientSocket = await serverSocket.AcceptAsync(cts.Token); var clientStream = await CreateClientStreamAsync(clientSocket, cts.Token).ConfigureAwait(false); - var middlewareSnapshot = _middlewares; + var pipeline = _connectionPipelineFactory?.Invoke(); + var middlewares = pipeline?.Middlewares ?? _middlewares; + var framer = pipeline?.Framer ?? _framer; + var codec = pipeline?.Codec; var client = new SquidStdTcpClient( clientSocket, clientStream, - middlewareSnapshot, - _framer, + middlewares, + framer, + codec, _receiveBufferSize, _historyBufferCapacity ); @@ -237,7 +254,7 @@ private async Task AcceptLoopAsync() catch (Exception ex) { _logger.Error(ex, "Accept loop failed"); - OnException?.Invoke(this, new(ex)); + OnException?.Invoke(this, new SquidStdTcpExceptionEventArgs(ex)); } } } @@ -256,10 +273,10 @@ private async Task CreateClientStreamAsync(Socket clientSocket, Cancella try { await sslStream.AuthenticateAsServerAsync( - _tlsOptions.ToAuthenticationOptions(), - cancellationToken - ) - .ConfigureAwait(false); + _tlsOptions.ToAuthenticationOptions(), + cancellationToken + ) + .ConfigureAwait(false); return sslStream; } @@ -275,41 +292,41 @@ await sslStream.AuthenticateAsServerAsync( private void WireClientEvents(SquidStdTcpClient client) { client.OnConnected += (_, args) => - { - _logger.Debug( - "OnClientConnect. SessionId={SessionId}, RemoteEndPoint={RemoteEndPoint}", - args.Client.SessionId, - args.Client.RemoteEndPoint - ); - OnClientConnect?.Invoke(this, args); - }; + { + _logger.Debug( + "OnClientConnect. SessionId={SessionId}, RemoteEndPoint={RemoteEndPoint}", + args.Client.SessionId, + args.Client.RemoteEndPoint + ); + OnClientConnect?.Invoke(this, args); + }; client.OnDataReceived += (_, args) => - { - _logger.Verbose( - "OnDataReceived. SessionId={SessionId}, Bytes={Bytes}", - args.Client.SessionId, - args.Data.Length - ); - OnDataReceived?.Invoke(this, args); - }; + { + _logger.Verbose( + "OnDataReceived. SessionId={SessionId}, Bytes={Bytes}", + args.Client.SessionId, + args.Data.Length + ); + OnDataReceived?.Invoke(this, args); + }; client.OnException += (_, args) => - { - _logger.Error( - args.Exception, - "OnException. SessionId={SessionId}", - args.Client?.SessionId - ); - OnException?.Invoke(this, args); - }; + { + _logger.Error( + args.Exception, + "OnException. SessionId={SessionId}", + args.Client?.SessionId + ); + OnException?.Invoke(this, args); + }; client.OnDisconnected += (_, args) => - { - _clients.TryRemove(args.Client.SessionId, out var _); - _logger.Debug( - "OnClientDisconnect. SessionId={SessionId}, RemoteEndPoint={RemoteEndPoint}", - args.Client.SessionId, - args.Client.RemoteEndPoint - ); - OnClientDisconnect?.Invoke(this, args); - }; + { + _clients.TryRemove(args.Client.SessionId, out var _); + _logger.Debug( + "OnClientDisconnect. SessionId={SessionId}, RemoteEndPoint={RemoteEndPoint}", + args.Client.SessionId, + args.Client.RemoteEndPoint + ); + OnClientDisconnect?.Invoke(this, args); + }; } } diff --git a/src/SquidStd.Network/Sessions/Session.cs b/src/SquidStd.Network/Sessions/Session.cs index 6ff1a269..dff61bd5 100644 --- a/src/SquidStd.Network/Sessions/Session.cs +++ b/src/SquidStd.Network/Sessions/Session.cs @@ -4,11 +4,21 @@ namespace SquidStd.Network.Sessions; /// -/// A tracked network connection with associated application state. +/// A tracked network connection with associated application state. /// /// Application-defined per-connection state. public sealed class Session { + public Session(long sessionId, INetworkConnection connection, TState state, DateTimeOffset createdAtUtc) + { + ArgumentNullException.ThrowIfNull(connection); + + Connection = connection; + SessionId = sessionId; + State = state; + CreatedAtUtc = createdAtUtc; + } + /// Unique connection identifier assigned by the transport. public long SessionId { get; } @@ -27,21 +37,15 @@ public sealed class Session /// Whether the underlying connection is still open. public bool IsConnected => Connection.IsConnected; - public Session(long sessionId, INetworkConnection connection, TState state, DateTimeOffset createdAtUtc) - { - ArgumentNullException.ThrowIfNull(connection); - - Connection = connection; - SessionId = sessionId; - State = state; - CreatedAtUtc = createdAtUtc; - } - /// Closes the underlying connection. public Task CloseAsync(CancellationToken cancellationToken = default) - => Connection.CloseAsync(cancellationToken); + { + return Connection.CloseAsync(cancellationToken); + } /// Sends a payload over the underlying connection. public Task SendAsync(ReadOnlyMemory payload, CancellationToken cancellationToken = default) - => Connection.SendAsync(payload, cancellationToken); + { + return Connection.SendAsync(payload, cancellationToken); + } } diff --git a/src/SquidStd.Network/Sessions/SessionManager.cs b/src/SquidStd.Network/Sessions/SessionManager.cs index 4f0d4b39..c939ea66 100644 --- a/src/SquidStd.Network/Sessions/SessionManager.cs +++ b/src/SquidStd.Network/Sessions/SessionManager.cs @@ -8,18 +8,44 @@ namespace SquidStd.Network.Sessions; /// -/// Observes a and maintains a registry of . -/// The server is not modified; the manager subscribes to its lifecycle events. +/// Observes a and maintains a registry of . +/// The server is not modified; the manager subscribes to its lifecycle events. /// /// Application-defined per-connection state. public sealed class SessionManager : ISessionManager, IDisposable { private readonly ILogger _logger = Log.ForContext>(); - private readonly ConcurrentDictionary> _sessions = new(); private readonly SquidTcpServer _server; + private readonly ConcurrentDictionary> _sessions = new(); private readonly Func _stateFactory; private int _disposed; + public SessionManager(SquidTcpServer server, Func stateFactory) + { + ArgumentNullException.ThrowIfNull(server); + ArgumentNullException.ThrowIfNull(stateFactory); + + _server = server; + _stateFactory = stateFactory; + + _server.OnClientConnect += HandleServerClientConnect; + _server.OnClientDisconnect += HandleServerClientDisconnect; + _server.OnDataReceived += HandleServerDataReceived; + } + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + _server.OnClientConnect -= HandleServerClientConnect; + _server.OnClientDisconnect -= HandleServerClientDisconnect; + _server.OnDataReceived -= HandleServerDataReceived; + _sessions.Clear(); + } + /// public int Count => _sessions.Count; @@ -35,19 +61,6 @@ public sealed class SessionManager : ISessionManager, IDisposabl /// public event EventHandler>? OnSessionData; - public SessionManager(SquidTcpServer server, Func stateFactory) - { - ArgumentNullException.ThrowIfNull(server); - ArgumentNullException.ThrowIfNull(stateFactory); - - _server = server; - _stateFactory = stateFactory; - - _server.OnClientConnect += HandleServerClientConnect; - _server.OnClientDisconnect += HandleServerClientDisconnect; - _server.OnDataReceived += HandleServerDataReceived; - } - /// public async Task BroadcastAsync(ReadOnlyMemory payload, CancellationToken cancellationToken = default) { @@ -64,32 +77,25 @@ public async Task BroadcastAsync(ReadOnlyMemory payload, CancellationToken /// public Task DisconnectAsync(long sessionId, CancellationToken cancellationToken = default) - => _sessions.TryGetValue(sessionId, out var session) - ? session.CloseAsync(cancellationToken) - : Task.CompletedTask; - - public void Dispose() { - if (Interlocked.Exchange(ref _disposed, 1) != 0) - { - return; - } - - _server.OnClientConnect -= HandleServerClientConnect; - _server.OnClientDisconnect -= HandleServerClientDisconnect; - _server.OnDataReceived -= HandleServerDataReceived; - _sessions.Clear(); + return _sessions.TryGetValue(sessionId, out var session) + ? session.CloseAsync(cancellationToken) + : Task.CompletedTask; } /// public Task SendAsync(long sessionId, ReadOnlyMemory payload, CancellationToken cancellationToken = default) - => _sessions.TryGetValue(sessionId, out var session) - ? session.SendAsync(payload, cancellationToken) - : Task.CompletedTask; + { + return _sessions.TryGetValue(sessionId, out var session) + ? session.SendAsync(payload, cancellationToken) + : Task.CompletedTask; + } /// public bool TryGetSession(long sessionId, out Session? session) - => _sessions.TryGetValue(sessionId, out session); + { + return _sessions.TryGetValue(sessionId, out session); + } internal void HandleConnected(INetworkConnection connection) { @@ -127,19 +133,25 @@ internal void HandleDisconnected(INetworkConnection connection) } private void HandleServerClientConnect(object? sender, SquidStdTcpClientEventArgs e) - => HandleConnected(e.Client); + { + HandleConnected(e.Client); + } private void HandleServerClientDisconnect(object? sender, SquidStdTcpClientEventArgs e) - => HandleDisconnected(e.Client); + { + HandleDisconnected(e.Client); + } private void HandleServerDataReceived(object? sender, SquidStdTcpDataReceivedEventArgs e) - => HandleData(e.Client, e.Data); + { + HandleData(e.Client, e.Data); + } private void RaiseSessionCreated(Session session) { try { - OnSessionCreated?.Invoke(this, new(session)); + OnSessionCreated?.Invoke(this, new SquidStdSessionEventArgs(session)); } catch (Exception ex) { @@ -151,7 +163,7 @@ private void RaiseSessionData(Session session, ReadOnlyMemory data { try { - OnSessionData?.Invoke(this, new(session, data)); + OnSessionData?.Invoke(this, new SquidStdSessionDataEventArgs(session, data)); } catch (Exception ex) { @@ -163,7 +175,7 @@ private void RaiseSessionRemoved(Session session) { try { - OnSessionRemoved?.Invoke(this, new(session)); + OnSessionRemoved?.Invoke(this, new SquidStdSessionEventArgs(session)); } catch (Exception ex) { diff --git a/src/SquidStd.Network/Sessions/UdpSessionConnection.cs b/src/SquidStd.Network/Sessions/UdpSessionConnection.cs index 9bfa8fb8..d3b4db0f 100644 --- a/src/SquidStd.Network/Sessions/UdpSessionConnection.cs +++ b/src/SquidStd.Network/Sessions/UdpSessionConnection.cs @@ -5,20 +5,16 @@ namespace SquidStd.Network.Sessions; /// -/// Virtual per-endpoint connection backing a UDP session. Sends route to the server's -/// ; closing removes the session via a callback. +/// Virtual per-endpoint connection backing a UDP session. Sends route to the server's +/// ; closing removes the session via a callback. /// internal sealed class UdpSessionConnection : INetworkConnection { - private readonly SquidStdUdpServer _server; - private readonly IPEndPoint _remoteEndPoint; private readonly Action _onClose; + private readonly IPEndPoint _remoteEndPoint; + private readonly SquidStdUdpServer _server; private int _closed; - public long SessionId { get; } - public EndPoint? RemoteEndPoint => _remoteEndPoint; - public bool IsConnected => Volatile.Read(ref _closed) == 0; - public UdpSessionConnection(SquidStdUdpServer server, IPEndPoint remoteEndPoint, long sessionId, Action onClose) { _server = server; @@ -27,6 +23,10 @@ public UdpSessionConnection(SquidStdUdpServer server, IPEndPoint remoteEndPoint, _onClose = onClose; } + public long SessionId { get; } + public EndPoint? RemoteEndPoint => _remoteEndPoint; + public bool IsConnected => Volatile.Read(ref _closed) == 0; + public Task CloseAsync(CancellationToken cancellationToken = default) { if (Interlocked.Exchange(ref _closed, 1) == 0) @@ -38,5 +38,7 @@ public Task CloseAsync(CancellationToken cancellationToken = default) } public Task SendAsync(ReadOnlyMemory payload, CancellationToken cancellationToken) - => _server.SendToAsync(_remoteEndPoint, payload, cancellationToken); + { + return _server.SendToAsync(_remoteEndPoint, payload, cancellationToken); + } } diff --git a/src/SquidStd.Network/Sessions/UdpSessionEntry.cs b/src/SquidStd.Network/Sessions/UdpSessionEntry.cs index 3d4d3903..3f7659f2 100644 --- a/src/SquidStd.Network/Sessions/UdpSessionEntry.cs +++ b/src/SquidStd.Network/Sessions/UdpSessionEntry.cs @@ -1,17 +1,17 @@ namespace SquidStd.Network.Sessions; /// -/// Internal registry entry pairing a UDP session with its last-activity timestamp. +/// Internal registry entry pairing a UDP session with its last-activity timestamp. /// /// Application-defined per-connection state. internal sealed class UdpSessionEntry { - public Session Session { get; } - public DateTimeOffset LastActivityUtc { get; set; } - public UdpSessionEntry(Session session, DateTimeOffset lastActivityUtc) { Session = session; LastActivityUtc = lastActivityUtc; } + + public Session Session { get; } + public DateTimeOffset LastActivityUtc { get; set; } } diff --git a/src/SquidStd.Network/Sessions/UdpSessionManager.cs b/src/SquidStd.Network/Sessions/UdpSessionManager.cs index 818b1bf0..c138a685 100644 --- a/src/SquidStd.Network/Sessions/UdpSessionManager.cs +++ b/src/SquidStd.Network/Sessions/UdpSessionManager.cs @@ -9,40 +9,22 @@ namespace SquidStd.Network.Sessions; /// -/// Tracks per-endpoint UDP sessions over a . Sessions are created on -/// the first datagram from an endpoint and removed by idle-timeout sweep or explicit disconnect. +/// Tracks per-endpoint UDP sessions over a . Sessions are created on +/// the first datagram from an endpoint and removed by idle-timeout sweep or explicit disconnect. /// /// Application-defined per-connection state. public sealed class UdpSessionManager : ISessionManager, IDisposable { - private readonly ILogger _logger = Log.ForContext>(); private readonly ConcurrentDictionary> _byEndpoint = new(); private readonly ConcurrentDictionary _byId = new(); private readonly Lock _createLock = new(); + private readonly ILogger _logger = Log.ForContext>(); private readonly SquidStdUdpServer _server; private readonly Func _stateFactory; - private readonly TimeProvider _timeProvider; private readonly ITimer _sweepTimer; - private long _sessionIdSequence; + private readonly TimeProvider _timeProvider; private int _disposed; - - /// - public int Count => _byEndpoint.Count; - - /// - public IReadOnlyCollection> Sessions => _byEndpoint.Values.Select(entry => entry.Session).ToArray(); - - /// Idle period after which an inactive session is removed. - public TimeSpan IdleTimeout { get; } - - /// - public event EventHandler>? OnSessionCreated; - - /// - public event EventHandler>? OnSessionRemoved; - - /// - public event EventHandler>? OnSessionData; + private long _sessionIdSequence; public UdpSessionManager( SquidStdUdpServer server, @@ -68,6 +50,37 @@ public UdpSessionManager( _sweepTimer = _timeProvider.CreateTimer(_ => SafeSweep(), null, interval, interval); } + /// Idle period after which an inactive session is removed. + public TimeSpan IdleTimeout { get; } + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + _server.OnDatagramReceived -= HandleServerDatagram; + _sweepTimer.Dispose(); + _byEndpoint.Clear(); + _byId.Clear(); + } + + /// + public int Count => _byEndpoint.Count; + + /// + public IReadOnlyCollection> Sessions => _byEndpoint.Values.Select(entry => entry.Session).ToArray(); + + /// + public event EventHandler>? OnSessionCreated; + + /// + public event EventHandler>? OnSessionRemoved; + + /// + public event EventHandler>? OnSessionData; + /// public async Task BroadcastAsync(ReadOnlyMemory payload, CancellationToken cancellationToken = default) { @@ -84,40 +97,19 @@ public async Task BroadcastAsync(ReadOnlyMemory payload, CancellationToken /// public Task DisconnectAsync(long sessionId, CancellationToken cancellationToken = default) - => TryGetSession(sessionId, out var session) - ? session!.CloseAsync(cancellationToken) - : Task.CompletedTask; - - /// Closes the session for the given endpoint. No-op when unknown. - public Task DisconnectAsync(IPEndPoint endPoint, CancellationToken cancellationToken = default) - => TryGetSession(endPoint, out var session) - ? session!.CloseAsync(cancellationToken) - : Task.CompletedTask; - - public void Dispose() { - if (Interlocked.Exchange(ref _disposed, 1) != 0) - { - return; - } - - _server.OnDatagramReceived -= HandleServerDatagram; - _sweepTimer.Dispose(); - _byEndpoint.Clear(); - _byId.Clear(); + return TryGetSession(sessionId, out var session) + ? session!.CloseAsync(cancellationToken) + : Task.CompletedTask; } /// public Task SendAsync(long sessionId, ReadOnlyMemory payload, CancellationToken cancellationToken = default) - => TryGetSession(sessionId, out var session) - ? session!.SendAsync(payload, cancellationToken) - : Task.CompletedTask; - - /// Sends a payload to the session for the given endpoint. No-op when unknown. - public Task SendToAsync(IPEndPoint endPoint, ReadOnlyMemory payload, CancellationToken cancellationToken = default) - => TryGetSession(endPoint, out var session) - ? session!.SendAsync(payload, cancellationToken) - : Task.CompletedTask; + { + return TryGetSession(sessionId, out var session) + ? session!.SendAsync(payload, cancellationToken) + : Task.CompletedTask; + } /// public bool TryGetSession(long sessionId, out Session? session) @@ -134,6 +126,22 @@ public bool TryGetSession(long sessionId, out Session? session) return false; } + /// Closes the session for the given endpoint. No-op when unknown. + public Task DisconnectAsync(IPEndPoint endPoint, CancellationToken cancellationToken = default) + { + return TryGetSession(endPoint, out var session) + ? session!.CloseAsync(cancellationToken) + : Task.CompletedTask; + } + + /// Sends a payload to the session for the given endpoint. No-op when unknown. + public Task SendToAsync(IPEndPoint endPoint, ReadOnlyMemory payload, CancellationToken cancellationToken = default) + { + return TryGetSession(endPoint, out var session) + ? session!.SendAsync(payload, cancellationToken) + : Task.CompletedTask; + } + /// Looks up a session by remote endpoint. public bool TryGetSession(IPEndPoint endPoint, out Session? session) { @@ -212,13 +220,15 @@ private UdpSessionEntry GetOrCreate(IPEndPoint remoteEndPoint, out bool } private void HandleServerDatagram(object? sender, SquidStdUdpDatagramReceivedEventArgs e) - => HandleDatagram(e.RemoteEndPoint, e.Data); + { + HandleDatagram(e.RemoteEndPoint, e.Data); + } private void RaiseSessionCreated(Session session) { try { - OnSessionCreated?.Invoke(this, new(session)); + OnSessionCreated?.Invoke(this, new SquidStdSessionEventArgs(session)); } catch (Exception ex) { @@ -230,7 +240,7 @@ private void RaiseSessionData(Session session, ReadOnlyMemory data { try { - OnSessionData?.Invoke(this, new(session, data)); + OnSessionData?.Invoke(this, new SquidStdSessionDataEventArgs(session, data)); } catch (Exception ex) { @@ -242,7 +252,7 @@ private void RaiseSessionRemoved(Session session) { try { - OnSessionRemoved?.Invoke(this, new(session)); + OnSessionRemoved?.Invoke(this, new SquidStdSessionEventArgs(session)); } catch (Exception ex) { diff --git a/src/SquidStd.Network/Spans/SpanReader.cs b/src/SquidStd.Network/Spans/SpanReader.cs index dd89efb1..46be5736 100644 --- a/src/SquidStd.Network/Spans/SpanReader.cs +++ b/src/SquidStd.Network/Spans/SpanReader.cs @@ -45,39 +45,57 @@ public int Read(scoped Span bytes) [MethodImpl(MethodImplOptions.AggressiveInlining)] public string ReadAscii(int fixedLength) - => ReadString(Encoding.ASCII, fixedLength: fixedLength); + { + return ReadString(Encoding.ASCII, fixedLength: fixedLength); + } [MethodImpl(MethodImplOptions.AggressiveInlining)] public string ReadAscii() - => ReadString(Encoding.ASCII); + { + return ReadString(Encoding.ASCII); + } [MethodImpl(MethodImplOptions.AggressiveInlining)] public string ReadAsciiSafe(int fixedLength) - => ReadString(Encoding.ASCII, true, fixedLength); + { + return ReadString(Encoding.ASCII, true, fixedLength); + } [MethodImpl(MethodImplOptions.AggressiveInlining)] public string ReadAsciiSafe() - => ReadString(Encoding.ASCII, true); + { + return ReadString(Encoding.ASCII, true); + } [MethodImpl(MethodImplOptions.AggressiveInlining)] public string ReadBigUni(int fixedLength) - => ReadString(Encoding.BigEndianUnicode, fixedLength: fixedLength); + { + return ReadString(Encoding.BigEndianUnicode, fixedLength: fixedLength); + } [MethodImpl(MethodImplOptions.AggressiveInlining)] public string ReadBigUni() - => ReadString(Encoding.BigEndianUnicode); + { + return ReadString(Encoding.BigEndianUnicode); + } [MethodImpl(MethodImplOptions.AggressiveInlining)] public string ReadBigUniSafe(int fixedLength) - => ReadString(Encoding.BigEndianUnicode, true, fixedLength); + { + return ReadString(Encoding.BigEndianUnicode, true, fixedLength); + } [MethodImpl(MethodImplOptions.AggressiveInlining)] public string ReadBigUniSafe() - => ReadString(Encoding.BigEndianUnicode, true); + { + return ReadString(Encoding.BigEndianUnicode, true); + } [MethodImpl(MethodImplOptions.AggressiveInlining)] public bool ReadBoolean() - => ReadByte() > 0; + { + return ReadByte() > 0; + } [MethodImpl(MethodImplOptions.AggressiveInlining)] public byte ReadByte() @@ -184,23 +202,33 @@ public long ReadInt64LE() [MethodImpl(MethodImplOptions.AggressiveInlining)] public string ReadLittleUni(int fixedLength) - => ReadString(Encoding.Unicode, fixedLength: fixedLength); + { + return ReadString(Encoding.Unicode, fixedLength: fixedLength); + } [MethodImpl(MethodImplOptions.AggressiveInlining)] public string ReadLittleUni() - => ReadString(Encoding.Unicode); + { + return ReadString(Encoding.Unicode); + } [MethodImpl(MethodImplOptions.AggressiveInlining)] public string ReadLittleUniSafe(int fixedLength) - => ReadString(Encoding.Unicode, true, fixedLength); + { + return ReadString(Encoding.Unicode, true, fixedLength); + } [MethodImpl(MethodImplOptions.AggressiveInlining)] public string ReadLittleUniSafe() - => ReadString(Encoding.Unicode, true); + { + return ReadString(Encoding.Unicode, true); + } [MethodImpl(MethodImplOptions.AggressiveInlining)] public sbyte ReadSByte() - => (sbyte)ReadByte(); + { + return (sbyte)ReadByte(); + } [MethodImpl(MethodImplOptions.AggressiveInlining)] public string ReadString(Encoding encoding, bool safeString = false, int fixedLength = -1) @@ -324,15 +352,21 @@ public ulong ReadUInt64LE() [MethodImpl(MethodImplOptions.AggressiveInlining)] public string ReadUTF8() - => ReadString(Encoding.UTF8); + { + return ReadString(Encoding.UTF8); + } [MethodImpl(MethodImplOptions.AggressiveInlining)] public string ReadUTF8Safe(int fixedLength) - => ReadString(Encoding.UTF8, true, fixedLength); + { + return ReadString(Encoding.UTF8, true, fixedLength); + } [MethodImpl(MethodImplOptions.AggressiveInlining)] public string ReadUTF8Safe() - => ReadString(Encoding.UTF8, true); + { + return ReadString(Encoding.UTF8, true); + } [MethodImpl(MethodImplOptions.AggressiveInlining)] public int Seek(int offset, SeekOrigin origin) @@ -358,12 +392,14 @@ public int Seek(int offset, SeekOrigin origin) } private static int GetTerminatorWidth(Encoding encoding) - => encoding switch + { + return encoding switch { UnicodeEncoding => 2, UTF32Encoding => 4, _ => 1 }; + } private static int IndexOfTerminator(ReadOnlySpan span, int terminatorWidth) { @@ -397,5 +433,7 @@ private static int IndexOfTerminator(ReadOnlySpan span, int terminatorWidt [DoesNotReturn] private static void ThrowInsufficientData() - => throw new InvalidOperationException("Insufficient data in buffer."); + { + throw new InvalidOperationException("Insufficient data in buffer."); + } } diff --git a/src/SquidStd.Network/Spans/SpanWriter.cs b/src/SquidStd.Network/Spans/SpanWriter.cs index 6f9bf8e2..04006dfd 100644 --- a/src/SquidStd.Network/Spans/SpanWriter.cs +++ b/src/SquidStd.Network/Spans/SpanWriter.cs @@ -54,7 +54,7 @@ public SpanWriter(int initialCapacity, bool resize = false) } /// - /// Represents SpanOwner. + /// Represents SpanOwner. /// public struct SpanOwner : IDisposable { @@ -120,7 +120,9 @@ public void EnsureCapacity(int capacity) } public ref byte GetPinnableReference() - => ref MemoryMarshal.GetReference(_buffer); + { + return ref MemoryMarshal.GetReference(_buffer); + } [MethodImpl(MethodImplOptions.NoInlining)] public void Grow(int additionalCapacity) @@ -194,7 +196,7 @@ public SpanOwner ToSpan() ArrayPool.Shared.Return(toReturn); } - return new(0, null, false); + return new SpanOwner(0, null, false); } // Capture the length BEFORE `this = default`, otherwise the reset zeroes @@ -206,14 +208,14 @@ public SpanOwner ToSpan() { this = default; - return new(length, currentPoolBuffer, true); + return new SpanOwner(length, currentPoolBuffer, true); } var ownedBuffer = ArrayPool.Shared.Rent(length); _buffer[..length].CopyTo(ownedBuffer); this = default; - return new(length, ownedBuffer, true); + return new SpanOwner(length, ownedBuffer, true); } [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -329,15 +331,21 @@ public void Write(ReadOnlySpan value, Encoding encoding, int fixedLength = [MethodImpl(MethodImplOptions.AggressiveInlining)] public void WriteAscii(char chr) - => Write((byte)chr); + { + Write((byte)chr); + } [MethodImpl(MethodImplOptions.AggressiveInlining)] public void WriteAscii(string value) - => Write(value.AsSpan(), Encoding.ASCII); + { + Write(value.AsSpan(), Encoding.ASCII); + } [MethodImpl(MethodImplOptions.AggressiveInlining)] public void WriteAscii(string value, int fixedLength) - => Write(value.AsSpan(), Encoding.ASCII, fixedLength); + { + Write(value.AsSpan(), Encoding.ASCII, fixedLength); + } [MethodImpl(MethodImplOptions.AggressiveInlining)] public void WriteAsciiNull(string value) @@ -348,11 +356,15 @@ public void WriteAsciiNull(string value) [MethodImpl(MethodImplOptions.AggressiveInlining)] public void WriteBigUni(string value) - => Write(value.AsSpan(), Encoding.BigEndianUnicode); + { + Write(value.AsSpan(), Encoding.BigEndianUnicode); + } [MethodImpl(MethodImplOptions.AggressiveInlining)] public void WriteBigUni(string value, int fixedLength) - => Write(value.AsSpan(), Encoding.BigEndianUnicode, fixedLength); + { + Write(value.AsSpan(), Encoding.BigEndianUnicode, fixedLength); + } [MethodImpl(MethodImplOptions.AggressiveInlining)] public void WriteBigUniNull(string value) @@ -395,11 +407,15 @@ public void WriteLE(uint value) [MethodImpl(MethodImplOptions.AggressiveInlining)] public void WriteLittleUni(string value) - => Write(value.AsSpan(), Encoding.Unicode); + { + Write(value.AsSpan(), Encoding.Unicode); + } [MethodImpl(MethodImplOptions.AggressiveInlining)] public void WriteLittleUni(string value, int fixedLength) - => Write(value.AsSpan(), Encoding.Unicode, fixedLength); + { + Write(value.AsSpan(), Encoding.Unicode, fixedLength); + } [MethodImpl(MethodImplOptions.AggressiveInlining)] public void WriteLittleUniNull(string value) @@ -410,7 +426,9 @@ public void WriteLittleUniNull(string value) [MethodImpl(MethodImplOptions.AggressiveInlining)] public void WriteUTF8(string value) - => Write(value.AsSpan(), Encoding.UTF8); + { + Write(value.AsSpan(), Encoding.UTF8); + } [MethodImpl(MethodImplOptions.AggressiveInlining)] public void WriteUTF8Null(string value) @@ -420,12 +438,14 @@ public void WriteUTF8Null(string value) } private static int GetTerminatorWidth(Encoding encoding) - => encoding switch + { + return encoding switch { UnicodeEncoding => 2, UTF32Encoding => 4, _ => 1 }; + } [MethodImpl(MethodImplOptions.AggressiveInlining)] private void GrowIfNeeded(int count) diff --git a/src/SquidStd.Persistence.Abstractions/Data/EntitySnapshotBucket.cs b/src/SquidStd.Persistence.Abstractions/Data/EntitySnapshotBucket.cs new file mode 100644 index 00000000..b0e7929f --- /dev/null +++ b/src/SquidStd.Persistence.Abstractions/Data/EntitySnapshotBucket.cs @@ -0,0 +1,10 @@ +namespace SquidStd.Persistence.Abstractions.Data; + +/// Serialized snapshot bucket for a single registered entity type. +public sealed class EntitySnapshotBucket +{ + public ushort TypeId { get; set; } + public string TypeName { get; set; } = string.Empty; + public int SchemaVersion { get; set; } + public byte[] Payload { get; set; } = []; +} diff --git a/src/SquidStd.Persistence.Abstractions/Data/Events/SnapshotSaveCompletedEvent.cs b/src/SquidStd.Persistence.Abstractions/Data/Events/SnapshotSaveCompletedEvent.cs new file mode 100644 index 00000000..a1f8f711 --- /dev/null +++ b/src/SquidStd.Persistence.Abstractions/Data/Events/SnapshotSaveCompletedEvent.cs @@ -0,0 +1,6 @@ +using SquidStd.Core.Interfaces.Events; + +namespace SquidStd.Persistence.Abstractions.Data.Events; + +/// Raised after a snapshot save completes and the journal is trimmed. +public sealed record SnapshotSaveCompletedEvent(long SequenceId, int BucketCount) : IEvent; diff --git a/src/SquidStd.Persistence.Abstractions/Data/Events/SnapshotSaveStartedEvent.cs b/src/SquidStd.Persistence.Abstractions/Data/Events/SnapshotSaveStartedEvent.cs new file mode 100644 index 00000000..438d6dda --- /dev/null +++ b/src/SquidStd.Persistence.Abstractions/Data/Events/SnapshotSaveStartedEvent.cs @@ -0,0 +1,6 @@ +using SquidStd.Core.Interfaces.Events; + +namespace SquidStd.Persistence.Abstractions.Data.Events; + +/// Raised before a snapshot save begins. +public sealed record SnapshotSaveStartedEvent(long SequenceId) : IEvent; diff --git a/src/SquidStd.Persistence.Abstractions/Data/JournalEntry.cs b/src/SquidStd.Persistence.Abstractions/Data/JournalEntry.cs new file mode 100644 index 00000000..6d1e7be5 --- /dev/null +++ b/src/SquidStd.Persistence.Abstractions/Data/JournalEntry.cs @@ -0,0 +1,13 @@ +using SquidStd.Persistence.Abstractions.Types; + +namespace SquidStd.Persistence.Abstractions.Data; + +/// Journal record appended for every persisted mutation. +public sealed class JournalEntry +{ + public long SequenceId { get; set; } + public long TimestampUnixMilliseconds { get; set; } + public ushort TypeId { get; set; } + public JournalEntityOperationType Operation { get; set; } + public byte[] Payload { get; set; } = []; +} diff --git a/src/SquidStd.Persistence.Abstractions/Data/PersistedBucket.cs b/src/SquidStd.Persistence.Abstractions/Data/PersistedBucket.cs new file mode 100644 index 00000000..451000ff --- /dev/null +++ b/src/SquidStd.Persistence.Abstractions/Data/PersistedBucket.cs @@ -0,0 +1,4 @@ +namespace SquidStd.Persistence.Abstractions.Data; + +/// A loaded per-type snapshot: the entity bucket plus the sequence id it was written at. +public sealed record PersistedBucket(EntitySnapshotBucket Bucket, long LastSequenceId); diff --git a/src/SquidStd.Persistence.Abstractions/Data/PersistenceConfig.cs b/src/SquidStd.Persistence.Abstractions/Data/PersistenceConfig.cs new file mode 100644 index 00000000..bfa8eedb --- /dev/null +++ b/src/SquidStd.Persistence.Abstractions/Data/PersistenceConfig.cs @@ -0,0 +1,11 @@ +namespace SquidStd.Persistence.Abstractions.Data; + +/// Configuration for the persistence service: autosave cadence and snapshot/journal file names. +public sealed class PersistenceConfig +{ + public TimeSpan AutosaveInterval { get; set; } = TimeSpan.FromSeconds(300); + public string SnapshotFileSuffix { get; set; } = ".snapshot.bin"; + public string JournalFileName { get; set; } = "world.journal.bin"; + public bool EnableFileLock { get; set; } = true; + public string? SaveDirectory { get; set; } +} diff --git a/src/SquidStd.Persistence.Abstractions/Data/SnapshotFileEnvelope.cs b/src/SquidStd.Persistence.Abstractions/Data/SnapshotFileEnvelope.cs new file mode 100644 index 00000000..50fefd46 --- /dev/null +++ b/src/SquidStd.Persistence.Abstractions/Data/SnapshotFileEnvelope.cs @@ -0,0 +1,10 @@ +namespace SquidStd.Persistence.Abstractions.Data; + +/// On-disk container for a single entity type's snapshot file. +public sealed class SnapshotFileEnvelope +{ + public int Version { get; set; } = 1; + public long LastSequenceId { get; set; } + public uint Checksum { get; set; } + public EntitySnapshotBucket Bucket { get; set; } = new(); +} diff --git a/src/SquidStd.Persistence.Abstractions/Interfaces/Persistence/IEntityStore.cs b/src/SquidStd.Persistence.Abstractions/Interfaces/Persistence/IEntityStore.cs new file mode 100644 index 00000000..ac49b13d --- /dev/null +++ b/src/SquidStd.Persistence.Abstractions/Interfaces/Persistence/IEntityStore.cs @@ -0,0 +1,15 @@ +namespace SquidStd.Persistence.Abstractions.Interfaces.Persistence; + +/// +/// In-memory CRUD over a registered persisted entity type. Reads complete synchronously from memory +/// and return detached clones; writes append to the journal then apply to memory. +/// +public interface IEntityStore +{ + ValueTask CountAsync(CancellationToken cancellationToken = default); + ValueTask> GetAllAsync(CancellationToken cancellationToken = default); + ValueTask GetByIdAsync(TKey id, CancellationToken cancellationToken = default); + IQueryable Query(); + ValueTask RemoveAsync(TKey id, CancellationToken cancellationToken = default); + ValueTask UpsertAsync(TEntity entity, CancellationToken cancellationToken = default); +} diff --git a/src/SquidStd.Persistence.Abstractions/Interfaces/Persistence/IJournalService.cs b/src/SquidStd.Persistence.Abstractions/Interfaces/Persistence/IJournalService.cs new file mode 100644 index 00000000..22022042 --- /dev/null +++ b/src/SquidStd.Persistence.Abstractions/Interfaces/Persistence/IJournalService.cs @@ -0,0 +1,13 @@ +using SquidStd.Persistence.Abstractions.Data; + +namespace SquidStd.Persistence.Abstractions.Interfaces.Persistence; + +/// Appends and replays journal entries from durable storage. +public interface IJournalService +{ + ValueTask AppendAsync(JournalEntry entry, CancellationToken cancellationToken = default); + ValueTask AppendBatchAsync(IReadOnlyList entries, CancellationToken cancellationToken = default); + ValueTask> ReadAllAsync(CancellationToken cancellationToken = default); + ValueTask ResetAsync(CancellationToken cancellationToken = default); + ValueTask TrimThroughSequenceAsync(long inclusiveSequenceId, CancellationToken cancellationToken = default); +} diff --git a/src/SquidStd.Persistence.Abstractions/Interfaces/Persistence/IPersistenceEntityDescriptor.cs b/src/SquidStd.Persistence.Abstractions/Interfaces/Persistence/IPersistenceEntityDescriptor.cs new file mode 100644 index 00000000..e41fece7 --- /dev/null +++ b/src/SquidStd.Persistence.Abstractions/Interfaces/Persistence/IPersistenceEntityDescriptor.cs @@ -0,0 +1,11 @@ +namespace SquidStd.Persistence.Abstractions.Interfaces.Persistence; + +/// Non-generic metadata + serialization view of a persisted entity descriptor. +public interface IPersistenceEntityDescriptor +{ + ushort TypeId { get; } + string TypeName { get; } + int SchemaVersion { get; } + Type EntityType { get; } + Type KeyType { get; } +} diff --git a/src/SquidStd.Persistence.Abstractions/Interfaces/Persistence/IPersistenceEntityDescriptorT.cs b/src/SquidStd.Persistence.Abstractions/Interfaces/Persistence/IPersistenceEntityDescriptorT.cs new file mode 100644 index 00000000..f7b5c0d8 --- /dev/null +++ b/src/SquidStd.Persistence.Abstractions/Interfaces/Persistence/IPersistenceEntityDescriptorT.cs @@ -0,0 +1,15 @@ +namespace SquidStd.Persistence.Abstractions.Interfaces.Persistence; + +/// Typed serialization contract for a persisted entity descriptor. +public interface IPersistenceEntityDescriptor : IPersistenceEntityDescriptor + where TKey : notnull +{ + TKey GetKey(TEntity entity); + TEntity Clone(TEntity entity); + byte[] SerializeEntity(TEntity entity); + TEntity DeserializeEntity(byte[] payload); + byte[] SerializeBucket(IReadOnlyCollection entities); + IReadOnlyList DeserializeBucket(byte[] payload); + byte[] SerializeKey(TKey key); + TKey DeserializeKey(byte[] payload); +} diff --git a/src/SquidStd.Persistence.Abstractions/Interfaces/Persistence/IPersistenceEntityRegistry.cs b/src/SquidStd.Persistence.Abstractions/Interfaces/Persistence/IPersistenceEntityRegistry.cs new file mode 100644 index 00000000..70a35e22 --- /dev/null +++ b/src/SquidStd.Persistence.Abstractions/Interfaces/Persistence/IPersistenceEntityRegistry.cs @@ -0,0 +1,14 @@ +namespace SquidStd.Persistence.Abstractions.Interfaces.Persistence; + +/// Registry of persisted entity descriptors used by snapshot and journal infrastructure. +public interface IPersistenceEntityRegistry +{ + bool IsFrozen { get; } + void Freeze(); + void Register(IPersistenceEntityDescriptor descriptor) where TKey : notnull; + IPersistenceEntityDescriptor GetDescriptor(ushort typeId); + IPersistenceEntityDescriptor GetDescriptor() where TKey : notnull; + IReadOnlyCollection GetRegisteredDescriptors(); + bool IsRegistered(ushort typeId); + bool IsRegistered() where TKey : notnull; +} diff --git a/src/SquidStd.Persistence.Abstractions/Interfaces/Persistence/IPersistenceService.cs b/src/SquidStd.Persistence.Abstractions/Interfaces/Persistence/IPersistenceService.cs new file mode 100644 index 00000000..06dd6305 --- /dev/null +++ b/src/SquidStd.Persistence.Abstractions/Interfaces/Persistence/IPersistenceService.cs @@ -0,0 +1,14 @@ +using SquidStd.Abstractions.Interfaces.Services; + +namespace SquidStd.Persistence.Abstractions.Interfaces.Persistence; + +/// +/// Owns persistence lifecycle: loads the snapshot and replays the journal at startup, autosaves +/// periodically, and hands out per-type instances. +/// +public interface IPersistenceService : ISquidStdService +{ + IEntityStore GetStore() where TKey : notnull; + ValueTask InitializeAsync(CancellationToken cancellationToken = default); + ValueTask SaveSnapshotAsync(CancellationToken cancellationToken = default); +} diff --git a/src/SquidStd.Persistence.Abstractions/Interfaces/Persistence/ISnapshotService.cs b/src/SquidStd.Persistence.Abstractions/Interfaces/Persistence/ISnapshotService.cs new file mode 100644 index 00000000..d25c1798 --- /dev/null +++ b/src/SquidStd.Persistence.Abstractions/Interfaces/Persistence/ISnapshotService.cs @@ -0,0 +1,14 @@ +using SquidStd.Persistence.Abstractions.Data; + +namespace SquidStd.Persistence.Abstractions.Interfaces.Persistence; + +/// Reads and writes per-type entity snapshot files. +public interface ISnapshotService +{ + ValueTask DeleteBucketAsync(string typeName, CancellationToken cancellationToken = default); + ValueTask LoadBucketAsync(string typeName, CancellationToken cancellationToken = default); + + ValueTask SaveBucketAsync( + EntitySnapshotBucket bucket, long lastSequenceId, CancellationToken cancellationToken = default + ); +} diff --git a/src/SquidStd.Persistence.Abstractions/README.md b/src/SquidStd.Persistence.Abstractions/README.md new file mode 100644 index 00000000..b0cbb6b9 --- /dev/null +++ b/src/SquidStd.Persistence.Abstractions/README.md @@ -0,0 +1,38 @@ +

+ SquidStd +

+ +

SquidStd.Persistence.Abstractions

+ +

+ NuGet + Downloads + license +

+ +Contracts and DTOs for SquidStd's binary persistence engine. Depend on this package to expose or consume +persistence types without pulling in the engine implementation. + +## Install + +```bash +dotnet add package SquidStd.Persistence.Abstractions +``` + +## Key types + +| Type | Purpose | +|---------------------------------------|---------------------------------------------------------------| +| `IPersistenceService` | Lifecycle (`ISquidStdService`) + `GetStore()`. | +| `IEntityStore` | In-memory CRUD contract (clones on read, journals on write). | +| `IPersistenceEntityRegistry` | Registry of entity descriptors keyed by `typeId`/type pair. | +| `IPersistenceEntityDescriptor`| Serialize / deserialize / clone / key extraction contract. | +| `ISnapshotService` / `IJournalService`| Per-type snapshot and append-only journal contracts. | +| `PersistenceConfig` | Autosave cadence, file names, save directory, file lock. | +| `JournalEntry`, `EntitySnapshotBucket`, `PersistedBucket`, `SnapshotFileEnvelope` | Persisted DTOs. | +| `JournalEntityOperationType` | `Upsert` / `Remove` journal operation discriminator. | +| `SnapshotSaveStartedEvent` / `SnapshotSaveCompletedEvent` | Snapshot lifecycle events (`IEvent`). | + +## License + +MIT — part of [SquidStd](https://github.com/tgiachi/squid-std). diff --git a/src/SquidStd.Persistence.Abstractions/SquidStd.Persistence.Abstractions.csproj b/src/SquidStd.Persistence.Abstractions/SquidStd.Persistence.Abstractions.csproj new file mode 100644 index 00000000..92ecae12 --- /dev/null +++ b/src/SquidStd.Persistence.Abstractions/SquidStd.Persistence.Abstractions.csproj @@ -0,0 +1,15 @@ + + + + net10.0 + enable + enable + true + + + + + + + + diff --git a/src/SquidStd.Persistence.Abstractions/Types/JournalEntityOperationType.cs b/src/SquidStd.Persistence.Abstractions/Types/JournalEntityOperationType.cs new file mode 100644 index 00000000..4d17a832 --- /dev/null +++ b/src/SquidStd.Persistence.Abstractions/Types/JournalEntityOperationType.cs @@ -0,0 +1,8 @@ +namespace SquidStd.Persistence.Abstractions.Types; + +/// Identifies a journal mutation for a registered entity type. +public enum JournalEntityOperationType : byte +{ + Upsert = 1, + Remove = 2 +} diff --git a/src/SquidStd.Persistence.MessagePack/MessagePackDataSerializer.cs b/src/SquidStd.Persistence.MessagePack/MessagePackDataSerializer.cs new file mode 100644 index 00000000..dca09d91 --- /dev/null +++ b/src/SquidStd.Persistence.MessagePack/MessagePackDataSerializer.cs @@ -0,0 +1,25 @@ +using MessagePack; +using MessagePack.Resolvers; +using SquidStd.Core.Interfaces.Serialization; + +namespace SquidStd.Persistence.MessagePack; + +/// +/// MessagePack-backed binary / using the +/// contractless resolver, so plain POCOs need no attributes. Recommended binary default for persistence. +/// +public sealed class MessagePackDataSerializer : IDataSerializer, IDataDeserializer +{ + private readonly MessagePackSerializerOptions _options; + + public MessagePackDataSerializer() + { + _options = ContractlessStandardResolver.Options; + } + + public ReadOnlyMemory Serialize(T value) + => MessagePackSerializer.Serialize(value, _options); + + public T Deserialize(ReadOnlyMemory data) + => MessagePackSerializer.Deserialize(data, _options)!; +} diff --git a/src/SquidStd.Persistence.MessagePack/README.md b/src/SquidStd.Persistence.MessagePack/README.md new file mode 100644 index 00000000..4041619c --- /dev/null +++ b/src/SquidStd.Persistence.MessagePack/README.md @@ -0,0 +1,48 @@ +

+ SquidStd +

+ +

SquidStd.Persistence.MessagePack

+ +

+ NuGet + Downloads + license +

+ +MessagePack-backed binary `IDataSerializer`/`IDataDeserializer` for `SquidStd.Persistence`. The recommended +compact binary default for entity payloads. Uses the contractless resolver, so plain POCOs need no +attributes. + +## Install + +```bash +dotnet add package SquidStd.Persistence.MessagePack +``` + +## Usage + +```csharp +using SquidStd.Core.Interfaces.Serialization; +using SquidStd.Persistence.MessagePack; + +IDataSerializer serializer = new MessagePackDataSerializer(); +IDataDeserializer deserializer = (MessagePackDataSerializer)serializer; + +// Pass to a PersistenceEntityDescriptor, or register in DI: +container.RegisterInstance(new MessagePackDataSerializer()); +container.RegisterInstance(new MessagePackDataSerializer()); +``` + +> **Note:** the MessagePack contractless resolver requires **public** entity types. For non-public types, +> use the JSON serializer from `SquidStd.Core` (`JsonDataSerializer`) instead. + +## Key types + +| Type | Purpose | +|----------------------------|----------------------------------------------------------| +| `MessagePackDataSerializer`| Contractless MessagePack `IDataSerializer`/`IDataDeserializer`. | + +## License + +MIT — part of [SquidStd](https://github.com/tgiachi/squid-std). diff --git a/src/SquidStd.Persistence.MessagePack/SquidStd.Persistence.MessagePack.csproj b/src/SquidStd.Persistence.MessagePack/SquidStd.Persistence.MessagePack.csproj new file mode 100644 index 00000000..1db39b62 --- /dev/null +++ b/src/SquidStd.Persistence.MessagePack/SquidStd.Persistence.MessagePack.csproj @@ -0,0 +1,18 @@ + + + + net10.0 + enable + enable + true + + + + + + + + + + + diff --git a/src/SquidStd.Persistence/Data/Internal/PersistedEntityRegistration.cs b/src/SquidStd.Persistence/Data/Internal/PersistedEntityRegistration.cs new file mode 100644 index 00000000..e328b9d6 --- /dev/null +++ b/src/SquidStd.Persistence/Data/Internal/PersistedEntityRegistration.cs @@ -0,0 +1,14 @@ +using DryIoc; +using SquidStd.Persistence.Abstractions.Interfaces.Persistence; + +namespace SquidStd.Persistence.Data.Internal; + +/// +/// A declarative persisted-entity registration consumed at bootstrap. The closure +/// captures the entity and key types at registration time, so registry population needs no reflection. +/// +public sealed record PersistedEntityRegistration( + ushort TypeId, + string TypeName, + Action Register +); diff --git a/src/SquidStd.Persistence/Data/PersistenceEntityDescriptor.cs b/src/SquidStd.Persistence/Data/PersistenceEntityDescriptor.cs new file mode 100644 index 00000000..1170c6cf --- /dev/null +++ b/src/SquidStd.Persistence/Data/PersistenceEntityDescriptor.cs @@ -0,0 +1,111 @@ +using SquidStd.Core.Interfaces.Serialization; +using SquidStd.Persistence.Abstractions.Data; +using SquidStd.Persistence.Abstractions.Interfaces.Persistence; +using SquidStd.Persistence.Internal; + +namespace SquidStd.Persistence.Data; + +/// +/// Default descriptor for a persisted entity type. Serializes via the injected +/// /; is a +/// serialize-then-deserialize deep copy for snapshot isolation. +/// +public sealed class PersistenceEntityDescriptor + : IPersistenceEntityDescriptor, IInternalEntityApplier + where TKey : notnull +{ + private readonly IDataDeserializer _deserializer; + private readonly Func _keySelector; + private readonly IDataSerializer _serializer; + + public ushort TypeId { get; } + public string TypeName { get; } + public int SchemaVersion { get; } + public Type EntityType => typeof(TEntity); + public Type KeyType => typeof(TKey); + + public PersistenceEntityDescriptor( + IDataSerializer serializer, + IDataDeserializer deserializer, + ushort typeId, + string typeName, + int schemaVersion, + Func keySelector + ) + { + ArgumentException.ThrowIfNullOrWhiteSpace(typeName); + ArgumentNullException.ThrowIfNull(keySelector); + + _serializer = serializer; + _deserializer = deserializer; + TypeId = typeId; + TypeName = typeName; + SchemaVersion = schemaVersion; + _keySelector = keySelector; + } + + public TKey GetKey(TEntity entity) + => _keySelector(entity); + + public TEntity Clone(TEntity entity) + => DeserializeEntity(SerializeEntity(entity)); + + public byte[] SerializeEntity(TEntity entity) + => _serializer.Serialize(entity).ToArray(); + + public TEntity DeserializeEntity(byte[] payload) + => _deserializer.Deserialize(payload); + + public byte[] SerializeBucket(IReadOnlyCollection entities) + => _serializer.Serialize(entities).ToArray(); + + public IReadOnlyList DeserializeBucket(byte[] payload) + => _deserializer.Deserialize>(payload); + + public byte[] SerializeKey(TKey key) + => _serializer.Serialize(key).ToArray(); + + public TKey DeserializeKey(byte[] payload) + => _deserializer.Deserialize(payload); + + void IInternalEntityApplier.ApplyUpsert(PersistenceStateStore stateStore, byte[] payload) + { + var entity = DeserializeEntity(payload); + stateStore.GetBucket(TypeId)[GetKey(entity)] = entity; + } + + void IInternalEntityApplier.ApplyRemove(PersistenceStateStore stateStore, byte[] payload) + => stateStore.GetBucket(TypeId).Remove(DeserializeKey(payload)); + + EntitySnapshotBucket? IInternalEntityApplier.CaptureBucket(PersistenceStateStore stateStore) + { + var entities = stateStore.GetBucket(TypeId).Values.ToArray(); + + if (entities.Length == 0) + { + return null; + } + + return new EntitySnapshotBucket + { + TypeId = TypeId, + TypeName = TypeName, + SchemaVersion = SchemaVersion, + Payload = SerializeBucket(entities) + }; + } + + void IInternalEntityApplier.LoadBucket(PersistenceStateStore stateStore, EntitySnapshotBucket bucket) + { + var typed = stateStore.GetBucket(TypeId); + typed.Clear(); + + foreach (var entity in DeserializeBucket(bucket.Payload)) + { + typed[GetKey(entity)] = entity; + } + } + + int IInternalEntityApplier.Count(PersistenceStateStore stateStore) + => stateStore.GetBucket(TypeId).Count; +} diff --git a/src/SquidStd.Persistence/Extensions/PersistenceRegistrationExtensions.cs b/src/SquidStd.Persistence/Extensions/PersistenceRegistrationExtensions.cs new file mode 100644 index 00000000..5dfb0d83 --- /dev/null +++ b/src/SquidStd.Persistence/Extensions/PersistenceRegistrationExtensions.cs @@ -0,0 +1,64 @@ +using DryIoc; +using SquidStd.Abstractions.Extensions.Container; +using SquidStd.Core.Interfaces.Serialization; +using SquidStd.Persistence.Abstractions.Interfaces.Persistence; +using SquidStd.Persistence.Data; +using SquidStd.Persistence.Data.Internal; + +namespace SquidStd.Persistence.Extensions; + +/// Registers persisted entity types for descriptor construction at bootstrap. +public static class PersistenceRegistrationExtensions +{ + /// Records a persisted entity type; its descriptor is built and registered at bootstrap. + public static IContainer RegisterPersistedEntity( + this IContainer container, + ushort typeId, + string typeName, + int schemaVersion, + Func keySelector + ) + where TKey : notnull + { + ArgumentException.ThrowIfNullOrWhiteSpace(typeName); + ArgumentNullException.ThrowIfNull(keySelector); + + container.AddToRegisterTypedList( + new PersistedEntityRegistration( + typeId, + typeName, + (registry, resolver) => registry.Register( + new PersistenceEntityDescriptor( + resolver.Resolve(), + resolver.Resolve(), + typeId, + typeName, + schemaVersion, + keySelector + ) + ) + ) + ); + + return container; + } + + /// Builds and registers all recorded persisted-entity descriptors into the registry. + public static IContainer ApplyPersistedEntityRegistrations(this IContainer container) + { + if (!container.IsRegistered>()) + { + return container; + } + + var registry = container.Resolve(); + var registrations = container.Resolve>(); + + for (var i = 0; i < registrations.Count; i++) + { + registrations[i].Register(registry, container); + } + + return container; + } +} diff --git a/src/SquidStd.Persistence/Internal/IInternalEntityApplier.cs b/src/SquidStd.Persistence/Internal/IInternalEntityApplier.cs new file mode 100644 index 00000000..36b9be0d --- /dev/null +++ b/src/SquidStd.Persistence/Internal/IInternalEntityApplier.cs @@ -0,0 +1,13 @@ +using SquidStd.Persistence.Abstractions.Data; + +namespace SquidStd.Persistence.Internal; + +/// Internal hooks letting a descriptor apply journal ops and snapshot buckets against the state store. +internal interface IInternalEntityApplier +{ + void ApplyUpsert(PersistenceStateStore stateStore, byte[] payload); + void ApplyRemove(PersistenceStateStore stateStore, byte[] payload); + EntitySnapshotBucket? CaptureBucket(PersistenceStateStore stateStore); + void LoadBucket(PersistenceStateStore stateStore, EntitySnapshotBucket bucket); + int Count(PersistenceStateStore stateStore); +} diff --git a/src/SquidStd.Persistence/Internal/JournalRecordCodec.cs b/src/SquidStd.Persistence/Internal/JournalRecordCodec.cs new file mode 100644 index 00000000..ca7a5b9c --- /dev/null +++ b/src/SquidStd.Persistence/Internal/JournalRecordCodec.cs @@ -0,0 +1,48 @@ +using System.Buffers.Binary; +using SquidStd.Persistence.Abstractions.Data; +using SquidStd.Persistence.Abstractions.Types; + +namespace SquidStd.Persistence.Internal; + +/// +/// Fixed-layout binary encoder/decoder for a : no MessagePack, no reflection. +/// Layout: long Seq | long Ts | ushort TypeId | byte Op | int PayloadLen | payload bytes. +/// +internal static class JournalRecordCodec +{ + private const int FixedHeader = 8 + 8 + 2 + 1 + 4; + + public static byte[] Encode(JournalEntry entry) + { + var buffer = new byte[FixedHeader + entry.Payload.Length]; + var span = buffer.AsSpan(); + + BinaryPrimitives.WriteInt64LittleEndian(span, entry.SequenceId); + BinaryPrimitives.WriteInt64LittleEndian(span[8..], entry.TimestampUnixMilliseconds); + BinaryPrimitives.WriteUInt16LittleEndian(span[16..], entry.TypeId); + span[18] = (byte)entry.Operation; + BinaryPrimitives.WriteInt32LittleEndian(span[19..], entry.Payload.Length); + entry.Payload.CopyTo(span[FixedHeader..]); + + return buffer; + } + + public static JournalEntry Decode(ReadOnlySpan bytes) + { + var sequenceId = BinaryPrimitives.ReadInt64LittleEndian(bytes); + var timestamp = BinaryPrimitives.ReadInt64LittleEndian(bytes[8..]); + var typeId = BinaryPrimitives.ReadUInt16LittleEndian(bytes[16..]); + var operation = (JournalEntityOperationType)bytes[18]; + var payloadLength = BinaryPrimitives.ReadInt32LittleEndian(bytes[19..]); + var payload = bytes.Slice(FixedHeader, payloadLength).ToArray(); + + return new JournalEntry + { + SequenceId = sequenceId, + TimestampUnixMilliseconds = timestamp, + TypeId = typeId, + Operation = operation, + Payload = payload + }; + } +} diff --git a/src/SquidStd.Persistence/Internal/PersistenceStateStore.cs b/src/SquidStd.Persistence/Internal/PersistenceStateStore.cs new file mode 100644 index 00000000..401468bb --- /dev/null +++ b/src/SquidStd.Persistence/Internal/PersistenceStateStore.cs @@ -0,0 +1,34 @@ +namespace SquidStd.Persistence.Internal; + +/// +/// In-memory mutable state shared by entity stores. Dictionary access is guarded by ; +/// writers serialize end-to-end (apply + journal append) on so journal order +/// matches sequence order. +/// +internal sealed class PersistenceStateStore +{ + private readonly Dictionary _entityBuckets = []; + + public object SyncRoot { get; } = new(); + + public SemaphoreSlim WriteLock { get; } = new(1, 1); + + public long LastSequenceId { get; set; } + + public Dictionary GetBucket(ushort typeId) + where TKey : notnull + { + if (_entityBuckets.TryGetValue(typeId, out var existing)) + { + return (Dictionary)existing; + } + + var created = new Dictionary(); + _entityBuckets[typeId] = created; + + return created; + } + + public void ClearBuckets() + => _entityBuckets.Clear(); +} diff --git a/src/SquidStd.Persistence/Internal/SnapshotEnvelopeCodec.cs b/src/SquidStd.Persistence/Internal/SnapshotEnvelopeCodec.cs new file mode 100644 index 00000000..ccd42719 --- /dev/null +++ b/src/SquidStd.Persistence/Internal/SnapshotEnvelopeCodec.cs @@ -0,0 +1,78 @@ +using System.Buffers.Binary; +using System.Text; +using SquidStd.Persistence.Abstractions.Data; + +namespace SquidStd.Persistence.Internal; + +/// +/// Fixed-layout binary encoder/decoder for a . +/// Layout: int Version | long LastSeq | uint Checksum | ushort TypeId | int NameLen | name UTF8 | +/// int SchemaVersion | int PayloadLen | payload bytes. +/// +internal static class SnapshotEnvelopeCodec +{ + public static byte[] Encode(SnapshotFileEnvelope envelope) + { + var nameBytes = Encoding.UTF8.GetBytes(envelope.Bucket.TypeName); + var payload = envelope.Bucket.Payload; + var buffer = new byte[4 + 8 + 4 + 2 + 4 + nameBytes.Length + 4 + 4 + payload.Length]; + var span = buffer.AsSpan(); + var offset = 0; + + BinaryPrimitives.WriteInt32LittleEndian(span[offset..], envelope.Version); + offset += 4; + BinaryPrimitives.WriteInt64LittleEndian(span[offset..], envelope.LastSequenceId); + offset += 8; + BinaryPrimitives.WriteUInt32LittleEndian(span[offset..], envelope.Checksum); + offset += 4; + BinaryPrimitives.WriteUInt16LittleEndian(span[offset..], envelope.Bucket.TypeId); + offset += 2; + BinaryPrimitives.WriteInt32LittleEndian(span[offset..], nameBytes.Length); + offset += 4; + nameBytes.CopyTo(span[offset..]); + offset += nameBytes.Length; + BinaryPrimitives.WriteInt32LittleEndian(span[offset..], envelope.Bucket.SchemaVersion); + offset += 4; + BinaryPrimitives.WriteInt32LittleEndian(span[offset..], payload.Length); + offset += 4; + payload.CopyTo(span[offset..]); + + return buffer; + } + + public static SnapshotFileEnvelope Decode(ReadOnlySpan bytes) + { + var offset = 0; + var version = BinaryPrimitives.ReadInt32LittleEndian(bytes[offset..]); + offset += 4; + var lastSequenceId = BinaryPrimitives.ReadInt64LittleEndian(bytes[offset..]); + offset += 8; + var checksum = BinaryPrimitives.ReadUInt32LittleEndian(bytes[offset..]); + offset += 4; + var typeId = BinaryPrimitives.ReadUInt16LittleEndian(bytes[offset..]); + offset += 2; + var nameLength = BinaryPrimitives.ReadInt32LittleEndian(bytes[offset..]); + offset += 4; + var typeName = Encoding.UTF8.GetString(bytes.Slice(offset, nameLength)); + offset += nameLength; + var schemaVersion = BinaryPrimitives.ReadInt32LittleEndian(bytes[offset..]); + offset += 4; + var payloadLength = BinaryPrimitives.ReadInt32LittleEndian(bytes[offset..]); + offset += 4; + var payload = bytes.Slice(offset, payloadLength).ToArray(); + + return new SnapshotFileEnvelope + { + Version = version, + LastSequenceId = lastSequenceId, + Checksum = checksum, + Bucket = new EntitySnapshotBucket + { + TypeId = typeId, + TypeName = typeName, + SchemaVersion = schemaVersion, + Payload = payload + } + }; + } +} diff --git a/src/SquidStd.Persistence/README.md b/src/SquidStd.Persistence/README.md new file mode 100644 index 00000000..d6afcd5b --- /dev/null +++ b/src/SquidStd.Persistence/README.md @@ -0,0 +1,97 @@ +

+ SquidStd +

+ +

SquidStd.Persistence

+ +

+ NuGet + Downloads + license +

+ +Embeddable in-memory entity store with durable **binary snapshot + journal (write-ahead log)** persistence. +Full state lives in memory (synchronous reads), every mutation is appended to a length+checksum-framed +binary journal, and a periodic snapshot captures all state and trims the journal. On startup the engine +loads the snapshot and replays the journal tail. The engine is **serializer-agnostic** (via SquidStd's +`IDataSerializer`/`IDataDeserializer`) and has no MessagePack or domain dependency. + +## Install + +```bash +dotnet add package SquidStd.Persistence +dotnet add package SquidStd.Persistence.MessagePack # recommended binary serializer +``` + +## Features + +- **Snapshot + journal**: in-memory state, WAL journal of every upsert/remove, periodic full snapshot + trim. +- **Crash-safe**: journal records are length+FNV-1a-checksum framed — a torn/corrupt trailing record is + detected on read and the tail is discarded. Snapshots are written atomically (temp + rename). +- **Serializer-agnostic**: per-entity payloads go through `IDataSerializer`/`IDataDeserializer`; the journal + and snapshot envelopes use a fixed binary layout. Pair with `SquidStd.Persistence.MessagePack` for a + compact binary default, or use the JSON serializer from `SquidStd.Core`. +- **Detached reads**: `GetByIdAsync`/`GetAllAsync`/`Query()` return deep clones, so callers never mutate + stored instances. +- **Write-ordered journaling**: writes serialize end-to-end (apply + append) so journal order always + matches sequence order — replay is deterministic. +- **Lifecycle service**: `PersistenceService` is an `ISquidStdService` that loads + replays at start, + autosaves on a timer, and snapshots on stop. Optional `IEventBus` integration raises + `SnapshotSaveStartedEvent`/`SnapshotSaveCompletedEvent`. + +## Usage + +```csharp +using SquidStd.Persistence.Abstractions.Data; +using SquidStd.Persistence.Data; +using SquidStd.Persistence.MessagePack; +using SquidStd.Persistence.Services; + +public sealed class Player +{ + public int Id { get; set; } + public string Name { get; set; } = string.Empty; +} + +var serializer = new MessagePackDataSerializer(); + +var registry = new PersistenceEntityRegistry(); +registry.Register(new PersistenceEntityDescriptor( + serializer, serializer, typeId: 1, typeName: "Player", schemaVersion: 1, keySelector: p => p.Id)); + +var config = new PersistenceConfig { SaveDirectory = "./save" }; +var journal = new BinaryJournalService(Path.Combine(config.SaveDirectory, config.JournalFileName)); +var snapshot = new SnapshotService(config.SaveDirectory, config.SnapshotFileSuffix); + +var persistence = new PersistenceService(registry, journal, snapshot, config); +await persistence.InitializeAsync(); // load snapshot + replay journal + +var players = persistence.GetStore(); +await players.UpsertAsync(new Player { Id = 1, Name = "Bob" }); // appended to the journal +await persistence.SaveSnapshotAsync(); // snapshot + trim journal + +var bob = await players.GetByIdAsync(1); // detached clone +``` + +### DI registration + +```csharp +container.RegisterPersistedEntity(typeId: 1, typeName: "Player", schemaVersion: 1, p => p.Id); +container.ApplyPersistedEntityRegistrations(); // builds descriptors into IPersistenceEntityRegistry +``` + +## Key types + +| Type | Purpose | +|---------------------------------------|---------------------------------------------------------------| +| `PersistenceService` | Lifecycle: load + replay, autosave, `GetStore()`. | +| `IEntityStore` | In-memory CRUD; reads clone, writes journal. | +| `PersistenceEntityDescriptor` | Serializer-injected descriptor (serialize/clone/key). | +| `PersistenceEntityRegistry` | Maps `typeId` ↔ descriptor; freezes after registration. | +| `BinaryJournalService` | Append-only framed binary WAL with tail-corruption recovery. | +| `SnapshotService` | Atomic per-type binary snapshot files with payload checksum. | +| `RegisterPersistedEntity()` | DI helper recording an entity for descriptor construction. | + +## License + +MIT — part of [SquidStd](https://github.com/tgiachi/squid-std). diff --git a/src/SquidStd.Persistence/Services/BinaryJournalService.cs b/src/SquidStd.Persistence/Services/BinaryJournalService.cs new file mode 100644 index 00000000..529408cb --- /dev/null +++ b/src/SquidStd.Persistence/Services/BinaryJournalService.cs @@ -0,0 +1,204 @@ +using System.Buffers.Binary; +using Serilog; +using SquidStd.Core.Utils; +using SquidStd.Persistence.Abstractions.Data; +using SquidStd.Persistence.Abstractions.Interfaces.Persistence; +using SquidStd.Persistence.Internal; +using ILogger = Serilog.ILogger; + +namespace SquidStd.Persistence.Services; + +/// +/// Append-only journal stored as length+checksum-framed fixed-binary records. A corrupt trailing +/// record (truncated write or checksum mismatch) is detected on read and the tail is discarded. +/// +public sealed class BinaryJournalService : IJournalService, IAsyncDisposable +{ + private const int FrameHeaderSize = 8; // int length + uint checksum + + private readonly SemaphoreSlim _ioLock = new(1, 1); + private readonly ILogger _logger = Log.ForContext(); + private readonly string _path; + + public BinaryJournalService(string journalFilePath, bool enableFileLock = true) + { + ArgumentException.ThrowIfNullOrWhiteSpace(journalFilePath); + + _path = Path.GetFullPath(journalFilePath); + _ = enableFileLock; + + var directory = Path.GetDirectoryName(_path); + + if (!string.IsNullOrWhiteSpace(directory)) + { + Directory.CreateDirectory(directory); + } + } + + public async ValueTask AppendAsync(JournalEntry entry, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(entry); + + await _ioLock.WaitAsync(cancellationToken); + + try + { + await using var stream = new FileStream(_path, FileMode.Append, FileAccess.Write, FileShare.Read); + await WriteRecordAsync(stream, entry, cancellationToken); + } + finally + { + _ioLock.Release(); + } + } + + public async ValueTask AppendBatchAsync( + IReadOnlyList entries, CancellationToken cancellationToken = default + ) + { + ArgumentNullException.ThrowIfNull(entries); + + await _ioLock.WaitAsync(cancellationToken); + + try + { + await using var stream = new FileStream(_path, FileMode.Append, FileAccess.Write, FileShare.Read); + + for (var i = 0; i < entries.Count; i++) + { + await WriteRecordAsync(stream, entries[i], cancellationToken); + } + } + finally + { + _ioLock.Release(); + } + } + + public async ValueTask> ReadAllAsync(CancellationToken cancellationToken = default) + { + await _ioLock.WaitAsync(cancellationToken); + + try + { + if (!File.Exists(_path)) + { + return []; + } + + return ParseAll(await File.ReadAllBytesAsync(_path, cancellationToken)); + } + finally + { + _ioLock.Release(); + } + } + + public async ValueTask ResetAsync(CancellationToken cancellationToken = default) + { + await _ioLock.WaitAsync(cancellationToken); + + try + { + await RewriteAsync([], cancellationToken); + } + finally + { + _ioLock.Release(); + } + } + + public async ValueTask TrimThroughSequenceAsync(long inclusiveSequenceId, CancellationToken cancellationToken = default) + { + await _ioLock.WaitAsync(cancellationToken); + + try + { + if (!File.Exists(_path)) + { + return; + } + + var kept = ParseAll(await File.ReadAllBytesAsync(_path, cancellationToken)) + .Where(entry => entry.SequenceId > inclusiveSequenceId) + .ToArray(); + + await RewriteAsync(kept, cancellationToken); + } + finally + { + _ioLock.Release(); + } + } + + private List ParseAll(byte[] bytes) + { + var entries = new List(); + var offset = 0; + + while (offset + FrameHeaderSize <= bytes.Length) + { + var length = BinaryPrimitives.ReadInt32LittleEndian(bytes.AsSpan(offset)); + var checksum = BinaryPrimitives.ReadUInt32LittleEndian(bytes.AsSpan(offset + 4)); + + if (length <= 0 || offset + FrameHeaderSize + length > bytes.Length) + { + _logger.Warning("Journal {Path}: truncated record at offset {Offset}; discarding tail", _path, offset); + + break; + } + + var record = bytes.AsSpan(offset + FrameHeaderSize, length); + + if (ChecksumUtils.Compute(record) != checksum) + { + _logger.Warning("Journal {Path}: checksum mismatch at offset {Offset}; discarding tail", _path, offset); + + break; + } + + entries.Add(JournalRecordCodec.Decode(record)); + offset += FrameHeaderSize + length; + } + + return entries; + } + + private async ValueTask RewriteAsync(IReadOnlyList entries, CancellationToken cancellationToken) + { + var tempPath = _path + ".tmp"; + + await using (var stream = new FileStream(tempPath, FileMode.Create, FileAccess.Write, FileShare.None)) + { + for (var i = 0; i < entries.Count; i++) + { + await WriteRecordAsync(stream, entries[i], cancellationToken); + } + + await stream.FlushAsync(cancellationToken); + } + + File.Move(tempPath, _path, true); + } + + private static async ValueTask WriteRecordAsync( + FileStream stream, JournalEntry entry, CancellationToken cancellationToken + ) + { + var record = JournalRecordCodec.Encode(entry); + var header = new byte[FrameHeaderSize]; + BinaryPrimitives.WriteInt32LittleEndian(header, record.Length); + BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(4), ChecksumUtils.Compute(record)); + + await stream.WriteAsync(header, cancellationToken); + await stream.WriteAsync(record, cancellationToken); + await stream.FlushAsync(cancellationToken); + } + + public ValueTask DisposeAsync() + { + _ioLock.Dispose(); + + return ValueTask.CompletedTask; + } +} diff --git a/src/SquidStd.Persistence/Services/EntityStore.cs b/src/SquidStd.Persistence/Services/EntityStore.cs new file mode 100644 index 00000000..3dd5311e --- /dev/null +++ b/src/SquidStd.Persistence/Services/EntityStore.cs @@ -0,0 +1,133 @@ +using SquidStd.Persistence.Abstractions.Data; +using SquidStd.Persistence.Abstractions.Interfaces.Persistence; +using SquidStd.Persistence.Abstractions.Types; +using SquidStd.Persistence.Internal; + +namespace SquidStd.Persistence.Services; + +/// +/// In-memory backed by the shared state store. Reads return +/// detached clones; writes serialize end-to-end on the state store's write lock (apply + journal append), +/// so journal order matches sequence order. +/// +public sealed class EntityStore : IEntityStore + where TKey : notnull +{ + private readonly IPersistenceEntityDescriptor _descriptor; + private readonly IJournalService _journalService; + private readonly PersistenceStateStore _stateStore; + + internal EntityStore( + PersistenceStateStore stateStore, + IJournalService journalService, + IPersistenceEntityDescriptor descriptor + ) + { + _stateStore = stateStore; + _journalService = journalService; + _descriptor = descriptor; + } + + public ValueTask CountAsync(CancellationToken cancellationToken = default) + { + lock (_stateStore.SyncRoot) + { + return ValueTask.FromResult(Bucket().Count); + } + } + + public ValueTask> GetAllAsync(CancellationToken cancellationToken = default) + { + lock (_stateStore.SyncRoot) + { + IReadOnlyCollection clones = [.. Bucket().Values.Select(_descriptor.Clone)]; + + return ValueTask.FromResult(clones); + } + } + + public ValueTask GetByIdAsync(TKey id, CancellationToken cancellationToken = default) + { + lock (_stateStore.SyncRoot) + { + return ValueTask.FromResult(Bucket().TryGetValue(id, out var entity) ? _descriptor.Clone(entity) : default); + } + } + + public IQueryable Query() + { + lock (_stateStore.SyncRoot) + { + return Bucket().Values.Select(_descriptor.Clone).ToArray().AsQueryable(); + } + } + + public async ValueTask UpsertAsync(TEntity entity, CancellationToken cancellationToken = default) + { + await _stateStore.WriteLock.WaitAsync(cancellationToken); + + try + { + JournalEntry entry; + + lock (_stateStore.SyncRoot) + { + var clone = _descriptor.Clone(entity); + var key = _descriptor.GetKey(clone); + Bucket()[key] = clone; + entry = new JournalEntry + { + SequenceId = ++_stateStore.LastSequenceId, + TimestampUnixMilliseconds = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + TypeId = _descriptor.TypeId, + Operation = JournalEntityOperationType.Upsert, + Payload = _descriptor.SerializeEntity(clone) + }; + } + + await _journalService.AppendAsync(entry, cancellationToken); + } + finally + { + _stateStore.WriteLock.Release(); + } + } + + public async ValueTask RemoveAsync(TKey id, CancellationToken cancellationToken = default) + { + await _stateStore.WriteLock.WaitAsync(cancellationToken); + + try + { + JournalEntry entry; + + lock (_stateStore.SyncRoot) + { + if (!Bucket().Remove(id)) + { + return false; + } + + entry = new JournalEntry + { + SequenceId = ++_stateStore.LastSequenceId, + TimestampUnixMilliseconds = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + TypeId = _descriptor.TypeId, + Operation = JournalEntityOperationType.Remove, + Payload = _descriptor.SerializeKey(id) + }; + } + + await _journalService.AppendAsync(entry, cancellationToken); + + return true; + } + finally + { + _stateStore.WriteLock.Release(); + } + } + + private Dictionary Bucket() + => _stateStore.GetBucket(_descriptor.TypeId); +} diff --git a/src/SquidStd.Persistence/Services/PersistenceEntityRegistry.cs b/src/SquidStd.Persistence/Services/PersistenceEntityRegistry.cs new file mode 100644 index 00000000..de66b945 --- /dev/null +++ b/src/SquidStd.Persistence/Services/PersistenceEntityRegistry.cs @@ -0,0 +1,106 @@ +using SquidStd.Persistence.Abstractions.Interfaces.Persistence; + +namespace SquidStd.Persistence.Services; + +/// Default registry of persisted entity descriptors keyed by type id and entity/key pair. +public sealed class PersistenceEntityRegistry : IPersistenceEntityRegistry +{ + private readonly Dictionary _byTypeId = []; + private readonly Dictionary<(Type Entity, Type Key), IPersistenceEntityDescriptor> _byTypePair = []; + private readonly Lock _sync = new(); + private bool _frozen; + + public bool IsFrozen + { + get + { + lock (_sync) + { + return _frozen; + } + } + } + + public void Freeze() + { + lock (_sync) + { + _frozen = true; + } + } + + public void Register(IPersistenceEntityDescriptor descriptor) + where TKey : notnull + { + ArgumentNullException.ThrowIfNull(descriptor); + + lock (_sync) + { + if (_frozen) + { + throw new InvalidOperationException("Cannot register entities after the registry is frozen."); + } + + if (!_byTypeId.TryAdd(descriptor.TypeId, descriptor)) + { + throw new InvalidOperationException($"Type id {descriptor.TypeId} is already registered."); + } + + _byTypePair[(typeof(TEntity), typeof(TKey))] = descriptor; + } + } + + public IPersistenceEntityDescriptor GetDescriptor(ushort typeId) + { + lock (_sync) + { + if (_byTypeId.TryGetValue(typeId, out var descriptor)) + { + return descriptor; + } + } + + throw new InvalidOperationException($"No persisted entity registered for type id {typeId}."); + } + + public IPersistenceEntityDescriptor GetDescriptor() + where TKey : notnull + { + lock (_sync) + { + if (_byTypePair.TryGetValue((typeof(TEntity), typeof(TKey)), out var descriptor)) + { + return (IPersistenceEntityDescriptor)descriptor; + } + } + + throw new InvalidOperationException( + $"No persisted entity registered for {typeof(TEntity).Name}/{typeof(TKey).Name}." + ); + } + + public IReadOnlyCollection GetRegisteredDescriptors() + { + lock (_sync) + { + return [.. _byTypeId.Values]; + } + } + + public bool IsRegistered(ushort typeId) + { + lock (_sync) + { + return _byTypeId.ContainsKey(typeId); + } + } + + public bool IsRegistered() + where TKey : notnull + { + lock (_sync) + { + return _byTypePair.ContainsKey((typeof(TEntity), typeof(TKey))); + } + } +} diff --git a/src/SquidStd.Persistence/Services/PersistenceService.cs b/src/SquidStd.Persistence/Services/PersistenceService.cs new file mode 100644 index 00000000..beeab293 --- /dev/null +++ b/src/SquidStd.Persistence/Services/PersistenceService.cs @@ -0,0 +1,222 @@ +using Serilog; +using SquidStd.Core.Interfaces.Events; +using SquidStd.Persistence.Abstractions.Data; +using SquidStd.Persistence.Abstractions.Data.Events; +using SquidStd.Persistence.Abstractions.Interfaces.Persistence; +using SquidStd.Persistence.Abstractions.Types; +using SquidStd.Persistence.Internal; +using ILogger = Serilog.ILogger; + +namespace SquidStd.Persistence.Services; + +/// +/// Owns persistence lifecycle: loads snapshots and replays the journal tail at startup, autosaves on a +/// timer, and hands out per-type instances. +/// +public sealed class PersistenceService : IPersistenceService, IAsyncDisposable +{ + private readonly PersistenceConfig _config; + private readonly IEventBus? _eventBus; + private readonly IJournalService _journalService; + private readonly ILogger _logger = Log.ForContext(); + private readonly IPersistenceEntityRegistry _registry; + private readonly ISnapshotService _snapshotService; + private readonly PersistenceStateStore _stateStore = new(); + private CancellationTokenSource? _autosaveCts; + private Task? _autosaveLoop; + + public PersistenceService( + IPersistenceEntityRegistry registry, + IJournalService journalService, + ISnapshotService snapshotService, + PersistenceConfig config, + IEventBus? eventBus = null + ) + { + _registry = registry; + _journalService = journalService; + _snapshotService = snapshotService; + _config = config; + _eventBus = eventBus; + } + + public IEntityStore GetStore() + where TKey : notnull + => new EntityStore(_stateStore, _journalService, _registry.GetDescriptor()); + + public async ValueTask InitializeAsync(CancellationToken cancellationToken = default) + { + _registry.Freeze(); + + var maxSequenceId = 0L; + + foreach (var descriptor in _registry.GetRegisteredDescriptors()) + { + var loaded = await _snapshotService.LoadBucketAsync(descriptor.TypeName, cancellationToken); + + if (loaded is null) + { + continue; + } + + ((IInternalEntityApplier)descriptor).LoadBucket(_stateStore, loaded.Bucket); + maxSequenceId = Math.Max(maxSequenceId, loaded.LastSequenceId); + } + + foreach (var entry in await _journalService.ReadAllAsync(cancellationToken)) + { + if (entry.SequenceId <= maxSequenceId) + { + continue; + } + + if (!_registry.IsRegistered(entry.TypeId)) + { + _logger.Warning( + "Journal replay: unregistered type id {TypeId}; skipping entry {SequenceId}", + entry.TypeId, + entry.SequenceId + ); + + continue; + } + + var applier = (IInternalEntityApplier)_registry.GetDescriptor(entry.TypeId); + + switch (entry.Operation) + { + case JournalEntityOperationType.Upsert: + applier.ApplyUpsert(_stateStore, entry.Payload); + + break; + case JournalEntityOperationType.Remove: + applier.ApplyRemove(_stateStore, entry.Payload); + + break; + } + + maxSequenceId = Math.Max(maxSequenceId, entry.SequenceId); + } + + _stateStore.LastSequenceId = maxSequenceId; + } + + public async ValueTask SaveSnapshotAsync(CancellationToken cancellationToken = default) + { + long capturedSequenceId; + List buckets = []; + List emptyTypeNames = []; + + await _stateStore.WriteLock.WaitAsync(cancellationToken); + + try + { + capturedSequenceId = _stateStore.LastSequenceId; + + foreach (var descriptor in _registry.GetRegisteredDescriptors()) + { + lock (_stateStore.SyncRoot) + { + var bucket = ((IInternalEntityApplier)descriptor).CaptureBucket(_stateStore); + + if (bucket is null) + { + emptyTypeNames.Add(descriptor.TypeName); + } + else + { + buckets.Add(bucket); + } + } + } + } + finally + { + _stateStore.WriteLock.Release(); + } + + if (_eventBus is not null) + { + await _eventBus.PublishAsync(new SnapshotSaveStartedEvent(capturedSequenceId), cancellationToken); + } + + foreach (var bucket in buckets) + { + await _snapshotService.SaveBucketAsync(bucket, capturedSequenceId, cancellationToken); + } + + foreach (var typeName in emptyTypeNames) + { + await _snapshotService.DeleteBucketAsync(typeName, cancellationToken); + } + + await _journalService.TrimThroughSequenceAsync(capturedSequenceId, cancellationToken); + + if (_eventBus is not null) + { + await _eventBus.PublishAsync( + new SnapshotSaveCompletedEvent(capturedSequenceId, buckets.Count), + cancellationToken + ); + } + } + + public async ValueTask StartAsync(CancellationToken cancellationToken = default) + { + await InitializeAsync(cancellationToken); + + _autosaveCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + _autosaveLoop = Task.Run(() => AutosaveLoopAsync(_autosaveCts.Token), CancellationToken.None); + } + + public async ValueTask StopAsync(CancellationToken cancellationToken = default) + { + if (_autosaveCts is not null) + { + await _autosaveCts.CancelAsync(); + } + + if (_autosaveLoop is not null) + { + try + { + await _autosaveLoop; + } + catch (OperationCanceledException) + { + // Expected on shutdown. + } + } + + await SaveSnapshotAsync(cancellationToken); + + _autosaveCts?.Dispose(); + _autosaveCts = null; + _autosaveLoop = null; + } + + private async Task AutosaveLoopAsync(CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + try + { + await Task.Delay(_config.AutosaveInterval, cancellationToken); + await SaveSnapshotAsync(cancellationToken); + } + catch (OperationCanceledException) + { + break; + } + catch (Exception ex) + { + _logger.Error(ex, "Autosave failed; journal retained for the next attempt"); + } + } + } + + public async ValueTask DisposeAsync() + { + await StopAsync(CancellationToken.None); + } +} diff --git a/src/SquidStd.Persistence/Services/SnapshotService.cs b/src/SquidStd.Persistence/Services/SnapshotService.cs new file mode 100644 index 00000000..16e026f9 --- /dev/null +++ b/src/SquidStd.Persistence/Services/SnapshotService.cs @@ -0,0 +1,147 @@ +using Serilog; +using SquidStd.Core.Utils; +using SquidStd.Persistence.Abstractions.Data; +using SquidStd.Persistence.Abstractions.Interfaces.Persistence; +using SquidStd.Persistence.Internal; +using ILogger = Serilog.ILogger; + +namespace SquidStd.Persistence.Services; + +/// +/// Stores each registered entity type as its own fixed-binary snapshot file +/// (<snake_case type name><suffix> under the save directory), written atomically +/// via temp + rename and verified by a payload checksum on load. +/// +public sealed class SnapshotService : ISnapshotService, IDisposable +{ + private static readonly char[] _invalidTypeNameChars = + [.. Path.GetInvalidFileNameChars(), Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar]; + + private readonly string _directory; + private readonly SemaphoreSlim _ioLock = new(1, 1); + private readonly ILogger _logger = Log.ForContext(); + private readonly string _suffix; + + public SnapshotService(string saveDirectory, string fileSuffix) + { + ArgumentException.ThrowIfNullOrWhiteSpace(saveDirectory); + ArgumentException.ThrowIfNullOrWhiteSpace(fileSuffix); + + _directory = Path.GetFullPath(saveDirectory); + _suffix = fileSuffix; + + Directory.CreateDirectory(_directory); + } + + public async ValueTask DeleteBucketAsync(string typeName, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(typeName); + + var path = PathFor(typeName); + + await _ioLock.WaitAsync(cancellationToken); + + try + { + File.Delete(path); + File.Delete(path + ".tmp"); + } + finally + { + _ioLock.Release(); + } + } + + public async ValueTask LoadBucketAsync(string typeName, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(typeName); + + var path = PathFor(typeName); + + await _ioLock.WaitAsync(cancellationToken); + + try + { + if (!File.Exists(path)) + { + return null; + } + + var bytes = await File.ReadAllBytesAsync(path, cancellationToken); + var envelope = SnapshotEnvelopeCodec.Decode(bytes); + + if (ChecksumUtils.Compute(envelope.Bucket.Payload) != envelope.Checksum || + !string.Equals(envelope.Bucket.TypeName, typeName, StringComparison.Ordinal)) + { + _logger.Error("Snapshot {Path}: checksum or type-name mismatch; treating as absent", path); + + return null; + } + + return new PersistedBucket(envelope.Bucket, envelope.LastSequenceId); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.Error(ex, "Snapshot {Path}: unreadable; treating as absent", path); + + return null; + } + finally + { + _ioLock.Release(); + } + } + + public async ValueTask SaveBucketAsync( + EntitySnapshotBucket bucket, long lastSequenceId, CancellationToken cancellationToken = default + ) + { + ArgumentNullException.ThrowIfNull(bucket); + + var envelope = new SnapshotFileEnvelope + { + Version = 1, + LastSequenceId = lastSequenceId, + Checksum = ChecksumUtils.Compute(bucket.Payload), + Bucket = bucket + }; + + var path = PathFor(bucket.TypeName); + var tempPath = path + ".tmp"; + var bytes = SnapshotEnvelopeCodec.Encode(envelope); + + await _ioLock.WaitAsync(cancellationToken); + + try + { + await using (var stream = new FileStream(tempPath, FileMode.Create, FileAccess.Write, FileShare.None)) + { + await stream.WriteAsync(bytes, cancellationToken); + await stream.FlushAsync(cancellationToken); + } + + File.Move(tempPath, path, true); + } + finally + { + _ioLock.Release(); + } + } + + private string PathFor(string typeName) + { + if (typeName.AsSpan().IndexOfAny(_invalidTypeNameChars) >= 0) + { + throw new InvalidOperationException($"Persisted type name '{typeName}' cannot be used as a snapshot file name."); + } + + return Path.Combine(_directory, StringUtils.ToSnakeCase(typeName) + _suffix); + } + + public void Dispose() + => _ioLock.Dispose(); +} diff --git a/src/SquidStd.Persistence/SquidStd.Persistence.csproj b/src/SquidStd.Persistence/SquidStd.Persistence.csproj new file mode 100644 index 00000000..50b10efc --- /dev/null +++ b/src/SquidStd.Persistence/SquidStd.Persistence.csproj @@ -0,0 +1,23 @@ + + + + net10.0 + enable + enable + true + + + + + + + + + + + + + + + + diff --git a/src/SquidStd.Plugin.Abstractions/Data/PluginContext.cs b/src/SquidStd.Plugin.Abstractions/Data/PluginContext.cs index 93fed50e..1d511a0a 100644 --- a/src/SquidStd.Plugin.Abstractions/Data/PluginContext.cs +++ b/src/SquidStd.Plugin.Abstractions/Data/PluginContext.cs @@ -5,5 +5,7 @@ public class PluginContext public Dictionary Data { get; } = new(); public TData GetData(string key) - => (TData)Data[key]; + { + return (TData)Data[key]; + } } diff --git a/src/SquidStd.Plugin.Abstractions/Data/PluginMetadata.cs b/src/SquidStd.Plugin.Abstractions/Data/PluginMetadata.cs index eeffa2f3..65672cfa 100644 --- a/src/SquidStd.Plugin.Abstractions/Data/PluginMetadata.cs +++ b/src/SquidStd.Plugin.Abstractions/Data/PluginMetadata.cs @@ -1,7 +1,7 @@ namespace SquidStd.Plugin.Abstractions.Data; /// -/// Describes a Moongate plugin. This is the source of truth for plugin identity. +/// Describes a Moongate plugin. This is the source of truth for plugin identity. /// public sealed class PluginMetadata { diff --git a/src/SquidStd.Plugin.Abstractions/Interfaces/Plugins/ISquidStdPlugin.cs b/src/SquidStd.Plugin.Abstractions/Interfaces/Plugins/ISquidStdPlugin.cs index d761f5a0..7b575827 100644 --- a/src/SquidStd.Plugin.Abstractions/Interfaces/Plugins/ISquidStdPlugin.cs +++ b/src/SquidStd.Plugin.Abstractions/Interfaces/Plugins/ISquidStdPlugin.cs @@ -4,7 +4,7 @@ namespace SquidStd.Plugin.Abstractions.Interfaces.Plugins; /// -/// Implemented by trusted .NET plugins loaded by Moongate during server startup. +/// Implemented by trusted .NET plugins loaded by Moongate during server startup. /// public interface ISquidStdPlugin { @@ -12,8 +12,8 @@ public interface ISquidStdPlugin PluginMetadata Metadata { get; } /// - /// Registers the plugin's services, handlers, config sections, Lua modules, and other integrations. - /// Called during container configuration before global server YAML config is loaded. + /// Registers the plugin's services, handlers, config sections, Lua modules, and other integrations. + /// Called during container configuration before global server YAML config is loaded. /// /// The DryIoc container being configured. /// The plugin-specific boot context. diff --git a/src/SquidStd.Scripting.Lua/Attributes/RegisterScriptModuleAttribute.cs b/src/SquidStd.Scripting.Lua/Attributes/RegisterScriptModuleAttribute.cs new file mode 100644 index 00000000..c714e3b2 --- /dev/null +++ b/src/SquidStd.Scripting.Lua/Attributes/RegisterScriptModuleAttribute.cs @@ -0,0 +1,9 @@ +namespace SquidStd.Scripting.Lua.Attributes; + +/// +/// Marks a Lua script module for generated registration. +/// +[AttributeUsage(AttributeTargets.Class, Inherited = false)] +public sealed class RegisterScriptModuleAttribute : Attribute +{ +} diff --git a/src/SquidStd.Scripting.Lua/Attributes/Scripts/ScriptFunctionAttribute.cs b/src/SquidStd.Scripting.Lua/Attributes/Scripts/ScriptFunctionAttribute.cs index 5f20e670..2cc84d78 100644 --- a/src/SquidStd.Scripting.Lua/Attributes/Scripts/ScriptFunctionAttribute.cs +++ b/src/SquidStd.Scripting.Lua/Attributes/Scripts/ScriptFunctionAttribute.cs @@ -1,23 +1,13 @@ namespace SquidStd.Scripting.Lua.Attributes.Scripts; /// -/// Attribute that marks a method as a script function exposed to scripting languages. +/// Attribute that marks a method as a script function exposed to scripting languages. /// [AttributeUsage(AttributeTargets.Method)] public class ScriptFunctionAttribute : Attribute { /// - /// Gets the optional name override for the script function. - /// - public string? FunctionName { get; } - - /// - /// Gets the optional help text describing the function's purpose. - /// - public string? HelpText { get; } - - /// - /// Initializes a new instance of the ScriptFunctionAttribute class. + /// Initializes a new instance of the ScriptFunctionAttribute class. /// /// The optional name override for the script function. /// The optional help text describing the function's purpose. @@ -26,4 +16,14 @@ public ScriptFunctionAttribute(string? functionName = null, string? helpText = n FunctionName = functionName; HelpText = helpText; } + + /// + /// Gets the optional name override for the script function. + /// + public string? FunctionName { get; } + + /// + /// Gets the optional help text describing the function's purpose. + /// + public string? HelpText { get; } } diff --git a/src/SquidStd.Scripting.Lua/Attributes/Scripts/ScriptModuleAttribute.cs b/src/SquidStd.Scripting.Lua/Attributes/Scripts/ScriptModuleAttribute.cs index 0e54b821..36a310d0 100644 --- a/src/SquidStd.Scripting.Lua/Attributes/Scripts/ScriptModuleAttribute.cs +++ b/src/SquidStd.Scripting.Lua/Attributes/Scripts/ScriptModuleAttribute.cs @@ -1,19 +1,13 @@ namespace SquidStd.Scripting.Lua.Attributes.Scripts; /// -/// Attribute that marks a class as a script module exposed to scripting languages. +/// Attribute that marks a class as a script module exposed to scripting languages. /// [AttributeUsage(AttributeTargets.Class)] public class ScriptModuleAttribute : Attribute { - /// Gets the name under which the module will be accessible in Lua. - public string Name { get; } - - /// Gets the optional help text describing the module's purpose. - public string? HelpText { get; } - /// - /// Initializes a new instance of the ScriptModuleAttribute class. + /// Initializes a new instance of the ScriptModuleAttribute class. /// /// The name under which the module will be accessible in Lua. /// The optional help text describing the module's purpose. @@ -24,4 +18,10 @@ public ScriptModuleAttribute(string name, string? helpText = null) Name = name; HelpText = helpText; } + + /// Gets the name under which the module will be accessible in Lua. + public string Name { get; } + + /// Gets the optional help text describing the module's purpose. + public string? HelpText { get; } } diff --git a/src/SquidStd.Scripting.Lua/Context/SquidStdScriptJsonContext.cs b/src/SquidStd.Scripting.Lua/Context/SquidStdScriptJsonContext.cs index d55ec080..485076cb 100644 --- a/src/SquidStd.Scripting.Lua/Context/SquidStdScriptJsonContext.cs +++ b/src/SquidStd.Scripting.Lua/Context/SquidStdScriptJsonContext.cs @@ -3,11 +3,15 @@ namespace SquidStd.Scripting.Lua.Context; -[JsonSerializable(typeof(LuarcConfig)), JsonSerializable(typeof(LuarcRuntimeConfig)), - JsonSerializable(typeof(LuarcWorkspaceConfig)), JsonSerializable(typeof(LuarcDiagnosticsConfig)), - JsonSerializable(typeof(LuarcCompletionConfig)), JsonSerializable(typeof(LuarcFormatConfig))] - +[JsonSerializable(typeof(LuarcConfig))] +[JsonSerializable(typeof(LuarcRuntimeConfig))] +[JsonSerializable(typeof(LuarcWorkspaceConfig))] +[JsonSerializable(typeof(LuarcDiagnosticsConfig))] +[JsonSerializable(typeof(LuarcCompletionConfig))] +[JsonSerializable(typeof(LuarcFormatConfig))] /// /// JSON serialization context for Lua scripting configuration types. /// -public partial class SquidStdScriptJsonContext : JsonSerializerContext { } +public partial class SquidStdScriptJsonContext : JsonSerializerContext +{ +} diff --git a/src/SquidStd.Scripting.Lua/Data/Config/LuaEngineConfig.cs b/src/SquidStd.Scripting.Lua/Data/Config/LuaEngineConfig.cs index 5a3821b0..db401073 100644 --- a/src/SquidStd.Scripting.Lua/Data/Config/LuaEngineConfig.cs +++ b/src/SquidStd.Scripting.Lua/Data/Config/LuaEngineConfig.cs @@ -2,12 +2,6 @@ namespace SquidStd.Scripting.Lua.Data.Config; public sealed record LuaEngineConfig { - public string LuarcDirectory { get; } - public string ScriptsDirectory { get; } - public string EngineVersion { get; } - - public string EngineName { get; } - public LuaEngineConfig(string luarcDirectory, string scriptsDirectory, string engineName, string engineVersion) { EngineName = engineName; @@ -15,4 +9,10 @@ public LuaEngineConfig(string luarcDirectory, string scriptsDirectory, string en ScriptsDirectory = scriptsDirectory; EngineVersion = engineVersion; } + + public string LuarcDirectory { get; } + public string ScriptsDirectory { get; } + public string EngineVersion { get; } + + public string EngineName { get; } } diff --git a/src/SquidStd.Scripting.Lua/Data/Internal/ScriptModuleData.cs b/src/SquidStd.Scripting.Lua/Data/Internal/ScriptModuleData.cs index 531d682d..92dcf1d7 100644 --- a/src/SquidStd.Scripting.Lua/Data/Internal/ScriptModuleData.cs +++ b/src/SquidStd.Scripting.Lua/Data/Internal/ScriptModuleData.cs @@ -1,17 +1,12 @@ namespace SquidStd.Scripting.Lua.Data.Internal; /// -/// Record containing data about a script module for internal processing. +/// Record containing data about a script module for internal processing. /// public sealed record ScriptModuleData { /// - /// The .NET type of the script module. - /// - public Type ModuleType { get; } - - /// - /// Initializes a new instance of the ScriptModuleData record. + /// Initializes a new instance of the ScriptModuleData record. /// /// The .NET type of the script module. public ScriptModuleData(Type moduleType) @@ -20,4 +15,9 @@ public ScriptModuleData(Type moduleType) ModuleType = moduleType; } + + /// + /// The .NET type of the script module. + /// + public Type ModuleType { get; } } diff --git a/src/SquidStd.Scripting.Lua/Data/Internal/ScriptUserData.cs b/src/SquidStd.Scripting.Lua/Data/Internal/ScriptUserData.cs index 6f518bbb..a002f05f 100644 --- a/src/SquidStd.Scripting.Lua/Data/Internal/ScriptUserData.cs +++ b/src/SquidStd.Scripting.Lua/Data/Internal/ScriptUserData.cs @@ -1,12 +1,12 @@ namespace SquidStd.Scripting.Lua.Data.Internal; /// -/// Represents user data for scripts. +/// Represents user data for scripts. /// public class ScriptUserData { /// - /// Gets or sets the user type. + /// Gets or sets the user type. /// public Type UserType { get; set; } } diff --git a/src/SquidStd.Scripting.Lua/Data/Luarc/LuarcCompletionConfig.cs b/src/SquidStd.Scripting.Lua/Data/Luarc/LuarcCompletionConfig.cs index b7086634..112af62e 100644 --- a/src/SquidStd.Scripting.Lua/Data/Luarc/LuarcCompletionConfig.cs +++ b/src/SquidStd.Scripting.Lua/Data/Luarc/LuarcCompletionConfig.cs @@ -3,18 +3,18 @@ namespace SquidStd.Scripting.Lua.Data.Luarc; /// -/// Completion configuration for Lua Language Server +/// Completion configuration for Lua Language Server /// public class LuarcCompletionConfig { /// - /// Gets or sets whether completion is enabled. + /// Gets or sets whether completion is enabled. /// [JsonPropertyName("enable")] public bool Enable { get; set; } = true; /// - /// Gets or sets the call snippet setting. + /// Gets or sets the call snippet setting. /// [JsonPropertyName("callSnippet")] public string CallSnippet { get; set; } = "Replace"; diff --git a/src/SquidStd.Scripting.Lua/Data/Luarc/LuarcConfig.cs b/src/SquidStd.Scripting.Lua/Data/Luarc/LuarcConfig.cs index 8726bd11..bf5b2e58 100644 --- a/src/SquidStd.Scripting.Lua/Data/Luarc/LuarcConfig.cs +++ b/src/SquidStd.Scripting.Lua/Data/Luarc/LuarcConfig.cs @@ -3,7 +3,7 @@ namespace SquidStd.Scripting.Lua.Data.Luarc; /// -/// Configuration class for Lua Language Server (.luarc.json file) +/// Configuration class for Lua Language Server (.luarc.json file) /// public class LuarcConfig { @@ -15,31 +15,31 @@ public class LuarcConfig public string Schema { get; set; } = "https://raw.githubusercontent.com/sumneko/vscode-lua/master/setting/schema.json"; /// - /// Gets or sets the runtime configuration. + /// Gets or sets the runtime configuration. /// [JsonPropertyName("runtime")] public LuarcRuntimeConfig Runtime { get; set; } = new(); /// - /// Gets or sets the workspace configuration. + /// Gets or sets the workspace configuration. /// [JsonPropertyName("workspace")] public LuarcWorkspaceConfig Workspace { get; set; } = new(); /// - /// Gets or sets the diagnostics configuration. + /// Gets or sets the diagnostics configuration. /// [JsonPropertyName("diagnostics")] public LuarcDiagnosticsConfig Diagnostics { get; set; } = new(); /// - /// Gets or sets the completion configuration. + /// Gets or sets the completion configuration. /// [JsonPropertyName("completion")] public LuarcCompletionConfig Completion { get; set; } = new(); /// - /// Gets or sets the format configuration. + /// Gets or sets the format configuration. /// [JsonPropertyName("format")] public LuarcFormatConfig Format { get; set; } = new(); diff --git a/src/SquidStd.Scripting.Lua/Data/Luarc/LuarcDiagnosticsConfig.cs b/src/SquidStd.Scripting.Lua/Data/Luarc/LuarcDiagnosticsConfig.cs index bb22e469..777a2b3d 100644 --- a/src/SquidStd.Scripting.Lua/Data/Luarc/LuarcDiagnosticsConfig.cs +++ b/src/SquidStd.Scripting.Lua/Data/Luarc/LuarcDiagnosticsConfig.cs @@ -3,10 +3,9 @@ namespace SquidStd.Scripting.Lua.Data.Luarc; /// -/// Diagnostics configuration for Lua Language Server +/// Diagnostics configuration for Lua Language Server /// public class LuarcDiagnosticsConfig { - [JsonPropertyName("globals")] - public string[] Globals { get; set; } = []; + [JsonPropertyName("globals")] public string[] Globals { get; set; } = []; } diff --git a/src/SquidStd.Scripting.Lua/Data/Luarc/LuarcFormatConfig.cs b/src/SquidStd.Scripting.Lua/Data/Luarc/LuarcFormatConfig.cs index 0828099e..5b823013 100644 --- a/src/SquidStd.Scripting.Lua/Data/Luarc/LuarcFormatConfig.cs +++ b/src/SquidStd.Scripting.Lua/Data/Luarc/LuarcFormatConfig.cs @@ -3,13 +3,11 @@ namespace SquidStd.Scripting.Lua.Data.Luarc; /// -/// Format configuration for Lua Language Server +/// Format configuration for Lua Language Server /// public class LuarcFormatConfig { - [JsonPropertyName("enable")] - public bool Enable { get; set; } = true; + [JsonPropertyName("enable")] public bool Enable { get; set; } = true; - [JsonPropertyName("defaultConfig")] - public LuarcFormatDefaultConfig DefaultConfig { get; set; } = new(); + [JsonPropertyName("defaultConfig")] public LuarcFormatDefaultConfig DefaultConfig { get; set; } = new(); } diff --git a/src/SquidStd.Scripting.Lua/Data/Luarc/LuarcFormatDefaultConfig.cs b/src/SquidStd.Scripting.Lua/Data/Luarc/LuarcFormatDefaultConfig.cs index e34ca695..0456d94e 100644 --- a/src/SquidStd.Scripting.Lua/Data/Luarc/LuarcFormatDefaultConfig.cs +++ b/src/SquidStd.Scripting.Lua/Data/Luarc/LuarcFormatDefaultConfig.cs @@ -3,13 +3,11 @@ namespace SquidStd.Scripting.Lua.Data.Luarc; /// -/// Default format configuration for Lua Language Server +/// Default format configuration for Lua Language Server /// public class LuarcFormatDefaultConfig { - [JsonPropertyName("indent_style")] - public string IndentStyle { get; set; } = "space"; + [JsonPropertyName("indent_style")] public string IndentStyle { get; set; } = "space"; - [JsonPropertyName("indent_size")] - public string IndentSize { get; set; } = "4"; + [JsonPropertyName("indent_size")] public string IndentSize { get; set; } = "4"; } diff --git a/src/SquidStd.Scripting.Lua/Data/Luarc/LuarcRuntimeConfig.cs b/src/SquidStd.Scripting.Lua/Data/Luarc/LuarcRuntimeConfig.cs index b6ecbe4c..0ac03aa8 100644 --- a/src/SquidStd.Scripting.Lua/Data/Luarc/LuarcRuntimeConfig.cs +++ b/src/SquidStd.Scripting.Lua/Data/Luarc/LuarcRuntimeConfig.cs @@ -3,13 +3,11 @@ namespace SquidStd.Scripting.Lua.Data.Luarc; /// -/// Runtime configuration for Lua Language Server +/// Runtime configuration for Lua Language Server /// public class LuarcRuntimeConfig { - [JsonPropertyName("version")] - public string Version { get; set; } = "Lua 5.4"; + [JsonPropertyName("version")] public string Version { get; set; } = "Lua 5.4"; - [JsonPropertyName("path")] - public string[] Path { get; set; } = []; + [JsonPropertyName("path")] public string[] Path { get; set; } = []; } diff --git a/src/SquidStd.Scripting.Lua/Data/Luarc/LuarcWorkspaceConfig.cs b/src/SquidStd.Scripting.Lua/Data/Luarc/LuarcWorkspaceConfig.cs index 72ba1948..fa114261 100644 --- a/src/SquidStd.Scripting.Lua/Data/Luarc/LuarcWorkspaceConfig.cs +++ b/src/SquidStd.Scripting.Lua/Data/Luarc/LuarcWorkspaceConfig.cs @@ -3,13 +3,11 @@ namespace SquidStd.Scripting.Lua.Data.Luarc; /// -/// Workspace configuration for Lua Language Server +/// Workspace configuration for Lua Language Server /// public class LuarcWorkspaceConfig { - [JsonPropertyName("library")] - public string[] Library { get; set; } = []; + [JsonPropertyName("library")] public string[] Library { get; set; } = []; - [JsonPropertyName("checkThirdParty")] - public bool CheckThirdParty { get; set; } = false; + [JsonPropertyName("checkThirdParty")] public bool CheckThirdParty { get; set; } = false; } diff --git a/src/SquidStd.Scripting.Lua/Data/Scripts/ScriptErrorInfo.cs b/src/SquidStd.Scripting.Lua/Data/Scripts/ScriptErrorInfo.cs index 5b88190f..41d257af 100644 --- a/src/SquidStd.Scripting.Lua/Data/Scripts/ScriptErrorInfo.cs +++ b/src/SquidStd.Scripting.Lua/Data/Scripts/ScriptErrorInfo.cs @@ -1,62 +1,62 @@ namespace SquidStd.Scripting.Lua.Data.Scripts; /// -/// Detailed information about a Lua execution error. +/// Detailed information about a Lua execution error. /// public class ScriptErrorInfo { /// - /// Gets or sets the error message. + /// Gets or sets the error message. /// public string Message { get; set; } = ""; /// - /// Gets or sets the stack trace. + /// Gets or sets the stack trace. /// public string? StackTrace { get; set; } /// - /// Gets or sets the line number. + /// Gets or sets the line number. /// public int? LineNumber { get; set; } /// - /// Gets or sets the column number. + /// Gets or sets the column number. /// public int? ColumnNumber { get; set; } /// - /// Gets or sets the file name. + /// Gets or sets the file name. /// public string? FileName { get; set; } /// - /// Gets or sets the error type. + /// Gets or sets the error type. /// public string? ErrorType { get; set; } /// - /// Gets or sets the source code. + /// Gets or sets the source code. /// public string? SourceCode { get; set; } /// - /// Original source file name when a mapped source is available. + /// Original source file name when a mapped source is available. /// public string? OriginalFileName { get; set; } /// - /// Original line number when a mapped source is available. + /// Original line number when a mapped source is available. /// public int? OriginalLineNumber { get; set; } /// - /// Original column number when a mapped source is available. + /// Original column number when a mapped source is available. /// public int? OriginalColumnNumber { get; set; } /// - /// Optional origin label — e.g. the Lua component name when the error came from a lifecycle hook. + /// Optional origin label — e.g. the Lua component name when the error came from a lifecycle hook. /// public string? Source { get; set; } } diff --git a/src/SquidStd.Scripting.Lua/Data/Scripts/ScriptExecutionMetrics.cs b/src/SquidStd.Scripting.Lua/Data/Scripts/ScriptExecutionMetrics.cs index a14d7fd1..aa2dd0f2 100644 --- a/src/SquidStd.Scripting.Lua/Data/Scripts/ScriptExecutionMetrics.cs +++ b/src/SquidStd.Scripting.Lua/Data/Scripts/ScriptExecutionMetrics.cs @@ -1,37 +1,37 @@ namespace SquidStd.Scripting.Lua.Data.Scripts; /// -/// Metrics about script execution performance. +/// Metrics about script execution performance. /// public class ScriptExecutionMetrics { /// - /// Gets or sets the execution time in milliseconds. + /// Gets or sets the execution time in milliseconds. /// public long ExecutionTimeMs { get; set; } /// - /// Gets or sets the memory used in bytes. + /// Gets or sets the memory used in bytes. /// public long MemoryUsedBytes { get; set; } /// - /// Gets or sets the number of statements executed. + /// Gets or sets the number of statements executed. /// public int StatementsExecuted { get; set; } /// - /// Gets or sets the number of cache hits. + /// Gets or sets the number of cache hits. /// public int CacheHits { get; set; } /// - /// Gets or sets the number of cache misses. + /// Gets or sets the number of cache misses. /// public int CacheMisses { get; set; } /// - /// Gets or sets the total number of scripts cached. + /// Gets or sets the total number of scripts cached. /// public int TotalScriptsCached { get; set; } } diff --git a/src/SquidStd.Scripting.Lua/Data/Scripts/ScriptResult.cs b/src/SquidStd.Scripting.Lua/Data/Scripts/ScriptResult.cs index a6e991f1..79f37619 100644 --- a/src/SquidStd.Scripting.Lua/Data/Scripts/ScriptResult.cs +++ b/src/SquidStd.Scripting.Lua/Data/Scripts/ScriptResult.cs @@ -1,22 +1,22 @@ namespace SquidStd.Scripting.Lua.Data.Scripts; /// -/// Represents the result of a script execution. +/// Represents the result of a script execution. /// public class ScriptResult { /// - /// Gets or sets a value indicating whether the script execution was successful. + /// Gets or sets a value indicating whether the script execution was successful. /// public bool Success { get; set; } /// - /// Gets or sets the message associated with the script result. + /// Gets or sets the message associated with the script result. /// public string Message { get; set; } /// - /// Gets or sets the data returned by the script execution. + /// Gets or sets the data returned by the script execution. /// public object? Data { get; set; } } diff --git a/src/SquidStd.Scripting.Lua/Data/Scripts/ScriptResultBuilder.cs b/src/SquidStd.Scripting.Lua/Data/Scripts/ScriptResultBuilder.cs index 147af4e1..0c13c2d5 100644 --- a/src/SquidStd.Scripting.Lua/Data/Scripts/ScriptResultBuilder.cs +++ b/src/SquidStd.Scripting.Lua/Data/Scripts/ScriptResultBuilder.cs @@ -1,7 +1,7 @@ namespace SquidStd.Scripting.Lua.Data.Scripts; /// -/// Builder class for creating ScriptResult instances. +/// Builder class for creating ScriptResult instances. /// public class ScriptResultBuilder { @@ -10,30 +10,36 @@ public class ScriptResultBuilder private bool _success; /// - /// Builds the ScriptResult instance. + /// Builds the ScriptResult instance. /// public ScriptResult Build() - => new() + { + return new ScriptResult { Success = _success, Message = _message, Data = _data }; + } /// - /// Creates a ScriptResultBuilder initialized for an error result. + /// Creates a ScriptResultBuilder initialized for an error result. /// public static ScriptResultBuilder CreateError() - => new ScriptResultBuilder().WithSuccess(false); + { + return new ScriptResultBuilder().WithSuccess(false); + } /// - /// Creates a ScriptResultBuilder initialized for a successful result. + /// Creates a ScriptResultBuilder initialized for a successful result. /// public static ScriptResultBuilder CreateSuccess() - => new ScriptResultBuilder().WithSuccess(true); + { + return new ScriptResultBuilder().WithSuccess(true); + } /// - /// Sets the result as failed. + /// Sets the result as failed. /// public ScriptResultBuilder Failure() { @@ -43,7 +49,7 @@ public ScriptResultBuilder Failure() } /// - /// Sets the result as successful. + /// Sets the result as successful. /// public ScriptResultBuilder Success() { @@ -53,7 +59,7 @@ public ScriptResultBuilder Success() } /// - /// Sets the data of the result. + /// Sets the data of the result. /// public ScriptResultBuilder WithData(object? data) { @@ -63,7 +69,7 @@ public ScriptResultBuilder WithData(object? data) } /// - /// Sets the message of the result. + /// Sets the message of the result. /// public ScriptResultBuilder WithMessage(string message) { @@ -73,7 +79,7 @@ public ScriptResultBuilder WithMessage(string message) } /// - /// Sets the success status of the result. + /// Sets the success status of the result. /// public ScriptResultBuilder WithSuccess(bool success) { diff --git a/src/SquidStd.Scripting.Lua/Descriptors/GenericUserDataDescriptor.cs b/src/SquidStd.Scripting.Lua/Descriptors/GenericUserDataDescriptor.cs index 245c9db8..458658b6 100644 --- a/src/SquidStd.Scripting.Lua/Descriptors/GenericUserDataDescriptor.cs +++ b/src/SquidStd.Scripting.Lua/Descriptors/GenericUserDataDescriptor.cs @@ -4,16 +4,16 @@ namespace SquidStd.Scripting.Lua.Descriptors; /// -/// Generic UserData descriptor that adds support for string concatenation and conversion. -/// Implements the __tostring metamethod to allow Lua to convert any userdata type to strings. +/// Generic UserData descriptor that adds support for string concatenation and conversion. +/// Implements the __tostring metamethod to allow Lua to convert any userdata type to strings. /// public class GenericUserDataDescriptor : StandardUserDataDescriptor { private readonly bool _isXnaType; /// - /// Creates a new descriptor for a type with reflection access mode. - /// Automatically detects if the type is an XNA Framework type. + /// Creates a new descriptor for a type with reflection access mode. + /// Automatically detects if the type is an XNA Framework type. /// /// The type to describe (can be XNA or any other .NET type). public GenericUserDataDescriptor(Type type) @@ -24,17 +24,17 @@ public GenericUserDataDescriptor(Type type) } /// - /// Converts the object to its string representation. - /// This is used by MoonSharp when the object is converted to a string in Lua (concatenation, tostring(), etc.). + /// Converts the object to its string representation. + /// This is used by MoonSharp when the object is converted to a string in Lua (concatenation, tostring(), etc.). /// /// The object to convert to string. /// - /// For XNA types: Uses ToString() for a readable representation. - /// For other types: Uses ToString() or the type name if ToString() returns null. + /// For XNA types: Uses ToString() for a readable representation. + /// For other types: Uses ToString() or the type name if ToString() returns null. /// /// - /// In Lua, this allows: - /// + /// In Lua, this allows: + /// /// local vec = Vector2(10, 20) /// print("Position: " .. vec) -- ✅ Calls AsString(vec) instead of erroring /// local str = tostring(vec) -- ✅ Works with tostring() @@ -51,10 +51,10 @@ public override string AsString(object obj) var str = obj.ToString(); return string.IsNullOrWhiteSpace(str) - ? + ? - // Fallback: use the type name if ToString() returns empty/null - $"{Type.Name}({{}})" - : str; + // Fallback: use the type name if ToString() returns empty/null + $"{Type.Name}({{}})" + : str; } } diff --git a/src/SquidStd.Scripting.Lua/Extensions/Scripts/AddScriptModuleExtension.cs b/src/SquidStd.Scripting.Lua/Extensions/Scripts/AddScriptModuleExtension.cs index 4cf0e5f0..df5c2fb8 100644 --- a/src/SquidStd.Scripting.Lua/Extensions/Scripts/AddScriptModuleExtension.cs +++ b/src/SquidStd.Scripting.Lua/Extensions/Scripts/AddScriptModuleExtension.cs @@ -6,7 +6,7 @@ namespace SquidStd.Scripting.Lua.Extensions.Scripts; /// -/// Extension methods for registering Lua script modules in the dependency injection container. +/// Extension methods for registering Lua script modules in the dependency injection container. /// public static class AddScriptModuleExtension { @@ -14,7 +14,7 @@ public static class AddScriptModuleExtension extension(IContainer container) { /// - /// Registers a user data type with the container for Lua scripting. + /// Registers a user data type with the container for Lua scripting. /// public IContainer RegisterLuaUserData(Type userDataType) { @@ -29,7 +29,7 @@ public IContainer RegisterLuaUserData(Type userDataType) } /// - /// Registers a user data type with the container for Lua scripting using generics. + /// Registers a user data type with the container for Lua scripting using generics. /// public IContainer RegisterLuaUserData() { @@ -39,7 +39,7 @@ public IContainer RegisterLuaUserData() } /// - /// Registers a Lua script module type with the container. + /// Registers a Lua script module type with the container. /// /// The type of the script module to register. /// The container for method chaining. @@ -59,11 +59,13 @@ public IContainer RegisterScriptModule(Type scriptModule) } /// - /// Registers a Lua script module type with the container using a generic type parameter. + /// Registers a Lua script module type with the container using a generic type parameter. /// /// The type of the script module to register. /// The container for method chaining. public IContainer RegisterScriptModule() where TScriptModule : class - => container.RegisterScriptModule(typeof(TScriptModule)); + { + return container.RegisterScriptModule(typeof(TScriptModule)); + } } } diff --git a/src/SquidStd.Scripting.Lua/Extensions/Scripts/TableExtensions.cs b/src/SquidStd.Scripting.Lua/Extensions/Scripts/TableExtensions.cs index 7b36a728..7159dd74 100644 --- a/src/SquidStd.Scripting.Lua/Extensions/Scripts/TableExtensions.cs +++ b/src/SquidStd.Scripting.Lua/Extensions/Scripts/TableExtensions.cs @@ -5,12 +5,12 @@ namespace SquidStd.Scripting.Lua.Extensions.Scripts; /// -/// Provides extension methods for MoonSharp Table objects to enable proxying to interfaces. +/// Provides extension methods for MoonSharp Table objects to enable proxying to interfaces. /// public static class TableExtensions { /// - /// Converts a MoonSharp Table to a proxy implementing the specified interface. + /// Converts a MoonSharp Table to a proxy implementing the specified interface. /// public static TInterface ToProxy(this Table table) where TInterface : class diff --git a/src/SquidStd.Scripting.Lua/Interfaces/Events/ILuaEventBridge.cs b/src/SquidStd.Scripting.Lua/Interfaces/Events/ILuaEventBridge.cs index d7e37dbb..fd74fdca 100644 --- a/src/SquidStd.Scripting.Lua/Interfaces/Events/ILuaEventBridge.cs +++ b/src/SquidStd.Scripting.Lua/Interfaces/Events/ILuaEventBridge.cs @@ -3,18 +3,18 @@ namespace SquidStd.Scripting.Lua.Interfaces.Events; /// -/// Bridges named server events and internal callbacks into Lua closures. +/// Bridges named server events and internal callbacks into Lua closures. /// public interface ILuaEventBridge { /// - /// Attaches the active MoonSharp script runtime used to invoke registered closures. + /// Attaches the active MoonSharp script runtime used to invoke registered closures. /// /// Active Lua script runtime. void Attach(Script script); /// - /// Invokes a single closure with the supplied payload. + /// Invokes a single closure with the supplied payload. /// /// Lua closure to invoke. /// Payload exposed to Lua as a table. @@ -22,14 +22,14 @@ public interface ILuaEventBridge DynValue Invoke(Closure callback, IReadOnlyDictionary payload); /// - /// Publishes a named event to every Lua callback registered for it. + /// Publishes a named event to every Lua callback registered for it. /// /// Stable event name, such as player.connected. /// Payload exposed to Lua as a table. void Publish(string eventName, IReadOnlyDictionary payload); /// - /// Registers a Lua callback for a named event. + /// Registers a Lua callback for a named event. /// /// Stable event name, such as player.connected. /// Lua closure to invoke when the event is published. diff --git a/src/SquidStd.Scripting.Lua/Interfaces/Scripts/IScriptEngineService.cs b/src/SquidStd.Scripting.Lua/Interfaces/Scripts/IScriptEngineService.cs index 4380ce33..92a89f95 100644 --- a/src/SquidStd.Scripting.Lua/Interfaces/Scripts/IScriptEngineService.cs +++ b/src/SquidStd.Scripting.Lua/Interfaces/Scripts/IScriptEngineService.cs @@ -4,65 +4,65 @@ namespace SquidStd.Scripting.Lua.Interfaces.Scripts; /// -/// Interface for the script engine service that manages Lua execution. +/// Interface for the script engine service that manages Lua execution. /// public interface IScriptEngineService : ISquidStdService { /// - /// Delegate for handling script file change events. + /// Delegate for handling script file change events. /// /// The path to the changed file. /// True if the file change was handled successfully, false otherwise. delegate bool LuaFileChangedHandler(string filePath); /// - /// Event raised when a script file is modified. + /// Event raised when a script file is modified. /// event LuaFileChangedHandler? FileChanged; /// - /// Event raised when a script error occurs + /// Event raised when a script error occurs /// event EventHandler? OnScriptError; /// - /// Fires once during StartAsync, after script modules have been registered - /// but before bootstrap scripts run. Handlers can install additional UserData types, globals, - /// or scanners that depend on the script runtime being ready. The argument is the underlying - /// MoonSharp Script, typed as so the interface stays - /// implementation-agnostic; callers cast as needed. + /// Fires once during StartAsync, after script modules have been registered + /// but before bootstrap scripts run. Handlers can install additional UserData types, globals, + /// or scanners that depend on the script runtime being ready. The argument is the underlying + /// MoonSharp Script, typed as so the interface stays + /// implementation-agnostic; callers cast as needed. /// event Action? AfterModulesRegistered; /// - /// Fires when a .lua file under a components/ subdirectory of the scripts - /// directory changes on disk. Carries the full file path. Used by the engine-side - /// component loader to hot-reload Lua-defined components. + /// Fires when a .lua file under a components/ subdirectory of the scripts + /// directory changes on disk. Carries the full file path. Used by the engine-side + /// component loader to hot-reload Lua-defined components. /// event Action? OnComponentFileChanged; /// - /// Adds a callback function that can be called from Lua. + /// Adds a callback function that can be called from Lua. /// /// The name of the callback function in Lua. /// The C# action to execute when the callback is invoked. void AddCallback(string name, Action callback); /// - /// Adds a constant value accessible from Lua. + /// Adds a constant value accessible from Lua. /// /// The name of the constant in Lua. /// The value of the constant. void AddConstant(string name, object value); /// - /// Adds a script to be executed during engine initialization. + /// Adds a script to be executed during engine initialization. /// /// The Lua code to execute on startup. void AddInitScript(string script); /// - /// Adds a manual module function that can be called from scripts. + /// Adds a manual module function that can be called from scripts. /// /// The name of the module. /// The name of the function. @@ -70,7 +70,7 @@ public interface IScriptEngineService : ISquidStdService void AddManualModuleFunction(string moduleName, string functionName, Action callback); /// - /// Adds a typed manual module function that can be called from scripts. + /// Adds a typed manual module function that can be called from scripts. /// /// The input parameter type. /// The output return type. @@ -84,43 +84,43 @@ void AddManualModuleFunction( ); /// - /// Adds a .NET type as a module accessible from Lua. + /// Adds a .NET type as a module accessible from Lua. /// /// The type to register as a script module. void AddScriptModule(Type type); /// - /// Adds a directory to the Lua script search paths. + /// Adds a directory to the Lua script search paths. /// /// Directory path to search for scripts. void AddSearchDirectory(string path); /// - /// Clears the script cache + /// Clears the script cache /// void ClearScriptCache(); /// - /// Executes a previously registered callback function. + /// Executes a previously registered callback function. /// /// The name of the callback to execute. /// Arguments to pass to the callback. void ExecuteCallback(string name, params object[] args); /// - /// Notifies the script engine that the engine initialization is complete and ready. + /// Notifies the script engine that the engine initialization is complete and ready. /// void ExecuteEngineReady(); /// - /// Executes a Lua function or expression and returns the result. + /// Executes a Lua function or expression and returns the result. /// /// The Lua function call or expression to execute. /// A ScriptResult containing the execution outcome. ScriptResult ExecuteFunction(string command); /// - /// Asynchronously executes a Lua function or expression and returns the result. + /// Asynchronously executes a Lua function or expression and returns the result. /// /// The Lua function call or expression to execute. /// Token used to cancel the operation. @@ -128,52 +128,52 @@ void AddManualModuleFunction( Task ExecuteFunctionAsync(string command, CancellationToken cancellationToken = default); /// - /// Executes a function defined in the bootstrap script. + /// Executes a function defined in the bootstrap script. /// /// void ExecuteFunctionFromBootstrap(string name); /// - /// Executes a Lua script string. + /// Executes a Lua script string. /// /// The Lua code to execute. void ExecuteScript(string script); /// - /// Executes a Lua file. + /// Executes a Lua file. /// /// The path to the Lua file to execute. void ExecuteScriptFile(string scriptFile); /// - /// Gets execution metrics for performance monitoring + /// Gets execution metrics for performance monitoring /// /// Metrics about script execution ScriptExecutionMetrics GetExecutionMetrics(); /// - /// Registers a global object/value accessible from scripts. + /// Registers a global object/value accessible from scripts. /// /// The name of the global in scripts. /// The object/value to register. void RegisterGlobal(string name, object value); /// - /// Registers a global function that can be called from scripts. + /// Registers a global function that can be called from scripts. /// /// The name of the global function in scripts. /// The delegate to register as a global function. void RegisterGlobalFunction(string name, Delegate func); /// - /// Converts a .NET method name to a Lua-compatible function name. + /// Converts a .NET method name to a Lua-compatible function name. /// /// The .NET method name to convert. /// The Lua-compatible function name. string ToScriptEngineFunctionName(string name); /// - /// Unregisters a global function or value. + /// Unregisters a global function or value. /// /// The name of the global to unregister. /// True if the global was found and removed, false otherwise. diff --git a/src/SquidStd.Scripting.Lua/Loaders/LuaScriptLoader.cs b/src/SquidStd.Scripting.Lua/Loaders/LuaScriptLoader.cs index f4571e88..0ee14b7b 100644 --- a/src/SquidStd.Scripting.Lua/Loaders/LuaScriptLoader.cs +++ b/src/SquidStd.Scripting.Lua/Loaders/LuaScriptLoader.cs @@ -6,8 +6,8 @@ namespace SquidStd.Scripting.Lua.Loaders; /// -/// Custom script loader for MoonSharp that loads Lua modules from the configured Scripts directory. -/// Implements the MoonSharp script loader interface to provide require() functionality. +/// Custom script loader for MoonSharp that loads Lua modules from the configured Scripts directory. +/// Implements the MoonSharp script loader interface to provide require() functionality. /// public class LuaScriptLoader : ScriptLoaderBase { @@ -15,14 +15,14 @@ public class LuaScriptLoader : ScriptLoaderBase private readonly List _scriptsDirectories; /// - /// Initializes a new instance of the LuaScriptLoader class. + /// Initializes a new instance of the LuaScriptLoader class. /// /// The directories configuration to resolve the scripts directory. public LuaScriptLoader(string rootDirectory) { ArgumentNullException.ThrowIfNull(rootDirectory); - _scriptsDirectories = new() { Path.GetFullPath(rootDirectory) }; + _scriptsDirectories = new List { Path.GetFullPath(rootDirectory) }; // Configure default module search paths ModulePaths = @@ -37,11 +37,13 @@ public LuaScriptLoader(string rootDirectory) } /// - /// Initializes a new instance of the LuaScriptLoader class with multiple search directories. + /// Initializes a new instance of the LuaScriptLoader class with multiple search directories. /// /// Ordered list of directories to search. public LuaScriptLoader(IReadOnlyList searchDirectories) - : this(searchDirectories, true) { } + : this(searchDirectories, true) + { + } private LuaScriptLoader(IReadOnlyList searchDirectories, bool _) { @@ -53,9 +55,9 @@ private LuaScriptLoader(IReadOnlyList searchDirectories, bool _) } _scriptsDirectories = searchDirectories.Where(d => !string.IsNullOrWhiteSpace(d)) - .Select(d => Path.GetFullPath(d.ResolvePathAndEnvs()).ResolvePathAndEnvs()) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToList(); + .Select(d => Path.GetFullPath(d.ResolvePathAndEnvs()).ResolvePathAndEnvs()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); if (_scriptsDirectories.Count == 0) { @@ -92,7 +94,7 @@ public void AddSearchDirectory(string directory) } /// - /// Loads a Lua script file from the configured scripts directory. + /// Loads a Lua script file from the configured scripts directory. /// /// The filename or module name to load. /// The global context table. @@ -130,7 +132,7 @@ public override object LoadFile(string file, Table globalContext) } /// - /// Checks if a script file exists in the configured scripts directory. + /// Checks if a script file exists in the configured scripts directory. /// /// The filename or module name to check. /// True if the file exists, false otherwise. @@ -145,7 +147,7 @@ public override bool ScriptFileExists(string name) } /// - /// Resolves a module name to a full file path by searching through configured module paths. + /// Resolves a module name to a full file path by searching through configured module paths. /// /// The module name to resolve. /// The full path to the module file, or null if not found. diff --git a/src/SquidStd.Scripting.Lua/Modules/EventsModule.cs b/src/SquidStd.Scripting.Lua/Modules/EventsModule.cs index 9137a3e8..366f1cc6 100644 --- a/src/SquidStd.Scripting.Lua/Modules/EventsModule.cs +++ b/src/SquidStd.Scripting.Lua/Modules/EventsModule.cs @@ -16,5 +16,7 @@ public EventsModule(ILuaEventBridge events) [ScriptFunction("on", "Registers a callback for a named server event.")] public void On(string eventName, Closure callback) - => _events.Register(eventName, callback); + { + _events.Register(eventName, callback); + } } diff --git a/src/SquidStd.Scripting.Lua/Modules/LogModule.cs b/src/SquidStd.Scripting.Lua/Modules/LogModule.cs index dd462dbc..c28979d7 100644 --- a/src/SquidStd.Scripting.Lua/Modules/LogModule.cs +++ b/src/SquidStd.Scripting.Lua/Modules/LogModule.cs @@ -10,13 +10,19 @@ public class LogModule [ScriptFunction(helpText: "Logs a message at the ERROR level.")] public void Error(string message, params object[]? args) - => _logger.Error(message, args); + { + _logger.Error(message, args); + } [ScriptFunction(helpText: "Logs a message at the INFO level.")] public void Info(string message, params object[]? args) - => _logger.Information(message, args); + { + _logger.Information(message, args); + } [ScriptFunction(helpText: "Logs a message at the WARNING level.")] public void Warning(string message, params object[]? args) - => _logger.Warning(message, args); + { + _logger.Warning(message, args); + } } diff --git a/src/SquidStd.Scripting.Lua/Modules/RandomModule.cs b/src/SquidStd.Scripting.Lua/Modules/RandomModule.cs index 4547ab25..a60d7e67 100644 --- a/src/SquidStd.Scripting.Lua/Modules/RandomModule.cs +++ b/src/SquidStd.Scripting.Lua/Modules/RandomModule.cs @@ -24,7 +24,9 @@ public bool Chance(double percent) [ScriptFunction("float", "Returns a random floating-point number between 0 and 1.")] public double Float() - => Random.Shared.NextDouble(); + { + return Random.Shared.NextDouble(); + } [ScriptFunction("int", "Returns a random integer in the inclusive range.")] public int Int(int min, int max) diff --git a/src/SquidStd.Scripting.Lua/Proxies/LuaProxy.cs b/src/SquidStd.Scripting.Lua/Proxies/LuaProxy.cs index bf5c8bd1..36d8cae5 100644 --- a/src/SquidStd.Scripting.Lua/Proxies/LuaProxy.cs +++ b/src/SquidStd.Scripting.Lua/Proxies/LuaProxy.cs @@ -4,7 +4,7 @@ namespace SquidStd.Scripting.Lua.Proxies; /// -/// A proxy class that implements an interface by delegating method calls to a MoonSharp Table. +/// A proxy class that implements an interface by delegating method calls to a MoonSharp Table. /// /// The interface type to implement. public class LuaProxy : DispatchProxy @@ -12,7 +12,7 @@ public class LuaProxy : DispatchProxy public Table Table { get; set; } /// - /// Invokes the Lua function corresponding to the method name on the associated table. + /// Invokes the Lua function corresponding to the method name on the associated table. /// /// The method information for the invoked method. /// The arguments passed to the method. @@ -27,8 +27,8 @@ protected override object Invoke(MethodInfo targetMethod, object[] args) } var dynArgs = args - .Select(a => DynValue.FromObject(null, a)) - .ToArray(); + .Select(a => DynValue.FromObject(null, a)) + .ToArray(); var result = fn.Function.Call(dynArgs); return result.ToObject(targetMethod.ReturnType); diff --git a/src/SquidStd.Scripting.Lua/README.md b/src/SquidStd.Scripting.Lua/README.md index 8c5b52ba..fcf0f1e4 100644 --- a/src/SquidStd.Scripting.Lua/README.md +++ b/src/SquidStd.Scripting.Lua/README.md @@ -25,6 +25,7 @@ dotnet add package SquidStd.Scripting.Lua - `IScriptEngineService` — load and run Lua scripts; register modules, constants, callbacks, init scripts. - Attribute-based modules: mark a class `[ScriptModule]` and methods `[ScriptFunction]` to expose them. +- `RegisterScriptModuleAttribute` — opt a `[ScriptModule]` class into generated registration. - `container.RegisterScriptModule()` / `RegisterLuaUserData()` registration extensions. - Event bridging to the SquidStd event bus (`ILuaEventBridge`). - Built-in modules (logging, events, random) and `.luarc` documentation generation. @@ -33,9 +34,12 @@ dotnet add package SquidStd.Scripting.Lua ```csharp using DryIoc; +using SquidStd.Generators.Scripting.Lua; +using SquidStd.Scripting.Lua.Attributes; using SquidStd.Scripting.Lua.Attributes.Scripts; using SquidStd.Scripting.Lua.Extensions.Scripts; +[RegisterScriptModule] [ScriptModule("math2")] public sealed class MathModule { @@ -44,7 +48,7 @@ public sealed class MathModule } var container = new Container(); -container.RegisterScriptModule(); +container.RegisterGeneratedScriptModules(); // Resolve IScriptEngineService to load and execute scripts that call math2.add(1, 2). ``` @@ -55,6 +59,7 @@ container.RegisterScriptModule(); | `IScriptEngineService` | Lua engine: load/run scripts, register modules/constants/callbacks. | | `ILuaEventBridge` | Bridges Lua scripts to the event bus. | | `ScriptModuleAttribute` / `ScriptFunctionAttribute` | Expose .NET classes/methods to Lua. | +| `RegisterScriptModuleAttribute` | Marks script modules for generated registration. | | `AddScriptModuleExtension` | `RegisterScriptModule()` / `RegisterLuaUserData()`. | ## License diff --git a/src/SquidStd.Scripting.Lua/Services/LuaEventBridge.cs b/src/SquidStd.Scripting.Lua/Services/LuaEventBridge.cs index fe473fb3..8201739e 100644 --- a/src/SquidStd.Scripting.Lua/Services/LuaEventBridge.cs +++ b/src/SquidStd.Scripting.Lua/Services/LuaEventBridge.cs @@ -6,7 +6,7 @@ namespace SquidStd.Scripting.Lua.Services; /// -/// Default Lua event bridge backed by named MoonSharp closures. +/// Default Lua event bridge backed by named MoonSharp closures. /// public sealed class LuaEventBridge : ILuaEventBridge { diff --git a/src/SquidStd.Scripting.Lua/Services/LuaScriptEngineService.cs b/src/SquidStd.Scripting.Lua/Services/LuaScriptEngineService.cs index 198d95b5..cddd51ac 100644 --- a/src/SquidStd.Scripting.Lua/Services/LuaScriptEngineService.cs +++ b/src/SquidStd.Scripting.Lua/Services/LuaScriptEngineService.cs @@ -30,8 +30,8 @@ namespace SquidStd.Scripting.Lua.Services; /// -/// Lua engine service that integrates MoonSharp with the SquidCraft game engine -/// Provides script execution, module loading, and Lua meta file generation +/// Lua engine service that integrates MoonSharp with the SquidCraft game engine +/// Provides script execution, module loading, and Lua meta file generation /// public class LuaScriptEngineService : IScriptEngineService, IDisposable { @@ -39,18 +39,18 @@ public class LuaScriptEngineService : IScriptEngineService, IDisposable private const string OnEngineRunFunctionName = "on_initialize"; private static readonly string[] _completionExcludedGlobals = ["delay", "toString"]; - - private readonly LuaEngineConfig _engineConfig; private readonly ConcurrentDictionary> _callbacks = new(); private readonly ConcurrentDictionary _constants = new(); - private readonly ConcurrentDictionary> _manualModuleFunctions = new(); private readonly DirectoriesConfig _directoriesConfig; + + private readonly LuaEngineConfig _engineConfig; private readonly List _initScripts; private readonly ConcurrentDictionary _loadedModules = new(); + private readonly List _loadedUserData; private readonly ILogger _logger = Log.ForContext(); + private readonly ConcurrentDictionary> _manualModuleFunctions = new(); private readonly ConcurrentDictionary _scriptCache = new(); private readonly List _scriptModules; - private readonly List _loadedUserData; private readonly IContainer _serviceProvider; private int _cacheHits; @@ -62,33 +62,7 @@ public class LuaScriptEngineService : IScriptEngineService, IDisposable private FileSystemWatcher? _watcher; /// - /// Gets the MoonSharp script instance. - /// - public Script LuaScript { get; } - - /// - /// Gets the script engine instance. - /// - public object Engine => LuaScript; - - /// - /// Raised when a watched Lua file changes. - /// - public event IScriptEngineService.LuaFileChangedHandler? FileChanged; - - /// - /// Event raised when a script error occurs - /// - public event EventHandler? OnScriptError; - - /// - public event Action? AfterModulesRegistered; - - /// - public event Action? OnComponentFileChanged; - - /// - /// Initializes a new instance of the LuaScriptEngineService class. + /// Initializes a new instance of the LuaScriptEngineService class. /// /// The directories configuration. /// The list of script modules. @@ -105,14 +79,14 @@ public LuaScriptEngineService( { JsonUtils.RegisterJsonContext(SquidStdScriptJsonContext.Default); - scriptModules ??= new(); - loadedUserData ??= new(); + scriptModules ??= new List(); + loadedUserData ??= new List(); _scriptModules = scriptModules; _directoriesConfig = directoriesConfig; _serviceProvider = serviceProvider; _engineConfig = engineConfig; - _loadedUserData = loadedUserData ?? new(); + _loadedUserData = loadedUserData ?? new List(); _initScripts = ["bootstrap.lua", "init.lua", "main.lua"]; CreateNameResolver(); @@ -123,7 +97,63 @@ public LuaScriptEngineService( } /// - /// Adds a callback function that can be called from Lua scripts. + /// Gets the MoonSharp script instance. + /// + public Script LuaScript { get; } + + /// + /// Gets the script engine instance. + /// + public object Engine => LuaScript; + + /// + /// Disposes of the resources used by the LuaScriptEngineService. + /// + public void Dispose() + { + if (_disposed) + { + return; + } + + try + { + _loadedModules.Clear(); + _callbacks.Clear(); + _constants.Clear(); + + GC.SuppressFinalize(this); + + _logger.Debug("Lua engine disposed successfully"); + } + catch (Exception ex) + { + _logger.Warning(ex, "Error during Lua engine disposal"); + } + finally + { + _disposed = true; + } + } + + /// + /// Raised when a watched Lua file changes. + /// + public event IScriptEngineService.LuaFileChangedHandler? FileChanged; + + /// + /// Event raised when a script error occurs + /// + public event EventHandler? OnScriptError; + + /// + public event Action? AfterModulesRegistered; + + /// + public event Action? OnComponentFileChanged; + + /// + /// Adds a callback function that can be called from Lua scripts. /// /// The name of the callback. /// The callback action. @@ -139,7 +169,7 @@ public void AddCallback(string name, Action callback) } /// - /// Adds a constant value that can be accessed from Lua scripts. + /// Adds a constant value that can be accessed from Lua scripts. /// /// The name of the constant. /// The value of the constant. @@ -169,7 +199,7 @@ public void AddConstant(string name, object? value) } /// - /// Adds an initialization script. + /// Adds an initialization script. /// /// The script to add. public void AddInitScript(string script) @@ -183,7 +213,7 @@ public void AddInitScript(string script) } /// - /// Adds a manual module function that can be called from Lua scripts with a callback. + /// Adds a manual module function that can be called from Lua scripts with a callback. /// public void AddManualModuleFunction(string moduleName, string functionName, Action callback) { @@ -193,8 +223,7 @@ public void AddManualModuleFunction(string moduleName, string functionName, Acti var (normalizedModule, normalizedFunction, moduleTable) = PrepareManualModule(moduleName, functionName); - moduleTable[normalizedFunction] = DynValue.NewCallback( - (_, args) => + moduleTable[normalizedFunction] = DynValue.NewCallback((_, args) => { try { @@ -221,7 +250,7 @@ public void AddManualModuleFunction(string moduleName, string functionName, Acti } /// - /// Adds a manual module function with typed input and output that can be called from Lua scripts. + /// Adds a manual module function with typed input and output that can be called from Lua scripts. /// public void AddManualModuleFunction( string moduleName, @@ -235,8 +264,7 @@ public void AddManualModuleFunction( var (normalizedModule, normalizedFunction, moduleTable) = PrepareManualModule(moduleName, functionName); - moduleTable[normalizedFunction] = DynValue.NewCallback( - (_, args) => + moduleTable[normalizedFunction] = DynValue.NewCallback((_, args) => { try { @@ -263,20 +291,22 @@ public void AddManualModuleFunction( } /// - /// Adds a script module to the engine. + /// Adds a script module to the engine. /// /// The type of the script module. public void AddScriptModule(Type type) { ArgumentNullException.ThrowIfNull(type); - _scriptModules.Add(new(type)); + _scriptModules.Add(new ScriptModuleData(type)); } public void AddSearchDirectory(string path) - => _scriptLoader.AddSearchDirectory(path); + { + _scriptLoader.AddSearchDirectory(path); + } /// - /// Clears the script cache + /// Clears the script cache /// public void ClearScriptCache() { @@ -287,37 +317,7 @@ public void ClearScriptCache() } /// - /// Disposes of the resources used by the LuaScriptEngineService. - /// - public void Dispose() - { - if (_disposed) - { - return; - } - - try - { - _loadedModules.Clear(); - _callbacks.Clear(); - _constants.Clear(); - - GC.SuppressFinalize(this); - - _logger.Debug("Lua engine disposed successfully"); - } - catch (Exception ex) - { - _logger.Warning(ex, "Error during Lua engine disposal"); - } - finally - { - _disposed = true; - } - } - - /// - /// Executes a registered callback with the specified arguments. + /// Executes a registered callback with the specified arguments. /// /// The name of the callback. /// The arguments to pass to the callback. @@ -348,13 +348,15 @@ public void ExecuteCallback(string name, params object[] args) } /// - /// Executes the engine ready function from bootstrap scripts. + /// Executes the engine ready function from bootstrap scripts. /// public void ExecuteEngineReady() - => ExecuteFunctionFromBootstrap(OnEngineRunFunctionName); + { + ExecuteFunctionFromBootstrap(OnEngineRunFunctionName); + } /// - /// Executes a Lua function and returns the result. + /// Executes a Lua function and returns the result. /// /// The function command to execute. /// The result of the function execution. @@ -380,10 +382,10 @@ public ScriptResult ExecuteFunction(string command) ); return ScriptResultBuilder.CreateError() - .WithMessage( - $"{errorInfo.ErrorType}: {errorInfo.Message} at line {errorInfo.LineNumber}" - ) - .Build(); + .WithMessage( + $"{errorInfo.ErrorType}: {errorInfo.Message} at line {errorInfo.LineNumber}" + ) + .Build(); } catch (InterpreterException luaEx) { @@ -400,10 +402,10 @@ public ScriptResult ExecuteFunction(string command) ); return ScriptResultBuilder.CreateError() - .WithMessage( - $"{errorInfo.ErrorType}: {errorInfo.Message} at line {errorInfo.LineNumber}" - ) - .Build(); + .WithMessage( + $"{errorInfo.ErrorType}: {errorInfo.Message} at line {errorInfo.LineNumber}" + ) + .Build(); } catch (Exception ex) { @@ -414,7 +416,7 @@ public ScriptResult ExecuteFunction(string command) } /// - /// Executes a Lua function asynchronously and returns the result. + /// Executes a Lua function asynchronously and returns the result. /// /// The function command to execute. /// Token used to cancel the operation. @@ -463,14 +465,16 @@ public void ExecuteFunctionFromBootstrap(string name) } /// - /// Executes a script string. + /// Executes a script string. /// /// The script to execute. public void ExecuteScript(string script) - => ExecuteScript(script, null); + { + ExecuteScript(script, null); + } /// - /// Executes a script from a file. + /// Executes a script from a file. /// /// The path to the script file. public void ExecuteScriptFile(string scriptFile) @@ -495,54 +499,20 @@ public void ExecuteScriptFile(string scriptFile) } /// - /// Executes a script file asynchronously. - /// - /// The path to the script file. - /// Token used to cancel the operation. - /// A task representing the asynchronous operation. - public async Task ExecuteScriptFileAsync(string scriptFile, CancellationToken cancellationToken = default) - { - ArgumentException.ThrowIfNullOrWhiteSpace(scriptFile); - - if (!File.Exists(scriptFile)) - { - throw new FileNotFoundException($"Script file not found: {scriptFile}", scriptFile); - } - - try - { - var content = await File.ReadAllTextAsync(scriptFile, cancellationToken).ConfigureAwait(false); - _logger.Debug("Executing script file asynchronously: {FileName}", Path.GetFileName(scriptFile)); - ExecuteScript(content, scriptFile); - } - catch (Exception ex) - { - _logger.Error(ex, "Failed to execute script file asynchronously: {FileName}", Path.GetFileName(scriptFile)); - - throw; - } - } - - /// - /// Gets execution metrics for performance monitoring + /// Gets execution metrics for performance monitoring /// public ScriptExecutionMetrics GetExecutionMetrics() - => new() + { + return new ScriptExecutionMetrics { CacheHits = _cacheHits, CacheMisses = _cacheMisses, TotalScriptsCached = _scriptCache.Count }; + } /// - /// Gets the statistics of the script engine. - /// - /// A tuple containing the module count, callback count, constant count, and initialization status. - public (int ModuleCount, int CallbackCount, int ConstantCount, bool IsInitialized) GetStats() - => (_loadedModules.Count, _callbacks.Count, _constants.Count, _isInitialized); - - /// - /// Registers a global variable in the Lua environment. + /// Registers a global variable in the Lua environment. /// /// The name of the variable. /// The value of the variable. @@ -556,7 +526,7 @@ public void RegisterGlobal(string name, object value) } /// - /// Registers a global function in the Lua environment. + /// Registers a global function in the Lua environment. /// /// The name of the function. /// The delegate representing the function. @@ -570,47 +540,7 @@ public void RegisterGlobalFunction(string name, Delegate func) } /// - /// Registers a global type user data. - /// - /// The type to register. - public void RegisterGlobalTypeUserData(Type type) - { - ArgumentNullException.ThrowIfNull(type); - - _logger.Debug("Global type user data registered: {TypeName}", type.Name); - - LuaScript.Globals[type.Name] = UserData.CreateStatic(type); - } - - /// - /// Registers a global type user data for the specified type. - /// - /// The type to register. - public void RegisterGlobalTypeUserData() - { - var type = typeof(T); - _logger.Debug("Global type user data registered: {TypeName}", type.Name); - - LuaScript.Globals[type.Name] = UserData.CreateStatic(type); - } - - /// - /// Resets the script engine to its initial state. - /// - public void Reset() - { - ObjectDisposedException.ThrowIf(_disposed, this); - - _loadedModules.Clear(); - _callbacks.Clear(); - _constants.Clear(); - _isInitialized = false; - - _logger.Debug("Lua engine reset"); - } - - /// - /// Starts the script engine asynchronously. + /// Starts the script engine asynchronously. /// /// A task representing the asynchronous operation. public async ValueTask StartAsync(CancellationToken cancellationToken = default) @@ -647,7 +577,7 @@ public async ValueTask StartAsync(CancellationToken cancellationToken = default) if (_watcher == null) { - _watcher = new(_engineConfig.ScriptsDirectory, "*.lua") + _watcher = new FileSystemWatcher(_engineConfig.ScriptsDirectory, "*.lua") { NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName | NotifyFilters.Size, IncludeSubdirectories = true, @@ -666,14 +596,16 @@ public async ValueTask StartAsync(CancellationToken cancellationToken = default) } /// - /// Stops the script engine asynchronously. + /// Stops the script engine asynchronously. /// /// A task representing the asynchronous operation. public ValueTask StopAsync(CancellationToken cancellationToken = default) - => ValueTask.CompletedTask; + { + return ValueTask.CompletedTask; + } /// - /// Converts a name to the script engine function name format. + /// Converts a name to the script engine function name format. /// /// The name to convert. /// The converted function name. @@ -685,7 +617,7 @@ public string ToScriptEngineFunctionName(string name) } /// - /// Unregisters a global variable from the Lua environment. + /// Unregisters a global variable from the Lua environment. /// /// The name of the variable to unregister. /// True if the variable was unregistered, false otherwise. @@ -708,6 +640,84 @@ public bool UnregisterGlobal(string name) return false; } + /// + /// Executes a script file asynchronously. + /// + /// The path to the script file. + /// Token used to cancel the operation. + /// A task representing the asynchronous operation. + public async Task ExecuteScriptFileAsync(string scriptFile, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(scriptFile); + + if (!File.Exists(scriptFile)) + { + throw new FileNotFoundException($"Script file not found: {scriptFile}", scriptFile); + } + + try + { + var content = await File.ReadAllTextAsync(scriptFile, cancellationToken).ConfigureAwait(false); + _logger.Debug("Executing script file asynchronously: {FileName}", Path.GetFileName(scriptFile)); + ExecuteScript(content, scriptFile); + } + catch (Exception ex) + { + _logger.Error(ex, "Failed to execute script file asynchronously: {FileName}", Path.GetFileName(scriptFile)); + + throw; + } + } + + /// + /// Gets the statistics of the script engine. + /// + /// A tuple containing the module count, callback count, constant count, and initialization status. + public (int ModuleCount, int CallbackCount, int ConstantCount, bool IsInitialized) GetStats() + { + return (_loadedModules.Count, _callbacks.Count, _constants.Count, _isInitialized); + } + + /// + /// Registers a global type user data. + /// + /// The type to register. + public void RegisterGlobalTypeUserData(Type type) + { + ArgumentNullException.ThrowIfNull(type); + + _logger.Debug("Global type user data registered: {TypeName}", type.Name); + + LuaScript.Globals[type.Name] = UserData.CreateStatic(type); + } + + /// + /// Registers a global type user data for the specified type. + /// + /// The type to register. + public void RegisterGlobalTypeUserData() + { + var type = typeof(T); + _logger.Debug("Global type user data registered: {TypeName}", type.Name); + + LuaScript.Globals[type.Name] = UserData.CreateStatic(type); + } + + /// + /// Resets the script engine to its initial state. + /// + public void Reset() + { + ObjectDisposedException.ThrowIf(_disposed, this); + + _loadedModules.Clear(); + _callbacks.Clear(); + _constants.Clear(); + _isInitialized = false; + + _logger.Debug("Lua engine reset"); + } + private void AttachLuaEventBridge() { var eventBridge = _serviceProvider.Resolve(IfUnresolved.ReturnDefault); @@ -732,7 +742,8 @@ private void AttachLuaEventBridge() } private static object? ConvertFromLua(DynValue dynValue, Type targetType) - => dynValue.Type switch + { + return dynValue.Type switch { DataType.Nil => null, DataType.Boolean => dynValue.Boolean, @@ -741,15 +752,19 @@ private void AttachLuaEventBridge() DataType.Table => dynValue.ToObject(), _ => dynValue.ToObject() }; + } private DynValue ConvertToLua(object? value) - => value == null ? DynValue.Nil : DynValue.FromObject(LuaScript, value); + { + return value == null ? DynValue.Nil : DynValue.FromObject(LuaScript, value); + } /// - /// Creates a Lua callback that invokes the constructor matching the number of arguments passed from Lua. + /// Creates a Lua callback that invokes the constructor matching the number of arguments passed from Lua. /// private DynValue CreateConstructorCallback( - [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] Type type + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] + Type type ) { var constructorsByParamCount = new Dictionary(); @@ -761,8 +776,7 @@ private DynValue CreateConstructorCallback( constructorsByParamCount.TryAdd(paramCount, ctor); } - return DynValue.NewCallback( - (_, args) => + return DynValue.NewCallback((_, args) => { var argCount = args.Count; @@ -799,7 +813,7 @@ private DynValue CreateConstructorCallback( } /// - /// Creates detailed error information from a Lua exception + /// Creates detailed error information from a Lua exception /// private static ScriptErrorInfo CreateErrorInfo(ScriptRuntimeException luaEx, string sourceCode, string? fileName = null) { @@ -818,7 +832,7 @@ private static ScriptErrorInfo CreateErrorInfo(ScriptRuntimeException luaEx, str } /// - /// Creates detailed error information from a Lua interpreter exception (syntax errors, etc.) + /// Creates detailed error information from a Lua interpreter exception (syntax errors, etc.) /// private static ScriptErrorInfo CreateErrorInfo(InterpreterException luaEx, string sourceCode, string? fileName = null) { @@ -862,8 +876,8 @@ private static ScriptErrorInfo CreateErrorInfo(InterpreterException luaEx, strin } private DynValue CreateMethodClosure(object instance, MethodInfo method) - => DynValue.NewCallback( - (context, args) => + { + return DynValue.NewCallback((context, args) => { try { @@ -922,6 +936,7 @@ private DynValue CreateMethodClosure(object instance, MethodInfo method) } } ); + } private Table CreateModuleTable( object instance, @@ -932,7 +947,7 @@ Type moduleType var moduleTable = new Table(LuaScript); var methods = moduleType.GetMethods(BindingFlags.Public | BindingFlags.Instance) - .Where(m => m.GetCustomAttribute() is not null); + .Where(m => m.GetCustomAttribute() is not null); foreach (var method in methods) { @@ -944,8 +959,8 @@ Type moduleType } var functionName = string.IsNullOrWhiteSpace(scriptFunctionAttr.FunctionName) - ? _nameResolver(method.Name) - : scriptFunctionAttr.FunctionName; + ? _nameResolver(method.Name) + : scriptFunctionAttr.FunctionName; // Create a closure that captures the instance and method var closure = CreateMethodClosure(instance, method); @@ -956,7 +971,9 @@ Type moduleType } private void CreateNameResolver() - => _nameResolver = name => name.ToSnakeCase(); + { + _nameResolver = name => name.ToSnakeCase(); + } // _nameResolver = _scriptEngineConfig.ScriptNameConversion switch // { @@ -967,7 +984,7 @@ private void CreateNameResolver() // }; private Script CreateOptimizedEngine() { - _scriptLoader = new(new[] { _engineConfig.ScriptsDirectory }); + _scriptLoader = new LuaScriptLoader(new[] { _engineConfig.ScriptsDirectory }); var script = new Script { Options = @@ -984,7 +1001,9 @@ private Script CreateOptimizedEngine() } private void ExecuteBootFunction() - => ExecuteFunctionFromBootstrap(OnReadyFunctionName); + { + ExecuteFunctionFromBootstrap(OnReadyFunctionName); + } private void ExecuteBootstrap() { @@ -1000,7 +1019,7 @@ private void ExecuteBootstrap() } /// - /// Executes a script string with an optional file name for error reporting. + /// Executes a script string with an optional file name for error reporting. /// /// The script to execute. /// Optional file name for error reporting. @@ -1115,7 +1134,7 @@ private async Task GenerateLuaMetaFileAsync(CancellationToken cancellationToken) "Moongate", _engineConfig.EngineVersion, _scriptModules, - new(_constants), + new Dictionary(_constants), manualModulesSnapshot, _nameResolver ); @@ -1149,7 +1168,7 @@ private string GenerateLuarcJson() var luarcConfig = new LuarcConfig { - Runtime = new() + Runtime = new LuarcRuntimeConfig { Path = [ @@ -1159,11 +1178,11 @@ private string GenerateLuarcJson() "modules/?/init.lua" ] }, - Workspace = new() + Workspace = new LuarcWorkspaceConfig { Library = [_engineConfig.ScriptsDirectory] }, - Diagnostics = new() + Diagnostics = new LuarcDiagnosticsConfig { Globals = [..globalsList] } @@ -1173,7 +1192,7 @@ private string GenerateLuarcJson() } /// - /// Generates a hash for script caching + /// Generates a hash for script caching /// private static string GetScriptHash(string script) { @@ -1183,7 +1202,9 @@ private static string GetScriptHash(string script) } private static bool IsSimpleType(Type type) - => type.IsPrimitive || type == typeof(string) || type.IsEnum; + { + return type.IsPrimitive || type == typeof(string) || type.IsEnum; + } private void LoadToUserData() { @@ -1299,7 +1320,7 @@ string functionName } else { - moduleTable = new(LuaScript); + moduleTable = new Table(LuaScript); LuaScript.Globals[normalizedModuleName] = moduleTable; } @@ -1347,8 +1368,7 @@ private void RegisterEnum(Type enumType) var metatable = new Table(LuaScript); // __index: allows case-insensitive access - metatable["__index"] = DynValue.NewCallback( - (ctx, args) => + metatable["__index"] = DynValue.NewCallback((ctx, args) => { var key = args[1].String; @@ -1382,8 +1402,7 @@ private void RegisterEnum(Type enumType) ); // __newindex: prevents modifications (read-only) - metatable["__newindex"] = DynValue.NewCallback( - (ctx, args) => + metatable["__newindex"] = DynValue.NewCallback((ctx, args) => { var key = args[1].String; @@ -1392,11 +1411,7 @@ private void RegisterEnum(Type enumType) ); // __tostring: pretty print - metatable["__tostring"] = DynValue.NewCallback( - (ctx, args) => - { - return DynValue.NewString($"enum<{enumName}>"); - } + metatable["__tostring"] = DynValue.NewCallback((ctx, args) => { return DynValue.NewString($"enum<{enumName}>"); } ); // Set the enum table first @@ -1438,9 +1453,9 @@ private void RegisterEnums() private void RegisterGlobalFunctions() { LuaScript.Globals["delay"] = (Func)(async milliseconds => - { - await Task.Delay(Math.Min(milliseconds, 5000)); - }); + { + await Task.Delay(Math.Min(milliseconds, 5000)); + }); // NOTE: do NOT define a bare 'log' global here — the LogModule registered above already // exposes 'log.info / log.warning / log.error' as a table; overwriting it with a function @@ -1451,7 +1466,7 @@ private void RegisterGlobalFunctions() private void RegisterManualModuleFunction(string moduleName, string functionName) { - var functions = _manualModuleFunctions.GetOrAdd(moduleName, _ => new()); + var functions = _manualModuleFunctions.GetOrAdd(moduleName, _ => new ConcurrentDictionary()); functions.TryAdd(functionName, 0); } diff --git a/src/SquidStd.Scripting.Lua/Utils/LuaDocumentationGenerator.cs b/src/SquidStd.Scripting.Lua/Utils/LuaDocumentationGenerator.cs index bc69e34c..92e25e5b 100644 --- a/src/SquidStd.Scripting.Lua/Utils/LuaDocumentationGenerator.cs +++ b/src/SquidStd.Scripting.Lua/Utils/LuaDocumentationGenerator.cs @@ -11,8 +11,8 @@ namespace SquidStd.Scripting.Lua.Utils; /// -/// Utility class for generating Lua meta files with EmmyLua/LuaLS annotations -/// Automatically creates meta.lua files with function signatures, types, and documentation +/// Utility class for generating Lua meta files with EmmyLua/LuaLS annotations +/// Automatically creates meta.lua files with function signatures, types, and documentation /// [RequiresUnreferencedCode( "This class uses reflection to analyze types for Lua meta generation and requires full type metadata." @@ -30,12 +30,12 @@ public static class LuaDocumentationGenerator private static Func _nameResolver = name => name.ToSnakeCase(); /// - /// List of enums found during documentation generation + /// List of enums found during documentation generation /// public static List FoundEnums { get; } = new(16); /// - /// Adds a class type to be generated in the documentation + /// Adds a class type to be generated in the documentation /// public static void AddClassToGenerate(Type type) { @@ -44,7 +44,7 @@ public static void AddClassToGenerate(Type type) } /// - /// Clears all internal caches and state + /// Clears all internal caches and state /// public static void ClearCaches() { @@ -60,13 +60,12 @@ public static void ClearCaches() } } - [SuppressMessage("Trimming", "IL2075:Reflection", Justification = "Reflection is required for script module analysis"), - SuppressMessage( - "Trimming", - "IL2072:Reflection", - Justification = "Reflection is required for parameter and return type analysis" - )] - + [SuppressMessage("Trimming", "IL2075:Reflection", Justification = "Reflection is required for script module analysis")] + [SuppressMessage( + "Trimming", + "IL2072:Reflection", + Justification = "Reflection is required for parameter and return type analysis" + )] /// /// Generates Lua documentation meta file with all module functions, classes, and constants /// @@ -125,8 +124,8 @@ public static string GenerateDocumentation( } var distinctConstants = constants - .GroupBy(kvp => kvp.Key) - .ToDictionary(g => g.Key, g => g.First().Value); + .GroupBy(kvp => kvp.Key) + .ToDictionary(g => g.Key, g => g.First().Value); ProcessConstants(distinctConstants); sb.Append(_constantsBuilder); @@ -157,9 +156,9 @@ public static string GenerateDocumentation( // Get all methods with ScriptFunction attribute var methods = module.ModuleType - .GetMethods(BindingFlags.Public | BindingFlags.Instance) - .Where(m => m.GetCustomAttribute() is not null) - .ToList(); + .GetMethods(BindingFlags.Public | BindingFlags.Instance) + .Where(m => m.GetCustomAttribute() is not null) + .ToList(); foreach (var method in methods) { @@ -171,8 +170,8 @@ public static string GenerateDocumentation( } var functionName = string.IsNullOrWhiteSpace(scriptFunctionAttr.FunctionName) - ? _nameResolver(method.Name) - : scriptFunctionAttr.FunctionName; + ? _nameResolver(method.Name) + : scriptFunctionAttr.FunctionName; sb.AppendLine(CultureInfo.InvariantCulture, $"{moduleName}.{functionName} = function() end"); } @@ -191,8 +190,8 @@ public static string GenerateDocumentation( } var functionName = string.IsNullOrWhiteSpace(scriptFunctionAttr.FunctionName) - ? _nameResolver(method.Name) - : scriptFunctionAttr.FunctionName; + ? _nameResolver(method.Name) + : scriptFunctionAttr.FunctionName; var description = scriptFunctionAttr.HelpText ?? "No description available"; sb.AppendLine("---"); @@ -206,8 +205,8 @@ public static string GenerateDocumentation( { var isParams = param.IsDefined(typeof(ParamArrayAttribute), false); var paramType = isParams - ? ConvertToLuaType(param.ParameterType.GetElementType()!) - : ConvertToLuaType(param.ParameterType); + ? ConvertToLuaType(param.ParameterType.GetElementType()!) + : ConvertToLuaType(param.ParameterType); var paramName = isParams ? "..." : param.Name ?? $"param{Array.IndexOf(parameters, param)}"; var paramDescription = GetParameterDescription(param, paramType); sb.AppendLine( @@ -295,11 +294,13 @@ public static string GenerateDocumentation( } private static bool CanProcessType(Type type) - => true; + { + return true; + } - [SuppressMessage("Trimming", "IL2070:Reflection", Justification = "Reflection is required for Lua type conversion"), - SuppressMessage("Trimming", "IL2072:Reflection", Justification = "Reflection is required for Lua type conversion"), - SuppressMessage("Trimming", "IL2062:Reflection", Justification = "Reflection is required for Lua type conversion")] + [SuppressMessage("Trimming", "IL2070:Reflection", Justification = "Reflection is required for Lua type conversion")] + [SuppressMessage("Trimming", "IL2072:Reflection", Justification = "Reflection is required for Lua type conversion")] + [SuppressMessage("Trimming", "IL2062:Reflection", Justification = "Reflection is required for Lua type conversion")] private static string ConvertToLuaType( [DynamicallyAccessedMembers( DynamicallyAccessedMemberTypes.PublicMethods | @@ -584,7 +585,7 @@ private static string FormatConstantValue(object? value, Type type) } /// - /// Generate all classes after collecting them, with robust circular dependency handling + /// Generate all classes after collecting them, with robust circular dependency handling /// private static void GenerateAllClasses() { @@ -634,10 +635,10 @@ private static void GenerateAllClasses() } /// - /// Generate a single class with properties, constructors, and methods + /// Generate a single class with properties, constructors, and methods /// - [SuppressMessage("Trimming", "IL2070:Reflection", Justification = "Reflection is required for class generation"), - SuppressMessage("Trimming", "IL2072:Reflection", Justification = "Reflection is required for class generation")] + [SuppressMessage("Trimming", "IL2070:Reflection", Justification = "Reflection is required for class generation")] + [SuppressMessage("Trimming", "IL2072:Reflection", Justification = "Reflection is required for class generation")] private static void GenerateClass( [DynamicallyAccessedMembers( DynamicallyAccessedMemberTypes.PublicProperties | @@ -673,8 +674,8 @@ Type type // Generate properties with documentation (exclude indexers which have parameters) var properties = type.GetProperties(BindingFlags.Public | BindingFlags.Instance) - .Where(p => p.CanRead && p.GetIndexParameters().Length == 0) - .ToList(); + .Where(p => p.CanRead && p.GetIndexParameters().Length == 0) + .ToList(); foreach (var property in properties) { @@ -693,8 +694,8 @@ Type type // Use original property name for built-in types (XNA), apply resolver for custom types var displayName = luaFieldAttr?.Name ?? (type.Namespace?.StartsWith("Microsoft.Xna.Framework", StringComparison.Ordinal) == true - ? propertyName - : _nameResolver(propertyName)); + ? propertyName + : _nameResolver(propertyName)); _classesBuilder.AppendLine( CultureInfo.InvariantCulture, @@ -704,8 +705,8 @@ Type type // Generate public constructors var constructors = type.GetConstructors(BindingFlags.Public) - .Where(c => c.GetParameters().Length > 0) - .ToList(); + .Where(c => c.GetParameters().Length > 0) + .ToList(); if (constructors.Count > 0) { @@ -728,8 +729,8 @@ Type type // Use original parameter names for XNA types, apply resolver for custom types var paramName = isXnaType - ? param.Name ?? $"param{i}" - : _nameResolver(param.Name ?? $"param{i}"); + ? param.Name ?? $"param{i}" + : _nameResolver(param.Name ?? $"param{i}"); _classesBuilder.Append(CultureInfo.InvariantCulture, $"{paramName}: {paramType}"); if (i < parameters.Length - 1) @@ -769,12 +770,11 @@ Type type }; var methods = type.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly) - .Where( - m => !propertyMethods.Contains(m.Name) && - !m.IsSpecialName && - !excludedMethods.Contains(m.Name) - ) - .ToList(); + .Where(m => !propertyMethods.Contains(m.Name) && + !m.IsSpecialName && + !excludedMethods.Contains(m.Name) + ) + .ToList(); if (methods.Count > 0) { @@ -794,8 +794,8 @@ Type type foreach (var method in methodGroup) { var returnType = method.ReturnType == typeof(void) - ? "nil" - : ConvertToLuaType(method.ReturnType); + ? "nil" + : ConvertToLuaType(method.ReturnType); var parameters = method.GetParameters(); @@ -808,8 +808,8 @@ Type type // Use original parameter names for XNA types, apply resolver for custom types var paramName = isXnaType - ? param.Name ?? $"param{i}" - : _nameResolver(param.Name ?? $"param{i}"); + ? param.Name ?? $"param{i}" + : _nameResolver(param.Name ?? $"param{i}"); _classesBuilder.Append(CultureInfo.InvariantCulture, $"{paramName}: {paramType}"); if (i < parameters.Length - 1) @@ -885,7 +885,7 @@ private static void GenerateEnumClass(Type enumType) } /// - /// Gets enhanced parameter description with type information + /// Gets enhanced parameter description with type information /// private static string GetParameterDescription(ParameterInfo param, string luaType) { @@ -933,7 +933,7 @@ private static string GetParameterDescription(ParameterInfo param, string luaTyp } /// - /// Gets enhanced return description with type information + /// Gets enhanced return description with type information /// private static string GetReturnDescription(Type returnType, string luaType) { @@ -968,7 +968,7 @@ private static string GetReturnDescription(Type returnType, string luaType) } /// - /// Check if a type is a C# record type + /// Check if a type is a C# record type /// [SuppressMessage("Trimming", "IL2070:Reflection", Justification = "Reflection is required for record type detection")] private static bool IsRecordType( @@ -1005,11 +1005,10 @@ Type type } var methods = type.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly); - var hasCompilerGeneratedToString = methods.Any( - m => - m.Name == "ToString" && - m.GetParameters().Length == 0 && - m.GetCustomAttributes().Any(attr => attr.GetType().Name.Contains("CompilerGenerated")) + var hasCompilerGeneratedToString = methods.Any(m => + m.Name == "ToString" && + m.GetParameters().Length == 0 && + m.GetCustomAttributes().Any(attr => attr.GetType().Name.Contains("CompilerGenerated")) ); var result = hasCompilerGeneratedToString; diff --git a/src/SquidStd.Search.Abstractions/Attributes/SearchIndexAttribute.cs b/src/SquidStd.Search.Abstractions/Attributes/SearchIndexAttribute.cs index f1c689ec..be4eeb47 100644 --- a/src/SquidStd.Search.Abstractions/Attributes/SearchIndexAttribute.cs +++ b/src/SquidStd.Search.Abstractions/Attributes/SearchIndexAttribute.cs @@ -1,18 +1,18 @@ namespace SquidStd.Search.Abstractions.Attributes; /// -/// Declares the Elasticsearch index for a type. The name supports environment-variable expansion: -/// ${VAR} and ${VAR:-default} (e.g. "orders_${ENV:-dev}"). +/// Declares the Elasticsearch index for a type. The name supports environment-variable expansion: +/// ${VAR} and ${VAR:-default} (e.g. "orders_${ENV:-dev}"). /// [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, Inherited = false)] public sealed class SearchIndexAttribute : Attribute { - /// The index name (template). - public string Name { get; } - /// Initializes the attribute with the index name template. public SearchIndexAttribute(string name) { Name = name; } + + /// The index name (template). + public string Name { get; } } diff --git a/src/SquidStd.Search.Abstractions/Exceptions/SearchException.cs b/src/SquidStd.Search.Abstractions/Exceptions/SearchException.cs index 69b187f7..d82f80a0 100644 --- a/src/SquidStd.Search.Abstractions/Exceptions/SearchException.cs +++ b/src/SquidStd.Search.Abstractions/Exceptions/SearchException.cs @@ -5,9 +5,13 @@ public sealed class SearchException : Exception { /// Initializes the exception with a message. public SearchException(string message) - : base(message) { } + : base(message) + { + } /// Initializes the exception with a message and inner exception. public SearchException(string message, Exception innerException) - : base(message, innerException) { } + : base(message, innerException) + { + } } diff --git a/src/SquidStd.Search.Abstractions/Interfaces/IIndexableEntity.cs b/src/SquidStd.Search.Abstractions/Interfaces/IIndexableEntity.cs index 4624c8a2..80be32f4 100644 --- a/src/SquidStd.Search.Abstractions/Interfaces/IIndexableEntity.cs +++ b/src/SquidStd.Search.Abstractions/Interfaces/IIndexableEntity.cs @@ -1,8 +1,8 @@ namespace SquidStd.Search.Abstractions.Interfaces; /// -/// Marks an entity as indexable and supplies its document id. The target index comes from a -/// on the type (or the lowercased type name). +/// Marks an entity as indexable and supplies its document id. The target index comes from a +/// on the type (or the lowercased type name). /// public interface IIndexableEntity { diff --git a/src/SquidStd.Search.Abstractions/Interfaces/ISearchService.cs b/src/SquidStd.Search.Abstractions/Interfaces/ISearchService.cs index be94562d..b7f9c6b4 100644 --- a/src/SquidStd.Search.Abstractions/Interfaces/ISearchService.cs +++ b/src/SquidStd.Search.Abstractions/Interfaces/ISearchService.cs @@ -1,7 +1,7 @@ namespace SquidStd.Search.Abstractions.Interfaces; /// -/// Indexes, deletes, and queries documents. +/// Indexes, deletes, and queries documents. /// public interface ISearchService { diff --git a/src/SquidStd.Search.Abstractions/Search/SearchIndexNameResolver.cs b/src/SquidStd.Search.Abstractions/Search/SearchIndexNameResolver.cs index 1a8b04f6..febd3317 100644 --- a/src/SquidStd.Search.Abstractions/Search/SearchIndexNameResolver.cs +++ b/src/SquidStd.Search.Abstractions/Search/SearchIndexNameResolver.cs @@ -4,9 +4,9 @@ namespace SquidStd.Search.Abstractions.Search; /// -/// Resolves the Elasticsearch index name for a type from its (or the -/// lowercased type name), expanding ${VAR} / ${VAR:-default} from environment variables. The -/// result is always lowercased (an Elasticsearch requirement). +/// Resolves the Elasticsearch index name for a type from its (or the +/// lowercased type name), expanding ${VAR} / ${VAR:-default} from environment variables. The +/// result is always lowercased (an Elasticsearch requirement). /// public static partial class SearchIndexNameResolver { @@ -17,14 +17,15 @@ public static string Resolve(Type type) var template = type.GetCustomAttributes(typeof(SearchIndexAttribute), false) is { Length: > 0 } attributes && attributes[0] is SearchIndexAttribute attribute - ? attribute.Name - : type.Name; + ? attribute.Name + : type.Name; return ExpandEnvironment(template).ToLowerInvariant(); } private static string ExpandEnvironment(string template) - => PlaceholderRegex() + { + return PlaceholderRegex() .Replace( template, match => @@ -48,6 +49,7 @@ private static string ExpandEnvironment(string template) ); } ); + } [GeneratedRegex(@"\$\{(?[A-Za-z_][A-Za-z0-9_]*)(:-(?[^}]*))?\}")] private static partial Regex PlaceholderRegex(); diff --git a/src/SquidStd.Search.Elasticsearch/Extensions/SearchRegistrationExtensions.cs b/src/SquidStd.Search.Elasticsearch/Extensions/SearchRegistrationExtensions.cs index 0e1973e5..a63cb2a5 100644 --- a/src/SquidStd.Search.Elasticsearch/Extensions/SearchRegistrationExtensions.cs +++ b/src/SquidStd.Search.Elasticsearch/Extensions/SearchRegistrationExtensions.cs @@ -42,6 +42,6 @@ private static ElasticsearchClient CreateClient(ElasticsearchOptions options) settings = settings.ServerCertificateValidationCallback((_, _, _, _) => true); } - return new(settings); + return new ElasticsearchClient(settings); } } diff --git a/src/SquidStd.Search.Elasticsearch/Linq/ElasticExpressionTranslator.cs b/src/SquidStd.Search.Elasticsearch/Linq/ElasticExpressionTranslator.cs index 0eff7e6f..60aa43d9 100644 --- a/src/SquidStd.Search.Elasticsearch/Linq/ElasticExpressionTranslator.cs +++ b/src/SquidStd.Search.Elasticsearch/Linq/ElasticExpressionTranslator.cs @@ -5,10 +5,10 @@ namespace SquidStd.Search.Elasticsearch.Linq; /// -/// Translates a constrained LINQ expression chain into an . Supported: Where -/// (==, !=, <, >, <=, >=, &&, ||, !, bool members, string.Contains/StartsWith), -/// OrderBy/ThenBy(Descending), Skip, Take, and the Match/FullText markers. Anything else throws -/// . +/// Translates a constrained LINQ expression chain into an . Supported: Where +/// (==, !=, <, >, <=, >=, &&, ||, !, bool members, string.Contains/StartsWith), +/// OrderBy/ThenBy(Descending), Skip, Take, and the Match/FullText markers. Anything else throws +/// . /// public static class ElasticExpressionTranslator { @@ -25,7 +25,7 @@ public static ElasticQuery Translate(Expression expression, Type elementType) if (must.Count > 0) { - result.Query = new() { ["bool"] = new JsonObject { ["must"] = must } }; + result.Query = new JsonObject { ["bool"] = new JsonObject { ["must"] = must } }; } if (sort.Count > 0) @@ -37,9 +37,11 @@ public static ElasticQuery Translate(Expression expression, Type elementType) } private static object? EvaluateValue(Expression expression) - => expression is ConstantExpression constant - ? constant.Value - : Expression.Lambda(expression).Compile().DynamicInvoke(); + { + return expression is ConstantExpression constant + ? constant.Value + : Expression.Lambda(expression).Compile().DynamicInvoke(); + } private static string FieldName(MemberExpression member) { @@ -56,15 +58,19 @@ private static string FieldName(MemberExpression member) } private static object? GetConstant(Expression expression) - => EvaluateValue(expression); + { + return EvaluateValue(expression); + } private static bool IsParameterBound(Expression expression) - => expression switch + { + return expression switch { ParameterExpression => true, MemberExpression m => m.Expression is not null && IsParameterBound(m.Expression), _ => false }; + } private static (MemberExpression Member, Expression Value) OrientMemberValue(BinaryExpression binary) { @@ -82,27 +88,33 @@ private static (MemberExpression Member, Expression Value) OrientMemberValue(Bin } private static JsonObject Range(string field, string op, JsonNode? value) - => new() { ["range"] = new JsonObject { [field] = new JsonObject { [op] = value } } }; + { + return new JsonObject { ["range"] = new JsonObject { [field] = new JsonObject { [op] = value } } }; + } private static JsonObject SortClause(Expression keySelector, string order) { var member = (MemberExpression)UnquoteLambda(keySelector).Body; - return new() { [FieldName(member)] = new JsonObject { ["order"] = order } }; + return new JsonObject { [FieldName(member)] = new JsonObject { ["order"] = order } }; } private static Expression StripConvert(Expression expression) - => expression is UnaryExpression { NodeType: ExpressionType.Convert } convert ? convert.Operand : expression; + { + return expression is UnaryExpression { NodeType: ExpressionType.Convert } convert ? convert.Operand : expression; + } private static JsonObject TermOrKeyword(string field, Type memberType, JsonNode? value) { var termField = memberType == typeof(string) ? $"{field}.keyword" : field; - return new() { ["term"] = new JsonObject { [termField] = value } }; + return new JsonObject { ["term"] = new JsonObject { [termField] = value } }; } private static JsonNode? ToJsonValue(object? value) - => value is null ? null : JsonSerializer.SerializeToNode(value, WebOptions); + { + return value is null ? null : JsonSerializer.SerializeToNode(value, WebOptions); + } private static JsonObject TranslateComparison(BinaryExpression binary) { @@ -113,7 +125,7 @@ private static JsonObject TranslateComparison(BinaryExpression binary) return binary.NodeType switch { ExpressionType.Equal => TermOrKeyword(field, member.Type, value), - ExpressionType.NotEqual => new() + ExpressionType.NotEqual => new JsonObject { ["bool"] = new JsonObject { ["must_not"] = new JsonArray(TermOrKeyword(field, member.Type, value)) } }, ExpressionType.GreaterThan => Range(field, "gt", value), ExpressionType.GreaterThanOrEqual => Range(field, "gte", value), @@ -128,13 +140,13 @@ private static JsonObject TranslatePredicate(Expression expression) switch (expression) { case BinaryExpression { NodeType: ExpressionType.AndAlso } and1: - return new() + return new JsonObject { ["bool"] = new JsonObject { ["must"] = new JsonArray(TranslatePredicate(and1.Left), TranslatePredicate(and1.Right)) } }; case BinaryExpression { NodeType: ExpressionType.OrElse } or1: - return new() + return new JsonObject { ["bool"] = new JsonObject { @@ -143,11 +155,12 @@ private static JsonObject TranslatePredicate(Expression expression) } }; case UnaryExpression { NodeType: ExpressionType.Not } not: - return new() { ["bool"] = new JsonObject { ["must_not"] = new JsonArray(TranslatePredicate(not.Operand)) } }; + return new JsonObject + { ["bool"] = new JsonObject { ["must_not"] = new JsonArray(TranslatePredicate(not.Operand)) } }; case BinaryExpression binary: return TranslateComparison(binary); case MemberExpression member when member.Type == typeof(bool): - return new() { ["term"] = new JsonObject { [FieldName(member)] = true } }; + return new JsonObject { ["term"] = new JsonObject { [FieldName(member)] = true } }; case MethodCallExpression methodCall: return TranslateStringMethod(methodCall); default: @@ -167,21 +180,25 @@ private static JsonObject TranslateStringMethod(MethodCallExpression call) return call.Method.Name switch { - "Contains" => new() { ["wildcard"] = new JsonObject { [$"{field}.keyword"] = $"*{arg}*" } }, - "StartsWith" => new() { ["prefix"] = new JsonObject { [$"{field}.keyword"] = arg } }, + "Contains" => new JsonObject { ["wildcard"] = new JsonObject { [$"{field}.keyword"] = $"*{arg}*" } }, + "StartsWith" => new JsonObject { ["prefix"] = new JsonObject { [$"{field}.keyword"] = arg } }, _ => throw Unsupported(call) }; } private static LambdaExpression UnquoteLambda(Expression expression) - => expression is UnaryExpression { NodeType: ExpressionType.Quote } quote - ? (LambdaExpression)quote.Operand - : (LambdaExpression)expression; + { + return expression is UnaryExpression { NodeType: ExpressionType.Quote } quote + ? (LambdaExpression)quote.Operand + : (LambdaExpression)expression; + } private static NotSupportedException Unsupported(Expression expression) - => new( + { + return new NotSupportedException( $"Expression '{expression}' is not supported by the Elasticsearch provider. Use the native ElasticsearchClient for advanced queries." ); + } private static void Walk(Expression expression, JsonArray must, JsonArray sort, ElasticQuery result) { diff --git a/src/SquidStd.Search.Elasticsearch/Linq/ElasticQueryProvider.cs b/src/SquidStd.Search.Elasticsearch/Linq/ElasticQueryProvider.cs index 93359d4d..65f5c23e 100644 --- a/src/SquidStd.Search.Elasticsearch/Linq/ElasticQueryProvider.cs +++ b/src/SquidStd.Search.Elasticsearch/Linq/ElasticQueryProvider.cs @@ -6,14 +6,14 @@ namespace SquidStd.Search.Elasticsearch.Linq; /// -/// Executes a translated against Elasticsearch. Synchronous LINQ execution is not -/// supported — use the async terminals in . +/// Executes a translated against Elasticsearch. Synchronous LINQ execution is not +/// supported — use the async terminals in . /// public sealed class ElasticQueryProvider : IQueryProvider { - private readonly ElasticTransport _transport; - private readonly string _index; private readonly Type _elementType; + private readonly string _index; + private readonly ElasticTransport _transport; public ElasticQueryProvider(ElasticTransport transport, string index, Type elementType) { @@ -22,6 +22,30 @@ public ElasticQueryProvider(ElasticTransport transport, string index, Type eleme _elementType = elementType; } + public IQueryable CreateQuery(Expression expression) + { + return (IQueryable)Activator.CreateInstance( + typeof(ElasticQueryable<>).MakeGenericType(_elementType), + this, + expression + )!; + } + + public IQueryable CreateQuery(Expression expression) + { + return new ElasticQueryable(this, expression); + } + + public object? Execute(Expression expression) + { + throw new NotSupportedException("Use the async terminals (ToListAsync/CountAsync/FirstOrDefaultAsync)."); + } + + public TResult Execute(Expression expression) + { + throw new NotSupportedException("Use the async terminals (ToListAsync/CountAsync/FirstOrDefaultAsync)."); + } + /// Runs a count and returns the total. public async Task CountAsync(Expression expression, CancellationToken cancellationToken) { @@ -32,28 +56,16 @@ public async Task CountAsync(Expression expression, CancellationToken canc return status == 404 ? 0 : response?["count"]?.GetValue() ?? 0; } - public IQueryable CreateQuery(Expression expression) - => (IQueryable)Activator.CreateInstance(typeof(ElasticQueryable<>).MakeGenericType(_elementType), this, expression)!; - - public IQueryable CreateQuery(Expression expression) - => new ElasticQueryable(this, expression); - - public object? Execute(Expression expression) - => throw new NotSupportedException("Use the async terminals (ToListAsync/CountAsync/FirstOrDefaultAsync)."); - - public TResult Execute(Expression expression) - => throw new NotSupportedException("Use the async terminals (ToListAsync/CountAsync/FirstOrDefaultAsync)."); - /// Runs the search and returns the deserialized hits. public async Task> ToListAsync(Expression expression, CancellationToken cancellationToken) { var query = ElasticExpressionTranslator.Translate(expression, _elementType); var (status, body) = await _transport.SendAsync( - HttpMethod.POST, - $"/{_index}/_search", - query.ToRequestBody(), - cancellationToken - ); + HttpMethod.POST, + $"/{_index}/_search", + query.ToRequestBody(), + cancellationToken + ); if (status == 404) { diff --git a/src/SquidStd.Search.Elasticsearch/Linq/ElasticQueryable.cs b/src/SquidStd.Search.Elasticsearch/Linq/ElasticQueryable.cs index a736a6b3..9bfefb01 100644 --- a/src/SquidStd.Search.Elasticsearch/Linq/ElasticQueryable.cs +++ b/src/SquidStd.Search.Elasticsearch/Linq/ElasticQueryable.cs @@ -25,8 +25,12 @@ public ElasticQueryable(ElasticQueryProvider provider) public IQueryProvider Provider { get; } public IEnumerator GetEnumerator() - => throw new NotSupportedException("Use the async terminals (ToListAsync/CountAsync/FirstOrDefaultAsync)."); + { + throw new NotSupportedException("Use the async terminals (ToListAsync/CountAsync/FirstOrDefaultAsync)."); + } IEnumerator IEnumerable.GetEnumerator() - => GetEnumerator(); + { + return GetEnumerator(); + } } diff --git a/src/SquidStd.Search.Elasticsearch/Linq/ElasticQueryableExtensions.cs b/src/SquidStd.Search.Elasticsearch/Linq/ElasticQueryableExtensions.cs index 7df9b8d4..a4688c77 100644 --- a/src/SquidStd.Search.Elasticsearch/Linq/ElasticQueryableExtensions.cs +++ b/src/SquidStd.Search.Elasticsearch/Linq/ElasticQueryableExtensions.cs @@ -4,14 +4,16 @@ namespace SquidStd.Search.Elasticsearch.Linq; /// -/// LINQ surface for the Elasticsearch provider. and are -/// markers recognized by the translator (no standalone runtime behavior); the async terminals execute the query. +/// LINQ surface for the Elasticsearch provider. and are +/// markers recognized by the translator (no standalone runtime behavior); the async terminals execute the query. /// public static class ElasticQueryableExtensions { /// Executes a count of matching documents. public static Task CountAsync(this IQueryable source, CancellationToken cancellationToken = default) - => Provider(source).CountAsync(source.Expression, cancellationToken); + { + return Provider(source).CountAsync(source.Expression, cancellationToken); + } /// Executes the query and returns the first matching document, or null. public static async Task FirstOrDefaultAsync( @@ -27,17 +29,20 @@ public static Task CountAsync(this IQueryable source, CancellationTo /// Full-text match of across all fields. public static IQueryable FullText(this IQueryable source, string text) - => source.Provider.CreateQuery( + { + return source.Provider.CreateQuery( Expression.Call( ((MethodInfo)MethodBase.GetCurrentMethod()!).MakeGenericMethod(typeof(T)), source.Expression, Expression.Constant(text) ) ); + } /// Full-text match of against a single field. public static IQueryable Match(this IQueryable source, string field, string text) - => source.Provider.CreateQuery( + { + return source.Provider.CreateQuery( Expression.Call( ((MethodInfo)MethodBase.GetCurrentMethod()!).MakeGenericMethod(typeof(T)), source.Expression, @@ -45,12 +50,19 @@ public static IQueryable Match(this IQueryable source, string field, st Expression.Constant(text) ) ); + } /// Executes the query and returns all matching documents. public static Task> ToListAsync(this IQueryable source, CancellationToken cancellationToken = default) - => Provider(source).ToListAsync(source.Expression, cancellationToken); + { + return Provider(source).ToListAsync(source.Expression, cancellationToken); + } private static ElasticQueryProvider Provider(IQueryable source) - => source.Provider as ElasticQueryProvider ?? - throw new NotSupportedException("These async terminals require a query created by ISearchService.Query()."); + { + return source.Provider as ElasticQueryProvider ?? + throw new NotSupportedException( + "These async terminals require a query created by ISearchService.Query()." + ); + } } diff --git a/src/SquidStd.Search.Elasticsearch/Services/ElasticSearchService.cs b/src/SquidStd.Search.Elasticsearch/Services/ElasticSearchService.cs index 3be78e80..d74c3d4f 100644 --- a/src/SquidStd.Search.Elasticsearch/Services/ElasticSearchService.cs +++ b/src/SquidStd.Search.Elasticsearch/Services/ElasticSearchService.cs @@ -11,14 +11,14 @@ namespace SquidStd.Search.Elasticsearch.Services; /// -/// Default : indexes, deletes, and queries documents over the Elasticsearch -/// low-level transport, with index names resolved from [SearchIndex] + the configured prefix. +/// Default : indexes, deletes, and queries documents over the Elasticsearch +/// low-level transport, with index names resolved from [SearchIndex] + the configured prefix. /// public sealed class ElasticSearchService : ISearchService { + private readonly string? _indexPrefix; private readonly ILogger _logger = Log.ForContext(); private readonly ElasticTransport _transport; - private readonly string? _indexPrefix; public ElasticSearchService(ElasticTransport transport, ElasticsearchOptions options) { @@ -82,11 +82,11 @@ public async Task IndexAsync(T entity, bool refresh = false, CancellationToke var index = ResolveIndex(); var path = $"/{index}/_doc/{Uri.EscapeDataString(entity.IndexId)}{RefreshQuery(refresh)}"; var (status, body) = await _transport.SendAsync( - HttpMethod.PUT, - path, - ElasticTransport.SerializeDocument(entity), - cancellationToken - ); + HttpMethod.PUT, + path, + ElasticTransport.SerializeDocument(entity), + cancellationToken + ); EnsureSuccess(status, body, $"index document '{entity.IndexId}' into '{index}'"); } @@ -130,7 +130,9 @@ public async Task IndexManyAsync( /// public IQueryable Query() where T : IIndexableEntity - => new ElasticQueryable(new(_transport, ResolveIndex(), typeof(T))); + { + return new ElasticQueryable(new ElasticQueryProvider(_transport, ResolveIndex(), typeof(T))); + } /// Resolves the (prefixed, lowercased) index name for a type. public string ResolveIndex() @@ -154,5 +156,7 @@ private void EnsureSuccess(int status, JsonNode? body, string operation) } private static string RefreshQuery(bool refresh) - => refresh ? "?refresh=wait_for" : string.Empty; + { + return refresh ? "?refresh=wait_for" : string.Empty; + } } diff --git a/src/SquidStd.Search.Elasticsearch/Services/ElasticTransport.cs b/src/SquidStd.Search.Elasticsearch/Services/ElasticTransport.cs index 3ab5c941..70e81ecf 100644 --- a/src/SquidStd.Search.Elasticsearch/Services/ElasticTransport.cs +++ b/src/SquidStd.Search.Elasticsearch/Services/ElasticTransport.cs @@ -7,8 +7,8 @@ namespace SquidStd.Search.Elasticsearch.Services; /// -/// Thin JSON request helper over the Elasticsearch client's low-level transport. Sends raw DSL/document JSON -/// and returns the parsed response, decoupling the provider from the strongly-typed query DSL. +/// Thin JSON request helper over the Elasticsearch client's low-level transport. Sends raw DSL/document JSON +/// and returns the parsed response, decoupling the provider from the strongly-typed query DSL. /// public sealed class ElasticTransport { @@ -37,7 +37,9 @@ public ElasticTransport(ElasticsearchClient client) /// Deserializes a document body (an Elasticsearch _source) to . public static T DeserializeDocument(JsonNode source) - => source.Deserialize(WebOptions)!; + { + return source.Deserialize(WebOptions)!; + } /// Sends a request with an optional JSON body and returns (statusCode, bodyJson). public Task<(int Status, JsonNode? Body)> SendAsync( @@ -46,7 +48,9 @@ public static T DeserializeDocument(JsonNode source) JsonNode? body, CancellationToken cancellationToken ) - => SendCoreAsync(method, path, body?.ToJsonString(), JsonRequest, cancellationToken); + { + return SendCoreAsync(method, path, body?.ToJsonString(), JsonRequest, cancellationToken); + } /// Sends a raw (already-serialized) NDJSON body for the bulk API. public Task<(int Status, JsonNode? Body)> SendRawAsync( @@ -55,11 +59,15 @@ CancellationToken cancellationToken string? body, CancellationToken cancellationToken ) - => SendCoreAsync(method, path, body, NdjsonRequest, cancellationToken); + { + return SendCoreAsync(method, path, body, NdjsonRequest, cancellationToken); + } /// Serializes a value to a using Web (camelCase) defaults. public static JsonNode SerializeDocument(T value) - => JsonSerializer.SerializeToNode(value, WebOptions)!; + { + return JsonSerializer.SerializeToNode(value, WebOptions)!; + } private async Task<(int Status, JsonNode? Body)> SendCoreAsync( HttpMethod method, @@ -72,14 +80,14 @@ CancellationToken cancellationToken var postData = body is null ? null : PostData.String(body); var response = await _client.Transport - .RequestAsync( - method, - path, - postData, - requestConfiguration, - cancellationToken - ) - .ConfigureAwait(false); + .RequestAsync( + method, + path, + postData, + requestConfiguration, + cancellationToken + ) + .ConfigureAwait(false); var status = response.ApiCallDetails.HttpStatusCode ?? 0; var text = response.Body; diff --git a/src/SquidStd.Services.Core/Extensions/Logger/SquidStdLogRollingIntervalExtensions.cs b/src/SquidStd.Services.Core/Extensions/Logger/SquidStdLogRollingIntervalExtensions.cs index 92d9d929..c40a727f 100644 --- a/src/SquidStd.Services.Core/Extensions/Logger/SquidStdLogRollingIntervalExtensions.cs +++ b/src/SquidStd.Services.Core/Extensions/Logger/SquidStdLogRollingIntervalExtensions.cs @@ -4,17 +4,18 @@ namespace SquidStd.Services.Core.Extensions.Logger; /// -/// Extension methods for converting SquidStd logger options to Serilog values. +/// Extension methods for converting SquidStd logger options to Serilog values. /// public static class SquidStdLogRollingIntervalExtensions { /// - /// Converts a SquidStd rolling interval to a Serilog rolling interval. + /// Converts a SquidStd rolling interval to a Serilog rolling interval. /// /// The rolling interval to convert. /// The corresponding Serilog rolling interval. public static RollingInterval ToSerilogRollingInterval(this SquidStdLogRollingIntervalType interval) - => interval switch + { + return interval switch { SquidStdLogRollingIntervalType.Infinite => RollingInterval.Infinite, SquidStdLogRollingIntervalType.Year => RollingInterval.Year, @@ -24,4 +25,5 @@ public static RollingInterval ToSerilogRollingInterval(this SquidStdLogRollingIn SquidStdLogRollingIntervalType.Minute => RollingInterval.Minute, _ => RollingInterval.Day }; + } } diff --git a/src/SquidStd.Services.Core/Extensions/RegisterDefaultServicesExtensions.cs b/src/SquidStd.Services.Core/Extensions/RegisterDefaultServicesExtensions.cs index e7664ea3..5a1faac1 100644 --- a/src/SquidStd.Services.Core/Extensions/RegisterDefaultServicesExtensions.cs +++ b/src/SquidStd.Services.Core/Extensions/RegisterDefaultServicesExtensions.cs @@ -4,6 +4,7 @@ using SquidStd.Abstractions.Extensions.Container; using SquidStd.Abstractions.Extensions.Services; using SquidStd.Core.Data.Bootstrap; +using SquidStd.Core.Data.Events; using SquidStd.Core.Data.Jobs; using SquidStd.Core.Data.Metrics; using SquidStd.Core.Data.Storage; @@ -25,7 +26,7 @@ namespace SquidStd.Services.Core.Extensions; /// -/// Extension methods for registering the default SquidStd core services. +/// Extension methods for registering the default SquidStd core services. /// public static class RegisterDefaultServicesExtensions { @@ -45,14 +46,16 @@ public IContainer RegisterConfigManagerService(string configName, string configD } /// - /// Registers the default SquidStd core services using the default config file location. + /// Registers the default SquidStd core services using the default config file location. /// /// The same container for chaining. public IContainer RegisterCoreServices() - => container.RegisterCoreServices("squidstd", Directory.GetCurrentDirectory()); + { + return container.RegisterCoreServices("squidstd", Directory.GetCurrentDirectory()); + } /// - /// Registers the default SquidStd core services and config manager. + /// Registers the default SquidStd core services and config manager. /// /// The logical config name or YAML file name. /// The directory where the config file is searched. @@ -74,14 +77,14 @@ public IContainer RegisterCoreServices(string configName, string configDirectory } /// - /// Registers the default config manager service as a singleton instance. + /// Registers the default config manager service as a singleton instance. /// /// The logical config name or YAML file name. /// The directory where the config file is searched. /// The same container for chaining. /// - /// Registers the default JSON data serializer for and - /// (same singleton instance). + /// Registers the default JSON data serializer for and + /// (same singleton instance). /// /// The same container for chaining. public IContainer RegisterDataSerializer() @@ -94,7 +97,7 @@ public IContainer RegisterDataSerializer() } /// - /// Registers the default SquidStd core configuration sections. + /// Registers the default SquidStd core configuration sections. /// /// The same container for chaining. public IContainer RegisterDefaultCoreConfigSections() @@ -110,14 +113,26 @@ public IContainer RegisterDefaultCoreConfigSections() } /// - /// Registers the default event bus service in the container. + /// Registers the default event bus service in the container. /// /// The same container for chaining. public IContainer RegisterEventBusService() - => container.RegisterStdService(-1); + { + container.RegisterInstance(new EventBusOptions()); + container.RegisterDelegate( + resolver => new EventBusService(resolver.Resolve()), + Reuse.Singleton + ); + container.AddToRegisterTypedList( + new ServiceRegistrationData(typeof(IEventBus), typeof(EventBusService), -1) + ); + container.RegisterStdService(-900); + + return container; + } /// - /// Registers the default job system service in the container. + /// Registers the default job system service in the container. /// /// The same container for chaining. public IContainer RegisterJobSystemService() @@ -128,14 +143,16 @@ public IContainer RegisterJobSystemService() } /// - /// Registers the default main-thread dispatcher service in the container. + /// Registers the default main-thread dispatcher service in the container. /// /// The same container for chaining. public IContainer RegisterMainThreadDispatcherService() - => container.RegisterStdService(-1); + { + return container.RegisterStdService(-1); + } /// - /// Registers the default metrics collection service in the container. + /// Registers the default metrics collection service in the container. /// /// The same container for chaining. public IContainer RegisterMetricsCollectionService() @@ -146,7 +163,7 @@ public IContainer RegisterMetricsCollectionService() } /// - /// Registers default encrypted local secret services in the container. + /// Registers default encrypted local secret services in the container. /// /// The same container for chaining. public IContainer RegisterSecretServices() @@ -159,7 +176,7 @@ public IContainer RegisterSecretServices() } /// - /// Registers the default timer wheel service in the container. + /// Registers the default timer wheel service in the container. /// /// The same container for chaining. public IContainer RegisterTimerWheelService() diff --git a/src/SquidStd.Services.Core/Extensions/RegisterHealthChecksServiceExtension.cs b/src/SquidStd.Services.Core/Extensions/RegisterHealthChecksServiceExtension.cs index b04fd4f9..a15c540e 100644 --- a/src/SquidStd.Services.Core/Extensions/RegisterHealthChecksServiceExtension.cs +++ b/src/SquidStd.Services.Core/Extensions/RegisterHealthChecksServiceExtension.cs @@ -7,7 +7,7 @@ namespace SquidStd.Services.Core.Extensions; /// -/// Extension methods for registering the health-check aggregator. +/// Extension methods for registering the health-check aggregator. /// public static class RegisterHealthChecksServiceExtension { @@ -15,8 +15,8 @@ public static class RegisterHealthChecksServiceExtension extension(IContainer container) { /// - /// Registers the health-check aggregator () as a singleton. - /// Concrete checks register themselves as IHealthCheck and are collected automatically. + /// Registers the health-check aggregator () as a singleton. + /// Concrete checks register themselves as IHealthCheck and are collected automatically. /// /// The same container for chaining. public IContainer RegisterHealthChecksService() diff --git a/src/SquidStd.Services.Core/Extensions/RegisterSchedulerServicesExtension.cs b/src/SquidStd.Services.Core/Extensions/RegisterSchedulerServicesExtension.cs index dbed6903..98473b2d 100644 --- a/src/SquidStd.Services.Core/Extensions/RegisterSchedulerServicesExtension.cs +++ b/src/SquidStd.Services.Core/Extensions/RegisterSchedulerServicesExtension.cs @@ -8,7 +8,7 @@ namespace SquidStd.Services.Core.Extensions; /// -/// Extension methods for registering the SquidStd cron scheduler. +/// Extension methods for registering the SquidStd cron scheduler. /// public static class RegisterSchedulerServicesExtension { @@ -16,8 +16,8 @@ public static class RegisterSchedulerServicesExtension extension(IContainer container) { /// - /// Registers the timer wheel pump and the cron scheduler. Must be called after - /// RegisterCoreServices so that ITimerService and IJobSystem exist. + /// Registers the timer wheel pump and the cron scheduler. Must be called after + /// RegisterCoreServices so that ITimerService and IJobSystem exist. /// /// The same container for chaining. public IContainer RegisterSchedulerServices() diff --git a/src/SquidStd.Services.Core/Services/Bootstrap/SquidStdBootstrap.cs b/src/SquidStd.Services.Core/Services/Bootstrap/SquidStdBootstrap.cs index e97c277a..ba7ed39c 100644 --- a/src/SquidStd.Services.Core/Services/Bootstrap/SquidStdBootstrap.cs +++ b/src/SquidStd.Services.Core/Services/Bootstrap/SquidStdBootstrap.cs @@ -14,35 +14,33 @@ namespace SquidStd.Services.Core.Services.Bootstrap; /// -/// Default SquidStd bootstrapper and service lifecycle orchestrator. +/// Default SquidStd bootstrapper and service lifecycle orchestrator. /// public sealed class SquidStdBootstrap : ISquidStdBootstrap { - private readonly Lock _syncRoot = new(); - private readonly List _startedServices = []; private readonly bool _ownsContainer; + private readonly List _startedServices = []; + private readonly Lock _syncRoot = new(); private int _disposed; private bool _loggerConfigured; private BootstrapStateType _state; - /// - public SquidStdOptions Options { get; } - - /// - public IContainer Container { get; } - /// - /// Initializes a bootstrapper with default options. + /// Initializes a bootstrapper with default options. /// public SquidStdBootstrap() - : this(new()) { } + : this(new SquidStdOptions()) + { + } /// - /// Initializes a bootstrapper with the specified options. + /// Initializes a bootstrapper with the specified options. /// /// Bootstrap options used to register core services. public SquidStdBootstrap(SquidStdOptions options) - : this(options, new Container(), true) { } + : this(options, new Container(), true) + { + } private SquidStdBootstrap(SquidStdOptions options, IContainer container, bool ownsContainer) { @@ -61,9 +59,17 @@ private SquidStdBootstrap(SquidStdOptions options, IContainer container, bool ow Container.RegisterCoreServices(Options.ConfigName, Options.RootDirectory); } + /// + public SquidStdOptions Options { get; } + + /// + public IContainer Container { get; } + /// public ISquidStdBootstrap ConfigureService(Func configure) - => ConfigureServices(configure); + { + return ConfigureServices(configure); + } /// public ISquidStdBootstrap ConfigureServices(Func configure) @@ -82,34 +88,10 @@ public ISquidStdBootstrap ConfigureServices(Func configu var configuredContainer = configure(Container); return !ReferenceEquals(configuredContainer, Container) - ? throw new InvalidOperationException("ConfigureServices must return the bootstrap container instance.") - : this; + ? throw new InvalidOperationException("ConfigureServices must return the bootstrap container instance.") + : this; } - /// - /// Creates a bootstrapper with default options. - /// - /// The created bootstrapper. - public static SquidStdBootstrap Create() - => new(); - - /// - /// Creates a bootstrapper with the specified options. - /// - /// Bootstrap options used to register core services. - /// The created bootstrapper. - public static SquidStdBootstrap Create(SquidStdOptions options) - => new(options); - - /// - /// Creates a bootstrapper using an externally owned DryIoc container. - /// - /// Bootstrap options used to register core services. - /// Externally owned container that receives SquidStd services. - /// The created bootstrapper. - public static SquidStdBootstrap Create(SquidStdOptions options, IContainer container) - => new(options, container, false); - /// public async ValueTask DisposeAsync() { @@ -153,7 +135,9 @@ public async Task RunAsync(CancellationToken cancellationToken = default) { await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + } finally { await StopAsync(CancellationToken.None); @@ -222,6 +206,36 @@ public async ValueTask StopAsync(CancellationToken cancellationToken = default) } } + /// + /// Creates a bootstrapper with default options. + /// + /// The created bootstrapper. + public static SquidStdBootstrap Create() + { + return new SquidStdBootstrap(); + } + + /// + /// Creates a bootstrapper with the specified options. + /// + /// Bootstrap options used to register core services. + /// The created bootstrapper. + public static SquidStdBootstrap Create(SquidStdOptions options) + { + return new SquidStdBootstrap(options); + } + + /// + /// Creates a bootstrapper using an externally owned DryIoc container. + /// + /// Bootstrap options used to register core services. + /// Externally owned container that receives SquidStd services. + /// The created bootstrapper. + public static SquidStdBootstrap Create(SquidStdOptions options, IContainer container) + { + return new SquidStdBootstrap(options, container, false); + } + private void ConfigureLogger() { if (!Container.IsRegistered()) @@ -268,9 +282,9 @@ private ServiceRegistrationData[] GetServiceRegistrations() return [ .. Container.Resolve>() - .OrderBy(registration => registration.Priority) - .ThenBy(registration => registration.ServiceType.FullName, StringComparer.Ordinal) - .ThenBy(registration => registration.ImplementationType.FullName, StringComparer.Ordinal) + .OrderBy(registration => registration.Priority) + .ThenBy(registration => registration.ServiceType.FullName, StringComparer.Ordinal) + .ThenBy(registration => registration.ImplementationType.FullName, StringComparer.Ordinal) ]; } @@ -327,8 +341,8 @@ private string ResolveLogPath(SquidStdLoggerOptions options) var logDirectory = string.IsNullOrWhiteSpace(options.LogDirectory) ? "logs" : options.LogDirectory; var fileName = string.IsNullOrWhiteSpace(options.FileName) ? "squidstd-.log" : options.FileName; var directory = Path.IsPathRooted(logDirectory) - ? logDirectory - : Path.Combine(Options.RootDirectory, logDirectory); + ? logDirectory + : Path.Combine(Options.RootDirectory, logDirectory); return Path.Combine(directory, fileName); } diff --git a/src/SquidStd.Services.Core/Services/ConfigManagerService.cs b/src/SquidStd.Services.Core/Services/ConfigManagerService.cs index 09ac24d7..0a0189b8 100644 --- a/src/SquidStd.Services.Core/Services/ConfigManagerService.cs +++ b/src/SquidStd.Services.Core/Services/ConfigManagerService.cs @@ -9,7 +9,7 @@ namespace SquidStd.Services.Core.Services; /// -/// Loads YAML configuration sections and registers them into DryIoc. +/// Loads YAML configuration sections and registers them into DryIoc. /// public sealed class ConfigManagerService : IConfigManagerService, ISquidStdService { @@ -17,20 +17,8 @@ public sealed class ConfigManagerService : IConfigManagerService, ISquidStdServi private readonly Dictionary _values = []; private int _started; - /// - public string ConfigName { get; } - - /// - public string ConfigDirectory { get; } - - /// - public string ConfigPath { get; } - - /// - public IReadOnlyCollection Entries => GetEntries(); - /// - /// Initializes the config manager service. + /// Initializes the config manager service. /// /// Container that receives loaded configuration sections. /// Logical configuration name or YAML file name. @@ -46,13 +34,29 @@ public ConfigManagerService(IContainer container, string configName, string conf ConfigPath = ResolveConfigPath(configName, ConfigDirectory); } + /// + public string ConfigName { get; } + + /// + public string ConfigDirectory { get; } + + /// + public string ConfigPath { get; } + + /// + public IReadOnlyCollection Entries => GetEntries(); + /// public string Compose() - => YamlUtils.SerializeSections(BuildSectionMap()); + { + return YamlUtils.SerializeSections(BuildSectionMap()); + } /// public TConfig GetConfig() where TConfig : class - => _container.Resolve(); + { + return _container.Resolve(); + } /// public void Load() @@ -66,11 +70,11 @@ public void Load() { var entry = entries[i]; var value = string.IsNullOrWhiteSpace(yaml) - ? entry.CreateDefault() - : YamlUtils.DeserializeSection(yaml, entry.SectionName, entry.ConfigType) ?? - entry.CreateDefault(); + ? entry.CreateDefault() + : YamlUtils.DeserializeSection(yaml, entry.SectionName, entry.ConfigType) ?? + entry.CreateDefault(); - ApplyEnvSubstitution(value, new(ReferenceEqualityComparer.Instance)); + ApplyEnvSubstitution(value, new HashSet(ReferenceEqualityComparer.Instance)); _values[entry.ConfigType] = value; _container.RegisterInstance(entry.ConfigType, value, IfAlreadyRegistered.Replace); @@ -84,7 +88,9 @@ public void Load() /// public void Save() - => YamlUtils.SerializeToFile(BuildSectionMap(), ConfigPath); + { + YamlUtils.SerializeToFile(BuildSectionMap(), ConfigPath); + } /// public ValueTask StartAsync(CancellationToken cancellationToken = default) @@ -170,7 +176,9 @@ private Dictionary BuildSectionMap() } private IReadOnlyCollection GetEntries() - => GetRegistrations().Cast().ToArray(); + { + return GetRegistrations().Cast().ToArray(); + } private List GetRegistrations() { @@ -182,8 +190,8 @@ private List GetRegistrations() return [ .. _container.Resolve>() - .OrderBy(entry => entry.Priority) - .ThenBy(entry => entry.SectionName, StringComparer.Ordinal) + .OrderBy(entry => entry.Priority) + .ThenBy(entry => entry.SectionName, StringComparer.Ordinal) ]; } diff --git a/src/SquidStd.Services.Core/Services/EventBusService.cs b/src/SquidStd.Services.Core/Services/EventBusService.cs index ec36c514..5b1b29b9 100644 --- a/src/SquidStd.Services.Core/Services/EventBusService.cs +++ b/src/SquidStd.Services.Core/Services/EventBusService.cs @@ -1,39 +1,40 @@ -using System.Threading.Channels; +using System.Collections.Concurrent; +using System.Diagnostics; +using Serilog; +using SquidStd.Core.Data.Events; using SquidStd.Core.Interfaces.Events; using SquidStd.Services.Core.Services.Internal; namespace SquidStd.Services.Core.Services; /// -/// Dispatches events to registered listeners through an internal channel. +/// In-process event bus with parallel per-listener dispatch, catch-all listeners, +/// fault isolation, and slow-listener telemetry. /// public sealed class EventBusService : IEventBus, IDisposable { - private readonly Channel _dispatches; - private readonly Task _dispatcher; - private readonly Lock _listenerSync = new(); - private readonly Dictionary> _asyncListeners = []; - private readonly Dictionary> _syncListeners = []; + private readonly ConcurrentDictionary> _listeners = new(); + private readonly ILogger _logger = Log.ForContext(); + private readonly TimeSpan _slowListenerThreshold; private bool _disposed; /// - /// Initializes the event bus service. + /// Initializes the event bus with default options. /// public EventBusService() + : this(new EventBusOptions()) { - _dispatches = Channel.CreateUnbounded( - new() - { - SingleReader = true, - SingleWriter = false - } - ); - _dispatcher = Task.Run(ProcessDispatchesAsync); } /// - /// Stops the internal dispatcher. + /// Initializes the event bus with the supplied options. /// + public EventBusService(EventBusOptions options) + { + _slowListenerThreshold = options.SlowListenerThreshold; + } + + /// public void Dispose() { if (_disposed) @@ -42,124 +43,147 @@ public void Dispose() } _disposed = true; - _dispatches.Writer.TryComplete(); - _dispatcher.GetAwaiter().GetResult(); + _listeners.Clear(); } /// public void Publish(TEvent eventData) where TEvent : IEvent { - var listeners = GetListeners>(_syncListeners); - var dispatch = new EventDispatch( - () => - { - for (var i = 0; i < listeners.Length; i++) - { - listeners[i].Handle(eventData); - } - - return Task.CompletedTask; - }, - CancellationToken.None - ); - - Enqueue(dispatch); - dispatch.Completion.GetAwaiter().GetResult(); + PublishAsync(eventData, CancellationToken.None).GetAwaiter().GetResult(); } /// - public async Task PublishAsync(TEvent eventData, CancellationToken cancellationToken) + public async Task PublishAsync(TEvent eventData, CancellationToken cancellationToken = default) where TEvent : IEvent { cancellationToken.ThrowIfCancellationRequested(); - var listeners = GetListeners>(_asyncListeners); - var dispatch = new EventDispatch( - async () => + var typed = Snapshot(typeof(TEvent)); + var global = typeof(TEvent) == typeof(IEvent) ? null : Snapshot(typeof(IEvent)); + + var total = (typed?.Length ?? 0) + (global?.Length ?? 0); + + if (total == 0) + { + return; + } + + if (total == 1) + { + var single = typed is { Length: 1 } ? typed[0] : global![0]; + await DispatchSafeAsync(single, eventData, cancellationToken); + + return; + } + + var tasks = new Task[total]; + var index = 0; + + if (typed is not null) + { + for (var i = 0; i < typed.Length; i++) + { + tasks[index++] = DispatchSafeAsync(typed[i], eventData, cancellationToken); + } + } + + if (global is not null) + { + for (var i = 0; i < global.Length; i++) { - cancellationToken.ThrowIfCancellationRequested(); - - for (var i = 0; i < listeners.Length; i++) - { - cancellationToken.ThrowIfCancellationRequested(); - await listeners[i].HandleAsync(eventData, cancellationToken); - } - }, - cancellationToken - ); - - Enqueue(dispatch); - await dispatch.Completion; + tasks[index++] = DispatchSafeAsync(global[i], eventData, cancellationToken); + } + } + + await Task.WhenAll(tasks); } /// - public void RegisterAsyncListener(IAsyncEventListener listener) + public IDisposable RegisterListener(IEventListener listener) where TEvent : IEvent { ArgumentNullException.ThrowIfNull(listener); - AddListener(_asyncListeners, listener); + + return Add(typeof(TEvent), listener); } /// - public void RegisterListener(ISyncEventListener listener) + public IDisposable Subscribe(Func handler) where TEvent : IEvent { - ArgumentNullException.ThrowIfNull(listener); - AddListener(_syncListeners, listener); + ArgumentNullException.ThrowIfNull(handler); + + return Add(typeof(TEvent), new DelegateEventListener(handler)); } - private void AddListener(Dictionary> listenersByType, object listener) - where TEvent : IEvent + private Subscription Add(Type eventType, object listener) { - lock (_listenerSync) - { - ThrowIfDisposed(); - - var eventType = typeof(TEvent); + ThrowIfDisposed(); - if (!listenersByType.TryGetValue(eventType, out var listeners)) - { - listeners = []; - listenersByType[eventType] = listeners; - } + var bucket = _listeners.GetOrAdd(eventType, static _ => []); - listeners.Add(listener); + lock (bucket) + { + bucket.Add(listener); } + + return new Subscription(bucket, listener); } - private void Enqueue(EventDispatch dispatch) + private object[]? Snapshot(Type eventType) { - ThrowIfDisposed(); + if (!_listeners.TryGetValue(eventType, out var bucket)) + { + return null; + } - if (!_dispatches.Writer.TryWrite(dispatch)) + lock (bucket) { - throw new ObjectDisposedException(nameof(EventBusService)); + return bucket.Count == 0 ? null : bucket.ToArray(); } } - private TListener[] GetListeners(Dictionary> listenersByType) + private async Task DispatchSafeAsync(object listener, TEvent eventData, CancellationToken cancellationToken) where TEvent : IEvent - where TListener : class { - lock (_listenerSync) - { - ThrowIfDisposed(); + var start = Stopwatch.GetTimestamp(); - if (!listenersByType.TryGetValue(typeof(TEvent), out var listeners)) + try + { + // IEventListener is contravariant, so a catch-all IEventListener + // also matches this cast and handles the concrete event. + if (listener is IEventListener typedListener) { - return []; + await typedListener.HandleAsync(eventData, cancellationToken); } - - return [.. listeners.Cast()]; } - } - - private async Task ProcessDispatchesAsync() - { - await foreach (var dispatch in _dispatches.Reader.ReadAllAsync()) + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { - await dispatch.ExecuteAsync(); + throw; + } + catch (Exception ex) + { + _logger.Error( + ex, + "Event listener {ListenerType} failed for event {EventType}", + listener.GetType().FullName, + typeof(TEvent).Name + ); + } + finally + { + var elapsed = Stopwatch.GetElapsedTime(start); + + if (elapsed >= _slowListenerThreshold) + { + _logger.Warning( + "Slow event listener event={EventType} listener={ListenerType} elapsed={ElapsedMs:0.###}ms", + typeof(TEvent).Name, + listener.GetType().FullName, + elapsed.TotalMilliseconds + ); + } } } diff --git a/src/SquidStd.Services.Core/Services/EventListenerActivator.cs b/src/SquidStd.Services.Core/Services/EventListenerActivator.cs new file mode 100644 index 00000000..35517a35 --- /dev/null +++ b/src/SquidStd.Services.Core/Services/EventListenerActivator.cs @@ -0,0 +1,41 @@ +using DryIoc; +using SquidStd.Abstractions.Data.Internal.Events; +using SquidStd.Abstractions.Interfaces.Services; +using SquidStd.Core.Interfaces.Events; + +namespace SquidStd.Services.Core.Services; + +/// +/// Subscribes every DI-registered event listener to the bus at startup, before publishing services run. +/// +internal sealed class EventListenerActivator : ISquidStdService +{ + private readonly IContainer _container; + private readonly IEventBus _eventBus; + + public EventListenerActivator(IContainer container, IEventBus eventBus) + { + _container = container; + _eventBus = eventBus; + } + + public ValueTask StartAsync(CancellationToken cancellationToken = default) + { + if (_container.IsRegistered>()) + { + var registrations = _container.Resolve>(); + + for (var i = 0; i < registrations.Count; i++) + { + registrations[i].Subscribe(_eventBus, _container); + } + } + + return ValueTask.CompletedTask; + } + + public ValueTask StopAsync(CancellationToken cancellationToken = default) + { + return ValueTask.CompletedTask; + } +} diff --git a/src/SquidStd.Services.Core/Services/HealthCheckService.cs b/src/SquidStd.Services.Core/Services/HealthCheckService.cs index 0c99fddc..76377539 100644 --- a/src/SquidStd.Services.Core/Services/HealthCheckService.cs +++ b/src/SquidStd.Services.Core/Services/HealthCheckService.cs @@ -8,14 +8,14 @@ namespace SquidStd.Services.Core.Services; /// -/// Runs every registered in parallel with a per-check timeout and -/// exception isolation, then aggregates the results into a single . +/// Runs every registered in parallel with a per-check timeout and +/// exception isolation, then aggregates the results into a single . /// public sealed class HealthCheckService : IHealthCheckService { - private readonly ILogger _logger = Log.ForContext(); - private readonly IHealthCheck[] _checks; private readonly TimeSpan _checkTimeout; + private readonly IHealthCheck[] _checks; + private readonly ILogger _logger = Log.ForContext(); public HealthCheckService(IEnumerable checks, HealthCheckOptions options) { @@ -36,7 +36,7 @@ public async ValueTask CheckHealthAsync(CancellationToken cancella if (_checks.Length == 0) { - return new() + return new HealthReport { Status = HealthStatus.Healthy, Entries = new Dictionary(StringComparer.Ordinal), @@ -77,7 +77,7 @@ public async ValueTask CheckHealthAsync(CancellationToken cancella } } - return new() + return new HealthReport { Status = overall, Entries = entries, @@ -105,7 +105,7 @@ CancellationToken cancellationToken catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { return (check.Name, - HealthCheckResult.Unhealthy($"Timed out after {_checkTimeout}.") with { Duration = stopwatch.Elapsed }); + HealthCheckResult.Unhealthy($"Timed out after {_checkTimeout}.") with { Duration = stopwatch.Elapsed }); } catch (OperationCanceledException) { diff --git a/src/SquidStd.Services.Core/Services/Internal/CronJobEntry.cs b/src/SquidStd.Services.Core/Services/Internal/CronJobEntry.cs index 4cbd953d..705f2f77 100644 --- a/src/SquidStd.Services.Core/Services/Internal/CronJobEntry.cs +++ b/src/SquidStd.Services.Core/Services/Internal/CronJobEntry.cs @@ -3,19 +3,19 @@ namespace SquidStd.Services.Core.Services.Internal; /// -/// Internal mutable state for a registered cron job. +/// Internal mutable state for a registered cron job. /// internal sealed class CronJobEntry { + public DateTime? LastRunUtc; + public DateTime? NextOccurrenceUtc; + public long RunCount; + public int Running; + + public string? TimerId; public required string JobId { get; init; } public required string Name { get; init; } public required string CronText { get; init; } public required CronExpression Expression { get; init; } public required Func Handler { get; init; } - - public string? TimerId; - public DateTime? NextOccurrenceUtc; - public DateTime? LastRunUtc; - public int Running; - public long RunCount; } diff --git a/src/SquidStd.Services.Core/Services/Internal/DelegateEventListener.cs b/src/SquidStd.Services.Core/Services/Internal/DelegateEventListener.cs new file mode 100644 index 00000000..3e64fa7b --- /dev/null +++ b/src/SquidStd.Services.Core/Services/Internal/DelegateEventListener.cs @@ -0,0 +1,23 @@ +using SquidStd.Core.Interfaces.Events; + +namespace SquidStd.Services.Core.Services.Internal; + +/// +/// Adapts a delegate handler to so the bus has a single dispatch path. +/// +/// The event type. +internal sealed class DelegateEventListener : IEventListener + where TEvent : IEvent +{ + private readonly Func _handler; + + public DelegateEventListener(Func handler) + { + _handler = handler; + } + + public Task HandleAsync(TEvent eventData, CancellationToken cancellationToken = default) + { + return _handler(eventData, cancellationToken); + } +} diff --git a/src/SquidStd.Services.Core/Services/Internal/EventDispatch.cs b/src/SquidStd.Services.Core/Services/Internal/EventDispatch.cs deleted file mode 100644 index 751616c8..00000000 --- a/src/SquidStd.Services.Core/Services/Internal/EventDispatch.cs +++ /dev/null @@ -1,37 +0,0 @@ -namespace SquidStd.Services.Core.Services.Internal; - -/// -/// A queued event dispatch with a completion signal, executed by the event bus dispatcher loop. -/// -internal sealed class EventDispatch -{ - private readonly CancellationToken _cancellationToken; - private readonly Func _dispatch; - private readonly TaskCompletionSource _completion; - - public Task Completion => _completion.Task; - - public EventDispatch(Func dispatch, CancellationToken cancellationToken) - { - _dispatch = dispatch; - _cancellationToken = cancellationToken; - _completion = new(TaskCreationOptions.RunContinuationsAsynchronously); - } - - public async Task ExecuteAsync() - { - try - { - await _dispatch(); - _completion.TrySetResult(); - } - catch (OperationCanceledException) - { - _completion.TrySetCanceled(_cancellationToken); - } - catch (Exception exception) - { - _completion.TrySetException(exception); - } - } -} diff --git a/src/SquidStd.Services.Core/Services/Internal/JobItem.cs b/src/SquidStd.Services.Core/Services/Internal/JobItem.cs index 71fd52ae..3e9f3014 100644 --- a/src/SquidStd.Services.Core/Services/Internal/JobItem.cs +++ b/src/SquidStd.Services.Core/Services/Internal/JobItem.cs @@ -1,7 +1,7 @@ namespace SquidStd.Services.Core.Services.Internal; /// -/// Internal envelope for a scheduled job. +/// Internal envelope for a scheduled job. /// internal sealed class JobItem { @@ -17,8 +17,12 @@ public JobItem(Action run, Action cancel) } public void Cancel() - => _cancel(); + { + _cancel(); + } public void Run() - => _run(); + { + _run(); + } } diff --git a/src/SquidStd.Services.Core/Services/Internal/MainThreadSynchronizationContext.cs b/src/SquidStd.Services.Core/Services/Internal/MainThreadSynchronizationContext.cs index 65255cbf..01630bf6 100644 --- a/src/SquidStd.Services.Core/Services/Internal/MainThreadSynchronizationContext.cs +++ b/src/SquidStd.Services.Core/Services/Internal/MainThreadSynchronizationContext.cs @@ -3,7 +3,7 @@ namespace SquidStd.Services.Core.Services.Internal; /// -/// Synchronization context that forwards asynchronous posts to a main-thread dispatcher. +/// Synchronization context that forwards asynchronous posts to a main-thread dispatcher. /// internal sealed class MainThreadSynchronizationContext : SynchronizationContext { diff --git a/src/SquidStd.Services.Core/Services/Internal/Subscription.cs b/src/SquidStd.Services.Core/Services/Internal/Subscription.cs new file mode 100644 index 00000000..6264dcfd --- /dev/null +++ b/src/SquidStd.Services.Core/Services/Internal/Subscription.cs @@ -0,0 +1,32 @@ +namespace SquidStd.Services.Core.Services.Internal; + +/// +/// Unsubscribe token that removes a listener from its bucket when disposed. +/// +internal sealed class Subscription : IDisposable +{ + private readonly List _bucket; + private readonly object _listener; + private bool _disposed; + + public Subscription(List bucket, object listener) + { + _bucket = bucket; + _listener = listener; + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + + lock (_bucket) + { + _bucket.Remove(_listener); + } + } +} diff --git a/src/SquidStd.Services.Core/Services/Internal/TimerEntry.cs b/src/SquidStd.Services.Core/Services/Internal/TimerEntry.cs index d13435d3..88a5415a 100644 --- a/src/SquidStd.Services.Core/Services/Internal/TimerEntry.cs +++ b/src/SquidStd.Services.Core/Services/Internal/TimerEntry.cs @@ -1,7 +1,7 @@ namespace SquidStd.Services.Core.Services.Internal; /// -/// Internal mutable timer wheel entry. +/// Internal mutable timer wheel entry. /// internal sealed class TimerEntry { diff --git a/src/SquidStd.Services.Core/Services/JobSystemService.cs b/src/SquidStd.Services.Core/Services/JobSystemService.cs index 2d7b83bc..7a9a6aec 100644 --- a/src/SquidStd.Services.Core/Services/JobSystemService.cs +++ b/src/SquidStd.Services.Core/Services/JobSystemService.cs @@ -8,7 +8,7 @@ namespace SquidStd.Services.Core.Services; /// -/// Schedules jobs on a fixed set of worker threads. +/// Schedules jobs on a fixed set of worker threads. /// public sealed class JobSystemService : IJobSystem, ISquidStdService { @@ -23,20 +23,8 @@ public sealed class JobSystemService : IJobSystem, ISquidStdService private int _pendingCount; private int _started; - /// - public int ActiveCount => Volatile.Read(ref _activeCount); - - /// - public long CompletedCount => Interlocked.Read(ref _completedCount); - - /// - public int PendingCount => Volatile.Read(ref _pendingCount); - - /// - public int WorkerCount { get; } - /// - /// Initializes the job system service. + /// Initializes the job system service. /// /// Job system configuration. public JobSystemService(JobsConfig config) @@ -45,7 +33,7 @@ public JobSystemService(JobsConfig config) _config = config; WorkerCount = ResolveWorkerCount(config.WorkerThreadCount); _channel = Channel.CreateUnbounded( - new() + new UnboundedChannelOptions { SingleReader = false, SingleWriter = false @@ -54,11 +42,25 @@ public JobSystemService(JobsConfig config) _workers = new Thread[WorkerCount]; } + /// + public int ActiveCount => Volatile.Read(ref _activeCount); + + /// + public long CompletedCount => Interlocked.Read(ref _completedCount); + + /// + public int PendingCount => Volatile.Read(ref _pendingCount); + + /// + public int WorkerCount { get; } + /// - /// Releases worker resources. + /// Releases worker resources. /// public void Dispose() - => Stop(CancellationToken.None); + { + Stop(CancellationToken.None); + } /// public Task ScheduleAsync(Action work, CancellationToken cancellationToken = default) @@ -206,7 +208,9 @@ private void OnUnobservedTaskException(object? sender, UnobservedTaskExceptionEv } private static int ResolveWorkerCount(int configured) - => configured > 0 ? configured : Math.Max(1, Environment.ProcessorCount - 1); + { + return configured > 0 ? configured : Math.Max(1, Environment.ProcessorCount - 1); + } private void Stop(CancellationToken cancellationToken) { diff --git a/src/SquidStd.Services.Core/Services/MainThreadDispatcherService.cs b/src/SquidStd.Services.Core/Services/MainThreadDispatcherService.cs index bbabe5e4..a6c84e6a 100644 --- a/src/SquidStd.Services.Core/Services/MainThreadDispatcherService.cs +++ b/src/SquidStd.Services.Core/Services/MainThreadDispatcherService.cs @@ -6,7 +6,7 @@ namespace SquidStd.Services.Core.Services; /// -/// Queues callbacks and drains them on the calling thread. +/// Queues callbacks and drains them on the calling thread. /// public sealed class MainThreadDispatcherService : IMainThreadDispatcher { diff --git a/src/SquidStd.Services.Core/Services/MetricsCollectionService.cs b/src/SquidStd.Services.Core/Services/MetricsCollectionService.cs index 0be8395b..bfb296c4 100644 --- a/src/SquidStd.Services.Core/Services/MetricsCollectionService.cs +++ b/src/SquidStd.Services.Core/Services/MetricsCollectionService.cs @@ -9,28 +9,29 @@ namespace SquidStd.Services.Core.Services; /// -/// Periodically collects metrics from registered providers and stores the latest snapshot. +/// Periodically collects metrics from registered providers and stores the latest snapshot. /// public sealed class MetricsCollectionService : IMetricsCollectionService, ISquidStdService, IDisposable { + private readonly MetricsConfig _config; + private readonly IEventBus? _eventBus; private readonly ILogger _logger = Log.ForContext(); private readonly ILogger _metricsLogger = Log.ForContext().ForContext("MetricsData", true); private readonly IReadOnlyList _providers; - private readonly MetricsConfig _config; - private readonly IEventBus? _eventBus; private readonly Lock _syncRoot = new(); - private CancellationTokenSource _lifetimeCts = new(); private Task _collectionTask = Task.CompletedTask; private int _disposed; - private int _started; + private CancellationTokenSource _lifetimeCts = new(); private MetricsSnapshot _snapshot = new( DateTimeOffset.MinValue, new Dictionary(StringComparer.Ordinal) ); + private int _started; + /// - /// Initializes the metrics collection service. + /// Initializes the metrics collection service. /// /// Metric providers to collect from. /// Metrics collection configuration. @@ -46,7 +47,7 @@ public MetricsCollectionService(IEnumerable providers, MetricsC } /// - /// Releases metrics collection resources. + /// Releases metrics collection resources. /// public void Dispose() { @@ -63,7 +64,9 @@ public void Dispose() { _collectionTask.GetAwaiter().GetResult(); } - catch (OperationCanceledException) { } + catch (OperationCanceledException) + { + } } _lifetimeCts.Dispose(); @@ -89,7 +92,9 @@ public MetricsSnapshot GetSnapshot() /// public MetricsSnapshot GetStatus() - => GetSnapshot(); + { + return GetSnapshot(); + } /// public ValueTask StartAsync(CancellationToken cancellationToken = default) @@ -110,7 +115,7 @@ public ValueTask StartAsync(CancellationToken cancellationToken = default) if (_lifetimeCts.IsCancellationRequested) { _lifetimeCts.Dispose(); - _lifetimeCts = new(); + _lifetimeCts = new CancellationTokenSource(); } _collectionTask = Task.Run(() => RunCollectionLoopAsync(_lifetimeCts.Token), _lifetimeCts.Token); @@ -134,7 +139,9 @@ public async ValueTask StopAsync(CancellationToken cancellationToken = default) { await _collectionTask; } - catch (OperationCanceledException) { } + catch (OperationCanceledException) + { + } } private async Task CollectOnceAsync(CancellationToken cancellationToken) @@ -180,8 +187,10 @@ private async Task CollectOnceAsync(CancellationToken cancellationToken) } private static string CreateMetricKey(string providerName, string metricName) - => string.IsNullOrWhiteSpace(providerName) ? metricName : - string.IsNullOrWhiteSpace(metricName) ? providerName : providerName + "." + metricName; + { + return string.IsNullOrWhiteSpace(providerName) ? metricName : + string.IsNullOrWhiteSpace(metricName) ? providerName : providerName + "." + metricName; + } private void LogProviderCollection(IMetricProvider provider, int metricCount) { @@ -207,15 +216,6 @@ private async Task PublishMetricsCollectedAsync(MetricsSnapshot snapshot, Cancel var eventData = new MetricsCollectedEvent(snapshot); - try - { - _eventBus.Publish(eventData); - } - catch (Exception ex) - { - _logger.Error(ex, "Metrics collected event dispatch failed for synchronous listeners"); - } - try { await _eventBus.PublishAsync(eventData, cancellationToken); @@ -226,7 +226,7 @@ private async Task PublishMetricsCollectedAsync(MetricsSnapshot snapshot, Cancel } catch (Exception ex) { - _logger.Error(ex, "Metrics collected event dispatch failed for asynchronous listeners"); + _logger.Error(ex, "Metrics collected event dispatch failed"); } } diff --git a/src/SquidStd.Services.Core/Services/Scheduling/CronSchedulerService.cs b/src/SquidStd.Services.Core/Services/Scheduling/CronSchedulerService.cs index 945fae37..c040432d 100644 --- a/src/SquidStd.Services.Core/Services/Scheduling/CronSchedulerService.cs +++ b/src/SquidStd.Services.Core/Services/Scheduling/CronSchedulerService.cs @@ -11,17 +11,17 @@ namespace SquidStd.Services.Core.Services.Scheduling; /// -/// Cron scheduler built on the timer wheel: each job is a one-shot, self-rescheduling -/// timer. On fire, the handler is dispatched through ; an occurrence -/// is skipped when the previous run of the same job is still in flight. +/// Cron scheduler built on the timer wheel: each job is a one-shot, self-rescheduling +/// timer. On fire, the handler is dispatched through ; an occurrence +/// is skipped when the previous run of the same job is still in flight. /// public sealed class CronSchedulerService : ICronScheduler, ISquidStdService, IDisposable { + private readonly CancellationTokenSource _cts = new(); + private readonly ConcurrentDictionary _entries = new(StringComparer.Ordinal); + private readonly IJobSystem _jobs; private readonly ILogger _logger = Log.ForContext(); private readonly ITimerService _timer; - private readonly IJobSystem _jobs; - private readonly ConcurrentDictionary _entries = new(StringComparer.Ordinal); - private readonly CancellationTokenSource _cts = new(); private int _disposed; public CronSchedulerService(ITimerService timer, IJobSystem jobs) @@ -33,31 +33,18 @@ public CronSchedulerService(ITimerService timer, IJobSystem jobs) /// public IReadOnlyCollection Jobs => _entries.Values - .Select( - entry => new CronJobInfo - { - JobId = entry.JobId, - Name = entry.Name, - CronExpression = entry.CronText, - NextOccurrenceUtc = entry.NextOccurrenceUtc, - IsRunning = Volatile.Read(ref entry.Running) == 1, - LastRunUtc = entry.LastRunUtc, - RunCount = Interlocked.Read(ref entry.RunCount) - } - ) - .ToArray(); - - /// - public void Dispose() - { - if (Interlocked.Exchange(ref _disposed, 1) != 0) - { - return; - } - - _cts.Cancel(); - _cts.Dispose(); - } + .Select(entry => new CronJobInfo + { + JobId = entry.JobId, + Name = entry.Name, + CronExpression = entry.CronText, + NextOccurrenceUtc = entry.NextOccurrenceUtc, + IsRunning = Volatile.Read(ref entry.Running) == 1, + LastRunUtc = entry.LastRunUtc, + RunCount = Interlocked.Read(ref entry.RunCount) + } + ) + .ToArray(); /// public string Schedule(string name, string cronExpression, Func handler) @@ -83,28 +70,6 @@ public string Schedule(string name, string cronExpression, Func - public ValueTask StartAsync(CancellationToken cancellationToken = default) - => ValueTask.CompletedTask; - - /// - public ValueTask StopAsync(CancellationToken cancellationToken = default) - { - _cts.Cancel(); - - foreach (var entry in _entries.Values) - { - if (entry.TimerId is not null) - { - _timer.UnregisterTimer(entry.TimerId); - } - } - - _entries.Clear(); - - return ValueTask.CompletedTask; - } - /// public bool Unschedule(string jobId) { @@ -138,6 +103,42 @@ public int UnscheduleByName(string name) return removed; } + /// + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + _cts.Cancel(); + _cts.Dispose(); + } + + /// + public ValueTask StartAsync(CancellationToken cancellationToken = default) + { + return ValueTask.CompletedTask; + } + + /// + public ValueTask StopAsync(CancellationToken cancellationToken = default) + { + _cts.Cancel(); + + foreach (var entry in _entries.Values) + { + if (entry.TimerId is not null) + { + _timer.UnregisterTimer(entry.TimerId); + } + } + + _entries.Clear(); + + return ValueTask.CompletedTask; + } + private void OnTimer(CronJobEntry entry) { if (!_entries.ContainsKey(entry.JobId)) diff --git a/src/SquidStd.Services.Core/Services/Scheduling/TimerWheelPumpService.cs b/src/SquidStd.Services.Core/Services/Scheduling/TimerWheelPumpService.cs index 7c210f98..ca7b18b4 100644 --- a/src/SquidStd.Services.Core/Services/Scheduling/TimerWheelPumpService.cs +++ b/src/SquidStd.Services.Core/Services/Scheduling/TimerWheelPumpService.cs @@ -7,18 +7,18 @@ namespace SquidStd.Services.Core.Services.Scheduling; /// -/// Periodically advances the timer wheel so that wheel-backed timers fire in a normal -/// (non-game-loop) application. Drives on a -/// background loop. +/// Periodically advances the timer wheel so that wheel-backed timers fire in a normal +/// (non-game-loop) application. Drives on a +/// background loop. /// public sealed class TimerWheelPumpService : ISquidStdService, IDisposable { + private readonly CancellationTokenSource _cts = new(); private readonly ILogger _logger = Log.ForContext(); - private readonly ITimerService _timer; private readonly TimeSpan _pumpInterval; - private readonly CancellationTokenSource _cts = new(); - private Task? _loop; + private readonly ITimerService _timer; private int _disposed; + private Task? _loop; public TimerWheelPumpService(ITimerService timer, TimerWheelPumpConfig config) { diff --git a/src/SquidStd.Services.Core/Services/Storage/AesGcmSecretProtector.cs b/src/SquidStd.Services.Core/Services/Storage/AesGcmSecretProtector.cs index d1230cbf..0e740776 100644 --- a/src/SquidStd.Services.Core/Services/Storage/AesGcmSecretProtector.cs +++ b/src/SquidStd.Services.Core/Services/Storage/AesGcmSecretProtector.cs @@ -7,7 +7,7 @@ namespace SquidStd.Services.Core.Services.Storage; /// -/// Protects secrets using AES-GCM and a key supplied by environment variable. +/// Protects secrets using AES-GCM and a key supplied by environment variable. /// public sealed class AesGcmSecretProtector : ISecretProtector { @@ -18,7 +18,7 @@ public sealed class AesGcmSecretProtector : ISecretProtector private readonly byte[] _key; /// - /// Initializes the AES-GCM secret protector. + /// Initializes the AES-GCM secret protector. /// /// Secret storage configuration. public AesGcmSecretProtector(SecretsConfig config) @@ -75,7 +75,9 @@ public byte[] Unprotect(byte[] protectedData) } private static byte[] CreateDefaultKey() - => SHA256.HashData(Encoding.UTF8.GetBytes(DefaultKeyMaterial)); + { + return SHA256.HashData(Encoding.UTF8.GetBytes(DefaultKeyMaterial)); + } private static byte[] ResolveKey(string environmentVariable) { @@ -105,7 +107,7 @@ private static byte[] ResolveKey(string environmentVariable) } return key.Length is 16 or 24 or 32 - ? key - : throw new InvalidOperationException("Secret key must be 16, 24, or 32 bytes after base64 decoding."); + ? key + : throw new InvalidOperationException("Secret key must be 16, 24, or 32 bytes after base64 decoding."); } } diff --git a/src/SquidStd.Services.Core/Services/Storage/FileSecretStore.cs b/src/SquidStd.Services.Core/Services/Storage/FileSecretStore.cs index ff3d3a22..6ec3513a 100644 --- a/src/SquidStd.Services.Core/Services/Storage/FileSecretStore.cs +++ b/src/SquidStd.Services.Core/Services/Storage/FileSecretStore.cs @@ -1,13 +1,14 @@ using System.Text; using SquidStd.Core.Data.Storage; using SquidStd.Core.Interfaces.Secrets; +using SquidStd.Storage.Abstractions.Data.Config; using SquidStd.Storage.Abstractions.Interfaces; using SquidStd.Storage.Services; namespace SquidStd.Services.Core.Services.Storage; /// -/// File-backed encrypted secret store. +/// File-backed encrypted secret store. /// public sealed class FileSecretStore : ISecretStore { @@ -15,7 +16,7 @@ public sealed class FileSecretStore : ISecretStore private readonly IStorageService _storageService; /// - /// Initializes the encrypted file secret store. + /// Initializes the encrypted file secret store. /// /// Secret storage configuration. /// Secret protector used for encryption. @@ -25,16 +26,20 @@ public FileSecretStore(SecretsConfig config, ISecretProtector secretProtector) ArgumentNullException.ThrowIfNull(secretProtector); _secretProtector = secretProtector; - _storageService = new FileStorageService(new() { RootDirectory = config.RootDirectory }); + _storageService = new FileStorageService(new StorageConfig { RootDirectory = config.RootDirectory }); } /// public ValueTask DeleteAsync(string name, CancellationToken cancellationToken = default) - => _storageService.DeleteAsync(ToStorageKey(name), cancellationToken); + { + return _storageService.DeleteAsync(ToStorageKey(name), cancellationToken); + } /// public ValueTask ExistsAsync(string name, CancellationToken cancellationToken = default) - => _storageService.ExistsAsync(ToStorageKey(name), cancellationToken); + { + return _storageService.ExistsAsync(ToStorageKey(name), cancellationToken); + } /// public async ValueTask GetAsync(string name, CancellationToken cancellationToken = default) diff --git a/src/SquidStd.Services.Core/Services/TimerWheelService.cs b/src/SquidStd.Services.Core/Services/TimerWheelService.cs index a7fc3f15..c26f2bcf 100644 --- a/src/SquidStd.Services.Core/Services/TimerWheelService.cs +++ b/src/SquidStd.Services.Core/Services/TimerWheelService.cs @@ -8,7 +8,7 @@ namespace SquidStd.Services.Core.Services; /// -/// Hashed timer wheel driven by absolute timestamp updates. +/// Hashed timer wheel driven by absolute timestamp updates. /// public sealed class TimerWheelService : ITimerService, ISquidStdService { @@ -24,7 +24,7 @@ public sealed class TimerWheelService : ITimerService, ISquidStdService private long _lastTimestampMilliseconds = -1; /// - /// Initializes the timer wheel service. + /// Initializes the timer wheel service. /// /// Timer wheel configuration. public TimerWheelService(TimerWheelConfig config) @@ -53,10 +53,25 @@ public TimerWheelService(TimerWheelConfig config) for (var i = 0; i < _wheel.Length; i++) { - _wheel[i] = new(); + _wheel[i] = new LinkedList(); } } + /// + public ValueTask StartAsync(CancellationToken cancellationToken = default) + { + return ValueTask.CompletedTask; + } + + /// + public ValueTask StopAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + UnregisterAllTimers(); + + return ValueTask.CompletedTask; + } + /// public string RegisterTimer( string name, @@ -111,19 +126,6 @@ public string RegisterTimer( return entry.Id; } - /// - public ValueTask StartAsync(CancellationToken cancellationToken = default) - => ValueTask.CompletedTask; - - /// - public ValueTask StopAsync(CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - UnregisterAllTimers(); - - return ValueTask.CompletedTask; - } - /// public void UnregisterAllTimers() { diff --git a/src/SquidStd.Services.Core/Types/BootstrapStateType.cs b/src/SquidStd.Services.Core/Types/BootstrapStateType.cs index 2b1d72ff..a6a59707 100644 --- a/src/SquidStd.Services.Core/Types/BootstrapStateType.cs +++ b/src/SquidStd.Services.Core/Types/BootstrapStateType.cs @@ -1,7 +1,7 @@ namespace SquidStd.Services.Core.Types; /// -/// Lifecycle state of the SquidStd bootstrapper. +/// Lifecycle state of the SquidStd bootstrapper. /// internal enum BootstrapStateType { diff --git a/src/SquidStd.Storage.Abstractions/Data/Config/StorageConfig.cs b/src/SquidStd.Storage.Abstractions/Data/Config/StorageConfig.cs index d75be728..2fda7e43 100644 --- a/src/SquidStd.Storage.Abstractions/Data/Config/StorageConfig.cs +++ b/src/SquidStd.Storage.Abstractions/Data/Config/StorageConfig.cs @@ -3,12 +3,12 @@ namespace SquidStd.Storage.Abstractions.Data.Config; /// -/// Configuration for local file storage. +/// Configuration for local file storage. /// public sealed class StorageConfig : IConfigEntry { /// - /// Gets or sets the root directory used by local storage. + /// Gets or sets the root directory used by local storage. /// public string RootDirectory { get; set; } = "storage"; @@ -17,5 +17,7 @@ public sealed class StorageConfig : IConfigEntry Type IConfigEntry.ConfigType => typeof(StorageConfig); object IConfigEntry.CreateDefault() - => new StorageConfig(); + { + return new StorageConfig(); + } } diff --git a/src/SquidStd.Storage.Abstractions/Interfaces/IObjectStorageService.cs b/src/SquidStd.Storage.Abstractions/Interfaces/IObjectStorageService.cs index 580e98d0..b2e4a504 100644 --- a/src/SquidStd.Storage.Abstractions/Interfaces/IObjectStorageService.cs +++ b/src/SquidStd.Storage.Abstractions/Interfaces/IObjectStorageService.cs @@ -1,12 +1,12 @@ namespace SquidStd.Storage.Abstractions.Interfaces; /// -/// Stores typed objects by logical key. +/// Stores typed objects by logical key. /// public interface IObjectStorageService { /// - /// Deletes a stored object. + /// Deletes a stored object. /// /// The logical storage key. /// Token used to cancel the operation. @@ -14,7 +14,7 @@ public interface IObjectStorageService ValueTask DeleteAsync(string key, CancellationToken cancellationToken = default); /// - /// Checks whether an object exists. + /// Checks whether an object exists. /// /// The logical storage key. /// Token used to cancel the operation. @@ -22,7 +22,7 @@ public interface IObjectStorageService ValueTask ExistsAsync(string key, CancellationToken cancellationToken = default); /// - /// Enumerates stored keys, optionally filtered by prefix. + /// Enumerates stored keys, optionally filtered by prefix. /// /// Optional key prefix; null or empty returns all keys. /// Token used to cancel the enumeration. @@ -30,7 +30,7 @@ public interface IObjectStorageService IAsyncEnumerable ListKeysAsync(string? prefix = null, CancellationToken cancellationToken = default); /// - /// Loads a stored object. + /// Loads a stored object. /// /// The logical storage key. /// Token used to cancel the operation. @@ -39,7 +39,7 @@ public interface IObjectStorageService ValueTask LoadAsync(string key, CancellationToken cancellationToken = default); /// - /// Saves an object. + /// Saves an object. /// /// The logical storage key. /// The object value. diff --git a/src/SquidStd.Storage.Abstractions/Interfaces/IStorageService.cs b/src/SquidStd.Storage.Abstractions/Interfaces/IStorageService.cs index b307f2aa..6b469e5d 100644 --- a/src/SquidStd.Storage.Abstractions/Interfaces/IStorageService.cs +++ b/src/SquidStd.Storage.Abstractions/Interfaces/IStorageService.cs @@ -1,12 +1,12 @@ namespace SquidStd.Storage.Abstractions.Interfaces; /// -/// Stores binary payloads by logical key. +/// Stores binary payloads by logical key. /// public interface IStorageService { /// - /// Deletes a stored payload. + /// Deletes a stored payload. /// /// The logical storage key. /// Token used to cancel the operation. @@ -14,7 +14,7 @@ public interface IStorageService ValueTask DeleteAsync(string key, CancellationToken cancellationToken = default); /// - /// Checks whether a payload exists. + /// Checks whether a payload exists. /// /// The logical storage key. /// Token used to cancel the operation. @@ -22,7 +22,7 @@ public interface IStorageService ValueTask ExistsAsync(string key, CancellationToken cancellationToken = default); /// - /// Enumerates stored keys, optionally filtered by prefix. + /// Enumerates stored keys, optionally filtered by prefix. /// /// Optional key prefix; null or empty returns all keys. /// Token used to cancel the enumeration. @@ -30,7 +30,7 @@ public interface IStorageService IAsyncEnumerable ListKeysAsync(string? prefix = null, CancellationToken cancellationToken = default); /// - /// Loads a binary payload. + /// Loads a binary payload. /// /// The logical storage key. /// Token used to cancel the operation. @@ -38,7 +38,7 @@ public interface IStorageService ValueTask LoadAsync(string key, CancellationToken cancellationToken = default); /// - /// Saves a binary payload atomically. + /// Saves a binary payload atomically. /// /// The logical storage key. /// The payload to store. diff --git a/src/SquidStd.Storage.S3/Data/Config/S3StorageOptions.cs b/src/SquidStd.Storage.S3/Data/Config/S3StorageOptions.cs index 071ae4d3..c26069bc 100644 --- a/src/SquidStd.Storage.S3/Data/Config/S3StorageOptions.cs +++ b/src/SquidStd.Storage.S3/Data/Config/S3StorageOptions.cs @@ -3,8 +3,8 @@ namespace SquidStd.Storage.S3.Data.Config; /// -/// Connection options for the S3-compatible (MinIO) storage provider. Connection details live in -/// (shared with other AWS-SDK providers); is storage-specific. +/// Connection options for the S3-compatible (MinIO) storage provider. Connection details live in +/// (shared with other AWS-SDK providers); is storage-specific. /// public sealed class S3StorageOptions { diff --git a/src/SquidStd.Storage.S3/Extensions/S3StorageRegistrationExtensions.cs b/src/SquidStd.Storage.S3/Extensions/S3StorageRegistrationExtensions.cs index 462c574a..c05ff12f 100644 --- a/src/SquidStd.Storage.S3/Extensions/S3StorageRegistrationExtensions.cs +++ b/src/SquidStd.Storage.S3/Extensions/S3StorageRegistrationExtensions.cs @@ -6,7 +6,7 @@ namespace SquidStd.Storage.S3.Extensions; /// -/// DryIoc registration helpers for the S3-compatible (MinIO) storage provider. +/// DryIoc registration helpers for the S3-compatible (MinIO) storage provider. /// public static class S3StorageRegistrationExtensions { diff --git a/src/SquidStd.Storage.S3/Services/S3StorageService.cs b/src/SquidStd.Storage.S3/Services/S3StorageService.cs index b8234908..09857bc9 100644 --- a/src/SquidStd.Storage.S3/Services/S3StorageService.cs +++ b/src/SquidStd.Storage.S3/Services/S3StorageService.cs @@ -8,14 +8,14 @@ namespace SquidStd.Storage.S3.Services; /// -/// S3-compatible backed by the MinIO client. The bucket is created -/// lazily on first use. +/// S3-compatible backed by the MinIO client. The bucket is created +/// lazily on first use. /// public sealed class S3StorageService : IStorageService, IDisposable { - private readonly IMinioClient _client; private readonly string _bucket; private readonly SemaphoreSlim _bucketLock = new(1, 1); + private readonly IMinioClient _client; private bool _bucketReady; private int _disposed; @@ -30,6 +30,18 @@ public S3StorageService(S3StorageOptions options) _bucket = options.Bucket; } + /// + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + _client.Dispose(); + _bucketLock.Dispose(); + } + /// public async ValueTask DeleteAsync(string key, CancellationToken cancellationToken = default) { @@ -46,18 +58,6 @@ await _client.RemoveObjectAsync( return true; } - /// - public void Dispose() - { - if (Interlocked.Exchange(ref _disposed, 1) != 0) - { - return; - } - - _client.Dispose(); - _bucketLock.Dispose(); - } - /// public async ValueTask ExistsAsync(string key, CancellationToken cancellationToken = default) { @@ -151,9 +151,9 @@ private static IMinioClient CreateClient(S3StorageOptions options) var endpoint = uri.IsDefaultPort ? uri.Host : $"{uri.Host}:{uri.Port}"; var minio = new MinioClient() - .WithEndpoint(endpoint) - .WithCredentials(options.Aws.AccessKey, options.Aws.SecretKey) - .WithSSL(string.Equals(uri.Scheme, "https", StringComparison.OrdinalIgnoreCase)); + .WithEndpoint(endpoint) + .WithCredentials(options.Aws.AccessKey, options.Aws.SecretKey) + .WithSSL(string.Equals(uri.Scheme, "https", StringComparison.OrdinalIgnoreCase)); if (!string.IsNullOrWhiteSpace(options.Aws.Region)) { diff --git a/src/SquidStd.Storage/Extensions/StorageRegistrationExtensions.cs b/src/SquidStd.Storage/Extensions/StorageRegistrationExtensions.cs index 271bede5..5f5f3890 100644 --- a/src/SquidStd.Storage/Extensions/StorageRegistrationExtensions.cs +++ b/src/SquidStd.Storage/Extensions/StorageRegistrationExtensions.cs @@ -6,7 +6,7 @@ namespace SquidStd.Storage.Extensions; /// -/// DryIoc registration helpers for the local file storage provider. +/// DryIoc registration helpers for the local file storage provider. /// public static class StorageRegistrationExtensions { diff --git a/src/SquidStd.Storage/Internal/StoragePathResolver.cs b/src/SquidStd.Storage/Internal/StoragePathResolver.cs index 8e357434..2ee06091 100644 --- a/src/SquidStd.Storage/Internal/StoragePathResolver.cs +++ b/src/SquidStd.Storage/Internal/StoragePathResolver.cs @@ -1,7 +1,7 @@ namespace SquidStd.Storage.Internal; /// -/// Resolves logical storage keys into paths constrained to one root directory. +/// Resolves logical storage keys into paths constrained to one root directory. /// internal static class StoragePathResolver { @@ -40,8 +40,8 @@ public static string ResolveFilePath(string rootDirectory, string key, string? e var fullPath = Path.GetFullPath(Path.Combine(normalizedRoot, relativePath)); var rootPrefix = normalizedRoot.EndsWith(Path.DirectorySeparatorChar) - ? normalizedRoot - : normalizedRoot + Path.DirectorySeparatorChar; + ? normalizedRoot + : normalizedRoot + Path.DirectorySeparatorChar; if (!fullPath.StartsWith(rootPrefix, StringComparison.Ordinal)) { diff --git a/src/SquidStd.Storage/Services/FileStorageService.cs b/src/SquidStd.Storage/Services/FileStorageService.cs index 94c75417..74118821 100644 --- a/src/SquidStd.Storage/Services/FileStorageService.cs +++ b/src/SquidStd.Storage/Services/FileStorageService.cs @@ -6,14 +6,14 @@ namespace SquidStd.Storage.Services; /// -/// Local file-backed binary storage. +/// Local file-backed binary storage. /// public sealed class FileStorageService : IStorageService { private readonly string _rootDirectory; /// - /// Initializes local file storage. + /// Initializes local file storage. /// /// Storage configuration. public FileStorageService(StorageConfig config) @@ -126,5 +126,7 @@ public async ValueTask SaveAsync( } private string ResolvePath(string key) - => StoragePathResolver.ResolveFilePath(_rootDirectory, key); + { + return StoragePathResolver.ResolveFilePath(_rootDirectory, key); + } } diff --git a/src/SquidStd.Storage/Services/YamlObjectStorageService.cs b/src/SquidStd.Storage/Services/YamlObjectStorageService.cs index 6bc30acb..619ab586 100644 --- a/src/SquidStd.Storage/Services/YamlObjectStorageService.cs +++ b/src/SquidStd.Storage/Services/YamlObjectStorageService.cs @@ -5,14 +5,14 @@ namespace SquidStd.Storage.Services; /// -/// YAML object storage built on top of binary storage. +/// YAML object storage built on top of binary storage. /// public sealed class YamlObjectStorageService : IObjectStorageService { private readonly IStorageService _storageService; /// - /// Initializes YAML object storage. + /// Initializes YAML object storage. /// /// Underlying binary storage service. public YamlObjectStorageService(IStorageService storageService) @@ -22,15 +22,21 @@ public YamlObjectStorageService(IStorageService storageService) /// public ValueTask DeleteAsync(string key, CancellationToken cancellationToken = default) - => _storageService.DeleteAsync(key, cancellationToken); + { + return _storageService.DeleteAsync(key, cancellationToken); + } /// public ValueTask ExistsAsync(string key, CancellationToken cancellationToken = default) - => _storageService.ExistsAsync(key, cancellationToken); + { + return _storageService.ExistsAsync(key, cancellationToken); + } /// public IAsyncEnumerable ListKeysAsync(string? prefix = null, CancellationToken cancellationToken = default) - => _storageService.ListKeysAsync(prefix, cancellationToken); + { + return _storageService.ListKeysAsync(prefix, cancellationToken); + } /// public async ValueTask LoadAsync(string key, CancellationToken cancellationToken = default) diff --git a/src/SquidStd.Telemetry.Abstractions/SquidStdActivity.cs b/src/SquidStd.Telemetry.Abstractions/SquidStdActivity.cs index bba30264..aa60d76c 100644 --- a/src/SquidStd.Telemetry.Abstractions/SquidStdActivity.cs +++ b/src/SquidStd.Telemetry.Abstractions/SquidStdActivity.cs @@ -3,8 +3,8 @@ namespace SquidStd.Telemetry.Abstractions; /// -/// Well-known SquidStd ActivitySource for app-level custom spans. SquidStd subsystems name their own -/// sources with the "SquidStd." prefix; the OpenTelemetry provider captures them via "SquidStd.*". +/// Well-known SquidStd ActivitySource for app-level custom spans. SquidStd subsystems name their own +/// sources with the "SquidStd." prefix; the OpenTelemetry provider captures them via "SquidStd.*". /// public static class SquidStdActivity { diff --git a/src/SquidStd.Telemetry.OpenTelemetry/Extensions/OpenTelemetryServiceCollectionExtensions.cs b/src/SquidStd.Telemetry.OpenTelemetry/Extensions/OpenTelemetryServiceCollectionExtensions.cs index 47ce5fce..d474a713 100644 --- a/src/SquidStd.Telemetry.OpenTelemetry/Extensions/OpenTelemetryServiceCollectionExtensions.cs +++ b/src/SquidStd.Telemetry.OpenTelemetry/Extensions/OpenTelemetryServiceCollectionExtensions.cs @@ -22,8 +22,7 @@ public static IServiceCollection AddSquidStdTelemetry(this IServiceCollection se if (options.EnableTracing) { - builder.WithTracing( - tracing => + builder.WithTracing(tracing => { TelemetryPipeline.ConfigureTracing(tracing, options, true); TelemetryPipeline.AddTraceExporters(tracing, options); @@ -34,8 +33,7 @@ public static IServiceCollection AddSquidStdTelemetry(this IServiceCollection se if (options.EnableMetrics) { services.AddHostedService(); - builder.WithMetrics( - metrics => + builder.WithMetrics(metrics => { TelemetryPipeline.ConfigureMetrics(metrics, options); TelemetryPipeline.AddMetricExporters(metrics, options); diff --git a/src/SquidStd.Telemetry.OpenTelemetry/Internal/MetricsBridgeActivator.cs b/src/SquidStd.Telemetry.OpenTelemetry/Internal/MetricsBridgeActivator.cs index fd8e8c76..a215f6c7 100644 --- a/src/SquidStd.Telemetry.OpenTelemetry/Internal/MetricsBridgeActivator.cs +++ b/src/SquidStd.Telemetry.OpenTelemetry/Internal/MetricsBridgeActivator.cs @@ -4,8 +4,8 @@ namespace SquidStd.Telemetry.OpenTelemetry.Internal; /// -/// Warms the at host start so its observable instruments exist -/// for the MeterProvider's first collection (ASP.NET Core host path). +/// Warms the at host start so its observable instruments exist +/// for the MeterProvider's first collection (ASP.NET Core host path). /// internal sealed class MetricsBridgeActivator : IHostedService { @@ -24,5 +24,7 @@ public Task StartAsync(CancellationToken cancellationToken) } public Task StopAsync(CancellationToken cancellationToken) - => Task.CompletedTask; + { + return Task.CompletedTask; + } } diff --git a/src/SquidStd.Telemetry.OpenTelemetry/Internal/TelemetryPipeline.cs b/src/SquidStd.Telemetry.OpenTelemetry/Internal/TelemetryPipeline.cs index 4649d044..07623277 100644 --- a/src/SquidStd.Telemetry.OpenTelemetry/Internal/TelemetryPipeline.cs +++ b/src/SquidStd.Telemetry.OpenTelemetry/Internal/TelemetryPipeline.cs @@ -11,18 +11,17 @@ namespace SquidStd.Telemetry.OpenTelemetry.Internal; /// -/// Shared OpenTelemetry pipeline configuration, used by both the IContainer and IServiceCollection -/// registration surfaces. Instrumentation/source configuration is split from exporter configuration so -/// tests can reuse the production pipeline and append an in-memory exporter. +/// Shared OpenTelemetry pipeline configuration, used by both the IContainer and IServiceCollection +/// registration surfaces. Instrumentation/source configuration is split from exporter configuration so +/// tests can reuse the production pipeline and append an in-memory exporter. /// internal static class TelemetryPipeline { public static void AddMetricExporters(MeterProviderBuilder builder, TelemetryOptions options) { - builder.AddOtlpExporter( - o => + builder.AddOtlpExporter(o => { - o.Endpoint = new(options.OtlpEndpoint); + o.Endpoint = new Uri(options.OtlpEndpoint); o.Protocol = Map(options.OtlpProtocol); } ); @@ -35,10 +34,9 @@ public static void AddMetricExporters(MeterProviderBuilder builder, TelemetryOpt public static void AddTraceExporters(TracerProviderBuilder builder, TelemetryOptions options) { - builder.AddOtlpExporter( - o => + builder.AddOtlpExporter(o => { - o.Endpoint = new(options.OtlpEndpoint); + o.Endpoint = new Uri(options.OtlpEndpoint); o.Protocol = Map(options.OtlpProtocol); } ); @@ -66,10 +64,12 @@ public static ResourceBuilder BuildResource(TelemetryOptions options) } public static void ConfigureMetrics(MeterProviderBuilder builder, TelemetryOptions options) - => builder - .SetResourceBuilder(BuildResource(options)) - .AddRuntimeInstrumentation() - .AddMeter(MetricsSnapshotBridge.MeterName); + { + builder + .SetResourceBuilder(BuildResource(options)) + .AddRuntimeInstrumentation() + .AddMeter(MetricsSnapshotBridge.MeterName); + } public static void ConfigureTracing(TracerProviderBuilder builder, TelemetryOptions options, bool includeAspNetCore) { @@ -87,5 +87,7 @@ public static void ConfigureTracing(TracerProviderBuilder builder, TelemetryOpti } public static OtlpExportProtocol Map(OtlpProtocolType protocol) - => protocol == OtlpProtocolType.HttpProtobuf ? OtlpExportProtocol.HttpProtobuf : OtlpExportProtocol.Grpc; + { + return protocol == OtlpProtocolType.HttpProtobuf ? OtlpExportProtocol.HttpProtobuf : OtlpExportProtocol.Grpc; + } } diff --git a/src/SquidStd.Telemetry.OpenTelemetry/Services/MetricsSnapshotBridge.cs b/src/SquidStd.Telemetry.OpenTelemetry/Services/MetricsSnapshotBridge.cs index ef5821de..cc70d8f0 100644 --- a/src/SquidStd.Telemetry.OpenTelemetry/Services/MetricsSnapshotBridge.cs +++ b/src/SquidStd.Telemetry.OpenTelemetry/Services/MetricsSnapshotBridge.cs @@ -6,24 +6,25 @@ namespace SquidStd.Telemetry.OpenTelemetry.Services; /// -/// Bridges the SquidStd metrics snapshot () to OpenTelemetry -/// observable instruments: counters become observable counters, everything else an observable gauge, -/// each callback reading the latest snapshot value for its metric name. +/// Bridges the SquidStd metrics snapshot () to OpenTelemetry +/// observable instruments: counters become observable counters, everything else an observable gauge, +/// each callback reading the latest snapshot value for its metric name. /// public sealed class MetricsSnapshotBridge : IDisposable { /// The meter name registered on the OpenTelemetry MeterProvider. public const string MeterName = "SquidStd.Metrics"; - private readonly IMetricsCollectionService _metrics; private readonly Meter _meter; + + private readonly IMetricsCollectionService _metrics; private readonly ConcurrentDictionary _registered = new(StringComparer.Ordinal); private int _disposed; public MetricsSnapshotBridge(IMetricsCollectionService metrics) { _metrics = metrics; - _meter = new(MeterName); + _meter = new Meter(MeterName); EnsureInstruments(); } @@ -66,9 +67,9 @@ private IEnumerable> Observe(string name) } var tags = sample.Tags is { Count: > 0 } - ? sample.Tags.Select(kv => new KeyValuePair(kv.Key, kv.Value)).ToArray() - : []; + ? sample.Tags.Select(kv => new KeyValuePair(kv.Key, kv.Value)).ToArray() + : []; - return [new(sample.Value, tags)]; + return [new Measurement(sample.Value, tags)]; } } diff --git a/src/SquidStd.Telemetry.OpenTelemetry/Services/TelemetryService.cs b/src/SquidStd.Telemetry.OpenTelemetry/Services/TelemetryService.cs index 45f3ca6e..d921edfa 100644 --- a/src/SquidStd.Telemetry.OpenTelemetry/Services/TelemetryService.cs +++ b/src/SquidStd.Telemetry.OpenTelemetry/Services/TelemetryService.cs @@ -9,17 +9,17 @@ namespace SquidStd.Telemetry.OpenTelemetry.Services; /// -/// Owns the OpenTelemetry TracerProvider/MeterProvider for non-web (DryIoc/worker) hosts, building -/// them on start and disposing (flushing) them on stop. Telemetry failures never crash the host. +/// Owns the OpenTelemetry TracerProvider/MeterProvider for non-web (DryIoc/worker) hosts, building +/// them on start and disposing (flushing) them on stop. Telemetry failures never crash the host. /// public sealed class TelemetryService : ISquidStdService, IDisposable { + private readonly MetricsSnapshotBridge _bridge; private readonly ILogger _logger = Log.ForContext(); private readonly TelemetryOptions _options; - private readonly MetricsSnapshotBridge _bridge; - private TracerProvider? _tracerProvider; - private MeterProvider? _meterProvider; private int _disposed; + private MeterProvider? _meterProvider; + private TracerProvider? _tracerProvider; public TelemetryService(TelemetryOptions options, MetricsSnapshotBridge bridge) { diff --git a/src/SquidStd.Templating/Extensions/TemplatingRegistrationExtensions.cs b/src/SquidStd.Templating/Extensions/TemplatingRegistrationExtensions.cs index 5ea4f59c..7735eec5 100644 --- a/src/SquidStd.Templating/Extensions/TemplatingRegistrationExtensions.cs +++ b/src/SquidStd.Templating/Extensions/TemplatingRegistrationExtensions.cs @@ -6,13 +6,13 @@ namespace SquidStd.Templating.Extensions; /// -/// DryIoc registration helpers for the templating module. +/// DryIoc registration helpers for the templating module. /// public static class TemplatingRegistrationExtensions { /// - /// Registers the Scriban template renderer as a singleton SquidStd service (so its startup - /// auto-load of templates/*.tmpl runs with the host). Requires a registered DirectoriesConfig. + /// Registers the Scriban template renderer as a singleton SquidStd service (so its startup + /// auto-load of templates/*.tmpl runs with the host). Requires a registered DirectoriesConfig. /// public static IContainer AddTemplating(this IContainer container) { diff --git a/src/SquidStd.Templating/Interfaces/ITemplateRenderer.cs b/src/SquidStd.Templating/Interfaces/ITemplateRenderer.cs index f947933f..48457e9d 100644 --- a/src/SquidStd.Templating/Interfaces/ITemplateRenderer.cs +++ b/src/SquidStd.Templating/Interfaces/ITemplateRenderer.cs @@ -1,7 +1,7 @@ namespace SquidStd.Templating.Interfaces; /// -/// Renders Scriban templates from strings or registered named templates. +/// Renders Scriban templates from strings or registered named templates. /// public interface ITemplateRenderer { diff --git a/src/SquidStd.Templating/Services/ScribanTemplateRenderer.cs b/src/SquidStd.Templating/Services/ScribanTemplateRenderer.cs index ed386152..e066c41e 100644 --- a/src/SquidStd.Templating/Services/ScribanTemplateRenderer.cs +++ b/src/SquidStd.Templating/Services/ScribanTemplateRenderer.cs @@ -8,8 +8,8 @@ namespace SquidStd.Templating.Services; /// -/// Scriban-backed . Named templates are compiled and cached; ad-hoc -/// strings are parsed per call. On start it auto-loads templates/**/*.tmpl. +/// Scriban-backed . Named templates are compiled and cached; ad-hoc +/// strings are parsed per call. On start it auto-loads templates/**/*.tmpl. /// public sealed class ScribanTemplateRenderer : ITemplateRenderer, ISquidStdService { @@ -23,6 +23,32 @@ public ScribanTemplateRenderer(DirectoriesConfig directories) _directories = directories; } + /// + public async ValueTask StartAsync(CancellationToken cancellationToken = default) + { + var directory = _directories["templates"]; + + if (!Directory.Exists(directory)) + { + return; + } + + foreach (var file in Directory.EnumerateFiles(directory, "*" + TemplateExtension, SearchOption.AllDirectories)) + { + var relative = Path.GetRelativePath(directory, file).Replace('\\', '/'); + var name = relative[..^TemplateExtension.Length]; + var content = await File.ReadAllTextAsync(file, cancellationToken); + + Register(name, content); + } + } + + /// + public ValueTask StopAsync(CancellationToken cancellationToken = default) + { + return ValueTask.CompletedTask; + } + /// public void Register(string name, string template) { @@ -57,30 +83,6 @@ public async ValueTask RenderByNameAsync( return await RenderCompiledAsync(compiled, model, name); } - /// - public async ValueTask StartAsync(CancellationToken cancellationToken = default) - { - var directory = _directories["templates"]; - - if (!Directory.Exists(directory)) - { - return; - } - - foreach (var file in Directory.EnumerateFiles(directory, "*" + TemplateExtension, SearchOption.AllDirectories)) - { - var relative = Path.GetRelativePath(directory, file).Replace('\\', '/'); - var name = relative[..^TemplateExtension.Length]; - var content = await File.ReadAllTextAsync(file, cancellationToken); - - Register(name, content); - } - } - - /// - public ValueTask StopAsync(CancellationToken cancellationToken = default) - => ValueTask.CompletedTask; - private static Template Parse(string text, string name) { var template = Template.Parse(text); diff --git a/src/SquidStd.Templating/TemplateException.cs b/src/SquidStd.Templating/TemplateException.cs index 6ceb4f3e..383b31e4 100644 --- a/src/SquidStd.Templating/TemplateException.cs +++ b/src/SquidStd.Templating/TemplateException.cs @@ -1,13 +1,17 @@ namespace SquidStd.Templating; /// -/// Raised when a template fails to parse or render. +/// Raised when a template fails to parse or render. /// public sealed class TemplateException : Exception { public TemplateException(string message) - : base(message) { } + : base(message) + { + } public TemplateException(string message, Exception innerException) - : base(message, innerException) { } + : base(message, innerException) + { + } } diff --git a/src/SquidStd.Workers.Abstractions/Data/JobRequest.cs b/src/SquidStd.Workers.Abstractions/Data/JobRequest.cs index 0effc8c4..cc9477e4 100644 --- a/src/SquidStd.Workers.Abstractions/Data/JobRequest.cs +++ b/src/SquidStd.Workers.Abstractions/Data/JobRequest.cs @@ -1,7 +1,7 @@ namespace SquidStd.Workers.Abstractions.Data; /// -/// A unit of work the manager enqueues on the jobs queue for a worker to execute. +/// A unit of work the manager enqueues on the jobs queue for a worker to execute. /// /// Logical name of the job, used by the worker to pick a handler. /// Opaque string key/value arguments for the job. diff --git a/src/SquidStd.Workers.Abstractions/Data/WorkerHeartbeat.cs b/src/SquidStd.Workers.Abstractions/Data/WorkerHeartbeat.cs index a9c75ba7..622fc150 100644 --- a/src/SquidStd.Workers.Abstractions/Data/WorkerHeartbeat.cs +++ b/src/SquidStd.Workers.Abstractions/Data/WorkerHeartbeat.cs @@ -3,13 +3,13 @@ namespace SquidStd.Workers.Abstractions.Data; /// -/// A liveness signal a worker publishes on the heartbeat topic at a fixed interval. +/// A liveness signal a worker publishes on the heartbeat topic at a fixed interval. /// /// Stable identity of the reporting worker. /// UTC time the heartbeat was produced. /// -/// Self-reported status ( when no job is running, else -/// ). +/// Self-reported status ( when no job is running, else +/// ). /// /// Number of jobs currently in progress on this worker. /// Maximum number of jobs the worker runs at once. diff --git a/src/SquidStd.Workers.Abstractions/Data/WorkerInfo.cs b/src/SquidStd.Workers.Abstractions/Data/WorkerInfo.cs index 86d5f548..036d7090 100644 --- a/src/SquidStd.Workers.Abstractions/Data/WorkerInfo.cs +++ b/src/SquidStd.Workers.Abstractions/Data/WorkerInfo.cs @@ -3,7 +3,7 @@ namespace SquidStd.Workers.Abstractions.Data; /// -/// The manager-side view of a worker, folded from incoming heartbeats. +/// The manager-side view of a worker, folded from incoming heartbeats. /// /// Stable identity of the worker. /// Last known status; the manager sets on missed heartbeats. diff --git a/src/SquidStd.Workers.Abstractions/Types/WorkerStatusType.cs b/src/SquidStd.Workers.Abstractions/Types/WorkerStatusType.cs index d3aa947c..449b32dd 100644 --- a/src/SquidStd.Workers.Abstractions/Types/WorkerStatusType.cs +++ b/src/SquidStd.Workers.Abstractions/Types/WorkerStatusType.cs @@ -1,8 +1,8 @@ namespace SquidStd.Workers.Abstractions.Types; /// -/// Lifecycle status of a worker. Workers report or ; -/// the manager assigns when a worker's heartbeats stop arriving. +/// Lifecycle status of a worker. Workers report or ; +/// the manager assigns when a worker's heartbeats stop arriving. /// public enum WorkerStatusType { diff --git a/src/SquidStd.Workers.Abstractions/WorkerChannels.cs b/src/SquidStd.Workers.Abstractions/WorkerChannels.cs index 6514a147..899972c2 100644 --- a/src/SquidStd.Workers.Abstractions/WorkerChannels.cs +++ b/src/SquidStd.Workers.Abstractions/WorkerChannels.cs @@ -1,8 +1,8 @@ namespace SquidStd.Workers.Abstractions; /// -/// Default names of the messaging channels the workers system uses. Shared so the manager -/// and workers agree out of the box; either side may override them via its own configuration. +/// Default names of the messaging channels the workers system uses. Shared so the manager +/// and workers agree out of the box; either side may override them via its own configuration. /// public static class WorkerChannels { diff --git a/src/SquidStd.Workers.Manager/Data/Config/WorkerManagerConfig.cs b/src/SquidStd.Workers.Manager/Data/Config/WorkerManagerConfig.cs index beef18cd..96e577db 100644 --- a/src/SquidStd.Workers.Manager/Data/Config/WorkerManagerConfig.cs +++ b/src/SquidStd.Workers.Manager/Data/Config/WorkerManagerConfig.cs @@ -3,7 +3,7 @@ namespace SquidStd.Workers.Manager.Data.Config; /// -/// Configuration for the worker manager (config section "workerManager"). +/// Configuration for the worker manager (config section "workerManager"). /// public sealed class WorkerManagerConfig { diff --git a/src/SquidStd.Workers.Manager/Data/EnqueueJobRequest.cs b/src/SquidStd.Workers.Manager/Data/EnqueueJobRequest.cs index 8869f5f7..d78e30bf 100644 --- a/src/SquidStd.Workers.Manager/Data/EnqueueJobRequest.cs +++ b/src/SquidStd.Workers.Manager/Data/EnqueueJobRequest.cs @@ -1,7 +1,7 @@ namespace SquidStd.Workers.Manager.Data; /// -/// Request body for enqueuing a job via the manager's HTTP surface. +/// Request body for enqueuing a job via the manager's HTTP surface. /// /// Logical name of the job. /// Optional string key/value arguments; treated as empty when null. diff --git a/src/SquidStd.Workers.Manager/Data/Events/WorkerStatusChangedEvent.cs b/src/SquidStd.Workers.Manager/Data/Events/WorkerStatusChangedEvent.cs index e2922b8c..c9e43cae 100644 --- a/src/SquidStd.Workers.Manager/Data/Events/WorkerStatusChangedEvent.cs +++ b/src/SquidStd.Workers.Manager/Data/Events/WorkerStatusChangedEvent.cs @@ -4,8 +4,8 @@ namespace SquidStd.Workers.Manager.Data.Events; /// -/// Published on the event bus when a worker's status changes: discovered ( null), -/// gone Offline (via the sweep), or returned (Offline → Idle/Busy). +/// Published on the event bus when a worker's status changes: discovered ( null), +/// gone Offline (via the sweep), or returned (Offline → Idle/Busy). /// /// The worker whose status changed. /// Previous status, or null when the worker was just discovered. diff --git a/src/SquidStd.Workers.Manager/Endpoints/WorkerManagerEndpoints.cs b/src/SquidStd.Workers.Manager/Endpoints/WorkerManagerEndpoints.cs index d3928537..0fcd2174 100644 --- a/src/SquidStd.Workers.Manager/Endpoints/WorkerManagerEndpoints.cs +++ b/src/SquidStd.Workers.Manager/Endpoints/WorkerManagerEndpoints.cs @@ -7,7 +7,7 @@ namespace SquidStd.Workers.Manager.Endpoints; /// -/// Typed minimal-API handlers for the worker manager surface. Static so they can be unit-tested directly. +/// Typed minimal-API handlers for the worker manager surface. Static so they can be unit-tested directly. /// public static class WorkerManagerEndpoints { @@ -52,5 +52,7 @@ public static Results, NotFound> GetWorker(string id, IWorkerRegi /// Lists all known workers. public static Ok> GetWorkers(IWorkerRegistry registry) - => TypedResults.Ok(registry.GetAll()); + { + return TypedResults.Ok(registry.GetAll()); + } } diff --git a/src/SquidStd.Workers.Manager/Extensions/WorkerManagerEndpointsExtensions.cs b/src/SquidStd.Workers.Manager/Extensions/WorkerManagerEndpointsExtensions.cs index 6290d917..44cc2f6e 100644 --- a/src/SquidStd.Workers.Manager/Extensions/WorkerManagerEndpointsExtensions.cs +++ b/src/SquidStd.Workers.Manager/Extensions/WorkerManagerEndpointsExtensions.cs @@ -5,12 +5,12 @@ namespace SquidStd.Workers.Manager.Extensions; /// -/// Maps the opt-in worker manager HTTP endpoints. +/// Maps the opt-in worker manager HTTP endpoints. /// public static class WorkerManagerEndpointsExtensions { /// - /// Maps GET /workers, GET /workers/{id}, and POST /jobs. + /// Maps GET /workers, GET /workers/{id}, and POST /jobs. /// public static IEndpointRouteBuilder MapWorkerManagerEndpoints(this IEndpointRouteBuilder endpoints) { diff --git a/src/SquidStd.Workers.Manager/Extensions/WorkerManagerRegistrationExtensions.cs b/src/SquidStd.Workers.Manager/Extensions/WorkerManagerRegistrationExtensions.cs index 4b825bab..15173e31 100644 --- a/src/SquidStd.Workers.Manager/Extensions/WorkerManagerRegistrationExtensions.cs +++ b/src/SquidStd.Workers.Manager/Extensions/WorkerManagerRegistrationExtensions.cs @@ -10,13 +10,13 @@ namespace SquidStd.Workers.Manager.Extensions; /// -/// DryIoc registration helpers for the worker manager. +/// DryIoc registration helpers for the worker manager. /// public static class WorkerManagerRegistrationExtensions { /// - /// Registers the worker manager: config section, registry, job scheduler, the collector and sweep - /// lifecycle services, and the timer-wheel pump (only if it is not already registered). + /// Registers the worker manager: config section, registry, job scheduler, the collector and sweep + /// lifecycle services, and the timer-wheel pump (only if it is not already registered). /// public static IContainer AddWorkerManager(this IContainer container) { diff --git a/src/SquidStd.Workers.Manager/Interfaces/IJobScheduler.cs b/src/SquidStd.Workers.Manager/Interfaces/IJobScheduler.cs index 337d0f5f..e5d68888 100644 --- a/src/SquidStd.Workers.Manager/Interfaces/IJobScheduler.cs +++ b/src/SquidStd.Workers.Manager/Interfaces/IJobScheduler.cs @@ -1,7 +1,7 @@ namespace SquidStd.Workers.Manager.Interfaces; /// -/// Enqueues jobs for workers to consume. +/// Enqueues jobs for workers to consume. /// public interface IJobScheduler { diff --git a/src/SquidStd.Workers.Manager/Interfaces/IWorkerRegistry.cs b/src/SquidStd.Workers.Manager/Interfaces/IWorkerRegistry.cs index 8c46d819..39eff65f 100644 --- a/src/SquidStd.Workers.Manager/Interfaces/IWorkerRegistry.cs +++ b/src/SquidStd.Workers.Manager/Interfaces/IWorkerRegistry.cs @@ -3,7 +3,7 @@ namespace SquidStd.Workers.Manager.Interfaces; /// -/// Read access to the manager's in-memory view of known workers. +/// Read access to the manager's in-memory view of known workers. /// public interface IWorkerRegistry { diff --git a/src/SquidStd.Workers.Manager/Services/HeartbeatCollectorService.cs b/src/SquidStd.Workers.Manager/Services/HeartbeatCollectorService.cs index 0737114d..86023ff2 100644 --- a/src/SquidStd.Workers.Manager/Services/HeartbeatCollectorService.cs +++ b/src/SquidStd.Workers.Manager/Services/HeartbeatCollectorService.cs @@ -9,15 +9,15 @@ namespace SquidStd.Workers.Manager.Services; /// -/// Subscribes to the heartbeat topic and folds each into the registry, -/// publishing any resulting status transition on the event bus. +/// Subscribes to the heartbeat topic and folds each into the registry, +/// publishing any resulting status transition on the event bus. /// public sealed class HeartbeatCollectorService : ISquidStdService { + private readonly IEventBus _eventBus; private readonly ILogger _logger = Log.ForContext(); - private readonly IMessageTopic _topic; private readonly WorkerRegistry _registry; - private readonly IEventBus _eventBus; + private readonly IMessageTopic _topic; private readonly string _topicName; private IDisposable? _subscription; @@ -32,8 +32,8 @@ WorkerManagerConfig config _registry = registry; _eventBus = eventBus; _topicName = string.IsNullOrWhiteSpace(config.HeartbeatTopic) - ? WorkerChannels.HeartbeatTopic - : config.HeartbeatTopic; + ? WorkerChannels.HeartbeatTopic + : config.HeartbeatTopic; } /// diff --git a/src/SquidStd.Workers.Manager/Services/JobScheduler.cs b/src/SquidStd.Workers.Manager/Services/JobScheduler.cs index 646c7b5f..c45dac81 100644 --- a/src/SquidStd.Workers.Manager/Services/JobScheduler.cs +++ b/src/SquidStd.Workers.Manager/Services/JobScheduler.cs @@ -7,7 +7,7 @@ namespace SquidStd.Workers.Manager.Services; /// -/// Default : publishes s onto the configured jobs queue. +/// Default : publishes s onto the configured jobs queue. /// public sealed class JobScheduler : IJobScheduler { @@ -26,9 +26,13 @@ public Task EnqueueAsync( IReadOnlyDictionary parameters, CancellationToken cancellationToken = default ) - => _queue.PublishAsync(_queueName, new JobRequest(jobName, parameters), cancellationToken); + { + return _queue.PublishAsync(_queueName, new JobRequest(jobName, parameters), cancellationToken); + } /// public Task EnqueueAsync(string jobName, CancellationToken cancellationToken = default) - => EnqueueAsync(jobName, new Dictionary(), cancellationToken); + { + return EnqueueAsync(jobName, new Dictionary(), cancellationToken); + } } diff --git a/src/SquidStd.Workers.Manager/Services/WorkerOfflineSweepService.cs b/src/SquidStd.Workers.Manager/Services/WorkerOfflineSweepService.cs index fc07aeb7..0937723a 100644 --- a/src/SquidStd.Workers.Manager/Services/WorkerOfflineSweepService.cs +++ b/src/SquidStd.Workers.Manager/Services/WorkerOfflineSweepService.cs @@ -7,18 +7,18 @@ namespace SquidStd.Workers.Manager.Services; /// -/// Periodically marks workers Offline whose heartbeats have stopped, using the timer wheel -/// (), and publishes the resulting transitions. +/// Periodically marks workers Offline whose heartbeats have stopped, using the timer wheel +/// (), and publishes the resulting transitions. /// public sealed class WorkerOfflineSweepService : ISquidStdService { private const int DefaultIntervalSeconds = 10; + private readonly IEventBus _eventBus; + private readonly TimeSpan _interval; private readonly ILogger _logger = Log.ForContext(); - private readonly ITimerService _timer; private readonly WorkerRegistry _registry; - private readonly IEventBus _eventBus; - private readonly TimeSpan _interval; + private readonly ITimerService _timer; private string? _timerId; public WorkerOfflineSweepService( @@ -46,22 +46,6 @@ WorkerManagerConfig config _interval = TimeSpan.FromSeconds(seconds); } - /// Runs one sweep and publishes the transitions. Public so it can be driven directly in tests. - public async Task RunSweepAsync() - { - try - { - foreach (var change in _registry.Sweep(DateTime.UtcNow)) - { - await _eventBus.PublishAsync(change, CancellationToken.None); - } - } - catch (Exception ex) - { - _logger.Error(ex, "Worker offline sweep failed."); - } - } - /// public ValueTask StartAsync(CancellationToken cancellationToken = default) { @@ -82,6 +66,24 @@ public ValueTask StopAsync(CancellationToken cancellationToken = default) return ValueTask.CompletedTask; } + /// Runs one sweep and publishes the transitions. Public so it can be driven directly in tests. + public async Task RunSweepAsync() + { + try + { + foreach (var change in _registry.Sweep(DateTime.UtcNow)) + { + await _eventBus.PublishAsync(change, CancellationToken.None); + } + } + catch (Exception ex) + { + _logger.Error(ex, "Worker offline sweep failed."); + } + } + private void OnTick() - => _ = RunSweepAsync(); + { + _ = RunSweepAsync(); + } } diff --git a/src/SquidStd.Workers.Manager/Services/WorkerRegistry.cs b/src/SquidStd.Workers.Manager/Services/WorkerRegistry.cs index 917da1c6..64cf72c6 100644 --- a/src/SquidStd.Workers.Manager/Services/WorkerRegistry.cs +++ b/src/SquidStd.Workers.Manager/Services/WorkerRegistry.cs @@ -7,14 +7,14 @@ namespace SquidStd.Workers.Manager.Services; /// -/// In-memory registry of workers, folded from heartbeats. Pure: it returns status transitions for the -/// caller to publish, and never touches the event bus or a real clock (the sweep takes the time as a parameter). +/// In-memory registry of workers, folded from heartbeats. Pure: it returns status transitions for the +/// caller to publish, and never touches the event bus or a real clock (the sweep takes the time as a parameter). /// public sealed class WorkerRegistry : IWorkerRegistry { + private readonly TimeSpan _offlineTimeout; private readonly Lock _sync = new(); private readonly Dictionary _workers = new(StringComparer.Ordinal); - private readonly TimeSpan _offlineTimeout; public WorkerRegistry(WorkerManagerConfig config) { @@ -41,8 +41,8 @@ public IReadOnlyCollection GetAll() } /// - /// Folds a heartbeat into the registry. Returns a transition only when the worker is newly discovered - /// or has returned from ; otherwise null. + /// Folds a heartbeat into the registry. Returns a transition only when the worker is newly discovered + /// or has returned from ; otherwise null. /// public WorkerStatusChangedEvent? Record(WorkerHeartbeat heartbeat) { @@ -52,7 +52,7 @@ public IReadOnlyCollection GetAll() { if (!_workers.TryGetValue(heartbeat.WorkerId, out var existing)) { - _workers[heartbeat.WorkerId] = new( + _workers[heartbeat.WorkerId] = new WorkerInfo( heartbeat.WorkerId, heartbeat.Status, heartbeat.ActiveJobs, @@ -61,7 +61,7 @@ public IReadOnlyCollection GetAll() now ); - return new(heartbeat.WorkerId, null, heartbeat.Status); + return new WorkerStatusChangedEvent(heartbeat.WorkerId, null, heartbeat.Status); } var old = existing.Status; @@ -74,14 +74,14 @@ public IReadOnlyCollection GetAll() }; return old == WorkerStatusType.Offline && heartbeat.Status != WorkerStatusType.Offline - ? new WorkerStatusChangedEvent(heartbeat.WorkerId, old, heartbeat.Status) - : null; + ? new WorkerStatusChangedEvent(heartbeat.WorkerId, old, heartbeat.Status) + : null; } } /// - /// Marks workers Offline whose last heartbeat is older than the configured timeout relative to - /// . Returns the resulting transitions. + /// Marks workers Offline whose last heartbeat is older than the configured timeout relative to + /// . Returns the resulting transitions. /// public IReadOnlyList Sweep(DateTime nowUtc) { @@ -97,7 +97,7 @@ public IReadOnlyList Sweep(DateTime nowUtc) } _workers[id] = info with { Status = WorkerStatusType.Offline }; - changes.Add(new(id, info.Status, WorkerStatusType.Offline)); + changes.Add(new WorkerStatusChangedEvent(id, info.Status, WorkerStatusType.Offline)); } } diff --git a/src/SquidStd.Workers/Attributes/RegisterJobHandlerAttribute.cs b/src/SquidStd.Workers/Attributes/RegisterJobHandlerAttribute.cs new file mode 100644 index 00000000..71b75a66 --- /dev/null +++ b/src/SquidStd.Workers/Attributes/RegisterJobHandlerAttribute.cs @@ -0,0 +1,9 @@ +namespace SquidStd.Workers.Attributes; + +/// +/// Marks a worker job handler for generated registration. +/// +[AttributeUsage(AttributeTargets.Class, Inherited = false)] +public sealed class RegisterJobHandlerAttribute : Attribute +{ +} diff --git a/src/SquidStd.Workers/Data/Config/WorkersConfig.cs b/src/SquidStd.Workers/Data/Config/WorkersConfig.cs index d9bf2b4f..3340c181 100644 --- a/src/SquidStd.Workers/Data/Config/WorkersConfig.cs +++ b/src/SquidStd.Workers/Data/Config/WorkersConfig.cs @@ -3,13 +3,13 @@ namespace SquidStd.Workers.Data.Config; /// -/// Configuration for the worker runtime (config section "workers"). +/// Configuration for the worker runtime (config section "workers"). /// public sealed class WorkersConfig { /// - /// Stable worker identity. When blank, the runtime falls back to the machine name - /// (in Docker, the container hostname). + /// Stable worker identity. When blank, the runtime falls back to the machine name + /// (in Docker, the container hostname). /// public string WorkerId { get; set; } = string.Empty; diff --git a/src/SquidStd.Workers/Exceptions/JobHandlerNotFoundException.cs b/src/SquidStd.Workers/Exceptions/JobHandlerNotFoundException.cs index 61a4d92d..4024a9a1 100644 --- a/src/SquidStd.Workers/Exceptions/JobHandlerNotFoundException.cs +++ b/src/SquidStd.Workers/Exceptions/JobHandlerNotFoundException.cs @@ -1,17 +1,17 @@ namespace SquidStd.Workers.Exceptions; /// -/// Thrown when a job arrives whose name has no registered . +/// Thrown when a job arrives whose name has no registered . /// public sealed class JobHandlerNotFoundException : Exception { - /// The job name that had no handler. - public string JobName { get; } - /// Initializes the exception for the given job name. public JobHandlerNotFoundException(string jobName) : base($"No job handler is registered for job '{jobName}'.") { JobName = jobName; } + + /// The job name that had no handler. + public string JobName { get; } } diff --git a/src/SquidStd.Workers/Extensions/WorkersRegistrationExtensions.cs b/src/SquidStd.Workers/Extensions/WorkersRegistrationExtensions.cs index 89be0b86..d8991d5e 100644 --- a/src/SquidStd.Workers/Extensions/WorkersRegistrationExtensions.cs +++ b/src/SquidStd.Workers/Extensions/WorkersRegistrationExtensions.cs @@ -8,12 +8,12 @@ namespace SquidStd.Workers.Extensions; /// -/// DryIoc registration helpers for the worker runtime. +/// DryIoc registration helpers for the worker runtime. /// public static class WorkersRegistrationExtensions { /// - /// Registers a job handler so the dispatcher can route jobs to it by name. + /// Registers a job handler so the dispatcher can route jobs to it by name. /// public static IContainer AddJobHandler(this IContainer container) where THandler : class, IJobHandler @@ -26,8 +26,8 @@ public static IContainer AddJobHandler(this IContainer container) } /// - /// Registers the worker runtime: the "workers" config section, shared state, job dispatcher, and the - /// consumer + heartbeat lifecycle services. + /// Registers the worker runtime: the "workers" config section, shared state, job dispatcher, and the + /// consumer + heartbeat lifecycle services. /// public static IContainer AddWorkers(this IContainer container) { diff --git a/src/SquidStd.Workers/Interfaces/IJobDispatcher.cs b/src/SquidStd.Workers/Interfaces/IJobDispatcher.cs index fdae86b5..9c9a18f3 100644 --- a/src/SquidStd.Workers/Interfaces/IJobDispatcher.cs +++ b/src/SquidStd.Workers/Interfaces/IJobDispatcher.cs @@ -3,12 +3,12 @@ namespace SquidStd.Workers.Interfaces; /// -/// Routes a to the registered for its job name. +/// Routes a to the registered for its job name. /// public interface IJobDispatcher { /// - /// Dispatches the job to its handler. + /// Dispatches the job to its handler. /// /// No handler matches the job name. Task DispatchAsync(JobRequest job, CancellationToken cancellationToken); diff --git a/src/SquidStd.Workers/Interfaces/IJobHandler.cs b/src/SquidStd.Workers/Interfaces/IJobHandler.cs index d64a706e..efd2a910 100644 --- a/src/SquidStd.Workers/Interfaces/IJobHandler.cs +++ b/src/SquidStd.Workers/Interfaces/IJobHandler.cs @@ -3,7 +3,7 @@ namespace SquidStd.Workers.Interfaces; /// -/// Handles jobs of a single named kind. Implemented by consumers of the worker library. +/// Handles jobs of a single named kind. Implemented by consumers of the worker library. /// public interface IJobHandler { diff --git a/src/SquidStd.Workers/Interfaces/IWorkerState.cs b/src/SquidStd.Workers/Interfaces/IWorkerState.cs index 98e90069..c27933a9 100644 --- a/src/SquidStd.Workers/Interfaces/IWorkerState.cs +++ b/src/SquidStd.Workers/Interfaces/IWorkerState.cs @@ -3,7 +3,7 @@ namespace SquidStd.Workers.Interfaces; /// -/// Shared runtime state of a worker, read by the heartbeat service and mutated by the consumer. +/// Shared runtime state of a worker, read by the heartbeat service and mutated by the consumer. /// public interface IWorkerState { diff --git a/src/SquidStd.Workers/README.md b/src/SquidStd.Workers/README.md index 1024d3c6..5f31fe8c 100644 --- a/src/SquidStd.Workers/README.md +++ b/src/SquidStd.Workers/README.md @@ -25,14 +25,17 @@ dotnet add package SquidStd.Workers ```csharp using DryIoc; +using SquidStd.Generators.Workers; +using SquidStd.Workers.Attributes; using SquidStd.Workers.Extensions; container.AddInMemoryMessaging(); // or AddRabbitMqMessaging(...) container.AddWorkers(); -container.AddJobHandler(); +container.RegisterGeneratedJobHandlers(); ``` ```csharp +[RegisterJobHandler] public sealed class ResizeImageHandler : IJobHandler { public string JobName => "resize-image"; @@ -51,6 +54,7 @@ public sealed class ResizeImageHandler : IJobHandler |---------------------------------|------------------------------------------------------------------------------| | `IJobHandler` | Handles jobs of one named kind. | | `WorkersConfig` | `WorkerId`, `HeartbeatIntervalSeconds`, `MaxConcurrency`, queue/topic names. | +| `RegisterJobHandlerAttribute` | Marks handlers for generated registration. | | `WorkersRegistrationExtensions` | `AddWorkers()` and `AddJobHandler()`. | ## License diff --git a/src/SquidStd.Workers/Services/JobDispatcher.cs b/src/SquidStd.Workers/Services/JobDispatcher.cs index 13071d0c..f097823d 100644 --- a/src/SquidStd.Workers/Services/JobDispatcher.cs +++ b/src/SquidStd.Workers/Services/JobDispatcher.cs @@ -6,12 +6,12 @@ namespace SquidStd.Workers.Services; /// -/// Default : indexes the registered handlers by job name. +/// Default : indexes the registered handlers by job name. /// public sealed class JobDispatcher : IJobDispatcher { - private readonly ILogger _logger = Log.ForContext(); private readonly Dictionary _handlers = new(StringComparer.Ordinal); + private readonly ILogger _logger = Log.ForContext(); public JobDispatcher(IEnumerable handlers) { diff --git a/src/SquidStd.Workers/Services/WorkerConsumerService.cs b/src/SquidStd.Workers/Services/WorkerConsumerService.cs index 54751940..1c9e0903 100644 --- a/src/SquidStd.Workers/Services/WorkerConsumerService.cs +++ b/src/SquidStd.Workers/Services/WorkerConsumerService.cs @@ -10,17 +10,17 @@ namespace SquidStd.Workers.Services; /// -/// Subscribes to the jobs queue and dispatches each to its handler, -/// bounded by . +/// Subscribes to the jobs queue and dispatches each to its handler, +/// bounded by . /// public sealed class WorkerConsumerService : ISquidStdService, IQueueMessageListenerAsync { + private readonly IJobDispatcher _dispatcher; private readonly ILogger _logger = Log.ForContext(); private readonly IMessageQueue _queue; - private readonly IJobDispatcher _dispatcher; - private readonly IWorkerState _state; - private readonly SemaphoreSlim _semaphore; private readonly string _queueName; + private readonly SemaphoreSlim _semaphore; + private readonly IWorkerState _state; private IDisposable? _subscription; /// Constructs the consumer wired to the messaging queue and worker state. @@ -29,7 +29,7 @@ public WorkerConsumerService(IMessageQueue queue, IJobDispatcher dispatcher, IWo _queue = queue; _dispatcher = dispatcher; _state = state; - _semaphore = new(state.MaxConcurrency); + _semaphore = new SemaphoreSlim(state.MaxConcurrency); _queueName = string.IsNullOrWhiteSpace(config.JobQueue) ? WorkerChannels.JobQueue : config.JobQueue; } diff --git a/src/SquidStd.Workers/Services/WorkerHeartbeatService.cs b/src/SquidStd.Workers/Services/WorkerHeartbeatService.cs index ea5197ec..c76ad93a 100644 --- a/src/SquidStd.Workers/Services/WorkerHeartbeatService.cs +++ b/src/SquidStd.Workers/Services/WorkerHeartbeatService.cs @@ -9,17 +9,17 @@ namespace SquidStd.Workers.Services; /// -/// Publishes a on the heartbeat topic immediately on start and then once -/// per configured interval. +/// Publishes a on the heartbeat topic immediately on start and then once +/// per configured interval. /// public sealed class WorkerHeartbeatService : ISquidStdService { private const int DefaultIntervalSeconds = 10; + private readonly TimeSpan _interval; private readonly ILogger _logger = Log.ForContext(); - private readonly IMessageTopic _topic; private readonly IWorkerState _state; - private readonly TimeSpan _interval; + private readonly IMessageTopic _topic; private readonly string _topicName; private CancellationTokenSource? _loopCts; private Task? _loopTask; @@ -42,8 +42,8 @@ public WorkerHeartbeatService(IMessageTopic topic, IWorkerState state, WorkersCo _interval = TimeSpan.FromSeconds(seconds); _topicName = string.IsNullOrWhiteSpace(config.HeartbeatTopic) - ? WorkerChannels.HeartbeatTopic - : config.HeartbeatTopic; + ? WorkerChannels.HeartbeatTopic + : config.HeartbeatTopic; } /// diff --git a/src/SquidStd.Workers/Services/WorkerState.cs b/src/SquidStd.Workers/Services/WorkerState.cs index 646fc7c2..684bfe7a 100644 --- a/src/SquidStd.Workers/Services/WorkerState.cs +++ b/src/SquidStd.Workers/Services/WorkerState.cs @@ -5,25 +5,25 @@ namespace SquidStd.Workers.Services; /// -/// Default backed by an interlocked counter; resolves identity and -/// concurrency from at construction. +/// Default backed by an interlocked counter; resolves identity and +/// concurrency from at construction. /// public sealed class WorkerState : IWorkerState { private int _activeJobs; - /// - public string WorkerId { get; } - - /// - public int MaxConcurrency { get; } - public WorkerState(WorkersConfig config) { WorkerId = string.IsNullOrWhiteSpace(config.WorkerId) ? Environment.MachineName : config.WorkerId; MaxConcurrency = config.MaxConcurrency > 0 ? config.MaxConcurrency : Environment.ProcessorCount; } + /// + public string WorkerId { get; } + + /// + public int MaxConcurrency { get; } + /// public int ActiveJobs => Volatile.Read(ref _activeJobs); @@ -32,9 +32,13 @@ public WorkerState(WorkersConfig config) /// public void JobFinished() - => Interlocked.Decrement(ref _activeJobs); + { + Interlocked.Decrement(ref _activeJobs); + } /// public void JobStarted() - => Interlocked.Increment(ref _activeJobs); + { + Interlocked.Increment(ref _activeJobs); + } } diff --git a/tests/SquidStd.Tests/Abstractions/ConfigRegistrationDataTests.cs b/tests/SquidStd.Tests/Abstractions/ConfigRegistrationDataTests.cs index 7b01f332..df9a2af0 100644 --- a/tests/SquidStd.Tests/Abstractions/ConfigRegistrationDataTests.cs +++ b/tests/SquidStd.Tests/Abstractions/ConfigRegistrationDataTests.cs @@ -8,7 +8,7 @@ public class ConfigRegistrationDataTests [Fact] public void CreateDefault_IncompatibleFactory_Throws() { - var entry = new ConfigRegistrationData("test", typeof(TestConfig), () => new()); + var entry = new ConfigRegistrationData("test", typeof(TestConfig), () => new object()); Assert.Throws(() => entry.CreateDefault()); } @@ -34,11 +34,18 @@ public void CreateDefault_UsesFactory() [Fact] public void Ctor_InvalidSectionName_Throws() - => Assert.Throws( - () => new ConfigRegistrationData(string.Empty, typeof(TestConfig), () => new TestConfig()) + { + Assert.Throws(() => new ConfigRegistrationData( + string.Empty, + typeof(TestConfig), + () => new TestConfig() + ) ); + } [Fact] public void Ctor_NullType_Throws() - => Assert.Throws(() => new ConfigRegistrationData("test", null!, () => new TestConfig())); + { + Assert.Throws(() => new ConfigRegistrationData("test", null!, () => new TestConfig())); + } } diff --git a/tests/SquidStd.Tests/AspNetCore/FakeSquidStdBootstrap.cs b/tests/SquidStd.Tests/AspNetCore/FakeSquidStdBootstrap.cs index 17489326..a2d78432 100644 --- a/tests/SquidStd.Tests/AspNetCore/FakeSquidStdBootstrap.cs +++ b/tests/SquidStd.Tests/AspNetCore/FakeSquidStdBootstrap.cs @@ -15,7 +15,9 @@ internal sealed class FakeSquidStdBootstrap : ISquidStdBootstrap public IContainer Container { get; } = new Container(); public ISquidStdBootstrap ConfigureService(Func configure) - => ConfigureServices(configure); + { + return ConfigureServices(configure); + } public ISquidStdBootstrap ConfigureServices(Func configure) { @@ -23,8 +25,8 @@ public ISquidStdBootstrap ConfigureServices(Func configu var configuredContainer = configure(Container); return ReferenceEquals(configuredContainer, Container) - ? this - : throw new InvalidOperationException("ConfigureServices must return the bootstrap container instance."); + ? this + : throw new InvalidOperationException("ConfigureServices must return the bootstrap container instance."); } public ValueTask DisposeAsync() @@ -35,10 +37,14 @@ public ValueTask DisposeAsync() } public TService Resolve() - => Container.Resolve(); + { + return Container.Resolve(); + } public async Task RunAsync(CancellationToken cancellationToken = default) - => await StartAsync(cancellationToken); + { + await StartAsync(cancellationToken); + } public ValueTask StartAsync(CancellationToken cancellationToken = default) { diff --git a/tests/SquidStd.Tests/AspNetCore/SquidStdAspNetCoreBuilderExtensionsTests.cs b/tests/SquidStd.Tests/AspNetCore/SquidStdAspNetCoreBuilderExtensionsTests.cs index c29c0a56..994698a1 100644 --- a/tests/SquidStd.Tests/AspNetCore/SquidStdAspNetCoreBuilderExtensionsTests.cs +++ b/tests/SquidStd.Tests/AspNetCore/SquidStdAspNetCoreBuilderExtensionsTests.cs @@ -108,8 +108,7 @@ public void UseSquidStd_WhenContainerCallbackReturnsDifferentContainer_Throws() using var temp = new TempDirectory(); var builder = CreateBuilder(temp.Path); - var ex = Assert.Throws( - () => builder.UseSquidStd( + var ex = Assert.Throws(() => builder.UseSquidStd( options => options.ConfigName = "app", _ => new Container() ) diff --git a/tests/SquidStd.Tests/AspNetCore/SquidStdHealthCheckAdapterTests.cs b/tests/SquidStd.Tests/AspNetCore/SquidStdHealthCheckAdapterTests.cs index 069efe15..53ee2055 100644 --- a/tests/SquidStd.Tests/AspNetCore/SquidStdHealthCheckAdapterTests.cs +++ b/tests/SquidStd.Tests/AspNetCore/SquidStdHealthCheckAdapterTests.cs @@ -12,7 +12,7 @@ public async Task CheckHealthAsync_MapsHealthy() { var adapter = new SquidStdHealthCheckAdapter(new FakeHealthCheck("ok", SquidHealthResult.Healthy("all good"))); - var result = await adapter.CheckHealthAsync(new()); + var result = await adapter.CheckHealthAsync(new HealthCheckContext()); Assert.Equal(HealthStatus.Healthy, result.Status); Assert.Equal("all good", result.Description); @@ -24,7 +24,7 @@ public async Task CheckHealthAsync_MapsUnhealthy_WithDescriptionAndException() var ex = new InvalidOperationException("boom"); var adapter = new SquidStdHealthCheckAdapter(new FakeHealthCheck("bad", SquidHealthResult.Unhealthy("down", ex))); - var result = await adapter.CheckHealthAsync(new()); + var result = await adapter.CheckHealthAsync(new HealthCheckContext()); Assert.Equal(HealthStatus.Unhealthy, result.Status); Assert.Equal("down", result.Description); diff --git a/tests/SquidStd.Tests/Bootstrap/SquidStdBootstrapTests.cs b/tests/SquidStd.Tests/Bootstrap/SquidStdBootstrapTests.cs index fb00f77d..caa9a1bb 100644 --- a/tests/SquidStd.Tests/Bootstrap/SquidStdBootstrapTests.cs +++ b/tests/SquidStd.Tests/Bootstrap/SquidStdBootstrapTests.cs @@ -14,82 +14,12 @@ namespace SquidStd.Tests.Bootstrap; [Collection(SerilogEventSinkCollection.Name)] public class SquidStdBootstrapTests { - private sealed class ConfigConsumerService(SquidStdLoggerOptions options, List events) : ISquidStdService - { - public ValueTask StartAsync(CancellationToken cancellationToken = default) - { - events.Add($"config:{options.MinimumLevel}"); - - return ValueTask.CompletedTask; - } - - public ValueTask StopAsync(CancellationToken cancellationToken = default) - => ValueTask.CompletedTask; - } - - private sealed class EarlyTrackedService(List events) : ISquidStdService - { - public ValueTask StartAsync(CancellationToken cancellationToken = default) - { - events.Add("early:start"); - - return ValueTask.CompletedTask; - } - - public ValueTask StopAsync(CancellationToken cancellationToken = default) - { - events.Add("early:stop"); - - return ValueTask.CompletedTask; - } - } - - private sealed class LateTrackedService(List events) : ISquidStdService - { - public ValueTask StartAsync(CancellationToken cancellationToken = default) - { - events.Add("late:start"); - - return ValueTask.CompletedTask; - } - - public ValueTask StopAsync(CancellationToken cancellationToken = default) - { - events.Add("late:stop"); - - return ValueTask.CompletedTask; - } - } - - private sealed class RunTrackedState - { - public TaskCompletionSource Started { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); - - public TaskCompletionSource Stopped { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); - } - - private sealed class RunTrackedService(RunTrackedState state) : ISquidStdService - { - public ValueTask StartAsync(CancellationToken cancellationToken = default) - { - state.Started.SetResult(); - - return ValueTask.CompletedTask; - } - - public ValueTask StopAsync(CancellationToken cancellationToken = default) - { - state.Stopped.SetResult(); - - return ValueTask.CompletedTask; - } - } - [Fact] public async Task Create_RegistersBootstrapAndDefaultServices() { using var temp = new TempDirectory(); - await using var bootstrap = SquidStdBootstrap.Create(new() { ConfigName = "app", RootDirectory = temp.Path }); + await using var bootstrap = + SquidStdBootstrap.Create(new SquidStdOptions { ConfigName = "app", RootDirectory = temp.Path }); var resolved = bootstrap.Resolve(); var configManager = bootstrap.Resolve(); @@ -106,7 +36,7 @@ public async Task Create_WithContainer_UsesProvidedContainer() var container = new Container(); await using var bootstrap = SquidStdBootstrap.Create( - new() + new SquidStdOptions { ConfigName = "app", RootDirectory = temp.Path @@ -128,13 +58,15 @@ public async Task DisposeAsync_WithProvidedContainer_DoesNotDisposeContainer() var container = new Container(); await using (SquidStdBootstrap.Create( - new() + new SquidStdOptions { ConfigName = "app", RootDirectory = temp.Path }, container - )) { } + )) + { + } container.RegisterInstance("still-open"); @@ -149,10 +81,10 @@ public async Task RunAsync_StartsUntilCancellationThenStops() using var temp = new TempDirectory(); using var cancellation = new CancellationTokenSource(); var state = new RunTrackedState(); - await using var bootstrap = SquidStdBootstrap.Create(new() { ConfigName = "app", RootDirectory = temp.Path }); + await using var bootstrap = + SquidStdBootstrap.Create(new SquidStdOptions { ConfigName = "app", RootDirectory = temp.Path }); - bootstrap.ConfigureService( - container => + bootstrap.ConfigureService(container => { container.RegisterInstance(state); container.Register(Reuse.Singleton); @@ -194,7 +126,8 @@ public async Task StartAsync_ConfiguresFileSinkFromLoggerOptions() RollingInterval: Infinite """ ); - await using var bootstrap = SquidStdBootstrap.Create(new() { ConfigName = "app", RootDirectory = temp.Path }); + await using var bootstrap = + SquidStdBootstrap.Create(new SquidStdOptions { ConfigName = "app", RootDirectory = temp.Path }); await bootstrap.StartAsync(CancellationToken.None); await bootstrap.StopAsync(CancellationToken.None); @@ -219,10 +152,10 @@ public async Task StartAsync_LoadsConfigBeforeResolvingRegisteredServices() """ ); var events = new List(); - await using var bootstrap = SquidStdBootstrap.Create(new() { ConfigName = "app", RootDirectory = temp.Path }); + await using var bootstrap = + SquidStdBootstrap.Create(new SquidStdOptions { ConfigName = "app", RootDirectory = temp.Path }); - bootstrap.ConfigureServices( - container => + bootstrap.ConfigureServices(container => { container.RegisterInstance(events); container.Register(Reuse.Singleton); @@ -249,10 +182,10 @@ public async Task StartAsync_OrdersServicesByPriorityAndStopAsync_ReversesOrder( { using var temp = new TempDirectory(); var events = new List(); - await using var bootstrap = SquidStdBootstrap.Create(new() { ConfigName = "app", RootDirectory = temp.Path }); + await using var bootstrap = + SquidStdBootstrap.Create(new SquidStdOptions { ConfigName = "app", RootDirectory = temp.Path }); - bootstrap.ConfigureServices( - container => + bootstrap.ConfigureServices(container => { container.RegisterInstance(events); container.Register(Reuse.Singleton); @@ -278,4 +211,77 @@ public async Task StartAsync_OrdersServicesByPriorityAndStopAsync_ReversesOrder( Assert.True(events.IndexOf("early:start") < events.IndexOf("late:start")); Assert.True(events.IndexOf("late:stop") < events.IndexOf("early:stop")); } + + private sealed class ConfigConsumerService(SquidStdLoggerOptions options, List events) : ISquidStdService + { + public ValueTask StartAsync(CancellationToken cancellationToken = default) + { + events.Add($"config:{options.MinimumLevel}"); + + return ValueTask.CompletedTask; + } + + public ValueTask StopAsync(CancellationToken cancellationToken = default) + { + return ValueTask.CompletedTask; + } + } + + private sealed class EarlyTrackedService(List events) : ISquidStdService + { + public ValueTask StartAsync(CancellationToken cancellationToken = default) + { + events.Add("early:start"); + + return ValueTask.CompletedTask; + } + + public ValueTask StopAsync(CancellationToken cancellationToken = default) + { + events.Add("early:stop"); + + return ValueTask.CompletedTask; + } + } + + private sealed class LateTrackedService(List events) : ISquidStdService + { + public ValueTask StartAsync(CancellationToken cancellationToken = default) + { + events.Add("late:start"); + + return ValueTask.CompletedTask; + } + + public ValueTask StopAsync(CancellationToken cancellationToken = default) + { + events.Add("late:stop"); + + return ValueTask.CompletedTask; + } + } + + private sealed class RunTrackedState + { + public TaskCompletionSource Started { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public TaskCompletionSource Stopped { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + } + + private sealed class RunTrackedService(RunTrackedState state) : ISquidStdService + { + public ValueTask StartAsync(CancellationToken cancellationToken = default) + { + state.Started.SetResult(); + + return ValueTask.CompletedTask; + } + + public ValueTask StopAsync(CancellationToken cancellationToken = default) + { + state.Stopped.SetResult(); + + return ValueTask.CompletedTask; + } + } } diff --git a/tests/SquidStd.Tests/Caching/CacheMetricsProviderTests.cs b/tests/SquidStd.Tests/Caching/CacheMetricsProviderTests.cs index fa35387f..2448e664 100644 --- a/tests/SquidStd.Tests/Caching/CacheMetricsProviderTests.cs +++ b/tests/SquidStd.Tests/Caching/CacheMetricsProviderTests.cs @@ -17,7 +17,9 @@ public async Task CollectAsync_ReportsCountersAndHitRatio() var samples = await metrics.CollectAsync(); double Value(string name) - => samples.Single(s => s.Name == name).Value; + { + return samples.Single(s => s.Name == name).Value; + } Assert.Equal(2, Value("hits")); Assert.Equal(1, Value("misses")); @@ -28,5 +30,7 @@ double Value(string name) [Fact] public void ProviderName_IsCache() - => Assert.Equal("cache", new CacheMetricsProvider().ProviderName); + { + Assert.Equal("cache", new CacheMetricsProvider().ProviderName); + } } diff --git a/tests/SquidStd.Tests/Caching/CacheServiceTests.cs b/tests/SquidStd.Tests/Caching/CacheServiceTests.cs index aa490ce6..66fc9f05 100644 --- a/tests/SquidStd.Tests/Caching/CacheServiceTests.cs +++ b/tests/SquidStd.Tests/Caching/CacheServiceTests.cs @@ -9,24 +9,26 @@ public class CacheServiceTests { [Fact] public async Task Get_Missing_ReturnsDefault() - => Assert.Null(await NewService(new()).GetAsync("absent")); + { + Assert.Null(await NewService(new FakeCacheProvider()).GetAsync("absent")); + } [Fact] public async Task GetOrSet_Hit_DoesNotInvokeFactory() { - var service = NewService(new()); + var service = NewService(new FakeCacheProvider()); await service.SetAsync("k", 7); var calls = 0; var value = await service.GetOrSetAsync( - "k", - _ => - { - calls++; + "k", + _ => + { + calls++; - return Task.FromResult(0); - } - ); + return Task.FromResult(0); + } + ); Assert.Equal(7, value); Assert.Equal(0, calls); @@ -40,14 +42,14 @@ public async Task GetOrSet_Miss_InvokesFactoryAndStores() var calls = 0; var value = await service.GetOrSetAsync( - "k", - _ => - { - calls++; + "k", + _ => + { + calls++; - return Task.FromResult(99); - } - ); + return Task.FromResult(99); + } + ); Assert.Equal(99, value); Assert.Equal(1, calls); @@ -58,7 +60,7 @@ public async Task GetOrSet_Miss_InvokesFactoryAndStores() public async Task KeyPrefix_IsAppliedToProvider() { var provider = new FakeCacheProvider(); - var service = NewService(provider, new() { KeyPrefix = "app:" }); + var service = NewService(provider, new CacheOptions { KeyPrefix = "app:" }); await service.SetAsync("k", "v"); @@ -70,7 +72,7 @@ public async Task KeyPrefix_IsAppliedToProvider() public async Task Metrics_RecordHitAndMiss() { var metrics = new CacheMetricsProvider(); - var service = NewService(new(), metrics: metrics); + var service = NewService(new FakeCacheProvider(), metrics: metrics); await service.GetAsync("absent"); // miss await service.SetAsync("k", 1); @@ -85,7 +87,7 @@ public async Task Metrics_RecordHitAndMiss() public async Task Set_PerEntryTtl_OverridesDefault() { var provider = new FakeCacheProvider(); - var service = NewService(provider, new() { DefaultTtl = TimeSpan.FromSeconds(30) }); + var service = NewService(provider, new CacheOptions { DefaultTtl = TimeSpan.FromSeconds(30) }); await service.SetAsync("k", "v", TimeSpan.FromSeconds(5)); @@ -96,7 +98,7 @@ public async Task Set_PerEntryTtl_OverridesDefault() public async Task Set_UsesDefaultTtl_WhenNoneGiven() { var provider = new FakeCacheProvider(); - var service = NewService(provider, new() { DefaultTtl = TimeSpan.FromSeconds(30) }); + var service = NewService(provider, new CacheOptions { DefaultTtl = TimeSpan.FromSeconds(30) }); await service.SetAsync("k", "v"); @@ -106,7 +108,7 @@ public async Task Set_UsesDefaultTtl_WhenNoneGiven() [Fact] public async Task SetThenGet_RoundTrips() { - var service = NewService(new()); + var service = NewService(new FakeCacheProvider()); await service.SetAsync("k", 42); @@ -121,6 +123,6 @@ private static CacheService NewService( { var serializer = new JsonDataSerializer(); - return new(provider, serializer, serializer, options ?? new CacheOptions(), metrics); + return new CacheService(provider, serializer, serializer, options ?? new CacheOptions(), metrics); } } diff --git a/tests/SquidStd.Tests/Caching/InMemoryCacheProviderTests.cs b/tests/SquidStd.Tests/Caching/InMemoryCacheProviderTests.cs index c803d1b3..5fc13cfc 100644 --- a/tests/SquidStd.Tests/Caching/InMemoryCacheProviderTests.cs +++ b/tests/SquidStd.Tests/Caching/InMemoryCacheProviderTests.cs @@ -20,7 +20,9 @@ public async Task Exists_And_Remove() [Fact] public async Task Get_Missing_ReturnsNull() - => Assert.Null(await NewProvider().GetAsync("absent")); + { + Assert.Null(await NewProvider().GetAsync("absent")); + } [Fact] public async Task SetThenGet_ReturnsValue() @@ -46,8 +48,12 @@ public async Task Ttl_Expires() } private static ReadOnlyMemory Bytes(string s) - => Encoding.UTF8.GetBytes(s); + { + return Encoding.UTF8.GetBytes(s); + } private static InMemoryCacheProvider NewProvider() - => new(new MemoryCache(new MemoryCacheOptions())); + { + return new InMemoryCacheProvider(new MemoryCache(new MemoryCacheOptions())); + } } diff --git a/tests/SquidStd.Tests/Caching/Redis/RedisCacheProviderTests.cs b/tests/SquidStd.Tests/Caching/Redis/RedisCacheProviderTests.cs index e3e0b318..42906a5c 100644 --- a/tests/SquidStd.Tests/Caching/Redis/RedisCacheProviderTests.cs +++ b/tests/SquidStd.Tests/Caching/Redis/RedisCacheProviderTests.cs @@ -1,4 +1,5 @@ using System.Text; +using SquidStd.Caching.Redis.Data.Config; using SquidStd.Caching.Redis.Services; namespace SquidStd.Tests.Caching.Redis; @@ -56,14 +57,22 @@ public async Task Ttl_Expires() } private static ReadOnlyMemory Bytes(string s) - => Encoding.UTF8.GetBytes(s); + { + return Encoding.UTF8.GetBytes(s); + } private static string Key() - => "k-" + Guid.NewGuid().ToString("N"); + { + return "k-" + Guid.NewGuid().ToString("N"); + } private RedisCacheProvider NewProvider() - => new(new() { Configuration = _fixture.ConnectionString }); + { + return new RedisCacheProvider(new RedisCacheOptions { Configuration = _fixture.ConnectionString }); + } private static string Text(ReadOnlyMemory b) - => Encoding.UTF8.GetString(b.Span); + { + return Encoding.UTF8.GetString(b.Span); + } } diff --git a/tests/SquidStd.Tests/Caching/Redis/RedisContainerFixture.cs b/tests/SquidStd.Tests/Caching/Redis/RedisContainerFixture.cs index bd17f84e..8d7df788 100644 --- a/tests/SquidStd.Tests/Caching/Redis/RedisContainerFixture.cs +++ b/tests/SquidStd.Tests/Caching/Redis/RedisContainerFixture.cs @@ -3,7 +3,7 @@ namespace SquidStd.Tests.Caching.Redis; /// -/// Starts a Redis container once for the whole collection and exposes its connection string. +/// Starts a Redis container once for the whole collection and exposes its connection string. /// public sealed class RedisContainerFixture : IAsyncLifetime { @@ -12,10 +12,14 @@ public sealed class RedisContainerFixture : IAsyncLifetime public string ConnectionString => _container.GetConnectionString(); public Task DisposeAsync() - => _container.DisposeAsync().AsTask(); + { + return _container.DisposeAsync().AsTask(); + } public Task InitializeAsync() - => _container.StartAsync(); + { + return _container.StartAsync(); + } } [CollectionDefinition(Name)] diff --git a/tests/SquidStd.Tests/Core/Json/JsonDataSerializerTests.cs b/tests/SquidStd.Tests/Core/Json/JsonDataSerializerTests.cs index 64849751..aa97328f 100644 --- a/tests/SquidStd.Tests/Core/Json/JsonDataSerializerTests.cs +++ b/tests/SquidStd.Tests/Core/Json/JsonDataSerializerTests.cs @@ -4,12 +4,6 @@ namespace SquidStd.Tests.Core.Json; public class JsonDataSerializerTests { - private sealed class Sample - { - public string Name { get; set; } = string.Empty; - public int Count { get; set; } - } - [Fact] public void Deserialize_NullJson_Throws() { @@ -30,4 +24,10 @@ public void RoundTrip_PreservesValue() Assert.Equal("abc", result.Name); Assert.Equal(7, result.Count); } + + private sealed class Sample + { + public string Name { get; set; } = string.Empty; + public int Count { get; set; } + } } diff --git a/tests/SquidStd.Tests/Core/Utils/ChecksumUtilsTests.cs b/tests/SquidStd.Tests/Core/Utils/ChecksumUtilsTests.cs new file mode 100644 index 00000000..047b3fe9 --- /dev/null +++ b/tests/SquidStd.Tests/Core/Utils/ChecksumUtilsTests.cs @@ -0,0 +1,30 @@ +using System.Text; +using SquidStd.Core.Utils; + +namespace SquidStd.Tests.Core.Utils; + +public class ChecksumUtilsTests +{ + [Fact] + public void Compute_EmptyInput_ReturnsOffsetBasis() + => Assert.Equal(2166136261u, ChecksumUtils.Compute(ReadOnlySpan.Empty)); + + [Fact] + public void Compute_KnownVector_MatchesFnv1a() + { + // FNV-1a 32-bit of ASCII "a" = 0xE40C292C. + Assert.Equal(0xE40C292Cu, ChecksumUtils.Compute("a"u8)); + } + + [Fact] + public void Compute_IsStableAcrossCalls() + { + var data = Encoding.UTF8.GetBytes("squidstd-persistence"); + + Assert.Equal(ChecksumUtils.Compute(data), ChecksumUtils.Compute(data)); + } + + [Fact] + public void Compute_DifferentInput_DiffersFromOther() + => Assert.NotEqual(ChecksumUtils.Compute("a"u8), ChecksumUtils.Compute("b"u8)); +} diff --git a/tests/SquidStd.Tests/Database/ConnectionStringParserTests.cs b/tests/SquidStd.Tests/Database/ConnectionStringParserTests.cs index 73ee8d68..f5d25619 100644 --- a/tests/SquidStd.Tests/Database/ConnectionStringParserTests.cs +++ b/tests/SquidStd.Tests/Database/ConnectionStringParserTests.cs @@ -7,7 +7,9 @@ public class ConnectionStringParserTests { [Fact] public void Parse_MissingHostForServerProvider_Throws() - => Assert.Throws(() => ConnectionStringParser.Parse("postgres:///db")); + { + Assert.Throws(() => ConnectionStringParser.Parse("postgres:///db")); + } [Fact] public void Parse_MySql_BuildsNativeString() @@ -76,5 +78,7 @@ public void Parse_SqlServer_BuildsNativeString() [Fact] public void Parse_UnknownScheme_Throws() - => Assert.Throws(() => ConnectionStringParser.Parse("oracle://h/db")); + { + Assert.Throws(() => ConnectionStringParser.Parse("oracle://h/db")); + } } diff --git a/tests/SquidStd.Tests/Database/FreeSqlDataAccessTests.cs b/tests/SquidStd.Tests/Database/FreeSqlDataAccessTests.cs index 7bdb22d8..42846655 100644 --- a/tests/SquidStd.Tests/Database/FreeSqlDataAccessTests.cs +++ b/tests/SquidStd.Tests/Database/FreeSqlDataAccessTests.cs @@ -1,3 +1,4 @@ +using SquidStd.Database.Abstractions.Data.Database; using SquidStd.Database.Abstractions.Data.Entities; using SquidStd.Database.Data; using SquidStd.Database.Services; @@ -15,6 +16,29 @@ public class FreeSqlDataAccessTests : IAsyncLifetime private string _dbPath = string.Empty; private DatabaseService _service = null!; + public async Task DisposeAsync() + { + await _service.StopAsync(); + + if (File.Exists(_dbPath)) + { + File.Delete(_dbPath); + } + } + + public async Task InitializeAsync() + { + _dbPath = Path.Combine(Path.GetTempPath(), "squidstd-db-" + Guid.NewGuid().ToString("N") + ".db"); + _service = new DatabaseService( + new DatabaseConfig + { + ConnectionString = $"sqlite://{_dbPath}", + AutoMigrate = true + } + ); + await _service.StartAsync(); + } + [Fact] public async Task BulkUpdateAndDelete_AffectRows() { @@ -60,23 +84,13 @@ await access.BulkInsertAsync( public async Task DeleteAsync_RemovesRow() { var access = NewAccess(); - var user = await access.InsertAsync(new() { Name = "Cara", Age = 40 }); + var user = await access.InsertAsync(new SampleUser { Name = "Cara", Age = 40 }); Assert.True(await access.DeleteAsync(user.Id)); Assert.Null(await access.GetByIdAsync(user.Id)); Assert.False(await access.DeleteAsync(user.Id)); } - public async Task DisposeAsync() - { - await _service.StopAsync(); - - if (File.Exists(_dbPath)) - { - File.Delete(_dbPath); - } - } - [Fact] public async Task GetPagedAsync_ReturnsMetadata() { @@ -84,7 +98,7 @@ public async Task GetPagedAsync_ReturnsMetadata() for (var i = 0; i < 25; i++) { - await access.InsertAsync(new() { Name = $"u{i}", Age = i }); + await access.InsertAsync(new SampleUser { Name = $"u{i}", Age = i }); } var page = await access.GetPagedAsync(2, 10, orderBy: u => u.Age); @@ -97,24 +111,11 @@ public async Task GetPagedAsync_ReturnsMetadata() Assert.Equal(10, page.Items[0].Age); } - public async Task InitializeAsync() - { - _dbPath = Path.Combine(Path.GetTempPath(), "squidstd-db-" + Guid.NewGuid().ToString("N") + ".db"); - _service = new( - new() - { - ConnectionString = $"sqlite://{_dbPath}", - AutoMigrate = true - } - ); - await _service.StartAsync(); - } - [Fact] public async Task InsertAsync_RollsBackOnFailure() { var access = NewAccess(); - var first = await access.InsertAsync(new() { Name = "first", Age = 1 }); + var first = await access.InsertAsync(new SampleUser { Name = "first", Age = 1 }); // Re-using an existing primary key forces a unique-constraint violation. var duplicate = new SampleUser { Id = first.Id, Name = "dup", Age = 2 }; @@ -127,7 +128,7 @@ public async Task InsertAsync_RollsBackOnFailure() public async Task InsertAsync_SetsIdAndTimestamps() { var access = NewAccess(); - var inserted = await access.InsertAsync(new() { Name = "Ann", Age = 30 }); + var inserted = await access.InsertAsync(new SampleUser { Name = "Ann", Age = 30 }); Assert.NotEqual(Guid.Empty, inserted.Id); Assert.NotEqual(default, inserted.Created); @@ -142,7 +143,7 @@ public async Task InsertAsync_SetsIdAndTimestamps() public async Task UpdateAsync_BumpsUpdated() { var access = NewAccess(); - var user = await access.InsertAsync(new() { Name = "Bob", Age = 20 }); + var user = await access.InsertAsync(new SampleUser { Name = "Bob", Age = 20 }); user.Age = 21; var updated = await access.UpdateAsync(user); @@ -152,5 +153,7 @@ public async Task UpdateAsync_BumpsUpdated() } private FreeSqlDataAccess NewAccess() - => new(_service); + { + return new FreeSqlDataAccess(_service); + } } diff --git a/tests/SquidStd.Tests/Env/ReplaceEnvTests.cs b/tests/SquidStd.Tests/Env/ReplaceEnvTests.cs index 84180983..59f97402 100644 --- a/tests/SquidStd.Tests/Env/ReplaceEnvTests.cs +++ b/tests/SquidStd.Tests/Env/ReplaceEnvTests.cs @@ -11,9 +11,14 @@ public void ReplaceEnv_LeavesUnknownVariableUntouched() Assert.Equal("x=$SQUID_MISSING_VAR", "x=$SQUID_MISSING_VAR".ReplaceEnv()); } - [Theory, InlineData(null), InlineData(""), InlineData("no tokens here")] + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("no tokens here")] public void ReplaceEnv_PassesThroughWhenNothingToReplace(string? input) - => Assert.Equal(input, input!.ReplaceEnv()); + { + Assert.Equal(input, input!.ReplaceEnv()); + } [Fact] public void ReplaceEnv_SubstitutesKnownVariable() diff --git a/tests/SquidStd.Tests/Extensions/Directories/DirectoriesExtensionTests.cs b/tests/SquidStd.Tests/Extensions/Directories/DirectoriesExtensionTests.cs index 7cd656a5..b9cd69dd 100644 --- a/tests/SquidStd.Tests/Extensions/Directories/DirectoriesExtensionTests.cs +++ b/tests/SquidStd.Tests/Extensions/Directories/DirectoriesExtensionTests.cs @@ -4,9 +4,14 @@ namespace SquidStd.Tests.Extensions.Directories; public class DirectoriesExtensionTests { - [Theory, InlineData(""), InlineData(" "), InlineData(null)] + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(null)] public void ResolvePathAndEnvs_NullOrWhitespace_ReturnsNull(string? path) - => Assert.Null(path!.ResolvePathAndEnvs()); + { + Assert.Null(path!.ResolvePathAndEnvs()); + } [Fact] public void ResolvePathAndEnvs_TildePrefix_ExpandsToUserProfile() diff --git a/tests/SquidStd.Tests/Extensions/Env/EnvExtensionsTests.cs b/tests/SquidStd.Tests/Extensions/Env/EnvExtensionsTests.cs index 1a408dfc..9a6e1e07 100644 --- a/tests/SquidStd.Tests/Extensions/Env/EnvExtensionsTests.cs +++ b/tests/SquidStd.Tests/Extensions/Env/EnvExtensionsTests.cs @@ -6,9 +6,13 @@ public class EnvExtensionsTests { private const string TestVariable = "SQUIDSTD_UNIT_TEST_VAR"; - [Theory, InlineData(""), InlineData("no variables here")] + [Theory] + [InlineData("")] + [InlineData("no variables here")] public void ExpandEnvironmentVariables_NoMatch_ReturnsInput(string input) - => Assert.Equal(input, input.ExpandEnvironmentVariables()); + { + Assert.Equal(input, input.ExpandEnvironmentVariables()); + } [Fact] public void ExpandEnvironmentVariables_ReplacesDollarPrefixedVariable() diff --git a/tests/SquidStd.Tests/Extensions/Logger/LogLevelExtensionsTests.cs b/tests/SquidStd.Tests/Extensions/Logger/LogLevelExtensionsTests.cs index bb72c066..d229a467 100644 --- a/tests/SquidStd.Tests/Extensions/Logger/LogLevelExtensionsTests.cs +++ b/tests/SquidStd.Tests/Extensions/Logger/LogLevelExtensionsTests.cs @@ -6,14 +6,21 @@ namespace SquidStd.Tests.Extensions.Logger; public class LogLevelExtensionsTests { - [Theory, InlineData(LogLevelType.Trace, LogEventLevel.Verbose), InlineData(LogLevelType.Debug, LogEventLevel.Debug), - InlineData(LogLevelType.Information, LogEventLevel.Information), - InlineData(LogLevelType.Warning, LogEventLevel.Warning), InlineData(LogLevelType.Error, LogEventLevel.Error), - InlineData(LogLevelType.Critical, LogEventLevel.Fatal)] + [Theory] + [InlineData(LogLevelType.Trace, LogEventLevel.Verbose)] + [InlineData(LogLevelType.Debug, LogEventLevel.Debug)] + [InlineData(LogLevelType.Information, LogEventLevel.Information)] + [InlineData(LogLevelType.Warning, LogEventLevel.Warning)] + [InlineData(LogLevelType.Error, LogEventLevel.Error)] + [InlineData(LogLevelType.Critical, LogEventLevel.Fatal)] public void ToSerilogLogLevel_KnownLevels_MapsExpected(LogLevelType input, LogEventLevel expected) - => Assert.Equal(expected, input.ToSerilogLogLevel()); + { + Assert.Equal(expected, input.ToSerilogLogLevel()); + } [Fact] public void ToSerilogLogLevel_UnmappedLevels_FallsBackToInformation() - => Assert.Equal(LogEventLevel.Information, ((LogLevelType)255).ToSerilogLogLevel()); + { + Assert.Equal(LogEventLevel.Information, ((LogLevelType)255).ToSerilogLogLevel()); + } } diff --git a/tests/SquidStd.Tests/Extensions/Strings/StringMethodExtensionTests.cs b/tests/SquidStd.Tests/Extensions/Strings/StringMethodExtensionTests.cs index 9aee1867..53ea0b58 100644 --- a/tests/SquidStd.Tests/Extensions/Strings/StringMethodExtensionTests.cs +++ b/tests/SquidStd.Tests/Extensions/Strings/StringMethodExtensionTests.cs @@ -6,41 +6,61 @@ public class StringMethodExtensionTests { [Fact] public void ToCamelCase_DelegatesToStringUtils() - => Assert.Equal("helloWorld", "HelloWorld".ToCamelCase()); + { + Assert.Equal("helloWorld", "HelloWorld".ToCamelCase()); + } [Fact] public void ToDotCase_DelegatesToStringUtils() - => Assert.Equal("hello.world", "HelloWorld".ToDotCase()); + { + Assert.Equal("hello.world", "HelloWorld".ToDotCase()); + } [Fact] public void ToKebabCase_DelegatesToStringUtils() - => Assert.Equal("hello-world", "HelloWorld".ToKebabCase()); + { + Assert.Equal("hello-world", "HelloWorld".ToKebabCase()); + } [Fact] public void ToPascalCase_DelegatesToStringUtils() - => Assert.Equal("HelloWorld", "hello_world".ToPascalCase()); + { + Assert.Equal("HelloWorld", "hello_world".ToPascalCase()); + } [Fact] public void ToPathCase_DelegatesToStringUtils() - => Assert.Equal("hello/world", "HelloWorld".ToPathCase()); + { + Assert.Equal("hello/world", "HelloWorld".ToPathCase()); + } [Fact] public void ToSentenceCase_DelegatesToStringUtils() - => Assert.Equal("Hello world", "hello world".ToSentenceCase()); + { + Assert.Equal("Hello world", "hello world".ToSentenceCase()); + } [Fact] public void ToSnakeCase_DelegatesToStringUtils() - => Assert.Equal("hello_world", "HelloWorld".ToSnakeCase()); + { + Assert.Equal("hello_world", "HelloWorld".ToSnakeCase()); + } [Fact] public void ToSnakeCaseUpper_DelegatesToStringUtils() - => Assert.Equal("HELLO_WORLD", "HelloWorld".ToSnakeCaseUpper()); + { + Assert.Equal("HELLO_WORLD", "HelloWorld".ToSnakeCaseUpper()); + } [Fact] public void ToTitleCase_DelegatesToStringUtils() - => Assert.Equal("Hello World", "hello_world".ToTitleCase()); + { + Assert.Equal("Hello World", "hello_world".ToTitleCase()); + } [Fact] public void ToTrainCase_DelegatesToStringUtils() - => Assert.Equal("Hello-World", "hello_world".ToTrainCase()); + { + Assert.Equal("Hello-World", "hello_world".ToTrainCase()); + } } diff --git a/tests/SquidStd.Tests/Generators/EventListeners/EventListenerRegistrationGeneratorTests.cs b/tests/SquidStd.Tests/Generators/EventListeners/EventListenerRegistrationGeneratorTests.cs new file mode 100644 index 00000000..ec012bf0 --- /dev/null +++ b/tests/SquidStd.Tests/Generators/EventListeners/EventListenerRegistrationGeneratorTests.cs @@ -0,0 +1,133 @@ +using SquidStd.Tests.Generators.Support; + +namespace SquidStd.Tests.Generators.EventListeners; + +public class EventListenerRegistrationGeneratorTests +{ + [Fact] + public void Run_GeneratesRegistrationExtension_WhenListenerExists() + { + const string source = """ + using System.Threading; + using System.Threading.Tasks; + using SquidStd.Abstractions.Attributes; + using SquidStd.Core.Interfaces.Events; + + namespace SampleApp; + + public sealed record PingEvent(string Message) : IEvent; + + [RegisterEventListener] + public sealed class PingListener : IEventListener + { + public Task HandleAsync(PingEvent eventData, CancellationToken cancellationToken = default) + { + return Task.CompletedTask; + } + } + """; + + var result = GeneratorTestCompiler.Run(source); + var generatedTree = Assert.Single( + result.RunResult.GeneratedTrees, + tree => tree.FilePath.EndsWith("SquidStd.GeneratedEventListenerRegistration.g.cs", StringComparison.Ordinal) + ); + + var generatedSource = generatedTree.GetText().ToString(); + + Assert.Contains("RegisterGeneratedEventListeners", generatedSource, StringComparison.Ordinal); + Assert.Contains( + "RegisterEventListener(container);", + generatedSource, + StringComparison.Ordinal + ); + } + + [Fact] + public void Run_DoesNotRegisterListener_WhenAttributeIsMissing() + { + const string source = """ + using System.Threading; + using System.Threading.Tasks; + using SquidStd.Abstractions.Attributes; + using SquidStd.Core.Interfaces.Events; + + namespace SampleApp; + + public sealed record PingEvent(string Message) : IEvent; + + public sealed class PingListener : IEventListener + { + public Task HandleAsync(PingEvent eventData, CancellationToken cancellationToken = default) + { + return Task.CompletedTask; + } + } + """; + + var result = GeneratorTestCompiler.Run(source); + var generatedTree = Assert.Single( + result.RunResult.GeneratedTrees, + tree => tree.FilePath.EndsWith("SquidStd.GeneratedEventListenerRegistration.g.cs", StringComparison.Ordinal) + ); + + var generatedSource = generatedTree.GetText().ToString(); + + Assert.Contains("RegisterGeneratedEventListeners", generatedSource, StringComparison.Ordinal); + Assert.DoesNotContain("RegisterEventListener<", generatedSource, StringComparison.Ordinal); + } + + [Fact] + public void Run_GeneratesNoOpExtension_WhenNoListenersExist() + { + const string source = """ + namespace SampleApp; + + public sealed class EmptyType + { + } + """; + + var result = GeneratorTestCompiler.Run(source); + var generatedTree = Assert.Single( + result.RunResult.GeneratedTrees, + tree => tree.FilePath.EndsWith("SquidStd.GeneratedEventListenerRegistration.g.cs", StringComparison.Ordinal) + ); + + var generatedSource = generatedTree.GetText().ToString(); + + Assert.Contains("RegisterGeneratedEventListeners", generatedSource, StringComparison.Ordinal); + Assert.Contains("return container;", generatedSource, StringComparison.Ordinal); + Assert.DoesNotContain("RegisterEventListener<", generatedSource, StringComparison.Ordinal); + } + + [Fact] + public void Run_ReportsDiagnostic_WhenAnnotatedListenerCannotBeGenerated() + { + const string source = """ + using System.Threading; + using System.Threading.Tasks; + using SquidStd.Abstractions.Attributes; + using SquidStd.Core.Interfaces.Events; + + namespace SampleApp; + + public sealed record PingEvent(string Message) : IEvent; + + [RegisterEventListener] + public sealed class GenericListener : IEventListener + { + public Task HandleAsync(PingEvent eventData, CancellationToken cancellationToken = default) + { + return Task.CompletedTask; + } + } + """; + + var result = GeneratorTestCompiler.Run(source); + + var diagnostics = result.RunResult.Diagnostics.Concat(result.Diagnostics); + + Assert.Contains(diagnostics, diagnostic => diagnostic.Id == "SQDGEN001"); + } +} diff --git a/tests/SquidStd.Tests/Generators/Registration/ConfigSectionRegistrationGeneratorTests.cs b/tests/SquidStd.Tests/Generators/Registration/ConfigSectionRegistrationGeneratorTests.cs new file mode 100644 index 00000000..6401bbe9 --- /dev/null +++ b/tests/SquidStd.Tests/Generators/Registration/ConfigSectionRegistrationGeneratorTests.cs @@ -0,0 +1,98 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using SquidStd.Generators.Config; +using SquidStd.Tests.Generators.Support; + +namespace SquidStd.Tests.Generators.Registration; + +public class ConfigSectionRegistrationGeneratorTests +{ + [Fact] + public void Run_GeneratesRegistrationExtension_WhenConfigIsAnnotated() + { + const string source = """ + using SquidStd.Abstractions.Attributes; + + namespace SampleApp; + + [RegisterConfigSection("workers", Priority = -50)] + public sealed class WorkersConfig { } + """; + + var result = GeneratorTestCompiler.Run(source, new ConfigSectionRegistrationGenerator()); + var generatedSource = SingleGeneratedSource(result, "SquidStd.GeneratedConfigSectionRegistration.g.cs"); + + Assert.Contains("RegisterGeneratedConfigSections", generatedSource, StringComparison.Ordinal); + Assert.Contains( + "RegisterConfigSection(container, \"workers\", priority: -50);", + generatedSource, + StringComparison.Ordinal + ); + } + + [Fact] + public void Run_DoesNotRegisterConfig_WhenAttributeIsMissing() + { + const string source = """ + namespace SampleApp; + + public sealed class WorkersConfig { } + """; + + var result = GeneratorTestCompiler.Run(source, new ConfigSectionRegistrationGenerator()); + var generatedSource = SingleGeneratedSource(result, "SquidStd.GeneratedConfigSectionRegistration.g.cs"); + + Assert.Contains("RegisterGeneratedConfigSections", generatedSource, StringComparison.Ordinal); + Assert.DoesNotContain("RegisterConfigSection<", generatedSource, StringComparison.Ordinal); + } + + [Fact] + public void Run_GeneratesNoOpExtension_WhenNoConfigsExist() + { + const string source = """ + namespace SampleApp; + + public sealed class EmptyType { } + """; + + var result = GeneratorTestCompiler.Run(source, new ConfigSectionRegistrationGenerator()); + var generatedSource = SingleGeneratedSource(result, "SquidStd.GeneratedConfigSectionRegistration.g.cs"); + + Assert.Contains("RegisterGeneratedConfigSections", generatedSource, StringComparison.Ordinal); + Assert.Contains("return container;", generatedSource, StringComparison.Ordinal); + Assert.DoesNotContain("RegisterConfigSection<", generatedSource, StringComparison.Ordinal); + } + + [Fact] + public void Run_ReportsDiagnostic_WhenSectionNameIsMissing() + { + const string source = """ + using SquidStd.Abstractions.Attributes; + + namespace SampleApp; + + [RegisterConfigSection] + public sealed class WorkersConfig { } + """; + + var result = GeneratorTestCompiler.Run(source, new ConfigSectionRegistrationGenerator()); + var diagnostics = result.RunResult.Diagnostics.Concat(result.Diagnostics); + + Assert.Contains(diagnostics, diagnostic => diagnostic.Id == "SQDGEN003"); + } + + private static string SingleGeneratedSource( + (Compilation Compilation, + GeneratorDriverRunResult RunResult, + ImmutableArray Diagnostics) result, + string fileName + ) + { + var generatedTree = Assert.Single( + result.RunResult.GeneratedTrees, + tree => tree.FilePath.EndsWith(fileName, StringComparison.Ordinal) + ); + + return generatedTree.GetText().ToString(); + } +} diff --git a/tests/SquidStd.Tests/Generators/Registration/JobHandlerRegistrationGeneratorTests.cs b/tests/SquidStd.Tests/Generators/Registration/JobHandlerRegistrationGeneratorTests.cs new file mode 100644 index 00000000..2998da28 --- /dev/null +++ b/tests/SquidStd.Tests/Generators/Registration/JobHandlerRegistrationGeneratorTests.cs @@ -0,0 +1,123 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using SquidStd.Generators.Workers; +using SquidStd.Tests.Generators.Support; + +namespace SquidStd.Tests.Generators.Registration; + +public class JobHandlerRegistrationGeneratorTests +{ + [Fact] + public void Run_GeneratesRegistrationExtension_WhenJobHandlerIsAnnotated() + { + const string source = """ + using System.Threading; + using System.Threading.Tasks; + using SquidStd.Workers.Abstractions.Data; + using SquidStd.Workers.Attributes; + using SquidStd.Workers.Interfaces; + + namespace SampleApp; + + [RegisterJobHandler] + public sealed class GreetJobHandler : IJobHandler + { + public string JobName => "greet"; + + public Task HandleAsync(JobRequest job, CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + } + """; + + var result = GeneratorTestCompiler.Run(source, new JobHandlerRegistrationGenerator()); + var generatedSource = SingleGeneratedSource(result, "SquidStd.GeneratedJobHandlerRegistration.g.cs"); + + Assert.Contains("RegisterGeneratedJobHandlers", generatedSource, StringComparison.Ordinal); + Assert.Contains( + "AddJobHandler(container);", + generatedSource, + StringComparison.Ordinal + ); + } + + [Fact] + public void Run_DoesNotRegisterJobHandler_WhenAttributeIsMissing() + { + const string source = """ + using System.Threading; + using System.Threading.Tasks; + using SquidStd.Workers.Abstractions.Data; + using SquidStd.Workers.Interfaces; + + namespace SampleApp; + + public sealed class GreetJobHandler : IJobHandler + { + public string JobName => "greet"; + + public Task HandleAsync(JobRequest job, CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + } + """; + + var result = GeneratorTestCompiler.Run(source, new JobHandlerRegistrationGenerator()); + var generatedSource = SingleGeneratedSource(result, "SquidStd.GeneratedJobHandlerRegistration.g.cs"); + + Assert.Contains("RegisterGeneratedJobHandlers", generatedSource, StringComparison.Ordinal); + Assert.DoesNotContain("AddJobHandler<", generatedSource, StringComparison.Ordinal); + } + + [Fact] + public void Run_GeneratesNoOpExtension_WhenNoJobHandlersExist() + { + const string source = """ + namespace SampleApp; + + public sealed class EmptyType { } + """; + + var result = GeneratorTestCompiler.Run(source, new JobHandlerRegistrationGenerator()); + var generatedSource = SingleGeneratedSource(result, "SquidStd.GeneratedJobHandlerRegistration.g.cs"); + + Assert.Contains("RegisterGeneratedJobHandlers", generatedSource, StringComparison.Ordinal); + Assert.Contains("return container;", generatedSource, StringComparison.Ordinal); + Assert.DoesNotContain("AddJobHandler<", generatedSource, StringComparison.Ordinal); + } + + [Fact] + public void Run_ReportsDiagnostic_WhenAnnotatedTypeIsNotAJobHandler() + { + const string source = """ + using SquidStd.Workers.Attributes; + + namespace SampleApp; + + [RegisterJobHandler] + public sealed class GreetJobHandler { } + """; + + var result = GeneratorTestCompiler.Run(source, new JobHandlerRegistrationGenerator()); + var diagnostics = result.RunResult.Diagnostics.Concat(result.Diagnostics); + + Assert.Contains(diagnostics, diagnostic => diagnostic.Id == "SQDGEN004"); + } + + private static string SingleGeneratedSource( + (Compilation Compilation, + GeneratorDriverRunResult RunResult, + ImmutableArray Diagnostics) result, + string fileName + ) + { + var generatedTree = Assert.Single( + result.RunResult.GeneratedTrees, + tree => tree.FilePath.EndsWith(fileName, StringComparison.Ordinal) + ); + + return generatedTree.GetText().ToString(); + } +} diff --git a/tests/SquidStd.Tests/Generators/Registration/RegistrationAttributeTests.cs b/tests/SquidStd.Tests/Generators/Registration/RegistrationAttributeTests.cs new file mode 100644 index 00000000..dda74dbd --- /dev/null +++ b/tests/SquidStd.Tests/Generators/Registration/RegistrationAttributeTests.cs @@ -0,0 +1,53 @@ +using SquidStd.Abstractions.Attributes; +using SquidStd.Scripting.Lua.Attributes; +using SquidStd.Workers.Attributes; + +namespace SquidStd.Tests.Generators.Registration; + +public class RegistrationAttributeTests +{ + [Fact] + public void RegisterStdServiceAttribute_StoresServiceTypeAndPriority() + { + var attribute = new RegisterStdServiceAttribute(typeof(ISampleService)) { Priority = 25 }; + + Assert.Equal(typeof(ISampleService), attribute.ServiceType); + Assert.Equal(25, attribute.Priority); + } + + [Fact] + public void RegisterStdServiceAttribute_AllowsMissingServiceTypeForGeneratorDiagnostic() + { + var attribute = new RegisterStdServiceAttribute(); + + Assert.Null(attribute.ServiceType); + Assert.Equal(0, attribute.Priority); + } + + [Fact] + public void RegisterConfigSectionAttribute_StoresSectionNameAndPriority() + { + var attribute = new RegisterConfigSectionAttribute("workers") { Priority = -50 }; + + Assert.Equal("workers", attribute.SectionName); + Assert.Equal(-50, attribute.Priority); + } + + [Fact] + public void RegisterConfigSectionAttribute_AllowsMissingSectionNameForGeneratorDiagnostic() + { + var attribute = new RegisterConfigSectionAttribute(); + + Assert.Null(attribute.SectionName); + Assert.Equal(0, attribute.Priority); + } + + [Fact] + public void MarkerAttributes_CanBeConstructed() + { + Assert.IsType(new RegisterJobHandlerAttribute()); + Assert.IsType(new RegisterScriptModuleAttribute()); + } + + private interface ISampleService; +} diff --git a/tests/SquidStd.Tests/Generators/Registration/ScriptModuleRegistrationGeneratorTests.cs b/tests/SquidStd.Tests/Generators/Registration/ScriptModuleRegistrationGeneratorTests.cs new file mode 100644 index 00000000..c359a939 --- /dev/null +++ b/tests/SquidStd.Tests/Generators/Registration/ScriptModuleRegistrationGeneratorTests.cs @@ -0,0 +1,123 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using SquidStd.Generators.Scripting.Lua; +using SquidStd.Tests.Generators.Support; + +namespace SquidStd.Tests.Generators.Registration; + +public class ScriptModuleRegistrationGeneratorTests +{ + [Fact] + public void Run_GeneratesRegistrationExtension_WhenScriptModuleIsAnnotated() + { + const string source = """ + using SquidStd.Scripting.Lua.Attributes; + using SquidStd.Scripting.Lua.Attributes.Scripts; + + namespace SampleApp; + + [RegisterScriptModule] + [ScriptModule("sample")] + public sealed class SampleScriptModule { } + """; + + var result = GeneratorTestCompiler.Run(source, new ScriptModuleRegistrationGenerator()); + var generatedSource = SingleGeneratedSource(result, "SquidStd.GeneratedScriptModuleRegistration.g.cs"); + + Assert.Contains("RegisterGeneratedScriptModules", generatedSource, StringComparison.Ordinal); + Assert.Contains( + "RegisterScriptModule(container);", + generatedSource, + StringComparison.Ordinal + ); + } + + [Fact] + public void Run_DoesNotRegisterScriptModule_WhenRegisterAttributeIsMissing() + { + const string source = """ + using SquidStd.Scripting.Lua.Attributes.Scripts; + + namespace SampleApp; + + [ScriptModule("sample")] + public sealed class SampleScriptModule { } + """; + + var result = GeneratorTestCompiler.Run(source, new ScriptModuleRegistrationGenerator()); + var generatedSource = SingleGeneratedSource(result, "SquidStd.GeneratedScriptModuleRegistration.g.cs"); + + Assert.Contains("RegisterGeneratedScriptModules", generatedSource, StringComparison.Ordinal); + Assert.DoesNotContain("RegisterScriptModule<", generatedSource, StringComparison.Ordinal); + } + + [Fact] + public void Run_GeneratesNoOpExtension_WhenNoScriptModulesExist() + { + const string source = """ + namespace SampleApp; + + public sealed class EmptyType { } + """; + + var result = GeneratorTestCompiler.Run(source, new ScriptModuleRegistrationGenerator()); + var generatedSource = SingleGeneratedSource(result, "SquidStd.GeneratedScriptModuleRegistration.g.cs"); + + Assert.Contains("RegisterGeneratedScriptModules", generatedSource, StringComparison.Ordinal); + Assert.Contains("return container;", generatedSource, StringComparison.Ordinal); + Assert.DoesNotContain("RegisterScriptModule<", generatedSource, StringComparison.Ordinal); + } + + [Fact] + public void Run_ReportsDiagnostic_WhenScriptModuleMetadataIsMissing() + { + const string source = """ + using SquidStd.Scripting.Lua.Attributes; + + namespace SampleApp; + + [RegisterScriptModule] + public sealed class SampleScriptModule { } + """; + + var result = GeneratorTestCompiler.Run(source, new ScriptModuleRegistrationGenerator()); + var diagnostics = result.RunResult.Diagnostics.Concat(result.Diagnostics); + + Assert.Contains(diagnostics, diagnostic => diagnostic.Id == "SQDGEN005"); + } + + [Fact] + public void Run_ReportsDiagnostic_WhenScriptModuleIsAbstract() + { + const string source = """ + using SquidStd.Scripting.Lua.Attributes; + using SquidStd.Scripting.Lua.Attributes.Scripts; + + namespace SampleApp; + + [RegisterScriptModule] + [ScriptModule("sample")] + public abstract class SampleScriptModule { } + """; + + var result = GeneratorTestCompiler.Run(source, new ScriptModuleRegistrationGenerator()); + var diagnostics = result.RunResult.Diagnostics.Concat(result.Diagnostics); + + Assert.Contains(diagnostics, diagnostic => diagnostic.Id == "SQDGEN005"); + } + + private static string SingleGeneratedSource( + (Compilation Compilation, + GeneratorDriverRunResult RunResult, + ImmutableArray Diagnostics) result, + string fileName + ) + { + var generatedTree = Assert.Single( + result.RunResult.GeneratedTrees, + tree => tree.FilePath.EndsWith(fileName, StringComparison.Ordinal) + ); + + return generatedTree.GetText().ToString(); + } +} diff --git a/tests/SquidStd.Tests/Generators/Registration/StdServiceRegistrationGeneratorTests.cs b/tests/SquidStd.Tests/Generators/Registration/StdServiceRegistrationGeneratorTests.cs new file mode 100644 index 00000000..e05f9e89 --- /dev/null +++ b/tests/SquidStd.Tests/Generators/Registration/StdServiceRegistrationGeneratorTests.cs @@ -0,0 +1,101 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using SquidStd.Generators.Services; +using SquidStd.Tests.Generators.Support; + +namespace SquidStd.Tests.Generators.Registration; + +public class StdServiceRegistrationGeneratorTests +{ + [Fact] + public void Run_GeneratesRegistrationExtension_WhenServiceIsAnnotated() + { + const string source = """ + using SquidStd.Abstractions.Attributes; + + namespace SampleApp; + + public interface ISampleService { } + + [RegisterStdService(typeof(ISampleService), Priority = 10)] + public sealed class SampleService : ISampleService { } + """; + + var result = GeneratorTestCompiler.Run(source, new StdServiceRegistrationGenerator()); + var generatedSource = SingleGeneratedSource(result, "SquidStd.GeneratedStdServiceRegistration.g.cs"); + + Assert.Contains("RegisterGeneratedStdServices", generatedSource, StringComparison.Ordinal); + Assert.Contains( + "RegisterStdService(container, 10);", + generatedSource, + StringComparison.Ordinal + ); + } + + [Fact] + public void Run_DoesNotRegisterService_WhenAttributeIsMissing() + { + const string source = """ + namespace SampleApp; + + public interface ISampleService { } + public sealed class SampleService : ISampleService { } + """; + + var result = GeneratorTestCompiler.Run(source, new StdServiceRegistrationGenerator()); + var generatedSource = SingleGeneratedSource(result, "SquidStd.GeneratedStdServiceRegistration.g.cs"); + + Assert.Contains("RegisterGeneratedStdServices", generatedSource, StringComparison.Ordinal); + Assert.DoesNotContain("RegisterStdService<", generatedSource, StringComparison.Ordinal); + } + + [Fact] + public void Run_GeneratesNoOpExtension_WhenNoServicesExist() + { + const string source = """ + namespace SampleApp; + + public sealed class EmptyType { } + """; + + var result = GeneratorTestCompiler.Run(source, new StdServiceRegistrationGenerator()); + var generatedSource = SingleGeneratedSource(result, "SquidStd.GeneratedStdServiceRegistration.g.cs"); + + Assert.Contains("RegisterGeneratedStdServices", generatedSource, StringComparison.Ordinal); + Assert.Contains("return container;", generatedSource, StringComparison.Ordinal); + Assert.DoesNotContain("RegisterStdService<", generatedSource, StringComparison.Ordinal); + } + + [Fact] + public void Run_ReportsDiagnostic_WhenServiceContractIsMissing() + { + const string source = """ + using SquidStd.Abstractions.Attributes; + + namespace SampleApp; + + [RegisterStdService] + public sealed class SampleService { } + """; + + var result = GeneratorTestCompiler.Run(source, new StdServiceRegistrationGenerator()); + var diagnostics = result.RunResult.Diagnostics.Concat(result.Diagnostics); + + Assert.Contains(diagnostics, diagnostic => diagnostic.Id == "SQDGEN002"); + } + + private static string SingleGeneratedSource( + (Compilation Compilation, + GeneratorDriverRunResult RunResult, + ImmutableArray Diagnostics) result, + string fileName + ) + { + var generatedTree = Assert.Single( + result.RunResult.GeneratedTrees, + tree => tree.FilePath.EndsWith(fileName, StringComparison.Ordinal) + ); + + return generatedTree.GetText().ToString(); + } +} diff --git a/tests/SquidStd.Tests/Generators/Support/GeneratorTestCompiler.cs b/tests/SquidStd.Tests/Generators/Support/GeneratorTestCompiler.cs new file mode 100644 index 00000000..8fbabee1 --- /dev/null +++ b/tests/SquidStd.Tests/Generators/Support/GeneratorTestCompiler.cs @@ -0,0 +1,93 @@ +using System.Collections.Immutable; +using DryIoc; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using SquidStd.Abstractions.Attributes; +using SquidStd.Abstractions.Extensions.Config; +using SquidStd.Abstractions.Extensions.Events; +using SquidStd.Abstractions.Extensions.Services; +using SquidStd.Core.Interfaces.Events; +using SquidStd.Generators.Events; +using SquidStd.Scripting.Lua.Attributes; +using SquidStd.Scripting.Lua.Extensions.Scripts; +using SquidStd.Workers.Abstractions.Data; +using SquidStd.Workers.Attributes; +using SquidStd.Workers.Extensions; +using SquidStd.Workers.Interfaces; + +namespace SquidStd.Tests.Generators.Support; + +internal static class GeneratorTestCompiler +{ + public static (Compilation Compilation, GeneratorDriverRunResult RunResult, ImmutableArray Diagnostics) Run( + string source, + params IIncrementalGenerator[] generators + ) + { + var parseOptions = new CSharpParseOptions(LanguageVersion.Preview); + var syntaxTree = CSharpSyntaxTree.ParseText(source, parseOptions); + + var references = CreateReferences(); + + var compilation = CSharpCompilation.Create( + "SquidStdGeneratorTests", + new[] { syntaxTree }, + references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary) + ); + + ISourceGenerator[] selectedGenerators; + if (generators.Length == 0) + { + selectedGenerators = new[] { new EventListenerRegistrationGenerator().AsSourceGenerator() }; + } + else + { + selectedGenerators = generators.Select(generator => generator.AsSourceGenerator()).ToArray(); + } + + GeneratorDriver driver = CSharpGeneratorDriver.Create(selectedGenerators, parseOptions: parseOptions); + driver = driver.RunGeneratorsAndUpdateCompilation( + compilation, + out var updatedCompilation, + out var diagnostics + ); + + return (updatedCompilation, driver.GetRunResult(), diagnostics); + } + + private static IReadOnlyList CreateReferences() + { + var trustedPlatformAssemblies = (string?)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES") ?? string.Empty; + var references = trustedPlatformAssemblies + .Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries) + .Select(path => MetadataReference.CreateFromFile(path)) + .Cast() + .ToList(); + + AddReference(references, typeof(IEvent).Assembly.Location); + AddReference(references, typeof(RegisterEventListenerAttribute).Assembly.Location); + AddReference(references, typeof(RegisterConfigSectionExtension).Assembly.Location); + AddReference(references, typeof(RegisterEventListenerExtension).Assembly.Location); + AddReference(references, typeof(RegisterStdServiceExtension).Assembly.Location); + AddReference(references, typeof(RegisterScriptModuleAttribute).Assembly.Location); + AddReference(references, typeof(AddScriptModuleExtension).Assembly.Location); + AddReference(references, typeof(JobRequest).Assembly.Location); + AddReference(references, typeof(RegisterJobHandlerAttribute).Assembly.Location); + AddReference(references, typeof(WorkersRegistrationExtensions).Assembly.Location); + AddReference(references, typeof(IJobHandler).Assembly.Location); + AddReference(references, typeof(IContainer).Assembly.Location); + + return references; + } + + private static void AddReference(List references, string location) + { + if (references.Any(reference => string.Equals(reference.Display, location, StringComparison.Ordinal))) + { + return; + } + + references.Add(MetadataReference.CreateFromFile(location)); + } +} diff --git a/tests/SquidStd.Tests/Health/HealthCheckServiceTests.cs b/tests/SquidStd.Tests/Health/HealthCheckServiceTests.cs index db9c7c82..cd462d69 100644 --- a/tests/SquidStd.Tests/Health/HealthCheckServiceTests.cs +++ b/tests/SquidStd.Tests/Health/HealthCheckServiceTests.cs @@ -57,9 +57,13 @@ public async Task CheckThatThrows_IsCapturedAsUnhealthy_AndDoesNotBreakOthers() [Fact] public void Ctor_NonPositiveTimeout_Throws() - => Assert.Throws( - () => new HealthCheckService([], new() { CheckTimeout = TimeSpan.Zero }) + { + Assert.Throws(() => new HealthCheckService( + [], + new HealthCheckOptions { CheckTimeout = TimeSpan.Zero } + ) ); + } [Fact] public async Task DuplicateNames_AreMadeUnique() @@ -109,8 +113,12 @@ public async Task OneUnhealthy_MakesOverallUnhealthy() } private static HealthCheckService NewService(HealthCheckOptions options, params IHealthCheck[] checks) - => new(checks, options); + { + return new HealthCheckService(checks, options); + } private static HealthCheckOptions Options(double timeoutSeconds = 5) - => new() { CheckTimeout = TimeSpan.FromSeconds(timeoutSeconds) }; + { + return new HealthCheckOptions { CheckTimeout = TimeSpan.FromSeconds(timeoutSeconds) }; + } } diff --git a/tests/SquidStd.Tests/Integration/Mail/GreenMailContainerFixture.cs b/tests/SquidStd.Tests/Integration/Mail/GreenMailContainerFixture.cs index c6d4d846..60152131 100644 --- a/tests/SquidStd.Tests/Integration/Mail/GreenMailContainerFixture.cs +++ b/tests/SquidStd.Tests/Integration/Mail/GreenMailContainerFixture.cs @@ -7,16 +7,16 @@ namespace SquidStd.Tests.Integration.Mail; public sealed class GreenMailContainerFixture : IAsyncLifetime { private readonly IContainer _container = new ContainerBuilder() - .WithImage("greenmail/standalone:2.1.0") - .WithEnvironment( - "GREENMAIL_OPTS", - "-Dgreenmail.setup.test.all -Dgreenmail.hostname=0.0.0.0 -Dgreenmail.auth.disabled -Dgreenmail.verbose" - ) - .WithPortBinding(3025, true) - .WithPortBinding(3143, true) - .WithPortBinding(3110, true) - .WithWaitStrategy(Wait.ForUnixContainer().UntilInternalTcpPortIsAvailable(3143)) - .Build(); + .WithImage("greenmail/standalone:2.1.0") + .WithEnvironment( + "GREENMAIL_OPTS", + "-Dgreenmail.setup.test.all -Dgreenmail.hostname=0.0.0.0 -Dgreenmail.auth.disabled -Dgreenmail.verbose" + ) + .WithPortBinding(3025, true) + .WithPortBinding(3143, true) + .WithPortBinding(3110, true) + .WithWaitStrategy(Wait.ForUnixContainer().UntilInternalTcpPortIsAvailable(3143)) + .Build(); public string Host => _container.Hostname; @@ -27,10 +27,14 @@ public sealed class GreenMailContainerFixture : IAsyncLifetime public int Pop3Port => _container.GetMappedPublicPort(3110); public Task DisposeAsync() - => _container.DisposeAsync().AsTask(); + { + return _container.DisposeAsync().AsTask(); + } public Task InitializeAsync() - => _container.StartAsync(); + { + return _container.StartAsync(); + } } [CollectionDefinition(Name)] diff --git a/tests/SquidStd.Tests/Integration/Mail/MailPollingServiceTests.cs b/tests/SquidStd.Tests/Integration/Mail/MailPollingServiceTests.cs index 28095c39..52646060 100644 --- a/tests/SquidStd.Tests/Integration/Mail/MailPollingServiceTests.cs +++ b/tests/SquidStd.Tests/Integration/Mail/MailPollingServiceTests.cs @@ -2,6 +2,7 @@ using MailKit.Security; using MimeKit; using SquidStd.Core.Interfaces.Events; +using SquidStd.Mail.Abstractions.Data.Config; using SquidStd.Mail.Abstractions.Data.Events; using SquidStd.Mail.Abstractions.Types.Mail; using SquidStd.Mail.MailKit.Services; @@ -22,23 +23,6 @@ public MailPollingServiceTests(GreenMailContainerFixture fixture) _fixture = fixture; } - private sealed class DelegateListener : IAsyncEventListener - { - private readonly Action _onEvent; - - public DelegateListener(Action onEvent) - { - _onEvent = onEvent; - } - - public Task HandleAsync(MailReceivedEvent eventData, CancellationToken cancellationToken) - { - _onEvent(eventData); - - return Task.CompletedTask; - } - } - [Fact] public async Task PollOnce_PublishesMailReceivedEvent() { @@ -58,10 +42,10 @@ public async Task PollOnce_PublishesMailReceivedEvent() var eventBus = new EventBusService(); var received = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - eventBus.RegisterAsyncListener(new DelegateListener(e => received.TrySetResult(e))); + eventBus.RegisterListener(new DelegateListener(e => received.TrySetResult(e))); var reader = new ImapMailReader( - new() + new MailOptions { Protocol = MailProtocolType.Imap, Host = _fixture.Host, @@ -76,11 +60,28 @@ public async Task PollOnce_PublishesMailReceivedEvent() reader, eventBus, new FakeTimerService(), - new() { PollIntervalSeconds = 60 } + new MailOptions { PollIntervalSeconds = 60 } ); await service.PollOnceAsync(); var evt = await received.Task.WaitAsync(Timeout); Assert.Equal("poll-subject", evt.Message.Subject); } + + private sealed class DelegateListener : IEventListener + { + private readonly Action _onEvent; + + public DelegateListener(Action onEvent) + { + _onEvent = onEvent; + } + + public Task HandleAsync(MailReceivedEvent eventData, CancellationToken cancellationToken) + { + _onEvent(eventData); + + return Task.CompletedTask; + } + } } diff --git a/tests/SquidStd.Tests/Integration/Mail/MailQueueIntegrationTests.cs b/tests/SquidStd.Tests/Integration/Mail/MailQueueIntegrationTests.cs index 83fa0fc4..be7b422f 100644 --- a/tests/SquidStd.Tests/Integration/Mail/MailQueueIntegrationTests.cs +++ b/tests/SquidStd.Tests/Integration/Mail/MailQueueIntegrationTests.cs @@ -1,6 +1,7 @@ using DryIoc; using SquidStd.Core.Interfaces.Events; using SquidStd.Mail.Abstractions.Data; +using SquidStd.Mail.Abstractions.Data.Config; using SquidStd.Mail.Abstractions.Types.Mail; using SquidStd.Mail.MailKit.Extensions; using SquidStd.Mail.MailKit.Services; @@ -32,25 +33,25 @@ public async Task Enqueue_IsSentByConsumer_AndDelivered() var container = new Container(); container.RegisterInstance(new EventBusService()); container.AddInMemoryMessaging(); - container.AddMailSender(new() { Host = _fixture.Host, Port = _fixture.SmtpPort, UseSsl = false }); + container.AddMailSender(new SmtpOptions { Host = _fixture.Host, Port = _fixture.SmtpPort, UseSsl = false }); container.AddMailQueue(); var consumer = container.Resolve(); await consumer.StartAsync(); await container.Resolve() - .EnqueueAsync( - new() - { - From = new("Sender", "sender@example.com"), - To = [new("Target", recipient)], - Subject = "queued-subject", - TextBody = "hi" - } - ); + .EnqueueAsync( + new OutgoingMailMessage + { + From = new MailAddress("Sender", "sender@example.com"), + To = [new MailAddress("Target", recipient)], + Subject = "queued-subject", + TextBody = "hi" + } + ); var reader = new ImapMailReader( - new() + new MailOptions { Protocol = MailProtocolType.Imap, Host = _fixture.Host, diff --git a/tests/SquidStd.Tests/Integration/Mail/MailReaderTests.cs b/tests/SquidStd.Tests/Integration/Mail/MailReaderTests.cs index e465f03b..392156f3 100644 --- a/tests/SquidStd.Tests/Integration/Mail/MailReaderTests.cs +++ b/tests/SquidStd.Tests/Integration/Mail/MailReaderTests.cs @@ -43,7 +43,7 @@ public async Task Pop3_FetchesNewMessage_WithDelete() var user = "pop-user@example.com"; await SendAsync(user, "pop-subject", false); var reader = new Pop3MailReader( - new() + new MailOptions { Protocol = MailProtocolType.Pop3, Host = _fixture.Host, @@ -63,7 +63,8 @@ public async Task Pop3_FetchesNewMessage_WithDelete() } private MailOptions ImapOptions(string user) - => new() + { + return new MailOptions { Protocol = MailProtocolType.Imap, Host = _fixture.Host, @@ -74,6 +75,7 @@ private MailOptions ImapOptions(string user) MarkAsSeen = true, IncludeRawEml = true }; + } private async Task SendAsync(string to, string subject, bool withAttachment) { diff --git a/tests/SquidStd.Tests/Integration/Mail/MailSenderTests.cs b/tests/SquidStd.Tests/Integration/Mail/MailSenderTests.cs index ef5a492b..ad1d5e7c 100644 --- a/tests/SquidStd.Tests/Integration/Mail/MailSenderTests.cs +++ b/tests/SquidStd.Tests/Integration/Mail/MailSenderTests.cs @@ -1,5 +1,7 @@ using System.Text; using SquidStd.Core.Interfaces.Events; +using SquidStd.Mail.Abstractions.Data; +using SquidStd.Mail.Abstractions.Data.Config; using SquidStd.Mail.Abstractions.Data.Events; using SquidStd.Mail.Abstractions.Exceptions; using SquidStd.Mail.Abstractions.Types.Mail; @@ -20,54 +22,36 @@ public MailSenderTests(GreenMailContainerFixture fixture) _fixture = fixture; } - private sealed class DelegateListener : IAsyncEventListener - where TEvent : IEvent - { - private readonly Action _onEvent; - - public DelegateListener(Action onEvent) - { - _onEvent = onEvent; - } - - public Task HandleAsync(TEvent eventData, CancellationToken cancellationToken) - { - _onEvent(eventData); - - return Task.CompletedTask; - } - } - [Fact] public async Task SendAsync_DeliversMessage_AndPublishesMailSent() { var recipient = "send-target@example.com"; var eventBus = new EventBusService(); var sent = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - eventBus.RegisterAsyncListener(new DelegateListener(e => sent.TrySetResult(e))); + eventBus.RegisterListener(new DelegateListener(e => sent.TrySetResult(e))); var sender = new MailKitMailSender( - new() { Host = _fixture.Host, Port = _fixture.SmtpPort, UseSsl = false }, + new SmtpOptions { Host = _fixture.Host, Port = _fixture.SmtpPort, UseSsl = false }, eventBus ); await sender.SendAsync( - new() - { - From = new("Sender", "sender@example.com"), - To = [new("Target", recipient)], - Subject = "sent-subject", - TextBody = "hello", - Attachments = [new("a.txt", "text/plain", Encoding.UTF8.GetBytes("xyz"))] - } - ) - .WaitAsync(Timeout); + new OutgoingMailMessage + { + From = new MailAddress("Sender", "sender@example.com"), + To = [new MailAddress("Target", recipient)], + Subject = "sent-subject", + TextBody = "hello", + Attachments = [new OutgoingAttachment("a.txt", "text/plain", Encoding.UTF8.GetBytes("xyz"))] + } + ) + .WaitAsync(Timeout); var sentEvent = await sent.Task.WaitAsync(Timeout); Assert.Equal("sent-subject", sentEvent.Subject); var reader = new ImapMailReader( - new() + new MailOptions { Protocol = MailProtocolType.Imap, Host = _fixture.Host, @@ -87,23 +71,40 @@ public async Task SendAsync_Throws_AndPublishesFailed_OnClosedPort() { var eventBus = new EventBusService(); var failed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - eventBus.RegisterAsyncListener(new DelegateListener(e => failed.TrySetResult(e))); - - var sender = new MailKitMailSender(new() { Host = _fixture.Host, Port = 1, UseSsl = false }, eventBus); - - await Assert.ThrowsAsync( - () => sender.SendAsync( - new() - { - From = new("Sender", "sender@example.com"), - To = [new("Target", "x@example.com")], - Subject = "fail-subject" - } - ) - .WaitAsync(Timeout) + eventBus.RegisterListener(new DelegateListener(e => failed.TrySetResult(e))); + + var sender = new MailKitMailSender(new SmtpOptions { Host = _fixture.Host, Port = 1, UseSsl = false }, eventBus); + + await Assert.ThrowsAsync(() => sender.SendAsync( + new OutgoingMailMessage + { + From = new MailAddress("Sender", "sender@example.com"), + To = [new MailAddress("Target", "x@example.com")], + Subject = "fail-subject" + } + ) + .WaitAsync(Timeout) ); var failedEvent = await failed.Task.WaitAsync(Timeout); Assert.Equal("fail-subject", failedEvent.Subject); } + + private sealed class DelegateListener : IEventListener + where TEvent : IEvent + { + private readonly Action _onEvent; + + public DelegateListener(Action onEvent) + { + _onEvent = onEvent; + } + + public Task HandleAsync(TEvent eventData, CancellationToken cancellationToken) + { + _onEvent(eventData); + + return Task.CompletedTask; + } + } } diff --git a/tests/SquidStd.Tests/Integration/Search/ElasticSearchServiceTests.cs b/tests/SquidStd.Tests/Integration/Search/ElasticSearchServiceTests.cs index eb5ef63c..3739755f 100644 --- a/tests/SquidStd.Tests/Integration/Search/ElasticSearchServiceTests.cs +++ b/tests/SquidStd.Tests/Integration/Search/ElasticSearchServiceTests.cs @@ -1,6 +1,7 @@ using DryIoc; using SquidStd.Search.Abstractions.Attributes; using SquidStd.Search.Abstractions.Interfaces; +using SquidStd.Search.Elasticsearch.Data.Config; using SquidStd.Search.Elasticsearch.Extensions; using SquidStd.Search.Elasticsearch.Linq; @@ -18,18 +19,6 @@ public ElasticSearchServiceTests(ElasticsearchContainerFixture fixture) _fixture = fixture; } - [SearchIndex("it_orders")] - private sealed record Order(string Id, string Status, int Total, string Name) : IIndexableEntity - { - public string IndexId => Id; - } - - [SearchIndex("it_unused_index")] - private sealed record UnusedDoc(string Id, string Status) : IIndexableEntity - { - public string IndexId => Id; - } - [Fact] public async Task Count_And_FirstOrDefault() { @@ -97,16 +86,16 @@ public async Task Query_Range_Order_Take() { var search = NewService(); await search.IndexManyAsync( - [new("a", "open", 50, "A"), new("b", "open", 200, "B"), new Order("c", "open", 300, "C")], - true - ) - .WaitAsync(Timeout); + [new Order("a", "open", 50, "A"), new Order("b", "open", 200, "B"), new Order("c", "open", 300, "C")], + true + ) + .WaitAsync(Timeout); var results = await search.Query() - .Where(o => o.Total > 100) - .OrderByDescending(o => o.Total) - .Take(1) - .ToListAsync(); + .Where(o => o.Total > 100) + .OrderByDescending(o => o.Total) + .Take(1) + .ToListAsync(); Assert.Single(results); Assert.Equal("c", results[0].Id); @@ -115,8 +104,20 @@ await search.IndexManyAsync( private ISearchService NewService() { var container = new Container(); - container.AddElasticsearch(new() { Uri = new(_fixture.ConnectionString) }); + container.AddElasticsearch(new ElasticsearchOptions { Uri = new Uri(_fixture.ConnectionString) }); return container.Resolve(); } + + [SearchIndex("it_orders")] + private sealed record Order(string Id, string Status, int Total, string Name) : IIndexableEntity + { + public string IndexId => Id; + } + + [SearchIndex("it_unused_index")] + private sealed record UnusedDoc(string Id, string Status) : IIndexableEntity + { + public string IndexId => Id; + } } diff --git a/tests/SquidStd.Tests/Integration/Search/ElasticsearchContainerFixture.cs b/tests/SquidStd.Tests/Integration/Search/ElasticsearchContainerFixture.cs index 0db6a967..4a4af548 100644 --- a/tests/SquidStd.Tests/Integration/Search/ElasticsearchContainerFixture.cs +++ b/tests/SquidStd.Tests/Integration/Search/ElasticsearchContainerFixture.cs @@ -5,34 +5,37 @@ namespace SquidStd.Tests.Integration.Search; /// -/// Starts a single-node Elasticsearch container once for the collection (security disabled → plain HTTP) and -/// exposes its URI. The default Elasticsearch wait strategy probes over https; we override it to poll http so -/// startup completes against the security-disabled node. +/// Starts a single-node Elasticsearch container once for the collection (security disabled → plain HTTP) and +/// exposes its URI. The default Elasticsearch wait strategy probes over https; we override it to poll http so +/// startup completes against the security-disabled node. /// public sealed class ElasticsearchContainerFixture : IAsyncLifetime { private readonly ElasticsearchContainer _container = new ElasticsearchBuilder() - .WithEnvironment("xpack.security.enabled", "false") - .WithWaitStrategy( - Wait.ForUnixContainer() - .UntilHttpRequestIsSucceeded( - request => - request.ForPort(9200) - .ForPath("/_cluster/health") - .ForStatusCode(HttpStatusCode.OK) - ) - ) - .Build(); + .WithEnvironment("xpack.security.enabled", "false") + .WithWaitStrategy( + Wait.ForUnixContainer() + .UntilHttpRequestIsSucceeded(request => + request.ForPort(9200) + .ForPath("/_cluster/health") + .ForStatusCode(HttpStatusCode.OK) + ) + ) + .Build(); // Security is disabled, so the node serves plain HTTP with no auth. GetConnectionString() still returns // an https:// URL with credentials for 8.x images, so build the endpoint explicitly instead. public string ConnectionString => $"http://{_container.Hostname}:{_container.GetMappedPublicPort(9200)}"; public Task DisposeAsync() - => _container.DisposeAsync().AsTask(); + { + return _container.DisposeAsync().AsTask(); + } public Task InitializeAsync() - => _container.StartAsync(); + { + return _container.StartAsync(); + } } [CollectionDefinition(Name)] diff --git a/tests/SquidStd.Tests/Integration/Templates/TemplatePackTests.cs b/tests/SquidStd.Tests/Integration/Templates/TemplatePackTests.cs index 7d636309..e168dbd7 100644 --- a/tests/SquidStd.Tests/Integration/Templates/TemplatePackTests.cs +++ b/tests/SquidStd.Tests/Integration/Templates/TemplatePackTests.cs @@ -3,17 +3,17 @@ namespace SquidStd.Tests.Integration.Templates; /// -/// Packs SquidStd.Templates, installs it into an isolated dotnet-new hive, instantiates each template, and -/// asserts the generated output (name substitution, version-sentinel replacement, messaging branch). No build -/// of the generated projects: the referenced SquidStd.* packages may not be published yet. +/// Packs SquidStd.Templates, installs it into an isolated dotnet-new hive, instantiates each template, and +/// asserts the generated output (name substitution, version-sentinel replacement, messaging branch). No build +/// of the generated projects: the referenced SquidStd.* packages may not be published yet. /// public sealed class TemplatePackTests : IDisposable { - private readonly string _repoRoot; - private readonly string _hive; - private readonly string _workDir; private readonly bool _dotnetAvailable; + private readonly string _hive; private readonly bool _installed; + private readonly string _repoRoot; + private readonly string _workDir; public TemplatePackTests() { @@ -34,12 +34,12 @@ public TemplatePackTests() Assert.True(TryRun("dotnet", $"pack \"{project}\" -c Release", _repoRoot, out _), "pack failed"); var nupkg = Directory - .GetFiles( - Path.Combine(_repoRoot, "src", "SquidStd.Templates", "bin", "Release"), - "SquidStd.Templates.*.nupkg" - ) - .OrderByDescending(File.GetLastWriteTimeUtc) - .First(); + .GetFiles( + Path.Combine(_repoRoot, "src", "SquidStd.Templates", "bin", "Release"), + "SquidStd.Templates.*.nupkg" + ) + .OrderByDescending(File.GetLastWriteTimeUtc) + .First(); _installed = TryRun("dotnet", $"new install \"{nupkg}\" --debug:custom-hive \"{_hive}\"", _repoRoot, out _); } @@ -47,6 +47,14 @@ public TemplatePackTests() // xUnit 2.9.3 has no dynamic skip; guard with an early return when the CLI/install is unavailable. private bool Ready => _dotnetAvailable && _installed; + public void Dispose() + { + // The hive is isolated to this test instance, so deleting it fully uninstalls the pack — no + // global state to clean up. + TryDelete(_workDir); + TryDelete(_hive); + } + [Fact] public void AspNetCore_Instantiates_WithDockerfile() { @@ -62,14 +70,6 @@ public void AspNetCore_Instantiates_WithDockerfile() Assert.Contains("UseSquidStd", File.ReadAllText(Path.Combine(outDir, "Program.cs"))); } - public void Dispose() - { - // The hive is isolated to this test instance, so deleting it fully uninstalls the pack — no - // global state to clean up. - TryDelete(_workDir); - TryDelete(_hive); - } - [Fact] public void Host_Instantiates_WithReplacedNameAndVersion() { diff --git a/tests/SquidStd.Tests/Integration/Workers/WorkerSystemIntegrationTests.cs b/tests/SquidStd.Tests/Integration/Workers/WorkerSystemIntegrationTests.cs index 1ef4078c..bdc33a8c 100644 --- a/tests/SquidStd.Tests/Integration/Workers/WorkerSystemIntegrationTests.cs +++ b/tests/SquidStd.Tests/Integration/Workers/WorkerSystemIntegrationTests.cs @@ -15,9 +15,9 @@ namespace SquidStd.Tests.Integration.Workers; /// -/// End-to-end test of the workers system over a real RabbitMQ broker (Testcontainers): the manager -/// enqueues a job that the worker consumes and runs (real queue), and the worker's heartbeat reaches the -/// manager's registry (real fan-out topic). +/// End-to-end test of the workers system over a real RabbitMQ broker (Testcontainers): the manager +/// enqueues a job that the worker consumes and runs (real queue), and the worker's heartbeat reaches the +/// manager's registry (real fan-out topic). /// [Collection(RabbitMqCollection.Name)] public class WorkerSystemIntegrationTests @@ -31,32 +31,12 @@ public WorkerSystemIntegrationTests(RabbitMqContainerFixture fixture) _fixture = fixture; } - private sealed class CapturingJobHandler : IJobHandler - { - private readonly TaskCompletionSource _completion; - - public CapturingJobHandler(string jobName, TaskCompletionSource completion) - { - JobName = jobName; - _completion = completion; - } - - public string JobName { get; } - - public Task HandleAsync(JobRequest job, CancellationToken cancellationToken) - { - _completion.TrySetResult(job); - - return Task.CompletedTask; - } - } - [Fact] public async Task Manager_EnqueuesJob_WorkerRunsIt_AndHeartbeatReachesRegistry() { using var container = new Container(); container.RegisterInstance(new EventBusService()); - container.AddRabbitMqMessaging(new RabbitMqOptions { Uri = new(_fixture.AmqpUri) }); + container.AddRabbitMqMessaging(new RabbitMqOptions { Uri = new Uri(_fixture.AmqpUri) }); // Unique channel names per run so parallel/other tests on the shared broker do not interfere. var suffix = Guid.NewGuid().ToString("N"); @@ -150,4 +130,24 @@ public async Task Manager_EnqueuesJob_WorkerRunsIt_AndHeartbeatReachesRegistry() return null; } + + private sealed class CapturingJobHandler : IJobHandler + { + private readonly TaskCompletionSource _completion; + + public CapturingJobHandler(string jobName, TaskCompletionSource completion) + { + JobName = jobName; + _completion = completion; + } + + public string JobName { get; } + + public Task HandleAsync(JobRequest job, CancellationToken cancellationToken) + { + _completion.TrySetResult(job); + + return Task.CompletedTask; + } + } } diff --git a/tests/SquidStd.Tests/Json/JsonContextTypeResolverTests.cs b/tests/SquidStd.Tests/Json/JsonContextTypeResolverTests.cs index bb476cef..ef40a5b4 100644 --- a/tests/SquidStd.Tests/Json/JsonContextTypeResolverTests.cs +++ b/tests/SquidStd.Tests/Json/JsonContextTypeResolverTests.cs @@ -43,11 +43,15 @@ public void GetTypeInfo_RegisteredType_ReturnsTypeInfo() [Fact] public void IsTypeRegistered_RegisteredType_ReturnsTrue() - => Assert.True(JsonContextTypeResolver.IsTypeRegistered(TestJsonContext.Default, typeof(SampleDto))); + { + Assert.True(JsonContextTypeResolver.IsTypeRegistered(TestJsonContext.Default, typeof(SampleDto))); + } [Fact] public void IsTypeRegistered_UnregisteredType_ReturnsFalse() - => Assert.False( + { + Assert.False( JsonContextTypeResolver.IsTypeRegistered(TestJsonContext.Default, typeof(JsonContextTypeResolverTests)) ); + } } diff --git a/tests/SquidStd.Tests/Json/JsonUtilsTests.cs b/tests/SquidStd.Tests/Json/JsonUtilsTests.cs index 47880313..4420d268 100644 --- a/tests/SquidStd.Tests/Json/JsonUtilsTests.cs +++ b/tests/SquidStd.Tests/Json/JsonUtilsTests.cs @@ -14,9 +14,6 @@ public JsonUtilsTests() JsonUtils.RegisterJsonContext(TestJsonContext.Default); } - // Local type whose name ends with "Entity" to verify suffix stripping. - private sealed class UserEntity { } - [Fact] public void AddAndRemoveJsonConverter_MutatesConverterList() { @@ -38,11 +35,17 @@ public void AddAndRemoveJsonConverter_MutatesConverterList() [Fact] public void Deserialize_InvalidJson_ThrowsJsonException() - => Assert.Throws(() => JsonUtils.Deserialize("{ not valid")); + { + Assert.Throws(() => JsonUtils.Deserialize("{ not valid")); + } - [Theory, InlineData(""), InlineData(" ")] + [Theory] + [InlineData("")] + [InlineData(" ")] public void Deserialize_NullOrWhitespace_Throws(string json) - => Assert.Throws(() => JsonUtils.Deserialize(json)); + { + Assert.Throws(() => JsonUtils.Deserialize(json)); + } [Fact] public void Deserialize_WithExplicitContext_ReturnsObject() @@ -57,9 +60,11 @@ public void Deserialize_WithExplicitContext_ReturnsObject() [Fact] public void DeserializeFromFile_MissingFile_Throws() - => Assert.Throws( - () => JsonUtils.DeserializeFromFile(Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".json")) + { + Assert.Throws(() => + JsonUtils.DeserializeFromFile(Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".json")) ); + } [Fact] public void DeserializeOrDefault_EmptyJson_ReturnsDefault() @@ -82,9 +87,13 @@ public void DeserializeOrDefault_ValidJson_ReturnsObject() [Fact] public void GetJsonConverters_ContainsDefaultEnumConverter() - => Assert.Contains(JsonUtils.GetJsonConverters(), converter => converter is JsonStringEnumConverter); + { + Assert.Contains(JsonUtils.GetJsonConverters(), converter => converter is JsonStringEnumConverter); + } - [Theory, InlineData("UserEntity", "user.schema.json"), InlineData("SampleDto", "sample_dto.schema.json")] + [Theory] + [InlineData("UserEntity", "user.schema.json")] + [InlineData("SampleDto", "sample_dto.schema.json")] public void GetSchemaFileName_GeneratesSnakeCaseSchemaName(string typeName, string expected) { // Map the parameterized type name to a real type with the same Name. @@ -93,18 +102,30 @@ public void GetSchemaFileName_GeneratesSnakeCaseSchemaName(string typeName, stri Assert.Equal(expected, JsonUtils.GetSchemaFileName(type)); } - [Theory, InlineData("[1,2,3]", true), InlineData("{\"a\":1}", false), InlineData("invalid", false)] + [Theory] + [InlineData("[1,2,3]", true)] + [InlineData("{\"a\":1}", false)] + [InlineData("invalid", false)] public void IsArray_VariousInputs_ReturnsExpected(string json, bool expected) - => Assert.Equal(expected, JsonUtils.IsArray(json)); + { + Assert.Equal(expected, JsonUtils.IsArray(json)); + } - [Theory, InlineData("{\"a\":1}", true), InlineData("[1,2,3]", true), InlineData("not json", false), - InlineData("", false)] + [Theory] + [InlineData("{\"a\":1}", true)] + [InlineData("[1,2,3]", true)] + [InlineData("not json", false)] + [InlineData("", false)] public void IsValidJson_VariousInputs_ReturnsExpected(string json, bool expected) - => Assert.Equal(expected, JsonUtils.IsValidJson(json)); + { + Assert.Equal(expected, JsonUtils.IsValidJson(json)); + } [Fact] public void Serialize_NullObject_Throws() - => Assert.Throws(() => JsonUtils.Serialize(null!)); + { + Assert.Throws(() => JsonUtils.Serialize(null!)); + } [Fact] public void SerializeDeserialize_RoundTrip_PreservesValues() @@ -132,4 +153,9 @@ public void SerializeToFile_DeserializeFromFile_RoundTrips() Assert.Equal("file", restored.Name); Assert.Equal(9, restored.Count); } + + // Local type whose name ends with "Entity" to verify suffix stripping. + private sealed class UserEntity + { + } } diff --git a/tests/SquidStd.Tests/Logging/EventSinkExtensionsTests.cs b/tests/SquidStd.Tests/Logging/EventSinkExtensionsTests.cs index 8e2aa0a5..c3dd804f 100644 --- a/tests/SquidStd.Tests/Logging/EventSinkExtensionsTests.cs +++ b/tests/SquidStd.Tests/Logging/EventSinkExtensionsTests.cs @@ -13,14 +13,16 @@ public void EventSink_WiredIntoSerilog_RaisesEventsForLogs() LogEventData? captured = null; void Handler(object? sender, LogEventData data) - => captured = data; + { + captured = data; + } EventSink.OnLogReceived += Handler; var logger = new LoggerConfiguration() - .WriteTo - .EventSink() - .CreateLogger(); + .WriteTo + .EventSink() + .CreateLogger(); try { diff --git a/tests/SquidStd.Tests/Logging/EventSinkTests.cs b/tests/SquidStd.Tests/Logging/EventSinkTests.cs index 1a0b90a8..6955c382 100644 --- a/tests/SquidStd.Tests/Logging/EventSinkTests.cs +++ b/tests/SquidStd.Tests/Logging/EventSinkTests.cs @@ -14,7 +14,9 @@ public void Emit_AfterClearSubscribers_DoesNotInvokeHandler() var invoked = false; void Handler(object? sender, LogEventData data) - => invoked = true; + { + invoked = true; + } EventSink.OnLogReceived += Handler; EventSink.ClearSubscribers(); @@ -48,7 +50,9 @@ public void Emit_WithSubscriber_RaisesEventWithMappedData() LogEventData? captured = null; void Handler(object? sender, LogEventData data) - => captured = data; + { + captured = data; + } EventSink.OnLogReceived += Handler; @@ -80,6 +84,6 @@ private static LogEvent CreateLogEvent(LogEventLevel level, string template, par { var parsedTemplate = new MessageTemplateParser().Parse(template); - return new(DateTimeOffset.UtcNow, level, null, parsedTemplate, properties); + return new LogEvent(DateTimeOffset.UtcNow, level, null, parsedTemplate, properties); } } diff --git a/tests/SquidStd.Tests/Mail/MailQueueTests.cs b/tests/SquidStd.Tests/Mail/MailQueueTests.cs index c5595a4a..a1fa7dfd 100644 --- a/tests/SquidStd.Tests/Mail/MailQueueTests.cs +++ b/tests/SquidStd.Tests/Mail/MailQueueTests.cs @@ -11,23 +11,6 @@ namespace SquidStd.Tests.Mail; public class MailQueueTests { - private sealed class CapturingListener : IQueueMessageListenerAsync - { - private readonly TaskCompletionSource _completion; - - public CapturingListener(TaskCompletionSource completion) - { - _completion = completion; - } - - public Task HandleAsync(OutgoingMailMessage message, CancellationToken cancellationToken) - { - _completion.TrySetResult(message); - - return Task.CompletedTask; - } - } - [Fact] public async Task EnqueueAsync_PublishesMessageToConfiguredQueue() { @@ -42,9 +25,9 @@ public async Task EnqueueAsync_PublishesMessageToConfiguredQueue() var queue = new MailQueue(messageQueue, options); await queue.EnqueueAsync( - new() + new OutgoingMailMessage { - To = [new("Bob", "bob@example.com")], + To = [new MailAddress("Bob", "bob@example.com")], Subject = "queued" } ); @@ -53,4 +36,21 @@ await queue.EnqueueAsync( Assert.Equal("queued", message.Subject); Assert.Contains(message.To, a => a.Address == "bob@example.com"); } + + private sealed class CapturingListener : IQueueMessageListenerAsync + { + private readonly TaskCompletionSource _completion; + + public CapturingListener(TaskCompletionSource completion) + { + _completion = completion; + } + + public Task HandleAsync(OutgoingMailMessage message, CancellationToken cancellationToken) + { + _completion.TrySetResult(message); + + return Task.CompletedTask; + } + } } diff --git a/tests/SquidStd.Tests/Mail/MailRegistrationExtensionsTests.cs b/tests/SquidStd.Tests/Mail/MailRegistrationExtensionsTests.cs index 4c477239..725d8860 100644 --- a/tests/SquidStd.Tests/Mail/MailRegistrationExtensionsTests.cs +++ b/tests/SquidStd.Tests/Mail/MailRegistrationExtensionsTests.cs @@ -41,8 +41,8 @@ public void AddMail_Throws_OnInvalidHostOrPort() { using var container = NewContainer(); - Assert.Throws(() => container.AddMail(new() { Host = string.Empty, Port = 993 })); - Assert.Throws(() => container.AddMail(new() { Host = "h", Port = 0 })); + Assert.Throws(() => container.AddMail(new MailOptions { Host = string.Empty, Port = 993 })); + Assert.Throws(() => container.AddMail(new MailOptions { Host = "h", Port = 0 })); } private static Container NewContainer() @@ -55,5 +55,8 @@ private static Container NewContainer() } private static MailOptions ValidOptions(MailProtocolType protocol) - => new() { Protocol = protocol, Host = "mail.example.com", Port = 993, Username = "u", Password = "p" }; + { + return new MailOptions + { Protocol = protocol, Host = "mail.example.com", Port = 993, Username = "u", Password = "p" }; + } } diff --git a/tests/SquidStd.Tests/Mail/MailSendConsumerServiceTests.cs b/tests/SquidStd.Tests/Mail/MailSendConsumerServiceTests.cs index ea847c9f..1cf74691 100644 --- a/tests/SquidStd.Tests/Mail/MailSendConsumerServiceTests.cs +++ b/tests/SquidStd.Tests/Mail/MailSendConsumerServiceTests.cs @@ -16,23 +16,6 @@ public class MailSendConsumerServiceTests { private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(10); - private sealed class CapturingListener : IQueueMessageListenerAsync - { - private readonly TaskCompletionSource _completion; - - public CapturingListener(TaskCompletionSource completion) - { - _completion = completion; - } - - public Task HandleAsync(OutgoingMailMessage message, CancellationToken cancellationToken) - { - _completion.TrySetResult(message); - - return Task.CompletedTask; - } - } - [Fact] public async Task Consumer_DeadLetters_AfterRetriesExhausted() { @@ -83,7 +66,9 @@ public async Task Consumer_SendsEnqueuedMessage() } private static OutgoingMailMessage NewMessage() - => new() { To = [new("Bob", "bob@example.com")], Subject = "queued" }; + { + return new OutgoingMailMessage { To = [new MailAddress("Bob", "bob@example.com")], Subject = "queued" }; + } private static async Task WaitUntilAsync(Func condition, TimeSpan timeout) { @@ -101,4 +86,21 @@ private static async Task WaitUntilAsync(Func condition, TimeSpan timeout) throw new TimeoutException("Condition not met within timeout."); } + + private sealed class CapturingListener : IQueueMessageListenerAsync + { + private readonly TaskCompletionSource _completion; + + public CapturingListener(TaskCompletionSource completion) + { + _completion = completion; + } + + public Task HandleAsync(OutgoingMailMessage message, CancellationToken cancellationToken) + { + _completion.TrySetResult(message); + + return Task.CompletedTask; + } + } } diff --git a/tests/SquidStd.Tests/Mail/MailSenderRegistrationExtensionsTests.cs b/tests/SquidStd.Tests/Mail/MailSenderRegistrationExtensionsTests.cs index 94308654..5b7d57ab 100644 --- a/tests/SquidStd.Tests/Mail/MailSenderRegistrationExtensionsTests.cs +++ b/tests/SquidStd.Tests/Mail/MailSenderRegistrationExtensionsTests.cs @@ -1,5 +1,6 @@ using DryIoc; using SquidStd.Core.Interfaces.Events; +using SquidStd.Mail.Abstractions.Data.Config; using SquidStd.Mail.Abstractions.Interfaces; using SquidStd.Mail.MailKit.Extensions; using SquidStd.Mail.MailKit.Services; @@ -14,7 +15,7 @@ public void AddMailSender_RegistersSender() { using var container = NewContainer(); - container.AddMailSender(new() { Host = "smtp.example.com", Port = 587 }); + container.AddMailSender(new SmtpOptions { Host = "smtp.example.com", Port = 587 }); Assert.IsType(container.Resolve()); } @@ -24,8 +25,8 @@ public void AddMailSender_Throws_OnInvalidHostOrPort() { using var container = NewContainer(); - Assert.Throws(() => container.AddMailSender(new() { Host = string.Empty, Port = 587 })); - Assert.Throws(() => container.AddMailSender(new() { Host = "h", Port = 0 })); + Assert.Throws(() => container.AddMailSender(new SmtpOptions { Host = string.Empty, Port = 587 })); + Assert.Throws(() => container.AddMailSender(new SmtpOptions { Host = "h", Port = 0 })); } private static Container NewContainer() diff --git a/tests/SquidStd.Tests/Mail/MimeMessageMapperTests.cs b/tests/SquidStd.Tests/Mail/MimeMessageMapperTests.cs index 3c59034d..c72d7c0d 100644 --- a/tests/SquidStd.Tests/Mail/MimeMessageMapperTests.cs +++ b/tests/SquidStd.Tests/Mail/MimeMessageMapperTests.cs @@ -55,7 +55,7 @@ private static MimeMessage BuildMessage() message.To.Add(new MailboxAddress("Bob", "bob@example.com")); message.Cc.Add(new MailboxAddress("Carol", "carol@example.com")); message.Subject = "Hello"; - message.Date = new(2026, 6, 24, 10, 0, 0, TimeSpan.Zero); + message.Date = new DateTimeOffset(2026, 6, 24, 10, 0, 0, TimeSpan.Zero); message.MessageId = "msg-1@example.com"; var builder = new BodyBuilder diff --git a/tests/SquidStd.Tests/Mail/OutgoingMessageMapperTests.cs b/tests/SquidStd.Tests/Mail/OutgoingMessageMapperTests.cs index 29bb27c0..a8ff5f22 100644 --- a/tests/SquidStd.Tests/Mail/OutgoingMessageMapperTests.cs +++ b/tests/SquidStd.Tests/Mail/OutgoingMessageMapperTests.cs @@ -11,7 +11,7 @@ public class OutgoingMessageMapperTests [Fact] public void ToMimeMessage_FallsBackToDefaultFrom() { - var options = new SmtpOptions { DefaultFrom = new("Sys", "sys@example.com") }; + var options = new SmtpOptions { DefaultFrom = new MailAddress("Sys", "sys@example.com") }; var mime = OutgoingMessageMapper.ToMimeMessage(Message(), options); @@ -21,9 +21,9 @@ public void ToMimeMessage_FallsBackToDefaultFrom() [Fact] public void ToMimeMessage_MapsRecipientsSubjectBodiesAndAttachment() { - var options = new SmtpOptions { DefaultFrom = new("Sys", "sys@example.com") }; + var options = new SmtpOptions { DefaultFrom = new MailAddress("Sys", "sys@example.com") }; - var mime = OutgoingMessageMapper.ToMimeMessage(Message(new("Alice", "alice@example.com")), options); + var mime = OutgoingMessageMapper.ToMimeMessage(Message(new MailAddress("Alice", "alice@example.com")), options); Assert.Equal("alice@example.com", mime.From.Mailboxes.Single().Address); Assert.Contains(mime.To.Mailboxes, m => m.Address == "bob@example.com"); @@ -37,18 +37,22 @@ public void ToMimeMessage_MapsRecipientsSubjectBodiesAndAttachment() [Fact] public void ToMimeMessage_Throws_WhenNoFromAndNoDefault() - => Assert.Throws(() => OutgoingMessageMapper.ToMimeMessage(Message(), new())); + { + Assert.Throws(() => OutgoingMessageMapper.ToMimeMessage(Message(), new SmtpOptions())); + } private static OutgoingMailMessage Message(MailAddress? from = null) - => new() + { + return new OutgoingMailMessage { From = from, - To = [new("Bob", "bob@example.com")], - Cc = [new("Carol", "carol@example.com")], - Bcc = [new("Dave", "dave@example.com")], + To = [new MailAddress("Bob", "bob@example.com")], + Cc = [new MailAddress("Carol", "carol@example.com")], + Bcc = [new MailAddress("Dave", "dave@example.com")], Subject = "Hello", TextBody = "plain", HtmlBody = "

html

", - Attachments = [new("a.txt", "text/plain", Encoding.UTF8.GetBytes("xyz"))] + Attachments = [new OutgoingAttachment("a.txt", "text/plain", Encoding.UTF8.GetBytes("xyz"))] }; + } } diff --git a/tests/SquidStd.Tests/Mail/Support/FakeTimerService.cs b/tests/SquidStd.Tests/Mail/Support/FakeTimerService.cs index b27bef21..deb15def 100644 --- a/tests/SquidStd.Tests/Mail/Support/FakeTimerService.cs +++ b/tests/SquidStd.Tests/Mail/Support/FakeTimerService.cs @@ -14,14 +14,22 @@ public string RegisterTimer(string name, TimeSpan interval, Action callback, Tim return "timer-1"; } - public void UnregisterAllTimers() { } + public void UnregisterAllTimers() + { + } public bool UnregisterTimer(string timerId) - => true; + { + return true; + } public int UnregisterTimersByName(string name) - => 0; + { + return 0; + } public int UpdateTicksDelta(long timestampMilliseconds) - => 0; + { + return 0; + } } diff --git a/tests/SquidStd.Tests/Mail/Support/ThrowingMailSender.cs b/tests/SquidStd.Tests/Mail/Support/ThrowingMailSender.cs index 1f94054c..48ee9089 100644 --- a/tests/SquidStd.Tests/Mail/Support/ThrowingMailSender.cs +++ b/tests/SquidStd.Tests/Mail/Support/ThrowingMailSender.cs @@ -7,5 +7,7 @@ namespace SquidStd.Tests.Mail.Support; public sealed class ThrowingMailSender : IMailSender { public Task SendAsync(OutgoingMailMessage message, CancellationToken cancellationToken = default) - => throw new InvalidOperationException("send failed"); + { + throw new InvalidOperationException("send failed"); + } } diff --git a/tests/SquidStd.Tests/Manager/HeartbeatCollectorServiceTests.cs b/tests/SquidStd.Tests/Manager/HeartbeatCollectorServiceTests.cs index eb1ea96e..decf3670 100644 --- a/tests/SquidStd.Tests/Manager/HeartbeatCollectorServiceTests.cs +++ b/tests/SquidStd.Tests/Manager/HeartbeatCollectorServiceTests.cs @@ -6,6 +6,7 @@ using SquidStd.Workers.Abstractions; using SquidStd.Workers.Abstractions.Data; using SquidStd.Workers.Abstractions.Types; +using SquidStd.Workers.Manager.Data.Config; using SquidStd.Workers.Manager.Data.Events; using SquidStd.Workers.Manager.Services; @@ -13,23 +14,6 @@ namespace SquidStd.Tests.Manager; public class HeartbeatCollectorServiceTests { - private sealed class DelegateListener : IAsyncEventListener - { - private readonly Action _onEvent; - - public DelegateListener(Action onEvent) - { - _onEvent = onEvent; - } - - public Task HandleAsync(WorkerStatusChangedEvent eventData, CancellationToken cancellationToken) - { - _onEvent(eventData); - - return Task.CompletedTask; - } - } - [Fact] public async Task Collector_RecordsHeartbeat_AndPublishesDiscoveredEvent() { @@ -39,12 +23,12 @@ public async Task Collector_RecordsHeartbeat_AndPublishesDiscoveredEvent() container.AddInMemoryMessaging(); var topic = container.Resolve(); - var registry = new WorkerRegistry(new()); + var registry = new WorkerRegistry(new WorkerManagerConfig()); var discovered = new TaskCompletionSource(); - eventBus.RegisterAsyncListener(new DelegateListener(e => discovered.TrySetResult(e))); + eventBus.RegisterListener(new DelegateListener(e => discovered.TrySetResult(e))); - var service = new HeartbeatCollectorService(topic, registry, eventBus, new()); + var service = new HeartbeatCollectorService(topic, registry, eventBus, new WorkerManagerConfig()); await service.StartAsync(); await topic.PublishAsync( @@ -59,4 +43,21 @@ await topic.PublishAsync( Assert.Null(change.OldStatus); Assert.NotNull(registry.Get("w1")); } + + private sealed class DelegateListener : IEventListener + { + private readonly Action _onEvent; + + public DelegateListener(Action onEvent) + { + _onEvent = onEvent; + } + + public Task HandleAsync(WorkerStatusChangedEvent eventData, CancellationToken cancellationToken) + { + _onEvent(eventData); + + return Task.CompletedTask; + } + } } diff --git a/tests/SquidStd.Tests/Manager/JobSchedulerTests.cs b/tests/SquidStd.Tests/Manager/JobSchedulerTests.cs index f9bbad1b..57c03f32 100644 --- a/tests/SquidStd.Tests/Manager/JobSchedulerTests.cs +++ b/tests/SquidStd.Tests/Manager/JobSchedulerTests.cs @@ -5,29 +5,13 @@ using SquidStd.Services.Core.Services; using SquidStd.Workers.Abstractions; using SquidStd.Workers.Abstractions.Data; +using SquidStd.Workers.Manager.Data.Config; using SquidStd.Workers.Manager.Services; namespace SquidStd.Tests.Manager; public class JobSchedulerTests { - private sealed class DelegateListener : IQueueMessageListenerAsync - { - private readonly Action _onMessage; - - public DelegateListener(Action onMessage) - { - _onMessage = onMessage; - } - - public Task HandleAsync(JobRequest message, CancellationToken cancellationToken) - { - _onMessage(message); - - return Task.CompletedTask; - } - } - [Fact] public async Task EnqueueAsync_PublishesJobRequestToConfiguredQueue() { @@ -42,11 +26,28 @@ public async Task EnqueueAsync_PublishesJobRequestToConfiguredQueue() new DelegateListener(job => received.TrySetResult(job)) ); - var scheduler = new JobScheduler(queue, new()); + var scheduler = new JobScheduler(queue, new WorkerManagerConfig()); await scheduler.EnqueueAsync("resize", new Dictionary { ["w"] = "100" }); var job = await received.Task.WaitAsync(TimeSpan.FromSeconds(5)); Assert.Equal("resize", job.JobName); Assert.Equal("100", job.Parameters["w"]); } + + private sealed class DelegateListener : IQueueMessageListenerAsync + { + private readonly Action _onMessage; + + public DelegateListener(Action onMessage) + { + _onMessage = onMessage; + } + + public Task HandleAsync(JobRequest message, CancellationToken cancellationToken) + { + _onMessage(message); + + return Task.CompletedTask; + } + } } diff --git a/tests/SquidStd.Tests/Manager/Support/FakeTimerService.cs b/tests/SquidStd.Tests/Manager/Support/FakeTimerService.cs index edd54854..3867541a 100644 --- a/tests/SquidStd.Tests/Manager/Support/FakeTimerService.cs +++ b/tests/SquidStd.Tests/Manager/Support/FakeTimerService.cs @@ -22,7 +22,9 @@ public string RegisterTimer(string name, TimeSpan interval, Action callback, Tim return "timer-1"; } - public void UnregisterAllTimers() { } + public void UnregisterAllTimers() + { + } public bool UnregisterTimer(string timerId) { @@ -32,8 +34,12 @@ public bool UnregisterTimer(string timerId) } public int UnregisterTimersByName(string name) - => 0; + { + return 0; + } public int UpdateTicksDelta(long timestampMilliseconds) - => 0; + { + return 0; + } } diff --git a/tests/SquidStd.Tests/Manager/WorkerManagerEndpointsTests.cs b/tests/SquidStd.Tests/Manager/WorkerManagerEndpointsTests.cs index a6cd82d1..d4bf2dcd 100644 --- a/tests/SquidStd.Tests/Manager/WorkerManagerEndpointsTests.cs +++ b/tests/SquidStd.Tests/Manager/WorkerManagerEndpointsTests.cs @@ -6,6 +6,8 @@ using SquidStd.Services.Core.Services; using SquidStd.Workers.Abstractions.Data; using SquidStd.Workers.Abstractions.Types; +using SquidStd.Workers.Manager.Data; +using SquidStd.Workers.Manager.Data.Config; using SquidStd.Workers.Manager.Endpoints; using SquidStd.Workers.Manager.Services; @@ -17,10 +19,10 @@ public class WorkerManagerEndpointsTests public async Task EnqueueJob_ReturnsAccepted_AndSchedulesJob() { var result = await WorkerManagerEndpoints.EnqueueJob( - new("resize", new Dictionary()), - NewScheduler(), - CancellationToken.None - ); + new EnqueueJobRequest("resize", new Dictionary()), + NewScheduler(), + CancellationToken.None + ); Assert.IsType(result.Result); } @@ -29,10 +31,10 @@ public async Task EnqueueJob_ReturnsAccepted_AndSchedulesJob() public async Task EnqueueJob_ReturnsBadRequest_WhenJobNameBlank() { var result = await WorkerManagerEndpoints.EnqueueJob( - new(" ", null), - NewScheduler(), - CancellationToken.None - ); + new EnqueueJobRequest(" ", null), + NewScheduler(), + CancellationToken.None + ); Assert.IsType>(result.Result); } @@ -67,16 +69,16 @@ private static JobScheduler NewScheduler() container.RegisterInstance(new EventBusService()); container.AddInMemoryMessaging(); - return new(container.Resolve(), new()); + return new JobScheduler(container.Resolve(), new WorkerManagerConfig()); } private static WorkerRegistry RegistryWith(params string[] workerIds) { - var registry = new WorkerRegistry(new()); + var registry = new WorkerRegistry(new WorkerManagerConfig()); foreach (var id in workerIds) { - registry.Record(new(id, DateTime.UtcNow, WorkerStatusType.Idle, 0, 8)); + registry.Record(new WorkerHeartbeat(id, DateTime.UtcNow, WorkerStatusType.Idle, 0, 8)); } return registry; diff --git a/tests/SquidStd.Tests/Manager/WorkerOfflineSweepServiceTests.cs b/tests/SquidStd.Tests/Manager/WorkerOfflineSweepServiceTests.cs index e25905b3..fd0933b1 100644 --- a/tests/SquidStd.Tests/Manager/WorkerOfflineSweepServiceTests.cs +++ b/tests/SquidStd.Tests/Manager/WorkerOfflineSweepServiceTests.cs @@ -1,7 +1,9 @@ using SquidStd.Core.Interfaces.Events; using SquidStd.Services.Core.Services; using SquidStd.Tests.Manager.Support; +using SquidStd.Workers.Abstractions.Data; using SquidStd.Workers.Abstractions.Types; +using SquidStd.Workers.Manager.Data.Config; using SquidStd.Workers.Manager.Data.Events; using SquidStd.Workers.Manager.Services; @@ -9,35 +11,18 @@ namespace SquidStd.Tests.Manager; public class WorkerOfflineSweepServiceTests { - private sealed class DelegateListener : IAsyncEventListener - { - private readonly Action _onEvent; - - public DelegateListener(Action onEvent) - { - _onEvent = onEvent; - } - - public Task HandleAsync(WorkerStatusChangedEvent eventData, CancellationToken cancellationToken) - { - _onEvent(eventData); - - return Task.CompletedTask; - } - } - [Fact] public async Task RunSweepAsync_MarksOverdueWorkerOffline_AndPublishesTransition() { - var registry = new WorkerRegistry(new() { OfflineTimeoutSeconds = 1 }); - registry.Record(new("w1", DateTime.UtcNow, WorkerStatusType.Idle, 0, 8)); + var registry = new WorkerRegistry(new WorkerManagerConfig { OfflineTimeoutSeconds = 1 }); + registry.Record(new WorkerHeartbeat("w1", DateTime.UtcNow, WorkerStatusType.Idle, 0, 8)); await Task.Delay(1100); var eventBus = new EventBusService(); var offline = new TaskCompletionSource(); - eventBus.RegisterAsyncListener(new DelegateListener(e => offline.TrySetResult(e))); + eventBus.RegisterListener(new DelegateListener(e => offline.TrySetResult(e))); - var service = new WorkerOfflineSweepService(new FakeTimerService(), registry, eventBus, new()); + var service = new WorkerOfflineSweepService(new FakeTimerService(), registry, eventBus, new WorkerManagerConfig()); await service.RunSweepAsync(); var change = await offline.Task.WaitAsync(TimeSpan.FromSeconds(5)); @@ -50,12 +35,12 @@ public async Task RunSweepAsync_MarksOverdueWorkerOffline_AndPublishesTransition public async Task StartAsync_RegistersRepeatingTimer_StopAsyncUnregisters() { var timer = new FakeTimerService(); - var registry = new WorkerRegistry(new()); + var registry = new WorkerRegistry(new WorkerManagerConfig()); var service = new WorkerOfflineSweepService( timer, registry, new EventBusService(), - new() { SweepIntervalSeconds = 5 } + new WorkerManagerConfig { SweepIntervalSeconds = 5 } ); await service.StartAsync(); @@ -67,4 +52,21 @@ public async Task StartAsync_RegistersRepeatingTimer_StopAsyncUnregisters() await service.StopAsync(); Assert.Equal("timer-1", timer.UnregisteredId); } + + private sealed class DelegateListener : IEventListener + { + private readonly Action _onEvent; + + public DelegateListener(Action onEvent) + { + _onEvent = onEvent; + } + + public Task HandleAsync(WorkerStatusChangedEvent eventData, CancellationToken cancellationToken) + { + _onEvent(eventData); + + return Task.CompletedTask; + } + } } diff --git a/tests/SquidStd.Tests/Manager/WorkerRegistryTests.cs b/tests/SquidStd.Tests/Manager/WorkerRegistryTests.cs index e2b45c92..f5a219fd 100644 --- a/tests/SquidStd.Tests/Manager/WorkerRegistryTests.cs +++ b/tests/SquidStd.Tests/Manager/WorkerRegistryTests.cs @@ -1,5 +1,6 @@ using SquidStd.Workers.Abstractions.Data; using SquidStd.Workers.Abstractions.Types; +using SquidStd.Workers.Manager.Data.Config; using SquidStd.Workers.Manager.Services; namespace SquidStd.Tests.Manager; @@ -89,8 +90,12 @@ public void Sweep_MarksOverdueWorkerOffline_AndReturnsTransition() } private static WorkerHeartbeat Heartbeat(string id, WorkerStatusType status, int activeJobs = 0) - => new(id, DateTime.UtcNow, status, activeJobs, 8); + { + return new WorkerHeartbeat(id, DateTime.UtcNow, status, activeJobs, 8); + } private static WorkerRegistry NewRegistry(int offlineTimeoutSeconds = 30) - => new(new() { OfflineTimeoutSeconds = offlineTimeoutSeconds }); + { + return new WorkerRegistry(new WorkerManagerConfig { OfflineTimeoutSeconds = offlineTimeoutSeconds }); + } } diff --git a/tests/SquidStd.Tests/Messaging/InMemoryQueueProviderTests.cs b/tests/SquidStd.Tests/Messaging/InMemoryQueueProviderTests.cs index d1f9e2f8..4bd45577 100644 --- a/tests/SquidStd.Tests/Messaging/InMemoryQueueProviderTests.cs +++ b/tests/SquidStd.Tests/Messaging/InMemoryQueueProviderTests.cs @@ -12,7 +12,7 @@ public class InMemoryQueueProviderTests [Fact] public async Task AlwaysFailing_IsDeadLetteredAfterMaxAttempts() { - await using var provider = NewProvider(options: new() { MaxDeliveryAttempts = 2 }); + await using var provider = NewProvider(options: new MessagingOptions { MaxDeliveryAttempts = 2 }); var attempts = 0; provider.Subscribe( "q", @@ -66,7 +66,7 @@ public async Task DisposedSubscription_StopsReceiving() [Fact] public async Task FailingThenSucceeding_IsRetriedAndDelivered() { - await using var provider = NewProvider(options: new() { MaxDeliveryAttempts = 3 }); + await using var provider = NewProvider(options: new MessagingOptions { MaxDeliveryAttempts = 3 }); var attempts = 0; var delivered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); provider.Subscribe( @@ -169,14 +169,20 @@ public async Task TwoSubscribers_ReceiveRoundRobin() } private static ReadOnlyMemory Bytes(string s) - => Encoding.UTF8.GetBytes(s); + { + return Encoding.UTF8.GetBytes(s); + } private static InMemoryQueueProvider NewProvider( MessagingMetricsProvider? metrics = null, MessagingOptions? options = null ) - => new(options ?? new MessagingOptions(), metrics ?? new MessagingMetricsProvider()); + { + return new InMemoryQueueProvider(options ?? new MessagingOptions(), metrics ?? new MessagingMetricsProvider()); + } private static string Text(ReadOnlyMemory b) - => Encoding.UTF8.GetString(b.Span); + { + return Encoding.UTF8.GetString(b.Span); + } } diff --git a/tests/SquidStd.Tests/Messaging/InMemoryTopicProviderTests.cs b/tests/SquidStd.Tests/Messaging/InMemoryTopicProviderTests.cs index 888e1224..97b02930 100644 --- a/tests/SquidStd.Tests/Messaging/InMemoryTopicProviderTests.cs +++ b/tests/SquidStd.Tests/Messaging/InMemoryTopicProviderTests.cs @@ -87,5 +87,7 @@ public async Task Publish_NoSubscribers_IsNoOp() } private static ReadOnlyMemory Bytes(string s) - => Encoding.UTF8.GetBytes(s); + { + return Encoding.UTF8.GetBytes(s); + } } diff --git a/tests/SquidStd.Tests/Messaging/MessageQueueTests.cs b/tests/SquidStd.Tests/Messaging/MessageQueueTests.cs index 1a40146e..78148e57 100644 --- a/tests/SquidStd.Tests/Messaging/MessageQueueTests.cs +++ b/tests/SquidStd.Tests/Messaging/MessageQueueTests.cs @@ -1,4 +1,5 @@ using SquidStd.Core.Json; +using SquidStd.Messaging.Abstractions.Data.Config; using SquidStd.Messaging.Abstractions.Interfaces; using SquidStd.Messaging.Abstractions.Services; using SquidStd.Messaging.Services; @@ -9,6 +10,22 @@ public class MessageQueueTests { private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5); + [Fact] + public async Task PublishAsync_DeliversTypedMessageToListener() + { + await using var provider = new InMemoryQueueProvider(new MessagingOptions(), new MessagingMetricsProvider()); + var serializer = new JsonDataSerializer(); + IMessageQueue queue = new MessageQueue(provider, serializer, serializer); + var listener = new CapturingListener(); + queue.Subscribe("orders", listener); + + await queue.PublishAsync("orders", new Order { Id = "A1", Amount = 42 }); + + var received = await listener.Received.Task.WaitAsync(Timeout); + Assert.Equal("A1", received.Id); + Assert.Equal(42, received.Amount); + } + private sealed class Order { public string Id { get; set; } = ""; @@ -26,20 +43,4 @@ public Task HandleAsync(Order message, CancellationToken cancellationToken) return Task.CompletedTask; } } - - [Fact] - public async Task PublishAsync_DeliversTypedMessageToListener() - { - await using var provider = new InMemoryQueueProvider(new(), new MessagingMetricsProvider()); - var serializer = new JsonDataSerializer(); - IMessageQueue queue = new MessageQueue(provider, serializer, serializer); - var listener = new CapturingListener(); - queue.Subscribe("orders", listener); - - await queue.PublishAsync("orders", new Order { Id = "A1", Amount = 42 }); - - var received = await listener.Received.Task.WaitAsync(Timeout); - Assert.Equal("A1", received.Id); - Assert.Equal(42, received.Amount); - } } diff --git a/tests/SquidStd.Tests/Messaging/MessageTopicTests.cs b/tests/SquidStd.Tests/Messaging/MessageTopicTests.cs index 582825df..d36efd5b 100644 --- a/tests/SquidStd.Tests/Messaging/MessageTopicTests.cs +++ b/tests/SquidStd.Tests/Messaging/MessageTopicTests.cs @@ -7,11 +7,6 @@ namespace SquidStd.Tests.Messaging; public class MessageTopicTests { - private sealed class Ping - { - public string Source { get; set; } = ""; - } - [Fact] public async Task PublishSubscribe_RoundTripsTypedMessage() { @@ -34,4 +29,9 @@ public async Task PublishSubscribe_RoundTripsTypedMessage() var got = await received.Task.WaitAsync(TimeSpan.FromSeconds(5)); Assert.Equal("w1", got.Source); } + + private sealed class Ping + { + public string Source { get; set; } = ""; + } } diff --git a/tests/SquidStd.Tests/Messaging/MessagingConnectionStringTests.cs b/tests/SquidStd.Tests/Messaging/MessagingConnectionStringTests.cs index 57be3c85..f82c8b0a 100644 --- a/tests/SquidStd.Tests/Messaging/MessagingConnectionStringTests.cs +++ b/tests/SquidStd.Tests/Messaging/MessagingConnectionStringTests.cs @@ -16,7 +16,9 @@ public void Parse_Memory_ReadsScheme() [Fact] public void Parse_NullOrWhitespace_Throws() - => Assert.Throws(() => MessagingConnectionString.Parse(" ")); + { + Assert.Throws(() => MessagingConnectionString.Parse(" ")); + } [Fact] public void Parse_RabbitMq_ReadsCredentialsHostPortVhost() diff --git a/tests/SquidStd.Tests/Messaging/MessagingMetricsProviderTests.cs b/tests/SquidStd.Tests/Messaging/MessagingMetricsProviderTests.cs index 4cf10819..ea163748 100644 --- a/tests/SquidStd.Tests/Messaging/MessagingMetricsProviderTests.cs +++ b/tests/SquidStd.Tests/Messaging/MessagingMetricsProviderTests.cs @@ -21,7 +21,9 @@ public async Task CollectAsync_ReportsCountersAndGaugesPerQueue() var samples = await metrics.CollectAsync(); double Value(string name) - => samples.Single(s => s.Name == name && s.Tags != null && s.Tags["queue"] == "orders").Value; + { + return samples.Single(s => s.Name == name && s.Tags != null && s.Tags["queue"] == "orders").Value; + } Assert.Equal(2, Value("published")); Assert.Equal(1, Value("delivered")); @@ -34,5 +36,7 @@ double Value(string name) [Fact] public void ProviderName_IsMessaging() - => Assert.Equal("messaging", new MessagingMetricsProvider().ProviderName); + { + Assert.Equal("messaging", new MessagingMetricsProvider().ProviderName); + } } diff --git a/tests/SquidStd.Tests/Messaging/RabbitMq/RabbitMqContainerFixture.cs b/tests/SquidStd.Tests/Messaging/RabbitMq/RabbitMqContainerFixture.cs index 3a1291f0..f30af310 100644 --- a/tests/SquidStd.Tests/Messaging/RabbitMq/RabbitMqContainerFixture.cs +++ b/tests/SquidStd.Tests/Messaging/RabbitMq/RabbitMqContainerFixture.cs @@ -3,7 +3,7 @@ namespace SquidStd.Tests.Messaging.RabbitMq; /// -/// Starts a RabbitMQ container once for the whole collection and exposes its AMQP URI. +/// Starts a RabbitMQ container once for the whole collection and exposes its AMQP URI. /// public sealed class RabbitMqContainerFixture : IAsyncLifetime { @@ -12,10 +12,14 @@ public sealed class RabbitMqContainerFixture : IAsyncLifetime public string AmqpUri => _container.GetConnectionString(); public Task DisposeAsync() - => _container.DisposeAsync().AsTask(); + { + return _container.DisposeAsync().AsTask(); + } public Task InitializeAsync() - => _container.StartAsync(); + { + return _container.StartAsync(); + } } [CollectionDefinition(Name)] diff --git a/tests/SquidStd.Tests/Messaging/RabbitMq/RabbitMqQueueProviderTests.cs b/tests/SquidStd.Tests/Messaging/RabbitMq/RabbitMqQueueProviderTests.cs index 663f83e1..ae74710c 100644 --- a/tests/SquidStd.Tests/Messaging/RabbitMq/RabbitMqQueueProviderTests.cs +++ b/tests/SquidStd.Tests/Messaging/RabbitMq/RabbitMqQueueProviderTests.cs @@ -1,5 +1,6 @@ using System.Text; using SquidStd.Messaging.Abstractions.Data.Config; +using SquidStd.Messaging.RabbitMq.Data.Config; using SquidStd.Messaging.RabbitMq.Services; namespace SquidStd.Tests.Messaging.RabbitMq; @@ -19,7 +20,7 @@ public RabbitMqQueueProviderTests(RabbitMqContainerFixture fixture) [Fact] public async Task AlwaysFailing_IsDeadLettered() { - await using var provider = NewProvider(new() { MaxDeliveryAttempts = 2 }); + await using var provider = NewProvider(new MessagingOptions { MaxDeliveryAttempts = 2 }); await provider.StartAsync(); var queue = Queue(); provider.Subscribe(queue, (_, _) => throw new InvalidOperationException("always")); @@ -105,14 +106,25 @@ public async Task TwoSubscribers_ShareTheLoad() } private static ReadOnlyMemory Bytes(string s) - => Encoding.UTF8.GetBytes(s); + { + return Encoding.UTF8.GetBytes(s); + } private RabbitMqQueueProvider NewProvider(MessagingOptions? options = null) - => new(new() { Uri = new(_fixture.AmqpUri) }, options ?? new MessagingOptions()); + { + return new RabbitMqQueueProvider( + new RabbitMqOptions { Uri = new Uri(_fixture.AmqpUri) }, + options ?? new MessagingOptions() + ); + } private static string Queue() - => "q-" + Guid.NewGuid().ToString("N"); + { + return "q-" + Guid.NewGuid().ToString("N"); + } private static string Text(ReadOnlyMemory b) - => Encoding.UTF8.GetString(b.Span); + { + return Encoding.UTF8.GetString(b.Span); + } } diff --git a/tests/SquidStd.Tests/Messaging/RabbitMq/RabbitMqTopicProviderTests.cs b/tests/SquidStd.Tests/Messaging/RabbitMq/RabbitMqTopicProviderTests.cs index 7cfbdffe..23691198 100644 --- a/tests/SquidStd.Tests/Messaging/RabbitMq/RabbitMqTopicProviderTests.cs +++ b/tests/SquidStd.Tests/Messaging/RabbitMq/RabbitMqTopicProviderTests.cs @@ -1,4 +1,5 @@ using System.Text; +using SquidStd.Messaging.RabbitMq.Data.Config; using SquidStd.Messaging.RabbitMq.Services; namespace SquidStd.Tests.Messaging.RabbitMq; @@ -75,11 +76,17 @@ public async Task Publish_FansOutToAllSubscribers() } private static ReadOnlyMemory Bytes(string s) - => Encoding.UTF8.GetBytes(s); + { + return Encoding.UTF8.GetBytes(s); + } private RabbitMqTopicProvider NewProvider() - => new(new() { Uri = new(_fixture.AmqpUri) }); + { + return new RabbitMqTopicProvider(new RabbitMqOptions { Uri = new Uri(_fixture.AmqpUri) }); + } private static string Topic() - => "topic-" + Guid.NewGuid().ToString("N"); + { + return "topic-" + Guid.NewGuid().ToString("N"); + } } diff --git a/tests/SquidStd.Tests/Messaging/Sqs/LocalStackContainerFixture.cs b/tests/SquidStd.Tests/Messaging/Sqs/LocalStackContainerFixture.cs index 87eafb5c..98b86d4e 100644 --- a/tests/SquidStd.Tests/Messaging/Sqs/LocalStackContainerFixture.cs +++ b/tests/SquidStd.Tests/Messaging/Sqs/LocalStackContainerFixture.cs @@ -4,8 +4,8 @@ namespace SquidStd.Tests.Messaging.Sqs; /// -/// Starts a LocalStack container (SQS + SNS) once for the whole collection and exposes an -/// pointing at its edge endpoint with dummy credentials. +/// Starts a LocalStack container (SQS + SNS) once for the whole collection and exposes an +/// pointing at its edge endpoint with dummy credentials. /// public sealed class LocalStackContainerFixture : IAsyncLifetime { @@ -21,10 +21,14 @@ public sealed class LocalStackContainerFixture : IAsyncLifetime }; public Task DisposeAsync() - => _container.DisposeAsync().AsTask(); + { + return _container.DisposeAsync().AsTask(); + } public Task InitializeAsync() - => _container.StartAsync(); + { + return _container.StartAsync(); + } } [CollectionDefinition(Name)] diff --git a/tests/SquidStd.Tests/Messaging/Sqs/SqsConnectionStringTests.cs b/tests/SquidStd.Tests/Messaging/Sqs/SqsConnectionStringTests.cs index 032978c8..00b83af0 100644 --- a/tests/SquidStd.Tests/Messaging/Sqs/SqsConnectionStringTests.cs +++ b/tests/SquidStd.Tests/Messaging/Sqs/SqsConnectionStringTests.cs @@ -36,7 +36,9 @@ public void Parse_WithoutCredentials_LeavesThemNull() [Fact] public void WrongScheme_Throws() - => Assert.Throws(() => SqsMessagingRegistrationExtensions.ParseOptions("rabbitmq://x")); + { + Assert.Throws(() => SqsMessagingRegistrationExtensions.ParseOptions("rabbitmq://x")); + } [Fact] public void AddSqsMessaging_RegistersBothProviders() diff --git a/tests/SquidStd.Tests/Messaging/Sqs/SqsNamesTests.cs b/tests/SquidStd.Tests/Messaging/Sqs/SqsNamesTests.cs index 31b555ab..47779db9 100644 --- a/tests/SquidStd.Tests/Messaging/Sqs/SqsNamesTests.cs +++ b/tests/SquidStd.Tests/Messaging/Sqs/SqsNamesTests.cs @@ -6,13 +6,19 @@ public class SqsNamesTests { [Fact] public void Sanitize_ReplacesDotsWithDashes() - => Assert.Equal("orders-dlq", SqsNames.Sanitize("orders.dlq")); + { + Assert.Equal("orders-dlq", SqsNames.Sanitize("orders.dlq")); + } [Fact] public void Sanitize_KeepsLettersDigitsDashUnderscore() - => Assert.Equal("a_b-9", SqsNames.Sanitize("a_b-9")); + { + Assert.Equal("a_b-9", SqsNames.Sanitize("a_b-9")); + } [Fact] public void Sanitize_ReplacesSlashesAndColons() - => Assert.Equal("a-b-c", SqsNames.Sanitize("a/b:c")); + { + Assert.Equal("a-b-c", SqsNames.Sanitize("a/b:c")); + } } diff --git a/tests/SquidStd.Tests/Messaging/Sqs/SqsQueueProviderTests.cs b/tests/SquidStd.Tests/Messaging/Sqs/SqsQueueProviderTests.cs index 5f4ffaa8..073a175f 100644 --- a/tests/SquidStd.Tests/Messaging/Sqs/SqsQueueProviderTests.cs +++ b/tests/SquidStd.Tests/Messaging/Sqs/SqsQueueProviderTests.cs @@ -49,8 +49,24 @@ public async Task TwoSubscribers_ShareTheLoad() var a = 0; var b = 0; using var done = new CountdownEvent(total); - provider.Subscribe(queue, (_, _) => { Interlocked.Increment(ref a); done.Signal(); return Task.CompletedTask; }); - provider.Subscribe(queue, (_, _) => { Interlocked.Increment(ref b); done.Signal(); return Task.CompletedTask; }); + provider.Subscribe( + queue, + (_, _) => + { + Interlocked.Increment(ref a); + done.Signal(); + return Task.CompletedTask; + } + ); + provider.Subscribe( + queue, + (_, _) => + { + Interlocked.Increment(ref b); + done.Signal(); + return Task.CompletedTask; + } + ); for (var i = 0; i < total; i++) { @@ -65,15 +81,22 @@ public async Task TwoSubscribers_ShareTheLoad() public async Task AlwaysFailing_IsDeadLettered() { await using var provider = NewProvider( - new() { MaxDeliveryAttempts = 1 }, - new() { Aws = _fixture.Aws, VisibilityTimeout = TimeSpan.FromSeconds(1), WaitTimeSeconds = 1 } + new MessagingOptions { MaxDeliveryAttempts = 1 }, + new SqsOptions { Aws = _fixture.Aws, VisibilityTimeout = TimeSpan.FromSeconds(1), WaitTimeSeconds = 1 } ); await provider.StartAsync(); var queue = Queue(); provider.Subscribe(queue, (_, _) => throw new InvalidOperationException("always")); var deadLettered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - provider.Subscribe(queue + "-dlq", (payload, _) => { deadLettered.TrySetResult(Text(payload)); return Task.CompletedTask; }); + provider.Subscribe( + queue + "-dlq", + (payload, _) => + { + deadLettered.TrySetResult(Text(payload)); + return Task.CompletedTask; + } + ); await provider.PublishAsync(queue, Bytes("poison")); @@ -81,17 +104,25 @@ public async Task AlwaysFailing_IsDeadLettered() } private SqsQueueProvider NewProvider(MessagingOptions? messaging = null, SqsOptions? sqs = null) - => new( + { + return new SqsQueueProvider( sqs ?? new SqsOptions { Aws = _fixture.Aws, WaitTimeSeconds = 1 }, messaging ?? new MessagingOptions() ); + } private static ReadOnlyMemory Bytes(string s) - => Encoding.UTF8.GetBytes(s); + { + return Encoding.UTF8.GetBytes(s); + } private static string Text(ReadOnlyMemory b) - => Encoding.UTF8.GetString(b.Span); + { + return Encoding.UTF8.GetString(b.Span); + } private static string Queue() - => "q-" + Guid.NewGuid().ToString("N"); + { + return "q-" + Guid.NewGuid().ToString("N"); + } } diff --git a/tests/SquidStd.Tests/Messaging/Sqs/SqsTopicProviderTests.cs b/tests/SquidStd.Tests/Messaging/Sqs/SqsTopicProviderTests.cs index a3cd5957..818df2ea 100644 --- a/tests/SquidStd.Tests/Messaging/Sqs/SqsTopicProviderTests.cs +++ b/tests/SquidStd.Tests/Messaging/Sqs/SqsTopicProviderTests.cs @@ -24,8 +24,22 @@ public async Task Publish_FansOutToAllSubscribers() var topic = Topic(); var a = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var b = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - provider.Subscribe(topic, (payload, _) => { a.TrySetResult(Text(payload)); return Task.CompletedTask; }); - provider.Subscribe(topic, (payload, _) => { b.TrySetResult(Text(payload)); return Task.CompletedTask; }); + provider.Subscribe( + topic, + (payload, _) => + { + a.TrySetResult(Text(payload)); + return Task.CompletedTask; + } + ); + provider.Subscribe( + topic, + (payload, _) => + { + b.TrySetResult(Text(payload)); + return Task.CompletedTask; + } + ); // Allow the subscriptions to be wired before publishing. await Task.Delay(2000); @@ -36,14 +50,22 @@ public async Task Publish_FansOutToAllSubscribers() } private SqsTopicProvider NewProvider() - => new(new SqsOptions { Aws = _fixture.Aws, WaitTimeSeconds = 1 }); + { + return new SqsTopicProvider(new SqsOptions { Aws = _fixture.Aws, WaitTimeSeconds = 1 }); + } private static ReadOnlyMemory Bytes(string s) - => Encoding.UTF8.GetBytes(s); + { + return Encoding.UTF8.GetBytes(s); + } private static string Text(ReadOnlyMemory b) - => Encoding.UTF8.GetString(b.Span); + { + return Encoding.UTF8.GetString(b.Span); + } private static string Topic() - => "topic-" + Guid.NewGuid().ToString("N"); + { + return "topic-" + Guid.NewGuid().ToString("N"); + } } diff --git a/tests/SquidStd.Tests/Messaging/TopicEventBridgeTests.cs b/tests/SquidStd.Tests/Messaging/TopicEventBridgeTests.cs index 89520262..a0122d1e 100644 --- a/tests/SquidStd.Tests/Messaging/TopicEventBridgeTests.cs +++ b/tests/SquidStd.Tests/Messaging/TopicEventBridgeTests.cs @@ -10,24 +10,6 @@ namespace SquidStd.Tests.Messaging; public class TopicEventBridgeTests { - private sealed class Beat - { - public string Worker { get; set; } = ""; - } - - private sealed class CapturingListener : IAsyncEventListener - { - public TaskCompletionSource Received { get; } = - new(TaskCreationOptions.RunContinuationsAsynchronously); - - public Task HandleAsync(TopicMessageEvent eventData, CancellationToken cancellationToken) - { - Received.TrySetResult(eventData); - - return Task.CompletedTask; - } - } - [Fact] public async Task Bridge_RepublishesTopicMessageOnEventBus() { @@ -38,7 +20,7 @@ public async Task Bridge_RepublishesTopicMessageOnEventBus() ITopicEventBridge bridge = new TopicEventBridge(topic, eventBus); var listener = new CapturingListener(); - eventBus.RegisterAsyncListener(listener); + eventBus.RegisterListener(listener); using var subscription = bridge.Bridge("workers.heartbeat"); await topic.PublishAsync("workers.heartbeat", new Beat { Worker = "w1" }); @@ -47,4 +29,22 @@ public async Task Bridge_RepublishesTopicMessageOnEventBus() Assert.Equal("workers.heartbeat", evt.Topic); Assert.Equal("w1", Assert.IsType(evt.Data).Worker); } + + private sealed class Beat + { + public string Worker { get; set; } = ""; + } + + private sealed class CapturingListener : IEventListener + { + public TaskCompletionSource Received { get; } = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public Task HandleAsync(TopicMessageEvent eventData, CancellationToken cancellationToken) + { + Received.TrySetResult(eventData); + + return Task.CompletedTask; + } + } } diff --git a/tests/SquidStd.Tests/Network/CircularBufferTests.cs b/tests/SquidStd.Tests/Network/CircularBufferTests.cs index 8b1215db..70306fe9 100644 --- a/tests/SquidStd.Tests/Network/CircularBufferTests.cs +++ b/tests/SquidStd.Tests/Network/CircularBufferTests.cs @@ -18,11 +18,15 @@ public void Clear_ResetsSizeButKeepsCapacity() [Fact] public void Constructor_NonPositiveCapacity_Throws() - => Assert.Throws(() => new CircularBuffer(0)); + { + Assert.Throws(() => new CircularBuffer(0)); + } [Fact] public void Constructor_TooManyItems_Throws() - => Assert.Throws(() => new CircularBuffer(2, [1, 2, 3])); + { + Assert.Throws(() => new CircularBuffer(2, [1, 2, 3])); + } [Fact] public void Constructor_WithItems_PopulatesBuffer() diff --git a/tests/SquidStd.Tests/Network/ConnectionPipelineTests.cs b/tests/SquidStd.Tests/Network/ConnectionPipelineTests.cs new file mode 100644 index 00000000..a9e194a4 --- /dev/null +++ b/tests/SquidStd.Tests/Network/ConnectionPipelineTests.cs @@ -0,0 +1,27 @@ +using SquidStd.Network.Data; +using SquidStd.Tests.Support; + +namespace SquidStd.Tests.Network; + +public class ConnectionPipelineTests +{ + [Fact] + public void Defaults_AreNull() + { + var pipeline = new ConnectionPipeline(); + + Assert.Null(pipeline.Codec); + Assert.Null(pipeline.Middlewares); + Assert.Null(pipeline.Framer); + } + + [Fact] + public void Positional_SetsCodec() + { + var codec = new CountingXorCodec(1); + + var pipeline = new ConnectionPipeline(codec); + + Assert.Same(codec, pipeline.Codec); + } +} diff --git a/tests/SquidStd.Tests/Network/NetMiddlewarePipelineTests.cs b/tests/SquidStd.Tests/Network/NetMiddlewarePipelineTests.cs index c205684e..2598633e 100644 --- a/tests/SquidStd.Tests/Network/NetMiddlewarePipelineTests.cs +++ b/tests/SquidStd.Tests/Network/NetMiddlewarePipelineTests.cs @@ -30,8 +30,8 @@ public async Task ExecuteAsync_CancelledToken_Throws() using var cts = new CancellationTokenSource(); await cts.CancelAsync(); - await Assert.ThrowsAsync( - async () => await pipeline.ExecuteAsync(null, new byte[] { 1 }, cts.Token) + await Assert.ThrowsAsync(async () => + await pipeline.ExecuteAsync(null, new byte[] { 1 }, cts.Token) ); } diff --git a/tests/SquidStd.Tests/Network/PacketExtensionsTests.cs b/tests/SquidStd.Tests/Network/PacketExtensionsTests.cs index 5f680b54..96099258 100644 --- a/tests/SquidStd.Tests/Network/PacketExtensionsTests.cs +++ b/tests/SquidStd.Tests/Network/PacketExtensionsTests.cs @@ -4,7 +4,13 @@ namespace SquidStd.Tests.Network; public class PacketExtensionsTests { - [Theory, InlineData(0x00, "0x00"), InlineData(0x0F, "0x0F"), InlineData(0xAB, "0xAB"), InlineData(0xFF, "0xFF")] + [Theory] + [InlineData(0x00, "0x00")] + [InlineData(0x0F, "0x0F")] + [InlineData(0xAB, "0xAB")] + [InlineData(0xFF, "0xFF")] public void ToPacketString_FormatsAsTwoDigitHex(byte opCode, string expected) - => Assert.Equal(expected, opCode.ToPacketString()); + { + Assert.Equal(expected, opCode.ToPacketString()); + } } diff --git a/tests/SquidStd.Tests/Network/SessionManagerTests.cs b/tests/SquidStd.Tests/Network/SessionManagerTests.cs index 7032ef30..54084230 100644 --- a/tests/SquidStd.Tests/Network/SessionManagerTests.cs +++ b/tests/SquidStd.Tests/Network/SessionManagerTests.cs @@ -139,7 +139,7 @@ public async Task Integration_LifecycleOverLoopback() { var timeout = TimeSpan.FromSeconds(5); - await using var server = new SquidTcpServer(new(IPAddress.Loopback, 0)); + await using var server = new SquidTcpServer(new IPEndPoint(IPAddress.Loopback, 0)); using var manager = NewManager(server); var created = new TaskCompletionSource>(TaskCreationOptions.RunContinuationsAsynchronously); @@ -152,7 +152,7 @@ public async Task Integration_LifecycleOverLoopback() await server.StartAsync(CancellationToken.None); var port = server.Port; - var client = await SquidStdTcpClient.ConnectAsync(new(IPAddress.Loopback, port)); + var client = await SquidStdTcpClient.ConnectAsync(new IPEndPoint(IPAddress.Loopback, port)); var session = await created.Task.WaitAsync(timeout); Assert.Equal(1, manager.Count); @@ -216,8 +216,12 @@ public void TryGetSession_HitAndMiss() } private static SessionManager NewManager(SquidTcpServer server) - => new(server, connection => $"state-{connection.SessionId}"); + { + return new SessionManager(server, connection => $"state-{connection.SessionId}"); + } private static SquidTcpServer NewServer() - => new(new(IPAddress.Loopback, 0)); + { + return new SquidTcpServer(new IPEndPoint(IPAddress.Loopback, 0)); + } } diff --git a/tests/SquidStd.Tests/Network/SquidStdUdpClientTests.cs b/tests/SquidStd.Tests/Network/SquidStdUdpClientTests.cs index e58b6dbf..f80106d8 100644 --- a/tests/SquidStd.Tests/Network/SquidStdUdpClientTests.cs +++ b/tests/SquidStd.Tests/Network/SquidStdUdpClientTests.cs @@ -10,7 +10,7 @@ public class SquidStdUdpClientTests [Fact] public async Task CloseAsync_RaisesDisconnectedOnceAndMarksDisconnected() { - var client = new SquidStdUdpClient(new(IPAddress.Loopback, 0)); + var client = new SquidStdUdpClient(new IPEndPoint(IPAddress.Loopback, 0)); var disconnects = 0; client.OnDisconnected += (_, _) => Interlocked.Increment(ref disconnects); await client.StartAsync(CancellationToken.None); @@ -25,8 +25,8 @@ public async Task CloseAsync_RaisesDisconnectedOnceAndMarksDisconnected() [Fact] public void Constructor_AssignsUniqueSessionIds() { - using var first = new SquidStdUdpClient(new(IPAddress.Loopback, 0)); - using var second = new SquidStdUdpClient(new(IPAddress.Loopback, 0)); + using var first = new SquidStdUdpClient(new IPEndPoint(IPAddress.Loopback, 0)); + using var second = new SquidStdUdpClient(new IPEndPoint(IPAddress.Loopback, 0)); Assert.NotEqual(first.SessionId, second.SessionId); } @@ -34,7 +34,7 @@ public void Constructor_AssignsUniqueSessionIds() [Fact] public void Constructor_BindsLocalEndPoint() { - using var client = new SquidStdUdpClient(new(IPAddress.Loopback, 0)); + using var client = new SquidStdUdpClient(new IPEndPoint(IPAddress.Loopback, 0)); var local = Assert.IsType(client.LocalEndPoint); Assert.NotEqual(0, local.Port); @@ -46,7 +46,7 @@ public void RemoteEndPoint_ReflectsConfiguredDefault() { var remote = new IPEndPoint(IPAddress.Loopback, 9999); - using var client = new SquidStdUdpClient(new(IPAddress.Loopback, 0), remote); + using var client = new SquidStdUdpClient(new IPEndPoint(IPAddress.Loopback, 0), remote); Assert.Equal(remote, client.RemoteEndPoint); } @@ -54,7 +54,7 @@ public void RemoteEndPoint_ReflectsConfiguredDefault() [Fact] public async Task SendAsync_UsesConfiguredDefaultRemote() { - await using var receiver = new SquidStdUdpClient(new(IPAddress.Loopback, 0)); + await using var receiver = new SquidStdUdpClient(new IPEndPoint(IPAddress.Loopback, 0)); var received = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); receiver.OnDataReceived += (_, e) => received.TrySetResult(e.Data.ToArray()); await receiver.StartAsync(CancellationToken.None); @@ -62,8 +62,8 @@ public async Task SendAsync_UsesConfiguredDefaultRemote() var receiverPort = ((IPEndPoint)receiver.LocalEndPoint!).Port; await using var sender = new SquidStdUdpClient( - new(IPAddress.Loopback, 0), - new(IPAddress.Loopback, receiverPort) + new IPEndPoint(IPAddress.Loopback, 0), + new IPEndPoint(IPAddress.Loopback, receiverPort) ); await sender.StartAsync(CancellationToken.None); await sender.SendAsync(new byte[] { 9, 8, 7 }, CancellationToken.None); @@ -75,28 +75,28 @@ public async Task SendAsync_UsesConfiguredDefaultRemote() [Fact] public async Task SendAsync_WithoutDefaultRemote_Throws() { - await using var client = new SquidStdUdpClient(new(IPAddress.Loopback, 0)); + await using var client = new SquidStdUdpClient(new IPEndPoint(IPAddress.Loopback, 0)); - await Assert.ThrowsAsync( - async () => await client.SendAsync(new byte[] { 1 }, CancellationToken.None) + await Assert.ThrowsAsync(async () => + await client.SendAsync(new byte[] { 1 }, CancellationToken.None) ); } [Fact] public async Task SendToAsync_DeliversDatagramToPeer() { - await using var receiver = new SquidStdUdpClient(new(IPAddress.Loopback, 0)); + await using var receiver = new SquidStdUdpClient(new IPEndPoint(IPAddress.Loopback, 0)); var received = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); receiver.OnDataReceived += (_, e) => received.TrySetResult(e.Data.ToArray()); await receiver.StartAsync(CancellationToken.None); var receiverPort = ((IPEndPoint)receiver.LocalEndPoint!).Port; - await using var sender = new SquidStdUdpClient(new(IPAddress.Loopback, 0)); + await using var sender = new SquidStdUdpClient(new IPEndPoint(IPAddress.Loopback, 0)); await sender.StartAsync(CancellationToken.None); await sender.SendToAsync( new byte[] { 1, 2, 3, 4 }, - new(IPAddress.Loopback, receiverPort), + new IPEndPoint(IPAddress.Loopback, receiverPort), CancellationToken.None ); @@ -107,7 +107,7 @@ await sender.SendToAsync( [Fact] public async Task StartAsync_RaisesConnected() { - await using var client = new SquidStdUdpClient(new(IPAddress.Loopback, 0)); + await using var client = new SquidStdUdpClient(new IPEndPoint(IPAddress.Loopback, 0)); var connected = false; client.OnConnected += (_, _) => connected = true; diff --git a/tests/SquidStd.Tests/Network/SquidStdUdpServerTests.cs b/tests/SquidStd.Tests/Network/SquidStdUdpServerTests.cs index 12a7eaa2..2f9d4d5d 100644 --- a/tests/SquidStd.Tests/Network/SquidStdUdpServerTests.cs +++ b/tests/SquidStd.Tests/Network/SquidStdUdpServerTests.cs @@ -12,7 +12,7 @@ public class SquidStdUdpServerTests [Fact] public async Task BindSingleInterface_HasOneListener() { - await using var server = new SquidStdUdpServer(new(IPAddress.Loopback, 0), false); + await using var server = new SquidStdUdpServer(new IPEndPoint(IPAddress.Loopback, 0), false); await server.StartAsync(CancellationToken.None); Assert.Equal(1, server.ListenerCount); @@ -21,17 +21,17 @@ public async Task BindSingleInterface_HasOneListener() [Fact] public async Task DefaultBehaviour_EchoesDatagramBackToSender() { - await using var server = new SquidStdUdpServer(new(IPAddress.Loopback, 0), false); + await using var server = new SquidStdUdpServer(new IPEndPoint(IPAddress.Loopback, 0), false); await server.StartAsync(CancellationToken.None); var serverPort = server.Port; - await using var client = new SquidStdUdpClient(new(IPAddress.Loopback, 0)); + await using var client = new SquidStdUdpClient(new IPEndPoint(IPAddress.Loopback, 0)); var received = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); client.OnDataReceived += (_, e) => received.TrySetResult(e.Data.ToArray()); await client.StartAsync(CancellationToken.None); var payload = new byte[] { 1, 2, 3, 4, 5 }; - await client.SendToAsync(payload, new(IPAddress.Loopback, serverPort), CancellationToken.None); + await client.SendToAsync(payload, new IPEndPoint(IPAddress.Loopback, serverPort), CancellationToken.None); Assert.Equal(payload, await received.Task.WaitAsync(Timeout)); } @@ -39,19 +39,19 @@ public async Task DefaultBehaviour_EchoesDatagramBackToSender() [Fact] public async Task OnDatagram_CustomResponse_IsReturnedToSender() { - await using var server = new SquidStdUdpServer(new(IPAddress.Loopback, 0), false) + await using var server = new SquidStdUdpServer(new IPEndPoint(IPAddress.Loopback, 0), false) { OnDatagram = (_, _) => new byte[] { 0xFF, 0xFE } }; await server.StartAsync(CancellationToken.None); var serverPort = server.Port; - await using var client = new SquidStdUdpClient(new(IPAddress.Loopback, 0)); + await using var client = new SquidStdUdpClient(new IPEndPoint(IPAddress.Loopback, 0)); var received = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); client.OnDataReceived += (_, e) => received.TrySetResult(e.Data.ToArray()); await client.StartAsync(CancellationToken.None); - await client.SendToAsync(new byte[] { 1 }, new(IPAddress.Loopback, serverPort), CancellationToken.None); + await client.SendToAsync(new byte[] { 1 }, new IPEndPoint(IPAddress.Loopback, serverPort), CancellationToken.None); Assert.Equal([0xFF, 0xFE], await received.Task.WaitAsync(Timeout)); } @@ -59,15 +59,19 @@ public async Task OnDatagram_CustomResponse_IsReturnedToSender() [Fact] public async Task OnDatagramReceived_RaisedForIncomingDatagram() { - await using var server = new SquidStdUdpServer(new(IPAddress.Loopback, 0), false); + await using var server = new SquidStdUdpServer(new IPEndPoint(IPAddress.Loopback, 0), false); var received = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); server.OnDatagramReceived += (_, e) => received.TrySetResult(e.Data.ToArray()); await server.StartAsync(CancellationToken.None); var serverPort = server.Port; - await using var client = new SquidStdUdpClient(new(IPAddress.Loopback, 0)); + await using var client = new SquidStdUdpClient(new IPEndPoint(IPAddress.Loopback, 0)); await client.StartAsync(CancellationToken.None); - await client.SendToAsync(new byte[] { 1, 2, 3 }, new(IPAddress.Loopback, serverPort), CancellationToken.None); + await client.SendToAsync( + new byte[] { 1, 2, 3 }, + new IPEndPoint(IPAddress.Loopback, serverPort), + CancellationToken.None + ); Assert.Equal([1, 2, 3], await received.Task.WaitAsync(Timeout)); } @@ -75,7 +79,7 @@ public async Task OnDatagramReceived_RaisedForIncomingDatagram() [Fact] public async Task SendToAsync_DeliversToEndpointThatWasSeen() { - await using var server = new SquidStdUdpServer(new(IPAddress.Loopback, 0), false) + await using var server = new SquidStdUdpServer(new IPEndPoint(IPAddress.Loopback, 0), false) { OnDatagram = static (_, _) => ReadOnlyMemory.Empty // suppress default echo for this test }; @@ -84,13 +88,13 @@ public async Task SendToAsync_DeliversToEndpointThatWasSeen() await server.StartAsync(CancellationToken.None); var serverPort = server.Port; - await using var client = new SquidStdUdpClient(new(IPAddress.Loopback, 0)); + await using var client = new SquidStdUdpClient(new IPEndPoint(IPAddress.Loopback, 0)); var clientReceived = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); client.OnDataReceived += (_, e) => clientReceived.TrySetResult(e.Data.ToArray()); await client.StartAsync(CancellationToken.None); // Make the server "see" the client endpoint first. - await client.SendToAsync(new byte[] { 0 }, new(IPAddress.Loopback, serverPort), CancellationToken.None); + await client.SendToAsync(new byte[] { 0 }, new IPEndPoint(IPAddress.Loopback, serverPort), CancellationToken.None); var clientEndpoint = await senderEndpoint.Task.WaitAsync(Timeout); await server.SendToAsync(clientEndpoint, new byte[] { 9, 9 }, CancellationToken.None); @@ -101,7 +105,7 @@ public async Task SendToAsync_DeliversToEndpointThatWasSeen() [Fact] public void ServerType_IsUdp() { - using var server = new SquidStdUdpServer(new(IPAddress.Loopback, 0), false); + using var server = new SquidStdUdpServer(new IPEndPoint(IPAddress.Loopback, 0), false); Assert.Equal(ServerType.UDP, server.ServerType); } @@ -109,7 +113,7 @@ public void ServerType_IsUdp() [Fact] public async Task StartStop_TogglesIsRunning() { - await using var server = new SquidStdUdpServer(new(IPAddress.Loopback, 0), false); + await using var server = new SquidStdUdpServer(new IPEndPoint(IPAddress.Loopback, 0), false); Assert.False(server.IsRunning); await server.StartAsync(CancellationToken.None); diff --git a/tests/SquidStd.Tests/Network/TransportCodecFakeTests.cs b/tests/SquidStd.Tests/Network/TransportCodecFakeTests.cs new file mode 100644 index 00000000..c361ee80 --- /dev/null +++ b/tests/SquidStd.Tests/Network/TransportCodecFakeTests.cs @@ -0,0 +1,33 @@ +using SquidStd.Tests.Support; + +namespace SquidStd.Tests.Network; + +public class TransportCodecFakeTests +{ + [Fact] + public void EncodeThenDecode_WithMatchingCodecs_RestoresOriginal() + { + var original = new byte[] { 10, 20, 30, 40, 50 }; + var buffer = (byte[])original.Clone(); + + new CountingXorCodec(7).Encode(buffer); + Assert.NotEqual(original, buffer); + + new CountingXorCodec(7).Decode(buffer); + Assert.Equal(original, buffer); + } + + [Fact] + public void Decode_AdvancesIndependentlyFromEncode() + { + var codec = new CountingXorCodec(); + var encodeProbe = new byte[] { 0, 0, 0 }; + var decodeProbe = new byte[] { 0, 0, 0 }; + + codec.Encode(encodeProbe); + codec.Decode(decodeProbe); + + // Both directions started at position 0, so they produce the same keystream. + Assert.Equal(encodeProbe, decodeProbe); + } +} diff --git a/tests/SquidStd.Tests/Network/TransportCodecTests.cs b/tests/SquidStd.Tests/Network/TransportCodecTests.cs new file mode 100644 index 00000000..fe812271 --- /dev/null +++ b/tests/SquidStd.Tests/Network/TransportCodecTests.cs @@ -0,0 +1,213 @@ +using System.Collections.Concurrent; +using System.Net; +using SquidStd.Network.Client; +using SquidStd.Network.Data; +using SquidStd.Network.Server; +using SquidStd.Tests.Support; + +namespace SquidStd.Tests.Network; + +public class TransportCodecTests +{ + private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5); + + [Fact] + public async Task Codec_RoundTripsClientToServer() + { + var received = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + await using var server = new SquidTcpServer( + new IPEndPoint(IPAddress.Loopback, 0), + connectionPipelineFactory: () => new ConnectionPipeline(new CountingXorCodec(7)) + ); + server.OnDataReceived += (_, e) => received.TrySetResult(e.Data.ToArray()); + await server.StartAsync(CancellationToken.None); + + await using var client = await SquidStdTcpClient.ConnectAsync( + new IPEndPoint(IPAddress.Loopback, server.Port), + codec: new CountingXorCodec(7) + ); + + var payload = new byte[] { 1, 2, 3, 4, 5 }; + await client.SendAsync(payload, CancellationToken.None); + + Assert.Equal(payload, await received.Task.WaitAsync(Timeout)); + } + + [Fact] + public async Task NoCodecNoFactory_PassesBytesUnchanged() + { + var received = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + await using var server = new SquidTcpServer(new IPEndPoint(IPAddress.Loopback, 0)); + server.OnDataReceived += (_, e) => received.TrySetResult(e.Data.ToArray()); + await server.StartAsync(CancellationToken.None); + + await using var client = await SquidStdTcpClient.ConnectAsync(new IPEndPoint(IPAddress.Loopback, server.Port)); + + var payload = new byte[] { 42, 43, 44 }; + await client.SendAsync(payload, CancellationToken.None); + + Assert.Equal(payload, await received.Task.WaitAsync(Timeout)); + } + + [Fact] + public async Task Codec_ConcurrentSends_PreserveKeystreamIntegrity() + { + const int messageCount = 40; + const int payloadSize = 16; + var concurrentTimeout = TimeSpan.FromSeconds(10); + + var frames = new ConcurrentBag(); + using var done = new CountdownEvent(messageCount); + + await using var server = new SquidTcpServer( + new IPEndPoint(IPAddress.Loopback, 0), + connectionPipelineFactory: () => new ConnectionPipeline(new CountingXorCodec(5), null, new LengthPrefixFramer()) + ); + server.OnDataReceived += (_, e) => + { + frames.Add(e.Data.ToArray()); + done.Signal(); + }; + await server.StartAsync(CancellationToken.None); + + await using var client = await SquidStdTcpClient.ConnectAsync( + new IPEndPoint(IPAddress.Loopback, server.Port), + codec: new CountingXorCodec(5) + ); + + await Parallel.ForEachAsync( + Enumerable.Range(0, messageCount), + async (id, ct) => + { + var frame = new byte[1 + payloadSize]; + frame[0] = payloadSize; + + for (var i = 1; i < frame.Length; i++) + { + frame[i] = (byte)id; + } + + await client.SendAsync(frame, ct); + } + ); + + Assert.True(done.Wait(concurrentTimeout)); + Assert.Equal(messageCount, frames.Count); + + var ids = new HashSet(); + + foreach (var frame in frames) + { + Assert.Equal(1 + payloadSize, frame.Length); + Assert.Equal(payloadSize, frame[0]); + var id = frame[1]; + + for (var i = 1; i < frame.Length; i++) + { + Assert.Equal(id, frame[i]); + } + + ids.Add(id); + } + + Assert.Equal(messageCount, ids.Count); + } + + [Fact] + public async Task Codec_WithFramer_EmitsDecodedFrames() + { + var frames = new BlockingCollection(); + + await using var server = new SquidTcpServer( + new IPEndPoint(IPAddress.Loopback, 0), + connectionPipelineFactory: () => new ConnectionPipeline(new CountingXorCodec(2), null, new LengthPrefixFramer()) + ); + server.OnDataReceived += (_, e) => frames.Add(e.Data.ToArray()); + await server.StartAsync(CancellationToken.None); + + await using var client = await SquidStdTcpClient.ConnectAsync( + new IPEndPoint(IPAddress.Loopback, server.Port), + codec: new CountingXorCodec(2) + ); + + // Three length-prefixed messages in a single send: [len=2][AA BB] [len=1][CC] [len=3][01 02 03]. + var buffer = new byte[] { 2, 0xAA, 0xBB, 1, 0xCC, 3, 0x01, 0x02, 0x03 }; + await client.SendAsync(buffer, CancellationToken.None); + + Assert.True(frames.TryTake(out var f1, Timeout)); + Assert.Equal(new byte[] { 2, 0xAA, 0xBB }, f1); + Assert.True(frames.TryTake(out var f2, Timeout)); + Assert.Equal(new byte[] { 1, 0xCC }, f2); + Assert.True(frames.TryTake(out var f3, Timeout)); + Assert.Equal(new byte[] { 3, 0x01, 0x02, 0x03 }, f3); + } + + [Fact] + public async Task Codec_IsolatesStatePerConnection() + { + var payload = new byte[] { 9, 8, 7, 6 }; + var inbox = new BlockingCollection(); + + await using var server = new SquidTcpServer( + new IPEndPoint(IPAddress.Loopback, 0), + connectionPipelineFactory: () => new ConnectionPipeline(new CountingXorCodec(3)) + ); + server.OnDataReceived += (_, e) => inbox.Add(e.Data.ToArray()); + await server.StartAsync(CancellationToken.None); + + // Two sequential connections. Each accepted connection must get a fresh codec (position 0); + // a shared codec would leave the second connection's decode position offset and corrupt the bytes. + for (var i = 0; i < 2; i++) + { + await using var client = await SquidStdTcpClient.ConnectAsync( + new IPEndPoint(IPAddress.Loopback, server.Port), + codec: new CountingXorCodec(3) + ); + await client.SendAsync(payload, CancellationToken.None); + + Assert.True(inbox.TryTake(out var got, Timeout)); + Assert.Equal(payload, got); + } + } + + [Fact] + public async Task Codec_SwapsAtomicallyMidConnection() + { + var msg1 = new byte[] { 1, 1, 1 }; + var msg2 = new byte[] { 2, 2, 2 }; + var first = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var second = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + await using var server = new SquidTcpServer( + new IPEndPoint(IPAddress.Loopback, 0), + connectionPipelineFactory: () => new ConnectionPipeline(new CountingXorCodec(10)) + ); + server.OnDataReceived += (_, e) => + { + if (first.Task.IsCompleted) + { + second.TrySetResult(e.Data.ToArray()); + } + else + { + e.Client.SwapCodec(new CountingXorCodec(20)); + first.TrySetResult(e.Data.ToArray()); + } + }; + await server.StartAsync(CancellationToken.None); + + await using var client = await SquidStdTcpClient.ConnectAsync( + new IPEndPoint(IPAddress.Loopback, server.Port), + codec: new CountingXorCodec(10) + ); + + await client.SendAsync(msg1, CancellationToken.None); + Assert.Equal(msg1, await first.Task.WaitAsync(Timeout)); + + client.SwapCodec(new CountingXorCodec(20)); + await client.SendAsync(msg2, CancellationToken.None); + Assert.Equal(msg2, await second.Task.WaitAsync(Timeout)); + } +} diff --git a/tests/SquidStd.Tests/Network/UdpSessionManagerTests.cs b/tests/SquidStd.Tests/Network/UdpSessionManagerTests.cs index 9ed1dfa2..5980026e 100644 --- a/tests/SquidStd.Tests/Network/UdpSessionManagerTests.cs +++ b/tests/SquidStd.Tests/Network/UdpSessionManagerTests.cs @@ -82,7 +82,7 @@ public async Task Integration_DatagramCreatesSessionAndManagerCanReply() { var timeout = TimeSpan.FromSeconds(5); - await using var server = new SquidStdUdpServer(new(IPAddress.Loopback, 0), false); + await using var server = new SquidStdUdpServer(new IPEndPoint(IPAddress.Loopback, 0), false); using var manager = new UdpSessionManager(server, c => $"state-{c.SessionId}"); var created = new TaskCompletionSource>(TaskCreationOptions.RunContinuationsAsynchronously); @@ -93,12 +93,16 @@ public async Task Integration_DatagramCreatesSessionAndManagerCanReply() await server.StartAsync(CancellationToken.None); var serverPort = server.Port; - await using var client = new SquidStdUdpClient(new(IPAddress.Loopback, 0)); + await using var client = new SquidStdUdpClient(new IPEndPoint(IPAddress.Loopback, 0)); var clientReceived = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); client.OnDataReceived += (_, e) => clientReceived.TrySetResult(e.Data.ToArray()); await client.StartAsync(CancellationToken.None); - await client.SendToAsync(new byte[] { 1, 2, 3 }, new(IPAddress.Loopback, serverPort), CancellationToken.None); + await client.SendToAsync( + new byte[] { 1, 2, 3 }, + new IPEndPoint(IPAddress.Loopback, serverPort), + CancellationToken.None + ); var session = await created.Task.WaitAsync(timeout); Assert.Equal([1, 2, 3], await data.Task.WaitAsync(timeout)); @@ -164,17 +168,23 @@ public void SweepExpiredSessions_RemovesIdleSessions() } private static UdpSessionManager NewManager(SquidStdUdpServer server, FakeTimeProvider time) - => new( + { + return new UdpSessionManager( server, connection => $"state-{connection.SessionId}", TimeSpan.FromSeconds(30), TimeSpan.FromSeconds(10), time ); + } private static SquidStdUdpServer NewServer() - => new(new(IPAddress.Loopback, 0), false); + { + return new SquidStdUdpServer(new IPEndPoint(IPAddress.Loopback, 0), false); + } private static IPEndPoint Peer(int port) - => new(IPAddress.Loopback, port); + { + return new IPEndPoint(IPAddress.Loopback, port); + } } diff --git a/tests/SquidStd.Tests/Persistence/BinaryJournalServiceTests.cs b/tests/SquidStd.Tests/Persistence/BinaryJournalServiceTests.cs new file mode 100644 index 00000000..a037391f --- /dev/null +++ b/tests/SquidStd.Tests/Persistence/BinaryJournalServiceTests.cs @@ -0,0 +1,108 @@ +using SquidStd.Persistence.Abstractions.Data; +using SquidStd.Persistence.Abstractions.Types; +using SquidStd.Persistence.Services; + +namespace SquidStd.Tests.Persistence; + +public sealed class BinaryJournalServiceTests : IDisposable +{ + private readonly string _dir = Path.Combine(Path.GetTempPath(), "squidstd-journal-" + Guid.NewGuid().ToString("N")); + + private string JournalPath => Path.Combine(_dir, "world.journal.bin"); + + private static JournalEntry Entry(long seq, JournalEntityOperationType op = JournalEntityOperationType.Upsert) + => new() + { + SequenceId = seq, + TimestampUnixMilliseconds = seq * 1000, + TypeId = 1, + Operation = op, + Payload = [(byte)seq] + }; + + [Fact] + public async Task AppendThenReadAll_RoundTrips() + { + await using var journal = new BinaryJournalService(JournalPath); + await journal.AppendAsync(Entry(1)); + await journal.AppendAsync(Entry(2)); + + var entries = (await journal.ReadAllAsync()).ToArray(); + + Assert.Equal([1, 2], entries.Select(e => e.SequenceId)); + } + + [Fact] + public async Task AppendBatch_WritesAll() + { + await using var journal = new BinaryJournalService(JournalPath); + await journal.AppendBatchAsync([Entry(1), Entry(2), Entry(3)]); + + Assert.Equal(3, (await journal.ReadAllAsync()).Count); + } + + [Fact] + public async Task ReadAll_DiscardsTruncatedTail() + { + await using (var journal = new BinaryJournalService(JournalPath)) + { + await journal.AppendAsync(Entry(1)); + await journal.AppendAsync(Entry(2)); + } + + var bytes = await File.ReadAllBytesAsync(JournalPath); + await File.WriteAllBytesAsync(JournalPath, bytes[..^1]); // chop one byte off the last record + + await using var reopened = new BinaryJournalService(JournalPath); + var entries = (await reopened.ReadAllAsync()).ToArray(); + + Assert.Equal([1], entries.Select(e => e.SequenceId)); + } + + [Fact] + public async Task ReadAll_DiscardsTailOnChecksumMismatch() + { + await using (var journal = new BinaryJournalService(JournalPath)) + { + await journal.AppendAsync(Entry(1)); + } + + var bytes = await File.ReadAllBytesAsync(JournalPath); + bytes[^1] ^= 0xFF; // corrupt last payload byte + await File.WriteAllBytesAsync(JournalPath, bytes); + + await using var reopened = new BinaryJournalService(JournalPath); + + Assert.Empty(await reopened.ReadAllAsync()); + } + + [Fact] + public async Task TrimThroughSequence_KeepsNewerOnly() + { + await using var journal = new BinaryJournalService(JournalPath); + await journal.AppendBatchAsync([Entry(1), Entry(2), Entry(3)]); + + await journal.TrimThroughSequenceAsync(2); + + Assert.Equal([3], (await journal.ReadAllAsync()).Select(e => e.SequenceId)); + } + + [Fact] + public async Task Reset_ClearsJournal() + { + await using var journal = new BinaryJournalService(JournalPath); + await journal.AppendAsync(Entry(1)); + + await journal.ResetAsync(); + + Assert.Empty(await journal.ReadAllAsync()); + } + + public void Dispose() + { + if (Directory.Exists(_dir)) + { + Directory.Delete(_dir, true); + } + } +} diff --git a/tests/SquidStd.Tests/Persistence/EntityStoreTests.cs b/tests/SquidStd.Tests/Persistence/EntityStoreTests.cs new file mode 100644 index 00000000..ccf90b82 --- /dev/null +++ b/tests/SquidStd.Tests/Persistence/EntityStoreTests.cs @@ -0,0 +1,110 @@ +using SquidStd.Core.Json; +using SquidStd.Persistence.Abstractions.Interfaces.Persistence; +using SquidStd.Persistence.Data; +using SquidStd.Persistence.Internal; +using SquidStd.Persistence.Services; + +namespace SquidStd.Tests.Persistence; + +public sealed class EntityStoreTests : IAsyncDisposable +{ + private sealed class Player + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public List Tags { get; set; } = []; + } + + private readonly string _dir = Path.Combine(Path.GetTempPath(), "squidstd-store-" + Guid.NewGuid().ToString("N")); + private readonly BinaryJournalService _journal; + private readonly PersistenceStateStore _stateStore = new(); + private readonly EntityStore _store; + + public EntityStoreTests() + { + var serializer = new JsonDataSerializer(); + IPersistenceEntityDescriptor descriptor = + new PersistenceEntityDescriptor(serializer, serializer, 1, "Player", 1, p => p.Id); + _journal = new BinaryJournalService(Path.Combine(_dir, "world.journal.bin")); + _store = new EntityStore(_stateStore, _journal, descriptor); + } + + [Fact] + public async Task Upsert_ThenGetById_ReturnsClone() + { + await _store.UpsertAsync(new Player { Id = 1, Name = "Bob" }); + + var fetched = await _store.GetByIdAsync(1); + + Assert.NotNull(fetched); + Assert.Equal("Bob", fetched.Name); + } + + [Fact] + public async Task GetById_ReturnsDetachedClone() + { + await _store.UpsertAsync(new Player { Id = 1, Tags = ["a"] }); + + var first = await _store.GetByIdAsync(1); + first!.Tags.Add("mutated"); + var second = await _store.GetByIdAsync(1); + + Assert.Equal(["a"], second!.Tags); + } + + [Fact] + public async Task Upsert_AppendsToJournal() + { + await _store.UpsertAsync(new Player { Id = 1 }); + + Assert.Single(await _journal.ReadAllAsync()); + } + + [Fact] + public async Task Remove_ExistingKey_ReturnsTrueAndJournals() + { + await _store.UpsertAsync(new Player { Id = 1 }); + + Assert.True(await _store.RemoveAsync(1)); + Assert.Null(await _store.GetByIdAsync(1)); + Assert.Equal(2, (await _journal.ReadAllAsync()).Count); + } + + [Fact] + public async Task Remove_MissingKey_ReturnsFalseAndDoesNotJournal() + { + Assert.False(await _store.RemoveAsync(99)); + Assert.Empty(await _journal.ReadAllAsync()); + } + + [Fact] + public async Task CountAndGetAll_ReflectState() + { + await _store.UpsertAsync(new Player { Id = 1 }); + await _store.UpsertAsync(new Player { Id = 2 }); + + Assert.Equal(2, await _store.CountAsync()); + Assert.Equal(2, (await _store.GetAllAsync()).Count); + } + + [Fact] + public async Task Query_ReturnsQueryableClones() + { + await _store.UpsertAsync(new Player { Id = 1, Name = "Alice" }); + await _store.UpsertAsync(new Player { Id = 2, Name = "Bob" }); + + var names = _store.Query().Where(p => p.Name.StartsWith('A')).Select(p => p.Name).ToArray(); + + Assert.Equal(["Alice"], names); + } + + public async ValueTask DisposeAsync() + { + await _journal.DisposeAsync(); + + if (Directory.Exists(_dir)) + { + Directory.Delete(_dir, true); + } + } +} diff --git a/tests/SquidStd.Tests/Persistence/Integration/PersistenceEndToEndTests.cs b/tests/SquidStd.Tests/Persistence/Integration/PersistenceEndToEndTests.cs new file mode 100644 index 00000000..c08e5d8e --- /dev/null +++ b/tests/SquidStd.Tests/Persistence/Integration/PersistenceEndToEndTests.cs @@ -0,0 +1,63 @@ +using SquidStd.Persistence.Abstractions.Data; +using SquidStd.Persistence.Data; +using SquidStd.Persistence.MessagePack; +using SquidStd.Persistence.Services; + +namespace SquidStd.Tests.Persistence.Integration; + +public sealed class PersistenceEndToEndTests : IDisposable +{ + public sealed class Item + { + public int Id { get; set; } + public string Label { get; set; } = string.Empty; + public int Quantity { get; set; } + } + + private readonly string _dir = Path.Combine(Path.GetTempPath(), "squidstd-e2e-" + Guid.NewGuid().ToString("N")); + + private PersistenceService Create() + { + var serializer = new MessagePackDataSerializer(); + var registry = new PersistenceEntityRegistry(); + registry.Register(new PersistenceEntityDescriptor(serializer, serializer, 1, "Item", 1, i => i.Id)); + var config = new PersistenceConfig { SaveDirectory = _dir, AutosaveInterval = TimeSpan.FromHours(1) }; + var journal = new BinaryJournalService(Path.Combine(_dir, config.JournalFileName)); + var snapshot = new SnapshotService(_dir, config.SnapshotFileSuffix); + + return new PersistenceService(registry, journal, snapshot, config, eventBus: null); + } + + [Fact] + public async Task FullCycle_SnapshotRemoveTailCrashRestart_RestoresExactState() + { + // First lifetime: snapshot, then mutate. No clean shutdown (no final snapshot) to simulate a crash, + // so recovery must come from the seq-2 snapshot plus the replayed journal tail (seq 3 and 4). + var service = Create(); + await service.InitializeAsync(); + var store = service.GetStore(); + await store.UpsertAsync(new Item { Id = 1, Label = "Sword", Quantity = 1 }); + await store.UpsertAsync(new Item { Id = 2, Label = "Potion", Quantity = 5 }); + await service.SaveSnapshotAsync(); // snapshot at seq 2 + await store.UpsertAsync(new Item { Id = 2, Label = "Potion", Quantity = 9 }); // tail update (seq 3) + await store.RemoveAsync(1); // tail remove (seq 4) + + var reloaded = Create(); + await reloaded.InitializeAsync(); + var store2 = reloaded.GetStore(); + var all = await store2.GetAllAsync(); + var potion = await store2.GetByIdAsync(2); + + Assert.Single(all); + Assert.Null(await store2.GetByIdAsync(1)); + Assert.Equal(9, potion!.Quantity); + } + + public void Dispose() + { + if (Directory.Exists(_dir)) + { + Directory.Delete(_dir, true); + } + } +} diff --git a/tests/SquidStd.Tests/Persistence/Internal/JournalRecordCodecTests.cs b/tests/SquidStd.Tests/Persistence/Internal/JournalRecordCodecTests.cs new file mode 100644 index 00000000..534b9f19 --- /dev/null +++ b/tests/SquidStd.Tests/Persistence/Internal/JournalRecordCodecTests.cs @@ -0,0 +1,41 @@ +using SquidStd.Persistence.Abstractions.Data; +using SquidStd.Persistence.Abstractions.Types; +using SquidStd.Persistence.Internal; + +namespace SquidStd.Tests.Persistence.Internal; + +public class JournalRecordCodecTests +{ + [Fact] + public void EncodeDecode_RoundTrips() + { + var entry = new JournalEntry + { + SequenceId = 42, + TimestampUnixMilliseconds = 1_700_000_000_000, + TypeId = 7, + Operation = JournalEntityOperationType.Upsert, + Payload = [1, 2, 3, 4, 5] + }; + + var bytes = JournalRecordCodec.Encode(entry); + var decoded = JournalRecordCodec.Decode(bytes); + + Assert.Equal(entry.SequenceId, decoded.SequenceId); + Assert.Equal(entry.TimestampUnixMilliseconds, decoded.TimestampUnixMilliseconds); + Assert.Equal(entry.TypeId, decoded.TypeId); + Assert.Equal(entry.Operation, decoded.Operation); + Assert.Equal(entry.Payload, decoded.Payload); + } + + [Fact] + public void EncodeDecode_EmptyPayload_RoundTrips() + { + var entry = new JournalEntry { SequenceId = 1, TypeId = 1, Operation = JournalEntityOperationType.Remove }; + + var decoded = JournalRecordCodec.Decode(JournalRecordCodec.Encode(entry)); + + Assert.Empty(decoded.Payload); + Assert.Equal(JournalEntityOperationType.Remove, decoded.Operation); + } +} diff --git a/tests/SquidStd.Tests/Persistence/Internal/SnapshotEnvelopeCodecTests.cs b/tests/SquidStd.Tests/Persistence/Internal/SnapshotEnvelopeCodecTests.cs new file mode 100644 index 00000000..0b31d104 --- /dev/null +++ b/tests/SquidStd.Tests/Persistence/Internal/SnapshotEnvelopeCodecTests.cs @@ -0,0 +1,35 @@ +using SquidStd.Persistence.Abstractions.Data; +using SquidStd.Persistence.Internal; + +namespace SquidStd.Tests.Persistence.Internal; + +public class SnapshotEnvelopeCodecTests +{ + [Fact] + public void EncodeDecode_RoundTrips() + { + var envelope = new SnapshotFileEnvelope + { + Version = 1, + LastSequenceId = 99, + Checksum = 123456u, + Bucket = new EntitySnapshotBucket + { + TypeId = 3, + TypeName = "PlayerData", + SchemaVersion = 2, + Payload = [9, 8, 7] + } + }; + + var decoded = SnapshotEnvelopeCodec.Decode(SnapshotEnvelopeCodec.Encode(envelope)); + + Assert.Equal(envelope.Version, decoded.Version); + Assert.Equal(envelope.LastSequenceId, decoded.LastSequenceId); + Assert.Equal(envelope.Checksum, decoded.Checksum); + Assert.Equal(envelope.Bucket.TypeId, decoded.Bucket.TypeId); + Assert.Equal(envelope.Bucket.TypeName, decoded.Bucket.TypeName); + Assert.Equal(envelope.Bucket.SchemaVersion, decoded.Bucket.SchemaVersion); + Assert.Equal(envelope.Bucket.Payload, decoded.Bucket.Payload); + } +} diff --git a/tests/SquidStd.Tests/Persistence/MessagePackDataSerializerTests.cs b/tests/SquidStd.Tests/Persistence/MessagePackDataSerializerTests.cs new file mode 100644 index 00000000..b33add82 --- /dev/null +++ b/tests/SquidStd.Tests/Persistence/MessagePackDataSerializerTests.cs @@ -0,0 +1,35 @@ +using SquidStd.Persistence.MessagePack; + +namespace SquidStd.Tests.Persistence; + +public class MessagePackDataSerializerTests +{ + public sealed class Player + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + } + + [Fact] + public void SerializeDeserialize_RoundTrips() + { + var serializer = new MessagePackDataSerializer(); + + var bytes = serializer.Serialize(new Player { Id = 3, Name = "Zed" }); + var restored = serializer.Deserialize(bytes); + + Assert.Equal(3, restored.Id); + Assert.Equal("Zed", restored.Name); + } + + [Fact] + public void Serialize_ProducesBinaryNotJsonText() + { + var serializer = new MessagePackDataSerializer(); + + var bytes = serializer.Serialize(new Player { Id = 1, Name = "A" }).ToArray(); + + // MessagePack of an object is not a JSON '{' opener. + Assert.NotEqual((byte)'{', bytes[0]); + } +} diff --git a/tests/SquidStd.Tests/Persistence/PersistenceEntityDescriptorTests.cs b/tests/SquidStd.Tests/Persistence/PersistenceEntityDescriptorTests.cs new file mode 100644 index 00000000..d64c0fc9 --- /dev/null +++ b/tests/SquidStd.Tests/Persistence/PersistenceEntityDescriptorTests.cs @@ -0,0 +1,70 @@ +using SquidStd.Core.Json; +using SquidStd.Persistence.Data; + +namespace SquidStd.Tests.Persistence; + +public class PersistenceEntityDescriptorTests +{ + private sealed class Player + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public List Tags { get; set; } = []; + } + + private static PersistenceEntityDescriptor CreateDescriptor() + { + var serializer = new JsonDataSerializer(); + + return new PersistenceEntityDescriptor(serializer, serializer, 1, "Player", 1, p => p.Id); + } + + [Fact] + public void GetKey_UsesSelector() + => Assert.Equal(5, CreateDescriptor().GetKey(new Player { Id = 5 })); + + [Fact] + public void SerializeDeserializeEntity_RoundTrips() + { + var descriptor = CreateDescriptor(); + var player = new Player { Id = 1, Name = "Bob", Tags = ["a", "b"] }; + + var restored = descriptor.DeserializeEntity(descriptor.SerializeEntity(player)); + + Assert.Equal("Bob", restored.Name); + Assert.Equal(["a", "b"], restored.Tags); + } + + [Fact] + public void Clone_IsDeep() + { + var descriptor = CreateDescriptor(); + var original = new Player { Id = 1, Name = "Bob", Tags = ["a"] }; + + var clone = descriptor.Clone(original); + clone.Tags.Add("mutated"); + clone.Name = "Changed"; + + Assert.Equal(["a"], original.Tags); + Assert.Equal("Bob", original.Name); + } + + [Fact] + public void SerializeDeserializeBucket_RoundTrips() + { + var descriptor = CreateDescriptor(); + Player[] players = [new() { Id = 1, Name = "A" }, new() { Id = 2, Name = "B" }]; + + var restored = descriptor.DeserializeBucket(descriptor.SerializeBucket(players)); + + Assert.Equal(2, restored.Count); + } + + [Fact] + public void SerializeDeserializeKey_RoundTrips() + { + var descriptor = CreateDescriptor(); + + Assert.Equal(99, descriptor.DeserializeKey(descriptor.SerializeKey(99))); + } +} diff --git a/tests/SquidStd.Tests/Persistence/PersistenceEntityRegistryTests.cs b/tests/SquidStd.Tests/Persistence/PersistenceEntityRegistryTests.cs new file mode 100644 index 00000000..40a8fa87 --- /dev/null +++ b/tests/SquidStd.Tests/Persistence/PersistenceEntityRegistryTests.cs @@ -0,0 +1,63 @@ +using SquidStd.Core.Json; +using SquidStd.Persistence.Data; +using SquidStd.Persistence.Services; + +namespace SquidStd.Tests.Persistence; + +public class PersistenceEntityRegistryTests +{ + private sealed class Player + { + public int Id { get; set; } + } + + private static PersistenceEntityDescriptor Descriptor(ushort typeId = 1) + { + var serializer = new JsonDataSerializer(); + + return new PersistenceEntityDescriptor(serializer, serializer, typeId, "Player", 1, p => p.Id); + } + + [Fact] + public void Register_ThenGetByTypeId_ReturnsDescriptor() + { + var registry = new PersistenceEntityRegistry(); + registry.Register(Descriptor()); + + Assert.Equal("Player", registry.GetDescriptor(1).TypeName); + Assert.True(registry.IsRegistered(1)); + Assert.True(registry.IsRegistered()); + } + + [Fact] + public void GetDescriptor_ByTypePair_ReturnsTyped() + { + var registry = new PersistenceEntityRegistry(); + registry.Register(Descriptor()); + + Assert.Equal(1, registry.GetDescriptor().TypeId); + } + + [Fact] + public void Register_AfterFreeze_Throws() + { + var registry = new PersistenceEntityRegistry(); + registry.Freeze(); + + Assert.True(registry.IsFrozen); + Assert.Throws(() => registry.Register(Descriptor())); + } + + [Fact] + public void Register_DuplicateTypeId_Throws() + { + var registry = new PersistenceEntityRegistry(); + registry.Register(Descriptor()); + + Assert.Throws(() => registry.Register(Descriptor())); + } + + [Fact] + public void GetDescriptor_Unregistered_Throws() + => Assert.Throws(() => new PersistenceEntityRegistry().GetDescriptor(99)); +} diff --git a/tests/SquidStd.Tests/Persistence/PersistenceRegistrationExtensionsTests.cs b/tests/SquidStd.Tests/Persistence/PersistenceRegistrationExtensionsTests.cs new file mode 100644 index 00000000..bd78c31e --- /dev/null +++ b/tests/SquidStd.Tests/Persistence/PersistenceRegistrationExtensionsTests.cs @@ -0,0 +1,33 @@ +using DryIoc; +using SquidStd.Core.Interfaces.Serialization; +using SquidStd.Core.Json; +using SquidStd.Persistence.Abstractions.Interfaces.Persistence; +using SquidStd.Persistence.Extensions; +using SquidStd.Persistence.Services; + +namespace SquidStd.Tests.Persistence; + +public class PersistenceRegistrationExtensionsTests +{ + private sealed class Player + { + public int Id { get; set; } + } + + [Fact] + public void RegisterPersistedEntity_PopulatesRegistry() + { + using var container = new Container(); + var serializer = new JsonDataSerializer(); + container.RegisterInstance(serializer); + container.RegisterInstance(serializer); + container.Register(Reuse.Singleton); + + container.RegisterPersistedEntity(1, "Player", 1, p => p.Id); + container.ApplyPersistedEntityRegistrations(); + + var registry = container.Resolve(); + Assert.True(registry.IsRegistered()); + Assert.Equal("Player", registry.GetDescriptor(1).TypeName); + } +} diff --git a/tests/SquidStd.Tests/Persistence/PersistenceServiceTests.cs b/tests/SquidStd.Tests/Persistence/PersistenceServiceTests.cs new file mode 100644 index 00000000..a46c6058 --- /dev/null +++ b/tests/SquidStd.Tests/Persistence/PersistenceServiceTests.cs @@ -0,0 +1,99 @@ +using SquidStd.Core.Json; +using SquidStd.Persistence.Abstractions.Data; +using SquidStd.Persistence.Data; +using SquidStd.Persistence.Services; + +namespace SquidStd.Tests.Persistence; + +public sealed class PersistenceServiceTests : IDisposable +{ + private sealed class Player + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + } + + private readonly string _dir = Path.Combine(Path.GetTempPath(), "squidstd-persistence-" + Guid.NewGuid().ToString("N")); + + private (PersistenceService Service, PersistenceEntityRegistry Registry) Create() + { + var serializer = new JsonDataSerializer(); + var registry = new PersistenceEntityRegistry(); + registry.Register(new PersistenceEntityDescriptor(serializer, serializer, 1, "Player", 1, p => p.Id)); + var config = new PersistenceConfig { SaveDirectory = _dir, AutosaveInterval = TimeSpan.FromHours(1) }; + var journal = new BinaryJournalService(Path.Combine(_dir, config.JournalFileName)); + var snapshot = new SnapshotService(_dir, config.SnapshotFileSuffix); + var service = new PersistenceService(registry, journal, snapshot, config, eventBus: null); + + return (service, registry); + } + + [Fact] + public async Task SnapshotThenReload_RestoresState() + { + var (service, _) = Create(); + await service.InitializeAsync(); + var store = service.GetStore(); + await store.UpsertAsync(new Player { Id = 1, Name = "Bob" }); + await service.SaveSnapshotAsync(); + + var (reloaded, _) = Create(); + await reloaded.InitializeAsync(); + var fetched = await reloaded.GetStore().GetByIdAsync(1); + + Assert.NotNull(fetched); + Assert.Equal("Bob", fetched.Name); + } + + [Fact] + public async Task NoSnapshot_ReplaysJournal() + { + var (service, _) = Create(); + await service.InitializeAsync(); + await service.GetStore().UpsertAsync(new Player { Id = 7, Name = "Eve" }); + + var (reloaded, _) = Create(); + await reloaded.InitializeAsync(); // only journal exists, no snapshot + var fetched = await reloaded.GetStore().GetByIdAsync(7); + + Assert.Equal("Eve", fetched!.Name); + } + + [Fact] + public async Task SnapshotPlusJournalTail_ReplaysTail() + { + var (service, _) = Create(); + await service.InitializeAsync(); + var store = service.GetStore(); + await store.UpsertAsync(new Player { Id = 1, Name = "First" }); + await service.SaveSnapshotAsync(); + await store.UpsertAsync(new Player { Id = 2, Name = "Second" }); + + var (reloaded, _) = Create(); + await reloaded.InitializeAsync(); + var all = await reloaded.GetStore().GetAllAsync(); + + Assert.Equal(2, all.Count); + } + + [Fact] + public async Task SaveSnapshot_TrimsJournal() + { + var (service, _) = Create(); + await service.InitializeAsync(); + await service.GetStore().UpsertAsync(new Player { Id = 1 }); + await service.SaveSnapshotAsync(); + + var journal = new BinaryJournalService(Path.Combine(_dir, "world.journal.bin")); + Assert.Empty(await journal.ReadAllAsync()); + await journal.DisposeAsync(); + } + + public void Dispose() + { + if (Directory.Exists(_dir)) + { + Directory.Delete(_dir, true); + } + } +} diff --git a/tests/SquidStd.Tests/Persistence/SnapshotServiceTests.cs b/tests/SquidStd.Tests/Persistence/SnapshotServiceTests.cs new file mode 100644 index 00000000..1e26ae81 --- /dev/null +++ b/tests/SquidStd.Tests/Persistence/SnapshotServiceTests.cs @@ -0,0 +1,65 @@ +using SquidStd.Persistence.Abstractions.Data; +using SquidStd.Persistence.Services; + +namespace SquidStd.Tests.Persistence; + +public sealed class SnapshotServiceTests : IDisposable +{ + private readonly string _dir = Path.Combine(Path.GetTempPath(), "squidstd-snapshot-" + Guid.NewGuid().ToString("N")); + + private SnapshotService Create() + => new(_dir, ".snapshot.bin"); + + private static EntitySnapshotBucket Bucket(string typeName = "Player") + => new() { TypeId = 1, TypeName = typeName, SchemaVersion = 1, Payload = [1, 2, 3] }; + + [Fact] + public async Task SaveThenLoad_RoundTrips() + { + var service = Create(); + await service.SaveBucketAsync(Bucket(), 42); + + var loaded = await service.LoadBucketAsync("Player"); + + Assert.NotNull(loaded); + Assert.Equal(42, loaded.LastSequenceId); + Assert.Equal([1, 2, 3], loaded.Bucket.Payload); + Assert.Equal("Player", loaded.Bucket.TypeName); + } + + [Fact] + public async Task LoadBucket_MissingFile_ReturnsNull() + => Assert.Null(await Create().LoadBucketAsync("Absent")); + + [Fact] + public async Task LoadBucket_CorruptPayload_ReturnsNull() + { + var service = Create(); + await service.SaveBucketAsync(Bucket(), 1); + var path = Directory.GetFiles(_dir, "*.snapshot.bin").Single(); + var bytes = await File.ReadAllBytesAsync(path); + bytes[^1] ^= 0xFF; // corrupt the payload so checksum mismatches + await File.WriteAllBytesAsync(path, bytes); + + Assert.Null(await service.LoadBucketAsync("Player")); + } + + [Fact] + public async Task DeleteBucket_RemovesFile() + { + var service = Create(); + await service.SaveBucketAsync(Bucket(), 1); + + await service.DeleteBucketAsync("Player"); + + Assert.Null(await service.LoadBucketAsync("Player")); + } + + public void Dispose() + { + if (Directory.Exists(_dir)) + { + Directory.Delete(_dir, true); + } + } +} diff --git a/tests/SquidStd.Tests/PluginAbstractions/PluginContextTests.cs b/tests/SquidStd.Tests/PluginAbstractions/PluginContextTests.cs index 6b49f609..c44c0461 100644 --- a/tests/SquidStd.Tests/PluginAbstractions/PluginContextTests.cs +++ b/tests/SquidStd.Tests/PluginAbstractions/PluginContextTests.cs @@ -6,11 +6,15 @@ public class PluginContextTests { [Fact] public void Data_IsEmptyByDefault() - => Assert.Empty(new PluginContext().Data); + { + Assert.Empty(new PluginContext().Data); + } [Fact] public void GetData_MissingKey_Throws() - => Assert.Throws(() => { _ = new PluginContext().GetData("missing"); }); + { + Assert.Throws(() => { _ = new PluginContext().GetData("missing"); }); + } [Fact] public void GetData_ReferenceType_ReturnsStoredValue() diff --git a/tests/SquidStd.Tests/PluginAbstractions/PluginMetadataTests.cs b/tests/SquidStd.Tests/PluginAbstractions/PluginMetadataTests.cs index bbaa345e..5d27d2af 100644 --- a/tests/SquidStd.Tests/PluginAbstractions/PluginMetadataTests.cs +++ b/tests/SquidStd.Tests/PluginAbstractions/PluginMetadataTests.cs @@ -11,13 +11,13 @@ public void Constructor_SetsRequiredProperties() { Id = "squidstd.weather", Name = "Weather", - Version = new(2, 1, 0), + Version = new Version(2, 1, 0), Author = "squid" }; Assert.Equal("squidstd.weather", metadata.Id); Assert.Equal("Weather", metadata.Name); - Assert.Equal(new(2, 1, 0), metadata.Version); + Assert.Equal(new Version(2, 1, 0), metadata.Version); Assert.Equal("squid", metadata.Author); } @@ -28,7 +28,7 @@ public void Dependencies_CanBeProvided() { Id = "id", Name = "name", - Version = new(1, 0), + Version = new Version(1, 0), Author = "author", Dependencies = ["squidstd.core", "squidstd.net"] }; @@ -43,7 +43,7 @@ public void Dependencies_DefaultsToEmpty() { Id = "id", Name = "name", - Version = new(1, 0), + Version = new Version(1, 0), Author = "author" }; @@ -57,7 +57,7 @@ public void Description_DefaultsToNull() { Id = "id", Name = "name", - Version = new(1, 0), + Version = new Version(1, 0), Author = "author" }; diff --git a/tests/SquidStd.Tests/PluginAbstractions/SquidStdPluginTests.cs b/tests/SquidStd.Tests/PluginAbstractions/SquidStdPluginTests.cs index 2fc16ed9..ea081e1d 100644 --- a/tests/SquidStd.Tests/PluginAbstractions/SquidStdPluginTests.cs +++ b/tests/SquidStd.Tests/PluginAbstractions/SquidStdPluginTests.cs @@ -25,7 +25,7 @@ public void Configure_RegistersServicesIntoContainer() using var container = new Container(); var plugin = new FakePlugin(); - ((ISquidStdPlugin)plugin).Configure(container, new()); + ((ISquidStdPlugin)plugin).Configure(container, new PluginContext()); Assert.Same(plugin, container.Resolve()); } @@ -36,6 +36,6 @@ public void Metadata_ExposesPluginIdentity() ISquidStdPlugin plugin = new FakePlugin(); Assert.Equal("squidstd.fake", plugin.Metadata.Id); - Assert.Equal(new(1, 2, 3), plugin.Metadata.Version); + Assert.Equal(new Version(1, 2, 3), plugin.Metadata.Version); } } diff --git a/tests/SquidStd.Tests/Scripting/Lua/AddScriptModuleExtensionTests.cs b/tests/SquidStd.Tests/Scripting/Lua/AddScriptModuleExtensionTests.cs index a4d41f83..70589743 100644 --- a/tests/SquidStd.Tests/Scripting/Lua/AddScriptModuleExtensionTests.cs +++ b/tests/SquidStd.Tests/Scripting/Lua/AddScriptModuleExtensionTests.cs @@ -6,10 +6,6 @@ namespace SquidStd.Tests.Scripting.Lua; public class AddScriptModuleExtensionTests { - public sealed class TestUserData; - - public sealed class TestModule; - [Fact] public void RegisterLuaUserData_AddsUserDataMetadata() { @@ -40,4 +36,8 @@ public void RegisterScriptModule_AddsMetadataAndRegistersSingleton() Assert.Equal(typeof(TestModule), registration.ModuleType); Assert.Same(container.Resolve(), container.Resolve()); } + + public sealed class TestUserData; + + public sealed class TestModule; } diff --git a/tests/SquidStd.Tests/Scripting/Lua/LuaEventBridgeTests.cs b/tests/SquidStd.Tests/Scripting/Lua/LuaEventBridgeTests.cs index 000afc41..212b0e28 100644 --- a/tests/SquidStd.Tests/Scripting/Lua/LuaEventBridgeTests.cs +++ b/tests/SquidStd.Tests/Scripting/Lua/LuaEventBridgeTests.cs @@ -12,13 +12,13 @@ public void Invoke_ConvertsNestedPayloadToLuaTable() var bridge = new LuaEventBridge(); bridge.Attach(script); var callback = script.DoString( - """ - return function(payload) - return payload.actor.name .. ':' .. payload.values[2] - end - """ - ) - .Function; + """ + return function(payload) + return payload.actor.name .. ':' .. payload.values[2] + end + """ + ) + .Function; var result = bridge.Invoke( callback, @@ -49,16 +49,16 @@ public void Publish_InvokesRegisteredCallbacksCaseInsensitively() var bridge = new LuaEventBridge(); bridge.Attach(script); var callback = script.DoString( - """ - calls = 0 - captured = nil - return function(payload) - calls = calls + 1 - captured = payload.name - end - """ - ) - .Function; + """ + calls = 0 + captured = nil + return function(payload) + calls = calls + 1 + captured = payload.name + end + """ + ) + .Function; bridge.Register("Spawned", callback); bridge.Publish("spawned", new Dictionary { ["name"] = "slime" }); diff --git a/tests/SquidStd.Tests/Scripting/Lua/LuaModuleTests.cs b/tests/SquidStd.Tests/Scripting/Lua/LuaModuleTests.cs index 2e474c89..450ea071 100644 --- a/tests/SquidStd.Tests/Scripting/Lua/LuaModuleTests.cs +++ b/tests/SquidStd.Tests/Scripting/Lua/LuaModuleTests.cs @@ -6,26 +6,6 @@ namespace SquidStd.Tests.Scripting.Lua; public class LuaModuleTests { - private sealed class CapturingLuaEventBridge : ILuaEventBridge - { - public Closure? Callback { get; private set; } - - public string? EventName { get; private set; } - - public void Attach(Script script) { } - - public DynValue Invoke(Closure callback, IReadOnlyDictionary payload) - => DynValue.Nil; - - public void Publish(string eventName, IReadOnlyDictionary payload) { } - - public void Register(string eventName, Closure callback) - { - EventName = eventName; - Callback = callback; - } - } - [Fact] public void EventsModule_RegistersCallbackWithBridge() { @@ -64,7 +44,7 @@ public void RandomModule_PickRejectsEmptyTable() { var module = new RandomModule(); - Assert.Throws(() => module.Pick(new(new()))); + Assert.Throws(() => module.Pick(new Table(new Script()))); } [Fact] @@ -72,16 +52,42 @@ public void RandomModule_WeightedRejectsEntriesWithoutPositiveWeight() { var script = new Script(); var entries = script.DoString( - """ - return { - { value = 'a', weight = 0 }, - { value = 'b', weight = -3 } - } - """ - ) - .Table; + """ + return { + { value = 'a', weight = 0 }, + { value = 'b', weight = -3 } + } + """ + ) + .Table; var module = new RandomModule(); Assert.Throws(() => module.Weighted(entries)); } + + private sealed class CapturingLuaEventBridge : ILuaEventBridge + { + public Closure? Callback { get; private set; } + + public string? EventName { get; private set; } + + public void Attach(Script script) + { + } + + public DynValue Invoke(Closure callback, IReadOnlyDictionary payload) + { + return DynValue.Nil; + } + + public void Publish(string eventName, IReadOnlyDictionary payload) + { + } + + public void Register(string eventName, Closure callback) + { + EventName = eventName; + Callback = callback; + } + } } diff --git a/tests/SquidStd.Tests/Scripting/Lua/LuaScriptEngineServiceTests.cs b/tests/SquidStd.Tests/Scripting/Lua/LuaScriptEngineServiceTests.cs index 88f24092..37fa939d 100644 --- a/tests/SquidStd.Tests/Scripting/Lua/LuaScriptEngineServiceTests.cs +++ b/tests/SquidStd.Tests/Scripting/Lua/LuaScriptEngineServiceTests.cs @@ -1,5 +1,7 @@ using DryIoc; using MoonSharp.Interpreter; +using SquidStd.Core.Directories; +using SquidStd.Scripting.Lua.Data.Config; using SquidStd.Scripting.Lua.Data.Internal; using SquidStd.Scripting.Lua.Data.Scripts; using SquidStd.Scripting.Lua.Interfaces.Events; @@ -10,31 +12,6 @@ namespace SquidStd.Tests.Scripting.Lua; public class LuaScriptEngineServiceTests { - private sealed record LimitConfig(string Name, int Count); - - public sealed class FiveArgumentUserData(int first, int second, int third, int fourth, int fifth) - { - public int Total { get; } = first + second + third + fourth + fifth; - } - - private sealed class CapturingLuaEventBridge : ILuaEventBridge - { - public Script? AttachedScript { get; private set; } - - public void Attach(Script script) - => AttachedScript = script; - - public DynValue Invoke( - Closure callback, - IReadOnlyDictionary payload - ) - => DynValue.Nil; - - public void Publish(string eventName, IReadOnlyDictionary payload) { } - - public void Register(string eventName, Closure callback) { } - } - [Fact] public void AddCallback_AndExecuteCallback_NormalizeNameAndPassArguments() { @@ -153,7 +130,7 @@ public void RegisteredUserData_CanBeConstructedFromLuaWithMoreThanFourArguments( container, loadedUserData: [ - new() { UserType = typeof(FiveArgumentUserData) } + new ScriptUserData { UserType = typeof(FiveArgumentUserData) } ] ); @@ -214,12 +191,45 @@ private static LuaScriptEngineService CreateEngine( var luarcDirectory = temp.Combine("luarc"); Directory.CreateDirectory(scriptsDirectory); - return new( - new(temp.Path, []), + return new LuaScriptEngineService( + new DirectoriesConfig(temp.Path, []), container, - new(luarcDirectory, scriptsDirectory, "SquidStd", "1.0.0"), + new LuaEngineConfig(luarcDirectory, scriptsDirectory, "SquidStd", "1.0.0"), scriptModules, loadedUserData ); } + + private sealed record LimitConfig(string Name, int Count); + + public sealed class FiveArgumentUserData(int first, int second, int third, int fourth, int fifth) + { + public int Total { get; } = first + second + third + fourth + fifth; + } + + private sealed class CapturingLuaEventBridge : ILuaEventBridge + { + public Script? AttachedScript { get; private set; } + + public void Attach(Script script) + { + AttachedScript = script; + } + + public DynValue Invoke( + Closure callback, + IReadOnlyDictionary payload + ) + { + return DynValue.Nil; + } + + public void Publish(string eventName, IReadOnlyDictionary payload) + { + } + + public void Register(string eventName, Closure callback) + { + } + } } diff --git a/tests/SquidStd.Tests/Scripting/Lua/LuaScriptLoaderTests.cs b/tests/SquidStd.Tests/Scripting/Lua/LuaScriptLoaderTests.cs index df24478d..4ed4ae4f 100644 --- a/tests/SquidStd.Tests/Scripting/Lua/LuaScriptLoaderTests.cs +++ b/tests/SquidStd.Tests/Scripting/Lua/LuaScriptLoaderTests.cs @@ -1,3 +1,4 @@ +using MoonSharp.Interpreter; using SquidStd.Scripting.Lua.Loaders; using SquidStd.Tests.Support; @@ -20,7 +21,9 @@ public void AddSearchDirectory_AddsAdditionalLookupRoot() [Fact] public void Constructor_EmptySearchDirectoriesThrows() - => Assert.Throws(() => new LuaScriptLoader(Array.Empty())); + { + Assert.Throws(() => new LuaScriptLoader(Array.Empty())); + } [Fact] public void LoadFile_LoadsContentFromFirstMatchingSearchDirectory() @@ -30,7 +33,7 @@ public void LoadFile_LoadsContentFromFirstMatchingSearchDirectory() File.WriteAllText(second.Combine("feature.lua"), "return 'loaded'"); var loader = new LuaScriptLoader([first.Path, second.Path]); - var content = loader.LoadFile("feature.lua", new(new())); + var content = loader.LoadFile("feature.lua", new Table(new Script())); Assert.Equal("return 'loaded'", content); } diff --git a/tests/SquidStd.Tests/Scripting/Lua/LuaTableReaderTests.cs b/tests/SquidStd.Tests/Scripting/Lua/LuaTableReaderTests.cs index 6cbbd2c1..c6f0bbac 100644 --- a/tests/SquidStd.Tests/Scripting/Lua/LuaTableReaderTests.cs +++ b/tests/SquidStd.Tests/Scripting/Lua/LuaTableReaderTests.cs @@ -5,12 +5,6 @@ namespace SquidStd.Tests.Scripting.Lua; public class LuaTableReaderTests { - private enum TestMode - { - First = 1, - Second = 2 - } - [Fact] public void Readers_ReturnDefaultsForMissingOrWrongTypes() { @@ -44,4 +38,10 @@ public void Readers_ReturnTypedValues() Assert.True(LuaTableReader.GetBool(table, "enabled")); Assert.Equal(TestMode.Second, LuaTableReader.GetEnum(table, "mode", TestMode.First)); } + + private enum TestMode + { + First = 1, + Second = 2 + } } diff --git a/tests/SquidStd.Tests/Scripting/Lua/ScriptResultBuilderTests.cs b/tests/SquidStd.Tests/Scripting/Lua/ScriptResultBuilderTests.cs index 2aeb3623..be23b5ba 100644 --- a/tests/SquidStd.Tests/Scripting/Lua/ScriptResultBuilderTests.cs +++ b/tests/SquidStd.Tests/Scripting/Lua/ScriptResultBuilderTests.cs @@ -8,8 +8,8 @@ public class ScriptResultBuilderTests public void CreateError_BuildsFailedResult() { var result = ScriptResultBuilder.CreateError() - .WithMessage("failed") - .Build(); + .WithMessage("failed") + .Build(); Assert.False(result.Success); Assert.Equal("failed", result.Message); @@ -20,9 +20,9 @@ public void CreateError_BuildsFailedResult() public void CreateSuccess_BuildsSuccessfulResult() { var result = ScriptResultBuilder.CreateSuccess() - .WithMessage("ok") - .WithData(42) - .Build(); + .WithMessage("ok") + .WithData(42) + .Build(); Assert.True(result.Success); Assert.Equal("ok", result.Message); diff --git a/tests/SquidStd.Tests/Scripting/Lua/TableExtensionsTests.cs b/tests/SquidStd.Tests/Scripting/Lua/TableExtensionsTests.cs index 56531400..0ffdd12b 100644 --- a/tests/SquidStd.Tests/Scripting/Lua/TableExtensionsTests.cs +++ b/tests/SquidStd.Tests/Scripting/Lua/TableExtensionsTests.cs @@ -5,25 +5,20 @@ namespace SquidStd.Tests.Scripting.Lua; public class TableExtensionsTests { - public interface ICalculator - { - int Sum(int left, int right); - } - [Fact] public void ToProxy_DelegatesInterfaceCallsToLuaFunctions() { var script = new Script(); var table = script.DoString( - """ - return { - Sum = function(left, right) - return left + right - end - } - """ - ) - .Table; + """ + return { + Sum = function(left, right) + return left + right + end + } + """ + ) + .Table; var proxy = table.ToProxy(); @@ -33,8 +28,13 @@ public void ToProxy_DelegatesInterfaceCallsToLuaFunctions() [Fact] public void ToProxy_MissingFunctionThrowsMissingMethodException() { - var proxy = new Table(new()).ToProxy(); + var proxy = new Table(new Script()).ToProxy(); Assert.Throws(() => proxy.Sum(1, 2)); } + + public interface ICalculator + { + int Sum(int left, int right); + } } diff --git a/tests/SquidStd.Tests/Search/ElasticExpressionTranslatorTests.cs b/tests/SquidStd.Tests/Search/ElasticExpressionTranslatorTests.cs index 0a5e85cc..33222803 100644 --- a/tests/SquidStd.Tests/Search/ElasticExpressionTranslatorTests.cs +++ b/tests/SquidStd.Tests/Search/ElasticExpressionTranslatorTests.cs @@ -7,46 +7,6 @@ namespace SquidStd.Tests.Search; public class ElasticExpressionTranslatorTests { - private sealed record Doc(string Status, int Total, string Name) : IIndexableEntity - { - public string IndexId => Name; - } - - private sealed class TranslateOnlyQueryable : IOrderedQueryable, IQueryProvider - { - public TranslateOnlyQueryable() - { - Expression = Expression.Constant(this); - } - - private TranslateOnlyQueryable(Expression expression) - { - Expression = expression; - } - - public Type ElementType => typeof(T); - public Expression Expression { get; } - public IQueryProvider Provider => this; - - public IQueryable CreateQuery(Expression expression) - => new TranslateOnlyQueryable(expression); - - public IQueryable CreateQuery(Expression expression) - => new TranslateOnlyQueryable(expression); - - public object? Execute(Expression expression) - => throw new NotSupportedException(); - - public TResult Execute(Expression expression) - => throw new NotSupportedException(); - - public IEnumerator GetEnumerator() - => throw new NotSupportedException(); - - IEnumerator IEnumerable.GetEnumerator() - => throw new NotSupportedException(); - } - [Fact] public void Match_ProducesMatchClause() { @@ -58,7 +18,9 @@ public void Match_ProducesMatchClause() [Fact] public void UnsupportedExpression_Throws() - => Assert.Throws(() => Translate(s => s.Where(d => d.Name.ToUpperInvariant() == "X"))); + { + Assert.Throws(() => Translate(s => s.Where(d => d.Name.ToUpperInvariant() == "X"))); + } [Fact] public void Where_Equality_ProducesTermOnKeyword() @@ -88,4 +50,56 @@ private static ElasticQuery Translate(Func, IQueryable> bui return ElasticExpressionTranslator.Translate(query.Expression, typeof(Doc)); } + + private sealed record Doc(string Status, int Total, string Name) : IIndexableEntity + { + public string IndexId => Name; + } + + private sealed class TranslateOnlyQueryable : IOrderedQueryable, IQueryProvider + { + public TranslateOnlyQueryable() + { + Expression = Expression.Constant(this); + } + + private TranslateOnlyQueryable(Expression expression) + { + Expression = expression; + } + + public Type ElementType => typeof(T); + public Expression Expression { get; } + public IQueryProvider Provider => this; + + public IEnumerator GetEnumerator() + { + throw new NotSupportedException(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + throw new NotSupportedException(); + } + + public IQueryable CreateQuery(Expression expression) + { + return new TranslateOnlyQueryable(expression); + } + + public IQueryable CreateQuery(Expression expression) + { + return new TranslateOnlyQueryable(expression); + } + + public object? Execute(Expression expression) + { + throw new NotSupportedException(); + } + + public TResult Execute(Expression expression) + { + throw new NotSupportedException(); + } + } } diff --git a/tests/SquidStd.Tests/Search/SearchIndexNameResolverTests.cs b/tests/SquidStd.Tests/Search/SearchIndexNameResolverTests.cs index 88037b10..26072076 100644 --- a/tests/SquidStd.Tests/Search/SearchIndexNameResolverTests.cs +++ b/tests/SquidStd.Tests/Search/SearchIndexNameResolverTests.cs @@ -6,35 +6,6 @@ namespace SquidStd.Tests.Search; public class SearchIndexNameResolverTests { - private sealed record PlainDoc : IIndexableEntity - { - public string IndexId => "1"; - } - - [SearchIndex("Orders")] - private sealed record AttributedDoc : IIndexableEntity - { - public string IndexId => "1"; - } - - [SearchIndex("orders_${SQ_ENV}")] - private sealed record EnvDoc : IIndexableEntity - { - public string IndexId => "1"; - } - - [SearchIndex("orders_${SQ_MISSING:-dev}")] - private sealed record EnvDefaultDoc : IIndexableEntity - { - public string IndexId => "1"; - } - - [SearchIndex("orders_${SQ_REQUIRED}")] - private sealed record EnvRequiredDoc : IIndexableEntity - { - public string IndexId => "1"; - } - [Fact] public void Resolve_ExpandsEnvVariable() { @@ -52,7 +23,9 @@ public void Resolve_ExpandsEnvVariable() [Fact] public void Resolve_FallsBackToLowercasedTypeName() - => Assert.Equal("plaindoc", SearchIndexNameResolver.Resolve(typeof(PlainDoc))); + { + Assert.Equal("plaindoc", SearchIndexNameResolver.Resolve(typeof(PlainDoc))); + } [Fact] public void Resolve_Throws_WhenRequiredVariableMissing() @@ -63,7 +36,9 @@ public void Resolve_Throws_WhenRequiredVariableMissing() [Fact] public void Resolve_UsesAttribute_Lowercased() - => Assert.Equal("orders", SearchIndexNameResolver.Resolve(typeof(AttributedDoc))); + { + Assert.Equal("orders", SearchIndexNameResolver.Resolve(typeof(AttributedDoc))); + } [Fact] public void Resolve_UsesDefault_WhenVariableMissing() @@ -71,4 +46,33 @@ public void Resolve_UsesDefault_WhenVariableMissing() Environment.SetEnvironmentVariable("SQ_MISSING", null); Assert.Equal("orders_dev", SearchIndexNameResolver.Resolve(typeof(EnvDefaultDoc))); } + + private sealed record PlainDoc : IIndexableEntity + { + public string IndexId => "1"; + } + + [SearchIndex("Orders")] + private sealed record AttributedDoc : IIndexableEntity + { + public string IndexId => "1"; + } + + [SearchIndex("orders_${SQ_ENV}")] + private sealed record EnvDoc : IIndexableEntity + { + public string IndexId => "1"; + } + + [SearchIndex("orders_${SQ_MISSING:-dev}")] + private sealed record EnvDefaultDoc : IIndexableEntity + { + public string IndexId => "1"; + } + + [SearchIndex("orders_${SQ_REQUIRED}")] + private sealed record EnvRequiredDoc : IIndexableEntity + { + public string IndexId => "1"; + } } diff --git a/tests/SquidStd.Tests/Security/SecretsTests.cs b/tests/SquidStd.Tests/Security/SecretsTests.cs index 449077ad..ba6ed233 100644 --- a/tests/SquidStd.Tests/Security/SecretsTests.cs +++ b/tests/SquidStd.Tests/Security/SecretsTests.cs @@ -12,16 +12,6 @@ namespace SquidStd.Tests.Security; [Collection(SerilogEventSinkCollection.Name)] public class SecretsTests { - private sealed class CapturingSink : ILogEventSink - { - private readonly List _events = []; - - public IReadOnlyList Events => _events; - - public void Emit(LogEvent logEvent) - => _events.Add(logEvent); - } - [Fact] public void AesGcmSecretProtector_Protect_Unprotect_RoundTripsWithoutPlaintext() { @@ -32,7 +22,7 @@ public void AesGcmSecretProtector_Protect_Unprotect_RoundTripsWithoutPlaintext() try { Environment.SetEnvironmentVariable(variableName, Convert.ToBase64String(key)); - var protector = new AesGcmSecretProtector(new() { KeyEnvironmentVariable = variableName }); + var protector = new AesGcmSecretProtector(new SecretsConfig { KeyEnvironmentVariable = variableName }); var plaintext = Encoding.UTF8.GetBytes("super-secret-value"); var protectedData = protector.Protect(plaintext); @@ -59,7 +49,7 @@ public void AesGcmSecretProtector_WhenKeyEnvironmentVariableIsMissing_UsesDefaul { Environment.SetEnvironmentVariable(variableName, null); Log.Logger = new LoggerConfiguration().MinimumLevel.Verbose().WriteTo.Sink(sink).CreateLogger(); - var protector = new AesGcmSecretProtector(new() { KeyEnvironmentVariable = variableName }); + var protector = new AesGcmSecretProtector(new SecretsConfig { KeyEnvironmentVariable = variableName }); var plaintext = Encoding.UTF8.GetBytes("default-key-secret"); var protectedData = protector.Protect(plaintext); @@ -112,4 +102,16 @@ public async Task FileSecretStore_SetAsync_GetAsync_StoresEncryptedPayload() Environment.SetEnvironmentVariable(variableName, previous); } } + + private sealed class CapturingSink : ILogEventSink + { + private readonly List _events = []; + + public IReadOnlyList Events => _events; + + public void Emit(LogEvent logEvent) + { + _events.Add(logEvent); + } + } } diff --git a/tests/SquidStd.Tests/Services/Core/EventBusServiceTests.cs b/tests/SquidStd.Tests/Services/Core/EventBusServiceTests.cs index af266259..d09aaf84 100644 --- a/tests/SquidStd.Tests/Services/Core/EventBusServiceTests.cs +++ b/tests/SquidStd.Tests/Services/Core/EventBusServiceTests.cs @@ -1,182 +1,301 @@ +using Serilog; +using Serilog.Events; +using SquidStd.Core.Data.Events; using SquidStd.Core.Interfaces.Events; using SquidStd.Services.Core.Services; +using SquidStd.Tests.Support; namespace SquidStd.Tests.Services.Core; public class EventBusServiceTests { - private sealed record TestEvent(string Payload) : IEvent; - - private sealed class SyncListener : ISyncEventListener + [Fact] + public async Task PublishAsync_NoListeners_Completes() { - private readonly List _calls; - private readonly string _name; - - public TestEvent? LastEvent { get; private set; } + using var eventBus = new EventBusService(); + IEventBus bus = eventBus; - public SyncListener(string name, List calls) - { - _name = name; - _calls = calls; - } + var exception = + await Record.ExceptionAsync(() => bus.PublishAsync(new TestEvent("ignored"), CancellationToken.None)); - public void Handle(TestEvent eventData) - { - LastEvent = eventData; - _calls.Add($"{_name}:{eventData.Payload}"); - } + Assert.Null(exception); } - private sealed class ThrowingSyncListener : ISyncEventListener + [Fact] + public async Task PublishAsync_TypedListener_ReceivesEvent() { - private readonly InvalidOperationException _exception; + using var eventBus = new EventBusService(); + IEventBus bus = eventBus; + var calls = new List(); + var listener = new RecordingListener("only", calls); + bus.RegisterListener(listener); + var eventData = new TestEvent("payload"); - public ThrowingSyncListener(InvalidOperationException exception) - { - _exception = exception; - } + await bus.PublishAsync(eventData, CancellationToken.None); - public void Handle(TestEvent eventData) - => throw _exception; + Assert.Equal(["only:payload"], calls); + Assert.Same(eventData, listener.LastEvent); } - private sealed class AsyncListener : IAsyncEventListener + [Fact] + public async Task PublishAsync_OnlyMatchingEventType_Receives() { - private readonly List _calls; - private readonly string _name; - - public CancellationToken CancellationToken { get; private set; } - public TestEvent? LastEvent { get; private set; } - - public AsyncListener(string name, List calls) - { - _name = name; - _calls = calls; - } + using var eventBus = new EventBusService(); + IEventBus bus = eventBus; + var testListener = new RecordingListener("test", []); + var otherListener = new OtherRecordingListener(); + bus.RegisterListener(testListener); + bus.RegisterListener(otherListener); - public async Task HandleAsync(TestEvent eventData, CancellationToken cancellationToken) - { - await Task.Yield(); + await bus.PublishAsync(new TestEvent("payload"), CancellationToken.None); - CancellationToken = cancellationToken; - LastEvent = eventData; - _calls.Add($"{_name}:{eventData.Payload}"); - } + Assert.NotNull(testListener.LastEvent); + Assert.Null(otherListener.LastEvent); } - private sealed class ThrowingAsyncListener : IAsyncEventListener + [Fact] + public async Task PublishAsync_CatchAllListener_ReceivesEveryEventType() { - private readonly InvalidOperationException _exception; + using var eventBus = new EventBusService(); + IEventBus bus = eventBus; + var catchAll = new CatchAllListener(); + bus.RegisterListener(catchAll); - public ThrowingAsyncListener(InvalidOperationException exception) - { - _exception = exception; - } + await bus.PublishAsync(new TestEvent("a"), CancellationToken.None); + await bus.PublishAsync(new OtherEvent(7), CancellationToken.None); - public Task HandleAsync(TestEvent eventData, CancellationToken cancellationToken) - => throw _exception; + Assert.Equal(2, catchAll.Received.Count); + Assert.IsType(catchAll.Received[0]); + Assert.IsType(catchAll.Received[1]); } [Fact] - public void Publish_NoSyncListeners_DoesNotThrow() + public async Task PublishAsync_MultipleListeners_AllReceive() { using var eventBus = new EventBusService(); IEventBus bus = eventBus; + var calls = new List(); + var first = new RecordingListener("first", calls); + var second = new RecordingListener("second", calls); + bus.RegisterListener(first); + bus.RegisterListener(second); - var exception = Record.Exception(() => bus.Publish(new TestEvent("ignored"))); + await bus.PublishAsync(new TestEvent("payload"), CancellationToken.None); - Assert.Null(exception); + Assert.Contains("first:payload", calls); + Assert.Contains("second:payload", calls); } [Fact] - public void Publish_RegisteredSyncListeners_InvokesEachInRegistrationOrder() + public async Task PublishAsync_WhenListenerThrows_IsolatesAndOthersStillRun() { using var eventBus = new EventBusService(); IEventBus bus = eventBus; var calls = new List(); - var first = new SyncListener("first", calls); - var second = new SyncListener("second", calls); - bus.RegisterListener(first); - bus.RegisterListener(second); - var eventData = new TestEvent("payload"); + var survivor = new RecordingListener("survivor", calls); + bus.RegisterListener(new ThrowingListener()); + bus.RegisterListener(survivor); - bus.Publish(eventData); + var exception = + await Record.ExceptionAsync(() => bus.PublishAsync(new TestEvent("payload"), CancellationToken.None)); - Assert.Equal(["first:payload", "second:payload"], calls); - Assert.Same(eventData, first.LastEvent); - Assert.Same(eventData, second.LastEvent); + Assert.Null(exception); + Assert.Equal(["survivor:payload"], calls); } [Fact] - public void Publish_WhenSyncListenerThrows_PropagatesException() + public async Task PublishAsync_PreCancelledToken_Throws() { using var eventBus = new EventBusService(); IEventBus bus = eventBus; - var expected = new InvalidOperationException("sync failure"); - bus.RegisterListener(new ThrowingSyncListener(expected)); - - var actual = Assert.Throws(() => bus.Publish(new TestEvent("payload"))); + bus.RegisterListener(new RecordingListener("x", [])); + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); - Assert.Same(expected, actual); + await Assert.ThrowsAnyAsync(() => bus.PublishAsync(new TestEvent("payload"), cts.Token) + ); } [Fact] - public async Task PublishAsync_NoAsyncListeners_Completes() + public async Task PublishAsync_ListenerCancellation_IsNotSwallowed() { using var eventBus = new EventBusService(); IEventBus bus = eventBus; + using var cts = new CancellationTokenSource(); + bus.RegisterListener(new SelfCancellingListener(cts)); - var exception = - await Record.ExceptionAsync(() => bus.PublishAsync(new TestEvent("ignored"), CancellationToken.None)); - - Assert.Null(exception); + await Assert.ThrowsAnyAsync(() => bus.PublishAsync(new TestEvent("payload"), cts.Token) + ); } [Fact] - public async Task PublishAsync_PassesCancellationToken() + public async Task RegisterListener_DisposeToken_StopsDelivery() { using var eventBus = new EventBusService(); IEventBus bus = eventBus; - using var cancellationTokenSource = new CancellationTokenSource(); - var listener = new AsyncListener("listener", []); - bus.RegisterAsyncListener(listener); + var calls = new List(); + var token = bus.RegisterListener(new RecordingListener("once", calls)); - await bus.PublishAsync(new TestEvent("payload"), cancellationTokenSource.Token); + await bus.PublishAsync(new TestEvent("first"), CancellationToken.None); + token.Dispose(); + await bus.PublishAsync(new TestEvent("second"), CancellationToken.None); - Assert.Equal(cancellationTokenSource.Token, listener.CancellationToken); + Assert.Equal(["once:first"], calls); } [Fact] - public async Task PublishAsync_RegisteredAsyncListeners_InvokesEachInRegistrationOrder() + public async Task Subscribe_DelegateHandler_ReceivesAndUnsubscribes() { using var eventBus = new EventBusService(); IEventBus bus = eventBus; - var calls = new List(); - var first = new AsyncListener("first", calls); - var second = new AsyncListener("second", calls); - bus.RegisterAsyncListener(first); - bus.RegisterAsyncListener(second); - var eventData = new TestEvent("payload"); + var received = new List(); + var token = bus.Subscribe((e, _) => + { + received.Add(e.Payload); - await bus.PublishAsync(eventData, CancellationToken.None); + return Task.CompletedTask; + } + ); + + await bus.PublishAsync(new TestEvent("first"), CancellationToken.None); + token.Dispose(); + await bus.PublishAsync(new TestEvent("second"), CancellationToken.None); - Assert.Equal(["first:payload", "second:payload"], calls); - Assert.Same(eventData, first.LastEvent); - Assert.Same(eventData, second.LastEvent); + Assert.Equal(["first"], received); } [Fact] - public async Task PublishAsync_WhenAsyncListenerThrows_PropagatesException() + public void Publish_Sync_DeliversToAllListeners() { using var eventBus = new EventBusService(); IEventBus bus = eventBus; - var expected = new InvalidOperationException("async failure"); - bus.RegisterAsyncListener(new ThrowingAsyncListener(expected)); + var calls = new List(); + bus.RegisterListener(new RecordingListener("a", calls)); + bus.RegisterListener(new RecordingListener("b", calls)); + + bus.Publish(new TestEvent("payload")); + + Assert.Contains("a:payload", calls); + Assert.Contains("b:payload", calls); + } + + private sealed record TestEvent(string Payload) : IEvent; - var actual = await Assert.ThrowsAsync( - () => bus.PublishAsync(new TestEvent("payload"), CancellationToken.None) - ); + private sealed record OtherEvent(int Value) : IEvent; - Assert.Same(expected, actual); + private sealed class RecordingListener : IEventListener + { + private readonly List _calls; + private readonly string _name; + + public RecordingListener(string name, List calls) + { + _name = name; + _calls = calls; + } + + public TestEvent? LastEvent { get; private set; } + + public Task HandleAsync(TestEvent eventData, CancellationToken cancellationToken = default) + { + LastEvent = eventData; + _calls.Add($"{_name}:{eventData.Payload}"); + + return Task.CompletedTask; + } + } + + private sealed class OtherRecordingListener : IEventListener + { + public OtherEvent? LastEvent { get; private set; } + + public Task HandleAsync(OtherEvent eventData, CancellationToken cancellationToken = default) + { + LastEvent = eventData; + + return Task.CompletedTask; + } + } + + private sealed class CatchAllListener : IEventListener + { + public List Received { get; } = []; + + public Task HandleAsync(IEvent eventData, CancellationToken cancellationToken = default) + { + Received.Add(eventData); + + return Task.CompletedTask; + } + } + + private sealed class ThrowingListener : IEventListener + { + public Task HandleAsync(TestEvent eventData, CancellationToken cancellationToken = default) + { + throw new InvalidOperationException("listener failure"); + } + } + + private sealed class SelfCancellingListener : IEventListener + { + private readonly CancellationTokenSource _cts; + + public SelfCancellingListener(CancellationTokenSource cts) + { + _cts = cts; + } + + public Task HandleAsync(TestEvent eventData, CancellationToken cancellationToken = default) + { + _cts.Cancel(); + cancellationToken.ThrowIfCancellationRequested(); + + return Task.CompletedTask; + } + } + + private sealed class SlowListener : IEventListener + { + public async Task HandleAsync(TestEvent eventData, CancellationToken cancellationToken = default) + { + await Task.Delay(TimeSpan.FromMilliseconds(40), cancellationToken); + } + } + + [Collection(SerilogEventSinkCollection.Name)] + public class Telemetry + { + [Fact] + public async Task PublishAsync_SlowListener_LogsWarning() + { + var sink = new CapturingLogSink(); + var original = Log.Logger; + Log.Logger = new LoggerConfiguration() + .MinimumLevel.Verbose() + .WriteTo.Sink(sink) + .CreateLogger(); + + try + { + using var eventBus = new EventBusService( + new EventBusOptions { SlowListenerThreshold = TimeSpan.FromMilliseconds(5) } + ); + IEventBus bus = eventBus; + bus.RegisterListener(new SlowListener()); + + await bus.PublishAsync(new TestEvent("payload"), CancellationToken.None); + + Assert.Contains( + sink.Events, + e => e.Level == LogEventLevel.Warning && e.MessageTemplate.Text.Contains("Slow event listener") + ); + } + finally + { + (Log.Logger as IDisposable)?.Dispose(); + Log.Logger = original; + } + } } } diff --git a/tests/SquidStd.Tests/Services/Core/EventListenerActivatorTests.cs b/tests/SquidStd.Tests/Services/Core/EventListenerActivatorTests.cs new file mode 100644 index 00000000..e2cde411 --- /dev/null +++ b/tests/SquidStd.Tests/Services/Core/EventListenerActivatorTests.cs @@ -0,0 +1,55 @@ +using DryIoc; +using SquidStd.Abstractions.Extensions.Events; +using SquidStd.Core.Interfaces.Events; +using SquidStd.Services.Core.Services; + +namespace SquidStd.Tests.Services.Core; + +public class EventListenerActivatorTests +{ + [Fact] + public async Task StartAsync_SubscribesRegisteredListeners() + { + using var container = new Container(); + var bus = new EventBusService(); + container.RegisterInstance(bus); + container.RegisterEventListener(); + + var activator = new EventListenerActivator(container, bus); + await activator.StartAsync(CancellationToken.None); + + await bus.PublishAsync(new PingEvent("hello"), CancellationToken.None); + + var listener = container.Resolve(); + Assert.NotNull(listener.LastEvent); + Assert.Equal("hello", listener.LastEvent.Message); + } + + [Fact] + public async Task StartAsync_NoRegistrations_DoesNotThrow() + { + using var container = new Container(); + var bus = new EventBusService(); + container.RegisterInstance(bus); + + var activator = new EventListenerActivator(container, bus); + + var exception = await Record.ExceptionAsync(() => activator.StartAsync(CancellationToken.None).AsTask()); + + Assert.Null(exception); + } + + private sealed record PingEvent(string Message) : IEvent; + + private sealed class PingListener : IEventListener + { + public PingEvent? LastEvent { get; private set; } + + public Task HandleAsync(PingEvent eventData, CancellationToken cancellationToken = default) + { + LastEvent = eventData; + + return Task.CompletedTask; + } + } +} diff --git a/tests/SquidStd.Tests/Services/Core/JobSystemServiceTests.cs b/tests/SquidStd.Tests/Services/Core/JobSystemServiceTests.cs index cdcaa8fc..2b0477b2 100644 --- a/tests/SquidStd.Tests/Services/Core/JobSystemServiceTests.cs +++ b/tests/SquidStd.Tests/Services/Core/JobSystemServiceTests.cs @@ -1,3 +1,4 @@ +using SquidStd.Core.Data.Jobs; using SquidStd.Core.Interfaces.Jobs; using SquidStd.Services.Core.Services; @@ -46,8 +47,7 @@ public async Task ScheduleAsync_ManyJobs_AllComplete() { var value = i; tasks.Add( - system.ScheduleAsync( - () => + system.ScheduleAsync(() => { lock (sync) { @@ -72,8 +72,8 @@ public async Task ScheduleAsync_ThrowingAction_PropagatesExceptionToAwaiter() IJobSystem system = jobs; await jobs.StartAsync(CancellationToken.None); - await Assert.ThrowsAsync( - () => system.ScheduleAsync(() => throw new InvalidOperationException("boom")) + await Assert.ThrowsAsync(() => + system.ScheduleAsync(() => throw new InvalidOperationException("boom")) ); } @@ -102,8 +102,7 @@ public async Task ScheduleAsync_TokenCancelledBeforePickup_TransitionsToCanceled using var cancellationTokenSource = new CancellationTokenSource(); await jobs.StartAsync(CancellationToken.None); - _ = system.ScheduleAsync( - () => + _ = system.ScheduleAsync(() => { firstStarted.Set(); gate.Wait(TimeSpan.FromSeconds(2)); @@ -128,8 +127,7 @@ public async Task StopAsync_CancelsQueuedJobsAndPreventsNewSchedules() using var firstStarted = new ManualResetEventSlim(false); await jobs.StartAsync(CancellationToken.None); - _ = system.ScheduleAsync( - () => + _ = system.ScheduleAsync(() => { firstStarted.Set(); gate.Wait(TimeSpan.FromSeconds(2)); @@ -142,11 +140,7 @@ public async Task StopAsync_CancelsQueuedJobsAndPreventsNewSchedules() gate.Set(); await Assert.ThrowsAsync(() => queued); - Assert.Throws( - () => - { - _ = system.ScheduleAsync(() => { }); - } + Assert.Throws(() => { _ = system.ScheduleAsync(() => { }); } ); jobs.Dispose(); } @@ -168,13 +162,15 @@ public void WorkerCount_UsesExplicitValue() } private static JobSystemService NewService(int workerCount) - => new( - new() + { + return new JobSystemService( + new JobsConfig { WorkerThreadCount = workerCount, ShutdownTimeoutSeconds = 1.0 } ); + } // CompletedCount is incremented by the worker after the scheduled task's completion fires, so // awaiting ScheduleAsync does not guarantee the counter is updated yet. Wait for it (bounded). diff --git a/tests/SquidStd.Tests/Services/Core/MainThreadDispatcherServiceTests.cs b/tests/SquidStd.Tests/Services/Core/MainThreadDispatcherServiceTests.cs index fb13ca65..52cd0af7 100644 --- a/tests/SquidStd.Tests/Services/Core/MainThreadDispatcherServiceTests.cs +++ b/tests/SquidStd.Tests/Services/Core/MainThreadDispatcherServiceTests.cs @@ -13,8 +13,7 @@ public void DrainPending_BudgetExceededAfterFirstCallback_DefersRest() { IMainThreadDispatcher dispatcher = new MainThreadDispatcherService(); var calls = new List(); - dispatcher.Post( - () => + dispatcher.Post(() => { calls.Add(1); Thread.Sleep(5); @@ -35,8 +34,7 @@ public void DrainPending_CallbackPostsAnother_DefersToNextDrain() { IMainThreadDispatcher dispatcher = new MainThreadDispatcherService(); var calls = new List(); - dispatcher.Post( - () => + dispatcher.Post(() => { calls.Add(1); dispatcher.Post(() => calls.Add(2)); diff --git a/tests/SquidStd.Tests/Services/Core/MetricsCollectionServiceTests.cs b/tests/SquidStd.Tests/Services/Core/MetricsCollectionServiceTests.cs index 22e86e9a..800b1277 100644 --- a/tests/SquidStd.Tests/Services/Core/MetricsCollectionServiceTests.cs +++ b/tests/SquidStd.Tests/Services/Core/MetricsCollectionServiceTests.cs @@ -7,68 +7,10 @@ namespace SquidStd.Tests.Services.Core; public class MetricsCollectionServiceTests { - private sealed class CountingMetricProvider : IMetricProvider - { - private readonly string _metricName; - private readonly double _value; - private int _collectionCount; - - public CountingMetricProvider(string providerName, string metricName, double value) - { - ProviderName = providerName; - _metricName = metricName; - _value = value; - } - - public int CollectionCount => Volatile.Read(ref _collectionCount); - - public string ProviderName { get; } - - public ValueTask> CollectAsync(CancellationToken cancellationToken = default) - { - Interlocked.Increment(ref _collectionCount); - - return ValueTask.FromResult>([new(_metricName, _value)]); - } - } - - private sealed class ThrowingMetricProvider : IMetricProvider - { - public ThrowingMetricProvider(string providerName) - { - ProviderName = providerName; - } - - public string ProviderName { get; } - - public ValueTask> CollectAsync(CancellationToken cancellationToken = default) - => throw new InvalidOperationException("Synthetic test failure."); - } - - private sealed class MetricsCollectedSyncListener : ISyncEventListener - { - public MetricsCollectedEvent? LastEvent { get; private set; } - - public void Handle(MetricsCollectedEvent eventData) - => LastEvent = eventData; - } - - private sealed class MetricsCollectedAsyncListener : IAsyncEventListener - { - public MetricsCollectedEvent? LastEvent { get; private set; } - - public Task HandleAsync(MetricsCollectedEvent eventData, CancellationToken cancellationToken) - { - LastEvent = eventData; - - return Task.CompletedTask; - } - } - [Fact] public void GetSnapshot_WhenNotStarted_ReturnsEmptySnapshot() { - using var service = new MetricsCollectionService([], new()); + using var service = new MetricsCollectionService([], new MetricsConfig()); var snapshot = service.GetSnapshot(); @@ -81,7 +23,7 @@ public async Task GetStatus_ReturnsLatestMetricsSnapshot() { using var service = new MetricsCollectionService( [new CountingMetricProvider("jobs", "completed.total", 11)], - new() + new MetricsConfig { IntervalMilliseconds = 10, LogEnabled = false @@ -103,7 +45,7 @@ public async Task StartAsync_CollectsMetricsFromRegisteredProviders() { using var service = new MetricsCollectionService( [new CountingMetricProvider("jobs", "pending.total", 7)], - new() + new MetricsConfig { IntervalMilliseconds = 10, LogEnabled = false @@ -126,13 +68,13 @@ public async Task StartAsync_PublishesMetricsCollectedEventThroughEventBus() { using var eventBus = new EventBusService(); IEventBus bus = eventBus; - var syncListener = new MetricsCollectedSyncListener(); - var asyncListener = new MetricsCollectedAsyncListener(); - bus.RegisterListener(syncListener); - bus.RegisterAsyncListener(asyncListener); + var firstListener = new MetricsCollectedListener(); + var secondListener = new MetricsCollectedListener(); + bus.RegisterListener(firstListener); + bus.RegisterListener(secondListener); using var service = new MetricsCollectionService( [new CountingMetricProvider("events", "published.total", 5)], - new() + new MetricsConfig { IntervalMilliseconds = 1000, LogEnabled = false @@ -141,14 +83,14 @@ [new CountingMetricProvider("events", "published.total", 5)], ); await service.StartAsync(CancellationToken.None); - await WaitUntilAsync(() => syncListener.LastEvent is not null && asyncListener.LastEvent is not null); + await WaitUntilAsync(() => firstListener.LastEvent is not null && secondListener.LastEvent is not null); await service.StopAsync(CancellationToken.None); - Assert.NotNull(syncListener.LastEvent); - Assert.NotNull(asyncListener.LastEvent); - Assert.Same(syncListener.LastEvent, asyncListener.LastEvent); - Assert.Same(service.GetStatus(), syncListener.LastEvent.Snapshot); - Assert.Equal(5, syncListener.LastEvent.Snapshot.Metrics["events.published.total"].Value); + Assert.NotNull(firstListener.LastEvent); + Assert.NotNull(secondListener.LastEvent); + Assert.Same(firstListener.LastEvent, secondListener.LastEvent); + Assert.Same(service.GetStatus(), firstListener.LastEvent.Snapshot); + Assert.Equal(5, firstListener.LastEvent.Snapshot.Metrics["events.published.total"].Value); } [Fact] @@ -157,7 +99,7 @@ public async Task StartAsync_WhenDisabled_DoesNotCollectMetrics() var provider = new CountingMetricProvider("jobs", "pending.total", 7); using var service = new MetricsCollectionService( [provider], - new() + new MetricsConfig { Enabled = false, IntervalMilliseconds = 10, @@ -180,7 +122,7 @@ public async Task StartAsync_WhenProviderThrows_ContinuesCollectingOtherProvider new ThrowingMetricProvider("broken"), new CountingMetricProvider("timer", "callbacks.total", 3) ], - new() + new MetricsConfig { IntervalMilliseconds = 10, LogEnabled = false @@ -202,7 +144,7 @@ public async Task StopAsync_StopsCollectionLoop() var provider = new CountingMetricProvider("bus", "dispatch.total", 1); using var service = new MetricsCollectionService( [provider], - new() + new MetricsConfig { IntervalMilliseconds = 10, LogEnabled = false @@ -235,4 +177,56 @@ private static async Task WaitUntilAsync(Func predicate) Assert.Fail("Condition was not met before timeout."); } + + private sealed class CountingMetricProvider : IMetricProvider + { + private readonly string _metricName; + private readonly double _value; + private int _collectionCount; + + public CountingMetricProvider(string providerName, string metricName, double value) + { + ProviderName = providerName; + _metricName = metricName; + _value = value; + } + + public int CollectionCount => Volatile.Read(ref _collectionCount); + + public string ProviderName { get; } + + public ValueTask> CollectAsync(CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref _collectionCount); + + return ValueTask.FromResult>([new MetricSample(_metricName, _value)]); + } + } + + private sealed class ThrowingMetricProvider : IMetricProvider + { + public ThrowingMetricProvider(string providerName) + { + ProviderName = providerName; + } + + public string ProviderName { get; } + + public ValueTask> CollectAsync(CancellationToken cancellationToken = default) + { + throw new InvalidOperationException("Synthetic test failure."); + } + } + + private sealed class MetricsCollectedListener : IEventListener + { + public MetricsCollectedEvent? LastEvent { get; private set; } + + public Task HandleAsync(MetricsCollectedEvent eventData, CancellationToken cancellationToken = default) + { + LastEvent = eventData; + + return Task.CompletedTask; + } + } } diff --git a/tests/SquidStd.Tests/Services/Core/Scheduling/CronSchedulerServiceTests.cs b/tests/SquidStd.Tests/Services/Core/Scheduling/CronSchedulerServiceTests.cs index 02ee3891..ff7ea983 100644 --- a/tests/SquidStd.Tests/Services/Core/Scheduling/CronSchedulerServiceTests.cs +++ b/tests/SquidStd.Tests/Services/Core/Scheduling/CronSchedulerServiceTests.cs @@ -1,4 +1,5 @@ using Cronos; +using SquidStd.Core.Data.Timing; using SquidStd.Services.Core.Services; using SquidStd.Services.Core.Services.Scheduling; using SquidStd.Tests.Support; @@ -103,7 +104,7 @@ public void Jobs_ExposesRegisteredJob() public void RealTimerWheel_FiresJob_WhenAdvancedPastAMinute() { var timer = new TimerWheelService( - new() + new TimerWheelConfig { TickDuration = TimeSpan.FromMilliseconds(8), WheelSize = 512 diff --git a/tests/SquidStd.Tests/Services/Core/Scheduling/TimerWheelPumpServiceTests.cs b/tests/SquidStd.Tests/Services/Core/Scheduling/TimerWheelPumpServiceTests.cs index 12cc50d1..e2dc7208 100644 --- a/tests/SquidStd.Tests/Services/Core/Scheduling/TimerWheelPumpServiceTests.cs +++ b/tests/SquidStd.Tests/Services/Core/Scheduling/TimerWheelPumpServiceTests.cs @@ -1,3 +1,4 @@ +using SquidStd.Core.Data.Timing; using SquidStd.Services.Core.Services.Scheduling; using SquidStd.Tests.Support; @@ -7,15 +8,22 @@ public class TimerWheelPumpServiceTests { [Fact] public void Ctor_NonPositiveInterval_Throws() - => Assert.Throws( - () => new TimerWheelPumpService(new FakeTimerService(), new() { PumpInterval = TimeSpan.Zero }) + { + Assert.Throws(() => new TimerWheelPumpService( + new FakeTimerService(), + new TimerWheelPumpConfig { PumpInterval = TimeSpan.Zero } + ) ); + } [Fact] public async Task Pump_AdvancesTheWheel() { var timer = new FakeTimerService(); - var pump = new TimerWheelPumpService(timer, new() { PumpInterval = TimeSpan.FromMilliseconds(20) }); + var pump = new TimerWheelPumpService( + timer, + new TimerWheelPumpConfig { PumpInterval = TimeSpan.FromMilliseconds(20) } + ); await pump.StartAsync(); diff --git a/tests/SquidStd.Tests/Services/Core/SquidStdLogRollingIntervalExtensionsTests.cs b/tests/SquidStd.Tests/Services/Core/SquidStdLogRollingIntervalExtensionsTests.cs index 900c5a58..28edec22 100644 --- a/tests/SquidStd.Tests/Services/Core/SquidStdLogRollingIntervalExtensionsTests.cs +++ b/tests/SquidStd.Tests/Services/Core/SquidStdLogRollingIntervalExtensionsTests.cs @@ -6,19 +6,24 @@ namespace SquidStd.Tests.Services.Core; public class SquidStdLogRollingIntervalExtensionsTests { - [Theory, InlineData(SquidStdLogRollingIntervalType.Infinite, RollingInterval.Infinite), - InlineData(SquidStdLogRollingIntervalType.Year, RollingInterval.Year), - InlineData(SquidStdLogRollingIntervalType.Month, RollingInterval.Month), - InlineData(SquidStdLogRollingIntervalType.Day, RollingInterval.Day), - InlineData(SquidStdLogRollingIntervalType.Hour, RollingInterval.Hour), - InlineData(SquidStdLogRollingIntervalType.Minute, RollingInterval.Minute)] + [Theory] + [InlineData(SquidStdLogRollingIntervalType.Infinite, RollingInterval.Infinite)] + [InlineData(SquidStdLogRollingIntervalType.Year, RollingInterval.Year)] + [InlineData(SquidStdLogRollingIntervalType.Month, RollingInterval.Month)] + [InlineData(SquidStdLogRollingIntervalType.Day, RollingInterval.Day)] + [InlineData(SquidStdLogRollingIntervalType.Hour, RollingInterval.Hour)] + [InlineData(SquidStdLogRollingIntervalType.Minute, RollingInterval.Minute)] public void ToSerilogRollingInterval_KnownIntervals_MapExpected( SquidStdLogRollingIntervalType input, RollingInterval expected ) - => Assert.Equal(expected, input.ToSerilogRollingInterval()); + { + Assert.Equal(expected, input.ToSerilogRollingInterval()); + } [Fact] public void ToSerilogRollingInterval_UnmappedInterval_FallsBackToDay() - => Assert.Equal(RollingInterval.Day, ((SquidStdLogRollingIntervalType)255).ToSerilogRollingInterval()); + { + Assert.Equal(RollingInterval.Day, ((SquidStdLogRollingIntervalType)255).ToSerilogRollingInterval()); + } } diff --git a/tests/SquidStd.Tests/Services/Core/TimerWheelServiceTests.cs b/tests/SquidStd.Tests/Services/Core/TimerWheelServiceTests.cs index 68f4aef6..d8e3947f 100644 --- a/tests/SquidStd.Tests/Services/Core/TimerWheelServiceTests.cs +++ b/tests/SquidStd.Tests/Services/Core/TimerWheelServiceTests.cs @@ -1,3 +1,4 @@ +using SquidStd.Core.Data.Timing; using SquidStd.Core.Interfaces.Timing; using SquidStd.Services.Core.Services; @@ -22,9 +23,11 @@ public void CallbackException_DoesNotStopOtherTimers() [Fact] public void Ctor_InvalidConfig_Throws() { - Assert.Throws(() => new TimerWheelService(new() { TickDuration = TimeSpan.Zero })); - Assert.Throws( - () => new TimerWheelService(new() { TickDuration = TimeSpan.FromMilliseconds(8), WheelSize = 0 }) + Assert.Throws(() => + new TimerWheelService(new TimerWheelConfig { TickDuration = TimeSpan.Zero }) + ); + Assert.Throws(() => + new TimerWheelService(new TimerWheelConfig { TickDuration = TimeSpan.FromMilliseconds(8), WheelSize = 0 }) ); } @@ -149,11 +152,13 @@ public void UpdateTicksDelta_AdvancesByWholeTicks() } private static TimerWheelService NewService(int tickDurationMs = 8, int wheelSize = 16) - => new( - new() + { + return new TimerWheelService( + new TimerWheelConfig { TickDuration = TimeSpan.FromMilliseconds(tickDurationMs), WheelSize = wheelSize } ); + } } diff --git a/tests/SquidStd.Tests/SquidStd.Tests.csproj b/tests/SquidStd.Tests/SquidStd.Tests.csproj index 28f9c8df..4c62678e 100644 --- a/tests/SquidStd.Tests/SquidStd.Tests.csproj +++ b/tests/SquidStd.Tests/SquidStd.Tests.csproj @@ -10,6 +10,7 @@ + @@ -36,12 +37,16 @@ + + + + diff --git a/tests/SquidStd.Tests/Storage/FileStorageServiceTests.cs b/tests/SquidStd.Tests/Storage/FileStorageServiceTests.cs index bfcd3907..2254f41f 100644 --- a/tests/SquidStd.Tests/Storage/FileStorageServiceTests.cs +++ b/tests/SquidStd.Tests/Storage/FileStorageServiceTests.cs @@ -1,4 +1,5 @@ using System.Text; +using SquidStd.Storage.Abstractions.Data.Config; using SquidStd.Storage.Services; using SquidStd.Tests.Support; @@ -6,18 +7,11 @@ namespace SquidStd.Tests.Storage; public class FileStorageServiceTests { - private sealed class SampleObject - { - public string Name { get; set; } = string.Empty; - - public int Value { get; set; } - } - [Fact] public async Task DeleteAsync_RemovesStoredValue() { using var temp = new TempDirectory(); - var service = new FileStorageService(new() { RootDirectory = temp.Path }); + var service = new FileStorageService(new StorageConfig { RootDirectory = temp.Path }); await service.SaveAsync("cache/value.bin", new byte[] { 1, 2, 3 }); var deleted = await service.DeleteAsync("cache/value.bin"); @@ -31,7 +25,7 @@ public async Task DeleteAsync_RemovesStoredValue() public async Task ListKeysAsync_EmptyStore_ReturnsEmpty() { using var temp = new TempDirectory(); - var service = new FileStorageService(new() { RootDirectory = temp.Path }); + var service = new FileStorageService(new StorageConfig { RootDirectory = temp.Path }); Assert.Empty(await ToListAsync(service.ListKeysAsync())); } @@ -40,7 +34,7 @@ public async Task ListKeysAsync_EmptyStore_ReturnsEmpty() public async Task ListKeysAsync_ReturnsSavedKeys_AndFiltersByPrefix() { using var temp = new TempDirectory(); - var service = new FileStorageService(new() { RootDirectory = temp.Path }); + var service = new FileStorageService(new StorageConfig { RootDirectory = temp.Path }); await service.SaveAsync("a/one.bin", new byte[] { 1 }); await service.SaveAsync("a/two.bin", new byte[] { 2 }); await service.SaveAsync("b/three.bin", new byte[] { 3 }); @@ -56,7 +50,7 @@ public async Task ListKeysAsync_ReturnsSavedKeys_AndFiltersByPrefix() public async Task ObjectStorage_ListKeysAsync_ReturnsSavedKeys() { using var temp = new TempDirectory(); - var storage = new FileStorageService(new() { RootDirectory = temp.Path }); + var storage = new FileStorageService(new StorageConfig { RootDirectory = temp.Path }); var objects = new YamlObjectStorageService(storage); await objects.SaveAsync("objects/x.yaml", new SampleObject { Name = "x", Value = 1 }); @@ -69,7 +63,7 @@ public async Task ObjectStorage_ListKeysAsync_ReturnsSavedKeys() public async Task ObjectStorage_SaveAsync_LoadAsync_RoundTripsYamlObject() { using var temp = new TempDirectory(); - var storage = new FileStorageService(new() { RootDirectory = temp.Path }); + var storage = new FileStorageService(new StorageConfig { RootDirectory = temp.Path }); var objects = new YamlObjectStorageService(storage); var expected = new SampleObject { @@ -90,7 +84,7 @@ public async Task ObjectStorage_SaveAsync_LoadAsync_RoundTripsYamlObject() public async Task SaveAsync_LoadAsync_RoundTripsBytes() { using var temp = new TempDirectory(); - var service = new FileStorageService(new() { RootDirectory = temp.Path }); + var service = new FileStorageService(new StorageConfig { RootDirectory = temp.Path }); var data = Encoding.UTF8.GetBytes("hello storage"); await service.SaveAsync("profiles/main.bin", data); @@ -101,11 +95,14 @@ public async Task SaveAsync_LoadAsync_RoundTripsBytes() Assert.True(await service.ExistsAsync("profiles/main.bin")); } - [Theory, InlineData("../escape.bin"), InlineData("/absolute.bin"), InlineData("nested/../../escape.bin")] + [Theory] + [InlineData("../escape.bin")] + [InlineData("/absolute.bin")] + [InlineData("nested/../../escape.bin")] public async Task SaveAsync_RejectsUnsafeKeys(string key) { using var temp = new TempDirectory(); - var service = new FileStorageService(new() { RootDirectory = temp.Path }); + var service = new FileStorageService(new StorageConfig { RootDirectory = temp.Path }); await Assert.ThrowsAsync(() => service.SaveAsync(key, new byte[] { 1 }).AsTask()); } @@ -121,4 +118,11 @@ private static async Task> ToListAsync(IAsyncEnumerable sou return list; } + + private sealed class SampleObject + { + public string Name { get; set; } = string.Empty; + + public int Value { get; set; } + } } diff --git a/tests/SquidStd.Tests/Storage/S3/MinioContainerFixture.cs b/tests/SquidStd.Tests/Storage/S3/MinioContainerFixture.cs index f7dda433..368a5826 100644 --- a/tests/SquidStd.Tests/Storage/S3/MinioContainerFixture.cs +++ b/tests/SquidStd.Tests/Storage/S3/MinioContainerFixture.cs @@ -3,7 +3,7 @@ namespace SquidStd.Tests.Storage.S3; /// -/// Starts a MinIO container once for the whole collection and exposes its endpoint and credentials. +/// Starts a MinIO container once for the whole collection and exposes its endpoint and credentials. /// public sealed class MinioContainerFixture : IAsyncLifetime { @@ -18,10 +18,14 @@ public sealed class MinioContainerFixture : IAsyncLifetime public string SecretKey => _container.GetSecretKey(); public Task DisposeAsync() - => _container.DisposeAsync().AsTask(); + { + return _container.DisposeAsync().AsTask(); + } public Task InitializeAsync() - => _container.StartAsync(); + { + return _container.StartAsync(); + } } [CollectionDefinition(Name)] diff --git a/tests/SquidStd.Tests/Storage/S3/S3StorageServiceTests.cs b/tests/SquidStd.Tests/Storage/S3/S3StorageServiceTests.cs index 3d764955..cf36ea79 100644 --- a/tests/SquidStd.Tests/Storage/S3/S3StorageServiceTests.cs +++ b/tests/SquidStd.Tests/Storage/S3/S3StorageServiceTests.cs @@ -1,4 +1,6 @@ using System.Text; +using SquidStd.Aws.Abstractions.Data.Config; +using SquidStd.Storage.S3.Data.Config; using SquidStd.Storage.S3.Services; namespace SquidStd.Tests.Storage.S3; @@ -15,9 +17,13 @@ public S3StorageServiceTests(MinioContainerFixture fixture) [Fact] public void Ctor_MissingServiceUrl_Throws() - => Assert.ThrowsAny( - () => new S3StorageService(new() { Aws = new() { AccessKey = "a", SecretKey = "b" }, Bucket = "c" }) + { + Assert.ThrowsAny(() => + new S3StorageService( + new S3StorageOptions { Aws = new AwsConfigEntry { AccessKey = "a", SecretKey = "b" }, Bucket = "c" } + ) ); + } [Fact] public async Task Exists_And_Delete() @@ -54,7 +60,9 @@ public async Task ListKeysAsync_ReturnsObjectsUnderPrefix() [Fact] public async Task Load_MissingKey_ReturnsNull() - => Assert.Null(await NewService().LoadAsync(Key())); + { + Assert.Null(await NewService().LoadAsync(Key())); + } [Fact] public async Task SaveThenLoad_RoundTrips_AndCreatesBucket() @@ -70,13 +78,16 @@ public async Task SaveThenLoad_RoundTrips_AndCreatesBucket() } private static string Key() - => "k-" + Guid.NewGuid().ToString("N"); + { + return "k-" + Guid.NewGuid().ToString("N"); + } private S3StorageService NewService() - => new( - new() + { + return new S3StorageService( + new S3StorageOptions { - Aws = new() + Aws = new AwsConfigEntry { ServiceUrl = _fixture.ServiceUrl, AccessKey = _fixture.AccessKey, @@ -85,4 +96,5 @@ private S3StorageService NewService() Bucket = "squidstd-tests" } ); + } } diff --git a/tests/SquidStd.Tests/Storage/StorageRegistrationTests.cs b/tests/SquidStd.Tests/Storage/StorageRegistrationTests.cs index a7d7f88c..6d35c2fd 100644 --- a/tests/SquidStd.Tests/Storage/StorageRegistrationTests.cs +++ b/tests/SquidStd.Tests/Storage/StorageRegistrationTests.cs @@ -1,4 +1,5 @@ using DryIoc; +using SquidStd.Storage.Abstractions.Data.Config; using SquidStd.Storage.Abstractions.Interfaces; using SquidStd.Storage.Extensions; @@ -12,7 +13,7 @@ public async Task AddFileStorage_ResolvesAndRoundTrips() var root = Path.Combine(Path.GetTempPath(), "squidstd-storage-" + Guid.NewGuid().ToString("N")); using var container = new Container(); - container.AddFileStorage(new() { RootDirectory = root }); + container.AddFileStorage(new StorageConfig { RootDirectory = root }); var storage = container.Resolve(); Assert.NotNull(container.Resolve()); diff --git a/tests/SquidStd.Tests/Support/AppendingMiddleware.cs b/tests/SquidStd.Tests/Support/AppendingMiddleware.cs index fc63af15..a3578f8f 100644 --- a/tests/SquidStd.Tests/Support/AppendingMiddleware.cs +++ b/tests/SquidStd.Tests/Support/AppendingMiddleware.cs @@ -4,8 +4,8 @@ namespace SquidStd.Tests.Support; /// -/// Test middleware that appends a marker byte to every payload, on both receive and send paths. -/// Used to verify pipeline ordering and transformation. +/// Test middleware that appends a marker byte to every payload, on both receive and send paths. +/// Used to verify pipeline ordering and transformation. /// public sealed class AppendingMiddleware : INetMiddleware { @@ -21,14 +21,18 @@ public ValueTask> ProcessAsync( ReadOnlyMemory data, CancellationToken cancellationToken = default ) - => ValueTask.FromResult>(Append(data)); + { + return ValueTask.FromResult>(Append(data)); + } public ValueTask> ProcessSendAsync( SquidStdTcpClient? client, ReadOnlyMemory data, CancellationToken cancellationToken = default ) - => ValueTask.FromResult>(Append(data)); + { + return ValueTask.FromResult>(Append(data)); + } private byte[] Append(ReadOnlyMemory data) { diff --git a/tests/SquidStd.Tests/Support/CapturingLogSink.cs b/tests/SquidStd.Tests/Support/CapturingLogSink.cs new file mode 100644 index 00000000..d0beaf98 --- /dev/null +++ b/tests/SquidStd.Tests/Support/CapturingLogSink.cs @@ -0,0 +1,32 @@ +using Serilog.Core; +using Serilog.Events; + +namespace SquidStd.Tests.Support; + +/// +/// In-memory Serilog sink that records emitted log events for assertions. +/// +public sealed class CapturingLogSink : ILogEventSink +{ + private readonly List _events = []; + private readonly Lock _sync = new(); + + public IReadOnlyList Events + { + get + { + lock (_sync) + { + return [.. _events]; + } + } + } + + public void Emit(LogEvent logEvent) + { + lock (_sync) + { + _events.Add(logEvent); + } + } +} diff --git a/tests/SquidStd.Tests/Support/CountingXorCodec.cs b/tests/SquidStd.Tests/Support/CountingXorCodec.cs new file mode 100644 index 00000000..150defb6 --- /dev/null +++ b/tests/SquidStd.Tests/Support/CountingXorCodec.cs @@ -0,0 +1,36 @@ +using SquidStd.Network.Interfaces.Codecs; + +namespace SquidStd.Tests.Support; + +/// +/// Deterministic stateful test codec: XORs each byte with a counter-based keystream (seed + position). +/// Decode and Encode keep independent positions, mirroring the separate receive/send state of real +/// stream ciphers. Two codecs created with the same seed cancel out (one encodes, the other decodes). +/// +public sealed class CountingXorCodec : ITransportCodec +{ + private readonly byte _seed; + private int _decodePosition; + private int _encodePosition; + + public CountingXorCodec(byte seed = 0) + { + _seed = seed; + } + + public void Decode(Span buffer) + { + for (var i = 0; i < buffer.Length; i++) + { + buffer[i] ^= (byte)(_seed + _decodePosition++); + } + } + + public void Encode(Span buffer) + { + for (var i = 0; i < buffer.Length; i++) + { + buffer[i] ^= (byte)(_seed + _encodePosition++); + } + } +} diff --git a/tests/SquidStd.Tests/Support/DroppingMiddleware.cs b/tests/SquidStd.Tests/Support/DroppingMiddleware.cs index 448a5063..833f7002 100644 --- a/tests/SquidStd.Tests/Support/DroppingMiddleware.cs +++ b/tests/SquidStd.Tests/Support/DroppingMiddleware.cs @@ -4,8 +4,8 @@ namespace SquidStd.Tests.Support; /// -/// Test middleware that drops every payload by returning , -/// short-circuiting the pipeline. +/// Test middleware that drops every payload by returning , +/// short-circuiting the pipeline. /// public sealed class DroppingMiddleware : INetMiddleware { @@ -14,12 +14,16 @@ public ValueTask> ProcessAsync( ReadOnlyMemory data, CancellationToken cancellationToken = default ) - => ValueTask.FromResult(ReadOnlyMemory.Empty); + { + return ValueTask.FromResult(ReadOnlyMemory.Empty); + } public ValueTask> ProcessSendAsync( SquidStdTcpClient? client, ReadOnlyMemory data, CancellationToken cancellationToken = default ) - => ValueTask.FromResult(ReadOnlyMemory.Empty); + { + return ValueTask.FromResult(ReadOnlyMemory.Empty); + } } diff --git a/tests/SquidStd.Tests/Support/DummyGuidConverter.cs b/tests/SquidStd.Tests/Support/DummyGuidConverter.cs index e989ec5f..219e12d5 100644 --- a/tests/SquidStd.Tests/Support/DummyGuidConverter.cs +++ b/tests/SquidStd.Tests/Support/DummyGuidConverter.cs @@ -4,13 +4,17 @@ namespace SquidStd.Tests.Support; /// -/// No-op JSON converter used to exercise converter registration helpers. +/// No-op JSON converter used to exercise converter registration helpers. /// public class DummyGuidConverter : JsonConverter { public override Guid Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - => Guid.Empty; + { + return Guid.Empty; + } public override void Write(Utf8JsonWriter writer, Guid value, JsonSerializerOptions options) - => writer.WriteStringValue(value.ToString()); + { + writer.WriteStringValue(value.ToString()); + } } diff --git a/tests/SquidStd.Tests/Support/FakeCacheProvider.cs b/tests/SquidStd.Tests/Support/FakeCacheProvider.cs index d2f71128..2fc4bd35 100644 --- a/tests/SquidStd.Tests/Support/FakeCacheProvider.cs +++ b/tests/SquidStd.Tests/Support/FakeCacheProvider.cs @@ -4,7 +4,7 @@ namespace SquidStd.Tests.Support; /// -/// In-memory for tests. Ignores TTL expiry (records the last TTL seen). +/// In-memory for tests. Ignores TTL expiry (records the last TTL seen). /// public sealed class FakeCacheProvider : ICacheProvider { @@ -13,7 +13,9 @@ public sealed class FakeCacheProvider : ICacheProvider public TimeSpan? LastTtl { get; private set; } public Task ExistsAsync(string key, CancellationToken cancellationToken = default) - => Task.FromResult(_store.ContainsKey(key)); + { + return Task.FromResult(_store.ContainsKey(key)); + } public Task?> GetAsync(string key, CancellationToken cancellationToken = default) { @@ -26,7 +28,9 @@ public Task ExistsAsync(string key, CancellationToken cancellationToken = } public Task RemoveAsync(string key, CancellationToken cancellationToken = default) - => Task.FromResult(_store.TryRemove(key, out _)); + { + return Task.FromResult(_store.TryRemove(key, out _)); + } public Task SetAsync( string key, @@ -42,8 +46,12 @@ public Task SetAsync( } public ValueTask StartAsync(CancellationToken cancellationToken = default) - => ValueTask.CompletedTask; + { + return ValueTask.CompletedTask; + } public ValueTask StopAsync(CancellationToken cancellationToken = default) - => ValueTask.CompletedTask; + { + return ValueTask.CompletedTask; + } } diff --git a/tests/SquidStd.Tests/Support/FakeHealthCheck.cs b/tests/SquidStd.Tests/Support/FakeHealthCheck.cs index 93ccd25b..daf92e9a 100644 --- a/tests/SquidStd.Tests/Support/FakeHealthCheck.cs +++ b/tests/SquidStd.Tests/Support/FakeHealthCheck.cs @@ -4,14 +4,14 @@ namespace SquidStd.Tests.Support; /// -/// Configurable for tests: returns a fixed result, optionally after a -/// delay, or throws a configured exception. +/// Configurable for tests: returns a fixed result, optionally after a +/// delay, or throws a configured exception. /// public sealed class FakeHealthCheck : IHealthCheck { + private readonly TimeSpan _delay; private readonly HealthCheckResult? _result; private readonly Exception? _throw; - private readonly TimeSpan _delay; public FakeHealthCheck( string name, diff --git a/tests/SquidStd.Tests/Support/FakeNetworkConnection.cs b/tests/SquidStd.Tests/Support/FakeNetworkConnection.cs index 43937999..87ada584 100644 --- a/tests/SquidStd.Tests/Support/FakeNetworkConnection.cs +++ b/tests/SquidStd.Tests/Support/FakeNetworkConnection.cs @@ -4,26 +4,27 @@ namespace SquidStd.Tests.Support; /// -/// In-memory for testing sessions without sockets. -/// Records sent payloads and close calls; SendCallback can inject failures. +/// In-memory for testing sessions without sockets. +/// Records sent payloads and close calls; SendCallback can inject failures. /// public sealed class FakeNetworkConnection : INetworkConnection { private readonly List _sent = new(); - public long SessionId { get; } - public EndPoint? RemoteEndPoint { get; } - public bool IsConnected { get; private set; } = true; - public IReadOnlyList SentPayloads => _sent; - public int CloseCount { get; private set; } - public Func, Task>? SendCallback { get; set; } - public FakeNetworkConnection(long sessionId = 1, EndPoint? remoteEndPoint = null) { SessionId = sessionId; RemoteEndPoint = remoteEndPoint ?? new IPEndPoint(IPAddress.Loopback, 1234); } + public IReadOnlyList SentPayloads => _sent; + public int CloseCount { get; private set; } + public Func, Task>? SendCallback { get; set; } + + public long SessionId { get; } + public EndPoint? RemoteEndPoint { get; } + public bool IsConnected { get; private set; } = true; + public Task CloseAsync(CancellationToken cancellationToken = default) { CloseCount++; diff --git a/tests/SquidStd.Tests/Support/FakePlugin.cs b/tests/SquidStd.Tests/Support/FakePlugin.cs index 93569292..5f9934d5 100644 --- a/tests/SquidStd.Tests/Support/FakePlugin.cs +++ b/tests/SquidStd.Tests/Support/FakePlugin.cs @@ -5,26 +5,26 @@ namespace SquidStd.Tests.Support; /// -/// Minimal implementation used to exercise the plugin contract. +/// Minimal implementation used to exercise the plugin contract. /// public class FakePlugin : ISquidStdPlugin { /// - /// Gets the plugin metadata. + /// Gets the context received during the last call. + /// + public PluginContext? ReceivedContext { get; private set; } + + /// + /// Gets the plugin metadata. /// public PluginMetadata Metadata { get; } = new() { Id = "squidstd.fake", Name = "Fake Plugin", - Version = new(1, 2, 3), + Version = new Version(1, 2, 3), Author = "tests" }; - /// - /// Gets the context received during the last call. - /// - public PluginContext? ReceivedContext { get; private set; } - public void Configure(IContainer container, PluginContext context) { ReceivedContext = context; diff --git a/tests/SquidStd.Tests/Support/FakeStdService.cs b/tests/SquidStd.Tests/Support/FakeStdService.cs index 24e464d0..f6cb6261 100644 --- a/tests/SquidStd.Tests/Support/FakeStdService.cs +++ b/tests/SquidStd.Tests/Support/FakeStdService.cs @@ -3,12 +3,12 @@ namespace SquidStd.Tests.Support; /// -/// Minimal implementation tracking start/stop calls. +/// Minimal implementation tracking start/stop calls. /// public class FakeStdService : ISquidStdService { /// - /// Gets a value indicating whether the service is currently started. + /// Gets a value indicating whether the service is currently started. /// public bool Started { get; private set; } diff --git a/tests/SquidStd.Tests/Support/FakeTimeProvider.cs b/tests/SquidStd.Tests/Support/FakeTimeProvider.cs index bfbfea96..25d4fa9f 100644 --- a/tests/SquidStd.Tests/Support/FakeTimeProvider.cs +++ b/tests/SquidStd.Tests/Support/FakeTimeProvider.cs @@ -1,8 +1,8 @@ namespace SquidStd.Tests.Support; /// -/// Test TimeProvider with a manually advanced clock and an inert timer (so periodic sweeps -/// never fire on their own; tests invoke the sweep explicitly). +/// Test TimeProvider with a manually advanced clock and an inert timer (so periodic sweeps +/// never fire on their own; tests invoke the sweep explicitly). /// public sealed class FakeTimeProvider : TimeProvider { @@ -13,23 +13,35 @@ public FakeTimeProvider(DateTimeOffset start) _utcNow = start; } - private sealed class InertTimer : ITimer + public void Advance(TimeSpan delta) { - public bool Change(TimeSpan dueTime, TimeSpan period) - => true; + _utcNow = _utcNow.Add(delta); + } - public void Dispose() { } + public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period) + { + return new InertTimer(); + } - public ValueTask DisposeAsync() - => ValueTask.CompletedTask; + public override DateTimeOffset GetUtcNow() + { + return _utcNow; } - public void Advance(TimeSpan delta) - => _utcNow = _utcNow.Add(delta); + private sealed class InertTimer : ITimer + { + public bool Change(TimeSpan dueTime, TimeSpan period) + { + return true; + } - public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period) - => new InertTimer(); + public void Dispose() + { + } - public override DateTimeOffset GetUtcNow() - => _utcNow; + public ValueTask DisposeAsync() + { + return ValueTask.CompletedTask; + } + } } diff --git a/tests/SquidStd.Tests/Support/FakeTimerService.cs b/tests/SquidStd.Tests/Support/FakeTimerService.cs index 71e4f4e9..fea9a939 100644 --- a/tests/SquidStd.Tests/Support/FakeTimerService.cs +++ b/tests/SquidStd.Tests/Support/FakeTimerService.cs @@ -3,9 +3,9 @@ namespace SquidStd.Tests.Support; /// -/// In-memory for tests. Timers do not fire on their own: -/// call to invoke and clear the currently-registered timers -/// (one-shot semantics). only records that a pump ran. +/// In-memory for tests. Timers do not fire on their own: +/// call to invoke and clear the currently-registered timers +/// (one-shot semantics). only records that a pump ran. /// public sealed class FakeTimerService : ITimerService { @@ -17,24 +17,6 @@ public sealed class FakeTimerService : ITimerService public ManualResetEventSlim Pumped { get; } = new(false); - /// Invokes and removes every currently-registered timer; returns how many fired. - public int FireDue() - { - var snapshot = _timers.ToArray(); - - foreach (var kv in snapshot) - { - _timers.Remove(kv.Key); - } - - foreach (var kv in snapshot) - { - kv.Value.Callback(); - } - - return snapshot.Length; - } - public string RegisterTimer(string name, TimeSpan interval, Action callback, TimeSpan? delay = null, bool repeat = false) { var id = Guid.NewGuid().ToString("N"); @@ -44,10 +26,14 @@ public string RegisterTimer(string name, TimeSpan interval, Action callback, Tim } public void UnregisterAllTimers() - => _timers.Clear(); + { + _timers.Clear(); + } public bool UnregisterTimer(string timerId) - => _timers.Remove(timerId); + { + return _timers.Remove(timerId); + } public int UnregisterTimersByName(string name) { @@ -68,4 +54,22 @@ public int UpdateTicksDelta(long timestampMilliseconds) return 0; } + + /// Invokes and removes every currently-registered timer; returns how many fired. + public int FireDue() + { + var snapshot = _timers.ToArray(); + + foreach (var kv in snapshot) + { + _timers.Remove(kv.Key); + } + + foreach (var kv in snapshot) + { + kv.Value.Callback(); + } + + return snapshot.Length; + } } diff --git a/tests/SquidStd.Tests/Support/LengthPrefixFramer.cs b/tests/SquidStd.Tests/Support/LengthPrefixFramer.cs new file mode 100644 index 00000000..90d40464 --- /dev/null +++ b/tests/SquidStd.Tests/Support/LengthPrefixFramer.cs @@ -0,0 +1,31 @@ +using SquidStd.Network.Interfaces.Framing; + +namespace SquidStd.Tests.Support; + +/// +/// Test framer: a single length-prefix byte followed by that many payload bytes. The emitted frame +/// length includes the prefix byte. +/// +public sealed class LengthPrefixFramer : INetFramer +{ + public bool TryReadFrame(ReadOnlySpan buffer, out int frameLength) + { + frameLength = 0; + + if (buffer.Length < 1) + { + return false; + } + + var total = 1 + buffer[0]; + + if (buffer.Length < total) + { + return false; + } + + frameLength = total; + + return true; + } +} diff --git a/tests/SquidStd.Tests/Support/ManualJobSystem.cs b/tests/SquidStd.Tests/Support/ManualJobSystem.cs index ed9d62d5..2e4a2679 100644 --- a/tests/SquidStd.Tests/Support/ManualJobSystem.cs +++ b/tests/SquidStd.Tests/Support/ManualJobSystem.cs @@ -3,9 +3,9 @@ namespace SquidStd.Tests.Support; /// -/// Single-threaded for tests: -/// queues the work; call to execute it. This keeps overlap and -/// rescheduling tests fully deterministic. +/// Single-threaded for tests: +/// queues the work; call to execute it. This keeps overlap and +/// rescheduling tests fully deterministic. /// public sealed class ManualJobSystem : IJobSystem { @@ -19,21 +19,8 @@ public sealed class ManualJobSystem : IJobSystem public long CompletedCount { get; private set; } - public void Dispose() { } - - /// Runs and clears all queued work; returns how many items ran. - public int RunAll() + public void Dispose() { - var snapshot = _pending.ToArray(); - _pending.Clear(); - - foreach (var action in snapshot) - { - action(); - CompletedCount++; - } - - return snapshot.Length; } public Task ScheduleAsync(Action work, CancellationToken cancellationToken = default) @@ -50,4 +37,19 @@ public Task ScheduleAsync(Func work, CancellationToken cancellationToke return Task.FromResult(result); } + + /// Runs and clears all queued work; returns how many items ran. + public int RunAll() + { + var snapshot = _pending.ToArray(); + _pending.Clear(); + + foreach (var action in snapshot) + { + action(); + CompletedCount++; + } + + return snapshot.Length; + } } diff --git a/tests/SquidStd.Tests/Support/OtherDto.cs b/tests/SquidStd.Tests/Support/OtherDto.cs index d3acf7c3..4aa40f90 100644 --- a/tests/SquidStd.Tests/Support/OtherDto.cs +++ b/tests/SquidStd.Tests/Support/OtherDto.cs @@ -1,12 +1,12 @@ namespace SquidStd.Tests.Support; /// -/// Secondary data carrier used to verify multi-type JSON serializer contexts. +/// Secondary data carrier used to verify multi-type JSON serializer contexts. /// public class OtherDto { /// - /// Gets or sets the value. + /// Gets or sets the value. /// public string Value { get; set; } = ""; } diff --git a/tests/SquidStd.Tests/Support/SampleDto.cs b/tests/SquidStd.Tests/Support/SampleDto.cs index e20b9f7c..f3442f31 100644 --- a/tests/SquidStd.Tests/Support/SampleDto.cs +++ b/tests/SquidStd.Tests/Support/SampleDto.cs @@ -1,17 +1,17 @@ namespace SquidStd.Tests.Support; /// -/// Simple data carrier used for JSON and YAML serialization round-trip tests. +/// Simple data carrier used for JSON and YAML serialization round-trip tests. /// public class SampleDto { /// - /// Gets or sets the display name. + /// Gets or sets the display name. /// public string Name { get; set; } = ""; /// - /// Gets or sets the numeric count. + /// Gets or sets the numeric count. /// public int Count { get; set; } } diff --git a/tests/SquidStd.Tests/Support/SerilogEventSinkCollection.cs b/tests/SquidStd.Tests/Support/SerilogEventSinkCollection.cs index a37b1d3b..808d4c74 100644 --- a/tests/SquidStd.Tests/Support/SerilogEventSinkCollection.cs +++ b/tests/SquidStd.Tests/Support/SerilogEventSinkCollection.cs @@ -1,8 +1,8 @@ namespace SquidStd.Tests.Support; /// -/// Serializes the tests that mutate the static EventSink.OnLogReceived handler, -/// preventing cross-talk between parallel test classes. +/// Serializes the tests that mutate the static EventSink.OnLogReceived handler, +/// preventing cross-talk between parallel test classes. /// [CollectionDefinition(Name, DisableParallelization = true)] public class SerilogEventSinkCollection diff --git a/tests/SquidStd.Tests/Support/TempDirectory.cs b/tests/SquidStd.Tests/Support/TempDirectory.cs index 931f7259..820742ad 100644 --- a/tests/SquidStd.Tests/Support/TempDirectory.cs +++ b/tests/SquidStd.Tests/Support/TempDirectory.cs @@ -3,15 +3,10 @@ namespace SquidStd.Tests.Support; /// -/// Creates a unique temporary directory for a test and removes it on dispose. +/// Creates a unique temporary directory for a test and removes it on dispose. /// public sealed class TempDirectory : IDisposable { - /// - /// Gets the absolute path of the temporary directory. - /// - public string Path { get; } - public TempDirectory() { Path = SysPath.Combine(SysPath.GetTempPath(), "squidstd-tests", Guid.NewGuid().ToString("N")); @@ -19,12 +14,9 @@ public TempDirectory() } /// - /// Combines a relative path with the temporary directory root. + /// Gets the absolute path of the temporary directory. /// - /// The relative path. - /// The combined absolute path. - public string Combine(string relative) - => SysPath.Combine(Path, relative); + public string Path { get; } public void Dispose() { @@ -40,4 +32,14 @@ public void Dispose() // Best-effort cleanup; ignore failures during teardown. } } + + /// + /// Combines a relative path with the temporary directory root. + /// + /// The relative path. + /// The combined absolute path. + public string Combine(string relative) + { + return SysPath.Combine(Path, relative); + } } diff --git a/tests/SquidStd.Tests/Support/TestDirectoryType.cs b/tests/SquidStd.Tests/Support/TestDirectoryType.cs index 12b64303..588a42ea 100644 --- a/tests/SquidStd.Tests/Support/TestDirectoryType.cs +++ b/tests/SquidStd.Tests/Support/TestDirectoryType.cs @@ -1,7 +1,7 @@ namespace SquidStd.Tests.Support; /// -/// Directory type values used to exercise DirectoriesConfig enum overloads. +/// Directory type values used to exercise DirectoriesConfig enum overloads. /// public enum TestDirectoryType { diff --git a/tests/SquidStd.Tests/Support/TestJsonContext.cs b/tests/SquidStd.Tests/Support/TestJsonContext.cs index f6c211e3..7f738a2a 100644 --- a/tests/SquidStd.Tests/Support/TestJsonContext.cs +++ b/tests/SquidStd.Tests/Support/TestJsonContext.cs @@ -3,7 +3,10 @@ namespace SquidStd.Tests.Support; /// -/// Source-generated JSON serializer context exposing the test DTO types. +/// Source-generated JSON serializer context exposing the test DTO types. /// -[JsonSerializable(typeof(SampleDto)), JsonSerializable(typeof(OtherDto))] -public partial class TestJsonContext : JsonSerializerContext { } +[JsonSerializable(typeof(SampleDto))] +[JsonSerializable(typeof(OtherDto))] +public partial class TestJsonContext : JsonSerializerContext +{ +} diff --git a/tests/SquidStd.Tests/Telemetry/MetricsSnapshotBridgeTests.cs b/tests/SquidStd.Tests/Telemetry/MetricsSnapshotBridgeTests.cs index 4e4cca5d..7482efe7 100644 --- a/tests/SquidStd.Tests/Telemetry/MetricsSnapshotBridgeTests.cs +++ b/tests/SquidStd.Tests/Telemetry/MetricsSnapshotBridgeTests.cs @@ -25,9 +25,9 @@ public void Bridge_ExportsSnapshotSamplesAsInstruments() using var bridge = new MetricsSnapshotBridge(metrics); using (var provider = Sdk.CreateMeterProviderBuilder() - .AddMeter(MetricsSnapshotBridge.MeterName) - .AddInMemoryExporter(exported) - .Build()) + .AddMeter(MetricsSnapshotBridge.MeterName) + .AddInMemoryExporter(exported) + .Build()) { provider.ForceFlush(); } @@ -43,8 +43,8 @@ private static double ReadValue(List metrics, string name) foreach (ref readonly var point in metric.GetMetricPoints()) { return metric.MetricType == OtelMetricType.DoubleGauge - ? point.GetGaugeLastValueDouble() - : point.GetSumDouble(); + ? point.GetGaugeLastValueDouble() + : point.GetSumDouble(); } return double.NaN; diff --git a/tests/SquidStd.Tests/Telemetry/OtlpProtocolTypeMappingTests.cs b/tests/SquidStd.Tests/Telemetry/OtlpProtocolTypeMappingTests.cs index fd757d99..46293f45 100644 --- a/tests/SquidStd.Tests/Telemetry/OtlpProtocolTypeMappingTests.cs +++ b/tests/SquidStd.Tests/Telemetry/OtlpProtocolTypeMappingTests.cs @@ -8,9 +8,13 @@ public class OtlpProtocolTypeMappingTests { [Fact] public void Grpc_MapsToGrpc() - => Assert.Equal(OtlpExportProtocol.Grpc, TelemetryPipeline.Map(OtlpProtocolType.Grpc)); + { + Assert.Equal(OtlpExportProtocol.Grpc, TelemetryPipeline.Map(OtlpProtocolType.Grpc)); + } [Fact] public void HttpProtobuf_MapsToHttpProtobuf() - => Assert.Equal(OtlpExportProtocol.HttpProtobuf, TelemetryPipeline.Map(OtlpProtocolType.HttpProtobuf)); + { + Assert.Equal(OtlpExportProtocol.HttpProtobuf, TelemetryPipeline.Map(OtlpProtocolType.HttpProtobuf)); + } } diff --git a/tests/SquidStd.Tests/Telemetry/Support/FakeMetricsCollectionService.cs b/tests/SquidStd.Tests/Telemetry/Support/FakeMetricsCollectionService.cs index 8c3d769a..837837a6 100644 --- a/tests/SquidStd.Tests/Telemetry/Support/FakeMetricsCollectionService.cs +++ b/tests/SquidStd.Tests/Telemetry/Support/FakeMetricsCollectionService.cs @@ -10,15 +10,21 @@ public sealed class FakeMetricsCollectionService : IMetricsCollectionService public FakeMetricsCollectionService(IReadOnlyDictionary metrics) { - _snapshot = new(DateTimeOffset.UnixEpoch, metrics); + _snapshot = new MetricsSnapshot(DateTimeOffset.UnixEpoch, metrics); } public IReadOnlyDictionary GetAllMetrics() - => _snapshot.Metrics; + { + return _snapshot.Metrics; + } public MetricsSnapshot GetSnapshot() - => _snapshot; + { + return _snapshot; + } public MetricsSnapshot GetStatus() - => _snapshot; + { + return _snapshot; + } } diff --git a/tests/SquidStd.Tests/Telemetry/TelemetryRegistrationTests.cs b/tests/SquidStd.Tests/Telemetry/TelemetryRegistrationTests.cs index d482b49f..e91befc5 100644 --- a/tests/SquidStd.Tests/Telemetry/TelemetryRegistrationTests.cs +++ b/tests/SquidStd.Tests/Telemetry/TelemetryRegistrationTests.cs @@ -5,6 +5,7 @@ using SquidStd.Abstractions.Interfaces.Services; using SquidStd.Core.Data.Metrics; using SquidStd.Core.Interfaces.Metrics; +using SquidStd.Telemetry.Abstractions.Data.Config; using SquidStd.Telemetry.OpenTelemetry.Extensions; using SquidStd.Telemetry.OpenTelemetry.Services; using SquidStd.Tests.Telemetry.Support; @@ -21,7 +22,7 @@ public async Task Container_RegistersTelemetryServiceAndStartsCleanly() new FakeMetricsCollectionService(new Dictionary()) ); - container.AddSquidStdTelemetry(new() { EnableConsoleExporter = false }); + container.AddSquidStdTelemetry(new TelemetryOptions { EnableConsoleExporter = false }); var service = container.Resolve(); Assert.IsAssignableFrom(service); @@ -34,14 +35,13 @@ public async Task Container_RegistersTelemetryServiceAndStartsCleanly() public void ServiceCollection_RegistersTracerAndMeterProviders() { var services = new ServiceCollection(); - ServiceCollectionServiceExtensions.AddSingleton( - services, + services.AddSingleton( new FakeMetricsCollectionService(new Dictionary()) ); - services.AddSquidStdTelemetry(new()); + services.AddSquidStdTelemetry(new TelemetryOptions()); - using var provider = ServiceCollectionContainerBuilderExtensions.BuildServiceProvider(services); + using var provider = services.BuildServiceProvider(); Assert.NotNull(provider.GetService(typeof(TracerProvider))); Assert.NotNull(provider.GetService(typeof(MeterProvider))); diff --git a/tests/SquidStd.Tests/Telemetry/TracingTests.cs b/tests/SquidStd.Tests/Telemetry/TracingTests.cs index 1bbfcab0..f95d2e7f 100644 --- a/tests/SquidStd.Tests/Telemetry/TracingTests.cs +++ b/tests/SquidStd.Tests/Telemetry/TracingTests.cs @@ -17,7 +17,7 @@ public void Pipeline_ExportsSpans() var exported = new List(); using var source = new ActivitySource(sourceName); - using (var provider = BuildProvider(new() { ServiceName = "test-svc" }, sourceName, exported)) + using (var provider = BuildProvider(new TelemetryOptions { ServiceName = "test-svc" }, sourceName, exported)) { using (var activity = source.StartActivity("do-work")) { @@ -38,9 +38,11 @@ public void Pipeline_WithZeroSampling_RecordsNothing() var exported = new List(); using var source = new ActivitySource(sourceName); - using (var provider = BuildProvider(new() { TracingSampleRatio = 0.0 }, sourceName, exported)) + using (var provider = BuildProvider(new TelemetryOptions { TracingSampleRatio = 0.0 }, sourceName, exported)) { - using (source.StartActivity("dropped")) { } + using (source.StartActivity("dropped")) + { + } provider.ForceFlush(); } diff --git a/tests/SquidStd.Tests/Templating/ScribanTemplateRendererTests.cs b/tests/SquidStd.Tests/Templating/ScribanTemplateRendererTests.cs index be52fb88..edf0eb31 100644 --- a/tests/SquidStd.Tests/Templating/ScribanTemplateRendererTests.cs +++ b/tests/SquidStd.Tests/Templating/ScribanTemplateRendererTests.cs @@ -1,3 +1,4 @@ +using SquidStd.Core.Directories; using SquidStd.Templating; using SquidStd.Templating.Services; using SquidStd.Tests.Support; @@ -82,5 +83,7 @@ public async Task StartAsync_AutoLoadsTemplatesFromDirectory() } private static ScribanTemplateRenderer NewRenderer(string root) - => new(new(root, [])); + { + return new ScribanTemplateRenderer(new DirectoriesConfig(root, [])); + } } diff --git a/tests/SquidStd.Tests/Utils/DirectoriesUtilsTests.cs b/tests/SquidStd.Tests/Utils/DirectoriesUtilsTests.cs index 2a7c61a9..9f30bddd 100644 --- a/tests/SquidStd.Tests/Utils/DirectoriesUtilsTests.cs +++ b/tests/SquidStd.Tests/Utils/DirectoriesUtilsTests.cs @@ -33,7 +33,9 @@ public void GetFiles_NoExtensionFilter_ReturnsAllFiles() [Fact] public void GetFiles_NonExistentDirectory_ReturnsEmpty() - => Assert.Empty(DirectoriesUtils.GetFiles(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")))); + { + Assert.Empty(DirectoriesUtils.GetFiles(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")))); + } [Fact] public void GetFiles_NonRecursive_ExcludesNestedFiles() diff --git a/tests/SquidStd.Tests/Utils/HashUtilsTests.cs b/tests/SquidStd.Tests/Utils/HashUtilsTests.cs index be8ca6dc..d97a393a 100644 --- a/tests/SquidStd.Tests/Utils/HashUtilsTests.cs +++ b/tests/SquidStd.Tests/Utils/HashUtilsTests.cs @@ -4,9 +4,14 @@ namespace SquidStd.Tests.Utils; public class HashUtilsTests { - [Theory, InlineData(""), InlineData(" "), InlineData(null)] + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(null)] public void HashPassword_NullOrWhitespace_Throws(string? password) - => Assert.Throws(() => HashUtils.HashPassword(password!)); + { + Assert.Throws(() => HashUtils.HashPassword(password!)); + } [Fact] public void HashPassword_SamePasswordTwice_ProducesDifferentHashes() @@ -39,15 +44,25 @@ public void VerifyPassword_CorrectPassword_ReturnsTrue() Assert.True(HashUtils.VerifyPassword("s3cret", hash)); } - [Theory, InlineData("not-a-hash"), InlineData("md5$100000$c2FsdA==$aGFzaA=="), - InlineData("pbkdf2-sha256$abc$c2FsdA==$aGFzaA=="), InlineData("pbkdf2-sha256$0$c2FsdA==$aGFzaA=="), - InlineData("pbkdf2-sha256$100000$not-base64$aGFzaA==")] + [Theory] + [InlineData("not-a-hash")] + [InlineData("md5$100000$c2FsdA==$aGFzaA==")] + [InlineData("pbkdf2-sha256$abc$c2FsdA==$aGFzaA==")] + [InlineData("pbkdf2-sha256$0$c2FsdA==$aGFzaA==")] + [InlineData("pbkdf2-sha256$100000$not-base64$aGFzaA==")] public void VerifyPassword_MalformedStoredHash_ReturnsFalse(string storedHash) - => Assert.False(HashUtils.VerifyPassword("s3cret", storedHash)); + { + Assert.False(HashUtils.VerifyPassword("s3cret", storedHash)); + } - [Theory, InlineData(""), InlineData(" "), InlineData(null)] + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(null)] public void VerifyPassword_NullOrWhitespacePassword_ReturnsFalse(string? password) - => Assert.False(HashUtils.VerifyPassword(password!, "pbkdf2-sha256$100000$c2FsdA==$aGFzaA==")); + { + Assert.False(HashUtils.VerifyPassword(password!, "pbkdf2-sha256$100000$c2FsdA==$aGFzaA==")); + } [Fact] public void VerifyPassword_WrongPassword_ReturnsFalse() diff --git a/tests/SquidStd.Tests/Utils/NetworkUtilsTests.cs b/tests/SquidStd.Tests/Utils/NetworkUtilsTests.cs index 6d634a6c..1c1a2a87 100644 --- a/tests/SquidStd.Tests/Utils/NetworkUtilsTests.cs +++ b/tests/SquidStd.Tests/Utils/NetworkUtilsTests.cs @@ -7,7 +7,9 @@ public class NetworkUtilsTests { [Fact] public void GetListeningAddresses_NullEndpoint_Throws() - => Assert.Throws(() => NetworkUtils.GetListeningAddresses(null!).ToList()); + { + Assert.Throws(() => NetworkUtils.GetListeningAddresses(null!).ToList()); + } [Fact] public void GetListeningAddresses_ReturnsEndpointsMatchingFamilyAndPort() @@ -26,43 +28,72 @@ public void GetListeningAddresses_ReturnsEndpointsMatchingFamilyAndPort() ); } - [Theory, InlineData(""), InlineData(" ")] + [Theory] + [InlineData("")] + [InlineData(" ")] public void ParseIpAddress_NullOrWhitespace_Throws(string ipAddress) - => Assert.Throws(() => NetworkUtils.ParseIpAddress(ipAddress)); + { + Assert.Throws(() => NetworkUtils.ParseIpAddress(ipAddress)); + } [Fact] public void ParseIpAddress_ValidAddress_ReturnsParsedAddress() - => Assert.Equal(IPAddress.Parse("127.0.0.1"), NetworkUtils.ParseIpAddress("127.0.0.1")); + { + Assert.Equal(IPAddress.Parse("127.0.0.1"), NetworkUtils.ParseIpAddress("127.0.0.1")); + } [Fact] public void ParseIpAddress_Wildcard_ReturnsAny() - => Assert.Equal(IPAddress.Any, NetworkUtils.ParseIpAddress("*")); + { + Assert.Equal(IPAddress.Any, NetworkUtils.ParseIpAddress("*")); + } - [Theory, InlineData("99999"), InlineData("-1"), InlineData("abc")] + [Theory] + [InlineData("99999")] + [InlineData("-1")] + [InlineData("abc")] public void ParsePorts_InvalidPort_ThrowsFormatException(string ports) - => Assert.Throws(() => NetworkUtils.ParsePorts(ports)); + { + Assert.Throws(() => NetworkUtils.ParsePorts(ports)); + } - [Theory, InlineData("8000-7000"), InlineData("1-2-3")] + [Theory] + [InlineData("8000-7000")] + [InlineData("1-2-3")] public void ParsePorts_InvalidRange_ThrowsFormatException(string ports) - => Assert.Throws(() => NetworkUtils.ParsePorts(ports)); + { + Assert.Throws(() => NetworkUtils.ParsePorts(ports)); + } [Fact] public void ParsePorts_MixedRangeAndList_ReturnsAllPorts() - => Assert.Equal([6666, 6667, 6668, 6669, 8000], NetworkUtils.ParsePorts("6666-6668,6669,8000")); + { + Assert.Equal([6666, 6667, 6668, 6669, 8000], NetworkUtils.ParsePorts("6666-6668,6669,8000")); + } - [Theory, InlineData(""), InlineData(" ")] + [Theory] + [InlineData("")] + [InlineData(" ")] public void ParsePorts_NullOrWhitespace_ThrowsArgumentException(string ports) - => Assert.Throws(() => NetworkUtils.ParsePorts(ports)); + { + Assert.Throws(() => NetworkUtils.ParsePorts(ports)); + } [Fact] public void ParsePorts_Range_ReturnsExpandedRange() - => Assert.Equal([6666, 6667, 6668], NetworkUtils.ParsePorts("6666-6668")); + { + Assert.Equal([6666, 6667, 6668], NetworkUtils.ParsePorts("6666-6668")); + } [Fact] public void ParsePorts_SinglePort_ReturnsSingleEntry() - => Assert.Equal([8000], NetworkUtils.ParsePorts("8000")); + { + Assert.Equal([8000], NetworkUtils.ParsePorts("8000")); + } [Fact] public void ParsePorts_TrimsWhitespaceEntries() - => Assert.Equal([80, 443], NetworkUtils.ParsePorts(" 80 , 443 ")); + { + Assert.Equal([80, 443], NetworkUtils.ParsePorts(" 80 , 443 ")); + } } diff --git a/tests/SquidStd.Tests/Utils/PlatformUtilsTests.cs b/tests/SquidStd.Tests/Utils/PlatformUtilsTests.cs index 01e1512e..874e6bab 100644 --- a/tests/SquidStd.Tests/Utils/PlatformUtilsTests.cs +++ b/tests/SquidStd.Tests/Utils/PlatformUtilsTests.cs @@ -9,21 +9,27 @@ public class PlatformUtilsTests public void GetCurrentPlatform_MatchesOperatingSystemDetection() { var expected = OperatingSystem.IsWindows() ? PlatformType.Windows : - OperatingSystem.IsMacOS() ? PlatformType.MacOS : - OperatingSystem.IsLinux() ? PlatformType.Linux : PlatformType.Unknown; + OperatingSystem.IsMacOS() ? PlatformType.MacOS : + OperatingSystem.IsLinux() ? PlatformType.Linux : PlatformType.Unknown; Assert.Equal(expected, PlatformUtils.GetCurrentPlatform()); } [Fact] public void IsRunningOnLinux_MatchesOperatingSystem() - => Assert.Equal(OperatingSystem.IsLinux(), PlatformUtils.IsRunningOnLinux()); + { + Assert.Equal(OperatingSystem.IsLinux(), PlatformUtils.IsRunningOnLinux()); + } [Fact] public void IsRunningOnMacOS_MatchesOperatingSystem() - => Assert.Equal(OperatingSystem.IsMacOS(), PlatformUtils.IsRunningOnMacOS()); + { + Assert.Equal(OperatingSystem.IsMacOS(), PlatformUtils.IsRunningOnMacOS()); + } [Fact] public void IsRunningOnWindows_MatchesOperatingSystem() - => Assert.Equal(OperatingSystem.IsWindows(), PlatformUtils.IsRunningOnWindows()); + { + Assert.Equal(OperatingSystem.IsWindows(), PlatformUtils.IsRunningOnWindows()); + } } diff --git a/tests/SquidStd.Tests/Utils/ResourceUtilsTests.cs b/tests/SquidStd.Tests/Utils/ResourceUtilsTests.cs index 5e685662..9b23e390 100644 --- a/tests/SquidStd.Tests/Utils/ResourceUtilsTests.cs +++ b/tests/SquidStd.Tests/Utils/ResourceUtilsTests.cs @@ -5,8 +5,8 @@ namespace SquidStd.Tests.Utils; public class ResourceUtilsTests { - private static readonly Assembly TestAssembly = typeof(ResourceUtilsTests).Assembly; private const string SampleResourceSuffix = "Support.Resources.sample.txt"; + private static readonly Assembly TestAssembly = typeof(ResourceUtilsTests).Assembly; [Fact] public void ConvertResourceNameToPath_NestedName_ConvertsDotsToSeparators() @@ -18,7 +18,9 @@ public void ConvertResourceNameToPath_NestedName_ConvertsDotsToSeparators() [Fact] public void ConvertResourceNameToPath_NoExtension_Throws() - => Assert.Throws(() => ResourceUtils.ConvertResourceNameToPath("Asm.file", "Asm")); + { + Assert.Throws(() => ResourceUtils.ConvertResourceNameToPath("Asm.file", "Asm")); + } [Fact] public void ConvertResourceNameToPath_ValidName_ReturnsFilePath() @@ -30,11 +32,15 @@ public void ConvertResourceNameToPath_ValidName_ReturnsFilePath() [Fact] public void ConvertResourceNameToPath_WrongNamespace_Throws() - => Assert.Throws(() => ResourceUtils.ConvertResourceNameToPath("Other.cfg.json", "Asm")); + { + Assert.Throws(() => ResourceUtils.ConvertResourceNameToPath("Other.cfg.json", "Asm")); + } [Fact] public void EmbeddedNameToPath_StripsPrefixAndConvertsDots() - => Assert.Equal("Folder/file/txt", ResourceUtils.EmbeddedNameToPath("Asm.Folder.file.txt", "Asm")); + { + Assert.Equal("Folder/file/txt", ResourceUtils.EmbeddedNameToPath("Asm.Folder.file.txt", "Asm")); + } [Fact] public void GetDirectoryPathFromResourceName_RemovesBaseNamespace() @@ -62,9 +68,11 @@ public void GetEmbeddedResourceNames_NoFilter_IncludesSampleResource() [Fact] public void GetEmbeddedResourceStream_MissingResource_Throws() - => Assert.Throws( - () => ResourceUtils.GetEmbeddedResourceStream(TestAssembly, "does-not-exist.bin") + { + Assert.Throws(() => + ResourceUtils.GetEmbeddedResourceStream(TestAssembly, "does-not-exist.bin") ); + } [Fact] public void GetEmbeddedResourceString_ExistingResource_ReturnsContent() @@ -76,11 +84,18 @@ public void GetEmbeddedResourceString_ExistingResource_ReturnsContent() [Fact] public void GetFileNameFromResourceName_ReturnsFileNameWithExtension() - => Assert.Equal("DefaultUiFont.ttf", ResourceUtils.GetFileNameFromResourceName("Assets.Fonts.DefaultUiFont.ttf")); + { + Assert.Equal("DefaultUiFont.ttf", ResourceUtils.GetFileNameFromResourceName("Assets.Fonts.DefaultUiFont.ttf")); + } - [Theory, InlineData("a/b/c.txt", "c.txt"), InlineData("a\\b\\c.txt", "c.txt"), InlineData("c.txt", "c.txt")] + [Theory] + [InlineData("a/b/c.txt", "c.txt")] + [InlineData("a\\b\\c.txt", "c.txt")] + [InlineData("c.txt", "c.txt")] public void GetFileNameFromResourcePath_ReturnsFinalSegment(string input, string expected) - => Assert.Equal(expected, ResourceUtils.GetFileNameFromResourcePath(input)); + { + Assert.Equal(expected, ResourceUtils.GetFileNameFromResourcePath(input)); + } [Fact] public void ReadEmbeddedResource_ExistingResource_ReturnsContent() @@ -93,7 +108,8 @@ public void ReadEmbeddedResource_ExistingResource_ReturnsContent() [Fact] public void ReadEmbeddedResource_MissingResource_Throws() - => Assert.Throws( - () => ResourceUtils.ReadEmbeddedResource("does-not-exist.bin", TestAssembly) + { + Assert.Throws(() => ResourceUtils.ReadEmbeddedResource("does-not-exist.bin", TestAssembly) ); + } } diff --git a/tests/SquidStd.Tests/Utils/StringUtilsTests.cs b/tests/SquidStd.Tests/Utils/StringUtilsTests.cs index c2850852..950798f8 100644 --- a/tests/SquidStd.Tests/Utils/StringUtilsTests.cs +++ b/tests/SquidStd.Tests/Utils/StringUtilsTests.cs @@ -4,65 +4,118 @@ namespace SquidStd.Tests.Utils; public class StringUtilsTests { - [Theory, InlineData(""), InlineData(null)] + [Theory] + [InlineData("")] + [InlineData(null)] public void ToCamelCase_NullOrEmpty_ReturnsEmpty(string? input) - => Assert.Equal("", StringUtils.ToCamelCase(input!)); + { + Assert.Equal("", StringUtils.ToCamelCase(input!)); + } [Fact] public void ToCamelCase_SingleCharacter_ReturnsLowerCase() - => Assert.Equal("a", StringUtils.ToCamelCase("A")); + { + Assert.Equal("a", StringUtils.ToCamelCase("A")); + } - [Theory, InlineData("HelloWorld", "helloWorld"), InlineData("API_RESPONSE", "apiResponse"), - InlineData("user-id", "userId"), InlineData("hello world", "helloWorld")] + [Theory] + [InlineData("HelloWorld", "helloWorld")] + [InlineData("API_RESPONSE", "apiResponse")] + [InlineData("user-id", "userId")] + [InlineData("hello world", "helloWorld")] public void ToCamelCase_VariousInputs_ReturnsCamelCase(string input, string expected) - => Assert.Equal(expected, StringUtils.ToCamelCase(input)); + { + Assert.Equal(expected, StringUtils.ToCamelCase(input)); + } - [Theory, InlineData("HelloWorld", "hello.world"), InlineData("API_RESPONSE", "api.response")] + [Theory] + [InlineData("HelloWorld", "hello.world")] + [InlineData("API_RESPONSE", "api.response")] public void ToDotCase_VariousInputs_ReturnsDotCase(string input, string expected) - => Assert.Equal(expected, StringUtils.ToDotCase(input)); + { + Assert.Equal(expected, StringUtils.ToDotCase(input)); + } - [Theory, InlineData("HelloWorld", "hello-world"), InlineData("API_RESPONSE", "api-response"), - InlineData("userId", "user-id")] + [Theory] + [InlineData("HelloWorld", "hello-world")] + [InlineData("API_RESPONSE", "api-response")] + [InlineData("userId", "user-id")] public void ToKebabCase_VariousInputs_ReturnsKebabCase(string input, string expected) - => Assert.Equal(expected, StringUtils.ToKebabCase(input)); + { + Assert.Equal(expected, StringUtils.ToKebabCase(input)); + } [Fact] public void ToPascalCase_SingleCharacter_ReturnsUpperCase() - => Assert.Equal("A", StringUtils.ToPascalCase("a")); + { + Assert.Equal("A", StringUtils.ToPascalCase("a")); + } - [Theory, InlineData("hello_world", "HelloWorld"), InlineData("api-response", "ApiResponse"), - InlineData("userId", "UserId")] + [Theory] + [InlineData("hello_world", "HelloWorld")] + [InlineData("api-response", "ApiResponse")] + [InlineData("userId", "UserId")] public void ToPascalCase_VariousInputs_ReturnsPascalCase(string input, string expected) - => Assert.Equal(expected, StringUtils.ToPascalCase(input)); + { + Assert.Equal(expected, StringUtils.ToPascalCase(input)); + } - [Theory, InlineData("HelloWorld", "hello/world"), InlineData("API_RESPONSE", "api/response")] + [Theory] + [InlineData("HelloWorld", "hello/world")] + [InlineData("API_RESPONSE", "api/response")] public void ToPathCase_VariousInputs_ReturnsPathCase(string input, string expected) - => Assert.Equal(expected, StringUtils.ToPathCase(input)); + { + Assert.Equal(expected, StringUtils.ToPathCase(input)); + } - [Theory, InlineData("hello world", "Hello world"), InlineData("API_RESPONSE", "Api response")] + [Theory] + [InlineData("hello world", "Hello world")] + [InlineData("API_RESPONSE", "Api response")] public void ToSentenceCase_VariousInputs_ReturnsSentenceCase(string input, string expected) - => Assert.Equal(expected, StringUtils.ToSentenceCase(input)); + { + Assert.Equal(expected, StringUtils.ToSentenceCase(input)); + } - [Theory, InlineData(""), InlineData(null)] + [Theory] + [InlineData("")] + [InlineData(null)] public void ToSnakeCase_NullOrEmpty_ReturnsEmpty(string? input) - => Assert.Equal("", StringUtils.ToSnakeCase(input!)); + { + Assert.Equal("", StringUtils.ToSnakeCase(input!)); + } - [Theory, InlineData("HelloWorld", "hello_world"), InlineData("APIResponse", "api_response"), - InlineData("userId", "user_id")] + [Theory] + [InlineData("HelloWorld", "hello_world")] + [InlineData("APIResponse", "api_response")] + [InlineData("userId", "user_id")] public void ToSnakeCase_VariousInputs_ReturnsSnakeCase(string input, string expected) - => Assert.Equal(expected, StringUtils.ToSnakeCase(input)); + { + Assert.Equal(expected, StringUtils.ToSnakeCase(input)); + } - [Theory, InlineData("hello_world", "Hello World"), InlineData("API_RESPONSE", "Api Response"), - InlineData("user-id", "User Id")] + [Theory] + [InlineData("hello_world", "Hello World")] + [InlineData("API_RESPONSE", "Api Response")] + [InlineData("user-id", "User Id")] public void ToTitleCase_VariousInputs_ReturnsTitleCase(string input, string expected) - => Assert.Equal(expected, StringUtils.ToTitleCase(input)); + { + Assert.Equal(expected, StringUtils.ToTitleCase(input)); + } - [Theory, InlineData("hello_world", "Hello-World"), InlineData("apiResponse", "Api-Response")] + [Theory] + [InlineData("hello_world", "Hello-World")] + [InlineData("apiResponse", "Api-Response")] public void ToTrainCase_VariousInputs_ReturnsTrainCase(string input, string expected) - => Assert.Equal(expected, StringUtils.ToTrainCase(input)); + { + Assert.Equal(expected, StringUtils.ToTrainCase(input)); + } - [Theory, InlineData("HelloWorld", "HELLO_WORLD"), InlineData("apiResponse", "API_RESPONSE"), - InlineData("user-id", "USER_ID")] + [Theory] + [InlineData("HelloWorld", "HELLO_WORLD")] + [InlineData("apiResponse", "API_RESPONSE")] + [InlineData("user-id", "USER_ID")] public void ToUpperSnakeCase_VariousInputs_ReturnsScreamingSnakeCase(string input, string expected) - => Assert.Equal(expected, StringUtils.ToUpperSnakeCase(input)); + { + Assert.Equal(expected, StringUtils.ToUpperSnakeCase(input)); + } } diff --git a/tests/SquidStd.Tests/Utils/VersionUtilsTests.cs b/tests/SquidStd.Tests/Utils/VersionUtilsTests.cs index 5347ceb4..aff8ef90 100644 --- a/tests/SquidStd.Tests/Utils/VersionUtilsTests.cs +++ b/tests/SquidStd.Tests/Utils/VersionUtilsTests.cs @@ -24,5 +24,7 @@ public void GetVersion_ExplicitAssembly_ReturnsNonEmptyVersion() [Fact] public void GetVersion_NullAssembly_Throws() - => Assert.Throws(() => VersionUtils.GetVersion(null!)); + { + Assert.Throws(() => VersionUtils.GetVersion(null!)); + } } diff --git a/tests/SquidStd.Tests/Workers/JobDispatcherTests.cs b/tests/SquidStd.Tests/Workers/JobDispatcherTests.cs index dbcf7402..108dd995 100644 --- a/tests/SquidStd.Tests/Workers/JobDispatcherTests.cs +++ b/tests/SquidStd.Tests/Workers/JobDispatcherTests.cs @@ -26,8 +26,10 @@ public async Task DispatchAsync_PropagatesHandlerException() var boom = new RecordingJobHandler("resize") { ThrowOnHandle = new InvalidOperationException("boom") }; var dispatcher = new JobDispatcher([boom]); - await Assert.ThrowsAsync( - () => dispatcher.DispatchAsync(Job("resize"), CancellationToken.None) + await Assert.ThrowsAsync(() => dispatcher.DispatchAsync( + Job("resize"), + CancellationToken.None + ) ); } @@ -36,13 +38,15 @@ public async Task DispatchAsync_ThrowsWhenNoHandlerMatches() { var dispatcher = new JobDispatcher([new RecordingJobHandler("resize")]); - var ex = await Assert.ThrowsAsync( - () => dispatcher.DispatchAsync(Job("unknown"), CancellationToken.None) - ); + var ex = await Assert.ThrowsAsync(() => + dispatcher.DispatchAsync(Job("unknown"), CancellationToken.None) + ); Assert.Equal("unknown", ex.JobName); } private static JobRequest Job(string name) - => new(name, new Dictionary()); + { + return new JobRequest(name, new Dictionary()); + } } diff --git a/tests/SquidStd.Tests/Workers/Support/FakeMessageQueue.cs b/tests/SquidStd.Tests/Workers/Support/FakeMessageQueue.cs index aa72a883..35d72091 100644 --- a/tests/SquidStd.Tests/Workers/Support/FakeMessageQueue.cs +++ b/tests/SquidStd.Tests/Workers/Support/FakeMessageQueue.cs @@ -5,17 +5,25 @@ namespace SquidStd.Tests.Workers.Support; /// Inert for unit tests that drive the consumer's HandleAsync directly. public sealed class FakeMessageQueue : IMessageQueue { - private sealed class Subscription : IDisposable + public Task PublishAsync(string queueName, TMessage message, CancellationToken cancellationToken = default) { - public void Dispose() { } + return Task.CompletedTask; } - public Task PublishAsync(string queueName, TMessage message, CancellationToken cancellationToken = default) - => Task.CompletedTask; - public IDisposable Subscribe(string queueName, IQueueMessageListener listener) - => new Subscription(); + { + return new Subscription(); + } public IDisposable Subscribe(string queueName, IQueueMessageListenerAsync listener) - => new Subscription(); + { + return new Subscription(); + } + + private sealed class Subscription : IDisposable + { + public void Dispose() + { + } + } } diff --git a/tests/SquidStd.Tests/Workers/Support/RecordingJobHandler.cs b/tests/SquidStd.Tests/Workers/Support/RecordingJobHandler.cs index 75fef96a..613f5953 100644 --- a/tests/SquidStd.Tests/Workers/Support/RecordingJobHandler.cs +++ b/tests/SquidStd.Tests/Workers/Support/RecordingJobHandler.cs @@ -4,8 +4,8 @@ namespace SquidStd.Tests.Workers.Support; /// -/// Configurable for tests: records the jobs it received and can be told -/// to throw, or to block until released, so concurrency and error paths can be exercised. +/// Configurable for tests: records the jobs it received and can be told +/// to throw, or to block until released, so concurrency and error paths can be exercised. /// public sealed class RecordingJobHandler : IJobHandler { @@ -17,8 +17,6 @@ public RecordingJobHandler(string jobName) JobName = jobName; } - public string JobName { get; } - public Exception? ThrowOnHandle { get; set; } /// When set, awaits this before returning. @@ -35,6 +33,8 @@ public IReadOnlyList Received } } + public string JobName { get; } + public async Task HandleAsync(JobRequest job, CancellationToken cancellationToken) { lock (_sync) diff --git a/tests/SquidStd.Tests/Workers/WorkerConsumerServiceTests.cs b/tests/SquidStd.Tests/Workers/WorkerConsumerServiceTests.cs index ef8383fb..13217db3 100644 --- a/tests/SquidStd.Tests/Workers/WorkerConsumerServiceTests.cs +++ b/tests/SquidStd.Tests/Workers/WorkerConsumerServiceTests.cs @@ -1,5 +1,6 @@ using SquidStd.Tests.Workers.Support; using SquidStd.Workers.Abstractions.Data; +using SquidStd.Workers.Data.Config; using SquidStd.Workers.Services; namespace SquidStd.Tests.Workers; @@ -9,7 +10,7 @@ public class WorkerConsumerServiceTests [Fact] public async Task HandleAsync_DropsUnknownJobWithoutThrowing() { - var state = new WorkerState(new() { WorkerId = "w1", MaxConcurrency = 2 }); + var state = new WorkerState(new WorkersConfig { WorkerId = "w1", MaxConcurrency = 2 }); var consumer = Build(state, new RecordingJobHandler("resize")); var ex = await Record.ExceptionAsync(() => consumer.HandleAsync(Job("unknown"), CancellationToken.None)); @@ -21,14 +22,14 @@ public async Task HandleAsync_DropsUnknownJobWithoutThrowing() [Fact] public async Task HandleAsync_NeverExceedsMaxConcurrency() { - var handler = new RecordingJobHandler("resize") { Gate = new() }; - var state = new WorkerState(new() { WorkerId = "w1", MaxConcurrency = 2 }); + var handler = new RecordingJobHandler("resize") { Gate = new TaskCompletionSource() }; + var state = new WorkerState(new WorkersConfig { WorkerId = "w1", MaxConcurrency = 2 }); var consumer = Build(state, handler); // Launch 5 concurrent dispatches against a handler that blocks on its gate. var inFlight = Enumerable.Range(0, 5) - .Select(_ => consumer.HandleAsync(Job("resize"), CancellationToken.None)) - .ToArray(); + .Select(_ => consumer.HandleAsync(Job("resize"), CancellationToken.None)) + .ToArray(); // Give the semaphore time to admit as many as it will, then assert the cap held. await Task.Delay(200); @@ -45,11 +46,10 @@ public async Task HandleAsync_NeverExceedsMaxConcurrency() public async Task HandleAsync_RethrowsHandlerExceptionForRequeue() { var handler = new RecordingJobHandler("resize") { ThrowOnHandle = new InvalidOperationException("boom") }; - var state = new WorkerState(new() { WorkerId = "w1", MaxConcurrency = 2 }); + var state = new WorkerState(new WorkersConfig { WorkerId = "w1", MaxConcurrency = 2 }); var consumer = Build(state, handler); - await Assert.ThrowsAsync( - () => consumer.HandleAsync(Job("resize"), CancellationToken.None) + await Assert.ThrowsAsync(() => consumer.HandleAsync(Job("resize"), CancellationToken.None) ); Assert.Equal(0, state.ActiveJobs); @@ -59,7 +59,7 @@ await Assert.ThrowsAsync( public async Task HandleAsync_RunsMatchingHandler() { var handler = new RecordingJobHandler("resize"); - var state = new WorkerState(new() { WorkerId = "w1", MaxConcurrency = 2 }); + var state = new WorkerState(new WorkersConfig { WorkerId = "w1", MaxConcurrency = 2 }); var consumer = Build(state, handler); await consumer.HandleAsync(Job("resize"), CancellationToken.None); @@ -69,8 +69,12 @@ public async Task HandleAsync_RunsMatchingHandler() } private static WorkerConsumerService Build(WorkerState state, params RecordingJobHandler[] handlers) - => new(new FakeMessageQueue(), new JobDispatcher(handlers), state, new()); + { + return new WorkerConsumerService(new FakeMessageQueue(), new JobDispatcher(handlers), state, new WorkersConfig()); + } private static JobRequest Job(string name) - => new(name, new Dictionary()); + { + return new JobRequest(name, new Dictionary()); + } } diff --git a/tests/SquidStd.Tests/Workers/WorkerContractsTests.cs b/tests/SquidStd.Tests/Workers/WorkerContractsTests.cs index d801fb9d..36c3e033 100644 --- a/tests/SquidStd.Tests/Workers/WorkerContractsTests.cs +++ b/tests/SquidStd.Tests/Workers/WorkerContractsTests.cs @@ -33,12 +33,15 @@ public void WorkerChannels_NamesAreNonEmptyAndDistinct() Assert.NotEqual(WorkerChannels.JobQueue, WorkerChannels.HeartbeatTopic); } - [Theory, InlineData(WorkerStatusType.Idle), InlineData(WorkerStatusType.Busy), InlineData(WorkerStatusType.Offline)] + [Theory] + [InlineData(WorkerStatusType.Idle)] + [InlineData(WorkerStatusType.Busy)] + [InlineData(WorkerStatusType.Offline)] public void WorkerHeartbeat_RoundTrips_EveryStatus(WorkerStatusType status) { var original = new WorkerHeartbeat( "worker-3", - new(2026, 6, 23, 11, 0, 0, DateTimeKind.Utc), + new DateTime(2026, 6, 23, 11, 0, 0, DateTimeKind.Utc), status, 0, 4 @@ -54,7 +57,7 @@ public void WorkerHeartbeat_RoundTrips_PreservingAllFields() { var original = new WorkerHeartbeat( "worker-1", - new(2026, 6, 23, 10, 0, 0, DateTimeKind.Utc), + new DateTime(2026, 6, 23, 10, 0, 0, DateTimeKind.Utc), WorkerStatusType.Busy, 3, 8 @@ -73,8 +76,8 @@ public void WorkerInfo_RoundTrips_PreservingAllFields() WorkerStatusType.Offline, 2, 8, - new(2026, 6, 23, 9, 0, 0, DateTimeKind.Utc), - new(2026, 6, 23, 9, 30, 0, DateTimeKind.Utc) + new DateTime(2026, 6, 23, 9, 0, 0, DateTimeKind.Utc), + new DateTime(2026, 6, 23, 9, 30, 0, DateTimeKind.Utc) ); var restored = Serializer.Deserialize(Serializer.Serialize(original)); diff --git a/tests/SquidStd.Tests/Workers/WorkerHeartbeatServiceTests.cs b/tests/SquidStd.Tests/Workers/WorkerHeartbeatServiceTests.cs index c52d488e..5c268bde 100644 --- a/tests/SquidStd.Tests/Workers/WorkerHeartbeatServiceTests.cs +++ b/tests/SquidStd.Tests/Workers/WorkerHeartbeatServiceTests.cs @@ -76,6 +76,6 @@ private static (IMessageTopic topic, WorkerState state) NewMessaging(WorkersConf container.RegisterInstance(new EventBusService()); container.AddInMemoryMessaging(); - return (container.Resolve(), new(config)); + return (container.Resolve(), new WorkerState(config)); } } diff --git a/tests/SquidStd.Tests/Workers/WorkerStateTests.cs b/tests/SquidStd.Tests/Workers/WorkerStateTests.cs index 281e2e5b..ab0b6a86 100644 --- a/tests/SquidStd.Tests/Workers/WorkerStateTests.cs +++ b/tests/SquidStd.Tests/Workers/WorkerStateTests.cs @@ -1,4 +1,5 @@ using SquidStd.Workers.Abstractions.Types; +using SquidStd.Workers.Data.Config; using SquidStd.Workers.Services; namespace SquidStd.Tests.Workers; @@ -8,7 +9,7 @@ public class WorkerStateTests [Fact] public void MaxConcurrency_FallsBackToProcessorCountWhenNotPositive() { - var state = new WorkerState(new() { WorkerId = "w1", MaxConcurrency = 0 }); + var state = new WorkerState(new WorkersConfig { WorkerId = "w1", MaxConcurrency = 0 }); Assert.Equal(Environment.ProcessorCount, state.MaxConcurrency); } @@ -16,7 +17,7 @@ public void MaxConcurrency_FallsBackToProcessorCountWhenNotPositive() [Fact] public void Status_IsBusyWhileJobsActive() { - var state = new WorkerState(new() { WorkerId = "w1", MaxConcurrency = 4 }); + var state = new WorkerState(new WorkersConfig { WorkerId = "w1", MaxConcurrency = 4 }); state.JobStarted(); state.JobStarted(); @@ -34,7 +35,7 @@ public void Status_IsBusyWhileJobsActive() [Fact] public void Status_IsIdleWhenNoActiveJobs() { - var state = new WorkerState(new() { WorkerId = "w1", MaxConcurrency = 4 }); + var state = new WorkerState(new WorkersConfig { WorkerId = "w1", MaxConcurrency = 4 }); Assert.Equal(WorkerStatusType.Idle, state.Status); Assert.Equal(0, state.ActiveJobs); @@ -43,7 +44,7 @@ public void Status_IsIdleWhenNoActiveJobs() [Fact] public void WorkerId_FallsBackToMachineNameWhenBlank() { - var state = new WorkerState(new() { WorkerId = " ", MaxConcurrency = 4 }); + var state = new WorkerState(new WorkersConfig { WorkerId = " ", MaxConcurrency = 4 }); Assert.Equal(Environment.MachineName, state.WorkerId); } diff --git a/tests/SquidStd.Tests/Workers/WorkersRegistrationExtensionsTests.cs b/tests/SquidStd.Tests/Workers/WorkersRegistrationExtensionsTests.cs index 057c9724..777f595b 100644 --- a/tests/SquidStd.Tests/Workers/WorkersRegistrationExtensionsTests.cs +++ b/tests/SquidStd.Tests/Workers/WorkersRegistrationExtensionsTests.cs @@ -12,20 +12,6 @@ namespace SquidStd.Tests.Workers; public class WorkersRegistrationExtensionsTests { - private sealed class EchoJobHandler : IJobHandler - { - public static int Calls; - - public string JobName => "echo"; - - public Task HandleAsync(JobRequest job, CancellationToken cancellationToken) - { - Interlocked.Increment(ref Calls); - - return Task.CompletedTask; - } - } - [Fact] public async Task AddJobHandler_MakesHandlerReachableFromDispatcher() { @@ -35,7 +21,7 @@ public async Task AddJobHandler_MakesHandlerReachableFromDispatcher() container.AddJobHandler(); var dispatcher = container.Resolve(); - await dispatcher.DispatchAsync(new("echo", new Dictionary()), CancellationToken.None); + await dispatcher.DispatchAsync(new JobRequest("echo", new Dictionary()), CancellationToken.None); Assert.Equal(1, EchoJobHandler.Calls); } @@ -64,4 +50,18 @@ private static Container NewContainer() return container; } + + private sealed class EchoJobHandler : IJobHandler + { + public static int Calls; + + public string JobName => "echo"; + + public Task HandleAsync(JobRequest job, CancellationToken cancellationToken) + { + Interlocked.Increment(ref Calls); + + return Task.CompletedTask; + } + } } diff --git a/tests/SquidStd.Tests/Yaml/YamlUtilsTests.cs b/tests/SquidStd.Tests/Yaml/YamlUtilsTests.cs index 218cd8c7..dc82d2bb 100644 --- a/tests/SquidStd.Tests/Yaml/YamlUtilsTests.cs +++ b/tests/SquidStd.Tests/Yaml/YamlUtilsTests.cs @@ -5,9 +5,13 @@ namespace SquidStd.Tests.Yaml; public class YamlUtilsTests { - [Theory, InlineData(""), InlineData(" ")] + [Theory] + [InlineData("")] + [InlineData(" ")] public void Deserialize_NullOrWhitespace_Throws(string yaml) - => Assert.Throws(() => YamlUtils.Deserialize(yaml)); + { + Assert.Throws(() => YamlUtils.Deserialize(yaml)); + } [Fact] public void Deserialize_RuntimeType_ReturnsTypedObject() @@ -27,9 +31,11 @@ public void Deserialize_RuntimeType_ReturnsTypedObject() [Fact] public void DeserializeFromFile_MissingFile_Throws() - => Assert.Throws( - () => YamlUtils.DeserializeFromFile(Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".yaml")) + { + Assert.Throws(() => + YamlUtils.DeserializeFromFile(Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".yaml")) ); + } [Fact] public void DeserializeSection_ExistingSection_ReturnsTypedObject() @@ -63,7 +69,9 @@ public void DeserializeSection_MissingSection_ReturnsNull() [Fact] public void Serialize_NullObject_Throws() - => Assert.Throws(() => YamlUtils.Serialize(null!)); + { + Assert.Throws(() => YamlUtils.Serialize(null!)); + } [Fact] public void SerializeDeserialize_RoundTrip_PreservesValues()