Table of Contents

Concurrency

Resourcefull is built to be called from many requests, processes and servers at once. Every built-in provider makes the same guarantees:

  • One current version per name, always. Uploads of the same name are made current one at a time, so however many run at once, each name ends up with exactly one current version: the one stored last. SQL Server also enforces this with a unique index.
  • Uploads don't wait for each other's content. Each upload streams its content on its own, and the name is locked only for the moment the new version is made current.
  • Create-only uploads are decided by the write. Of several concurrent uploads of a new name with ResourceWriteMode.Create, the default 4.0, exactly one succeeds and the rest throw ResourceConflictException. There's no window between a check and the write.
  • Creating a container is decided by the write too. Of several concurrent CreateContainerAsync calls for one name, exactly one succeeds. CreateContainerIfNotExistsAsync returns a client to every caller.
  • An upload never succeeds into a missing container. A container deleted while an upload into it runs makes the upload throw ResourceContainerNotFoundException, not report success. With SQL Server, PostgreSQL and SQLite, the upload can throw ResourceUploadInterruptedException 4.0 instead, when the delete reaches the upload's version before it removes the container. Either way, nothing was stored.
  • Pruning never leaves more than MaxVersions, even with concurrent uploads, because each prune works from the versions as they stand after its own write. (With expiry on 4.0, the extra versions expire five minutes later instead of being deleted at once.) A prune that fails leaves the extra versions until a later upload of the name prunes them (see Versioning).
  • A download reads the version it resolved. If another upload makes a newer version current while you read, you keep reading the version the ResourceInfo describes, as long as that version still exists. An upload can delete it: without versioning, a replacing upload deletes the name's only version, and with versioning, one past the limit prunes the oldest. With SQL Server, a download of a version deleted partway through throws IOException, so a client downloading a file that's often replaced should be ready to download it again. Turning on expiry 4.0 avoids this: a replaced or pruned version then stays five minutes more, so downloads already reading it finish.
  • Readers don't block writers. With SQL Server, reads use row versions (READ_COMMITTED_SNAPSHOT) instead of shared locks, and deadlocks that contention still causes are retried for you.

What isn't coordinated is the order of independent operations. Two overwriting uploads that run at once both succeed, and the one that's made current last wins.

Updating only if unchanged

When a client reads a resource, changes it and uploads it again, another client can do the same in between, and one of the two changes is silently lost. Make the upload conditional on the version you read:

ResourceInfo read = (await container.GetResourceInfoAsync("report.docx"))!;
// ... edit ...
await container.UploadAsync("report.docx", edited, new ResourceUploadOptions
{
    Conditions = new ResourceConditions { IfMatchVersionId = read.VersionId }
});

The condition is checked by the write itself, at the moment it would make the new version current, so of several clients that read the same version, exactly one succeeds. The others get ResourcePreconditionFailedException, which carries the version that's current now (CurrentVersionId and CurrentContentHash), and nothing of theirs is stored. They can read again, reapply their change and retry.

  • IfMatchContentHash compares the content hash instead. Serving downloads sends it as the ETag, in quotes as HTTP requires, so pass the tag from an If-Match header without its quotes. UploadFromRequestAsync in the ASP.NET Core package does that for you, along with *, weak tags and lists of tags. Versions stored before 3.0 have no hash until the backfill records one, and until then they meet no hash condition.
  • Set both and both must hold.
  • A name with no current version meets no condition. For "only if it doesn't exist yet", use ResourceWriteMode.Create; it can't be combined with conditions.
  • A conditional upload replaces the version it matched, so it needs ResourceWriteMode.Replace 4.0.
  • Deletes take conditions too, with DeleteResourceIfExistsAsync(name, versionId, conditions, cancellationToken). The conditions apply to the name's current version, and a name that doesn't exist throws rather than returning false.

Clients are safe to share. IResourceServiceClient is registered as a scoped service, and a container client is a lightweight handle over it, so create them freely per request or per unit of work.