I have an API that allows users to upload documents. To optimize the performance I save the documents to the database in parallel. After uploading has completed I return a list of URI's pointing to the document locations.
Here is what the code looks like:
[HttpPost] | |
[ProducesResponseType(StatusCodes.Status200OK)] | |
public async Task<IActionResult> UploadDocuments(List<IFormFile> documents) | |
{ | |
var tasks = new List<Task<string>>(); | |
foreach (var document in documents) | |
{ | |
var task=UploadDocument(document); | |
tasks.Add(task); | |
} | |
var documentUris = await Task.WhenAll(tasks); | |
return Ok(documentUris); | |
async Task<string> UploadDocument(IFormFile document) | |
{ | |
var data = await document.GetData(); | |
var createdDocument = await _documentService.CreateDocument(data, document.FileName, document.ContentType); | |
return Url.ActionLink("GetDocumentContent", "Document", new { createdDocument.Id, filename = createdDocument.Metadata.FileName }); | |
} | |
} |
As you can see in the code above, I combine a Task.WhenAll with a local function. Nothing special.
However when I executed this code under high load, I started to get errors back from the API. A look at the logs, showed the following exception:
Index was out of range. Must be non-negative and less than the size of the collection. Parameter name: chunkLength
at System.Text.StringBuilder.ToString()
at Microsoft.AspNetCore.Mvc.Routing.UrlHelperBase.GenerateUrl(String protocol, String host, String path)
at Microsoft.AspNetCore.Mvc.Routing.EndpointRoutingUrlHelper.Action(UrlActionContext urlActionContext)
at Microsoft.AspNetCore.Mvc.UrlHelperExtensions.ActionLink(IUrlHelper helper, String action, String controller, Object values, String protocol, String host, String fragment)
at DocumentStorage.API.Controllers.DocumentsController.<UploadDocuments>g__UploadDocument|4_0(IFormFile document) in D:\b\3\_work\129\s\DocumentStorage\DocumentStorage.API\Controllers\DocumentsController.cs:line 53
at DocumentStorage.API.Controllers.DocumentsController.UploadDocuments(List`1 documents)
It turns out that the Url.ActionLink()
method is using a StringBuilder
behind the scenes. And the StringBuilder
is not thread-safe which brings us into trouble when we start using it in combination with the TPL.
To fix the threading issue I moved the url generation logic outside the parallel execution path:
[HttpPost] | |
[ProducesResponseType(StatusCodes.Status200OK)] | |
public async Task<IActionResult> UploadDocuments(List<IFormFile> documents) | |
{ | |
var tasks = new List<Task<(string key, string fileName)>>(); | |
foreach (var document in documents) | |
{ | |
var task=UploadDocument(document); | |
tasks.Add(task); | |
} | |
var uploadedDocuments=await Task.WhenAll(tasks); | |
//Url.ActionLink turns out not to be thread-safe. Therefore we do the URL generation outside the UploadDocument inline function. | |
var documentUris = uploadedDocuments | |
.Select(document => Url.ActionLink("GetDocumentContent", "Document", new { document.id, document.fileName })) | |
.ToArray(); | |
return Ok(documentUris); | |
async Task<(string id, string filename)> UploadDocument(IFormFile document) | |
{ | |
var data = await document.GetData(); | |
var createdDocument = await _documentService.CreateDocument(data, document.FileName, document.ContentType); | |
return (createdDocument.Id, createdDocument.Metadata.FileName); | |
} | |
} |