Changelog
Unreleased
Added
- PostgreSQL. The new
Resourcefull.PostgreSqlpackage stores resources in PostgreSQL, with everything the SQL Server provider does: chunked streaming, versions, conditional writes, copies and moves, expiry, staged uploads, container policies, export and import.UsePostgreSql(connectionString, configure, schema)registers it, andMigrateResourcefullDatabaseAsyncandGenerateResourcefullScriptprovision it. It keeps times to the microsecond, lists names in Unicode code point order, and refuses U+0000 in the names, version IDs and content types it stores.AddResourcefullPostgreSqlCheckis its health check, and it counts retries and abandoned uploads inresourcefull.postgresql.*. It's tested against PostgreSQL 16 and 17. See PostgreSQL. - SQLite. The new
Resourcefull.Sqlitepackage stores resources in a SQLite database file, with no server to run, and does everything the other providers do:UseSqlite(connectionString, configure)registers it,MigrateResourcefullDatabaseAsyncprovisions it and turns on write-ahead logging, andAddResourcefullSqliteCheckis its health check. It's built on the same EF Core code as the SQL Server and PostgreSQL providers. Each write takes the database's write lock as it begins, so writers take turns. Times are stored as UTC ticks, which keeps their full precision. Like PostgreSQL, it lists names in Unicode code point order and refuses U+0000. It counts abandoned uploads inresourcefull.sqlite.abandoned_uploads. See SQLite. - File-system storage. The new
Resourcefull.FileSystempackage stores resources as files in a directory, on a local disk or a network share, with no database or storage service:UseFileSystem(rootPath)registers it. It works as the S3 and Azure Blob providers do and shares their code, so it supports everything they do. Each write goes to a file flushed to disk, then moved into place, so nothing reads a file half-written; a change to a name holds a lock file while it compares and writes, so processes sharing the directory take turns. Names are escaped so that no filesystem changes them, which keeps names that differ only in case or Unicode normalization apart on Windows and macOS. They're listed in Unicode code point order. On macOS, a name whose file's path would pass the system's 1,023-byte limit throwsResourceNameException. It counts its retries, recovered moves, stale expiry entries and abandoned uploads inresourcefull.filesystem.*, and is tested on Linux, Windows and macOS. See File system. - S3-compatible storage. The new
Resourcefull.S3package stores resources in a bucket of Amazon S3 or another store that speaks its API, using only the store's conditional writes (If-None-Match: *andIf-MatchonPutObject) to keep one current version per name.UseS3(bucketName, configure)registers it, with a client and a key prefix of your choosing. It supports versions, create-only and conditional writes and deletes, copies, container and version metadata, container policies, export and import, expiry, upload sessions, and moving and renaming. A move marks both names, then commits with one conditional write of its entry, so its versions are never seen in both places or in neither; across containers, the store copies the content. Once a day, the expiry sweep deletes content no head names, which writes and deletes that stopped partway leave behind, and finishes purging deleted containers. Expiry keeps an index of the versions that expire, so a sweep lists only what's due. Content larger than 78 GiB, the most one multipart upload of 8 MiB parts takes, goes up in pieces that the store then joins, so an upload holds up to S3's 5 TiB; past that, it throws the newResourceUploadLimitException(413 Content Too Large in ASP.NET Core). An upload session stores its appends in segments of up to 64 MiB, and completing has the store join them withUploadPartCopy, so the bytes don't pass through the application. Once a session's segments take as many parts as the store joins, the next append first has the store join those since the last join into one, so a session holds up to 5 TiB too; an append past that throws the same exception. Names are kept exactly as given, even by stores that keep keys as files, and are listed in Unicode code point order. It counts writes retried after a concurrent change to the same name or session inresourcefull.s3.retries, stale expiry entries a sweep dropped inresourcefull.s3.stale_expiries, abandoned sessions and unnamed content the sweep deleted inresourcefull.s3.abandoned_uploads, joins of a session's segments inresourcefull.s3.session_joins, and, inresourcefull.s3.moves_recovered, moves the sweep finished or undid after their mover stopped. It's tested against SeaweedFS. See S3-compatible storage. - Azure Blob Storage. The new
Resourcefull.AzureBlobpackage stores resources in a blob container of an Azure Storage account, working as the S3 provider does and sharing its code: conditional writes (If-None-Match: *andIf-Match) keep one current version per name, and content of 8 MiB or more goes up in blocks.UseAzureBlobStorage(containerName, configure)registers it, with aBlobServiceClientand a key prefix of your choosing, or theBlobServiceClientregistered in your services. It supports everything the S3 provider does. Completing an upload session has Azure join its segments withPut Block From URL, reading them through a link signed with the account key or, with Microsoft Entra ID, a user delegation key. It counts writes retried after a concurrent change to the same name or session inresourcefull.azureblob.retries, stale expiry entries a sweep dropped inresourcefull.azureblob.stale_expiries, abandoned sessions and unnamed content the sweep deleted inresourcefull.azureblob.abandoned_uploads, joins of a session's segments inresourcefull.azureblob.session_joins, and, inresourcefull.azureblob.moves_recovered, moves the sweep finished or undid after their mover stopped. It's tested against Azurite. See Azure Blob Storage. - Resumable uploads from browsers and apps.
MapResourcefullUploadsmaps endpoints that speak tus 1.0 over upload sessions, with the creation, creation-with-upload, termination and expiration extensions, so tus clients such as Uppy and tus-js-client resume large uploads with no upload code of their own. A requiredResolveAsyncdecides each upload's container, name, mode, content type and metadata, or refuses it, andOnCompletedAsyncruns once the file is stored. A client is told an upload finished only once its version is stored: aCreateupload whose name exists is refused when it starts, a refused completion cancels the upload, and one that fails otherwise answers 503; when the client resumes, completing is tried again, and while it still fails the endpoints answer 423 Locked, which tus clients retry. Upload URLs are sealed with Data Protection and work only for the user who started them, and only with endpoints mapped with the same pattern for the same store (StoreNamepicks a named store). No request stores more than its upload has left, so the endpoints lift ASP.NET Core's request size limit for their own requests;MaxSizesets a limit of their own, andWithResourcefullUploadHeadersadds what they need to a CORS policy. Each request's span getsresourcefull.tus. The core gainsResourcefullOptions.AbandonedUploadsDeletedAfter, which the endpoints send asUpload-Expires.MapResourcefullUploadLinksmaps the same endpoints for upload links, so whoever holds one can resume an upload without signing in: the link's name or prefix, size limit, content types and mode are checked when the upload starts, and its expiry stops new uploads, not those under way. See Resumable uploads and Large files: resumable uploads. - Uploading in parts. An upload session stores a large file over several appends, and what's stored survives a
dropped connection or a restart:
StartUploadAsync, thenAppendUploadAsyncfrom each part's offset,GetUploadAsyncto find where to resume, andCompleteUploadAsync, which makes the version current with the write mode and conditions in itsResourceCompleteUploadOptions, asUploadAsyncdoes.CancelUploadAsyncgives up on one. An append that doesn't start where the stored content ends throwsResourceUploadOffsetException(409 Conflict in ASP.NET Core), so a part is never stored twice. Completing reads the content back once to hash and inspect it. The expiry sweep deletes a session nothing was appended to for a day, and the same goes for what a stopped upload left behind, which used to be swept after an hour. Providers support sessions by overriding six newResourceRepositorymembers, andResourcefull.Testinggains aSupportsUploadSessionsswitch for those that don't; every built-in provider supports them. A session that declared a length completes only with that length appended (ResourceContentLengthMismatchExceptionotherwise), and theResourcefull_AddDeclaredLengthmigration adds the nullable column that keeps it. Each call records a span, and appends count bytes inresourcefull.client.appended. See Uploading in parts. - A conditions check for providers.
conditions.ThrowIfNotMet(resource, currentVersionId, currentContentHash), an extension method inResourcefull.Providers, makes the check every provider must make when a write has conditions: given the current version the write read, it throwsResourcePreconditionFailedExceptionif they aren't met. A provider can call it instead of writing its own check, and the built-in providers now do.ResourceConditions.IsEmptyis public too. See Writing a provider. - The contract tests as a package.
Resourcefull.Testingholds the tests every provider must pass, as an abstract xUnit v3 class (xunit.v34.0.2 or later): derive fromResourcefullContractTests, register your provider, and the suite runs against it. A provider can leave out optional features: the suite skips those features' tests, and other tests check that the clients refuse them withNotSupportedException. For expiry, retention and the bin, the suite reads the provider's own flags. For container metadata, changing a version's metadata and content type, moves, imports and upload sessions, the provider's test class turns off aSupports…switch. A switch added after 4.0 is off by default.TimePrecisionandNameOrderdescribe a store that keeps times less precisely or orders names differently. See Testing a provider. - Several stores in one application.
AddKeyedResourcefull("archive", …)adds a store under a name, beside the oneAddResourcefulladds, with its own provider and settings; resolve its client with[FromKeyedServices("archive")]. Each store has its own policies, URI provider, upload inspector, cleanup handler and expiry sweep.MigrateResourcefullDatabaseAsync,GenerateResourcefullScript, the SQL Server hash backfill and both health checks take a store's name. Spans and metrics from a named store carryresourcefull.store. Providers read the store's name fromResourcefullOptionsBuilder.StoreName, and a newUseProvideroverload takes a factory.IResourceServiceClient.StoreNamesays which store a client works with; a class that implements the interface implements it too. Signed links work with every store:AddResourcefullLinks("archive")adds a keyed signer for a named store, andResults.SharedResourceandResults.SharedUploadcheck a token with the signer of the store whose client they're given. A link made for one store doesn't open at another's endpoint. See Several stores. - Listing containers a page at a time.
ListContainersAsyncenumerates every container, or those whose names start with a prefix, fetching a page at a time, so it holds only one page in memory, even in a store with a container for each user.GetContainerPageAsyncreturns one page and a continuation token, for an API that pages for its own callers. Providers implementGetContainerPageAsyncin place ofGetAllResourceContainerInfoAsync, which returned every container. Each page records alist_containersspan withresourcefull.page_size. See Listing containers. - A schema for SQL Server's tables.
UseSqlServer(connectionString, configure, schema: "resourcefull")puts Resourcefull's tables, and its own migration history, in a schema of their own, so they don't mix with an application's tables indbo.MigrateResourcefullDatabaseAsynccreates the schema, and the newGenerateResourcefullScriptwrites the provisioning script for it, since the packaged scripts are fordbo. Without a schema nothing changes. Themigratespan recordsresourcefull.schema. See Choosing a schema, which also shows how to move existing tables. - Exporting and importing containers.
ExportContainerAsyncwrites a container, every version of every file, to a zip, andImportContainerAsynccreates a new container from one, keeping each version's ID andLastModified. Imports check the manifest and every file's hash and length, and delete what they created if anything fails. Providers getImportVersionAsync, which returns what it stored and whose default throwsNotSupportedException. With SQL Server, a version whose write hits a transient failure is retried, reading its file from the export again. While an import runs, its container's metadata hasresourcefull.import: in-progress, so one that never finished can be found; providers getSetContainerMetadataAsyncfor it, whose default leaves imports unmarked. Exports read a container 1,000 names at a time, throughGetVersionPageAsync, and write the manifest to a temporary file rather than holding it in memory; providers can answer it in one query, and its default asks for each name's versions in turn. See Backing up and moving containers. - Moving to another provider.
CopyContainerToAsynccopies a container to another store, such as one with another provider, with every version as it was. It stores through the destination's newImportContainerAsyncoverload, which imports from a stream of versions rather than a zip.CopyStoreToAsynccopies every container. By default a container the destination already has fails; withExistingContainers.SkipMatchingit's skipped once it's found to match version for version, and withExistingContainers.Updateit's brought up to date in place through the newImportVersionsAsync.Updatenever deletes the container.StoreCopyFailures.Stop, the default, stops at the first failure with aStoreCopyException;Continuereports every failure in the result. An import's mark now carries the time it was last written, inresourcefull.import_at, so a copy can tell an import still running from one that stopped. Copies recordcopy_containerandcopy_storespans and count versions inresourcefull.client.copy.versions;ImportVersionsAsyncrecords animport_versionsspan. See Moving to another provider. - Signed, expiring links.
Resourcefull.AspNetCorecan create links that let anyone download one file, without signing in, until they expire:AddResourcefullLinks,IResourceLinkSigner.CreateTokenAsyncandResults.SharedResource. Links are sealed with ASP.NET Core Data Protection, can't be changed or read, and show the version that was current when they were made unless they follow the current one. See Sharing files with links. - Upload links.
IResourceLinkSigner.CreateUploadTokencreates links that let anyone upload without signing in until they expire. A link uploads to one name or to names under a prefix, and can set a size limit, allowed content types, and whether an upload may replace a file (by default it can't).Results.SharedUploadserves them, checking the link's limits before the body is read, andCheckUploadreads a token. Upload and download tokens are sealed apart, so neither works as the other.ContainerPolicy.AllowsContentTypesays whether a policy allows a type. APOSTof amultipart/form-dataform, as a plain HTML<form>sends one, stores the files in the form: a link with a prefix stores each under the prefix and its file name, and a link to one name takes one file. Each file is checked against the link's types and refused once it passes the link's size limit. The first file refused stops the upload, and the JSON response lists the files stored. See Receiving files through links. - A dashboard.
app.MapResourcefullDashboard("/resourcefull").RequireAuthorization("admins")maps a page showing the health check's result, how many expired versions are waiting for the sweep, each container's usage against its policy, its largest versions, the versions that expire soonest, its files, and each file's versions, along with the JSON endpoints the page reads, which can serve as an admin API. It refuses to run until authorization is required on it, a fallback policy covers it, or it's markedAllowAnonymous(). For development,ResourcefullDashboardOptions.AllowChangeslets it upload, delete and restore files and versions, change a version's expiry, content type and metadata, and create and delete containers.DownloadLinkPathandUploadLinkPathlet it create links. Each change needs a header that the dashboard's page sends and another site's page can't, and is logged with the user who made it. See The dashboard. - Versions by size or expiry.
GetTopVersionsAsync(ResourceVersionOrder.LargestFirst)lists a container's largest versions, current and older, to show what's filling it, andResourceVersionOrder.ExpiringFirstthe ones that expire soonest.IResourceServiceClient.GetExpiredVersionsAsynclists the expired versions the sweep hasn't deleted yet, in every container, to show whether it's keeping up. Providers answer the first through a newResourceRepository.GetTopVersionsAsync, whose default reads every version; SQL Server and PostgreSQL answer it in one query. The two methods recordlist_topandlist_expiredoperations. - Expiring resources.
ResourceUploadOptions.ExpiresAtsets when a version expires, andUseExpiryruns a background service that deletes expired versions within one interval, on every instance safely.SetExpiryAsyncchanges or clears a version's expiry, or the current version's with anullversion ID. AnIResourceCleanupHandlerhears about every version Resourcefull deletes by itself, expired or pruned, before it's deleted, for auditing. With expiry on, pruning sets a version to expire five minutes later instead of deleting it, so a download already reading it finishes. TheAddExpiresAtmigration adds a nullable column and an index. Providers getSupportsExpiry. When it'sfalse, a write that sets an expiry throwsNotSupportedExceptionandUseExpiryfails when the application starts. Providers also get three members whose defaults throwNotSupportedException, andDeleteExpiredVersionsAsync, which a sweep deletes each page of up to 100 expired versions through: with SQL Server, one transaction for the page (more when its versions hold over 256 chunks, about 10 MB, so no transaction logs a page of large files): 16 expired 1 MiB files take 14 round trips, where one version at a time took 98. Its default deletes them one at a time. See Expiring resources. - Retention dates and legal holds.
ResourceUploadOptions.RetainUntilkeeps a version from being deleted until a date, andLegalHolduntil the hold is lifted;SetRetentionAsyncmoves a version's date later (never earlier), andSetLegalHoldAsyncsets or lifts its hold.ContainerPolicy.RetainForgives every new version in a container a date. While either applies, a delete of the version, its name or its container throwsResourceRetainedException(409 with the ASP.NET Core exception handler) and deletes nothing (unless the version was protected while a batched delete was under way), expiry leaves it, and pruning keeps it pastMaxVersions. Moves keep both, and exports and imports carry them. Every provider supports them, deciding in the delete itself, so a hold set at the same time can't lose the race; theResourcefull_AddRetentionmigration adds two columns and two small indexes. Refused deletes are counted inresourcefull.client.delete.refused. Providers getSupportsRetention,SetRetentionAsyncandSetLegalHoldAsync, and with one that doesn't support them, a write that would protect a version throwsNotSupportedException. See Deleting safely. - The bin.
ContainerPolicy.KeepDeletedForkeeps deleted and pruned versions in their container's bin for that long.UndeleteAsyncbrings them back as older versions, andListDeletedAsyncandGetDeletedPageAsynclist them, newest deletion first, withResourceInfo.DeletedAtandPurgeAt. The expiry sweep purges each version at itsPurgeAt, fixed when it was deleted, telling the cleanup handler first (ResourceCleanupReason.Purged), andPurgeDeletedAsyncpurges one early. Expired versions and container deletes skip the bin, and the bin doesn't count towardMaxContainerSizeorMaxResourceCount:ContainerUsagereports it separately, asDeletedSizeandDeletedVersionCount, and so does the dashboard.KeepDeletedForneedsUseExpiry. Every provider has a bin; theResourcefull_AddBinmigration adds two columns and two small indexes. The bin recordslist_deleted,undelete,purge_deletedandpurgespans and theresourcefull.client.purged,purge.failuresandundeletedcounters. Providers getSupportsSoftDeleteand six members, and with one that doesn't have a bin, a delete in a container that keeps deleted versions throwsNotSupportedException. See Deleting safely. - Finding resources by metadata.
UseMetadataSearch("customer")names the metadata keys a listing can search by, andResourceListOptions.MetadataMatch = new ResourceMetadataMatch("customer", "42")lists the resources whose current version has that value, in name order, by page, with a prefix if you like. Every provider supports it: SQL Server, PostgreSQL and SQLite keep the index in aResourceMetadatatable, which theResourcefull_AddMetadataSearchmigration adds, and the file system, S3 and Azure Blob keep an index entry for each value, so a search reads only the matches. A searchable key's value is limited to 256 characters. The container client'sIndexMetadataAsyncindexes versions stored before a key was named. It has anindex_metadataspan and counts what it indexed inresourcefull.client.metadata.indexed; a search'slist_pagespan carries the key searched, never its value, and the file system, S3 and Azure Blob count the stale index entries a search dropped inresourcefull.*.stale_search_entries. S3's key prefix can be at most 190 bytes of UTF-8, and Azure Blob's at most 190 characters, to leave room for the index entries. Providers getFindResourcePageAsync, whose default reads every name, andIndexMetadataAsync. See Finding resources by metadata. - Copy, move and rename.
CopyResourceAsynccopies a version to a name in the same or another container, as a new version there, andMoveResourceAsyncmoves a resource with every version, keeping their IDs; renaming is a move within the container. With SQL Server, no content passes through the application. A copy can be create-only or conditional, as an upload can. A copy or restore records its source in the newResourceInfo.CopiedFrom. Providers getCopyResourceAsync, with a default implementation, andMoveResourceAsync, which throwsNotSupportedExceptionuntil implemented. TheAddCopiedFrommigration adds three nullable columns.RenameResourceAsyncrenames within the container, andIResourceContainerClient.Namegives the container's name.RestoreVersionAsyncrestores throughCopyResourceAsync, so with SQL Server a restore runs within the database and is retried after a transient failure (for a version stored before 3.0, once the hash backfill has recorded its hash). See Copying and moving. - Container policies.
UseContainerPoliciessets limits for every container, andUsePolicyProvidergives any container its own, such as a quota for each user's container that an admin can change while the application runs:MaxResourceSize,MaxContainerSize,MaxResourceCount,AllowedContentTypesandReadOnly. Sizes areResourceSizevalues, such asResourceSize.Megabytes(100), or"100 MB"in configuration. A refused write throwsResourcePolicyViolationExceptionand stores nothing, andAddResourcefullExceptionHandlerturns it into 413, 409, 403 or 415. When the policy provider fails, a container's last known policy stands in for up to 5 minutes (keepLastKnownFor).GetUsageAsyncreports what a container holds, andGetPolicyAsyncthe limits its writes are checked against. Providers getGetContainerUsageAsyncwith a default implementation, so providers written for 3.1 keep working. See Container policies. - Inspecting uploads. An
IResourceUploadInspector, registered withUseUploadInspector, reads each upload's and imported version's content while it's stored, so it can scan it for malware or check that it's the type it claims, and refuse it. No version becomes current until it answers; a refusal fails the upload withResourceContentRefusedException, whichAddResourcefullExceptionHandlerturns into 422. The content is read once and never held whole, and with SQL Server nothing is locked while the inspector decides. Providers must read the content to its end before making a version current, which every built-in provider does. See Inspecting uploads, with examples for ClamAV and Windows AMSI. - Changing metadata and content type.
SetResourceMetadataAsyncandSetContentTypeAsyncchange a version's metadata or content type in place, without uploading it again: it keeps its ID, content,LastModifiedand ETag, and no new version is made. A new content type is checked againstAllowedContentTypes, and a read-only container refuses both.IResourceContainerClient.SetMetadataAsyncreplaces a container's metadata, which could only be set when the container was created. Providers getSetResourceMetadataAsyncandSetContentTypeAsync, whose defaults throwNotSupportedException. See Changing metadata and content type. - Exceptions say what they're about.
ResourcefullExceptionhasContainerNameandResourceName, set by Resourcefull's providers and filled in by the clients for a provider that doesn't. A container conflict has noResourceName, so it can be told from a resource conflict without reading the message.ResourceNameExceptionhasName, the name that broke the rules. Every message names the container or resource it's about, in one wording. See Errors. - Settings from configuration.
UseConfiguration(section)reads versioning, expiry, telemetry and container policy settings from configuration, such as appsettings.json. Each section present turns its feature on, and a key that isn't a setting stops the application at startup. See Settings from configuration. - Range downloads. A
DownloadStreamingAsyncoverload takes aResourceRangeand reads only those bytes from storage, for resuming downloads and seeking in media; every download's result now hasTotalLength, andRangefor a range.Results.Resource(container, name)inResourcefull.AspNetCorelooks the resource up itself, the current version or theversionIdgiven, and answersRangerequests with 206 Partial Content, honoringIf-Range, and 416 for a range past the end. A range past the end throwsResourceRangeNotSatisfiableException, with the content's length, whichAddResourcefullExceptionHandleranswers with 416. Providers get a range overload ofGetResourceStreamAsyncwith a default implementation, so providers written for 3.1 keep working. See Downloading part of a resource. - Health checks.
AddHealthChecks().AddResourcefullCheck()reports whether storage can be reached through any provider, andAddResourcefullSqlServerCheck()also reports Unhealthy when the database is missing migrations this release needs, and Degraded when a newer release has upgraded it. See Health checks. - Telemetry. Every client operation records a trace span and metrics, through
ActivitySourceandMeternamedResourcefull: each operation's duration, and the bytes uploaded and downloaded, tagged by operation, provider and error type. SQL Server's migrations and hash backfill record spans. Resource names, and failure messages that may contain them, go on spans only withUseTelemetry(t => t.RecordResourceNames = true). Counters for failed prunes, expired versions deleted and failed, cleanup handler failures, and SQL Server's retries show work that doesn't fail the call that started it. Nothing is measured until something subscribes. See Telemetry. - Pruning a whole container. The container client's
PruneAsyncprunes every resource in its container toMaxVersions, as each upload prunes its own name, for after an import or a failed prune. See Versioning. - SQL Server options.
UseSqlServer(connectionString, sql => ...)changes EF Core's SQL Server options after Resourcefull's own, such as the command timeout or the retries. See SQL Server. - Disposable downloads.
ResourceDownloadResultimplementsIDisposableandIAsyncDisposable, disposing its stream, soawait usingworks on the download itself. - Razor Pages. The results
TypedResults.ResourceandTypedResults.SharedResourcereturn are also MVC action results, so a Razor Pages handler, which can't return anIResult, can return them. See MVC, Razor Pages and Blazor.
Changed
- Consistent names in the core API. Parameters:
containerNamereplacesresourceContainerName,destinationContainerNamereplacesdestinationContaineronCopyResourceAsyncandMoveResourceAsync,countreplacesmaxVersionsonGetExpiredVersionsAsync, andstoreNamereplacesnameonAddKeyedResourcefull. Types:IResourceUriProviderreplacesIUriProvider;ResourceUriContextreplacesResourcefullContext, is sealed, and renamesResourceVersionIdtoVersionId; andContainerPolicy,ContainerUsage,ContainerExportResultandContainerImportResultreplaceResourceContainerPolicy,ResourceContainerUsage,ResourceExportResultandResourceImportResult. Members:ResourceCleanup.VersionreplacesResource, andResourceListOptions.MetadataMatchreplacesMetadata.CreateContainerAsync,CreateContainerIfNotExistsAsyncandSetResourceMetadataAsynctakeIReadOnlyDictionary<string, string>. Policies inappsettings.jsondon't change. - Client interfaces have no defaults that only throw. A class that implements
IResourceContainerClientorIResourceServiceClientmust now implement every member that used to throw by default, so forgetting one is a compile error rather than aNotSupportedExceptionat run time. OnlyListResourcesAsync,ListDeletedAsync,ListContainersAsyncandRenameResourceAsynckeep defaults, which work through the other members. A member added after 4.0 gets a default. - Sweeps have clearer names, and can't skip a version. The service client's
PurgeDeletedAsync()is nowPurgeDueAsync(), which pairs withDeleteExpiredAsync. It purges only versions whosePurgeAthas passed, while the container client'sPurgeDeletedAsync(name, versionId)purges right away.GetExpiredAsync,GetPurgeableAsync,GetDeletedPageAsyncandDeletedVersionPage.Nextuse aResourceVersionKey(time, container, name and version ID) in place of a time and version ID. Copying a container keeps version IDs and expiries, so two versions can share both, and a sweep whose batch ended between them left the second until the next sweep.DeleteExpiredVersionsAsynctakes and returnsResourceVersionAddressrecords in place of tuples. TheResourcefull_SweepKeysmigration rebuilds three indexes with the extra columns. Counts of versions arelongthroughout:DeleteExpiredAsync,PurgeDueAsync,PruneAsync,IndexMetadataAsync,ContentHashBackfillResult.Hashed, and the provider'sIndexMetadataAsync,DeleteAbandonedUploadsAsync,UndeleteAsyncandPurgeDeletedAsync. - One method per operation. Each operation of
IResourceContainerClientandIResourceServiceClientfrom before 3.1 is now one method, with its optional parameters and then an optionalCancellationTokenlast, rather than a pair of overloads where only one took a token. A token can now be named without passingnullfor the parameters before it:UploadAsync("a", data, cancellationToken: token).DownloadStreamingAsynctakes its range, andDeleteResourceIfExistsAsyncits conditions, as optional parameters before the token, so a token passed by position after a version ID needs naming.UploadAsync'suploadOptionsparameter is nowoptions.GetResourcesAsync, which read a whole container into memory, is removed, and with it thelistspan; useListResourcesAsync. See Upgrading to 4.0. - Uploads say whether they replace.
ResourceUploadOptions.ModeandResourceCopyOptions.Mode, aResourceWriteMode, replaceOverwriteandCreateOnly:Create, the default, refuses a name that exists, andReplacestores a new current version, whether or not versioning is on. With versioning on, an upload of an existing name is now refused unless it asks to replace. Conditions needReplace.ContainerPolicygainsMaxVersions, so each container can keep its own number of versions;UseVersioningsets the default for the rest.ResourcefullOptions.IsVersioningEnabledis gone.RestoreVersionAsyncworks with versioning off, as long as the version exists. Theuploadandcopyspans gainresourcefull.write_mode.UploadFromRequestAsyncstill replaces, as aPUTdoes. See Upgrading to 4.0. - Provisioning works for any provider.
MigrateResourcefullDatabaseAsyncmoves fromResourcefull.SqlServertoResourcefull, beside the newGenerateResourcefullScript, and a provider that keeps a database registers aResourceDatabasefor them to work on. For a store on the in-memory provider, migrating now does nothing rather than throwing. See Upgrading to 4.0. - Adding Resourcefull twice throws. A second
AddResourcefullreplaced the provider and kept the first call's other settings; it now throwsInvalidOperationException. Add a second store withAddKeyedResourcefull. See Upgrading to 4.0. - A container client holds only the container's name.
IResourceContainerClient.Infois gone, so changing it can no longer send the client's calls to another container, andNameis the container's name.GetInfoAsyncreads the container's attributes as they're stored now (Infoon a client fromGetContainerClienthad empty metadata). The operations on one existing container are on its client:GetInfoAsync,SetMetadataAsync,PruneAsyncandIndexMetadataAsync, in place of the service client'sGetContainerInfoAsync(GetResourceContainerInfoAsyncin 3.x),SetContainerMetadataAsync,PruneContainerAsyncandIndexMetadataAsync.ContainerInfois a sealed record withinitproperties and read-only metadata, likeResourceInfo, so changing its metadata can't look like it changed what's stored.ResourceInfoandContainerInfocompare their metadata by its entries, so two reads of the same version, or of the same container, are equal. Telemetry's operation names don't change. See Containers. - Consistent names. The container methods drop "Resource":
CreateContainerAsync,CreateContainerIfNotExistsAsync,DeleteContainerIfExistsAsyncandGetContainerClient, beside the existingContainerExistsAsync.GetResourceContainersAsync, which read every container into memory, is replaced byListContainersAsync, which lists a page at a time.ResourceContainerInfoisContainerInfo.SetUriProviderisUseUriProvider.ResourceUploadOptionshasinitproperties. The provider types move toResourcefull.Providers, and the provider's container members are renamed to match. Telemetry's operation names don't change. See Upgrading to 4.0. - Stricter names. Uploads, copies, moves and renames, and creating or importing a container, refuse new names with
control characters, Unicode's bidirectional controls or unpaired surrogates, and
ResourceNames.IsValidContainerNameandIsValidResourceNamenow returnfalsefor them. Names already stored stay readable and deletable, resources with them can be renamed, and exports of them can be imported. See Names. - The stored
Uricolumns are dropped. TheResourcefull_DropStoredUrimigration dropsUrifromResourcesandResourceContainers;Uricomes from your URI provider on every read, as it has since 3.0. 3.x applications still read and write the columns, so they fail against an upgraded database: stop them before migrating, and roll back by restoring a backup. See Upgrading to 4.0. - Providers derive from the
ResourceRepositorybase class, which replaces theIResourceRepositoryinterface. Its members take aResourceAddressrather than a container name and a resource name, returnIReadOnlyList<T>rather thanList<T>, and take metadata asIReadOnlyDictionary<string, string>. A write is given aNewResourceVersionrather than aResourceInfo; it holds only what the write's caller can know.AddResourceAsyncandDeleteResourceAsynceach have one overload, taking an optional condition, so every provider decides conditional writes itself. Providers no longer storeUri.GetResourcePageAsyncis required, andGetAllResourceInfoAsync, which read a whole container, is gone. A provider that indexes metadata for search gets the keys through the base class's constructor, and a provider that wraps another passesFindResourcePageAsyncandIndexMetadataAsyncon, so the wrapped provider's index answers searches. The helpers for providers,ThrowIfNotMetandResourceRetainedException.Create, are extension members inResourcefull.Providers. See Upgrading to 4.0. ResourceInfocan't be changed: it's a sealed record withinit-only properties, and itsMetadatais anIReadOnlyDictionary<string, string>. Change a stored version withSetResourceMetadataAsyncorSetContentTypeAsync, or make a changed copy withwith. See Upgrading to 4.0.IResourceContainerClientisn'tIDisposable, since itsDisposedid nothing; removeusingwhere you callGetContainerClient.- Uploads to SQL Server hold nothing while their content arrives. An upload stores its version staged, hidden
from every read, then its content a MiB at a time, each batch committing on its own on a connection it gives back,
and makes the version current in a short transaction at the end. So a slow client no longer holds a connection,
locks and the transaction log for as long as it takes, and a transient failure partway sends the batch again rather
than failing the upload, even from a stream that can't seek, such as a request body. A canceled or failed upload
deletes what it stored; the expiry sweep deletes what an upload whose process died left behind, a day after it
last stored anything. An upload whose staged version is deleted before it completes, as by a container delete at the
same time, throws
ResourceUploadInterruptedException(409 with the ASP.NET Core exception handler) and stores nothing. Each upload costs about one round trip more. TheResourcefull_AddStagedAtmigration adds a nullable column and an index. Providers getDeleteAbandonedUploadsAsync, whose default does nothing. The upload span gainsresourcefull.batches, and theexpirespanresourcefull.abandoned_uploads. See Uploads, connections and the log. UseVersioningwith fewer than one version throwsArgumentOutOfRangeException, asUseExpiryandUseContainerPoliciesdo for a value out of range, rather thanInvalidOperationException.- The core package has three new dependencies, for features new in this release:
Microsoft.Extensions.Caching.Memory(container policies' cache),Microsoft.Extensions.Diagnostics.HealthChecks(AddResourcefullCheck) andMicrosoft.Extensions.Hosting.Abstractions(theUseExpirysweep). ASP.NET Core applications already have them; others take them on upgrading. - Downloads are safer by default.
Results.ResourcesendsCache-Control: private, no-cache, so shared caches such as CDNs no longer keep files (setResourceResultOptions.CacheControlto cache public files), andX-Content-Type-Options: nosniff.Inlinenow displays only types that are safe to show (common image formats (PNG, JPEG, GIF, WebP, AVIF and BMP; not SVG), PDF, audio, video and plain text) and offers anything else as a download;InlineContentTypeschanges the list. See Serving downloads. - Deleting a resource with many versions on SQL Server happens in batches that each commit, oldest first and the current version last, rather than in one transaction, so it no longer holds the whole history in one transaction. A delete that's canceled or fails partway leaves the rest whole, and calling it again finishes it. Deleting a large version also no longer locks the whole content table while it runs. See Versioning.
- A new look. The packages have a new icon, and the documentation site has a new design, a home page, and a sidebar grouped by what you're trying to do. Pages that covered several topics are split into one page each; links to the old pages still work.
ResourceInfo.VersionIdis non-nullable, since every stored version has one, so passing it to methods that take a version ID no longer needs!. Code that checked it fornullstill compiles. It's alsorequired, so a provider must set it on everyResourceInfoit builds.- Deleting a large container on SQL Server is faster: each batch deletes its versions in one round trip without loading them first. A container of a million files deletes in about a third less time, with about 40% fewer round trips and allocations.
- SQL Server stores content in about 11% less space. New content goes in 40,200-byte chunks, which fill SQL Server's pages exactly; the 65,535-byte chunks of earlier releases also took a page of pointers each. Content already stored keeps its chunks and stays readable. Uploads also allocate about half the memory they did. See How SQL Server stores files.
Fixed
- Creating or deleting a container on SQL Server no longer reports failure after succeeding. When the database committed but the connection failed before saying so, the automatic retry ran again and reported a conflict for a create, or a concurrency error for a delete; now it recognizes its own success, as uploads already did.
- A file moved out of a container while the container was being deleted on SQL Server could be deleted from its new container. The delete now removes only what's still in the container it's deleting.
- A write on SQL Server whose wait for its name's write lock lost a deadlock or timed out failed with "Could not lock the resource name for writing" instead of being retried, as any other deadlock is. It now raises error 50001, which the retry policy retries.
- A download's file name leaves out control characters and the characters that change text direction, which a
resource name can hold. A right-to-left override (U+202E) could make a download of
invoice+ U+202E +fdp.exeshow asinvoiceexe.pdf, and a name with a line break failed with an error instead of downloading. - The library's own awaits no longer risk a deadlock when the caller blocks on a call. A desktop app, or any other app
with a synchronization context, could hang when it waited for a call with
.Resultor.Wait(), because the library's own awaits resumed on that context. They now resume on the thread pool.
3.1.0 - 2026-10-29
New features, all additive: code written for 3.0 keeps compiling, and custom providers keep working. The one thing to
check is a test that expects exactly ArgumentException for a bad name; it's now the subclass
ResourceNameException.
Added
- Conditional uploads and deletes.
ResourceUploadOptions.Conditionsand aDeleteResourceIfExistsAsyncoverload takeResourceConditions, so a write goes ahead only if the name's current version still has the version ID or content hash you read. The write decides it, so of concurrent writers that read the same version exactly one succeeds; the rest throw the newResourcePreconditionFailedException(HTTP 412). See Updating only if unchanged. Resourcefull.AspNetCore, a new package for serving and accepting files.Results.Resource(download)sends a download withContent-Length, a strongETag,Last-ModifiedandContent-Disposition, and answers conditional andHEADrequests.UploadFromRequestAsyncstores a request body, honoringIf-Match,If-None-MatchandIf-Unmodified-Sincerace-free.AddResourcefullExceptionHandlerturns Resourcefull's exceptions into problem details with the right status codes. See ASP.NET Core.ResourceNameException, thrown for a name that breaks the name rules. It derives fromArgumentException, so existingcatch (ArgumentException)code still catches it, but a test that expects exactlyArgumentExceptionneeds updating.- Listing by prefix, a page at a time.
ListResourcesAsyncenumerates a container's current resources, or those under a prefix, fetching pages as you go, andGetResourcePageAsyncreturns one page with a continuation token for APIs that page for their own callers. With SQL Server a page costs the same wherever it starts. See Listing resources. - For provider authors:
IResourceRepository.GetResourcePageAsync, whose default implementation sorts and filters the full listing, so providers written for 3.0 keep working until they answer it directly. - For provider authors: conditional overloads of
IResourceRepository.AddResourceAsyncandDeleteResourceAsync, with default implementations, so providers written for 3.0 keep compiling and refuse conditional writes withNotSupportedException.
Fixed
- With SQL Server, a delete whose commit succeeded but whose acknowledgment was lost to a transient error, and was retried, reported that nothing was deleted. It now reports the delete.
3.0.0 - 2026-10-29
The first release of Resourcefull for general use. Coming from an earlier release, read Upgrading to 3.0 first: it lists every change to code, behavior and the database, with what each needs from you. Items marked (from 1.x) were already in 2.x.
Added
- Content hashes. Every version records the SHA-256 of its content, measured as it's written, as
ResourceInfo.ContentHash.ContentHashBackfillrecords hashes for versions stored earlier. - Measured lengths.
ContentLengthis measured from the stored content, and an upload whose declared length differs throwsResourceContentLengthMismatchExceptionand stores nothing. - Cancellation. Every method that contacts storage has an overload that takes a
CancellationToken. (from 1.x) - Create-only uploads with
ResourceUploadOptions.CreateOnly, decided by the write, so concurrent ones can't both succeed. - Provisioning from code with
MigrateResourcefullDatabaseAsync, into any database the connection string names. - Writing a provider.
IResourceRepositoryis public, with a documented contract. - Name checks.
ResourceNames.IsValidContainerNameandIsValidResourceNamecheck a name against the rules before you use it. - Logging. A version prune that fails after its upload was stored is logged as a warning.
- Documentation for every concept, both providers, ASP.NET Core and upgrading, and an API reference generated from the XML documentation that ships in the packages.
Changed
- Methods return values and throw exceptions instead of returning
ErrorOr, and several were renamed. (from 1.x) - Names are case-sensitive and limited to 128 characters for a container and 256 for a resource, with no whitespace at either end.
- Deleting a name without a version ID deletes all its versions.
- New version IDs are GUIDs (older ones stay valid), to compare rather than parse, and storage decides which version is current and the order versions are listed in.
- Uploads read from the stream's current position, without rewinding it first.
LastModifiedis when the version became current, by the storing server's clock.- Download streams can't seek, and with SQL Server, a download whose version is deleted midway throws
IOException. Uricomes from the URI provider whenever a container or version is returned, so a changed base URL applies to everything already stored.- The SQL Server package's EF Core migration classes are internal.
- SQL Server provisioning scripts no longer name a database, and ship in the package's
scriptsfolder rather than being added to consuming projects.
Fixed
- Concurrent uploads of one name always leave exactly one current version.
- Uploads and downloads of any size use a fixed amount of memory, and uploads make about half as many round trips.
- An upload into a container deleted moments before reports the missing container. (from 1.x)
- Deleting a large container works in bounded batches, and can be resumed if it stops partway.