diff --git a/README.md b/README.md
index a879589d..306d9ab4 100644
--- a/README.md
+++ b/README.md
@@ -86,6 +86,7 @@ block until cancellation for long-running hosts.
| `SquidStd.Storage` | Local file storage backend (`AddFileStorage`). | [](src/SquidStd.Storage/README.md) · [](https://www.nuget.org/packages/SquidStd.Storage/) |
| `SquidStd.Storage.S3` | S3/MinIO storage backend (`AddS3Storage`). | [](src/SquidStd.Storage.S3/README.md) · [](https://www.nuget.org/packages/SquidStd.Storage.S3/) |
| `SquidStd.Scripting.Lua` | Lua scripting engine with attribute-based modules and event bridging. | [](src/SquidStd.Scripting.Lua/README.md) · [](https://www.nuget.org/packages/SquidStd.Scripting.Lua/) |
+| `SquidStd.Templating` | Scriban templating with a named-template registry and `templates/*.tmpl` auto-load (`AddTemplating`). | [](src/SquidStd.Templating/README.md) · [](https://www.nuget.org/packages/SquidStd.Templating/) |
## Architecture
diff --git a/SquidStd.slnx b/SquidStd.slnx
index 12d68a83..6d73a395 100644
--- a/SquidStd.slnx
+++ b/SquidStd.slnx
@@ -18,6 +18,10 @@
+
+
+
+
diff --git a/docs/articles/templating.md b/docs/articles/templating.md
new file mode 100644
index 00000000..3bcd926d
--- /dev/null
+++ b/docs/articles/templating.md
@@ -0,0 +1 @@
+[!include[](../../src/SquidStd.Templating/README.md)]
diff --git a/docs/articles/toc.yml b/docs/articles/toc.yml
index 229340cd..280a0eb9 100644
--- a/docs/articles/toc.yml
+++ b/docs/articles/toc.yml
@@ -36,6 +36,14 @@
href: storage-s3.md
- name: SquidStd.Scripting.Lua
href: scripting-lua.md
+- name: SquidStd.Templating
+ href: templating.md
+- name: SquidStd.Workers.Abstractions
+ href: workers-abstractions.md
+- name: SquidStd.Workers
+ href: workers.md
+- name: SquidStd.Workers.Manager
+ href: workers-manager.md
- name: Serialization
href: serialization.md
- name: Scheduler
diff --git a/docs/articles/workers-abstractions.md b/docs/articles/workers-abstractions.md
new file mode 100644
index 00000000..274c4be9
--- /dev/null
+++ b/docs/articles/workers-abstractions.md
@@ -0,0 +1 @@
+[!include[](../../src/SquidStd.Workers.Abstractions/README.md)]
diff --git a/docs/articles/workers-manager.md b/docs/articles/workers-manager.md
new file mode 100644
index 00000000..2d372ba9
--- /dev/null
+++ b/docs/articles/workers-manager.md
@@ -0,0 +1 @@
+[!include[](../../src/SquidStd.Workers.Manager/README.md)]
diff --git a/docs/articles/workers.md b/docs/articles/workers.md
new file mode 100644
index 00000000..22565bdb
--- /dev/null
+++ b/docs/articles/workers.md
@@ -0,0 +1 @@
+[!include[](../../src/SquidStd.Workers/README.md)]
diff --git a/src/SquidStd.Messaging.Abstractions/Data/Events/TopicMessageEvent.cs b/src/SquidStd.Messaging.Abstractions/Data/Events/TopicMessageEvent.cs
new file mode 100644
index 00000000..b3a43554
--- /dev/null
+++ b/src/SquidStd.Messaging.Abstractions/Data/Events/TopicMessageEvent.cs
@@ -0,0 +1,9 @@
+using SquidStd.Core.Interfaces.Events;
+
+namespace SquidStd.Messaging.Abstractions.Data.Events;
+
+///
+/// Event published on the in-process event bus when a topic message is bridged. is the
+/// deserialized message.
+///
+public sealed record TopicMessageEvent(string Topic, object Data) : IEvent;
diff --git a/src/SquidStd.Messaging.Abstractions/Interfaces/IMessageTopic.cs b/src/SquidStd.Messaging.Abstractions/Interfaces/IMessageTopic.cs
new file mode 100644
index 00000000..e1b1c2be
--- /dev/null
+++ b/src/SquidStd.Messaging.Abstractions/Interfaces/IMessageTopic.cs
@@ -0,0 +1,13 @@
+namespace SquidStd.Messaging.Abstractions.Interfaces;
+
+///
+/// Typed facade for publishing to and subscribing to topics (fan-out).
+///
+public interface IMessageTopic
+{
+ /// Publishes a typed message to a topic.
+ Task PublishAsync(string topic, TMessage message, CancellationToken cancellationToken = default);
+
+ /// Subscribes a typed handler to a topic. Dispose to unsubscribe.
+ IDisposable Subscribe(string topic, Func handler);
+}
diff --git a/src/SquidStd.Messaging.Abstractions/Interfaces/ITopicEventBridge.cs b/src/SquidStd.Messaging.Abstractions/Interfaces/ITopicEventBridge.cs
new file mode 100644
index 00000000..2ba8483c
--- /dev/null
+++ b/src/SquidStd.Messaging.Abstractions/Interfaces/ITopicEventBridge.cs
@@ -0,0 +1,11 @@
+namespace SquidStd.Messaging.Abstractions.Interfaces;
+
+///
+/// Bridges a topic into the in-process event bus: each message of type T on the topic is republished
+/// as a TopicMessageEvent.
+///
+public interface ITopicEventBridge
+{
+ /// Starts bridging the given topic; dispose the result to stop.
+ IDisposable Bridge(string topic);
+}
diff --git a/src/SquidStd.Messaging.Abstractions/Interfaces/ITopicProvider.cs b/src/SquidStd.Messaging.Abstractions/Interfaces/ITopicProvider.cs
new file mode 100644
index 00000000..c5882dc4
--- /dev/null
+++ b/src/SquidStd.Messaging.Abstractions/Interfaces/ITopicProvider.cs
@@ -0,0 +1,16 @@
+using SquidStd.Abstractions.Interfaces.Services;
+
+namespace SquidStd.Messaging.Abstractions.Interfaces;
+
+///
+/// Byte-level publish/subscribe transport: every current subscriber of a topic receives every message
+/// (transient, at-most-once fan-out).
+///
+public interface ITopicProvider : ISquidStdService, IAsyncDisposable
+{
+ /// Publishes a raw payload to a topic (fan-out to all current subscribers).
+ Task PublishAsync(string topic, ReadOnlyMemory payload, CancellationToken cancellationToken = default);
+
+ /// Subscribes a raw handler to a topic. Dispose to unsubscribe.
+ IDisposable Subscribe(string topic, Func, CancellationToken, Task> handler);
+}
diff --git a/src/SquidStd.Messaging.Abstractions/Services/MessageTopic.cs b/src/SquidStd.Messaging.Abstractions/Services/MessageTopic.cs
new file mode 100644
index 00000000..f6c4a6cc
--- /dev/null
+++ b/src/SquidStd.Messaging.Abstractions/Services/MessageTopic.cs
@@ -0,0 +1,34 @@
+using SquidStd.Core.Interfaces.Serialization;
+using SquidStd.Messaging.Abstractions.Interfaces;
+
+namespace SquidStd.Messaging.Abstractions.Services;
+
+///
+/// Typed facade over an : serializes outgoing messages and deserializes
+/// incoming payloads.
+///
+public sealed class MessageTopic : IMessageTopic
+{
+ private readonly ITopicProvider _provider;
+ private readonly IDataSerializer _serializer;
+ private readonly IDataDeserializer _deserializer;
+
+ public MessageTopic(ITopicProvider provider, IDataSerializer serializer, IDataDeserializer deserializer)
+ {
+ _provider = provider;
+ _serializer = serializer;
+ _deserializer = deserializer;
+ }
+
+ ///
+ public Task PublishAsync(string topic, TMessage message, CancellationToken cancellationToken = default)
+ => _provider.PublishAsync(topic, _serializer.Serialize(message), cancellationToken);
+
+ ///
+ public IDisposable Subscribe(string topic, Func handler)
+ {
+ ArgumentNullException.ThrowIfNull(handler);
+
+ return _provider.Subscribe(topic, (payload, cancellationToken) => handler(_deserializer.Deserialize(payload), cancellationToken));
+ }
+}
diff --git a/src/SquidStd.Messaging.Abstractions/Services/TopicEventBridge.cs b/src/SquidStd.Messaging.Abstractions/Services/TopicEventBridge.cs
new file mode 100644
index 00000000..9cebd1f8
--- /dev/null
+++ b/src/SquidStd.Messaging.Abstractions/Services/TopicEventBridge.cs
@@ -0,0 +1,25 @@
+using SquidStd.Core.Interfaces.Events;
+using SquidStd.Messaging.Abstractions.Data.Events;
+using SquidStd.Messaging.Abstractions.Interfaces;
+
+namespace SquidStd.Messaging.Abstractions.Services;
+
+///
+/// One-way Topic → EventBus bridge. Subscribes a topic and republishes each message as a
+/// on the .
+///
+public sealed class TopicEventBridge : ITopicEventBridge
+{
+ private readonly IMessageTopic _topic;
+ private readonly IEventBus _eventBus;
+
+ public TopicEventBridge(IMessageTopic topic, IEventBus eventBus)
+ {
+ _topic = topic;
+ _eventBus = eventBus;
+ }
+
+ ///
+ public IDisposable Bridge(string topic)
+ => _topic.Subscribe(topic, (data, cancellationToken) => _eventBus.PublishAsync(new TopicMessageEvent(topic, data!), cancellationToken));
+}
diff --git a/src/SquidStd.Messaging.RabbitMq/Extensions/RabbitMqMessagingRegistrationExtensions.cs b/src/SquidStd.Messaging.RabbitMq/Extensions/RabbitMqMessagingRegistrationExtensions.cs
index b56cd98c..839e8ccf 100644
--- a/src/SquidStd.Messaging.RabbitMq/Extensions/RabbitMqMessagingRegistrationExtensions.cs
+++ b/src/SquidStd.Messaging.RabbitMq/Extensions/RabbitMqMessagingRegistrationExtensions.cs
@@ -49,6 +49,10 @@ public static IContainer AddRabbitMqMessaging(
);
container.Register(Reuse.Singleton);
+ container.Register(Reuse.Singleton);
+ container.Register(Reuse.Singleton);
+ container.Register(Reuse.Singleton);
+
return container;
}
diff --git a/src/SquidStd.Messaging.RabbitMq/Services/RabbitMqTopicProvider.cs b/src/SquidStd.Messaging.RabbitMq/Services/RabbitMqTopicProvider.cs
new file mode 100644
index 00000000..ccb60449
--- /dev/null
+++ b/src/SquidStd.Messaging.RabbitMq/Services/RabbitMqTopicProvider.cs
@@ -0,0 +1,218 @@
+using RabbitMQ.Client;
+using RabbitMQ.Client.Events;
+using Serilog;
+using SquidStd.Messaging.Abstractions.Interfaces;
+using SquidStd.Messaging.RabbitMq.Data.Config;
+
+namespace SquidStd.Messaging.RabbitMq.Services;
+
+///
+/// RabbitMQ : topics map to fanout exchanges; each subscriber binds an
+/// exclusive auto-delete queue and consumes with auto-ack (transient, at-most-once fan-out).
+///
+public sealed class RabbitMqTopicProvider : ITopicProvider
+{
+ private readonly ILogger _logger = Log.ForContext();
+ private readonly RabbitMqOptions _options;
+ private readonly SemaphoreSlim _publishLock = new(1, 1);
+ private readonly Lock _exchangeSync = new();
+ private readonly HashSet _declared = new(StringComparer.Ordinal);
+ private IConnection? _connection;
+ private IChannel? _publishChannel;
+ private int _disposed;
+
+ public RabbitMqTopicProvider(RabbitMqOptions options)
+ {
+ _options = options;
+ }
+
+ ///
+ 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 async Task PublishAsync(string topic, ReadOnlyMemory payload, CancellationToken cancellationToken = default)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(topic);
+ var channel = _publishChannel ?? throw new InvalidOperationException("Provider not started.");
+
+ await EnsureExchangeAsync(channel, topic, cancellationToken);
+
+ var properties = new BasicProperties();
+
+ await _publishLock.WaitAsync(cancellationToken);
+
+ try
+ {
+ await channel.BasicPublishAsync(
+ exchange: topic,
+ routingKey: string.Empty,
+ mandatory: false,
+ basicProperties: properties,
+ body: payload,
+ cancellationToken: cancellationToken
+ );
+ }
+ finally
+ {
+ _publishLock.Release();
+ }
+ }
+
+ ///
+ public IDisposable Subscribe(string topic, Func, CancellationToken, Task> handler)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(topic);
+ ArgumentNullException.ThrowIfNull(handler);
+
+ var connection = _connection ?? throw new InvalidOperationException("Provider not started.");
+ var subscription = new Subscription(this, connection, topic, handler);
+ subscription.Start();
+
+ return subscription;
+ }
+
+ private async Task EnsureExchangeAsync(IChannel channel, string topic, CancellationToken cancellationToken)
+ {
+ lock (_exchangeSync)
+ {
+ if (!_declared.Add(topic))
+ {
+ return;
+ }
+ }
+
+ await channel.ExchangeDeclareAsync(
+ exchange: topic,
+ type: ExchangeType.Fanout,
+ durable: false,
+ autoDelete: false,
+ 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 RabbitMqTopicProvider _provider;
+ private readonly IConnection _connection;
+ private readonly string _topic;
+ private readonly Func, CancellationToken, Task> _handler;
+ private IChannel? _channel;
+ private string? _consumerTag;
+ private int _disposed;
+
+ public Subscription(
+ RabbitMqTopicProvider provider,
+ IConnection connection,
+ string topic,
+ Func, CancellationToken, Task> handler
+ )
+ {
+ _provider = provider;
+ _connection = connection;
+ _topic = topic;
+ _handler = handler;
+ }
+
+ public void Start()
+ => StartAsync().GetAwaiter().GetResult();
+
+ private async Task StartAsync()
+ {
+ _channel = await _connection.CreateChannelAsync();
+
+ await _channel.ExchangeDeclareAsync(_topic, ExchangeType.Fanout, durable: false, autoDelete: false);
+
+ 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);
+
+ var consumer = new AsyncEventingBasicConsumer(_channel);
+ consumer.ReceivedAsync += OnReceivedAsync;
+
+ _consumerTag = await _channel.BasicConsumeAsync(queue.QueueName, autoAck: true, consumer: consumer);
+ }
+
+ 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);
+ }
+ }
+
+ 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.
+ }
+ }
+ }
+ }
+}
diff --git a/src/SquidStd.Messaging/Extensions/MessagingRegistrationExtensions.cs b/src/SquidStd.Messaging/Extensions/MessagingRegistrationExtensions.cs
index 92a3299a..a2ca9fee 100644
--- a/src/SquidStd.Messaging/Extensions/MessagingRegistrationExtensions.cs
+++ b/src/SquidStd.Messaging/Extensions/MessagingRegistrationExtensions.cs
@@ -37,6 +37,10 @@ public static IContainer AddInMemoryMessaging(this IContainer container, Messagi
container.Register(Reuse.Singleton);
container.Register(Reuse.Singleton);
+ container.Register(Reuse.Singleton);
+ container.Register(Reuse.Singleton);
+ container.Register(Reuse.Singleton);
+
return container;
}
diff --git a/src/SquidStd.Messaging/Services/InMemoryTopicProvider.cs b/src/SquidStd.Messaging/Services/InMemoryTopicProvider.cs
new file mode 100644
index 00000000..3a950261
--- /dev/null
+++ b/src/SquidStd.Messaging/Services/InMemoryTopicProvider.cs
@@ -0,0 +1,96 @@
+using System.Collections.Concurrent;
+using Serilog;
+using SquidStd.Messaging.Abstractions.Interfaces;
+
+namespace SquidStd.Messaging.Services;
+
+///
+/// In-memory : fan-out delivery to all current subscribers of a topic.
+/// Transient and at-most-once; exceptions in one subscriber are isolated.
+///
+public sealed class InMemoryTopicProvider : ITopicProvider
+{
+ private readonly ILogger _logger = Log.ForContext();
+ private readonly ConcurrentDictionary, CancellationToken, Task>>> _topics = new(StringComparer.Ordinal);
+ private int _disposed;
+
+ ///
+ public ValueTask StartAsync(CancellationToken cancellationToken = default)
+ => ValueTask.CompletedTask;
+
+ ///
+ public ValueTask StopAsync(CancellationToken cancellationToken = default)
+ => DisposeAsync();
+
+ ///
+ public async Task PublishAsync(string topic, ReadOnlyMemory payload, CancellationToken cancellationToken = default)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(topic);
+
+ if (!_topics.TryGetValue(topic, out var handlers))
+ {
+ return;
+ }
+
+ foreach (var handler in handlers.Values)
+ {
+ try
+ {
+ await handler(payload, cancellationToken);
+ }
+ catch (Exception ex)
+ {
+ _logger.Error(ex, "Topic '{Topic}' subscriber failed", topic);
+ }
+ }
+ }
+
+ ///
+ public IDisposable Subscribe(string topic, Func, CancellationToken, Task> handler)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(topic);
+ ArgumentNullException.ThrowIfNull(handler);
+
+ var handlers = _topics.GetOrAdd(topic, static _ => new());
+ var id = Guid.NewGuid();
+ handlers[id] = handler;
+
+ 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.Services.Core/Extensions/RegisterDefaultServicesExtensions.cs b/src/SquidStd.Services.Core/Extensions/RegisterDefaultServicesExtensions.cs
index 1ce76469..148ff3a6 100644
--- a/src/SquidStd.Services.Core/Extensions/RegisterDefaultServicesExtensions.cs
+++ b/src/SquidStd.Services.Core/Extensions/RegisterDefaultServicesExtensions.cs
@@ -4,6 +4,7 @@
using SquidStd.Abstractions.Extensions.Container;
using SquidStd.Abstractions.Extensions.Services;
using SquidStd.Core.Data.Bootstrap;
+using SquidStd.Core.Directories;
using SquidStd.Core.Data.Jobs;
using SquidStd.Core.Data.Metrics;
using SquidStd.Core.Data.Storage;
@@ -79,6 +80,7 @@ public IContainer RegisterCoreServices()
/// The same container for chaining.
public IContainer RegisterCoreServices(string configName, string configDirectory)
{
+ container.RegisterInstance(new DirectoriesConfig(configDirectory, []));
container.RegisterDataSerializer();
container.RegisterDefaultCoreConfigSections();
container.RegisterConfigManagerService(configName, configDirectory);
diff --git a/src/SquidStd.Templating/Extensions/TemplatingRegistrationExtensions.cs b/src/SquidStd.Templating/Extensions/TemplatingRegistrationExtensions.cs
new file mode 100644
index 00000000..5ea4f59c
--- /dev/null
+++ b/src/SquidStd.Templating/Extensions/TemplatingRegistrationExtensions.cs
@@ -0,0 +1,23 @@
+using DryIoc;
+using SquidStd.Abstractions.Extensions.Services;
+using SquidStd.Templating.Interfaces;
+using SquidStd.Templating.Services;
+
+namespace SquidStd.Templating.Extensions;
+
+///
+/// DryIoc registration helpers for the templating module.
+///
+public static class TemplatingRegistrationExtensions
+{
+ ///
+ /// Registers the Scriban template renderer as a singleton SquidStd service (so its startup
+ /// auto-load of templates/*.tmpl runs with the host). Requires a registered DirectoriesConfig.
+ ///
+ public static IContainer AddTemplating(this IContainer container)
+ {
+ ArgumentNullException.ThrowIfNull(container);
+
+ return container.RegisterStdService(-1);
+ }
+}
diff --git a/src/SquidStd.Templating/Interfaces/ITemplateRenderer.cs b/src/SquidStd.Templating/Interfaces/ITemplateRenderer.cs
new file mode 100644
index 00000000..8fa4ee8c
--- /dev/null
+++ b/src/SquidStd.Templating/Interfaces/ITemplateRenderer.cs
@@ -0,0 +1,16 @@
+namespace SquidStd.Templating.Interfaces;
+
+///
+/// Renders Scriban templates from strings or registered named templates.
+///
+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 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
new file mode 100644
index 00000000..6cc99300
--- /dev/null
+++ b/src/SquidStd.Templating/README.md
@@ -0,0 +1,62 @@
+
+
+
+
+SquidStd.Templating
+
+
+
+
+
+
+
+
+Templating for SquidStd, backed by [Scriban](https://github.com/scriban/scriban). `ITemplateRenderer`
+renders ad-hoc template strings and named templates (compiled and cached), and auto-loads
+`*.tmpl` files from the `templates/` directory at startup.
+
+## Install
+
+```bash
+dotnet add package SquidStd.Templating
+```
+
+## Features
+
+- `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`).
+- Scriban default member naming (`snake_case`): a `UserName` property is `{{ user.user_name }}`.
+- Parse/render failures surface as `TemplateException`; unknown names as `InvalidOperationException`.
+
+## Usage
+
+```csharp
+using DryIoc;
+using SquidStd.Templating.Extensions;
+using SquidStd.Templating.Interfaces;
+
+container.AddTemplating(); // after RegisterCoreServices (a shared DirectoriesConfig is registered there)
+
+var renderer = container.Resolve();
+
+// ad-hoc
+var hi = await renderer.RenderAsync("Hi {{ user.name }}!", new { User = new { Name = "squid" } });
+
+// named (registered manually or auto-loaded from templates/)
+renderer.Register("welcome", "Welcome {{ user.name }}");
+var welcome = await renderer.RenderByNameAsync("welcome", new { User = new { Name = "squid" } });
+```
+
+## 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. |
+
+## License
+
+MIT — part of [SquidStd](https://github.com/tgiachi/squid-std).
diff --git a/src/SquidStd.Templating/Services/ScribanTemplateRenderer.cs b/src/SquidStd.Templating/Services/ScribanTemplateRenderer.cs
new file mode 100644
index 00000000..c3b703b6
--- /dev/null
+++ b/src/SquidStd.Templating/Services/ScribanTemplateRenderer.cs
@@ -0,0 +1,103 @@
+using System.Collections.Concurrent;
+using Scriban;
+using Scriban.Syntax;
+using SquidStd.Abstractions.Interfaces.Services;
+using SquidStd.Core.Directories;
+using SquidStd.Templating.Interfaces;
+
+namespace SquidStd.Templating.Services;
+
+///
+/// Scriban-backed . Named templates are compiled and cached; ad-hoc
+/// strings are parsed per call. On start it auto-loads templates/**/*.tmpl.
+///
+public sealed class ScribanTemplateRenderer : ITemplateRenderer, ISquidStdService
+{
+ private const string TemplateExtension = ".tmpl";
+
+ private readonly DirectoriesConfig _directories;
+ private readonly ConcurrentDictionary _named = new(StringComparer.Ordinal);
+
+ public ScribanTemplateRenderer(DirectoriesConfig directories)
+ {
+ _directories = directories;
+ }
+
+ ///
+ public async ValueTask RenderAsync(string template, object? model, CancellationToken cancellationToken = default)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(template);
+
+ return await RenderCompiledAsync(Parse(template, "(inline)"), model, "(inline)");
+ }
+
+ ///
+ public void Register(string name, string template)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(name);
+ ArgumentException.ThrowIfNullOrWhiteSpace(template);
+
+ _named[name] = Parse(template, name);
+ }
+
+ ///
+ public async ValueTask RenderByNameAsync(string name, object? model, CancellationToken cancellationToken = default)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(name);
+
+ if (!_named.TryGetValue(name, out var compiled))
+ {
+ throw new InvalidOperationException($"No template registered with name '{name}'.");
+ }
+
+ return await RenderCompiledAsync(compiled, model, name);
+ }
+
+ ///
+ public async ValueTask StartAsync(CancellationToken cancellationToken = default)
+ {
+ var directory = _directories["templates"];
+
+ if (!Directory.Exists(directory))
+ {
+ return;
+ }
+
+ foreach (var file in Directory.EnumerateFiles(directory, "*" + TemplateExtension, SearchOption.AllDirectories))
+ {
+ var relative = Path.GetRelativePath(directory, file).Replace('\\', '/');
+ var name = relative[..^TemplateExtension.Length];
+ var content = await File.ReadAllTextAsync(file, cancellationToken);
+
+ Register(name, content);
+ }
+ }
+
+ ///
+ public ValueTask StopAsync(CancellationToken cancellationToken = default)
+ => ValueTask.CompletedTask;
+
+ private static Template Parse(string text, string name)
+ {
+ var template = Template.Parse(text);
+
+ if (template.HasErrors)
+ {
+ throw new TemplateException($"Template '{name}' has parse errors: {string.Join("; ", template.Messages)}");
+ }
+
+ return template;
+ }
+
+ private static async ValueTask RenderCompiledAsync(Template template, object? model, string name)
+ {
+ try
+ {
+ return await template.RenderAsync(model);
+ }
+ catch (ScriptRuntimeException ex)
+ {
+ throw new TemplateException($"Template '{name}' failed to render: {ex.Message}", ex);
+ }
+ }
+}
diff --git a/src/SquidStd.Templating/SquidStd.Templating.csproj b/src/SquidStd.Templating/SquidStd.Templating.csproj
new file mode 100644
index 00000000..c088a852
--- /dev/null
+++ b/src/SquidStd.Templating/SquidStd.Templating.csproj
@@ -0,0 +1,20 @@
+
+
+
+ net10.0
+ enable
+ enable
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/SquidStd.Templating/TemplateException.cs b/src/SquidStd.Templating/TemplateException.cs
new file mode 100644
index 00000000..73f2bd57
--- /dev/null
+++ b/src/SquidStd.Templating/TemplateException.cs
@@ -0,0 +1,17 @@
+namespace SquidStd.Templating;
+
+///
+/// Raised when a template fails to parse or render.
+///
+public sealed class TemplateException : Exception
+{
+ public TemplateException(string message)
+ : base(message)
+ {
+ }
+
+ public TemplateException(string message, Exception innerException)
+ : base(message, innerException)
+ {
+ }
+}
diff --git a/src/SquidStd.Workers.Abstractions/Data/JobRequest.cs b/src/SquidStd.Workers.Abstractions/Data/JobRequest.cs
new file mode 100644
index 00000000..0effc8c4
--- /dev/null
+++ b/src/SquidStd.Workers.Abstractions/Data/JobRequest.cs
@@ -0,0 +1,8 @@
+namespace SquidStd.Workers.Abstractions.Data;
+
+///
+/// A unit of work the manager enqueues on the jobs queue for a worker to execute.
+///
+/// Logical name of the job, used by the worker to pick a handler.
+/// Opaque string key/value arguments for the job.
+public sealed record JobRequest(string JobName, IReadOnlyDictionary Parameters);
diff --git a/src/SquidStd.Workers.Abstractions/Data/WorkerHeartbeat.cs b/src/SquidStd.Workers.Abstractions/Data/WorkerHeartbeat.cs
new file mode 100644
index 00000000..1213b09a
--- /dev/null
+++ b/src/SquidStd.Workers.Abstractions/Data/WorkerHeartbeat.cs
@@ -0,0 +1,18 @@
+using SquidStd.Workers.Abstractions.Types;
+
+namespace SquidStd.Workers.Abstractions.Data;
+
+///
+/// A liveness signal a worker publishes on the heartbeat topic at a fixed interval.
+///
+/// Stable identity of the reporting worker.
+/// UTC time the heartbeat was produced.
+/// Self-reported status ( when no job is running, else ).
+/// Number of jobs currently in progress on this worker.
+/// Maximum number of jobs the worker runs at once.
+public sealed record WorkerHeartbeat(
+ string WorkerId,
+ DateTime TimestampUtc,
+ WorkerStatusType Status,
+ int ActiveJobs,
+ int MaxConcurrency);
diff --git a/src/SquidStd.Workers.Abstractions/Data/WorkerInfo.cs b/src/SquidStd.Workers.Abstractions/Data/WorkerInfo.cs
new file mode 100644
index 00000000..22eac1ab
--- /dev/null
+++ b/src/SquidStd.Workers.Abstractions/Data/WorkerInfo.cs
@@ -0,0 +1,20 @@
+using SquidStd.Workers.Abstractions.Types;
+
+namespace SquidStd.Workers.Abstractions.Data;
+
+///
+/// The manager-side view of a worker, folded from incoming heartbeats.
+///
+/// Stable identity of the worker.
+/// Last known status; the manager sets on missed heartbeats.
+/// Jobs in progress as of the most recent heartbeat.
+/// Maximum number of jobs the worker runs at once.
+/// UTC time the manager first saw this worker.
+/// UTC time of the most recent heartbeat.
+public sealed record WorkerInfo(
+ string WorkerId,
+ WorkerStatusType Status,
+ int ActiveJobs,
+ int MaxConcurrency,
+ DateTime FirstSeenUtc,
+ DateTime LastSeenUtc);
diff --git a/src/SquidStd.Workers.Abstractions/README.md b/src/SquidStd.Workers.Abstractions/README.md
new file mode 100644
index 00000000..32063e9f
--- /dev/null
+++ b/src/SquidStd.Workers.Abstractions/README.md
@@ -0,0 +1,35 @@
+
+
+
+
+SquidStd.Workers.Abstractions
+
+
+
+
+
+
+
+
+Shared, dependency-free contracts between the SquidStd worker runtime and manager: the job descriptor, the
+worker heartbeat, the manager-side worker view, the worker status enum, and the conventional channel names.
+
+## Install
+
+```bash
+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. |
+
+## License
+
+MIT — part of [SquidStd](https://github.com/tgiachi/squid-std).
diff --git a/src/SquidStd.Workers.Abstractions/SquidStd.Workers.Abstractions.csproj b/src/SquidStd.Workers.Abstractions/SquidStd.Workers.Abstractions.csproj
new file mode 100644
index 00000000..2f0e9b5b
--- /dev/null
+++ b/src/SquidStd.Workers.Abstractions/SquidStd.Workers.Abstractions.csproj
@@ -0,0 +1,10 @@
+
+
+
+ net10.0
+ enable
+ enable
+ true
+
+
+
diff --git a/src/SquidStd.Workers.Abstractions/Types/WorkerStatusType.cs b/src/SquidStd.Workers.Abstractions/Types/WorkerStatusType.cs
new file mode 100644
index 00000000..d3aa947c
--- /dev/null
+++ b/src/SquidStd.Workers.Abstractions/Types/WorkerStatusType.cs
@@ -0,0 +1,17 @@
+namespace SquidStd.Workers.Abstractions.Types;
+
+///
+/// Lifecycle status of a worker. Workers report or ;
+/// the manager assigns when a worker's heartbeats stop arriving.
+///
+public enum WorkerStatusType
+{
+ /// The worker is running and has no job in progress.
+ Idle,
+
+ /// The worker is currently processing a job.
+ Busy,
+
+ /// The manager has not seen a heartbeat within the expected window.
+ Offline
+}
diff --git a/src/SquidStd.Workers.Abstractions/WorkerChannels.cs b/src/SquidStd.Workers.Abstractions/WorkerChannels.cs
new file mode 100644
index 00000000..6514a147
--- /dev/null
+++ b/src/SquidStd.Workers.Abstractions/WorkerChannels.cs
@@ -0,0 +1,14 @@
+namespace SquidStd.Workers.Abstractions;
+
+///
+/// Default names of the messaging channels the workers system uses. Shared so the manager
+/// and workers agree out of the box; either side may override them via its own configuration.
+///
+public static class WorkerChannels
+{
+ /// Competing-consumers queue the manager publishes JobRequests onto.
+ public const string JobQueue = "squidstd.workers.jobs";
+
+ /// Fan-out topic workers publish WorkerHeartbeats onto.
+ public const string HeartbeatTopic = "squidstd.workers.heartbeat";
+}
diff --git a/src/SquidStd.Workers.Manager/Data/Config/WorkerManagerConfig.cs b/src/SquidStd.Workers.Manager/Data/Config/WorkerManagerConfig.cs
new file mode 100644
index 00000000..beef18cd
--- /dev/null
+++ b/src/SquidStd.Workers.Manager/Data/Config/WorkerManagerConfig.cs
@@ -0,0 +1,21 @@
+using SquidStd.Workers.Abstractions;
+
+namespace SquidStd.Workers.Manager.Data.Config;
+
+///
+/// Configuration for the worker manager (config section "workerManager").
+///
+public sealed class WorkerManagerConfig
+{
+ /// Seconds without a heartbeat before a worker is considered Offline. Falls back to 30 when not positive.
+ public int OfflineTimeoutSeconds { get; set; } = 30;
+
+ /// Seconds between offline sweeps. Falls back to 10 when not positive.
+ public int SweepIntervalSeconds { get; set; } = 10;
+
+ /// Queue jobs are enqueued onto.
+ public string JobQueue { get; set; } = WorkerChannels.JobQueue;
+
+ /// Topic heartbeats are collected from.
+ public string HeartbeatTopic { get; set; } = WorkerChannels.HeartbeatTopic;
+}
diff --git a/src/SquidStd.Workers.Manager/Data/EnqueueJobRequest.cs b/src/SquidStd.Workers.Manager/Data/EnqueueJobRequest.cs
new file mode 100644
index 00000000..8869f5f7
--- /dev/null
+++ b/src/SquidStd.Workers.Manager/Data/EnqueueJobRequest.cs
@@ -0,0 +1,8 @@
+namespace SquidStd.Workers.Manager.Data;
+
+///
+/// Request body for enqueuing a job via the manager's HTTP surface.
+///
+/// Logical name of the job.
+/// Optional string key/value arguments; treated as empty when null.
+public sealed record EnqueueJobRequest(string JobName, IReadOnlyDictionary? Parameters);
diff --git a/src/SquidStd.Workers.Manager/Data/Events/WorkerStatusChangedEvent.cs b/src/SquidStd.Workers.Manager/Data/Events/WorkerStatusChangedEvent.cs
new file mode 100644
index 00000000..9b15b767
--- /dev/null
+++ b/src/SquidStd.Workers.Manager/Data/Events/WorkerStatusChangedEvent.cs
@@ -0,0 +1,13 @@
+using SquidStd.Core.Interfaces.Events;
+using SquidStd.Workers.Abstractions.Types;
+
+namespace SquidStd.Workers.Manager.Data.Events;
+
+///
+/// Published on the event bus when a worker's status changes: discovered ( null),
+/// gone Offline (via the sweep), or returned (Offline → Idle/Busy).
+///
+/// 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;
diff --git a/src/SquidStd.Workers.Manager/Endpoints/WorkerManagerEndpoints.cs b/src/SquidStd.Workers.Manager/Endpoints/WorkerManagerEndpoints.cs
new file mode 100644
index 00000000..95765ec7
--- /dev/null
+++ b/src/SquidStd.Workers.Manager/Endpoints/WorkerManagerEndpoints.cs
@@ -0,0 +1,48 @@
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Http.HttpResults;
+using SquidStd.Workers.Abstractions.Data;
+using SquidStd.Workers.Manager.Data;
+using SquidStd.Workers.Manager.Interfaces;
+
+namespace SquidStd.Workers.Manager.Endpoints;
+
+///
+/// Typed minimal-API handlers for the worker manager surface. Static so they can be unit-tested directly.
+///
+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)
+ {
+ if (string.IsNullOrWhiteSpace(request.JobName))
+ {
+ return TypedResults.BadRequest("JobName is required.");
+ }
+
+ try
+ {
+ 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.");
+ }
+ }
+}
diff --git a/src/SquidStd.Workers.Manager/Extensions/WorkerManagerEndpointsExtensions.cs b/src/SquidStd.Workers.Manager/Extensions/WorkerManagerEndpointsExtensions.cs
new file mode 100644
index 00000000..6290d917
--- /dev/null
+++ b/src/SquidStd.Workers.Manager/Extensions/WorkerManagerEndpointsExtensions.cs
@@ -0,0 +1,25 @@
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Routing;
+using SquidStd.Workers.Manager.Endpoints;
+
+namespace SquidStd.Workers.Manager.Extensions;
+
+///
+/// Maps the opt-in worker manager HTTP endpoints.
+///
+public static class WorkerManagerEndpointsExtensions
+{
+ ///
+ /// Maps GET /workers, GET /workers/{id}, and POST /jobs.
+ ///
+ public static IEndpointRouteBuilder MapWorkerManagerEndpoints(this IEndpointRouteBuilder endpoints)
+ {
+ ArgumentNullException.ThrowIfNull(endpoints);
+
+ endpoints.MapGet("/workers", WorkerManagerEndpoints.GetWorkers);
+ endpoints.MapGet("/workers/{id}", WorkerManagerEndpoints.GetWorker);
+ endpoints.MapPost("/jobs", WorkerManagerEndpoints.EnqueueJob);
+
+ return endpoints;
+ }
+}
diff --git a/src/SquidStd.Workers.Manager/Extensions/WorkerManagerRegistrationExtensions.cs b/src/SquidStd.Workers.Manager/Extensions/WorkerManagerRegistrationExtensions.cs
new file mode 100644
index 00000000..4b825bab
--- /dev/null
+++ b/src/SquidStd.Workers.Manager/Extensions/WorkerManagerRegistrationExtensions.cs
@@ -0,0 +1,42 @@
+using DryIoc;
+using SquidStd.Abstractions.Extensions.Config;
+using SquidStd.Abstractions.Extensions.Services;
+using SquidStd.Core.Data.Timing;
+using SquidStd.Services.Core.Services.Scheduling;
+using SquidStd.Workers.Manager.Data.Config;
+using SquidStd.Workers.Manager.Interfaces;
+using SquidStd.Workers.Manager.Services;
+
+namespace SquidStd.Workers.Manager.Extensions;
+
+///
+/// DryIoc registration helpers for the worker manager.
+///
+public static class WorkerManagerRegistrationExtensions
+{
+ ///
+ /// Registers the worker manager: config section, registry, job scheduler, the collector and sweep
+ /// lifecycle services, and the timer-wheel pump (only if it is not already registered).
+ ///
+ public static IContainer AddWorkerManager(this IContainer container)
+ {
+ ArgumentNullException.ThrowIfNull(container);
+
+ container.RegisterConfigSection("workerManager", static () => new WorkerManagerConfig(), -50);
+
+ container.Register(Reuse.Singleton);
+ container.RegisterMapping();
+ container.Register(Reuse.Singleton);
+
+ container.RegisterStdService(100);
+ container.RegisterStdService(110);
+
+ if (!container.IsRegistered())
+ {
+ container.RegisterConfigSection("timerWheelPump", static () => new TimerWheelPumpConfig(), -90);
+ container.RegisterStdService(-1);
+ }
+
+ return container;
+ }
+}
diff --git a/src/SquidStd.Workers.Manager/Interfaces/IJobScheduler.cs b/src/SquidStd.Workers.Manager/Interfaces/IJobScheduler.cs
new file mode 100644
index 00000000..25efa737
--- /dev/null
+++ b/src/SquidStd.Workers.Manager/Interfaces/IJobScheduler.cs
@@ -0,0 +1,13 @@
+namespace SquidStd.Workers.Manager.Interfaces;
+
+///
+/// Enqueues jobs for workers to consume.
+///
+public interface IJobScheduler
+{
+ /// Enqueues a job with parameters.
+ 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
new file mode 100644
index 00000000..1859d6ca
--- /dev/null
+++ b/src/SquidStd.Workers.Manager/Interfaces/IWorkerRegistry.cs
@@ -0,0 +1,15 @@
+using SquidStd.Workers.Abstractions.Data;
+
+namespace SquidStd.Workers.Manager.Interfaces;
+
+///
+/// Read access to the manager's in-memory view of known workers.
+///
+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);
+}
diff --git a/src/SquidStd.Workers.Manager/README.md b/src/SquidStd.Workers.Manager/README.md
new file mode 100644
index 00000000..a1e33a74
--- /dev/null
+++ b/src/SquidStd.Workers.Manager/README.md
@@ -0,0 +1,50 @@
+
+
+
+
+SquidStd.Workers.Manager
+
+
+
+
+
+
+
+
+The SquidStd worker manager. Enqueues jobs, collects worker heartbeats into an in-memory registry, marks
+workers `Offline` on a periodic sweep, publishes status-transition events, and exposes an opt-in ASP.NET
+minimal-API surface.
+
+## Install
+
+```bash
+dotnet add package SquidStd.Workers.Manager
+```
+
+## Usage
+
+```csharp
+using DryIoc;
+using SquidStd.Workers.Manager.Extensions;
+
+container.AddInMemoryMessaging(); // or AddRabbitMqMessaging(...)
+container.AddWorkerManager();
+
+// In an ASP.NET app, map the opt-in HTTP surface:
+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()`. |
+
+## License
+
+MIT — part of [SquidStd](https://github.com/tgiachi/squid-std).
diff --git a/src/SquidStd.Workers.Manager/Services/HeartbeatCollectorService.cs b/src/SquidStd.Workers.Manager/Services/HeartbeatCollectorService.cs
new file mode 100644
index 00000000..91317e0a
--- /dev/null
+++ b/src/SquidStd.Workers.Manager/Services/HeartbeatCollectorService.cs
@@ -0,0 +1,65 @@
+using Serilog;
+using SquidStd.Abstractions.Interfaces.Services;
+using SquidStd.Core.Interfaces.Events;
+using SquidStd.Messaging.Abstractions.Interfaces;
+using SquidStd.Workers.Abstractions;
+using SquidStd.Workers.Abstractions.Data;
+using SquidStd.Workers.Manager.Data.Config;
+
+namespace SquidStd.Workers.Manager.Services;
+
+///
+/// Subscribes to the heartbeat topic and folds each into the registry,
+/// publishing any resulting status transition on the event bus.
+///
+public sealed class HeartbeatCollectorService : ISquidStdService
+{
+ private readonly ILogger _logger = Log.ForContext();
+ private readonly IMessageTopic _topic;
+ private readonly WorkerRegistry _registry;
+ private readonly IEventBus _eventBus;
+ private readonly string _topicName;
+ private IDisposable? _subscription;
+
+ 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;
+ }
+
+ ///
+ public ValueTask StartAsync(CancellationToken cancellationToken = default)
+ {
+ _subscription = _topic.Subscribe(_topicName, OnHeartbeatAsync);
+ _logger.Information("Worker manager collecting heartbeats from topic '{Topic}'.", _topicName);
+
+ return ValueTask.CompletedTask;
+ }
+
+ ///
+ public ValueTask StopAsync(CancellationToken cancellationToken = default)
+ {
+ _subscription?.Dispose();
+ _subscription = null;
+
+ return ValueTask.CompletedTask;
+ }
+
+ private async Task OnHeartbeatAsync(WorkerHeartbeat heartbeat, CancellationToken cancellationToken)
+ {
+ try
+ {
+ var change = _registry.Record(heartbeat);
+ if (change is not null)
+ {
+ await _eventBus.PublishAsync(change, cancellationToken);
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.Error(ex, "Failed to process heartbeat from '{WorkerId}'.", heartbeat.WorkerId);
+ }
+ }
+}
diff --git a/src/SquidStd.Workers.Manager/Services/JobScheduler.cs b/src/SquidStd.Workers.Manager/Services/JobScheduler.cs
new file mode 100644
index 00000000..82336a01
--- /dev/null
+++ b/src/SquidStd.Workers.Manager/Services/JobScheduler.cs
@@ -0,0 +1,30 @@
+using SquidStd.Messaging.Abstractions.Interfaces;
+using SquidStd.Workers.Abstractions;
+using SquidStd.Workers.Abstractions.Data;
+using SquidStd.Workers.Manager.Data.Config;
+using SquidStd.Workers.Manager.Interfaces;
+
+namespace SquidStd.Workers.Manager.Services;
+
+///
+/// Default : publishes s onto the configured jobs queue.
+///
+public sealed class JobScheduler : IJobScheduler
+{
+ private readonly IMessageQueue _queue;
+ private readonly string _queueName;
+
+ public JobScheduler(IMessageQueue queue, WorkerManagerConfig config)
+ {
+ _queue = queue;
+ _queueName = string.IsNullOrWhiteSpace(config.JobQueue) ? WorkerChannels.JobQueue : config.JobQueue;
+ }
+
+ ///
+ public Task EnqueueAsync(string jobName, IReadOnlyDictionary parameters, CancellationToken cancellationToken = default)
+ => _queue.PublishAsync(_queueName, new JobRequest(jobName, parameters), cancellationToken);
+
+ ///
+ public Task EnqueueAsync(string jobName, CancellationToken cancellationToken = default)
+ => EnqueueAsync(jobName, new Dictionary(), cancellationToken);
+}
diff --git a/src/SquidStd.Workers.Manager/Services/WorkerOfflineSweepService.cs b/src/SquidStd.Workers.Manager/Services/WorkerOfflineSweepService.cs
new file mode 100644
index 00000000..34d63a8d
--- /dev/null
+++ b/src/SquidStd.Workers.Manager/Services/WorkerOfflineSweepService.cs
@@ -0,0 +1,80 @@
+using Serilog;
+using SquidStd.Abstractions.Interfaces.Services;
+using SquidStd.Core.Interfaces.Events;
+using SquidStd.Core.Interfaces.Timing;
+using SquidStd.Workers.Manager.Data.Config;
+
+namespace SquidStd.Workers.Manager.Services;
+
+///
+/// Periodically marks workers Offline whose heartbeats have stopped, using the timer wheel
+/// (), and publishes the resulting transitions.
+///
+public sealed class WorkerOfflineSweepService : ISquidStdService
+{
+ private const int DefaultIntervalSeconds = 10;
+
+ private readonly ILogger _logger = Log.ForContext();
+ private readonly ITimerService _timer;
+ private readonly WorkerRegistry _registry;
+ private readonly IEventBus _eventBus;
+ private readonly TimeSpan _interval;
+ private string? _timerId;
+
+ 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);
+ }
+
+ _interval = TimeSpan.FromSeconds(seconds);
+ }
+
+ ///
+ public ValueTask StartAsync(CancellationToken cancellationToken = default)
+ {
+ _timerId = _timer.RegisterTimer("worker-offline-sweep", _interval, OnTick, delay: _interval, repeat: true);
+
+ return ValueTask.CompletedTask;
+ }
+
+ ///
+ public ValueTask StopAsync(CancellationToken cancellationToken = default)
+ {
+ if (_timerId is not null)
+ {
+ _timer.UnregisterTimer(_timerId);
+ _timerId = null;
+ }
+
+ return ValueTask.CompletedTask;
+ }
+
+ /// Runs one 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
new file mode 100644
index 00000000..d7804ae8
--- /dev/null
+++ b/src/SquidStd.Workers.Manager/Services/WorkerRegistry.cs
@@ -0,0 +1,105 @@
+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.Interfaces;
+
+namespace SquidStd.Workers.Manager.Services;
+
+///
+/// In-memory registry of workers, folded from heartbeats. Pure: it returns status transitions for the
+/// caller to publish, and never touches the event bus or a real clock (the sweep takes the time as a parameter).
+///
+public sealed class WorkerRegistry : IWorkerRegistry
+{
+ private readonly Lock _sync = new();
+ private readonly Dictionary _workers = new(StringComparer.Ordinal);
+ private readonly TimeSpan _offlineTimeout;
+
+ public WorkerRegistry(WorkerManagerConfig config)
+ {
+ var seconds = config.OfflineTimeoutSeconds > 0 ? config.OfflineTimeoutSeconds : 30;
+ _offlineTimeout = TimeSpan.FromSeconds(seconds);
+ }
+
+ ///
+ public IReadOnlyCollection GetAll()
+ {
+ lock (_sync)
+ {
+ return _workers.Values.ToArray();
+ }
+ }
+
+ ///
+ public WorkerInfo? Get(string workerId)
+ {
+ lock (_sync)
+ {
+ return _workers.GetValueOrDefault(workerId);
+ }
+ }
+
+ ///
+ /// Folds a heartbeat into the registry. Returns a transition only when the worker is newly discovered
+ /// or has returned from ; otherwise null.
+ ///
+ public WorkerStatusChangedEvent? Record(WorkerHeartbeat heartbeat)
+ {
+ var now = DateTime.UtcNow;
+
+ lock (_sync)
+ {
+ if (!_workers.TryGetValue(heartbeat.WorkerId, out var existing))
+ {
+ _workers[heartbeat.WorkerId] = new WorkerInfo(
+ heartbeat.WorkerId,
+ heartbeat.Status,
+ heartbeat.ActiveJobs,
+ heartbeat.MaxConcurrency,
+ now,
+ now);
+
+ return new WorkerStatusChangedEvent(heartbeat.WorkerId, null, heartbeat.Status);
+ }
+
+ var old = existing.Status;
+ _workers[heartbeat.WorkerId] = existing with
+ {
+ Status = heartbeat.Status,
+ ActiveJobs = heartbeat.ActiveJobs,
+ MaxConcurrency = heartbeat.MaxConcurrency,
+ LastSeenUtc = now
+ };
+
+ return old == WorkerStatusType.Offline && heartbeat.Status != WorkerStatusType.Offline
+ ? new WorkerStatusChangedEvent(heartbeat.WorkerId, old, heartbeat.Status)
+ : null;
+ }
+ }
+
+ ///
+ /// Marks workers Offline whose last heartbeat is older than the configured timeout relative to
+ /// . Returns the resulting transitions.
+ ///
+ public IReadOnlyList Sweep(DateTime nowUtc)
+ {
+ var changes = new List();
+
+ lock (_sync)
+ {
+ foreach (var (id, info) in _workers.ToArray())
+ {
+ if (info.Status == WorkerStatusType.Offline || nowUtc - info.LastSeenUtc <= _offlineTimeout)
+ {
+ continue;
+ }
+
+ _workers[id] = info with { Status = WorkerStatusType.Offline };
+ changes.Add(new WorkerStatusChangedEvent(id, info.Status, WorkerStatusType.Offline));
+ }
+ }
+
+ return changes;
+ }
+}
diff --git a/src/SquidStd.Workers.Manager/SquidStd.Workers.Manager.csproj b/src/SquidStd.Workers.Manager/SquidStd.Workers.Manager.csproj
new file mode 100644
index 00000000..6b1b3dcc
--- /dev/null
+++ b/src/SquidStd.Workers.Manager/SquidStd.Workers.Manager.csproj
@@ -0,0 +1,20 @@
+
+
+
+ net10.0
+ enable
+ enable
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/SquidStd.Workers/Data/Config/WorkersConfig.cs b/src/SquidStd.Workers/Data/Config/WorkersConfig.cs
new file mode 100644
index 00000000..d9bf2b4f
--- /dev/null
+++ b/src/SquidStd.Workers/Data/Config/WorkersConfig.cs
@@ -0,0 +1,27 @@
+using SquidStd.Workers.Abstractions;
+
+namespace SquidStd.Workers.Data.Config;
+
+///
+/// Configuration for the worker runtime (config section "workers").
+///
+public sealed class WorkersConfig
+{
+ ///
+ /// Stable worker identity. When blank, the runtime falls back to the machine name
+ /// (in Docker, the container hostname).
+ ///
+ public string WorkerId { get; set; } = string.Empty;
+
+ /// Seconds between heartbeats. Falls back to 10 when not positive.
+ public int HeartbeatIntervalSeconds { get; set; } = 10;
+
+ /// Maximum jobs processed in parallel. Falls back to the processor count when not positive.
+ public int MaxConcurrency { get; set; }
+
+ /// Queue the worker consumes jobs from.
+ public string JobQueue { get; set; } = WorkerChannels.JobQueue;
+
+ /// Topic the worker publishes heartbeats to.
+ public string HeartbeatTopic { get; set; } = WorkerChannels.HeartbeatTopic;
+}
diff --git a/src/SquidStd.Workers/Exceptions/JobHandlerNotFoundException.cs b/src/SquidStd.Workers/Exceptions/JobHandlerNotFoundException.cs
new file mode 100644
index 00000000..61a4d92d
--- /dev/null
+++ b/src/SquidStd.Workers/Exceptions/JobHandlerNotFoundException.cs
@@ -0,0 +1,17 @@
+namespace SquidStd.Workers.Exceptions;
+
+///
+/// Thrown when a job arrives whose name has no registered .
+///
+public sealed class JobHandlerNotFoundException : Exception
+{
+ /// The job name that had no handler.
+ public string JobName { get; }
+
+ /// Initializes the exception for the given job name.
+ public JobHandlerNotFoundException(string jobName)
+ : base($"No job handler is registered for job '{jobName}'.")
+ {
+ JobName = jobName;
+ }
+}
diff --git a/src/SquidStd.Workers/Extensions/WorkersRegistrationExtensions.cs b/src/SquidStd.Workers/Extensions/WorkersRegistrationExtensions.cs
new file mode 100644
index 00000000..18eb37ba
--- /dev/null
+++ b/src/SquidStd.Workers/Extensions/WorkersRegistrationExtensions.cs
@@ -0,0 +1,46 @@
+using DryIoc;
+using SquidStd.Abstractions.Extensions.Config;
+using SquidStd.Abstractions.Extensions.Services;
+using SquidStd.Workers.Data.Config;
+using SquidStd.Workers.Interfaces;
+using SquidStd.Workers.Services;
+
+namespace SquidStd.Workers.Extensions;
+
+///
+/// DryIoc registration helpers for the worker runtime.
+///
+public static class WorkersRegistrationExtensions
+{
+ ///
+ /// Registers the worker runtime: the "workers" config section, shared state, job dispatcher, and the
+ /// consumer + heartbeat lifecycle services.
+ ///
+ public static IContainer AddWorkers(this IContainer container)
+ {
+ ArgumentNullException.ThrowIfNull(container);
+
+ container.RegisterConfigSection("workers", static () => new WorkersConfig(), -50);
+
+ container.Register(Reuse.Singleton);
+ container.Register(Reuse.Singleton);
+
+ container.RegisterStdService(100);
+ container.RegisterStdService(110);
+
+ 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/IJobDispatcher.cs b/src/SquidStd.Workers/Interfaces/IJobDispatcher.cs
new file mode 100644
index 00000000..fdae86b5
--- /dev/null
+++ b/src/SquidStd.Workers/Interfaces/IJobDispatcher.cs
@@ -0,0 +1,15 @@
+using SquidStd.Workers.Abstractions.Data;
+
+namespace SquidStd.Workers.Interfaces;
+
+///
+/// Routes a to the registered for its job name.
+///
+public interface IJobDispatcher
+{
+ ///
+ /// Dispatches the job to its handler.
+ ///
+ /// No handler matches the job name.
+ Task DispatchAsync(JobRequest job, CancellationToken cancellationToken);
+}
diff --git a/src/SquidStd.Workers/Interfaces/IJobHandler.cs b/src/SquidStd.Workers/Interfaces/IJobHandler.cs
new file mode 100644
index 00000000..d64a706e
--- /dev/null
+++ b/src/SquidStd.Workers/Interfaces/IJobHandler.cs
@@ -0,0 +1,15 @@
+using SquidStd.Workers.Abstractions.Data;
+
+namespace SquidStd.Workers.Interfaces;
+
+///
+/// Handles jobs of a single named kind. Implemented by consumers of the worker library.
+///
+public interface IJobHandler
+{
+ /// The this handler processes.
+ string JobName { get; }
+
+ /// Executes the job.
+ Task HandleAsync(JobRequest job, CancellationToken cancellationToken);
+}
diff --git a/src/SquidStd.Workers/Interfaces/IWorkerState.cs b/src/SquidStd.Workers/Interfaces/IWorkerState.cs
new file mode 100644
index 00000000..685e510c
--- /dev/null
+++ b/src/SquidStd.Workers/Interfaces/IWorkerState.cs
@@ -0,0 +1,27 @@
+using SquidStd.Workers.Abstractions.Types;
+
+namespace SquidStd.Workers.Interfaces;
+
+///
+/// Shared runtime state of a worker, read by the heartbeat service and mutated by the consumer.
+///
+public interface IWorkerState
+{
+ /// Stable worker identity.
+ string WorkerId { get; }
+
+ /// Maximum jobs processed in parallel.
+ int MaxConcurrency { get; }
+
+ /// Jobs currently in progress.
+ int ActiveJobs { get; }
+
+ /// 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();
+}
diff --git a/src/SquidStd.Workers/README.md b/src/SquidStd.Workers/README.md
new file mode 100644
index 00000000..753a2a65
--- /dev/null
+++ b/src/SquidStd.Workers/README.md
@@ -0,0 +1,58 @@
+
+
+
+
+SquidStd.Workers
+
+
+
+
+
+
+
+
+The SquidStd worker runtime. Consumes `JobRequest`s from the jobs queue, dispatches each to a named
+`IJobHandler` (up to `MaxConcurrency` in parallel), and publishes a `WorkerHeartbeat` on the heartbeat topic
+every few seconds.
+
+## Install
+
+```bash
+dotnet add package SquidStd.Workers
+```
+
+## Usage
+
+```csharp
+using DryIoc;
+using SquidStd.Workers.Extensions;
+
+container.AddInMemoryMessaging(); // or AddRabbitMqMessaging(...)
+container.AddWorkers();
+container.AddJobHandler();
+```
+
+```csharp
+public sealed class ResizeImageHandler : IJobHandler
+{
+ public string JobName => "resize-image";
+
+ public Task HandleAsync(JobRequest job, CancellationToken cancellationToken)
+ {
+ // job.Parameters["width"], ...
+ return Task.CompletedTask;
+ }
+}
+```
+
+## Key types
+
+| Type | Purpose |
+|------|---------|
+| `IJobHandler` | Handles jobs of one named kind. |
+| `WorkersConfig` | `WorkerId`, `HeartbeatIntervalSeconds`, `MaxConcurrency`, queue/topic names. |
+| `WorkersRegistrationExtensions` | `AddWorkers()` and `AddJobHandler()`. |
+
+## License
+
+MIT — part of [SquidStd](https://github.com/tgiachi/squid-std).
diff --git a/src/SquidStd.Workers/Services/JobDispatcher.cs b/src/SquidStd.Workers/Services/JobDispatcher.cs
new file mode 100644
index 00000000..9d481073
--- /dev/null
+++ b/src/SquidStd.Workers/Services/JobDispatcher.cs
@@ -0,0 +1,41 @@
+using Serilog;
+using SquidStd.Workers.Abstractions.Data;
+using SquidStd.Workers.Exceptions;
+using SquidStd.Workers.Interfaces;
+
+namespace SquidStd.Workers.Services;
+
+///
+/// Default : indexes the registered handlers by job name.
+///
+public sealed class JobDispatcher : IJobDispatcher
+{
+ private readonly ILogger _logger = Log.ForContext();
+ private readonly Dictionary _handlers = new(StringComparer.Ordinal);
+
+ public JobDispatcher(IEnumerable handlers)
+ {
+ foreach (var handler in handlers)
+ {
+ if (_handlers.ContainsKey(handler.JobName))
+ {
+ _logger.Warning(
+ "Duplicate job handler for '{JobName}'; the last registration wins.",
+ handler.JobName);
+ }
+
+ _handlers[handler.JobName] = handler;
+ }
+ }
+
+ ///
+ public Task DispatchAsync(JobRequest job, CancellationToken cancellationToken)
+ {
+ if (!_handlers.TryGetValue(job.JobName, out var handler))
+ {
+ throw new JobHandlerNotFoundException(job.JobName);
+ }
+
+ return handler.HandleAsync(job, cancellationToken);
+ }
+}
diff --git a/src/SquidStd.Workers/Services/WorkerConsumerService.cs b/src/SquidStd.Workers/Services/WorkerConsumerService.cs
new file mode 100644
index 00000000..e598cde5
--- /dev/null
+++ b/src/SquidStd.Workers/Services/WorkerConsumerService.cs
@@ -0,0 +1,86 @@
+using Serilog;
+using SquidStd.Abstractions.Interfaces.Services;
+using SquidStd.Messaging.Abstractions.Interfaces;
+using SquidStd.Workers.Abstractions;
+using SquidStd.Workers.Abstractions.Data;
+using SquidStd.Workers.Data.Config;
+using SquidStd.Workers.Exceptions;
+using SquidStd.Workers.Interfaces;
+
+namespace SquidStd.Workers.Services;
+
+///
+/// Subscribes to the jobs queue and dispatches each to its handler,
+/// bounded by .
+///
+public sealed class WorkerConsumerService : ISquidStdService, IQueueMessageListenerAsync
+{
+ private readonly ILogger _logger = Log.ForContext();
+ private readonly IMessageQueue _queue;
+ private readonly IJobDispatcher _dispatcher;
+ private readonly IWorkerState _state;
+ private readonly SemaphoreSlim _semaphore;
+ private readonly string _queueName;
+ private IDisposable? _subscription;
+
+ /// Constructs the consumer wired to the messaging queue and worker state.
+ public WorkerConsumerService(IMessageQueue queue, IJobDispatcher dispatcher, IWorkerState state, WorkersConfig config)
+ {
+ _queue = queue;
+ _dispatcher = dispatcher;
+ _state = state;
+ _semaphore = new SemaphoreSlim(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)
+ {
+ await _semaphore.WaitAsync(cancellationToken);
+ _state.JobStarted();
+
+ try
+ {
+ await _dispatcher.DispatchAsync(message, cancellationToken);
+ }
+ catch (JobHandlerNotFoundException)
+ {
+ _logger.Warning("No handler for job '{JobName}'; dropping.", message.JobName);
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ _logger.Debug("Job '{JobName}' cancelled during shutdown; will be redelivered.", message.JobName);
+
+ throw;
+ }
+ catch (Exception ex)
+ {
+ _logger.Error(ex, "Job '{JobName}' failed; it will be retried.", message.JobName);
+
+ throw;
+ }
+ finally
+ {
+ _state.JobFinished();
+ _semaphore.Release();
+ }
+ }
+}
diff --git a/src/SquidStd.Workers/Services/WorkerHeartbeatService.cs b/src/SquidStd.Workers/Services/WorkerHeartbeatService.cs
new file mode 100644
index 00000000..c5fa548a
--- /dev/null
+++ b/src/SquidStd.Workers/Services/WorkerHeartbeatService.cs
@@ -0,0 +1,117 @@
+using Serilog;
+using SquidStd.Abstractions.Interfaces.Services;
+using SquidStd.Messaging.Abstractions.Interfaces;
+using SquidStd.Workers.Abstractions;
+using SquidStd.Workers.Abstractions.Data;
+using SquidStd.Workers.Data.Config;
+using SquidStd.Workers.Interfaces;
+
+namespace SquidStd.Workers.Services;
+
+///
+/// Publishes a on the heartbeat topic immediately on start and then once
+/// per configured interval.
+///
+public sealed class WorkerHeartbeatService : ISquidStdService
+{
+ private const int DefaultIntervalSeconds = 10;
+
+ private readonly ILogger _logger = Log.ForContext();
+ private readonly IMessageTopic _topic;
+ private readonly IWorkerState _state;
+ private readonly TimeSpan _interval;
+ private readonly string _topicName;
+ private CancellationTokenSource? _loopCts;
+ private Task? _loopTask;
+
+ public WorkerHeartbeatService(IMessageTopic topic, IWorkerState state, WorkersConfig config)
+ {
+ _topic = topic;
+ _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);
+ }
+
+ _interval = TimeSpan.FromSeconds(seconds);
+ _topicName = string.IsNullOrWhiteSpace(config.HeartbeatTopic) ? WorkerChannels.HeartbeatTopic : config.HeartbeatTopic;
+ }
+
+ ///
+ public ValueTask StartAsync(CancellationToken cancellationToken = default)
+ {
+ _loopCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ _loopTask = Task.Run(() => RunLoopAsync(_loopCts.Token), CancellationToken.None);
+
+ return ValueTask.CompletedTask;
+ }
+
+ ///
+ public async ValueTask StopAsync(CancellationToken cancellationToken = default)
+ {
+ if (_loopCts is null)
+ {
+ return;
+ }
+
+ await _loopCts.CancelAsync();
+
+ try
+ {
+ if (_loopTask is not null)
+ {
+ await _loopTask;
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ // Expected on shutdown.
+ }
+ finally
+ {
+ _loopCts.Dispose();
+ _loopCts = null;
+ _loopTask = null;
+ }
+ }
+
+ 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
+ {
+ var heartbeat = new WorkerHeartbeat(
+ _state.WorkerId,
+ DateTime.UtcNow,
+ _state.Status,
+ _state.ActiveJobs,
+ _state.MaxConcurrency);
+
+ await _topic.PublishAsync(_topicName, heartbeat, cancellationToken);
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ _logger.Error(ex, "Failed to publish heartbeat; will retry on the next tick.");
+ }
+ }
+}
diff --git a/src/SquidStd.Workers/Services/WorkerState.cs b/src/SquidStd.Workers/Services/WorkerState.cs
new file mode 100644
index 00000000..8b96046b
--- /dev/null
+++ b/src/SquidStd.Workers/Services/WorkerState.cs
@@ -0,0 +1,40 @@
+using SquidStd.Workers.Abstractions.Types;
+using SquidStd.Workers.Data.Config;
+using SquidStd.Workers.Interfaces;
+
+namespace SquidStd.Workers.Services;
+
+///
+/// Default backed by an interlocked counter; resolves identity and
+/// concurrency from at construction.
+///
+public sealed class WorkerState : IWorkerState
+{
+ private int _activeJobs;
+
+ ///
+ public string WorkerId { get; }
+
+ ///
+ public int MaxConcurrency { get; }
+
+ public WorkerState(WorkersConfig config)
+ {
+ WorkerId = string.IsNullOrWhiteSpace(config.WorkerId) ? Environment.MachineName : config.WorkerId;
+ MaxConcurrency = config.MaxConcurrency > 0 ? config.MaxConcurrency : Environment.ProcessorCount;
+ }
+
+ ///
+ public int ActiveJobs => Volatile.Read(ref _activeJobs);
+
+ ///
+ public WorkerStatusType Status => ActiveJobs == 0 ? WorkerStatusType.Idle : WorkerStatusType.Busy;
+
+ ///
+ public void JobStarted()
+ => Interlocked.Increment(ref _activeJobs);
+
+ ///
+ public void JobFinished()
+ => Interlocked.Decrement(ref _activeJobs);
+}
diff --git a/src/SquidStd.Workers/SquidStd.Workers.csproj b/src/SquidStd.Workers/SquidStd.Workers.csproj
new file mode 100644
index 00000000..ff5daeb4
--- /dev/null
+++ b/src/SquidStd.Workers/SquidStd.Workers.csproj
@@ -0,0 +1,15 @@
+
+
+
+ net10.0
+ enable
+ enable
+ true
+
+
+
+
+
+
+
+
diff --git a/tests/SquidStd.Tests/Integration/Workers/WorkerSystemIntegrationTests.cs b/tests/SquidStd.Tests/Integration/Workers/WorkerSystemIntegrationTests.cs
new file mode 100644
index 00000000..d02ed74e
--- /dev/null
+++ b/tests/SquidStd.Tests/Integration/Workers/WorkerSystemIntegrationTests.cs
@@ -0,0 +1,151 @@
+using DryIoc;
+using SquidStd.Core.Interfaces.Events;
+using SquidStd.Messaging.Abstractions.Interfaces;
+using SquidStd.Messaging.RabbitMq.Data.Config;
+using SquidStd.Messaging.RabbitMq.Extensions;
+using SquidStd.Services.Core.Services;
+using SquidStd.Tests.Messaging.RabbitMq;
+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;
+
+namespace SquidStd.Tests.Integration.Workers;
+
+///
+/// End-to-end test of the workers system over a real RabbitMQ broker (Testcontainers): the manager
+/// enqueues a job that the worker consumes and runs (real queue), and the worker's heartbeat reaches the
+/// manager's registry (real fan-out topic).
+///
+[Collection(RabbitMqCollection.Name)]
+public class WorkerSystemIntegrationTests
+{
+ private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(30);
+
+ private readonly RabbitMqContainerFixture _fixture;
+
+ public WorkerSystemIntegrationTests(RabbitMqContainerFixture fixture)
+ {
+ _fixture = fixture;
+ }
+
+ [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) });
+
+ // Unique channel names per run so parallel/other tests on the shared broker do not interfere.
+ var suffix = Guid.NewGuid().ToString("N");
+ var jobQueue = $"it.jobs.{suffix}";
+ var heartbeatTopic = $"it.hb.{suffix}";
+
+ var workersConfig = new WorkersConfig
+ {
+ WorkerId = "it-worker",
+ MaxConcurrency = 2,
+ HeartbeatIntervalSeconds = 1,
+ JobQueue = jobQueue,
+ HeartbeatTopic = heartbeatTopic
+ };
+ var managerConfig = new WorkerManagerConfig
+ {
+ JobQueue = jobQueue,
+ HeartbeatTopic = heartbeatTopic
+ };
+
+ // Open the RabbitMQ connections.
+ var queueProvider = container.Resolve();
+ var topicProvider = container.Resolve();
+ await queueProvider.StartAsync();
+ await topicProvider.StartAsync();
+
+ var queue = container.Resolve();
+ var topic = container.Resolve();
+ var eventBus = container.Resolve();
+
+ // Worker side.
+ var jobRan = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var handler = new CapturingJobHandler("greet", jobRan);
+ var workerState = new WorkerState(workersConfig);
+ var dispatcher = new JobDispatcher([handler]);
+ var consumer = new WorkerConsumerService(queue, dispatcher, workerState, workersConfig);
+ var heartbeatService = new WorkerHeartbeatService(topic, workerState, workersConfig);
+
+ // Manager side.
+ var registry = new WorkerRegistry(managerConfig);
+ var collector = new HeartbeatCollectorService(topic, registry, eventBus, managerConfig);
+ var scheduler = new JobScheduler(queue, managerConfig);
+
+ try
+ {
+ await collector.StartAsync();
+ await consumer.StartAsync();
+ await heartbeatService.StartAsync();
+
+ // Let the broker register the queue consumer and the topic binding before publishing.
+ await Task.Delay(500);
+
+ await scheduler.EnqueueAsync("greet", new Dictionary { ["name"] = "squid" });
+
+ // The worker consumed the job from the real queue and ran the handler.
+ var ranJob = await jobRan.Task.WaitAsync(Timeout);
+ Assert.Equal("greet", ranJob.JobName);
+ Assert.Equal("squid", ranJob.Parameters["name"]);
+
+ // The worker's heartbeat reached the manager's registry over the real topic.
+ var seen = await PollForWorker(registry, "it-worker");
+ Assert.NotNull(seen);
+ Assert.Equal("it-worker", seen!.WorkerId);
+ Assert.Equal(2, seen.MaxConcurrency);
+ }
+ finally
+ {
+ await heartbeatService.StopAsync();
+ await consumer.StopAsync();
+ await collector.StopAsync();
+ await topicProvider.StopAsync();
+ await queueProvider.StopAsync();
+ }
+ }
+
+ 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;
+ }
+
+ await Task.Delay(200);
+ }
+
+ 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/Manager/HeartbeatCollectorServiceTests.cs b/tests/SquidStd.Tests/Manager/HeartbeatCollectorServiceTests.cs
new file mode 100644
index 00000000..736e52c1
--- /dev/null
+++ b/tests/SquidStd.Tests/Manager/HeartbeatCollectorServiceTests.cs
@@ -0,0 +1,62 @@
+using DryIoc;
+using SquidStd.Core.Interfaces.Events;
+using SquidStd.Messaging.Abstractions.Interfaces;
+using SquidStd.Messaging.Extensions;
+using SquidStd.Services.Core.Services;
+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;
+
+namespace SquidStd.Tests.Manager;
+
+public class HeartbeatCollectorServiceTests
+{
+ [Fact]
+ public async Task Collector_RecordsHeartbeat_AndPublishesDiscoveredEvent()
+ {
+ var container = new Container();
+ var eventBus = new EventBusService();
+ container.RegisterInstance(eventBus);
+ container.AddInMemoryMessaging();
+
+ var topic = container.Resolve();
+ var registry = new WorkerRegistry(new WorkerManagerConfig());
+
+ var discovered = new TaskCompletionSource();
+ eventBus.RegisterAsyncListener(new DelegateListener(e => discovered.TrySetResult(e)));
+
+ var service = new HeartbeatCollectorService(topic, registry, eventBus, new WorkerManagerConfig());
+ await service.StartAsync();
+
+ await topic.PublishAsync(
+ WorkerChannels.HeartbeatTopic,
+ new WorkerHeartbeat("w1", DateTime.UtcNow, WorkerStatusType.Idle, 0, 8));
+
+ var change = await discovered.Task.WaitAsync(TimeSpan.FromSeconds(5));
+ await service.StopAsync();
+
+ Assert.Equal("w1", change.WorkerId);
+ 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
new file mode 100644
index 00000000..7da319c6
--- /dev/null
+++ b/tests/SquidStd.Tests/Manager/JobSchedulerTests.cs
@@ -0,0 +1,52 @@
+using DryIoc;
+using SquidStd.Core.Interfaces.Events;
+using SquidStd.Messaging.Abstractions.Interfaces;
+using SquidStd.Messaging.Extensions;
+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
+{
+ [Fact]
+ public async Task EnqueueAsync_PublishesJobRequestToConfiguredQueue()
+ {
+ var container = new Container();
+ container.RegisterInstance(new EventBusService());
+ container.AddInMemoryMessaging();
+ var queue = container.Resolve();
+
+ var received = new TaskCompletionSource();
+ using var _ = queue.Subscribe(
+ WorkerChannels.JobQueue,
+ new DelegateListener(job => received.TrySetResult(job)));
+
+ var scheduler = new JobScheduler(queue, new WorkerManagerConfig());
+ await scheduler.EnqueueAsync("resize", new Dictionary { ["w"] = "100" });
+
+ var job = await received.Task.WaitAsync(TimeSpan.FromSeconds(5));
+ Assert.Equal("resize", job.JobName);
+ Assert.Equal("100", job.Parameters["w"]);
+ }
+
+ private sealed class DelegateListener : IQueueMessageListenerAsync
+ {
+ private readonly Action _onMessage;
+
+ public DelegateListener(Action onMessage)
+ {
+ _onMessage = onMessage;
+ }
+
+ public Task HandleAsync(JobRequest message, CancellationToken cancellationToken)
+ {
+ _onMessage(message);
+
+ return Task.CompletedTask;
+ }
+ }
+}
diff --git a/tests/SquidStd.Tests/Manager/Support/FakeTimerService.cs b/tests/SquidStd.Tests/Manager/Support/FakeTimerService.cs
new file mode 100644
index 00000000..03857243
--- /dev/null
+++ b/tests/SquidStd.Tests/Manager/Support/FakeTimerService.cs
@@ -0,0 +1,41 @@
+using SquidStd.Core.Interfaces.Timing;
+
+namespace SquidStd.Tests.Manager.Support;
+
+/// Captures timer registration for tests without running a real timer wheel.
+public sealed class FakeTimerService : ITimerService
+{
+ public string? RegisteredName { get; private set; }
+
+ public TimeSpan RegisteredInterval { get; private set; }
+
+ public bool RegisteredRepeat { get; private set; }
+
+ public string? UnregisteredId { get; private set; }
+
+ public string RegisterTimer(string name, TimeSpan interval, Action callback, TimeSpan? delay = null, bool repeat = false)
+ {
+ RegisteredName = name;
+ RegisteredInterval = interval;
+ RegisteredRepeat = repeat;
+
+ return "timer-1";
+ }
+
+ public void UnregisterAllTimers()
+ {
+ }
+
+ public bool UnregisterTimer(string timerId)
+ {
+ UnregisteredId = timerId;
+
+ return true;
+ }
+
+ public int UnregisterTimersByName(string name)
+ => 0;
+
+ public int UpdateTicksDelta(long timestampMilliseconds)
+ => 0;
+}
diff --git a/tests/SquidStd.Tests/Manager/WorkerManagerEndpointsTests.cs b/tests/SquidStd.Tests/Manager/WorkerManagerEndpointsTests.cs
new file mode 100644
index 00000000..589e2324
--- /dev/null
+++ b/tests/SquidStd.Tests/Manager/WorkerManagerEndpointsTests.cs
@@ -0,0 +1,83 @@
+using DryIoc;
+using Microsoft.AspNetCore.Http.HttpResults;
+using SquidStd.Core.Interfaces.Events;
+using SquidStd.Messaging.Abstractions.Interfaces;
+using SquidStd.Messaging.Extensions;
+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;
+
+namespace SquidStd.Tests.Manager;
+
+public class WorkerManagerEndpointsTests
+{
+ private static WorkerRegistry RegistryWith(params string[] workerIds)
+ {
+ var registry = new WorkerRegistry(new WorkerManagerConfig());
+ foreach (var id in workerIds)
+ {
+ registry.Record(new WorkerHeartbeat(id, DateTime.UtcNow, WorkerStatusType.Idle, 0, 8));
+ }
+
+ return registry;
+ }
+
+ private static JobScheduler NewScheduler()
+ {
+ var container = new Container();
+ container.RegisterInstance(new EventBusService());
+ container.AddInMemoryMessaging();
+
+ return new JobScheduler(container.Resolve(), new WorkerManagerConfig());
+ }
+
+ [Fact]
+ public void GetWorkers_ReturnsOkWithAll()
+ {
+ var result = WorkerManagerEndpoints.GetWorkers(RegistryWith("w1", "w2"));
+
+ Assert.Equal(2, result.Value!.Count);
+ }
+
+ [Fact]
+ public void GetWorker_ReturnsOk_WhenPresent()
+ {
+ var result = WorkerManagerEndpoints.GetWorker("w1", RegistryWith("w1"));
+
+ Assert.IsType>(result.Result);
+ }
+
+ [Fact]
+ public void GetWorker_ReturnsNotFound_WhenAbsent()
+ {
+ var result = WorkerManagerEndpoints.GetWorker("missing", RegistryWith("w1"));
+
+ Assert.IsType(result.Result);
+ }
+
+ [Fact]
+ public async Task EnqueueJob_ReturnsAccepted_AndSchedulesJob()
+ {
+ var result = await WorkerManagerEndpoints.EnqueueJob(
+ new EnqueueJobRequest("resize", new Dictionary()),
+ NewScheduler(),
+ CancellationToken.None);
+
+ Assert.IsType(result.Result);
+ }
+
+ [Fact]
+ public async Task EnqueueJob_ReturnsBadRequest_WhenJobNameBlank()
+ {
+ var result = await WorkerManagerEndpoints.EnqueueJob(
+ new EnqueueJobRequest(" ", null),
+ NewScheduler(),
+ CancellationToken.None);
+
+ Assert.IsType>(result.Result);
+ }
+}
diff --git a/tests/SquidStd.Tests/Manager/WorkerManagerRegistrationExtensionsTests.cs b/tests/SquidStd.Tests/Manager/WorkerManagerRegistrationExtensionsTests.cs
new file mode 100644
index 00000000..56c6c7c2
--- /dev/null
+++ b/tests/SquidStd.Tests/Manager/WorkerManagerRegistrationExtensionsTests.cs
@@ -0,0 +1,62 @@
+using DryIoc;
+using SquidStd.Core.Interfaces.Events;
+using SquidStd.Core.Interfaces.Timing;
+using SquidStd.Messaging.Extensions;
+using SquidStd.Services.Core.Services;
+using SquidStd.Services.Core.Services.Scheduling;
+using SquidStd.Tests.Manager.Support;
+using SquidStd.Workers.Manager.Data.Config;
+using SquidStd.Workers.Manager.Extensions;
+using SquidStd.Workers.Manager.Interfaces;
+using SquidStd.Workers.Manager.Services;
+
+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()
+ {
+ using var container = NewContainer();
+
+ container.AddWorkerManager();
+
+ Assert.NotNull(container.Resolve());
+ Assert.NotNull(container.Resolve());
+ Assert.NotNull(container.Resolve());
+ Assert.NotNull(container.Resolve());
+ }
+
+ [Fact]
+ public void AddWorkerManager_RegistersTimerWheelPump_WhenAbsent()
+ {
+ using var container = NewContainer();
+
+ container.AddWorkerManager();
+
+ Assert.True(container.IsRegistered());
+ }
+
+ [Fact]
+ public void AddWorkerManager_SharesRegistryInstanceAcrossInterfaceAndConcrete()
+ {
+ using var container = NewContainer();
+
+ container.AddWorkerManager();
+
+ Assert.Same(container.Resolve(), container.Resolve());
+ }
+}
diff --git a/tests/SquidStd.Tests/Manager/WorkerOfflineSweepServiceTests.cs b/tests/SquidStd.Tests/Manager/WorkerOfflineSweepServiceTests.cs
new file mode 100644
index 00000000..d1a0dfec
--- /dev/null
+++ b/tests/SquidStd.Tests/Manager/WorkerOfflineSweepServiceTests.cs
@@ -0,0 +1,67 @@
+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()
+ {
+ var timer = new FakeTimerService();
+ var registry = new WorkerRegistry(new WorkerManagerConfig());
+ var service = new WorkerOfflineSweepService(timer, registry, new EventBusService(), new WorkerManagerConfig { SweepIntervalSeconds = 5 });
+
+ await service.StartAsync();
+
+ Assert.Equal("worker-offline-sweep", timer.RegisteredName);
+ Assert.Equal(TimeSpan.FromSeconds(5), timer.RegisteredInterval);
+ Assert.True(timer.RegisteredRepeat);
+
+ await service.StopAsync();
+ Assert.Equal("timer-1", timer.UnregisteredId);
+ }
+
+ [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));
+ 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());
+ await service.RunSweepAsync();
+
+ var change = await offline.Task.WaitAsync(TimeSpan.FromSeconds(5));
+ Assert.Equal("w1", change.WorkerId);
+ Assert.Equal(WorkerStatusType.Offline, change.NewStatus);
+ Assert.Equal(WorkerStatusType.Offline, registry.Get("w1")!.Status);
+ }
+
+ 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/WorkerRegistryTests.cs b/tests/SquidStd.Tests/Manager/WorkerRegistryTests.cs
new file mode 100644
index 00000000..1cf24a7d
--- /dev/null
+++ b/tests/SquidStd.Tests/Manager/WorkerRegistryTests.cs
@@ -0,0 +1,97 @@
+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 });
+
+ private static WorkerHeartbeat Heartbeat(string id, WorkerStatusType status, int activeJobs = 0)
+ => new(id, DateTime.UtcNow, status, activeJobs, 8);
+
+ [Fact]
+ public void Record_NewWorker_ReturnsDiscoveredTransitionAndStores()
+ {
+ var registry = NewRegistry();
+
+ var change = registry.Record(Heartbeat("w1", WorkerStatusType.Idle));
+
+ 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 Record_ExistingWorker_IdleToBusy_NoTransitionButStateUpdated()
+ {
+ var registry = NewRegistry();
+ registry.Record(Heartbeat("w1", WorkerStatusType.Idle));
+
+ var change = registry.Record(Heartbeat("w1", WorkerStatusType.Busy, activeJobs: 2));
+
+ Assert.Null(change);
+ Assert.Equal(WorkerStatusType.Busy, registry.Get("w1")!.Status);
+ Assert.Equal(2, registry.Get("w1")!.ActiveJobs);
+ }
+
+ [Fact]
+ public void Sweep_MarksOverdueWorkerOffline_AndReturnsTransition()
+ {
+ var registry = NewRegistry(offlineTimeoutSeconds: 30);
+ registry.Record(Heartbeat("w1", WorkerStatusType.Idle));
+
+ var changes = registry.Sweep(DateTime.UtcNow.AddSeconds(31));
+
+ 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 Sweep_DoesNotReEmitAlreadyOfflineWorker()
+ {
+ var registry = NewRegistry(offlineTimeoutSeconds: 30);
+ registry.Record(Heartbeat("w1", WorkerStatusType.Idle));
+ registry.Sweep(DateTime.UtcNow.AddSeconds(31));
+
+ var changes = registry.Sweep(DateTime.UtcNow.AddSeconds(62));
+
+ Assert.Empty(changes);
+ }
+
+ [Fact]
+ public void Record_AfterOffline_ReturnsReturnTransition()
+ {
+ var registry = NewRegistry(offlineTimeoutSeconds: 30);
+ registry.Record(Heartbeat("w1", WorkerStatusType.Idle));
+ registry.Sweep(DateTime.UtcNow.AddSeconds(31));
+
+ var change = registry.Record(Heartbeat("w1", WorkerStatusType.Busy));
+
+ Assert.NotNull(change);
+ Assert.Equal(WorkerStatusType.Offline, change!.OldStatus);
+ Assert.Equal(WorkerStatusType.Busy, change.NewStatus);
+ }
+
+ [Fact]
+ public void GetAll_ReturnsAllKnownWorkers()
+ {
+ var registry = NewRegistry();
+ registry.Record(Heartbeat("w1", WorkerStatusType.Idle));
+ registry.Record(Heartbeat("w2", WorkerStatusType.Busy));
+
+ Assert.Equal(2, registry.GetAll().Count);
+ Assert.Null(registry.Get("missing"));
+ }
+}
diff --git a/tests/SquidStd.Tests/Messaging/InMemoryTopicProviderTests.cs b/tests/SquidStd.Tests/Messaging/InMemoryTopicProviderTests.cs
new file mode 100644
index 00000000..7757d639
--- /dev/null
+++ b/tests/SquidStd.Tests/Messaging/InMemoryTopicProviderTests.cs
@@ -0,0 +1,58 @@
+using System.Text;
+using SquidStd.Messaging.Services;
+
+namespace SquidStd.Tests.Messaging;
+
+public class InMemoryTopicProviderTests
+{
+ private static ReadOnlyMemory Bytes(string s) => Encoding.UTF8.GetBytes(s);
+
+ [Fact]
+ 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 Dispose_StopsDelivery()
+ {
+ await using var provider = new InMemoryTopicProvider();
+ 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(0, count);
+ }
+
+ [Fact]
+ public async Task Publish_NoSubscribers_IsNoOp()
+ {
+ await using var provider = new InMemoryTopicProvider();
+
+ await provider.PublishAsync("t", Bytes("x"));
+ }
+
+ [Fact]
+ public async Task FailingSubscriber_IsIsolated()
+ {
+ 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);
+ }
+}
diff --git a/tests/SquidStd.Tests/Messaging/MessageTopicTests.cs b/tests/SquidStd.Tests/Messaging/MessageTopicTests.cs
new file mode 100644
index 00000000..dcbcafa2
--- /dev/null
+++ b/tests/SquidStd.Tests/Messaging/MessageTopicTests.cs
@@ -0,0 +1,29 @@
+using SquidStd.Core.Json;
+using SquidStd.Messaging.Abstractions.Interfaces;
+using SquidStd.Messaging.Abstractions.Services;
+using SquidStd.Messaging.Services;
+
+namespace SquidStd.Tests.Messaging;
+
+public class MessageTopicTests
+{
+ private sealed class Ping
+ {
+ public string Source { get; set; } = "";
+ }
+
+ [Fact]
+ public async Task PublishSubscribe_RoundTripsTypedMessage()
+ {
+ await using var provider = new InMemoryTopicProvider();
+ 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; });
+
+ await topic.PublishAsync("pings", new Ping { Source = "w1" });
+
+ var got = await received.Task.WaitAsync(TimeSpan.FromSeconds(5));
+ Assert.Equal("w1", got.Source);
+ }
+}
diff --git a/tests/SquidStd.Tests/Messaging/MessagingRegistrationExtensionsTests.cs b/tests/SquidStd.Tests/Messaging/MessagingRegistrationExtensionsTests.cs
index 140608b0..e06330f0 100644
--- a/tests/SquidStd.Tests/Messaging/MessagingRegistrationExtensionsTests.cs
+++ b/tests/SquidStd.Tests/Messaging/MessagingRegistrationExtensionsTests.cs
@@ -1,9 +1,11 @@
using DryIoc;
+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.Services.Core.Services;
namespace SquidStd.Tests.Messaging;
@@ -34,4 +36,17 @@ public void AddInMemoryMessaging_MetricsAndProviderShareInstance()
Assert.Same(container.Resolve(), container.Resolve());
}
+
+ [Fact]
+ public void AddInMemoryMessaging_RegistersTopicServices()
+ {
+ using var container = new Container();
+ container.RegisterInstance(new EventBusService());
+
+ container.AddInMemoryMessaging();
+
+ Assert.NotNull(container.Resolve());
+ Assert.NotNull(container.Resolve());
+ Assert.NotNull(container.Resolve());
+ }
}
diff --git a/tests/SquidStd.Tests/Messaging/RabbitMq/RabbitMqTopicProviderTests.cs b/tests/SquidStd.Tests/Messaging/RabbitMq/RabbitMqTopicProviderTests.cs
new file mode 100644
index 00000000..28775802
--- /dev/null
+++ b/tests/SquidStd.Tests/Messaging/RabbitMq/RabbitMqTopicProviderTests.cs
@@ -0,0 +1,59 @@
+using System.Text;
+using SquidStd.Messaging.RabbitMq.Data.Config;
+using SquidStd.Messaging.RabbitMq.Services;
+
+namespace SquidStd.Tests.Messaging.RabbitMq;
+
+[Collection(RabbitMqCollection.Name)]
+public class RabbitMqTopicProviderTests
+{
+ private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(30);
+
+ private readonly RabbitMqContainerFixture _fixture;
+
+ public RabbitMqTopicProviderTests(RabbitMqContainerFixture fixture)
+ {
+ _fixture = fixture;
+ }
+
+ private RabbitMqTopicProvider NewProvider()
+ => new(new RabbitMqOptions { Uri = new Uri(_fixture.AmqpUri) });
+
+ private static ReadOnlyMemory Bytes(string s) => Encoding.UTF8.GetBytes(s);
+ private static string Topic() => "topic-" + Guid.NewGuid().ToString("N");
+
+ [Fact]
+ public async Task Publish_FansOutToAllSubscribers()
+ {
+ await using var provider = NewProvider();
+ await provider.StartAsync();
+ 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; });
+
+ // Allow the broker to register the bindings before publishing.
+ await Task.Delay(300);
+ await provider.PublishAsync(topic, Bytes("hello"));
+
+ Assert.Equal("hello", await a.Task.WaitAsync(Timeout));
+ 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();
+
+ 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)));
+ }
+}
diff --git a/tests/SquidStd.Tests/Messaging/TopicEventBridgeTests.cs b/tests/SquidStd.Tests/Messaging/TopicEventBridgeTests.cs
new file mode 100644
index 00000000..d68276d7
--- /dev/null
+++ b/tests/SquidStd.Tests/Messaging/TopicEventBridgeTests.cs
@@ -0,0 +1,49 @@
+using SquidStd.Core.Interfaces.Events;
+using SquidStd.Core.Json;
+using SquidStd.Messaging.Abstractions.Data.Events;
+using SquidStd.Messaging.Abstractions.Interfaces;
+using SquidStd.Messaging.Abstractions.Services;
+using SquidStd.Messaging.Services;
+using SquidStd.Services.Core.Services;
+
+namespace SquidStd.Tests.Messaging;
+
+public class TopicEventBridgeTests
+{
+ private sealed class Beat
+ {
+ public string Worker { get; set; } = "";
+ }
+
+ private sealed class CapturingListener : IAsyncEventListener
+ {
+ public TaskCompletionSource Received { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ public Task HandleAsync(TopicMessageEvent eventData, CancellationToken cancellationToken)
+ {
+ Received.TrySetResult(eventData);
+
+ return Task.CompletedTask;
+ }
+ }
+
+ [Fact]
+ public async Task Bridge_RepublishesTopicMessageOnEventBus()
+ {
+ await using var provider = new InMemoryTopicProvider();
+ var serializer = new JsonDataSerializer();
+ IMessageTopic topic = new MessageTopic(provider, serializer, serializer);
+ var eventBus = new EventBusService();
+ ITopicEventBridge bridge = new TopicEventBridge(topic, eventBus);
+
+ var listener = new CapturingListener();
+ eventBus.RegisterAsyncListener(listener);
+ using var subscription = bridge.Bridge("workers.heartbeat");
+
+ await topic.PublishAsync("workers.heartbeat", new Beat { Worker = "w1" });
+
+ var evt = await listener.Received.Task.WaitAsync(TimeSpan.FromSeconds(5));
+ Assert.Equal("workers.heartbeat", evt.Topic);
+ Assert.Equal("w1", Assert.IsType(evt.Data).Worker);
+ }
+}
diff --git a/tests/SquidStd.Tests/Services/Core/RegisterDefaultServicesExtensionsTests.cs b/tests/SquidStd.Tests/Services/Core/RegisterDefaultServicesExtensionsTests.cs
index 74e259a7..e8d2ee14 100644
--- a/tests/SquidStd.Tests/Services/Core/RegisterDefaultServicesExtensionsTests.cs
+++ b/tests/SquidStd.Tests/Services/Core/RegisterDefaultServicesExtensionsTests.cs
@@ -2,6 +2,7 @@
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;
@@ -180,6 +181,17 @@ public void RegisterCoreServices_RegistersSecretServices()
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/SquidStd.Tests.csproj b/tests/SquidStd.Tests/SquidStd.Tests.csproj
index 59802f79..7e8df98e 100644
--- a/tests/SquidStd.Tests/SquidStd.Tests.csproj
+++ b/tests/SquidStd.Tests/SquidStd.Tests.csproj
@@ -48,6 +48,10 @@
+
+
+
+
diff --git a/tests/SquidStd.Tests/Templating/ScribanTemplateRendererTests.cs b/tests/SquidStd.Tests/Templating/ScribanTemplateRendererTests.cs
new file mode 100644
index 00000000..414f63ba
--- /dev/null
+++ b/tests/SquidStd.Tests/Templating/ScribanTemplateRendererTests.cs
@@ -0,0 +1,87 @@
+using SquidStd.Core.Directories;
+using SquidStd.Templating;
+using SquidStd.Templating.Services;
+using SquidStd.Tests.Support;
+
+namespace SquidStd.Tests.Templating;
+
+public class ScribanTemplateRendererTests
+{
+ private static ScribanTemplateRenderer NewRenderer(string root)
+ => new(new DirectoriesConfig(root, []));
+
+ [Fact]
+ public async Task RenderAsync_RendersModel()
+ {
+ 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);
+ }
+
+ [Fact]
+ public async Task RegisterThenRenderByName_Works()
+ {
+ using var temp = new TempDirectory();
+ var renderer = NewRenderer(temp.Path);
+ renderer.Register("greet", "Hi {{ user.name }}");
+
+ var result = await renderer.RenderByNameAsync("greet", new { User = new { Name = "squid" } });
+
+ Assert.Equal("Hi squid", result);
+ }
+
+ [Fact]
+ public async Task RenderByNameAsync_UnknownName_Throws()
+ {
+ using var temp = new TempDirectory();
+ var renderer = NewRenderer(temp.Path);
+
+ await Assert.ThrowsAsync(async () => await renderer.RenderByNameAsync("nope", null));
+ }
+
+ [Fact]
+ public async Task RenderAsync_MalformedTemplate_Throws()
+ {
+ using var temp = new TempDirectory();
+ var renderer = NewRenderer(temp.Path);
+
+ await Assert.ThrowsAsync(async () => await renderer.RenderAsync("{{ for x in }}", null));
+ }
+
+ [Fact]
+ public void Register_MalformedTemplate_Throws()
+ {
+ using var temp = new TempDirectory();
+ var renderer = NewRenderer(temp.Path);
+
+ Assert.Throws(() => renderer.Register("bad", "{{ for x in }}"));
+ }
+
+ [Fact]
+ public async Task RenderAsync_EmptyTemplate_Throws()
+ {
+ using var temp = new TempDirectory();
+ var renderer = NewRenderer(temp.Path);
+
+ await Assert.ThrowsAsync(async () => await renderer.RenderAsync(string.Empty, null));
+ }
+
+ [Fact]
+ public async Task StartAsync_AutoLoadsTemplatesFromDirectory()
+ {
+ using var temp = new TempDirectory();
+ var emails = Path.Combine(temp.Path, "templates", "emails");
+ Directory.CreateDirectory(emails);
+ await File.WriteAllTextAsync(Path.Combine(emails, "welcome.tmpl"), "Welcome {{ user.name }}");
+
+ var renderer = NewRenderer(temp.Path);
+ await renderer.StartAsync();
+
+ var result = await renderer.RenderByNameAsync("emails/welcome", new { User = new { Name = "squid" } });
+
+ Assert.Equal("Welcome squid", result);
+ }
+}
diff --git a/tests/SquidStd.Tests/Templating/TemplatingRegistrationTests.cs b/tests/SquidStd.Tests/Templating/TemplatingRegistrationTests.cs
new file mode 100644
index 00000000..7046a789
--- /dev/null
+++ b/tests/SquidStd.Tests/Templating/TemplatingRegistrationTests.cs
@@ -0,0 +1,25 @@
+using DryIoc;
+using SquidStd.Core.Directories;
+using SquidStd.Templating.Extensions;
+using SquidStd.Templating.Interfaces;
+using SquidStd.Tests.Support;
+
+namespace SquidStd.Tests.Templating;
+
+public class TemplatingRegistrationTests
+{
+ [Fact]
+ public async Task AddTemplating_ResolvesRendererAndRenders()
+ {
+ using var temp = new TempDirectory();
+ var container = new Container();
+ container.RegisterInstance(new DirectoriesConfig(temp.Path, []));
+
+ container.AddTemplating();
+
+ var renderer = container.Resolve();
+ var result = await renderer.RenderAsync("Hi {{ user.name }}", new { User = new { Name = "squid" } });
+
+ Assert.Equal("Hi squid", result);
+ }
+}
diff --git a/tests/SquidStd.Tests/Workers/JobDispatcherTests.cs b/tests/SquidStd.Tests/Workers/JobDispatcherTests.cs
new file mode 100644
index 00000000..6114d4dc
--- /dev/null
+++ b/tests/SquidStd.Tests/Workers/JobDispatcherTests.cs
@@ -0,0 +1,46 @@
+using SquidStd.Tests.Workers.Support;
+using SquidStd.Workers.Abstractions.Data;
+using SquidStd.Workers.Exceptions;
+using SquidStd.Workers.Services;
+
+namespace SquidStd.Tests.Workers;
+
+public class JobDispatcherTests
+{
+ private static JobRequest Job(string name)
+ => new(name, new Dictionary());
+
+ [Fact]
+ public async Task DispatchAsync_InvokesHandlerMatchingJobName()
+ {
+ var resize = new RecordingJobHandler("resize");
+ var encode = new RecordingJobHandler("encode");
+ var dispatcher = new JobDispatcher([resize, encode]);
+
+ await dispatcher.DispatchAsync(Job("encode"), CancellationToken.None);
+
+ Assert.Empty(resize.Received);
+ Assert.Single(encode.Received);
+ }
+
+ [Fact]
+ public async Task DispatchAsync_ThrowsWhenNoHandlerMatches()
+ {
+ var dispatcher = new JobDispatcher([new RecordingJobHandler("resize")]);
+
+ var ex = await Assert.ThrowsAsync(
+ () => 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));
+ }
+}
diff --git a/tests/SquidStd.Tests/Workers/Support/FakeMessageQueue.cs b/tests/SquidStd.Tests/Workers/Support/FakeMessageQueue.cs
new file mode 100644
index 00000000..1a1812d2
--- /dev/null
+++ b/tests/SquidStd.Tests/Workers/Support/FakeMessageQueue.cs
@@ -0,0 +1,23 @@
+using SquidStd.Messaging.Abstractions.Interfaces;
+
+namespace SquidStd.Tests.Workers.Support;
+
+/// Inert for unit tests that drive the consumer's HandleAsync directly.
+public sealed class FakeMessageQueue : IMessageQueue
+{
+ public Task PublishAsync(string queueName, TMessage message, CancellationToken cancellationToken = default)
+ => Task.CompletedTask;
+
+ public IDisposable Subscribe(string queueName, IQueueMessageListener listener)
+ => new Subscription();
+
+ public IDisposable Subscribe(string queueName, IQueueMessageListenerAsync listener)
+ => new Subscription();
+
+ private sealed class Subscription : IDisposable
+ {
+ public void Dispose()
+ {
+ }
+ }
+}
diff --git a/tests/SquidStd.Tests/Workers/Support/RecordingJobHandler.cs b/tests/SquidStd.Tests/Workers/Support/RecordingJobHandler.cs
new file mode 100644
index 00000000..75fef96a
--- /dev/null
+++ b/tests/SquidStd.Tests/Workers/Support/RecordingJobHandler.cs
@@ -0,0 +1,55 @@
+using SquidStd.Workers.Abstractions.Data;
+using SquidStd.Workers.Interfaces;
+
+namespace SquidStd.Tests.Workers.Support;
+
+///
+/// Configurable for tests: records the jobs it received and can be told
+/// to throw, or to block until released, so concurrency and error paths can be exercised.
+///
+public sealed class RecordingJobHandler : IJobHandler
+{
+ private readonly List _received = [];
+ private readonly Lock _sync = new();
+
+ public RecordingJobHandler(string jobName)
+ {
+ JobName = jobName;
+ }
+
+ public string JobName { get; }
+
+ public Exception? ThrowOnHandle { get; set; }
+
+ /// When set, awaits this before returning.
+ public TaskCompletionSource? Gate { get; set; }
+
+ public IReadOnlyList Received
+ {
+ get
+ {
+ lock (_sync)
+ {
+ return _received.ToArray();
+ }
+ }
+ }
+
+ public async Task HandleAsync(JobRequest job, CancellationToken cancellationToken)
+ {
+ lock (_sync)
+ {
+ _received.Add(job);
+ }
+
+ if (ThrowOnHandle is not null)
+ {
+ throw ThrowOnHandle;
+ }
+
+ if (Gate is not null)
+ {
+ await Gate.Task.WaitAsync(cancellationToken);
+ }
+ }
+}
diff --git a/tests/SquidStd.Tests/Workers/WorkerConsumerServiceTests.cs b/tests/SquidStd.Tests/Workers/WorkerConsumerServiceTests.cs
new file mode 100644
index 00000000..7f04c1e3
--- /dev/null
+++ b/tests/SquidStd.Tests/Workers/WorkerConsumerServiceTests.cs
@@ -0,0 +1,76 @@
+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()
+ {
+ var handler = new RecordingJobHandler("resize");
+ var state = new WorkerState(new() { WorkerId = "w1", MaxConcurrency = 2 });
+ var consumer = Build(state, handler);
+
+ await consumer.HandleAsync(Job("resize"), CancellationToken.None);
+
+ Assert.Single(handler.Received);
+ Assert.Equal(0, state.ActiveJobs);
+ }
+
+ [Fact]
+ public async Task HandleAsync_DropsUnknownJobWithoutThrowing()
+ {
+ var state = new WorkerState(new() { WorkerId = "w1", MaxConcurrency = 2 });
+ var consumer = Build(state, new RecordingJobHandler("resize"));
+
+ var ex = await Record.ExceptionAsync(() => consumer.HandleAsync(Job("unknown"), CancellationToken.None));
+
+ Assert.Null(ex);
+ Assert.Equal(0, state.ActiveJobs);
+ }
+
+ [Fact]
+ public async Task HandleAsync_RethrowsHandlerExceptionForRequeue()
+ {
+ var handler = new RecordingJobHandler("resize") { ThrowOnHandle = new InvalidOperationException("boom") };
+ var state = new WorkerState(new() { WorkerId = "w1", MaxConcurrency = 2 });
+ var consumer = Build(state, handler);
+
+ await Assert.ThrowsAsync(
+ () => consumer.HandleAsync(Job("resize"), CancellationToken.None));
+
+ Assert.Equal(0, state.ActiveJobs);
+ }
+
+ [Fact]
+ public async Task HandleAsync_NeverExceedsMaxConcurrency()
+ {
+ var handler = new RecordingJobHandler("resize") { Gate = new TaskCompletionSource() };
+ 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");
+
+ // Release the gate so everything drains.
+ handler.Gate!.SetResult();
+ await Task.WhenAll(inFlight);
+ Assert.Equal(0, state.ActiveJobs);
+ Assert.Equal(5, handler.Received.Count);
+ }
+}
diff --git a/tests/SquidStd.Tests/Workers/WorkerContractsTests.cs b/tests/SquidStd.Tests/Workers/WorkerContractsTests.cs
new file mode 100644
index 00000000..8e9df30b
--- /dev/null
+++ b/tests/SquidStd.Tests/Workers/WorkerContractsTests.cs
@@ -0,0 +1,83 @@
+using SquidStd.Core.Json;
+using SquidStd.Workers.Abstractions;
+using SquidStd.Workers.Abstractions.Data;
+using SquidStd.Workers.Abstractions.Types;
+
+namespace SquidStd.Tests.Workers;
+
+public class WorkerContractsTests
+{
+ private static readonly JsonDataSerializer Serializer = new();
+
+ [Fact]
+ public void JobRequest_RoundTrips_PreservingNameAndParameters()
+ {
+ var original = new JobRequest(
+ "resize-image",
+ new Dictionary { ["width"] = "800", ["height"] = "600" });
+
+ var bytes = Serializer.Serialize(original);
+ var restored = Serializer.Deserialize(bytes);
+
+ Assert.Equal("resize-image", restored.JobName);
+ Assert.Equal("800", restored.Parameters["width"]);
+ Assert.Equal("600", restored.Parameters["height"]);
+ }
+
+ [Fact]
+ public void WorkerHeartbeat_RoundTrips_PreservingAllFields()
+ {
+ 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);
+ }
+
+ [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),
+ status,
+ 0,
+ 4);
+
+ var restored = Serializer.Deserialize(Serializer.Serialize(original));
+
+ Assert.Equal(status, restored.Status);
+ }
+
+ [Fact]
+ public void WorkerInfo_RoundTrips_PreservingAllFields()
+ {
+ var original = new WorkerInfo(
+ "worker-4",
+ WorkerStatusType.Offline,
+ 2,
+ 8,
+ new DateTime(2026, 6, 23, 9, 0, 0, DateTimeKind.Utc),
+ new DateTime(2026, 6, 23, 9, 30, 0, DateTimeKind.Utc));
+
+ var restored = Serializer.Deserialize(Serializer.Serialize(original));
+
+ 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
new file mode 100644
index 00000000..6efcf659
--- /dev/null
+++ b/tests/SquidStd.Tests/Workers/WorkerHeartbeatServiceTests.cs
@@ -0,0 +1,69 @@
+using DryIoc;
+using SquidStd.Core.Interfaces.Events;
+using SquidStd.Messaging.Abstractions.Interfaces;
+using SquidStd.Messaging.Extensions;
+using SquidStd.Services.Core.Services;
+using SquidStd.Workers.Abstractions;
+using SquidStd.Workers.Abstractions.Data;
+using SquidStd.Workers.Abstractions.Types;
+using SquidStd.Workers.Data.Config;
+using SquidStd.Workers.Services;
+
+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()
+ {
+ var config = new WorkersConfig { WorkerId = "w1", MaxConcurrency = 8, HeartbeatIntervalSeconds = 60 };
+ var (topic, state) = NewMessaging(config);
+
+ var received = new TaskCompletionSource();
+ using var _ = topic.Subscribe(
+ WorkerChannels.HeartbeatTopic,
+ (hb, _) => { received.TrySetResult(hb); return Task.CompletedTask; });
+
+ var service = new WorkerHeartbeatService(topic, state, config);
+ await service.StartAsync();
+
+ 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);
+ }
+
+ [Fact]
+ 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; });
+
+ var service = new WorkerHeartbeatService(topic, state, config);
+ await service.StartAsync();
+
+ var heartbeat = await received.Task.WaitAsync(TimeSpan.FromSeconds(5));
+ await service.StopAsync();
+
+ Assert.Equal(1, heartbeat.ActiveJobs);
+ Assert.Equal(WorkerStatusType.Busy, heartbeat.Status);
+ }
+}
diff --git a/tests/SquidStd.Tests/Workers/WorkerStateTests.cs b/tests/SquidStd.Tests/Workers/WorkerStateTests.cs
new file mode 100644
index 00000000..8e8b7942
--- /dev/null
+++ b/tests/SquidStd.Tests/Workers/WorkerStateTests.cs
@@ -0,0 +1,51 @@
+using SquidStd.Workers.Data.Config;
+using SquidStd.Workers.Services;
+using SquidStd.Workers.Abstractions.Types;
+
+namespace SquidStd.Tests.Workers;
+
+public class WorkerStateTests
+{
+ [Fact]
+ public void Status_IsIdleWhenNoActiveJobs()
+ {
+ var state = new WorkerState(new WorkersConfig { WorkerId = "w1", MaxConcurrency = 4 });
+
+ Assert.Equal(WorkerStatusType.Idle, state.Status);
+ Assert.Equal(0, state.ActiveJobs);
+ }
+
+ [Fact]
+ public void Status_IsBusyWhileJobsActive()
+ {
+ var state = new WorkerState(new WorkersConfig { WorkerId = "w1", MaxConcurrency = 4 });
+
+ state.JobStarted();
+ state.JobStarted();
+
+ Assert.Equal(WorkerStatusType.Busy, state.Status);
+ Assert.Equal(2, state.ActiveJobs);
+
+ state.JobFinished();
+ state.JobFinished();
+
+ Assert.Equal(WorkerStatusType.Idle, state.Status);
+ Assert.Equal(0, state.ActiveJobs);
+ }
+
+ [Fact]
+ public void WorkerId_FallsBackToMachineNameWhenBlank()
+ {
+ var state = new WorkerState(new WorkersConfig { WorkerId = " ", MaxConcurrency = 4 });
+
+ Assert.Equal(Environment.MachineName, state.WorkerId);
+ }
+
+ [Fact]
+ public void MaxConcurrency_FallsBackToProcessorCountWhenNotPositive()
+ {
+ var state = new WorkerState(new WorkersConfig { WorkerId = "w1", MaxConcurrency = 0 });
+
+ Assert.Equal(Environment.ProcessorCount, state.MaxConcurrency);
+ }
+}
diff --git a/tests/SquidStd.Tests/Workers/WorkersRegistrationExtensionsTests.cs b/tests/SquidStd.Tests/Workers/WorkersRegistrationExtensionsTests.cs
new file mode 100644
index 00000000..5d4b73b4
--- /dev/null
+++ b/tests/SquidStd.Tests/Workers/WorkersRegistrationExtensionsTests.cs
@@ -0,0 +1,65 @@
+using DryIoc;
+using SquidStd.Core.Interfaces.Events;
+using SquidStd.Messaging.Extensions;
+using SquidStd.Services.Core.Services;
+using SquidStd.Workers.Data.Config;
+using SquidStd.Workers.Extensions;
+using SquidStd.Workers.Interfaces;
+using SquidStd.Workers.Services;
+
+namespace SquidStd.Tests.Workers;
+
+public class WorkersRegistrationExtensionsTests
+{
+ 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;
+ }
+
+ [Fact]
+ public void AddWorkers_RegistersResolvableServices()
+ {
+ using var container = NewContainer();
+
+ container.AddWorkers();
+
+ Assert.NotNull(container.Resolve());
+ Assert.NotNull(container.Resolve());
+ Assert.NotNull(container.Resolve());
+ Assert.NotNull(container.Resolve());
+ }
+
+ [Fact]
+ public async Task AddJobHandler_MakesHandlerReachableFromDispatcher()
+ {
+ using var container = NewContainer();
+
+ container.AddWorkers();
+ container.AddJobHandler();
+
+ var dispatcher = container.Resolve();
+ await dispatcher.DispatchAsync(new("echo", new Dictionary()), CancellationToken.None);
+
+ Assert.Equal(1, EchoJobHandler.Calls);
+ }
+
+ private sealed class EchoJobHandler : IJobHandler
+ {
+ public static int Calls;
+
+ public string JobName => "echo";
+
+ public Task HandleAsync(SquidStd.Workers.Abstractions.Data.JobRequest job, CancellationToken cancellationToken)
+ {
+ Interlocked.Increment(ref Calls);
+
+ return Task.CompletedTask;
+ }
+ }
+}