Skip to content

Attachments

Attachments let a message carry a payload far larger than the transport allows. The file is stored out of band and only its id travels in the message body, so a 100 MB file can ride on a transport with a 256 KB message limit.

Attachments are transport independent: the same mechanism works on Service Bus, Storage Queues, PostgreSQL, Redis and NATS.

Sending an attachment

Implement ICommandWithAttachment on the command alongside its transport interface:

public class ImportFile : IServiceBusCommand, ICommandWithAttachment
{
    public string Description { get; set; }
    public IMessageAttachment Attachment { get; set; }
}

Then attach a stream when sending:

await bus.SendAsync(new ImportFile
{
    Description = "Customer import",
    Attachment = new MessageAttachment(
        "customers.csv",
        "text/csv",
        File.OpenRead("customers.csv")
    ),
});

MessageAttachment also takes an optional metadata dictionary:

new MessageAttachment(
    "customers.csv",
    "text/csv",
    stream,
    new Dictionary<string, string> { ["tenant"] = "acme" }
)

Receiving an attachment

The processor needs no special interface. By the time ProcessAsync runs, Attachment is populated and its Stream is open:

public class ImportFileProcessor : IProcessCommand<ImportFile, DefaultSettings>
{
    public async Task ProcessAsync(ImportFile message, CancellationToken cancellationToken)
    {
        using var reader = new StreamReader(message.Attachment.Stream);
        var contents = await reader.ReadToEndAsync();
    }
}

IMessageAttachment exposes Filename, ContentType, Length, Stream and Metadata. Length is 0 for non-seekable streams.

The stream is disposed for you once the message finishes, so do not hold on to it past the end of ProcessAsync.

Registering a provider

Attachments need a store, and both the sender and the receiver must register one — the sender uploads, the receiver downloads. Azure Blob Storage is the only provider KnightBus ships; for anything else, write your own.

services
    .UseBlobStorage(storageConnectionString)
    .UseBlobStorageAttachments();

With optional compression:

services.UseBlobStorageAttachments(options =>
{
    options.EnableCompression = true;
    options.CompressionLevel = CompressionLevel.Optimal;
});

Compression uses Brotli and is off by default. Compressed blobs get a .brotli suffix and ContentEncoding: br, and decompression on read is decided per blob by that suffix — so turning compression on is backwards compatible with attachments already in the store.

Compression and decompression stream — a compressed result over 4 MB is uploaded in blocks as it is produced rather than buffered whole in memory (smaller results are buffered and sent as a single request), and downloads decompress on the fly. Reading a compressed attachment makes its Stream read-forward only (CanSeek is false); its Length is preserved through blob metadata, and reports 0 for attachments sent from non-seekable streams and for compressed attachments stored by KnightBus.Azure.Storage versions before 18.1.0.

Filename is a reserved metadata key, and UncompressedLength is reserved when compression is enabled — your own entries under those names are overwritten on upload.

The provider works with any transport, and is chosen per host rather than per transport — the NATS example uses NATS for messages and Blob Storage for attachments. UseBlobStorageAttachments() needs UseBlobStorage(...) for the connection, not UseTransport<StorageTransport>(). The middleware resolves a single IMessageAttachmentProvider, so register one: a second registration silently wins over the first.

Lifecycle and cleanup

Attachments follow the fate of the message:

Outcome What happens to the attachment
Processed successfully Deleted. A failed delete is logged as a warning and does not fail the message.
Processing failed Kept — the retry needs it.
Dead-lettered Kept, so the message can be inspected or requeued intact.

Dead-lettered attachments are never cleaned up

KnightBus deliberately leaves attachments of dead-lettered messages in place. Nothing will ever delete them, so set a lifecycle policy on the container or key space if they must expire.

How it works

On send, an IMessagePreProcessor uploads the attachment and puts its id in the _attachments message property. On receive, AttachmentMiddleware reads that property, downloads the file and assigns message.Attachment before your processor runs.

One consequence follows: serializers must skip the Attachment property. Both bundled serializers do. If you write your own, mirror that — see serialization.

Custom providers

Implement IMessageAttachmentProvider to store attachments anywhere:

public interface IMessageAttachmentProvider
{
    Task<IMessageAttachment> GetAttachmentAsync(string queueName, string id, CancellationToken ct = default);
    Task<string> UploadAttachmentAsync(string queueName, IMessageAttachment attachment, CancellationToken ct = default);
    Task<bool> DeleteAttachmentAsync(string queueName, string id, CancellationToken ct = default);
}

UploadAttachmentAsync returns the id that travels with the message. Register the provider plus the middleware and pre-processor the built-in extensions register for you:

services.AddSingleton<IMessageAttachmentProvider, MyAttachmentProvider>();
services.AddMiddleware<AttachmentMiddleware>();
services.AddSingleton<IMessagePreProcessor, AttachmentPreProcessor>();

See also