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.
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¶
- Messages and mappings — pre-processors in general.
- Management API — reading attachments of dead-lettered messages.