Large Attachment Support
Connexion v26 includes a revised architecture for storing and processing large payloads. Large messages and large attachments can be stored outside the repository messaging tables, and their content isn’t fetched until processing actually requests it.
Version 16 materialized message bodies and attachments into memory before message processing, even when a channel only needed metadata or never used an attachment. Version 26 separates payload metadata from large-object content. Attachment streaming remains a public device API, while primary-message streaming is an internal optimization used by built-in devices. Code that requests an entire body or attachment as a string, byte array or parsed object still uses memory for that materialized value.
Large-object storage
Repository-local large-object storage uses chunked payload tables in PostgreSQL, dedicated payload storage in SQL Server Queue V2, and GridFS in MongoDB. Amazon S3, Google Cloud Storage and Azure Blob Storage are also available as object-storage targets, including configurable endpoints. These stores use the same pluggable storage model.
Large-object storage can be configured at the repository level and overridden at the group level. Groups inherit their repository configuration unless an override is set. Changing the selected target affects new writes; descriptors for existing payloads continue to resolve through the store that owns them.
Large-object storage is different from Tier 2 storage. Large-object storage holds the body or attachment for an active message. Tier 2 storage archives older message partitions after their hot-storage retention period.
Large Object Storage configuration at the repository level. The repository database is the default store, but other targets (like S3) can be added and selected.
Attachments
Version 26 decouples large attachment content from the parent message row. A device can enumerate attachment names, sizes, timestamps, comments, and compression information without downloading the attachment bodies. This matters because many channels don’t directly access attachments once they’ve been associated with a message.
Attachment content is opened individually and only when requested. Large attachment content is immutable: change it by adding a replacement payload at the same path, or use add/remove. The collection itself remains mutable, so attachments can be added, removed, and replaced.
Reading attachments
For a small attachment—or code that genuinely needs a complete value in memory—use a materializing getter:
var attachment = await context.Attachments.GetAsync(
"documents/report.pdf",
cancellationToken);
byte[] bytes = await attachment.GetBytesAsync(cancellationToken);
string text = await attachment.GetStringAsync(cancellationToken);For bounded-memory processing, use OpenStreamAsync(). The same code works for inline and large-object attachments:
foreach (var attachment in context.Attachments)
{
Console.WriteLine(
$"{attachment.Path}: {attachment.UncompressedSize} bytes");
await using var stream =
await attachment.OpenStreamAsync(cancellationToken);
await DoSomeStreamWork(stream, cancellationToken);
}Each call returns a caller-owned, read-only stream. Check CanSeek: uncompressed and seekable-compressed payloads can support random access, while legacy compression or providers without range support may be forward-only. Pass cancellation tokens to asynchronous reads as well as to the open call.
Adding or replacing attachments
Complete text or byte values can be added directly:
context.Attachments.Add("notes.txt", "Attachment text");
context.Attachments.Add(
"images/logo.png",
imageBytes);Large or naturally streamed content should be added from a stream:
await context.Attachments.AddAsync(
"documents/report.pdf",
sourceStream,
cancellationToken);Adding an attachment at an existing path replaces that entry. The attachment collection takes ownership of a supplied stream and disposes it. The current add APIs select storage compression internally and do not take a shouldCompress argument.
Repeated attachment reads
Use OpenStreamAsync for normal single-pass or sparse reads. When the same attachment must be read repeatedly, OpenLocallyCachedStreamAsync can share an encrypted local cache owned by the message context, avoiding repeated downloads. It trades a complete download and local disk work for reuse. Dispose each returned reader before the message context is disposed.
Materialized vs. Streamed Large Attachments
This earlier development test compared streamed and materialized attachment processing using the test build's application-level switch. It used a channel that added a 5 MiB attachment before queueing and did not access it after queueing. The current built-in streaming path does not require that opt-in switch.
Attachment workload | Measured memory impact of streaming | Processing impact | Maximum measured private-memory increase (streamed / materialized) |
|---|---|---|---|
10 concurrent channels, one 5 MiB attachment per message | About 88% less median incremental private memory (8.4× lower) and about 85% less working-set growth (6.5× lower). | Streaming used 51% less wall-clock processing time, equivalent to approximately 2.04× throughput. | Streamed: 22 MiB; materialized: 131 MiB. |
Measured post-queue attachment consumption
A second test used the same 5 MiB attachment size, but this time each persisted attachment was opened after dequeue and written by a terminal File Writer. With streaming enabled, File Writer copied the repository stream directly to the output file. With streaming disabled, the queue eagerly materialized the attachment before File Writer copied it.
Attachment workload | Measured memory impact of streaming | Processing impact | Maximum measured private-memory increase (streamed / materialized) |
|---|---|---|---|
10 concurrent channels consuming one 5 MiB attachment per message | About 89% less incremental private memory (9.4× lower) and about 88% less working-set growth (8.6× lower). | 50% less resume-to-completion time (41.285 s vs 83.307 s), equivalent to 2.02× end-to-end throughput. The server active span improved from 70.520 s to 29.060 s (2.43× rate), while Connexion CPU time fell by 55%. | Streamed: 11 MiB; materialized: 107 MiB. |
Large Message Payloads
Support for large primary messages has also been extended to allow storage of large message payloads in the large object store. Under the covers, these larger payloads can be streamed into and out of the database, and then materialized when accessed (via the standard GetMessageAsync<T> method).
While attachments remain the correct location for large payloads, in some cases message processing can make more efficient use of these large payloads. For example, channels which do not contain any message mutation calls and use devices which support streaming under the covers can stream a large payload directly out of the large object store (think a channel with only a file writer, http/secure/remote agent sender, branch device, etc. These devices often transport a payload only and don’t change it).
Branching without copying payloads
Large primary messages and attachments benefit from the same immutable large-object architecture. When a message is branched, Connexion duplicates the target message and message-data metadata but reuses the existing large-object content. The branch operation does not download, hash, rewrite, or duplicate the payload bytes.
This makes fan-out almost independent of payload size. In testing, branching 1 GiB payloads completed in 17–46 ms locally and created no additional large objects. Additional branches add relatively small metadata rows instead of another complete body and attachment copy. If a destination routes using metadata and never requests the content, the body and attachments remain unopened.
A destination that consumes the content still reads and transmits those bytes from the shared object. Branching removes payload copying and duplicate persistent storage at fan-out; it does not eliminate the downstream I/O performed by consumers.