Oct 14, 2026SitecoreSitecoreAISitecore XM CloudContent Migration
Migrating Content to SitecoreAI with the XM to XM Cloud Migration Tool: Two Walls I Hit and How I Got Past Them
I recently moved a Sitecore 10.2 site's content into SitecoreAI using the XM to XM Cloud Migration Tool (version 2.1.71). Two problems stopped me: IIS rejected the tool's requests before they reached Sitecore, and later, transfers with a lot of media died partway through with a SQL connection pool timeout. Both are fixable in a few lines. This post walks through each one.
A quick map of the moving parts, since both problems live on the source side:
- The migration tool drops a generic handler,
_MigrationHarness/MigrationHandler.ashx, onto the source CM. - The tool calls that handler to test the connection, browse the tree and start a transfer job.
- The handler runs a Sitecore job that reads each item, serializes it into
.raifpackages (protobuf), and uploads them to the SitecoreAI CM, which then consumes them.
Wall 1: requests rejected before they reach Sitecore
The first calls to the handler failed until I raised the request size limits in the source CM's
web.config. The defaults are sized for normal page traffic, not for a migration tool. The connection test sends the target URL and access token in the query string, and a bearer token alone can blow past 2,048 characters. Starting a transfer posts a JSON configuration listing every tree you selected, which grows with the size of the migration.Two layers enforce limits, and the lower one wins, so both need raising:
| Setting | Layer | Default | What I set | Unit |
|---|---|---|---|---|
requestLimits maxQueryString | IIS | 2,048 | 65,534 | characters |
requestLimits maxAllowedContentLength | IIS | 30,000,000 (~28.6 MB) | 524,288,000 (500 MB) | bytes |
httpRuntime maxQueryStringLength | ASP.NET | 2,048 | 65,534 | characters |
httpRuntime maxRequestLength | ASP.NET | 4,096 (4 MB) | 512,000 (500 MB) | kilobytes |
IIS request filtering lives under
system.webServer:<system.webServer>
<security>
<requestFiltering>
<requestLimits maxQueryString="65534" maxAllowedContentLength="524288000" />
</requestFiltering>
</security>
</system.webServer>
ASP.NET's own limits live on
httpRuntime under system.web. Keep whatever other attributes your httpRuntime element already has:<system.web>
<httpRuntime targetFramework="4.8" maxRequestLength="512000" maxQueryStringLength="65534" />
</system.web>
Watch the units:
maxAllowedContentLength is in bytes and maxRequestLength is in kilobytes, so 524,288,000 bytes and 512,000 KB are the same 500 MB. If you hit the IIS limit you get a 404.13 (content too long) or 404.15 (query string too long). If you hit the ASP.NET limit you get a Maximum request length exceeded or maxQueryStringLength exception instead.These limits apply to the whole CM, and I raised them only to let the migration through. Once the migration is done, remember to reset the
requestLimits (and httpRuntime) values to what you had before. If you would rather not raise them site-wide at all, wrap both elements in a <location path="_MigrationHarness"> block instead.Wall 2: the connection pool runs dry on media
With the requests getting through, content-only transfers worked. Transfers that included media items stored in the database failed partway through with this in the job messages (trimmed):
Method ProcessProtobuf: Sitecore.Framework.Data.Blobs.Abstractions.BlobProviderException: Operation failed.
---> System.InvalidOperationException: Timeout expired. The timeout period elapsed prior to
obtaining a connection from the pool. This may have occurred because all pooled connections
were in use and max pool size was reached.
at System.Data.SqlClient.SqlConnection.Open()
...
at Sitecore.Data.SqlServer.SqlServerDataProvider.BlobStreamExists(Guid blobId, CallContext context)
...
at Sitecore.Data.Blobs.ClassicSqlBlobProvider.BlobExists(BlobIdentifier identifier)
at MigrationHandler.ToProtobufTransferredItem(Item scItem) in ...\_MigrationHarness\MigrationHandler.ashx:line 291
at MigrationHandler.ProcessProtobuf(...) in ...\_MigrationHarness\MigrationHandler.ashx:line 611
The stack trace points at a blob existence check, but that check is only the victim. It was simply the first call that needed a connection after the pool was already empty. The real cause is a few lines further down in
ToProtobufTransferredItem, where the handler reads each media item's blob:if (tItem.Blobs.All(b => b.Id != mediaData.MediaId))
{
var itemStream = mediaData.GetStream();
var blob = new Sitecore.Data.ItemsTransfer.TransferredBlob(mediaData.MediaId, itemStream.Stream);
tItem.Blobs.Add(blob);
}
GetStream() returns a MediaStream, and nothing ever disposes it. With the classic SQL blob provider, that stream reads straight from the database and holds a pooled SQL connection open for as long as it lives. Every database-stored media version you export leaves one connection checked out until the garbage collector gets around to it. The default pool has 100 connections, so a media-heavy transfer runs through them quickly.The fix is to copy the blob into memory and dispose the Sitecore stream immediately, which hands the connection straight back to the pool:
if (tItem.Blobs.All(b => b.Id != mediaData.MediaId))
{
// A SQL-backed MediaStream holds a pooled connection open until disposed.
// Buffer it and release it now, or a media-heavy transfer exhausts the pool.
using (var itemStream = mediaData.GetStream())
{
if (itemStream != null && itemStream.Stream != null)
{
var blobBuffer = new System.IO.MemoryStream();
itemStream.Stream.CopyTo(blobBuffer);
blobBuffer.Position = 0;
var blob = new Sitecore.Data.ItemsTransfer.TransferredBlob(mediaData.MediaId, blobBuffer);
tItem.Blobs.Add(blob);
}
}
}
Buffering costs almost nothing extra, because the handler already builds each
.raif package in a MemoryStream before uploading it, so the bytes end up in memory either way. The null check covers media items whose blob is missing.Two things that look like fixes but are not:
- Raising
Max Pool Sizein the connection string. The streams still pile up until garbage collection closes them; you just fail later. - Lowering items per package. The leaked connections are not tied to a package, so smaller packages do not release them any sooner.
After deploying the change, recycle the source CM's app pool before re-running. Connections leaked by the failed run are still checked out, and a re-run without a recycle can fail the same way.
Share this post