Implement configurable message body storage - #5646
Conversation
This change introduces a pluggable message body storage mechanism, replacing the previous fake implementation. It enables configuring the storage type (e.g., FileSystem, Azure Blob, S3) via settings.
This change adds support for storing message bodies in Azure Blob Storage.
This completes the S3 body storage option introduced in the configurable message body storage feature.
Refactors Azure Blob and S3 body storage to stream large message bodies directly from cloud providers. This change avoids buffering entire messages into memory, improving performance and reducing memory consumption for large payloads.
| // existing one is left untouched. | ||
| public class FileSystemBodyStoragePersistence(EFPersisterSettings settings) : IBodyStoragePersistence | ||
| { | ||
| const int FormatVersion = 1; |
There was a problem hiding this comment.
Its not clear to me how this is meant to be used. So if we change the body format, we increase this version number. Does that then mean users with error messages in the queue and body in external storage, retrieving these messages will just throw?
- What's does that experience look like for the user?
- Maybe add some comments to explain it? Making it clear what the impact of updating this actually is.
- We should probably create a gate of some sort, maybe a test, to ensure this version is incremented.
Applies to the other seams as well
There was a problem hiding this comment.
The version marker is insurance rather than something we plan to use. It exists so that if the layout ever does change, a body written under the old one fails loudly instead of being silently misread.
To your first question: if we bumped it today with no other change, every body already in storage would throw out of ReadBody and the message body panel would return a 500 until those messages aged past ErrorRetentionPeriod. That is exactly why the rule has to be that we never bump it on its own. Any future format change has to ship with read support for every version still within the retention window, so the bump becomes additive and no existing body breaks.
On the gate: agreed in principle, but there is nothing to gate yet while there is only one version. I would rather add the pinned-layout test at the point we actually introduce a v2, when we will know what shape the dispatch needs to take. Happy to add a comment stating the contract now if that is useful.
| { | ||
| var metadata = content.Details.Metadata; | ||
|
|
||
| if (metadata.TryGetValue("FormatVersion", out var version) && version != FormatVersion) |
There was a problem hiding this comment.
Seems a missing FormatVersion is tolerated in Azure and AWS, but not file storage. Is there a reason for this?
There was a problem hiding this comment.
Not a deliberate difference. The file header is positional so the field is present by construction and absence is unrepresentable, whereas the cloud stores do a dictionary lookup that can miss, and the miss path just falls through. In practice it is not load-bearing: we always write the metadata, and this ships new so there are no pre-existing objects without it.
Happy to tighten Azure and S3 to require it if you would prefer them consistent, it is a two-line change.
| Stream bodyStream = isCompressed | ||
| ? new BrotliStream(fileStream, CompressionMode.Decompress, leaveOpen: false) | ||
| : fileStream; |
There was a problem hiding this comment.
AI found the below:
When we save a body to disk we write a small header first and then the body. On the way back out we read the header and hand the rest of the file to ASP.NET to send to the browser.
The problem is that we hand over the file handle itself. ASP.NET asks it how big it is, and it answers with the size of the whole file, header included. But we've already read past the header, so only the body bytes actually get sent. We promise 38 bytes and send 4. Kestrel spots the mismatch and kills the response, so the download fails.
| Stream bodyStream = isCompressed | |
| ? new BrotliStream(fileStream, CompressionMode.Decompress, leaveOpen: false) | |
| : fileStream; | |
| Stream bodyStream = isCompressed | |
| ? new ExpectedLengthStream(new BrotliStream(fileStream, CompressionMode.Decompress, leaveOpen: false), bodySize) | |
| : new ExpectedLengthStream(fileStream, bodySize); |
There was a problem hiding this comment.
Would it be better to store the metadata beside the body which I believe we do for Learning Transport, or will that bloat the disk space and operations too much?
There was a problem hiding this comment.
@rbev that would mean 2 reads instead of just one, not sure if it is worth it.
It also means compression needs to be done 2x.
Extracts body storage-related configuration from `EFPersisterSettings` into dedicated `BodyStorageSettings`, `AzureBlobStorageSettings`, and `S3StorageSettings`. This improves the organization and clarity of the persistence settings by grouping related properties and reducing the surface area of `EFPersisterSettings`.
| { | ||
| var raw = SettingsReader.Read<string>(settingsRootNamespace, BodyStorageTypeKey); | ||
|
|
||
| if (string.IsNullOrWhiteSpace(raw)) |
There was a problem hiding this comment.
should this error or default to filesystem in some predictable location instead of failing?
There was a problem hiding this comment.
There's no defensible default path that holds across SCMU installs and containers, so any default we picked would be wrong somewhere. More importantly, silently writing bodies to local disk on an instance the operator intended to point at blob storage is a worse failure than refusing to start.
So yes it is a deliberate choice.
| settings.AzureBlobServiceUri = hasServiceUri ? serviceUri : null; | ||
| settings.AzureBlobManagedIdentityClientId = SettingsReader.Read<string>(settingsRootNamespace, AzureManagedIdentityClientIdKey); | ||
| settings.AzureBlobAuthorityHost = ReadAzureAuthorityHost(settingsRootNamespace); | ||
| settings.AzureBlobContainerName = SettingsReader.Read(settingsRootNamespace, AzureContainerNameKey, settings.AzureBlobContainerName); |
There was a problem hiding this comment.
this value should probably be mandatory if you are using Azure Blob
There was a problem hiding this comment.
For this setting we actually have a default of "error-bodies", so that is why we left it as not required
| var buffer = ArrayPool<byte>.Shared.Rent(BrotliEncoder.GetMaxCompressedLength(body.Length)); | ||
| try | ||
| { | ||
| return BrotliEncoder.TryCompress(body.Span, buffer, out var written, quality: 1, window: 22) |
There was a problem hiding this comment.
Should there be documentation around the decision to use Brotli over something else?
There was a problem hiding this comment.
Is there something else that we should use instead?
Does it matter?
There was a problem hiding this comment.
Anyway, I have added more docs about it.
| using ServiceControl.Persistence.EFCore.Infrastructure; | ||
|
|
||
| public class BodyStorage : IBodyStorage | ||
| // A body is stored inline in BodyText (small text) or in external storage (binary, or large text |
There was a problem hiding this comment.
should this be /// doc comments?
| Stream bodyStream = isCompressed | ||
| ? new BrotliStream(fileStream, CompressionMode.Decompress, leaveOpen: false) | ||
| : fileStream; |
There was a problem hiding this comment.
Would it be better to store the metadata beside the body which I believe we do for Learning Transport, or will that bloat the disk space and operations too much?
|
|
||
| if (shouldCompress) | ||
| { | ||
| var brotliStream = new BrotliStream(fileStream, CompressionLevel.Fastest, leaveOpen: true); |
There was a problem hiding this comment.
Should the Brotli part of this be using the BodyCompression class?
There was a problem hiding this comment.
No, I tried that, and trying to reuse that part of the code got too complex and therefore opted this way.
The complexity is becasue the filesystem has the file has the cache, so there is no in memory data.
| var isCompressed = reader.ReadBoolean(); | ||
|
|
||
| // The returned stream owns fileStream and is disposed by the caller. | ||
| Stream bodyStream = isCompressed |
There was a problem hiding this comment.
Should the Brotli part of this be using the BodyCompression class?
| var isCompressed = bool.TryParse(metadata["is-compressed"], out var compressed) && compressed; | ||
| var contentType = response.Headers.ContentType ?? "application/octet-stream"; | ||
| Stream stream = isCompressed | ||
| ? new ExpectedLengthStream(new BrotliStream(response.ResponseStream, CompressionMode.Decompress), bodySize) |
There was a problem hiding this comment.
Should the Brotli part of this be using the BodyCompression class?
| if (!string.IsNullOrEmpty(serviceUrl)) | ||
| { | ||
| config.ServiceURL = serviceUrl; | ||
| config.ForcePathStyle = true; // Required for S3-compatible endpoints (MinIO, LocalStack). |
There was a problem hiding this comment.
What are the implications of this option when not on the non-AWS stacks?
There was a problem hiding this comment.
It's only set when a custom ServiceUrl is configured, so it never applies against real AWS, where the SDK uses virtual-host-style addressing. The flag is there for S3-compatible endpoints like LocalStack and MinIO, which can't do virtual-host addressing without wildcard DNS.
This change organizes the message body storage implementation by: * Moving related files into a dedicated `Implementation.BodyStorage` namespace. * Extracting default container and key prefix names into `const` fields. * Adding XML documentation to key body storage components.
This change introduces a pluggable message body storage mechanism, replacing the previous fake implementation. It enables configuring the storage type (e.g., FileSystem, Azure Blob, S3) via settings.