Skip to content

MigrationGuide

blehnen edited this page Nov 10, 2026 · 5 revisions

Migration Guide

This guide covers breaking changes from version 0.8.0 through 0.16.0, organized chronologically. Find your current version and read all sections that follow it.


0.8.0 -- SQL Client Change

The SQL Server transport switched from System.Data.SqlClient to Microsoft.Data.SqlClient.

Before:

<PackageReference Include="System.Data.SqlClient" Version="..." />

After:

<PackageReference Include="Microsoft.Data.SqlClient" Version="..." />

Connection strings are mostly compatible. There is one behavioral difference: Microsoft.Data.SqlClient defaults to Encrypt=True, but System.Data.SqlClient defaulted to Encrypt=False. If you connect to a SQL Server instance without a trusted certificate, add Encrypt=False;TrustServerCertificate=True; to your connection string or configure the server certificate appropriately.


0.9.1 -- Metrics Migration

App.Metrics was replaced with the built-in System.Diagnostics.Metrics API. The DotNetWorkQueue.AppMetrics NuGet package was removed.

Before:

// Add App.Metrics NuGet packages and configure:
var metrics = new MetricsBuilder().Build();
services.AddMetrics(metrics);

After:

// Use OpenTelemetry.Metrics (or any System.Diagnostics.Metrics exporter):
services.AddOpenTelemetry()
    .WithMetrics(builder => builder
        .AddMeter("DotNetWorkQueue")
        .AddPrometheusExporter());

Other API changes:

  • The SamplingTypes enum was removed. It was a parameter on IMetrics.Histogram() and IMetrics.Timer(). Remove any references to SamplingTypes from your code.
  • dynamic CollectedMetrics was replaced with the strongly typed MetricsSnapshot GetCollectedMetrics(). Update any code that called GetCollectedMetrics() to use the MetricsSnapshot type.

See Metrics and Monitoring for current usage.


0.9.13 -- Thread.Abort Removal

AbortWorkerThreadsWhenStopping was removed from IWorkerConfiguration. Worker shutdown now relies only on cooperative cancellation through CancellationToken.

Before:

configuration.Worker.AbortWorkerThreadsWhenStopping = true;

After:

// Remove the line above entirely.
// Control shutdown timing with:
configuration.Worker.TimeToWaitForWorkersToCancel = TimeSpan.FromSeconds(5);
configuration.Worker.TimeToWaitForWorkersToStop = TimeSpan.FromSeconds(10);

Workers that do not respond to cancellation within the configured windows will be abandoned rather than aborted. Make sure that your message handlers observe the CancellationToken passed to them.

Thread.Sleep in internal worker loops was replaced with ManualResetEventSlim for more responsive cancellation.

See WorkerConfiguration for all shutdown timing options.


0.9.19 -- .NET Framework Drop and Dynamic LINQ Removal

This release has two removals:

.NET Target Framework Changes

.NET Framework 4.8 and .NET Standard 2.0 targets were dropped. The library now targets .NET 8.0 and .NET 10.0 only. Update your project's <TargetFramework> (or <TargetFrameworks>) to net8.0 or net10.0.

All #if NETFULL and #if NETSTANDARD2_0 conditional compilation blocks were removed from the library source.

Dynamic LINQ Removal

String-based dynamic LINQ expressions (LinqExpressionToRun) were removed. This feature was only available on .NET Framework 4.8 through JpLabs.DynamicCode.

Before:

producer.Send(new LinqExpressionToRun(
    "(message, workerNotification) => new SimpleMessageMethod().Handle((SimpleMessage)message.Body, workerNotification)",
    new[] { "MyAssembly.dll" }
));

After:

// Use a compiled lambda with ActionMethodReceiver:
producer.Send<SimpleMessageMethod, SimpleMessage>(message);

See ProducerMethod for the current method-based producer API.


0.9.30 -- Schyntax to Cron Migration

The Schyntax schedule format was replaced with standard cron expressions through Cronos. You must update all schedule strings in the heartbeat configuration and the job scheduler.

Supported formats:

Format Example Granularity
5-field cron * * * * * Minute
6-field cron (with seconds) * * * * * * Second

Before:

// Schyntax format
jobScheduler.AddUpdateJob<SimpleMessage>("my-job", queue, "seconds(0/5)");

After:

// Cron format -- every 5 seconds (6-field)
jobScheduler.AddUpdateJob<SimpleMessage>("my-job", queue, "*/5 * * * * *");

// Or every minute (5-field)
jobScheduler.AddUpdateJob<SimpleMessage>("my-job", queue, "* * * * *");

IJobSchedule API Changes

  • The IJobSchedule.Previous() return type changed from DateTimeOffset to DateTimeOffset?. Update callers to handle null (null means the job has never run).

Before:

DateTimeOffset prev = schedule.Previous();

After:

DateTimeOffset? prev = schedule.Previous();
if (prev.HasValue) { /* use prev.Value */ }
  • IJobSchedule.Description is a new property that returns a human-readable description of the cron expression.

All heartbeat configuration strings that previously used Schyntax format must also be updated to cron format.

See Scheduler and JobScheduler for current scheduling documentation.


0.9.31 -- Multi-Source Dashboard Config

The Dashboard UI configuration changed from a flat DashboardApi:BaseUrl / DashboardApi:ApiKey format to a DashboardApi:Sources[] array. The old format throws an InvalidOperationException at startup with a migration example.

Before:

{
  "DashboardApi": {
    "BaseUrl": "http://localhost:5000",
    "ApiKey": "my-key"
  }
}

After:

{
  "DashboardApi": {
    "Sources": [
      {
        "Name": "Local",
        "BaseUrl": "http://localhost:5000",
        "ApiKey": "my-key"
      }
    ]
  }
}

Each source entry needs a Name and a BaseUrl. The ApiKey is optional. The UI uses Name as the display label and derives the URL slug from it. Add multiple entries to connect one UI deployment to separate Dashboard API instances.

URL Routing Change

All page URLs now include a /source/{slug} prefix (e.g., /source/local/connections/{id}). Single-source deployments redirect automatically from / to the source view.

Self-Contained / Docker Mode

In self-contained mode, the in-process API auto-registers as a "Local" source. You do not need to add a Sources entry for it. Remove the old DashboardApi:BaseUrl and DashboardApi:ApiKey keys after migration.

See Dashboard UI and Dashboard Docker Deployment for full configuration details.

0.9.41 -- Batch Send Is Whole-Batch Atomic

Send(List<...>) became a true bulk insert on SQL Server, SQLite and PostgreSQL. This also changed the failure semantics.

Before: one bad message failed on its own and the rest of the batch committed.

After: any failure rolls back every message in the batch, and each returned IQueueOutputMessage reports the failure.

If you rely on per-message isolation, call Send(message) in a loop instead of passing a collection.

Two other changes are worth knowing:

  • Scheduled-job messages are rejected on the batch path with a NotSupportedException. Send those individually. The "is this job already queued?" check is a check-then-act, and the batch path deliberately does not lock for it.
  • Send(List<...>) returns generated ids in caller input order, on every transport. Previously a ConcurrentBag/Parallel.ForEach discarded the order, so matching an id back to its message by position was unreliable.

A new BatchSize transport option on SQL Server, SQLite and PostgreSQL caps the chunk size. The value is clamped down to a safe maximum (0 uses that maximum). You do not normally need to set it.

0.9.42 -- Held-Transaction Batch Send Throws

This affects only the held-transaction path: Send(List<...>, DbTransaction) with EnableHoldTransactionUntilMessageCommitted, on SQL Server and PostgreSQL.

Before: a failure was swallowed into the per-message results.

After: a failure throws, so you can roll back the transaction you own.

That is the point of the change: on this path the caller owns commit and rollback, so the caller has to be told. The standalone batch path is unchanged. SQLite is excluded by design, because its single-writer model makes the held-transaction inbox pattern non-viable.

0.10.0 -- SQLite Connection Pooling

Connection pooling is now on by default for the SQLite transport. Unlike the SQL Server and PostgreSQL providers, System.Data.SQLite defaults pooling to off. Turning it on is most of why a single send went from ~8.5 ms to ~80 us.

⚠️ Breaking for callers that delete SQLite database files. Pooled connections stay open for the lifetime of the queue that created them. The database file handle stays open too, so File.Delete now fails where it previously succeeded.

What to do:

  • Dispose the producer or consumer before deleting its database, and delete the -wal and -shm files alongside the main one. SqLiteMessageQueueCreation.RemoveQueue() does both for you.
  • SQLiteConnection.ClearAllPools() is not sufficient on its own. It does not affect connections that a live queue holds open.
  • To opt out entirely, set Pooling=False in the connection string. An explicit Pooling=True or Pooling=False is always respected.

In-memory databases are never pooled. The hold connection already keeps them alive. With cache=shared, pooling one would keep a database alive past the point the caller disposed of it.

There are two additive API changes, and neither breaks existing code. IDbFactory gains CreateCommand(IDbConnection, string) as a default interface method. DbFactory now implements IDisposable, and disposing it is what closes the pooled connections.

0.11.0 -- Serializer Marker, LiteDb Batch, Dashboard Requeue Order

Messages now name the serializer that wrote them

Every message carries a Queue-SerializerId header, and consumers pick the matching serializer to read the body back. This header makes switching serializers possible at all. If you stay on the default Newtonsoft serializer, it needs nothing from you.

⚠️ If you switch serializers on a queue that already holds messages, set ISerializerResolver.Fallback to whatever wrote them. Messages written before this release carry no header and fall back to that resolver. If you point it at the wrong serializer, they cannot be read.

See Serialization for the full migration, including the order to deploy consumers and producers in.

LiteDb batch send is whole-batch atomic

⚠️ LiteDb now matches the other transports: a failure rolls back the whole batch, where previously one bad message failed alone and the rest committed.

If you need per-message isolation, call Send(message) in a loop. Send scheduled jobs individually, because the batch path rejects them. This is the same change SQL Server, SQLite and PostgreSQL took in 0.9.41.

LiteDb dashboard requeue ordering

⚠️ Where a dashboard requeue inserts a new meta record, the message now goes to the back of the queue rather than to the position its original enqueue time would have given it. The single-message requeue always inserts one. Requeue-all inserts one when no meta record survives for that message.

Where requeue-all instead updates an existing meta record, the record keeps its identifier and therefore its place. The original enqueue time is still stored and still shown in the dashboard.

API additions and a deprecation

These changes are additive, so existing code keeps compiling: ISerializer.SerializerId (a default interface method) and IStandardHeaders.SerializerId.

AddStandardMessageHeaders takes an additional ISerializer constructor argument. That is source-breaking only for code that constructs it directly rather than resolving it from the container.

The Guard overloads that take Expression<Func<T>> are deprecated ([Obsolete]), but they still work. Use the overloads that take the value alone. The compiler supplies the parameter name, and the reported ParamName is unchanged.

0.16.0 -- 3DES Removal

TripleDesMessageInterceptor and TripleDesMessageInterceptorConfiguration are gone. They were deprecated in 0.9.43, when AesMessageInterceptor (AES-256-GCM) replaced them.

⚠️ Nothing in 0.16.0 can read a 3DES-encrypted message. There is no fallback and no compatibility switch. Finish the migration before you upgrade.

Do the migration on 0.15.0 first

0.15.0 is the last release that ships both interceptors, and it exists partly for this: it carries 3DES alongside the returning netstandard2.0 target, so a .NET Framework application can move onto it, migrate off 3DES, and only then take 0.16.0.

Follow Migrating from 3DES to AES while still on 0.15.0. When no 3DES messages remain anywhere, including error queues and scheduled jobs that have not fired yet, upgrade.

Checking you are done

A 3DES message is one whose interceptor graph names TripleDesMessageInterceptor. If you cannot account for every queue, drain rather than guess: after upgrading, those messages cannot be read back, and the payload is not recoverable from the queue alone.

Dashboard

The Interceptors:TripleDes configuration section is gone with the interceptor.

A configuration that still carries one is refused at startup with an InvalidOperationException naming the setting. That is deliberate. The options binder ignores keys it does not recognise. Without the check, a dashboard with the section in place would start, but it silently could not decrypt anything. Every 3DES message would then fail to deserialize a long way from the cause.

Remove the section and use Interceptors:Aes.

Clone this wiki locally