Large Attachment Support

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. Large-object support is opt-in, and the regular message-processing APIs remain valid for small and large messages and attachments.

Large-object storage

PostgreSQL uses its built-in Large Object facility, SQL Server Queue v2 uses a dedicated large-object table, and MongoDB continues to use GridFS. These built-in implementations sit behind a pluggable storage abstraction. Additional stores can be provided; for example, Connexion includes S3 support for AWS environments where large payloads should live outside the message database.

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. Seeking is not guaranteed; compressed attachments are forward-only.

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, shouldCompress: false);

Large or naturally streamed content should be added from a stream:

await context.Attachments.AddAsync( "documents/report.pdf", sourceStream, shouldCompress: false, cancellationToken);

Adding an attachment at an existing path replaces that entry. The attachment collection takes ownership of a supplied stream and disposes it.

Materialized vs. Streamed Large Attachments

This test was to compare version 26 with attachment streaming enabled and disabled (via an application level switch). This used a channel which added a 5MiB attachment pre-queue, but did not access that attachment post-queue.

Attachment workload

Measured memory impact of streaming

Processing impact

Maximum measured private-memory increase (streamed / materialized)

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)

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.