Skip to content

Commit 348d334

Browse files
committed
fix(sqlite-outbox): address PR #151 review feedback
CodeRabbit review on PR #151 surfaced 6 actionable findings; all addressed. PRODUCTION (Major: sealed-cast coupling per CLAUDE.md lesson): - SendMessageCommandHandler.HandleExternalTransaction and the async fork no longer downcast relCommand.ExternalTransaction to System.Data.SQLite's sealed SQLiteTransaction/SQLiteConnection. Both handlers now operate on IDbTransaction/IDbConnection only — the producer's GuardSQLiteTransaction still enforces the concrete type at the API boundary. Removing the casts: * matches the PG/SS transport discipline (sealed-cast lesson, CLAUDE.md) * makes the outbox-fork path unit-testable with NSubstitute on the IDb* interfaces (the sealed types cannot be mocked at all) TESTS (Minor + Major fixes): - SqliteOutboxIntegrationTestBase: added EnsureActivityListenerRegistered helper + ClassInitialize hooks across all 5 outbox suites, so trace decorators are exercised (CLAUDE.md trace-decorator coverage lesson; mirrors PG/SS inbox base). - QueueScope/ProducerScope Dispose() now guard with Interlocked.Exchange for idempotent double-dispose safety. - SqliteOutboxValidationTests.Validation_GuardPass_HappyPath_SingleSend now asserts result.HasError == false in addition to row visibility. - SqliteOutboxRetryBypassTests.RetryBypass_TransientError_SingleAttempt uses Assert.ThrowsExactly<InvalidOperationException> instead of catch-all + null check — false-positive resistance. - Comment cleanup: "hold-tx" → "hold transaction" (matches no-Tx-abbreviation preference and CodeRabbit ask). LESSON CAPTURE: - CLAUDE.md: new SQLite FullUri/SQLiteConnectionStringBuilder symmetry lesson anchored to commit d111b0b — documents the asymmetric-builder bug shape for future SQLite work. 171/171 SQLite unit tests pass; both prod and IT projects build clean.
1 parent d111b0b commit 348d334

9 files changed

Lines changed: 100 additions & 51 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,7 @@ Projects target net10.0 and net8.0. Legacy conditional compilation symbols (NETF
179179
- **Plan code shapes can drift from the actual API surface.** Outbox PR #138 / REVIEW-1.2 caught a missing `using DotNetWorkQueue.Configuration;` in the `docs/outbox-pattern.md` tutorial code block — copy-paste would have failed CS0246 because `QueueConnection` lives in `DotNetWorkQueue.Configuration` not `DotNetWorkQueue` root. Pattern: reviewers for doc/code-example plans should TRACE the example against the current API surface (mentally compile it), not just verify it matches the plan's code shape.
180180
- **Phase scope reframing — RESEARCH should validate the phase isn't already done.** ROADMAP Phase 7 framed as "add XML doc comments to phases 2-4 public types." RESEARCH §1 surfaced that the builders had already added docs as they went. Phase 7 reframed to a VERIFICATION pass + csproj gate fixes (net8 `<DocumentationFile>` gap + ISSUE-032 NU1902 closure on Transport.SQLite). Pattern: when a phase title implies authoring, researcher should explicitly confirm the work was not already incidentally done before architect plans new authoring work.
181181
- **`<WarningsNotAsErrors>` pattern for accepted advisory carry-forward.** ISSUE-032 (OpenTelemetry NU1902 advisory) blocked the strictest `dotnet build -c Release -p:CI=true` build path on `Transport.SQLite`. Phase 7 PLAN-1.1 / commit `88ff8996` added `<WarningsNotAsErrors>NU1902</WarningsNotAsErrors>` to all 3 Release PropertyGroup blocks (`Release|net10.0`, `Release|net8.0`, `Release|AnyCPU`). The advisory still surfaces as a visible warning on every Release build (long-term remediation isn't forgotten) but the build is no longer blocked. Reusable pattern when a ship-blocking advisory is out of scope for the current milestone.
182+
- **`SQLiteConnectionStringBuilder.DataSource` is empty when `FullUri=` is used — both sides of any compare MUST go through the builder symmetrically.** Outbox milestone PR #151 shipped with an asymmetric validator (`Container` side via builder, connection side via `conn.DataSource`) that passed local on-disk integration but failed every shared-in-memory FullUri test on Jenkins with `'' != 'file:Iabc?mode=memory&cache=shared'`. Pre-fix: extractor read `conn.DataSource` (raw URI on opened FullUri connection); queue side read `Container` → `DbDataSource.DataSource` → `builder.DataSource` (empty). Fix (commit `d111b0b5`): added `SqLiteExternalDbNameExtractor.Canonicalize(string)` that parses via `SQLiteConnectionStringBuilder` and selects `FullUri ?? DataSource` before `Path.GetFileNameWithoutExtension`; validator now uses `Canonicalize(_connectionInfo.ConnectionString)` for `expected`. Pattern: any new "compare two SQLite database identities" code must route both sides through the same builder + selection, never one side via the builder and the other via the open connection's `DataSource` property. Same root shape as the SqlServer `ToUpperInvariant` symmetric-normalization lesson from PR #138.
182183
- **SQLite + hold-transaction inbox pattern: structurally incompatible.** Inbox milestone (PR #143, issue #149) attempted to add `IRelationalWorkerNotification` to the SQLite transport alongside SqlServer + PostgreSQL. Failed on Jenkins across 14 CI runs in two recurring shapes: (1) `Sync_Commit`/`Async_Commit` NRE on `relational.Transaction` despite cast succeeding, (2) `Sync_Rollback`/`Async_Rollback`/`BusinessRow_*Visibility` handler-never-invoked 30s timeouts. Root cause is NOT a code bug — SQLite uses `BEGIN EXCLUSIVE`-on-write semantics. Holding the dequeue transaction for the duration of the user handler blocks ALL other writers on the queue table, including the worker thread's own next dequeue attempt → deadlock. `EnableHoldTransactionUntilMessageCommitted` has always been a no-op on SQLite for this reason. **Inbox pattern is a permanent non-goal for SQLite; outbox is viable with a documented concurrency caveat.** Treat the existing options-property as obsolete on SqlLite (issue #149 tracks the cleanup). Don't re-attempt the four debugging directions tried in PR #143: lazy-options removal, property-injection, static `AsyncLocal<>`, factory-delegate wiring — none address the structural lock.
183184
- **Decorator-chain parity audit when porting features across transports.** When adding a capability (e.g., outbox `IRetrySkippable`) to a new transport, audit the FULL handler chain — producer + decorator(s) — not just the producer-level shape. SQLite outbox milestone (issue #149) Phase 1 added the producer queue + send-handler outbox branches + DI wiring, but missed updating `Source/DotNetWorkQueue.Transport.SQLite/Decorator/RetryCommandHandlerOutputDecorator{,Async}.cs` to honor `IRetrySkippable.SkipRetry`. PG and SqlServer have the guard at line 54/55; SQLite was silently retrying caller-managed outbox transactions through Polly. Integration tests caught it (Phase 3 PLAN-2.5 review); fix in commit `66f7e864`. The PG/SS-mirror discipline must extend through decorators, not stop at producers.
184185
- **`System.Data.SQLite` vs `Microsoft.Data.Sqlite` provider name distinction.** This transport is built on `System.Data.SQLite` (types `SQLiteConnection` / `SQLiteTransaction` — all-caps `SQLite`). `Microsoft.Data.Sqlite` uses `SqliteConnection` / `SqliteTransaction` (lowercase second letter onward). The two are NOT interchangeable. Docs/code identifiers must use the correct provider — copy-paste from Microsoft.Data.Sqlite docs/samples will compile against the wrong NuGet package and fail at runtime in `GuardSQLiteTransaction` (which uses fully-qualified `System.Data.SQLite.SQLiteTransaction`). Caught at Phase 4 docs review for `docs/outbox-pattern.md`; fix in commit `447250fc`.

Source/DotNetWorkQueue.Transport.SQLite.Integration.Tests/Outbox/SqliteOutboxAdditionalDataTests.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ namespace DotNetWorkQueue.Transport.SQLite.Integration.Tests.Outbox
4646
[TestClass]
4747
public class SqliteOutboxAdditionalDataTests : SqliteOutboxIntegrationTestBase
4848
{
49+
[ClassInitialize]
50+
public static void Init(TestContext _) => EnsureActivityListenerRegistered();
51+
4952
private const byte ExpectedPriority = 7;
5053

5154
/// <summary>

Source/DotNetWorkQueue.Transport.SQLite.Integration.Tests/Outbox/SqliteOutboxIntegrationTestBase.cs

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
using System;
2020
using System.Collections.Generic;
2121
using System.Data.SQLite;
22+
using System.Diagnostics;
23+
using System.Threading;
2224
using DotNetWorkQueue.Configuration;
2325
using DotNetWorkQueue.IntegrationTests.Shared;
2426
using DotNetWorkQueue.Messages;
@@ -53,6 +55,27 @@ namespace DotNetWorkQueue.Transport.SQLite.Integration.Tests.Outbox
5355
/// </remarks>
5456
public abstract class SqliteOutboxIntegrationTestBase
5557
{
58+
// ---- ActivityListener (mandatory per CLAUDE.md trace-decorator lesson) ----
59+
// Without an ActivityListener registered against the DotNetWorkQueue ActivitySource,
60+
// ActivitySource.StartActivity() returns null and trace decorators short-circuit
61+
// silently, leaving them at 0% coverage. Mirrors the PG/SS inbox base pattern.
62+
private static readonly object ListenerLock = new();
63+
private static ActivityListener _listener;
64+
65+
protected static void EnsureActivityListenerRegistered()
66+
{
67+
lock (ListenerLock)
68+
{
69+
if (_listener != null) return;
70+
_listener = new ActivityListener
71+
{
72+
ShouldListenTo = src => src.Name.StartsWith("DotNetWorkQueue", StringComparison.Ordinal),
73+
Sample = (ref ActivityCreationOptions<ActivityContext> _) => ActivitySamplingResult.AllData
74+
};
75+
ActivitySource.AddActivityListener(_listener);
76+
}
77+
}
78+
5679
/// <summary>
5780
/// Generates a fresh queue name that satisfies DNQ's alphanumeric/underscore/dot constraint
5881
/// (no hyphens — <see cref="Guid.ToString(string)"/> with "N" strips them).
@@ -76,8 +99,10 @@ protected sealed class QueueScope : IDisposable
7699
public SqLiteMessageQueueCreation OCreation { get; init; }
77100
public ICreationScope Scope { get; init; }
78101

102+
private int _disposed;
79103
public void Dispose()
80104
{
105+
if (Interlocked.Exchange(ref _disposed, 1) != 0) return;
81106
try { OCreation?.RemoveQueue(); } catch { /* swallow — best-effort cleanup */ }
82107
OCreation?.Dispose();
83108
Scope?.Dispose();
@@ -106,7 +131,7 @@ protected QueueScope CreateQueue(QueueConnection queueConnection, bool enablePri
106131
oCreation.Options.EnableMessageExpiration = false;
107132
// EnableHoldTransactionUntilMessageCommitted is not set here — it is an explicit
108133
// ITransportOptions implementation on SqLiteMessageQueueTransportOptions, hardwired
109-
// to false with a discarded setter (SQLite BEGIN EXCLUSIVE semantics make hold-tx
134+
// to false with a discarded setter (SQLite BEGIN EXCLUSIVE semantics make hold-transaction
110135
// structurally non-viable; see issue #149 and the class XML doc above).
111136
oCreation.Options.EnablePriority = enablePriority;
112137

@@ -132,8 +157,10 @@ protected sealed class ProducerScope : IDisposable
132157
public IProducerQueue<FakeMessage> Producer { get; init; }
133158
public IRelationalProducerQueue<FakeMessage> RelationalProducer { get; init; }
134159

160+
private int _disposed;
135161
public void Dispose()
136162
{
163+
if (Interlocked.Exchange(ref _disposed, 1) != 0) return;
137164
Producer?.Dispose();
138165
Creator?.Dispose();
139166
}

Source/DotNetWorkQueue.Transport.SQLite.Integration.Tests/Outbox/SqliteOutboxRetryBypassTests.cs

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ namespace DotNetWorkQueue.Transport.SQLite.Integration.Tests.Outbox
3737
[TestClass]
3838
public class SqliteOutboxRetryBypassTests : SqliteOutboxIntegrationTestBase
3939
{
40+
[ClassInitialize]
41+
public static void Init(TestContext _) => EnsureActivityListenerRegistered();
42+
4043
/// <summary>
4144
/// Verifies that sending with a completed (already-committed) transaction throws on the
4245
/// first attempt and does not engage the Polly retry chain.
@@ -64,16 +67,11 @@ public void RetryBypass_TransientError_SingleAttempt(bool inMemory)
6467
var completedTransaction = conn.BeginTransaction();
6568
completedTransaction.Commit();
6669

67-
Exception thrown = null;
6870
var stopwatch = Stopwatch.StartNew();
69-
try
70-
{
71-
producer.RelationalProducer.Send(new FakeMessage(), new AdditionalMessageData(), completedTransaction);
72-
}
73-
catch (Exception ex)
74-
{
75-
thrown = ex;
76-
}
71+
// The validator's #2 check (null Connection on completed transaction) throws
72+
// InvalidOperationException; SkipRetry=true short-circuits Polly before any retry.
73+
var thrown = Assert.ThrowsExactly<InvalidOperationException>(() =>
74+
producer.RelationalProducer.Send(new FakeMessage(), new AdditionalMessageData(), completedTransaction));
7775
stopwatch.Stop();
7876

7977
// WHY: Polly's default 3-attempt retry chain with exponential back-off adds seconds. A

Source/DotNetWorkQueue.Transport.SQLite.Integration.Tests/Outbox/SqliteOutboxSendAsyncTests.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ namespace DotNetWorkQueue.Transport.SQLite.Integration.Tests.Outbox
2828
[TestClass]
2929
public class SqliteOutboxSendAsyncTests : SqliteOutboxIntegrationTestBase
3030
{
31+
[ClassInitialize]
32+
public static void Init(TestContext _) => EnsureActivityListenerRegistered();
33+
3134
[TestMethod]
3235
[DataRow(true)]
3336
[DataRow(false)]

Source/DotNetWorkQueue.Transport.SQLite.Integration.Tests/Outbox/SqliteOutboxSendTests.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ namespace DotNetWorkQueue.Transport.SQLite.Integration.Tests.Outbox
2727
[TestClass]
2828
public class SqliteOutboxSendTests : SqliteOutboxIntegrationTestBase
2929
{
30+
[ClassInitialize]
31+
public static void Init(TestContext _) => EnsureActivityListenerRegistered();
32+
3033
[TestMethod]
3134
[DataRow(true)]
3235
[DataRow(false)]

Source/DotNetWorkQueue.Transport.SQLite.Integration.Tests/Outbox/SqliteOutboxValidationTests.cs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ namespace DotNetWorkQueue.Transport.SQLite.Integration.Tests.Outbox
3636
[TestClass]
3737
public class SqliteOutboxValidationTests : SqliteOutboxIntegrationTestBase
3838
{
39+
[ClassInitialize]
40+
public static void Init(TestContext _) => EnsureActivityListenerRegistered();
41+
3942
/// <summary>
4043
/// Guard: cross-database mismatch — transaction belongs to a DIFFERENT SQLite file/URI
4144
/// than the queue's database. Must throw before any INSERT lands in the queue table.
@@ -131,8 +134,9 @@ public void Validation_GuardPass_HappyPath_SingleSend(bool inMemory)
131134
conn.Open();
132135
using var transaction = conn.BeginTransaction();
133136

134-
// Assert no throw — happy path.
135-
producer.RelationalProducer.Send(new FakeMessage(), new AdditionalMessageData(), transaction);
137+
// Assert no throw AND no send error — happy path.
138+
var result = producer.RelationalProducer.Send(new FakeMessage(), new AdditionalMessageData(), transaction);
139+
Assert.IsFalse(result.HasError, result.SendingException?.ToString());
136140

137141
transaction.Commit();
138142

Source/DotNetWorkQueue.Transport.SQLite/Basic/CommandHandler/SendMessageCommandHandler.cs

Lines changed: 26 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -227,15 +227,20 @@ public long Handle(SendMessageCommand commandSend)
227227

228228
/// <summary>
229229
/// Caller-supplied-transaction fork of <see cref="Handle(SendMessageCommand)"/>. Reuses
230-
/// the caller's <see cref="SQLiteTransaction"/> and its <see cref="SQLiteConnection"/>
231-
/// for all queue INSERTs; never commits, rolls back, closes, or disposes the caller's
232-
/// resources. Invoked from <see cref="Handle"/> when
233-
/// <see cref="RelationalSendMessageCommand.ExternalTransaction"/> is non-null. The producer
234-
/// surface (<c>SqliteRelationalProducerQueue&lt;T&gt;</c>) guarantees the
235-
/// transaction is a <see cref="SQLiteTransaction"/> and its connection's database
236-
/// matches the queue's configured database via the validator at the API boundary,
237-
/// so this method performs no validation of its own.
230+
/// the caller's transaction and its connection for all queue INSERTs; never commits,
231+
/// rolls back, closes, or disposes the caller's resources. Invoked from
232+
/// <see cref="Handle"/> when <see cref="RelationalSendMessageCommand.ExternalTransaction"/>
233+
/// is non-null. The producer surface (<c>SqliteRelationalProducerQueue&lt;T&gt;</c>)
234+
/// guarantees the transaction is a <c>System.Data.SQLite.SQLiteTransaction</c> and its
235+
/// connection's database matches the queue's configured database via the validator at
236+
/// the API boundary, so this method performs no validation of its own.
238237
/// </summary>
238+
/// <remarks>
239+
/// The handler operates on <see cref="IDbTransaction"/> / <see cref="IDbConnection"/>
240+
/// abstractions only — no downcast to the sealed <c>System.Data.SQLite</c> provider
241+
/// types. Same discipline applied across the PG/SS transports (CLAUDE.md sealed-cast
242+
/// lesson) so the method remains unit-testable with NSubstitute on the interfaces.
243+
/// </remarks>
239244
/// <param name="commandSend">The send-message command carrying a non-null
240245
/// <see cref="RelationalSendMessageCommand.ExternalTransaction"/>.</param>
241246
/// <returns>The newly-inserted message ID.</returns>
@@ -247,11 +252,10 @@ private long HandleExternalTransaction(SendMessageCommand commandSend)
247252
// Cast guard: Handle() only routes here for RelationalSendMessageCommand
248253
// (the `commandSend is RelationalSendMessageCommand` pattern in the fork check),
249254
// so this cast is always safe. The producer subclass also enforces the
250-
// DbTransaction subtype via GuardSQLiteTransaction before construction.
255+
// SQLiteTransaction subtype via GuardSQLiteTransaction before construction.
251256
var relCommand = (RelationalSendMessageCommand)commandSend;
252-
// fully-qualified to avoid collision with DotNetWorkQueue.Transport.SQLite.Basic.SQLiteTransaction wrapper
253-
var sqliteTransaction = (System.Data.SQLite.SQLiteTransaction)relCommand.ExternalTransaction;
254-
var sqliteConn = (System.Data.SQLite.SQLiteConnection)sqliteTransaction.Connection;
257+
IDbTransaction transaction = relCommand.ExternalTransaction;
258+
IDbConnection connection = transaction.Connection;
255259

256260
var expiration = TimeSpan.Zero;
257261
if (_messageExpirationEnabled.Value)
@@ -270,20 +274,20 @@ private long HandleExternalTransaction(SendMessageCommand commandSend)
270274

271275
if (!(string.IsNullOrWhiteSpace(jobName) ||
272276
_jobExistsHandler.Handle(new DoesJobExistQuery<IDbConnection, IDbTransaction>(
273-
jobName, scheduledTime, sqliteConn, sqliteTransaction)) == QueueStatuses.NotQueued))
277+
jobName, scheduledTime, connection, transaction)) == QueueStatuses.NotQueued))
274278
{
275279
throw new DotNetWorkQueueException(
276280
"Failed to insert record - the job has already been queued or processed");
277281
}
278282

279283
long id;
280-
using (var command = SendMessage.GetMainCommand(commandSend, sqliteConn, _commandCache, _headers, _serializer))
284+
using (var command = SendMessage.GetMainCommand(commandSend, connection, _commandCache, _headers, _serializer))
281285
{
282-
command.Transaction = sqliteTransaction;
286+
command.Transaction = transaction;
283287
command.ExecuteNonQuery();
284-
using (var commandId = sqliteConn.CreateCommand())
288+
using (var commandId = connection.CreateCommand())
285289
{
286-
commandId.Transaction = sqliteTransaction;
290+
commandId.Transaction = transaction;
287291
commandId.CommandText = "SELECT last_insert_rowid();";
288292
id = Convert.ToInt64(commandId.ExecuteScalar());
289293
}
@@ -296,10 +300,10 @@ private long HandleExternalTransaction(SendMessageCommand commandSend)
296300
}
297301

298302
using (var commandMeta = SendMessage.CreateMetaDataRecord(commandSend.MessageData.GetDelay(), expiration,
299-
sqliteConn, commandSend.MessageToSend, commandSend.MessageData, _tableNameHelper, _headers,
303+
connection, commandSend.MessageToSend, commandSend.MessageData, _tableNameHelper, _headers,
300304
_options.Value, _getTime))
301305
{
302-
commandMeta.Transaction = sqliteTransaction;
306+
commandMeta.Transaction = transaction;
303307
var param = commandMeta.CreateParameter();
304308
param.ParameterName = "@QueueID";
305309
param.DbType = DbType.Int64;
@@ -310,9 +314,9 @@ private long HandleExternalTransaction(SendMessageCommand commandSend)
310314

311315
if (_options.Value.EnableStatusTable)
312316
{
313-
using (var commandStatus = CreateStatusRecord(sqliteConn, commandSend.MessageToSend, commandSend.MessageData))
317+
using (var commandStatus = CreateStatusRecord(connection, commandSend.MessageToSend, commandSend.MessageData))
314318
{
315-
commandStatus.Transaction = sqliteTransaction;
319+
commandStatus.Transaction = transaction;
316320
var param = commandStatus.CreateParameter();
317321
param.ParameterName = "@QueueID";
318322
param.DbType = DbType.Int64;
@@ -325,7 +329,7 @@ private long HandleExternalTransaction(SendMessageCommand commandSend)
325329
if (!string.IsNullOrWhiteSpace(jobName))
326330
{
327331
_sendJobStatus.Handle(new SetJobLastKnownEventCommand<IDbConnection, IDbTransaction>(
328-
jobName, eventTime, scheduledTime, sqliteConn, sqliteTransaction));
332+
jobName, eventTime, scheduledTime, connection, transaction));
329333
}
330334

331335
// Caller owns lifecycle: no Commit, Rollback, Close, or Dispose performed here.

0 commit comments

Comments
 (0)