Skip to content

Commit b8d8532

Browse files
authored
Route incoming attachment reads through the connection factory (#795)
* Route incoming attachment reads through the connection factory Reads (GetStream, CopyTo, GetBytes, ProcessStream, …) now always open a fresh connection from the configured factory instead of sharing the receive transaction's connection. This lets a handler hold an OpenOutgoingAttachment sink open while reading incoming attachments without colliding on a non-MARS connection. Writes are unchanged — they still use the ambient connection/transaction so saves remain atomic with the receive under SendsAtomicWithReceive. Tradeoff: incoming reads are no longer enlisted in the receive transaction. The attachment row was committed by the sender before the message arrived, so reads see committed data either way; the change only affects callers that deliberately relied on rolling back the receive to "undo" a read. Also fixes a lifetime bug in Persister.GetStream where the command and reader were disposed at method exit (via await using), killing the returned SqlSequentialStream before the caller could read it. Disposal is now deferred to the AttachmentStream's cleanups on the success path. The unused MessageAttachmentsFromTransaction, MessageAttachmentsFromSqlTransaction, and MessageAttachmentsFromSqlConnection wrapper classes are removed. * Update Directory.Build.props
1 parent 698db06 commit b8d8532

13 files changed

Lines changed: 317 additions & 485 deletions

docs/mdsource/sql.source.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ Attachments can leverage the ambient SQL connectivity from either the [transport
3232

3333
If both `UseSynchronizedStorageSessionConnectivity` and `UseTransportConnectivity` are defined, the `SynchronizedStorageSession` will be used first, followed by the `TransportTransaction`.
3434

35+
Ambient connectivity applies to attachment writes only — attachment saves run on the ambient connection/transaction so the save is atomic with the receive (under `SendsAtomicWithReceive`) or the persister's storage session. Attachment reads always run on a fresh connection from the `connectionFactory` and are not enlisted in the receive transaction. This lets a handler hold an `OpenOutgoingAttachment` sink open while reading incoming attachments without colliding with the write on a non-MARS connection. Each read call (`GetStream`, `CopyTo`, `GetBytes`, `ProcessStream`, etc.) opens its own short-lived connection; SQL connection pooling makes this cheap.
36+
3537

3638
#### Use SynchronizedStorageSession connectivity
3739

@@ -42,8 +44,8 @@ snippet: UseSynchronizedStorageSessionConnectivity
4244
This approach attempts to use the SynchronizedStorageSession using the following steps:
4345

4446
* For the current context attempt to retrieve an instance of `SynchronizedStorageSession`. If no `SynchronizedStorageSession` exists, don't continue and fall back to the [SqlConnection](https://docs.microsoft.com/en-us/dotnet/api/system.data.sqlclient.sqlconnection) retrieved by the `connectionFactory`.
45-
* Attempt to retrieve a property named 'Transaction' that is a [SqlTransaction](https://docs.microsoft.com/en-us/dotnet/api/system.data.sqlclient.sqltransaction) from the `SynchronizedStorageSession`. If it exists, use it for all SQL operations in the current pipeline.
46-
* Attempt to retrieve a property named 'Connection' that is a [SqlConnection](https://docs.microsoft.com/en-us/dotnet/api/system.data.sqlclient.sqlconnection) from the `SynchronizedStorageSession`. If it exists, use it for all SQL operations in the current pipeline.
47+
* Attempt to retrieve a property named 'Transaction' that is a [SqlTransaction](https://docs.microsoft.com/en-us/dotnet/api/system.data.sqlclient.sqltransaction) from the `SynchronizedStorageSession`. If it exists, use it for outgoing attachment operations in the current pipeline.
48+
* Attempt to retrieve a property named 'Connection' that is a [SqlConnection](https://docs.microsoft.com/en-us/dotnet/api/system.data.sqlclient.sqlconnection) from the `SynchronizedStorageSession`. If it exists, use it for outgoing attachment operations in the current pipeline.
4749

4850
The properties are retrieved using [reflection](https://docs.microsoft.com/en-us/dotnet/framework/reflection-and-codedom/reflection) since there is no API in NServiceBus to access SynchronizedStorageSession data via type.
4951

@@ -57,9 +59,9 @@ snippet: UseTransportConnectivity
5759
This approach attempts to use the transport transaction using the following steps:
5860

5961
* For the current context, attempt to retrieve an instance of `TransportTransaction`. If no `TransportTransaction` exists, don't continue and fall back to using the [SqlConnection](https://docs.microsoft.com/en-us/dotnet/api/system.data.sqlclient.sqlconnection) retrieved by the `connectionFactory`.
60-
* Attempt to retrieve an instance of [Transaction](https://docs.microsoft.com/en-us/dotnet/api/system.transactions.transaction) from the `TransportTransaction`. If it exists, use it in [SqlConnection.EnlistTransaction](https://docs.microsoft.com/en-us/dotnet/api/system.data.sqlclient.sqlconnection.enlisttransaction) with an instance of [SqlConnection](https://docs.microsoft.com/en-us/dotnet/api/system.data.sqlclient.sqlconnection) retrieved by the `connectionFactory`. Then use that [SqlConnection](https://docs.microsoft.com/en-us/dotnet/api/system.data.sqlclient.sqlconnection) for all SQL operations in the current pipeline.
61-
* Attempt to retrieve an instance of [SqlTransaction](https://docs.microsoft.com/en-us/dotnet/api/system.data.sqlclient.sqltransaction) from the `TransportTransaction`. If it exists, use it for all SQL operations in the current pipeline.
62-
* Attempt to retrieve an instance of [SqlConnection](https://docs.microsoft.com/en-us/dotnet/api/system.data.sqlclient.sqlconnection) from the `TransportTransaction`. If it exists, use it for all SQL operations in the current pipeline.
62+
* Attempt to retrieve an instance of [Transaction](https://docs.microsoft.com/en-us/dotnet/api/system.transactions.transaction) from the `TransportTransaction`. If it exists, use it in [SqlConnection.EnlistTransaction](https://docs.microsoft.com/en-us/dotnet/api/system.data.sqlclient.sqlconnection.enlisttransaction) with an instance of [SqlConnection](https://docs.microsoft.com/en-us/dotnet/api/system.data.sqlclient.sqlconnection) retrieved by the `connectionFactory`. Then use that [SqlConnection](https://docs.microsoft.com/en-us/dotnet/api/system.data.sqlclient.sqlconnection) for outgoing attachment operations in the current pipeline.
63+
* Attempt to retrieve an instance of [SqlTransaction](https://docs.microsoft.com/en-us/dotnet/api/system.data.sqlclient.sqltransaction) from the `TransportTransaction`. If it exists, use it for outgoing attachment operations in the current pipeline.
64+
* Attempt to retrieve an instance of [SqlConnection](https://docs.microsoft.com/en-us/dotnet/api/system.data.sqlclient.sqlconnection) from the `TransportTransaction`. If it exists, use it for outgoing attachment operations in the current pipeline.
6365
* Any attachments associated with a message send will be deleted after message processing.
6466

6567

docs/sql.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,8 @@ Attachments can leverage the ambient SQL connectivity from either the [transport
8787

8888
If both `UseSynchronizedStorageSessionConnectivity` and `UseTransportConnectivity` are defined, the `SynchronizedStorageSession` will be used first, followed by the `TransportTransaction`.
8989

90+
Ambient connectivity applies to attachment writes only — attachment saves run on the ambient connection/transaction so the save is atomic with the receive (under `SendsAtomicWithReceive`) or the persister's storage session. Attachment reads always run on a fresh connection from the `connectionFactory` and are not enlisted in the receive transaction. This lets a handler hold an `OpenOutgoingAttachment` sink open while reading incoming attachments without colliding with the write on a non-MARS connection. Each read call (`GetStream`, `CopyTo`, `GetBytes`, `ProcessStream`, etc.) opens its own short-lived connection; SQL connection pooling makes this cheap.
91+
9092

9193
#### Use SynchronizedStorageSession connectivity
9294

@@ -106,8 +108,8 @@ attachments.UseSynchronizedStorageSessionConnectivity();
106108
This approach attempts to use the SynchronizedStorageSession using the following steps:
107109

108110
* For the current context attempt to retrieve an instance of `SynchronizedStorageSession`. If no `SynchronizedStorageSession` exists, don't continue and fall back to the [SqlConnection](https://docs.microsoft.com/en-us/dotnet/api/system.data.sqlclient.sqlconnection) retrieved by the `connectionFactory`.
109-
* Attempt to retrieve a property named 'Transaction' that is a [SqlTransaction](https://docs.microsoft.com/en-us/dotnet/api/system.data.sqlclient.sqltransaction) from the `SynchronizedStorageSession`. If it exists, use it for all SQL operations in the current pipeline.
110-
* Attempt to retrieve a property named 'Connection' that is a [SqlConnection](https://docs.microsoft.com/en-us/dotnet/api/system.data.sqlclient.sqlconnection) from the `SynchronizedStorageSession`. If it exists, use it for all SQL operations in the current pipeline.
111+
* Attempt to retrieve a property named 'Transaction' that is a [SqlTransaction](https://docs.microsoft.com/en-us/dotnet/api/system.data.sqlclient.sqltransaction) from the `SynchronizedStorageSession`. If it exists, use it for outgoing attachment operations in the current pipeline.
112+
* Attempt to retrieve a property named 'Connection' that is a [SqlConnection](https://docs.microsoft.com/en-us/dotnet/api/system.data.sqlclient.sqlconnection) from the `SynchronizedStorageSession`. If it exists, use it for outgoing attachment operations in the current pipeline.
111113

112114
The properties are retrieved using [reflection](https://docs.microsoft.com/en-us/dotnet/framework/reflection-and-codedom/reflection) since there is no API in NServiceBus to access SynchronizedStorageSession data via type.
113115

@@ -130,9 +132,9 @@ attachments.UseTransportConnectivity();
130132
This approach attempts to use the transport transaction using the following steps:
131133

132134
* For the current context, attempt to retrieve an instance of `TransportTransaction`. If no `TransportTransaction` exists, don't continue and fall back to using the [SqlConnection](https://docs.microsoft.com/en-us/dotnet/api/system.data.sqlclient.sqlconnection) retrieved by the `connectionFactory`.
133-
* Attempt to retrieve an instance of [Transaction](https://docs.microsoft.com/en-us/dotnet/api/system.transactions.transaction) from the `TransportTransaction`. If it exists, use it in [SqlConnection.EnlistTransaction](https://docs.microsoft.com/en-us/dotnet/api/system.data.sqlclient.sqlconnection.enlisttransaction) with an instance of [SqlConnection](https://docs.microsoft.com/en-us/dotnet/api/system.data.sqlclient.sqlconnection) retrieved by the `connectionFactory`. Then use that [SqlConnection](https://docs.microsoft.com/en-us/dotnet/api/system.data.sqlclient.sqlconnection) for all SQL operations in the current pipeline.
134-
* Attempt to retrieve an instance of [SqlTransaction](https://docs.microsoft.com/en-us/dotnet/api/system.data.sqlclient.sqltransaction) from the `TransportTransaction`. If it exists, use it for all SQL operations in the current pipeline.
135-
* Attempt to retrieve an instance of [SqlConnection](https://docs.microsoft.com/en-us/dotnet/api/system.data.sqlclient.sqlconnection) from the `TransportTransaction`. If it exists, use it for all SQL operations in the current pipeline.
135+
* Attempt to retrieve an instance of [Transaction](https://docs.microsoft.com/en-us/dotnet/api/system.transactions.transaction) from the `TransportTransaction`. If it exists, use it in [SqlConnection.EnlistTransaction](https://docs.microsoft.com/en-us/dotnet/api/system.data.sqlclient.sqlconnection.enlisttransaction) with an instance of [SqlConnection](https://docs.microsoft.com/en-us/dotnet/api/system.data.sqlclient.sqlconnection) retrieved by the `connectionFactory`. Then use that [SqlConnection](https://docs.microsoft.com/en-us/dotnet/api/system.data.sqlclient.sqlconnection) for outgoing attachment operations in the current pipeline.
136+
* Attempt to retrieve an instance of [SqlTransaction](https://docs.microsoft.com/en-us/dotnet/api/system.data.sqlclient.sqltransaction) from the `TransportTransaction`. If it exists, use it for outgoing attachment operations in the current pipeline.
137+
* Attempt to retrieve an instance of [SqlConnection](https://docs.microsoft.com/en-us/dotnet/api/system.data.sqlclient.sqlconnection) from the `TransportTransaction`. If it exists, use it for outgoing attachment operations in the current pipeline.
136138
* Any attachments associated with a message send will be deleted after message processing.
137139

138140

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
using Microsoft.Extensions.DependencyInjection;
2+
3+
[NotInParallel]
4+
public class OpenOutgoingAttachmentConcurrentReadTests
5+
{
6+
static readonly TestState state = new();
7+
8+
// Reads run on a fresh connection (MessageContextExtensions.Attachments goes through the
9+
// connection factory). This test holds an OpenOutgoingAttachment sink open on the receive
10+
// transport's connection while reading the incoming attachment — the previous design would
11+
// have failed with "MultipleActiveResultSets".
12+
[Test]
13+
[Arguments(TransportTransactionMode.SendsAtomicWithReceive)]
14+
[Arguments(TransportTransactionMode.ReceiveOnly)]
15+
public async Task ReadsIncomingWhileSinkIsOpen(TransportTransactionMode transactionMode)
16+
{
17+
state.Bytes = null;
18+
state.Reply.Reset();
19+
20+
var dbName = $"OpenConcurrent_{transactionMode}";
21+
await using var database = await Connection.SqlInstance.Build(dbName);
22+
var connectionString = database.ConnectionString;
23+
var databaseName = new SqlConnectionStringBuilder(connectionString).InitialCatalog;
24+
25+
var configuration = new EndpointConfiguration($"SqlOpenConcurrent_{transactionMode}");
26+
SqlConnection NewConnection() => new(connectionString);
27+
var attachments = configuration.EnableAttachments(NewConnection, TimeToKeep.Default, database: databaseName, table: "Attachments");
28+
attachments.UseTransportConnectivity();
29+
configuration.UseSerialization<SystemJsonSerializer>();
30+
configuration.UsePersistence<LearningPersistence>();
31+
configuration.DisableRetries();
32+
configuration.EnableInstallers();
33+
configuration.PurgeOnStartup(true);
34+
attachments.DisableCleanupTask();
35+
configuration.RegisterComponents(_ => _.AddSingleton(state));
36+
37+
var transport = configuration.UseTransport<SqlServerTransport>();
38+
transport.ConnectionString(connectionString);
39+
transport.Transactions(transactionMode);
40+
41+
var endpoint = await Endpoint.Start(configuration);
42+
43+
var sendOptions = new SendOptions();
44+
sendOptions.RouteToThisEndpoint();
45+
var outgoing = sendOptions.Attachments();
46+
outgoing.AddStream("input", async stream =>
47+
{
48+
await using var writer = new StreamWriter(stream, leaveOpen: true);
49+
await writer.WriteAsync("hello");
50+
});
51+
await endpoint.Send(new InMessage(), sendOptions);
52+
53+
if (!state.Reply.WaitOne(TimeSpan.FromSeconds(30)))
54+
{
55+
await endpoint.Stop();
56+
throw new($"TimedOut for mode {transactionMode}");
57+
}
58+
59+
await endpoint.Stop();
60+
61+
await Assert.That(Encoding.UTF8.GetString(state.Bytes!)).IsEqualTo("HELLO");
62+
}
63+
64+
class TestState
65+
{
66+
public byte[]? Bytes;
67+
public ManualResetEvent Reply = new(false);
68+
}
69+
70+
class InMessage :
71+
IMessage;
72+
73+
class OutMessage :
74+
IMessage;
75+
76+
class InHandler :
77+
IHandleMessages<InMessage>
78+
{
79+
public async Task Handle(InMessage message, HandlerContext context)
80+
{
81+
var incoming = context.Attachments();
82+
var replyOptions = new ReplyOptions();
83+
84+
await using (var sink = await context.OpenOutgoingAttachment(replyOptions, "output"))
85+
{
86+
// Read the incoming attachment while the outgoing sink is held open.
87+
using var sourceBuffer = new MemoryStream();
88+
await incoming.CopyTo("input", sourceBuffer, context.CancellationToken);
89+
sourceBuffer.Position = 0;
90+
using var reader = new StreamReader(sourceBuffer, leaveOpen: true);
91+
var content = await reader.ReadToEndAsync(context.CancellationToken);
92+
await using var writer = new StreamWriter(sink, leaveOpen: true);
93+
await writer.WriteAsync(content.ToUpperInvariant());
94+
}
95+
96+
await context.Reply(new OutMessage(), replyOptions);
97+
}
98+
}
99+
100+
class OutHandler :
101+
IHandleMessages<OutMessage>
102+
{
103+
public async Task Handle(OutMessage message, HandlerContext context)
104+
{
105+
await using var memoryStream = new MemoryStream();
106+
var incoming = context.Attachments();
107+
await incoming.CopyTo("output", memoryStream, context.CancellationToken);
108+
state.Bytes = memoryStream.ToArray();
109+
state.Reply.Set();
110+
}
111+
}
112+
}

0 commit comments

Comments
 (0)