Skip to content

Commit 5c7731b

Browse files
authored
Merge pull request #143 from blehnen/phase-2-inbox-foundation
Inbox pattern support for relational transports
2 parents 1262cfe + c3ee642 commit 5c7731b

26 files changed

Lines changed: 2587 additions & 0 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+
- **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.
182183

183184
## Code Quality
184185
- Prefer correct, complete implementations over minimal ones.

Source/DotNetWorkQueue.Transport.LiteDb.Tests/Basic/LiteDbProducerDoesNotImplementRelationalTests.cs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,5 +64,22 @@ public void LiteDb_ProducerQueue_DoesNotImplement_IRelationalProducerQueue()
6464
$"'{transportAssembly.GetName().Name}' must NOT contain any type " +
6565
"implementing IRelationalProducerQueue<T>.");
6666
}
67+
68+
[TestMethod]
69+
public void LiteDb_WorkerNotification_DoesNotImplement_IRelationalWorkerNotification()
70+
{
71+
// Phase 6 negative-path coverage for the inbox capability-cast pattern.
72+
Assert.IsFalse(
73+
typeof(IRelationalWorkerNotification).IsAssignableFrom(typeof(WorkerNotification)),
74+
"LiteDb transport invariant violated: core WorkerNotification must NOT implement " +
75+
"IRelationalWorkerNotification (PROJECT.md §Success Criteria #3).");
76+
77+
var transportAssembly = typeof(LiteDbMessageQueueInit).Assembly;
78+
var anyImplementsRelational = transportAssembly.GetTypes()
79+
.Any(t => typeof(IRelationalWorkerNotification).IsAssignableFrom(t));
80+
Assert.IsFalse(anyImplementsRelational,
81+
$"LiteDb transport assembly '{transportAssembly.GetName().Name}' must NOT " +
82+
"contain any type implementing IRelationalWorkerNotification.");
83+
}
6784
}
6885
}

Source/DotNetWorkQueue.Transport.Memory.Tests/Basic/MemoryProducerDoesNotImplementRelationalTests.cs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,5 +64,27 @@ public void Memory_ProducerQueue_DoesNotImplement_IRelationalProducerQueue()
6464
$"'{transportAssembly.GetName().Name}' must NOT contain any type " +
6565
"implementing IRelationalProducerQueue<T>.");
6666
}
67+
68+
[TestMethod]
69+
public void Memory_WorkerNotification_DoesNotImplement_IRelationalWorkerNotification()
70+
{
71+
// Phase 6 negative-path coverage for the inbox capability-cast pattern: the Memory
72+
// transport's IWorkerNotification resolves to the core WorkerNotification, which
73+
// must NOT implement IRelationalWorkerNotification (PROJECT.md §Success Criteria #3).
74+
Assert.IsFalse(
75+
typeof(IRelationalWorkerNotification).IsAssignableFrom(typeof(WorkerNotification)),
76+
"Memory transport invariant violated: core WorkerNotification must NOT implement " +
77+
"IRelationalWorkerNotification. The inbox capability cast is intended to cleanly " +
78+
"fail on non-relational transports.");
79+
80+
// Assembly scan: no type in the Memory transport assembly should implement
81+
// IRelationalWorkerNotification.
82+
var transportAssembly = typeof(MemoryDashboardInit).Assembly;
83+
var anyImplementsRelational = transportAssembly.GetTypes()
84+
.Any(t => typeof(IRelationalWorkerNotification).IsAssignableFrom(t));
85+
Assert.IsFalse(anyImplementsRelational,
86+
$"Memory transport assembly '{transportAssembly.GetName().Name}' must NOT " +
87+
"contain any type implementing IRelationalWorkerNotification.");
88+
}
6789
}
6890
}
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
// ---------------------------------------------------------------------
2+
//This file is part of DotNetWorkQueue
3+
//Copyright © 2015-2026 Brian Lehnen
4+
//
5+
//This library is free software; you can redistribute it and/or
6+
//modify it under the terms of the GNU Lesser General Public
7+
//License as published by the Free Software Foundation; either
8+
//version 2.1 of the License, or (at your option) any later version.
9+
//
10+
//This library is distributed in the hope that it will be useful,
11+
//but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
//MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
13+
//Lesser General Public License for more details.
14+
//
15+
//You should have received a copy of the GNU Lesser General Public
16+
//License along with this library; if not, write to the Free Software
17+
//Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
18+
// ---------------------------------------------------------------------
19+
using System;
20+
using System.Threading;
21+
using System.Threading.Tasks;
22+
using DotNetWorkQueue.Configuration;
23+
using DotNetWorkQueue.IntegrationTests.Shared;
24+
using DotNetWorkQueue.Transport.PostgreSQL.Basic;
25+
using DotNetWorkQueue.Transport.RelationalDatabase;
26+
using Microsoft.VisualStudio.TestTools.UnitTesting;
27+
28+
namespace DotNetWorkQueue.Transport.PostgreSQL.Integration.Tests.Inbox
29+
{
30+
/// <summary>
31+
/// Async inbox-pattern integration tests for PostgreSQL.
32+
/// </summary>
33+
[TestClass]
34+
public class PostgreSqlInboxAsyncHandlerTests : PostgreSqlInboxIntegrationTestBase
35+
{
36+
[ClassInitialize]
37+
public static void Init(TestContext _) => EnsureActivityListenerRegistered();
38+
39+
[TestMethod]
40+
public void Async_Commit_BothRowsVisible()
41+
{
42+
var connStr = ConnectionInfo.ConnectionString;
43+
var qc = new QueueConnection(NewQueueName(), connStr);
44+
var businessTable = NewBusinessTableName();
45+
46+
using var queue = CreateQueue(qc, enableHoldTransaction: true);
47+
CreateBusinessTable(connStr, businessTable);
48+
try
49+
{
50+
var handlerInvoked = new ManualResetEventSlim(false);
51+
Exception capturedException = null;
52+
var castSucceeded = false;
53+
54+
using (var queueContainer = new QueueContainer<PostgreSqlMessageQueueInit>())
55+
{
56+
using (var producer = queueContainer.CreateProducer<FakeMessage>(qc))
57+
{
58+
var sendResult = producer.Send(new FakeMessage());
59+
Assert.IsFalse(sendResult.HasError, sendResult.SendingException?.ToString());
60+
}
61+
62+
using (var consumer = queueContainer.CreateConsumerAsync(qc))
63+
{
64+
consumer.Configuration.Worker.WorkerCount = 1;
65+
consumer.Start<FakeMessage>((message, workerNotification) =>
66+
{
67+
try
68+
{
69+
if (workerNotification is IRelationalWorkerNotification relational)
70+
{
71+
castSucceeded = true;
72+
InsertBusinessRowOnInboxTransaction(relational.Transaction, businessTable, 1, "commit-async");
73+
}
74+
}
75+
catch (Exception ex)
76+
{
77+
capturedException = ex;
78+
throw;
79+
}
80+
finally
81+
{
82+
handlerInvoked.Set();
83+
}
84+
return Task.CompletedTask;
85+
}, null);
86+
87+
Assert.IsTrue(handlerInvoked.Wait(TimeSpan.FromSeconds(30)), "async handler was not invoked within 30s");
88+
}
89+
}
90+
91+
Assert.IsNull(capturedException, $"async handler threw unexpectedly: {capturedException}");
92+
Assert.IsTrue(castSucceeded, "capability cast to IRelationalWorkerNotification failed when option=true");
93+
AssertBusinessRowCountFromSeparateConnection(connStr, businessTable, 1);
94+
}
95+
finally
96+
{
97+
DropBusinessTable(connStr, businessTable);
98+
}
99+
}
100+
101+
[TestMethod]
102+
public void Async_Rollback_NeitherRowVisible()
103+
{
104+
var connStr = ConnectionInfo.ConnectionString;
105+
var qc = new QueueConnection(NewQueueName(), connStr);
106+
var businessTable = NewBusinessTableName();
107+
108+
using var queue = CreateQueue(qc, enableHoldTransaction: true);
109+
CreateBusinessTable(connStr, businessTable);
110+
try
111+
{
112+
var handlerInvoked = new ManualResetEventSlim(false);
113+
var castSucceeded = false;
114+
115+
using (var queueContainer = new QueueContainer<PostgreSqlMessageQueueInit>())
116+
{
117+
using (var producer = queueContainer.CreateProducer<FakeMessage>(qc))
118+
{
119+
var sendResult = producer.Send(new FakeMessage());
120+
Assert.IsFalse(sendResult.HasError, sendResult.SendingException?.ToString());
121+
}
122+
123+
using (var consumer = queueContainer.CreateConsumerAsync(qc))
124+
{
125+
consumer.Configuration.Worker.WorkerCount = 1;
126+
consumer.Configuration.TransportConfiguration.RetryDelayBehavior.Add(
127+
typeof(InvalidOperationException),
128+
new System.Collections.Generic.List<TimeSpan>
129+
{
130+
TimeSpan.FromMilliseconds(100)
131+
});
132+
133+
consumer.Start<FakeMessage>((message, workerNotification) =>
134+
{
135+
if (workerNotification is IRelationalWorkerNotification relational)
136+
{
137+
castSucceeded = true;
138+
InsertBusinessRowOnInboxTransaction(relational.Transaction, businessTable, 1, "rollback-async");
139+
}
140+
handlerInvoked.Set();
141+
throw new InvalidOperationException("intentional throw — should roll back inbox transaction");
142+
}, null);
143+
144+
Assert.IsTrue(handlerInvoked.Wait(TimeSpan.FromSeconds(30)), "async handler was not invoked within 30s");
145+
}
146+
}
147+
148+
Assert.IsTrue(castSucceeded, "capability cast to IRelationalWorkerNotification failed when option=true");
149+
AssertBusinessRowCountStaysAt(connStr, businessTable, 0);
150+
}
151+
finally
152+
{
153+
DropBusinessTable(connStr, businessTable);
154+
}
155+
}
156+
}
157+
}

0 commit comments

Comments
 (0)