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
12 changes: 12 additions & 0 deletions docs/postgresql/ado-net.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,18 @@ services.UsePostgreSqlAdoNetOutbox(options =>
});
```

## Worker Connections

`UseWorkerConnectionFactory(...)` is required when the application processes
the outbox, either through `ITinyOutboxProcessor` directly or through the
hosted worker.

The hosted worker validates that the factory is configured before polling. It
does not invoke the delegate or open a database connection during startup
validation. Missing configuration stops startup; failures creating or opening
a configured connection occur during processing and follow the worker's
operational retry behavior.

## Publishing Transaction Ownership

TinyEvents:
Expand Down
11 changes: 11 additions & 0 deletions docs/postgresql/ef-core.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,17 @@ The provider option controls SQL claiming and marking. The model builder extensi

The PostgreSQL mapping uses `text` for `EventType`, `Payload`, `ClaimedBy`, and `LastError`.

## Worker Startup Validation

The hosted worker validates that `TDbContext` uses the Npgsql EF Core provider
before polling. A missing or different EF Core provider stops startup because
the worker store executes PostgreSQL-specific claim and mark commands.

Validation reads EF Core provider metadata only. It does not open a database
connection, check credentials, inspect the schema, or run migrations. Connection
failures after startup remain operational iteration failures and follow the
worker's retry behavior.

## Worker Claiming

The PostgreSQL EF Core store opens the underlying relational connection when needed and executes PostgreSQL claim/mark statements.
Expand Down
12 changes: 12 additions & 0 deletions docs/sql-server/ado-net.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,18 @@ services.UseSqlServerAdoNetOutbox(options =>
});
```

## Worker Connections

`UseWorkerConnectionFactory(...)` is required when the application processes
the outbox, either through `ITinyOutboxProcessor` directly or through the
hosted worker.

The hosted worker validates that the factory is configured before polling. It
does not invoke the delegate or open a database connection during startup
validation. Missing configuration stops startup; failures creating or opening
a configured connection occur during processing and follow the worker's
operational retry behavior.

## Publishing Transaction Ownership

TinyEvents:
Expand Down
11 changes: 11 additions & 0 deletions docs/sql-server/ef-core.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,17 @@ The provider option controls SQL claiming and marking. The model builder extensi

The SQL Server mapping uses `NVARCHAR(512)` for `EventType`, `NVARCHAR(MAX)` for `Payload`, `NVARCHAR(256)` for `ClaimedBy`, and `NVARCHAR(MAX)` for `LastError`.

## Worker Startup Validation

The hosted worker validates that `TDbContext` uses the SQL Server EF Core
provider before polling. A missing or different EF Core provider stops startup
because the worker store executes SQL Server-specific claim and mark commands.

Validation reads EF Core provider metadata only. It does not open a database
connection, check credentials, inspect the schema, or run migrations. Connection
failures after startup remain operational iteration failures and follow the
worker's retry behavior.

## Worker Claiming

The SQL Server EF Core store opens the underlying relational connection when needed and executes SQL Server claim/mark statements.
Expand Down
18 changes: 18 additions & 0 deletions docs/workers.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,24 @@ On shutdown, TinyEvents does not scan and release claims. If processing does not

A hosted worker can remain running while processing iterations repeatedly fail, for example during a database outage. Treat worker logs and host-level health checks as part of production operations.

## Startup Validation

Before polling, the hosted worker resolves the processing graph once in a
temporary scope. This validates the processor dependencies and generated
dispatcher registrations without claiming or processing messages.

A startup validation failure escapes to the host. It is not logged, counted,
or retried as a processing-iteration failure because changing the application
configuration or registrations is required to recover.

After validation succeeds, operational iteration failures are logged and the
worker continues polling. Consumer failures, deserialization failures, unknown
event types stored in messages, and lease loss remain message-level outcomes
handled by the processor.

Database schema initialization is separate from runtime graph validation.
TinyEvents does not currently run schema migrations as part of worker startup.

## Runtime Logging

TinyEvents uses `Microsoft.Extensions.Logging`. The application owns log
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ public TinyPostgreSqlAdoNetWorkerConnectionFactory(
throw new ArgumentNullException(nameof(serviceProvider));
}

options.ValidateWorkerConfiguration();

this.options = options;
this.serviceProvider = serviceProvider;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,17 @@ internal ValueTask<DbConnection> CreateWorkerConnectionAsync(
throw new ArgumentNullException(nameof(serviceProvider));
}

ValidateWorkerConfiguration();

return workerConnectionFactory!(serviceProvider, cancellationToken);
}

internal void ValidateWorkerConfiguration()
{
if (workerConnectionFactory is null)
{
throw new InvalidOperationException(
"An ADO.NET worker connection factory is required. Configure UseWorkerConnectionFactory(...) for outbox claiming and marking operations.");
}

return workerConnectionFactory(serviceProvider, cancellationToken);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ namespace TinyEvents.PostgreSql.EntityFrameworkCore;
internal sealed class TinyPostgreSqlEfCoreOutboxStore<TDbContext> : ITinyOutboxStore
where TDbContext : DbContext
{
private const string PostgreSqlProviderName = "Npgsql.EntityFrameworkCore.PostgreSQL";

private readonly TDbContext dbContext;
private readonly TinyPostgreSqlEfCoreTableName tableName;

Expand All @@ -25,10 +27,24 @@ public TinyPostgreSqlEfCoreOutboxStore(
throw new ArgumentNullException(nameof(options));
}

ValidateDatabaseProvider(dbContext);

this.dbContext = dbContext;
tableName = TinyPostgreSqlEfCoreTableName.Parse(options.TableName);
}

private static void ValidateDatabaseProvider(TDbContext dbContext)
{
if (!string.Equals(
dbContext.Database.ProviderName,
PostgreSqlProviderName,
StringComparison.Ordinal))
{
throw new InvalidOperationException(
"The TinyEvents PostgreSQL EF Core provider requires the Npgsql EF Core provider. Configure the DbContext with UseNpgsql(...).");
}
}

public async ValueTask<IReadOnlyList<TinyOutboxMessage>> ClaimPendingAsync(
int maxCount,
string workerId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ public TinySqlServerAdoNetWorkerConnectionFactory(
throw new ArgumentNullException(nameof(serviceProvider));
}

options.ValidateWorkerConfiguration();

this.options = options;
this.serviceProvider = serviceProvider;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,17 @@ internal ValueTask<DbConnection> CreateWorkerConnectionAsync(
throw new ArgumentNullException(nameof(serviceProvider));
}

ValidateWorkerConfiguration();

return workerConnectionFactory!(serviceProvider, cancellationToken);
}

internal void ValidateWorkerConfiguration()
{
if (workerConnectionFactory is null)
{
throw new InvalidOperationException(
"An ADO.NET worker connection factory is required. Configure UseWorkerConnectionFactory(...) for outbox claiming and marking operations.");
}

return workerConnectionFactory(serviceProvider, cancellationToken);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ namespace TinyEvents.SqlServer.EntityFrameworkCore;
internal sealed class TinySqlServerEfCoreOutboxStore<TDbContext> : ITinyOutboxStore
where TDbContext : DbContext
{
private const string SqlServerProviderName = "Microsoft.EntityFrameworkCore.SqlServer";

private readonly TDbContext dbContext;
private readonly TinySqlServerEfCoreTableName tableName;

Expand All @@ -25,10 +27,24 @@ public TinySqlServerEfCoreOutboxStore(
throw new ArgumentNullException(nameof(options));
}

ValidateDatabaseProvider(dbContext);

this.dbContext = dbContext;
tableName = TinySqlServerEfCoreTableName.Parse(options.TableName);
}

private static void ValidateDatabaseProvider(TDbContext dbContext)
{
if (!string.Equals(
dbContext.Database.ProviderName,
SqlServerProviderName,
StringComparison.Ordinal))
{
throw new InvalidOperationException(
"The TinyEvents SQL Server EF Core provider requires the SQL Server EF Core provider. Configure the DbContext with UseSqlServer(...).");
}
}

public async ValueTask<IReadOnlyList<TinyOutboxMessage>> ClaimPendingAsync(
int maxCount,
string workerId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,32 @@ public static IServiceCollection AddTinyEventsWorker(
throw new ArgumentNullException(nameof(services));
}

var workerOptions = CreateOptions(configure);
services.TryAddSingleton(workerOptions);
var workerOptions = ConfigureOptions(services, configure);
services.TryAddEnumerable(ServiceDescriptor.Singleton<IHostedService, TinyEventsBackgroundService>());
services.ConfigureTinyEventsForWorker(workerOptions);

return services;
}

private static TinyEventsWorkerOptions ConfigureOptions(
IServiceCollection services,
Action<TinyEventsWorkerOptions>? configure)
{
var existingOptions = services
.LastOrDefault(descriptor => descriptor.ServiceType == typeof(TinyEventsWorkerOptions))
?.ImplementationInstance as TinyEventsWorkerOptions;

if (existingOptions is not null)
{
configure?.Invoke(existingOptions);
return existingOptions;
}

var options = CreateOptions(configure);
services.TryAddSingleton(options);
return options;
}

private static TinyEventsWorkerOptions CreateOptions(Action<TinyEventsWorkerOptions>? configure)
{
var options = new TinyEventsWorkerOptions();
Expand Down
3 changes: 3 additions & 0 deletions src/TinyEvents.Worker/Properties/AssemblyInfo.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;

[assembly: InternalsVisibleTo("TinyEvents.Worker.Tests")]
21 changes: 21 additions & 0 deletions src/TinyEvents.Worker/Startup/TinyEventsWorkerStartupValidator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using Microsoft.Extensions.DependencyInjection;

namespace TinyEvents.Worker;

internal sealed class TinyEventsWorkerStartupValidator
{
private readonly IServiceScopeFactory scopeFactory;

public TinyEventsWorkerStartupValidator(IServiceScopeFactory scopeFactory)
{
this.scopeFactory = scopeFactory
?? throw new ArgumentNullException(nameof(scopeFactory));
}

public void ValidateConfiguration()
{
using var scope = scopeFactory.CreateScope();

_ = scope.ServiceProvider.GetRequiredService<ITinyOutboxProcessor>();
}
}
6 changes: 6 additions & 0 deletions src/TinyEvents.Worker/TinyEventsBackgroundService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ namespace TinyEvents.Worker;
public sealed class TinyEventsBackgroundService : BackgroundService
{
private readonly IServiceScopeFactory scopeFactory;
private readonly TinyEventsWorkerStartupValidator startupValidator;
private readonly TinyEventsWorkerOptions options;
private readonly ILogger<TinyEventsBackgroundService> logger;

Expand Down Expand Up @@ -39,6 +40,7 @@ public TinyEventsBackgroundService(
}

this.scopeFactory = scopeFactory;
startupValidator = new TinyEventsWorkerStartupValidator(scopeFactory);
this.options = options;
this.logger = logger;
}
Expand All @@ -53,6 +55,10 @@ public async ValueTask ProcessOnceAsync(CancellationToken cancellationToken = de

protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
stoppingToken.ThrowIfCancellationRequested();

startupValidator.ValidateConfiguration();

var failures = new TinyEventsWorkerFailureTracker();

while (!stoppingToken.IsCancellationRequested)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,12 +90,10 @@ public async Task Worker_connection_factory_does_not_open_already_open_connectio
}

[Fact]
public async Task Worker_connection_factory_fails_clearly_when_delegate_is_missing()
public void Worker_connection_factory_rejects_missing_delegate()
{
var factory = NewFactory(new TinyEventsPostgreSqlAdoNetOptions());

var exception = await Assert.ThrowsAsync<InvalidOperationException>(
async () => await factory.CreateOpenConnectionAsync(CancellationToken.None));
var exception = Assert.Throws<InvalidOperationException>(
() => NewFactory(new TinyEventsPostgreSqlAdoNetOptions()));

Assert.Contains("Configure UseWorkerConnectionFactory(...)", exception.Message);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,41 @@ public void Use_postgre_sql_ado_net_outbox_registers_provider_services()
provider.GetRequiredService<ITinyPostgreSqlAdoNetWorkerConnectionFactory>());
}

[Fact]
public void Resolving_processor_rejects_missing_worker_connection_factory()
{
var services = new ServiceCollection();
services.UsePostgreSqlAdoNetOutbox(_ => { });
using var provider = services.BuildServiceProvider();
using var scope = provider.CreateScope();

var exception = Assert.Throws<InvalidOperationException>(
() => scope.ServiceProvider.GetRequiredService<ITinyOutboxProcessor>());

Assert.Contains("Configure UseWorkerConnectionFactory(...)", exception.Message);
}

[Fact]
public void Resolving_processor_does_not_invoke_worker_connection_factory()
{
var factoryCalls = 0;
var services = new ServiceCollection();
services.UsePostgreSqlAdoNetOutbox(options =>
{
options.UseWorkerConnectionFactory((_, _) =>
{
factoryCalls++;
return new ValueTask<DbConnection>(new RecordingConnection());
});
});
using var provider = services.BuildServiceProvider();
using var scope = provider.CreateScope();

_ = scope.ServiceProvider.GetRequiredService<ITinyOutboxProcessor>();

Assert.Equal(0, factoryCalls);
}

[Fact]
public void Use_postgre_sql_ado_net_outbox_does_not_register_unit_of_work()
{
Expand Down
Loading
Loading