Skip to content

Implement configurable message body storage - #5646

Merged
johnsimons merged 6 commits into
masterfrom
john/body_storage
Jul 27, 2026
Merged

Implement configurable message body storage#5646
johnsimons merged 6 commits into
masterfrom
john/body_storage

Conversation

@johnsimons

Copy link
Copy Markdown
Member

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 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.
@johnsimons johnsimons self-assigned this Jul 24, 2026
@johnsimons
johnsimons marked this pull request as ready for review July 24, 2026 05:38
Comment thread src/ServiceControl.Persistence.EFCore/Abstractions/EFPersisterSettings.cs Outdated
// existing one is left untouched.
public class FileSystemBodyStoragePersistence(EFPersisterSettings settings) : IBodyStoragePersistence
{
const int FormatVersion = 1;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

  1. What's does that experience look like for the user?
  2. Maybe add some comments to explain it? Making it clear what the impact of updating this actually is.
  3. 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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems a missing FormatVersion is tolerated in Azure and AWS, but not file storage. Is there a reason for this?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +104 to +106
Stream bodyStream = isCompressed
? new BrotliStream(fileStream, CompressionMode.Decompress, leaveOpen: false)
: fileStream;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed and added test

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this error or default to filesystem in some predictable location instead of failing?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this value should probably be mandatory if you are using Azure Blob

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should there be documentation around the decision to use Brotli over something else?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there something else that we should use instead?
Does it matter?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this be /// doc comments?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed

Comment on lines +104 to +106
Stream bodyStream = isCompressed
? new BrotliStream(fileStream, CompressionMode.Decompress, leaveOpen: false)
: fileStream;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should the Brotli part of this be using the BodyCompression class?

@johnsimons johnsimons Jul 27, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What are the implications of this option when not on the non-AWS stacks?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@johnsimons
johnsimons enabled auto-merge July 27, 2026 23:07
@johnsimons
johnsimons merged commit 6f33fbf into master Jul 27, 2026
37 checks passed
@johnsimons
johnsimons deleted the john/body_storage branch July 27, 2026 23:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants