Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion docs/articles/concepts/bootstrap-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,24 @@ bootstrap.ConfigureServices(container =>

See [dependency injection](dependency-injection.md) for the container and the `AddXxx` / `RegisterXxx` pattern.

## OnConfigLoaded

After the configuration is loaded - and before the logger is built and services start - the
bootstrap applies any typed config hooks. Use them to inspect or override loaded sections at
startup; changes are in-memory only:

```csharp
var bootstrap = SquidStdBootstrap.Create(o => o.ConfigName = "myapp");
bootstrap.ConfigureServices(c => c.RegisterCoreServices());
bootstrap.OnConfigLoaded<SquidStdLoggerOptions>(o => o.MinimumLevel = LogLevelType.Debug);

await bootstrap.StartAsync();
```

The effective order is: load configuration, apply config hooks, configure the logger, start
services. Hooks are re-applied on every configuration load, so overrides are never lost. See
[Inspecting and overriding loaded configuration](../guides/configuration.md#inspecting-and-overriding-loaded-configuration) for more examples.

## Migrating to 0.15: explicit core services

Up to 0.14, `SquidStdBootstrap.Create` registered every core service on creation. From 0.15 the bootstrap registers only the configuration core - `DirectoriesConfig`, the `logger` config section and the config manager. The remaining core services (JSON serializer, event bus, job system, main-thread dispatcher, timer wheel, metrics collection, secrets) are opted into with the parameterless `RegisterCoreServices()`:
Expand All @@ -53,7 +71,7 @@ builder.UseSquidStd(options => options.ConfigName = "myapp", c => c.RegisterCore

## Start and stop over ISquidStdService

Services implementing `ISquidStdService` participate in the lifecycle. On `StartAsync` they are started in registration order; on `StopAsync` they are stopped in reverse order, so dependencies remain available while their dependents shut down.
Services implementing `ISquidStdService` participate in the lifecycle. On `StartAsync` the configuration is loaded and the config hooks are applied, then services are started in registration order; on `StopAsync` they are stopped in reverse order, so dependencies remain available while their dependents shut down.

The bootstrap logs its whole lifecycle: a startup banner with the application name and version (set `SquidStdOptions.AppName`; it defaults to the entry assembly name and is attached to every event as the `Application` / `ApplicationVersion` properties), a registration summary (per-registration detail at Debug), one line per service started with its duration, and the shutdown sequence. A service that fails to stop is logged as a warning and the remaining services are still stopped. Extra Serilog sinks can be plugged by registering `ILogEventSink` instances in the container before start.

Expand Down
36 changes: 36 additions & 0 deletions docs/articles/guides/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,39 @@ myService:
endpoint: "https://$API_HOST"
retries: 5
```

## Inspecting and overriding loaded configuration

The config manager loads sections transparently, but nothing stays hidden:

```csharp
var config = bootstrap.Resolve<IConfigManagerService>();
Console.WriteLine(config.Compose()); // full current configuration as YAML
var logger = config.GetConfig<SquidStdLoggerOptions>(); // one typed section
```

To inspect or tweak a section at startup - before the logger and the services consume it -
register a typed hook on the bootstrap. Hooks run after every configuration load and mutate
the section in memory only: the YAML file is never rewritten (call `Save()` explicitly if you
want to persist).

```csharp
// Tune a section at startup
bootstrap.OnConfigLoaded<SquidStdLoggerOptions>(o => o.MinimumLevel = LogLevelType.Debug);

// Override from the environment (WorkersConfig comes with SquidStd.Workers)
bootstrap.OnConfigLoaded<WorkersConfig>(w =>
{
if (int.TryParse(Environment.GetEnvironmentVariable("WORKERS_MAX"), out var max))
{
w.MaxConcurrency = max;
}
});

// Inspect a loaded section before services start
bootstrap.OnConfigLoaded<StorageConfig>(s => Console.WriteLine($"storage root: {s.RootDirectory}"));
```

Hooks compose: register as many as you need, also on the same section - they run in
registration order. Registering a hook for a type that is not a config section fails at
startup with a clear error; registering one after the bootstrap has started throws.
3 changes: 2 additions & 1 deletion docs/tutorials/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@ The host starts, logs the service lifecycle, and waits until you press Ctrl+C.
## How it works

`SquidStdBootstrap` is the composition root: it builds the container, registers the configuration core, loads
the config sections, and orchestrates the `ISquidStdService` lifecycle. The core services come from the
the config sections, and orchestrates the `ISquidStdService` lifecycle. Need to inspect or tweak a loaded
value before services start? See [Inspecting and overriding loaded configuration](../articles/guides/configuration.md#inspecting-and-overriding-loaded-configuration). The core services come from the
explicit `RegisterCoreServices()` call in `ConfigureServices`, and every other SquidStd module plugs into the
same container through its `Add…` extension methods.

Expand Down
11 changes: 11 additions & 0 deletions src/SquidStd.Core/Interfaces/Bootstrap/ISquidStdBootstrap.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,17 @@ public interface ISquidStdBootstrap : IAsyncDisposable
/// <returns>The same bootstrap instance for chaining.</returns>
ISquidStdBootstrap ConfigureServices(Func<IContainer, IContainer> configure);

/// <summary>
/// Registers a callback invoked after the configuration is loaded (and re-applied on every
/// reload performed through the bootstrap) and before the logger and services consume it.
/// The callback receives the section singleton and may inspect or mutate it; changes are
/// in-memory only - the YAML file is not rewritten.
/// </summary>
/// <typeparam name="TConfig">The configuration section type.</typeparam>
/// <param name="configure">Callback that receives the loaded section.</param>
/// <returns>The same bootstrap for chaining.</returns>
ISquidStdBootstrap OnConfigLoaded<TConfig>(Action<TConfig> configure) where TConfig : class;

/// <summary>
/// Resolves a service from the owned dependency injection container.
/// </summary>
Expand Down
6 changes: 6 additions & 0 deletions src/SquidStd.Core/Interfaces/Config/IConfigManagerService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@ public interface IConfigManagerService
/// </summary>
IReadOnlyCollection<IConfigEntry> Entries { get; }

/// <summary>
/// Raised after every successful <see cref="Load" />, once the section instances are
/// registered into DI.
/// </summary>
event Action? ConfigLoaded;

/// <summary>
/// Composes the currently loaded sections into YAML.
/// </summary>
Expand Down
7 changes: 7 additions & 0 deletions src/SquidStd.Services.Core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,13 @@ bootstrap.ConfigureServices(c => c.RegisterCoreServices());
await bootstrap.StartAsync();
```

Need to inspect or override a loaded config section before services start? Register a typed hook
(in-memory only - the YAML file is untouched):

```csharp
bootstrap.OnConfigLoaded<SquidStdLoggerOptions>(o => o.MinimumLevel = LogLevelType.Debug);
```

### Command dispatch

```csharp
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,11 @@ namespace SquidStd.Services.Core.Services.Bootstrap;
/// </summary>
public sealed class SquidStdBootstrap : ISquidStdBootstrap
{
private readonly List<(Type ConfigType, Action<object> Apply)> _configHooks = [];
private readonly bool _ownsContainer;
private readonly List<ISquidStdService> _startedServices = [];
private readonly Lock _syncRoot = new();
private bool _configHooksSubscribed;
private int _disposed;
private bool _loggerConfigured;
private BootstrapStateType _state;
Expand Down Expand Up @@ -91,6 +93,25 @@ public ISquidStdBootstrap ConfigureServices(Func<IContainer, IContainer> configu
: this;
}

/// <inheritdoc />
public ISquidStdBootstrap OnConfigLoaded<TConfig>(Action<TConfig> configure) where TConfig : class
{
ArgumentNullException.ThrowIfNull(configure);
ThrowIfDisposed();

lock (_syncRoot)
{
if (_state != BootstrapStateType.Created)
{
throw new InvalidOperationException("Config hooks cannot be registered after bootstrap start.");
}
}

_configHooks.Add((typeof(TConfig), instance => configure((TConfig)instance)));

return this;
}

/// <inheritdoc />
public async ValueTask DisposeAsync()
{
Expand Down Expand Up @@ -135,7 +156,15 @@ public void ConfigureLogging()
return;
}

Container.Resolve<IConfigManagerService>().Load();
var configManager = Container.Resolve<IConfigManagerService>();

if (!_configHooksSubscribed)
{
configManager.ConfigLoaded += ApplyConfigHooks;
_configHooksSubscribed = true;
}

configManager.Load();
ConfigureLogger();
}

Expand Down Expand Up @@ -382,6 +411,33 @@ private void ConfigureLogger()
_loggerConfigured = true;
}

private void ApplyConfigHooks()
{
if (_configHooks.Count == 0)
{
return;
}

List<ConfigRegistrationData> sections = Container.IsRegistered<List<ConfigRegistrationData>>()
? Container.Resolve<List<ConfigRegistrationData>>()
: [];
var logger = Log.ForContext<SquidStdBootstrap>();

foreach (var (configType, apply) in _configHooks)
{
if (!sections.Any(section => section.ConfigType == configType))
{
throw new InvalidOperationException(
$"No config section registered for type '{configType.Name}'. "
+ "Register it with RegisterConfigSection before using OnConfigLoaded."
);
}

apply(Container.Resolve(configType));
logger.Debug("Applied config hook to {Section:l}", configType.Name);
}
}

private void LogRegistrations(ILogger logger, ServiceRegistrationData[] lifecycleRegistrations)
{
List<ConfigRegistrationData> sections = Container.IsRegistered<List<ConfigRegistrationData>>()
Expand Down
5 changes: 5 additions & 0 deletions src/SquidStd.Services.Core/Services/ConfigManagerService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ public sealed class ConfigManagerService : IConfigManagerService, ISquidStdServi
/// <inheritdoc />
public IReadOnlyCollection<IConfigEntry> Entries => GetEntries();

/// <inheritdoc />
public event Action? ConfigLoaded;

/// <summary>
/// Initializes the config manager service.
/// </summary>
Expand Down Expand Up @@ -80,6 +83,8 @@ public void Load()
{
Save();
}

ConfigLoaded?.Invoke();
}

/// <inheritdoc />
Expand Down
7 changes: 7 additions & 0 deletions tests/SquidStd.Tests/AspNetCore/FakeSquidStdBootstrap.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ public ISquidStdBootstrap ConfigureServices(Func<IContainer, IContainer> configu
: throw new InvalidOperationException("ConfigureServices must return the bootstrap container instance.");
}

public ISquidStdBootstrap OnConfigLoaded<TConfig>(Action<TConfig> configure) where TConfig : class
{
ArgumentNullException.ThrowIfNull(configure);

return this;
}

public ValueTask DisposeAsync()
{
Container.Dispose();
Expand Down
Loading
Loading