diff --git a/Directory.Build.props b/Directory.Build.props index 4d3a6fc6..c93c3323 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -69,7 +69,7 @@ - + @@ -77,7 +77,7 @@ README.md - + diff --git a/README.md b/README.md index 306d9ab4..c4efbf33 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,15 @@ block until cancellation for long-running hosts. | `SquidStd.Storage.S3` | S3/MinIO storage backend (`AddS3Storage`). | [![readme](https://img.shields.io/badge/readme-1390A3.svg)](src/SquidStd.Storage.S3/README.md) · [![NuGet](https://img.shields.io/nuget/v/SquidStd.Storage.S3.svg)](https://www.nuget.org/packages/SquidStd.Storage.S3/) | | `SquidStd.Scripting.Lua` | Lua scripting engine with attribute-based modules and event bridging. | [![readme](https://img.shields.io/badge/readme-1390A3.svg)](src/SquidStd.Scripting.Lua/README.md) · [![NuGet](https://img.shields.io/nuget/v/SquidStd.Scripting.Lua.svg)](https://www.nuget.org/packages/SquidStd.Scripting.Lua/) | | `SquidStd.Templating` | Scriban templating with a named-template registry and `templates/*.tmpl` auto-load (`AddTemplating`). | [![readme](https://img.shields.io/badge/readme-1390A3.svg)](src/SquidStd.Templating/README.md) · [![NuGet](https://img.shields.io/nuget/v/SquidStd.Templating.svg)](https://www.nuget.org/packages/SquidStd.Templating/) | +| `SquidStd.Workers.Abstractions` | Worker/manager shared contracts (`JobRequest`, `WorkerHeartbeat`, `WorkerInfo`, `WorkerChannels`). | [![readme](https://img.shields.io/badge/readme-1390A3.svg)](src/SquidStd.Workers.Abstractions/README.md) · [![NuGet](https://img.shields.io/nuget/v/SquidStd.Workers.Abstractions.svg)](https://www.nuget.org/packages/SquidStd.Workers.Abstractions/) | +| `SquidStd.Workers` | Worker runtime: consume jobs, dispatch to `IJobHandler`s, publish heartbeats (`AddWorkers`). | [![readme](https://img.shields.io/badge/readme-1390A3.svg)](src/SquidStd.Workers/README.md) · [![NuGet](https://img.shields.io/nuget/v/SquidStd.Workers.svg)](https://www.nuget.org/packages/SquidStd.Workers/) | +| `SquidStd.Workers.Manager` | Job enqueue, heartbeat registry, offline sweep, opt-in ASP.NET endpoints (`AddWorkerManager`). | [![readme](https://img.shields.io/badge/readme-1390A3.svg)](src/SquidStd.Workers.Manager/README.md) · [![NuGet](https://img.shields.io/nuget/v/SquidStd.Workers.Manager.svg)](https://www.nuget.org/packages/SquidStd.Workers.Manager/) | +| `SquidStd.Templates` | `dotnet new` templates for scaffolding SquidStd projects (host, ASP.NET, worker, manager). | [![readme](https://img.shields.io/badge/readme-1390A3.svg)](src/SquidStd.Templates/README.md) · [![NuGet](https://img.shields.io/nuget/v/SquidStd.Templates.svg)](https://www.nuget.org/packages/SquidStd.Templates/) | +| `SquidStd.Search.Abstractions` | Search/indexing contracts (`IIndexableEntity`, `[SearchIndex]`, `ISearchService`). | [![readme](https://img.shields.io/badge/readme-1390A3.svg)](src/SquidStd.Search.Abstractions/README.md) · [![NuGet](https://img.shields.io/nuget/v/SquidStd.Search.Abstractions.svg)](https://www.nuget.org/packages/SquidStd.Search.Abstractions/) | +| `SquidStd.Search.Elasticsearch` | Elasticsearch indexing + constrained LINQ query provider (`AddElasticsearch`). | [![readme](https://img.shields.io/badge/readme-1390A3.svg)](src/SquidStd.Search.Elasticsearch/README.md) · [![NuGet](https://img.shields.io/nuget/v/SquidStd.Search.Elasticsearch.svg)](https://www.nuget.org/packages/SquidStd.Search.Elasticsearch/) | +| `SquidStd.Mail.Abstractions` | Mail contracts (`MailMessage`, `MailReceivedEvent`, `IMailReader`, `MailOptions`). | [![readme](https://img.shields.io/badge/readme-1390A3.svg)](src/SquidStd.Mail.Abstractions/README.md) · [![NuGet](https://img.shields.io/nuget/v/SquidStd.Mail.Abstractions.svg)](https://www.nuget.org/packages/SquidStd.Mail.Abstractions/) | +| `SquidStd.Mail.MailKit` | IMAP/POP3 mail poller that publishes `MailReceivedEvent` (`AddMail`). | [![readme](https://img.shields.io/badge/readme-1390A3.svg)](src/SquidStd.Mail.MailKit/README.md) · [![NuGet](https://img.shields.io/nuget/v/SquidStd.Mail.MailKit.svg)](https://www.nuget.org/packages/SquidStd.Mail.MailKit/) | +| `SquidStd.Mail.Queue` | Outbound mail send queue over the messaging queue (`AddMailQueue`, `IMailQueue`). | [![readme](https://img.shields.io/badge/readme-1390A3.svg)](src/SquidStd.Mail.Queue/README.md) · [![NuGet](https://img.shields.io/nuget/v/SquidStd.Mail.Queue.svg)](https://www.nuget.org/packages/SquidStd.Mail.Queue/) | ## Architecture diff --git a/SquidStd.slnx b/SquidStd.slnx index 6d73a395..9188a0a0 100644 --- a/SquidStd.slnx +++ b/SquidStd.slnx @@ -1,29 +1,51 @@ - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + diff --git a/docs/articles/mail-abstractions.md b/docs/articles/mail-abstractions.md new file mode 100644 index 00000000..c480642b --- /dev/null +++ b/docs/articles/mail-abstractions.md @@ -0,0 +1 @@ +[!include[](../../src/SquidStd.Mail.Abstractions/README.md)] diff --git a/docs/articles/mail-mailkit.md b/docs/articles/mail-mailkit.md new file mode 100644 index 00000000..43c11c3a --- /dev/null +++ b/docs/articles/mail-mailkit.md @@ -0,0 +1 @@ +[!include[](../../src/SquidStd.Mail.MailKit/README.md)] diff --git a/docs/articles/mail-queue.md b/docs/articles/mail-queue.md new file mode 100644 index 00000000..a23eecd3 --- /dev/null +++ b/docs/articles/mail-queue.md @@ -0,0 +1 @@ +[!include[](../../src/SquidStd.Mail.Queue/README.md)] diff --git a/docs/articles/search-abstractions.md b/docs/articles/search-abstractions.md new file mode 100644 index 00000000..34bd075b --- /dev/null +++ b/docs/articles/search-abstractions.md @@ -0,0 +1 @@ +[!include[](../../src/SquidStd.Search.Abstractions/README.md)] diff --git a/docs/articles/search-elasticsearch.md b/docs/articles/search-elasticsearch.md new file mode 100644 index 00000000..bff4c934 --- /dev/null +++ b/docs/articles/search-elasticsearch.md @@ -0,0 +1 @@ +[!include[](../../src/SquidStd.Search.Elasticsearch/README.md)] diff --git a/docs/articles/templates.md b/docs/articles/templates.md new file mode 100644 index 00000000..8cbeaa7e --- /dev/null +++ b/docs/articles/templates.md @@ -0,0 +1 @@ +[!include[](../../src/SquidStd.Templates/README.md)] diff --git a/docs/articles/toc.yml b/docs/articles/toc.yml index 280a0eb9..fcc51d45 100644 --- a/docs/articles/toc.yml +++ b/docs/articles/toc.yml @@ -44,6 +44,18 @@ href: workers.md - name: SquidStd.Workers.Manager href: workers-manager.md +- name: SquidStd.Templates + href: templates.md +- name: SquidStd.Search.Abstractions + href: search-abstractions.md +- name: SquidStd.Search.Elasticsearch + href: search-elasticsearch.md +- name: SquidStd.Mail.Abstractions + href: mail-abstractions.md +- name: SquidStd.Mail.MailKit + href: mail-mailkit.md +- name: SquidStd.Mail.Queue + href: mail-queue.md - name: Serialization href: serialization.md - name: Scheduler diff --git a/docs/docfx.json b/docs/docfx.json index 8565ef16..26c8aa44 100644 --- a/docs/docfx.json +++ b/docs/docfx.json @@ -15,7 +15,7 @@ "build": { "content": [ { "files": [ "api/**.{yml,md}" ] }, - { "files": [ "articles/**.{md,yml}", "*.md", "toc.yml" ] } + { "files": [ "articles/**.{md,yml}", "tutorials/**.{md,yml}", "*.md", "toc.yml" ] } ], "resource": [ { "files": [ "images/**" ] } diff --git a/docs/toc.yml b/docs/toc.yml index 019419e8..f780a685 100644 --- a/docs/toc.yml +++ b/docs/toc.yml @@ -2,5 +2,7 @@ href: index.md - name: Articles href: articles/ +- name: Tutorials + href: tutorials/ - name: API href: api/ diff --git a/docs/tutorials/aspnetcore-app.md b/docs/tutorials/aspnetcore-app.md new file mode 100644 index 00000000..93f37afe --- /dev/null +++ b/docs/tutorials/aspnetcore-app.md @@ -0,0 +1,55 @@ +# Host SquidStd in an ASP.NET Core app + +Wire SquidStd into an ASP.NET Core Minimal API and expose its health checks over HTTP. + +## What you'll build + +A Minimal API web app that uses SquidStd (`SquidStd.AspNetCore`) as its DryIoc-backed service provider, surfaces +the registered SquidStd health checks at `/health`, and answers a simple root endpoint. + +## Prerequisites + +- .NET 10 SDK +- `dotnet add package SquidStd.AspNetCore` + +## Steps + +### 1. Register SquidStd on the builder + +`UseSquidStd` swaps in DryIoc as the ASP.NET Core service provider and bootstraps SquidStd. The root directory is +taken from the host environment automatically, so you only set the config name. + +[!code-csharp[](../../samples/SquidStd.Samples.AspNetCore/Program.cs#step-1)] + +### 2. Bridge the health checks + +`AddSquidStdHealthChecks` registers every SquidStd `IHealthCheck` as a standard ASP.NET Core health check. Call it +after `UseSquidStd`. + +[!code-csharp[](../../samples/SquidStd.Samples.AspNetCore/Program.cs#step-2)] + +### 3. Build the app and map endpoints + +Build the app, expose the health checks with the standard `MapHealthChecks`, add a root endpoint, and run. + +[!code-csharp[](../../samples/SquidStd.Samples.AspNetCore/Program.cs#step-3)] + +## Run it + +```bash +dotnet run --project samples/SquidStd.Samples.AspNetCore +``` + +Browse to `/` for the "SquidStd up" message and to `/health` for the aggregated health-check status. + +## How it works + +`UseSquidStd` creates an owned DryIoc container, registers it through `DryIocServiceProviderFactory`, and hooks the +SquidStd lifecycle into the ASP.NET Core host via a hosted service. `AddSquidStdHealthChecks` resolves the SquidStd +`IHealthCheck` instances from that container and adapts each one into the standard health-check pipeline, so +`MapHealthChecks` reports them like any other ASP.NET Core check. + +## See also + +- [SquidStd.AspNetCore reference](../articles/aspnetcore.md) +- Previous: [Getting started](getting-started.md) diff --git a/docs/tutorials/caching.md b/docs/tutorials/caching.md new file mode 100644 index 00000000..2d7d84c0 --- /dev/null +++ b/docs/tutorials/caching.md @@ -0,0 +1,52 @@ +# Caching (in-memory + Redis) + +Cache typed values with a TTL, then swap the in-memory backend for Redis without changing your code. + +## What you'll build + +A host that resolves `ICacheService` (`SquidStd.Caching.Abstractions`) backed by the in-memory provider +(`SquidStd.Caching`), and the one-line change to use Redis (`SquidStd.Caching.Redis`) instead. + +## Prerequisites + +- .NET 10 SDK +- `dotnet add package SquidStd.Caching` (and `SquidStd.Caching.Redis` for the Redis backend) +- For Redis: `docker run -p 6379:6379 redis` + +## Steps + +### 1. Register the in-memory cache + +[!code-csharp[](../../samples/SquidStd.Samples.Caching/Program.cs#step-1)] + +### 2. Set and get typed values + +`ICacheService` stores typed values with an optional TTL; `GetAsync` returns `null` on a miss. + +[!code-csharp[](../../samples/SquidStd.Samples.Caching/Program.cs#step-2)] + +### 3. Switch to Redis + +Replace the registration with the Redis backend — the `ICacheService` usage is identical: + +```csharp +container.AddRedisCache("redis://localhost:6379"); +``` + +## Run it + +```bash +dotnet run --project samples/SquidStd.Samples.Caching +``` + +Prints `hello`. + +## How it works + +`ICacheService` is the typed facade over an `ICacheProvider`; the provider is the swappable backend (in-memory or +Redis). Values are serialized with the shared `IDataSerializer`, so the same code works on either backend. + +## See also + +- [SquidStd.Caching reference](../articles/caching.md) +- [SquidStd.Caching.Redis reference](../articles/caching-redis.md) diff --git a/docs/tutorials/database.md b/docs/tutorials/database.md new file mode 100644 index 00000000..b1a86e10 --- /dev/null +++ b/docs/tutorials/database.md @@ -0,0 +1,52 @@ +# Database (FreeSql + SQLite) + +Persist a typed entity and read it back with paging, using SQLite with no external database. + +## What you'll build + +A host that registers the database subsystem (`SquidStd.Database`) and resolves the generic data access +`IDataAccess` (`SquidStd.Database.Abstractions`) to insert and page over a simple entity. It runs against a +local SQLite file out of the box. + +## Prerequisites + +- .NET 10 SDK +- `dotnet add package SquidStd.Database` +- No external database: the default connection string is `sqlite://squidstd.db`, and the schema is auto-migrated on + startup. + +## Steps + +### 1. Register the database service + +`RegisterDatabase` wires the `database` config section, the `IDatabaseService` (which owns the FreeSql instance), and +the open-generic `IDataAccess<>`. Starting the bootstrap starts the database service and auto-creates the schema. + +[!code-csharp[](../../samples/SquidStd.Samples.Database/Program.cs#step-1)] + +### 2. Insert and page over an entity + +Resolve `IDataAccess` for any `BaseEntity`-derived type. `InsertAsync` assigns the id and timestamps; +`GetPagedAsync` returns a `PagedResultData` with items and paging metadata. + +[!code-csharp[](../../samples/SquidStd.Samples.Database/Program.cs#step-2)] + +## Run it + +```bash +dotnet run --project samples/SquidStd.Samples.Database +``` + +Prints the two inserted products ordered by price, with the total count and page metadata. + +## How it works + +`IDatabaseService` builds a single FreeSql ORM from the URI-style connection string (the scheme selects the provider: +`sqlite`, `postgres`, `mysql`, `sqlserver`). `IDataAccess` is the generic repository over any +`BaseEntity`: it exposes CRUD, bulk operations, a composable `Query()` (FreeSql `ISelect`), and `GetPagedAsync` for +page + total-count reads. Every entity gets a `Guid` id plus UTC `Created`/`Updated` timestamps from `BaseEntity`. + +## See also + +- [SquidStd.Database reference](../articles/database.md) +- [SquidStd.Database.Abstractions reference](../articles/database-abstractions.md) diff --git a/docs/tutorials/email.md b/docs/tutorials/email.md new file mode 100644 index 00000000..16fae30d --- /dev/null +++ b/docs/tutorials/email.md @@ -0,0 +1,60 @@ +# Email: receive, send, and queue + +Poll a mailbox for inbound mail, send outbound mail over SMTP, and queue mail for background delivery. + +## What you'll build + +A host using `SquidStd.Mail.MailKit` and `SquidStd.Mail.Queue`: an IMAP poller that raises a `MailReceivedEvent` +for each new email, an SMTP sender (`IMailSender`), and a fire-and-forget send queue (`IMailQueue`) backed by +messaging. The contracts live in `SquidStd.Mail.Abstractions`. + +## Prerequisites + +- .NET 10 SDK +- `dotnet add package SquidStd.Mail.MailKit` +- `dotnet add package SquidStd.Mail.Queue` +- Real IMAP/SMTP credentials are only needed to actually connect; the sample compiles and runs without them. + +## Steps + +### 1. Receive: poll a mailbox + +`AddMail` registers an IMAP/POP3 poller; each received message is published as a `MailReceivedEvent`, which an +`IAsyncEventListener` handles. + +[!code-csharp[](../../samples/SquidStd.Samples.Email/Program.cs#step-1)] + +### 2. Send: outbound SMTP + +`AddMailSender` registers `IMailSender`; build an `OutgoingMailMessage` and call `SendAsync`. + +[!code-csharp[](../../samples/SquidStd.Samples.Email/Program.cs#step-2)] + +### 3. Queue: background delivery + +`AddInMemoryMessaging` plus `AddMailQueue` register `IMailQueue`; `EnqueueAsync` hands the message to a +background consumer that sends it via the SMTP sender. + +[!code-csharp[](../../samples/SquidStd.Samples.Email/Program.cs#step-3)] + +## Run it + +```bash +dotnet run --project samples/SquidStd.Samples.Email +``` + +It registers the poller, sender, and queue, registers the received-mail listener, and enqueues a message. +Pass `--send` to also attempt a synchronous SMTP send (requires a reachable server). + +## How it works + +The poller runs on the timer wheel, fetches new messages over IMAP/POP3 with MailKit, and publishes a +`MailReceivedEvent` on the event bus. `IMailSender` maps `OutgoingMailMessage` to a MIME message and sends it. +`AddMailQueue` layers a queue over `SquidStd.Messaging`: `EnqueueAsync` publishes to the queue and a background +consumer (`MailSendConsumerService`) drains it through `IMailSender`, decoupling request latency from delivery. + +## See also + +- [SquidStd.Mail.MailKit reference](../articles/mail-mailkit.md) +- [SquidStd.Mail.Queue reference](../articles/mail-queue.md) +- [SquidStd.Mail.Abstractions reference](../articles/mail-abstractions.md) diff --git a/docs/tutorials/events-jobs-scheduling.md b/docs/tutorials/events-jobs-scheduling.md new file mode 100644 index 00000000..80f3a594 --- /dev/null +++ b/docs/tutorials/events-jobs-scheduling.md @@ -0,0 +1,58 @@ +# Events, jobs and scheduling + +Publish in-process events, run work on a thread pool, and schedule recurring jobs with cron. + +## What you'll build + +A host that uses three core services from `SquidStd.Services.Core`: the `IEventBus` (publish/subscribe), the +`IJobSystem` (thread-pool work), and the `ICronScheduler` (cron-based recurring jobs). + +## Prerequisites + +- .NET 10 SDK +- `dotnet add package SquidStd.Services.Core` + +The cron scheduler and timer wheel are opt-in — enable them with `RegisterSchedulerServices()`: + +[!code-csharp[](../../samples/SquidStd.Samples.EventsJobsScheduling/Program.cs#step-1)] + +## Steps + +### 1. Publish and handle an event + +Implement `IAsyncEventListener`, register it, and publish an `IEvent`. Every registered listener is awaited. + +[!code-csharp[](../../samples/SquidStd.Samples.EventsJobsScheduling/Program.cs#step-1)] + +### 2. Run work on the job system + +`IJobSystem.ScheduleAsync` runs an `Action` on a worker thread and returns a `Task` that completes when it finishes. + +[!code-csharp[](../../samples/SquidStd.Samples.EventsJobsScheduling/Program.cs#step-2)] + +### 3. Schedule a cron job + +`ICronScheduler.Schedule` takes a name, a 5-field cron expression (UTC), and an async handler invoked on each +occurrence. + +[!code-csharp[](../../samples/SquidStd.Samples.EventsJobsScheduling/Program.cs#step-3)] + +## Run it + +```bash +dotnet run --project samples/SquidStd.Samples.EventsJobsScheduling +``` + +You'll see `received: hello`, `job ran on a worker thread`, and—if you let it run across a 5-minute boundary— +`cron tick`. + +## How it works + +The event bus dispatches to sync and async listeners; the job system is a fixed-size worker-thread pool; the cron +scheduler is driven by the timer wheel (registered by `RegisterSchedulerServices`). + +## See also + +- [SquidStd.Services.Core reference](../articles/services-core.md) +- [SquidStd.Core reference](../articles/core.md) +- Previous: [Getting started](getting-started.md) diff --git a/docs/tutorials/getting-started.md b/docs/tutorials/getting-started.md new file mode 100644 index 00000000..efbc9def --- /dev/null +++ b/docs/tutorials/getting-started.md @@ -0,0 +1,56 @@ +# Getting started: your first SquidStd host + +Build a minimal SquidStd application: a bootstrapper that loads config, configures logging, and runs the +registered services until shutdown. + +## What you'll build + +A console host on top of `SquidStdBootstrap` (from `SquidStd.Services.Core`, which brings in `SquidStd.Core` +and `SquidStd.Abstractions`). It loads its config, wires the default services, and runs until you stop it. + +## Prerequisites + +- .NET 10 SDK +- `dotnet add package SquidStd.Services.Core` + +## Steps + +### 1. Create the bootstrapper + +`SquidStdBootstrap.Create` takes a `SquidStdOptions` (config name + root directory) and registers the core +services (config, logging, event bus, jobs, timers…) into an owned DryIoc container. + +[!code-csharp[](../../samples/SquidStd.Samples.GettingStarted/Program.cs#step-1)] + +### 2. Start the services + +`StartAsync` starts every registered `ISquidStdService` in priority order. + +[!code-csharp[](../../samples/SquidStd.Samples.GettingStarted/Program.cs#step-2)] + +### 3. Run until shutdown + +`RunAsync` starts (if not already started), waits for cancellation, then stops the services cleanly. Use either +`StartAsync` plus your own loop, or just `RunAsync`. + +[!code-csharp[](../../samples/SquidStd.Samples.GettingStarted/Program.cs#step-3)] + +## Run it + +```bash +dotnet run --project samples/SquidStd.Samples.GettingStarted +``` + +The host starts, logs the service lifecycle, and waits until you press Ctrl+C. + +## How it works + +`SquidStdBootstrap` is the composition root: it builds the container, registers the core services, loads the +config sections, and orchestrates the `ISquidStdService` lifecycle. Every other SquidStd module plugs into this +container through its `Add…` extension methods. + +## See also + +- [SquidStd.Services.Core reference](../articles/services-core.md) +- [SquidStd.Core reference](../articles/core.md) +- Next: [Events, jobs and scheduling](events-jobs-scheduling.md) diff --git a/docs/tutorials/index.md b/docs/tutorials/index.md new file mode 100644 index 00000000..3ab9e45e --- /dev/null +++ b/docs/tutorials/index.md @@ -0,0 +1,29 @@ +# Tutorials + +Task-oriented, step-by-step guides. For per-type API details, see the Articles and +API sections. + +## Fundamentals +- [Getting started: host, config, logging](getting-started.md) +- [Events, jobs and scheduling](events-jobs-scheduling.md) +- [ASP.NET apps and health checks](aspnetcore-app.md) + +## Data & storage +- [Caching (in-memory + Redis)](caching.md) +- [Storage (files & objects, local + S3)](storage.md) +- [Database access](database.md) + +## Messaging & workers +- [Messaging: queues & pub/sub](messaging.md) +- [A worker system end-to-end](worker-system.md) + +## Mail & search +- [Email: receive, send, queue](email.md) +- [Indexing and search with Elasticsearch](search.md) + +## Tooling & extras +- [Templating with Scriban](templating.md) +- [Lua scripting](scripting-lua.md) +- [TCP networking](networking.md) +- [Plugins](plugins.md) +- [Scaffolding with `dotnet new`](scaffolding.md) diff --git a/docs/tutorials/messaging.md b/docs/tutorials/messaging.md new file mode 100644 index 00000000..b69535a4 --- /dev/null +++ b/docs/tutorials/messaging.md @@ -0,0 +1,51 @@ +# Messaging: queues and pub/sub + +Send work to a competing-consumers queue, and broadcast events to a fan-out topic. + +## What you'll build + +A host using `SquidStd.Messaging`: an `IMessageQueue` (one consumer handles each message) and an `IMessageTopic` +(every subscriber receives each message). The same APIs work over RabbitMQ via `SquidStd.Messaging.RabbitMq`. + +## Prerequisites + +- .NET 10 SDK +- `dotnet add package SquidStd.Messaging` (or `SquidStd.Messaging.RabbitMq`) + +## Steps + +### 1. Register in-memory messaging + +[!code-csharp[](../../samples/SquidStd.Samples.Messaging/Program.cs#step-1)] + +### 2. Queue: competing consumers + +`Subscribe` a listener and `PublishAsync` to a named queue. Each message is handled by exactly one consumer. + +[!code-csharp[](../../samples/SquidStd.Samples.Messaging/Program.cs#step-2)] + +### 3. Topic: fan-out pub/sub + +A topic delivers each published message to every current subscriber. + +[!code-csharp[](../../samples/SquidStd.Samples.Messaging/Program.cs#step-3)] + +## Run it + +```bash +dotnet run --project samples/SquidStd.Samples.Messaging +``` + +Prints `queue handled order-1` and `topic saw order-2`. + +## How it works + +Queues use competing-consumers with retry and dead-lettering (`MessagingOptions`); topics use transient fan-out +(at-most-once). Swap `AddInMemoryMessaging()` for `AddRabbitMqMessaging(...)` for a durable broker — the +`IMessageQueue`/`IMessageTopic` code is unchanged. + +## See also + +- [SquidStd.Messaging reference](../articles/messaging.md) +- [SquidStd.Messaging.RabbitMq reference](../articles/messaging-rabbitmq.md) +- Related: [A worker system end-to-end](worker-system.md) diff --git a/docs/tutorials/networking.md b/docs/tutorials/networking.md new file mode 100644 index 00000000..b125208a --- /dev/null +++ b/docs/tutorials/networking.md @@ -0,0 +1,51 @@ +# TCP networking (server + client) + +Stand up a TCP server, subscribe to its lifecycle and data events, then round-trip a message from a client. + +## What you'll build + +A `SquidTcpServer` (`SquidStd.Network`) bound to a loopback endpoint that logs client connections and received +payloads, plus a `SquidStdTcpClient` that connects and sends bytes. The server run is guarded behind `--run` so +`dotnet run` returns immediately by default. + +## Prerequisites + +- .NET 10 SDK +- `dotnet add package SquidStd.Network` +- A free local TCP port (the sample uses 9099 on loopback) + +## Steps + +### 1. Create the server and subscribe to events + +Construct `SquidTcpServer` with an `IPEndPoint`, then subscribe to `OnClientConnect` and `OnDataReceived`. The data +event carries the source client and the payload as `ReadOnlyMemory`. + +[!code-csharp[](../../samples/SquidStd.Samples.Networking/Program.cs#step-1)] + +### 2. Start the server and connect a client + +`StartAsync` begins accepting connections. `SquidStdTcpClient.ConnectAsync` opens an outbound connection and starts +its receive loop; `SendAsync` writes the payload, which surfaces on the server's `OnDataReceived`. + +[!code-csharp[](../../samples/SquidStd.Samples.Networking/Program.cs#step-2)] + +## Run it + +```bash +dotnet run --project samples/SquidStd.Samples.Networking -- --run +``` + +Without `--run` the program just prints a hint and exits. With `--run` it prints the connecting session and the +received message (`hello squid`). + +## How it works + +`SquidTcpServer` recreates its listening socket on every `StartAsync`, so Stop/Start cycles are supported. Each +accepted socket is wrapped in a `SquidStdTcpClient` whose events (`OnConnected`, `OnDataReceived`, `OnDisconnected`, +`OnException`) are re-raised by the server. An optional `INetFramer` lets you emit one `OnDataReceived` per logical +frame instead of per socket read, and `INetMiddleware` components can transform inbound and outbound payloads. + +## See also + +- [SquidStd.Network reference](../articles/network.md) diff --git a/docs/tutorials/plugins.md b/docs/tutorials/plugins.md new file mode 100644 index 00000000..1cff0815 --- /dev/null +++ b/docs/tutorials/plugins.md @@ -0,0 +1,54 @@ +# Plugins + +Implement the SquidStd plugin contract and have a plugin register its own services into the container. + +## What you'll build + +A console app that defines a plugin implementing `ISquidStdPlugin` (`SquidStd.Plugin.Abstractions`), describes +it with `PluginMetadata`, and calls `Configure` to register a service that the host then resolves. + +## Prerequisites + +- .NET 10 SDK +- `dotnet add package SquidStd.Plugin.Abstractions` + +## Steps + +### 1. Implement the plugin contract + +A plugin exposes a `PluginMetadata` (its identity and dependencies) and a `Configure` method that registers its +services, handlers, and integrations into the DryIoc container. + +[!code-csharp[](../../samples/SquidStd.Samples.Plugins/Program.cs#step-1)] + +### 2. Describe, configure, and resolve + +The host builds a `PluginContext` for boot-time data, invokes `Configure` on the plugin, and then resolves the +services the plugin registered. + +[!code-csharp[](../../samples/SquidStd.Samples.Plugins/Program.cs#step-2)] + +## Run it + +```bash +dotnet run --project samples/SquidStd.Samples.Plugins +``` + +Prints: + +``` +Loading Weather Plugin v1.0.0 by SquidStd Samples +Hello squid, the weather plugin is online. +``` + +## How it works + +`ISquidStdPlugin` is the contract a host loads: `Metadata` is the source of truth for plugin identity and load +order (`Dependencies`), while `Configure(IContainer, PluginContext)` is called during container configuration so +the plugin can register everything it contributes. `PluginContext.Data` carries host-supplied boot values into +the plugin. A real host discovers plugin assemblies and calls `Configure` in dependency order; this sample wires +one plugin by hand so the contract is fully runnable without external assemblies. + +## See also + +- [SquidStd.Plugin.Abstractions reference](../articles/plugin-abstractions.md) diff --git a/docs/tutorials/scaffolding.md b/docs/tutorials/scaffolding.md new file mode 100644 index 00000000..d852beb3 --- /dev/null +++ b/docs/tutorials/scaffolding.md @@ -0,0 +1,68 @@ +# Scaffolding projects with `dotnet new` + +Generate ready-to-run SquidStd projects from the command line with the `SquidStd.Templates` pack. + +## What you'll build + +Nothing by hand — you'll install the template pack and scaffold a console host, an ASP.NET app, a worker +microservice, and a worker manager, each pre-wired to SquidStd. + +## Prerequisites + +- .NET 10 SDK + +## Steps + +### 1. Install the template pack + +```bash +dotnet new install SquidStd.Templates +``` + +This registers the SquidStd templates; list them with `dotnet new list squidstd`. + +### 2. Scaffold a project + +```bash +# console host +dotnet new squidstd-host -n Acme.Host + +# ASP.NET minimal API (UseSquidStd + health checks) +dotnet new squidstd-aspnetcore -n Acme.Api + +# worker microservice (consumes jobs, publishes heartbeats) +dotnet new squidstd-worker -n Acme.Worker + +# worker manager (enqueue jobs + ASP.NET endpoints) +dotnet new squidstd-manager -n Acme.Manager +``` + +### 3. Pick a messaging transport (worker / manager) + +The `squidstd-worker` and `squidstd-manager` templates accept `--messaging`: + +```bash +dotnet new squidstd-worker -n Acme.Worker --messaging rabbitmq # default +dotnet new squidstd-worker -n Acme.Worker --messaging inmemory +``` + +## Run it + +```bash +cd Acme.Worker +dotnet run +``` + +The generated project references the SquidStd packages at the same version as the template pack and is ready to +build and run. + +## How it works + +`SquidStd.Templates` is a `dotnet new` template pack. Each template ships a runnable project (with a `Dockerfile` +for the service templates) whose `PackageReference` versions are injected to match the pack, so a scaffolded app +always lines up with the libraries it targets. + +## See also + +- [SquidStd.Templates reference](../articles/templates.md) +- Related: [A worker system end-to-end](worker-system.md) diff --git a/docs/tutorials/scripting-lua.md b/docs/tutorials/scripting-lua.md new file mode 100644 index 00000000..5b231783 --- /dev/null +++ b/docs/tutorials/scripting-lua.md @@ -0,0 +1,54 @@ +# Scripting with Lua + +Embed a Lua engine in your host, expose C# values to scripts, and read results back. + +## What you'll build + +A console host that registers the `IScriptEngineService` (`SquidStd.Scripting.Lua`) through the SquidStd +bootstrap, runs a small Lua script, and prints values evaluated by the engine. + +## Prerequisites + +- .NET 10 SDK +- `dotnet add package SquidStd.Scripting.Lua` +- `dotnet add package SquidStd.Services.Core` + +## 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. + +[!code-csharp[](../../samples/SquidStd.Samples.ScriptingLua/Program.cs#step-1)] + +### 2. Run a script and read results + +`RegisterGlobal` exposes a C# value under an exact name, `ExecuteScript` runs Lua code, and +`ExecuteFunction` evaluates an expression and returns a `ScriptResult` whose `Data` holds the value. + +[!code-csharp[](../../samples/SquidStd.Samples.ScriptingLua/Program.cs#step-2)] + +## Run it + +```bash +dotnet run --project samples/SquidStd.Samples.ScriptingLua +``` + +Prints: + +``` +3 + 4 = 7 +result = hello from C# and lua +``` + +## How it works + +`IScriptEngineService` wraps a MoonSharp `Script`. Registering it with `RegisterStdService` lets the bootstrap +call its `StartAsync` during `StartAsync`, which wires up modules, constants, and a file watcher over the +configured scripts directory. Globals you register become Lua variables, and `ExecuteFunction` evaluates a +`return ` and surfaces the value through `ScriptResult.Data`. + +## See also + +- [SquidStd.Scripting.Lua reference](../articles/scripting-lua.md) diff --git a/docs/tutorials/search.md b/docs/tutorials/search.md new file mode 100644 index 00000000..b5cfd683 --- /dev/null +++ b/docs/tutorials/search.md @@ -0,0 +1,60 @@ +# Search: index and query with Elasticsearch + +Index typed documents and query them with LINQ, translated to the Elasticsearch query DSL. + +## What you'll build + +A host using `SquidStd.Search.Elasticsearch`: register the provider, mark a record as indexable, then index a +document and query it back with a strongly-typed `IQueryable`. The contracts live in +`SquidStd.Search.Abstractions`, so your code stays decoupled from the Elasticsearch client. + +## Prerequisites + +- .NET 10 SDK +- `dotnet add package SquidStd.Search.Elasticsearch` +- A running Elasticsearch (only needed to actually execute queries): + + ```bash + docker run -p 9200:9200 -e discovery.type=single-node -e xpack.security.enabled=false elasticsearch:8.6.1 + ``` + +## Steps + +### 1. Register the Elasticsearch provider + +`AddElasticsearch` registers the client and `ISearchService` against your cluster endpoint. + +[!code-csharp[](../../samples/SquidStd.Samples.Search/Program.cs#step-1)] + +### 2. Define an indexable document + +Implement `IIndexableEntity` (supplying the document id) and tag the type with `[SearchIndex("orders")]`. + +[!code-csharp[](../../samples/SquidStd.Samples.Search/Program.cs#step-2)] + +### 3. Index and query + +`IndexAsync` stores a document; `Query()` returns a LINQ surface whose async terminals +(`ToListAsync`, `CountAsync`, `FirstOrDefaultAsync`) execute against the cluster. + +[!code-csharp[](../../samples/SquidStd.Samples.Search/Program.cs#step-3)] + +## Run it + +```bash +dotnet run --project samples/SquidStd.Samples.Search +``` + +With Elasticsearch running it prints `found 1 open order(s)`. + +## How it works + +`ISearchService.Query()` returns a constrained `IQueryable` backed by a custom provider. The expression +tree is translated to the Elasticsearch query DSL by `ElasticExpressionTranslator`; the async terminals and the +`.Match`/`.FullText` markers come from `SquidStd.Search.Elasticsearch.Linq`. The target index is resolved from +the `[SearchIndex]` attribute (with `${VAR}` expansion) or the lowercased type name. + +## See also + +- [SquidStd.Search.Elasticsearch reference](../articles/search-elasticsearch.md) +- [SquidStd.Search.Abstractions reference](../articles/search-abstractions.md) diff --git a/docs/tutorials/storage.md b/docs/tutorials/storage.md new file mode 100644 index 00000000..9be3be47 --- /dev/null +++ b/docs/tutorials/storage.md @@ -0,0 +1,52 @@ +# Object storage (file system + S3) + +Save and load typed objects by key on the local file system, then swap to S3 without changing your code. + +## What you'll build + +A host that resolves `IObjectStorageService` (`SquidStd.Storage.Abstractions`) backed by the file-system provider +(`SquidStd.Storage`), and the one-line change to use S3 instead. + +## Prerequisites + +- .NET 10 SDK +- `dotnet add package SquidStd.Storage` (and `SquidStd.Storage.S3` for the S3 backend) +- For S3: an S3-compatible endpoint and bucket (e.g. AWS S3 or MinIO) + +## Steps + +### 1. Register file storage + +[!code-csharp[](../../samples/SquidStd.Samples.Storage/Program.cs#step-1)] + +### 2. Save and load a typed object + +`IObjectStorageService` stores typed objects by logical key; `LoadAsync` returns `null` on a miss. + +[!code-csharp[](../../samples/SquidStd.Samples.Storage/Program.cs#step-2)] + +### 3. Switch to S3 + +Replace the registration with the S3 backend — the `IObjectStorageService` usage is identical: + +```csharp +container.AddS3Storage(/* endpoint, bucket and credentials */); +``` + +## Run it + +```bash +dotnet run --project samples/SquidStd.Samples.Storage +``` + +Prints `squid `. + +## How it works + +`IObjectStorageService` is the typed facade over a swappable storage backend (local file system or S3). Values are +serialized with the shared `IDataSerializer`, so the same `SaveAsync`/`LoadAsync` code works on either backend. + +## See also + +- [SquidStd.Storage reference](../articles/storage.md) +- [SquidStd.Storage.S3 reference](../articles/storage-s3.md) diff --git a/docs/tutorials/templating.md b/docs/tutorials/templating.md new file mode 100644 index 00000000..65b389ac --- /dev/null +++ b/docs/tutorials/templating.md @@ -0,0 +1,51 @@ +# Templating (Scriban) + +Render Scriban templates from inline strings or registered named templates against a model. + +## What you'll build + +A host that resolves `ITemplateRenderer` (`SquidStd.Templating`), renders an inline template against a model, then +registers a named template and renders it by name. + +## Prerequisites + +- .NET 10 SDK +- `dotnet add package SquidStd.Templating` +- No external infrastructure required + +## Steps + +### 1. Register templating + +[!code-csharp[](../../samples/SquidStd.Samples.Templating/Program.cs#step-1)] + +### 2. Render an inline template + +`RenderAsync` compiles and renders an ad-hoc template string against a model; properties are resolved with the +Scriban `{{ ... }}` syntax. + +[!code-csharp[](../../samples/SquidStd.Samples.Templating/Program.cs#step-2)] + +### 3. Register and render a named template + +`Register` compiles and caches a template under a name; `RenderByNameAsync` renders it later without recompiling. + +[!code-csharp[](../../samples/SquidStd.Samples.Templating/Program.cs#step-3)] + +## Run it + +```bash +dotnet run --project samples/SquidStd.Samples.Templating +``` + +Prints `Hi squid!` then `Welcome aboard, squid.`. + +## How it works + +`ITemplateRenderer` wraps the Scriban engine: `RenderAsync` handles one-off templates, while `Register` plus +`RenderByNameAsync` lets you compile a template once and reuse it by name. The model is bound by property name using +Scriban's case-insensitive member access. + +## See also + +- [SquidStd.Templating reference](../articles/templating.md) diff --git a/docs/tutorials/toc.yml b/docs/tutorials/toc.yml new file mode 100644 index 00000000..f6942f09 --- /dev/null +++ b/docs/tutorials/toc.yml @@ -0,0 +1,32 @@ +- name: Overview + href: index.md +- name: Getting started + href: getting-started.md +- name: Events, jobs and scheduling + href: events-jobs-scheduling.md +- name: Caching + href: caching.md +- name: Messaging + href: messaging.md +- name: ASP.NET apps and health checks + href: aspnetcore-app.md +- name: Storage + href: storage.md +- name: Database access + href: database.md +- name: A worker system end-to-end + href: worker-system.md +- name: Email + href: email.md +- name: Indexing and search + href: search.md +- name: Templating + href: templating.md +- name: Lua scripting + href: scripting-lua.md +- name: TCP networking + href: networking.md +- name: Plugins + href: plugins.md +- name: Scaffolding with dotnet new + href: scaffolding.md diff --git a/docs/tutorials/worker-system.md b/docs/tutorials/worker-system.md new file mode 100644 index 00000000..b66d2577 --- /dev/null +++ b/docs/tutorials/worker-system.md @@ -0,0 +1,65 @@ +# Build a worker system + +Run a background job system end to end: register workers, enqueue a job through the manager, and handle it. + +## What you'll build + +A console host that wires in-memory messaging, the worker runtime (`SquidStd.Workers`), and the worker manager +(`SquidStd.Workers.Manager`) on top of `SquidStdBootstrap`. It enqueues a `greet` job and a handler picks it up +and prints a greeting. + +## Prerequisites + +- .NET 10 SDK +- `dotnet add package SquidStd.Workers` +- `dotnet add package SquidStd.Workers.Manager` +- `dotnet add package SquidStd.Messaging` +- `dotnet add package SquidStd.Services.Core` + +## 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). + +[!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`. + +[!code-csharp[](../../samples/SquidStd.Samples.WorkerSystem/Program.cs#step-2)] + +### 3. Start, enqueue, and stop + +Start the host, resolve the `IJobScheduler`, enqueue a `greet` job with a parameter, give the worker a moment to +consume it, then stop cleanly. + +[!code-csharp[](../../samples/SquidStd.Samples.WorkerSystem/Program.cs#step-3)] + +## Run it + +```bash +dotnet run --project samples/SquidStd.Samples.WorkerSystem +``` + +You'll see `Hello, squid! (job: greet)` once the worker consumes the enqueued job. + +When hosting inside an ASP.NET Core app, you can instead expose the manager over HTTP with +`app.MapWorkerManagerEndpoints()`, which maps `GET /workers`, `GET /workers/{id}`, and `POST /jobs`. + +## How it works + +The manager's `IJobScheduler` publishes a `JobRequest` onto the messaging queue. The worker runtime's consumer +service reads the queue, the dispatcher routes each request to the `IJobHandler` whose `JobName` matches, and the +handler runs the work. Messaging, workers, and the manager are independent modules that compose through the shared +DryIoc container. + +## See also + +- [SquidStd.Workers reference](../articles/workers.md) +- [SquidStd.Workers.Manager reference](../articles/workers-manager.md) +- [SquidStd.Workers.Abstractions reference](../articles/workers-abstractions.md) +- Previous: [Events, jobs and scheduling](events-jobs-scheduling.md) diff --git a/samples/SquidStd.Samples.AspNetCore/Program.cs b/samples/SquidStd.Samples.AspNetCore/Program.cs new file mode 100644 index 00000000..2ec4105d --- /dev/null +++ b/samples/SquidStd.Samples.AspNetCore/Program.cs @@ -0,0 +1,18 @@ +using SquidStd.AspNetCore.Extensions; + +var builder = WebApplication.CreateBuilder(args); + +#region step-1 +builder.UseSquidStd(o => o.ConfigName = "squidstd"); +#endregion + +#region step-2 +builder.AddSquidStdHealthChecks(); +#endregion + +#region step-3 +var app = builder.Build(); +app.MapHealthChecks("/health"); +app.MapGet("/", () => "SquidStd up"); +app.Run(); +#endregion diff --git a/samples/SquidStd.Samples.AspNetCore/SquidStd.Samples.AspNetCore.csproj b/samples/SquidStd.Samples.AspNetCore/SquidStd.Samples.AspNetCore.csproj new file mode 100644 index 00000000..6183042e --- /dev/null +++ b/samples/SquidStd.Samples.AspNetCore/SquidStd.Samples.AspNetCore.csproj @@ -0,0 +1,18 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + diff --git a/samples/SquidStd.Samples.Caching/Program.cs b/samples/SquidStd.Samples.Caching/Program.cs new file mode 100644 index 00000000..a5c3baf2 --- /dev/null +++ b/samples/SquidStd.Samples.Caching/Program.cs @@ -0,0 +1,27 @@ +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 SquidStdOptions +{ + ConfigName = "squidstd", + RootDirectory = AppContext.BaseDirectory +}); + +#region step-1 +bootstrap.ConfigureServices(container => container.AddInMemoryCache()); +#endregion + +await bootstrap.StartAsync(); + +#region step-2 +var cache = bootstrap.Resolve(); + +await cache.SetAsync("greeting", "hello", TimeSpan.FromMinutes(5)); +var value = await cache.GetAsync("greeting"); + +Console.WriteLine(value); +#endregion + +await bootstrap.StopAsync(); diff --git a/samples/SquidStd.Samples.Caching/SquidStd.Samples.Caching.csproj b/samples/SquidStd.Samples.Caching/SquidStd.Samples.Caching.csproj new file mode 100644 index 00000000..370bfaed --- /dev/null +++ b/samples/SquidStd.Samples.Caching/SquidStd.Samples.Caching.csproj @@ -0,0 +1,16 @@ + + + + Exe + net10.0 + enable + enable + false + + + + + + + + diff --git a/samples/SquidStd.Samples.Database/Program.cs b/samples/SquidStd.Samples.Database/Program.cs new file mode 100644 index 00000000..d2adee30 --- /dev/null +++ b/samples/SquidStd.Samples.Database/Program.cs @@ -0,0 +1,45 @@ +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 SquidStdOptions +{ + ConfigName = "squidstd", + RootDirectory = AppContext.BaseDirectory +}); + +#region step-1 +bootstrap.ConfigureServices(container => container.RegisterDatabase()); + +await bootstrap.StartAsync(); +#endregion + +#region step-2 +var products = bootstrap.Resolve>(); + +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( + page: 1, + pageSize: 10, + orderBy: product => product.Price); + +Console.WriteLine($"Found {page.TotalCount} product(s) on page {page.Page}/{page.TotalPages}:"); + +foreach (var product in page.Items) +{ + Console.WriteLine($" {product.Name} - {product.Price:C}"); +} +#endregion + +await bootstrap.StopAsync(); + +internal sealed class Product : BaseEntity +{ + public string Name { get; set; } = string.Empty; + + public decimal Price { get; set; } +} diff --git a/samples/SquidStd.Samples.Database/SquidStd.Samples.Database.csproj b/samples/SquidStd.Samples.Database/SquidStd.Samples.Database.csproj new file mode 100644 index 00000000..7735fb2e --- /dev/null +++ b/samples/SquidStd.Samples.Database/SquidStd.Samples.Database.csproj @@ -0,0 +1,16 @@ + + + + Exe + net10.0 + enable + enable + false + + + + + + + + diff --git a/samples/SquidStd.Samples.Email/Program.cs b/samples/SquidStd.Samples.Email/Program.cs new file mode 100644 index 00000000..a8b339cf --- /dev/null +++ b/samples/SquidStd.Samples.Email/Program.cs @@ -0,0 +1,81 @@ +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; +using SquidStd.Mail.MailKit.Extensions; +using SquidStd.Mail.Queue.Extensions; +using SquidStd.Mail.Queue.Interfaces; +using SquidStd.Messaging.Extensions; +using SquidStd.Services.Core.Services.Bootstrap; + +var bootstrap = SquidStdBootstrap.Create(new SquidStdOptions +{ + ConfigName = "squidstd", + RootDirectory = AppContext.BaseDirectory +}); + +#region step-1 +bootstrap.ConfigureServices(container => container.AddMail(new MailOptions +{ + Protocol = MailProtocolType.Imap, + Host = "imap.example.com", + Port = 993, + Username = "alice@example.com", + Password = "app-password" +})); +#endregion + +#region step-2 +bootstrap.ConfigureServices(container => container.AddMailSender(new SmtpOptions +{ + Host = "smtp.example.com", + Port = 587 +})); +#endregion + +#region step-3 +bootstrap.ConfigureServices(container => container + .AddInMemoryMessaging() + .AddMailQueue()); +#endregion + +await bootstrap.StartAsync(); + +// Inbound: react to each received email on the event bus. +var eventBus = bootstrap.Resolve(); +eventBus.RegisterAsyncListener(new MailReceivedLogger()); + +var outgoing = new OutgoingMailMessage +{ + To = [new MailAddress("Bob", "bob@example.com")], + Subject = "Hi", + HtmlBody = "

Hi

" +}; + +// Outbound: queue for background sending (no network call). +var queue = bootstrap.Resolve(); +await queue.EnqueueAsync(outgoing); + +// Or send synchronously; guarded so the sample runs without a live SMTP server. +if (args.Contains("--send")) +{ + var sender = bootstrap.Resolve(); + await sender.SendAsync(outgoing); +} + +await bootstrap.StopAsync(); + +/// Logs every received email as it arrives on the event bus. +public sealed class MailReceivedLogger : IAsyncEventListener +{ + /// Handles a received-mail event. + public Task HandleAsync(MailReceivedEvent eventData, CancellationToken cancellationToken) + { + Console.WriteLine($"received: {eventData.Message.Subject}"); + + return Task.CompletedTask; + } +} diff --git a/samples/SquidStd.Samples.Email/SquidStd.Samples.Email.csproj b/samples/SquidStd.Samples.Email/SquidStd.Samples.Email.csproj new file mode 100644 index 00000000..1d68b9d9 --- /dev/null +++ b/samples/SquidStd.Samples.Email/SquidStd.Samples.Email.csproj @@ -0,0 +1,18 @@ + + + + Exe + net10.0 + enable + enable + false + + + + + + + + + + diff --git a/samples/SquidStd.Samples.EventsJobsScheduling/Program.cs b/samples/SquidStd.Samples.EventsJobsScheduling/Program.cs new file mode 100644 index 00000000..5e377260 --- /dev/null +++ b/samples/SquidStd.Samples.EventsJobsScheduling/Program.cs @@ -0,0 +1,54 @@ +using SquidStd.Core.Data.Bootstrap; +using SquidStd.Core.Interfaces.Events; +using SquidStd.Core.Interfaces.Jobs; +using SquidStd.Core.Interfaces.Scheduling; +using SquidStd.Services.Core.Extensions; +using SquidStd.Services.Core.Services.Bootstrap; + +var bootstrap = SquidStdBootstrap.Create(new SquidStdOptions +{ + ConfigName = "squidstd", + RootDirectory = AppContext.BaseDirectory +}); + +// The cron scheduler and timer wheel are opt-in. +bootstrap.ConfigureServices(container => container.RegisterSchedulerServices()); + +await bootstrap.StartAsync(); + +#region step-1 +var eventBus = bootstrap.Resolve(); +eventBus.RegisterAsyncListener(new PingListener()); +await eventBus.PublishAsync(new PingEvent("hello"), CancellationToken.None); +#endregion + +#region step-2 +var jobs = bootstrap.Resolve(); +await jobs.ScheduleAsync(() => Console.WriteLine("job ran on a worker thread")); +#endregion + +#region step-3 +var cron = bootstrap.Resolve(); +cron.Schedule("heartbeat", "*/5 * * * *", _ => +{ + Console.WriteLine("cron tick"); + + return Task.CompletedTask; +}); +#endregion + +await bootstrap.StopAsync(); + +/// A sample event published on the bus. +public sealed record PingEvent(string Message) : IEvent; + +/// Handles . +public sealed class PingListener : IAsyncEventListener +{ + public Task HandleAsync(PingEvent eventData, CancellationToken cancellationToken) + { + Console.WriteLine($"received: {eventData.Message}"); + + return Task.CompletedTask; + } +} diff --git a/samples/SquidStd.Samples.EventsJobsScheduling/SquidStd.Samples.EventsJobsScheduling.csproj b/samples/SquidStd.Samples.EventsJobsScheduling/SquidStd.Samples.EventsJobsScheduling.csproj new file mode 100644 index 00000000..52bad92e --- /dev/null +++ b/samples/SquidStd.Samples.EventsJobsScheduling/SquidStd.Samples.EventsJobsScheduling.csproj @@ -0,0 +1,15 @@ + + + + Exe + net10.0 + enable + enable + false + + + + + + + diff --git a/samples/SquidStd.Samples.GettingStarted/Program.cs b/samples/SquidStd.Samples.GettingStarted/Program.cs new file mode 100644 index 00000000..bea03ed3 --- /dev/null +++ b/samples/SquidStd.Samples.GettingStarted/Program.cs @@ -0,0 +1,18 @@ +using SquidStd.Core.Data.Bootstrap; +using SquidStd.Services.Core.Services.Bootstrap; + +#region step-1 +var bootstrap = SquidStdBootstrap.Create(new SquidStdOptions +{ + ConfigName = "squidstd", + RootDirectory = AppContext.BaseDirectory +}); +#endregion + +#region step-2 +await bootstrap.StartAsync(); +#endregion + +#region step-3 +await bootstrap.RunAsync(); +#endregion diff --git a/samples/SquidStd.Samples.GettingStarted/SquidStd.Samples.GettingStarted.csproj b/samples/SquidStd.Samples.GettingStarted/SquidStd.Samples.GettingStarted.csproj new file mode 100644 index 00000000..52bad92e --- /dev/null +++ b/samples/SquidStd.Samples.GettingStarted/SquidStd.Samples.GettingStarted.csproj @@ -0,0 +1,15 @@ + + + + Exe + net10.0 + enable + enable + false + + + + + + + diff --git a/samples/SquidStd.Samples.Messaging/Program.cs b/samples/SquidStd.Samples.Messaging/Program.cs new file mode 100644 index 00000000..9f7f70a0 --- /dev/null +++ b/samples/SquidStd.Samples.Messaging/Program.cs @@ -0,0 +1,55 @@ +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 SquidStdOptions +{ + ConfigName = "squidstd", + RootDirectory = AppContext.BaseDirectory +}); + +#region step-1 +bootstrap.ConfigureServices(container => container.AddInMemoryMessaging()); +#endregion + +await bootstrap.StartAsync(); + +#region step-2 +var queue = bootstrap.Resolve(); +using (queue.Subscribe("orders", new OrderListener())) +{ + await queue.PublishAsync("orders", new OrderPlaced("order-1")); + await Task.Delay(200); +} +#endregion + +#region step-3 +var topic = bootstrap.Resolve(); +using (topic.Subscribe("order-events", (order, _) => +{ + Console.WriteLine($"topic saw {order.Id}"); + + return Task.CompletedTask; +})) +{ + await topic.PublishAsync("order-events", new OrderPlaced("order-2")); + await Task.Delay(200); +} +#endregion + +await bootstrap.StopAsync(); + +/// A sample message. +public sealed record OrderPlaced(string Id); + +/// Competing-consumers listener for the orders queue. +public sealed class OrderListener : IQueueMessageListenerAsync +{ + public Task HandleAsync(OrderPlaced message, CancellationToken cancellationToken) + { + Console.WriteLine($"queue handled {message.Id}"); + + return Task.CompletedTask; + } +} diff --git a/samples/SquidStd.Samples.Messaging/SquidStd.Samples.Messaging.csproj b/samples/SquidStd.Samples.Messaging/SquidStd.Samples.Messaging.csproj new file mode 100644 index 00000000..602ca8e5 --- /dev/null +++ b/samples/SquidStd.Samples.Messaging/SquidStd.Samples.Messaging.csproj @@ -0,0 +1,16 @@ + + + + Exe + net10.0 + enable + enable + false + + + + + + + + diff --git a/samples/SquidStd.Samples.Networking/Program.cs b/samples/SquidStd.Samples.Networking/Program.cs new file mode 100644 index 00000000..18967535 --- /dev/null +++ b/samples/SquidStd.Samples.Networking/Program.cs @@ -0,0 +1,34 @@ +using System.Net; +using System.Text; +using SquidStd.Network.Client; +using SquidStd.Network.Server; + +#region step-1 +var endPoint = new IPEndPoint(IPAddress.Loopback, 9099); +var server = new SquidTcpServer(endPoint); + +server.OnClientConnect += (_, args) => + 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)}"); +#endregion + +if (!args.Contains("--run")) +{ + Console.WriteLine("Pass --run to start the TCP server and round-trip a message."); + + return; +} + +#region step-2 +await server.StartAsync(CancellationToken.None); + +var client = await SquidStdTcpClient.ConnectAsync(endPoint); +await client.SendAsync(Encoding.UTF8.GetBytes("hello squid"), CancellationToken.None); + +await Task.Delay(200); + +await client.DisposeAsync(); +await server.DisposeAsync(); +#endregion diff --git a/samples/SquidStd.Samples.Networking/SquidStd.Samples.Networking.csproj b/samples/SquidStd.Samples.Networking/SquidStd.Samples.Networking.csproj new file mode 100644 index 00000000..76741092 --- /dev/null +++ b/samples/SquidStd.Samples.Networking/SquidStd.Samples.Networking.csproj @@ -0,0 +1,15 @@ + + + + Exe + net10.0 + enable + enable + false + + + + + + + diff --git a/samples/SquidStd.Samples.Plugins/Program.cs b/samples/SquidStd.Samples.Plugins/Program.cs new file mode 100644 index 00000000..b4a13771 --- /dev/null +++ b/samples/SquidStd.Samples.Plugins/Program.cs @@ -0,0 +1,56 @@ +using DryIoc; +using SquidStd.Plugin.Abstractions.Data; +using SquidStd.Plugin.Abstractions.Interfaces.Plugins; + +namespace SquidStd.Samples.Plugins; + +#region step-1 +public interface IGreeter +{ + string Greet(string name); +} + +public sealed class WeatherGreeter : IGreeter +{ + public string Greet(string name) + => $"Hello {name}, the weather plugin is online."; +} + +public sealed class WeatherPlugin : ISquidStdPlugin +{ + public PluginMetadata Metadata { get; } = new() + { + Id = "squidstd.weather", + Name = "Weather Plugin", + 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); + } +} +#endregion + +internal static class Program +{ + private static void Main() + { + #region step-2 + var container = new Container(); + var context = new PluginContext(); + context.Data["startedAt"] = DateTimeOffset.UtcNow; + + ISquidStdPlugin plugin = new WeatherPlugin(); + + Console.WriteLine($"Loading {plugin.Metadata.Name} v{plugin.Metadata.Version} by {plugin.Metadata.Author}"); + plugin.Configure(container, context); + + var greeter = container.Resolve(); + Console.WriteLine(greeter.Greet("squid")); + #endregion + } +} diff --git a/samples/SquidStd.Samples.Plugins/SquidStd.Samples.Plugins.csproj b/samples/SquidStd.Samples.Plugins/SquidStd.Samples.Plugins.csproj new file mode 100644 index 00000000..3cd1aff6 --- /dev/null +++ b/samples/SquidStd.Samples.Plugins/SquidStd.Samples.Plugins.csproj @@ -0,0 +1,15 @@ + + + + Exe + net10.0 + enable + enable + false + + + + + + + diff --git a/samples/SquidStd.Samples.ScriptingLua/Program.cs b/samples/SquidStd.Samples.ScriptingLua/Program.cs new file mode 100644 index 00000000..b9f2fddc --- /dev/null +++ b/samples/SquidStd.Samples.ScriptingLua/Program.cs @@ -0,0 +1,50 @@ +using DryIoc; +using SquidStd.Abstractions.Extensions.Services; +using SquidStd.Core.Data.Bootstrap; +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 SquidStdOptions +{ + ConfigName = "squidstd", + RootDirectory = AppContext.BaseDirectory +}); + +#region step-1 +var scriptsDirectory = Path.Combine(AppContext.BaseDirectory, "scripts"); +Directory.CreateDirectory(scriptsDirectory); + +bootstrap.ConfigureServices(container => +{ + var engineConfig = new LuaEngineConfig( + luarcDirectory: AppContext.BaseDirectory, + scriptsDirectory: scriptsDirectory, + engineName: "SquidStd.Samples.ScriptingLua", + engineVersion: "1.0.0" + ); + + container.RegisterInstance(engineConfig); + container.RegisterStdService(); + + return container; +}); +#endregion + +await bootstrap.StartAsync(); + +#region step-2 +var engine = bootstrap.Resolve(); + +engine.RegisterGlobal("greeting", "hello from C#"); +engine.ExecuteScript("result = greeting .. ' and lua'"); + +var sum = engine.ExecuteFunction("3 + 4"); +var message = engine.ExecuteFunction("result"); + +Console.WriteLine($"3 + 4 = {sum.Data}"); +Console.WriteLine($"result = {message.Data}"); +#endregion + +await bootstrap.StopAsync(); diff --git a/samples/SquidStd.Samples.ScriptingLua/SquidStd.Samples.ScriptingLua.csproj b/samples/SquidStd.Samples.ScriptingLua/SquidStd.Samples.ScriptingLua.csproj new file mode 100644 index 00000000..d480fed2 --- /dev/null +++ b/samples/SquidStd.Samples.ScriptingLua/SquidStd.Samples.ScriptingLua.csproj @@ -0,0 +1,16 @@ + + + + Exe + net10.0 + enable + enable + false + + + + + + + + diff --git a/samples/SquidStd.Samples.Search/Program.cs b/samples/SquidStd.Samples.Search/Program.cs new file mode 100644 index 00000000..2526a60f --- /dev/null +++ b/samples/SquidStd.Samples.Search/Program.cs @@ -0,0 +1,46 @@ +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 SquidStdOptions +{ + ConfigName = "squidstd", + RootDirectory = AppContext.BaseDirectory +}); + +#region step-1 +bootstrap.ConfigureServices(container => container.AddElasticsearch(new ElasticsearchOptions +{ + Uri = new Uri("http://localhost:9200") +})); +#endregion + +await bootstrap.StartAsync(); + +var search = bootstrap.Resolve(); + +#region step-3 +await search.IndexAsync(new Order("1", "open", 150), refresh: true); + +var open = await search.Query() + .Where(o => o.Status == "open") + .ToListAsync(); + +Console.WriteLine($"found {open.Count} open order(s)"); +#endregion + +await bootstrap.StopAsync(); + +#region step-2 +/// An order document stored in the orders index. +[SearchIndex("orders")] +public sealed record Order(string Id, string Status, int Total) : IIndexableEntity +{ + /// Stable document id within the index. + public string IndexId => Id; +} +#endregion diff --git a/samples/SquidStd.Samples.Search/SquidStd.Samples.Search.csproj b/samples/SquidStd.Samples.Search/SquidStd.Samples.Search.csproj new file mode 100644 index 00000000..e5b2ff55 --- /dev/null +++ b/samples/SquidStd.Samples.Search/SquidStd.Samples.Search.csproj @@ -0,0 +1,16 @@ + + + + Exe + net10.0 + enable + enable + false + + + + + + + + diff --git a/samples/SquidStd.Samples.Storage/Program.cs b/samples/SquidStd.Samples.Storage/Program.cs new file mode 100644 index 00000000..ae1de94c --- /dev/null +++ b/samples/SquidStd.Samples.Storage/Program.cs @@ -0,0 +1,29 @@ +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 SquidStdOptions +{ + ConfigName = "squidstd", + RootDirectory = AppContext.BaseDirectory +}); + +#region step-1 +bootstrap.ConfigureServices(container => container.AddFileStorage()); +#endregion + +await bootstrap.StartAsync(); + +#region step-2 +var storage = bootstrap.Resolve(); + +await storage.SaveAsync("user:1", new User("squid", "squid@stormwind.it")); +var loaded = await storage.LoadAsync("user:1"); + +Console.WriteLine($"{loaded?.Name} <{loaded?.Email}>"); +#endregion + +await bootstrap.StopAsync(); + +internal sealed record User(string Name, string Email); diff --git a/samples/SquidStd.Samples.Storage/SquidStd.Samples.Storage.csproj b/samples/SquidStd.Samples.Storage/SquidStd.Samples.Storage.csproj new file mode 100644 index 00000000..44a230f1 --- /dev/null +++ b/samples/SquidStd.Samples.Storage/SquidStd.Samples.Storage.csproj @@ -0,0 +1,16 @@ + + + + Exe + net10.0 + enable + enable + false + + + + + + + + diff --git a/samples/SquidStd.Samples.Templating/Program.cs b/samples/SquidStd.Samples.Templating/Program.cs new file mode 100644 index 00000000..86341eaf --- /dev/null +++ b/samples/SquidStd.Samples.Templating/Program.cs @@ -0,0 +1,34 @@ +using SquidStd.Core.Data.Bootstrap; +using SquidStd.Services.Core.Services.Bootstrap; +using SquidStd.Templating.Extensions; +using SquidStd.Templating.Interfaces; + +var bootstrap = SquidStdBootstrap.Create(new SquidStdOptions +{ + ConfigName = "squidstd", + RootDirectory = AppContext.BaseDirectory +}); + +#region step-1 +bootstrap.ConfigureServices(container => container.AddTemplating()); +#endregion + +await bootstrap.StartAsync(); + +#region step-2 +var renderer = bootstrap.Resolve(); + +var greeting = await renderer.RenderAsync("Hi {{ user.name }}!", new { User = new { Name = "squid" } }); + +Console.WriteLine(greeting); +#endregion + +#region step-3 +renderer.Register("welcome", "Welcome aboard, {{ user.name }}."); + +var welcome = await renderer.RenderByNameAsync("welcome", new { User = new { Name = "squid" } }); + +Console.WriteLine(welcome); +#endregion + +await bootstrap.StopAsync(); diff --git a/samples/SquidStd.Samples.Templating/SquidStd.Samples.Templating.csproj b/samples/SquidStd.Samples.Templating/SquidStd.Samples.Templating.csproj new file mode 100644 index 00000000..f7de606d --- /dev/null +++ b/samples/SquidStd.Samples.Templating/SquidStd.Samples.Templating.csproj @@ -0,0 +1,16 @@ + + + + Exe + net10.0 + enable + enable + false + + + + + + + + diff --git a/samples/SquidStd.Samples.WorkerSystem/Program.cs b/samples/SquidStd.Samples.WorkerSystem/Program.cs new file mode 100644 index 00000000..620f7be6 --- /dev/null +++ b/samples/SquidStd.Samples.WorkerSystem/Program.cs @@ -0,0 +1,53 @@ +using SquidStd.Core.Data.Bootstrap; +using SquidStd.Messaging.Extensions; +using SquidStd.Services.Core.Services.Bootstrap; +using SquidStd.Workers.Abstractions.Data; +using SquidStd.Workers.Extensions; +using SquidStd.Workers.Interfaces; +using SquidStd.Workers.Manager.Extensions; +using SquidStd.Workers.Manager.Interfaces; + +var bootstrap = SquidStdBootstrap.Create(new SquidStdOptions +{ + ConfigName = "squidstd", + RootDirectory = AppContext.BaseDirectory +}); + +#region step-1 +bootstrap.ConfigureServices(c => +{ + c.AddInMemoryMessaging(); + c.AddWorkers(); + c.AddJobHandler(); + c.AddWorkerManager(); + return c; +}); +#endregion + +#region step-3 +await bootstrap.StartAsync(); + +var scheduler = bootstrap.Resolve(); +await scheduler.EnqueueAsync("greet", new Dictionary { ["name"] = "squid" }); + +await Task.Delay(500); +await bootstrap.StopAsync(); +#endregion + +#region step-2 +internal sealed class GreetJobHandler : IJobHandler +{ + public string JobName + { + get { return "greet"; } + } + + public Task HandleAsync(JobRequest job, CancellationToken cancellationToken) + { + var name = job.Parameters.TryGetValue("name", out var value) ? value : "world"; + Console.WriteLine($"Hello, {name}! (job: {job.JobName})"); + + return Task.CompletedTask; + } +} +#endregion diff --git a/samples/SquidStd.Samples.WorkerSystem/SquidStd.Samples.WorkerSystem.csproj b/samples/SquidStd.Samples.WorkerSystem/SquidStd.Samples.WorkerSystem.csproj new file mode 100644 index 00000000..160a8bde --- /dev/null +++ b/samples/SquidStd.Samples.WorkerSystem/SquidStd.Samples.WorkerSystem.csproj @@ -0,0 +1,18 @@ + + + + Exe + net10.0 + enable + enable + false + + + + + + + + + + diff --git a/src/SquidStd.Abstractions/README.md b/src/SquidStd.Abstractions/README.md index 38d1732a..f41238e9 100644 --- a/src/SquidStd.Abstractions/README.md +++ b/src/SquidStd.Abstractions/README.md @@ -44,13 +44,13 @@ container.RegisterConfigSection("my"); ## Key types -| 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. | +| 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. | ## License diff --git a/src/SquidStd.AspNetCore/Extensions/SquidStdAspNetCoreBuilderExtensions.cs b/src/SquidStd.AspNetCore/Extensions/SquidStdAspNetCoreBuilderExtensions.cs index 3e8aac31..927e4dc1 100644 --- a/src/SquidStd.AspNetCore/Extensions/SquidStdAspNetCoreBuilderExtensions.cs +++ b/src/SquidStd.AspNetCore/Extensions/SquidStdAspNetCoreBuilderExtensions.cs @@ -2,7 +2,6 @@ using DryIoc.Microsoft.DependencyInjection; using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; using SquidStd.AspNetCore.Services; using SquidStd.Core.Data.Bootstrap; using SquidStd.Services.Core.Services.Bootstrap; diff --git a/src/SquidStd.AspNetCore/Extensions/SquidStdHealthChecksExtensions.cs b/src/SquidStd.AspNetCore/Extensions/SquidStdHealthChecksExtensions.cs index 090f6180..ab54d1ff 100644 --- a/src/SquidStd.AspNetCore/Extensions/SquidStdHealthChecksExtensions.cs +++ b/src/SquidStd.AspNetCore/Extensions/SquidStdHealthChecksExtensions.cs @@ -2,7 +2,6 @@ using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Diagnostics.HealthChecks; -using Microsoft.Extensions.Hosting; using SquidStd.AspNetCore.Services; using SquidHealthCheck = SquidStd.Core.Interfaces.Health.IHealthCheck; @@ -26,8 +25,11 @@ public WebApplicationBuilder AddSquidStdHealthChecks() { ArgumentNullException.ThrowIfNull(builder); - if (!builder.Host.Properties.TryGetValue(SquidStdAspNetCoreBuilderExtensions.ContainerPropertyKey, out var value) - || value is not IContainer container) + if (!builder.Host.Properties.TryGetValue( + SquidStdAspNetCoreBuilderExtensions.ContainerPropertyKey, + out var value + ) || + value is not IContainer container) { throw new InvalidOperationException("Call UseSquidStd before AddSquidStdHealthChecks."); } @@ -38,11 +40,11 @@ public WebApplicationBuilder AddSquidStdHealthChecks() foreach (var check in checks) { healthChecks.Add( - new HealthCheckRegistration( + new( check.Name, _ => new SquidStdHealthCheckAdapter(check), - failureStatus: HealthStatus.Unhealthy, - tags: null + HealthStatus.Unhealthy, + null ) ); } diff --git a/src/SquidStd.AspNetCore/README.md b/src/SquidStd.AspNetCore/README.md index e1308be0..2c621eb4 100644 --- a/src/SquidStd.AspNetCore/README.md +++ b/src/SquidStd.AspNetCore/README.md @@ -27,7 +27,8 @@ dotnet add package SquidStd.AspNetCore - Configures DryIoc as the host's service-provider factory. - Registers `SquidStdHostedService` to start/stop SquidStd services with the host. - Optional `SquidStdOptions` configuration callback. -- `WebApplicationBuilder.AddSquidStdHealthChecks()` — bridges every SquidStd `IHealthCheck` into the standard ASP.NET Core health-check system (one entry per check), exposed via the standard `app.MapHealthChecks(...)`. +- `WebApplicationBuilder.AddSquidStdHealthChecks()` — bridges every SquidStd `IHealthCheck` into the standard ASP.NET Core + health-check system (one entry per check), exposed via the standard `app.MapHealthChecks(...)`. ## Usage @@ -64,11 +65,11 @@ Each registered `IHealthCheck` appears as its own entry in the report. Check nam ## Key types -| Type | Purpose | -|------|---------| -| `SquidStdAspNetCoreBuilderExtensions` | `UseSquidStd(...)` builder extension. | -| `SquidStdHostedService` | Hosted service bridging SquidStd service lifecycle to the host. | -| `SquidStdHealthChecksExtensions` | `AddSquidStdHealthChecks(...)` — bridge to ASP.NET Core health checks. | +| Type | Purpose | +|---------------------------------------|------------------------------------------------------------------------| +| `SquidStdAspNetCoreBuilderExtensions` | `UseSquidStd(...)` builder extension. | +| `SquidStdHostedService` | Hosted service bridging SquidStd service lifecycle to the host. | +| `SquidStdHealthChecksExtensions` | `AddSquidStdHealthChecks(...)` — bridge to ASP.NET Core health checks. | ## License diff --git a/src/SquidStd.AspNetCore/Services/SquidStdHostedService.cs b/src/SquidStd.AspNetCore/Services/SquidStdHostedService.cs index 158e7d04..b279eb7f 100644 --- a/src/SquidStd.AspNetCore/Services/SquidStdHostedService.cs +++ b/src/SquidStd.AspNetCore/Services/SquidStdHostedService.cs @@ -21,13 +21,9 @@ 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.Caching.Abstractions/Data/Config/CacheConnectionString.cs b/src/SquidStd.Caching.Abstractions/Data/Config/CacheConnectionString.cs index 33ab8f4f..8d124dec 100644 --- a/src/SquidStd.Caching.Abstractions/Data/Config/CacheConnectionString.cs +++ b/src/SquidStd.Caching.Abstractions/Data/Config/CacheConnectionString.cs @@ -63,7 +63,11 @@ 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); + .ToFrozenDictionary( + key => key!, + key => query[key] ?? string.Empty, + StringComparer.OrdinalIgnoreCase + ); return new( uri.Scheme, diff --git a/src/SquidStd.Caching.Abstractions/Interfaces/ICacheMetrics.cs b/src/SquidStd.Caching.Abstractions/Interfaces/ICacheMetrics.cs index cf7be0af..bb966b18 100644 --- a/src/SquidStd.Caching.Abstractions/Interfaces/ICacheMetrics.cs +++ b/src/SquidStd.Caching.Abstractions/Interfaces/ICacheMetrics.cs @@ -11,9 +11,9 @@ public interface ICacheMetrics /// Records a cache miss. void OnMiss(string key); - /// Records a value being stored. - void OnSet(string key); - /// Records a key being removed. void OnRemove(string key); + + /// Records a value being stored. + void OnSet(string key); } diff --git a/src/SquidStd.Caching.Abstractions/Interfaces/ICacheProvider.cs b/src/SquidStd.Caching.Abstractions/Interfaces/ICacheProvider.cs index 7b27ddab..de8a47ae 100644 --- a/src/SquidStd.Caching.Abstractions/Interfaces/ICacheProvider.cs +++ b/src/SquidStd.Caching.Abstractions/Interfaces/ICacheProvider.cs @@ -7,15 +7,15 @@ namespace SquidStd.Caching.Abstractions.Interfaces; /// public interface ICacheProvider : ISquidStdService { + /// Returns whether a key exists. + Task ExistsAsync(string key, CancellationToken cancellationToken = default); + /// Gets the raw value for a key, or null when absent. Task?> GetAsync(string key, CancellationToken cancellationToken = default); - /// Stores a raw value with an optional time-to-live. - Task SetAsync(string key, ReadOnlyMemory value, TimeSpan? ttl, CancellationToken cancellationToken = default); - /// Removes a key; returns whether it existed. Task RemoveAsync(string key, CancellationToken cancellationToken = default); - /// Returns whether a key exists. - Task ExistsAsync(string key, CancellationToken cancellationToken = default); + /// Stores a raw value with an optional time-to-live. + Task SetAsync(string key, ReadOnlyMemory value, TimeSpan? ttl, CancellationToken cancellationToken = default); } diff --git a/src/SquidStd.Caching.Abstractions/Interfaces/ICacheService.cs b/src/SquidStd.Caching.Abstractions/Interfaces/ICacheService.cs index 8d8dec96..db57782e 100644 --- a/src/SquidStd.Caching.Abstractions/Interfaces/ICacheService.cs +++ b/src/SquidStd.Caching.Abstractions/Interfaces/ICacheService.cs @@ -5,18 +5,12 @@ namespace SquidStd.Caching.Abstractions.Interfaces; /// public interface ICacheService { - /// Gets a typed value, or default when absent. - Task GetAsync(string key, CancellationToken cancellationToken = default); - - /// Stores a typed value with an optional time-to-live (falls back to the default TTL). - Task SetAsync(string key, T value, TimeSpan? ttl = null, CancellationToken cancellationToken = default); - - /// Removes a key; returns whether it existed. - Task RemoveAsync(string key, CancellationToken cancellationToken = default); - /// Returns whether a key exists. Task ExistsAsync(string key, CancellationToken cancellationToken = default); + /// Gets a typed value, or default when absent. + Task GetAsync(string key, CancellationToken cancellationToken = default); + /// Returns the cached value, or computes, stores and returns it on a miss. Task GetOrSetAsync( string key, @@ -24,4 +18,10 @@ Task GetOrSetAsync( TimeSpan? ttl = null, CancellationToken cancellationToken = default ); + + /// Removes a key; returns whether it existed. + Task RemoveAsync(string key, CancellationToken cancellationToken = default); + + /// Stores a typed value with an optional time-to-live (falls back to the default TTL). + Task SetAsync(string key, T value, TimeSpan? ttl = null, CancellationToken cancellationToken = default); } diff --git a/src/SquidStd.Caching.Abstractions/README.md b/src/SquidStd.Caching.Abstractions/README.md index 9e5cbdfd..93d452fe 100644 --- a/src/SquidStd.Caching.Abstractions/README.md +++ b/src/SquidStd.Caching.Abstractions/README.md @@ -42,14 +42,14 @@ public Task GetOrComputeAsync(ICacheService cache) ## Key types -| Type | Purpose | -|------|---------| -| `ICacheService` | Typed cache-aside facade. | -| `ICacheProvider` | Byte-level backend contract (per provider). | -| `CacheService` | Shared facade: serialization, key prefix, default TTL, cache-aside. | -| `ICacheMetrics` | Hit/miss/set/remove metrics sink. | -| `CacheOptions` | Default TTL and key prefix. | -| `CacheConnectionString` | `scheme://host[?params]` parsing into `CacheOptions`. | +| Type | Purpose | +|-------------------------|---------------------------------------------------------------------| +| `ICacheService` | Typed cache-aside facade. | +| `ICacheProvider` | Byte-level backend contract (per provider). | +| `CacheService` | Shared facade: serialization, key prefix, default TTL, cache-aside. | +| `ICacheMetrics` | Hit/miss/set/remove metrics sink. | +| `CacheOptions` | Default TTL and key prefix. | +| `CacheConnectionString` | `scheme://host[?params]` parsing into `CacheOptions`. | ## License diff --git a/src/SquidStd.Caching.Abstractions/Services/CacheMetricsProvider.cs b/src/SquidStd.Caching.Abstractions/Services/CacheMetricsProvider.cs index 36722cb3..4835ad1f 100644 --- a/src/SquidStd.Caching.Abstractions/Services/CacheMetricsProvider.cs +++ b/src/SquidStd.Caching.Abstractions/Services/CacheMetricsProvider.cs @@ -18,22 +18,6 @@ public sealed class CacheMetricsProvider : ICacheMetrics, IMetricProvider /// public string ProviderName => "cache"; - /// - public void OnHit(string key) - => Interlocked.Increment(ref _hits); - - /// - public void OnMiss(string key) - => Interlocked.Increment(ref _misses); - - /// - public void OnSet(string key) - => Interlocked.Increment(ref _sets); - - /// - public void OnRemove(string key) - => Interlocked.Increment(ref _removes); - /// public ValueTask> CollectAsync(CancellationToken cancellationToken = default) { @@ -53,4 +37,20 @@ 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 c8ddc290..4cb661d8 100644 --- a/src/SquidStd.Caching.Abstractions/Services/CacheService.cs +++ b/src/SquidStd.Caching.Abstractions/Services/CacheService.cs @@ -33,6 +33,10 @@ public CacheService( _keyPrefix = options.KeyPrefix; } + /// + public Task ExistsAsync(string key, CancellationToken cancellationToken = default) + => _provider.ExistsAsync(Prefixed(key), cancellationToken); + /// public async Task GetAsync(string key, CancellationToken cancellationToken = default) { @@ -50,31 +54,6 @@ public CacheService( return _deserializer.Deserialize(bytes.Value); } - /// - public async Task SetAsync(string key, T value, TimeSpan? ttl = null, CancellationToken cancellationToken = default) - { - var bytes = _serializer.Serialize(value); - await _provider.SetAsync(Prefixed(key), bytes, ttl ?? _defaultTtl, cancellationToken); - _metrics.OnSet(key); - } - - /// - public async Task RemoveAsync(string key, CancellationToken cancellationToken = default) - { - var removed = await _provider.RemoveAsync(Prefixed(key), cancellationToken); - - if (removed) - { - _metrics.OnRemove(key); - } - - return removed; - } - - /// - public Task ExistsAsync(string key, CancellationToken cancellationToken = default) - => _provider.ExistsAsync(Prefixed(key), cancellationToken); - /// public async Task GetOrSetAsync( string key, @@ -101,6 +80,27 @@ public async Task GetOrSetAsync( return value; } + /// + public async Task RemoveAsync(string key, CancellationToken cancellationToken = default) + { + var removed = await _provider.RemoveAsync(Prefixed(key), cancellationToken); + + if (removed) + { + _metrics.OnRemove(key); + } + + return removed; + } + + /// + public async Task SetAsync(string key, T value, TimeSpan? ttl = null, CancellationToken cancellationToken = default) + { + var bytes = _serializer.Serialize(value); + await _provider.SetAsync(Prefixed(key), bytes, ttl ?? _defaultTtl, cancellationToken); + _metrics.OnSet(key); + } + private string Prefixed(string key) => _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 c52877a1..285be016 100644 --- a/src/SquidStd.Caching.Abstractions/Services/NoOpCacheMetrics.cs +++ b/src/SquidStd.Caching.Abstractions/Services/NoOpCacheMetrics.cs @@ -10,19 +10,11 @@ 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 OnSet(string key) - { - } + public void OnRemove(string key) { } - public void OnRemove(string key) - { - } + public void OnSet(string key) { } } diff --git a/src/SquidStd.Caching.Abstractions/SquidStd.Caching.Abstractions.csproj b/src/SquidStd.Caching.Abstractions/SquidStd.Caching.Abstractions.csproj index f73ae1d5..92ecae12 100644 --- a/src/SquidStd.Caching.Abstractions/SquidStd.Caching.Abstractions.csproj +++ b/src/SquidStd.Caching.Abstractions/SquidStd.Caching.Abstractions.csproj @@ -8,8 +8,8 @@ - - + + diff --git a/src/SquidStd.Caching.Redis/README.md b/src/SquidStd.Caching.Redis/README.md index c6502da9..ebaa1742 100644 --- a/src/SquidStd.Caching.Redis/README.md +++ b/src/SquidStd.Caching.Redis/README.md @@ -46,11 +46,11 @@ var user = await cache.GetAsync("user:1"); ## Key types -| Type | Purpose | -|------|---------| -| `RedisCacheRegistrationExtensions` | `AddRedisCache(...)` registration. | -| `RedisCacheProvider` | StackExchange.Redis-backed `ICacheProvider`. | -| `RedisCacheOptions` | StackExchange.Redis connection configuration. | +| Type | Purpose | +|------------------------------------|-----------------------------------------------| +| `RedisCacheRegistrationExtensions` | `AddRedisCache(...)` registration. | +| `RedisCacheProvider` | StackExchange.Redis-backed `ICacheProvider`. | +| `RedisCacheOptions` | StackExchange.Redis connection configuration. | ## License diff --git a/src/SquidStd.Caching.Redis/Services/RedisCacheProvider.cs b/src/SquidStd.Caching.Redis/Services/RedisCacheProvider.cs index 2da7d8a9..75221a88 100644 --- a/src/SquidStd.Caching.Redis/Services/RedisCacheProvider.cs +++ b/src/SquidStd.Caching.Redis/Services/RedisCacheProvider.cs @@ -18,15 +18,27 @@ public RedisCacheProvider(RedisCacheOptions options) _options = options; } + private IDatabase Database + => (_connection ?? throw new InvalidOperationException("Provider not started.")).GetDatabase(); + /// - public async ValueTask StartAsync(CancellationToken cancellationToken = default) + public async ValueTask DisposeAsync() { - _connection = await ConnectionMultiplexer.ConnectAsync(_options.Configuration); + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + if (_connection is not null) + { + await _connection.CloseAsync(); + _connection.Dispose(); + } } /// - public ValueTask StopAsync(CancellationToken cancellationToken = default) - => DisposeAsync(); + public Task ExistsAsync(string key, CancellationToken cancellationToken = default) + => Database.KeyExistsAsync(key); /// public async Task?> GetAsync(string key, CancellationToken cancellationToken = default) @@ -41,36 +53,27 @@ public ValueTask StopAsync(CancellationToken cancellationToken = default) return new ReadOnlyMemory((byte[])value!); } - /// - public async Task SetAsync(string key, ReadOnlyMemory value, TimeSpan? ttl, CancellationToken cancellationToken = default) - { - var expiry = ttl is null ? Expiration.Default : new Expiration(ttl.Value); - await Database.StringSetAsync(key, value.ToArray(), expiry); - } - /// public Task RemoveAsync(string key, CancellationToken cancellationToken = default) => Database.KeyDeleteAsync(key); /// - public Task ExistsAsync(string key, CancellationToken cancellationToken = default) - => Database.KeyExistsAsync(key); - - private IDatabase Database - => (_connection ?? throw new InvalidOperationException("Provider not started.")).GetDatabase(); + public async Task SetAsync( + string key, + ReadOnlyMemory value, + TimeSpan? ttl, + CancellationToken cancellationToken = default + ) + { + var expiry = ttl is null ? Expiration.Default : new(ttl.Value); + await Database.StringSetAsync(key, value.ToArray(), expiry); + } /// - public async ValueTask DisposeAsync() - { - if (Interlocked.Exchange(ref _disposed, 1) != 0) - { - return; - } + public async ValueTask StartAsync(CancellationToken cancellationToken = default) + => _connection = await ConnectionMultiplexer.ConnectAsync(_options.Configuration); - if (_connection is not null) - { - await _connection.CloseAsync(); - _connection.Dispose(); - } - } + /// + public ValueTask StopAsync(CancellationToken cancellationToken = default) + => DisposeAsync(); } diff --git a/src/SquidStd.Caching.Redis/SquidStd.Caching.Redis.csproj b/src/SquidStd.Caching.Redis/SquidStd.Caching.Redis.csproj index 4ea81181..9a7406bf 100644 --- a/src/SquidStd.Caching.Redis/SquidStd.Caching.Redis.csproj +++ b/src/SquidStd.Caching.Redis/SquidStd.Caching.Redis.csproj @@ -8,12 +8,12 @@ - + - - + + diff --git a/src/SquidStd.Caching/README.md b/src/SquidStd.Caching/README.md index 82390319..9e3f6622 100644 --- a/src/SquidStd.Caching/README.md +++ b/src/SquidStd.Caching/README.md @@ -46,10 +46,10 @@ var user = await cache.GetAsync("user:1"); ## Key types -| Type | Purpose | -|------|---------| -| `CacheRegistrationExtensions` | `AddInMemoryCache(...)` registration. | -| `InMemoryCacheProvider` | `IMemoryCache`-backed `ICacheProvider`. | +| Type | Purpose | +|-------------------------------|-----------------------------------------| +| `CacheRegistrationExtensions` | `AddInMemoryCache(...)` registration. | +| `InMemoryCacheProvider` | `IMemoryCache`-backed `ICacheProvider`. | ## License diff --git a/src/SquidStd.Caching/Services/InMemoryCacheProvider.cs b/src/SquidStd.Caching/Services/InMemoryCacheProvider.cs index 1d8934e8..d63c2879 100644 --- a/src/SquidStd.Caching/Services/InMemoryCacheProvider.cs +++ b/src/SquidStd.Caching/Services/InMemoryCacheProvider.cs @@ -15,6 +15,10 @@ public InMemoryCacheProvider(IMemoryCache cache) _cache = cache; } + /// + public Task ExistsAsync(string key, CancellationToken cancellationToken = default) + => Task.FromResult(_cache.TryGetValue(key, out _)); + /// public Task?> GetAsync(string key, CancellationToken cancellationToken = default) { @@ -27,7 +31,21 @@ public InMemoryCacheProvider(IMemoryCache cache) } /// - public Task SetAsync(string key, ReadOnlyMemory value, TimeSpan? ttl, CancellationToken cancellationToken = default) + public Task RemoveAsync(string key, CancellationToken cancellationToken = default) + { + var existed = _cache.TryGetValue(key, out _); + _cache.Remove(key); + + return Task.FromResult(existed); + } + + /// + public Task SetAsync( + string key, + ReadOnlyMemory value, + TimeSpan? ttl, + CancellationToken cancellationToken = default + ) { var options = new MemoryCacheEntryOptions(); @@ -41,19 +59,6 @@ public Task SetAsync(string key, ReadOnlyMemory value, TimeSpan? ttl, Canc return Task.CompletedTask; } - /// - public Task RemoveAsync(string key, CancellationToken cancellationToken = default) - { - var existed = _cache.TryGetValue(key, out _); - _cache.Remove(key); - - return Task.FromResult(existed); - } - - /// - public Task ExistsAsync(string key, CancellationToken cancellationToken = default) - => Task.FromResult(_cache.TryGetValue(key, out _)); - /// public ValueTask StartAsync(CancellationToken cancellationToken = default) => ValueTask.CompletedTask; diff --git a/src/SquidStd.Caching/SquidStd.Caching.csproj b/src/SquidStd.Caching/SquidStd.Caching.csproj index 2e317d0f..08b2ba18 100644 --- a/src/SquidStd.Caching/SquidStd.Caching.csproj +++ b/src/SquidStd.Caching/SquidStd.Caching.csproj @@ -8,12 +8,12 @@ - + - - + + diff --git a/src/SquidStd.Core/Data/Metrics/MetricsConfig.cs b/src/SquidStd.Core/Data/Metrics/MetricsConfig.cs index 5add62b2..531ce7f7 100644 --- a/src/SquidStd.Core/Data/Metrics/MetricsConfig.cs +++ b/src/SquidStd.Core/Data/Metrics/MetricsConfig.cs @@ -1,5 +1,5 @@ -using SquidStd.Core.Types; using SquidStd.Core.Interfaces.Config; +using SquidStd.Core.Types; namespace SquidStd.Core.Data.Metrics; diff --git a/src/SquidStd.Core/Extensions/Env/EnvExtensions.cs b/src/SquidStd.Core/Extensions/Env/EnvExtensions.cs index de4f34fe..7b1ded91 100644 --- a/src/SquidStd.Core/Extensions/Env/EnvExtensions.cs +++ b/src/SquidStd.Core/Extensions/Env/EnvExtensions.cs @@ -8,6 +8,11 @@ namespace SquidStd.Core.Extensions.Env; /// public static class EnvExtensions { + private static readonly Regex EnvTokenRegex = new( + @"\$([A-Za-z_][A-Za-z0-9_]*)", + RegexOptions.Compiled + ); + /// /// Expands environment variables in a string using custom $VARIABLE syntax /// @@ -51,10 +56,7 @@ public static string ReplaceEnv(this string input) var value = Environment.GetEnvironmentVariable(name); return value ?? match.Value; - }); + } + ); } - - private static readonly Regex EnvTokenRegex = new( - @"\$([A-Za-z_][A-Za-z0-9_]*)", - RegexOptions.Compiled); } diff --git a/src/SquidStd.Core/Interfaces/Bootstrap/ISquidStdBootstrap.cs b/src/SquidStd.Core/Interfaces/Bootstrap/ISquidStdBootstrap.cs index 86e74330..857851bf 100644 --- a/src/SquidStd.Core/Interfaces/Bootstrap/ISquidStdBootstrap.cs +++ b/src/SquidStd.Core/Interfaces/Bootstrap/ISquidStdBootstrap.cs @@ -39,6 +39,13 @@ public interface ISquidStdBootstrap : IAsyncDisposable /// The resolved service instance. TService Resolve(); + /// + /// 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. /// @@ -52,11 +59,4 @@ public interface ISquidStdBootstrap : IAsyncDisposable /// Token used to cancel the stop operation. /// A task that represents the asynchronous stop operation. ValueTask StopAsync(CancellationToken cancellationToken = default); - - /// - /// 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); } diff --git a/src/SquidStd.Core/Interfaces/Metrics/IMetricsCollectionService.cs b/src/SquidStd.Core/Interfaces/Metrics/IMetricsCollectionService.cs index ef6c5e20..37aef401 100644 --- a/src/SquidStd.Core/Interfaces/Metrics/IMetricsCollectionService.cs +++ b/src/SquidStd.Core/Interfaces/Metrics/IMetricsCollectionService.cs @@ -14,14 +14,14 @@ public interface IMetricsCollectionService IReadOnlyDictionary GetAllMetrics(); /// - /// Gets the current metrics status. + /// Gets the latest collected metrics snapshot. /// /// The current metrics snapshot. - MetricsSnapshot GetStatus(); + MetricsSnapshot GetSnapshot(); /// - /// Gets the latest collected metrics snapshot. + /// Gets the current metrics status. /// /// The current metrics snapshot. - MetricsSnapshot GetSnapshot(); + MetricsSnapshot GetStatus(); } diff --git a/src/SquidStd.Core/Interfaces/Scheduling/ICronScheduler.cs b/src/SquidStd.Core/Interfaces/Scheduling/ICronScheduler.cs index 3d43bc23..a3e6b1d5 100644 --- a/src/SquidStd.Core/Interfaces/Scheduling/ICronScheduler.cs +++ b/src/SquidStd.Core/Interfaces/Scheduling/ICronScheduler.cs @@ -7,6 +7,9 @@ namespace SquidStd.Core.Interfaces.Scheduling; /// public interface ICronScheduler { + /// Gets a snapshot of all registered jobs. + IReadOnlyCollection Jobs { get; } + /// /// Registers a cron job. /// @@ -25,7 +28,4 @@ public interface ICronScheduler /// The job name. /// The number of removed jobs. int UnscheduleByName(string name); - - /// Gets a snapshot of all registered jobs. - IReadOnlyCollection Jobs { get; } } diff --git a/src/SquidStd.Core/Json/JsonDataSerializer.cs b/src/SquidStd.Core/Json/JsonDataSerializer.cs index b06c8739..c20a7576 100644 --- a/src/SquidStd.Core/Json/JsonDataSerializer.cs +++ b/src/SquidStd.Core/Json/JsonDataSerializer.cs @@ -18,14 +18,14 @@ public JsonDataSerializer() _options = new(JsonSerializerDefaults.Web); } - [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); - [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}."); + + [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); } diff --git a/src/SquidStd.Core/README.md b/src/SquidStd.Core/README.md index d6400c49..6658f9ca 100644 --- a/src/SquidStd.Core/README.md +++ b/src/SquidStd.Core/README.md @@ -47,15 +47,15 @@ var yaml = YamlUtils.Serialize(new { name = "squid", port = 9000 }); ## Key types -| Type | Purpose | -|------|---------| -| `IConfigEntry` | A registrable YAML configuration section. | +| Type | Purpose | +|-------------------------|-----------------------------------------------| +| `IConfigEntry` | A registrable YAML configuration section. | | `IConfigManagerService` | Loads YAML config and exposes typed sections. | -| `IEventBus` | Publish/subscribe in-process event bus. | -| `IJobSystem` | Background job scheduling/execution. | -| `ITimerService` | Timer-wheel based scheduling. | -| `IMetricProvider` | Source of metric samples for collection. | -| `IStorageService` | File/object storage abstraction. | +| `IEventBus` | Publish/subscribe in-process event bus. | +| `IJobSystem` | Background job scheduling/execution. | +| `ITimerService` | Timer-wheel based scheduling. | +| `IMetricProvider` | Source of metric samples for collection. | +| `IStorageService` | File/object storage abstraction. | ## License diff --git a/src/SquidStd.Database.Abstractions/Data/PagedResultData.cs b/src/SquidStd.Database.Abstractions/Data/PagedResultData.cs index 30c3f641..a259ad19 100644 --- a/src/SquidStd.Database.Abstractions/Data/PagedResultData.cs +++ b/src/SquidStd.Database.Abstractions/Data/PagedResultData.cs @@ -36,13 +36,11 @@ public sealed class PagedResultData /// The total matching row count. /// The paged result. public static PagedResultData Create(IReadOnlyList items, int page, int pageSize, long totalCount) - { - return new PagedResultData + => new() { 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 587633e4..a5d8e020 100644 --- a/src/SquidStd.Database.Abstractions/Interfaces/Data/IDataAccess.cs +++ b/src/SquidStd.Database.Abstractions/Interfaces/Data/IDataAccess.cs @@ -6,20 +6,23 @@ 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 where TEntity : BaseEntity { - /// Inserts a single entity (assigns Id/Created/Updated) inside a transaction. - Task InsertAsync(TEntity entity, CancellationToken cancellationToken = default); + /// Bulk-deletes entities matching the predicate inside a transaction. Returns affected rows. + Task BulkDeleteAsync(Expression> predicate, CancellationToken cancellationToken = default); - /// Gets an entity by its identifier, or null if not found. - Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default); + /// Bulk-inserts entities inside a transaction. Returns affected rows. + Task BulkInsertAsync(IEnumerable entities, CancellationToken cancellationToken = default); - /// Updates an entity (bumps Updated) inside a transaction. - Task UpdateAsync(TEntity entity, CancellationToken cancellationToken = default); + /// Bulk-updates entities inside a transaction. Returns affected rows. + Task BulkUpdateAsync(IEnumerable entities, CancellationToken cancellationToken = default); + + /// Counts entities matching the optional predicate. + Task CountAsync(Expression>? predicate = null, CancellationToken cancellationToken = default); /// Deletes an entity by id. Returns true if a row was removed. Task DeleteAsync(Guid id, CancellationToken cancellationToken = default); @@ -27,26 +30,11 @@ public interface IDataAccess /// Deletes the given entity. Returns true if a row was removed. Task DeleteAsync(TEntity entity, CancellationToken cancellationToken = default); - /// Counts entities matching the optional predicate. - Task CountAsync(Expression>? predicate = null, CancellationToken cancellationToken = default); - /// Returns true if any entity matches the predicate. Task ExistsAsync(Expression> predicate, CancellationToken cancellationToken = default); - /// Bulk-inserts entities inside a transaction. Returns affected rows. - Task BulkInsertAsync(IEnumerable entities, CancellationToken cancellationToken = default); - - /// Bulk-updates entities inside a transaction. Returns affected rows. - Task BulkUpdateAsync(IEnumerable entities, CancellationToken cancellationToken = default); - - /// Bulk-deletes entities matching the predicate inside a transaction. Returns affected rows. - Task BulkDeleteAsync(Expression> predicate, CancellationToken cancellationToken = default); - - /// Returns a composable, SQL-translated query (FreeSql ISelect) over the entity set. - ISelect Query(); - - /// Materializes entities matching the optional predicate. - Task> QueryAsync(Expression>? predicate = null, CancellationToken cancellationToken = default); + /// Gets an entity by its identifier, or null if not found. + Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default); /// Returns a page of entities with total-count metadata. Task> GetPagedAsync( @@ -55,5 +43,21 @@ Task> GetPagedAsync( Expression>? predicate = null, Expression>? orderBy = null, bool descending = false, - CancellationToken cancellationToken = default); + CancellationToken cancellationToken = default + ); + + /// Inserts a single entity (assigns Id/Created/Updated) inside a transaction. + Task InsertAsync(TEntity entity, CancellationToken cancellationToken = default); + + /// Returns a composable, SQL-translated query (FreeSql ISelect) over the entity set. + ISelect Query(); + + /// Materializes entities matching the optional predicate. + Task> QueryAsync( + Expression>? predicate = null, + CancellationToken cancellationToken = default + ); + + /// Updates an entity (bumps Updated) inside a transaction. + Task UpdateAsync(TEntity entity, CancellationToken cancellationToken = default); } diff --git a/src/SquidStd.Database.Abstractions/README.md b/src/SquidStd.Database.Abstractions/README.md index 8a71a756..0526ab16 100644 --- a/src/SquidStd.Database.Abstractions/README.md +++ b/src/SquidStd.Database.Abstractions/README.md @@ -51,13 +51,13 @@ public async Task ExampleAsync(IDataAccess users) ## Key types -| Type | Purpose | -|------|---------| +| Type | Purpose | +|------------------------|--------------------------------------------------| | `IDataAccess` | CRUD + bulk + paged + composable query contract. | -| `BaseEntity` | Guid id + UTC created/updated base entity. | -| `PagedResultData` | Paginated result with metadata. | -| `DatabaseConfig` | Connection string + auto-migrate config section. | -| `DatabaseProviderType` | Supported provider enum. | +| `BaseEntity` | Guid id + UTC created/updated base entity. | +| `PagedResultData` | Paginated result with metadata. | +| `DatabaseConfig` | Connection string + auto-migrate config section. | +| `DatabaseProviderType` | Supported provider enum. | ## License diff --git a/src/SquidStd.Database/Connection/ConnectionStringParser.cs b/src/SquidStd.Database/Connection/ConnectionStringParser.cs index 89a95a01..1559f0c0 100644 --- a/src/SquidStd.Database/Connection/ConnectionStringParser.cs +++ b/src/SquidStd.Database/Connection/ConnectionStringParser.cs @@ -1,3 +1,4 @@ +using System.Globalization; using SquidStd.Database.Abstractions.Types.Data; namespace SquidStd.Database.Connection; @@ -28,30 +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 ParsedConnection(provider, native); - } - - private static DatabaseProviderType ResolveProvider(string scheme) - => scheme switch - { - "sqlite" => DatabaseProviderType.Sqlite, - "postgres" or "postgresql" => DatabaseProviderType.Postgres, - "sqlserver" or "mssql" => DatabaseProviderType.SqlServer, - "mysql" => DatabaseProviderType.MySql, - _ => throw new NotSupportedException($"Unsupported database scheme '{scheme}'.") - }; - - private static string BuildSqlite(string remainder) - { - if (string.IsNullOrWhiteSpace(remainder)) - { - throw new FormatException("SQLite connection string requires a file path or ':memory:'."); - } - - return $"Data Source={remainder}"; + return new(provider, native); } private static string BuildServer(DatabaseProviderType provider, string remainder) @@ -95,6 +76,26 @@ private static string BuildServer(DatabaseProviderType provider, string remainde }; } + private static string BuildSqlite(string remainder) + { + if (string.IsNullOrWhiteSpace(remainder)) + { + throw new FormatException("SQLite connection string requires a file path or ':memory:'."); + } + + return $"Data Source={remainder}"; + } + + private static DatabaseProviderType ResolveProvider(string scheme) + => scheme switch + { + "sqlite" => DatabaseProviderType.Sqlite, + "postgres" or "postgresql" => DatabaseProviderType.Postgres, + "sqlserver" or "mssql" => DatabaseProviderType.SqlServer, + "mysql" => DatabaseProviderType.MySql, + _ => throw new NotSupportedException($"Unsupported database scheme '{scheme}'.") + }; + private static (string User, string Password, string HostPort) SplitAuthority(string authority) { var at = authority.LastIndexOf('@'); @@ -129,7 +130,7 @@ private static (string Host, int? Port) SplitHostPort(string hostPort) } var host = hostPort[..separator]; - var port = int.Parse(hostPort[(separator + 1)..], System.Globalization.CultureInfo.InvariantCulture); + var port = int.Parse(hostPort[(separator + 1)..], CultureInfo.InvariantCulture); return (host, port); } diff --git a/src/SquidStd.Database/Data/FreeSqlDataAccess.cs b/src/SquidStd.Database/Data/FreeSqlDataAccess.cs index feb89b40..b1fe240e 100644 --- a/src/SquidStd.Database/Data/FreeSqlDataAccess.cs +++ b/src/SquidStd.Database/Data/FreeSqlDataAccess.cs @@ -1,3 +1,4 @@ +using System.Data.Common; using System.Linq.Expressions; using FreeSql; using Serilog; @@ -9,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 @@ -29,53 +30,83 @@ public FreeSqlDataAccess(IDatabaseService databaseService) } /// - public async Task InsertAsync(TEntity entity, CancellationToken cancellationToken = default) + public Task BulkDeleteAsync( + Expression> predicate, + CancellationToken cancellationToken = default + ) + => RunInTransactionAsync( + transaction => _orm.Delete() + .Where(predicate) + .WithTransaction(transaction) + .ExecuteAffrowsAsync(cancellationToken), + "BulkDelete", + null, + cancellationToken + ); + + /// + public Task BulkInsertAsync(IEnumerable entities, CancellationToken cancellationToken = default) + { + var list = Materialize(entities, true); + + return RunInTransactionAsync( + transaction => _orm.Insert(list).WithTransaction(transaction).ExecuteAffrowsAsync(cancellationToken), + "BulkInsert", + list.Count, + cancellationToken + ); + } + + /// + public Task BulkUpdateAsync(IEnumerable entities, CancellationToken cancellationToken = default) { + var list = entities.ToList(); var now = DateTimeOffset.UtcNow; - if (entity.Id == Guid.Empty) + foreach (var entity in list) { - entity.Id = Guid.CreateVersion7(); + entity.Updated = now; } - entity.Created = now; - entity.Updated = now; - - await RunInTransactionAsync( - transaction => _orm.Insert(entity).WithTransaction(transaction).ExecuteAffrowsAsync(cancellationToken), - "Insert", - 1, - cancellationToken); - - return entity; + return RunInTransactionAsync( + transaction => _orm.Update() + .SetSource(list) + .WithTransaction(transaction) + .ExecuteAffrowsAsync(cancellationToken), + "BulkUpdate", + list.Count, + cancellationToken + ); } /// - public Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) - => _orm.Select().Where(e => e.Id == id).FirstAsync(cancellationToken)!; - - /// - public async Task UpdateAsync(TEntity entity, CancellationToken cancellationToken = default) + public Task CountAsync( + Expression>? predicate = null, + CancellationToken cancellationToken = default + ) { - entity.Updated = DateTimeOffset.UtcNow; + var select = _orm.Select(); - await RunInTransactionAsync( - transaction => _orm.Update().SetSource(entity).WithTransaction(transaction).ExecuteAffrowsAsync(cancellationToken), - "Update", - 1, - cancellationToken); + if (predicate is not null) + { + select = select.Where(predicate); + } - return entity; + return select.CountAsync(cancellationToken); } /// 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; } @@ -85,7 +116,22 @@ public Task DeleteAsync(TEntity entity, CancellationToken cancellationToke => DeleteAsync(entity.Id, cancellationToken); /// - public Task CountAsync(Expression>? predicate = null, CancellationToken cancellationToken = default) + public Task ExistsAsync(Expression> predicate, CancellationToken cancellationToken = default) + => _orm.Select().Where(predicate).AnyAsync(cancellationToken); + + /// + public Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) + => _orm.Select().Where(e => e.Id == id).FirstAsync(cancellationToken)!; + + /// + public async Task> GetPagedAsync( + int page, + int pageSize, + Expression>? predicate = null, + Expression>? orderBy = null, + bool descending = false, + CancellationToken cancellationToken = default + ) { var select = _orm.Select(); @@ -94,57 +140,50 @@ public Task CountAsync(Expression>? predicate = null, select = select.Where(predicate); } - return select.CountAsync(cancellationToken); - } + var total = await select.CountAsync(cancellationToken); - /// - public Task ExistsAsync(Expression> predicate, CancellationToken cancellationToken = default) - => _orm.Select().Where(predicate).AnyAsync(cancellationToken); + if (orderBy is not null) + { + select = descending ? select.OrderByDescending(orderBy) : select.OrderBy(orderBy); + } - /// - public Task BulkInsertAsync(IEnumerable entities, CancellationToken cancellationToken = default) - { - var list = Materialize(entities, stampUpdated: true); + var items = await select.Page(page, pageSize).ToListAsync(cancellationToken); - return RunInTransactionAsync( - transaction => _orm.Insert(list).WithTransaction(transaction).ExecuteAffrowsAsync(cancellationToken), - "BulkInsert", - list.Count, - cancellationToken); + return PagedResultData.Create(items, page, pageSize, total); } /// - public Task BulkUpdateAsync(IEnumerable entities, CancellationToken cancellationToken = default) + public async Task InsertAsync(TEntity entity, CancellationToken cancellationToken = default) { - var list = entities.ToList(); var now = DateTimeOffset.UtcNow; - foreach (var entity in list) + if (entity.Id == Guid.Empty) { - entity.Updated = now; + entity.Id = Guid.CreateVersion7(); } - return RunInTransactionAsync( - transaction => _orm.Update().SetSource(list).WithTransaction(transaction).ExecuteAffrowsAsync(cancellationToken), - "BulkUpdate", - list.Count, - cancellationToken); - } + entity.Created = now; + entity.Updated = now; - /// - public Task BulkDeleteAsync(Expression> predicate, CancellationToken cancellationToken = default) - => RunInTransactionAsync( - transaction => _orm.Delete().Where(predicate).WithTransaction(transaction).ExecuteAffrowsAsync(cancellationToken), - "BulkDelete", - null, - cancellationToken); + await RunInTransactionAsync( + transaction => _orm.Insert(entity).WithTransaction(transaction).ExecuteAffrowsAsync(cancellationToken), + "Insert", + 1, + cancellationToken + ); + + return entity; + } /// public ISelect Query() => _orm.Select(); /// - public async Task> QueryAsync(Expression>? predicate = null, CancellationToken cancellationToken = default) + public async Task> QueryAsync( + Expression>? predicate = null, + CancellationToken cancellationToken = default + ) { var select = _orm.Select(); @@ -157,31 +196,21 @@ public async Task> QueryAsync(Expression - public async Task> GetPagedAsync( - int page, - int pageSize, - Expression>? predicate = null, - Expression>? orderBy = null, - bool descending = false, - CancellationToken cancellationToken = default) + public async Task UpdateAsync(TEntity entity, CancellationToken cancellationToken = default) { - var select = _orm.Select(); - - if (predicate is not null) - { - select = select.Where(predicate); - } - - var total = await select.CountAsync(cancellationToken); - - if (orderBy is not null) - { - select = descending ? select.OrderByDescending(orderBy) : select.OrderBy(orderBy); - } + entity.Updated = DateTimeOffset.UtcNow; - var items = await select.Page(page, pageSize).ToListAsync(cancellationToken); + await RunInTransactionAsync( + transaction => _orm.Update() + .SetSource(entity) + .WithTransaction(transaction) + .ExecuteAffrowsAsync(cancellationToken), + "Update", + 1, + cancellationToken + ); - return PagedResultData.Create(items, page, pageSize, total); + return entity; } private void EnsureSynced() @@ -218,10 +247,11 @@ private static List Materialize(IEnumerable entities, bool sta } private async Task RunInTransactionAsync( - Func> action, + Func> action, string operation, int? expectedCount, - CancellationToken cancellationToken) + CancellationToken cancellationToken + ) { cancellationToken.ThrowIfCancellationRequested(); @@ -245,6 +275,7 @@ private async Task RunInTransactionAsync( { uow.Rollback(); Logger.Error(ex, "{Operation} rolled back", operation); + throw; } } diff --git a/src/SquidStd.Database/Extensions/ZLinqResultExtensions.cs b/src/SquidStd.Database/Extensions/ZLinqResultExtensions.cs index 219b9cdf..f5fe342d 100644 --- a/src/SquidStd.Database/Extensions/ZLinqResultExtensions.cs +++ b/src/SquidStd.Database/Extensions/ZLinqResultExtensions.cs @@ -17,10 +17,9 @@ public static class ZLinqResultExtensions /// The projected list. public static List MapToList( this IReadOnlyList source, - Func selector) - { - return source.AsValueEnumerable().Select(selector).ToList(); - } + Func selector + ) + => source.AsValueEnumerable().Select(selector).ToList(); /// /// Takes an in-memory page of a materialized list using ZLinq (no SQL involved). diff --git a/src/SquidStd.Database/Interfaces/Services/IDatabaseService.cs b/src/SquidStd.Database/Interfaces/Services/IDatabaseService.cs index b9253e53..0ce04e6d 100644 --- a/src/SquidStd.Database/Interfaces/Services/IDatabaseService.cs +++ b/src/SquidStd.Database/Interfaces/Services/IDatabaseService.cs @@ -1,4 +1,3 @@ -using FreeSql; using SquidStd.Abstractions.Interfaces.Services; namespace SquidStd.Database.Interfaces.Services; diff --git a/src/SquidStd.Database/README.md b/src/SquidStd.Database/README.md index c5e38fa6..87db6627 100644 --- a/src/SquidStd.Database/README.md +++ b/src/SquidStd.Database/README.md @@ -49,13 +49,13 @@ var page = await users.GetPagedAsync(page: 1, pageSize: 20, orderBy: u => u.Name ## Key types -| Type | Purpose | -|------|---------| -| `RegisterDatabaseExtension` | `RegisterDatabase()` DI registration. | -| `DatabaseService` | Owns the singleton `IFreeSql`; builds it and (optionally) migrates. | -| `FreeSqlDataAccess` | FreeSql `IDataAccess` implementation. | -| `ConnectionStringParser` | URI → provider + native connection string. | -| `ZLinqResultExtensions` | Zero-alloc in-memory result helpers. | +| Type | Purpose | +|------------------------------|---------------------------------------------------------------------| +| `RegisterDatabaseExtension` | `RegisterDatabase()` DI registration. | +| `DatabaseService` | Owns the singleton `IFreeSql`; builds it and (optionally) migrates. | +| `FreeSqlDataAccess` | FreeSql `IDataAccess` implementation. | +| `ConnectionStringParser` | URI → provider + native connection string. | +| `ZLinqResultExtensions` | Zero-alloc in-memory result helpers. | ## License diff --git a/src/SquidStd.Database/Services/DatabaseService.cs b/src/SquidStd.Database/Services/DatabaseService.cs index 6a095358..7b606ea2 100644 --- a/src/SquidStd.Database/Services/DatabaseService.cs +++ b/src/SquidStd.Database/Services/DatabaseService.cs @@ -19,8 +19,7 @@ public sealed class DatabaseService : IDatabaseService private int _started; /// - public IFreeSql Orm - => _orm ?? throw new InvalidOperationException("Database service is not started."); + public IFreeSql Orm => _orm ?? throw new InvalidOperationException("Database service is not started."); /// /// Initializes the database service. @@ -45,18 +44,19 @@ 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})", parsed.Provider, - _config.AutoMigrate); + _config.AutoMigrate + ); return ValueTask.CompletedTask; } @@ -75,10 +75,10 @@ public ValueTask StopAsync(CancellationToken cancellationToken = default) private static DataType MapDataType(DatabaseProviderType provider) => provider switch { - DatabaseProviderType.Sqlite => DataType.Sqlite, - DatabaseProviderType.Postgres => DataType.PostgreSQL, + DatabaseProviderType.Sqlite => DataType.Sqlite, + DatabaseProviderType.Postgres => DataType.PostgreSQL, DatabaseProviderType.SqlServer => DataType.SqlServer, - DatabaseProviderType.MySql => DataType.MySql, - _ => throw new NotSupportedException($"Unsupported provider {provider}.") + DatabaseProviderType.MySql => DataType.MySql, + _ => throw new NotSupportedException($"Unsupported provider {provider}.") }; } diff --git a/src/SquidStd.Mail.Abstractions/Data/Config/MailOptions.cs b/src/SquidStd.Mail.Abstractions/Data/Config/MailOptions.cs new file mode 100644 index 00000000..e79dc1f6 --- /dev/null +++ b/src/SquidStd.Mail.Abstractions/Data/Config/MailOptions.cs @@ -0,0 +1,46 @@ +using SquidStd.Mail.Abstractions.Types.Mail; + +namespace SquidStd.Mail.Abstractions.Data.Config; + +/// Plain options for a single mailbox poller (not a config section — passed directly to AddMail). +public sealed class MailOptions +{ + /// Retrieval protocol. + public MailProtocolType Protocol { get; set; } = MailProtocolType.Imap; + + /// Mail server host. + public string Host { get; set; } = string.Empty; + + /// Mail server port. + public int Port { get; set; } = 993; + + /// Use an SSL/TLS connection. + public bool UseSsl { get; set; } = true; + + /// Login user name. + public string Username { get; set; } = string.Empty; + + /// Login password. + public string Password { get; set; } = string.Empty; + + /// IMAP folder to poll. + public string Folder { get; set; } = "INBOX"; + + /// Seconds between polls. + public int PollIntervalSeconds { get; set; } = 60; + + /// Mark IMAP messages as seen after a successful publish. + public bool MarkAsSeen { get; set; } = true; + + /// Delete POP3 messages after download. + public bool DeleteAfterDownload { get; set; } + + /// Include attachment bytes in the message. + public bool IncludeAttachmentContent { get; set; } = true; + + /// Include the full raw .eml bytes in the message. + public bool IncludeRawEml { get; set; } + + /// Maximum messages fetched per poll. + public int MaxMessagesPerPoll { get; set; } = 50; +} diff --git a/src/SquidStd.Mail.Abstractions/Data/Config/SmtpOptions.cs b/src/SquidStd.Mail.Abstractions/Data/Config/SmtpOptions.cs new file mode 100644 index 00000000..763282f1 --- /dev/null +++ b/src/SquidStd.Mail.Abstractions/Data/Config/SmtpOptions.cs @@ -0,0 +1,23 @@ +namespace SquidStd.Mail.Abstractions.Data.Config; + +/// Plain options for the SMTP sender (passed directly to AddMailSender). +public sealed class SmtpOptions +{ + /// SMTP server host. + public string Host { get; set; } = string.Empty; + + /// SMTP server port. + public int Port { get; set; } = 587; + + /// Use SSL-on-connect (true) or STARTTLS-when-available (false). + public bool UseSsl { get; set; } + + /// Login user name; when empty, authentication is skipped. + public string Username { get; set; } = string.Empty; + + /// Login password. + public string Password { get; set; } = string.Empty; + + /// Default sender used when a message has no From. + public MailAddress? DefaultFrom { get; set; } +} diff --git a/src/SquidStd.Mail.Abstractions/Data/Events/MailReceivedEvent.cs b/src/SquidStd.Mail.Abstractions/Data/Events/MailReceivedEvent.cs new file mode 100644 index 00000000..24456ac5 --- /dev/null +++ b/src/SquidStd.Mail.Abstractions/Data/Events/MailReceivedEvent.cs @@ -0,0 +1,7 @@ +using SquidStd.Core.Interfaces.Events; + +namespace SquidStd.Mail.Abstractions.Data.Events; + +/// Published on the event bus for each newly received email. +/// The received message. +public sealed record MailReceivedEvent(MailMessage Message) : IEvent; diff --git a/src/SquidStd.Mail.Abstractions/Data/Events/MailSendFailedEvent.cs b/src/SquidStd.Mail.Abstractions/Data/Events/MailSendFailedEvent.cs new file mode 100644 index 00000000..65c3b3db --- /dev/null +++ b/src/SquidStd.Mail.Abstractions/Data/Events/MailSendFailedEvent.cs @@ -0,0 +1,9 @@ +using SquidStd.Core.Interfaces.Events; + +namespace SquidStd.Mail.Abstractions.Data.Events; + +/// Published when sending a message fails. +/// Primary recipients. +/// Message subject. +/// Failure reason. +public sealed record MailSendFailedEvent(IReadOnlyList To, string Subject, string Error) : IEvent; diff --git a/src/SquidStd.Mail.Abstractions/Data/Events/MailSentEvent.cs b/src/SquidStd.Mail.Abstractions/Data/Events/MailSentEvent.cs new file mode 100644 index 00000000..f9bf75da --- /dev/null +++ b/src/SquidStd.Mail.Abstractions/Data/Events/MailSentEvent.cs @@ -0,0 +1,8 @@ +using SquidStd.Core.Interfaces.Events; + +namespace SquidStd.Mail.Abstractions.Data.Events; + +/// Published after a message is sent successfully. +/// Primary recipients. +/// Message subject. +public sealed record MailSentEvent(IReadOnlyList To, string Subject) : IEvent; diff --git a/src/SquidStd.Mail.Abstractions/Data/MailAddress.cs b/src/SquidStd.Mail.Abstractions/Data/MailAddress.cs new file mode 100644 index 00000000..8767c678 --- /dev/null +++ b/src/SquidStd.Mail.Abstractions/Data/MailAddress.cs @@ -0,0 +1,6 @@ +namespace SquidStd.Mail.Abstractions.Data; + +/// An email address with an optional display name. +/// Display name (may be empty). +/// The email address. +public sealed record MailAddress(string Name, string Address); diff --git a/src/SquidStd.Mail.Abstractions/Data/MailAttachment.cs b/src/SquidStd.Mail.Abstractions/Data/MailAttachment.cs new file mode 100644 index 00000000..5d21f907 --- /dev/null +++ b/src/SquidStd.Mail.Abstractions/Data/MailAttachment.cs @@ -0,0 +1,8 @@ +namespace SquidStd.Mail.Abstractions.Data; + +/// An attachment; is populated only when attachment content is included. +/// Attachment file name. +/// MIME content type. +/// Content length in bytes. +/// Raw bytes, or null when content is excluded. +public sealed record MailAttachment(string FileName, string ContentType, long Size, byte[]? Content); diff --git a/src/SquidStd.Mail.Abstractions/Data/MailMessage.cs b/src/SquidStd.Mail.Abstractions/Data/MailMessage.cs new file mode 100644 index 00000000..869a745c --- /dev/null +++ b/src/SquidStd.Mail.Abstractions/Data/MailMessage.cs @@ -0,0 +1,15 @@ +namespace SquidStd.Mail.Abstractions.Data; + +/// A received email, parsed from MIME. +public sealed record MailMessage( + MailAddress From, + IReadOnlyList To, + IReadOnlyList Cc, + string Subject, + DateTime DateUtc, + string MessageId, + string? TextBody, + string? HtmlBody, + IReadOnlyList Attachments, + byte[]? RawEml +); diff --git a/src/SquidStd.Mail.Abstractions/Data/OutgoingAttachment.cs b/src/SquidStd.Mail.Abstractions/Data/OutgoingAttachment.cs new file mode 100644 index 00000000..49111f1b --- /dev/null +++ b/src/SquidStd.Mail.Abstractions/Data/OutgoingAttachment.cs @@ -0,0 +1,7 @@ +namespace SquidStd.Mail.Abstractions.Data; + +/// An attachment to send with an outgoing message. +/// Attachment file name. +/// MIME content type, e.g. application/pdf. +/// Raw bytes. +public sealed record OutgoingAttachment(string FileName, string ContentType, byte[] Content); diff --git a/src/SquidStd.Mail.Abstractions/Data/OutgoingMailMessage.cs b/src/SquidStd.Mail.Abstractions/Data/OutgoingMailMessage.cs new file mode 100644 index 00000000..4efc4413 --- /dev/null +++ b/src/SquidStd.Mail.Abstractions/Data/OutgoingMailMessage.cs @@ -0,0 +1,29 @@ +namespace SquidStd.Mail.Abstractions.Data; + +/// An email to send. Build with an object initializer. +public sealed record OutgoingMailMessage +{ + /// Sender; falls back to SmtpOptions.DefaultFrom when null. + public MailAddress? From { get; init; } + + /// Primary recipients (required, non-empty). + public IReadOnlyList To { get; init; } = []; + + /// Carbon-copy recipients. + public IReadOnlyList Cc { get; init; } = []; + + /// Blind carbon-copy recipients. + public IReadOnlyList Bcc { get; init; } = []; + + /// Subject line. + public string Subject { get; init; } = string.Empty; + + /// Plain-text body, or null. + public string? TextBody { get; init; } + + /// HTML body, or null. + public string? HtmlBody { get; init; } + + /// Attachments. + public IReadOnlyList Attachments { get; init; } = []; +} diff --git a/src/SquidStd.Mail.Abstractions/Exceptions/MailSendException.cs b/src/SquidStd.Mail.Abstractions/Exceptions/MailSendException.cs new file mode 100644 index 00000000..d640d699 --- /dev/null +++ b/src/SquidStd.Mail.Abstractions/Exceptions/MailSendException.cs @@ -0,0 +1,9 @@ +namespace SquidStd.Mail.Abstractions.Exceptions; + +/// Thrown when an outbound message cannot be sent. +public sealed class MailSendException : Exception +{ + /// Initializes the exception with a message and the underlying cause. + public MailSendException(string message, Exception innerException) + : base(message, innerException) { } +} diff --git a/src/SquidStd.Mail.Abstractions/Interfaces/IMailReader.cs b/src/SquidStd.Mail.Abstractions/Interfaces/IMailReader.cs new file mode 100644 index 00000000..3038bfb5 --- /dev/null +++ b/src/SquidStd.Mail.Abstractions/Interfaces/IMailReader.cs @@ -0,0 +1,13 @@ +using SquidStd.Mail.Abstractions.Data; + +namespace SquidStd.Mail.Abstractions.Interfaces; + +/// Fetches new messages from a mailbox. +public interface IMailReader +{ + /// + /// 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.Abstractions/Interfaces/IMailSender.cs b/src/SquidStd.Mail.Abstractions/Interfaces/IMailSender.cs new file mode 100644 index 00000000..0f255327 --- /dev/null +++ b/src/SquidStd.Mail.Abstractions/Interfaces/IMailSender.cs @@ -0,0 +1,10 @@ +using SquidStd.Mail.Abstractions.Data; + +namespace SquidStd.Mail.Abstractions.Interfaces; + +/// Sends outbound email. +public interface IMailSender +{ + /// Sends a message; throws on failure. + Task SendAsync(OutgoingMailMessage message, CancellationToken cancellationToken = default); +} diff --git a/src/SquidStd.Mail.Abstractions/README.md b/src/SquidStd.Mail.Abstractions/README.md new file mode 100644 index 00000000..f211c894 --- /dev/null +++ b/src/SquidStd.Mail.Abstractions/README.md @@ -0,0 +1,38 @@ +

+ SquidStd +

+ +

SquidStd.Mail.Abstractions

+ +

+ NuGet + Downloads + docs + license +

+ +Mail contracts for SquidStd: the `MailMessage` model, the `MailReceivedEvent`, the `IMailReader` interface, and +the plain `MailOptions`. + +## Install + +```bash +dotnet add package SquidStd.Mail.Abstractions +``` + +## Key types + +| Type | Purpose | +|-----------------------------------------|-----------------------------------------------------------------| +| `MailMessage` | Parsed email (headers, bodies, attachments, optional raw .eml). | +| `MailReceivedEvent` | Published on the event bus per new message. | +| `IMailReader` | Fetches new messages from a mailbox. | +| `MailOptions` | Host/port/credentials, protocol, polling and fetch options. | +| `IMailSender` | Sends outbound email. | +| `OutgoingMailMessage` | An email to send (recipients, subject, bodies, attachments). | +| `SmtpOptions` | SMTP host/port/SSL/credentials and a default sender. | +| `MailSentEvent` / `MailSendFailedEvent` | Published on the event bus on send success/failure. | + +## License + +MIT — part of [SquidStd](https://github.com/tgiachi/squid-std). diff --git a/src/SquidStd.Mail.Abstractions/SquidStd.Mail.Abstractions.csproj b/src/SquidStd.Mail.Abstractions/SquidStd.Mail.Abstractions.csproj new file mode 100644 index 00000000..92ecae12 --- /dev/null +++ b/src/SquidStd.Mail.Abstractions/SquidStd.Mail.Abstractions.csproj @@ -0,0 +1,15 @@ + + + + net10.0 + enable + enable + true + + + + + + + + diff --git a/src/SquidStd.Mail.Abstractions/Types/Mail/MailProtocolType.cs b/src/SquidStd.Mail.Abstractions/Types/Mail/MailProtocolType.cs new file mode 100644 index 00000000..13381680 --- /dev/null +++ b/src/SquidStd.Mail.Abstractions/Types/Mail/MailProtocolType.cs @@ -0,0 +1,11 @@ +namespace SquidStd.Mail.Abstractions.Types.Mail; + +/// Mail retrieval protocol. +public enum MailProtocolType +{ + /// IMAP. + Imap, + + /// POP3. + Pop3 +} diff --git a/src/SquidStd.Mail.MailKit/Extensions/MailRegistrationExtensions.cs b/src/SquidStd.Mail.MailKit/Extensions/MailRegistrationExtensions.cs new file mode 100644 index 00000000..730dc8f3 --- /dev/null +++ b/src/SquidStd.Mail.MailKit/Extensions/MailRegistrationExtensions.cs @@ -0,0 +1,52 @@ +using DryIoc; +using SquidStd.Abstractions.Extensions.Config; +using SquidStd.Abstractions.Extensions.Services; +using SquidStd.Core.Data.Timing; +using SquidStd.Mail.Abstractions.Data.Config; +using SquidStd.Mail.Abstractions.Interfaces; +using SquidStd.Mail.Abstractions.Types.Mail; +using SquidStd.Mail.MailKit.Services; +using SquidStd.Services.Core.Services.Scheduling; + +namespace SquidStd.Mail.MailKit.Extensions; + +/// DryIoc registration helpers for the mail poller. +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). + /// + public static IContainer AddMail(this IContainer container, MailOptions options) + { + ArgumentNullException.ThrowIfNull(container); + ArgumentNullException.ThrowIfNull(options); + ArgumentException.ThrowIfNullOrWhiteSpace(options.Host); + + if (options.Port <= 0) + { + throw new ArgumentException("Port must be positive.", nameof(options)); + } + + container.RegisterInstance(options); + + if (options.Protocol == MailProtocolType.Pop3) + { + container.Register(Reuse.Singleton); + } + else + { + container.Register(Reuse.Singleton); + } + + container.RegisterStdService(100); + + if (!container.IsRegistered()) + { + container.RegisterConfigSection("timerWheelPump", static () => new TimerWheelPumpConfig(), -90); + container.RegisterStdService(-1); + } + + return container; + } +} diff --git a/src/SquidStd.Mail.MailKit/Extensions/MailSenderRegistrationExtensions.cs b/src/SquidStd.Mail.MailKit/Extensions/MailSenderRegistrationExtensions.cs new file mode 100644 index 00000000..ba32a886 --- /dev/null +++ b/src/SquidStd.Mail.MailKit/Extensions/MailSenderRegistrationExtensions.cs @@ -0,0 +1,28 @@ +using DryIoc; +using SquidStd.Mail.Abstractions.Data.Config; +using SquidStd.Mail.Abstractions.Interfaces; +using SquidStd.Mail.MailKit.Services; + +namespace SquidStd.Mail.MailKit.Extensions; + +/// DryIoc registration helper for the SMTP sender. +public static class MailSenderRegistrationExtensions +{ + /// Registers and (MailKit SMTP). + public static IContainer AddMailSender(this IContainer container, SmtpOptions options) + { + ArgumentNullException.ThrowIfNull(container); + ArgumentNullException.ThrowIfNull(options); + ArgumentException.ThrowIfNullOrWhiteSpace(options.Host); + + if (options.Port <= 0) + { + throw new ArgumentException("Port must be positive.", nameof(options)); + } + + container.RegisterInstance(options); + container.Register(Reuse.Singleton); + + return container; + } +} diff --git a/src/SquidStd.Mail.MailKit/README.md b/src/SquidStd.Mail.MailKit/README.md new file mode 100644 index 00000000..8dc716ef --- /dev/null +++ b/src/SquidStd.Mail.MailKit/README.md @@ -0,0 +1,79 @@ +

+ SquidStd +

+ +

SquidStd.Mail.MailKit

+ +

+ NuGet + Downloads + docs + license +

+ +MailKit-backed IMAP/POP3 provider for SquidStd.Mail. Polls a mailbox on the timer wheel and publishes a +`MailReceivedEvent` on the `IEventBus` for each new message. + +## Install + +```bash +dotnet add package SquidStd.Mail.MailKit +``` + +## Usage + +```csharp +using DryIoc; +using SquidStd.Mail.Abstractions.Data.Config; +using SquidStd.Mail.Abstractions.Types.Mail; +using SquidStd.Mail.MailKit.Extensions; + +container.AddMail(new MailOptions +{ + Protocol = MailProtocolType.Imap, + Host = "imap.example.com", + Port = 993, + UseSsl = true, + Username = "user@example.com", + Password = "secret", + PollIntervalSeconds = 30, + IncludeRawEml = true +}); +``` + +Listen with an `IAsyncEventListener` registered on the `IEventBus`. IMAP marks messages +`\Seen` after fetch (configurable); POP3 dedups by UIDL and can delete after download. + +## Sending (SMTP) + +```csharp +using DryIoc; +using SquidStd.Mail.Abstractions.Data; +using SquidStd.Mail.Abstractions.Data.Config; +using SquidStd.Mail.Abstractions.Interfaces; +using SquidStd.Mail.MailKit.Extensions; + +container.AddMailSender(new SmtpOptions +{ + Host = "smtp.example.com", + Port = 587, + UseSsl = false, + Username = "user@example.com", + Password = "secret", + DefaultFrom = new MailAddress("App", "app@example.com") +}); + +var sender = container.Resolve(); +await sender.SendAsync(new OutgoingMailMessage +{ + To = [new MailAddress("Bob", "bob@example.com")], + Subject = "Welcome", + HtmlBody = "

Hello!

" +}); +``` + +`MailSentEvent` / `MailSendFailedEvent` are published on the `IEventBus`; failures throw `MailSendException`. + +## License + +MIT — part of [SquidStd](https://github.com/tgiachi/squid-std). diff --git a/src/SquidStd.Mail.MailKit/Services/ImapMailReader.cs b/src/SquidStd.Mail.MailKit/Services/ImapMailReader.cs new file mode 100644 index 00000000..d8690473 --- /dev/null +++ b/src/SquidStd.Mail.MailKit/Services/ImapMailReader.cs @@ -0,0 +1,72 @@ +using MailKit; +using MailKit.Net.Imap; +using MailKit.Search; +using MailKit.Security; +using Serilog; +using SquidStd.Mail.Abstractions.Data; +using SquidStd.Mail.Abstractions.Data.Config; +using SquidStd.Mail.Abstractions.Interfaces; + +namespace SquidStd.Mail.MailKit.Services; + +/// IMAP : fetches unseen messages and marks them seen after mapping. +public sealed class ImapMailReader : IMailReader +{ + private readonly ILogger _logger = Log.ForContext(); + private readonly MailOptions _options; + + public ImapMailReader(MailOptions options) + { + _options = options; + } + + /// + public async Task> FetchNewAsync(CancellationToken cancellationToken = default) + { + using var client = new ImapClient(); + var results = new List(); + + try + { + await client.ConnectAsync( + _options.Host, + _options.Port, + _options.UseSsl ? SecureSocketOptions.SslOnConnect : SecureSocketOptions.StartTlsWhenAvailable, + cancellationToken + ); + await client.AuthenticateAsync(_options.Username, _options.Password, cancellationToken); + + var folder = await client.GetFolderAsync(_options.Folder, cancellationToken); + await folder.OpenAsync(FolderAccess.ReadWrite, cancellationToken); + + var uids = await folder.SearchAsync(SearchQuery.NotSeen, cancellationToken); + + foreach (var uid in uids.Take(_options.MaxMessagesPerPoll)) + { + try + { + var mime = await folder.GetMessageAsync(uid, cancellationToken); + results.Add(MimeMessageMapper.Map(mime, _options.IncludeAttachmentContent, _options.IncludeRawEml)); + + if (_options.MarkAsSeen) + { + await folder.AddFlagsAsync(uid, MessageFlags.Seen, true, cancellationToken); + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.Error(ex, "Failed to map IMAP message {Uid}; leaving it unseen.", uid); + } + } + } + finally + { + if (client.IsConnected) + { + await client.DisconnectAsync(true, CancellationToken.None); + } + } + + return results; + } +} diff --git a/src/SquidStd.Mail.MailKit/Services/MailKitMailSender.cs b/src/SquidStd.Mail.MailKit/Services/MailKitMailSender.cs new file mode 100644 index 00000000..93f2d2c1 --- /dev/null +++ b/src/SquidStd.Mail.MailKit/Services/MailKitMailSender.cs @@ -0,0 +1,96 @@ +using MailKit.Net.Smtp; +using MailKit.Security; +using Serilog; +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.Interfaces; + +namespace SquidStd.Mail.MailKit.Services; + +/// MailKit : sends via SMTP and publishes send events. +public sealed class MailKitMailSender : IMailSender +{ + private readonly ILogger _logger = Log.ForContext(); + private readonly SmtpOptions _options; + private readonly IEventBus _eventBus; + + public MailKitMailSender(SmtpOptions options, IEventBus eventBus) + { + _options = options; + _eventBus = eventBus; + } + + /// + public async Task SendAsync(OutgoingMailMessage message, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(message); + + if (message.To.Count == 0) + { + throw new ArgumentException("At least one recipient (To) is required.", nameof(message)); + } + + var mime = OutgoingMessageMapper.ToMimeMessage(message, _options); + + try + { + using var client = new SmtpClient(); + + try + { + await client.ConnectAsync( + _options.Host, + _options.Port, + _options.UseSsl ? SecureSocketOptions.SslOnConnect : SecureSocketOptions.StartTlsWhenAvailable, + cancellationToken + ); + + if (!string.IsNullOrWhiteSpace(_options.Username)) + { + await client.AuthenticateAsync(_options.Username, _options.Password, cancellationToken); + } + + await client.SendAsync(mime, cancellationToken); + } + finally + { + if (client.IsConnected) + { + await client.DisconnectAsync(true, CancellationToken.None); + } + } + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + await PublishSafelyAsync( + new MailSendFailedEvent(message.To, message.Subject, ex.Message), + CancellationToken.None + ); + _logger.Error(ex, "Failed to send mail '{Subject}'.", message.Subject); + + throw new MailSendException($"Failed to send mail '{message.Subject}'.", ex); + } + + await PublishSafelyAsync(new MailSentEvent(message.To, message.Subject), cancellationToken); + } + + private async Task PublishSafelyAsync(TEvent payload, CancellationToken cancellationToken = default) + where TEvent : IEvent + { + try + { + await _eventBus.PublishAsync(payload, cancellationToken); + } + catch (Exception ex) + { + _logger.Warning(ex, "Failed to publish {Event}.", typeof(TEvent).Name); + } + } +} diff --git a/src/SquidStd.Mail.MailKit/Services/MailPollingService.cs b/src/SquidStd.Mail.MailKit/Services/MailPollingService.cs new file mode 100644 index 00000000..89ec168a --- /dev/null +++ b/src/SquidStd.Mail.MailKit/Services/MailPollingService.cs @@ -0,0 +1,97 @@ +using Serilog; +using SquidStd.Abstractions.Interfaces.Services; +using SquidStd.Core.Interfaces.Events; +using SquidStd.Core.Interfaces.Timing; +using SquidStd.Mail.Abstractions.Data.Config; +using SquidStd.Mail.Abstractions.Data.Events; +using SquidStd.Mail.Abstractions.Interfaces; + +namespace SquidStd.Mail.MailKit.Services; + +/// Polls the mailbox on the timer wheel and publishes a per new message. +public sealed class MailPollingService : ISquidStdService, IDisposable +{ + private const int DefaultIntervalSeconds = 60; + + 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) + { + _reader = reader; + _eventBus = eventBus; + _timer = timer; + + var seconds = options.PollIntervalSeconds > 0 ? options.PollIntervalSeconds : DefaultIntervalSeconds; + + if (options.PollIntervalSeconds <= 0) + { + _logger.Warning( + "PollIntervalSeconds was {Value}; falling back to {Default}s.", + options.PollIntervalSeconds, + DefaultIntervalSeconds + ); + } + + _interval = TimeSpan.FromSeconds(seconds); + } + + /// + public void Dispose() + => _gate.Dispose(); + + /// Runs one poll and publishes events. Public so tests can drive it without the timer wheel. + public async Task PollOnceAsync() + { + if (!await _gate.WaitAsync(0)) + { + return; + } + + try + { + var messages = await _reader.FetchNewAsync(); + + foreach (var message in messages) + { + await _eventBus.PublishAsync(new MailReceivedEvent(message), CancellationToken.None); + } + } + catch (Exception ex) + { + _logger.Error(ex, "Mail poll failed."); + } + finally + { + _gate.Release(); + } + } + + /// + 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; + } + + private void OnTick() + => _ = PollOnceAsync(); +} diff --git a/src/SquidStd.Mail.MailKit/Services/MimeMessageMapper.cs b/src/SquidStd.Mail.MailKit/Services/MimeMessageMapper.cs new file mode 100644 index 00000000..52335d34 --- /dev/null +++ b/src/SquidStd.Mail.MailKit/Services/MimeMessageMapper.cs @@ -0,0 +1,68 @@ +using MimeKit; +using SquidStd.Mail.Abstractions.Data; + +namespace SquidStd.Mail.MailKit.Services; + +/// Maps a MimeKit to a SquidStd . +public static class MimeMessageMapper +{ + /// Maps a MIME message; attachment bytes and the raw .eml are included only when requested. + public static MailMessage Map(MimeMessage message, bool includeAttachmentContent, bool includeRawEml) + { + ArgumentNullException.ThrowIfNull(message); + + var from = message.From.Mailboxes.Select(ToAddress).FirstOrDefault() ?? new MailAddress(string.Empty, string.Empty); + 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(); + + byte[]? rawEml = null; + + if (includeRawEml) + { + using var stream = new MemoryStream(); + message.WriteTo(stream); + rawEml = stream.ToArray(); + } + + return new( + from, + to, + cc, + message.Subject ?? string.Empty, + message.Date.UtcDateTime, + message.MessageId ?? string.Empty, + message.TextBody, + message.HtmlBody, + attachments, + rawEml + ); + } + + private static MailAddress ToAddress(MailboxAddress mailbox) + => new(mailbox.Name ?? string.Empty, mailbox.Address); + + private static MailAttachment ToAttachment(MimePart part, bool includeContent) + { + byte[]? content = null; + long size; + + using (var stream = new MemoryStream()) + { + part.Content.DecodeTo(stream); + size = stream.Length; + + if (includeContent) + { + content = stream.ToArray(); + } + } + + var fileName = part.FileName ?? string.Empty; + + return new(fileName, part.ContentType.MimeType, size, content); + } +} diff --git a/src/SquidStd.Mail.MailKit/Services/OutgoingMessageMapper.cs b/src/SquidStd.Mail.MailKit/Services/OutgoingMessageMapper.cs new file mode 100644 index 00000000..7ac2bd8b --- /dev/null +++ b/src/SquidStd.Mail.MailKit/Services/OutgoingMessageMapper.cs @@ -0,0 +1,48 @@ +using MimeKit; +using SquidStd.Mail.Abstractions.Data; +using SquidStd.Mail.Abstractions.Data.Config; + +namespace SquidStd.Mail.MailKit.Services; + +/// Maps a SquidStd to a MimeKit . +public static class OutgoingMessageMapper +{ + /// Builds a MIME message; From falls back to . + public static MimeMessage ToMimeMessage(OutgoingMailMessage message, SmtpOptions options) + { + ArgumentNullException.ThrowIfNull(message); + ArgumentNullException.ThrowIfNull(options); + + var from = message.From ?? + options.DefaultFrom ?? + throw new ArgumentException( + "No sender: set OutgoingMailMessage.From or SmtpOptions.DefaultFrom.", + nameof(message) + ); + + var mime = new MimeMessage(); + mime.From.Add(ToMailbox(from)); + mime.To.AddRange(message.To.Select(ToMailbox)); + mime.Cc.AddRange(message.Cc.Select(ToMailbox)); + mime.Bcc.AddRange(message.Bcc.Select(ToMailbox)); + mime.Subject = message.Subject; + + var builder = new BodyBuilder + { + TextBody = message.TextBody, + HtmlBody = message.HtmlBody + }; + + foreach (var attachment in message.Attachments) + { + builder.Attachments.Add(attachment.FileName, attachment.Content, ContentType.Parse(attachment.ContentType)); + } + + mime.Body = builder.ToMessageBody(); + + return mime; + } + + private static MailboxAddress ToMailbox(MailAddress address) + => new(address.Name, address.Address); +} diff --git a/src/SquidStd.Mail.MailKit/Services/Pop3MailReader.cs b/src/SquidStd.Mail.MailKit/Services/Pop3MailReader.cs new file mode 100644 index 00000000..c605ddc2 --- /dev/null +++ b/src/SquidStd.Mail.MailKit/Services/Pop3MailReader.cs @@ -0,0 +1,87 @@ +using MailKit.Net.Pop3; +using MailKit.Security; +using Serilog; +using SquidStd.Mail.Abstractions.Data; +using SquidStd.Mail.Abstractions.Data.Config; +using SquidStd.Mail.Abstractions.Interfaces; + +namespace SquidStd.Mail.MailKit.Services; + +/// POP3 : fetches messages not seen before (by UIDL), optionally deletes them. +public sealed class Pop3MailReader : IMailReader +{ + private readonly ILogger _logger = Log.ForContext(); + private readonly MailOptions _options; + private readonly HashSet _seenUids = new(StringComparer.Ordinal); + private readonly Lock _sync = new(); + + public Pop3MailReader(MailOptions options) + { + _options = options; + } + + /// + public async Task> FetchNewAsync(CancellationToken cancellationToken = default) + { + using var client = new Pop3Client(); + var results = new List(); + + try + { + await client.ConnectAsync( + _options.Host, + _options.Port, + _options.UseSsl ? SecureSocketOptions.SslOnConnect : SecureSocketOptions.StartTlsWhenAvailable, + cancellationToken + ); + await client.AuthenticateAsync(_options.Username, _options.Password, cancellationToken); + + var uids = await client.GetMessageUidsAsync(cancellationToken); + var taken = 0; + + for (var index = 0; index < uids.Count && taken < _options.MaxMessagesPerPoll; index++) + { + var uid = uids[index]; + + lock (_sync) + { + if (!_seenUids.Add(uid)) + { + continue; + } + } + + taken++; + + try + { + var mime = await client.GetMessageAsync(index, cancellationToken); + results.Add(MimeMessageMapper.Map(mime, _options.IncludeAttachmentContent, _options.IncludeRawEml)); + + if (_options.DeleteAfterDownload) + { + await client.DeleteMessageAsync(index, cancellationToken); + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.Error(ex, "Failed to map POP3 message {Uid}.", uid); + + lock (_sync) + { + _seenUids.Remove(uid); + } + } + } + } + finally + { + if (client.IsConnected) + { + await client.DisconnectAsync(true, CancellationToken.None); + } + } + + return results; + } +} diff --git a/src/SquidStd.Mail.MailKit/SquidStd.Mail.MailKit.csproj b/src/SquidStd.Mail.MailKit/SquidStd.Mail.MailKit.csproj new file mode 100644 index 00000000..3994a508 --- /dev/null +++ b/src/SquidStd.Mail.MailKit/SquidStd.Mail.MailKit.csproj @@ -0,0 +1,19 @@ + + + + net10.0 + enable + enable + true + + + + + + + + + + + + diff --git a/src/SquidStd.Mail.Queue/Data/Config/MailQueueOptions.cs b/src/SquidStd.Mail.Queue/Data/Config/MailQueueOptions.cs new file mode 100644 index 00000000..4d88b436 --- /dev/null +++ b/src/SquidStd.Mail.Queue/Data/Config/MailQueueOptions.cs @@ -0,0 +1,8 @@ +namespace SquidStd.Mail.Queue.Data.Config; + +/// Plain options for the mail send queue (passed directly to AddMailQueue). +public sealed class MailQueueOptions +{ + /// Name of the queue outbound messages are published to. + public string QueueName { get; set; } = "squidstd.mail.outbound"; +} diff --git a/src/SquidStd.Mail.Queue/Extensions/MailQueueRegistrationExtensions.cs b/src/SquidStd.Mail.Queue/Extensions/MailQueueRegistrationExtensions.cs new file mode 100644 index 00000000..ceb036e1 --- /dev/null +++ b/src/SquidStd.Mail.Queue/Extensions/MailQueueRegistrationExtensions.cs @@ -0,0 +1,26 @@ +using DryIoc; +using SquidStd.Abstractions.Extensions.Services; +using SquidStd.Mail.Queue.Data.Config; +using SquidStd.Mail.Queue.Interfaces; +using SquidStd.Mail.Queue.Services; + +namespace SquidStd.Mail.Queue.Extensions; + +/// DryIoc registration helper for the mail send queue. +public static class MailQueueRegistrationExtensions +{ + /// + /// 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) + { + ArgumentNullException.ThrowIfNull(container); + + container.RegisterInstance(options ?? new MailQueueOptions()); + container.Register(Reuse.Singleton); + container.RegisterStdService(100); + + return container; + } +} diff --git a/src/SquidStd.Mail.Queue/Interfaces/IMailQueue.cs b/src/SquidStd.Mail.Queue/Interfaces/IMailQueue.cs new file mode 100644 index 00000000..a90263e7 --- /dev/null +++ b/src/SquidStd.Mail.Queue/Interfaces/IMailQueue.cs @@ -0,0 +1,10 @@ +using SquidStd.Mail.Abstractions.Data; + +namespace SquidStd.Mail.Queue.Interfaces; + +/// Enqueues outbound email for asynchronous sending. +public interface IMailQueue +{ + /// Enqueues a message; it is sent later by the background consumer. + Task EnqueueAsync(OutgoingMailMessage message, CancellationToken cancellationToken = default); +} diff --git a/src/SquidStd.Mail.Queue/README.md b/src/SquidStd.Mail.Queue/README.md new file mode 100644 index 00000000..8cc95d04 --- /dev/null +++ b/src/SquidStd.Mail.Queue/README.md @@ -0,0 +1,51 @@ +

+ SquidStd +

+ +

SquidStd.Mail.Queue

+ +

+ NuGet + Downloads + docs + license +

+ +Outbound mail send queue for SquidStd. Enqueue an `OutgoingMailMessage`; a background consumer sends it via +`IMailSender`. Retry, backoff, and dead-lettering come from the SquidStd messaging queue. + +## Install + +```bash +dotnet add package SquidStd.Mail.Queue +``` + +## Usage + +```csharp +using DryIoc; +using SquidStd.Messaging.Extensions; +using SquidStd.Mail.Abstractions.Data; +using SquidStd.Mail.MailKit.Extensions; +using SquidStd.Mail.Queue.Extensions; +using SquidStd.Mail.Queue.Interfaces; + +container.AddInMemoryMessaging(); // or AddRabbitMqMessaging(...) +container.AddMailSender(new SmtpOptions { Host = "smtp.example.com", Port = 587 }); +container.AddMailQueue(); + +var queue = container.Resolve(); +await queue.EnqueueAsync(new OutgoingMailMessage +{ + To = [new MailAddress("Bob", "bob@example.com")], + Subject = "Welcome", + HtmlBody = "

Hello!

" +}); +``` + +Retry/backoff/dead-letter are configured via `MessagingOptions` (`MaxDeliveryAttempts`, `RetryDelay`, +`DeadLetterQueueSuffix`). With RabbitMQ the queue is durable across restarts. + +## License + +MIT — part of [SquidStd](https://github.com/tgiachi/squid-std). diff --git a/src/SquidStd.Mail.Queue/Services/MailQueue.cs b/src/SquidStd.Mail.Queue/Services/MailQueue.cs new file mode 100644 index 00000000..5ed70a4b --- /dev/null +++ b/src/SquidStd.Mail.Queue/Services/MailQueue.cs @@ -0,0 +1,27 @@ +using SquidStd.Mail.Abstractions.Data; +using SquidStd.Mail.Queue.Data.Config; +using SquidStd.Mail.Queue.Interfaces; +using SquidStd.Messaging.Abstractions.Interfaces; + +namespace SquidStd.Mail.Queue.Services; + +/// Default : publishes the message onto the messaging queue. +public sealed class MailQueue : IMailQueue +{ + private readonly IMessageQueue _queue; + private readonly string _queueName; + + public MailQueue(IMessageQueue queue, MailQueueOptions options) + { + _queue = queue; + _queueName = string.IsNullOrWhiteSpace(options.QueueName) ? "squidstd.mail.outbound" : options.QueueName; + } + + /// + public Task EnqueueAsync(OutgoingMailMessage message, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(message); + + return _queue.PublishAsync(_queueName, message, cancellationToken); + } +} diff --git a/src/SquidStd.Mail.Queue/Services/MailSendConsumerService.cs b/src/SquidStd.Mail.Queue/Services/MailSendConsumerService.cs new file mode 100644 index 00000000..b0760751 --- /dev/null +++ b/src/SquidStd.Mail.Queue/Services/MailSendConsumerService.cs @@ -0,0 +1,50 @@ +using Serilog; +using SquidStd.Abstractions.Interfaces.Services; +using SquidStd.Mail.Abstractions.Data; +using SquidStd.Mail.Abstractions.Interfaces; +using SquidStd.Mail.Queue.Data.Config; +using SquidStd.Messaging.Abstractions.Interfaces; + +namespace SquidStd.Mail.Queue.Services; + +/// +/// 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 IDisposable? _subscription; + + public MailSendConsumerService(IMessageQueue queue, IMailSender sender, MailQueueOptions options) + { + _queue = queue; + _sender = sender; + _queueName = string.IsNullOrWhiteSpace(options.QueueName) ? "squidstd.mail.outbound" : options.QueueName; + } + + /// + public Task HandleAsync(OutgoingMailMessage message, CancellationToken cancellationToken) + => _sender.SendAsync(message, cancellationToken); + + /// + public ValueTask StartAsync(CancellationToken cancellationToken = default) + { + _subscription = _queue.Subscribe(_queueName, this); + _logger.Information("Mail send consumer listening on queue '{Queue}'.", _queueName); + + return ValueTask.CompletedTask; + } + + /// + public ValueTask StopAsync(CancellationToken cancellationToken = default) + { + _subscription?.Dispose(); + _subscription = null; + + return ValueTask.CompletedTask; + } +} diff --git a/src/SquidStd.Mail.Queue/SquidStd.Mail.Queue.csproj b/src/SquidStd.Mail.Queue/SquidStd.Mail.Queue.csproj new file mode 100644 index 00000000..9d1478e5 --- /dev/null +++ b/src/SquidStd.Mail.Queue/SquidStd.Mail.Queue.csproj @@ -0,0 +1,15 @@ + + + + net10.0 + enable + enable + true + + + + + + + + diff --git a/src/SquidStd.Messaging.Abstractions/Data/Config/MessagingConnectionString.cs b/src/SquidStd.Messaging.Abstractions/Data/Config/MessagingConnectionString.cs index 0f123b59..b54f257d 100644 --- a/src/SquidStd.Messaging.Abstractions/Data/Config/MessagingConnectionString.cs +++ b/src/SquidStd.Messaging.Abstractions/Data/Config/MessagingConnectionString.cs @@ -69,7 +69,11 @@ public static MessagingConnectionString 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); + .ToFrozenDictionary( + key => key!, + key => query[key] ?? string.Empty, + StringComparer.OrdinalIgnoreCase + ); return new( uri.Scheme, @@ -86,7 +90,8 @@ public static MessagingConnectionString Parse(string connectionString) public MessagingOptions ToMessagingOptions() => new() { - MaxDeliveryAttempts = Parameters.TryGetValue("maxDeliveryAttempts", out var max) && int.TryParse(max, out var parsedMax) + MaxDeliveryAttempts = Parameters.TryGetValue("maxDeliveryAttempts", out var max) && + int.TryParse(max, out var parsedMax) ? parsedMax : 3, DeadLetterQueueSuffix = Parameters.TryGetValue("deadLetterSuffix", out var suffix) ? suffix : ".dlq", diff --git a/src/SquidStd.Messaging.Abstractions/Interfaces/IMessagingMetrics.cs b/src/SquidStd.Messaging.Abstractions/Interfaces/IMessagingMetrics.cs index f3058c03..4ebcdb4e 100644 --- a/src/SquidStd.Messaging.Abstractions/Interfaces/IMessagingMetrics.cs +++ b/src/SquidStd.Messaging.Abstractions/Interfaces/IMessagingMetrics.cs @@ -5,8 +5,8 @@ namespace SquidStd.Messaging.Abstractions.Interfaces; ///
public interface IMessagingMetrics { - /// Records a message published to a queue. - void OnPublished(string queueName); + /// Records a message moved to the dead-letter queue. + void OnDeadLettered(string queueName); /// Records a message delivered successfully. void OnDelivered(string queueName); @@ -14,12 +14,12 @@ public interface IMessagingMetrics /// Records a failed delivery attempt. void OnFailed(string queueName); + /// Records a message published to a queue. + void OnPublished(string queueName); + /// Records a retry of a message. void OnRetried(string queueName); - /// Records a message moved to the dead-letter queue. - void OnDeadLettered(string queueName); - /// Sets the current buffered depth of a queue. void SetQueueDepth(string queueName, int depth); diff --git a/src/SquidStd.Messaging.Abstractions/README.md b/src/SquidStd.Messaging.Abstractions/README.md index 62bf059d..6995e8d6 100644 --- a/src/SquidStd.Messaging.Abstractions/README.md +++ b/src/SquidStd.Messaging.Abstractions/README.md @@ -50,14 +50,14 @@ public async Task PublishAsync(IMessageQueue queue) ## Key types -| Type | Purpose | -|------|---------| -| `IMessageQueue` | Typed publish/subscribe facade. | -| `IQueueProvider` | Transport contract (per backend). | -| `IMessageSerializer` | Payload (de)serialization. | -| `IQueueMessageListener` | Subscriber callbacks. | -| `IMessagingMetrics` | Delivery metrics sink. | -| `MessagingOptions` | Delivery attempts, retry delay, and dead-letter-queue suffix. | +| Type | Purpose | +|----------------------------|---------------------------------------------------------------| +| `IMessageQueue` | Typed publish/subscribe facade. | +| `IQueueProvider` | Transport contract (per backend). | +| `IMessageSerializer` | Payload (de)serialization. | +| `IQueueMessageListener` | Subscriber callbacks. | +| `IMessagingMetrics` | Delivery metrics sink. | +| `MessagingOptions` | Delivery attempts, retry delay, and dead-letter-queue suffix. | ## License diff --git a/src/SquidStd.Messaging.Abstractions/Services/MessageQueue.cs b/src/SquidStd.Messaging.Abstractions/Services/MessageQueue.cs index 7bcaa5bd..298f2747 100644 --- a/src/SquidStd.Messaging.Abstractions/Services/MessageQueue.cs +++ b/src/SquidStd.Messaging.Abstractions/Services/MessageQueue.cs @@ -47,7 +47,10 @@ public IDisposable Subscribe(string queueName, IQueueMessageListenerAs return _provider.Subscribe( queueName, - (payload, cancellationToken) => listener.HandleAsync(_deserializer.Deserialize(payload), cancellationToken) + (payload, cancellationToken) => listener.HandleAsync( + _deserializer.Deserialize(payload), + cancellationToken + ) ); } } diff --git a/src/SquidStd.Messaging.Abstractions/Services/MessageTopic.cs b/src/SquidStd.Messaging.Abstractions/Services/MessageTopic.cs index f6c4a6cc..7677f737 100644 --- a/src/SquidStd.Messaging.Abstractions/Services/MessageTopic.cs +++ b/src/SquidStd.Messaging.Abstractions/Services/MessageTopic.cs @@ -29,6 +29,9 @@ public IDisposable Subscribe(string topic, Func handler(_deserializer.Deserialize(payload), cancellationToken)); + return _provider.Subscribe( + topic, + (payload, cancellationToken) => handler(_deserializer.Deserialize(payload), cancellationToken) + ); } } diff --git a/src/SquidStd.Messaging.Abstractions/Services/MessagingMetricsProvider.cs b/src/SquidStd.Messaging.Abstractions/Services/MessagingMetricsProvider.cs index ffefab6e..5d73279f 100644 --- a/src/SquidStd.Messaging.Abstractions/Services/MessagingMetricsProvider.cs +++ b/src/SquidStd.Messaging.Abstractions/Services/MessagingMetricsProvider.cs @@ -16,9 +16,43 @@ public sealed class MessagingMetricsProvider : IMessagingMetrics, IMetricProvide /// public string ProviderName => "messaging"; + private sealed class QueueCounters + { + public long Published; + public long Delivered; + public long Failed; + public long Retried; + public long DeadLettered; + public int Depth; + public int Subscribers; + } + /// - public void OnPublished(string queueName) - => Interlocked.Increment(ref Counters(queueName).Published); + 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("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); + } + + /// + public void OnDeadLettered(string queueName) + => Interlocked.Increment(ref Counters(queueName).DeadLettered); /// public void OnDelivered(string queueName) @@ -29,12 +63,12 @@ public void OnFailed(string queueName) => Interlocked.Increment(ref Counters(queueName).Failed); /// - public void OnRetried(string queueName) - => Interlocked.Increment(ref Counters(queueName).Retried); + public void OnPublished(string queueName) + => Interlocked.Increment(ref Counters(queueName).Published); /// - public void OnDeadLettered(string queueName) - => Interlocked.Increment(ref Counters(queueName).DeadLettered); + public void OnRetried(string queueName) + => Interlocked.Increment(ref Counters(queueName).Retried); /// public void SetQueueDepth(string queueName, int depth) @@ -44,38 +78,6 @@ public void SetQueueDepth(string queueName, int depth) public void SetSubscriberCount(string queueName, int count) => Volatile.Write(ref Counters(queueName).Subscribers, count); - /// - 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("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); - } - private QueueCounters Counters(string queueName) - => _queues.GetOrAdd(queueName, static _ => new QueueCounters()); - - private sealed class QueueCounters - { - public long Published; - public long Delivered; - public long Failed; - public long Retried; - public long DeadLettered; - public int Depth; - public int Subscribers; - } + => _queues.GetOrAdd(queueName, static _ => new()); } diff --git a/src/SquidStd.Messaging.Abstractions/Services/NoOpMessagingMetrics.cs b/src/SquidStd.Messaging.Abstractions/Services/NoOpMessagingMetrics.cs index b6c7bf71..49fbfb26 100644 --- a/src/SquidStd.Messaging.Abstractions/Services/NoOpMessagingMetrics.cs +++ b/src/SquidStd.Messaging.Abstractions/Services/NoOpMessagingMetrics.cs @@ -10,31 +10,17 @@ public sealed class NoOpMessagingMetrics : IMessagingMetrics /// Shared instance. public static NoOpMessagingMetrics Instance { get; } = new(); - public void OnPublished(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 OnRetried(string queueName) - { - } + public void OnPublished(string queueName) { } - public void OnDeadLettered(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 9cebd1f8..29481e60 100644 --- a/src/SquidStd.Messaging.Abstractions/Services/TopicEventBridge.cs +++ b/src/SquidStd.Messaging.Abstractions/Services/TopicEventBridge.cs @@ -21,5 +21,8 @@ public TopicEventBridge(IMessageTopic topic, IEventBus eventBus) /// public IDisposable Bridge(string topic) - => _topic.Subscribe(topic, (data, cancellationToken) => _eventBus.PublishAsync(new TopicMessageEvent(topic, data!), cancellationToken)); + => _topic.Subscribe( + topic, + (data, cancellationToken) => _eventBus.PublishAsync(new TopicMessageEvent(topic, data!), cancellationToken) + ); } diff --git a/src/SquidStd.Messaging.Abstractions/SquidStd.Messaging.Abstractions.csproj b/src/SquidStd.Messaging.Abstractions/SquidStd.Messaging.Abstractions.csproj index 5f9c6603..791744d4 100644 --- a/src/SquidStd.Messaging.Abstractions/SquidStd.Messaging.Abstractions.csproj +++ b/src/SquidStd.Messaging.Abstractions/SquidStd.Messaging.Abstractions.csproj @@ -8,8 +8,8 @@ - - + + diff --git a/src/SquidStd.Messaging.RabbitMq/Extensions/RabbitMqMessagingRegistrationExtensions.cs b/src/SquidStd.Messaging.RabbitMq/Extensions/RabbitMqMessagingRegistrationExtensions.cs index 839e8ccf..9391f499 100644 --- a/src/SquidStd.Messaging.RabbitMq/Extensions/RabbitMqMessagingRegistrationExtensions.cs +++ b/src/SquidStd.Messaging.RabbitMq/Extensions/RabbitMqMessagingRegistrationExtensions.cs @@ -5,7 +5,6 @@ using SquidStd.Messaging.Abstractions.Data.Config; using SquidStd.Messaging.Abstractions.Interfaces; using SquidStd.Messaging.Abstractions.Services; - using SquidStd.Messaging.RabbitMq.Data.Config; using SquidStd.Messaging.RabbitMq.Services; @@ -78,7 +77,8 @@ public static IContainer AddRabbitMqMessaging(this IContainer container, string VirtualHost = cs.VirtualHost, UserName = cs.UserName ?? "guest", Password = cs.Password ?? "guest", - PrefetchCount = cs.Parameters.TryGetValue("prefetch", out var prefetch) && ushort.TryParse(prefetch, out var parsed) + PrefetchCount = cs.Parameters.TryGetValue("prefetch", out var prefetch) && + ushort.TryParse(prefetch, out var parsed) ? parsed : (ushort)10 }; diff --git a/src/SquidStd.Messaging.RabbitMq/README.md b/src/SquidStd.Messaging.RabbitMq/README.md index 4bf15d44..8c2fe3f3 100644 --- a/src/SquidStd.Messaging.RabbitMq/README.md +++ b/src/SquidStd.Messaging.RabbitMq/README.md @@ -44,11 +44,11 @@ await queue.PublishAsync("orders", new { Id = 1 }); ## Key types -| Type | Purpose | -|------|---------| +| Type | Purpose | +|-------------------------------------------|-------------------------------------------| | `RabbitMqMessagingRegistrationExtensions` | `AddRabbitMqMessaging(...)` registration. | -| `RabbitMqQueueProvider` | RabbitMQ-backed `IQueueProvider`. | -| `RabbitMqOptions` | Connection + prefetch configuration. | +| `RabbitMqQueueProvider` | RabbitMQ-backed `IQueueProvider`. | +| `RabbitMqOptions` | Connection + prefetch configuration. | ## License diff --git a/src/SquidStd.Messaging.RabbitMq/Services/RabbitMqQueueProvider.cs b/src/SquidStd.Messaging.RabbitMq/Services/RabbitMqQueueProvider.cs index 77c80894..af2e97a7 100644 --- a/src/SquidStd.Messaging.RabbitMq/Services/RabbitMqQueueProvider.cs +++ b/src/SquidStd.Messaging.RabbitMq/Services/RabbitMqQueueProvider.cs @@ -5,8 +5,8 @@ using SquidStd.Messaging.Abstractions.Data.Config; using SquidStd.Messaging.Abstractions.Interfaces; using SquidStd.Messaging.Abstractions.Services; - using SquidStd.Messaging.RabbitMq.Data.Config; + namespace SquidStd.Messaging.RabbitMq.Services; /// @@ -27,41 +27,134 @@ public sealed class RabbitMqQueueProvider : IQueueProvider private IChannel? _publishChannel; private int _disposed; - public RabbitMqQueueProvider(RabbitMqOptions options, MessagingOptions messagingOptions, IMessagingMetrics? metrics = null) + public RabbitMqQueueProvider( + RabbitMqOptions options, + MessagingOptions messagingOptions, + IMessagingMetrics? metrics = null + ) { _options = options; _messagingOptions = messagingOptions; _metrics = metrics ?? NoOpMessagingMetrics.Instance; } - /// - public async ValueTask StartAsync(CancellationToken cancellationToken = default) + private sealed class Subscription : IDisposable { - var factory = new ConnectionFactory { AutomaticRecoveryEnabled = true }; + 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; - if (_options.Uri is not null) + public Subscription( + RabbitMqQueueProvider provider, + IConnection connection, + string queueName, + Func, CancellationToken, Task> handler + ) { - factory.Uri = _options.Uri; + _provider = provider; + _connection = connection; + _queueName = queueName; + _handler = handler; } - else + + public void Dispose() { - factory.HostName = _options.HostName; - factory.Port = _options.Port; - factory.VirtualHost = _options.VirtualHost; - factory.UserName = _options.UserName; - factory.Password = _options.Password; + 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)) + ); } - _connection = await factory.CreateConnectionAsync(cancellationToken); - _publishChannel = await _connection.CreateChannelAsync(cancellationToken: cancellationToken); + 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 ValueTask StopAsync(CancellationToken cancellationToken = default) - => DisposeAsync(); + public async ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + if (_publishChannel is not null) + { + await _publishChannel.CloseAsync(); + await _publishChannel.DisposeAsync(); + } + + if (_connection is not null) + { + await _connection.CloseAsync(); + await _connection.DisposeAsync(); + } + + _publishLock.Dispose(); + } /// - public async Task PublishAsync(string queueName, ReadOnlyMemory payload, CancellationToken cancellationToken = default) + public async Task PublishAsync( + string queueName, + ReadOnlyMemory payload, + CancellationToken cancellationToken = default + ) { ArgumentException.ThrowIfNullOrWhiteSpace(queueName); var channel = _publishChannel ?? throw new InvalidOperationException("Provider not started."); @@ -75,12 +168,12 @@ public async Task PublishAsync(string queueName, ReadOnlyMemory payload, C try { await channel.BasicPublishAsync( - exchange: string.Empty, - routingKey: queueName, - mandatory: false, - basicProperties: properties, - body: payload, - cancellationToken: cancellationToken + string.Empty, + queueName, + false, + properties, + payload, + cancellationToken ); } finally @@ -91,6 +184,32 @@ await channel.BasicPublishAsync( _metrics.OnPublished(queueName); } + /// + public async ValueTask StartAsync(CancellationToken cancellationToken = default) + { + var factory = new ConnectionFactory { AutomaticRecoveryEnabled = true }; + + if (_options.Uri is not null) + { + factory.Uri = _options.Uri; + } + else + { + factory.HostName = _options.HostName; + factory.Port = _options.Port; + factory.VirtualHost = _options.VirtualHost; + factory.UserName = _options.UserName; + factory.Password = _options.Password; + } + + _connection = await factory.CreateConnectionAsync(cancellationToken); + _publishChannel = await _connection.CreateChannelAsync(cancellationToken: cancellationToken); + } + + /// + public ValueTask StopAsync(CancellationToken cancellationToken = default) + => DisposeAsync(); + /// public IDisposable Subscribe(string queueName, Func, CancellationToken, Task> handler) { @@ -119,11 +238,11 @@ private async Task EnsureTopologyAsync(IChannel channel, string queueName, Cance if (queueName.EndsWith(_messagingOptions.DeadLetterQueueSuffix, StringComparison.Ordinal)) { await channel.QueueDeclareAsync( - queue: queueName, - durable: true, - exclusive: false, - autoDelete: false, - arguments: new Dictionary { ["x-queue-type"] = "quorum" }, + queueName, + true, + false, + false, + new Dictionary { ["x-queue-type"] = "quorum" }, cancellationToken: cancellationToken ); @@ -133,20 +252,20 @@ await channel.QueueDeclareAsync( var deadLetterQueue = queueName + _messagingOptions.DeadLetterQueueSuffix; await channel.QueueDeclareAsync( - queue: deadLetterQueue, - durable: true, - exclusive: false, - autoDelete: false, - arguments: new Dictionary { ["x-queue-type"] = "quorum" }, + deadLetterQueue, + true, + false, + false, + new Dictionary { ["x-queue-type"] = "quorum" }, cancellationToken: cancellationToken ); await channel.QueueDeclareAsync( - queue: queueName, - durable: true, - exclusive: false, - autoDelete: false, - arguments: new Dictionary + queueName, + true, + false, + false, + new Dictionary { ["x-queue-type"] = "quorum", ["x-delivery-limit"] = _messagingOptions.MaxDeliveryAttempts, @@ -156,115 +275,4 @@ await channel.QueueDeclareAsync( cancellationToken: cancellationToken ); } - - /// - public async ValueTask DisposeAsync() - { - if (Interlocked.Exchange(ref _disposed, 1) != 0) - { - return; - } - - if (_publishChannel is not null) - { - await _publishChannel.CloseAsync(); - await _publishChannel.DisposeAsync(); - } - - if (_connection is not null) - { - await _connection.CloseAsync(); - await _connection.DisposeAsync(); - } - - _publishLock.Dispose(); - } - - 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 Start() - => StartAsync().GetAwaiter().GetResult(); - - 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, autoAck: false, consumer: consumer); - } - - private async Task OnReceivedAsync(object sender, BasicDeliverEventArgs args) - { - var channel = _channel!; - - try - { - await _handler(args.Body, CancellationToken.None); - await channel.BasicAckAsync(args.DeliveryTag, multiple: 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, multiple: false, requeue: true); - } - } - - 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)) - ); - } - } } diff --git a/src/SquidStd.Messaging.RabbitMq/Services/RabbitMqTopicProvider.cs b/src/SquidStd.Messaging.RabbitMq/Services/RabbitMqTopicProvider.cs index ccb60449..e77d0055 100644 --- a/src/SquidStd.Messaging.RabbitMq/Services/RabbitMqTopicProvider.cs +++ b/src/SquidStd.Messaging.RabbitMq/Services/RabbitMqTopicProvider.cs @@ -26,91 +26,84 @@ public RabbitMqTopicProvider(RabbitMqOptions options) _options = options; } - /// - public async ValueTask StartAsync(CancellationToken cancellationToken = default) + private sealed class Subscription : IDisposable { - var factory = new ConnectionFactory { AutomaticRecoveryEnabled = true }; + 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; - if (_options.Uri is not null) - { - factory.Uri = _options.Uri; - } - else + public Subscription( + RabbitMqTopicProvider provider, + IConnection connection, + string topic, + Func, CancellationToken, Task> handler + ) { - factory.HostName = _options.HostName; - factory.Port = _options.Port; - factory.VirtualHost = _options.VirtualHost; - factory.UserName = _options.UserName; - factory.Password = _options.Password; + _provider = provider; + _connection = connection; + _topic = topic; + _handler = handler; } - _connection = await factory.CreateConnectionAsync(cancellationToken); - _publishChannel = await _connection.CreateChannelAsync(cancellationToken: cancellationToken); - } - - /// - public ValueTask StopAsync(CancellationToken cancellationToken = default) - => DisposeAsync(); - - /// - public async Task PublishAsync(string topic, ReadOnlyMemory payload, CancellationToken cancellationToken = default) - { - ArgumentException.ThrowIfNullOrWhiteSpace(topic); - var channel = _publishChannel ?? throw new InvalidOperationException("Provider not started."); + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } - await EnsureExchangeAsync(channel, topic, cancellationToken); + if (_channel is not null) + { + try + { + if (_consumerTag is not null) + { + _channel.BasicCancelAsync(_consumerTag).GetAwaiter().GetResult(); + } - var properties = new BasicProperties(); + _channel.CloseAsync().GetAwaiter().GetResult(); + _channel.DisposeAsync().AsTask().GetAwaiter().GetResult(); + } + catch + { + // Best-effort teardown. + } + } + } - await _publishLock.WaitAsync(cancellationToken); + public void Start() + => StartAsync().GetAwaiter().GetResult(); - try + private async Task OnReceivedAsync(object sender, BasicDeliverEventArgs args) { - await channel.BasicPublishAsync( - exchange: topic, - routingKey: string.Empty, - mandatory: false, - basicProperties: properties, - body: payload, - cancellationToken: cancellationToken - ); + try + { + await _handler(args.Body, CancellationToken.None); + } + catch (Exception ex) + { + _provider._logger.Warning(ex, "RabbitMq topic '{Topic}' handler failed", _topic); + } } - finally + + private async Task StartAsync() { - _publishLock.Release(); - } - } + _channel = await _connection.CreateChannelAsync(); - /// - public IDisposable Subscribe(string topic, Func, CancellationToken, Task> handler) - { - ArgumentException.ThrowIfNullOrWhiteSpace(topic); - ArgumentNullException.ThrowIfNull(handler); + await _channel.ExchangeDeclareAsync(_topic, ExchangeType.Fanout, false, false); - var connection = _connection ?? throw new InvalidOperationException("Provider not started."); - var subscription = new Subscription(this, connection, topic, handler); - subscription.Start(); + var queue = await _channel.QueueDeclareAsync(string.Empty, false, true, true); + await _channel.QueueBindAsync(queue.QueueName, _topic, string.Empty); - return subscription; - } + var consumer = new AsyncEventingBasicConsumer(_channel); + consumer.ReceivedAsync += OnReceivedAsync; - private async Task EnsureExchangeAsync(IChannel channel, string topic, CancellationToken cancellationToken) - { - lock (_exchangeSync) - { - if (!_declared.Add(topic)) - { - return; - } + _consumerTag = await _channel.BasicConsumeAsync(queue.QueueName, true, consumer); } - - await channel.ExchangeDeclareAsync( - exchange: topic, - type: ExchangeType.Fanout, - durable: false, - autoDelete: false, - cancellationToken: cancellationToken - ); } /// @@ -136,83 +129,90 @@ public async ValueTask DisposeAsync() _publishLock.Dispose(); } - private sealed class Subscription : IDisposable + /// + public async Task PublishAsync(string topic, ReadOnlyMemory payload, CancellationToken cancellationToken = default) { - 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; + ArgumentException.ThrowIfNullOrWhiteSpace(topic); + var channel = _publishChannel ?? throw new InvalidOperationException("Provider not started."); - public Subscription( - RabbitMqTopicProvider provider, - IConnection connection, - string topic, - Func, CancellationToken, Task> handler - ) + await EnsureExchangeAsync(channel, topic, cancellationToken); + + var properties = new BasicProperties(); + + await _publishLock.WaitAsync(cancellationToken); + + try { - _provider = provider; - _connection = connection; - _topic = topic; - _handler = handler; + await channel.BasicPublishAsync( + topic, + string.Empty, + false, + properties, + payload, + cancellationToken + ); + } + finally + { + _publishLock.Release(); } + } - public void Start() - => StartAsync().GetAwaiter().GetResult(); + /// + public async ValueTask StartAsync(CancellationToken cancellationToken = default) + { + var factory = new ConnectionFactory { AutomaticRecoveryEnabled = true }; - private async Task StartAsync() + if (_options.Uri is not null) { - _channel = await _connection.CreateChannelAsync(); + factory.Uri = _options.Uri; + } + else + { + factory.HostName = _options.HostName; + factory.Port = _options.Port; + factory.VirtualHost = _options.VirtualHost; + factory.UserName = _options.UserName; + factory.Password = _options.Password; + } - await _channel.ExchangeDeclareAsync(_topic, ExchangeType.Fanout, durable: false, autoDelete: false); + _connection = await factory.CreateConnectionAsync(cancellationToken); + _publishChannel = await _connection.CreateChannelAsync(cancellationToken: cancellationToken); + } - var queue = await _channel.QueueDeclareAsync(queue: string.Empty, durable: false, exclusive: true, autoDelete: true); - await _channel.QueueBindAsync(queue: queue.QueueName, exchange: _topic, routingKey: string.Empty); + /// + public ValueTask StopAsync(CancellationToken cancellationToken = default) + => DisposeAsync(); - var consumer = new AsyncEventingBasicConsumer(_channel); - consumer.ReceivedAsync += OnReceivedAsync; + /// + public IDisposable Subscribe(string topic, Func, CancellationToken, Task> handler) + { + ArgumentException.ThrowIfNullOrWhiteSpace(topic); + ArgumentNullException.ThrowIfNull(handler); - _consumerTag = await _channel.BasicConsumeAsync(queue.QueueName, autoAck: true, consumer: consumer); - } + var connection = _connection ?? throw new InvalidOperationException("Provider not started."); + var subscription = new Subscription(this, connection, topic, handler); + subscription.Start(); - 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); - } - } + return subscription; + } - public void Dispose() + private async Task EnsureExchangeAsync(IChannel channel, string topic, CancellationToken cancellationToken) + { + lock (_exchangeSync) { - if (Interlocked.Exchange(ref _disposed, 1) != 0) + if (!_declared.Add(topic)) { 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. - } - } } + + await channel.ExchangeDeclareAsync( + topic, + ExchangeType.Fanout, + false, + false, + cancellationToken: cancellationToken + ); } } diff --git a/src/SquidStd.Messaging.RabbitMq/SquidStd.Messaging.RabbitMq.csproj b/src/SquidStd.Messaging.RabbitMq/SquidStd.Messaging.RabbitMq.csproj index f63ef6a3..bfb50afe 100644 --- a/src/SquidStd.Messaging.RabbitMq/SquidStd.Messaging.RabbitMq.csproj +++ b/src/SquidStd.Messaging.RabbitMq/SquidStd.Messaging.RabbitMq.csproj @@ -8,12 +8,12 @@ - + - - + + diff --git a/src/SquidStd.Messaging/Internal/InMemoryQueue.cs b/src/SquidStd.Messaging/Internal/InMemoryQueue.cs index 8ae6a7c3..ed926dae 100644 --- a/src/SquidStd.Messaging/Internal/InMemoryQueue.cs +++ b/src/SquidStd.Messaging/Internal/InMemoryQueue.cs @@ -13,19 +13,11 @@ internal sealed class InMemoryQueue private int _depth; public Channel Channel { get; } = System.Threading.Channels.Channel.CreateUnbounded( - new UnboundedChannelOptions { SingleReader = true, SingleWriter = false } + new() { SingleReader = true, SingleWriter = false } ); public Task? ConsumerLoop { get; set; } - /// Increments the buffered depth and returns the new value. - public int IncrementDepth() - => Interlocked.Increment(ref _depth); - - /// Decrements the buffered depth and returns the new value. - public int DecrementDepth() - => Interlocked.Decrement(ref _depth); - public int HandlerCount { get @@ -45,13 +37,13 @@ public void AddHandler(Func, CancellationToken, Task> handl } } - public void RemoveHandler(Func, CancellationToken, Task> handler) - { - lock (_handlerSync) - { - _handlers.Remove(handler); - } - } + /// Decrements the buffered depth and returns the new value. + public int DecrementDepth() + => Interlocked.Decrement(ref _depth); + + /// Increments the buffered depth and returns the new value. + public int IncrementDepth() + => Interlocked.Increment(ref _depth); /// Returns the next handler in round-robin order, or null when none are registered. public Func, CancellationToken, Task>? NextHandler() @@ -69,4 +61,12 @@ public void RemoveHandler(Func, CancellationToken, Task> ha return handler; } } + + public void RemoveHandler(Func, CancellationToken, Task> handler) + { + lock (_handlerSync) + { + _handlers.Remove(handler); + } + } } diff --git a/src/SquidStd.Messaging/README.md b/src/SquidStd.Messaging/README.md index b29aceb2..dec66618 100644 --- a/src/SquidStd.Messaging/README.md +++ b/src/SquidStd.Messaging/README.md @@ -45,10 +45,10 @@ await queue.PublishAsync("orders", new { Id = 1 }); ## Key types -| Type | Purpose | -|------|---------| +| Type | Purpose | +|-----------------------------------|-------------------------------------------| | `MessagingRegistrationExtensions` | `AddInMemoryMessaging(...)` registration. | -| `InMemoryQueueProvider` | Channel-based in-memory `IQueueProvider`. | +| `InMemoryQueueProvider` | Channel-based in-memory `IQueueProvider`. | ## License diff --git a/src/SquidStd.Messaging/Services/InMemoryQueueProvider.cs b/src/SquidStd.Messaging/Services/InMemoryQueueProvider.cs index 82bf7c66..2ca77098 100644 --- a/src/SquidStd.Messaging/Services/InMemoryQueueProvider.cs +++ b/src/SquidStd.Messaging/Services/InMemoryQueueProvider.cs @@ -21,7 +21,11 @@ public sealed class InMemoryQueueProvider : IQueueProvider private readonly TimeProvider _timeProvider; private int _disposed; - public InMemoryQueueProvider(MessagingOptions options, IMessagingMetrics? metrics = null, TimeProvider? timeProvider = null) + public InMemoryQueueProvider( + MessagingOptions options, + IMessagingMetrics? metrics = null, + TimeProvider? timeProvider = null + ) { ArgumentNullException.ThrowIfNull(options); @@ -30,13 +34,71 @@ public InMemoryQueueProvider(MessagingOptions options, IMessagingMetrics? metric _timeProvider = timeProvider ?? TimeProvider.System; } - /// - public ValueTask StartAsync(CancellationToken cancellationToken = default) - => ValueTask.CompletedTask; + 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 ValueTask StopAsync(CancellationToken cancellationToken = default) - => DisposeAsync(); + public async ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + await _shutdownCts.CancelAsync(); + + foreach (var queue in _queues.Values) + { + queue.Channel.Writer.TryComplete(); + } + + foreach (var queue in _queues.Values) + { + if (queue.ConsumerLoop is not null) + { + try + { + await queue.ConsumerLoop; + } + catch + { + // Loop failures are already logged. + } + } + } + + _shutdownCts.Dispose(); + } /// public Task PublishAsync(string queueName, ReadOnlyMemory payload, CancellationToken cancellationToken = default) @@ -50,6 +112,14 @@ public Task PublishAsync(string queueName, ReadOnlyMemory payload, Cancell return Task.CompletedTask; } + /// + public ValueTask StartAsync(CancellationToken cancellationToken = default) + => ValueTask.CompletedTask; + + /// + public ValueTask StopAsync(CancellationToken cancellationToken = default) + => DisposeAsync(); + /// public IDisposable Subscribe(string queueName, Func, CancellationToken, Task> handler) { @@ -63,27 +133,6 @@ public IDisposable Subscribe(string queueName, Func, Cancel return new Subscription(this, queueName, queue, handler); } - private InMemoryQueue GetOrCreate(string queueName) - => _queues.GetOrAdd( - queueName, - name => - { - var queue = new InMemoryQueue(); - queue.ConsumerLoop = Task.Run(() => ConsumeLoopAsync(name, queue, _shutdownCts.Token), CancellationToken.None); - - return queue; - } - ); - - private void Enqueue(string queueName, QueuedMessage message) - => Write(GetOrCreate(queueName), queueName, message); - - private void Write(InMemoryQueue queue, string queueName, QueuedMessage message) - { - queue.Channel.Writer.TryWrite(message); - _metrics.SetQueueDepth(queueName, queue.IncrementDepth()); - } - private async Task ConsumeLoopAsync(string queueName, InMemoryQueue queue, CancellationToken cancellationToken) { try @@ -124,33 +173,23 @@ private async Task ConsumeLoopAsync(string queueName, InMemoryQueue queue, Cance } } - private async Task, CancellationToken, Task>?> WaitForHandlerAsync( - InMemoryQueue queue, - CancellationToken cancellationToken - ) - { - // Messages can arrive before any subscriber; wait until a handler exists. - while (!cancellationToken.IsCancellationRequested) - { - var handler = queue.NextHandler(); + private void Enqueue(string queueName, QueuedMessage message) + => Write(GetOrCreate(queueName), queueName, message); - if (handler is not null) + private InMemoryQueue GetOrCreate(string queueName) + => _queues.GetOrAdd( + queueName, + name => { - return handler; - } + var queue = new InMemoryQueue(); + queue.ConsumerLoop = Task.Run( + () => ConsumeLoopAsync(name, queue, _shutdownCts.Token), + CancellationToken.None + ); - try - { - await Task.Delay(TimeSpan.FromMilliseconds(10), _timeProvider, cancellationToken); - } - catch (OperationCanceledException) - { - return null; + return queue; } - } - - return null; - } + ); private void HandleFailure(string queueName, InMemoryQueue queue, QueuedMessage message, Exception exception) { @@ -165,7 +204,12 @@ private void HandleFailure(string queueName, InMemoryQueue queue, QueuedMessage return; } - _logger.Warning(exception, "Message dead-lettered on queue {QueueName} after {Attempts} attempts", queueName, nextAttempt); + _logger.Warning( + exception, + "Message dead-lettered on queue {QueueName} after {Attempts} attempts", + queueName, + nextAttempt + ); _metrics.OnDeadLettered(queueName); Enqueue(queueName + _options.DeadLetterQueueSuffix, new(message.Payload, 0)); } @@ -187,69 +231,37 @@ private async Task RequeueAsync(InMemoryQueue queue, string queueName, QueuedMes Write(queue, queueName, message); } - /// - public async ValueTask DisposeAsync() + private async Task, CancellationToken, Task>?> WaitForHandlerAsync( + InMemoryQueue queue, + CancellationToken cancellationToken + ) { - if (Interlocked.Exchange(ref _disposed, 1) != 0) + // Messages can arrive before any subscriber; wait until a handler exists. + while (!cancellationToken.IsCancellationRequested) { - return; - } - - await _shutdownCts.CancelAsync(); + var handler = queue.NextHandler(); - foreach (var queue in _queues.Values) - { - queue.Channel.Writer.TryComplete(); - } + if (handler is not null) + { + return handler; + } - foreach (var queue in _queues.Values) - { - if (queue.ConsumerLoop is not null) + try { - try - { - await queue.ConsumerLoop; - } - catch - { - // Loop failures are already logged. - } + await Task.Delay(TimeSpan.FromMilliseconds(10), _timeProvider, cancellationToken); + } + catch (OperationCanceledException) + { + return null; } } - _shutdownCts.Dispose(); + return null; } - private sealed class Subscription : IDisposable + private void Write(InMemoryQueue queue, string queueName, QueuedMessage message) { - 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); - } + queue.Channel.Writer.TryWrite(message); + _metrics.SetQueueDepth(queueName, queue.IncrementDepth()); } } diff --git a/src/SquidStd.Messaging/Services/InMemoryTopicProvider.cs b/src/SquidStd.Messaging/Services/InMemoryTopicProvider.cs index 3a950261..455c3c87 100644 --- a/src/SquidStd.Messaging/Services/InMemoryTopicProvider.cs +++ b/src/SquidStd.Messaging/Services/InMemoryTopicProvider.cs @@ -11,16 +11,51 @@ namespace SquidStd.Messaging.Services; public sealed class InMemoryTopicProvider : ITopicProvider { private readonly ILogger _logger = Log.ForContext(); - private readonly ConcurrentDictionary, CancellationToken, Task>>> _topics = new(StringComparer.Ordinal); + + private readonly + ConcurrentDictionary, CancellationToken, Task>>> + _topics = new(StringComparer.Ordinal); + private int _disposed; - /// - public ValueTask StartAsync(CancellationToken cancellationToken = default) - => ValueTask.CompletedTask; + 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 StopAsync(CancellationToken cancellationToken = default) - => DisposeAsync(); + public ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return ValueTask.CompletedTask; + } + + _topics.Clear(); + + return ValueTask.CompletedTask; + } /// public async Task PublishAsync(string topic, ReadOnlyMemory payload, CancellationToken cancellationToken = default) @@ -45,6 +80,14 @@ public async Task PublishAsync(string topic, ReadOnlyMemory payload, Cance } } + /// + public ValueTask StartAsync(CancellationToken cancellationToken = default) + => ValueTask.CompletedTask; + + /// + public ValueTask StopAsync(CancellationToken cancellationToken = default) + => DisposeAsync(); + /// public IDisposable Subscribe(string topic, Func, CancellationToken, Task> handler) { @@ -57,40 +100,4 @@ public IDisposable Subscribe(string topic, Func, Cancellati return new Subscription(handlers, id); } - - /// - public ValueTask DisposeAsync() - { - if (Interlocked.Exchange(ref _disposed, 1) != 0) - { - return ValueTask.CompletedTask; - } - - _topics.Clear(); - - return ValueTask.CompletedTask; - } - - 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.Messaging/SquidStd.Messaging.csproj b/src/SquidStd.Messaging/SquidStd.Messaging.csproj index 0d0fa10f..b6def0db 100644 --- a/src/SquidStd.Messaging/SquidStd.Messaging.csproj +++ b/src/SquidStd.Messaging/SquidStd.Messaging.csproj @@ -8,16 +8,16 @@ - - + + - + - + diff --git a/src/SquidStd.Network/Client/SquidStdTcpClient.cs b/src/SquidStd.Network/Client/SquidStdTcpClient.cs index b409dbb3..411f2236 100644 --- a/src/SquidStd.Network/Client/SquidStdTcpClient.cs +++ b/src/SquidStd.Network/Client/SquidStdTcpClient.cs @@ -288,6 +288,34 @@ 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(); + } + /// /// Returns a snapshot of recent received bytes from the circular history buffer. /// @@ -574,32 +602,4 @@ private void ReleasePendingBuffer() _pendingBuffer = null; _pendingLength = 0; } - - /// - 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(); - } } diff --git a/src/SquidStd.Network/Client/SquidStdUdpClient.cs b/src/SquidStd.Network/Client/SquidStdUdpClient.cs index bbd52973..463298e0 100644 --- a/src/SquidStd.Network/Client/SquidStdUdpClient.cs +++ b/src/SquidStd.Network/Client/SquidStdUdpClient.cs @@ -120,6 +120,32 @@ 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. /// @@ -240,30 +266,4 @@ private async Task ReceiveLoopAsync() await CloseAsync(CancellationToken.None); } } - - /// - 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(); - } } diff --git a/src/SquidStd.Network/README.md b/src/SquidStd.Network/README.md index cd7ac252..6a2cb3c6 100644 --- a/src/SquidStd.Network/README.md +++ b/src/SquidStd.Network/README.md @@ -43,14 +43,14 @@ await server.StopAsync(CancellationToken.None); ## Key types -| Type | Purpose | -|------|---------| -| `INetworkServer` | TCP/UDP server contract (`StartAsync`/`StopAsync`). | -| `INetworkConnection` | A single client connection. | -| `ISessionManager` | Tracks sessions and their typed state. | -| `INetFramer` | Splits the byte stream into messages. | -| `INetMiddleware` | Pipeline stage over inbound/outbound data. | -| `SpanReader` / `SpanWriter` | Allocation-free binary read/write. | +| Type | Purpose | +|-----------------------------|-----------------------------------------------------| +| `INetworkServer` | TCP/UDP server contract (`StartAsync`/`StopAsync`). | +| `INetworkConnection` | A single client connection. | +| `ISessionManager` | Tracks sessions and their typed state. | +| `INetFramer` | Splits the byte stream into messages. | +| `INetMiddleware` | Pipeline stage over inbound/outbound data. | +| `SpanReader` / `SpanWriter` | Allocation-free binary read/write. | ## License diff --git a/src/SquidStd.Network/Server/SquidStdUdpServer.cs b/src/SquidStd.Network/Server/SquidStdUdpServer.cs index f3868104..801e2005 100644 --- a/src/SquidStd.Network/Server/SquidStdUdpServer.cs +++ b/src/SquidStd.Network/Server/SquidStdUdpServer.cs @@ -107,6 +107,50 @@ public SquidStdUdpServer(IPEndPoint endPoint, bool bindAllInterfaces = true) _bindAllInterfaces = bindAllInterfaces; } + /// + public void Dispose() + => DisposeAsync().AsTask().GetAwaiter().GetResult(); + + /// + public async ValueTask DisposeAsync() + => await StopAsync(CancellationToken.None); + + /// + /// 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(ex)); + } + } + /// /// Starts listening, binding sockets and launching a receive loop per socket. Recreates the /// sockets on every call, so Stop/Start cycles are supported. @@ -268,38 +312,6 @@ private async Task ReceiveLoopAsync(UdpClient listener, CancellationToken cancel } } - /// - /// 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(ex)); - } - } - private IEnumerable ResolveBindEndPoints() { if (!_bindAllInterfaces) @@ -313,12 +325,4 @@ .. NetworkUtils.GetListeningAddresses(_endPoint) .Select(address => new IPEndPoint(address.Address, _endPoint.Port)) ]; } - - /// - public void Dispose() - => DisposeAsync().AsTask().GetAwaiter().GetResult(); - - /// - public async ValueTask DisposeAsync() - => await StopAsync(CancellationToken.None); } diff --git a/src/SquidStd.Network/Server/SquidTcpServer.cs b/src/SquidStd.Network/Server/SquidTcpServer.cs index 5456abcc..731a49fa 100644 --- a/src/SquidStd.Network/Server/SquidTcpServer.cs +++ b/src/SquidStd.Network/Server/SquidTcpServer.cs @@ -109,6 +109,14 @@ public SquidTcpServer AddMiddleware(INetMiddleware middleware) return this; } + /// + public void Dispose() + => DisposeAsync().AsTask().GetAwaiter().GetResult(); + + /// + public async ValueTask DisposeAsync() + => await StopAsync(CancellationToken.None); + /// /// Starts accepting clients. Recreates the listening socket on every call, /// so Stop/Start cycles are supported. @@ -304,12 +312,4 @@ private void WireClientEvents(SquidStdTcpClient client) OnClientDisconnect?.Invoke(this, args); }; } - - /// - public void Dispose() - => DisposeAsync().AsTask().GetAwaiter().GetResult(); - - /// - public async ValueTask DisposeAsync() - => await StopAsync(CancellationToken.None); } diff --git a/src/SquidStd.Network/Sessions/SessionManager.cs b/src/SquidStd.Network/Sessions/SessionManager.cs index 86a448e1..4f0d4b39 100644 --- a/src/SquidStd.Network/Sessions/SessionManager.cs +++ b/src/SquidStd.Network/Sessions/SessionManager.cs @@ -68,6 +68,19 @@ public Task DisconnectAsync(long sessionId, CancellationToken cancellationToken ? 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(); + } + /// public Task SendAsync(long sessionId, ReadOnlyMemory payload, CancellationToken cancellationToken = default) => _sessions.TryGetValue(sessionId, out var session) @@ -173,17 +186,4 @@ CancellationToken cancellationToken _logger.Warning(ex, "Broadcast send failed for session {SessionId}", session.SessionId); } } - - public void Dispose() - { - if (Interlocked.Exchange(ref _disposed, 1) != 0) - { - return; - } - - _server.OnClientConnect -= HandleServerClientConnect; - _server.OnClientDisconnect -= HandleServerClientDisconnect; - _server.OnDataReceived -= HandleServerDataReceived; - _sessions.Clear(); - } } diff --git a/src/SquidStd.Network/Sessions/UdpSessionConnection.cs b/src/SquidStd.Network/Sessions/UdpSessionConnection.cs index 35755e24..9bfa8fb8 100644 --- a/src/SquidStd.Network/Sessions/UdpSessionConnection.cs +++ b/src/SquidStd.Network/Sessions/UdpSessionConnection.cs @@ -27,9 +27,6 @@ public UdpSessionConnection(SquidStdUdpServer server, IPEndPoint remoteEndPoint, _onClose = onClose; } - public Task SendAsync(ReadOnlyMemory payload, CancellationToken cancellationToken) - => _server.SendToAsync(_remoteEndPoint, payload, cancellationToken); - public Task CloseAsync(CancellationToken cancellationToken = default) { if (Interlocked.Exchange(ref _closed, 1) == 0) @@ -39,4 +36,7 @@ public Task CloseAsync(CancellationToken cancellationToken = default) return Task.CompletedTask; } + + public Task SendAsync(ReadOnlyMemory payload, CancellationToken cancellationToken) + => _server.SendToAsync(_remoteEndPoint, payload, cancellationToken); } diff --git a/src/SquidStd.Network/Sessions/UdpSessionManager.cs b/src/SquidStd.Network/Sessions/UdpSessionManager.cs index 4ccd022b..818b1bf0 100644 --- a/src/SquidStd.Network/Sessions/UdpSessionManager.cs +++ b/src/SquidStd.Network/Sessions/UdpSessionManager.cs @@ -21,7 +21,6 @@ public sealed class UdpSessionManager : ISessionManager, IDispos private readonly Lock _createLock = new(); private readonly SquidStdUdpServer _server; private readonly Func _stateFactory; - private readonly TimeSpan _idleTimeout; private readonly TimeProvider _timeProvider; private readonly ITimer _sweepTimer; private long _sessionIdSequence; @@ -31,11 +30,10 @@ public sealed class UdpSessionManager : ISessionManager, IDispos public int Count => _byEndpoint.Count; /// - public IReadOnlyCollection> Sessions - => _byEndpoint.Values.Select(entry => entry.Session).ToArray(); + public IReadOnlyCollection> Sessions => _byEndpoint.Values.Select(entry => entry.Session).ToArray(); /// Idle period after which an inactive session is removed. - public TimeSpan IdleTimeout => _idleTimeout; + public TimeSpan IdleTimeout { get; } /// public event EventHandler>? OnSessionCreated; @@ -59,7 +57,7 @@ public UdpSessionManager( _server = server; _stateFactory = stateFactory; - _idleTimeout = idleTimeout ?? TimeSpan.FromSeconds(30); + IdleTimeout = idleTimeout ?? TimeSpan.FromSeconds(30); _timeProvider = timeProvider ?? TimeProvider.System; // Suppress the server's default echo while sessions are managed here. @@ -71,33 +69,42 @@ public UdpSessionManager( } /// - public bool TryGetSession(long sessionId, out Session? session) + public async Task BroadcastAsync(ReadOnlyMemory payload, CancellationToken cancellationToken = default) { - if (_byId.TryGetValue(sessionId, out var endPoint) && _byEndpoint.TryGetValue(endPoint, out var entry)) - { - session = entry.Session; + var snapshot = _byEndpoint.Values.ToArray(); + var tasks = new List(snapshot.Length); - return true; + foreach (var entry in snapshot) + { + tasks.Add(SendSafelyAsync(entry.Session, payload, cancellationToken)); } - session = null; - - return false; + await Task.WhenAll(tasks); } - /// Looks up a session by remote endpoint. - public bool TryGetSession(IPEndPoint endPoint, out Session? session) + /// + 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 (_byEndpoint.TryGetValue(endPoint, out var entry)) + if (Interlocked.Exchange(ref _disposed, 1) != 0) { - session = entry.Session; - - return true; + return; } - session = null; - - return false; + _server.OnDatagramReceived -= HandleServerDatagram; + _sweepTimer.Dispose(); + _byEndpoint.Clear(); + _byId.Clear(); } /// @@ -113,30 +120,34 @@ public Task SendToAsync(IPEndPoint endPoint, ReadOnlyMemory payload, Cance : Task.CompletedTask; /// - public async Task BroadcastAsync(ReadOnlyMemory payload, CancellationToken cancellationToken = default) + public bool TryGetSession(long sessionId, out Session? session) { - var snapshot = _byEndpoint.Values.ToArray(); - var tasks = new List(snapshot.Length); - - foreach (var entry in snapshot) + if (_byId.TryGetValue(sessionId, out var endPoint) && _byEndpoint.TryGetValue(endPoint, out var entry)) { - tasks.Add(SendSafelyAsync(entry.Session, payload, cancellationToken)); + session = entry.Session; + + return true; } - await Task.WhenAll(tasks); + session = null; + + return false; } - /// - public Task DisconnectAsync(long sessionId, CancellationToken cancellationToken = default) - => TryGetSession(sessionId, out var session) - ? session!.CloseAsync(cancellationToken) - : Task.CompletedTask; + /// Looks up a session by remote endpoint. + public bool TryGetSession(IPEndPoint endPoint, out Session? session) + { + if (_byEndpoint.TryGetValue(endPoint, out var entry)) + { + session = entry.Session; - /// 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; + return true; + } + + session = null; + + return false; + } internal void HandleDatagram(IPEndPoint remoteEndPoint, ReadOnlyMemory data) { @@ -157,7 +168,7 @@ internal void SweepExpiredSessions() foreach (var kvp in _byEndpoint.ToArray()) { - if (now - kvp.Value.LastActivityUtc > _idleTimeout) + if (now - kvp.Value.LastActivityUtc > IdleTimeout) { RemoveSession(kvp.Key); } @@ -183,7 +194,12 @@ private UdpSessionEntry GetOrCreate(IPEndPoint remoteEndPoint, out bool } var sessionId = Interlocked.Increment(ref _sessionIdSequence); - var connection = new UdpSessionConnection(_server, remoteEndPoint, sessionId, () => RemoveSession(remoteEndPoint)); + var connection = new UdpSessionConnection( + _server, + remoteEndPoint, + sessionId, + () => RemoveSession(remoteEndPoint) + ); var session = new Session(sessionId, connection, _stateFactory(connection), _timeProvider.GetUtcNow()); var entry = new UdpSessionEntry(session, _timeProvider.GetUtcNow()); @@ -195,88 +211,79 @@ private UdpSessionEntry GetOrCreate(IPEndPoint remoteEndPoint, out bool } } - private void RemoveSession(IPEndPoint endPoint) - { - if (_byEndpoint.TryRemove(endPoint, out var entry)) - { - _byId.TryRemove(entry.Session.SessionId, out _); - RaiseSessionRemoved(entry.Session); - } - } + private void HandleServerDatagram(object? sender, SquidStdUdpDatagramReceivedEventArgs e) + => HandleDatagram(e.RemoteEndPoint, e.Data); - private void SafeSweep() + private void RaiseSessionCreated(Session session) { try { - SweepExpiredSessions(); + OnSessionCreated?.Invoke(this, new(session)); } catch (Exception ex) { - _logger.Error(ex, "UDP session sweep failed"); + _logger.Error(ex, "OnSessionCreated handler failed for session {SessionId}", session.SessionId); } } - private async Task SendSafelyAsync(Session session, ReadOnlyMemory payload, CancellationToken cancellationToken) + private void RaiseSessionData(Session session, ReadOnlyMemory data) { try { - await session.SendAsync(payload, cancellationToken); + OnSessionData?.Invoke(this, new(session, data)); } catch (Exception ex) { - _logger.Warning(ex, "Broadcast send failed for session {SessionId}", session.SessionId); + _logger.Error(ex, "OnSessionData handler failed for session {SessionId}", session.SessionId); } } - private void HandleServerDatagram(object? sender, SquidStdUdpDatagramReceivedEventArgs e) - => HandleDatagram(e.RemoteEndPoint, e.Data); - - private void RaiseSessionCreated(Session session) + private void RaiseSessionRemoved(Session session) { try { - OnSessionCreated?.Invoke(this, new(session)); + OnSessionRemoved?.Invoke(this, new(session)); } catch (Exception ex) { - _logger.Error(ex, "OnSessionCreated handler failed for session {SessionId}", session.SessionId); + _logger.Error(ex, "OnSessionRemoved handler failed for session {SessionId}", session.SessionId); } } - private void RaiseSessionRemoved(Session session) + private void RemoveSession(IPEndPoint endPoint) { - try - { - OnSessionRemoved?.Invoke(this, new(session)); - } - catch (Exception ex) + if (_byEndpoint.TryRemove(endPoint, out var entry)) { - _logger.Error(ex, "OnSessionRemoved handler failed for session {SessionId}", session.SessionId); + _byId.TryRemove(entry.Session.SessionId, out _); + RaiseSessionRemoved(entry.Session); } } - private void RaiseSessionData(Session session, ReadOnlyMemory data) + private void SafeSweep() { try { - OnSessionData?.Invoke(this, new(session, data)); + SweepExpiredSessions(); } catch (Exception ex) { - _logger.Error(ex, "OnSessionData handler failed for session {SessionId}", session.SessionId); + _logger.Error(ex, "UDP session sweep failed"); } } - public void Dispose() + private async Task SendSafelyAsync( + Session session, + ReadOnlyMemory payload, + CancellationToken cancellationToken + ) { - if (Interlocked.Exchange(ref _disposed, 1) != 0) + try { - return; + await session.SendAsync(payload, cancellationToken); + } + catch (Exception ex) + { + _logger.Warning(ex, "Broadcast send failed for session {SessionId}", session.SessionId); } - - _server.OnDatagramReceived -= HandleServerDatagram; - _sweepTimer.Dispose(); - _byEndpoint.Clear(); - _byId.Clear(); } } diff --git a/src/SquidStd.Network/Spans/SpanReader.cs b/src/SquidStd.Network/Spans/SpanReader.cs index 3168a69e..dd89efb1 100644 --- a/src/SquidStd.Network/Spans/SpanReader.cs +++ b/src/SquidStd.Network/Spans/SpanReader.cs @@ -21,6 +21,13 @@ public SpanReader(ReadOnlySpan span) Length = span.Length; } + public void Dispose() + { + _buffer = default; + Position = 0; + Length = 0; + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] public int Read(scoped Span bytes) { @@ -391,11 +398,4 @@ private static int IndexOfTerminator(ReadOnlySpan span, int terminatorWidt [DoesNotReturn] private static void ThrowInsufficientData() => throw new InvalidOperationException("Insufficient data in buffer."); - - public void Dispose() - { - _buffer = default; - Position = 0; - Length = 0; - } } diff --git a/src/SquidStd.Network/Spans/SpanWriter.cs b/src/SquidStd.Network/Spans/SpanWriter.cs index 1672a363..6f9bf8e2 100644 --- a/src/SquidStd.Network/Spans/SpanWriter.cs +++ b/src/SquidStd.Network/Spans/SpanWriter.cs @@ -94,6 +94,18 @@ public void Clear(int count) Position += count; } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Dispose() + { + var toReturn = _arrayToReturnToPool; + this = default; + + if (toReturn is not null) + { + ArrayPool.Shared.Return(toReturn); + } + } + public void EnsureCapacity(int capacity) { if (capacity > _buffer.Length) @@ -430,16 +442,4 @@ private void GrowIfNeeded(int count) Grow(count); } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public void Dispose() - { - var toReturn = _arrayToReturnToPool; - this = default; - - if (toReturn is not null) - { - ArrayPool.Shared.Return(toReturn); - } - } } diff --git a/src/SquidStd.Plugin.Abstractions/README.md b/src/SquidStd.Plugin.Abstractions/README.md index f83a5a8e..57af815d 100644 --- a/src/SquidStd.Plugin.Abstractions/README.md +++ b/src/SquidStd.Plugin.Abstractions/README.md @@ -53,11 +53,11 @@ public sealed class MyPlugin : ISquidStdPlugin ## Key types -| Type | Purpose | -|------|---------| +| Type | Purpose | +|-------------------|-----------------------------------------------| | `ISquidStdPlugin` | Plugin entry point (`Metadata`, `Configure`). | -| `PluginMetadata` | Plugin identity and dependency declarations. | -| `PluginContext` | Shared boot data passed to the plugin. | +| `PluginMetadata` | Plugin identity and dependency declarations. | +| `PluginContext` | Shared boot data passed to the plugin. | ## License diff --git a/src/SquidStd.Scripting.Lua/Context/SquidStdScriptJsonContext.cs b/src/SquidStd.Scripting.Lua/Context/SquidStdScriptJsonContext.cs index 485076cb..d55ec080 100644 --- a/src/SquidStd.Scripting.Lua/Context/SquidStdScriptJsonContext.cs +++ b/src/SquidStd.Scripting.Lua/Context/SquidStdScriptJsonContext.cs @@ -3,15 +3,11 @@ 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/README.md b/src/SquidStd.Scripting.Lua/README.md index 7ee460dd..8c5b52ba 100644 --- a/src/SquidStd.Scripting.Lua/README.md +++ b/src/SquidStd.Scripting.Lua/README.md @@ -50,12 +50,12 @@ container.RegisterScriptModule(); ## Key types -| Type | Purpose | -|------|---------| -| `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. | -| `AddScriptModuleExtension` | `RegisterScriptModule()` / `RegisterLuaUserData()`. | +| Type | Purpose | +|-----------------------------------------------------|---------------------------------------------------------------------| +| `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. | +| `AddScriptModuleExtension` | `RegisterScriptModule()` / `RegisterLuaUserData()`. | ## License diff --git a/src/SquidStd.Scripting.Lua/Services/LuaScriptEngineService.cs b/src/SquidStd.Scripting.Lua/Services/LuaScriptEngineService.cs index 4afb9ff3..198d95b5 100644 --- a/src/SquidStd.Scripting.Lua/Services/LuaScriptEngineService.cs +++ b/src/SquidStd.Scripting.Lua/Services/LuaScriptEngineService.cs @@ -286,6 +286,36 @@ public void ClearScriptCache() _logger.Information("Script cache cleared"); } + /// + /// 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. /// @@ -579,13 +609,6 @@ public void Reset() _logger.Debug("Lua engine reset"); } - /// - /// Stops the script engine asynchronously. - /// - /// A task representing the asynchronous operation. - public ValueTask StopAsync(CancellationToken cancellationToken = default) - => ValueTask.CompletedTask; - /// /// Starts the script engine asynchronously. /// @@ -642,6 +665,13 @@ public async ValueTask StartAsync(CancellationToken cancellationToken = default) } } + /// + /// Stops the script engine asynchronously. + /// + /// A task representing the asynchronous operation. + public ValueTask StopAsync(CancellationToken cancellationToken = default) + => ValueTask.CompletedTask; + /// /// Converts a name to the script engine function name format. /// @@ -1465,34 +1495,4 @@ private async Task RegisterScriptModulesAsync(CancellationToken cancellationToke RegisterEnums(); } - - /// - /// 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; - } - } } diff --git a/src/SquidStd.Scripting.Lua/SquidStd.Scripting.Lua.csproj b/src/SquidStd.Scripting.Lua/SquidStd.Scripting.Lua.csproj index 57b7c331..7150ebf0 100644 --- a/src/SquidStd.Scripting.Lua/SquidStd.Scripting.Lua.csproj +++ b/src/SquidStd.Scripting.Lua/SquidStd.Scripting.Lua.csproj @@ -17,8 +17,8 @@ - - + + diff --git a/src/SquidStd.Search.Abstractions/Attributes/SearchIndexAttribute.cs b/src/SquidStd.Search.Abstractions/Attributes/SearchIndexAttribute.cs new file mode 100644 index 00000000..f1c689ec --- /dev/null +++ b/src/SquidStd.Search.Abstractions/Attributes/SearchIndexAttribute.cs @@ -0,0 +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}"). +/// +[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; + } +} diff --git a/src/SquidStd.Search.Abstractions/Exceptions/SearchException.cs b/src/SquidStd.Search.Abstractions/Exceptions/SearchException.cs new file mode 100644 index 00000000..69b187f7 --- /dev/null +++ b/src/SquidStd.Search.Abstractions/Exceptions/SearchException.cs @@ -0,0 +1,13 @@ +namespace SquidStd.Search.Abstractions.Exceptions; + +/// Thrown when Elasticsearch returns a non-successful response. +public sealed class SearchException : Exception +{ + /// Initializes the exception with a message. + public SearchException(string message) + : base(message) { } + + /// Initializes the exception with a message and inner exception. + public SearchException(string message, Exception innerException) + : base(message, innerException) { } +} diff --git a/src/SquidStd.Search.Abstractions/Interfaces/IIndexableEntity.cs b/src/SquidStd.Search.Abstractions/Interfaces/IIndexableEntity.cs new file mode 100644 index 00000000..4624c8a2 --- /dev/null +++ b/src/SquidStd.Search.Abstractions/Interfaces/IIndexableEntity.cs @@ -0,0 +1,11 @@ +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). +/// +public interface IIndexableEntity +{ + /// Stable document id within the index. + string IndexId { get; } +} diff --git a/src/SquidStd.Search.Abstractions/Interfaces/ISearchService.cs b/src/SquidStd.Search.Abstractions/Interfaces/ISearchService.cs new file mode 100644 index 00000000..be94562d --- /dev/null +++ b/src/SquidStd.Search.Abstractions/Interfaces/ISearchService.cs @@ -0,0 +1,25 @@ +namespace SquidStd.Search.Abstractions.Interfaces; + +/// +/// Indexes, deletes, and queries documents. +/// +public interface ISearchService +{ + /// Deletes a document by id. Returns false when it did not exist. + Task DeleteAsync(string indexId, bool refresh = false, CancellationToken cancellationToken = default) + where T : IIndexableEntity; + + /// Creates the index for if it does not exist (dynamic mapping). Idempotent. + Task EnsureIndexAsync(CancellationToken cancellationToken = default) where T : IIndexableEntity; + + /// Indexes (creates or replaces) a single document. + Task IndexAsync(T entity, bool refresh = false, CancellationToken cancellationToken = default) + where T : IIndexableEntity; + + /// Indexes many documents in one bulk request. + Task IndexManyAsync(IEnumerable entities, bool refresh = false, CancellationToken cancellationToken = default) + where T : IIndexableEntity; + + /// Returns a constrained translated to the Elasticsearch query DSL. + IQueryable Query() where T : IIndexableEntity; +} diff --git a/src/SquidStd.Search.Abstractions/README.md b/src/SquidStd.Search.Abstractions/README.md new file mode 100644 index 00000000..250d1877 --- /dev/null +++ b/src/SquidStd.Search.Abstractions/README.md @@ -0,0 +1,34 @@ +

+ SquidStd +

+ +

SquidStd.Search.Abstractions

+ +

+ NuGet + Downloads + docs + license +

+ +Search/indexing contracts for SquidStd: tag entities with `IIndexableEntity` + `[SearchIndex]` (with +`${VAR}` / `${VAR:-default}` environment expansion) and query them via `ISearchService`. + +## Install + +```bash +dotnet add package SquidStd.Search.Abstractions +``` + +## Key types + +| Type | Purpose | +|---------------------------|----------------------------------------------------------------| +| `IIndexableEntity` | Marks an entity indexable and supplies its `IndexId`. | +| `SearchIndexAttribute` | Declares the index name (env-expanded). | +| `ISearchService` | Index / delete / ensure-index / `Query()`. | +| `SearchIndexNameResolver` | Resolves the index name from the attribute/type + environment. | + +## License + +MIT — part of [SquidStd](https://github.com/tgiachi/squid-std). diff --git a/src/SquidStd.Search.Abstractions/Search/SearchIndexNameResolver.cs b/src/SquidStd.Search.Abstractions/Search/SearchIndexNameResolver.cs new file mode 100644 index 00000000..1a8b04f6 --- /dev/null +++ b/src/SquidStd.Search.Abstractions/Search/SearchIndexNameResolver.cs @@ -0,0 +1,54 @@ +using System.Text.RegularExpressions; +using SquidStd.Search.Abstractions.Attributes; + +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). +/// +public static partial class SearchIndexNameResolver +{ + /// Resolves the index name for . + public static string Resolve(Type type) + { + ArgumentNullException.ThrowIfNull(type); + + var template = type.GetCustomAttributes(typeof(SearchIndexAttribute), false) is { Length: > 0 } attributes && + attributes[0] is SearchIndexAttribute attribute + ? attribute.Name + : type.Name; + + return ExpandEnvironment(template).ToLowerInvariant(); + } + + private static string ExpandEnvironment(string template) + => PlaceholderRegex() + .Replace( + template, + match => + { + var name = match.Groups["name"].Value; + var hasDefault = match.Groups["default"].Success; + var value = Environment.GetEnvironmentVariable(name); + + if (!string.IsNullOrEmpty(value)) + { + return value; + } + + if (hasDefault) + { + return match.Groups["default"].Value; + } + + throw new InvalidOperationException( + $"Environment variable '{name}' is not set for index template '{template}'." + ); + } + ); + + [GeneratedRegex(@"\$\{(?[A-Za-z_][A-Za-z0-9_]*)(:-(?[^}]*))?\}")] + private static partial Regex PlaceholderRegex(); +} diff --git a/src/SquidStd.Search.Abstractions/SquidStd.Search.Abstractions.csproj b/src/SquidStd.Search.Abstractions/SquidStd.Search.Abstractions.csproj new file mode 100644 index 00000000..92ecae12 --- /dev/null +++ b/src/SquidStd.Search.Abstractions/SquidStd.Search.Abstractions.csproj @@ -0,0 +1,15 @@ + + + + net10.0 + enable + enable + true + + + + + + + + diff --git a/src/SquidStd.Search.Elasticsearch/Data/Config/ElasticsearchOptions.cs b/src/SquidStd.Search.Elasticsearch/Data/Config/ElasticsearchOptions.cs new file mode 100644 index 00000000..56b31d90 --- /dev/null +++ b/src/SquidStd.Search.Elasticsearch/Data/Config/ElasticsearchOptions.cs @@ -0,0 +1,23 @@ +namespace SquidStd.Search.Elasticsearch.Data.Config; + +/// Configuration for the Elasticsearch search provider. +public sealed class ElasticsearchOptions +{ + /// Cluster endpoint, e.g. http://localhost:9200. + public Uri Uri { get; set; } = new("http://localhost:9200"); + + /// Optional basic-auth username. + public string? Username { get; set; } + + /// Optional basic-auth password. + public string? Password { get; set; } + + /// Optional API key (Base64) used instead of basic auth. + public string? ApiKey { get; set; } + + /// Optional prefix prepended to every resolved index name. + public string? IndexPrefix { get; set; } + + /// When true, accepts self-signed/untrusted server certificates (tests/local only). + public bool AllowUntrustedCertificate { get; set; } +} diff --git a/src/SquidStd.Search.Elasticsearch/Extensions/SearchRegistrationExtensions.cs b/src/SquidStd.Search.Elasticsearch/Extensions/SearchRegistrationExtensions.cs new file mode 100644 index 00000000..0e1973e5 --- /dev/null +++ b/src/SquidStd.Search.Elasticsearch/Extensions/SearchRegistrationExtensions.cs @@ -0,0 +1,47 @@ +using DryIoc; +using Elastic.Clients.Elasticsearch; +using Elastic.Transport; +using SquidStd.Search.Abstractions.Interfaces; +using SquidStd.Search.Elasticsearch.Data.Config; +using SquidStd.Search.Elasticsearch.Services; + +namespace SquidStd.Search.Elasticsearch.Extensions; + +/// DryIoc registration helpers for the Elasticsearch search provider. +public static class SearchRegistrationExtensions +{ + /// Registers the Elasticsearch client, transport helper, and . + public static IContainer AddElasticsearch(this IContainer container, ElasticsearchOptions options) + { + ArgumentNullException.ThrowIfNull(container); + ArgumentNullException.ThrowIfNull(options); + + container.RegisterInstance(options); + container.RegisterDelegate(_ => CreateClient(options), Reuse.Singleton); + container.Register(Reuse.Singleton); + container.Register(Reuse.Singleton); + + return container; + } + + private static ElasticsearchClient CreateClient(ElasticsearchOptions options) + { + var settings = new ElasticsearchClientSettings(options.Uri); + + if (!string.IsNullOrWhiteSpace(options.ApiKey)) + { + settings = settings.Authentication(new ApiKey(options.ApiKey)); + } + else if (!string.IsNullOrWhiteSpace(options.Username)) + { + settings = settings.Authentication(new BasicAuthentication(options.Username, options.Password ?? string.Empty)); + } + + if (options.AllowUntrustedCertificate) + { + settings = settings.ServerCertificateValidationCallback((_, _, _, _) => true); + } + + return new(settings); + } +} diff --git a/src/SquidStd.Search.Elasticsearch/Linq/ElasticExpressionTranslator.cs b/src/SquidStd.Search.Elasticsearch/Linq/ElasticExpressionTranslator.cs new file mode 100644 index 00000000..0eff7e6f --- /dev/null +++ b/src/SquidStd.Search.Elasticsearch/Linq/ElasticExpressionTranslator.cs @@ -0,0 +1,248 @@ +using System.Linq.Expressions; +using System.Text.Json; +using System.Text.Json.Nodes; + +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 +/// . +/// +public static class ElasticExpressionTranslator +{ + private static readonly JsonSerializerOptions WebOptions = new(JsonSerializerDefaults.Web); + + /// Translates the query expression for an element type. + public static ElasticQuery Translate(Expression expression, Type elementType) + { + var result = new ElasticQuery(); + var must = new JsonArray(); + var sort = new JsonArray(); + + Walk(expression, must, sort, result); + + if (must.Count > 0) + { + result.Query = new() { ["bool"] = new JsonObject { ["must"] = must } }; + } + + if (sort.Count > 0) + { + result.Sort = sort; + } + + return result; + } + + private static object? EvaluateValue(Expression expression) + => expression is ConstantExpression constant + ? constant.Value + : Expression.Lambda(expression).Compile().DynamicInvoke(); + + private static string FieldName(MemberExpression member) + { + var parts = new Stack(); + Expression? current = member; + + while (current is MemberExpression m) + { + parts.Push(JsonNamingPolicy.CamelCase.ConvertName(m.Member.Name)); + current = m.Expression; + } + + return string.Join('.', parts); + } + + private static object? GetConstant(Expression expression) + => EvaluateValue(expression); + + private static bool IsParameterBound(Expression expression) + => expression switch + { + ParameterExpression => true, + MemberExpression m => m.Expression is not null && IsParameterBound(m.Expression), + _ => false + }; + + private static (MemberExpression Member, Expression Value) OrientMemberValue(BinaryExpression binary) + { + if (StripConvert(binary.Left) is MemberExpression leftMember && IsParameterBound(leftMember)) + { + return (leftMember, binary.Right); + } + + if (StripConvert(binary.Right) is MemberExpression rightMember && IsParameterBound(rightMember)) + { + return (rightMember, binary.Left); + } + + throw Unsupported(binary); + } + + private static JsonObject Range(string field, string op, JsonNode? value) + => new() { ["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 } }; + } + + private static Expression StripConvert(Expression expression) + => 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 } }; + } + + private static JsonNode? ToJsonValue(object? value) + => value is null ? null : JsonSerializer.SerializeToNode(value, WebOptions); + + private static JsonObject TranslateComparison(BinaryExpression binary) + { + var (member, valueExpr) = OrientMemberValue(binary); + var field = FieldName(member); + var value = ToJsonValue(EvaluateValue(valueExpr)); + + return binary.NodeType switch + { + ExpressionType.Equal => TermOrKeyword(field, member.Type, value), + ExpressionType.NotEqual => new() + { ["bool"] = new JsonObject { ["must_not"] = new JsonArray(TermOrKeyword(field, member.Type, value)) } }, + ExpressionType.GreaterThan => Range(field, "gt", value), + ExpressionType.GreaterThanOrEqual => Range(field, "gte", value), + ExpressionType.LessThan => Range(field, "lt", value), + ExpressionType.LessThanOrEqual => Range(field, "lte", value), + _ => throw Unsupported(binary) + }; + } + + private static JsonObject TranslatePredicate(Expression expression) + { + switch (expression) + { + case BinaryExpression { NodeType: ExpressionType.AndAlso } and1: + return new() + { + ["bool"] = new JsonObject + { ["must"] = new JsonArray(TranslatePredicate(and1.Left), TranslatePredicate(and1.Right)) } + }; + case BinaryExpression { NodeType: ExpressionType.OrElse } or1: + return new() + { + ["bool"] = new JsonObject + { + ["should"] = new JsonArray(TranslatePredicate(or1.Left), TranslatePredicate(or1.Right)), + ["minimum_should_match"] = 1 + } + }; + case UnaryExpression { NodeType: ExpressionType.Not } not: + return new() { ["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 } }; + case MethodCallExpression methodCall: + return TranslateStringMethod(methodCall); + default: + throw Unsupported(expression); + } + } + + private static JsonObject TranslateStringMethod(MethodCallExpression call) + { + if (call.Object is not MemberExpression member) + { + throw Unsupported(call); + } + + var field = FieldName(member); + var arg = (string)EvaluateValue(call.Arguments[0])!; + + return call.Method.Name switch + { + "Contains" => new() { ["wildcard"] = new JsonObject { [$"{field}.keyword"] = $"*{arg}*" } }, + "StartsWith" => new() { ["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; + + private static NotSupportedException Unsupported(Expression expression) + => new( + $"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) + { + if (expression is not MethodCallExpression call) + { + return; + } + + Walk(call.Arguments[0], must, sort, result); + + switch (call.Method.Name) + { + case "Where": + must.Add(TranslatePredicate(UnquoteLambda(call.Arguments[1]).Body)); + + break; + case "OrderBy": + case "ThenBy": + sort.Add(SortClause(call.Arguments[1], "asc")); + + break; + case "OrderByDescending": + case "ThenByDescending": + sort.Add(SortClause(call.Arguments[1], "desc")); + + break; + case "Skip": + result.From = (int)GetConstant(call.Arguments[1])!; + + break; + case "Take": + result.Size = (int)GetConstant(call.Arguments[1])!; + + break; + case "Match": + must.Add( + new JsonObject + { + ["match"] = new JsonObject + { + [(string)GetConstant(call.Arguments[1])!] = + JsonValue.Create((string)GetConstant(call.Arguments[2])!) + } + } + ); + + break; + case "FullText": + must.Add( + new JsonObject + { + ["query_string"] = new JsonObject + { ["query"] = JsonValue.Create((string)GetConstant(call.Arguments[1])!) } + } + ); + + break; + default: + throw new NotSupportedException( + $"LINQ operator '{call.Method.Name}' is not supported by the Elasticsearch provider. Use the native ElasticsearchClient for advanced queries." + ); + } + } +} diff --git a/src/SquidStd.Search.Elasticsearch/Linq/ElasticQuery.cs b/src/SquidStd.Search.Elasticsearch/Linq/ElasticQuery.cs new file mode 100644 index 00000000..aed5ae33 --- /dev/null +++ b/src/SquidStd.Search.Elasticsearch/Linq/ElasticQuery.cs @@ -0,0 +1,42 @@ +using System.Text.Json.Nodes; + +namespace SquidStd.Search.Elasticsearch.Linq; + +/// The translated Elasticsearch search request body plus paging/terminal intent. +public sealed class ElasticQuery +{ + /// The query clause (defaults to match_all). + public JsonObject Query { get; set; } = new() { ["match_all"] = new JsonObject() }; + + /// The sort array, or null. + public JsonArray? Sort { get; set; } + + /// The from offset, or null. + public int? From { get; set; } + + /// The size limit, or null. + public int? Size { get; set; } + + /// Builds the full request body JSON. + public JsonObject ToRequestBody() + { + var body = new JsonObject { ["query"] = Query.DeepClone() }; + + if (Sort is not null) + { + body["sort"] = Sort.DeepClone(); + } + + if (From is not null) + { + body["from"] = From.Value; + } + + if (Size is not null) + { + body["size"] = Size.Value; + } + + return body; + } +} diff --git a/src/SquidStd.Search.Elasticsearch/Linq/ElasticQueryProvider.cs b/src/SquidStd.Search.Elasticsearch/Linq/ElasticQueryProvider.cs new file mode 100644 index 00000000..93359d4d --- /dev/null +++ b/src/SquidStd.Search.Elasticsearch/Linq/ElasticQueryProvider.cs @@ -0,0 +1,81 @@ +using System.Linq.Expressions; +using System.Text.Json.Nodes; +using SquidStd.Search.Elasticsearch.Services; +using HttpMethod = Elastic.Transport.HttpMethod; + +namespace SquidStd.Search.Elasticsearch.Linq; + +/// +/// 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; + + public ElasticQueryProvider(ElasticTransport transport, string index, Type elementType) + { + _transport = transport; + _index = index; + _elementType = elementType; + } + + /// Runs a count and returns the total. + public async Task CountAsync(Expression expression, CancellationToken cancellationToken) + { + var query = ElasticExpressionTranslator.Translate(expression, _elementType); + var body = new JsonObject { ["query"] = query.Query.DeepClone() }; + var (status, response) = await _transport.SendAsync(HttpMethod.POST, $"/{_index}/_count", body, cancellationToken); + + 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 + ); + + if (status == 404) + { + return []; + } + + var hits = body?["hits"]?["hits"]?.AsArray(); + var results = new List(); + + if (hits is not null) + { + foreach (var hit in hits) + { + var source = hit?["_source"]; + + if (source is not null) + { + results.Add(ElasticTransport.DeserializeDocument(source)); + } + } + } + + return results; + } +} diff --git a/src/SquidStd.Search.Elasticsearch/Linq/ElasticQueryable.cs b/src/SquidStd.Search.Elasticsearch/Linq/ElasticQueryable.cs new file mode 100644 index 00000000..a736a6b3 --- /dev/null +++ b/src/SquidStd.Search.Elasticsearch/Linq/ElasticQueryable.cs @@ -0,0 +1,32 @@ +using System.Collections; +using System.Linq.Expressions; + +namespace SquidStd.Search.Elasticsearch.Linq; + +/// An backed by . +public sealed class ElasticQueryable : IOrderedQueryable +{ + public ElasticQueryable(ElasticQueryProvider provider, Expression expression) + { + Provider = provider; + Expression = expression; + } + + public ElasticQueryable(ElasticQueryProvider provider) + { + Provider = provider; + Expression = Expression.Constant(this); + } + + public Type ElementType => typeof(T); + + public Expression Expression { get; } + + public IQueryProvider Provider { get; } + + public IEnumerator GetEnumerator() + => throw new NotSupportedException("Use the async terminals (ToListAsync/CountAsync/FirstOrDefaultAsync)."); + + IEnumerator IEnumerable.GetEnumerator() + => GetEnumerator(); +} diff --git a/src/SquidStd.Search.Elasticsearch/Linq/ElasticQueryableExtensions.cs b/src/SquidStd.Search.Elasticsearch/Linq/ElasticQueryableExtensions.cs new file mode 100644 index 00000000..7df9b8d4 --- /dev/null +++ b/src/SquidStd.Search.Elasticsearch/Linq/ElasticQueryableExtensions.cs @@ -0,0 +1,56 @@ +using System.Linq.Expressions; +using System.Reflection; + +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. +/// +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); + + /// Executes the query and returns the first matching document, or null. + public static async Task FirstOrDefaultAsync( + this IQueryable source, + CancellationToken cancellationToken = default + ) + { + var limited = source.Take(1); + var results = await Provider(limited).ToListAsync(limited.Expression, cancellationToken); + + return results.Count > 0 ? results[0] : default; + } + + /// Full-text match of across all fields. + public static IQueryable FullText(this IQueryable source, string text) + => 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( + Expression.Call( + ((MethodInfo)MethodBase.GetCurrentMethod()!).MakeGenericMethod(typeof(T)), + source.Expression, + Expression.Constant(field), + 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); + + private static ElasticQueryProvider Provider(IQueryable source) + => source.Provider as ElasticQueryProvider ?? + throw new NotSupportedException("These async terminals require a query created by ISearchService.Query()."); +} diff --git a/src/SquidStd.Search.Elasticsearch/README.md b/src/SquidStd.Search.Elasticsearch/README.md new file mode 100644 index 00000000..5f75cf97 --- /dev/null +++ b/src/SquidStd.Search.Elasticsearch/README.md @@ -0,0 +1,51 @@ +

+ SquidStd +

+ +

SquidStd.Search.Elasticsearch

+ +

+ NuGet + Downloads + docs + license +

+ +Elasticsearch provider for SquidStd.Search. Indexes `IIndexableEntity` documents and exposes a constrained +LINQ `IQueryable` translated to the Elasticsearch query DSL; the native `ElasticsearchClient` is registered +for advanced queries. + +## Install + +```bash +dotnet add package SquidStd.Search.Elasticsearch +``` + +## Usage + +```csharp +using DryIoc; +using SquidStd.Search.Abstractions.Interfaces; +using SquidStd.Search.Elasticsearch.Data.Config; +using SquidStd.Search.Elasticsearch.Extensions; +using SquidStd.Search.Elasticsearch.Linq; + +container.AddElasticsearch(new ElasticsearchOptions { Uri = new Uri("http://localhost:9200") }); + +var search = container.Resolve(); +await search.IndexAsync(new Order("1", "open", 150), refresh: true); + +var open = await search.Query() + .Where(o => o.Status == "open" && o.Total > 100) + .OrderByDescending(o => o.Total) + .Take(20) + .ToListAsync(); +``` + +Supported LINQ: `Where` (`==`, `!=`, `<`, `>`, `<=`, `>=`, `&&`, `||`, `!`, `string.Contains`/`StartsWith`, +bool members), `OrderBy`/`ThenBy`(`Descending`), `Skip`/`Take`, and `.Match(field, text)` / `.FullText(text)`. +Anything else throws `NotSupportedException` — drop down to the native `ElasticsearchClient`. + +## License + +MIT — part of [SquidStd](https://github.com/tgiachi/squid-std). diff --git a/src/SquidStd.Search.Elasticsearch/Services/ElasticSearchService.cs b/src/SquidStd.Search.Elasticsearch/Services/ElasticSearchService.cs new file mode 100644 index 00000000..3be78e80 --- /dev/null +++ b/src/SquidStd.Search.Elasticsearch/Services/ElasticSearchService.cs @@ -0,0 +1,158 @@ +using System.Text; +using System.Text.Json.Nodes; +using Serilog; +using SquidStd.Search.Abstractions.Exceptions; +using SquidStd.Search.Abstractions.Interfaces; +using SquidStd.Search.Abstractions.Search; +using SquidStd.Search.Elasticsearch.Data.Config; +using SquidStd.Search.Elasticsearch.Linq; +using HttpMethod = Elastic.Transport.HttpMethod; + +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. +/// +public sealed class ElasticSearchService : ISearchService +{ + private readonly ILogger _logger = Log.ForContext(); + private readonly ElasticTransport _transport; + private readonly string? _indexPrefix; + + public ElasticSearchService(ElasticTransport transport, ElasticsearchOptions options) + { + _transport = transport; + _indexPrefix = options.IndexPrefix; + } + + /// + public async Task DeleteAsync( + string indexId, + bool refresh = false, + CancellationToken cancellationToken = default + ) + where T : IIndexableEntity + { + ArgumentException.ThrowIfNullOrWhiteSpace(indexId); + + var index = ResolveIndex(); + var path = $"/{index}/_doc/{Uri.EscapeDataString(indexId)}{RefreshQuery(refresh)}"; + var (status, body) = await _transport.SendAsync(HttpMethod.DELETE, path, null, cancellationToken); + + if (status == 404) + { + return false; + } + + EnsureSuccess(status, body, $"delete document '{indexId}' from '{index}'"); + + return body?["result"]?.GetValue() == "deleted"; + } + + /// + public async Task EnsureIndexAsync(CancellationToken cancellationToken = default) + where T : IIndexableEntity + { + var index = ResolveIndex(); + var (existsStatus, _) = await _transport.SendAsync(HttpMethod.HEAD, $"/{index}", null, cancellationToken); + + if (existsStatus == 200) + { + return; + } + + var (status, body) = await _transport.SendAsync(HttpMethod.PUT, $"/{index}", null, cancellationToken); + + if (status == 400 && body?["error"]?["type"]?.GetValue() == "resource_already_exists_exception") + { + return; + } + + EnsureSuccess(status, body, $"create index '{index}'"); + } + + /// + public async Task IndexAsync(T entity, bool refresh = false, CancellationToken cancellationToken = default) + where T : IIndexableEntity + { + ArgumentNullException.ThrowIfNull(entity); + ArgumentException.ThrowIfNullOrWhiteSpace(entity.IndexId); + + 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 + ); + + EnsureSuccess(status, body, $"index document '{entity.IndexId}' into '{index}'"); + } + + /// + public async Task IndexManyAsync( + IEnumerable entities, + bool refresh = false, + CancellationToken cancellationToken = default + ) + where T : IIndexableEntity + { + ArgumentNullException.ThrowIfNull(entities); + + var index = ResolveIndex(); + var lines = new StringBuilder(); + + foreach (var entity in entities) + { + ArgumentException.ThrowIfNullOrWhiteSpace(entity.IndexId); + var action = new JsonObject { ["index"] = new JsonObject { ["_id"] = entity.IndexId } }; + lines.Append(action.ToJsonString()).Append('\n'); + lines.Append(ElasticTransport.SerializeDocument(entity).ToJsonString()).Append('\n'); + } + + if (lines.Length == 0) + { + return; + } + + var path = $"/{index}/_bulk{RefreshQuery(refresh)}"; + var (status, body) = await _transport.SendRawAsync(HttpMethod.POST, path, lines.ToString(), cancellationToken); + + EnsureSuccess(status, body, $"bulk index into '{index}'"); + + if (body?["errors"]?.GetValue() == true) + { + throw new SearchException($"Bulk index into '{index}' reported item errors."); + } + } + + /// + public IQueryable Query() where T : IIndexableEntity + => new ElasticQueryable(new(_transport, ResolveIndex(), typeof(T))); + + /// Resolves the (prefixed, lowercased) index name for a type. + public string ResolveIndex() + { + var name = SearchIndexNameResolver.Resolve(typeof(T)); + + return string.IsNullOrWhiteSpace(_indexPrefix) ? name : $"{_indexPrefix}{name}".ToLowerInvariant(); + } + + private void EnsureSuccess(int status, JsonNode? body, string operation) + { + if (status is >= 200 and < 300) + { + return; + } + + var reason = body?["error"]?["reason"]?.GetValue() ?? $"HTTP {status}"; + _logger.Error("Elasticsearch failed to {Operation}: {Reason}", operation, reason); + + throw new SearchException($"Elasticsearch failed to {operation}: {reason}"); + } + + private static string RefreshQuery(bool refresh) + => refresh ? "?refresh=wait_for" : string.Empty; +} diff --git a/src/SquidStd.Search.Elasticsearch/Services/ElasticTransport.cs b/src/SquidStd.Search.Elasticsearch/Services/ElasticTransport.cs new file mode 100644 index 00000000..3ab5c941 --- /dev/null +++ b/src/SquidStd.Search.Elasticsearch/Services/ElasticTransport.cs @@ -0,0 +1,90 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using Elastic.Clients.Elasticsearch; +using Elastic.Transport; +using HttpMethod = Elastic.Transport.HttpMethod; + +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. +/// +public sealed class ElasticTransport +{ + private static readonly JsonSerializerOptions WebOptions = new(JsonSerializerDefaults.Web); + + // Elasticsearch 8.x rejects requests without an explicit media type, so force it on every request. + private static readonly RequestConfiguration JsonRequest = new() + { + Accept = "application/json", + ContentType = "application/json" + }; + + // The bulk API expects newline-delimited JSON. + private static readonly RequestConfiguration NdjsonRequest = new() + { + Accept = "application/json", + ContentType = "application/x-ndjson" + }; + + private readonly ElasticsearchClient _client; + + public ElasticTransport(ElasticsearchClient client) + { + _client = client; + } + + /// Deserializes a document body (an Elasticsearch _source) to . + public static T DeserializeDocument(JsonNode source) + => source.Deserialize(WebOptions)!; + + /// Sends a request with an optional JSON body and returns (statusCode, bodyJson). + public Task<(int Status, JsonNode? Body)> SendAsync( + HttpMethod method, + string path, + JsonNode? body, + CancellationToken cancellationToken + ) + => 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( + HttpMethod method, + string path, + string? body, + CancellationToken cancellationToken + ) + => 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)!; + + private async Task<(int Status, JsonNode? Body)> SendCoreAsync( + HttpMethod method, + string path, + string? body, + RequestConfiguration requestConfiguration, + CancellationToken cancellationToken + ) + { + var postData = body is null ? null : PostData.String(body); + + var response = await _client.Transport + .RequestAsync( + method, + path, + postData, + requestConfiguration, + cancellationToken + ) + .ConfigureAwait(false); + + var status = response.ApiCallDetails.HttpStatusCode ?? 0; + var text = response.Body; + var node = string.IsNullOrEmpty(text) ? null : JsonNode.Parse(text); + + return (status, node); + } +} diff --git a/src/SquidStd.Search.Elasticsearch/SquidStd.Search.Elasticsearch.csproj b/src/SquidStd.Search.Elasticsearch/SquidStd.Search.Elasticsearch.csproj new file mode 100644 index 00000000..0efc3361 --- /dev/null +++ b/src/SquidStd.Search.Elasticsearch/SquidStd.Search.Elasticsearch.csproj @@ -0,0 +1,18 @@ + + + + net10.0 + enable + enable + true + + + + + + + + + + + diff --git a/src/SquidStd.Services.Core/Extensions/RegisterDefaultServicesExtensions.cs b/src/SquidStd.Services.Core/Extensions/RegisterDefaultServicesExtensions.cs index 148ff3a6..e7664ea3 100644 --- a/src/SquidStd.Services.Core/Extensions/RegisterDefaultServicesExtensions.cs +++ b/src/SquidStd.Services.Core/Extensions/RegisterDefaultServicesExtensions.cs @@ -4,24 +4,23 @@ using SquidStd.Abstractions.Extensions.Container; using SquidStd.Abstractions.Extensions.Services; using SquidStd.Core.Data.Bootstrap; -using SquidStd.Core.Directories; using SquidStd.Core.Data.Jobs; using SquidStd.Core.Data.Metrics; using SquidStd.Core.Data.Storage; -using SquidStd.Storage.Abstractions.Data.Config; using SquidStd.Core.Data.Timing; +using SquidStd.Core.Directories; using SquidStd.Core.Interfaces.Config; using SquidStd.Core.Interfaces.Events; using SquidStd.Core.Interfaces.Jobs; using SquidStd.Core.Interfaces.Metrics; using SquidStd.Core.Interfaces.Secrets; using SquidStd.Core.Interfaces.Serialization; -using SquidStd.Storage.Abstractions.Interfaces; using SquidStd.Core.Interfaces.Threading; using SquidStd.Core.Interfaces.Timing; using SquidStd.Core.Json; using SquidStd.Services.Core.Services; using SquidStd.Services.Core.Services.Storage; +using SquidStd.Storage.Abstractions.Data.Config; namespace SquidStd.Services.Core.Extensions; @@ -33,26 +32,6 @@ public static class RegisterDefaultServicesExtensions /// Container that receives the core service registrations. extension(IContainer container) { - /// - /// 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). - /// - /// The same container for chaining. - public IContainer RegisterDataSerializer() - { - var serializer = new JsonDataSerializer(); - container.RegisterInstance(serializer, IfAlreadyRegistered.Keep); - container.RegisterInstance(serializer, IfAlreadyRegistered.Keep); - - return container; - } - public IContainer RegisterConfigManagerService(string configName, string configDirectory) { var service = new ConfigManagerService(container, configName, configDirectory); @@ -94,6 +73,26 @@ public IContainer RegisterCoreServices(string configName, string configDirectory return container; } + /// + /// 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). + /// + /// The same container for chaining. + public IContainer RegisterDataSerializer() + { + var serializer = new JsonDataSerializer(); + container.RegisterInstance(serializer, IfAlreadyRegistered.Keep); + container.RegisterInstance(serializer, IfAlreadyRegistered.Keep); + + return container; + } + /// /// Registers the default SquidStd core configuration sections. /// @@ -128,6 +127,13 @@ public IContainer RegisterJobSystemService() return container.RegisterStdService(-1); } + /// + /// Registers the default main-thread dispatcher service in the container. + /// + /// The same container for chaining. + public IContainer RegisterMainThreadDispatcherService() + => container.RegisterStdService(-1); + /// /// Registers the default metrics collection service in the container. /// @@ -152,13 +158,6 @@ public IContainer RegisterSecretServices() return container; } - /// - /// Registers the default main-thread dispatcher service in the container. - /// - /// The same container for chaining. - public IContainer RegisterMainThreadDispatcherService() - => container.RegisterStdService(-1); - /// /// Registers the default timer wheel service in the container. /// diff --git a/src/SquidStd.Services.Core/README.md b/src/SquidStd.Services.Core/README.md index 2c814494..e18010b6 100644 --- a/src/SquidStd.Services.Core/README.md +++ b/src/SquidStd.Services.Core/README.md @@ -46,15 +46,15 @@ container.RegisterCoreServices("squidstd", Directory.GetCurrentDirectory()); ## Key types -| Type | Purpose | -|------|---------| +| Type | Purpose | +|-------------------------------------|---------------------------------------------------------------------------| | `RegisterDefaultServicesExtensions` | `RegisterCoreServices()` / `RegisterConfigManagerService()` entry points. | -| `ConfigManagerService` | YAML config load/save with env-var substitution. | -| `EventBusService` | In-process event bus implementation. | -| `JobSystemService` | Background job execution. | -| `TimerWheelService` | Timer-wheel scheduling. | -| `MainThreadDispatcherService` | Main-thread work dispatch. | -| `MetricsCollectionService` | Metric sample aggregation. | +| `ConfigManagerService` | YAML config load/save with env-var substitution. | +| `EventBusService` | In-process event bus implementation. | +| `JobSystemService` | Background job execution. | +| `TimerWheelService` | Timer-wheel scheduling. | +| `MainThreadDispatcherService` | Main-thread work dispatch. | +| `MetricsCollectionService` | Metric sample aggregation. | ## License diff --git a/src/SquidStd.Services.Core/Services/Bootstrap/SquidStdBootstrap.cs b/src/SquidStd.Services.Core/Services/Bootstrap/SquidStdBootstrap.cs index e870864a..e97c277a 100644 --- a/src/SquidStd.Services.Core/Services/Bootstrap/SquidStdBootstrap.cs +++ b/src/SquidStd.Services.Core/Services/Bootstrap/SquidStdBootstrap.cs @@ -35,18 +35,14 @@ public sealed class SquidStdBootstrap : ISquidStdBootstrap /// Initializes a bootstrapper with default options. ///
public SquidStdBootstrap() - : this(new SquidStdOptions()) - { - } + : this(new()) { } /// /// 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) { @@ -65,6 +61,31 @@ private SquidStdBootstrap(SquidStdOptions options, IContainer container, bool ow Container.RegisterCoreServices(Options.ConfigName, Options.RootDirectory); } + /// + public ISquidStdBootstrap ConfigureService(Func configure) + => ConfigureServices(configure); + + /// + public ISquidStdBootstrap ConfigureServices(Func configure) + { + ArgumentNullException.ThrowIfNull(configure); + ThrowIfDisposed(); + + lock (_syncRoot) + { + if (_state != BootstrapStateType.Created) + { + throw new InvalidOperationException("Services cannot be configured after bootstrap start."); + } + } + + var configuredContainer = configure(Container); + + return !ReferenceEquals(configuredContainer, Container) + ? throw new InvalidOperationException("ConfigureServices must return the bootstrap container instance.") + : this; + } + /// /// Creates a bootstrapper with default options. /// @@ -90,28 +111,29 @@ public static SquidStdBootstrap Create(SquidStdOptions options, IContainer conta => new(options, container, false); /// - public ISquidStdBootstrap ConfigureService(Func configure) - => ConfigureServices(configure); - - /// - public ISquidStdBootstrap ConfigureServices(Func configure) + public async ValueTask DisposeAsync() { - ArgumentNullException.ThrowIfNull(configure); - ThrowIfDisposed(); + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } - lock (_syncRoot) + try { - if (_state != BootstrapStateType.Created) + await StopAsync(CancellationToken.None); + } + finally + { + if (_loggerConfigured) { - throw new InvalidOperationException("Services cannot be configured after bootstrap start."); + await Log.CloseAndFlushAsync(); } - } - var configuredContainer = configure(Container); - - return !ReferenceEquals(configuredContainer, Container) - ? throw new InvalidOperationException("ConfigureServices must return the bootstrap container instance.") - : this; + if (_ownsContainer) + { + Container.Dispose(); + } + } } /// @@ -122,6 +144,22 @@ public TService Resolve() return Container.Resolve(); } + /// + public async Task RunAsync(CancellationToken cancellationToken = default) + { + await StartAsync(cancellationToken); + + try + { + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { } + finally + { + await StopAsync(CancellationToken.None); + } + } + /// public async ValueTask StartAsync(CancellationToken cancellationToken = default) { @@ -184,22 +222,6 @@ public async ValueTask StopAsync(CancellationToken cancellationToken = default) } } - /// - public async Task RunAsync(CancellationToken cancellationToken = default) - { - await StartAsync(cancellationToken); - - try - { - await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { } - finally - { - await StopAsync(CancellationToken.None); - } - } - private void ConfigureLogger() { if (!Container.IsRegistered()) @@ -217,7 +239,7 @@ private void ConfigureLogger() if (options.EnableConsole) { - loggerConfiguration.WriteTo.Console(restrictedToMinimumLevel: minimumLevel); + loggerConfiguration.WriteTo.Console(minimumLevel); } if (options.EnableFile) @@ -252,15 +274,12 @@ .. Container.Resolve>() ]; } - private string ResolveLogPath(SquidStdLoggerOptions options) + private void MarkCreated() { - 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); - - return Path.Combine(directory, fileName); + lock (_syncRoot) + { + _state = BootstrapStateType.Created; + } } private void MarkStarting() @@ -281,14 +300,6 @@ private void MarkStarting() } } - private void MarkCreated() - { - lock (_syncRoot) - { - _state = BootstrapStateType.Created; - } - } - private bool MarkStopping() { lock (_syncRoot) @@ -311,37 +322,22 @@ private bool MarkStopping() } } - private void ThrowIfDisposed() + private string ResolveLogPath(SquidStdLoggerOptions options) { - if (Volatile.Read(ref _disposed) != 0) - { - throw new ObjectDisposedException(nameof(SquidStdBootstrap)); - } + 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); + + return Path.Combine(directory, fileName); } - /// - public async ValueTask DisposeAsync() + private void ThrowIfDisposed() { - if (Interlocked.Exchange(ref _disposed, 1) != 0) - { - return; - } - - try - { - await StopAsync(CancellationToken.None); - } - finally + if (Volatile.Read(ref _disposed) != 0) { - if (_loggerConfigured) - { - await Log.CloseAndFlushAsync(); - } - - if (_ownsContainer) - { - Container.Dispose(); - } + throw new ObjectDisposedException(nameof(SquidStdBootstrap)); } } } diff --git a/src/SquidStd.Services.Core/Services/ConfigManagerService.cs b/src/SquidStd.Services.Core/Services/ConfigManagerService.cs index 038d96ef..09ac24d7 100644 --- a/src/SquidStd.Services.Core/Services/ConfigManagerService.cs +++ b/src/SquidStd.Services.Core/Services/ConfigManagerService.cs @@ -70,7 +70,7 @@ public void Load() : YamlUtils.DeserializeSection(yaml, entry.SectionName, entry.ConfigType) ?? entry.CreateDefault(); - ApplyEnvSubstitution(value, new HashSet(ReferenceEqualityComparer.Instance)); + ApplyEnvSubstitution(value, new(ReferenceEqualityComparer.Instance)); _values[entry.ConfigType] = value; _container.RegisterInstance(entry.ConfigType, value, IfAlreadyRegistered.Replace); @@ -109,44 +109,6 @@ public ValueTask StopAsync(CancellationToken cancellationToken = default) return ValueTask.CompletedTask; } - private Dictionary BuildSectionMap() - { - var sections = new Dictionary(StringComparer.Ordinal); - var entries = GetRegistrations(); - - for (var i = 0; i < entries.Count; i++) - { - var entry = entries[i]; - - if (!_values.TryGetValue(entry.ConfigType, out var value)) - { - value = entry.CreateDefault(); - } - - sections[entry.SectionName] = value; - } - - return sections; - } - - private IReadOnlyCollection GetEntries() - => GetRegistrations().Cast().ToArray(); - - private List GetRegistrations() - { - if (!_container.IsRegistered>()) - { - return []; - } - - return - [ - .. _container.Resolve>() - .OrderBy(entry => entry.Priority) - .ThenBy(entry => entry.SectionName, StringComparer.Ordinal) - ]; - } - private static void ApplyEnvSubstitution(object? instance, HashSet visited) { if (instance is null || !visited.Add(instance)) @@ -187,6 +149,44 @@ private static void ApplyEnvSubstitution(object? instance, HashSet visit } } + private Dictionary BuildSectionMap() + { + var sections = new Dictionary(StringComparer.Ordinal); + var entries = GetRegistrations(); + + for (var i = 0; i < entries.Count; i++) + { + var entry = entries[i]; + + if (!_values.TryGetValue(entry.ConfigType, out var value)) + { + value = entry.CreateDefault(); + } + + sections[entry.SectionName] = value; + } + + return sections; + } + + private IReadOnlyCollection GetEntries() + => GetRegistrations().Cast().ToArray(); + + private List GetRegistrations() + { + if (!_container.IsRegistered>()) + { + return []; + } + + return + [ + .. _container.Resolve>() + .OrderBy(entry => entry.Priority) + .ThenBy(entry => entry.SectionName, StringComparer.Ordinal) + ]; + } + private static string ResolveConfigPath(string configName, string configDirectory) { var fileName = Path.HasExtension(configName) ? configName : $"{configName}.yaml"; diff --git a/src/SquidStd.Services.Core/Services/EventBusService.cs b/src/SquidStd.Services.Core/Services/EventBusService.cs index 8d969a26..ec36c514 100644 --- a/src/SquidStd.Services.Core/Services/EventBusService.cs +++ b/src/SquidStd.Services.Core/Services/EventBusService.cs @@ -31,6 +31,21 @@ public EventBusService() _dispatcher = Task.Run(ProcessDispatchesAsync); } + /// + /// Stops the internal dispatcher. + /// + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + _dispatches.Writer.TryComplete(); + _dispatcher.GetAwaiter().GetResult(); + } + /// public void Publish(TEvent eventData) where TEvent : IEvent @@ -155,19 +170,4 @@ private void ThrowIfDisposed() throw new ObjectDisposedException(nameof(EventBusService)); } } - - /// - /// Stops the internal dispatcher. - /// - public void Dispose() - { - if (_disposed) - { - return; - } - - _disposed = true; - _dispatches.Writer.TryComplete(); - _dispatcher.GetAwaiter().GetResult(); - } } diff --git a/src/SquidStd.Services.Core/Services/HealthCheckService.cs b/src/SquidStd.Services.Core/Services/HealthCheckService.cs index 57af6f94..0c99fddc 100644 --- a/src/SquidStd.Services.Core/Services/HealthCheckService.cs +++ b/src/SquidStd.Services.Core/Services/HealthCheckService.cs @@ -36,7 +36,7 @@ public async ValueTask CheckHealthAsync(CancellationToken cancella if (_checks.Length == 0) { - return new HealthReport + return new() { Status = HealthStatus.Healthy, Entries = new Dictionary(StringComparer.Ordinal), @@ -77,7 +77,7 @@ public async ValueTask CheckHealthAsync(CancellationToken cancella } } - return new HealthReport + return new() { Status = overall, Entries = entries, @@ -86,7 +86,10 @@ public async ValueTask CheckHealthAsync(CancellationToken cancella }; } - private async Task<(string Name, HealthCheckResult Result)> RunCheckAsync(IHealthCheck check, CancellationToken cancellationToken) + private async Task<(string Name, HealthCheckResult Result)> RunCheckAsync( + IHealthCheck check, + CancellationToken cancellationToken + ) { var stopwatch = Stopwatch.StartNew(); @@ -101,7 +104,8 @@ public async ValueTask CheckHealthAsync(CancellationToken cancella } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { - return (check.Name, HealthCheckResult.Unhealthy($"Timed out after {_checkTimeout}.") with { Duration = stopwatch.Elapsed }); + return (check.Name, + HealthCheckResult.Unhealthy($"Timed out after {_checkTimeout}.") with { Duration = stopwatch.Elapsed }); } catch (OperationCanceledException) { diff --git a/src/SquidStd.Services.Core/Services/JobSystemService.cs b/src/SquidStd.Services.Core/Services/JobSystemService.cs index 07ae39d1..2d7b83bc 100644 --- a/src/SquidStd.Services.Core/Services/JobSystemService.cs +++ b/src/SquidStd.Services.Core/Services/JobSystemService.cs @@ -54,6 +54,12 @@ public JobSystemService(JobsConfig config) _workers = new Thread[WorkerCount]; } + /// + /// Releases worker resources. + /// + public void Dispose() + => Stop(CancellationToken.None); + /// public Task ScheduleAsync(Action work, CancellationToken cancellationToken = default) { @@ -311,10 +317,4 @@ private void WorkerLoop() _logger.Error(ex, "JobSystemService worker loop terminated unexpectedly"); } } - - /// - /// Releases worker resources. - /// - public void Dispose() - => Stop(CancellationToken.None); } diff --git a/src/SquidStd.Services.Core/Services/MetricsCollectionService.cs b/src/SquidStd.Services.Core/Services/MetricsCollectionService.cs index 8bac0a6a..0be8395b 100644 --- a/src/SquidStd.Services.Core/Services/MetricsCollectionService.cs +++ b/src/SquidStd.Services.Core/Services/MetricsCollectionService.cs @@ -45,6 +45,30 @@ public MetricsCollectionService(IEnumerable providers, MetricsC _eventBus = eventBus; } + /// + /// Releases metrics collection resources. + /// + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + if (Volatile.Read(ref _started) != 0) + { + _lifetimeCts.Cancel(); + + try + { + _collectionTask.GetAwaiter().GetResult(); + } + catch (OperationCanceledException) { } + } + + _lifetimeCts.Dispose(); + } + /// public IReadOnlyDictionary GetAllMetrics() { @@ -54,10 +78,6 @@ public IReadOnlyDictionary GetAllMetrics() } } - /// - public MetricsSnapshot GetStatus() - => GetSnapshot(); - /// public MetricsSnapshot GetSnapshot() { @@ -67,6 +87,10 @@ public MetricsSnapshot GetSnapshot() } } + /// + public MetricsSnapshot GetStatus() + => GetSnapshot(); + /// public ValueTask StartAsync(CancellationToken cancellationToken = default) { @@ -110,9 +134,7 @@ public async ValueTask StopAsync(CancellationToken cancellationToken = default) { await _collectionTask; } - catch (OperationCanceledException) - { - } + catch (OperationCanceledException) { } } private async Task CollectOnceAsync(CancellationToken cancellationToken) @@ -227,30 +249,4 @@ private void ThrowIfDisposed() throw new ObjectDisposedException(nameof(MetricsCollectionService)); } } - - /// - /// Releases metrics collection resources. - /// - public void Dispose() - { - if (Interlocked.Exchange(ref _disposed, 1) != 0) - { - return; - } - - if (Volatile.Read(ref _started) != 0) - { - _lifetimeCts.Cancel(); - - try - { - _collectionTask.GetAwaiter().GetResult(); - } - catch (OperationCanceledException) - { - } - } - - _lifetimeCts.Dispose(); - } } diff --git a/src/SquidStd.Services.Core/Services/Scheduling/CronSchedulerService.cs b/src/SquidStd.Services.Core/Services/Scheduling/CronSchedulerService.cs index 6b404fb1..945fae37 100644 --- a/src/SquidStd.Services.Core/Services/Scheduling/CronSchedulerService.cs +++ b/src/SquidStd.Services.Core/Services/Scheduling/CronSchedulerService.cs @@ -33,19 +33,31 @@ 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(); + .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(); + } /// public string Schedule(string name, string cronExpression, Func handler) @@ -71,39 +83,6 @@ public string Schedule(string name, string cronExpression, Func - public bool Unschedule(string jobId) - { - if (!_entries.TryRemove(jobId, out var entry)) - { - return false; - } - - if (entry.TimerId is not null) - { - _timer.UnregisterTimer(entry.TimerId); - } - - return true; - } - - /// - public int UnscheduleByName(string name) - { - var ids = _entries.Values.Where(entry => entry.Name == name).Select(entry => entry.JobId).ToArray(); - var removed = 0; - - foreach (var id in ids) - { - if (Unschedule(id)) - { - removed++; - } - } - - return removed; - } - /// public ValueTask StartAsync(CancellationToken cancellationToken = default) => ValueTask.CompletedTask; @@ -126,29 +105,37 @@ public ValueTask StopAsync(CancellationToken cancellationToken = default) return ValueTask.CompletedTask; } - private void ScheduleNext(CronJobEntry entry) + /// + public bool Unschedule(string jobId) { - var now = DateTime.UtcNow; - var next = entry.Expression.GetNextOccurrence(now); - - if (next is null) + if (!_entries.TryRemove(jobId, out var entry)) { - _logger.Information("Cron job '{Name}' ({JobId}) has no further occurrences", entry.Name, entry.JobId); - entry.TimerId = null; - entry.NextOccurrenceUtc = null; + return false; + } - return; + if (entry.TimerId is not null) + { + _timer.UnregisterTimer(entry.TimerId); } - var delay = next.Value - now; + return true; + } - if (delay <= TimeSpan.Zero) + /// + public int UnscheduleByName(string name) + { + var ids = _entries.Values.Where(entry => entry.Name == name).Select(entry => entry.JobId).ToArray(); + var removed = 0; + + foreach (var id in ids) { - delay = TimeSpan.FromMilliseconds(1); + if (Unschedule(id)) + { + removed++; + } } - entry.NextOccurrenceUtc = next.Value; - entry.TimerId = _timer.RegisterTimer(entry.Name, delay, () => OnTimer(entry)); + return removed; } private void OnTimer(CronJobEntry entry) @@ -192,15 +179,28 @@ private void RunJob(CronJobEntry entry) } } - /// - public void Dispose() + private void ScheduleNext(CronJobEntry entry) { - if (Interlocked.Exchange(ref _disposed, 1) != 0) + var now = DateTime.UtcNow; + var next = entry.Expression.GetNextOccurrence(now); + + if (next is null) { + _logger.Information("Cron job '{Name}' ({JobId}) has no further occurrences", entry.Name, entry.JobId); + entry.TimerId = null; + entry.NextOccurrenceUtc = null; + return; } - _cts.Cancel(); - _cts.Dispose(); + var delay = next.Value - now; + + if (delay <= TimeSpan.Zero) + { + delay = TimeSpan.FromMilliseconds(1); + } + + entry.NextOccurrenceUtc = next.Value; + entry.TimerId = _timer.RegisterTimer(entry.Name, delay, () => OnTimer(entry)); } } diff --git a/src/SquidStd.Services.Core/Services/Scheduling/TimerWheelPumpService.cs b/src/SquidStd.Services.Core/Services/Scheduling/TimerWheelPumpService.cs index 284477ca..7c210f98 100644 --- a/src/SquidStd.Services.Core/Services/Scheduling/TimerWheelPumpService.cs +++ b/src/SquidStd.Services.Core/Services/Scheduling/TimerWheelPumpService.cs @@ -31,6 +31,18 @@ public TimerWheelPumpService(ITimerService timer, TimerWheelPumpConfig config) _pumpInterval = config.PumpInterval; } + /// + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + _cts.Cancel(); + _cts.Dispose(); + } + /// public ValueTask StartAsync(CancellationToken cancellationToken = default) { @@ -85,16 +97,4 @@ private async Task PumpLoopAsync(CancellationToken token) // Expected on shutdown. } } - - /// - public void Dispose() - { - if (Interlocked.Exchange(ref _disposed, 1) != 0) - { - return; - } - - _cts.Cancel(); - _cts.Dispose(); - } } diff --git a/src/SquidStd.Services.Core/Services/Storage/AesGcmSecretProtector.cs b/src/SquidStd.Services.Core/Services/Storage/AesGcmSecretProtector.cs index 53de0334..d1230cbf 100644 --- a/src/SquidStd.Services.Core/Services/Storage/AesGcmSecretProtector.cs +++ b/src/SquidStd.Services.Core/Services/Storage/AesGcmSecretProtector.cs @@ -74,6 +74,9 @@ public byte[] Unprotect(byte[] protectedData) return plaintext; } + private static byte[] CreateDefaultKey() + => SHA256.HashData(Encoding.UTF8.GetBytes(DefaultKeyMaterial)); + private static byte[] ResolveKey(string environmentVariable) { ArgumentException.ThrowIfNullOrWhiteSpace(environmentVariable); @@ -105,7 +108,4 @@ private static byte[] ResolveKey(string environmentVariable) ? key : throw new InvalidOperationException("Secret key must be 16, 24, or 32 bytes after base64 decoding."); } - - private static byte[] CreateDefaultKey() - => SHA256.HashData(Encoding.UTF8.GetBytes(DefaultKeyMaterial)); } diff --git a/src/SquidStd.Services.Core/SquidStd.Services.Core.csproj b/src/SquidStd.Services.Core/SquidStd.Services.Core.csproj index fec735f0..d4e99c5a 100644 --- a/src/SquidStd.Services.Core/SquidStd.Services.Core.csproj +++ b/src/SquidStd.Services.Core/SquidStd.Services.Core.csproj @@ -8,18 +8,18 @@ - - - - + + + + - - - - - + + + + + diff --git a/src/SquidStd.Storage.Abstractions/Interfaces/IObjectStorageService.cs b/src/SquidStd.Storage.Abstractions/Interfaces/IObjectStorageService.cs index 7977ec61..580e98d0 100644 --- a/src/SquidStd.Storage.Abstractions/Interfaces/IObjectStorageService.cs +++ b/src/SquidStd.Storage.Abstractions/Interfaces/IObjectStorageService.cs @@ -21,6 +21,14 @@ public interface IObjectStorageService /// true when the object exists; otherwise false. ValueTask ExistsAsync(string key, CancellationToken cancellationToken = default); + /// + /// Enumerates stored keys, optionally filtered by prefix. + /// + /// Optional key prefix; null or empty returns all keys. + /// Token used to cancel the enumeration. + /// An async sequence of storage keys. + IAsyncEnumerable ListKeysAsync(string? prefix = null, CancellationToken cancellationToken = default); + /// /// Loads a stored object. /// @@ -38,12 +46,4 @@ public interface IObjectStorageService /// Token used to cancel the operation. /// The object type. ValueTask SaveAsync(string key, T value, CancellationToken cancellationToken = default); - - /// - /// Enumerates stored keys, optionally filtered by prefix. - /// - /// Optional key prefix; null or empty returns all keys. - /// Token used to cancel the enumeration. - /// An async sequence of storage keys. - IAsyncEnumerable ListKeysAsync(string? prefix = null, CancellationToken cancellationToken = default); } diff --git a/src/SquidStd.Storage.Abstractions/Interfaces/IStorageService.cs b/src/SquidStd.Storage.Abstractions/Interfaces/IStorageService.cs index 3d3b8980..b307f2aa 100644 --- a/src/SquidStd.Storage.Abstractions/Interfaces/IStorageService.cs +++ b/src/SquidStd.Storage.Abstractions/Interfaces/IStorageService.cs @@ -21,6 +21,14 @@ public interface IStorageService /// true when the payload exists; otherwise false. ValueTask ExistsAsync(string key, CancellationToken cancellationToken = default); + /// + /// Enumerates stored keys, optionally filtered by prefix. + /// + /// Optional key prefix; null or empty returns all keys. + /// Token used to cancel the enumeration. + /// An async sequence of storage keys. + IAsyncEnumerable ListKeysAsync(string? prefix = null, CancellationToken cancellationToken = default); + /// /// Loads a binary payload. /// @@ -36,12 +44,4 @@ public interface IStorageService /// The payload to store. /// Token used to cancel the operation. ValueTask SaveAsync(string key, ReadOnlyMemory data, CancellationToken cancellationToken = default); - - /// - /// Enumerates stored keys, optionally filtered by prefix. - /// - /// Optional key prefix; null or empty returns all keys. - /// Token used to cancel the enumeration. - /// An async sequence of storage keys. - IAsyncEnumerable ListKeysAsync(string? prefix = null, CancellationToken cancellationToken = default); } diff --git a/src/SquidStd.Storage.Abstractions/README.md b/src/SquidStd.Storage.Abstractions/README.md index c3890fe8..cacca83c 100644 --- a/src/SquidStd.Storage.Abstractions/README.md +++ b/src/SquidStd.Storage.Abstractions/README.md @@ -45,11 +45,11 @@ public async Task DumpKeysAsync(IStorageService storage) ## Key types -| Type | Purpose | -|------|---------| -| `IStorageService` | Binary blob store (save/load/delete/exists/list). | -| `IObjectStorageService` | Typed object store over a blob backend. | -| `StorageConfig` | Root directory for file storage. | +| Type | Purpose | +|-------------------------|---------------------------------------------------| +| `IStorageService` | Binary blob store (save/load/delete/exists/list). | +| `IObjectStorageService` | Typed object store over a blob backend. | +| `StorageConfig` | Root directory for file storage. | ## License diff --git a/src/SquidStd.Storage.Abstractions/SquidStd.Storage.Abstractions.csproj b/src/SquidStd.Storage.Abstractions/SquidStd.Storage.Abstractions.csproj index d1a27def..53c041ce 100644 --- a/src/SquidStd.Storage.Abstractions/SquidStd.Storage.Abstractions.csproj +++ b/src/SquidStd.Storage.Abstractions/SquidStd.Storage.Abstractions.csproj @@ -8,7 +8,7 @@ - + diff --git a/src/SquidStd.Storage.S3/README.md b/src/SquidStd.Storage.S3/README.md index 8650ff8c..a8648a44 100644 --- a/src/SquidStd.Storage.S3/README.md +++ b/src/SquidStd.Storage.S3/README.md @@ -51,11 +51,11 @@ await storage.SaveAsync("reports/2026.json", "{}"u8.ToArray()); ## Key types -| Type | Purpose | -|------|---------| -| `S3StorageRegistrationExtensions` | `AddS3Storage(...)` registration. | -| `S3StorageService` | MinIO-backed `IStorageService`. | -| `S3StorageOptions` | Endpoint, credentials, bucket, TLS, region. | +| Type | Purpose | +|-----------------------------------|---------------------------------------------| +| `S3StorageRegistrationExtensions` | `AddS3Storage(...)` registration. | +| `S3StorageService` | MinIO-backed `IStorageService`. | +| `S3StorageOptions` | Endpoint, credentials, bucket, TLS, region. | ## License diff --git a/src/SquidStd.Storage.S3/Services/S3StorageService.cs b/src/SquidStd.Storage.S3/Services/S3StorageService.cs index ce14a250..e0c795a8 100644 --- a/src/SquidStd.Storage.S3/Services/S3StorageService.cs +++ b/src/SquidStd.Storage.S3/Services/S3StorageService.cs @@ -31,46 +31,31 @@ public S3StorageService(S3StorageOptions options) } /// - public async ValueTask SaveAsync(string key, ReadOnlyMemory data, CancellationToken cancellationToken = default) + public async ValueTask DeleteAsync(string key, CancellationToken cancellationToken = default) { - await EnsureBucketAsync(cancellationToken); - - var bytes = data.ToArray(); - using var stream = new MemoryStream(bytes, writable: false); + if (!await ExistsAsync(key, cancellationToken)) + { + return false; + } - await _client.PutObjectAsync( - new PutObjectArgs() - .WithBucket(_bucket) - .WithObject(key) - .WithStreamData(stream) - .WithObjectSize(bytes.LongLength), + await _client.RemoveObjectAsync( + new RemoveObjectArgs().WithBucket(_bucket).WithObject(key), cancellationToken ); + + return true; } /// - public async ValueTask LoadAsync(string key, CancellationToken cancellationToken = default) + public void Dispose() { - await EnsureBucketAsync(cancellationToken); - - using var buffer = new MemoryStream(); - - try - { - await _client.GetObjectAsync( - new GetObjectArgs() - .WithBucket(_bucket) - .WithObject(key) - .WithCallbackStream(async (stream, ct) => await stream.CopyToAsync(buffer, ct)), - cancellationToken - ); - } - catch (ObjectNotFoundException) + if (Interlocked.Exchange(ref _disposed, 1) != 0) { - return null; + return; } - return buffer.ToArray(); + _client.Dispose(); + _bucketLock.Dispose(); } /// @@ -93,22 +78,6 @@ await _client.StatObjectAsync( } } - /// - public async ValueTask DeleteAsync(string key, CancellationToken cancellationToken = default) - { - if (!await ExistsAsync(key, cancellationToken)) - { - return false; - } - - await _client.RemoveObjectAsync( - new RemoveObjectArgs().WithBucket(_bucket).WithObject(key), - cancellationToken - ); - - return true; - } - /// public async IAsyncEnumerable ListKeysAsync( string? prefix = null, @@ -133,12 +102,55 @@ public async IAsyncEnumerable ListKeysAsync( } } + /// + public async ValueTask LoadAsync(string key, CancellationToken cancellationToken = default) + { + await EnsureBucketAsync(cancellationToken); + + using var buffer = new MemoryStream(); + + try + { + await _client.GetObjectAsync( + new GetObjectArgs() + .WithBucket(_bucket) + .WithObject(key) + .WithCallbackStream(async (stream, ct) => await stream.CopyToAsync(buffer, ct)), + cancellationToken + ); + } + catch (ObjectNotFoundException) + { + return null; + } + + return buffer.ToArray(); + } + + /// + public async ValueTask SaveAsync(string key, ReadOnlyMemory data, CancellationToken cancellationToken = default) + { + await EnsureBucketAsync(cancellationToken); + + var bytes = data.ToArray(); + using var stream = new MemoryStream(bytes, false); + + await _client.PutObjectAsync( + new PutObjectArgs() + .WithBucket(_bucket) + .WithObject(key) + .WithStreamData(stream) + .WithObjectSize(bytes.LongLength), + cancellationToken + ); + } + private static IMinioClient CreateClient(S3StorageOptions options) { var minio = new MinioClient() - .WithEndpoint(options.Endpoint) - .WithCredentials(options.AccessKey, options.SecretKey) - .WithSSL(options.UseSsl); + .WithEndpoint(options.Endpoint) + .WithCredentials(options.AccessKey, options.SecretKey) + .WithSSL(options.UseSsl); if (!string.IsNullOrWhiteSpace(options.Region)) { @@ -173,16 +185,4 @@ private async ValueTask EnsureBucketAsync(CancellationToken cancellationToken) _bucketLock.Release(); } } - - /// - public void Dispose() - { - if (Interlocked.Exchange(ref _disposed, 1) != 0) - { - return; - } - - _client.Dispose(); - _bucketLock.Dispose(); - } } diff --git a/src/SquidStd.Storage.S3/SquidStd.Storage.S3.csproj b/src/SquidStd.Storage.S3/SquidStd.Storage.S3.csproj index a6463d49..e962b177 100644 --- a/src/SquidStd.Storage.S3/SquidStd.Storage.S3.csproj +++ b/src/SquidStd.Storage.S3/SquidStd.Storage.S3.csproj @@ -8,12 +8,12 @@ - + - - + + diff --git a/src/SquidStd.Storage/README.md b/src/SquidStd.Storage/README.md index 5750ee3b..3d509a2e 100644 --- a/src/SquidStd.Storage/README.md +++ b/src/SquidStd.Storage/README.md @@ -45,11 +45,11 @@ await storage.SaveAsync("profiles/main.bin", new byte[] { 1, 2, 3 }); ## Key types -| Type | Purpose | -|------|---------| -| `StorageRegistrationExtensions` | `AddFileStorage(...)` registration. | -| `FileStorageService` | Filesystem-backed `IStorageService`. | -| `YamlObjectStorageService` | YAML-backed `IObjectStorageService` over a blob store. | +| Type | Purpose | +|---------------------------------|--------------------------------------------------------| +| `StorageRegistrationExtensions` | `AddFileStorage(...)` registration. | +| `FileStorageService` | Filesystem-backed `IStorageService`. | +| `YamlObjectStorageService` | YAML-backed `IObjectStorageService` over a blob store. | ## License diff --git a/src/SquidStd.Storage/Services/FileStorageService.cs b/src/SquidStd.Storage/Services/FileStorageService.cs index 8e283dd8..94c75417 100644 --- a/src/SquidStd.Storage/Services/FileStorageService.cs +++ b/src/SquidStd.Storage/Services/FileStorageService.cs @@ -48,6 +48,41 @@ public ValueTask ExistsAsync(string key, CancellationToken cancellationTok return ValueTask.FromResult(File.Exists(ResolvePath(key))); } + /// + public async IAsyncEnumerable ListKeysAsync( + string? prefix = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default + ) + { + await Task.CompletedTask; + + if (!Directory.Exists(_rootDirectory)) + { + yield break; + } + + foreach (var file in Directory.EnumerateFiles(_rootDirectory, "*", SearchOption.AllDirectories)) + { + cancellationToken.ThrowIfCancellationRequested(); + + var name = Path.GetFileName(file); + + if (name.StartsWith('.') && name.EndsWith(".tmp", StringComparison.Ordinal)) + { + continue; + } + + var key = Path.GetRelativePath(_rootDirectory, file).Replace('\\', '/'); + + if (!string.IsNullOrEmpty(prefix) && !key.StartsWith(prefix, StringComparison.Ordinal)) + { + continue; + } + + yield return key; + } + } + /// public async ValueTask LoadAsync(string key, CancellationToken cancellationToken = default) { @@ -90,41 +125,6 @@ public async ValueTask SaveAsync( } } - /// - public async IAsyncEnumerable ListKeysAsync( - string? prefix = null, - [EnumeratorCancellation] CancellationToken cancellationToken = default - ) - { - await Task.CompletedTask; - - if (!Directory.Exists(_rootDirectory)) - { - yield break; - } - - foreach (var file in Directory.EnumerateFiles(_rootDirectory, "*", SearchOption.AllDirectories)) - { - cancellationToken.ThrowIfCancellationRequested(); - - var name = Path.GetFileName(file); - - if (name.StartsWith('.') && name.EndsWith(".tmp", StringComparison.Ordinal)) - { - continue; - } - - var key = Path.GetRelativePath(_rootDirectory, file).Replace('\\', '/'); - - if (!string.IsNullOrEmpty(prefix) && !key.StartsWith(prefix, StringComparison.Ordinal)) - { - continue; - } - - yield return key; - } - } - private string ResolvePath(string key) => StoragePathResolver.ResolveFilePath(_rootDirectory, key); } diff --git a/src/SquidStd.Storage/Services/YamlObjectStorageService.cs b/src/SquidStd.Storage/Services/YamlObjectStorageService.cs index edbefb2c..6bc30acb 100644 --- a/src/SquidStd.Storage/Services/YamlObjectStorageService.cs +++ b/src/SquidStd.Storage/Services/YamlObjectStorageService.cs @@ -1,6 +1,6 @@ using System.Text; -using SquidStd.Storage.Abstractions.Interfaces; using SquidStd.Core.Yaml; +using SquidStd.Storage.Abstractions.Interfaces; namespace SquidStd.Storage.Services; @@ -28,6 +28,10 @@ public ValueTask DeleteAsync(string key, CancellationToken cancellationTok public ValueTask ExistsAsync(string key, CancellationToken cancellationToken = default) => _storageService.ExistsAsync(key, cancellationToken); + /// + public IAsyncEnumerable ListKeysAsync(string? prefix = null, CancellationToken cancellationToken = default) + => _storageService.ListKeysAsync(prefix, cancellationToken); + /// public async ValueTask LoadAsync(string key, CancellationToken cancellationToken = default) { @@ -51,8 +55,4 @@ public async ValueTask SaveAsync(string key, T value, CancellationToken cance var yaml = YamlUtils.Serialize(value); await _storageService.SaveAsync(key, Encoding.UTF8.GetBytes(yaml), cancellationToken); } - - /// - public IAsyncEnumerable ListKeysAsync(string? prefix = null, CancellationToken cancellationToken = default) - => _storageService.ListKeysAsync(prefix, cancellationToken); } diff --git a/src/SquidStd.Storage/SquidStd.Storage.csproj b/src/SquidStd.Storage/SquidStd.Storage.csproj index 32d53f52..7503996d 100644 --- a/src/SquidStd.Storage/SquidStd.Storage.csproj +++ b/src/SquidStd.Storage/SquidStd.Storage.csproj @@ -8,12 +8,12 @@ - - + + - + diff --git a/src/SquidStd.Templates/README.md b/src/SquidStd.Templates/README.md new file mode 100644 index 00000000..fce6efba --- /dev/null +++ b/src/SquidStd.Templates/README.md @@ -0,0 +1,44 @@ +

+ SquidStd +

+ +

SquidStd.Templates

+ +

+ NuGet + Downloads + docs + license +

+ +`dotnet new` templates for scaffolding SquidStd projects. The generated projects reference the SquidStd packages +at the same version as this template pack. + +## Install + +```bash +dotnet new install SquidStd.Templates +``` + +## Templates + +| Template | Short name | What you get | +|---------------------|-----------------------|-----------------------------------------------------------------| +| Console host | `squidstd-host` | A `SquidStdBootstrap` console host. | +| ASP.NET minimal API | `squidstd-aspnetcore` | `UseSquidStd` + health checks + a sample endpoint + Dockerfile. | +| Worker microservice | `squidstd-worker` | `AddWorkers` + a sample `IJobHandler` + Dockerfile. | +| Worker manager | `squidstd-manager` | `AddWorkerManager` + `MapWorkerManagerEndpoints` + Dockerfile. | + +## Usage + +```bash +dotnet new squidstd-worker -n Acme.Resizer +dotnet new squidstd-worker -n Acme.Resizer --messaging inmemory +dotnet new squidstd-manager -n Acme.Manager +``` + +`--messaging` (`rabbitmq` default | `inmemory`) is available on the worker and manager templates. + +## License + +MIT — part of [SquidStd](https://github.com/tgiachi/squid-std). diff --git a/src/SquidStd.Templates/SquidStd.Templates.csproj b/src/SquidStd.Templates/SquidStd.Templates.csproj new file mode 100644 index 00000000..b510a48f --- /dev/null +++ b/src/SquidStd.Templates/SquidStd.Templates.csproj @@ -0,0 +1,73 @@ + + + + net10.0 + Template + SquidStd.Templates + SquidStd project templates + dotnet new templates for SquidStd: console host, ASP.NET minimal API, worker microservice, and worker manager. + dotnet-new;templates;squidstd + true + false + true + content + true + false + true + false + $(NoWarn);NU5128 + $(TargetsForTfmSpecificContentInPackage);AddTemplatesToPackage + + + + + + + + + + + + + + + + + + $(MSBuildProjectDirectory)/../../templates + $(IntermediateOutputPath)templates-processed + + + + + + + + + + + + + + + + + + content/%(RecursiveDir)%(Filename)%(Extension) + + + + + diff --git a/src/SquidStd.Templating/Interfaces/ITemplateRenderer.cs b/src/SquidStd.Templating/Interfaces/ITemplateRenderer.cs index 8fa4ee8c..f947933f 100644 --- a/src/SquidStd.Templating/Interfaces/ITemplateRenderer.cs +++ b/src/SquidStd.Templating/Interfaces/ITemplateRenderer.cs @@ -5,12 +5,12 @@ namespace SquidStd.Templating.Interfaces; /// public interface ITemplateRenderer { - /// Renders an ad-hoc template string against a model. - ValueTask RenderAsync(string template, object? model, CancellationToken cancellationToken = default); - /// Compiles and registers a named template (replacing any existing one with the same name). void Register(string name, string template); + /// Renders an ad-hoc template string against a model. + ValueTask RenderAsync(string template, object? model, CancellationToken cancellationToken = default); + /// Renders a previously registered template by name. ValueTask RenderByNameAsync(string name, object? model, CancellationToken cancellationToken = default); } diff --git a/src/SquidStd.Templating/README.md b/src/SquidStd.Templating/README.md index 6cc99300..aebc6a33 100644 --- a/src/SquidStd.Templating/README.md +++ b/src/SquidStd.Templating/README.md @@ -25,7 +25,8 @@ dotnet add package SquidStd.Templating - `ITemplateRenderer.RenderAsync(template, model)` — render an ad-hoc template string. - `Register(name, template)` + `RenderByNameAsync(name, model)` — compiled, cached named templates. -- Startup auto-load: every `templates/**/*.tmpl` is registered by relative path without extension (`emails/welcome.tmpl` → `emails/welcome`). +- Startup auto-load: every `templates/**/*.tmpl` is registered by relative path without extension (`emails/welcome.tmpl` → + `emails/welcome`). - Scriban default member naming (`snake_case`): a `UserName` property is `{{ user.user_name }}`. - Parse/render failures surface as `TemplateException`; unknown names as `InvalidOperationException`. @@ -50,12 +51,12 @@ var welcome = await renderer.RenderByNameAsync("welcome", new { User = new { Nam ## Key types -| Type | Purpose | -|------|---------| -| `ITemplateRenderer` | Render ad-hoc and named templates. | -| `ScribanTemplateRenderer` | Scriban implementation; compiles/caches named templates and auto-loads `templates/*.tmpl`. | -| `TemplateException` | Raised on parse/render failures. | -| `TemplatingRegistrationExtensions` | `AddTemplating(...)` registration. | +| Type | Purpose | +|------------------------------------|--------------------------------------------------------------------------------------------| +| `ITemplateRenderer` | Render ad-hoc and named templates. | +| `ScribanTemplateRenderer` | Scriban implementation; compiles/caches named templates and auto-loads `templates/*.tmpl`. | +| `TemplateException` | Raised on parse/render failures. | +| `TemplatingRegistrationExtensions` | `AddTemplating(...)` registration. | ## License diff --git a/src/SquidStd.Templating/Services/ScribanTemplateRenderer.cs b/src/SquidStd.Templating/Services/ScribanTemplateRenderer.cs index c3b703b6..ed386152 100644 --- a/src/SquidStd.Templating/Services/ScribanTemplateRenderer.cs +++ b/src/SquidStd.Templating/Services/ScribanTemplateRenderer.cs @@ -24,24 +24,28 @@ public ScribanTemplateRenderer(DirectoriesConfig directories) } /// - public async ValueTask RenderAsync(string template, object? model, CancellationToken cancellationToken = default) + public void Register(string name, string template) { + ArgumentException.ThrowIfNullOrWhiteSpace(name); ArgumentException.ThrowIfNullOrWhiteSpace(template); - return await RenderCompiledAsync(Parse(template, "(inline)"), model, "(inline)"); + _named[name] = Parse(template, name); } /// - public void Register(string name, string template) + public async ValueTask RenderAsync(string template, object? model, CancellationToken cancellationToken = default) { - ArgumentException.ThrowIfNullOrWhiteSpace(name); ArgumentException.ThrowIfNullOrWhiteSpace(template); - _named[name] = Parse(template, name); + return await RenderCompiledAsync(Parse(template, "(inline)"), model, "(inline)"); } /// - public async ValueTask RenderByNameAsync(string name, object? model, CancellationToken cancellationToken = default) + public async ValueTask RenderByNameAsync( + string name, + object? model, + CancellationToken cancellationToken = default + ) { ArgumentException.ThrowIfNullOrWhiteSpace(name); diff --git a/src/SquidStd.Templating/SquidStd.Templating.csproj b/src/SquidStd.Templating/SquidStd.Templating.csproj index c088a852..c472b51c 100644 --- a/src/SquidStd.Templating/SquidStd.Templating.csproj +++ b/src/SquidStd.Templating/SquidStd.Templating.csproj @@ -8,13 +8,13 @@ - - + + - - + + diff --git a/src/SquidStd.Templating/TemplateException.cs b/src/SquidStd.Templating/TemplateException.cs index 73f2bd57..6ceb4f3e 100644 --- a/src/SquidStd.Templating/TemplateException.cs +++ b/src/SquidStd.Templating/TemplateException.cs @@ -6,12 +6,8 @@ namespace SquidStd.Templating; 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/WorkerHeartbeat.cs b/src/SquidStd.Workers.Abstractions/Data/WorkerHeartbeat.cs index 1213b09a..a9c75ba7 100644 --- a/src/SquidStd.Workers.Abstractions/Data/WorkerHeartbeat.cs +++ b/src/SquidStd.Workers.Abstractions/Data/WorkerHeartbeat.cs @@ -7,7 +7,10 @@ namespace SquidStd.Workers.Abstractions.Data; /// /// 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. public sealed record WorkerHeartbeat( @@ -15,4 +18,5 @@ public sealed record WorkerHeartbeat( DateTime TimestampUtc, WorkerStatusType Status, int ActiveJobs, - int MaxConcurrency); + int MaxConcurrency +); diff --git a/src/SquidStd.Workers.Abstractions/Data/WorkerInfo.cs b/src/SquidStd.Workers.Abstractions/Data/WorkerInfo.cs index 22eac1ab..86d5f548 100644 --- a/src/SquidStd.Workers.Abstractions/Data/WorkerInfo.cs +++ b/src/SquidStd.Workers.Abstractions/Data/WorkerInfo.cs @@ -17,4 +17,5 @@ public sealed record WorkerInfo( int ActiveJobs, int MaxConcurrency, DateTime FirstSeenUtc, - DateTime LastSeenUtc); + DateTime LastSeenUtc +); diff --git a/src/SquidStd.Workers.Abstractions/README.md b/src/SquidStd.Workers.Abstractions/README.md index 32063e9f..d9e579d9 100644 --- a/src/SquidStd.Workers.Abstractions/README.md +++ b/src/SquidStd.Workers.Abstractions/README.md @@ -22,13 +22,13 @@ dotnet add package SquidStd.Workers.Abstractions ## Key types -| Type | Purpose | -|------|---------| -| `JobRequest` | A job (name + string parameters) enqueued by the manager and consumed by a worker. | -| `WorkerHeartbeat` | Liveness signal a worker publishes (status, active jobs, max concurrency). | -| `WorkerInfo` | The manager-side view of a worker, folded from heartbeats. | -| `WorkerStatusType` | `Idle` / `Busy` / `Offline`. | -| `WorkerChannels` | Default jobs-queue and heartbeat-topic names. | +| Type | Purpose | +|--------------------|------------------------------------------------------------------------------------| +| `JobRequest` | A job (name + string parameters) enqueued by the manager and consumed by a worker. | +| `WorkerHeartbeat` | Liveness signal a worker publishes (status, active jobs, max concurrency). | +| `WorkerInfo` | The manager-side view of a worker, folded from heartbeats. | +| `WorkerStatusType` | `Idle` / `Busy` / `Offline`. | +| `WorkerChannels` | Default jobs-queue and heartbeat-topic names. | ## License diff --git a/src/SquidStd.Workers.Manager/Data/Events/WorkerStatusChangedEvent.cs b/src/SquidStd.Workers.Manager/Data/Events/WorkerStatusChangedEvent.cs index 9b15b767..e2922b8c 100644 --- a/src/SquidStd.Workers.Manager/Data/Events/WorkerStatusChangedEvent.cs +++ b/src/SquidStd.Workers.Manager/Data/Events/WorkerStatusChangedEvent.cs @@ -10,4 +10,8 @@ namespace SquidStd.Workers.Manager.Data.Events; /// The worker whose status changed. /// Previous status, or null when the worker was just discovered. /// The new status. -public sealed record WorkerStatusChangedEvent(string WorkerId, WorkerStatusType? OldStatus, WorkerStatusType NewStatus) : IEvent; +public sealed record WorkerStatusChangedEvent( + string WorkerId, + WorkerStatusType? OldStatus, + WorkerStatusType NewStatus +) : IEvent; diff --git a/src/SquidStd.Workers.Manager/Endpoints/WorkerManagerEndpoints.cs b/src/SquidStd.Workers.Manager/Endpoints/WorkerManagerEndpoints.cs index 95765ec7..d3928537 100644 --- a/src/SquidStd.Workers.Manager/Endpoints/WorkerManagerEndpoints.cs +++ b/src/SquidStd.Workers.Manager/Endpoints/WorkerManagerEndpoints.cs @@ -11,23 +11,12 @@ namespace SquidStd.Workers.Manager.Endpoints; /// public static class WorkerManagerEndpoints { - /// Lists all known workers. - public static Ok> GetWorkers(IWorkerRegistry registry) - => TypedResults.Ok(registry.GetAll()); - - /// Returns a single worker, or 404 when unknown. - public static Results, NotFound> GetWorker(string id, IWorkerRegistry registry) - { - var info = registry.Get(id); - - return info is null ? TypedResults.NotFound() : TypedResults.Ok(info); - } - /// Enqueues a job; 400 on a blank name, 503 when the queue is unavailable. public static async Task, ProblemHttpResult>> EnqueueJob( EnqueueJobRequest request, IJobScheduler scheduler, - CancellationToken cancellationToken) + CancellationToken cancellationToken + ) { if (string.IsNullOrWhiteSpace(request.JobName)) { @@ -36,13 +25,32 @@ public static async Task, ProblemHttpResult try { - await scheduler.EnqueueAsync(request.JobName, request.Parameters ?? new Dictionary(), cancellationToken); + await scheduler.EnqueueAsync( + request.JobName, + request.Parameters ?? new Dictionary(), + cancellationToken + ); return TypedResults.Accepted((string?)null); } catch (Exception) { - return TypedResults.Problem(statusCode: StatusCodes.Status503ServiceUnavailable, detail: "Job queue unavailable."); + return TypedResults.Problem( + statusCode: StatusCodes.Status503ServiceUnavailable, + detail: "Job queue unavailable." + ); } } + + /// Returns a single worker, or 404 when unknown. + public static Results, NotFound> GetWorker(string id, IWorkerRegistry registry) + { + var info = registry.Get(id); + + return info is null ? TypedResults.NotFound() : TypedResults.Ok(info); + } + + /// Lists all known workers. + public static Ok> GetWorkers(IWorkerRegistry registry) + => TypedResults.Ok(registry.GetAll()); } diff --git a/src/SquidStd.Workers.Manager/Interfaces/IJobScheduler.cs b/src/SquidStd.Workers.Manager/Interfaces/IJobScheduler.cs index 25efa737..337d0f5f 100644 --- a/src/SquidStd.Workers.Manager/Interfaces/IJobScheduler.cs +++ b/src/SquidStd.Workers.Manager/Interfaces/IJobScheduler.cs @@ -6,7 +6,11 @@ namespace SquidStd.Workers.Manager.Interfaces; public interface IJobScheduler { /// Enqueues a job with parameters. - Task EnqueueAsync(string jobName, IReadOnlyDictionary parameters, CancellationToken cancellationToken = default); + Task EnqueueAsync( + string jobName, + IReadOnlyDictionary parameters, + CancellationToken cancellationToken = default + ); /// Enqueues a job with no parameters. Task EnqueueAsync(string jobName, CancellationToken cancellationToken = default); diff --git a/src/SquidStd.Workers.Manager/Interfaces/IWorkerRegistry.cs b/src/SquidStd.Workers.Manager/Interfaces/IWorkerRegistry.cs index 1859d6ca..8c46d819 100644 --- a/src/SquidStd.Workers.Manager/Interfaces/IWorkerRegistry.cs +++ b/src/SquidStd.Workers.Manager/Interfaces/IWorkerRegistry.cs @@ -7,9 +7,9 @@ namespace SquidStd.Workers.Manager.Interfaces; /// public interface IWorkerRegistry { - /// Returns a snapshot of all known workers. - IReadOnlyCollection GetAll(); - /// Returns the worker with the given id, or null when unknown. WorkerInfo? Get(string workerId); + + /// Returns a snapshot of all known workers. + IReadOnlyCollection GetAll(); } diff --git a/src/SquidStd.Workers.Manager/README.md b/src/SquidStd.Workers.Manager/README.md index a1e33a74..3265e8e8 100644 --- a/src/SquidStd.Workers.Manager/README.md +++ b/src/SquidStd.Workers.Manager/README.md @@ -36,14 +36,14 @@ app.MapWorkerManagerEndpoints(); // GET /workers, GET /workers/{id}, POST /jobs ## Key types -| Type | Purpose | -|------|---------| -| `IJobScheduler` | `EnqueueAsync(jobName, parameters)` onto the jobs queue. | -| `IWorkerRegistry` | `GetAll()` / `Get(id)` over the live worker view. | -| `WorkerStatusChangedEvent` | Published on the event bus on discover / offline / return. | -| `WorkerManagerConfig` | `OfflineTimeoutSeconds`, `SweepIntervalSeconds`, queue/topic names. | -| `WorkerManagerRegistrationExtensions` | `AddWorkerManager()`. | -| `WorkerManagerEndpointsExtensions` | `MapWorkerManagerEndpoints()`. | +| Type | Purpose | +|---------------------------------------|---------------------------------------------------------------------| +| `IJobScheduler` | `EnqueueAsync(jobName, parameters)` onto the jobs queue. | +| `IWorkerRegistry` | `GetAll()` / `Get(id)` over the live worker view. | +| `WorkerStatusChangedEvent` | Published on the event bus on discover / offline / return. | +| `WorkerManagerConfig` | `OfflineTimeoutSeconds`, `SweepIntervalSeconds`, queue/topic names. | +| `WorkerManagerRegistrationExtensions` | `AddWorkerManager()`. | +| `WorkerManagerEndpointsExtensions` | `MapWorkerManagerEndpoints()`. | ## License diff --git a/src/SquidStd.Workers.Manager/Services/HeartbeatCollectorService.cs b/src/SquidStd.Workers.Manager/Services/HeartbeatCollectorService.cs index 91317e0a..0737114d 100644 --- a/src/SquidStd.Workers.Manager/Services/HeartbeatCollectorService.cs +++ b/src/SquidStd.Workers.Manager/Services/HeartbeatCollectorService.cs @@ -21,12 +21,19 @@ public sealed class HeartbeatCollectorService : ISquidStdService private readonly string _topicName; private IDisposable? _subscription; - public HeartbeatCollectorService(IMessageTopic topic, WorkerRegistry registry, IEventBus eventBus, WorkerManagerConfig config) + public HeartbeatCollectorService( + IMessageTopic topic, + WorkerRegistry registry, + IEventBus eventBus, + WorkerManagerConfig config + ) { _topic = topic; _registry = registry; _eventBus = eventBus; - _topicName = string.IsNullOrWhiteSpace(config.HeartbeatTopic) ? WorkerChannels.HeartbeatTopic : config.HeartbeatTopic; + _topicName = string.IsNullOrWhiteSpace(config.HeartbeatTopic) + ? WorkerChannels.HeartbeatTopic + : config.HeartbeatTopic; } /// @@ -52,6 +59,7 @@ private async Task OnHeartbeatAsync(WorkerHeartbeat heartbeat, CancellationToken try { var change = _registry.Record(heartbeat); + if (change is not null) { await _eventBus.PublishAsync(change, cancellationToken); diff --git a/src/SquidStd.Workers.Manager/Services/JobScheduler.cs b/src/SquidStd.Workers.Manager/Services/JobScheduler.cs index 82336a01..646c7b5f 100644 --- a/src/SquidStd.Workers.Manager/Services/JobScheduler.cs +++ b/src/SquidStd.Workers.Manager/Services/JobScheduler.cs @@ -21,7 +21,11 @@ public JobScheduler(IMessageQueue queue, WorkerManagerConfig config) } /// - public Task EnqueueAsync(string jobName, IReadOnlyDictionary parameters, CancellationToken cancellationToken = default) + public Task EnqueueAsync( + string jobName, + IReadOnlyDictionary parameters, + CancellationToken cancellationToken = default + ) => _queue.PublishAsync(_queueName, new JobRequest(jobName, parameters), cancellationToken); /// diff --git a/src/SquidStd.Workers.Manager/Services/WorkerOfflineSweepService.cs b/src/SquidStd.Workers.Manager/Services/WorkerOfflineSweepService.cs index 34d63a8d..fc07aeb7 100644 --- a/src/SquidStd.Workers.Manager/Services/WorkerOfflineSweepService.cs +++ b/src/SquidStd.Workers.Manager/Services/WorkerOfflineSweepService.cs @@ -21,28 +21,51 @@ public sealed class WorkerOfflineSweepService : ISquidStdService private readonly TimeSpan _interval; private string? _timerId; - public WorkerOfflineSweepService(ITimerService timer, WorkerRegistry registry, IEventBus eventBus, WorkerManagerConfig config) + public WorkerOfflineSweepService( + ITimerService timer, + WorkerRegistry registry, + IEventBus eventBus, + WorkerManagerConfig config + ) { _timer = timer; _registry = registry; _eventBus = eventBus; var seconds = config.SweepIntervalSeconds > 0 ? config.SweepIntervalSeconds : DefaultIntervalSeconds; + if (config.SweepIntervalSeconds <= 0) { _logger.Warning( "SweepIntervalSeconds was {Value}; falling back to {Default}s.", config.SweepIntervalSeconds, - DefaultIntervalSeconds); + DefaultIntervalSeconds + ); } _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) { - _timerId = _timer.RegisterTimer("worker-offline-sweep", _interval, OnTick, delay: _interval, repeat: true); + _timerId = _timer.RegisterTimer("worker-offline-sweep", _interval, OnTick, _interval, true); return ValueTask.CompletedTask; } @@ -59,22 +82,6 @@ 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(); } diff --git a/src/SquidStd.Workers.Manager/Services/WorkerRegistry.cs b/src/SquidStd.Workers.Manager/Services/WorkerRegistry.cs index d7804ae8..917da1c6 100644 --- a/src/SquidStd.Workers.Manager/Services/WorkerRegistry.cs +++ b/src/SquidStd.Workers.Manager/Services/WorkerRegistry.cs @@ -23,20 +23,20 @@ public WorkerRegistry(WorkerManagerConfig config) } /// - public IReadOnlyCollection GetAll() + public WorkerInfo? Get(string workerId) { lock (_sync) { - return _workers.Values.ToArray(); + return _workers.GetValueOrDefault(workerId); } } /// - public WorkerInfo? Get(string workerId) + public IReadOnlyCollection GetAll() { lock (_sync) { - return _workers.GetValueOrDefault(workerId); + return _workers.Values.ToArray(); } } @@ -52,15 +52,16 @@ public IReadOnlyCollection GetAll() { if (!_workers.TryGetValue(heartbeat.WorkerId, out var existing)) { - _workers[heartbeat.WorkerId] = new WorkerInfo( + _workers[heartbeat.WorkerId] = new( heartbeat.WorkerId, heartbeat.Status, heartbeat.ActiveJobs, heartbeat.MaxConcurrency, now, - now); + now + ); - return new WorkerStatusChangedEvent(heartbeat.WorkerId, null, heartbeat.Status); + return new(heartbeat.WorkerId, null, heartbeat.Status); } var old = existing.Status; @@ -73,8 +74,8 @@ 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; } } @@ -96,7 +97,7 @@ public IReadOnlyList Sweep(DateTime nowUtc) } _workers[id] = info with { Status = WorkerStatusType.Offline }; - changes.Add(new WorkerStatusChangedEvent(id, info.Status, WorkerStatusType.Offline)); + changes.Add(new(id, info.Status, WorkerStatusType.Offline)); } } diff --git a/src/SquidStd.Workers.Manager/SquidStd.Workers.Manager.csproj b/src/SquidStd.Workers.Manager/SquidStd.Workers.Manager.csproj index 6b1b3dcc..657b6792 100644 --- a/src/SquidStd.Workers.Manager/SquidStd.Workers.Manager.csproj +++ b/src/SquidStd.Workers.Manager/SquidStd.Workers.Manager.csproj @@ -8,13 +8,13 @@ - + - - - + + + diff --git a/src/SquidStd.Workers/Extensions/WorkersRegistrationExtensions.cs b/src/SquidStd.Workers/Extensions/WorkersRegistrationExtensions.cs index 18eb37ba..89be0b86 100644 --- a/src/SquidStd.Workers/Extensions/WorkersRegistrationExtensions.cs +++ b/src/SquidStd.Workers/Extensions/WorkersRegistrationExtensions.cs @@ -12,6 +12,19 @@ namespace SquidStd.Workers.Extensions; /// public static class WorkersRegistrationExtensions { + /// + /// 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 + { + ArgumentNullException.ThrowIfNull(container); + + container.Register(Reuse.Singleton); + + return container; + } + /// /// Registers the worker runtime: the "workers" config section, shared state, job dispatcher, and the /// consumer + heartbeat lifecycle services. @@ -30,17 +43,4 @@ public static IContainer AddWorkers(this IContainer container) return container; } - - /// - /// 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 - { - ArgumentNullException.ThrowIfNull(container); - - container.Register(Reuse.Singleton); - - return container; - } } diff --git a/src/SquidStd.Workers/Interfaces/IWorkerState.cs b/src/SquidStd.Workers/Interfaces/IWorkerState.cs index 685e510c..98e90069 100644 --- a/src/SquidStd.Workers/Interfaces/IWorkerState.cs +++ b/src/SquidStd.Workers/Interfaces/IWorkerState.cs @@ -19,9 +19,9 @@ public interface IWorkerState /// while any job is active, otherwise . WorkerStatusType Status { get; } - /// Records that a job started (increments ). - void JobStarted(); - /// Records that a job finished (decrements ). void JobFinished(); + + /// Records that a job started (increments ). + void JobStarted(); } diff --git a/src/SquidStd.Workers/README.md b/src/SquidStd.Workers/README.md index 753a2a65..1024d3c6 100644 --- a/src/SquidStd.Workers/README.md +++ b/src/SquidStd.Workers/README.md @@ -47,11 +47,11 @@ public sealed class ResizeImageHandler : IJobHandler ## Key types -| Type | Purpose | -|------|---------| -| `IJobHandler` | Handles jobs of one named kind. | -| `WorkersConfig` | `WorkerId`, `HeartbeatIntervalSeconds`, `MaxConcurrency`, queue/topic names. | -| `WorkersRegistrationExtensions` | `AddWorkers()` and `AddJobHandler()`. | +| Type | Purpose | +|---------------------------------|------------------------------------------------------------------------------| +| `IJobHandler` | Handles jobs of one named kind. | +| `WorkersConfig` | `WorkerId`, `HeartbeatIntervalSeconds`, `MaxConcurrency`, queue/topic names. | +| `WorkersRegistrationExtensions` | `AddWorkers()` and `AddJobHandler()`. | ## License diff --git a/src/SquidStd.Workers/Services/JobDispatcher.cs b/src/SquidStd.Workers/Services/JobDispatcher.cs index 9d481073..13071d0c 100644 --- a/src/SquidStd.Workers/Services/JobDispatcher.cs +++ b/src/SquidStd.Workers/Services/JobDispatcher.cs @@ -21,7 +21,8 @@ public JobDispatcher(IEnumerable handlers) { _logger.Warning( "Duplicate job handler for '{JobName}'; the last registration wins.", - handler.JobName); + handler.JobName + ); } _handlers[handler.JobName] = handler; diff --git a/src/SquidStd.Workers/Services/WorkerConsumerService.cs b/src/SquidStd.Workers/Services/WorkerConsumerService.cs index e598cde5..54751940 100644 --- a/src/SquidStd.Workers/Services/WorkerConsumerService.cs +++ b/src/SquidStd.Workers/Services/WorkerConsumerService.cs @@ -29,28 +29,10 @@ public WorkerConsumerService(IMessageQueue queue, IJobDispatcher dispatcher, IWo _queue = queue; _dispatcher = dispatcher; _state = state; - _semaphore = new SemaphoreSlim(state.MaxConcurrency); + _semaphore = new(state.MaxConcurrency); _queueName = string.IsNullOrWhiteSpace(config.JobQueue) ? WorkerChannels.JobQueue : config.JobQueue; } - /// - public ValueTask StartAsync(CancellationToken cancellationToken = default) - { - _subscription = _queue.Subscribe(_queueName, this); - _logger.Information("Worker consuming jobs from queue '{Queue}'.", _queueName); - - return ValueTask.CompletedTask; - } - - /// - public ValueTask StopAsync(CancellationToken cancellationToken = default) - { - _subscription?.Dispose(); - _subscription = null; - - return ValueTask.CompletedTask; - } - /// public async Task HandleAsync(JobRequest message, CancellationToken cancellationToken) { @@ -83,4 +65,22 @@ public async Task HandleAsync(JobRequest message, CancellationToken cancellation _semaphore.Release(); } } + + /// + public ValueTask StartAsync(CancellationToken cancellationToken = default) + { + _subscription = _queue.Subscribe(_queueName, this); + _logger.Information("Worker consuming jobs from queue '{Queue}'.", _queueName); + + return ValueTask.CompletedTask; + } + + /// + public ValueTask StopAsync(CancellationToken cancellationToken = default) + { + _subscription?.Dispose(); + _subscription = null; + + return ValueTask.CompletedTask; + } } diff --git a/src/SquidStd.Workers/Services/WorkerHeartbeatService.cs b/src/SquidStd.Workers/Services/WorkerHeartbeatService.cs index c5fa548a..ea5197ec 100644 --- a/src/SquidStd.Workers/Services/WorkerHeartbeatService.cs +++ b/src/SquidStd.Workers/Services/WorkerHeartbeatService.cs @@ -30,16 +30,20 @@ public WorkerHeartbeatService(IMessageTopic topic, IWorkerState state, WorkersCo _state = state; var seconds = config.HeartbeatIntervalSeconds > 0 ? config.HeartbeatIntervalSeconds : DefaultIntervalSeconds; + if (config.HeartbeatIntervalSeconds <= 0) { _logger.Warning( "HeartbeatIntervalSeconds was {Value}; falling back to {Default}s.", config.HeartbeatIntervalSeconds, - DefaultIntervalSeconds); + DefaultIntervalSeconds + ); } _interval = TimeSpan.FromSeconds(seconds); - _topicName = string.IsNullOrWhiteSpace(config.HeartbeatTopic) ? WorkerChannels.HeartbeatTopic : config.HeartbeatTopic; + _topicName = string.IsNullOrWhiteSpace(config.HeartbeatTopic) + ? WorkerChannels.HeartbeatTopic + : config.HeartbeatTopic; } /// @@ -80,18 +84,6 @@ public async ValueTask StopAsync(CancellationToken cancellationToken = default) } } - private async Task RunLoopAsync(CancellationToken cancellationToken) - { - using var timer = new PeriodicTimer(_interval); - - await PublishAsync(cancellationToken); - - while (await timer.WaitForNextTickAsync(cancellationToken)) - { - await PublishAsync(cancellationToken); - } - } - private async Task PublishAsync(CancellationToken cancellationToken) { try @@ -101,7 +93,8 @@ private async Task PublishAsync(CancellationToken cancellationToken) DateTime.UtcNow, _state.Status, _state.ActiveJobs, - _state.MaxConcurrency); + _state.MaxConcurrency + ); await _topic.PublishAsync(_topicName, heartbeat, cancellationToken); } @@ -114,4 +107,16 @@ private async Task PublishAsync(CancellationToken cancellationToken) _logger.Error(ex, "Failed to publish heartbeat; will retry on the next tick."); } } + + private async Task RunLoopAsync(CancellationToken cancellationToken) + { + using var timer = new PeriodicTimer(_interval); + + await PublishAsync(cancellationToken); + + while (await timer.WaitForNextTickAsync(cancellationToken)) + { + await PublishAsync(cancellationToken); + } + } } diff --git a/src/SquidStd.Workers/Services/WorkerState.cs b/src/SquidStd.Workers/Services/WorkerState.cs index 8b96046b..646fc7c2 100644 --- a/src/SquidStd.Workers/Services/WorkerState.cs +++ b/src/SquidStd.Workers/Services/WorkerState.cs @@ -30,11 +30,11 @@ public WorkerState(WorkersConfig config) /// public WorkerStatusType Status => ActiveJobs == 0 ? WorkerStatusType.Idle : WorkerStatusType.Busy; - /// - public void JobStarted() - => Interlocked.Increment(ref _activeJobs); - /// public void JobFinished() => Interlocked.Decrement(ref _activeJobs); + + /// + public void JobStarted() + => Interlocked.Increment(ref _activeJobs); } diff --git a/src/SquidStd.Workers/SquidStd.Workers.csproj b/src/SquidStd.Workers/SquidStd.Workers.csproj index ff5daeb4..ccb67e7b 100644 --- a/src/SquidStd.Workers/SquidStd.Workers.csproj +++ b/src/SquidStd.Workers/SquidStd.Workers.csproj @@ -8,8 +8,8 @@ - - + + diff --git a/templates/Directory.Build.props b/templates/Directory.Build.props new file mode 100644 index 00000000..51b1db8b --- /dev/null +++ b/templates/Directory.Build.props @@ -0,0 +1,4 @@ + + + diff --git a/templates/squidstd-aspnetcore/.template.config/template.json b/templates/squidstd-aspnetcore/.template.config/template.json new file mode 100644 index 00000000..c424be0d --- /dev/null +++ b/templates/squidstd-aspnetcore/.template.config/template.json @@ -0,0 +1,28 @@ +{ + "$schema": "http://json.schemastore.org/template", + "author": "Squid Development", + "classifications": [ "SquidStd", "Web", "WebAPI" ], + "identity": "SquidStd.AspNetCoreApp", + "name": "SquidStd ASP.NET Minimal API", + "shortName": "squidstd-aspnetcore", + "tags": { "language": "C#", "type": "project" }, + "sourceName": "SquidStd.AspNetCoreApp.Template", + "preferNameDirectory": true, + "symbols": { + "skipRestore": { + "type": "parameter", + "datatype": "bool", + "defaultValue": "false", + "description": "Skip the automatic dotnet restore after creation." + } + }, + "postActions": [ + { + "condition": "(!skipRestore)", + "description": "Restore NuGet packages.", + "manualInstructions": [ { "text": "Run 'dotnet restore'." } ], + "actionId": "210D431B-A78B-4D2F-B762-4ED3E3EA9025", + "continueOnError": true + } + ] +} diff --git a/templates/squidstd-aspnetcore/Dockerfile b/templates/squidstd-aspnetcore/Dockerfile new file mode 100644 index 00000000..3a9a072d --- /dev/null +++ b/templates/squidstd-aspnetcore/Dockerfile @@ -0,0 +1,9 @@ +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src +COPY . . +RUN dotnet publish -c Release -o /app + +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime +WORKDIR /app +COPY --from=build /app . +ENTRYPOINT ["dotnet", "SquidStd.AspNetCoreApp.Template.dll"] diff --git a/templates/squidstd-aspnetcore/Program.cs b/templates/squidstd-aspnetcore/Program.cs new file mode 100644 index 00000000..58803cff --- /dev/null +++ b/templates/squidstd-aspnetcore/Program.cs @@ -0,0 +1,16 @@ +using SquidStd.AspNetCore.Extensions; + +var builder = WebApplication.CreateBuilder(args); + +builder.UseSquidStd(options => +{ + options.ConfigName = "squidstd"; +}); +builder.AddSquidStdHealthChecks(); + +var app = builder.Build(); + +app.MapHealthChecks("/health"); +app.MapGet("/hello", () => "Hello from SquidStd!"); + +app.Run(); diff --git a/templates/squidstd-aspnetcore/SquidStd.AspNetCoreApp.Template.csproj b/templates/squidstd-aspnetcore/SquidStd.AspNetCoreApp.Template.csproj new file mode 100644 index 00000000..3048ede7 --- /dev/null +++ b/templates/squidstd-aspnetcore/SquidStd.AspNetCoreApp.Template.csproj @@ -0,0 +1,14 @@ + + + + net10.0 + enable + enable + SquidStd.AspNetCoreApp.Template + + + + + + + diff --git a/templates/squidstd-aspnetcore/squidstd.yaml b/templates/squidstd-aspnetcore/squidstd.yaml new file mode 100644 index 00000000..4ec5d6c6 --- /dev/null +++ b/templates/squidstd-aspnetcore/squidstd.yaml @@ -0,0 +1,2 @@ +logger: + minimumLevel: Information diff --git a/templates/squidstd-host/.template.config/template.json b/templates/squidstd-host/.template.config/template.json new file mode 100644 index 00000000..afb964c0 --- /dev/null +++ b/templates/squidstd-host/.template.config/template.json @@ -0,0 +1,28 @@ +{ + "$schema": "http://json.schemastore.org/template", + "author": "Squid Development", + "classifications": [ "SquidStd", "Console" ], + "identity": "SquidStd.Host", + "name": "SquidStd Console Host", + "shortName": "squidstd-host", + "tags": { "language": "C#", "type": "project" }, + "sourceName": "SquidStd.Host.Template", + "preferNameDirectory": true, + "symbols": { + "skipRestore": { + "type": "parameter", + "datatype": "bool", + "defaultValue": "false", + "description": "Skip the automatic dotnet restore after creation." + } + }, + "postActions": [ + { + "condition": "(!skipRestore)", + "description": "Restore NuGet packages.", + "manualInstructions": [ { "text": "Run 'dotnet restore'." } ], + "actionId": "210D431B-A78B-4D2F-B762-4ED3E3EA9025", + "continueOnError": true + } + ] +} diff --git a/templates/squidstd-host/Program.cs b/templates/squidstd-host/Program.cs new file mode 100644 index 00000000..59c481e8 --- /dev/null +++ b/templates/squidstd-host/Program.cs @@ -0,0 +1,8 @@ +using SquidStd.Services.Core.Services.Bootstrap; + +var bootstrap = SquidStdBootstrap.Create(options => +{ + options.ConfigName = "squidstd"; +}); + +await bootstrap.RunAsync(); diff --git a/templates/squidstd-host/SquidStd.Host.Template.csproj b/templates/squidstd-host/SquidStd.Host.Template.csproj new file mode 100644 index 00000000..272e28e7 --- /dev/null +++ b/templates/squidstd-host/SquidStd.Host.Template.csproj @@ -0,0 +1,15 @@ + + + + Exe + net10.0 + enable + enable + SquidStd.Host.Template + + + + + + + diff --git a/templates/squidstd-host/squidstd.yaml b/templates/squidstd-host/squidstd.yaml new file mode 100644 index 00000000..4ec5d6c6 --- /dev/null +++ b/templates/squidstd-host/squidstd.yaml @@ -0,0 +1,2 @@ +logger: + minimumLevel: Information diff --git a/templates/squidstd-manager/.template.config/template.json b/templates/squidstd-manager/.template.config/template.json new file mode 100644 index 00000000..a1ba1d6b --- /dev/null +++ b/templates/squidstd-manager/.template.config/template.json @@ -0,0 +1,38 @@ +{ + "$schema": "http://json.schemastore.org/template", + "author": "Squid Development", + "classifications": [ "SquidStd", "Web", "Worker" ], + "identity": "SquidStd.Manager", + "name": "SquidStd Worker Manager (ASP.NET)", + "shortName": "squidstd-manager", + "tags": { "language": "C#", "type": "project" }, + "sourceName": "SquidStd.Manager.Template", + "preferNameDirectory": true, + "symbols": { + "messaging": { + "type": "parameter", + "datatype": "choice", + "choices": [ + { "choice": "rabbitmq", "description": "RabbitMQ transport (for real multi-process deployments)." }, + { "choice": "inmemory", "description": "In-memory transport (single process, for trying it out)." } + ], + "defaultValue": "rabbitmq", + "description": "Messaging transport to wire up." + }, + "skipRestore": { + "type": "parameter", + "datatype": "bool", + "defaultValue": "false", + "description": "Skip the automatic dotnet restore after creation." + } + }, + "postActions": [ + { + "condition": "(!skipRestore)", + "description": "Restore NuGet packages.", + "manualInstructions": [ { "text": "Run 'dotnet restore'." } ], + "actionId": "210D431B-A78B-4D2F-B762-4ED3E3EA9025", + "continueOnError": true + } + ] +} diff --git a/templates/squidstd-manager/Dockerfile b/templates/squidstd-manager/Dockerfile new file mode 100644 index 00000000..0220b3d5 --- /dev/null +++ b/templates/squidstd-manager/Dockerfile @@ -0,0 +1,9 @@ +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src +COPY . . +RUN dotnet publish -c Release -o /app + +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime +WORKDIR /app +COPY --from=build /app . +ENTRYPOINT ["dotnet", "SquidStd.Manager.Template.dll"] diff --git a/templates/squidstd-manager/Program.cs b/templates/squidstd-manager/Program.cs new file mode 100644 index 00000000..46b8b62a --- /dev/null +++ b/templates/squidstd-manager/Program.cs @@ -0,0 +1,29 @@ +using SquidStd.AspNetCore.Extensions; +using SquidStd.Workers.Manager.Extensions; +#if (messaging == "rabbitmq") +using SquidStd.Messaging.RabbitMq.Extensions; +#else +using SquidStd.Messaging.Extensions; +#endif + +var builder = WebApplication.CreateBuilder(args); + +builder.UseSquidStd( + options => options.ConfigName = "squidstd", + container => + { +#if (messaging == "rabbitmq") + container.AddRabbitMqMessaging("rabbitmq://guest:guest@localhost:5672"); +#else + container.AddInMemoryMessaging(); +#endif + container.AddWorkerManager(); + + return container; + }); + +var app = builder.Build(); + +app.MapWorkerManagerEndpoints(); + +app.Run(); diff --git a/templates/squidstd-manager/SquidStd.Manager.Template.csproj b/templates/squidstd-manager/SquidStd.Manager.Template.csproj new file mode 100644 index 00000000..34b8bb78 --- /dev/null +++ b/templates/squidstd-manager/SquidStd.Manager.Template.csproj @@ -0,0 +1,20 @@ + + + + net10.0 + enable + enable + SquidStd.Manager.Template + + + + + + + + + + + + + diff --git a/templates/squidstd-manager/squidstd.yaml b/templates/squidstd-manager/squidstd.yaml new file mode 100644 index 00000000..a405fc14 --- /dev/null +++ b/templates/squidstd-manager/squidstd.yaml @@ -0,0 +1,5 @@ +logger: + minimumLevel: Information +workerManager: + offlineTimeoutSeconds: 30 + sweepIntervalSeconds: 10 diff --git a/templates/squidstd-worker/.template.config/template.json b/templates/squidstd-worker/.template.config/template.json new file mode 100644 index 00000000..15e726c9 --- /dev/null +++ b/templates/squidstd-worker/.template.config/template.json @@ -0,0 +1,38 @@ +{ + "$schema": "http://json.schemastore.org/template", + "author": "Squid Development", + "classifications": [ "SquidStd", "Console", "Worker" ], + "identity": "SquidStd.Worker", + "name": "SquidStd Worker Microservice", + "shortName": "squidstd-worker", + "tags": { "language": "C#", "type": "project" }, + "sourceName": "SquidStd.Worker.Template", + "preferNameDirectory": true, + "symbols": { + "messaging": { + "type": "parameter", + "datatype": "choice", + "choices": [ + { "choice": "rabbitmq", "description": "RabbitMQ transport (for real multi-process deployments)." }, + { "choice": "inmemory", "description": "In-memory transport (single process, for trying it out)." } + ], + "defaultValue": "rabbitmq", + "description": "Messaging transport to wire up." + }, + "skipRestore": { + "type": "parameter", + "datatype": "bool", + "defaultValue": "false", + "description": "Skip the automatic dotnet restore after creation." + } + }, + "postActions": [ + { + "condition": "(!skipRestore)", + "description": "Restore NuGet packages.", + "manualInstructions": [ { "text": "Run 'dotnet restore'." } ], + "actionId": "210D431B-A78B-4D2F-B762-4ED3E3EA9025", + "continueOnError": true + } + ] +} diff --git a/templates/squidstd-worker/Dockerfile b/templates/squidstd-worker/Dockerfile new file mode 100644 index 00000000..1174af65 --- /dev/null +++ b/templates/squidstd-worker/Dockerfile @@ -0,0 +1,9 @@ +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src +COPY . . +RUN dotnet publish -c Release -o /app + +FROM mcr.microsoft.com/dotnet/runtime:10.0 AS runtime +WORKDIR /app +COPY --from=build /app . +ENTRYPOINT ["dotnet", "SquidStd.Worker.Template.dll"] diff --git a/templates/squidstd-worker/GreetJobHandler.cs b/templates/squidstd-worker/GreetJobHandler.cs new file mode 100644 index 00000000..812d18ce --- /dev/null +++ b/templates/squidstd-worker/GreetJobHandler.cs @@ -0,0 +1,23 @@ +using Serilog; +using SquidStd.Workers.Abstractions.Data; +using SquidStd.Workers.Interfaces; + +namespace SquidStd.Worker.Template; + +/// +/// Sample job handler. Replace with your own implementations. +/// +public sealed class GreetJobHandler : IJobHandler +{ + private readonly ILogger _logger = Log.ForContext(); + + public string JobName => "greet"; + + public Task HandleAsync(JobRequest job, CancellationToken cancellationToken) + { + var name = job.Parameters.TryGetValue("name", out var value) ? value : "world"; + _logger.Information("Hello, {Name}!", name); + + return Task.CompletedTask; + } +} diff --git a/templates/squidstd-worker/Program.cs b/templates/squidstd-worker/Program.cs new file mode 100644 index 00000000..955ac48f --- /dev/null +++ b/templates/squidstd-worker/Program.cs @@ -0,0 +1,28 @@ +using DryIoc; +using SquidStd.Services.Core.Services.Bootstrap; +using SquidStd.Workers.Extensions; +#if (messaging == "rabbitmq") +using SquidStd.Messaging.RabbitMq.Extensions; +#else +using SquidStd.Messaging.Extensions; +#endif + +var bootstrap = SquidStdBootstrap.Create(options => +{ + options.ConfigName = "squidstd"; +}); + +bootstrap.ConfigureServices(container => +{ +#if (messaging == "rabbitmq") + container.AddRabbitMqMessaging("rabbitmq://guest:guest@localhost:5672"); +#else + container.AddInMemoryMessaging(); +#endif + container.AddWorkers(); + container.AddJobHandler(); + + return container; +}); + +await bootstrap.RunAsync(); diff --git a/templates/squidstd-worker/SquidStd.Worker.Template.csproj b/templates/squidstd-worker/SquidStd.Worker.Template.csproj new file mode 100644 index 00000000..2bb5fbba --- /dev/null +++ b/templates/squidstd-worker/SquidStd.Worker.Template.csproj @@ -0,0 +1,20 @@ + + + + Exe + net10.0 + enable + enable + SquidStd.Worker.Template + + + + + + + + + + + + diff --git a/templates/squidstd-worker/squidstd.yaml b/templates/squidstd-worker/squidstd.yaml new file mode 100644 index 00000000..a6824e1c --- /dev/null +++ b/templates/squidstd-worker/squidstd.yaml @@ -0,0 +1,5 @@ +logger: + minimumLevel: Information +workers: + maxConcurrency: 4 + heartbeatIntervalSeconds: 10 diff --git a/tests/SquidStd.Tests/AspNetCore/FakeSquidStdBootstrap.cs b/tests/SquidStd.Tests/AspNetCore/FakeSquidStdBootstrap.cs index 52218c51..17489326 100644 --- a/tests/SquidStd.Tests/AspNetCore/FakeSquidStdBootstrap.cs +++ b/tests/SquidStd.Tests/AspNetCore/FakeSquidStdBootstrap.cs @@ -27,9 +27,19 @@ public ISquidStdBootstrap ConfigureServices(Func configu : throw new InvalidOperationException("ConfigureServices must return the bootstrap container instance."); } + public ValueTask DisposeAsync() + { + Container.Dispose(); + + return ValueTask.CompletedTask; + } + public TService Resolve() => Container.Resolve(); + public async Task RunAsync(CancellationToken cancellationToken = default) + => await StartAsync(cancellationToken); + public ValueTask StartAsync(CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); @@ -45,16 +55,4 @@ public ValueTask StopAsync(CancellationToken cancellationToken = default) return ValueTask.CompletedTask; } - - public async Task RunAsync(CancellationToken cancellationToken = default) - { - await StartAsync(cancellationToken); - } - - public ValueTask DisposeAsync() - { - Container.Dispose(); - - return ValueTask.CompletedTask; - } } diff --git a/tests/SquidStd.Tests/AspNetCore/SquidStdAspNetCoreBuilderExtensionsTests.cs b/tests/SquidStd.Tests/AspNetCore/SquidStdAspNetCoreBuilderExtensionsTests.cs index 2a5f8a9f..c29c0a56 100644 --- a/tests/SquidStd.Tests/AspNetCore/SquidStdAspNetCoreBuilderExtensionsTests.cs +++ b/tests/SquidStd.Tests/AspNetCore/SquidStdAspNetCoreBuilderExtensionsTests.cs @@ -1,7 +1,6 @@ using System.Net; using DryIoc; using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; @@ -17,44 +16,6 @@ namespace SquidStd.Tests.AspNetCore; public class SquidStdAspNetCoreBuilderExtensionsTests { - [Fact] - public async Task UseSquidStd_RegistersBootstrapWithContentRootDefaults() - { - using var temp = new TempDirectory(); - var builder = CreateBuilder(temp.Path); - - var returned = builder.UseSquidStd(options => options.ConfigName = "app"); - - await using var app = builder.Build(); - var bootstrap = app.Services.GetRequiredService(); - - Assert.Same(builder, returned); - Assert.Equal("app", bootstrap.Options.ConfigName); - Assert.Equal(Path.GetFullPath(temp.Path), Path.GetFullPath(bootstrap.Options.RootDirectory)); - } - - [Fact] - public async Task UseSquidStd_ResolvesSquidStdServicesAfterHostStart() - { - using var temp = new TempDirectory(); - var builder = CreateBuilder(temp.Path); - builder.UseSquidStd(options => options.ConfigName = "app"); - - await using var app = builder.Build(); - await app.StartAsync(); - - try - { - Assert.NotNull(app.Services.GetRequiredService()); - Assert.NotNull(app.Services.GetRequiredService()); - Assert.NotNull(app.Services.GetRequiredService()); - } - finally - { - await app.StopAsync(); - } - } - [Fact] public async Task UseSquidStd_AllowsMinimalApiInjection() { @@ -103,6 +64,44 @@ public async Task UseSquidStd_AppliesCustomDryIocRegistrations() Assert.Equal("custom", app.Services.GetRequiredService().Value); } + [Fact] + public async Task UseSquidStd_RegistersBootstrapWithContentRootDefaults() + { + using var temp = new TempDirectory(); + var builder = CreateBuilder(temp.Path); + + var returned = builder.UseSquidStd(options => options.ConfigName = "app"); + + await using var app = builder.Build(); + var bootstrap = app.Services.GetRequiredService(); + + Assert.Same(builder, returned); + Assert.Equal("app", bootstrap.Options.ConfigName); + Assert.Equal(Path.GetFullPath(temp.Path), Path.GetFullPath(bootstrap.Options.RootDirectory)); + } + + [Fact] + public async Task UseSquidStd_ResolvesSquidStdServicesAfterHostStart() + { + using var temp = new TempDirectory(); + var builder = CreateBuilder(temp.Path); + builder.UseSquidStd(options => options.ConfigName = "app"); + + await using var app = builder.Build(); + await app.StartAsync(); + + try + { + Assert.NotNull(app.Services.GetRequiredService()); + Assert.NotNull(app.Services.GetRequiredService()); + Assert.NotNull(app.Services.GetRequiredService()); + } + finally + { + await app.StopAsync(); + } + } + [Fact] public void UseSquidStd_WhenContainerCallbackReturnsDifferentContainer_Throws() { @@ -125,9 +124,7 @@ public void UseSquidStd_WhenOptionsAreInvalid_Throws() using var temp = new TempDirectory(); var builder = CreateBuilder(temp.Path); - Assert.Throws( - () => builder.UseSquidStd(options => options.ConfigName = string.Empty) - ); + Assert.Throws(() => builder.UseSquidStd(options => options.ConfigName = string.Empty)); } private static WebApplicationBuilder CreateBuilder(string contentRootPath) diff --git a/tests/SquidStd.Tests/AspNetCore/SquidStdHealthCheckAdapterTests.cs b/tests/SquidStd.Tests/AspNetCore/SquidStdHealthCheckAdapterTests.cs index 53ee2055..069efe15 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 HealthCheckContext()); + var result = await adapter.CheckHealthAsync(new()); 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 HealthCheckContext()); + var result = await adapter.CheckHealthAsync(new()); Assert.Equal(HealthStatus.Unhealthy, result.Status); Assert.Equal("down", result.Description); diff --git a/tests/SquidStd.Tests/AspNetCore/SquidStdHealthChecksBridgeTests.cs b/tests/SquidStd.Tests/AspNetCore/SquidStdHealthChecksBridgeTests.cs index 1120170a..46d2bab8 100644 --- a/tests/SquidStd.Tests/AspNetCore/SquidStdHealthChecksBridgeTests.cs +++ b/tests/SquidStd.Tests/AspNetCore/SquidStdHealthChecksBridgeTests.cs @@ -1,6 +1,5 @@ using DryIoc; using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Diagnostics.HealthChecks; @@ -23,7 +22,10 @@ public async Task AddSquidStdHealthChecks_BridgesEachCheckToStandardReport() options => options.ConfigName = "app", container => { - container.RegisterInstance(new FakeHealthCheck("alpha"), IfAlreadyRegistered.AppendNotKeyed); + container.RegisterInstance( + new FakeHealthCheck("alpha"), + IfAlreadyRegistered.AppendNotKeyed + ); container.RegisterInstance( new FakeHealthCheck("beta", SquidHealthResult.Unhealthy("bad")), IfAlreadyRegistered.AppendNotKeyed diff --git a/tests/SquidStd.Tests/Bootstrap/SquidStdBootstrapTests.cs b/tests/SquidStd.Tests/Bootstrap/SquidStdBootstrapTests.cs index ad32af8d..fb00f77d 100644 --- a/tests/SquidStd.Tests/Bootstrap/SquidStdBootstrapTests.cs +++ b/tests/SquidStd.Tests/Bootstrap/SquidStdBootstrapTests.cs @@ -6,8 +6,6 @@ using SquidStd.Core.Data.Bootstrap; using SquidStd.Core.Interfaces.Bootstrap; using SquidStd.Core.Interfaces.Config; -using SquidStd.Core.Types; -using SquidStd.Services.Core.Services; using SquidStd.Services.Core.Services.Bootstrap; using SquidStd.Tests.Support; @@ -16,13 +14,82 @@ 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 SquidStdOptions { ConfigName = "app", RootDirectory = temp.Path } - ); + await using var bootstrap = SquidStdBootstrap.Create(new() { ConfigName = "app", RootDirectory = temp.Path }); var resolved = bootstrap.Resolve(); var configManager = bootstrap.Resolve(); @@ -67,9 +134,7 @@ public async Task DisposeAsync_WithProvidedContainer_DoesNotDisposeContainer() RootDirectory = temp.Path }, container - )) - { - } + )) { } container.RegisterInstance("still-open"); @@ -78,92 +143,13 @@ public async Task DisposeAsync_WithProvidedContainer_DoesNotDisposeContainer() container.Dispose(); } - [Fact] - public async Task StartAsync_LoadsConfigBeforeResolvingRegisteredServices() - { - using var temp = new TempDirectory(); - File.WriteAllText( - temp.Combine("app.yaml"), - """ - logger: - MinimumLevel: Warning - EnableConsole: false - """ - ); - var events = new List(); - await using var bootstrap = SquidStdBootstrap.Create( - new SquidStdOptions { ConfigName = "app", RootDirectory = temp.Path } - ); - - bootstrap.ConfigureServices( - container => - { - container.RegisterInstance(events); - container.Register(Reuse.Singleton); - container.AddToRegisterTypedList( - new ServiceRegistrationData( - typeof(ConfigConsumerService), - typeof(ConfigConsumerService), - -10 - ) - ); - - return container; - } - ); - - await bootstrap.StartAsync(CancellationToken.None); - await bootstrap.StopAsync(CancellationToken.None); - - Assert.Contains("config:Warning", events); - } - - [Fact] - public async Task StartAsync_OrdersServicesByPriorityAndStopAsync_ReversesOrder() - { - using var temp = new TempDirectory(); - var events = new List(); - await using var bootstrap = SquidStdBootstrap.Create( - new SquidStdOptions { ConfigName = "app", RootDirectory = temp.Path } - ); - - bootstrap.ConfigureServices( - container => - { - container.RegisterInstance(events); - container.Register(Reuse.Singleton); - container.Register(Reuse.Singleton); - container.AddToRegisterTypedList( - new ServiceRegistrationData( - typeof(EarlyTrackedService), - typeof(EarlyTrackedService), - -20 - ) - ); - container.AddToRegisterTypedList( - new ServiceRegistrationData(typeof(LateTrackedService), typeof(LateTrackedService), 40) - ); - - return container; - } - ); - - await bootstrap.StartAsync(CancellationToken.None); - await bootstrap.StopAsync(CancellationToken.None); - - Assert.True(events.IndexOf("early:start") < events.IndexOf("late:start")); - Assert.True(events.IndexOf("late:stop") < events.IndexOf("early:stop")); - } - [Fact] 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 SquidStdOptions { ConfigName = "app", RootDirectory = temp.Path } - ); + await using var bootstrap = SquidStdBootstrap.Create(new() { ConfigName = "app", RootDirectory = temp.Path }); bootstrap.ConfigureService( container => @@ -208,9 +194,7 @@ public async Task StartAsync_ConfiguresFileSinkFromLoggerOptions() RollingInterval: Infinite """ ); - await using var bootstrap = SquidStdBootstrap.Create( - new SquidStdOptions { ConfigName = "app", RootDirectory = temp.Path } - ); + await using var bootstrap = SquidStdBootstrap.Create(new() { ConfigName = "app", RootDirectory = temp.Path }); await bootstrap.StartAsync(CancellationToken.None); await bootstrap.StopAsync(CancellationToken.None); @@ -222,74 +206,76 @@ public async Task StartAsync_ConfiguresFileSinkFromLoggerOptions() Assert.Contains("JobSystemService started", content); } - 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 + [Fact] + public async Task StartAsync_LoadsConfigBeforeResolvingRegisteredServices() { - 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; - } - } + using var temp = new TempDirectory(); + File.WriteAllText( + temp.Combine("app.yaml"), + """ + logger: + MinimumLevel: Warning + EnableConsole: false + """ + ); + var events = new List(); + await using var bootstrap = SquidStdBootstrap.Create(new() { ConfigName = "app", RootDirectory = temp.Path }); - private sealed class LateTrackedService(List events) : ISquidStdService - { - public ValueTask StartAsync(CancellationToken cancellationToken = default) - { - events.Add("late:start"); + bootstrap.ConfigureServices( + container => + { + container.RegisterInstance(events); + container.Register(Reuse.Singleton); + container.AddToRegisterTypedList( + new ServiceRegistrationData( + typeof(ConfigConsumerService), + typeof(ConfigConsumerService), + -10 + ) + ); - return ValueTask.CompletedTask; - } + return container; + } + ); - public ValueTask StopAsync(CancellationToken cancellationToken = default) - { - events.Add("late:stop"); + await bootstrap.StartAsync(CancellationToken.None); + await bootstrap.StopAsync(CancellationToken.None); - return ValueTask.CompletedTask; - } + Assert.Contains("config:Warning", events); } - private sealed class RunTrackedState + [Fact] + public async Task StartAsync_OrdersServicesByPriorityAndStopAsync_ReversesOrder() { - public TaskCompletionSource Started { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); - - public TaskCompletionSource Stopped { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); - } + using var temp = new TempDirectory(); + var events = new List(); + await using var bootstrap = SquidStdBootstrap.Create(new() { ConfigName = "app", RootDirectory = temp.Path }); - private sealed class RunTrackedService(RunTrackedState state) : ISquidStdService - { - public ValueTask StartAsync(CancellationToken cancellationToken = default) - { - state.Started.SetResult(); + bootstrap.ConfigureServices( + container => + { + container.RegisterInstance(events); + container.Register(Reuse.Singleton); + container.Register(Reuse.Singleton); + container.AddToRegisterTypedList( + new ServiceRegistrationData( + typeof(EarlyTrackedService), + typeof(EarlyTrackedService), + -20 + ) + ); + container.AddToRegisterTypedList( + new ServiceRegistrationData(typeof(LateTrackedService), typeof(LateTrackedService), 40) + ); - return ValueTask.CompletedTask; - } + return container; + } + ); - public ValueTask StopAsync(CancellationToken cancellationToken = default) - { - state.Stopped.SetResult(); + await bootstrap.StartAsync(CancellationToken.None); + await bootstrap.StopAsync(CancellationToken.None); - return ValueTask.CompletedTask; - } + Assert.True(events.IndexOf("early:start") < events.IndexOf("late:start")); + Assert.True(events.IndexOf("late:stop") < events.IndexOf("early:stop")); } } diff --git a/tests/SquidStd.Tests/Bootstrap/SquidStdOptionsTests.cs b/tests/SquidStd.Tests/Bootstrap/SquidStdOptionsTests.cs index 3661cd21..0e1ca700 100644 --- a/tests/SquidStd.Tests/Bootstrap/SquidStdOptionsTests.cs +++ b/tests/SquidStd.Tests/Bootstrap/SquidStdOptionsTests.cs @@ -5,15 +5,6 @@ namespace SquidStd.Tests.Bootstrap; public class SquidStdOptionsTests { - [Fact] - public void SquidStdOptions_Defaults_AreUsable() - { - var options = new SquidStdOptions(); - - Assert.False(string.IsNullOrWhiteSpace(options.RootDirectory)); - Assert.Equal("squidstd", options.ConfigName); - } - [Fact] public void SquidStdLoggerOptions_Defaults_EnableConsoleAndDisableFile() { @@ -26,4 +17,13 @@ public void SquidStdLoggerOptions_Defaults_EnableConsoleAndDisableFile() Assert.Equal("squidstd-.log", options.FileName); Assert.Equal(SquidStdLogRollingIntervalType.Day, options.RollingInterval); } + + [Fact] + public void SquidStdOptions_Defaults_AreUsable() + { + var options = new SquidStdOptions(); + + Assert.False(string.IsNullOrWhiteSpace(options.RootDirectory)); + Assert.Equal("squidstd", options.ConfigName); + } } diff --git a/tests/SquidStd.Tests/Caching/CacheConnectionStringTests.cs b/tests/SquidStd.Tests/Caching/CacheConnectionStringTests.cs index f588e5f9..e0adb7e7 100644 --- a/tests/SquidStd.Tests/Caching/CacheConnectionStringTests.cs +++ b/tests/SquidStd.Tests/Caching/CacheConnectionStringTests.cs @@ -25,6 +25,15 @@ public void Parse_Redis_ReadsHostPortAndUserInfo() Assert.Equal("pass", cs.Password); } + [Fact] + public void ToCacheOptions_Defaults_WhenNoParams() + { + var options = CacheConnectionString.Parse("memory://localhost").ToCacheOptions(); + + Assert.Null(options.DefaultTtl); + Assert.Equal(string.Empty, options.KeyPrefix); + } + [Fact] public void ToCacheOptions_ReadsTtlAndPrefix() { @@ -35,13 +44,4 @@ public void ToCacheOptions_ReadsTtlAndPrefix() Assert.Equal(TimeSpan.FromSeconds(30), options.DefaultTtl); Assert.Equal("app:", options.KeyPrefix); } - - [Fact] - public void ToCacheOptions_Defaults_WhenNoParams() - { - var options = CacheConnectionString.Parse("memory://localhost").ToCacheOptions(); - - Assert.Null(options.DefaultTtl); - Assert.Equal(string.Empty, options.KeyPrefix); - } } diff --git a/tests/SquidStd.Tests/Caching/CacheMetricsProviderTests.cs b/tests/SquidStd.Tests/Caching/CacheMetricsProviderTests.cs index c64f83c6..fa35387f 100644 --- a/tests/SquidStd.Tests/Caching/CacheMetricsProviderTests.cs +++ b/tests/SquidStd.Tests/Caching/CacheMetricsProviderTests.cs @@ -4,10 +4,6 @@ namespace SquidStd.Tests.Caching; public class CacheMetricsProviderTests { - [Fact] - public void ProviderName_IsCache() - => Assert.Equal("cache", new CacheMetricsProvider().ProviderName); - [Fact] public async Task CollectAsync_ReportsCountersAndHitRatio() { @@ -20,7 +16,8 @@ public async Task CollectAsync_ReportsCountersAndHitRatio() var samples = await metrics.CollectAsync(); - double Value(string name) => samples.Single(s => s.Name == name).Value; + double Value(string name) + => samples.Single(s => s.Name == name).Value; Assert.Equal(2, Value("hits")); Assert.Equal(1, Value("misses")); @@ -28,4 +25,8 @@ public async Task CollectAsync_ReportsCountersAndHitRatio() Assert.Equal(1, Value("removes")); Assert.Equal(2d / 3d, Value("hit_ratio"), 3); } + + [Fact] + public void ProviderName_IsCache() + => Assert.Equal("cache", new CacheMetricsProvider().ProviderName); } diff --git a/tests/SquidStd.Tests/Caching/CacheRegistrationTests.cs b/tests/SquidStd.Tests/Caching/CacheRegistrationTests.cs index 693f8c90..ca6a146f 100644 --- a/tests/SquidStd.Tests/Caching/CacheRegistrationTests.cs +++ b/tests/SquidStd.Tests/Caching/CacheRegistrationTests.cs @@ -7,14 +7,15 @@ namespace SquidStd.Tests.Caching; public class CacheRegistrationTests { [Fact] - public void AddInMemoryCache_ResolvesService() + public async Task AddInMemoryCache_FromUrl_ResolvesAndWorks() { var container = new Container(); - container.AddInMemoryCache(); + container.AddInMemoryCache("memory://localhost?keyPrefix=app:"); + var cache = container.Resolve(); - Assert.NotNull(container.Resolve()); - Assert.NotNull(container.Resolve()); + await cache.SetAsync("k", 5); + Assert.Equal(5, await cache.GetAsync("k")); } [Fact] @@ -26,14 +27,13 @@ public void AddInMemoryCache_FromUrl_WrongScheme_Throws() } [Fact] - public async Task AddInMemoryCache_FromUrl_ResolvesAndWorks() + public void AddInMemoryCache_ResolvesService() { var container = new Container(); - container.AddInMemoryCache("memory://localhost?keyPrefix=app:"); - var cache = container.Resolve(); + container.AddInMemoryCache(); - await cache.SetAsync("k", 5); - Assert.Equal(5, await cache.GetAsync("k")); + Assert.NotNull(container.Resolve()); + Assert.NotNull(container.Resolve()); } } diff --git a/tests/SquidStd.Tests/Caching/CacheServiceTests.cs b/tests/SquidStd.Tests/Caching/CacheServiceTests.cs index c458ada4..aa490ce6 100644 --- a/tests/SquidStd.Tests/Caching/CacheServiceTests.cs +++ b/tests/SquidStd.Tests/Caching/CacheServiceTests.cs @@ -7,104 +7,120 @@ namespace SquidStd.Tests.Caching; public class CacheServiceTests { - private static CacheService NewService( - FakeCacheProvider provider, - CacheOptions? options = null, - CacheMetricsProvider? metrics = null - ) + [Fact] + public async Task Get_Missing_ReturnsDefault() + => Assert.Null(await NewService(new()).GetAsync("absent")); + + [Fact] + public async Task GetOrSet_Hit_DoesNotInvokeFactory() { - var serializer = new JsonDataSerializer(); + var service = NewService(new()); + await service.SetAsync("k", 7); + var calls = 0; + + var value = await service.GetOrSetAsync( + "k", + _ => + { + calls++; - return new CacheService(provider, serializer, serializer, options ?? new CacheOptions(), metrics); + return Task.FromResult(0); + } + ); + + Assert.Equal(7, value); + Assert.Equal(0, calls); } [Fact] - public async Task SetThenGet_RoundTrips() + public async Task GetOrSet_Miss_InvokesFactoryAndStores() { - var service = NewService(new FakeCacheProvider()); + var provider = new FakeCacheProvider(); + var service = NewService(provider); + var calls = 0; - await service.SetAsync("k", 42); + var value = await service.GetOrSetAsync( + "k", + _ => + { + calls++; - Assert.Equal(42, await service.GetAsync("k")); - } + return Task.FromResult(99); + } + ); - [Fact] - public async Task Get_Missing_ReturnsDefault() - => Assert.Null(await NewService(new FakeCacheProvider()).GetAsync("absent")); + Assert.Equal(99, value); + Assert.Equal(1, calls); + Assert.Equal(99, await service.GetAsync("k")); + } [Fact] - public async Task Set_UsesDefaultTtl_WhenNoneGiven() + public async Task KeyPrefix_IsAppliedToProvider() { var provider = new FakeCacheProvider(); - var service = NewService(provider, new CacheOptions { DefaultTtl = TimeSpan.FromSeconds(30) }); + var service = NewService(provider, new() { KeyPrefix = "app:" }); await service.SetAsync("k", "v"); - Assert.Equal(TimeSpan.FromSeconds(30), provider.LastTtl); + Assert.True(await provider.ExistsAsync("app:k")); + Assert.False(await provider.ExistsAsync("k")); } [Fact] - public async Task Set_PerEntryTtl_OverridesDefault() + public async Task Metrics_RecordHitAndMiss() { - var provider = new FakeCacheProvider(); - var service = NewService(provider, new CacheOptions { DefaultTtl = TimeSpan.FromSeconds(30) }); + var metrics = new CacheMetricsProvider(); + var service = NewService(new(), metrics: metrics); - await service.SetAsync("k", "v", TimeSpan.FromSeconds(5)); + await service.GetAsync("absent"); // miss + await service.SetAsync("k", 1); + await service.GetAsync("k"); // hit - Assert.Equal(TimeSpan.FromSeconds(5), provider.LastTtl); + var samples = await metrics.CollectAsync(); + Assert.Equal(1, samples.Single(s => s.Name == "hits").Value); + Assert.Equal(1, samples.Single(s => s.Name == "misses").Value); } [Fact] - public async Task KeyPrefix_IsAppliedToProvider() + public async Task Set_PerEntryTtl_OverridesDefault() { var provider = new FakeCacheProvider(); - var service = NewService(provider, new CacheOptions { KeyPrefix = "app:" }); + var service = NewService(provider, new() { DefaultTtl = TimeSpan.FromSeconds(30) }); - await service.SetAsync("k", "v"); + await service.SetAsync("k", "v", TimeSpan.FromSeconds(5)); - Assert.True(await provider.ExistsAsync("app:k")); - Assert.False(await provider.ExistsAsync("k")); + Assert.Equal(TimeSpan.FromSeconds(5), provider.LastTtl); } [Fact] - public async Task GetOrSet_Miss_InvokesFactoryAndStores() + public async Task Set_UsesDefaultTtl_WhenNoneGiven() { var provider = new FakeCacheProvider(); - var service = NewService(provider); - var calls = 0; + var service = NewService(provider, new() { DefaultTtl = TimeSpan.FromSeconds(30) }); - var value = await service.GetOrSetAsync("k", _ => { calls++; return Task.FromResult(99); }); + await service.SetAsync("k", "v"); - Assert.Equal(99, value); - Assert.Equal(1, calls); - Assert.Equal(99, await service.GetAsync("k")); + Assert.Equal(TimeSpan.FromSeconds(30), provider.LastTtl); } [Fact] - public async Task GetOrSet_Hit_DoesNotInvokeFactory() + public async Task SetThenGet_RoundTrips() { - var service = NewService(new FakeCacheProvider()); - await service.SetAsync("k", 7); - var calls = 0; + var service = NewService(new()); - var value = await service.GetOrSetAsync("k", _ => { calls++; return Task.FromResult(0); }); + await service.SetAsync("k", 42); - Assert.Equal(7, value); - Assert.Equal(0, calls); + Assert.Equal(42, await service.GetAsync("k")); } - [Fact] - public async Task Metrics_RecordHitAndMiss() + private static CacheService NewService( + FakeCacheProvider provider, + CacheOptions? options = null, + CacheMetricsProvider? metrics = null + ) { - var metrics = new CacheMetricsProvider(); - var service = NewService(new FakeCacheProvider(), metrics: metrics); - - await service.GetAsync("absent"); // miss - await service.SetAsync("k", 1); - await service.GetAsync("k"); // hit + var serializer = new JsonDataSerializer(); - var samples = await metrics.CollectAsync(); - Assert.Equal(1, samples.Single(s => s.Name == "hits").Value); - Assert.Equal(1, samples.Single(s => s.Name == "misses").Value); + return new(provider, serializer, serializer, options ?? new CacheOptions(), metrics); } } diff --git a/tests/SquidStd.Tests/Caching/InMemoryCacheProviderTests.cs b/tests/SquidStd.Tests/Caching/InMemoryCacheProviderTests.cs index 810c8818..c803d1b3 100644 --- a/tests/SquidStd.Tests/Caching/InMemoryCacheProviderTests.cs +++ b/tests/SquidStd.Tests/Caching/InMemoryCacheProviderTests.cs @@ -1,3 +1,4 @@ +using System.Text; using Microsoft.Extensions.Caching.Memory; using SquidStd.Caching.Services; @@ -5,21 +6,16 @@ namespace SquidStd.Tests.Caching; public class InMemoryCacheProviderTests { - private static InMemoryCacheProvider NewProvider() - => new(new MemoryCache(new MemoryCacheOptions())); - - private static ReadOnlyMemory Bytes(string s) => System.Text.Encoding.UTF8.GetBytes(s); - [Fact] - public async Task SetThenGet_ReturnsValue() + public async Task Exists_And_Remove() { var provider = NewProvider(); await provider.SetAsync("k", Bytes("v"), null); - var value = await provider.GetAsync("k"); - - Assert.NotNull(value); - Assert.Equal("v", System.Text.Encoding.UTF8.GetString(value!.Value.Span)); + Assert.True(await provider.ExistsAsync("k")); + Assert.True(await provider.RemoveAsync("k")); + Assert.False(await provider.ExistsAsync("k")); + Assert.False(await provider.RemoveAsync("k")); } [Fact] @@ -27,15 +23,15 @@ public async Task Get_Missing_ReturnsNull() => Assert.Null(await NewProvider().GetAsync("absent")); [Fact] - public async Task Exists_And_Remove() + public async Task SetThenGet_ReturnsValue() { var provider = NewProvider(); await provider.SetAsync("k", Bytes("v"), null); - Assert.True(await provider.ExistsAsync("k")); - Assert.True(await provider.RemoveAsync("k")); - Assert.False(await provider.ExistsAsync("k")); - Assert.False(await provider.RemoveAsync("k")); + var value = await provider.GetAsync("k"); + + Assert.NotNull(value); + Assert.Equal("v", Encoding.UTF8.GetString(value!.Value.Span)); } [Fact] @@ -48,4 +44,10 @@ public async Task Ttl_Expires() Assert.Null(await provider.GetAsync("k")); } + + private static ReadOnlyMemory Bytes(string s) + => Encoding.UTF8.GetBytes(s); + + private static InMemoryCacheProvider NewProvider() + => new(new MemoryCache(new MemoryCacheOptions())); } diff --git a/tests/SquidStd.Tests/Caching/Redis/RedisCacheProviderTests.cs b/tests/SquidStd.Tests/Caching/Redis/RedisCacheProviderTests.cs index 27918634..e3e0b318 100644 --- a/tests/SquidStd.Tests/Caching/Redis/RedisCacheProviderTests.cs +++ b/tests/SquidStd.Tests/Caching/Redis/RedisCacheProviderTests.cs @@ -1,5 +1,4 @@ using System.Text; -using SquidStd.Caching.Redis.Data.Config; using SquidStd.Caching.Redis.Services; namespace SquidStd.Tests.Caching.Redis; @@ -16,38 +15,31 @@ public RedisCacheProviderTests(RedisContainerFixture fixture) _fixture = fixture; } - private RedisCacheProvider NewProvider() - => new(new RedisCacheOptions { Configuration = _fixture.ConnectionString }); - - private static ReadOnlyMemory Bytes(string s) => Encoding.UTF8.GetBytes(s); - private static string Text(ReadOnlyMemory b) => Encoding.UTF8.GetString(b.Span); - private static string Key() => "k-" + Guid.NewGuid().ToString("N"); - [Fact] - public async Task SetThenGet_RoundTrips() + public async Task Exists_And_Remove() { await using var provider = NewProvider(); await provider.StartAsync(); var key = Key(); + await provider.SetAsync(key, Bytes("v"), null); - await provider.SetAsync(key, Bytes("hello"), null); - var value = await provider.GetAsync(key); - - Assert.NotNull(value); - Assert.Equal("hello", Text(value!.Value)); + Assert.True(await provider.ExistsAsync(key)); + Assert.True(await provider.RemoveAsync(key)); + Assert.False(await provider.ExistsAsync(key)); } [Fact] - public async Task Exists_And_Remove() + public async Task SetThenGet_RoundTrips() { await using var provider = NewProvider(); await provider.StartAsync(); var key = Key(); - await provider.SetAsync(key, Bytes("v"), null); - Assert.True(await provider.ExistsAsync(key)); - Assert.True(await provider.RemoveAsync(key)); - Assert.False(await provider.ExistsAsync(key)); + await provider.SetAsync(key, Bytes("hello"), null); + var value = await provider.GetAsync(key); + + Assert.NotNull(value); + Assert.Equal("hello", Text(value!.Value)); } [Fact] @@ -62,4 +54,16 @@ public async Task Ttl_Expires() Assert.Null(await provider.GetAsync(key).WaitAsync(Timeout)); } + + private static ReadOnlyMemory Bytes(string s) + => Encoding.UTF8.GetBytes(s); + + private static string Key() + => "k-" + Guid.NewGuid().ToString("N"); + + private RedisCacheProvider NewProvider() + => new(new() { Configuration = _fixture.ConnectionString }); + + private static string Text(ReadOnlyMemory b) + => Encoding.UTF8.GetString(b.Span); } diff --git a/tests/SquidStd.Tests/Caching/Redis/RedisContainerFixture.cs b/tests/SquidStd.Tests/Caching/Redis/RedisContainerFixture.cs index e6ee7358..bd17f84e 100644 --- a/tests/SquidStd.Tests/Caching/Redis/RedisContainerFixture.cs +++ b/tests/SquidStd.Tests/Caching/Redis/RedisContainerFixture.cs @@ -11,11 +11,11 @@ public sealed class RedisContainerFixture : IAsyncLifetime public string ConnectionString => _container.GetConnectionString(); - public Task InitializeAsync() - => _container.StartAsync(); - public Task DisposeAsync() => _container.DisposeAsync().AsTask(); + + public Task InitializeAsync() + => _container.StartAsync(); } [CollectionDefinition(Name)] diff --git a/tests/SquidStd.Tests/Core/Json/JsonDataSerializerTests.cs b/tests/SquidStd.Tests/Core/Json/JsonDataSerializerTests.cs index 82587c95..64849751 100644 --- a/tests/SquidStd.Tests/Core/Json/JsonDataSerializerTests.cs +++ b/tests/SquidStd.Tests/Core/Json/JsonDataSerializerTests.cs @@ -11,23 +11,23 @@ private sealed class Sample } [Fact] - public void RoundTrip_PreservesValue() + public void Deserialize_NullJson_Throws() { var serializer = new JsonDataSerializer(); - var bytes = serializer.Serialize(new Sample { Name = "abc", Count = 7 }); - - var result = serializer.Deserialize(bytes); + var bytes = serializer.Serialize(null); - Assert.Equal("abc", result.Name); - Assert.Equal(7, result.Count); + Assert.Throws(() => serializer.Deserialize(bytes)); } [Fact] - public void Deserialize_NullJson_Throws() + public void RoundTrip_PreservesValue() { var serializer = new JsonDataSerializer(); - var bytes = serializer.Serialize(null); + var bytes = serializer.Serialize(new Sample { Name = "abc", Count = 7 }); - Assert.Throws(() => serializer.Deserialize(bytes)); + var result = serializer.Deserialize(bytes); + + Assert.Equal("abc", result.Name); + Assert.Equal(7, result.Count); } } diff --git a/tests/SquidStd.Tests/Database/ConnectionStringParserTests.cs b/tests/SquidStd.Tests/Database/ConnectionStringParserTests.cs index d536854f..73ee8d68 100644 --- a/tests/SquidStd.Tests/Database/ConnectionStringParserTests.cs +++ b/tests/SquidStd.Tests/Database/ConnectionStringParserTests.cs @@ -6,25 +6,18 @@ namespace SquidStd.Tests.Database; public class ConnectionStringParserTests { [Fact] - public void Parse_SqliteRelativePath() - { - var result = ConnectionStringParser.Parse("sqlite://app.db"); - Assert.Equal(DatabaseProviderType.Sqlite, result.Provider); - Assert.Equal("Data Source=app.db", result.NativeConnectionString); - } - - [Fact] - public void Parse_SqliteAbsolutePath() - { - var result = ConnectionStringParser.Parse("sqlite:///var/data/app.db"); - Assert.Equal("Data Source=/var/data/app.db", result.NativeConnectionString); - } + public void Parse_MissingHostForServerProvider_Throws() + => Assert.Throws(() => ConnectionStringParser.Parse("postgres:///db")); [Fact] - public void Parse_SqliteInMemory() + public void Parse_MySql_BuildsNativeString() { - var result = ConnectionStringParser.Parse("sqlite://:memory:"); - Assert.Equal("Data Source=:memory:", result.NativeConnectionString); + var result = ConnectionStringParser.Parse("mysql://root:secret@127.0.0.1:3307/shop"); + Assert.Equal(DatabaseProviderType.MySql, result.Provider); + Assert.Equal( + "Server=127.0.0.1;Port=3307;Uid=root;Pwd=secret;Database=shop", + result.NativeConnectionString + ); } [Fact] @@ -32,45 +25,56 @@ public void Parse_Postgres_BuildsNativeString() { var result = ConnectionStringParser.Parse("postgres://user:pass@db.host:5433/appdb"); Assert.Equal(DatabaseProviderType.Postgres, result.Provider); - Assert.Equal("Host=db.host;Port=5433;Username=user;Password=pass;Database=appdb", - result.NativeConnectionString); + Assert.Equal( + "Host=db.host;Port=5433;Username=user;Password=pass;Database=appdb", + result.NativeConnectionString + ); } [Fact] public void Parse_Postgres_DefaultsPortAndDecodesCredentials() { var result = ConnectionStringParser.Parse("postgresql://u%40corp:p%3Aw@h/appdb"); - Assert.Equal("Host=h;Port=5432;Username=u@corp;Password=p:w;Database=appdb", - result.NativeConnectionString); + Assert.Equal( + "Host=h;Port=5432;Username=u@corp;Password=p:w;Database=appdb", + result.NativeConnectionString + ); } [Fact] - public void Parse_MySql_BuildsNativeString() + public void Parse_SqliteAbsolutePath() { - var result = ConnectionStringParser.Parse("mysql://root:secret@127.0.0.1:3307/shop"); - Assert.Equal(DatabaseProviderType.MySql, result.Provider); - Assert.Equal("Server=127.0.0.1;Port=3307;Uid=root;Pwd=secret;Database=shop", - result.NativeConnectionString); + var result = ConnectionStringParser.Parse("sqlite:///var/data/app.db"); + Assert.Equal("Data Source=/var/data/app.db", result.NativeConnectionString); } [Fact] - public void Parse_SqlServer_BuildsNativeString() + public void Parse_SqliteInMemory() { - var result = ConnectionStringParser.Parse("sqlserver://sa:Str0ng@localhost:1433/master"); - Assert.Equal(DatabaseProviderType.SqlServer, result.Provider); - Assert.Equal("Server=localhost,1433;User Id=sa;Password=Str0ng;Database=master;TrustServerCertificate=true", - result.NativeConnectionString); + var result = ConnectionStringParser.Parse("sqlite://:memory:"); + Assert.Equal("Data Source=:memory:", result.NativeConnectionString); } [Fact] - public void Parse_UnknownScheme_Throws() + public void Parse_SqliteRelativePath() { - Assert.Throws(() => ConnectionStringParser.Parse("oracle://h/db")); + var result = ConnectionStringParser.Parse("sqlite://app.db"); + Assert.Equal(DatabaseProviderType.Sqlite, result.Provider); + Assert.Equal("Data Source=app.db", result.NativeConnectionString); } [Fact] - public void Parse_MissingHostForServerProvider_Throws() + public void Parse_SqlServer_BuildsNativeString() { - Assert.Throws(() => ConnectionStringParser.Parse("postgres:///db")); + var result = ConnectionStringParser.Parse("sqlserver://sa:Str0ng@localhost:1433/master"); + Assert.Equal(DatabaseProviderType.SqlServer, result.Provider); + Assert.Equal( + "Server=localhost,1433;User Id=sa;Password=Str0ng;Database=master;TrustServerCertificate=true", + result.NativeConnectionString + ); } + + [Fact] + public void Parse_UnknownScheme_Throws() + => Assert.Throws(() => ConnectionStringParser.Parse("oracle://h/db")); } diff --git a/tests/SquidStd.Tests/Database/FreeSqlDataAccessTests.cs b/tests/SquidStd.Tests/Database/FreeSqlDataAccessTests.cs index 04b42344..7bdb22d8 100644 --- a/tests/SquidStd.Tests/Database/FreeSqlDataAccessTests.cs +++ b/tests/SquidStd.Tests/Database/FreeSqlDataAccessTests.cs @@ -1,4 +1,3 @@ -using SquidStd.Database.Abstractions.Data.Database; using SquidStd.Database.Abstractions.Data.Entities; using SquidStd.Database.Data; using SquidStd.Database.Services; @@ -16,118 +15,79 @@ public class FreeSqlDataAccessTests : IAsyncLifetime private string _dbPath = string.Empty; private DatabaseService _service = null!; - public async Task InitializeAsync() + [Fact] + public async Task BulkUpdateAndDelete_AffectRows() { - _dbPath = Path.Combine(Path.GetTempPath(), "squidstd-db-" + Guid.NewGuid().ToString("N") + ".db"); - _service = new DatabaseService(new DatabaseConfig + var access = NewAccess(); + var users = new List { - ConnectionString = $"sqlite://{_dbPath}", - AutoMigrate = true - }); - await _service.StartAsync(); - } - - public async Task DisposeAsync() - { - await _service.StopAsync(); + new() { Name = "a", Age = 1 }, + new() { Name = "b", Age = 2 }, + new() { Name = "c", Age = 3 } + }; + await access.BulkInsertAsync(users); - if (File.Exists(_dbPath)) + foreach (var u in users) { - File.Delete(_dbPath); + u.Age += 100; } - } - private FreeSqlDataAccess NewAccess() - => new(_service); - - [Fact] - public async Task InsertAsync_SetsIdAndTimestamps() - { - var access = NewAccess(); - var inserted = await access.InsertAsync(new SampleUser { Name = "Ann", Age = 30 }); - - Assert.NotEqual(Guid.Empty, inserted.Id); - Assert.NotEqual(default, inserted.Created); - Assert.Equal(inserted.Created, inserted.Updated); - - var fetched = await access.GetByIdAsync(inserted.Id); - Assert.NotNull(fetched); - Assert.Equal("Ann", fetched!.Name); + Assert.Equal(3, await access.BulkUpdateAsync(users)); + Assert.Equal(3, await access.CountAsync(u => u.Age > 100)); + Assert.Equal(3, await access.BulkDeleteAsync(u => u.Age > 100)); + Assert.Equal(0, await access.CountAsync()); } [Fact] - public async Task UpdateAsync_BumpsUpdated() + public async Task CountAndExists_RespectPredicate() { var access = NewAccess(); - var user = await access.InsertAsync(new SampleUser { Name = "Bob", Age = 20 }); - - user.Age = 21; - var updated = await access.UpdateAsync(user); + await access.BulkInsertAsync( + new[] + { + new SampleUser { Name = "x", Age = 10 }, + new SampleUser { Name = "y", Age = 50 } + } + ); - Assert.Equal(21, updated.Age); - Assert.True(updated.Updated >= updated.Created); + Assert.Equal(2, await access.CountAsync()); + Assert.Equal(1, await access.CountAsync(u => u.Age >= 50)); + Assert.True(await access.ExistsAsync(u => u.Age == 10)); + Assert.False(await access.ExistsAsync(u => u.Age == 999)); } [Fact] public async Task DeleteAsync_RemovesRow() { var access = NewAccess(); - var user = await access.InsertAsync(new SampleUser { Name = "Cara", Age = 40 }); + var user = await access.InsertAsync(new() { 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)); } - [Fact] - public async Task CountAndExists_RespectPredicate() - { - var access = NewAccess(); - await access.BulkInsertAsync(new[] - { - new SampleUser { Name = "x", Age = 10 }, - new SampleUser { Name = "y", Age = 50 } - }); - - Assert.Equal(2, await access.CountAsync()); - Assert.Equal(1, await access.CountAsync(u => u.Age >= 50)); - Assert.True(await access.ExistsAsync(u => u.Age == 10)); - Assert.False(await access.ExistsAsync(u => u.Age == 999)); - } - - [Fact] - public async Task BulkUpdateAndDelete_AffectRows() + public async Task DisposeAsync() { - var access = NewAccess(); - var users = new List - { - new() { Name = "a", Age = 1 }, - new() { Name = "b", Age = 2 }, - new() { Name = "c", Age = 3 } - }; - await access.BulkInsertAsync(users); + await _service.StopAsync(); - foreach (var u in users) + if (File.Exists(_dbPath)) { - u.Age += 100; + File.Delete(_dbPath); } - - Assert.Equal(3, await access.BulkUpdateAsync(users)); - Assert.Equal(3, await access.CountAsync(u => u.Age > 100)); - Assert.Equal(3, await access.BulkDeleteAsync(u => u.Age > 100)); - Assert.Equal(0, await access.CountAsync()); } [Fact] public async Task GetPagedAsync_ReturnsMetadata() { var access = NewAccess(); + for (var i = 0; i < 25; i++) { - await access.InsertAsync(new SampleUser { Name = $"u{i}", Age = i }); + await access.InsertAsync(new() { Name = $"u{i}", Age = i }); } - var page = await access.GetPagedAsync(page: 2, pageSize: 10, orderBy: u => u.Age); + var page = await access.GetPagedAsync(2, 10, orderBy: u => u.Age); Assert.Equal(10, page.Items.Count); Assert.Equal(25, page.TotalCount); @@ -137,11 +97,24 @@ 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 SampleUser { Name = "first", Age = 1 }); + var first = await access.InsertAsync(new() { 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 }; @@ -149,4 +122,35 @@ public async Task InsertAsync_RollsBackOnFailure() await Assert.ThrowsAnyAsync(() => access.InsertAsync(duplicate)); Assert.Equal(1, await access.CountAsync()); } + + [Fact] + public async Task InsertAsync_SetsIdAndTimestamps() + { + var access = NewAccess(); + var inserted = await access.InsertAsync(new() { Name = "Ann", Age = 30 }); + + Assert.NotEqual(Guid.Empty, inserted.Id); + Assert.NotEqual(default, inserted.Created); + Assert.Equal(inserted.Created, inserted.Updated); + + var fetched = await access.GetByIdAsync(inserted.Id); + Assert.NotNull(fetched); + Assert.Equal("Ann", fetched!.Name); + } + + [Fact] + public async Task UpdateAsync_BumpsUpdated() + { + var access = NewAccess(); + var user = await access.InsertAsync(new() { Name = "Bob", Age = 20 }); + + user.Age = 21; + var updated = await access.UpdateAsync(user); + + Assert.Equal(21, updated.Age); + Assert.True(updated.Updated >= updated.Created); + } + + private FreeSqlDataAccess NewAccess() + => new(_service); } diff --git a/tests/SquidStd.Tests/Database/PagedResultDataTests.cs b/tests/SquidStd.Tests/Database/PagedResultDataTests.cs index abee62ff..4b5d1c66 100644 --- a/tests/SquidStd.Tests/Database/PagedResultDataTests.cs +++ b/tests/SquidStd.Tests/Database/PagedResultDataTests.cs @@ -9,7 +9,7 @@ public void Create_ComputesPagingMetadata() { var items = new[] { 1, 2, 3 }; - var result = PagedResultData.Create(items, page: 2, pageSize: 3, totalCount: 10); + var result = PagedResultData.Create(items, 2, 3, 10); Assert.Equal(items, result.Items); Assert.Equal(2, result.Page); @@ -21,22 +21,22 @@ public void Create_ComputesPagingMetadata() } [Fact] - public void Create_FirstPageHasNoPrevious() + public void Create_EmptyResultHasZeroPages() { - var result = PagedResultData.Create(new[] { 1 }, page: 1, pageSize: 10, totalCount: 1); + var result = PagedResultData.Create(Array.Empty(), 1, 10, 0); - Assert.Equal(1, result.TotalPages); + Assert.Empty(result.Items); + Assert.Equal(0, result.TotalPages); Assert.False(result.HasNext); Assert.False(result.HasPrevious); } [Fact] - public void Create_EmptyResultHasZeroPages() + public void Create_FirstPageHasNoPrevious() { - var result = PagedResultData.Create(Array.Empty(), page: 1, pageSize: 10, totalCount: 0); + var result = PagedResultData.Create(new[] { 1 }, 1, 10, 1); - Assert.Empty(result.Items); - Assert.Equal(0, result.TotalPages); + Assert.Equal(1, result.TotalPages); Assert.False(result.HasNext); Assert.False(result.HasPrevious); } diff --git a/tests/SquidStd.Tests/Env/ReplaceEnvTests.cs b/tests/SquidStd.Tests/Env/ReplaceEnvTests.cs index 5928bdf8..84180983 100644 --- a/tests/SquidStd.Tests/Env/ReplaceEnvTests.cs +++ b/tests/SquidStd.Tests/Env/ReplaceEnvTests.cs @@ -4,10 +4,22 @@ namespace SquidStd.Tests.Env; public class ReplaceEnvTests { + [Fact] + public void ReplaceEnv_LeavesUnknownVariableUntouched() + { + Environment.SetEnvironmentVariable("SQUID_MISSING_VAR", null); + Assert.Equal("x=$SQUID_MISSING_VAR", "x=$SQUID_MISSING_VAR".ReplaceEnv()); + } + + [Theory, InlineData(null), InlineData(""), InlineData("no tokens here")] + public void ReplaceEnv_PassesThroughWhenNothingToReplace(string? input) + => Assert.Equal(input, input!.ReplaceEnv()); + [Fact] public void ReplaceEnv_SubstitutesKnownVariable() { Environment.SetEnvironmentVariable("SQUID_TEST_VAR", "secret"); + try { Assert.Equal("pwd=secret;", "pwd=$SQUID_TEST_VAR;".ReplaceEnv()); @@ -18,18 +30,12 @@ public void ReplaceEnv_SubstitutesKnownVariable() } } - [Fact] - public void ReplaceEnv_LeavesUnknownVariableUntouched() - { - Environment.SetEnvironmentVariable("SQUID_MISSING_VAR", null); - Assert.Equal("x=$SQUID_MISSING_VAR", "x=$SQUID_MISSING_VAR".ReplaceEnv()); - } - [Fact] public void ReplaceEnv_SubstitutesMultipleTokens() { Environment.SetEnvironmentVariable("SQUID_A", "1"); Environment.SetEnvironmentVariable("SQUID_B", "2"); + try { Assert.Equal("1-2", "$SQUID_A-$SQUID_B".ReplaceEnv()); @@ -40,13 +46,4 @@ public void ReplaceEnv_SubstitutesMultipleTokens() Environment.SetEnvironmentVariable("SQUID_B", null); } } - - [Theory] - [InlineData(null)] - [InlineData("")] - [InlineData("no tokens here")] - public void ReplaceEnv_PassesThroughWhenNothingToReplace(string? input) - { - Assert.Equal(input, input!.ReplaceEnv()); - } } diff --git a/tests/SquidStd.Tests/Health/HealthCheckServiceTests.cs b/tests/SquidStd.Tests/Health/HealthCheckServiceTests.cs index 9173b23a..db9c7c82 100644 --- a/tests/SquidStd.Tests/Health/HealthCheckServiceTests.cs +++ b/tests/SquidStd.Tests/Health/HealthCheckServiceTests.cs @@ -9,21 +9,6 @@ namespace SquidStd.Tests.Health; public class HealthCheckServiceTests { - private static HealthCheckService NewService(HealthCheckOptions options, params IHealthCheck[] checks) - => new(checks, options); - - private static HealthCheckOptions Options(double timeoutSeconds = 5) - => new() { CheckTimeout = TimeSpan.FromSeconds(timeoutSeconds) }; - - [Fact] - public async Task NoChecks_ReturnsHealthyEmptyReport() - { - var report = await NewService(Options()).CheckHealthAsync(); - - Assert.Equal(HealthStatus.Healthy, report.Status); - Assert.Empty(report.Entries); - } - [Fact] public async Task AllHealthy_ReportsHealthyWithEntries() { @@ -37,19 +22,19 @@ public async Task AllHealthy_ReportsHealthyWithEntries() } [Fact] - public async Task OneUnhealthy_MakesOverallUnhealthy() + public async Task CheckThatExceedsTimeout_IsUnhealthy_OthersUnaffected() { var service = NewService( - Options(), - new FakeHealthCheck("ok"), - new FakeHealthCheck("bad", HealthCheckResult.Unhealthy("nope")) + Options(0.05), + new FakeHealthCheck("slow", delay: TimeSpan.FromSeconds(2)), + new FakeHealthCheck("fast") ); var report = await service.CheckHealthAsync(); - Assert.Equal(HealthStatus.Unhealthy, report.Status); - Assert.Equal(HealthStatus.Unhealthy, report.Entries["bad"].Status); - Assert.Equal(HealthStatus.Healthy, report.Entries["ok"].Status); + Assert.Equal(HealthStatus.Unhealthy, report.Entries["slow"].Status); + Assert.Contains("imed out", report.Entries["slow"].Description); + Assert.Equal(HealthStatus.Healthy, report.Entries["fast"].Status); } [Fact] @@ -71,21 +56,11 @@ public async Task CheckThatThrows_IsCapturedAsUnhealthy_AndDoesNotBreakOthers() } [Fact] - public async Task CheckThatExceedsTimeout_IsUnhealthy_OthersUnaffected() - { - var service = NewService( - Options(timeoutSeconds: 0.05), - new FakeHealthCheck("slow", delay: TimeSpan.FromSeconds(2)), - new FakeHealthCheck("fast") + public void Ctor_NonPositiveTimeout_Throws() + => Assert.Throws( + () => new HealthCheckService([], new() { CheckTimeout = TimeSpan.Zero }) ); - var report = await service.CheckHealthAsync(); - - Assert.Equal(HealthStatus.Unhealthy, report.Entries["slow"].Status); - Assert.Contains("imed out", report.Entries["slow"].Description); - Assert.Equal(HealthStatus.Healthy, report.Entries["fast"].Status); - } - [Fact] public async Task DuplicateNames_AreMadeUnique() { @@ -109,10 +84,33 @@ public async Task ExternalCancellation_Propagates() } [Fact] - public void Ctor_NonPositiveTimeout_Throws() + public async Task NoChecks_ReturnsHealthyEmptyReport() + { + var report = await NewService(Options()).CheckHealthAsync(); + + Assert.Equal(HealthStatus.Healthy, report.Status); + Assert.Empty(report.Entries); + } + + [Fact] + public async Task OneUnhealthy_MakesOverallUnhealthy() { - Assert.Throws( - () => new HealthCheckService([], new HealthCheckOptions { CheckTimeout = TimeSpan.Zero }) + var service = NewService( + Options(), + new FakeHealthCheck("ok"), + new FakeHealthCheck("bad", HealthCheckResult.Unhealthy("nope")) ); + + var report = await service.CheckHealthAsync(); + + Assert.Equal(HealthStatus.Unhealthy, report.Status); + Assert.Equal(HealthStatus.Unhealthy, report.Entries["bad"].Status); + Assert.Equal(HealthStatus.Healthy, report.Entries["ok"].Status); } + + private static HealthCheckService NewService(HealthCheckOptions options, params IHealthCheck[] checks) + => new(checks, options); + + private static HealthCheckOptions Options(double timeoutSeconds = 5) + => new() { CheckTimeout = TimeSpan.FromSeconds(timeoutSeconds) }; } diff --git a/tests/SquidStd.Tests/Integration/Mail/GreenMailContainerFixture.cs b/tests/SquidStd.Tests/Integration/Mail/GreenMailContainerFixture.cs new file mode 100644 index 00000000..c6d4d846 --- /dev/null +++ b/tests/SquidStd.Tests/Integration/Mail/GreenMailContainerFixture.cs @@ -0,0 +1,40 @@ +using DotNet.Testcontainers.Builders; +using DotNet.Testcontainers.Containers; + +namespace SquidStd.Tests.Integration.Mail; + +/// Starts a GreenMail container exposing SMTP/IMAP/POP3 on plain ports (auth disabled). +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(); + + public string Host => _container.Hostname; + + public int SmtpPort => _container.GetMappedPublicPort(3025); + + public int ImapPort => _container.GetMappedPublicPort(3143); + + public int Pop3Port => _container.GetMappedPublicPort(3110); + + public Task DisposeAsync() + => _container.DisposeAsync().AsTask(); + + public Task InitializeAsync() + => _container.StartAsync(); +} + +[CollectionDefinition(Name)] +public sealed class GreenMailCollection : ICollectionFixture +{ + public const string Name = "GreenMail"; +} diff --git a/tests/SquidStd.Tests/Integration/Mail/MailPollingServiceTests.cs b/tests/SquidStd.Tests/Integration/Mail/MailPollingServiceTests.cs new file mode 100644 index 00000000..28095c39 --- /dev/null +++ b/tests/SquidStd.Tests/Integration/Mail/MailPollingServiceTests.cs @@ -0,0 +1,86 @@ +using MailKit.Net.Smtp; +using MailKit.Security; +using MimeKit; +using SquidStd.Core.Interfaces.Events; +using SquidStd.Mail.Abstractions.Data.Events; +using SquidStd.Mail.Abstractions.Types.Mail; +using SquidStd.Mail.MailKit.Services; +using SquidStd.Services.Core.Services; +using SquidStd.Tests.Mail.Support; + +namespace SquidStd.Tests.Integration.Mail; + +[Collection(GreenMailCollection.Name)] +public class MailPollingServiceTests +{ + private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(60); + + private readonly GreenMailContainerFixture _fixture; + + 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() + { + var user = "poll-user@example.com"; + var message = new MimeMessage(); + message.From.Add(new MailboxAddress("Sender", "sender@example.com")); + message.To.Add(new MailboxAddress("Rcpt", user)); + message.Subject = "poll-subject"; + message.Body = new TextPart("plain") { Text = "hi" }; + + using (var smtp = new SmtpClient()) + { + await smtp.ConnectAsync(_fixture.Host, _fixture.SmtpPort, SecureSocketOptions.None).WaitAsync(Timeout); + await smtp.SendAsync(message).WaitAsync(Timeout); + await smtp.DisconnectAsync(true).WaitAsync(Timeout); + } + + var eventBus = new EventBusService(); + var received = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + eventBus.RegisterAsyncListener(new DelegateListener(e => received.TrySetResult(e))); + + var reader = new ImapMailReader( + new() + { + Protocol = MailProtocolType.Imap, + Host = _fixture.Host, + Port = _fixture.ImapPort, + UseSsl = false, + Username = user, + Password = "pwd" + } + ); + + using var service = new MailPollingService( + reader, + eventBus, + new FakeTimerService(), + new() { PollIntervalSeconds = 60 } + ); + await service.PollOnceAsync(); + + var evt = await received.Task.WaitAsync(Timeout); + Assert.Equal("poll-subject", evt.Message.Subject); + } +} diff --git a/tests/SquidStd.Tests/Integration/Mail/MailQueueIntegrationTests.cs b/tests/SquidStd.Tests/Integration/Mail/MailQueueIntegrationTests.cs new file mode 100644 index 00000000..83fa0fc4 --- /dev/null +++ b/tests/SquidStd.Tests/Integration/Mail/MailQueueIntegrationTests.cs @@ -0,0 +1,81 @@ +using DryIoc; +using SquidStd.Core.Interfaces.Events; +using SquidStd.Mail.Abstractions.Data; +using SquidStd.Mail.Abstractions.Types.Mail; +using SquidStd.Mail.MailKit.Extensions; +using SquidStd.Mail.MailKit.Services; +using SquidStd.Mail.Queue.Extensions; +using SquidStd.Mail.Queue.Interfaces; +using SquidStd.Mail.Queue.Services; +using SquidStd.Messaging.Extensions; +using SquidStd.Services.Core.Services; + +namespace SquidStd.Tests.Integration.Mail; + +[Collection(GreenMailCollection.Name)] +public class MailQueueIntegrationTests +{ + private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(60); + + private readonly GreenMailContainerFixture _fixture; + + public MailQueueIntegrationTests(GreenMailContainerFixture fixture) + { + _fixture = fixture; + } + + [Fact] + public async Task Enqueue_IsSentByConsumer_AndDelivered() + { + var recipient = "queue-target@example.com"; + + var container = new Container(); + container.RegisterInstance(new EventBusService()); + container.AddInMemoryMessaging(); + container.AddMailSender(new() { 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" + } + ); + + var reader = new ImapMailReader( + new() + { + Protocol = MailProtocolType.Imap, + Host = _fixture.Host, + Port = _fixture.ImapPort, + UseSsl = false, + Username = recipient, + Password = "pwd" + } + ); + + IReadOnlyList received = []; + var deadline = DateTime.UtcNow + Timeout; + + while (DateTime.UtcNow < deadline && received.Count == 0) + { + received = await reader.FetchNewAsync().WaitAsync(Timeout); + + if (received.Count == 0) + { + await Task.Delay(500); + } + } + + await consumer.StopAsync(); + + Assert.Contains(received, m => m.Subject == "queued-subject"); + } +} diff --git a/tests/SquidStd.Tests/Integration/Mail/MailReaderTests.cs b/tests/SquidStd.Tests/Integration/Mail/MailReaderTests.cs new file mode 100644 index 00000000..e465f03b --- /dev/null +++ b/tests/SquidStd.Tests/Integration/Mail/MailReaderTests.cs @@ -0,0 +1,99 @@ +using System.Text; +using MailKit.Net.Smtp; +using MailKit.Security; +using MimeKit; +using SquidStd.Mail.Abstractions.Data.Config; +using SquidStd.Mail.Abstractions.Types.Mail; +using SquidStd.Mail.MailKit.Services; + +namespace SquidStd.Tests.Integration.Mail; + +[Collection(GreenMailCollection.Name)] +public class MailReaderTests +{ + private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(60); + + private readonly GreenMailContainerFixture _fixture; + + public MailReaderTests(GreenMailContainerFixture fixture) + { + _fixture = fixture; + } + + [Fact] + public async Task Imap_FetchesNewMessage_AndMarksSeen() + { + var user = "imap-user@example.com"; + await SendAsync(user, "imap-subject", true); + var reader = new ImapMailReader(ImapOptions(user)); + + var first = await reader.FetchNewAsync().WaitAsync(Timeout); + var second = await reader.FetchNewAsync().WaitAsync(Timeout); + + var message = Assert.Single(first); + Assert.Equal("imap-subject", message.Subject); + Assert.Single(message.Attachments); + Assert.NotNull(message.RawEml); + Assert.Empty(second); + } + + [Fact] + public async Task Pop3_FetchesNewMessage_WithDelete() + { + var user = "pop-user@example.com"; + await SendAsync(user, "pop-subject", false); + var reader = new Pop3MailReader( + new() + { + Protocol = MailProtocolType.Pop3, + Host = _fixture.Host, + Port = _fixture.Pop3Port, + UseSsl = false, + Username = user, + Password = "pwd", + DeleteAfterDownload = true + } + ); + + var first = await reader.FetchNewAsync().WaitAsync(Timeout); + var second = await reader.FetchNewAsync().WaitAsync(Timeout); + + Assert.Contains(first, m => m.Subject == "pop-subject"); + Assert.Empty(second); + } + + private MailOptions ImapOptions(string user) + => new() + { + Protocol = MailProtocolType.Imap, + Host = _fixture.Host, + Port = _fixture.ImapPort, + UseSsl = false, + Username = user, + Password = "pwd", + MarkAsSeen = true, + IncludeRawEml = true + }; + + private async Task SendAsync(string to, string subject, bool withAttachment) + { + var message = new MimeMessage(); + message.From.Add(new MailboxAddress("Sender", "sender@example.com")); + message.To.Add(new MailboxAddress("Rcpt", to)); + message.Subject = subject; + + var builder = new BodyBuilder { TextBody = "body" }; + + if (withAttachment) + { + builder.Attachments.Add("a.txt", Encoding.UTF8.GetBytes("xyz"), ContentType.Parse("text/plain")); + } + + message.Body = builder.ToMessageBody(); + + using var smtp = new SmtpClient(); + await smtp.ConnectAsync(_fixture.Host, _fixture.SmtpPort, SecureSocketOptions.None).WaitAsync(Timeout); + await smtp.SendAsync(message).WaitAsync(Timeout); + await smtp.DisconnectAsync(true).WaitAsync(Timeout); + } +} diff --git a/tests/SquidStd.Tests/Integration/Mail/MailSenderTests.cs b/tests/SquidStd.Tests/Integration/Mail/MailSenderTests.cs new file mode 100644 index 00000000..ef5a492b --- /dev/null +++ b/tests/SquidStd.Tests/Integration/Mail/MailSenderTests.cs @@ -0,0 +1,109 @@ +using System.Text; +using SquidStd.Core.Interfaces.Events; +using SquidStd.Mail.Abstractions.Data.Events; +using SquidStd.Mail.Abstractions.Exceptions; +using SquidStd.Mail.Abstractions.Types.Mail; +using SquidStd.Mail.MailKit.Services; +using SquidStd.Services.Core.Services; + +namespace SquidStd.Tests.Integration.Mail; + +[Collection(GreenMailCollection.Name)] +public class MailSenderTests +{ + private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(60); + + private readonly GreenMailContainerFixture _fixture; + + 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))); + + var sender = new MailKitMailSender( + new() { 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); + + var sentEvent = await sent.Task.WaitAsync(Timeout); + Assert.Equal("sent-subject", sentEvent.Subject); + + var reader = new ImapMailReader( + new() + { + Protocol = MailProtocolType.Imap, + Host = _fixture.Host, + Port = _fixture.ImapPort, + UseSsl = false, + Username = recipient, + Password = "pwd" + } + ); + + var received = await reader.FetchNewAsync().WaitAsync(Timeout); + Assert.Contains(received, m => m.Subject == "sent-subject"); + } + + [Fact] + 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) + ); + + var failedEvent = await failed.Task.WaitAsync(Timeout); + Assert.Equal("fail-subject", failedEvent.Subject); + } +} diff --git a/tests/SquidStd.Tests/Integration/Search/ElasticSearchServiceTests.cs b/tests/SquidStd.Tests/Integration/Search/ElasticSearchServiceTests.cs new file mode 100644 index 00000000..eb5ef63c --- /dev/null +++ b/tests/SquidStd.Tests/Integration/Search/ElasticSearchServiceTests.cs @@ -0,0 +1,122 @@ +using DryIoc; +using SquidStd.Search.Abstractions.Attributes; +using SquidStd.Search.Abstractions.Interfaces; +using SquidStd.Search.Elasticsearch.Extensions; +using SquidStd.Search.Elasticsearch.Linq; + +namespace SquidStd.Tests.Integration.Search; + +[Collection(ElasticsearchCollection.Name)] +public class ElasticSearchServiceTests +{ + private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(60); + + private readonly ElasticsearchContainerFixture _fixture; + + 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() + { + var search = NewService(); + await search.IndexAsync(new Order("cnt", "counted", 1, "X"), true).WaitAsync(Timeout); + + var count = await search.Query().Where(o => o.Status == "counted").CountAsync(); + var first = await search.Query().Where(o => o.Status == "counted").FirstOrDefaultAsync(); + var none = await search.Query().Where(o => o.Status == "nope-nothing").FirstOrDefaultAsync(); + + Assert.True(count >= 1); + Assert.NotNull(first); + Assert.Null(none); + } + + [Fact] + public async Task Delete_RemovesDocument() + { + var search = NewService(); + await search.IndexAsync(new Order("del", "open", 1, "Y"), true).WaitAsync(Timeout); + + var deleted = await search.DeleteAsync("del", true).WaitAsync(Timeout); + var missing = await search.DeleteAsync("does-not-exist", true).WaitAsync(Timeout); + var results = await search.Query().Where(o => o.Id == "del").ToListAsync(); + + Assert.True(deleted); + Assert.False(missing); + Assert.DoesNotContain(results, o => o.Id == "del"); + } + + [Fact] + public async Task FullText_Match() + { + var search = NewService(); + await search.IndexAsync(new Order("ft1", "open", 10, "Gaming Laptop Pro"), true).WaitAsync(Timeout); + + var results = await search.Query().Match("name", "laptop").ToListAsync(); + + Assert.Contains(results, o => o.Id == "ft1"); + } + + [Fact] + public async Task Index_Then_Query_FindsDocument() + { + var search = NewService(); + await search.IndexAsync(new Order("1", "open", 150, "Laptop"), true).WaitAsync(Timeout); + + var results = await search.Query().Where(o => o.Status == "open").ToListAsync(); + + Assert.Contains(results, o => o.Id == "1"); + } + + [Fact] + public async Task Query_MissingIndex_ReturnsEmpty() + { + var search = NewService(); + + var results = await search.Query().Where(d => d.Status == "x").ToListAsync().WaitAsync(Timeout); + + Assert.Empty(results); + } + + [Fact] + 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); + + var results = await search.Query() + .Where(o => o.Total > 100) + .OrderByDescending(o => o.Total) + .Take(1) + .ToListAsync(); + + Assert.Single(results); + Assert.Equal("c", results[0].Id); + } + + private ISearchService NewService() + { + var container = new Container(); + container.AddElasticsearch(new() { Uri = new(_fixture.ConnectionString) }); + + return container.Resolve(); + } +} diff --git a/tests/SquidStd.Tests/Integration/Search/ElasticsearchContainerFixture.cs b/tests/SquidStd.Tests/Integration/Search/ElasticsearchContainerFixture.cs new file mode 100644 index 00000000..0db6a967 --- /dev/null +++ b/tests/SquidStd.Tests/Integration/Search/ElasticsearchContainerFixture.cs @@ -0,0 +1,42 @@ +using System.Net; +using DotNet.Testcontainers.Builders; +using Testcontainers.Elasticsearch; + +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. +/// +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(); + + // 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(); + + public Task InitializeAsync() + => _container.StartAsync(); +} + +[CollectionDefinition(Name)] +public sealed class ElasticsearchCollection : ICollectionFixture +{ + public const string Name = "Elasticsearch"; +} diff --git a/tests/SquidStd.Tests/Integration/Templates/TemplatePackTests.cs b/tests/SquidStd.Tests/Integration/Templates/TemplatePackTests.cs new file mode 100644 index 00000000..7d636309 --- /dev/null +++ b/tests/SquidStd.Tests/Integration/Templates/TemplatePackTests.cs @@ -0,0 +1,189 @@ +using System.Diagnostics; + +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. +/// +public sealed class TemplatePackTests : IDisposable +{ + private readonly string _repoRoot; + private readonly string _hive; + private readonly string _workDir; + private readonly bool _dotnetAvailable; + private readonly bool _installed; + + public TemplatePackTests() + { + _repoRoot = FindRepoRoot(); + _hive = Path.Combine(Path.GetTempPath(), "squidstd-templates-hive-" + Guid.NewGuid().ToString("N")); + _workDir = Path.Combine(Path.GetTempPath(), "squidstd-templates-out-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_hive); + Directory.CreateDirectory(_workDir); + + _dotnetAvailable = TryRun("dotnet", "--version", _repoRoot, out _); + + if (!_dotnetAvailable) + { + return; + } + + var project = Path.Combine(_repoRoot, "src", "SquidStd.Templates", "SquidStd.Templates.csproj"); + 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(); + + _installed = TryRun("dotnet", $"new install \"{nupkg}\" --debug:custom-hive \"{_hive}\"", _repoRoot, out _); + } + + // xUnit 2.9.3 has no dynamic skip; guard with an early return when the CLI/install is unavailable. + private bool Ready => _dotnetAvailable && _installed; + + [Fact] + public void AspNetCore_Instantiates_WithDockerfile() + { + if (!Ready) + { + return; + } + + var outDir = New("squidstd-aspnetcore", "Acme.Api"); + + Assert.True(File.Exists(Path.Combine(outDir, "Acme.Api.csproj"))); + Assert.True(File.Exists(Path.Combine(outDir, "Dockerfile"))); + 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() + { + if (!Ready) + { + return; + } + + var outDir = New("squidstd-host", "Acme.Host"); + + var csproj = Path.Combine(outDir, "Acme.Host.csproj"); + Assert.True(File.Exists(csproj)); + var text = File.ReadAllText(csproj); + Assert.DoesNotContain("$squidstdversion$", text); + Assert.DoesNotContain("SquidStd.Host.Template", text); + Assert.Contains("SquidStd.Services.Core", text); + } + + [Fact] + public void Manager_Instantiates_WithEndpointsMapped() + { + if (!Ready) + { + return; + } + + var outDir = New("squidstd-manager", "Acme.Manager"); + + var program = File.ReadAllText(Path.Combine(outDir, "Program.cs")); + Assert.Contains("MapWorkerManagerEndpoints", program); + Assert.Contains("AddWorkerManager", program); + } + + [Fact] + public void Worker_InMemory_WiresInMemoryMessaging() + { + if (!Ready) + { + return; + } + + var outDir = New("squidstd-worker", "Acme.WorkerMem", "--messaging inmemory"); + + var program = File.ReadAllText(Path.Combine(outDir, "Program.cs")); + Assert.Contains("AddInMemoryMessaging", program); + Assert.DoesNotContain("AddRabbitMqMessaging", program); + } + + [Fact] + public void Worker_RabbitMq_WiresRabbitMqMessaging() + { + if (!Ready) + { + return; + } + + var outDir = New("squidstd-worker", "Acme.Worker", "--messaging rabbitmq"); + + var program = File.ReadAllText(Path.Combine(outDir, "Program.cs")); + Assert.Contains("AddRabbitMqMessaging", program); + Assert.DoesNotContain("AddInMemoryMessaging", program); + Assert.Contains("SquidStd.Messaging.RabbitMq", File.ReadAllText(Path.Combine(outDir, "Acme.Worker.csproj"))); + } + + private static string FindRepoRoot() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + + while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "SquidStd.slnx"))) + { + dir = dir.Parent; + } + + return dir?.FullName ?? throw new InvalidOperationException("Could not locate the repo root (SquidStd.slnx)."); + } + + private string New(string shortName, string name, string extraArgs = "") + { + var outDir = Path.Combine(_workDir, name); + var args = $"new {shortName} -n {name} -o \"{outDir}\" --debug:custom-hive \"{_hive}\" {extraArgs}".Trim(); + Assert.True(TryRun("dotnet", args, _repoRoot, out var output), $"dotnet new failed: {output}"); + + return outDir; + } + + private static void TryDelete(string path) + { + try + { + if (Directory.Exists(path)) + { + Directory.Delete(path, true); + } + } + catch + { + // Best-effort cleanup. + } + } + + private static bool TryRun(string file, string args, string workingDir, out string output) + { + var psi = new ProcessStartInfo(file, args) + { + WorkingDirectory = workingDir, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false + }; + + using var process = Process.Start(psi)!; + output = process.StandardOutput.ReadToEnd() + process.StandardError.ReadToEnd(); + process.WaitForExit(120_000); + + return process.HasExited && process.ExitCode == 0; + } +} diff --git a/tests/SquidStd.Tests/Integration/Workers/WorkerSystemIntegrationTests.cs b/tests/SquidStd.Tests/Integration/Workers/WorkerSystemIntegrationTests.cs index d02ed74e..1ef4078c 100644 --- a/tests/SquidStd.Tests/Integration/Workers/WorkerSystemIntegrationTests.cs +++ b/tests/SquidStd.Tests/Integration/Workers/WorkerSystemIntegrationTests.cs @@ -8,9 +8,9 @@ using SquidStd.Workers.Abstractions.Data; using SquidStd.Workers.Data.Config; using SquidStd.Workers.Interfaces; -using SquidStd.Workers.Services; using SquidStd.Workers.Manager.Data.Config; using SquidStd.Workers.Manager.Services; +using SquidStd.Workers.Services; namespace SquidStd.Tests.Integration.Workers; @@ -31,12 +31,32 @@ 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 Uri(_fixture.AmqpUri) }); + container.AddRabbitMqMessaging(new RabbitMqOptions { Uri = new(_fixture.AmqpUri) }); // Unique channel names per run so parallel/other tests on the shared broker do not interfere. var suffix = Guid.NewGuid().ToString("N"); @@ -115,9 +135,11 @@ public async Task Manager_EnqueuesJob_WorkerRunsIt_AndHeartbeatReachesRegistry() private static async Task PollForWorker(WorkerRegistry registry, string workerId) { var deadline = DateTime.UtcNow + Timeout; + while (DateTime.UtcNow < deadline) { var info = registry.Get(workerId); + if (info is not null) { return info; @@ -128,24 +150,4 @@ 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/Mail/MailOptionsTests.cs b/tests/SquidStd.Tests/Mail/MailOptionsTests.cs new file mode 100644 index 00000000..4890c86a --- /dev/null +++ b/tests/SquidStd.Tests/Mail/MailOptionsTests.cs @@ -0,0 +1,22 @@ +using SquidStd.Mail.Abstractions.Data.Config; +using SquidStd.Mail.Abstractions.Types.Mail; + +namespace SquidStd.Tests.Mail; + +public class MailOptionsTests +{ + [Fact] + public void Defaults_AreSensible() + { + var options = new MailOptions(); + + Assert.Equal(MailProtocolType.Imap, options.Protocol); + Assert.Equal("INBOX", options.Folder); + Assert.Equal(60, options.PollIntervalSeconds); + Assert.True(options.MarkAsSeen); + Assert.False(options.DeleteAfterDownload); + Assert.True(options.IncludeAttachmentContent); + Assert.False(options.IncludeRawEml); + Assert.Equal(50, options.MaxMessagesPerPoll); + } +} diff --git a/tests/SquidStd.Tests/Mail/MailQueueOptionsTests.cs b/tests/SquidStd.Tests/Mail/MailQueueOptionsTests.cs new file mode 100644 index 00000000..389be35e --- /dev/null +++ b/tests/SquidStd.Tests/Mail/MailQueueOptionsTests.cs @@ -0,0 +1,14 @@ +using SquidStd.Mail.Queue.Data.Config; + +namespace SquidStd.Tests.Mail; + +public class MailQueueOptionsTests +{ + [Fact] + public void Defaults_AreSensible() + { + var options = new MailQueueOptions(); + + Assert.Equal("squidstd.mail.outbound", options.QueueName); + } +} diff --git a/tests/SquidStd.Tests/Mail/MailQueueRegistrationExtensionsTests.cs b/tests/SquidStd.Tests/Mail/MailQueueRegistrationExtensionsTests.cs new file mode 100644 index 00000000..f8b7b215 --- /dev/null +++ b/tests/SquidStd.Tests/Mail/MailQueueRegistrationExtensionsTests.cs @@ -0,0 +1,28 @@ +using DryIoc; +using SquidStd.Core.Interfaces.Events; +using SquidStd.Mail.Abstractions.Interfaces; +using SquidStd.Mail.Queue.Extensions; +using SquidStd.Mail.Queue.Interfaces; +using SquidStd.Mail.Queue.Services; +using SquidStd.Messaging.Extensions; +using SquidStd.Services.Core.Services; +using SquidStd.Tests.Mail.Support; + +namespace SquidStd.Tests.Mail; + +public class MailQueueRegistrationExtensionsTests +{ + [Fact] + public void AddMailQueue_RegistersQueueAndConsumer() + { + var container = new Container(); + container.RegisterInstance(new EventBusService()); + container.AddInMemoryMessaging(); + container.RegisterInstance(new RecordingMailSender()); + + container.AddMailQueue(); + + Assert.IsType(container.Resolve()); + Assert.NotNull(container.Resolve()); + } +} diff --git a/tests/SquidStd.Tests/Mail/MailQueueTests.cs b/tests/SquidStd.Tests/Mail/MailQueueTests.cs new file mode 100644 index 00000000..c5595a4a --- /dev/null +++ b/tests/SquidStd.Tests/Mail/MailQueueTests.cs @@ -0,0 +1,56 @@ +using DryIoc; +using SquidStd.Core.Interfaces.Events; +using SquidStd.Mail.Abstractions.Data; +using SquidStd.Mail.Queue.Data.Config; +using SquidStd.Mail.Queue.Services; +using SquidStd.Messaging.Abstractions.Interfaces; +using SquidStd.Messaging.Extensions; +using SquidStd.Services.Core.Services; + +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() + { + var container = new Container(); + container.RegisterInstance(new EventBusService()); + container.AddInMemoryMessaging(); + var messageQueue = container.Resolve(); + + var options = new MailQueueOptions(); + var received = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var _ = messageQueue.Subscribe(options.QueueName, new CapturingListener(received)); + + var queue = new MailQueue(messageQueue, options); + await queue.EnqueueAsync( + new() + { + To = [new("Bob", "bob@example.com")], + Subject = "queued" + } + ); + + var message = await received.Task.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.Equal("queued", message.Subject); + Assert.Contains(message.To, a => a.Address == "bob@example.com"); + } +} diff --git a/tests/SquidStd.Tests/Mail/MailRegistrationExtensionsTests.cs b/tests/SquidStd.Tests/Mail/MailRegistrationExtensionsTests.cs new file mode 100644 index 00000000..4c477239 --- /dev/null +++ b/tests/SquidStd.Tests/Mail/MailRegistrationExtensionsTests.cs @@ -0,0 +1,59 @@ +using DryIoc; +using SquidStd.Core.Interfaces.Events; +using SquidStd.Core.Interfaces.Timing; +using SquidStd.Mail.Abstractions.Data.Config; +using SquidStd.Mail.Abstractions.Interfaces; +using SquidStd.Mail.Abstractions.Types.Mail; +using SquidStd.Mail.MailKit.Extensions; +using SquidStd.Mail.MailKit.Services; +using SquidStd.Services.Core.Services; +using SquidStd.Services.Core.Services.Scheduling; +using SquidStd.Tests.Mail.Support; + +namespace SquidStd.Tests.Mail; + +public class MailRegistrationExtensionsTests +{ + [Fact] + public void AddMail_Imap_RegistersImapReaderAndService() + { + using var container = NewContainer(); + + container.AddMail(ValidOptions(MailProtocolType.Imap)); + + Assert.IsType(container.Resolve()); + Assert.NotNull(container.Resolve()); + Assert.True(container.IsRegistered()); + } + + [Fact] + public void AddMail_Pop3_RegistersPop3Reader() + { + using var container = NewContainer(); + + container.AddMail(ValidOptions(MailProtocolType.Pop3)); + + Assert.IsType(container.Resolve()); + } + + [Fact] + 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 })); + } + + private static Container NewContainer() + { + var container = new Container(); + container.RegisterInstance(new EventBusService()); + container.RegisterInstance(new FakeTimerService()); + + return container; + } + + private static MailOptions ValidOptions(MailProtocolType protocol) + => new() { 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 new file mode 100644 index 00000000..ea847c9f --- /dev/null +++ b/tests/SquidStd.Tests/Mail/MailSendConsumerServiceTests.cs @@ -0,0 +1,104 @@ +using DryIoc; +using SquidStd.Core.Interfaces.Events; +using SquidStd.Mail.Abstractions.Data; +using SquidStd.Mail.Abstractions.Interfaces; +using SquidStd.Mail.Queue.Data.Config; +using SquidStd.Mail.Queue.Services; +using SquidStd.Messaging.Abstractions.Data.Config; +using SquidStd.Messaging.Abstractions.Interfaces; +using SquidStd.Messaging.Extensions; +using SquidStd.Services.Core.Services; +using SquidStd.Tests.Mail.Support; + +namespace SquidStd.Tests.Mail; + +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() + { + var container = new Container(); + container.RegisterInstance(new EventBusService()); + container.AddInMemoryMessaging(new MessagingOptions { MaxDeliveryAttempts = 2, RetryDelay = TimeSpan.Zero }); + var sender = new ThrowingMailSender(); + container.RegisterInstance(sender); + + var options = new MailQueueOptions(); + var queue = container.Resolve(); + + var deadLettered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var _ = queue.Subscribe(options.QueueName + ".dlq", new CapturingListener(deadLettered)); + + var consumer = new MailSendConsumerService(queue, sender, options); + await consumer.StartAsync(); + + await new MailQueue(queue, options).EnqueueAsync(NewMessage()); + + var dead = await deadLettered.Task.WaitAsync(Timeout); + await consumer.StopAsync(); + + Assert.Equal("queued", dead.Subject); + } + + [Fact] + public async Task Consumer_SendsEnqueuedMessage() + { + var container = new Container(); + container.RegisterInstance(new EventBusService()); + container.AddInMemoryMessaging(); + var sender = new RecordingMailSender(); + container.RegisterInstance(sender); + + var options = new MailQueueOptions(); + var queue = container.Resolve(); + var consumer = new MailSendConsumerService(queue, sender, options); + await consumer.StartAsync(); + + await new MailQueue(queue, options).EnqueueAsync(NewMessage()); + + await WaitUntilAsync(() => sender.Sent.Count == 1, Timeout); + await consumer.StopAsync(); + + Assert.Single(sender.Sent); + Assert.Equal("queued", sender.Sent[0].Subject); + } + + private static OutgoingMailMessage NewMessage() + => new() { To = [new("Bob", "bob@example.com")], Subject = "queued" }; + + private static async Task WaitUntilAsync(Func condition, TimeSpan timeout) + { + var deadline = DateTime.UtcNow + timeout; + + while (DateTime.UtcNow < deadline) + { + if (condition()) + { + return; + } + + await Task.Delay(50); + } + + throw new TimeoutException("Condition not met within timeout."); + } +} diff --git a/tests/SquidStd.Tests/Mail/MailSenderRegistrationExtensionsTests.cs b/tests/SquidStd.Tests/Mail/MailSenderRegistrationExtensionsTests.cs new file mode 100644 index 00000000..94308654 --- /dev/null +++ b/tests/SquidStd.Tests/Mail/MailSenderRegistrationExtensionsTests.cs @@ -0,0 +1,38 @@ +using DryIoc; +using SquidStd.Core.Interfaces.Events; +using SquidStd.Mail.Abstractions.Interfaces; +using SquidStd.Mail.MailKit.Extensions; +using SquidStd.Mail.MailKit.Services; +using SquidStd.Services.Core.Services; + +namespace SquidStd.Tests.Mail; + +public class MailSenderRegistrationExtensionsTests +{ + [Fact] + public void AddMailSender_RegistersSender() + { + using var container = NewContainer(); + + container.AddMailSender(new() { Host = "smtp.example.com", Port = 587 }); + + Assert.IsType(container.Resolve()); + } + + [Fact] + 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 })); + } + + private static Container NewContainer() + { + var container = new Container(); + container.RegisterInstance(new EventBusService()); + + return container; + } +} diff --git a/tests/SquidStd.Tests/Mail/MimeMessageMapperTests.cs b/tests/SquidStd.Tests/Mail/MimeMessageMapperTests.cs new file mode 100644 index 00000000..3c59034d --- /dev/null +++ b/tests/SquidStd.Tests/Mail/MimeMessageMapperTests.cs @@ -0,0 +1,71 @@ +using System.Text; +using MimeKit; +using SquidStd.Mail.MailKit.Services; + +namespace SquidStd.Tests.Mail; + +public class MimeMessageMapperTests +{ + [Fact] + public void Map_ExtractsHeadersBodiesAndAttachmentContent() + { + var result = MimeMessageMapper.Map(BuildMessage(), true, false); + + Assert.Equal("alice@example.com", result.From.Address); + Assert.Equal("Alice", result.From.Name); + Assert.Contains(result.To, a => a.Address == "bob@example.com"); + Assert.Contains(result.Cc, a => a.Address == "carol@example.com"); + Assert.Equal("Hello", result.Subject); + Assert.Equal(DateTimeKind.Utc, result.DateUtc.Kind); + Assert.Equal("plain text", result.TextBody); + Assert.Equal("

html

", result.HtmlBody); + + var attachment = Assert.Single(result.Attachments); + Assert.Equal("note.txt", attachment.FileName); + Assert.NotNull(attachment.Content); + Assert.Equal("file-bytes", Encoding.UTF8.GetString(attachment.Content!)); + Assert.Null(result.RawEml); + } + + [Fact] + public void Map_IncludesRawEml_WhenEnabled() + { + var result = MimeMessageMapper.Map(BuildMessage(), false, true); + + Assert.NotNull(result.RawEml); + using var stream = new MemoryStream(result.RawEml!); + var reparsed = MimeMessage.Load(stream); + Assert.Equal("Hello", reparsed.Subject); + } + + [Fact] + public void Map_OmitsAttachmentContent_WhenDisabled() + { + var result = MimeMessageMapper.Map(BuildMessage(), false, false); + + var attachment = Assert.Single(result.Attachments); + Assert.Null(attachment.Content); + Assert.True(attachment.Size > 0); + } + + private static MimeMessage BuildMessage() + { + var message = new MimeMessage(); + message.From.Add(new MailboxAddress("Alice", "alice@example.com")); + 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.MessageId = "msg-1@example.com"; + + var builder = new BodyBuilder + { + TextBody = "plain text", + HtmlBody = "

html

" + }; + builder.Attachments.Add("note.txt", Encoding.UTF8.GetBytes("file-bytes"), ContentType.Parse("text/plain")); + message.Body = builder.ToMessageBody(); + + return message; + } +} diff --git a/tests/SquidStd.Tests/Mail/OutgoingMessageMapperTests.cs b/tests/SquidStd.Tests/Mail/OutgoingMessageMapperTests.cs new file mode 100644 index 00000000..29bb27c0 --- /dev/null +++ b/tests/SquidStd.Tests/Mail/OutgoingMessageMapperTests.cs @@ -0,0 +1,54 @@ +using System.Text; +using MimeKit; +using SquidStd.Mail.Abstractions.Data; +using SquidStd.Mail.Abstractions.Data.Config; +using SquidStd.Mail.MailKit.Services; + +namespace SquidStd.Tests.Mail; + +public class OutgoingMessageMapperTests +{ + [Fact] + public void ToMimeMessage_FallsBackToDefaultFrom() + { + var options = new SmtpOptions { DefaultFrom = new("Sys", "sys@example.com") }; + + var mime = OutgoingMessageMapper.ToMimeMessage(Message(), options); + + Assert.Equal("sys@example.com", mime.From.Mailboxes.Single().Address); + } + + [Fact] + public void ToMimeMessage_MapsRecipientsSubjectBodiesAndAttachment() + { + var options = new SmtpOptions { DefaultFrom = new("Sys", "sys@example.com") }; + + var mime = OutgoingMessageMapper.ToMimeMessage(Message(new("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"); + Assert.Contains(mime.Cc.Mailboxes, m => m.Address == "carol@example.com"); + Assert.Contains(mime.Bcc.Mailboxes, m => m.Address == "dave@example.com"); + Assert.Equal("Hello", mime.Subject); + Assert.Equal("plain", mime.TextBody); + Assert.Equal("

html

", mime.HtmlBody); + Assert.Contains(mime.Attachments.OfType(), p => p.FileName == "a.txt"); + } + + [Fact] + public void ToMimeMessage_Throws_WhenNoFromAndNoDefault() + => Assert.Throws(() => OutgoingMessageMapper.ToMimeMessage(Message(), new())); + + private static OutgoingMailMessage Message(MailAddress? from = null) + => new() + { + From = from, + To = [new("Bob", "bob@example.com")], + Cc = [new("Carol", "carol@example.com")], + Bcc = [new("Dave", "dave@example.com")], + Subject = "Hello", + TextBody = "plain", + HtmlBody = "

html

", + Attachments = [new("a.txt", "text/plain", Encoding.UTF8.GetBytes("xyz"))] + }; +} diff --git a/tests/SquidStd.Tests/Mail/SmtpOptionsTests.cs b/tests/SquidStd.Tests/Mail/SmtpOptionsTests.cs new file mode 100644 index 00000000..ee03dc27 --- /dev/null +++ b/tests/SquidStd.Tests/Mail/SmtpOptionsTests.cs @@ -0,0 +1,17 @@ +using SquidStd.Mail.Abstractions.Data.Config; + +namespace SquidStd.Tests.Mail; + +public class SmtpOptionsTests +{ + [Fact] + public void Defaults_AreSensible() + { + var options = new SmtpOptions(); + + Assert.Equal(587, options.Port); + Assert.False(options.UseSsl); + Assert.Equal(string.Empty, options.Host); + Assert.Null(options.DefaultFrom); + } +} diff --git a/tests/SquidStd.Tests/Mail/Support/FakeTimerService.cs b/tests/SquidStd.Tests/Mail/Support/FakeTimerService.cs new file mode 100644 index 00000000..b27bef21 --- /dev/null +++ b/tests/SquidStd.Tests/Mail/Support/FakeTimerService.cs @@ -0,0 +1,27 @@ +using SquidStd.Core.Interfaces.Timing; + +namespace SquidStd.Tests.Mail.Support; + +/// Captures timer registration for tests without running a real timer wheel. +public sealed class FakeTimerService : ITimerService +{ + public string? RegisteredName { get; private set; } + + public string RegisterTimer(string name, TimeSpan interval, Action callback, TimeSpan? delay = null, bool repeat = false) + { + RegisteredName = name; + + return "timer-1"; + } + + public void UnregisterAllTimers() { } + + public bool UnregisterTimer(string timerId) + => true; + + public int UnregisterTimersByName(string name) + => 0; + + public int UpdateTicksDelta(long timestampMilliseconds) + => 0; +} diff --git a/tests/SquidStd.Tests/Mail/Support/RecordingMailSender.cs b/tests/SquidStd.Tests/Mail/Support/RecordingMailSender.cs new file mode 100644 index 00000000..b978ec77 --- /dev/null +++ b/tests/SquidStd.Tests/Mail/Support/RecordingMailSender.cs @@ -0,0 +1,32 @@ +using SquidStd.Mail.Abstractions.Data; +using SquidStd.Mail.Abstractions.Interfaces; + +namespace SquidStd.Tests.Mail.Support; + +/// Test that records the messages it was asked to send. +public sealed class RecordingMailSender : IMailSender +{ + private readonly List _sent = []; + private readonly Lock _sync = new(); + + public IReadOnlyList Sent + { + get + { + lock (_sync) + { + return _sent.ToArray(); + } + } + } + + public Task SendAsync(OutgoingMailMessage message, CancellationToken cancellationToken = default) + { + lock (_sync) + { + _sent.Add(message); + } + + return Task.CompletedTask; + } +} diff --git a/tests/SquidStd.Tests/Mail/Support/ThrowingMailSender.cs b/tests/SquidStd.Tests/Mail/Support/ThrowingMailSender.cs new file mode 100644 index 00000000..1f94054c --- /dev/null +++ b/tests/SquidStd.Tests/Mail/Support/ThrowingMailSender.cs @@ -0,0 +1,11 @@ +using SquidStd.Mail.Abstractions.Data; +using SquidStd.Mail.Abstractions.Interfaces; + +namespace SquidStd.Tests.Mail.Support; + +/// Test that always throws, to exercise the retry/dead-letter path. +public sealed class ThrowingMailSender : IMailSender +{ + public Task SendAsync(OutgoingMailMessage message, CancellationToken cancellationToken = default) + => throw new InvalidOperationException("send failed"); +} diff --git a/tests/SquidStd.Tests/Manager/HeartbeatCollectorServiceTests.cs b/tests/SquidStd.Tests/Manager/HeartbeatCollectorServiceTests.cs index 736e52c1..eb1ea96e 100644 --- a/tests/SquidStd.Tests/Manager/HeartbeatCollectorServiceTests.cs +++ b/tests/SquidStd.Tests/Manager/HeartbeatCollectorServiceTests.cs @@ -6,7 +6,6 @@ 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; @@ -14,6 +13,23 @@ 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() { @@ -23,17 +39,18 @@ public async Task Collector_RecordsHeartbeat_AndPublishesDiscoveredEvent() container.AddInMemoryMessaging(); var topic = container.Resolve(); - var registry = new WorkerRegistry(new WorkerManagerConfig()); + var registry = new WorkerRegistry(new()); var discovered = new TaskCompletionSource(); eventBus.RegisterAsyncListener(new DelegateListener(e => discovered.TrySetResult(e))); - var service = new HeartbeatCollectorService(topic, registry, eventBus, new WorkerManagerConfig()); + var service = new HeartbeatCollectorService(topic, registry, eventBus, new()); await service.StartAsync(); await topic.PublishAsync( WorkerChannels.HeartbeatTopic, - new WorkerHeartbeat("w1", DateTime.UtcNow, WorkerStatusType.Idle, 0, 8)); + new WorkerHeartbeat("w1", DateTime.UtcNow, WorkerStatusType.Idle, 0, 8) + ); var change = await discovered.Task.WaitAsync(TimeSpan.FromSeconds(5)); await service.StopAsync(); @@ -42,21 +59,4 @@ await topic.PublishAsync( Assert.Null(change.OldStatus); Assert.NotNull(registry.Get("w1")); } - - 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; - } - } } diff --git a/tests/SquidStd.Tests/Manager/JobSchedulerTests.cs b/tests/SquidStd.Tests/Manager/JobSchedulerTests.cs index 7da319c6..f9bbad1b 100644 --- a/tests/SquidStd.Tests/Manager/JobSchedulerTests.cs +++ b/tests/SquidStd.Tests/Manager/JobSchedulerTests.cs @@ -5,13 +5,29 @@ 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() { @@ -21,32 +37,16 @@ public async Task EnqueueAsync_PublishesJobRequestToConfiguredQueue() var queue = container.Resolve(); var received = new TaskCompletionSource(); - using var _ = queue.Subscribe( + using var _ = queue.Subscribe( WorkerChannels.JobQueue, - new DelegateListener(job => received.TrySetResult(job))); + new DelegateListener(job => received.TrySetResult(job)) + ); - var scheduler = new JobScheduler(queue, new WorkerManagerConfig()); + var scheduler = new JobScheduler(queue, new()); 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 03857243..edd54854 100644 --- a/tests/SquidStd.Tests/Manager/Support/FakeTimerService.cs +++ b/tests/SquidStd.Tests/Manager/Support/FakeTimerService.cs @@ -22,9 +22,7 @@ public string RegisterTimer(string name, TimeSpan interval, Action callback, Tim return "timer-1"; } - public void UnregisterAllTimers() - { - } + public void UnregisterAllTimers() { } public bool UnregisterTimer(string timerId) { diff --git a/tests/SquidStd.Tests/Manager/WorkerManagerEndpointsTests.cs b/tests/SquidStd.Tests/Manager/WorkerManagerEndpointsTests.cs index 589e2324..a6cd82d1 100644 --- a/tests/SquidStd.Tests/Manager/WorkerManagerEndpointsTests.cs +++ b/tests/SquidStd.Tests/Manager/WorkerManagerEndpointsTests.cs @@ -6,8 +6,6 @@ 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; @@ -15,32 +13,36 @@ namespace SquidStd.Tests.Manager; public class WorkerManagerEndpointsTests { - private static WorkerRegistry RegistryWith(params string[] workerIds) + [Fact] + public async Task EnqueueJob_ReturnsAccepted_AndSchedulesJob() { - var registry = new WorkerRegistry(new WorkerManagerConfig()); - foreach (var id in workerIds) - { - registry.Record(new WorkerHeartbeat(id, DateTime.UtcNow, WorkerStatusType.Idle, 0, 8)); - } + var result = await WorkerManagerEndpoints.EnqueueJob( + new("resize", new Dictionary()), + NewScheduler(), + CancellationToken.None + ); - return registry; + Assert.IsType(result.Result); } - private static JobScheduler NewScheduler() + [Fact] + public async Task EnqueueJob_ReturnsBadRequest_WhenJobNameBlank() { - var container = new Container(); - container.RegisterInstance(new EventBusService()); - container.AddInMemoryMessaging(); + var result = await WorkerManagerEndpoints.EnqueueJob( + new(" ", null), + NewScheduler(), + CancellationToken.None + ); - return new JobScheduler(container.Resolve(), new WorkerManagerConfig()); + Assert.IsType>(result.Result); } [Fact] - public void GetWorkers_ReturnsOkWithAll() + public void GetWorker_ReturnsNotFound_WhenAbsent() { - var result = WorkerManagerEndpoints.GetWorkers(RegistryWith("w1", "w2")); + var result = WorkerManagerEndpoints.GetWorker("missing", RegistryWith("w1")); - Assert.Equal(2, result.Value!.Count); + Assert.IsType(result.Result); } [Fact] @@ -52,32 +54,31 @@ public void GetWorker_ReturnsOk_WhenPresent() } [Fact] - public void GetWorker_ReturnsNotFound_WhenAbsent() + public void GetWorkers_ReturnsOkWithAll() { - var result = WorkerManagerEndpoints.GetWorker("missing", RegistryWith("w1")); + var result = WorkerManagerEndpoints.GetWorkers(RegistryWith("w1", "w2")); - Assert.IsType(result.Result); + Assert.Equal(2, result.Value!.Count); } - [Fact] - public async Task EnqueueJob_ReturnsAccepted_AndSchedulesJob() + private static JobScheduler NewScheduler() { - var result = await WorkerManagerEndpoints.EnqueueJob( - new EnqueueJobRequest("resize", new Dictionary()), - NewScheduler(), - CancellationToken.None); + var container = new Container(); + container.RegisterInstance(new EventBusService()); + container.AddInMemoryMessaging(); - Assert.IsType(result.Result); + return new(container.Resolve(), new()); } - [Fact] - public async Task EnqueueJob_ReturnsBadRequest_WhenJobNameBlank() + private static WorkerRegistry RegistryWith(params string[] workerIds) { - var result = await WorkerManagerEndpoints.EnqueueJob( - new EnqueueJobRequest(" ", null), - NewScheduler(), - CancellationToken.None); + var registry = new WorkerRegistry(new()); - Assert.IsType>(result.Result); + foreach (var id in workerIds) + { + registry.Record(new(id, DateTime.UtcNow, WorkerStatusType.Idle, 0, 8)); + } + + return registry; } } diff --git a/tests/SquidStd.Tests/Manager/WorkerManagerRegistrationExtensionsTests.cs b/tests/SquidStd.Tests/Manager/WorkerManagerRegistrationExtensionsTests.cs index 56c6c7c2..63d3e216 100644 --- a/tests/SquidStd.Tests/Manager/WorkerManagerRegistrationExtensionsTests.cs +++ b/tests/SquidStd.Tests/Manager/WorkerManagerRegistrationExtensionsTests.cs @@ -14,19 +14,6 @@ namespace SquidStd.Tests.Manager; public class WorkerManagerRegistrationExtensionsTests { - private static Container NewContainer() - { - var container = new Container(); - container.RegisterInstance(new EventBusService()); - container.AddInMemoryMessaging(); - // RegisterCoreServices normally provides ITimerService; supply a fake so the sweep service resolves. - container.RegisterInstance(new FakeTimerService()); - // ConfigManager normally registers config instances; register one directly for the test. - container.RegisterInstance(new WorkerManagerConfig()); - - return container; - } - [Fact] public void AddWorkerManager_RegistersResolvableServices() { @@ -59,4 +46,19 @@ public void AddWorkerManager_SharesRegistryInstanceAcrossInterfaceAndConcrete() Assert.Same(container.Resolve(), container.Resolve()); } + + private static Container NewContainer() + { + var container = new Container(); + container.RegisterInstance(new EventBusService()); + container.AddInMemoryMessaging(); + + // RegisterCoreServices normally provides ITimerService; supply a fake so the sweep service resolves. + container.RegisterInstance(new FakeTimerService()); + + // ConfigManager normally registers config instances; register one directly for the test. + container.RegisterInstance(new WorkerManagerConfig()); + + return container; + } } diff --git a/tests/SquidStd.Tests/Manager/WorkerOfflineSweepServiceTests.cs b/tests/SquidStd.Tests/Manager/WorkerOfflineSweepServiceTests.cs index d1a0dfec..e25905b3 100644 --- a/tests/SquidStd.Tests/Manager/WorkerOfflineSweepServiceTests.cs +++ b/tests/SquidStd.Tests/Manager/WorkerOfflineSweepServiceTests.cs @@ -1,45 +1,43 @@ +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; -using SquidStd.Core.Interfaces.Events; namespace SquidStd.Tests.Manager; public class WorkerOfflineSweepServiceTests { - [Fact] - public async Task StartAsync_RegistersRepeatingTimer_StopAsyncUnregisters() + private sealed class DelegateListener : IAsyncEventListener { - var timer = new FakeTimerService(); - var registry = new WorkerRegistry(new WorkerManagerConfig()); - var service = new WorkerOfflineSweepService(timer, registry, new EventBusService(), new WorkerManagerConfig { SweepIntervalSeconds = 5 }); + private readonly Action _onEvent; - await service.StartAsync(); + public DelegateListener(Action onEvent) + { + _onEvent = onEvent; + } - Assert.Equal("worker-offline-sweep", timer.RegisteredName); - Assert.Equal(TimeSpan.FromSeconds(5), timer.RegisteredInterval); - Assert.True(timer.RegisteredRepeat); + public Task HandleAsync(WorkerStatusChangedEvent eventData, CancellationToken cancellationToken) + { + _onEvent(eventData); - await service.StopAsync(); - Assert.Equal("timer-1", timer.UnregisteredId); + return Task.CompletedTask; + } } [Fact] public async Task RunSweepAsync_MarksOverdueWorkerOffline_AndPublishesTransition() { - var registry = new WorkerRegistry(new WorkerManagerConfig { OfflineTimeoutSeconds = 1 }); - registry.Record(new WorkerHeartbeat("w1", DateTime.UtcNow, WorkerStatusType.Idle, 0, 8)); + var registry = new WorkerRegistry(new() { OfflineTimeoutSeconds = 1 }); + registry.Record(new("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))); - var service = new WorkerOfflineSweepService(new FakeTimerService(), registry, eventBus, new WorkerManagerConfig()); + var service = new WorkerOfflineSweepService(new FakeTimerService(), registry, eventBus, new()); await service.RunSweepAsync(); var change = await offline.Task.WaitAsync(TimeSpan.FromSeconds(5)); @@ -48,20 +46,25 @@ public async Task RunSweepAsync_MarksOverdueWorkerOffline_AndPublishesTransition Assert.Equal(WorkerStatusType.Offline, registry.Get("w1")!.Status); } - private sealed class DelegateListener : IAsyncEventListener + [Fact] + public async Task StartAsync_RegistersRepeatingTimer_StopAsyncUnregisters() { - private readonly Action _onEvent; + var timer = new FakeTimerService(); + var registry = new WorkerRegistry(new()); + var service = new WorkerOfflineSweepService( + timer, + registry, + new EventBusService(), + new() { SweepIntervalSeconds = 5 } + ); - public DelegateListener(Action onEvent) - { - _onEvent = onEvent; - } + await service.StartAsync(); - public Task HandleAsync(WorkerStatusChangedEvent eventData, CancellationToken cancellationToken) - { - _onEvent(eventData); + Assert.Equal("worker-offline-sweep", timer.RegisteredName); + Assert.Equal(TimeSpan.FromSeconds(5), timer.RegisteredInterval); + Assert.True(timer.RegisteredRepeat); - return Task.CompletedTask; - } + await service.StopAsync(); + Assert.Equal("timer-1", timer.UnregisteredId); } } diff --git a/tests/SquidStd.Tests/Manager/WorkerRegistryTests.cs b/tests/SquidStd.Tests/Manager/WorkerRegistryTests.cs index 1cf24a7d..e89c12e3 100644 --- a/tests/SquidStd.Tests/Manager/WorkerRegistryTests.cs +++ b/tests/SquidStd.Tests/Manager/WorkerRegistryTests.cs @@ -1,34 +1,34 @@ 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; public class WorkerRegistryTests { - private static WorkerRegistry NewRegistry(int offlineTimeoutSeconds = 30) - => new(new WorkerManagerConfig { OfflineTimeoutSeconds = offlineTimeoutSeconds }); + [Fact] + public void GetAll_ReturnsAllKnownWorkers() + { + var registry = NewRegistry(); + registry.Record(Heartbeat("w1", WorkerStatusType.Idle)); + registry.Record(Heartbeat("w2", WorkerStatusType.Busy)); - private static WorkerHeartbeat Heartbeat(string id, WorkerStatusType status, int activeJobs = 0) - => new(id, DateTime.UtcNow, status, activeJobs, 8); + Assert.Equal(2, registry.GetAll().Count); + Assert.Null(registry.Get("missing")); + } [Fact] - public void Record_NewWorker_ReturnsDiscoveredTransitionAndStores() + public void Record_AfterOffline_ReturnsReturnTransition() { - var registry = NewRegistry(); + var registry = NewRegistry(30); + registry.Record(Heartbeat("w1", WorkerStatusType.Idle)); + registry.Sweep(DateTime.UtcNow.AddSeconds(31)); - var change = registry.Record(Heartbeat("w1", WorkerStatusType.Idle)); + var change = registry.Record(Heartbeat("w1", WorkerStatusType.Busy)); Assert.NotNull(change); - Assert.Equal("w1", change!.WorkerId); - Assert.Null(change.OldStatus); - Assert.Equal(WorkerStatusType.Idle, change.NewStatus); - - var info = registry.Get("w1"); - Assert.NotNull(info); - Assert.Equal(WorkerStatusType.Idle, info!.Status); - Assert.Equal(8, info.MaxConcurrency); + Assert.Equal(WorkerStatusType.Offline, change!.OldStatus); + Assert.Equal(WorkerStatusType.Busy, change.NewStatus); } [Fact] @@ -37,7 +37,7 @@ public void Record_ExistingWorker_IdleToBusy_NoTransitionButStateUpdated() var registry = NewRegistry(); registry.Record(Heartbeat("w1", WorkerStatusType.Idle)); - var change = registry.Record(Heartbeat("w1", WorkerStatusType.Busy, activeJobs: 2)); + var change = registry.Record(Heartbeat("w1", WorkerStatusType.Busy, 2)); Assert.Null(change); Assert.Equal(WorkerStatusType.Busy, registry.Get("w1")!.Status); @@ -45,23 +45,27 @@ public void Record_ExistingWorker_IdleToBusy_NoTransitionButStateUpdated() } [Fact] - public void Sweep_MarksOverdueWorkerOffline_AndReturnsTransition() + public void Record_NewWorker_ReturnsDiscoveredTransitionAndStores() { - var registry = NewRegistry(offlineTimeoutSeconds: 30); - registry.Record(Heartbeat("w1", WorkerStatusType.Idle)); + var registry = NewRegistry(); - var changes = registry.Sweep(DateTime.UtcNow.AddSeconds(31)); + var change = registry.Record(Heartbeat("w1", WorkerStatusType.Idle)); - var change = Assert.Single(changes); - Assert.Equal("w1", change.WorkerId); - Assert.Equal(WorkerStatusType.Offline, change.NewStatus); - Assert.Equal(WorkerStatusType.Offline, registry.Get("w1")!.Status); + Assert.NotNull(change); + Assert.Equal("w1", change!.WorkerId); + Assert.Null(change.OldStatus); + Assert.Equal(WorkerStatusType.Idle, change.NewStatus); + + var info = registry.Get("w1"); + Assert.NotNull(info); + Assert.Equal(WorkerStatusType.Idle, info!.Status); + Assert.Equal(8, info.MaxConcurrency); } [Fact] public void Sweep_DoesNotReEmitAlreadyOfflineWorker() { - var registry = NewRegistry(offlineTimeoutSeconds: 30); + var registry = NewRegistry(30); registry.Record(Heartbeat("w1", WorkerStatusType.Idle)); registry.Sweep(DateTime.UtcNow.AddSeconds(31)); @@ -71,27 +75,22 @@ public void Sweep_DoesNotReEmitAlreadyOfflineWorker() } [Fact] - public void Record_AfterOffline_ReturnsReturnTransition() + public void Sweep_MarksOverdueWorkerOffline_AndReturnsTransition() { - var registry = NewRegistry(offlineTimeoutSeconds: 30); + var registry = NewRegistry(30); registry.Record(Heartbeat("w1", WorkerStatusType.Idle)); - registry.Sweep(DateTime.UtcNow.AddSeconds(31)); - var change = registry.Record(Heartbeat("w1", WorkerStatusType.Busy)); + var changes = registry.Sweep(DateTime.UtcNow.AddSeconds(31)); - Assert.NotNull(change); - Assert.Equal(WorkerStatusType.Offline, change!.OldStatus); - Assert.Equal(WorkerStatusType.Busy, change.NewStatus); + var change = Assert.Single(changes); + Assert.Equal("w1", change.WorkerId); + Assert.Equal(WorkerStatusType.Offline, change.NewStatus); + Assert.Equal(WorkerStatusType.Offline, registry.Get("w1")!.Status); } - [Fact] - public void GetAll_ReturnsAllKnownWorkers() - { - var registry = NewRegistry(); - registry.Record(Heartbeat("w1", WorkerStatusType.Idle)); - registry.Record(Heartbeat("w2", WorkerStatusType.Busy)); + private static WorkerHeartbeat Heartbeat(string id, WorkerStatusType status, int activeJobs = 0) + => new(id, DateTime.UtcNow, status, activeJobs, 8); - Assert.Equal(2, registry.GetAll().Count); - Assert.Null(registry.Get("missing")); - } + private static WorkerRegistry NewRegistry(int offlineTimeoutSeconds = 30) + => new(new() { OfflineTimeoutSeconds = offlineTimeoutSeconds }); } diff --git a/tests/SquidStd.Tests/Messaging/InMemoryQueueProviderTests.cs b/tests/SquidStd.Tests/Messaging/InMemoryQueueProviderTests.cs index 0ea816cf..d1f9e2f8 100644 --- a/tests/SquidStd.Tests/Messaging/InMemoryQueueProviderTests.cs +++ b/tests/SquidStd.Tests/Messaging/InMemoryQueueProviderTests.cs @@ -1,8 +1,7 @@ using System.Text; -using SquidStd.Messaging.Extensions; -using SquidStd.Messaging.Services; using SquidStd.Messaging.Abstractions.Data.Config; using SquidStd.Messaging.Abstractions.Services; +using SquidStd.Messaging.Services; namespace SquidStd.Tests.Messaging; @@ -10,63 +9,64 @@ public class InMemoryQueueProviderTests { private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5); - private static InMemoryQueueProvider NewProvider(MessagingMetricsProvider? metrics = null, MessagingOptions? options = null) - => new(options ?? new MessagingOptions(), metrics ?? new MessagingMetricsProvider()); - - private static ReadOnlyMemory Bytes(string s) - => Encoding.UTF8.GetBytes(s); - - private static string Text(ReadOnlyMemory b) - => Encoding.UTF8.GetString(b.Span); - [Fact] - public async Task Publish_DeliversToSubscriber() + public async Task AlwaysFailing_IsDeadLetteredAfterMaxAttempts() { - await using var provider = NewProvider(); - var received = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - provider.Subscribe("q", (payload, _) => { received.TrySetResult(Text(payload)); return Task.CompletedTask; }); + await using var provider = NewProvider(options: new() { MaxDeliveryAttempts = 2 }); + var attempts = 0; + provider.Subscribe( + "q", + (_, _) => + { + Interlocked.Increment(ref attempts); - await provider.PublishAsync("q", Bytes("hello")); + throw new InvalidOperationException("always"); + } + ); - Assert.Equal("hello", await received.Task.WaitAsync(Timeout)); - } + var deadLettered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + provider.Subscribe( + "q.dlq", + (payload, _) => + { + deadLettered.TrySetResult(Text(payload)); - [Fact] - public async Task MessagesPublishedBeforeSubscribe_AreBuffered() - { - await using var provider = NewProvider(); - await provider.PublishAsync("q", Bytes("early")); + return Task.CompletedTask; + } + ); - var received = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - provider.Subscribe("q", (payload, _) => { received.TrySetResult(Text(payload)); return Task.CompletedTask; }); + await provider.PublishAsync("q", Bytes("poison")); - Assert.Equal("early", await received.Task.WaitAsync(Timeout)); + Assert.Equal("poison", await deadLettered.Task.WaitAsync(Timeout)); + Assert.Equal(2, attempts); } [Fact] - public async Task TwoSubscribers_ReceiveRoundRobin() + public async Task DisposedSubscription_StopsReceiving() { await using var provider = NewProvider(); - var aCount = 0; - var bCount = 0; - var done = new CountdownEvent(4); - provider.Subscribe("q", (_, _) => { Interlocked.Increment(ref aCount); done.Signal(); return Task.CompletedTask; }); - provider.Subscribe("q", (_, _) => { Interlocked.Increment(ref bCount); done.Signal(); return Task.CompletedTask; }); + var count = 0; + var subscription = provider.Subscribe( + "q", + (_, _) => + { + Interlocked.Increment(ref count); - for (var i = 0; i < 4; i++) - { - await provider.PublishAsync("q", Bytes("m")); - } + return Task.CompletedTask; + } + ); + subscription.Dispose(); - Assert.True(done.Wait(Timeout)); - Assert.Equal(2, aCount); - Assert.Equal(2, bCount); + await provider.PublishAsync("q", Bytes("m")); + await Task.Delay(200); + + Assert.Equal(0, count); } [Fact] public async Task FailingThenSucceeding_IsRetriedAndDelivered() { - await using var provider = NewProvider(options: new MessagingOptions { MaxDeliveryAttempts = 3 }); + await using var provider = NewProvider(options: new() { MaxDeliveryAttempts = 3 }); var attempts = 0; var delivered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); provider.Subscribe( @@ -91,32 +91,92 @@ public async Task FailingThenSucceeding_IsRetriedAndDelivered() } [Fact] - public async Task AlwaysFailing_IsDeadLetteredAfterMaxAttempts() + public async Task MessagesPublishedBeforeSubscribe_AreBuffered() { - await using var provider = NewProvider(options: new MessagingOptions { MaxDeliveryAttempts = 2 }); - var attempts = 0; - provider.Subscribe("q", (_, _) => { Interlocked.Increment(ref attempts); throw new InvalidOperationException("always"); }); + await using var provider = NewProvider(); + await provider.PublishAsync("q", Bytes("early")); - var deadLettered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - provider.Subscribe("q.dlq", (payload, _) => { deadLettered.TrySetResult(Text(payload)); return Task.CompletedTask; }); + var received = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + provider.Subscribe( + "q", + (payload, _) => + { + received.TrySetResult(Text(payload)); - await provider.PublishAsync("q", Bytes("poison")); + return Task.CompletedTask; + } + ); - Assert.Equal("poison", await deadLettered.Task.WaitAsync(Timeout)); - Assert.Equal(2, attempts); + Assert.Equal("early", await received.Task.WaitAsync(Timeout)); } [Fact] - public async Task DisposedSubscription_StopsReceiving() + public async Task Publish_DeliversToSubscriber() { await using var provider = NewProvider(); - var count = 0; - var subscription = provider.Subscribe("q", (_, _) => { Interlocked.Increment(ref count); return Task.CompletedTask; }); - subscription.Dispose(); + var received = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + provider.Subscribe( + "q", + (payload, _) => + { + received.TrySetResult(Text(payload)); - await provider.PublishAsync("q", Bytes("m")); - await Task.Delay(200); + return Task.CompletedTask; + } + ); - Assert.Equal(0, count); + await provider.PublishAsync("q", Bytes("hello")); + + Assert.Equal("hello", await received.Task.WaitAsync(Timeout)); + } + + [Fact] + public async Task TwoSubscribers_ReceiveRoundRobin() + { + await using var provider = NewProvider(); + var aCount = 0; + var bCount = 0; + var done = new CountdownEvent(4); + provider.Subscribe( + "q", + (_, _) => + { + Interlocked.Increment(ref aCount); + done.Signal(); + + return Task.CompletedTask; + } + ); + provider.Subscribe( + "q", + (_, _) => + { + Interlocked.Increment(ref bCount); + done.Signal(); + + return Task.CompletedTask; + } + ); + + for (var i = 0; i < 4; i++) + { + await provider.PublishAsync("q", Bytes("m")); + } + + Assert.True(done.Wait(Timeout)); + Assert.Equal(2, aCount); + Assert.Equal(2, bCount); } + + private static ReadOnlyMemory Bytes(string s) + => Encoding.UTF8.GetBytes(s); + + private static InMemoryQueueProvider NewProvider( + MessagingMetricsProvider? metrics = null, + MessagingOptions? options = null + ) + => new(options ?? new MessagingOptions(), metrics ?? new MessagingMetricsProvider()); + + private static string Text(ReadOnlyMemory b) + => Encoding.UTF8.GetString(b.Span); } diff --git a/tests/SquidStd.Tests/Messaging/InMemoryTopicProviderTests.cs b/tests/SquidStd.Tests/Messaging/InMemoryTopicProviderTests.cs index 7757d639..888e1224 100644 --- a/tests/SquidStd.Tests/Messaging/InMemoryTopicProviderTests.cs +++ b/tests/SquidStd.Tests/Messaging/InMemoryTopicProviderTests.cs @@ -5,54 +5,87 @@ namespace SquidStd.Tests.Messaging; public class InMemoryTopicProviderTests { - private static ReadOnlyMemory Bytes(string s) => Encoding.UTF8.GetBytes(s); - [Fact] - public async Task Publish_FansOutToAllSubscribers() + public async Task Dispose_StopsDelivery() { await using var provider = new InMemoryTopicProvider(); - var a = 0; - var b = 0; - provider.Subscribe("t", (_, _) => { Interlocked.Increment(ref a); return Task.CompletedTask; }); - provider.Subscribe("t", (_, _) => { Interlocked.Increment(ref b); return Task.CompletedTask; }); + var count = 0; + var subscription = provider.Subscribe( + "t", + (_, _) => + { + Interlocked.Increment(ref count); + + return Task.CompletedTask; + } + ); + subscription.Dispose(); await provider.PublishAsync("t", Bytes("x")); - Assert.Equal(1, a); - Assert.Equal(1, b); + Assert.Equal(0, count); } [Fact] - public async Task Dispose_StopsDelivery() + public async Task FailingSubscriber_IsIsolated() { await using var provider = new InMemoryTopicProvider(); - var count = 0; - var subscription = provider.Subscribe("t", (_, _) => { Interlocked.Increment(ref count); return Task.CompletedTask; }); + var received = 0; + provider.Subscribe("t", (_, _) => throw new InvalidOperationException("boom")); + provider.Subscribe( + "t", + (_, _) => + { + Interlocked.Increment(ref received); + + return Task.CompletedTask; + } + ); - subscription.Dispose(); await provider.PublishAsync("t", Bytes("x")); - Assert.Equal(0, count); + Assert.Equal(1, received); } [Fact] - public async Task Publish_NoSubscribers_IsNoOp() + public async Task Publish_FansOutToAllSubscribers() { await using var provider = new InMemoryTopicProvider(); + var a = 0; + var b = 0; + provider.Subscribe( + "t", + (_, _) => + { + Interlocked.Increment(ref a); + + return Task.CompletedTask; + } + ); + provider.Subscribe( + "t", + (_, _) => + { + Interlocked.Increment(ref b); + + return Task.CompletedTask; + } + ); await provider.PublishAsync("t", Bytes("x")); + + Assert.Equal(1, a); + Assert.Equal(1, b); } [Fact] - public async Task FailingSubscriber_IsIsolated() + public async Task Publish_NoSubscribers_IsNoOp() { await using var provider = new InMemoryTopicProvider(); - var received = 0; - provider.Subscribe("t", (_, _) => throw new InvalidOperationException("boom")); - provider.Subscribe("t", (_, _) => { Interlocked.Increment(ref received); return Task.CompletedTask; }); await provider.PublishAsync("t", Bytes("x")); - - Assert.Equal(1, received); } + + private static ReadOnlyMemory Bytes(string s) + => Encoding.UTF8.GetBytes(s); } diff --git a/tests/SquidStd.Tests/Messaging/MessageQueueTests.cs b/tests/SquidStd.Tests/Messaging/MessageQueueTests.cs index f84be209..1a40146e 100644 --- a/tests/SquidStd.Tests/Messaging/MessageQueueTests.cs +++ b/tests/SquidStd.Tests/Messaging/MessageQueueTests.cs @@ -1,9 +1,7 @@ using SquidStd.Core.Json; -using SquidStd.Messaging.Extensions; -using SquidStd.Messaging.Services; -using SquidStd.Messaging.Abstractions.Data.Config; -using SquidStd.Messaging.Abstractions.Services; using SquidStd.Messaging.Abstractions.Interfaces; +using SquidStd.Messaging.Abstractions.Services; +using SquidStd.Messaging.Services; namespace SquidStd.Tests.Messaging; @@ -32,7 +30,7 @@ public Task HandleAsync(Order message, CancellationToken cancellationToken) [Fact] public async Task PublishAsync_DeliversTypedMessageToListener() { - await using var provider = new InMemoryQueueProvider(new MessagingOptions(), new MessagingMetricsProvider()); + await using var provider = new InMemoryQueueProvider(new(), new MessagingMetricsProvider()); var serializer = new JsonDataSerializer(); IMessageQueue queue = new MessageQueue(provider, serializer, serializer); var listener = new CapturingListener(); diff --git a/tests/SquidStd.Tests/Messaging/MessageTopicTests.cs b/tests/SquidStd.Tests/Messaging/MessageTopicTests.cs index dcbcafa2..582825df 100644 --- a/tests/SquidStd.Tests/Messaging/MessageTopicTests.cs +++ b/tests/SquidStd.Tests/Messaging/MessageTopicTests.cs @@ -19,7 +19,15 @@ public async Task PublishSubscribe_RoundTripsTypedMessage() var serializer = new JsonDataSerializer(); IMessageTopic topic = new MessageTopic(provider, serializer, serializer); var received = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - topic.Subscribe("pings", (ping, _) => { received.TrySetResult(ping); return Task.CompletedTask; }); + topic.Subscribe( + "pings", + (ping, _) => + { + received.TrySetResult(ping); + + return Task.CompletedTask; + } + ); await topic.PublishAsync("pings", new Ping { Source = "w1" }); diff --git a/tests/SquidStd.Tests/Messaging/MessagingConnectionStringTests.cs b/tests/SquidStd.Tests/Messaging/MessagingConnectionStringTests.cs index 0b036357..57be3c85 100644 --- a/tests/SquidStd.Tests/Messaging/MessagingConnectionStringTests.cs +++ b/tests/SquidStd.Tests/Messaging/MessagingConnectionStringTests.cs @@ -1,4 +1,3 @@ -using SquidStd.Messaging.Abstractions; using SquidStd.Messaging.Abstractions.Data.Config; namespace SquidStd.Tests.Messaging; @@ -15,6 +14,10 @@ public void Parse_Memory_ReadsScheme() Assert.Null(cs.Port); } + [Fact] + public void Parse_NullOrWhitespace_Throws() + => Assert.Throws(() => MessagingConnectionString.Parse(" ")); + [Fact] public void Parse_RabbitMq_ReadsCredentialsHostPortVhost() { @@ -40,7 +43,9 @@ public void Parse_ReadsQueryParameters() [Fact] public void ToMessagingOptions_ReadsParameters() { - var cs = MessagingConnectionString.Parse("rabbitmq://broker/?maxDeliveryAttempts=5&deadLetterSuffix=.dead&retryDelayMs=250"); + var cs = MessagingConnectionString.Parse( + "rabbitmq://broker/?maxDeliveryAttempts=5&deadLetterSuffix=.dead&retryDelayMs=250" + ); var options = cs.ToMessagingOptions(); @@ -48,8 +53,4 @@ public void ToMessagingOptions_ReadsParameters() Assert.Equal(".dead", options.DeadLetterQueueSuffix); Assert.Equal(TimeSpan.FromMilliseconds(250), options.RetryDelay); } - - [Fact] - public void Parse_NullOrWhitespace_Throws() - => Assert.Throws(() => MessagingConnectionString.Parse(" ")); } diff --git a/tests/SquidStd.Tests/Messaging/MessagingMetricsProviderTests.cs b/tests/SquidStd.Tests/Messaging/MessagingMetricsProviderTests.cs index 490d402a..4cf10819 100644 --- a/tests/SquidStd.Tests/Messaging/MessagingMetricsProviderTests.cs +++ b/tests/SquidStd.Tests/Messaging/MessagingMetricsProviderTests.cs @@ -4,10 +4,6 @@ namespace SquidStd.Tests.Messaging; public class MessagingMetricsProviderTests { - [Fact] - public void ProviderName_IsMessaging() - => Assert.Equal("messaging", new MessagingMetricsProvider().ProviderName); - [Fact] public async Task CollectAsync_ReportsCountersAndGaugesPerQueue() { @@ -24,8 +20,8 @@ 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; + double Value(string name) + => samples.Single(s => s.Name == name && s.Tags != null && s.Tags["queue"] == "orders").Value; Assert.Equal(2, Value("published")); Assert.Equal(1, Value("delivered")); @@ -35,4 +31,8 @@ double Value(string name) => samples.Single( Assert.Equal(5, Value("queue_depth")); Assert.Equal(2, Value("subscribers")); } + + [Fact] + public void ProviderName_IsMessaging() + => Assert.Equal("messaging", new MessagingMetricsProvider().ProviderName); } diff --git a/tests/SquidStd.Tests/Messaging/MessagingOptionsTests.cs b/tests/SquidStd.Tests/Messaging/MessagingOptionsTests.cs index 733c3884..a58e2af8 100644 --- a/tests/SquidStd.Tests/Messaging/MessagingOptionsTests.cs +++ b/tests/SquidStd.Tests/Messaging/MessagingOptionsTests.cs @@ -1,5 +1,4 @@ using SquidStd.Messaging.Abstractions.Data.Config; -using SquidStd.Messaging.Abstractions.Services; namespace SquidStd.Tests.Messaging; diff --git a/tests/SquidStd.Tests/Messaging/MessagingRegistrationExtensionsTests.cs b/tests/SquidStd.Tests/Messaging/MessagingRegistrationExtensionsTests.cs index e06330f0..490f224a 100644 --- a/tests/SquidStd.Tests/Messaging/MessagingRegistrationExtensionsTests.cs +++ b/tests/SquidStd.Tests/Messaging/MessagingRegistrationExtensionsTests.cs @@ -2,9 +2,8 @@ using SquidStd.Core.Interfaces.Events; using SquidStd.Core.Interfaces.Metrics; using SquidStd.Core.Interfaces.Serialization; -using SquidStd.Messaging.Extensions; -using SquidStd.Messaging.Services; using SquidStd.Messaging.Abstractions.Interfaces; +using SquidStd.Messaging.Extensions; using SquidStd.Services.Core.Services; namespace SquidStd.Tests.Messaging; @@ -12,29 +11,29 @@ namespace SquidStd.Tests.Messaging; public class MessagingRegistrationExtensionsTests { [Fact] - public void AddInMemoryMessaging_RegistersResolvableServices() + public void AddInMemoryMessaging_MetricsAndProviderShareInstance() { using var container = new Container(); container.AddInMemoryMessaging(); - Assert.NotNull(container.Resolve()); - Assert.NotNull(container.Resolve()); - Assert.NotNull(container.Resolve()); - Assert.NotNull(container.Resolve()); - Assert.NotNull(container.Resolve()); - - Assert.Contains(container.Resolve>(), p => p.ProviderName == "messaging"); + Assert.Same(container.Resolve(), container.Resolve()); } [Fact] - public void AddInMemoryMessaging_MetricsAndProviderShareInstance() + public void AddInMemoryMessaging_RegistersResolvableServices() { using var container = new Container(); container.AddInMemoryMessaging(); - Assert.Same(container.Resolve(), container.Resolve()); + Assert.NotNull(container.Resolve()); + Assert.NotNull(container.Resolve()); + Assert.NotNull(container.Resolve()); + Assert.NotNull(container.Resolve()); + Assert.NotNull(container.Resolve()); + + Assert.Contains(container.Resolve>(), p => p.ProviderName == "messaging"); } [Fact] diff --git a/tests/SquidStd.Tests/Messaging/RabbitMq/RabbitMqContainerFixture.cs b/tests/SquidStd.Tests/Messaging/RabbitMq/RabbitMqContainerFixture.cs index 64acc41a..3a1291f0 100644 --- a/tests/SquidStd.Tests/Messaging/RabbitMq/RabbitMqContainerFixture.cs +++ b/tests/SquidStd.Tests/Messaging/RabbitMq/RabbitMqContainerFixture.cs @@ -11,11 +11,11 @@ public sealed class RabbitMqContainerFixture : IAsyncLifetime public string AmqpUri => _container.GetConnectionString(); - public Task InitializeAsync() - => _container.StartAsync(); - public Task DisposeAsync() => _container.DisposeAsync().AsTask(); + + public Task InitializeAsync() + => _container.StartAsync(); } [CollectionDefinition(Name)] diff --git a/tests/SquidStd.Tests/Messaging/RabbitMq/RabbitMqQueueProviderTests.cs b/tests/SquidStd.Tests/Messaging/RabbitMq/RabbitMqQueueProviderTests.cs index 3876c036..663f83e1 100644 --- a/tests/SquidStd.Tests/Messaging/RabbitMq/RabbitMqQueueProviderTests.cs +++ b/tests/SquidStd.Tests/Messaging/RabbitMq/RabbitMqQueueProviderTests.cs @@ -1,6 +1,5 @@ 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; @@ -17,12 +16,29 @@ public RabbitMqQueueProviderTests(RabbitMqContainerFixture fixture) _fixture = fixture; } - private RabbitMqQueueProvider NewProvider(MessagingOptions? options = null) - => new(new RabbitMqOptions { Uri = new Uri(_fixture.AmqpUri) }, options ?? new MessagingOptions()); + [Fact] + public async Task AlwaysFailing_IsDeadLettered() + { + await using var provider = NewProvider(new() { MaxDeliveryAttempts = 2 }); + await provider.StartAsync(); + var queue = Queue(); + provider.Subscribe(queue, (_, _) => throw new InvalidOperationException("always")); - private static ReadOnlyMemory Bytes(string s) => Encoding.UTF8.GetBytes(s); - private static string Text(ReadOnlyMemory b) => Encoding.UTF8.GetString(b.Span); - private static string Queue() => "q-" + Guid.NewGuid().ToString("N"); + var deadLettered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + provider.Subscribe( + queue + ".dlq", + (payload, _) => + { + deadLettered.TrySetResult(Text(payload)); + + return Task.CompletedTask; + } + ); + + await provider.PublishAsync(queue, Bytes("poison")); + + Assert.Equal("poison", await deadLettered.Task.WaitAsync(Timeout)); + } [Fact] public async Task Publish_DeliversToSubscriber() @@ -31,7 +47,15 @@ public async Task Publish_DeliversToSubscriber() await provider.StartAsync(); var queue = Queue(); var received = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - provider.Subscribe(queue, (payload, _) => { received.TrySetResult(Text(payload)); return Task.CompletedTask; }); + provider.Subscribe( + queue, + (payload, _) => + { + received.TrySetResult(Text(payload)); + + return Task.CompletedTask; + } + ); await provider.PublishAsync(queue, Bytes("hello")); @@ -48,8 +72,26 @@ 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++) { @@ -62,19 +104,15 @@ public async Task TwoSubscribers_ShareTheLoad() Assert.True(b > 0); } - [Fact] - public async Task AlwaysFailing_IsDeadLettered() - { - await using var provider = NewProvider(new MessagingOptions { MaxDeliveryAttempts = 2 }); - await provider.StartAsync(); - var queue = Queue(); - provider.Subscribe(queue, (_, _) => throw new InvalidOperationException("always")); + private static ReadOnlyMemory Bytes(string s) + => Encoding.UTF8.GetBytes(s); - var deadLettered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - provider.Subscribe(queue + ".dlq", (payload, _) => { deadLettered.TrySetResult(Text(payload)); return Task.CompletedTask; }); + private RabbitMqQueueProvider NewProvider(MessagingOptions? options = null) + => new(new() { Uri = new(_fixture.AmqpUri) }, options ?? new MessagingOptions()); - await provider.PublishAsync(queue, Bytes("poison")); + private static string Queue() + => "q-" + Guid.NewGuid().ToString("N"); - Assert.Equal("poison", await deadLettered.Task.WaitAsync(Timeout)); - } + private static string Text(ReadOnlyMemory b) + => Encoding.UTF8.GetString(b.Span); } diff --git a/tests/SquidStd.Tests/Messaging/RabbitMq/RabbitMqTopicProviderTests.cs b/tests/SquidStd.Tests/Messaging/RabbitMq/RabbitMqTopicProviderTests.cs index 28775802..7cfbdffe 100644 --- a/tests/SquidStd.Tests/Messaging/RabbitMq/RabbitMqTopicProviderTests.cs +++ b/tests/SquidStd.Tests/Messaging/RabbitMq/RabbitMqTopicProviderTests.cs @@ -1,5 +1,4 @@ using System.Text; -using SquidStd.Messaging.RabbitMq.Data.Config; using SquidStd.Messaging.RabbitMq.Services; namespace SquidStd.Tests.Messaging.RabbitMq; @@ -16,11 +15,29 @@ public RabbitMqTopicProviderTests(RabbitMqContainerFixture fixture) _fixture = fixture; } - private RabbitMqTopicProvider NewProvider() - => new(new RabbitMqOptions { Uri = new Uri(_fixture.AmqpUri) }); + [Fact] + public async Task Publish_BeforeAnySubscriber_IsNotReceived() + { + await using var provider = NewProvider(); + await provider.StartAsync(); + var topic = Topic(); - private static ReadOnlyMemory Bytes(string s) => Encoding.UTF8.GetBytes(s); - private static string Topic() => "topic-" + Guid.NewGuid().ToString("N"); + await provider.PublishAsync(topic, Bytes("lost")); + + var received = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + provider.Subscribe( + topic, + (payload, _) => + { + received.TrySetResult(Encoding.UTF8.GetString(payload.Span)); + + return Task.CompletedTask; + } + ); + await Task.Delay(300); + + await Assert.ThrowsAsync(async () => await received.Task.WaitAsync(TimeSpan.FromSeconds(1))); + } [Fact] public async Task Publish_FansOutToAllSubscribers() @@ -30,8 +47,24 @@ 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(Encoding.UTF8.GetString(payload.Span)); return Task.CompletedTask; }); - provider.Subscribe(topic, (payload, _) => { b.TrySetResult(Encoding.UTF8.GetString(payload.Span)); return Task.CompletedTask; }); + provider.Subscribe( + topic, + (payload, _) => + { + a.TrySetResult(Encoding.UTF8.GetString(payload.Span)); + + return Task.CompletedTask; + } + ); + provider.Subscribe( + topic, + (payload, _) => + { + b.TrySetResult(Encoding.UTF8.GetString(payload.Span)); + + return Task.CompletedTask; + } + ); // Allow the broker to register the bindings before publishing. await Task.Delay(300); @@ -41,19 +74,12 @@ public async Task Publish_FansOutToAllSubscribers() Assert.Equal("hello", await b.Task.WaitAsync(Timeout)); } - [Fact] - public async Task Publish_BeforeAnySubscriber_IsNotReceived() - { - await using var provider = NewProvider(); - await provider.StartAsync(); - var topic = Topic(); + private static ReadOnlyMemory Bytes(string s) + => Encoding.UTF8.GetBytes(s); - await provider.PublishAsync(topic, Bytes("lost")); - - var received = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - provider.Subscribe(topic, (payload, _) => { received.TrySetResult(Encoding.UTF8.GetString(payload.Span)); return Task.CompletedTask; }); - await Task.Delay(300); + private RabbitMqTopicProvider NewProvider() + => new(new() { Uri = new(_fixture.AmqpUri) }); - await Assert.ThrowsAsync(async () => await received.Task.WaitAsync(TimeSpan.FromSeconds(1))); - } + private static string Topic() + => "topic-" + Guid.NewGuid().ToString("N"); } diff --git a/tests/SquidStd.Tests/Messaging/TopicEventBridgeTests.cs b/tests/SquidStd.Tests/Messaging/TopicEventBridgeTests.cs index d68276d7..89520262 100644 --- a/tests/SquidStd.Tests/Messaging/TopicEventBridgeTests.cs +++ b/tests/SquidStd.Tests/Messaging/TopicEventBridgeTests.cs @@ -17,7 +17,8 @@ private sealed class Beat private sealed class CapturingListener : IAsyncEventListener { - public TaskCompletionSource Received { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource Received { get; } = + new(TaskCreationOptions.RunContinuationsAsynchronously); public Task HandleAsync(TopicMessageEvent eventData, CancellationToken cancellationToken) { diff --git a/tests/SquidStd.Tests/Network/SessionManagerTests.cs b/tests/SquidStd.Tests/Network/SessionManagerTests.cs index 535fa308..7032ef30 100644 --- a/tests/SquidStd.Tests/Network/SessionManagerTests.cs +++ b/tests/SquidStd.Tests/Network/SessionManagerTests.cs @@ -15,7 +15,7 @@ public async Task BroadcastAsync_SendsToAll_IsolatesFailures() using var server = NewServer(); using var manager = NewManager(server); - var good = new FakeNetworkConnection(1); + var good = new FakeNetworkConnection(); var faulty = new FakeNetworkConnection(2) { SendCallback = _ => throw new InvalidOperationException("boom") @@ -58,7 +58,7 @@ public void Dispose_ClearsSessionsAndIsIdempotent() { using var server = NewServer(); var manager = NewManager(server); - manager.HandleConnected(new FakeNetworkConnection(1)); + manager.HandleConnected(new FakeNetworkConnection()); manager.Dispose(); manager.Dispose(); // idempotent, no throw diff --git a/tests/SquidStd.Tests/Network/SquidStdUdpServerTests.cs b/tests/SquidStd.Tests/Network/SquidStdUdpServerTests.cs index 90737b6a..12a7eaa2 100644 --- a/tests/SquidStd.Tests/Network/SquidStdUdpServerTests.cs +++ b/tests/SquidStd.Tests/Network/SquidStdUdpServerTests.cs @@ -56,31 +56,6 @@ public async Task OnDatagram_CustomResponse_IsReturnedToSender() Assert.Equal([0xFF, 0xFE], await received.Task.WaitAsync(Timeout)); } - [Fact] - public void ServerType_IsUdp() - { - using var server = new SquidStdUdpServer(new(IPAddress.Loopback, 0), false); - - Assert.Equal(ServerType.UDP, server.ServerType); - } - - [Fact] - public async Task StartStop_TogglesIsRunning() - { - await using var server = new SquidStdUdpServer(new(IPAddress.Loopback, 0), false); - - Assert.False(server.IsRunning); - await server.StartAsync(CancellationToken.None); - Assert.True(server.IsRunning); - - await server.StopAsync(CancellationToken.None); - Assert.False(server.IsRunning); - - // Start/Stop/Start cycle is supported. - await server.StartAsync(CancellationToken.None); - Assert.True(server.IsRunning); - } - [Fact] public async Task OnDatagramReceived_RaisedForIncomingDatagram() { @@ -122,4 +97,29 @@ public async Task SendToAsync_DeliversToEndpointThatWasSeen() Assert.Equal([9, 9], await clientReceived.Task.WaitAsync(Timeout)); } + + [Fact] + public void ServerType_IsUdp() + { + using var server = new SquidStdUdpServer(new(IPAddress.Loopback, 0), false); + + Assert.Equal(ServerType.UDP, server.ServerType); + } + + [Fact] + public async Task StartStop_TogglesIsRunning() + { + await using var server = new SquidStdUdpServer(new(IPAddress.Loopback, 0), false); + + Assert.False(server.IsRunning); + await server.StartAsync(CancellationToken.None); + Assert.True(server.IsRunning); + + await server.StopAsync(CancellationToken.None); + Assert.False(server.IsRunning); + + // Start/Stop/Start cycle is supported. + await server.StartAsync(CancellationToken.None); + Assert.True(server.IsRunning); + } } diff --git a/tests/SquidStd.Tests/Network/UdpSessionManagerTests.cs b/tests/SquidStd.Tests/Network/UdpSessionManagerTests.cs index f0d2d9e2..9ed1dfa2 100644 --- a/tests/SquidStd.Tests/Network/UdpSessionManagerTests.cs +++ b/tests/SquidStd.Tests/Network/UdpSessionManagerTests.cs @@ -10,19 +10,49 @@ public class UdpSessionManagerTests { private static readonly DateTimeOffset Start = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); - private static SquidStdUdpServer NewServer() - => new(new IPEndPoint(IPAddress.Loopback, 0), bindAllInterfaces: false); + [Fact] + public async Task DisconnectAsync_ByEndpoint_RemovesSessionOnce() + { + using var server = NewServer(); + var time = new FakeTimeProvider(Start); + using var manager = NewManager(server, time); - private static UdpSessionManager NewManager(SquidStdUdpServer server, FakeTimeProvider time) - => new( - server, - connection => $"state-{connection.SessionId}", - idleTimeout: TimeSpan.FromSeconds(30), - sweepInterval: TimeSpan.FromSeconds(10), - timeProvider: time); + var removals = 0; + manager.OnSessionRemoved += (_, _) => removals++; + manager.HandleDatagram(Peer(5000), new byte[] { 1 }); - private static IPEndPoint Peer(int port) - => new(IPAddress.Loopback, port); + await manager.DisconnectAsync(Peer(5000), CancellationToken.None); + await manager.DisconnectAsync(Peer(5000), CancellationToken.None); // idempotent + + Assert.Equal(0, manager.Count); + Assert.Equal(1, removals); + } + + [Fact] + public async Task DisconnectAsync_UnknownId_IsNoOp() + { + using var server = NewServer(); + var time = new FakeTimeProvider(Start); + using var manager = NewManager(server, time); + + await manager.DisconnectAsync(999, CancellationToken.None); + + // No exception = pass. + } + + [Fact] + public void Dispose_ClearsSessionsAndIsIdempotent() + { + using var server = NewServer(); + var time = new FakeTimeProvider(Start); + var manager = NewManager(server, time); + manager.HandleDatagram(Peer(5000), new byte[] { 1 }); + + manager.Dispose(); + manager.Dispose(); + + Assert.Equal(0, manager.Count); + } [Fact] public void FirstDatagram_CreatesSessionAndRaisesCreatedThenData() @@ -47,6 +77,37 @@ public void FirstDatagram_CreatesSessionAndRaisesCreatedThenData() Assert.Same(byEp, byId); } + [Fact] + public async Task Integration_DatagramCreatesSessionAndManagerCanReply() + { + var timeout = TimeSpan.FromSeconds(5); + + await using var server = new SquidStdUdpServer(new(IPAddress.Loopback, 0), false); + using var manager = new UdpSessionManager(server, c => $"state-{c.SessionId}"); + + var created = new TaskCompletionSource>(TaskCreationOptions.RunContinuationsAsynchronously); + var data = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + manager.OnSessionCreated += (_, e) => created.TrySetResult(e.Session); + manager.OnSessionData += (_, e) => data.TrySetResult(e.Data.ToArray()); + + await server.StartAsync(CancellationToken.None); + var serverPort = server.Port; + + await using var client = new SquidStdUdpClient(new(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); + + var session = await created.Task.WaitAsync(timeout); + Assert.Equal([1, 2, 3], await data.Task.WaitAsync(timeout)); + Assert.Equal(1, manager.Count); + + await session.SendAsync(new byte[] { 4, 5 }, CancellationToken.None); + Assert.Equal([4, 5], await clientReceived.Task.WaitAsync(timeout)); + } + [Fact] public void SecondDatagram_SameEndpoint_RaisesDataOnly() { @@ -67,25 +128,6 @@ public void SecondDatagram_SameEndpoint_RaisesDataOnly() Assert.Equal(1, manager.Count); } - [Fact] - public void SweepExpiredSessions_RemovesIdleSessions() - { - using var server = NewServer(); - var time = new FakeTimeProvider(Start); - using var manager = NewManager(server, time); - - Session? removed = null; - manager.OnSessionRemoved += (_, e) => removed = e.Session; - - manager.HandleDatagram(Peer(5000), new byte[] { 1 }); - time.Advance(TimeSpan.FromSeconds(31)); - manager.SweepExpiredSessions(); - - Assert.Equal(0, manager.Count); - Assert.NotNull(removed); - Assert.False(manager.TryGetSession(Peer(5000), out _)); - } - [Fact] public void SweepExpiredSessions_KeepsRecentlyActiveSessions() { @@ -103,76 +145,36 @@ public void SweepExpiredSessions_KeepsRecentlyActiveSessions() } [Fact] - public async Task DisconnectAsync_ByEndpoint_RemovesSessionOnce() - { - using var server = NewServer(); - var time = new FakeTimeProvider(Start); - using var manager = NewManager(server, time); - - var removals = 0; - manager.OnSessionRemoved += (_, _) => removals++; - manager.HandleDatagram(Peer(5000), new byte[] { 1 }); - - await manager.DisconnectAsync(Peer(5000), CancellationToken.None); - await manager.DisconnectAsync(Peer(5000), CancellationToken.None); // idempotent - - Assert.Equal(0, manager.Count); - Assert.Equal(1, removals); - } - - [Fact] - public async Task DisconnectAsync_UnknownId_IsNoOp() + public void SweepExpiredSessions_RemovesIdleSessions() { using var server = NewServer(); var time = new FakeTimeProvider(Start); using var manager = NewManager(server, time); - await manager.DisconnectAsync(999, CancellationToken.None); - // No exception = pass. - } + Session? removed = null; + manager.OnSessionRemoved += (_, e) => removed = e.Session; - [Fact] - public void Dispose_ClearsSessionsAndIsIdempotent() - { - using var server = NewServer(); - var time = new FakeTimeProvider(Start); - var manager = NewManager(server, time); manager.HandleDatagram(Peer(5000), new byte[] { 1 }); - - manager.Dispose(); - manager.Dispose(); + time.Advance(TimeSpan.FromSeconds(31)); + manager.SweepExpiredSessions(); Assert.Equal(0, manager.Count); + Assert.NotNull(removed); + Assert.False(manager.TryGetSession(Peer(5000), out _)); } - [Fact] - public async Task Integration_DatagramCreatesSessionAndManagerCanReply() - { - var timeout = TimeSpan.FromSeconds(5); - - await using var server = new SquidStdUdpServer(new IPEndPoint(IPAddress.Loopback, 0), bindAllInterfaces: false); - using var manager = new UdpSessionManager(server, c => $"state-{c.SessionId}"); - - var created = new TaskCompletionSource>(TaskCreationOptions.RunContinuationsAsynchronously); - var data = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - manager.OnSessionCreated += (_, e) => created.TrySetResult(e.Session); - manager.OnSessionData += (_, e) => data.TrySetResult(e.Data.ToArray()); - - await server.StartAsync(CancellationToken.None); - var serverPort = server.Port; - - 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 IPEndPoint(IPAddress.Loopback, serverPort), CancellationToken.None); + private static UdpSessionManager NewManager(SquidStdUdpServer server, FakeTimeProvider time) + => new( + server, + connection => $"state-{connection.SessionId}", + TimeSpan.FromSeconds(30), + TimeSpan.FromSeconds(10), + time + ); - var session = await created.Task.WaitAsync(timeout); - Assert.Equal([1, 2, 3], await data.Task.WaitAsync(timeout)); - Assert.Equal(1, manager.Count); + private static SquidStdUdpServer NewServer() + => new(new(IPAddress.Loopback, 0), false); - await session.SendAsync(new byte[] { 4, 5 }, CancellationToken.None); - Assert.Equal([4, 5], await clientReceived.Task.WaitAsync(timeout)); - } + private static IPEndPoint Peer(int port) + => new(IPAddress.Loopback, port); } diff --git a/tests/SquidStd.Tests/Scripting/Lua/AddScriptModuleExtensionTests.cs b/tests/SquidStd.Tests/Scripting/Lua/AddScriptModuleExtensionTests.cs index 00ee9e12..a4d41f83 100644 --- a/tests/SquidStd.Tests/Scripting/Lua/AddScriptModuleExtensionTests.cs +++ b/tests/SquidStd.Tests/Scripting/Lua/AddScriptModuleExtensionTests.cs @@ -1,5 +1,4 @@ using DryIoc; -using SquidStd.Abstractions.Extensions.Container; using SquidStd.Scripting.Lua.Data.Internal; using SquidStd.Scripting.Lua.Extensions.Scripts; @@ -7,6 +6,10 @@ namespace SquidStd.Tests.Scripting.Lua; public class AddScriptModuleExtensionTests { + public sealed class TestUserData; + + public sealed class TestModule; + [Fact] public void RegisterLuaUserData_AddsUserDataMetadata() { @@ -19,26 +22,22 @@ public void RegisterLuaUserData_AddsUserDataMetadata() } [Fact] - public void RegisterScriptModule_AddsMetadataAndRegistersSingleton() + public void RegisterLuaUserData_NullTypeThrows() { using var container = new Container(); - container.RegisterScriptModule(); - - var registration = Assert.Single(container.Resolve>()); - Assert.Equal(typeof(TestModule), registration.ModuleType); - Assert.Same(container.Resolve(), container.Resolve()); + Assert.Throws(() => container.RegisterLuaUserData(null!)); } [Fact] - public void RegisterLuaUserData_NullTypeThrows() + public void RegisterScriptModule_AddsMetadataAndRegistersSingleton() { using var container = new Container(); - Assert.Throws(() => container.RegisterLuaUserData(null!)); - } - - public sealed class TestUserData; + container.RegisterScriptModule(); - public sealed class TestModule; + var registration = Assert.Single(container.Resolve>()); + Assert.Equal(typeof(TestModule), registration.ModuleType); + Assert.Same(container.Resolve(), container.Resolve()); + } } diff --git a/tests/SquidStd.Tests/Scripting/Lua/LuaEventBridgeTests.cs b/tests/SquidStd.Tests/Scripting/Lua/LuaEventBridgeTests.cs index a66070dd..000afc41 100644 --- a/tests/SquidStd.Tests/Scripting/Lua/LuaEventBridgeTests.cs +++ b/tests/SquidStd.Tests/Scripting/Lua/LuaEventBridgeTests.cs @@ -5,16 +5,6 @@ namespace SquidStd.Tests.Scripting.Lua; public class LuaEventBridgeTests { - [Fact] - public void Invoke_WithoutAttachThrows() - { - var script = new Script(); - var callback = script.DoString("return function(payload) return payload.name end").Function; - var bridge = new LuaEventBridge(); - - Assert.Throws(() => bridge.Invoke(callback, new Dictionary())); - } - [Fact] public void Invoke_ConvertsNestedPayloadToLuaTable() { @@ -22,12 +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, @@ -41,6 +32,16 @@ public void Invoke_ConvertsNestedPayloadToLuaTable() Assert.Equal("squid:7", result.String); } + [Fact] + public void Invoke_WithoutAttachThrows() + { + var script = new Script(); + var callback = script.DoString("return function(payload) return payload.name end").Function; + var bridge = new LuaEventBridge(); + + Assert.Throws(() => bridge.Invoke(callback, new Dictionary())); + } + [Fact] public void Publish_InvokesRegisteredCallbacksCaseInsensitively() { @@ -48,15 +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 6eb5b921..2e474c89 100644 --- a/tests/SquidStd.Tests/Scripting/Lua/LuaModuleTests.cs +++ b/tests/SquidStd.Tests/Scripting/Lua/LuaModuleTests.cs @@ -6,6 +6,40 @@ 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() + { + var script = new Script(); + var bridge = new CapturingLuaEventBridge(); + var callback = script.DoString("return function() end").Function; + var module = new EventsModule(bridge); + + module.On("spawned", callback); + + Assert.Equal("spawned", bridge.EventName); + Assert.Same(callback, bridge.Callback); + } + [Fact] public void RandomModule_ChanceHandlesBoundaries() { @@ -30,7 +64,7 @@ public void RandomModule_PickRejectsEmptyTable() { var module = new RandomModule(); - Assert.Throws(() => module.Pick(new Table(new Script()))); + Assert.Throws(() => module.Pick(new(new()))); } [Fact] @@ -38,53 +72,16 @@ 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)); } - - [Fact] - public void EventsModule_RegistersCallbackWithBridge() - { - var script = new Script(); - var bridge = new CapturingLuaEventBridge(); - var callback = script.DoString("return function() end").Function; - var module = new EventsModule(bridge); - - module.On("spawned", callback); - - Assert.Equal("spawned", bridge.EventName); - Assert.Same(callback, bridge.Callback); - } - - 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; - } - } } diff --git a/tests/SquidStd.Tests/Scripting/Lua/LuaScriptEngineServiceTests.cs b/tests/SquidStd.Tests/Scripting/Lua/LuaScriptEngineServiceTests.cs index 4aa71e7c..88f24092 100644 --- a/tests/SquidStd.Tests/Scripting/Lua/LuaScriptEngineServiceTests.cs +++ b/tests/SquidStd.Tests/Scripting/Lua/LuaScriptEngineServiceTests.cs @@ -1,7 +1,5 @@ -using MoonSharp.Interpreter; using DryIoc; -using SquidStd.Core.Directories; -using SquidStd.Scripting.Lua.Data.Config; +using MoonSharp.Interpreter; using SquidStd.Scripting.Lua.Data.Internal; using SquidStd.Scripting.Lua.Data.Scripts; using SquidStd.Scripting.Lua.Interfaces.Events; @@ -12,6 +10,31 @@ 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() { @@ -62,16 +85,17 @@ public void AddManualModuleFunction_RegistersActionAndTypedFunction() } [Fact] - public void ExecuteFunction_ReturnsSuccessForValidExpression() + public void AddSearchDirectory_AllowsRequireFromAdditionalDirectory() { using var temp = new TempDirectory(); + using var extra = new TempDirectory(); using var container = new Container(); + File.WriteAllText(extra.Combine("feature.lua"), "return { value = 42 }"); using var engine = CreateEngine(temp, container); - var result = engine.ExecuteFunction("2 + 3"); + engine.AddSearchDirectory(extra.Path); - Assert.True(result.Success); - Assert.Equal(5d, result.Data); + Assert.Equal(42d, engine.ExecuteFunction("require('feature').value").Data); } [Fact] @@ -90,6 +114,19 @@ public void ExecuteFunction_RaisesScriptErrorForInvalidExpression() Assert.Contains("nil", captured.Message, StringComparison.OrdinalIgnoreCase); } + [Fact] + public void ExecuteFunction_ReturnsSuccessForValidExpression() + { + using var temp = new TempDirectory(); + using var container = new Container(); + using var engine = CreateEngine(temp, container); + + var result = engine.ExecuteFunction("2 + 3"); + + Assert.True(result.Success); + Assert.Equal(5d, result.Data); + } + [Fact] public void ExecuteScript_TracksCacheHitsAndMisses() { @@ -107,31 +144,41 @@ public void ExecuteScript_TracksCacheHitsAndMisses() } [Fact] - public void RegisterGlobalAndUnregisterGlobal_UpdateLuaGlobals() + public void RegisteredUserData_CanBeConstructedFromLuaWithMoreThanFourArguments() { using var temp = new TempDirectory(); using var container = new Container(); - using var engine = CreateEngine(temp, container); + using var engine = CreateEngine( + temp, + container, + loadedUserData: + [ + new() { UserType = typeof(FiveArgumentUserData) } + ] + ); - engine.RegisterGlobal("answer", 42); + var result = engine.LuaScript.DoString( + """ + local value = FiveArgumentUserData(1, 2, 3, 4, 5) + return value.Total + """ + ); - Assert.Equal(42d, engine.ExecuteFunction("answer").Data); - Assert.True(engine.UnregisterGlobal("answer")); - Assert.False(engine.UnregisterGlobal("answer")); + Assert.Equal(15, (int)result.Number); } [Fact] - public void AddSearchDirectory_AllowsRequireFromAdditionalDirectory() + public void RegisterGlobalAndUnregisterGlobal_UpdateLuaGlobals() { using var temp = new TempDirectory(); - using var extra = new TempDirectory(); using var container = new Container(); - File.WriteAllText(extra.Combine("feature.lua"), "return { value = 42 }"); using var engine = CreateEngine(temp, container); - engine.AddSearchDirectory(extra.Path); + engine.RegisterGlobal("answer", 42); - Assert.Equal(42d, engine.ExecuteFunction("require('feature').value").Data); + Assert.Equal(42d, engine.ExecuteFunction("answer").Data); + Assert.True(engine.UnregisterGlobal("answer")); + Assert.False(engine.UnregisterGlobal("answer")); } [Fact] @@ -156,30 +203,6 @@ public async Task StartAsync_AttachesEventBridgeAndAddsBootstrapConstants() Assert.Equal("SquidStd", engine.ExecuteFunction("ENGINE").Data); } - [Fact] - public void RegisteredUserData_CanBeConstructedFromLuaWithMoreThanFourArguments() - { - using var temp = new TempDirectory(); - using var container = new Container(); - using var engine = CreateEngine( - temp, - container, - loadedUserData: - [ - new() { UserType = typeof(FiveArgumentUserData) } - ] - ); - - var result = engine.LuaScript.DoString( - """ - local value = FiveArgumentUserData(1, 2, 3, 4, 5) - return value.Total - """ - ); - - Assert.Equal(15, (int)result.Number); - } - private static LuaScriptEngineService CreateEngine( TempDirectory temp, IContainer container, @@ -192,40 +215,11 @@ private static LuaScriptEngineService CreateEngine( Directory.CreateDirectory(scriptsDirectory); return new( - new DirectoriesConfig(temp.Path, []), + new(temp.Path, []), container, - new LuaEngineConfig(luarcDirectory, scriptsDirectory, "SquidStd", "1.0.0"), + new(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 - ) - => 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 ed3856c2..df24478d 100644 --- a/tests/SquidStd.Tests/Scripting/Lua/LuaScriptLoaderTests.cs +++ b/tests/SquidStd.Tests/Scripting/Lua/LuaScriptLoaderTests.cs @@ -1,4 +1,3 @@ -using MoonSharp.Interpreter; using SquidStd.Scripting.Lua.Loaders; using SquidStd.Tests.Support; @@ -7,22 +6,22 @@ namespace SquidStd.Tests.Scripting.Lua; public class LuaScriptLoaderTests { [Fact] - public void ScriptFileExists_FindsModulePathPatterns() + public void AddSearchDirectory_AddsAdditionalLookupRoot() { - using var temp = new TempDirectory(); - Directory.CreateDirectory(temp.Combine("modules")); - Directory.CreateDirectory(temp.Combine(Path.Combine("modules", "inventory"))); - File.WriteAllText(temp.Combine("plain.lua"), "return 1"); - File.WriteAllText(temp.Combine(Path.Combine("modules", "combat.lua")), "return 2"); - File.WriteAllText(temp.Combine(Path.Combine("modules", "inventory", "init.lua")), "return 3"); - var loader = new LuaScriptLoader(temp.Path); + using var first = new TempDirectory(); + using var second = new TempDirectory(); + File.WriteAllText(second.Combine("extra.lua"), "return 'extra'"); + var loader = new LuaScriptLoader(first.Path); - Assert.True(loader.ScriptFileExists("plain")); - Assert.True(loader.ScriptFileExists("combat")); - Assert.True(loader.ScriptFileExists("inventory")); - Assert.False(loader.ScriptFileExists("missing")); + loader.AddSearchDirectory(second.Path); + + Assert.True(loader.ScriptFileExists("extra")); } + [Fact] + public void Constructor_EmptySearchDirectoriesThrows() + => Assert.Throws(() => new LuaScriptLoader(Array.Empty())); + [Fact] public void LoadFile_LoadsContentFromFirstMatchingSearchDirectory() { @@ -31,25 +30,25 @@ 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 Table(new Script())); + var content = loader.LoadFile("feature.lua", new(new())); Assert.Equal("return 'loaded'", content); } [Fact] - public void AddSearchDirectory_AddsAdditionalLookupRoot() + public void ScriptFileExists_FindsModulePathPatterns() { - using var first = new TempDirectory(); - using var second = new TempDirectory(); - File.WriteAllText(second.Combine("extra.lua"), "return 'extra'"); - var loader = new LuaScriptLoader(first.Path); - - loader.AddSearchDirectory(second.Path); + using var temp = new TempDirectory(); + Directory.CreateDirectory(temp.Combine("modules")); + Directory.CreateDirectory(temp.Combine(Path.Combine("modules", "inventory"))); + File.WriteAllText(temp.Combine("plain.lua"), "return 1"); + File.WriteAllText(temp.Combine(Path.Combine("modules", "combat.lua")), "return 2"); + File.WriteAllText(temp.Combine(Path.Combine("modules", "inventory", "init.lua")), "return 3"); + var loader = new LuaScriptLoader(temp.Path); - Assert.True(loader.ScriptFileExists("extra")); + Assert.True(loader.ScriptFileExists("plain")); + Assert.True(loader.ScriptFileExists("combat")); + Assert.True(loader.ScriptFileExists("inventory")); + Assert.False(loader.ScriptFileExists("missing")); } - - [Fact] - public void Constructor_EmptySearchDirectoriesThrows() - => Assert.Throws(() => new LuaScriptLoader(Array.Empty())); } diff --git a/tests/SquidStd.Tests/Scripting/Lua/LuaTableReaderTests.cs b/tests/SquidStd.Tests/Scripting/Lua/LuaTableReaderTests.cs index cfd6e8cc..6cbbd2c1 100644 --- a/tests/SquidStd.Tests/Scripting/Lua/LuaTableReaderTests.cs +++ b/tests/SquidStd.Tests/Scripting/Lua/LuaTableReaderTests.cs @@ -5,22 +5,10 @@ namespace SquidStd.Tests.Scripting.Lua; public class LuaTableReaderTests { - [Fact] - public void Readers_ReturnTypedValues() + private enum TestMode { - var script = new Script(); - var table = new Table(script); - table["name"] = "squid"; - table["count"] = 3; - table["ratio"] = 1.5; - table["enabled"] = true; - table["mode"] = "Second"; - - Assert.Equal("squid", LuaTableReader.GetString(table, "name")); - Assert.Equal(3, LuaTableReader.GetInt(table, "count")); - Assert.Equal(1.5f, LuaTableReader.GetFloat(table, "ratio")); - Assert.True(LuaTableReader.GetBool(table, "enabled")); - Assert.Equal(TestMode.Second, LuaTableReader.GetEnum(table, "mode", TestMode.First)); + First = 1, + Second = 2 } [Fact] @@ -39,9 +27,21 @@ public void Readers_ReturnDefaultsForMissingOrWrongTypes() Assert.Equal(TestMode.First, LuaTableReader.GetEnum(table, "mode", TestMode.First)); } - private enum TestMode + [Fact] + public void Readers_ReturnTypedValues() { - First = 1, - Second = 2 + var script = new Script(); + var table = new Table(script); + table["name"] = "squid"; + table["count"] = 3; + table["ratio"] = 1.5; + table["enabled"] = true; + table["mode"] = "Second"; + + Assert.Equal("squid", LuaTableReader.GetString(table, "name")); + Assert.Equal(3, LuaTableReader.GetInt(table, "count")); + Assert.Equal(1.5f, LuaTableReader.GetFloat(table, "ratio")); + Assert.True(LuaTableReader.GetBool(table, "enabled")); + Assert.Equal(TestMode.Second, LuaTableReader.GetEnum(table, "mode", TestMode.First)); } } diff --git a/tests/SquidStd.Tests/Scripting/Lua/ScriptResultBuilderTests.cs b/tests/SquidStd.Tests/Scripting/Lua/ScriptResultBuilderTests.cs index 06ffbd07..2aeb3623 100644 --- a/tests/SquidStd.Tests/Scripting/Lua/ScriptResultBuilderTests.cs +++ b/tests/SquidStd.Tests/Scripting/Lua/ScriptResultBuilderTests.cs @@ -4,6 +4,18 @@ namespace SquidStd.Tests.Scripting.Lua; public class ScriptResultBuilderTests { + [Fact] + public void CreateError_BuildsFailedResult() + { + var result = ScriptResultBuilder.CreateError() + .WithMessage("failed") + .Build(); + + Assert.False(result.Success); + Assert.Equal("failed", result.Message); + Assert.Null(result.Data); + } + [Fact] public void CreateSuccess_BuildsSuccessfulResult() { @@ -16,16 +28,4 @@ public void CreateSuccess_BuildsSuccessfulResult() Assert.Equal("ok", result.Message); Assert.Equal(42, result.Data); } - - [Fact] - public void CreateError_BuildsFailedResult() - { - var result = ScriptResultBuilder.CreateError() - .WithMessage("failed") - .Build(); - - Assert.False(result.Success); - Assert.Equal("failed", result.Message); - Assert.Null(result.Data); - } } diff --git a/tests/SquidStd.Tests/Scripting/Lua/TableExtensionsTests.cs b/tests/SquidStd.Tests/Scripting/Lua/TableExtensionsTests.cs index dd20bcbe..56531400 100644 --- a/tests/SquidStd.Tests/Scripting/Lua/TableExtensionsTests.cs +++ b/tests/SquidStd.Tests/Scripting/Lua/TableExtensionsTests.cs @@ -5,19 +5,25 @@ 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(); @@ -27,13 +33,8 @@ public void ToProxy_DelegatesInterfaceCallsToLuaFunctions() [Fact] public void ToProxy_MissingFunctionThrowsMissingMethodException() { - var proxy = new Table(new Script()).ToProxy(); + var proxy = new Table(new()).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 new file mode 100644 index 00000000..0a5e85cc --- /dev/null +++ b/tests/SquidStd.Tests/Search/ElasticExpressionTranslatorTests.cs @@ -0,0 +1,91 @@ +using System.Collections; +using System.Linq.Expressions; +using SquidStd.Search.Abstractions.Interfaces; +using SquidStd.Search.Elasticsearch.Linq; + +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() + { + var q = Translate(s => s.Match("name", "laptop")); + + var match = q.Query["bool"]!["must"]!.AsArray()[0]!["match"]!.AsObject(); + Assert.Equal("laptop", match["name"]!.GetValue()); + } + + [Fact] + public void UnsupportedExpression_Throws() + => Assert.Throws(() => Translate(s => s.Where(d => d.Name.ToUpperInvariant() == "X"))); + + [Fact] + public void Where_Equality_ProducesTermOnKeyword() + { + var q = Translate(s => s.Where(d => d.Status == "open")); + + var term = q.Query["bool"]!["must"]!.AsArray()[0]!["term"]!.AsObject(); + Assert.True(term.ContainsKey("status.keyword")); + Assert.Equal("open", term["status.keyword"]!.GetValue()); + } + + [Fact] + public void Where_Range_And_Order_And_Take() + { + var q = Translate(s => s.Where(d => d.Total > 100).OrderByDescending(d => d.Total).Take(5)); + + Assert.Equal(5, q.Size); + Assert.Equal("desc", q.Sort!.AsArray()[0]!["total"]!["order"]!.GetValue()); + var range = q.Query["bool"]!["must"]!.AsArray()[0]!["range"]!["total"]!.AsObject(); + Assert.Equal(100, range["gt"]!.GetValue()); + } + + private static ElasticQuery Translate(Func, IQueryable> build) + { + IQueryable root = new TranslateOnlyQueryable(); + var query = build(root); + + return ElasticExpressionTranslator.Translate(query.Expression, typeof(Doc)); + } +} diff --git a/tests/SquidStd.Tests/Search/SearchIndexNameResolverTests.cs b/tests/SquidStd.Tests/Search/SearchIndexNameResolverTests.cs new file mode 100644 index 00000000..88037b10 --- /dev/null +++ b/tests/SquidStd.Tests/Search/SearchIndexNameResolverTests.cs @@ -0,0 +1,74 @@ +using SquidStd.Search.Abstractions.Attributes; +using SquidStd.Search.Abstractions.Interfaces; +using SquidStd.Search.Abstractions.Search; + +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() + { + Environment.SetEnvironmentVariable("SQ_ENV", "Prod"); + + try + { + Assert.Equal("orders_prod", SearchIndexNameResolver.Resolve(typeof(EnvDoc))); + } + finally + { + Environment.SetEnvironmentVariable("SQ_ENV", null); + } + } + + [Fact] + public void Resolve_FallsBackToLowercasedTypeName() + => Assert.Equal("plaindoc", SearchIndexNameResolver.Resolve(typeof(PlainDoc))); + + [Fact] + public void Resolve_Throws_WhenRequiredVariableMissing() + { + Environment.SetEnvironmentVariable("SQ_REQUIRED", null); + Assert.Throws(() => SearchIndexNameResolver.Resolve(typeof(EnvRequiredDoc))); + } + + [Fact] + public void Resolve_UsesAttribute_Lowercased() + => Assert.Equal("orders", SearchIndexNameResolver.Resolve(typeof(AttributedDoc))); + + [Fact] + public void Resolve_UsesDefault_WhenVariableMissing() + { + Environment.SetEnvironmentVariable("SQ_MISSING", null); + Assert.Equal("orders_dev", SearchIndexNameResolver.Resolve(typeof(EnvDefaultDoc))); + } +} diff --git a/tests/SquidStd.Tests/Security/SecretsTests.cs b/tests/SquidStd.Tests/Security/SecretsTests.cs index 1c62ecda..449077ad 100644 --- a/tests/SquidStd.Tests/Security/SecretsTests.cs +++ b/tests/SquidStd.Tests/Security/SecretsTests.cs @@ -12,6 +12,16 @@ 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() { @@ -102,14 +112,4 @@ 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/ConfigManagerServiceEnvTests.cs b/tests/SquidStd.Tests/Services/Core/ConfigManagerServiceEnvTests.cs index d15f07ba..7cc5fab2 100644 --- a/tests/SquidStd.Tests/Services/Core/ConfigManagerServiceEnvTests.cs +++ b/tests/SquidStd.Tests/Services/Core/ConfigManagerServiceEnvTests.cs @@ -13,11 +13,13 @@ public void Load_SubstitutesEnvTokensInStringProperties() var dir = Path.Combine(Path.GetTempPath(), "squidstd-cfg-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir); Environment.SetEnvironmentVariable("SQUID_DB_PASS", "p@ss"); + try { File.WriteAllText( Path.Combine(dir, "app.yaml"), - "database:\n ConnectionString: postgres://u:$SQUID_DB_PASS@h:5432/db\n AutoMigrate: true\n"); + "database:\n ConnectionString: postgres://u:$SQUID_DB_PASS@h:5432/db\n AutoMigrate: true\n" + ); var container = new Container(); container.RegisterConfigSection("database"); @@ -31,7 +33,7 @@ public void Load_SubstitutesEnvTokensInStringProperties() finally { Environment.SetEnvironmentVariable("SQUID_DB_PASS", null); - Directory.Delete(dir, recursive: true); + Directory.Delete(dir, true); } } } diff --git a/tests/SquidStd.Tests/Services/Core/MetricsCollectionServiceTests.cs b/tests/SquidStd.Tests/Services/Core/MetricsCollectionServiceTests.cs index a097c643..22e86e9a 100644 --- a/tests/SquidStd.Tests/Services/Core/MetricsCollectionServiceTests.cs +++ b/tests/SquidStd.Tests/Services/Core/MetricsCollectionServiceTests.cs @@ -7,37 +7,102 @@ 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 async Task StartAsync_CollectsMetricsFromRegisteredProviders() + public void GetSnapshot_WhenNotStarted_ReturnsEmptySnapshot() + { + using var service = new MetricsCollectionService([], new()); + + var snapshot = service.GetSnapshot(); + + Assert.Equal(DateTimeOffset.MinValue, snapshot.CollectedAt); + Assert.Empty(snapshot.Metrics); + } + + [Fact] + public async Task GetStatus_ReturnsLatestMetricsSnapshot() { using var service = new MetricsCollectionService( - [new CountingMetricProvider("jobs", "pending.total", 7)], + [new CountingMetricProvider("jobs", "completed.total", 11)], new() { IntervalMilliseconds = 10, LogEnabled = false } ); + IMetricsCollectionService metrics = service; await service.StartAsync(CancellationToken.None); - await WaitUntilAsync(() => service.GetAllMetrics().ContainsKey("jobs.pending.total")); + await WaitUntilAsync(() => metrics.GetStatus().Metrics.ContainsKey("jobs.completed.total")); - var metrics = service.GetAllMetrics(); - var sample = metrics["jobs.pending.total"]; + var status = metrics.GetStatus(); - Assert.Equal("pending.total", sample.Name); - Assert.Equal(7, sample.Value); - Assert.NotNull(sample.Timestamp); + Assert.NotEqual(DateTimeOffset.MinValue, status.CollectedAt); + Assert.Equal(11, status.Metrics["jobs.completed.total"].Value); } [Fact] - public async Task StartAsync_WhenProviderThrows_ContinuesCollectingOtherProviders() + public async Task StartAsync_CollectsMetricsFromRegisteredProviders() { using var service = new MetricsCollectionService( - [ - new ThrowingMetricProvider("broken"), - new CountingMetricProvider("timer", "callbacks.total", 3) - ], + [new CountingMetricProvider("jobs", "pending.total", 7)], new() { IntervalMilliseconds = 10, @@ -46,35 +111,44 @@ public async Task StartAsync_WhenProviderThrows_ContinuesCollectingOtherProvider ); await service.StartAsync(CancellationToken.None); - await WaitUntilAsync(() => service.GetAllMetrics().ContainsKey("timer.callbacks.total")); + await WaitUntilAsync(() => service.GetAllMetrics().ContainsKey("jobs.pending.total")); var metrics = service.GetAllMetrics(); + var sample = metrics["jobs.pending.total"]; - Assert.Contains("timer.callbacks.total", metrics.Keys); - Assert.DoesNotContain("broken", metrics.Keys); + Assert.Equal("pending.total", sample.Name); + Assert.Equal(7, sample.Value); + Assert.NotNull(sample.Timestamp); } [Fact] - public async Task StopAsync_StopsCollectionLoop() + public async Task StartAsync_PublishesMetricsCollectedEventThroughEventBus() { - var provider = new CountingMetricProvider("bus", "dispatch.total", 1); + using var eventBus = new EventBusService(); + IEventBus bus = eventBus; + var syncListener = new MetricsCollectedSyncListener(); + var asyncListener = new MetricsCollectedAsyncListener(); + bus.RegisterListener(syncListener); + bus.RegisterAsyncListener(asyncListener); using var service = new MetricsCollectionService( - [provider], + [new CountingMetricProvider("events", "published.total", 5)], new() { - IntervalMilliseconds = 10, + IntervalMilliseconds = 1000, LogEnabled = false - } + }, + bus ); await service.StartAsync(CancellationToken.None); - await WaitUntilAsync(() => provider.CollectionCount > 0); + await WaitUntilAsync(() => syncListener.LastEvent is not null && asyncListener.LastEvent is not null); await service.StopAsync(CancellationToken.None); - var countAfterStop = provider.CollectionCount; - - await Task.Delay(50); - Assert.Equal(countAfterStop, provider.CollectionCount); + 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); } [Fact] @@ -99,66 +173,50 @@ public async Task StartAsync_WhenDisabled_DoesNotCollectMetrics() } [Fact] - public void GetSnapshot_WhenNotStarted_ReturnsEmptySnapshot() - { - using var service = new MetricsCollectionService([], new()); - - var snapshot = service.GetSnapshot(); - - Assert.Equal(DateTimeOffset.MinValue, snapshot.CollectedAt); - Assert.Empty(snapshot.Metrics); - } - - [Fact] - public async Task GetStatus_ReturnsLatestMetricsSnapshot() + public async Task StartAsync_WhenProviderThrows_ContinuesCollectingOtherProviders() { using var service = new MetricsCollectionService( - [new CountingMetricProvider("jobs", "completed.total", 11)], + [ + new ThrowingMetricProvider("broken"), + new CountingMetricProvider("timer", "callbacks.total", 3) + ], new() { IntervalMilliseconds = 10, LogEnabled = false } ); - IMetricsCollectionService metrics = service; await service.StartAsync(CancellationToken.None); - await WaitUntilAsync(() => metrics.GetStatus().Metrics.ContainsKey("jobs.completed.total")); + await WaitUntilAsync(() => service.GetAllMetrics().ContainsKey("timer.callbacks.total")); - var status = metrics.GetStatus(); + var metrics = service.GetAllMetrics(); - Assert.NotEqual(DateTimeOffset.MinValue, status.CollectedAt); - Assert.Equal(11, status.Metrics["jobs.completed.total"].Value); + Assert.Contains("timer.callbacks.total", metrics.Keys); + Assert.DoesNotContain("broken", metrics.Keys); } [Fact] - public async Task StartAsync_PublishesMetricsCollectedEventThroughEventBus() + public async Task StopAsync_StopsCollectionLoop() { - using var eventBus = new EventBusService(); - IEventBus bus = eventBus; - var syncListener = new MetricsCollectedSyncListener(); - var asyncListener = new MetricsCollectedAsyncListener(); - bus.RegisterListener(syncListener); - bus.RegisterAsyncListener(asyncListener); + var provider = new CountingMetricProvider("bus", "dispatch.total", 1); using var service = new MetricsCollectionService( - [new CountingMetricProvider("events", "published.total", 5)], + [provider], new() { - IntervalMilliseconds = 1000, + IntervalMilliseconds = 10, LogEnabled = false - }, - bus + } ); await service.StartAsync(CancellationToken.None); - await WaitUntilAsync(() => syncListener.LastEvent is not null && asyncListener.LastEvent is not null); + await WaitUntilAsync(() => provider.CollectionCount > 0); await service.StopAsync(CancellationToken.None); + var countAfterStop = provider.CollectionCount; - 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); + await Task.Delay(50); + + Assert.Equal(countAfterStop, provider.CollectionCount); } private static async Task WaitUntilAsync(Func predicate) @@ -177,62 +235,4 @@ 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(_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; - } - } } diff --git a/tests/SquidStd.Tests/Services/Core/RegisterDefaultServicesExtensionsTests.cs b/tests/SquidStd.Tests/Services/Core/RegisterDefaultServicesExtensionsTests.cs index e8d2ee14..02cbbac7 100644 --- a/tests/SquidStd.Tests/Services/Core/RegisterDefaultServicesExtensionsTests.cs +++ b/tests/SquidStd.Tests/Services/Core/RegisterDefaultServicesExtensionsTests.cs @@ -2,19 +2,18 @@ using SquidStd.Abstractions.Data.Internal.Config; using SquidStd.Abstractions.Data.Internal.Services; using SquidStd.Core.Data.Bootstrap; -using SquidStd.Core.Directories; using SquidStd.Core.Data.Jobs; using SquidStd.Core.Data.Metrics; using SquidStd.Core.Data.Storage; -using SquidStd.Storage.Abstractions.Data.Config; using SquidStd.Core.Data.Timing; +using SquidStd.Core.Directories; using SquidStd.Core.Interfaces.Config; using SquidStd.Core.Interfaces.Metrics; using SquidStd.Core.Interfaces.Secrets; -using SquidStd.Storage.Abstractions.Interfaces; using SquidStd.Services.Core.Extensions; using SquidStd.Services.Core.Services; -using SquidStd.Services.Core.Services.Storage; +using SquidStd.Storage.Abstractions.Data.Config; +using SquidStd.Storage.Abstractions.Interfaces; using SquidStd.Tests.Support; namespace SquidStd.Tests.Services.Core; @@ -53,6 +52,43 @@ public void RegisterConfigManagerService_RegistersSingletonInstance() Assert.Equal(Path.Combine(temp.Path, "app.yaml"), first.ConfigPath); } + [Fact] + public void RegisterCoreServices_RegistersDirectoriesConfig() + { + using var temp = new TempDirectory(); + using var container = new Container(); + + container.RegisterCoreServices("app", temp.Path); + + Assert.True(container.IsRegistered()); + } + + [Fact] + public void RegisterCoreServices_RegistersMetricsCollectionService() + { + using var temp = new TempDirectory(); + using var container = new Container(); + + container.RegisterCoreServices("app", temp.Path); + + Assert.True(container.IsRegistered()); + } + + [Fact] + public void RegisterCoreServices_RegistersSecretServices() + { + using var temp = new TempDirectory(); + using var container = new Container(); + + container.RegisterCoreServices("app", temp.Path); + + // Storage is opt-in (AddFileStorage); core no longer registers it. + Assert.False(container.IsRegistered()); + Assert.False(container.IsRegistered()); + Assert.True(container.IsRegistered()); + Assert.True(container.IsRegistered()); + } + [Fact] public async Task RegisterCoreServices_StartsConfigManagerBeforeResolvingConfigConsumers() { @@ -155,43 +191,6 @@ public void RegisterMetricsCollectionService_AddsLateServiceRegistrationData() Assert.Equal(1000, entry.Priority); } - [Fact] - public void RegisterCoreServices_RegistersMetricsCollectionService() - { - using var temp = new TempDirectory(); - using var container = new Container(); - - container.RegisterCoreServices("app", temp.Path); - - Assert.True(container.IsRegistered()); - } - - [Fact] - public void RegisterCoreServices_RegistersSecretServices() - { - using var temp = new TempDirectory(); - using var container = new Container(); - - container.RegisterCoreServices("app", temp.Path); - - // Storage is opt-in (AddFileStorage); core no longer registers it. - Assert.False(container.IsRegistered()); - Assert.False(container.IsRegistered()); - Assert.True(container.IsRegistered()); - Assert.True(container.IsRegistered()); - } - - [Fact] - public void RegisterCoreServices_RegistersDirectoriesConfig() - { - using var temp = new TempDirectory(); - using var container = new Container(); - - container.RegisterCoreServices("app", temp.Path); - - Assert.True(container.IsRegistered()); - } - [Fact] public void RegisterSecretServices_RegistersSecretProtectorAndStore() { diff --git a/tests/SquidStd.Tests/Services/Core/Scheduling/CronSchedulerServiceTests.cs b/tests/SquidStd.Tests/Services/Core/Scheduling/CronSchedulerServiceTests.cs index b16a19c1..02ee3891 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.Services.Core.Services; using SquidStd.Services.Core.Services.Scheduling; using SquidStd.Tests.Support; @@ -6,6 +7,24 @@ namespace SquidStd.Tests.Services.Core.Scheduling; public class CronSchedulerServiceTests { + [Fact] + public void Fire_HandlerThrows_IsLogged_AndKeepsRescheduling() + { + var timer = new FakeTimerService(); + var jobs = new ManualJobSystem(); + using var scheduler = new CronSchedulerService(timer, jobs); + scheduler.Schedule("boom", "* * * * *", _ => throw new InvalidOperationException("boom")); + + timer.FireDue(); + jobs.RunAll(); // handler throws, swallowed + Assert.Equal(1, timer.Count); // still rescheduled + + timer.FireDue(); + jobs.RunAll(); + Assert.Equal(1, timer.Count); // still alive + Assert.Equal(0, Assert.Single(scheduler.Jobs).RunCount); // failures do not count as runs + } + [Fact] public void Fire_RunsHandler_AndReschedules() { @@ -13,11 +32,20 @@ public void Fire_RunsHandler_AndReschedules() var jobs = new ManualJobSystem(); using var scheduler = new CronSchedulerService(timer, jobs); var count = 0; - scheduler.Schedule("tick", "* * * * *", _ => { count++; return Task.CompletedTask; }); + scheduler.Schedule( + "tick", + "* * * * *", + _ => + { + count++; + + return Task.CompletedTask; + } + ); Assert.Equal(1, timer.Count); // one-shot registered - timer.FireDue(); // fires -> job queued + rescheduled + timer.FireDue(); // fires -> job queued + rescheduled Assert.Equal(1, jobs.RunAll()); Assert.Equal(1, count); Assert.Equal(1, timer.Count); // rescheduled @@ -27,6 +55,32 @@ public void Fire_RunsHandler_AndReschedules() Assert.Equal(2, count); } + [Fact] + public void Fire_WhileRunning_SkipsOverlappingOccurrence() + { + var timer = new FakeTimerService(); + var jobs = new ManualJobSystem(); + using var scheduler = new CronSchedulerService(timer, jobs); + var count = 0; + scheduler.Schedule( + "tick", + "* * * * *", + _ => + { + count++; + + return Task.CompletedTask; + } + ); + + timer.FireDue(); // running flag set, job queued (not yet run) + timer.FireDue(); // still running -> occurrence skipped, no second job queued + + Assert.Equal(1, jobs.PendingCount); + jobs.RunAll(); + Assert.Equal(1, count); + } + [Fact] public void Jobs_ExposesRegisteredJob() { @@ -46,30 +100,44 @@ public void Jobs_ExposesRegisteredJob() } [Fact] - public void Schedule_InvalidCron_Throws() + public void RealTimerWheel_FiresJob_WhenAdvancedPastAMinute() { - var timer = new FakeTimerService(); + var timer = new TimerWheelService( + new() + { + TickDuration = TimeSpan.FromMilliseconds(8), + WheelSize = 512 + } + ); var jobs = new ManualJobSystem(); using var scheduler = new CronSchedulerService(timer, jobs); + var count = 0; + scheduler.Schedule( + "tick", + "* * * * *", + _ => + { + count++; - Assert.Throws(() => { scheduler.Schedule("bad", "not a cron", _ => Task.CompletedTask); }); + return Task.CompletedTask; + } + ); + + timer.UpdateTicksDelta(0); // baseline + timer.UpdateTicksDelta(61_000); // advance just over one minute + + Assert.True(jobs.RunAll() >= 1); + Assert.True(count >= 1); } [Fact] - public void Fire_WhileRunning_SkipsOverlappingOccurrence() + public void Schedule_InvalidCron_Throws() { var timer = new FakeTimerService(); var jobs = new ManualJobSystem(); using var scheduler = new CronSchedulerService(timer, jobs); - var count = 0; - scheduler.Schedule("tick", "* * * * *", _ => { count++; return Task.CompletedTask; }); - - timer.FireDue(); // running flag set, job queued (not yet run) - timer.FireDue(); // still running -> occurrence skipped, no second job queued - Assert.Equal(1, jobs.PendingCount); - jobs.RunAll(); - Assert.Equal(1, count); + Assert.Throws(() => { scheduler.Schedule("bad", "not a cron", _ => Task.CompletedTask); }); } [Fact] @@ -79,7 +147,16 @@ public void Unschedule_StopsFutureFirings() var jobs = new ManualJobSystem(); using var scheduler = new CronSchedulerService(timer, jobs); var count = 0; - var id = scheduler.Schedule("tick", "* * * * *", _ => { count++; return Task.CompletedTask; }); + var id = scheduler.Schedule( + "tick", + "* * * * *", + _ => + { + count++; + + return Task.CompletedTask; + } + ); Assert.True(scheduler.Unschedule(id)); Assert.Equal(0, timer.Count); @@ -102,44 +179,4 @@ public void UnscheduleByName_RemovesAllMatching() Assert.Equal(2, scheduler.UnscheduleByName("dup")); Assert.Single(scheduler.Jobs); } - - [Fact] - public void Fire_HandlerThrows_IsLogged_AndKeepsRescheduling() - { - var timer = new FakeTimerService(); - var jobs = new ManualJobSystem(); - using var scheduler = new CronSchedulerService(timer, jobs); - scheduler.Schedule("boom", "* * * * *", _ => throw new InvalidOperationException("boom")); - - timer.FireDue(); - jobs.RunAll(); // handler throws, swallowed - Assert.Equal(1, timer.Count); // still rescheduled - - timer.FireDue(); - jobs.RunAll(); - Assert.Equal(1, timer.Count); // still alive - Assert.Equal(0, Assert.Single(scheduler.Jobs).RunCount); // failures do not count as runs - } - - [Fact] - public void RealTimerWheel_FiresJob_WhenAdvancedPastAMinute() - { - var timer = new SquidStd.Services.Core.Services.TimerWheelService( - new SquidStd.Core.Data.Timing.TimerWheelConfig - { - TickDuration = TimeSpan.FromMilliseconds(8), - WheelSize = 512 - } - ); - var jobs = new ManualJobSystem(); - using var scheduler = new CronSchedulerService(timer, jobs); - var count = 0; - scheduler.Schedule("tick", "* * * * *", _ => { count++; return Task.CompletedTask; }); - - timer.UpdateTicksDelta(0); // baseline - timer.UpdateTicksDelta(61_000); // advance just over one minute - - Assert.True(jobs.RunAll() >= 1); - Assert.True(count >= 1); - } } diff --git a/tests/SquidStd.Tests/Services/Core/Scheduling/TimerWheelPumpServiceTests.cs b/tests/SquidStd.Tests/Services/Core/Scheduling/TimerWheelPumpServiceTests.cs index c5eb6cba..12cc50d1 100644 --- a/tests/SquidStd.Tests/Services/Core/Scheduling/TimerWheelPumpServiceTests.cs +++ b/tests/SquidStd.Tests/Services/Core/Scheduling/TimerWheelPumpServiceTests.cs @@ -1,4 +1,3 @@ -using SquidStd.Core.Data.Timing; using SquidStd.Services.Core.Services.Scheduling; using SquidStd.Tests.Support; @@ -8,17 +7,15 @@ public class TimerWheelPumpServiceTests { [Fact] public void Ctor_NonPositiveInterval_Throws() - { - Assert.Throws( - () => new TimerWheelPumpService(new FakeTimerService(), new TimerWheelPumpConfig { PumpInterval = TimeSpan.Zero }) + => Assert.Throws( + () => new TimerWheelPumpService(new FakeTimerService(), new() { PumpInterval = TimeSpan.Zero }) ); - } [Fact] public async Task Pump_AdvancesTheWheel() { var timer = new FakeTimerService(); - var pump = new TimerWheelPumpService(timer, new TimerWheelPumpConfig { PumpInterval = TimeSpan.FromMilliseconds(20) }); + var pump = new TimerWheelPumpService(timer, new() { PumpInterval = TimeSpan.FromMilliseconds(20) }); await pump.StartAsync(); diff --git a/tests/SquidStd.Tests/SquidStd.Tests.csproj b/tests/SquidStd.Tests/SquidStd.Tests.csproj index 7e8df98e..03081568 100644 --- a/tests/SquidStd.Tests/SquidStd.Tests.csproj +++ b/tests/SquidStd.Tests/SquidStd.Tests.csproj @@ -8,50 +8,56 @@ - - - - - - - - + + + + + + + + + - + - + - + - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/SquidStd.Tests/Storage/FileStorageServiceTests.cs b/tests/SquidStd.Tests/Storage/FileStorageServiceTests.cs index 36c21abe..bfcd3907 100644 --- a/tests/SquidStd.Tests/Storage/FileStorageServiceTests.cs +++ b/tests/SquidStd.Tests/Storage/FileStorageServiceTests.cs @@ -6,19 +6,11 @@ namespace SquidStd.Tests.Storage; public class FileStorageServiceTests { - [Fact] - public async Task SaveAsync_LoadAsync_RoundTripsBytes() + private sealed class SampleObject { - using var temp = new TempDirectory(); - var service = new FileStorageService(new() { RootDirectory = temp.Path }); - var data = Encoding.UTF8.GetBytes("hello storage"); - - await service.SaveAsync("profiles/main.bin", data); - - var loaded = await service.LoadAsync("profiles/main.bin"); + public string Name { get; set; } = string.Empty; - Assert.Equal(data, loaded); - Assert.True(await service.ExistsAsync("profiles/main.bin")); + public int Value { get; set; } } [Fact] @@ -35,13 +27,42 @@ public async Task DeleteAsync_RemovesStoredValue() Assert.Null(await service.LoadAsync("cache/value.bin")); } - [Theory, InlineData("../escape.bin"), InlineData("/absolute.bin"), InlineData("nested/../../escape.bin")] - public async Task SaveAsync_RejectsUnsafeKeys(string key) + [Fact] + public async Task ListKeysAsync_EmptyStore_ReturnsEmpty() { using var temp = new TempDirectory(); var service = new FileStorageService(new() { RootDirectory = temp.Path }); - await Assert.ThrowsAsync(() => service.SaveAsync(key, new byte[] { 1 }).AsTask()); + Assert.Empty(await ToListAsync(service.ListKeysAsync())); + } + + [Fact] + public async Task ListKeysAsync_ReturnsSavedKeys_AndFiltersByPrefix() + { + using var temp = new TempDirectory(); + var service = new FileStorageService(new() { 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 }); + + var all = (await ToListAsync(service.ListKeysAsync())).OrderBy(k => k, StringComparer.Ordinal).ToArray(); + var aOnly = (await ToListAsync(service.ListKeysAsync("a/"))).OrderBy(k => k, StringComparer.Ordinal).ToArray(); + + Assert.Equal(new[] { "a/one.bin", "a/two.bin", "b/three.bin" }, all); + Assert.Equal(new[] { "a/one.bin", "a/two.bin" }, aOnly); + } + + [Fact] + public async Task ObjectStorage_ListKeysAsync_ReturnsSavedKeys() + { + using var temp = new TempDirectory(); + var storage = new FileStorageService(new() { RootDirectory = temp.Path }); + var objects = new YamlObjectStorageService(storage); + await objects.SaveAsync("objects/x.yaml", new SampleObject { Name = "x", Value = 1 }); + + var keys = await ToListAsync(objects.ListKeysAsync("objects/")); + + Assert.Contains("objects/x.yaml", keys); } [Fact] @@ -65,60 +86,39 @@ public async Task ObjectStorage_SaveAsync_LoadAsync_RoundTripsYamlObject() Assert.Equal(expected.Value, actual.Value); } - private sealed class SampleObject - { - public string Name { get; set; } = string.Empty; - - public int Value { get; set; } - } - - private static async Task> ToListAsync(IAsyncEnumerable source) - { - var list = new List(); - - await foreach (var item in source) - { - list.Add(item); - } - - return list; - } - [Fact] - public async Task ListKeysAsync_ReturnsSavedKeys_AndFiltersByPrefix() + public async Task SaveAsync_LoadAsync_RoundTripsBytes() { using var temp = new TempDirectory(); var service = new FileStorageService(new() { 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 }); + var data = Encoding.UTF8.GetBytes("hello storage"); - var all = (await ToListAsync(service.ListKeysAsync())).OrderBy(k => k, StringComparer.Ordinal).ToArray(); - var aOnly = (await ToListAsync(service.ListKeysAsync("a/"))).OrderBy(k => k, StringComparer.Ordinal).ToArray(); + await service.SaveAsync("profiles/main.bin", data); - Assert.Equal(new[] { "a/one.bin", "a/two.bin", "b/three.bin" }, all); - Assert.Equal(new[] { "a/one.bin", "a/two.bin" }, aOnly); + var loaded = await service.LoadAsync("profiles/main.bin"); + + Assert.Equal(data, loaded); + Assert.True(await service.ExistsAsync("profiles/main.bin")); } - [Fact] - public async Task ListKeysAsync_EmptyStore_ReturnsEmpty() + [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 }); - Assert.Empty(await ToListAsync(service.ListKeysAsync())); + await Assert.ThrowsAsync(() => service.SaveAsync(key, new byte[] { 1 }).AsTask()); } - [Fact] - public async Task ObjectStorage_ListKeysAsync_ReturnsSavedKeys() + private static async Task> ToListAsync(IAsyncEnumerable source) { - using var temp = new TempDirectory(); - var storage = new FileStorageService(new() { RootDirectory = temp.Path }); - var objects = new YamlObjectStorageService(storage); - await objects.SaveAsync("objects/x.yaml", new SampleObject { Name = "x", Value = 1 }); + var list = new List(); - var keys = await ToListAsync(objects.ListKeysAsync("objects/")); + await foreach (var item in source) + { + list.Add(item); + } - Assert.Contains("objects/x.yaml", keys); + return list; } } diff --git a/tests/SquidStd.Tests/Storage/S3/MinioContainerFixture.cs b/tests/SquidStd.Tests/Storage/S3/MinioContainerFixture.cs index 04cbb7c1..c4763289 100644 --- a/tests/SquidStd.Tests/Storage/S3/MinioContainerFixture.cs +++ b/tests/SquidStd.Tests/Storage/S3/MinioContainerFixture.cs @@ -15,11 +15,11 @@ public sealed class MinioContainerFixture : IAsyncLifetime public string SecretKey => _container.GetSecretKey(); - public Task InitializeAsync() - => _container.StartAsync(); - public Task DisposeAsync() => _container.DisposeAsync().AsTask(); + + public Task InitializeAsync() + => _container.StartAsync(); } [CollectionDefinition(Name)] diff --git a/tests/SquidStd.Tests/Storage/S3/S3StorageServiceTests.cs b/tests/SquidStd.Tests/Storage/S3/S3StorageServiceTests.cs index 8899a9a1..60cd0c79 100644 --- a/tests/SquidStd.Tests/Storage/S3/S3StorageServiceTests.cs +++ b/tests/SquidStd.Tests/Storage/S3/S3StorageServiceTests.cs @@ -1,5 +1,4 @@ using System.Text; -using SquidStd.Storage.S3.Data.Config; using SquidStd.Storage.S3.Services; namespace SquidStd.Tests.Storage.S3; @@ -14,36 +13,11 @@ public S3StorageServiceTests(MinioContainerFixture fixture) _fixture = fixture; } - private S3StorageService NewService() - => new( - new S3StorageOptions - { - Endpoint = _fixture.Endpoint, - AccessKey = _fixture.AccessKey, - SecretKey = _fixture.SecretKey, - Bucket = "squidstd-tests", - UseSsl = false - } - ); - - private static string Key() => "k-" + Guid.NewGuid().ToString("N"); - - [Fact] - public async Task SaveThenLoad_RoundTrips_AndCreatesBucket() - { - var storage = NewService(); - var key = Key(); - - await storage.SaveAsync(key, Encoding.UTF8.GetBytes("hello")); - var loaded = await storage.LoadAsync(key); - - Assert.NotNull(loaded); - Assert.Equal("hello", Encoding.UTF8.GetString(loaded!)); - } - [Fact] - public async Task Load_MissingKey_ReturnsNull() - => Assert.Null(await NewService().LoadAsync(Key())); + public void Ctor_MissingEndpoint_Throws() + => Assert.Throws( + () => new S3StorageService(new() { AccessKey = "a", SecretKey = "b", Bucket = "c" }) + ); [Fact] public async Task Exists_And_Delete() @@ -58,14 +32,6 @@ public async Task Exists_And_Delete() Assert.False(await storage.DeleteAsync(key)); } - [Fact] - public void Ctor_MissingEndpoint_Throws() - { - Assert.Throws( - () => new S3StorageService(new S3StorageOptions { AccessKey = "a", SecretKey = "b", Bucket = "c" }) - ); - } - [Fact] public async Task ListKeysAsync_ReturnsObjectsUnderPrefix() { @@ -85,4 +51,36 @@ public async Task ListKeysAsync_ReturnsObjectsUnderPrefix() Assert.Contains(prefix + "a", keys); Assert.Contains(prefix + "b", keys); } + + [Fact] + public async Task Load_MissingKey_ReturnsNull() + => Assert.Null(await NewService().LoadAsync(Key())); + + [Fact] + public async Task SaveThenLoad_RoundTrips_AndCreatesBucket() + { + var storage = NewService(); + var key = Key(); + + await storage.SaveAsync(key, Encoding.UTF8.GetBytes("hello")); + var loaded = await storage.LoadAsync(key); + + Assert.NotNull(loaded); + Assert.Equal("hello", Encoding.UTF8.GetString(loaded!)); + } + + private static string Key() + => "k-" + Guid.NewGuid().ToString("N"); + + private S3StorageService NewService() + => new( + new() + { + Endpoint = _fixture.Endpoint, + AccessKey = _fixture.AccessKey, + SecretKey = _fixture.SecretKey, + Bucket = "squidstd-tests", + UseSsl = false + } + ); } diff --git a/tests/SquidStd.Tests/Storage/StorageConfigTests.cs b/tests/SquidStd.Tests/Storage/StorageConfigTests.cs index d663842a..ff4df751 100644 --- a/tests/SquidStd.Tests/Storage/StorageConfigTests.cs +++ b/tests/SquidStd.Tests/Storage/StorageConfigTests.cs @@ -1,21 +1,11 @@ using SquidStd.Core.Data.Storage; -using SquidStd.Storage.Abstractions.Data.Config; using SquidStd.Core.Interfaces.Config; +using SquidStd.Storage.Abstractions.Data.Config; namespace SquidStd.Tests.Storage; public class StorageConfigTests { - [Fact] - public void StorageConfig_ImplementsConfigEntry() - { - IConfigEntry entry = new StorageConfig(); - - Assert.Equal("storage", entry.SectionName); - Assert.Equal(typeof(StorageConfig), entry.ConfigType); - Assert.IsType(entry.CreateDefault()); - } - [Fact] public void SecretsConfig_ImplementsConfigEntry() { @@ -25,4 +15,14 @@ public void SecretsConfig_ImplementsConfigEntry() Assert.Equal(typeof(SecretsConfig), entry.ConfigType); Assert.IsType(entry.CreateDefault()); } + + [Fact] + public void StorageConfig_ImplementsConfigEntry() + { + IConfigEntry entry = new StorageConfig(); + + Assert.Equal("storage", entry.SectionName); + Assert.Equal(typeof(StorageConfig), entry.ConfigType); + Assert.IsType(entry.CreateDefault()); + } } diff --git a/tests/SquidStd.Tests/Storage/StorageRegistrationTests.cs b/tests/SquidStd.Tests/Storage/StorageRegistrationTests.cs index 4bbc2ffc..a7d7f88c 100644 --- a/tests/SquidStd.Tests/Storage/StorageRegistrationTests.cs +++ b/tests/SquidStd.Tests/Storage/StorageRegistrationTests.cs @@ -1,5 +1,4 @@ using DryIoc; -using SquidStd.Storage.Abstractions.Data.Config; using SquidStd.Storage.Abstractions.Interfaces; using SquidStd.Storage.Extensions; @@ -13,7 +12,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 StorageConfig { RootDirectory = root }); + container.AddFileStorage(new() { RootDirectory = root }); var storage = container.Resolve(); Assert.NotNull(container.Resolve()); @@ -23,6 +22,6 @@ public async Task AddFileStorage_ResolvesAndRoundTrips() Assert.Equal(new byte[] { 1, 2, 3 }, loaded); - Directory.Delete(root, recursive: true); + Directory.Delete(root, true); } } diff --git a/tests/SquidStd.Tests/Support/FakeCacheProvider.cs b/tests/SquidStd.Tests/Support/FakeCacheProvider.cs index 8dd5120a..d2f71128 100644 --- a/tests/SquidStd.Tests/Support/FakeCacheProvider.cs +++ b/tests/SquidStd.Tests/Support/FakeCacheProvider.cs @@ -12,6 +12,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)); + public Task?> GetAsync(string key, CancellationToken cancellationToken = default) { if (_store.TryGetValue(key, out var value)) @@ -22,7 +25,15 @@ public sealed class FakeCacheProvider : ICacheProvider return Task.FromResult?>(null); } - public Task SetAsync(string key, ReadOnlyMemory value, TimeSpan? ttl, CancellationToken cancellationToken = default) + public Task RemoveAsync(string key, CancellationToken cancellationToken = default) + => Task.FromResult(_store.TryRemove(key, out _)); + + public Task SetAsync( + string key, + ReadOnlyMemory value, + TimeSpan? ttl, + CancellationToken cancellationToken = default + ) { LastTtl = ttl; _store[key] = value.ToArray(); @@ -30,12 +41,6 @@ public Task SetAsync(string key, ReadOnlyMemory value, TimeSpan? ttl, Canc return Task.CompletedTask; } - public Task RemoveAsync(string key, CancellationToken cancellationToken = default) - => Task.FromResult(_store.TryRemove(key, out _)); - - public Task ExistsAsync(string key, CancellationToken cancellationToken = default) - => Task.FromResult(_store.ContainsKey(key)); - public ValueTask StartAsync(CancellationToken cancellationToken = default) => ValueTask.CompletedTask; diff --git a/tests/SquidStd.Tests/Support/FakeHealthCheck.cs b/tests/SquidStd.Tests/Support/FakeHealthCheck.cs index 3e52efb4..93ccd25b 100644 --- a/tests/SquidStd.Tests/Support/FakeHealthCheck.cs +++ b/tests/SquidStd.Tests/Support/FakeHealthCheck.cs @@ -13,7 +13,12 @@ public sealed class FakeHealthCheck : IHealthCheck private readonly Exception? _throw; private readonly TimeSpan _delay; - public FakeHealthCheck(string name, HealthCheckResult? result = null, TimeSpan? delay = null, Exception? throwException = null) + public FakeHealthCheck( + string name, + HealthCheckResult? result = null, + TimeSpan? delay = null, + Exception? throwException = null + ) { Name = name; _result = result; diff --git a/tests/SquidStd.Tests/Support/FakeTimeProvider.cs b/tests/SquidStd.Tests/Support/FakeTimeProvider.cs index cfff2cde..bfbfea96 100644 --- a/tests/SquidStd.Tests/Support/FakeTimeProvider.cs +++ b/tests/SquidStd.Tests/Support/FakeTimeProvider.cs @@ -13,25 +13,23 @@ public FakeTimeProvider(DateTimeOffset start) _utcNow = start; } - public override DateTimeOffset GetUtcNow() - => _utcNow; - - public void Advance(TimeSpan delta) - => _utcNow = _utcNow.Add(delta); - - public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period) - => new InertTimer(); - private sealed class InertTimer : ITimer { public bool Change(TimeSpan dueTime, TimeSpan period) => true; - public void Dispose() - { - } + public void Dispose() { } public ValueTask DisposeAsync() => ValueTask.CompletedTask; } + + public void Advance(TimeSpan delta) + => _utcNow = _utcNow.Add(delta); + + public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period) + => new InertTimer(); + + public override DateTimeOffset GetUtcNow() + => _utcNow; } diff --git a/tests/SquidStd.Tests/Support/FakeTimerService.cs b/tests/SquidStd.Tests/Support/FakeTimerService.cs index be9668a5..71e4f4e9 100644 --- a/tests/SquidStd.Tests/Support/FakeTimerService.cs +++ b/tests/SquidStd.Tests/Support/FakeTimerService.cs @@ -17,6 +17,24 @@ 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"); @@ -50,22 +68,4 @@ 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/ManualJobSystem.cs b/tests/SquidStd.Tests/Support/ManualJobSystem.cs index 866281ab..ed9d62d5 100644 --- a/tests/SquidStd.Tests/Support/ManualJobSystem.cs +++ b/tests/SquidStd.Tests/Support/ManualJobSystem.cs @@ -19,20 +19,7 @@ public sealed class ManualJobSystem : IJobSystem public long CompletedCount { get; private set; } - public Task ScheduleAsync(Action work, CancellationToken cancellationToken = default) - { - _pending.Add(work); - - return Task.CompletedTask; - } - - public Task ScheduleAsync(Func work, CancellationToken cancellationToken = default) - { - var result = work(); - CompletedCount++; - - return Task.FromResult(result); - } + public void Dispose() { } /// Runs and clears all queued work; returns how many items ran. public int RunAll() @@ -49,7 +36,18 @@ public int RunAll() return snapshot.Length; } - public void Dispose() + public Task ScheduleAsync(Action work, CancellationToken cancellationToken = default) { + _pending.Add(work); + + return Task.CompletedTask; + } + + public Task ScheduleAsync(Func work, CancellationToken cancellationToken = default) + { + var result = work(); + CompletedCount++; + + return Task.FromResult(result); } } diff --git a/tests/SquidStd.Tests/Templating/ScribanTemplateRendererTests.cs b/tests/SquidStd.Tests/Templating/ScribanTemplateRendererTests.cs index 414f63ba..be52fb88 100644 --- a/tests/SquidStd.Tests/Templating/ScribanTemplateRendererTests.cs +++ b/tests/SquidStd.Tests/Templating/ScribanTemplateRendererTests.cs @@ -1,4 +1,3 @@ -using SquidStd.Core.Directories; using SquidStd.Templating; using SquidStd.Templating.Services; using SquidStd.Tests.Support; @@ -7,18 +6,13 @@ namespace SquidStd.Tests.Templating; public class ScribanTemplateRendererTests { - private static ScribanTemplateRenderer NewRenderer(string root) - => new(new DirectoriesConfig(root, [])); - [Fact] - public async Task RenderAsync_RendersModel() + public void Register_MalformedTemplate_Throws() { using var temp = new TempDirectory(); var renderer = NewRenderer(temp.Path); - var result = await renderer.RenderAsync("Hi {{ user.name }}", new { User = new { Name = "squid" } }); - - Assert.Equal("Hi squid", result); + Assert.Throws(() => renderer.Register("bad", "{{ for x in }}")); } [Fact] @@ -34,12 +28,12 @@ public async Task RegisterThenRenderByName_Works() } [Fact] - public async Task RenderByNameAsync_UnknownName_Throws() + public async Task RenderAsync_EmptyTemplate_Throws() { using var temp = new TempDirectory(); var renderer = NewRenderer(temp.Path); - await Assert.ThrowsAsync(async () => await renderer.RenderByNameAsync("nope", null)); + await Assert.ThrowsAsync(async () => await renderer.RenderAsync(string.Empty, null)); } [Fact] @@ -52,21 +46,23 @@ public async Task RenderAsync_MalformedTemplate_Throws() } [Fact] - public void Register_MalformedTemplate_Throws() + public async Task RenderAsync_RendersModel() { using var temp = new TempDirectory(); var renderer = NewRenderer(temp.Path); - Assert.Throws(() => renderer.Register("bad", "{{ for x in }}")); + var result = await renderer.RenderAsync("Hi {{ user.name }}", new { User = new { Name = "squid" } }); + + Assert.Equal("Hi squid", result); } [Fact] - public async Task RenderAsync_EmptyTemplate_Throws() + public async Task RenderByNameAsync_UnknownName_Throws() { using var temp = new TempDirectory(); var renderer = NewRenderer(temp.Path); - await Assert.ThrowsAsync(async () => await renderer.RenderAsync(string.Empty, null)); + await Assert.ThrowsAsync(async () => await renderer.RenderByNameAsync("nope", null)); } [Fact] @@ -84,4 +80,7 @@ public async Task StartAsync_AutoLoadsTemplatesFromDirectory() Assert.Equal("Welcome squid", result); } + + private static ScribanTemplateRenderer NewRenderer(string root) + => new(new(root, [])); } diff --git a/tests/SquidStd.Tests/Workers/JobDispatcherTests.cs b/tests/SquidStd.Tests/Workers/JobDispatcherTests.cs index 6114d4dc..dbcf7402 100644 --- a/tests/SquidStd.Tests/Workers/JobDispatcherTests.cs +++ b/tests/SquidStd.Tests/Workers/JobDispatcherTests.cs @@ -7,9 +7,6 @@ namespace SquidStd.Tests.Workers; public class JobDispatcherTests { - private static JobRequest Job(string name) - => new(name, new Dictionary()); - [Fact] public async Task DispatchAsync_InvokesHandlerMatchingJobName() { @@ -23,24 +20,29 @@ public async Task DispatchAsync_InvokesHandlerMatchingJobName() Assert.Single(encode.Received); } + [Fact] + 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) + ); + } + [Fact] public async Task DispatchAsync_ThrowsWhenNoHandlerMatches() { var dispatcher = new JobDispatcher([new RecordingJobHandler("resize")]); var ex = await Assert.ThrowsAsync( - () => dispatcher.DispatchAsync(Job("unknown"), CancellationToken.None)); + () => dispatcher.DispatchAsync(Job("unknown"), CancellationToken.None) + ); Assert.Equal("unknown", ex.JobName); } - [Fact] - 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)); - } + private static JobRequest Job(string name) + => new(name, new Dictionary()); } diff --git a/tests/SquidStd.Tests/Workers/Support/FakeMessageQueue.cs b/tests/SquidStd.Tests/Workers/Support/FakeMessageQueue.cs index 1a1812d2..aa72a883 100644 --- a/tests/SquidStd.Tests/Workers/Support/FakeMessageQueue.cs +++ b/tests/SquidStd.Tests/Workers/Support/FakeMessageQueue.cs @@ -5,6 +5,11 @@ 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 void Dispose() { } + } + public Task PublishAsync(string queueName, TMessage message, CancellationToken cancellationToken = default) => Task.CompletedTask; @@ -13,11 +18,4 @@ public IDisposable Subscribe(string queueName, IQueueMessageListener(string queueName, IQueueMessageListenerAsync listener) => new Subscription(); - - private sealed class Subscription : IDisposable - { - public void Dispose() - { - } - } } diff --git a/tests/SquidStd.Tests/Workers/WorkerConsumerServiceTests.cs b/tests/SquidStd.Tests/Workers/WorkerConsumerServiceTests.cs index 7f04c1e3..ef8383fb 100644 --- a/tests/SquidStd.Tests/Workers/WorkerConsumerServiceTests.cs +++ b/tests/SquidStd.Tests/Workers/WorkerConsumerServiceTests.cs @@ -1,41 +1,44 @@ using SquidStd.Tests.Workers.Support; using SquidStd.Workers.Abstractions.Data; -using SquidStd.Workers.Data.Config; using SquidStd.Workers.Services; namespace SquidStd.Tests.Workers; public class WorkerConsumerServiceTests { - private static JobRequest Job(string name) - => new(name, new Dictionary()); - - private static WorkerConsumerService Build(WorkerState state, params RecordingJobHandler[] handlers) - => new(new FakeMessageQueue(), new JobDispatcher(handlers), state, new WorkersConfig()); - [Fact] - public async Task HandleAsync_RunsMatchingHandler() + public async Task HandleAsync_DropsUnknownJobWithoutThrowing() { - var handler = new RecordingJobHandler("resize"); var state = new WorkerState(new() { WorkerId = "w1", MaxConcurrency = 2 }); - var consumer = Build(state, handler); + var consumer = Build(state, new RecordingJobHandler("resize")); - await consumer.HandleAsync(Job("resize"), CancellationToken.None); + var ex = await Record.ExceptionAsync(() => consumer.HandleAsync(Job("unknown"), CancellationToken.None)); - Assert.Single(handler.Received); + Assert.Null(ex); Assert.Equal(0, state.ActiveJobs); } [Fact] - public async Task HandleAsync_DropsUnknownJobWithoutThrowing() + public async Task HandleAsync_NeverExceedsMaxConcurrency() { + var handler = new RecordingJobHandler("resize") { Gate = new() }; var state = new WorkerState(new() { WorkerId = "w1", MaxConcurrency = 2 }); - var consumer = Build(state, new RecordingJobHandler("resize")); + var consumer = Build(state, handler); - var ex = await Record.ExceptionAsync(() => consumer.HandleAsync(Job("unknown"), CancellationToken.None)); + // 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(); - Assert.Null(ex); + // Give the semaphore time to admit as many as it will, then assert the cap held. + await Task.Delay(200); + Assert.True(state.ActiveJobs <= 2, $"ActiveJobs was {state.ActiveJobs}, expected <= 2"); + + // Release the gate so everything drains. + handler.Gate!.SetResult(); + await Task.WhenAll(inFlight); Assert.Equal(0, state.ActiveJobs); + Assert.Equal(5, handler.Received.Count); } [Fact] @@ -46,31 +49,28 @@ public async Task HandleAsync_RethrowsHandlerExceptionForRequeue() var consumer = Build(state, handler); await Assert.ThrowsAsync( - () => consumer.HandleAsync(Job("resize"), CancellationToken.None)); + () => consumer.HandleAsync(Job("resize"), CancellationToken.None) + ); Assert.Equal(0, state.ActiveJobs); } [Fact] - public async Task HandleAsync_NeverExceedsMaxConcurrency() + public async Task HandleAsync_RunsMatchingHandler() { - var handler = new RecordingJobHandler("resize") { Gate = new TaskCompletionSource() }; + var handler = new RecordingJobHandler("resize"); var state = new WorkerState(new() { 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(); - - // Give the semaphore time to admit as many as it will, then assert the cap held. - await Task.Delay(200); - Assert.True(state.ActiveJobs <= 2, $"ActiveJobs was {state.ActiveJobs}, expected <= 2"); + await consumer.HandleAsync(Job("resize"), CancellationToken.None); - // Release the gate so everything drains. - handler.Gate!.SetResult(); - await Task.WhenAll(inFlight); + Assert.Single(handler.Received); Assert.Equal(0, state.ActiveJobs); - Assert.Equal(5, handler.Received.Count); } + + private static WorkerConsumerService Build(WorkerState state, params RecordingJobHandler[] handlers) + => new(new FakeMessageQueue(), new JobDispatcher(handlers), state, new()); + + private static JobRequest Job(string name) + => new(name, new Dictionary()); } diff --git a/tests/SquidStd.Tests/Workers/WorkerContractsTests.cs b/tests/SquidStd.Tests/Workers/WorkerContractsTests.cs index 8e9df30b..d801fb9d 100644 --- a/tests/SquidStd.Tests/Workers/WorkerContractsTests.cs +++ b/tests/SquidStd.Tests/Workers/WorkerContractsTests.cs @@ -14,7 +14,8 @@ public void JobRequest_RoundTrips_PreservingNameAndParameters() { var original = new JobRequest( "resize-image", - new Dictionary { ["width"] = "800", ["height"] = "600" }); + new Dictionary { ["width"] = "800", ["height"] = "600" } + ); var bytes = Serializer.Serialize(original); var restored = Serializer.Deserialize(bytes); @@ -25,38 +26,45 @@ public void JobRequest_RoundTrips_PreservingNameAndParameters() } [Fact] - public void WorkerHeartbeat_RoundTrips_PreservingAllFields() + public void WorkerChannels_NamesAreNonEmptyAndDistinct() { - var original = new WorkerHeartbeat( - "worker-1", - new DateTime(2026, 6, 23, 10, 0, 0, DateTimeKind.Utc), - WorkerStatusType.Busy, - 3, - 8); - - var restored = Serializer.Deserialize(Serializer.Serialize(original)); - - Assert.Equal(original, restored); + Assert.False(string.IsNullOrWhiteSpace(WorkerChannels.JobQueue)); + Assert.False(string.IsNullOrWhiteSpace(WorkerChannels.HeartbeatTopic)); + 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 DateTime(2026, 6, 23, 11, 0, 0, DateTimeKind.Utc), + new(2026, 6, 23, 11, 0, 0, DateTimeKind.Utc), status, 0, - 4); + 4 + ); var restored = Serializer.Deserialize(Serializer.Serialize(original)); Assert.Equal(status, restored.Status); } + [Fact] + public void WorkerHeartbeat_RoundTrips_PreservingAllFields() + { + var original = new WorkerHeartbeat( + "worker-1", + new(2026, 6, 23, 10, 0, 0, DateTimeKind.Utc), + WorkerStatusType.Busy, + 3, + 8 + ); + + var restored = Serializer.Deserialize(Serializer.Serialize(original)); + + Assert.Equal(original, restored); + } + [Fact] public void WorkerInfo_RoundTrips_PreservingAllFields() { @@ -65,19 +73,12 @@ public void WorkerInfo_RoundTrips_PreservingAllFields() WorkerStatusType.Offline, 2, 8, - new DateTime(2026, 6, 23, 9, 0, 0, DateTimeKind.Utc), - new DateTime(2026, 6, 23, 9, 30, 0, DateTimeKind.Utc)); + new(2026, 6, 23, 9, 0, 0, DateTimeKind.Utc), + new(2026, 6, 23, 9, 30, 0, DateTimeKind.Utc) + ); var restored = Serializer.Deserialize(Serializer.Serialize(original)); Assert.Equal(original, restored); } - - [Fact] - public void WorkerChannels_NamesAreNonEmptyAndDistinct() - { - Assert.False(string.IsNullOrWhiteSpace(WorkerChannels.JobQueue)); - Assert.False(string.IsNullOrWhiteSpace(WorkerChannels.HeartbeatTopic)); - Assert.NotEqual(WorkerChannels.JobQueue, WorkerChannels.HeartbeatTopic); - } } diff --git a/tests/SquidStd.Tests/Workers/WorkerHeartbeatServiceTests.cs b/tests/SquidStd.Tests/Workers/WorkerHeartbeatServiceTests.cs index 6efcf659..c52d488e 100644 --- a/tests/SquidStd.Tests/Workers/WorkerHeartbeatServiceTests.cs +++ b/tests/SquidStd.Tests/Workers/WorkerHeartbeatServiceTests.cs @@ -13,25 +13,23 @@ namespace SquidStd.Tests.Workers; public class WorkerHeartbeatServiceTests { - private static (IMessageTopic topic, WorkerState state) NewMessaging(WorkersConfig config) - { - var container = new Container(); - container.RegisterInstance(new EventBusService()); - container.AddInMemoryMessaging(); - - return (container.Resolve(), new WorkerState(config)); - } - [Fact] - public async Task StartAsync_PublishesHeartbeatImmediately() + public async Task Heartbeat_ReflectsBusyStateWithActiveJobs() { var config = new WorkersConfig { WorkerId = "w1", MaxConcurrency = 8, HeartbeatIntervalSeconds = 60 }; var (topic, state) = NewMessaging(config); + state.JobStarted(); var received = new TaskCompletionSource(); using var _ = topic.Subscribe( WorkerChannels.HeartbeatTopic, - (hb, _) => { received.TrySetResult(hb); return Task.CompletedTask; }); + (hb, _) => + { + received.TrySetResult(hb); + + return Task.CompletedTask; + } + ); var service = new WorkerHeartbeatService(topic, state, config); await service.StartAsync(); @@ -39,23 +37,26 @@ public async Task StartAsync_PublishesHeartbeatImmediately() var heartbeat = await received.Task.WaitAsync(TimeSpan.FromSeconds(5)); await service.StopAsync(); - Assert.Equal("w1", heartbeat.WorkerId); - Assert.Equal(8, heartbeat.MaxConcurrency); - Assert.Equal(0, heartbeat.ActiveJobs); - Assert.Equal(WorkerStatusType.Idle, heartbeat.Status); + Assert.Equal(1, heartbeat.ActiveJobs); + Assert.Equal(WorkerStatusType.Busy, heartbeat.Status); } [Fact] - public async Task Heartbeat_ReflectsBusyStateWithActiveJobs() + public async Task StartAsync_PublishesHeartbeatImmediately() { var config = new WorkersConfig { WorkerId = "w1", MaxConcurrency = 8, HeartbeatIntervalSeconds = 60 }; var (topic, state) = NewMessaging(config); - state.JobStarted(); var received = new TaskCompletionSource(); using var _ = topic.Subscribe( WorkerChannels.HeartbeatTopic, - (hb, _) => { received.TrySetResult(hb); return Task.CompletedTask; }); + (hb, _) => + { + received.TrySetResult(hb); + + return Task.CompletedTask; + } + ); var service = new WorkerHeartbeatService(topic, state, config); await service.StartAsync(); @@ -63,7 +64,18 @@ public async Task Heartbeat_ReflectsBusyStateWithActiveJobs() var heartbeat = await received.Task.WaitAsync(TimeSpan.FromSeconds(5)); await service.StopAsync(); - Assert.Equal(1, heartbeat.ActiveJobs); - Assert.Equal(WorkerStatusType.Busy, heartbeat.Status); + Assert.Equal("w1", heartbeat.WorkerId); + Assert.Equal(8, heartbeat.MaxConcurrency); + Assert.Equal(0, heartbeat.ActiveJobs); + Assert.Equal(WorkerStatusType.Idle, heartbeat.Status); + } + + private static (IMessageTopic topic, WorkerState state) NewMessaging(WorkersConfig config) + { + var container = new Container(); + container.RegisterInstance(new EventBusService()); + container.AddInMemoryMessaging(); + + return (container.Resolve(), new(config)); } } diff --git a/tests/SquidStd.Tests/Workers/WorkerStateTests.cs b/tests/SquidStd.Tests/Workers/WorkerStateTests.cs index 8e8b7942..281e2e5b 100644 --- a/tests/SquidStd.Tests/Workers/WorkerStateTests.cs +++ b/tests/SquidStd.Tests/Workers/WorkerStateTests.cs @@ -1,24 +1,22 @@ -using SquidStd.Workers.Data.Config; -using SquidStd.Workers.Services; using SquidStd.Workers.Abstractions.Types; +using SquidStd.Workers.Services; namespace SquidStd.Tests.Workers; public class WorkerStateTests { [Fact] - public void Status_IsIdleWhenNoActiveJobs() + public void MaxConcurrency_FallsBackToProcessorCountWhenNotPositive() { - var state = new WorkerState(new WorkersConfig { WorkerId = "w1", MaxConcurrency = 4 }); + var state = new WorkerState(new() { WorkerId = "w1", MaxConcurrency = 0 }); - Assert.Equal(WorkerStatusType.Idle, state.Status); - Assert.Equal(0, state.ActiveJobs); + Assert.Equal(Environment.ProcessorCount, state.MaxConcurrency); } [Fact] public void Status_IsBusyWhileJobsActive() { - var state = new WorkerState(new WorkersConfig { WorkerId = "w1", MaxConcurrency = 4 }); + var state = new WorkerState(new() { WorkerId = "w1", MaxConcurrency = 4 }); state.JobStarted(); state.JobStarted(); @@ -34,18 +32,19 @@ public void Status_IsBusyWhileJobsActive() } [Fact] - public void WorkerId_FallsBackToMachineNameWhenBlank() + public void Status_IsIdleWhenNoActiveJobs() { - var state = new WorkerState(new WorkersConfig { WorkerId = " ", MaxConcurrency = 4 }); + var state = new WorkerState(new() { WorkerId = "w1", MaxConcurrency = 4 }); - Assert.Equal(Environment.MachineName, state.WorkerId); + Assert.Equal(WorkerStatusType.Idle, state.Status); + Assert.Equal(0, state.ActiveJobs); } [Fact] - public void MaxConcurrency_FallsBackToProcessorCountWhenNotPositive() + public void WorkerId_FallsBackToMachineNameWhenBlank() { - var state = new WorkerState(new WorkersConfig { WorkerId = "w1", MaxConcurrency = 0 }); + var state = new WorkerState(new() { WorkerId = " ", MaxConcurrency = 4 }); - Assert.Equal(Environment.ProcessorCount, state.MaxConcurrency); + Assert.Equal(Environment.MachineName, state.WorkerId); } } diff --git a/tests/SquidStd.Tests/Workers/WorkersRegistrationExtensionsTests.cs b/tests/SquidStd.Tests/Workers/WorkersRegistrationExtensionsTests.cs index 5d4b73b4..057c9724 100644 --- a/tests/SquidStd.Tests/Workers/WorkersRegistrationExtensionsTests.cs +++ b/tests/SquidStd.Tests/Workers/WorkersRegistrationExtensionsTests.cs @@ -2,6 +2,7 @@ using SquidStd.Core.Interfaces.Events; using SquidStd.Messaging.Extensions; using SquidStd.Services.Core.Services; +using SquidStd.Workers.Abstractions.Data; using SquidStd.Workers.Data.Config; using SquidStd.Workers.Extensions; using SquidStd.Workers.Interfaces; @@ -11,28 +12,18 @@ namespace SquidStd.Tests.Workers; public class WorkersRegistrationExtensionsTests { - private static Container NewContainer() + private sealed class EchoJobHandler : IJobHandler { - var container = new Container(); - container.RegisterInstance(new EventBusService()); - container.AddInMemoryMessaging(); - // ConfigManager normally registers config instances; register one directly for the test. - container.RegisterInstance(new WorkersConfig { WorkerId = "w1", MaxConcurrency = 4 }); - - return container; - } + public static int Calls; - [Fact] - public void AddWorkers_RegistersResolvableServices() - { - using var container = NewContainer(); + public string JobName => "echo"; - container.AddWorkers(); + public Task HandleAsync(JobRequest job, CancellationToken cancellationToken) + { + Interlocked.Increment(ref Calls); - Assert.NotNull(container.Resolve()); - Assert.NotNull(container.Resolve()); - Assert.NotNull(container.Resolve()); - Assert.NotNull(container.Resolve()); + return Task.CompletedTask; + } } [Fact] @@ -49,17 +40,28 @@ public async Task AddJobHandler_MakesHandlerReachableFromDispatcher() Assert.Equal(1, EchoJobHandler.Calls); } - private sealed class EchoJobHandler : IJobHandler + [Fact] + public void AddWorkers_RegistersResolvableServices() { - public static int Calls; + using var container = NewContainer(); - public string JobName => "echo"; + container.AddWorkers(); - public Task HandleAsync(SquidStd.Workers.Abstractions.Data.JobRequest job, CancellationToken cancellationToken) - { - Interlocked.Increment(ref Calls); + Assert.NotNull(container.Resolve()); + Assert.NotNull(container.Resolve()); + Assert.NotNull(container.Resolve()); + Assert.NotNull(container.Resolve()); + } - return Task.CompletedTask; - } + private static Container NewContainer() + { + var container = new Container(); + container.RegisterInstance(new EventBusService()); + container.AddInMemoryMessaging(); + + // ConfigManager normally registers config instances; register one directly for the test. + container.RegisterInstance(new WorkersConfig { WorkerId = "w1", MaxConcurrency = 4 }); + + return container; } }