MintPlayer.Spark.SubscriptionWorker
10.0.0-preview.64
dotnet add package MintPlayer.Spark.SubscriptionWorker --version 10.0.0-preview.64
NuGet\Install-Package MintPlayer.Spark.SubscriptionWorker -Version 10.0.0-preview.64
<PackageReference Include="MintPlayer.Spark.SubscriptionWorker" Version="10.0.0-preview.64" />
<PackageVersion Include="MintPlayer.Spark.SubscriptionWorker" Version="10.0.0-preview.64" />
<PackageReference Include="MintPlayer.Spark.SubscriptionWorker" />
paket add MintPlayer.Spark.SubscriptionWorker --version 10.0.0-preview.64
#r "nuget: MintPlayer.Spark.SubscriptionWorker, 10.0.0-preview.64"
#:package MintPlayer.Spark.SubscriptionWorker@10.0.0-preview.64
#addin nuget:?package=MintPlayer.Spark.SubscriptionWorker&version=10.0.0-preview.64&prerelease
#tool nuget:?package=MintPlayer.Spark.SubscriptionWorker&version=10.0.0-preview.64&prerelease
MintPlayer.Spark.SubscriptionWorker
A RavenDB subscription worker framework with built-in retry logic, incremental backoff, categorized exception handling, and ASP.NET Core lifecycle management. Fully independent -- no dependency on the core Spark CRUD framework. Any project with a RavenDB IDocumentStore can use it.
Installation
dotnet add package MintPlayer.Spark.SubscriptionWorker
If you also use the Spark source generators (for auto-registration), ensure the MintPlayer.Spark.SourceGenerators package is referenced.
Overview
A subscription worker continuously listens for document changes in RavenDB via the Data Subscriptions mechanism. When documents match the subscription's RQL query, RavenDB delivers them in batches to the worker for processing.
SparkSubscriptionWorker<T> wraps this into an ASP.NET Core BackgroundService with:
- Automatic subscription creation/update on startup
- A connection loop that reconnects after errors or normal completion
- Categorized exception handling (retryable vs. fatal)
- Per-document retry tracking via
RetryNumerator - Lifecycle hooks for startup, shutdown, and batch completion
Quick Start
1. Create a Subscription Worker
Extend SparkSubscriptionWorker<T> and implement two abstract methods:
ConfigureSubscription()-- returns the RQL query that filters which documents are deliveredProcessBatchAsync()-- handles each batch of documents
using MintPlayer.Spark.SubscriptionWorker;
using Raven.Client.Documents;
using Raven.Client.Documents.Subscriptions;
public partial class OrderProcessingWorker : SparkSubscriptionWorker<Order>
{
private readonly RetryNumerator _retryNumerator = new();
// No constructor: the base class takes its IDocumentStore and ILoggerFactory through
// [Inject], and the source generator emits the constructor. Declare your own dependencies
// the same way — which is why the class must be `partial`.
[Inject] private readonly IOrderService _orders;
protected override SubscriptionCreationOptions ConfigureSubscription()
=> new() { Query = "from Orders where Status = 'Pending'" };
protected override async Task ProcessBatchAsync(
SubscriptionBatch<Order> batch, CancellationToken cancellationToken)
{
using var session = batch.OpenAsyncSession();
foreach (var item in batch.Items)
{
try
{
var order = item.Result;
// Process the order...
order.Status = "Processed";
await _retryNumerator.ClearRetryAsync(session, order);
}
catch (Exception ex)
{
var retry = await _retryNumerator.TrackRetryAsync(
session, item.Result, ex, Logger);
if (!retry.WillRetry)
{
Logger.LogError(ex, "Permanently failed processing order {Id} after {Attempts} attempts",
item.Id, retry.AttemptCount);
}
}
}
await session.SaveChangesAsync(cancellationToken);
}
}
Subscription Naming
By default, the subscription name in RavenDB is derived from the class name by stripping common suffixes:
OrderProcessingWorkerbecomes"OrderProcessing"OrderProcessingSubscriptionWorkerbecomes"OrderProcessing"
Override SubscriptionName to set a custom name:
protected override string SubscriptionName => "MyCustomSubscription";
2. Register the Worker
Option A: Source-Generated Registration (Recommended)
If your project references MintPlayer.Spark.SourceGenerators, a source generator discovers all SparkSubscriptionWorker<T> subclasses in your project and generates an AddSubscriptionWorkers() extension on ISparkBuilder:
// Program.cs
builder.Services.AddSparkSubscriptions();
builder.Services.AddSpark(builder.Configuration, spark =>
{
spark.AddSubscriptionWorkers(); // source-generated
});
The generated code calls AddSubscriptionWorker<T>() for each worker class found, registering each as a hosted service. It is emitted by SubscriptionWorkerRegistrationGenerator into your own root namespace, so it is only visible from your assembly.
Option B: Manual Registration
Register workers individually:
builder.Services.AddSparkSubscriptions();
builder.Services.AddSubscriptionWorker<OrderProcessingWorker>();
How It Works
Subscription Lifecycle
Each worker runs as a BackgroundService:
- Startup:
EnsureSubscriptionExistsAsynccreates or updates the RavenDB subscription (idempotent -- if it already exists, the query is updated). OnWorkerStartedAsync(): Lifecycle hook called after the subscription is ready, before the first batch.- Connection loop: Opens a subscription worker connection and starts receiving document batches.
- Batch processing: Calls
ProcessBatchAsync()for each batch, thenOnBatchCompletedAsync(itemCount). - Error recovery: Catches and categorizes exceptions with automatic reconnection (see table below).
- Shutdown: Triggered by
CancellationTokencancellation (e.g., app shutdown). CallsOnWorkerStoppedAsync().
Categorized Exception Handling
The connection loop classifies exceptions into three categories.
Retryable Errors
These errors cause the worker to wait and then reconnect:
| Exception | Wait Time | Description |
|---|---|---|
SubscriptionInUseException |
RetryDelay * 2 |
Another node holds the subscription |
SubscriberErrorException |
RetryDelay |
Error in the subscriber callback |
Other unexpected exceptions (when KeepRunning = true) |
RetryDelay |
Transient errors |
Non-Recoverable Errors
These errors cause the worker to stop permanently and call OnNonRecoverableErrorAsync():
| Exception | Description |
|---|---|
SubscriptionClosedException |
The subscription was deleted or disabled |
DatabaseDoesNotExistException |
The target database does not exist |
SubscriptionDoesNotExistException |
The subscription was removed |
SubscriptionInvalidStateException |
The subscription is in an invalid state |
AuthorizationException |
Authentication/authorization failure |
Other unexpected exceptions (when KeepRunning = false) |
Any error when auto-reconnect is disabled |
Cancellation
OperationCanceledException when the CancellationToken is cancelled triggers a graceful shutdown.
Configuration
Override virtual properties on your worker class to tune behavior:
public class OrderProcessingWorker : SparkSubscriptionWorker<Order>
{
// Subscription name in RavenDB (default: class name minus "Worker"/"SubscriptionWorker")
protected override string SubscriptionName => "MyCustomSubscription";
// Target database (default: null = store default)
protected override string? Database => null;
// Max documents per batch (default: 256)
protected override int MaxDocsPerBatch => 100;
// Whether to reconnect after normal subscription completion (default: true)
protected override bool KeepRunning => true;
// Wait time before connection retry (default: 30 seconds)
protected override TimeSpan RetryDelay => TimeSpan.FromSeconds(30);
// Max erroneous period before giving up on connection (default: 5 minutes)
protected override TimeSpan MaxDownTime => TimeSpan.FromMinutes(5);
}
Global Options
AddSparkSubscriptions() accepts an optional configuration callback, but
SparkSubscriptionOptions currently carries no options:
builder.Services.AddSparkSubscriptions();
Earlier versions documented
WaitForNonStaleIndexesandNonStaleIndexTimeouthere. Nothing ever read them — the workers never waited on indexes — so they were removed rather than left looking like a configured guarantee. If a worker depends on an index being current, wait for the specific query it needs rather than gating startup on every index in the database.
Lifecycle Hooks
Override these virtual methods to react to worker events:
// Called after startup, before the first batch
protected override Task OnWorkerStartedAsync() => Task.CompletedTask;
// Called when the worker stops (graceful or error)
protected override Task OnWorkerStoppedAsync() => Task.CompletedTask;
// Called after each batch is successfully processed
protected override Task OnBatchCompletedAsync(int itemCount) => Task.CompletedTask;
// Called when a non-recoverable error occurs (before stopping)
protected override Task OnNonRecoverableErrorAsync(Exception exception) => Task.CompletedTask;
RetryNumerator: Per-Document Retry Tracking
RetryNumerator tracks failed processing attempts for individual documents using RavenDB counters and the @refresh metadata mechanism.
How It Works
- When
TrackRetryAsync()is called for a failed document, it increments a RavenDB counter on the document. - It sets the
@refreshmetadata to a future timestamp, which causes RavenDB to redeliver the document to the subscription at that time. - If the maximum number of attempts is exhausted, the counter is cleared and the document is "parked" for a longer delay (default: 1 day).
Configuration
var retryNumerator = new RetryNumerator
{
MaxAttempts = 5, // default: 5
BaseDelay = TimeSpan.FromSeconds(30), // default: 30s
CounterName = "SparkRetryAttempts", // default
ExhaustedDelay = TimeSpan.FromDays(1), // default: 1 day
};
Backoff Schedule
RetryNumerator uses linear incremental backoff (BaseDelay * attempt):
| Attempt | Delay |
|---|---|
| 1 | 30 seconds |
| 2 | 60 seconds |
| 3 | 90 seconds |
| 4 | 120 seconds |
| 5 | 150 seconds |
| Exhausted | 1 day (parked) |
When an attempt fails, TrackRetryAsync increments the counter and sets @refresh metadata to schedule redelivery. After max attempts, the counter is deleted and the document is parked for ExhaustedDelay.
Usage in ProcessBatchAsync
protected override async Task ProcessBatchAsync(
SubscriptionBatch<Order> batch, CancellationToken cancellationToken)
{
using var session = batch.OpenAsyncSession();
foreach (var item in batch.Items)
{
try
{
// Process the document...
await _retryNumerator.ClearRetryAsync(session, item.Result);
}
catch (Exception ex)
{
var retry = await _retryNumerator.TrackRetryAsync(
session, item.Result, ex, Logger);
// retry.WillRetry is false when max attempts are exhausted;
// retry.AttemptCount and retry.NextAttemptAtUtc describe this failure.
}
}
await session.SaveChangesAsync(cancellationToken);
}
Call ClearRetryAsync() after successful processing to remove any leftover retry counters from previous failures.
Revision Subscriptions
For change detection (comparing previous vs. current document state), subscribe to Revision<T>:
public partial class CompanyChangeWorker : SparkSubscriptionWorker<Revision<Company>>
{
protected override SubscriptionCreationOptions ConfigureSubscription()
=> new() { Query = "from Companies (Revisions = true)" };
protected override async Task ProcessBatchAsync(
SubscriptionBatch<Revision<Company>> batch, CancellationToken cancellationToken)
{
using var session = batch.OpenAsyncSession();
foreach (var item in batch.Items)
{
var previous = item.Result.Previous;
var current = item.Result.Current;
// React to changes between previous and current...
}
await session.SaveChangesAsync(cancellationToken);
}
}
This requires RavenDB document revisions to be enabled on the collection.
Real-World Example: Spark Messaging
The MintPlayer.Spark.Messaging package uses SparkSubscriptionWorker<T> internally for its message processing pipeline. MessageSubscriptionWorker subscribes to SparkMessage documents filtered by queue name and status:
internal sealed class MessageSubscriptionWorker : SparkSubscriptionWorker<SparkMessage>
{
protected override string SubscriptionName => $"SparkMessaging-{_queueName}";
protected override int MaxDocsPerBatch => 1;
protected override SubscriptionCreationOptions ConfigureSubscription()
{
return new SubscriptionCreationOptions
{
Query = $@"from SparkMessages
where QueueName = '{_queueName}'
and Status = 'Pending'
and (NextAttemptAtUtc = null or NextAttemptAtUtc <= now())"
};
}
protected override async Task ProcessBatchAsync(
SubscriptionBatch<SparkMessage> batch, CancellationToken cancellationToken)
{
foreach (var item in batch.Items)
{
var message = item.Result;
var session = batch.OpenAsyncSession();
// Mark as Processing, deserialize payload, resolve handlers,
// handle retries, dead-lettering, and expiration...
}
}
}
This demonstrates a pattern where the subscription query does server-side filtering (only pending messages past their retry delay), and the worker handles retries, dead-lettering, and state transitions within ProcessBatchAsync.
Extension Methods
| Method | Description |
|---|---|
AddSparkSubscriptions(Action<SparkSubscriptionOptions>?) |
Register subscription infrastructure |
AddSubscriptionWorker<TWorker>() |
Register a single worker as a hosted service |
Source-Generated
| Method | Description |
|---|---|
spark.AddSubscriptionWorkers() |
Auto-registers all SparkSubscriptionWorker<T> subclasses in your project |
Source Generator Details
The SubscriptionWorkerRegistrationGenerator source generator scans your project for all non-abstract classes that inherit from SparkSubscriptionWorker<T> (at any depth in the inheritance chain). It generates a static extension method:
// Auto-generated: SparkSubscriptionWorkerRegistrations.g.cs
namespace YourProject
{
internal static class SparkSubscriptionWorkersBuilderExtensions
{
internal static ISparkBuilder AddSubscriptionWorkers(this ISparkBuilder builder)
{
SparkSubscriptionExtensions.AddSubscriptionWorker<OrderProcessingWorker>(builder.Services);
SparkSubscriptionExtensions.AddSubscriptionWorker<CompanyChangeWorker>(builder.Services);
return builder;
}
}
}
This eliminates the need to manually register each worker in Program.cs.
Requirements
- .NET 10.0+
- RavenDB 6.2+
- An
IDocumentStoreregistered in the DI container (provided byAddSpark()or registered manually)
Complete Example
See the following files for working implementations:
SparkSubscriptionWorker.cs-- abstract base class with connection loop and error handlingRetryNumerator.cs-- per-document retry trackingSparkSubscriptionExtensions.cs-- DI registration helpers../MintPlayer.Spark.Messaging/Services/MessageSubscriptionWorker.cs-- real-world usage in the messaging package../MintPlayer.Spark.SourceGenerators/Generators/SubscriptionWorkerRegistrationGenerator.cs-- source generator for auto-registration
License
MIT License
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- MintPlayer.Spark.SubscriptionWorker.Abstractions (>= 10.0.0-preview.64)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 10.0.0-preview.64 | 47 | 8/24/2026 |
| 10.0.0-preview.63 | 43 | 8/23/2026 |
| 10.0.0-preview.62 | 51 | 8/23/2026 |
| 10.0.0-preview.60 | 57 | 8/21/2026 |
| 10.0.0-preview.59 | 47 | 8/21/2026 |
| 10.0.0-preview.58 | 52 | 8/21/2026 |
| 10.0.0-preview.57 | 44 | 8/20/2026 |
| 10.0.0-preview.56 | 43 | 8/20/2026 |
| 10.0.0-preview.55 | 54 | 8/19/2026 |
| 10.0.0-preview.54 | 48 | 8/19/2026 |
| 10.0.0-preview.53 | 48 | 8/18/2026 |
| 10.0.0-preview.52 | 73 | 8/18/2026 |
| 10.0.0-preview.51 | 56 | 8/16/2026 |
| 10.0.0-preview.49 | 55 | 8/16/2026 |
| 10.0.0-preview.48 | 47 | 8/16/2026 |
| 10.0.0-preview.47 | 53 | 8/16/2026 |
| 10.0.0-preview.46 | 55 | 8/15/2026 |
| 10.0.0-preview.45 | 56 | 8/15/2026 |
| 10.0.0-preview.44 | 56 | 8/14/2026 |
| 10.0.0-preview.43 | 68 | 8/14/2026 |