Unified.Data.Tables
0.9.0
dotnet add package Unified.Data.Tables --version 0.9.0
NuGet\Install-Package Unified.Data.Tables -Version 0.9.0
<PackageReference Include="Unified.Data.Tables" Version="0.9.0" />
<PackageVersion Include="Unified.Data.Tables" Version="0.9.0" />
<PackageReference Include="Unified.Data.Tables" />
paket add Unified.Data.Tables --version 0.9.0
#r "nuget: Unified.Data.Tables, 0.9.0"
#:package Unified.Data.Tables@0.9.0
#addin nuget:?package=Unified.Data.Tables&version=0.9.0
#tool nuget:?package=Unified.Data.Tables&version=0.9.0
Unified.Data.Tables
A small, reusable Azure Table Storage data layer for .NET: a generic IStorage<T> repository with
configurable in-memory caching, optimistic concurrency, upserts and batch transactions, bounded
prefix queries, builder-driven partial updates, role-gated properties, legacy-column aliases, and a
reflection-based object-graph serializer that transparently handles nested types and the 64 KB
per-cell limit — plus a semantically faithful in-memory backend for tests.
It exists so the same battle-tested storage primitives can be shared across projects instead of being copy-pasted into each one.
Features
- Generic repository —
IStorage<T>over Azure Tables, one table per entity type (typeof(T).Name), created lazily on first use (EnsureCreatedAsync()for fail-fast hosts). - Reflection-based serializer — flattens nested objects into
Parent_Childcolumns, stores enums as strings and money-styledecimals as doubles, and falls back to JSON (then GZip) for collections and complex graphs. Oversized string and JSON cells are compressed, and an oversized payload throws rather than being trimmed (a plainbyte[]cell is never compressed: one above 64 KB is refused by the service with a 400PropertyValueTooLarge, notOversizedCellException). Opt a property into[TableValueSplit]to store arbitrarily large values losslessly. - Large values & blob offload —
[TableValueSplit]stores strings and byte arrays far beyond the 64 KB cell limit as split cells, continuation rows or an Azure Blob, SHA-256 verified on read;TableBlobContentstores files as blobs with a lazy stream; an optional hosted sweep removes orphan blobs. Opt-in; types that use neither are stored exactly as before (roll forward only once a type opts in). - Configurable caching — reads are served from
IMemoryCacheper a registration-timeCachePolicy(Sliding,Absolute, orDisabled— per entity type or globally); writes keep the entity cache coherent and invalidate the relevant query caches automatically. - Optimistic concurrency (ETag) — a caller-supplied
ETagenforces strict concurrency; a lost race throws the provider-agnosticConcurrencyConflictException(map to HTTP 409). ExplicitConcurrencyMode.Strict/LastWriterWinsoverloads make intent greppable, andMutateAsync(id, e => e.Count++)packages the read → mutate → strict-write → retry loop that makes derived values (counters, unions) correct under concurrency. - Upsert & batches —
UpsertAsync(single round-trip insert-or-replace; a point read and a conditional write for a type with large values),CreateBatchAsync/UpsertBatchAsync(partition-grouped 100-row transactions),CountAsync(keys-only projection). - Bounded queries —
QueryAsync(QueryOptions)and streamingQueryStreamAsyncwith partition scope, canonical RowKey-prefix ranges, andTake— never cached, never a disguised full scan. - Server-side LINQ, paging & append logs —
QueryAsync(x => x.Status == Open)translates to an OData$filter(server-side, not a scan);QueryPageAsyncreturns a page plus a query-bound continuation cursor for grids and infinite scroll;AppendAsync/RecentAsyncgive the newest-N event-stream shape for free. The in-memory fake validates predicates through the same translator, so green tests hold on Azure. - Partial (Merge) updates —
UpdateAsync(id, builder)writes only the columns you declare (including nested paths:x => x.Address.City→Address_City), leaving the rest of the row untouched with no read required — concurrent writers touching disjoint columns never conflict.builder.WithETag(...)makes the merge conditional for column-level compare-and-swap. - Legacy column aliases —
[ColumnAlias]reads old column names (e.g. after a property rename) when the canonical column is absent; writes stay canonical, so rows converge without a migration job. - Protected properties — mark a property
[ProtectedProperty("admin,...")]and role-gate writes through a pluggableIProtectedPropertyAuthorizer(the package itself has no ASP.NET Core dependency). - Faithful in-memory backend —
Unified.Data.Tables.InMemoryround-trips rows through the REAL serializer with 409/412/404 and ETag semantics, so tests exercise production behaviour. - ASP.NET Core Identity stores —
Unified.Data.Tables.Identityimplements Identity'sIUserStore/IRoleStorefamily (users, roles, claims, logins, tokens, two-factor and lockout) on top ofIStorage<T>, registered with oneAddUnifiedIdentityStores()call — and, because it is justIStorage<T>, the whole Identity stack runs against the in-memory backend in unit tests. - Composite id convention —
Idis"{PartitionKey}|{RowKey}"(shared helpers inEntityId); row keys may themselves contain|.
Installation
dotnet add package Unified.Data.Tables
This is shipped as four packages:
| Package | Contents | Use it in |
|---|---|---|
| Unified.Data.Tables | TableStorage<T>, the serializer, cache policies, DI helpers (Azure dependencies) |
server / host projects |
| Unified.Data.Tables.Abstractions | Entity, IStorage<T>, QueryOptions, ConcurrencyMode, UpdateBuilder<T>, EntityId, [ColumnAlias], [ProtectedProperty] — no Azure/hosting deps |
shared/domain & Blazor WebAssembly projects |
| Unified.Data.Tables.InMemory | InMemoryStorage<T> — serializer-faithful in-memory IStorage<T> |
test projects, dev/offline mode |
| Unified.Data.Tables.Identity | ASP.NET Core Identity IUserStore/IRoleStore persisted through IStorage<T> |
hosts using ASP.NET Core Identity |
Unified.Data.Tables references the abstractions transitively, so most apps just install it. The
implementation packages pin each other exactly: Unified.Data.Tables depends on
Unified.Data.Tables.Abstractions [x.y.z] and Unified.Data.Tables.InMemory on Unified.Data.Tables
[x.y.z] (they share internals), so upgrade them together. Blob offload needs Azure.Storage.Blobs
12.29.2 or later; the optional hosted sweep uses Microsoft.Extensions.Hosting.Abstractions. In a browser-safe shared library that only defines entities and repository contracts, reference the abstractions alone.
Requires .NET 10 and an Azure Storage account (or the local Azurite emulator).
Quick start
1. Define an entity
Derive from Entity. Id is the composite "{PartitionKey}|{RowKey}"; CreatedAt, UpdatedAt,
ETag and the service-managed Timestamp are maintained for you.
using Unified.Data.Tables;
public sealed class Customer : Entity
{
public string Name { get; set; } = "";
public string Email { get; set; } = "";
public decimal Balance { get; set; }
}
2. Register it
using Azure.Data.Tables;
using Unified.Data.Tables;
// Option A — from a connection string:
builder.Services.AddUnifiedTableStorage(connectionString);
// Option B — managed identity:
builder.Services.AddUnifiedTableStorage(
new Uri("https://myaccount.table.core.windows.net"), new DefaultAzureCredential());
// Option C — you already register a TableServiceClient yourself:
builder.Services.AddSingleton(_ => new TableServiceClient(connectionString));
builder.Services.AddUnifiedTableStorage();
// Cache policy is a DEPLOYMENT concern — configure it per host:
builder.Services.AddUnifiedTableStorage(connectionString, o =>
{
o.Cache = CachePolicy.Absolute(TimeSpan.FromSeconds(30)); // bound cross-process staleness
o.CacheFor<ChatMessage>(CachePolicy.Disabled); // huge partitions — don't cache
});
All overloads register IMemoryCache, the configured options, and the open-generic
IStorage<T> → TableStorage<T> mapping as singletons.
Sharing tables across processes? A second process (worker, subprocess, sidecar) writing to the same tables makes
Slidingcaching unbounded-stale in the readers. UseCachePolicy.Disabledin secondary processes andAbsolutewith a short TTL in the primary.
Within one process the cache follows the writes made through it: once a write or delete this process
made has returned, the cache does not answer with an older version, except for the moment described
below while an earlier, overlapping write of the same entity is still running. This holds for every
entity type, across all stores of one entity type and table that share an IMemoryCache (the DI
registration shares one), and for the cached QueryAsync(partition) lists. Entries are keyed by entity
type and table (account endpoint plus table name). Stores of different tables can safely share one
IMemoryCache, and stores of the same table share entries:
- a read that is in flight while a write or delete of the same entity completes may still return the version it fetched, but it does not leave that version in the cache;
- when two writes of one entity overlap in time (create, update, upsert or
UpdateAsync(id, builder)), or a write and a delete do, neither keeps the entity cached: once both have returned, the nextOneAsyncorExistsAsyncreads the table. While an earlier write of an entity is still running, a read made after a later write or delete of that entity has returned can, for a moment, be answered with the earlier write's version; once both calls have returned, the store answers what the table holds. Use ETags (ConcurrencyMode.Strict,MutateAsync) for an entity that several callers write at once. Writes that do not overlap behave as before: the entity a write returns is cached with the version the service returned; - when a create, update, upsert, builder update or delete throws (a timeout or a cancelled token
included), the store cannot know whether the row was written, so it drops the entity's entry and its
partition's cached lists. A duplicate create (409,
DuplicateKeyException) is one of them, so the next read after it costs one point read: a 409 can be the SDK's retry meeting this call's own committed insert. If the cache itself throws while it does (a disposedMemoryCacheduring host shutdown, say), that failure is logged as a warning, and the exception the call throws is still the one the storage call produced, or the library's translation of it (DuplicateKeyException,ConcurrencyConflictException). A call refused by its own checks before anything is sent (an argument, a missing ETag inStrictmode, a protected property, a value of a plain type that cannot be serialized, a builder update of a large-value member) drops nothing. For a type with large values, a create, update or upsert that fails while its values are prepared (a stream that fails while it is read, a value too large to store, a cancelled token) also drops the entry, although nothing was written; the next read goes to the table; - after
UpdateAsync(id, builder)the store keeps the entity cached with the update applied, as 0.8.3 did, only when all of this holds: the entity was cached when the update was sent, and no read or other write has put another version of it into the cache since; every path of the builder names a top-level property; and every value is a non-null scalar of the property's own type that the update writes as one cell in the property's own column and that a read converts back to an equal value: astringthat theOversizedCellPolicydid not trim or compress, abool,int,uint,long,ulong,byte,double,Guid,TimeSpanor enum (or the nullable form of one), adecimalthat its storeddoubleconverts back to, aDateTimeOffsetother than the instant 1601-01-01T00:00Z, or aDateTimethat is neither local nor that instant. Equal means equal by the type's own equality (adecimalwhatever its scale, aDateTimewhatever itsKind, aDateTimeOffsetby its instant). A read the cache answers returns each value as a read of the row converts it: adecimalwith the scale a read gives (7.5for7.50), aDateTimewithKindUnspecified. ADateTimeOffsetkeeps the offset it was set with, where the table service returns the instant in UTC. 0.8.3 does the same. Then the store copies the cached entity once (its constructor and getters run once, as on 0.8.3) and sets the values on the copy. In every other case the store drops the entry without copying the entity, and the next read fetches the row: a nested path or a name that maps to no property, a null put intoUpdates, an object, a collection or another JSON-stored value (afloat,short,char,sbyteorushortis stored as JSON), abyte[], a trimmed or compressed string, adecimalthat its storeddoubledoes not convert back to (0.1234567890123456789m), the instant 1601-01-01T00:00Z (stored as an unset date, which a read returns as the default value), a localDateTime(stored as its UTC instant), or a value of another type than the property's. If the constructor or a getter throws while the copy is made, or the cache throws while the entry is put, the update has been stored and the entry is dropped before the exception leaves the call, as on 0.8.3 (aTargetInvocationExceptionaround the constructor's exception, or the cache's own); the next read returns the stored row. The entity kept cached takes the ETag the update returned only when the update was conditional on exactly the cached entity's version (WithETagwith the ETag of the entity this store holds). In every other case (noWithETag, aWithETagnaming another version, a response without an ETag) it keeps the ETag it had, as on 0.8.3. That ETag is older than the row's: aStrictupdate orMutateAsyncwith it meets a conflict, which drops the entry, and the retry reads the row. So after an unconditional builder update,OneAsynccan return the updated entity with its previous ETag; to hold the current ETag, use the string the builder update returns, or make the update conditional on the version you read. A cached entity never carries an ETag newer than its content, so an optimistic write of a cached entity never overwrites a change it has not seen; "a"and"a|a"address the same row and share one entry, andDeletePartitionAsyncevicts every row it listed, also when it fails part-way.
Each of these can cost one more point read. None changes a cell or the request of a write; what a read
returns changes only from a stale version to the current one, and a failed write whose cache eviction
itself throws now throws the storage call's exception (on 0.8.3 a Strict update that met a 412 over a
disposed cache threw ObjectDisposedException). What the cache does not cover:
- a write made by another process, or through a store over another
IMemoryCache: it is not seen until the cache entry expires, hence the note above. A store built withCachePolicy.Disabledover the cache of a caching store does not evict that store's entry on a successful create, update or upsert; - a write of an entity that does not round-trip through the serializer (it could not be read back either): it caches nothing and leaves the cached copy of the version it replaced.
3. Use it
public sealed class CustomerService(IStorage<Customer> storage)
{
public Task<Customer> Add(Customer c) => storage.CreateAsync(c);
public Task<Customer> Save(Customer c) => storage.UpsertAsync(c);
public Task<Customer?> Get(string id) => storage.OneAsync(id);
public Task<bool> Exists(string id) => storage.ExistsAsync(id);
public Task<IEnumerable<Customer>> InRegion(string region) => storage.QueryAsync(region);
public Task Remove(string id) => storage.DeleteAsync(id);
}
// PartitionKey = "eu", RowKey = "alice@example.com"
var customer = new Customer
{
Id = "eu|alice@example.com",
Name = "Alice",
Email = "alice@example.com",
Balance = 100m
};
await storage.CreateAsync(customer); // ids are normalized: trim → spaces to '-' → lower-case
var loaded = await storage.OneAsync("eu|alice@example.com");
var euOnes = await storage.QueryAsync("eu"); // scope to a partition (omit for the whole table)
IStorage<T>
| Method | Description |
|---|---|
CreateAsync(entity) |
Insert a new row (DuplicateKeyException when it exists); returns it with its populated ETag. |
UpsertAsync(entity) |
Insert-or-replace in one round trip. Unconditional by design (last writer wins); preserves a caller-supplied CreatedAt. For a type with large values it is a point read and a conditional write, retried on a lost race; ConcurrencyConflictException after five. |
OneAsync(id) |
Fetch one row by composite id, or null. |
ExistsAsync(id) |
Whether a row exists (cache-aware). It never assembles or downloads a large value: a row with continuation rows or an offloaded value, or one whose part count or in-row value is damaged, answers true and is not cached by this call. |
QueryAsync(partition?) |
All rows, or just one partition (the cached read path). |
QueryAsync(options) |
Bounded query: partition + RowKey prefix + Take. Never cached. |
QueryStreamAsync(options?) |
Streaming variant — never caches, never buffers. |
QueryPageAsync(options) |
One server page + an opaque, query-bound cursor for the next (Take = page size, default 100). Resumable grid / infinite-scroll paging. |
QueryAsync(predicate, partition?, take?) |
Server-side LINQ filter translated to an OData $filter — not a client-side scan. |
QueryStreamAsync(predicate, partition?, take?) |
Streaming variant of the LINQ filter. |
AnyAsync(predicate, partition?) |
Take(1) existence check for a server-side predicate. |
AppendAsync(partition, entity, subStream?) |
Extension: append a time-ordered event (inverted-ticks RowKey); the Id is assigned for you. |
RecentAsync(partition, count, subStream?) |
Extension: read the newest N events, newest-first — one bounded partition scan. |
UpdateAsync(entity) |
Full replace with adaptive (Auto) ETag concurrency. |
UpdateAsync(entity, mode) |
Full replace with explicit ConcurrencyMode. LastWriterWins on a type with large values can throw ConcurrencyConflictException after five lost races. |
UpdateAsync(id, builder) |
Partial Merge — writes only the declared columns (nested paths supported, optional WithETag); returns the new ETag. |
MutateAsync(id, e => …) |
Extension: read → mutate → Strict write, re-reading and re-applying on conflict (CAS). |
GetOrCreateAsync(id, factory) |
Extension: read-or-insert; a lost create race converges on the winner's row. |
MutateOrCreateAsync(id, create, mutate) |
Extension: insert-or-mutate CAS — the delta applies uniformly on first insert and on updates. |
TryMutateAsync(id, e => …) |
Extension: outcome-returning CAS — Updated / NotFound / Conflicted, nothing thrown for expected branches. |
TryTransitionAsync(id, when, apply) |
Extension: exactly-once transition as a result — the race-loser gets PreconditionFailed with the winner's row. |
CreateBatchAsync(entities) |
Transactional inserts, grouped by partition, 100 per transaction, atomic per transaction (DuplicateKeyException on an existing or repeated key). Types with large values too: an entity's continuation rows ride in its transaction. A failed transaction applies nothing; the partitions and transactions before it stay committed, on InMemoryStorage<T> as on Azure. |
UpsertBatchAsync(entities) |
Transactional insert-or-replace, same chunking; returns the number of entities passed. A plain type sends the rows as they are: a key named twice in one transaction fails it with the service's 400 InvalidDuplicateRow (a TableTransactionFailedException), and transactions already sent stay committed. A type with large values writes the last occurrence of a repeated key (it counts twice and is written once), reads each row first and replaces it conditionally; ConcurrencyConflictException after five lost races. |
CountAsync(partition?) |
Row count via keys-only projection (Tables has no server-side count). |
DeleteAsync(id) |
Delete one row (idempotent). For a type with large values it reads the row first and deletes it, with its continuation rows and blobs, on condition of the version read; ConcurrencyConflictException after five lost races. |
DeletePartitionAsync(partition) |
Batch-delete a whole partition in transactions of up to 100 rows; returns the number of entities deleted (continuation rows are not counted). Atomic per transaction only: call it again to finish one that failed. A write that races the delete leaves its entity gone, never a continuation row without its main row. |
SweepOrphanBlobsAsync(partition, olderThan) |
Extension (IOrphanBlobSweeper): delete offloaded blobs of the partition that no row references and that are older than olderThan; returns the count. See Large values. |
SweepOrphanBlobsAsync(olderThan) |
Extension (IOrphanBlobSweeper): the same for every partition of the table. |
Bounded queries
// Chat messages for one vision, bounded by RowKey prefix:
var messages = await storage.QueryAsync(new QueryOptions
{
Partition = visionId,
RowKeyPrefix = "msg_",
Take = 100,
});
// Stream a huge partition without buffering:
await foreach (var run in storage.QueryStreamAsync(new QueryOptions { Partition = visionId }))
Process(run);
Results arrive in lexical (PartitionKey, RowKey) order — encode any other order into your RowKeys;
RowKeys.InvertedTicks(now) makes later timestamps sort FIRST, so "most recent N" is just
QueryAsync(new QueryOptions { Partition = p, Take = n }) with no client-side sorting.
RowKeyPrefix requires Partition (a cross-partition RowKey range would be a full table scan).
Server-side LINQ filters
QueryAsync(predicate) translates a strongly-typed predicate into a server-side Azure Tables
OData $filter — the service does the filtering, not a client-side scan of the whole partition. The
translation maps to the stored representation: an enum compares against its string name, a
decimal against its stored double, and a nested x.Address.City against the flattened
Address_City column.
var open = await storage.QueryAsync(x => x.Status == Status.Open && x.Amount >= 100m);
var mine = await storage.QueryAsync(x => x.Owner == userId, partition: tenantId, take: 50);
if (await storage.AnyAsync(x => x.Email == email)) { /* ... */ }
Supported: == != < <= > >=, &&, ||, !, and a bare bool member, over string, bool,
int/uint/long/ulong, double, decimal, Guid, DateTime(Offset), and enums. Anything the
service can't filter — method calls (StartsWith, Contains), column-to-column comparisons, null
comparisons, or a JSON-backed property — throws NotSupportedException rather than silently degrading
to a scan. The in-memory fake validates the predicate through the same translator, so a green test
means the filter also runs on Azure.
To keep that "green fake ⇒ works on Azure" guarantee airtight, the translator also rejects the
handful of shapes where a server-side OData filter would disagree with in-memory evaluation: ordering
(</>) on an enum or ulong (stored form isn't order-preserving), inequality or a negated comparison
(!=, !(x == v), !(x >= v)) on a nullable-or-reference column (absent-column semantics differ between
Azure and in-memory evaluation), computed / get-only / [IgnoreDataMember] properties (no stored column),
and x.Nullable.Value (compare the member directly). Two remaining caveats are documented rather than blocked: decimal is stored and
compared as double, so equality can differ beyond ~15 significant digits (prefer range comparisons);
and a server-side filter on a property that still carries only a legacy [ColumnAlias] column won't match
until that row is rewritten. The partition argument is matched against stored (already-normalized) keys.
Resumable paging
QueryPageAsync returns one server page plus an opaque cursor bound to the query — the canonical
grid / infinite-scroll primitive, with no load-the-whole-partition-then-slice:
string? cursor = null;
do
{
var page = await storage.QueryPageAsync(new QueryOptions
{
Partition = visionId, Take = 50, ContinuationToken = cursor,
});
Render(page.Items);
cursor = page.ContinuationToken; // null when exhausted
}
while (cursor is not null); // loop on HasMore, not on Items.Count
The cursor is bound to its exact bounds (partition, RowKey prefix, page size) — replaying it against a
different query throws. There is deliberately no total count (Azure Tables has none; a total would
force a second full scan) — drive the UI off HasMore and use CountAsync only when you truly need a
number.
Append logs
For the "append an event, read the newest N in order" shape — events, chat, agent runs, audit — the
append helpers key rows with inverted ticks so the newest sort first, making RecentAsync a single
bounded partition scan:
await storage.AppendAsync("vision-42", new ChatMessage { Text = "hi" }, subStream: sessionId);
var latest = await storage.RecentAsync("vision-42", 20, subStream: sessionId); // newest first
The optional subStream lets one partition hold several independent streams (e.g. per session) that
RecentAsync isolates by RowKey prefix.
Versioned streams
For append-only, per-stream versioned snapshots — event-sourced read models, "state as of version N"
— derive from VersionedEntity (or implement IVersionedEntity) and use the versioned-stream
extensions. The stream id is the partition; the version becomes the RowKey via
RowKeys.VersionKey(version) — inverted and zero-padded (a stable wire format, byte-compatible with
the common hand-rolled int.MaxValue - version scheme), so the newest version sorts first and
every read below is a single bounded, server-side operation:
public sealed class OrderSnapshot : VersionedEntity { public string State { get; set; } = ""; }
await storage.AppendVersionAsync("order-42", new OrderSnapshot { Version = 3, State = "packed" });
// versions are immutable: appending an existing version throws DuplicateKeyException
var latest = await storage.LatestAsync("order-42"); // newest snapshot, 1 bounded read
var exact = await storage.AtVersionAsync("order-42", 2); // or null
var asOf = await storage.AtOrBeforeAsync("order-42", 5); // highest version <= 5 ("state as of")
await foreach (var s in storage.HistoryAsync("order-42", take: 10)) { /* newest first */ }
Like the append-log helpers, these are thin compositions over IStorage<T> — no new interface, no
separate backend — so caching, the outcome verbs, and the in-memory fake work unchanged. Throwing
variants (GetLatestAsync, GetAtVersionAsync, GetAtOrBeforeAsync) raise KeyNotFoundException.
For case-sensitive stream ids, configure IdNormalization.AsWritten (the all-digit version segment
is unaffected either way). Adopting a pre-existing inverted-key table: the key-addressed reads work
over legacy rows as-is, but AtOrBeforeAsync filters on the Version column (present on every row
the pack writes) — backfill it on foreign rows before relying on "state as of" there.
Partial updates
Only the properties you set are written; everything else on the row is preserved, and no read is
needed. Nested access writes the flattened column (Address_City), leaving sibling columns of the
nested object untouched.
await storage.UpdateAsync("eu|alice@example.com", b => b
.SetProperty(x => x.Balance, 250m)
.SetProperty(x => x.Address.City, "Lviv"));
// Conditional merge — column-level compare-and-swap:
var read = await storage.OneAsync(id);
await storage.UpdateAsync(id, b => b
.WithETag(read!.ETag!) // ConcurrencyConflictException if the row moved on
.SetProperty(x => x.Name, "Alice A."));
Optimistic concurrency
OneAsync/QueryAsync populate Entity.ETag. Pass it back on UpdateAsync(entity) for a strict
check — a concurrent modification throws ConcurrencyConflictException (the provider's 412 rides
along as InnerException; a type with large values reads the row first and, when that read already
shows another version, has no provider exception to attach), and the conflicting row's cache entry is
evicted so the next read is fresh.
var c = await storage.OneAsync(id);
c!.Balance += 100;
await storage.UpdateAsync(c); // ConcurrencyConflictException if the row changed since it was read
⚠️ Since 0.6.0,
UpdateAsync(entity)with no ETag throwsInvalidOperationException.Automode has no version to check against without one, and silently writing unconditionally was lost-update territory — so the contract violation now surfaces loudly instead. If you read, mutate a field, and write it back, round-trip the ETag (OneAsync/QueryAsyncpopulate it) — or useMutateAsync, which owns the read→mutate→strict-write loop for you. When you genuinely mean "make the row look like this object regardless of its current state", say so explicitly withUpdateAsync(entity, ConcurrencyMode.LastWriterWins). Migrating a large codebase? SetUnifiedTableStorageOptions.ImplicitLastWriterWins = trueto temporarily restore the pre-0.6.0 fallback (unconditional replace + a warning log) while you convert call sites.
For values derived from the current row (counters, unions, merges), use the packaged compare-and-swap loop — it re-reads and re-applies on conflict, so no increment is ever lost:
await storage.MutateAsync(id, e => e.OccurrenceCount++); // read → mutate → Strict write, ≤3 attempts with jittered backoff
A lost response can apply a mutation twice.
MutateAsync,TryMutateAsyncandMutateOrCreateAsynccan apply a mutation twice when the service applied the strict update but its response was lost (a timeout or a dropped connection). The SDK retries the request and gets 412, and the mutation is applied again to the re-read row. This is unchanged since 0.8.3. Make such mutations idempotent, or setTableClientOptions.Retry.MaxRetries = 0so that the lost response surfaces as an error. For example, record an operation id on the entity and skip it if already present:await storage.MutateAsync(id, e => { if (e.AppliedOps.Contains(opId)) return; // already applied by an earlier attempt e.AppliedOps.Add(opId); e.OccurrenceCount++; });
Outcome verbs — expected situations as return values
The expected branches of concurrent programs — already exists, gone, someone got there first — come back as return values instead of exceptions, so a forgotten catch can never turn an expected race into a 500:
// Idempotent create — a lost create race converges on the winner's row:
var member = await storage.GetOrCreateAsync(id, () => new MemberEntity { /* ... */ });
// Insert-or-mutate CAS — the delta behaves identically on first insert and on updates:
await storage.MutateOrCreateAsync(id,
create: () => new FeedbackEntity { OccurrenceCount = 0 },
mutate: e => e.OccurrenceCount++);
// Exactly-once transitions — the race-loser is an EXPECTED branch, not an exception:
var result = await storage.TryTransitionAsync(gateId,
when: g => g.Status == "open",
apply: g => { g.Status = "resolved"; g.ResolvedBy = userId; });
// result.Status: Updated | PreconditionFailed (carries the winner's fresh row) | NotFound | Conflicted
Losing the race to the same transition reports PreconditionFailed (the retry re-reads, sees
the precondition no longer holds, and returns the winner's row) — Conflicted is reserved for a
genuinely hot row whose precondition still held every attempt.
For a type with large values, a read whose row kept changing
throws ConcurrencyConflictException (see Ordering and integrity).
MutateAsync, MutateOrCreateAsync, TryMutateAsync and TryTransitionAsync count such a read as a
lost race, exactly like a conflict of their write: it uses one of the maxAttempts attempts and is
retried after the same backoff. When the attempts run out, TryMutateAsync and TryTransitionAsync
return MutationStatus.Conflicted (nothing thrown), and MutateAsync and MutateOrCreateAsync throw
ConcurrencyConflictException. Any other failure of the read (TableValueIntegrityException, a
cancelled token) leaves the helper at once. GetOrCreateAsync has no attempt budget: a
ConcurrencyConflictException from its read leaves it, as it leaves OneAsync.
Choosing a write strategy (concurrency cookbook)
| Your write looks like… | Use | Why |
|---|---|---|
| Writers touch different fields of the same row (PATCH endpoints, per-subsystem status fields, progress columns vs. approval flags) | UpdateAsync(id, builder) |
Merge is atomic per request; disjoint columns from concurrent writers both land — no ETags, no retries, no clobber |
| The new value is computed from the current one (counters, evidence unions, weighted merges) | MutateAsync(id, e => …) |
Merge would persist a value computed from a stale read; CAS re-reads and re-applies until it wins |
Mutating inside a JSON-serialized column (an item in a List<> property) |
Remodel as row-per-item (RowKey prefix + QueryOptions), or MutateAsync |
The column is the unit of atomicity — two writers editing different items of one list still clobber |
| A transition that must happen exactly once (approve, resolve, finalize) | TryTransitionAsync(id, when, apply) |
The loser is an EXPECTED branch: it gets PreconditionFailed carrying the winner's row — three switch arms, no catch. (Raw UpdateAsync(entity, ConcurrencyMode.Strict) remains the primitive when you want the loser to throw.) |
| Create-if-absent (member registration, idempotent provisioning) | GetOrCreateAsync(id, factory) |
A lost create race converges on the winner's row instead of throwing DuplicateKeyException |
| Insert-or-apply-delta (occurrence counters, feedback dedupe) | MutateOrCreateAsync(id, create, mutate) |
The delta applies exactly once per attempt, uniformly on first insert and on every later call |
| Deliberate unconditional overwrite (convergent upserts, supersede sweeps) | UpdateAsync(entity, ConcurrencyMode.LastWriterWins) |
Explicit and greppable. Nulling the ETag to "turn off" concurrency throws since 0.6.0 — LWW must be spelled out |
Entity.Timestamp mirrors the service-managed last-write time: populated on every read, reset to
null on writes (write responses don't carry it), never stored as a column. Unlike UpdatedAt it is
bumped by ANY storage write — including migrations — and cannot be set by clients.
Legacy column aliases
Renamed a property (or adopted this package with pre-existing rows)? Declare the old column name and reads fall back to it whenever the canonical column is absent — writes always use the property name, so rows converge to the canonical schema as they are rewritten. No migration job.
// Property you own:
public sealed class Customer : Entity
{
[ColumnAlias("FullName")] // rows written before the rename
public string Name { get; set; } = "";
}
// Inherited property — the class-level form targets properties on base types:
[ColumnAlias(nameof(Entity.CreatedAt), "LegacyStamp")]
public sealed class LegacyCustomer : Entity { }
The Entity base class itself ships with Created → CreatedAt and Modified → UpdatedAt
aliases, so rows written by pre-0.3.0 serializers deserialize correctly out of the box — no
per-project opt-in needed.
The canonical column wins unconditionally when both exist. Aliases cover the __Json/__GZip cell
variants and, for a [TableValueSplit] or TableBlobContent property, the split cells, continuation
rows and blob reference stored under the old name (see
Large values). They apply to top-level columns only. A
declaration is checked the first time the type is written or a row of it is read, not when the store is
constructed: a collision or an unknown target then throws InvalidOperationException.
Protected properties
Gate sensitive columns behind roles. Enforcement runs on the whole-entity update/upsert paths and
needs an IProtectedPropertyAuthorizer; if none is registered, changing a protected value is denied.
public sealed class Employee : Entity
{
public string Name { get; set; } = "";
[ProtectedProperty("admin,accountant")]
public decimal Salary { get; set; }
}
Provide an authorizer from your host (this is where the ClaimsPrincipal lives — kept out of the package
so it stays dependency-light):
public sealed class RoleAuthorizer(IHttpContextAccessor accessor) : IProtectedPropertyAuthorizer
{
public bool IsAllowed(string roles) =>
roles.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)
.Any(role => accessor.HttpContext?.User.IsInRole(role) == true);
}
builder.Services.AddHttpContextAccessor();
builder.Services.AddSingleton<IProtectedPropertyAuthorizer, RoleAuthorizer>();
For the builder path, protected properties are rejected unless you opt in after verifying authorization:
await storage.UpdateAsync(id, b => b.AllowProtected().SetProperty(x => x.Salary, 5000m));
Batch writes bypass protected-property enforcement (per-row reads would defeat the batch) — treat them as trusted server-side paths.
A protected property counts as changed when its new value does not Equals the stored one, and the
refusal quotes both values; a byte[] counts as changed only when its bytes differ, on both stores and
for every type. A protected large value (a
TableBlobContent, or a value opted in with [TableValueSplit]) is compared by content instead: a file
is unchanged when it is the same blob (same SHA-256, length and content type), a byte[] when it has the
same bytes, a string as always. So saving an entity as it was read, with only other properties changed,
is not refused. A TableBlobContent made from a stream that has not been written yet has no known hash
and counts as changed (the stream is not read and no blob is downloaded to compare); so does removing
the file or setting another one. The refusal of such a change gives the kind and length of both values
("from a string of 700,000 characters to a string of 650,000 characters"), never the values.
Serialization
TableStorage<T> uses TableEntitySerializer under the hood, but you can call it directly:
TableEntity row = customer.ToTableEntity(partitionKey, rowKey);
Customer back = row.FromTableEntity<Customer>();
- Scalars map to native cells; enums become strings;
decimalis stored asdouble. - Nested objects fan out to
Parent_Childcolumns. - Collections / complex graphs serialize to JSON (
__Jsoncolumn suffix), GZip-compressed (__GZip) when they exceed the cell limit. - Legacy tolerance — a stored date surfaced by the SDK as a
DateTimeor astringstill deserializes into aDateTimeOffset(orDateTime) property without throwing. - Set
persistType: trueto embed the type name and use the late-boundFromTableEntity()overload. TableEntitySerializer.FlattenPropertyis public for alternativeIStorage<T>implementations.- A leading
_is reserved._-prefixed columns belong to the storage layer and are never written into a property. Before 0.8.0,_TypeNamewas parsed as property path["TypeName"], so a type declaring aTypeNameproperty silently received the assembly-qualified name as its value — the same held for any_-prefixed sentinel. A property cannot produce such a column either:_is also the column-path delimiter, so since 0.8.1 the writer refuses any property whose name contains_(_Foo,Foo_Bar) withSerializationExceptionnaming the property. Keep_out of property names. persistType: truewrites_TypeNamewithType.AssemblyQualifiedName.FromTableEntity<TBase>(discriminator)reads it back constrained to a base type, andTryFromTableEntity<TBase>additionally tolerates a row that carries no discriminator at all.- A base-constrained read does not recompute
Idfrom the row keys, because a polymorphic key (an aggregate version, an inverted tick count) is unrelated to any property.
Large values: split and blob offload
A table cell holds 64 KB and a row 1 MB. By default an oversized value throws
(OversizedCellPolicy.Throw); nothing is ever trimmed. Opt a property in and it is stored
losslessly instead. A store plans large values (split cells, continuation rows, blobs) only for an
entity type that is large-value aware, called split-aware below; every other type is a plain type.
Whether a type is aware is decided from its declared types alone, by reflection (below). Plain types produce the same
columns and make the same SDK calls as in 0.8.3, with deliberate differences that apply to every type: a
projected query no longer names odata.etag in its $select (the entities still carry their ETag),
the read cache corrections described under Register it, the reads of TableStorage<T>
skipping rows that carry a _SplitOf cell (see Limitations), and no byte[] shared
between a caller and the read cache or the rows of the in-memory store (the read cache does share a
TableBlobContent, and with it the array of a FromBytes content, see Files).
Roll forward only. Rows written split or offloaded cannot be read correctly by 0.8.x or older. Deploy the 0.9.0 package bump first with no attributes, and add
[TableValueSplit]orTableBlobContentin a later deploy, once no older reader (a staging slot, a rollback target, another service) can reach the table. See Upgrading, backups and restores.
public sealed class Document : Entity
{
[TableValueSplit(SplitEncoding.Text)] // large text
public string Body { get; set; } = "";
[TableValueSplit(SplitEncoding.Gzip, Offload = BlobOffload.Never)] // never leaves the table
public string Notes { get; set; } = "";
public TableBlobContent? Attachment { get; set; } // a file: always a blob
}
The attribute goes on a property or on a class. On a property it must be a string, a byte[] or a
type stored as one JSON cell (a collection, or a concrete type that does not flatten into columns).
Anywhere else it is a misuse, reported when an entity of the type is written or a row of it is read
(from the first such use, and on every later one until the attribute is fixed), not when the store is
constructed or resolved from DI: InvalidOperationException on a native scalar, on a concrete type that
flattens into columns and on a TableBlobContent; NotSupportedException on an interface or abstract
type, which may flatten at runtime. The message names the member.
The value the property holds at run time must be one value too. A property declared as a type stored as
one JSON value by its constructors (a class that is not sealed whose public constructors all take
parameters, such as a positional record, or one of whose public constructors carries [JsonConstructor])
can hold a subclass with a public parameterless constructor (and none marked [JsonConstructor]), which
the serializer stores as columns, one per member. A property declared as a nullable struct (S?) whose
struct declares a public parameterless constructor of its own holds an S, which is stored as columns
too (a struct that declares none is stored as one JSON value). A write of such a value is
refused with NotSupportedException before anything is uploaded or stored, at every size and under every
OversizedCellPolicy, on every write path of both stores (CreateAsync, UpsertAsync, UpdateAsync,
both batches, and ToTableEntity). The message names the column, the property with its declared type
(a nullable struct as S?) and the type of the object. It says that the attribute applies to one value stored in the property's own cells, not to an object stored
as columns, and names one remedy: declare the property as the runtime type and move [TableValueSplit]
from it to the members of that type that can hold a large value. For a property declared as S?, that
is: declare it as S and put the attribute on the members of S. For example:
'Doc' holds a DocImpl, and Order.Doc (declared as DocBase) is marked [TableValueSplit]. The serializer
stores a DocImpl as columns, one for each of its members, not as one value, and [TableValueSplit] on a
property applies to one value stored in the property's own cells (a string, a byte[] or an object stored
as one JSON value), not to an object stored as columns: its members would be stored as DocImpl declares
them, and a trimming OversizedCellPolicy could trim them. Declare Order.Doc as DocImpl and move
[TableValueSplit] from it to the members of DocImpl that can hold a large value. Nothing was stored.
A row written before, whose members of such an object are ordinary cells, still reads back; writing the entity back is refused the same way.
The attribute on a property declared on an interface is a misuse too (InvalidOperationException): .NET
does not pass the attributes of an interface member to the class that implements it, so the library
cannot honour it. It makes no type large-value aware and lists no large-value column. It is reported
whenever the serializer flattens a class that implements the interface into columns: a write of such an
instance as the entity or in a property that flattens into columns (at any size, under every
OversizedCellPolicy; the value is never trimmed), or a read that creates or fills one, before anything
is stored. An instance held in a JSON-stored property (a collection, or a type stored as one JSON cell)
is not checked: it is written as JSON like every other value of that cell, and a trimming
OversizedCellPolicy can trim that cell. The message names the interface member (INote.Notes), the
implementing class and the remedy: put [TableValueSplit] on the property of the implementing class (or
on that class) and remove it from the interface. When the implementing class or struct is stored as one
JSON value (a class whose public constructors all take parameters, or a struct that declares no public
parameterless constructor of its own), an attribute on its members has no effect: put [TableValueSplit]
on the property that holds it instead, declared as the implementing type (on a property declared as the
interface the attribute is refused, as above). For a struct that implements the interface,
the message names putting the attribute on the struct's property and removing it from the interface; it
does not name a class-level attribute, which C# does not allow on a struct. Where an entity holds the
implementation in a property declared as the interface (or as another type), also mark the entity class
[TableValueSplit] so that the entity is large-value aware. A value that needs split cells or a blob is
then stored only when a read returns it into a large-value member: give that property an instance of the
implementing class on a new entity (the refused writes below list the other declarations that work). On
a read below the entity itself, the misuse arrives as the inner exception of a SerializationException
("Failed to set ..."), as for every misused attribute of a nested type. An attribute on an abstract
property, or on a base-class property that a derived class overrides or hides, is honoured (below).
A builder partial update (UpdateAsync(id, builder), on TableStorage<T> and InMemoryStorage<T>) meets
the misuse through the declared types: when the entity type, or the declared type of a property along the
updated path, is or implements an interface that declares a member marked [TableValueSplit], the update is
refused with the same InvalidOperationException before anything is written, under every
OversizedCellPolicy and at every size, also for a path to another member of that type; the message names
the interface member and the remedy. The same holds when the path passes, at any depth, through a
property declared as a nullable struct S? where S implements such an interface: under the
serializer's column name (Extra_Notes, with no Value segment) and under the lambda's
(x => x.Extra!.Value.Notes, column Extra_Value_Notes), whether the property holds a value or not. The
message then also names declaring that property as S, because a member marked [TableValueSplit] of a
nullable struct is refused in turn ("... and declare Order.Extra as S."). InMemoryStorage<T>
looks at the row first: when the row does not exist it throws RequestFailedException (404), and when
WithETag names another version ConcurrencyConflictException, as for every builder update;
TableStorage<T> reports the misuse in both cases. The misuse is reported first; a path the declared
types lead to a correctly marked member or a TableBlobContent is then refused with
NotSupportedException ("partial updates cannot write it — replace the entity instead"), for every entity
type (see Limitations).
This check reads declared types and attributes by reflection only, so for a plain entity type it does not
see a property declared as a base class or object whose value at run time is such a class: a path
through it (x => x.Doc.Notes) writes an ordinary cell, which a trimming OversizedCellPolicy can trim
(the library constructs nothing for such a type). When the entity's constructor puts an instance of the
implementing class in that property, every later read of the row then reports the misuse (a
SerializationException whose inner exception is the InvalidOperationException), so the row cannot be
read until the attribute is fixed; otherwise a read creates the declared type and returns the value as an
ordinary cell, with no report. Marking the entity class [TableValueSplit] makes such a property seen
only when the entity's constructor puts an instance of the implementing class in it: a property that is
empty on a new entity is still not seen. Declare the property with the implementing class, or mark the
entity class and initialise the property with such an instance, and fix the attribute as the message says.
[TableValueSplit] on a member has no effect when the object that owns the member is itself stored as
one JSON cell. That happens for an element of a collection, a struct that declares no public
parameterless constructor of its own, a constructor-only record or class, or a runtime subclass with no
public parameterless constructor held in an opted-in entity. The whole object is one JSON value, so it is too large for a cell under OversizedCellPolicy.Throw
(OversizedCellException) and trimmed as a whole under either Trim policy. Mark the property that holds
the object instead.
On a class the attribute covers every string, byte[] and JSON-stored property of the class, whether
the class declares the property or inherits it, except the entity Id, which stays an ordinary key column
that can be filtered. It does not cover a scalar the serializer stores as JSON only because Azure Tables
has no native cell for it (float, short, ushort, sbyte, char, DateOnly, TimeOnly, Half,
Int128, UInt128, nullable or not). Such a value is a few characters, so it is stored, updated with a
builder and refused by LINQ exactly as on a plain type. It covers a value stored as one value only: a
property it covers by its declared type that holds an object stored as columns (a subclass with a public parameterless constructor) is not
covered, and that object's members are stored as its own type declares them, as for a property declared as
an interface or an abstract class. That is how an entity opted in at class level stores the marked members
of a runtime subtype held in a property declared as a record or another base class. A class-level
attribute does not cover a property declared as a nullable struct that is stored as columns either: the
members of the struct are stored as the struct declares them, and a builder update of one that is not
marked is written as before. A derived class inherits the attribute of its base class; when it declares
its own, that one applies to all its properties. An attribute on a property wins over the one on its class
(encoding and Offload), and a property that a derived class overrides or hides (new) keeps the
attribute of the property it re-declares. Every covered property is a large property, which cannot be used
in a LINQ filter or in a builder partial update (see Limitations): to keep a string
property filterable, put the attribute on the properties that need it instead of on the class.
An entity type is large-value aware when it, or a type it flattens into columns (a nested object
property), has a [TableValueSplit] property, a TableBlobContent property or a class-level
[TableValueSplit] that covers one of its properties; or when the entity class itself (or a base class)
carries [TableValueSplit], also when that covers none of its own properties. The answer is read from the
declared types by reflection, so it is the same in every process and at every moment.
The library runs none of a plain type's code (constructor, static constructor, property getters, of the
entity type or of the types of its properties) except where 0.8.3 runs it: when a read materializes a row,
when an entity you passed in is serialized, and when UpdateAsync(id, builder) keeps a cached entity warm
(see Register it), which copies that entity once and so constructs it once, as 0.8.3
did. Constructing a store (also when a DI container resolves it), DeleteAsync, ExistsAsync of a
missing row, CountAsync and DeletePartitionAsync construct nothing, and neither does a builder update
that does not keep a cached entity warm, so whatever the entity's constructor does cannot change, break or
delay those calls. For a large-value aware type the store constructs the entity once, with its public
parameterless constructor, when it first needs the list of its large-value columns, and reads the
properties that may hold a nested object, to learn the columns of the objects the constructor puts there.
If the constructor or a getter throws then, the exception is ignored, the columns of the declared types
stand for that operation, and a later use tries again; it never makes the store treat the type as plain.
Once the constructor succeeds, replaces and deletes release the blobs of members of runtime subtypes again
(a blob that an operation during the failure did not release is left to the orphan sweep). A write of
such a type may construct the entity too (and the declared type of an empty nested property), once for
each column of a TableBlobContent, and of a [TableValueSplit] value below a nested object (or of a
subclass written through a store of its base type), that has not been resolved before, to learn whether a
read returns that column into a large-value member (see the refused writes below). While that
constructor or a getter throws, such a write of a value below a nested object, or of such a subclass,
that needs split cells or a blob (every TableBlobContent does) is refused with NotSupportedException
naming the column, before anything is uploaded or stored. It carries no inner exception; for an entity of
the store's own type its message says that a read cannot create the object that owns the value. A value
of one cell is written, and so is every top-level value of an entity of the store's own type, a file
included. The failure is not remembered: write the entity again once the constructor succeeds. A
builder partial update of such a type may construct
the entity (and the declared type of an empty nested property on the updated path) once per column it
names for the first time, to learn whether the column is a large value as a read would see it, and copies
a cached entity it keeps warm once, as for a plain type. While that constructor or a getter throws, or an
object along the path cannot be created (an empty property declared as an interface or an abstract type),
a builder update that names a column the store has not resolved before (a top-level one too) is refused
with NotSupportedException before anything is written. Its inner exception is the failure (for a
throwing constructor, a TargetInvocationException whose inner exception is the constructor's): the
library cannot tell whether the column is a large value, which a partial update cannot write. The failure
is not remembered: replace the entity instead, or send the update again once the constructor succeeds. A
column resolved once is remembered and needs no constructor again. A misused [TableValueSplit] that this
resolution meets is not wrapped: the update throws the exception a whole-entity write of the type reports.
Such a constructor should have no side effects.
A member of a runtime subtype can be a large value too. When a property is declared as an interface, an
abstract class or a base class, its [TableValueSplit] and TableBlobContent members are those of the
object it holds. The declared types do not show such a member, so it does not make the entity aware: opt
the entity in with a class-level [TableValueSplit]. Until it is opted in, the entity is a plain type, and
a builder update that names such a member's column by name (a key of UpdateBuilder.Updates; no lambda
can name a member the declared types do not have) writes it as an ordinary cell, as 0.8.3 does: under
TrimSilently or TrimWithMarker a value too large for one cell is stored trimmed (a TableBlobContent
value is refused, see below). A whole-entity write
of such a value is refused, as below. A read does not know the held object's type either:
it keeps the instance the entity's constructor puts in the property, and creates the declared type where
there is none. So initialise such a property with an instance of the type that has the members (without
one, a value of such a member that needs split cells or a blob is refused, see the refused writes below):
public interface IShape { }
public sealed class Circle : IShape
{
[TableValueSplit(SplitEncoding.Text)]
public string Notes { get; set; } = "";
}
[TableValueSplit(SplitEncoding.Text)] // opts Drawing in
public sealed class Drawing : Entity
{
public IShape Shape { get; set; } = new Circle(); // the instance a read keeps
}
Shape_Notes is then stored and read back at every size, and a projection that names Shape_Notes or
Shape returns it. The class-level attribute has its usual cost: it also covers the entity's own
string, byte[] and JSON-stored properties, which become large values (a value that fits one cell is
still one ordinary cell, but no LINQ filter and no builder partial update on them). To avoid that, mark
one of the entity's own properties instead (a string, byte[] or JSON-stored property you do not filter
on, or a TableBlobContent property): the entity is then aware by its declared types, the value below the
runtime subtype is stored and read back at every size, and every other property stays an ordinary column.
Or declare the property with the type that carries the member (public Circle Shape { get; set; }).
[TableValueSplit] on the property that holds the subtype is not an opt-in: on an interface, an abstract
class or a type that flattens it is the misuse described above.
A value too large for one cell is stored only where a read returns it into a large-value member, and a
store of a plain type stores no large-value form at all. These writes are refused with
NotSupportedException naming the column or the property, before anything is uploaded or stored:
- Through a store of a plain type, a value in a
[TableValueSplit]member or aTableBlobContentvalue below a runtime subtype, whatever its size: a 10-character value is refused as a 700,000-character one is, so such a model fails on its first write of the member, not on its first large value. A member that holds null is not refused. A builder update that sets such a value (SetProperty(x => x.Shape, new Circle { Notes = "..." })) is refused the same way. The message names the member, where it sits and the remedies above. Where the declared types already give the property that owns the member the type that carries it, and a property on the way is the reason the entity is not aware (one declared as an abstract or base class whose member of that name the class does not store, an[IgnoreDataMember]or get-only member that the object's class overrides or re-declares with a setter, or a class stored as one JSON value by its constructors whose object is stored as columns), the message names those properties with the types of the objects they hold (declare Order.Doc as DocImpl), beside the class-level attribute, and not the declaration that is in place. - Below an object whose type a read would not recreate, a value that needs split cells or a blob (every
TableBlobContentdoes). A read keeps the instance the constructor puts in the property and creates the declared type where there is none, and the value is stored only when that object carries it as a large-value member: where the object has no member of that name, a read would drop the value, and where its member of that name is not a large-value member, a read would bring the value back into a member that cannot carry it (the store's own copy of the entity for its cache, and the next save of the entity that was read, would trim it or fail). The usual shape is an entity opted in with a class-level[TableValueSplit]whose property is declared as a base class and empty on a new entity (public MemoBase? Doc { get; set; }), written holding a subclass that marks the member (MemoMarked : MemoBasewith[TableValueSplit] override string? Notes). The same holds when the property's default instance is of the base class and for such a property deeper in the graph, for astring,byte[]or JSON-stored member, and under everyOversizedCellPolicy. For such a value the message names the declarations that work: initialise the property with an instance of the runtime type in the constructor of its owner (public MemoBase Doc { get; set; } = new MemoMarked();), or declare the property as the runtime type (public MemoMarked? Doc { get; set; }); where the type a read creates has the member, it also names both types and a third remedy,[TableValueSplit]on that member of the declared type. A value of the same member that fits one cell is an ordinary cell, as it always was (a read drops the members the created type does not have), and is not moved to split form to make a crowded row fit (see Tiers). - Below an object a read cannot create at all, a value that needs split cells or a blob: a struct held in a
nullable property (
public Attachment? Extra { get; set; }), or an interface- or abstract-typed property that holds no instance on a new entity. A value of one cell there is an ordinary cell, as on 0.8.3 (a nullable struct then reads back null; a property with no instance fails the read withSerializationException, as for its ordinary cells). A struct in a nullable property does not make the entity aware (Attachment?is notAttachment), so a plain entity refuses a value in that member at every size: declare the property asAttachment, not nullable (that makes the entity aware and round-trips the value). Marking the entity[TableValueSplit]does not help: a value below a nullable struct that needs split cells or a blob is still refused, and one of one cell reads back null. When the struct is reached through a property declared as an interface, an abstract or a base class, the message names both declarations that work: the struct property as the struct itself, not nullable, and the holder property with the type that has it. - A
TableBlobContentin a property declared asobject(the only other type that can hold one, sinceTableBlobContentis sealed), on every store, aware or not; marking the class[TableValueSplit]does not change that. The message says to declare the property asTableBlobContent, the only type through which a content is stored. A builder update of a plain type that puts aTableBlobContentin such a property is refused too: the message names the declaration of the member the declared types reach (Declare Owner.File as TableBlobContent), also when the update names the property by a legacy[ColumnAlias]name; for a member only a runtime subtype declares, the update is refused as every partial update of a large value is ("replace the entity instead"), and the whole-entity write names the declarations. For a subclass written through a store of its base type it names the declaring class: when the base declares the property, declaring it there is enough; when only the subclass declares it, store the subclass through anIStorageof its own type as well. When the refusal concerns aTableBlobContentbelow an object held in a property declared asobject(or another type that is not the holder's), declare that property with the holder's type as well: a read creates an object only for a property whose declared type it can construct. - A subclass written through a store of its base type (
IStorage<TBase>): a read creates aTBase, so a store never stores a value of a subclass that it could not read back. WhenTBaseis plain, a value in any large-value member of the subclass is refused whatever its size; a subclass that opts into nothing is written as on 0.8.3 (its extra members are cells that a read through the base store drops). WhenTBaseis aware, a value that would leave its cell (split cells, continuation rows, a blob; everyTableBlobContentis one) is refused in a column only the subclass declares, before the caller's stream is read, and in a member the subclass marks[TableValueSplit]andTBasedeclares without marking it (a read would bring it intoTBase's ordinary member); a value of one cell is an ordinary cell (a read drops one of a column only the subclass declares). Store such entities through anIStorage<TDerived>, after markingTDerived[TableValueSplit]when it is not aware itself; for a memberTBasedeclares, marking it onTBaseworks too. This holds forCreateAsync,UpsertAsync,UpdateAsync,CreateBatchAsyncandUpsertBatchAsyncon both stores.
In CreateBatchAsync and UpsertBatchAsync the refusal comes before anything of the refused entity's
partition is uploaded or written; the transactions of partitions the batch wrote before it stay committed,
as for any failing transaction of a batch. A file refused because a read could not map its column may
have had its source read first (see Files).
[ColumnAlias] works with large values. [ColumnAlias("Old")] on a renamed [TableValueSplit] or
TableBlobContent property, or the class-level [ColumnAlias("Property", "Old")], reads a value stored
under the old column in every form: one cell, Old__Json or Old__GZip (written by a type that had not
opted in), split cells, continuation rows, or a blob reference Old__blob. A projection that names the
renamed property returns it. A read moves nothing. A builder update of the legacy name of such a
property, in any of these forms (Old, Old__Json, Old__GZip, Old__c0, Old__chunks, Old__blob
and the other split cells), is refused with NotSupportedException before anything is sent, at every
size, as one of the property's own name is ("'Old__Json' is a large value; partial updates cannot write
it — replace the entity instead."). The same forms of a legacy name of an ordinary property are written
as before (Updates["Old__Json"] of an ordinary string property is stored as that cell and read back
into the property). A cell below a legacy name (Old_Member) is not a legacy name: no read maps it, and
a builder update writes it as on 0.8.3. The next save through the new type stores the value
under the new column and drops the old cells and continuation rows with the replaced row; an offloaded
value is uploaded again under the new column's blob name (a TableBlobContent is copied from the old
blob), and the old blob is deleted after the new row has committed, subject to CleanupGrace.
Offload = BlobOffload.Never forbids tier 3 below; a value that would need it throws
OversizedValueException (a SerializationException). The same exception reports every entity that is
too large to store: also one whose ordinary cells, which cannot leave the main row, exceed one entity.
Its ColumnName (a column of the entity), EncodedBytes and Reason describe what does not fit, and
its message ends with what to do. OffloadForbiddenByProperty is true when blob offload is configured
and the property forbids it (set its Offload to BlobOffload.AboveThreshold). When it is false,
follow the message: configure UnifiedTableStorageOptions.BlobOffload when a value needs a blob and no
offload is configured; mark the large properties (or the class) [TableValueSplit], or store less in
one entity, when the bytes of the cells that must stay on the main row are what does not fit (for a value
that a read would not return into a large-value member, see Tiers). When their
bytes fit but the main row has no property left for the cells of its split values (see
Tiers), ColumnName is the largest split value, EncodedBytes its own encoded size, and the
message says how many properties its cells need and how many of the 252 the other properties leave. The
remedy then is fewer properties in one entity or, with offload configured and the property's Offload not
BlobOffload.Never, a BlobOffloadOptions.ThresholdBytes below the value's encoded size, so that it is
offloaded (one cell; every other split value above the new threshold is offloaded too).
Tiers
For each opted-in property the smallest tier that fits is used:
- Inline - one cell, exactly as before, when the value fits.
- Split - cells
{Prop}__c0..cN(strings in chunks of at most 32,000 chars, never splitting a surrogate pair;byte[]as raw binary cells of at most 65,536 bytes), plus the four manifest cells{Prop}__chunks,{Prop}__sha256,{Prop}__encand{Prop}__len. When the cells do not fit one row, the rest go to continuation rows with RowKey{RowKey}~0001,~0002, ... in the same partition, written in one entity-group transaction with the main row. - Blob offload - when the encoded value exceeds
BlobOffloadOptions.ThresholdBytes(default 3,145,728) or the row would not fit one transaction, the row holds only{Prop}__blob(reference JSON: path, sha256, length, content type, encoding) and the bytes live in an Azure Blob.
Many opted-in values that each fit one cell can exceed one entity together (twenty strings of 30,000
characters are 1.2 MB). When the cells that must stay on the main row, which is every cell but the
chunks of split values, exceed one entity (1,000,000 estimated bytes), the largest of the values that
sit in one cell are stored in split form instead (one chunk and the manifest), only as many as it takes,
so that their chunks can go to continuation rows; tier 3 then applies to them as to any split value. A
row that fits one entity is stored exactly as before: every value in its own cell, one row, no manifest.
A moved value keeps four cells on the main row where it had one, so the limit of 252 properties can be
what stops it; the OversizedValueException then says so. A value that a read would not return into a
large-value member (see above) is never moved: it stays in its
cell, the other values are moved instead, and when moving them is not enough the write throws
OversizedValueException before anything is stored. When the bytes of the cells that must stay on the
main row are what does not fit, the exception names the largest of those cells, and its message advises
marking the large properties (or the class) [TableValueSplit], or storing less in one entity. When that
cell is such a value, the value is opted in already, and marking the entity class does not reach a value
below a nested object: to let that value move, use a remedy that the refusal of such a value names
(above; below a nested object, an instance of the runtime type in the property that holds it, put there
by the constructor, that property declared with the runtime type, or [TableValueSplit] on the member of
the declared type). Marking the other large properties, or the class, still lets the other values move,
which can be enough.
One entity holds 252 properties besides PartitionKey, RowKey and Timestamp. A split value keeps its
four manifest cells on the main row besides its chunks, a row with split values keeps its part count
(_SplitParts) there too, and an offloaded value keeps one cell ({Prop}__blob). So an entity with P
other properties holds S split values while P + 4 × S + 1 ≤ 252: one with only the properties of Entity
(Id, CreatedAt, UpdatedAt) holds 62 split values and refuses 63. The planner does not fall back to a
blob below the offload threshold when the properties do not fit; a value above ThresholdBytes is a blob
reference from the start.
"Fits one transaction" is measured as the SDK sends it, not only as the service stores it: the SDK
JSON-escapes every non-ASCII character and ", \, <, >, &, ', + and ` as \uXXXX, so
such text is up to six bytes per character on the wire (and base64 binary four bytes per three). A
transaction is planned within the 4 MiB payload limit (less 64 KiB for framing), and the deletes
of stale continuation rows count toward it; split cells are placed so that each row's request body also
stays within 1 MB, which the storage emulator enforces. So 700,000 characters of Cyrillic text
(1.4 MB as UTF-8, 4.2 MB on the wire) is offloaded, or throws OversizedValueException when
offload is not configured, instead of failing with HTTP 413.
__enc is text, gzip, binary or utf16le. SplitEncoding.Text stores UTF-8; SplitEncoding.Gzip
gzips first and stores binary cells (a blob carries Content-Encoding: gzip), which suits compressible
text. A string that is not well-formed UTF-16 (an unpaired surrogate) cannot survive UTF-8, so it ignores
the requested encoding and is stored as utf16le, code unit for code unit. The SHA-256 is over the stored
bytes; a split value and the same value offloaded have the same hash. A value stored as JSON (a collection
or an object stored as one JSON cell) cannot carry an unpaired surrogate, because JSON writes U+FFFD in its
place. A [TableValueSplit] property holding one is refused with NotSupportedException naming the
column. Store such text in a marked string or byte[] member instead.
Registering blob offload
services.AddUnifiedTableStorage(o =>
{
o.BlobOffload = new BlobOffloadOptions
{
ContainerName = "unified-tables-values", // default
ThresholdBytes = 3_145_728, // default; 1 to 4,194,304
CleanupGrace = TimeSpan.FromMinutes(5), // default
};
});
The blob client is chosen only when BlobOffload is set, and the library never registers or replaces
the host's own BlobServiceClient service. It follows from the registered UnifiedTableStorageOptions
(the unkeyed registration every store receives), whoever registered them: an AddUnifiedTableStorage or
AddUnifiedInMemoryStorage call, or the host itself, as an instance, a factory or a type. In this order:
- their
BlobOffloadOptions.ServiceClient, when set (aTableStorage<T>constructed directly uses it only when noBlobServiceClientis passed to the constructor: an explicit constructor argument wins); - else, when they set
BlobOffloadOptions.UseRegisteredServiceClient, theBlobServiceClientthe host registers in DI, and no other one (resolved lazily, so it may be registered before or afterAddUnifiedTableStorage); without a host client a store of a type with large values fails to resolve. Register the host'sBlobServiceClientas a singleton. EveryTableStorage<T>is a singleton that takes the offload client when it is constructed, so with a scoped registration every store, a plain one too, fails to resolve underValidateScopes('Cannot resolve scoped service ... from root provider'), and withoutValidateScopesthe stores keep the root scope's instance; - only when they choose neither, the client a call supplied: that call's own
ServiceClient, the host's client when that call setsUseRegisteredServiceClient, or one the overload derives, kept to the library. The connection-string overload builds it from the same string when it has a blob endpoint (BlobEndpoint,UseDevelopmentStorage=true, or anAccountNamewith noTableEndpointor an Azure Storage one); the credential overload derives{account}.blob.core.{suffix}only for*.table.core.*hosts (not Cosmos.table.cosmos.azure.com). The first client a call supplies is kept; a later call never replaces it.
services.AddUnifiedTableStorage(o => o.BlobOffload = new BlobOffloadOptions
{
ServiceClient = new BlobServiceClient(blobUri, credential), // or UseRegisteredServiceClient = true
});
With no client found, only stores of large-value aware types fail to resolve
(InvalidOperationException listing the ways a store gets a client); every other IStorage<T> resolves as
before. Without offload configured, tiers 1 and 2 still work and a value that needs tier 3 throws
OversizedValueException.
AddUnifiedTableStorage may be called more than once, by a library module and by the host, say. The
first call's options are the registered ones (unless the host registered its own before it), and the
options of a later call are not used. A later call does one thing: when the registered options set
BlobOffload, choose no client, and no call has supplied one yet, it supplies it, by step 3. So
AddUnifiedTableStorage(o => o.BlobOffload = new BlobOffloadOptions()) followed by
AddUnifiedTableStorage(connectionString) resolves large-value stores. The other order does not turn
offload on (the first call's options win), and a later connection-string or credential call never
replaces the client the registered options name or ask for. Only an unkeyed registration of
UnifiedTableStorageOptions counts as registered options: options the host registers under a key, for a
purpose of its own, are never what a store receives, so the first library call beside them behaves as a
first call (its options are registered, its OversizedCells policy is applied, and it supplies their
client).
Options registered through a factory or a type cannot be read at registration. Beside them a later call
offers its client only when it has one (a connection-string or credential overload, or a call whose own
BlobOffload sets ServiceClient or UseRegisteredServiceClient), and the choice above is made when the
container builds the client. So such options that set UseRegisteredServiceClient, beside only
AddUnifiedTableStorage() calls, get no client, and a large-value store fails to resolve; a
connection-string call whose blob endpoint the Blobs SDK rejects does not fail at registration there
either. Give options registered that way a ServiceClient, register them as an instance, or let
AddUnifiedTableStorage(configure) register them. A connection-string call that is the one to supply the
client otherwise throws ArgumentException at registration when the string's blob endpoint is malformed.
Call one registration or the other, not both: after AddUnifiedInMemoryStorage registered options with
BlobOffload set (and no client chosen), a later AddUnifiedTableStorage(connectionString) or
(endpoint, credential) call is the one to supply the offload client, so it validates the blob endpoint
of its string (and throws ArgumentException for a malformed one, a placeholder AccountKey say), and
otherwise registers a client that no store uses.
The offload client reaches TableStorage<T> through a keyed BlobServiceClient registration
([FromKeyedServices]), so blob offload in DI relies on Microsoft's keyed-service rules (a plain
resolution never sees a keyed registration). A host that uses no offload is unaffected on every
container: nothing keyed is registered when the registered options are known to set no BlobOffload
(beside options registered through a factory or a type, a connection-string or credential call registers
the keyed client anyway). Microsoft.Extensions.DependencyInjection behaves as described. Other containers
were measured:
- Autofac (Autofac.Extensions.DependencyInjection 11.0.2): as described, except that with
UseRegisteredServiceClientand noBlobServiceClientregistered by the host every store fails to resolve, plain ones too. - Lamar (16.0.0): an unkeyed request resolves to the last registration of the type, keyed or not. Register
the host's own
BlobServiceClientafterAddUnifiedTableStorage(or pass the client asBlobOffloadOptions.ServiceClient), otherwise the host's own code is given the library's offload client. UseUseRegisteredServiceClientonly when the host's client is registered after the call; otherwise resolution recurses until the stack overflows and the process exits, for plain stores too. - DryIoc (DryIoc.Microsoft.DependencyInjection 6.2.0) is not supported for blob offload in DI: its adapter does not implement keyed services.
Beside UnifiedTableStorageOptions that the host registered through a factory or a type, a
connection-string or credential AddUnifiedTableStorage call registers a keyed BlobServiceClient even
when those options use no blob offload. On Lamar the host's own unkeyed BlobServiceClient then resolves
to the library's client unless the host registers it after the call; on the DryIoc adapter (6.2.0) it
resolves to null. Register such options as an instance, or let AddUnifiedTableStorage(configure)
register them, to keep 0.8.3's registrations.
The container belongs to offload alone: do not store other blobs in it. Sweeps and partition deletes
only ever delete names of the exact offload shape (see below) and skip anything else, but they list
the whole {table}/ prefix.
Files: TableBlobContent
A TableBlobContent property is always offloaded, whatever its size, and reading an entity never
downloads it. A content is stored only through a property declared as TableBlobContent (see above for
an object-typed one). TableBlobContent.FromBytes(bytes, contentType) and
FromStream(stream, contentType) create one; FromStream never buffers the content in memory and leaves
your stream open. A non-seekable stream is copied to a delete-on-close temporary file under
Path.GetTempPath() (so the host needs temp disk space and access); a seekable stream is read from its
current position, twice (hash, then upload), and must not change until the write completes. Length and
Sha256 are known after the write (immediately for FromBytes). ContentType is the type recorded in
the row. The blob's own Content-Type header is set when its bytes are first uploaded and is not changed
when the same bytes are saved again into the same property with another type: the library's reads report
the row's type, while anything that serves the blob directly (a SAS link, a CDN) sends the header of the
first upload.
On a loaded entity, await content.OpenReadAsync() streams the blob lazily and has verified it by the
time it has delivered it: the read that would return the last byte of the expected length compares the
SHA-256 first, and throws TableValueIntegrityException instead of returning that data when it does
not match. A caller that reads exactly Length bytes therefore never gets changed content. Bytes beyond
the expected length are never handed out, a blob that ends early fails at the end of the stream, and
once a check has failed every later read of that stream throws the same exception. Nothing is verified
before the last byte: a caller that processes the content while reading it has processed all but the
last read when a mismatch is reported, so buffer first when that matters. Saving the entity again while
one of its files is being read does not disturb the read. A blob that is deleted under an open stream
(the row was replaced with other content or deleted, and cleanup or a sweep removed the blob) fails the
read with TableValueIntegrityException, unless the stream already holds the rest of its bytes. When
OpenReadAsync finds the blob gone (the row was replaced or deleted since the entity was read, by another
process too), it throws TableValueIntegrityException, and the TableStorage<T> that returned the entity
stops answering that row from its cache (its entry and its partition's cached lists are evicted): read
the entity again and open the current content.
The library puts a seekable stream back at the position the content started from whenever it is done
reading it: after hashing, after each upload, after a write that failed or was cancelled, and after a
successful write. So a write that fails before its upload finished can be retried with the same content,
and a retry that creates a new content over the same stream (a MutateAsync, TryMutateAsync,
TryTransitionAsync or MutateOrCreateAsync delegate that runs again after a conflict, or a retry loop
of your own) stores the same bytes as the first attempt. A seekable stream that can no longer be put back
(seeking throws, or it was disposed) makes the next write of that content, and any write of a new content
over that stream, fail with InvalidOperationException before anything is uploaded.
A forward-only (non-seekable) stream can be read once. The library remembers the stream objects a write
of a TableBlobContent has read, for as long as the stream object lives (being remembered does not keep
a stream alive), and recognises a stream by its object: the stream given to FromStream, and the
streams below it that the framework wrappers expose, followed recursively:
BufferedStream (its underlying stream), GZipStream, DeflateStream, ZLibStream and BrotliStream
(their base stream).
- A non-seekable stream that a write read to its end stays remembered whether that write stored its row or
failed (a conflict, a duplicate key, any exception), and whatever became of any other write (another
transaction of the same batch, another write that shares the content): no commit makes the library
forget it. A new content over that stream object is stored whole when the stream delivers at least one
byte: the object was given a new payload. So
TableBlobContent.FromStream(Request.Body, ...)stores every non-empty request of a keep-alive connection, although the server hands all of them the same request-body object. - When the stream delivers nothing, the new content is refused with
InvalidOperationExceptionbefore anything is uploaded, after a write that committed and after one that failed alike: that is the retry that would otherwise store an empty file and report success. Every row keeps the file it holds (a row that a batch committed before a later transaction of the batch failed keeps the committed file). A new, empty payload on a reused stream object is therefore refused too; the message says so. Store an empty file withTableBlobContent.FromBytes([], contentType)(an empty array), or from a seekable stream. - A write that read a non-seekable stream to its end and then failed or was cancelled while it finished its temporary copy counts the stream as read to its end, not as partly read: a new payload on it is stored, an empty one refused. That write's own content has no copy left and is refused for every later write ("its temporary copy was released by a failed write"): create the content again.
- When the temporary copy of a non-seekable stream cannot be created (no usable temporary directory), the write throws before it reads anything and leaves the stream as it was: the same content, or a new content over the stream, can be written once a temporary copy can be made.
- Two contents over one non-seekable stream written by one write (two properties of one entity, two
entities of one batch) are refused with
InvalidOperationExceptionbefore anything is written. - A stream that a failed or cancelled write read only part of, and a seekable stream that a write could not
put back where the content started, are refused with
InvalidOperationExceptionfor every later content over them, also when they are wrapped in one of the framework wrappers above. A write cancelled once it has begun to read a non-seekable stream counts the stream as read part-way, also when the stream had delivered no byte yet: the library cannot tell whether the stream took bytes from its own source that it did not deliver. A content whose write failed after it began to read a non-seekable stream and before its blob was uploaded has lost its bytes (the stream is partly read, or its temporary copy was deleted), and every later write of it is refused the same way. - A retry that wraps the stream an earlier attempt consumed in a new
BufferedStream,GZipStream,DeflateStream,ZLibStreamorBrotliStream(also several deep) is recognised: it is refused before anything is uploaded when the inner stream delivers nothing, and stored when the caller put the inner stream back (a seekable inner stream rewound) or gave it a new payload. A seekable stream below such a wrapper that the first attempt moved counts as consumed. - Wrappers of any other kind are not recognised (a user-written stream, a decryption stream such as
CryptoStream, a substream): a new such wrapper over a stream an earlier attempt consumed stores whatever that stream still delivers, possibly nothing, and the write reports success.
So when a write may be retried (MutateAsync, TryMutateAsync, TryTransitionAsync,
MutateOrCreateAsync, or a retry loop of your own), create the content once, outside the delegate that
may run again, and assign that instance on every attempt (it is copied once and reused); or use a seekable
stream or FromBytes; or open the source again for each attempt, where "open again" means a new source of
the bytes (the file opened again, a new request), never a new wrapper over the stream an earlier attempt
read. The refusal messages say which case applies and give these remedies.
A server author must know two consequences. A web server hands every request of a connection the same
request-body object, so after a write that read the body only part-way (the read failed or was
cancelled), every later content over that object is refused for as long as the server reuses it, also on
the next requests of that connection. Buffer the body into a seekable stream (a MemoryStream, or a file)
and create the content over that, or close the connection after such a failure. And once a write has read
that object to its end, a later request with an empty body over it is refused (see above): store an
empty upload with TableBlobContent.FromBytes([], contentType).
A write refused because a read could not map a file's column back to a property (a TableBlobContent
below an object whose runtime type a read does not recreate) may have read the file's source first; a
content created from a non-seekable stream must then be created again from its source.
Once an attempt has uploaded the content's blob (the row write then failed, say, or the upload of another
value of the same write failed, or the write was cancelled after this upload), the content streams
that blob and no longer reads its stream: a retry with the same content reuses the blob. That retry has
to come before the blob is collected (by an orphan sweep whose age has passed, or a partition delete's
cleanup). After that the write throws TableValueIntegrityException ("the blob this content was uploaded
to or read from ... is gone"), nothing is written, and the content must be created again (over the same
seekable stream, which stands where the content started, or from the source). A content from FromBytes
is uploaded again from its bytes.
One content may be assigned to several properties or entities, and those entities may be written at the
same time (with Task.WhenAll, say), through one store or several. The writes take turns on the content:
one hashes its source (copying a non-seekable one to a temporary file), and the others use that result.
Every write that succeeds stores the whole content, a seekable or a forward-only one; none stores a part
of it. A write waiting for its turn can be cancelled with its token, and it waits only for writes that use
one of its own contents. A source whose read never completes keeps the other writes of that content
waiting until the write that reads it is cancelled.
A content taken from a loaded entity may be written into another one: every row gets a blob of its own. A
content that already streams a blob keeps streaming that blob, through the store it was read or written
through, and the property that was written is given a new TableBlobContent for the blob of its own row
in its own store. The written property is given its new content as soon as the blob is uploaded, before
the row is written; a write that then fails leaves the property streaming the blob uploaded for that row.
Retry before an orphan sweep collects that blob, or assign the original content again (or read its row
again) before retrying. With a read from the table, after b.Attachment = a.Attachment; await storage.CreateAsync(b); the two are therefore not the same object: both return the same bytes, each from
its own blob, and replacing or deleting one row does not affect the other entity or its cached copy. The
same holds for an entity saved through a second store of its type whose blob has the same name (a dual
write to a primary and a secondary account, a migration): the written property gets a content that
streams the second store's blob, and the entity the first store returned, and its cached copy, keep
reading the first. Two store instances count as different stores even over the same container (the
written property then gets a new instance bound to the same blob). A content read by a store without
blob offload is the exception: saved to its own row through a store with offload, it stays on the
property and becomes readable through that store. Content created with FromBytes reads its own bytes and
is never replaced. Its array is not copied, so do not change it afterwards: every entity that holds the
content would read the change, the read cache's copy of the written entity included, while the stored
blob keeps the bytes that were written and a fresh read returns them (so does every read of
InMemoryStorage<T>, which has no cache). After concurrent writes of one content, exactly one of the
written entities keeps the shared instance.
A property whose owner the entity holds by value (a struct flattened into columns) cannot be given a new
content object. When such an entity is written into another row with a content taken from another row,
the row written references a blob of its own, but the written entity, and the store's cached copy of it,
go on streaming the blob of the row the content came from until the entity is read from the table (a
query; OneAsync returns the cached copy while its entry lives). If that source blob is deleted, those
copies fail to open their file, and that failure evicts the source row's cache entry, not this one.
Ordering and integrity
Write - the blob is uploaded create-only first (an existing blob with the same name and matching
sha256counts as success, and itsLast-Modifiedis touched), then the entity is written.Replace - new blob, entity swap guarded by the ETag, then best-effort delete of old blobs the new row no longer references. Only the row's own blobs are deleted, those named
{table}/{partitionKey}/{rowKey}/...: a reference to any other blob, as a row copied raw from another table or key carries, is left alone.Delete - the entity first, then the blobs of its large-value columns; a blob referenced from another column is left for the orphan sweep (see Limitations for which blobs a write or delete releases at once).
Partition delete - the rows first, in transactions of at most 100 deletes that never divide a main row from its continuation rows, so a failure or a cancelled token between two transactions leaves whole entities (the call throws; call it again to finish). The count returned is of entities, not rows. An entity written while the delete runs is, after it, either whole or gone: each main row is deleted on condition of the version it was listed at; when a write committed in between (it grew or shrank the entity), that transaction's entities are deleted one by one as they are then, so the call completes and leaves no continuation row behind; continuation rows listed without their main row (a leftover, or the rows of an entity created between two listing pages) are deleted last, and only when no main row exists for them after the listing (one point read per such entity) and each row is still the version that was listed; a row that changed or is gone is left alone, and the call does not fail because of it. With no concurrent writer the partition is left empty. A partition in which every continuation row is listed with its main row costs the same requests as before (the main rows now carry If-Match); each entity whose continuation rows were listed alone costs one point read and one transaction more. Then the blobs under the partition that are older than
CleanupGraceand that no row of the partition references at that moment: a row another writer created meanwhile keeps its blobs.A failed entity write never deletes a blob. Blobs are named
{table}/{partitionKey}/{rowKey}/{property}/{sha256}, so rewriting identical content reuses the blob. Each segment is percent-escaped (Uri.EscapeDataString) with.also escaped as%2E, and an empty key is written as%: no segment is ever.or.., which the Blobs SDK would collapse out of the URI (a key.or..would otherwise address another blob, even outside the table's prefix).Reads reassemble or download eagerly (
string,byte[]) and verify the SHA-256. A mismatch, a missing chunk, continuation row or blob throwsTableValueIntegrityException; partial data is never returned. A transport that transparently decompressesContent-Encoding: gzipwould break the hash check. A damaged row stays deletable:DeleteAsyncremoves it with the continuation rows it finds, also when its part count (_SplitParts) cannot be read. A row in that last state cannot be upserted or updated either, so delete it and create it again (ExistsAsyncanswerstruefor it). A row whose value is stored in the row's own cells and fails its integrity check (cells damaged outside the library) answerstruetoExistsAsyncon both stores and is not cached;OneAsyncand the queries throwTableValueIntegrityExceptionfor it, andDeleteAsyncremoves it.A read that races a replace or delete (a point read, a query, a stream or a page; the main row is read before its continuation rows and blobs) can see parts or a missing blob of another version, or have a blob deleted while it downloads it (a blob that is only touched, by a write that reuses its content, does not disturb the download). On such a failure the main row is read again: if its ETag is unchanged, the stored value is damaged and the read throws
TableValueIntegrityException; a row deleted meanwhile reads as absent (OneAsyncreturns null, a query skips it); if the ETag changed, the whole read is retried from the new version after the pause a write that lost a race takes (it honours the cancellation token). A read is attempted five times in all, as a write is. When the row changed during each of the five, the read throwsConcurrencyConflictException("... did not complete: the row changed during each of its 5 attempts ... Nothing stored is damaged; the read can be repeated."), whoseInnerExceptionis theTableValueIntegrityExceptionof the last attempt; nothing is damaged, so repeat the read. In a query, a stream or a page it ends the whole call. Data of two versions is never combined. The in-memory store does the same for blobs a concurrent replace releases.An offloaded value is never returned empty: writing such an entity back would drop the reference and let the sweep delete the value. Reading a row that holds a
{Prop}__blobreference through something that cannot fetch it throws, naming the column:- the public
FromTableEntity<T>(), or a store of a plain type, throwsInvalidOperationException; - a store whose type has such properties but no
BlobOffloadconfigured throwsBlobOffloadNotConfiguredException(anInvalidOperationExceptionwithTableNameandColumnName; a configuration error, not corruption, and not retried) from the read. ATableBlobContentproperty is still read lazily, and throws that exception when it is opened.
Only a projection (
QueryOptions.Select) that omits the property leaves it at its default. Split cells are reassembled by any reader; a plain reader that sees a split row without its continuation rows throwsTableValueIntegrityException. A plain reader takes a group of cells for the library's only when its manifest is complete and well formed (__chunksa positive count,__encone oftext,gzip,utf16le,binary,__sha25664 hexadecimal digits,__lena non-negative length) or its__blobcell parses as a blob reference. A column that merely has such a name (Body__len,Body__c0, written by another program) is read as 0.8.3 reads it: ignored, or mapped to a nested member (Stats__lentoStats.len). For a split-aware type an incomplete group is damage and throws.- the public
Sweeping orphans
Lost races, crashes and failed deletes leave unreferenced blobs. Best-effort cleanup after a replace,
delete or partition delete only removes blobs untouched for BlobOffloadOptions.CleanupGrace (default
5 minutes), and after a replace or delete only the row's own; the others are left for the sweep, which
must be scheduled: call it yourself, or register the hosted sweep. A committed write or
delete is never reported as failed because its token was cancelled: its cleanup runs on for at most 5
more seconds, then stops and leaves the rest of the blobs (a large partition's, say, during a host
shutdown) to the sweep.
CleanupGrace is not a delay after the change: a replace or delete deletes a released blob at once when
it was last written or touched longer ago than the grace, counted back from the storage service's time of
the commit (below), and leaves a blob touched within the grace for the sweep. Keep CleanupGrace well
above the time a write takes from its blob upload to its row commit. A blob's Last-Modified has
one-second resolution, so a grace of zero, or below one second, protects nothing: a blob that another
writer touched in the same second as the commit, and is about to reference, can be deleted under it. Use
such a grace only in tests.
CleanupGrace = TimeSpan.MaxValue switches this cleanup off: nothing is deleted after a write, a delete
or a partition delete, and every released blob is left for the sweep.
Both ages are measured on the storage service's clock, the one that stamps a blob's Last-Modified: the
cleanup after a write or delete uses the Date of the commit response, and the sweep and the cleanup after
a partition delete ask the blob service for its time (one container properties request) before listing.
A client clock that runs ahead therefore cannot make a freshly touched blob look old. Only when no service
time is available is the client clock used, moved back by a further 5 minutes
(LargeValueTable.ClientClockMargin). The service clock has one-second resolution, so a blob written in
the current second is not yet older than TimeSpan.Zero. Like the sweep, the cleanup after a partition
delete reads the partition's rows once (keys and reference cells) to see which blobs are still
referenced, and only when some blob is old enough.
int deleted = await storage.SweepOrphanBlobsAsync("tenant-a", olderThan: TimeSpan.FromHours(1));
int all = await storage.SweepOrphanBlobsAsync(olderThan: TimeSpan.FromHours(1)); // every partition
olderThan must exceed the longest write (blob upload to entity commit): a younger blob may belong to
a write still in flight. In a batch write that is one transaction's duration, not the whole batch's: each
transaction's blobs are uploaded just before it. Each delete is conditional on the blob being unmodified since it was listed, so a
write that reuses an old orphan is never undercut. Only names of the exact offload shape
{table}/{partitionKey}/{rowKey}/{property}/{64 lower-hex sha256} are ever deleted.
The sweep is the capability interface IOrphanBlobSweeper, implemented by TableStorage<T> and
InMemoryStorage<T>; on an IStorage<T> the same calls are extension methods that throw
NotSupportedException for a store without it. The table-wide overload lists the partitions from the
container ({table}/ with the delimiter /), takes one cutoff from the service clock, and sweeps each
partition in turn. A failing partition does not stop the others, and an orphan that cannot be deleted (an
immutability policy, a permission, a service error) does not stop the rest of its partition: every orphan
is tried, then the failures are thrown together as an AggregateException whose message says how many
blobs were deleted. The partition overload throws one inner exception per blob it could not delete; the
table-wide overload throws one per failed partition, and a partition whose deletes failed contributes an
AggregateException of them. The partition key may be empty or whitespace (only null throws): it is
normalized as ids are on write, so SweepOrphanBlobsAsync(" ", ...) sweeps the partition a write of
" |r" stored ("" under IdNormalization.Normalized, " " under AsWritten).
Hosted sweep
AddUnifiedOrphanBlobSweep registers an IHostedService that runs the table-wide sweep of each
registered type on a schedule:
services.AddUnifiedTableStorage(connectionString, o => o.BlobOffload = new BlobOffloadOptions());
services.AddUnifiedOrphanBlobSweep(o =>
{
o.Interval = TimeSpan.FromHours(1); // default; pause between rounds
o.OlderThan = TimeSpan.FromHours(1); // default; must exceed the longest write
o.InitialDelay = TimeSpan.FromMinutes(1); // default; first round after startup
});
services.AddUnifiedOrphanBlobSweep<Document>(); // once per type with large-value members
Each round resolves IStorage<T> for every registered type (in a fresh scope) and calls
SweepOrphanBlobsAsync(OlderThan); the next round starts Interval after the previous one ended. A type
that throws (a partition failure, a store that cannot be built, a timeout) is logged through
ILogger<> at Error and retried next round; the other types still run. A store that does not
implement IOrphanBlobSweeper is skipped with a warning. Shutdown cancels the round in progress. Each
round sweeps the store that IStorage<T> resolves to, whatever UnifiedTableStorageOptions are
registered: a store built with options and a blob client of its own is swept, and a TableStorage<T> or
InMemoryStorage<T> without blob offload (or a type with no large-value member) is skipped and logged
once at Information. With no type registered, the service logs that once and does nothing. The first
AddUnifiedOrphanBlobSweep(configure) call's options win; out-of-range options throw
ArgumentOutOfRangeException at registration. Works the same over AddUnifiedInMemoryStorage. Every
instance that registers it sweeps: concurrent sweeps are safe (each delete is conditional) but redundant,
so a scaled-out app may register it in one instance only.
Upgrading, backups and restores
- Roll forward only. A 0.8.x (or older) reader does not know the 0.9.0 layout: it returns
continuation rows (
{RowKey}~0001, ...) as entities of their own, readsStages__c0as the nested path[Stages, c0]so a split collection reads as empty, and sees an offloaded property as null. A later replace or upsert from that reader writes the empty value back and the data is lost (its blob is then an orphan the sweep deletes). Values that each fit one cell are not exempt: an entity whose opted-in values together exceed one entity is written with split cells and continuation rows too. Deploy in two steps: first the package bump with no[TableValueSplit]orTableBlobContent, which writes exactly what 0.8.3 wrote; then, once no older reader remains (swapped slots drained, rollback no longer to a pre-0.9.0 build, every service that reads the table upgraded), the attributes. Rolling back past the second deploy needs the data rewritten first. - Do not remove
[TableValueSplit]from a property while rows hold large values of it (split cells, continuation rows or a blob). A read brings such a value into a member that is no longer a large value, andTableStorage<T>copies the entity it read as ordinary cells: the copy it caches, and every entity thatQueryAsync(partition)andQueryAsync()return (with caching off too). When the value is a string or a JSON-stored value that does not fit one cell even compressed, a trimmingOversizedCellPolicytrims it in those copies (so a laterOneAsyncthat the cache answers returns it trimmed) and in the next save of the entity, which stores it trimmed (a JSON-stored value that is not a list is dropped instead);OneAsyncwhen it reads the table, and the other queries, return it whole. UnderThrow,QueryAsync(partition),QueryAsync()and the save throwOversizedCellException, whileOneAsyncand the other queries return the value whole and the entity is not cached. Once the type opts into nothing at all, a read of a row that holds such a value in a blob or on continuation rows fails. - Back up and restore an entity as one unit: its main row, its continuation rows (same partition,
RowKeys
{RowKey}~NNNN) and its blobs in the offload container, taken together. A row restored without its parts or blobs (or with those of another version) reads asTableValueIntegrityException. - A table copied raw shares the source's blobs: each
{Prop}__blobreference holds the blob path under the source table's name, so the copy reads the source's blobs (a row copied raw to another key likewise reads the blobs of the row it was copied from). Writes through the copy never delete them: cleanup removes only a row's own blobs, so loading a copied entity and saving it through the library uploads a blob of the copy's own and leaves the source's untouched. The other direction is not protected: once the source replaces or deletes those rows, its cleanup, partition delete and sweep delete blobs the copy still reads. Give the copy its own copy of the blobs before the source changes (rewrite the entities through the library, or copy the container and point the copy'sContainerNameat it). Blob versioning or soft delete on the container makes such a mistake recoverable. - The offload container is the library's. Do not put other blobs in it, and do not share it between applications whose tables have the same names.
- Schedule the sweep (above): nothing else removes orphans younger than
CleanupGraceor left by a crash.
Limitations
- In RowKeys of split-aware types, only the continuation-row shape is reserved: a key ending with
~and exactly four digits (r~0001,a~b~0042). Writes, batch writes and partial updates refuse it withArgumentException, as they refuse a row key longer than 480 characters (which leaves room for the continuation-row suffix); every other~is allowed (a~b,x~12,a~0001x, append-log keys). A batch checks every key before it stamps any entity, so a batch refused this way leaves the entities you passed unchanged (ETag,CreatedAt,UpdatedAt). Deletes accept any key, so a row written before the type opted in stays deletable; deleting a continuation row's id is a no-op. A write never overwrites a foreign row at the key of a NEW continuation row: a row keyed{RowKey}~0001that is not a continuation row of that entity (one from before the opt-in, or of another type sharing the table) makes a write of{RowKey}that needs that key fail as a whole withInvalidOperationExceptionnaming the key, stores nothing and is left as it is. A row another writer puts at a continuation key the entity already declares is treated as the entity's own: its next write replaces it, or its next shrink or delete deletes it, and until then a read of the entity throwsTableValueIntegrityException. Do not write rows with the continuation-row keys of a split entity through anything else. The escaped blob name must fit 1,024 characters. - A continuation row whose
_SplitOfcell names an entity belongs to that entity even when no main row declares it. Such leftover rows appear when a writer that does not know continuation rows (a type without[TableValueSplit]on the same table, a 0.8.x instance, the raw SDK) deletes or replaces the main row or stops halfway through a partition delete, and when a partition delete races an upsert that adds continuation rows. The reads and queries of every type never return them, nor doesCountAsyncof the split-aware type (a plain type on the same table counts them, as on 0.8.3; see the next bullet), and they do not block the key: a write of the entity that needs a leftover's key replaces it and deletes the entity's other leftovers in the same transaction (at the cost of one failed transaction and one range query), andDeleteAsyncof an id that has no main row removes them (so aDeleteAsyncthat finds no row costs a split-aware type one range query more). If another caller (a delete of the same id, a shrinking upsert, a partition delete) removes that leftover between the write's failed transaction and its look at the key, the write counts a lost race and retries within its five attempts. A write looks for leftovers only when one holds a key it needs, so the others stay, unseen, until the entity grows onto them, shrinks or is deleted while its main row declares continuation rows,DeleteAsyncfinds no main row at its id, or the partition is deleted. - A table shared between a split-aware type and a plain type or a polymorphic store: the reads of
TableStorage<T>(OneAsync,ExistsAsync, the queries, streams and pages) skip every row that carries a_SplitOfcell, for every type, so the plain type does not return the split-aware type's continuation rows;CountAsyncof the plain type still counts them, as on 0.8.3, and so does a projectedQueryAsyncof the plain type (one withQueryOptions.Select), because_SplitOfis not selected.InMemoryStorage<T>reads and counts such rows the same way. A plain type and a polymorphic store delete a partition as 0.8.3 does, every listed row by key, in chunks of 100. Such a delete can remove continuation rows of a split entity that is written while it runs, and leave that entity unreadable (TableValueIntegrityException): delete such a partition through the split-aware type, or do not write into it while the delete runs. A polymorphic store refuses an item whose type is not large-value aware and holds a value in a large-value member below a runtime subtype with the plain store'sNotSupportedException(whose remedies are forIStorage<T>); polymorphic stores write no split or offloaded value at all, so store such an item through anIStorage<T>of a large-value aware type. - Keys of split-aware types must be well-formed UTF-16: a partition or row key with an unpaired surrogate
(
"a\uD800") would name the same blobs as one with U+FFFD in its place, so writes refuse it withArgumentException. Plain types are unaffected. - No LINQ filter and no partial update (
UpdateAsync(id, builder)) on a large property (both throwNotSupportedException); not supported byIPolymorphicStorage. That includes every property a class-level attribute covers. One filter is not refused: on a member that an interface or base class declares without the attribute and the runtime type opts in. It translates, and does not match rows whose value is stored split or offloaded. A builder update is refused withNotSupportedExceptionbefore anything is sent, on both stores and for every entity type (also one that is not large-value aware), when its column path, followed through the declared types, reaches a member marked[TableValueSplit](on the property, or covered by a class-level attribute) or aTableBlobContentmember, at the end of the path or on the way. Every member of each segment's name counts, whatever its accessors and attributes: a get-only property, one marked[IgnoreDataMember](an override in the class the property holds at run time does not inherit that attribute), one re-declared withnewor overridden, inherited at any depth, of an interface or of a class. It makes no difference whether the path is a lambda, a built expression or a key ofUpdateBuilder.Updates. The path is followed through a property declared as a nullable structS?, at any depth, into the members ofS: under the serializer's column name (Extra_Notes) and under the lambda's (x => x.Extra!.Value.Notes, columnExtra_Value_Notes); an ordinary member of the same struct (Extra_Name) is still written. A legacy[ColumnAlias]name of a large property, in any form a read maps onto it, is refused as the property's own name is (see Large values). This check of the declared types reads types and attributes by reflection only and constructs nothing; it is remembered per type and path (for a large-value aware type the path is also resolved as a read resolves it, see Large values). On a plain type, a member only a runtime subtype declares is not seen: a key ofUpdateBuilder.Updatesthat names it writes an ordinary cell, as on 0.8.3 (aTableBlobContentvalue is refused). Replace the entity instead (a whole-entity write). - Batch writes of these types keep the batch contract: per partition, each entity's rows (main row,
continuation rows, deletes of stale ones) are one unit, units are packed in order into entity-group
transactions (at most 100 actions and the same byte and 4 MiB wire budgets as a single write), and
each transaction is atomic; an entity's rows never straddle two. Atomicity is per transaction only: a
failing transaction leaves earlier ones committed. Each transaction's blobs are uploaded just before
that transaction. The blobs the replaced rows no longer reference are deleted once, after the last
transaction of the call has committed (each subject to
CleanupGrace, counted from the commit that released it). So one batch can store a content read from a row that the same batch replaces (keep a document's previous file in a history entity while replacing it, exchange the files of two documents, move a file to another document), whatever the order of the entities and however many transactions the batch needs. A batch that fails part way, or is cancelled between two transactions, deletes none of the blobs its committed transactions released (the orphan sweep removes them); every entity it did not write keeps a content that can still be read, and sending the same batch again stores it. A row repeated inCreateBatchAsyncthrowsDuplicateKeyExceptionbefore anything is written; inUpsertBatchAsyncits last occurrence is written (the ids"a"and"a|a"address the same row). Upserts do one point read per entity first, and each replace is conditional on the version read: a transaction that loses a race is planned again and retried as a single write is (next bullet), and after five lost racesUpsertBatchAsyncthrowsConcurrencyConflictExceptionnaming the entity (earlier transactions stay committed). - For a split-aware type
UpsertAsync,UpdateAsync(entity),DeleteAsyncandUpsertBatchAsyncare not single round trips: each reads the row first (one point read, to find the continuation rows and blobs it replaces) and writes on condition of the version it read. For a write the order is table get, blob get, blob upload, table update. AStrictupdate, or anAutoupdate with an ETag, that finds another version is a conflict at once, as for any type.UpsertAsync, aLastWriterWinsUpdateAsync,DeleteAsyncandUpsertBatchAsyncstill mean "last writer wins", but a call that loses its condition to a concurrent writer reads again and retries: five attempts in all, thenConcurrencyConflictException. Before each retry the call waits a random time (5 to 10 ms before the first retry, doubling each time, at most 150 ms in all; the wait honours the cancellation token). Each lost attempt is caused by another writer's commit, so up to four concurrent writes of one row all succeed. With more, a conflict is unlikely but possible: catchConcurrencyConflictExceptionand retry, or serialize the writes of that row.CreateAsyncandCreateBatchAsyncread nothing first (an existing key isDuplicateKeyException). They are retried only when they take back leftover continuation rows (above), and end inConcurrencyConflictExceptiononly if those rows keep changing or vanishing under them. So for a split-aware typeConcurrencyConflictExceptioncomes from aStrictorAutoupdate with an ETag and a builder update withWithETag(as for any type), from the writes and deletes above after five lost races, and from a read whose row changed during each of its five attempts (see Ordering and integrity). - The read before a write projects the stored row onto the part-count and continuation-marker cells and
one
{column}__blobcell per large-value column of the type. When that would name more than 200 large-value columns, or be longer than 2,000 characters as a URL-encoded$select, the store reads the whole row instead, to stay within the 255 properties the service projects and the request-URL limit of the service and the emulator. A type above that bound (several hundred opted-in columns, such as sections of blocks under a class-level[TableValueSplit], or a few dozen columns with long flattened names) pays one whole-row read, up to 1 MB, per write or delete instead of a few cells. - A replace, upsert, update, batch upsert or delete deletes at once (subject to
CleanupGrace) the blobs the stored row referenced in the cells that read returns: the type's own large-value columns, including legacy[ColumnAlias]columns and the columns of the runtime subtypes of its default instances, or every reference cell of the row when the row is read whole. A blob referenced in any other cell (a column another entity type wrote on a shared table, a value below a type that repeats below itself, a property removed from the type) is left until the orphan sweep removes it.InMemoryStorage<T>follows the same rule. - After a write that needed an entity-group transaction (continuation rows were written or removed), the
entity's
ETagis the version the transaction's response reports for the main row; the row is not read again, and the entity is not put in the entity cache. If that response carries no version,ETagis null after the write, as after a batch: read the entity again before an optimistic update. - A
QueryPageAsyncpage of a split-aware type can hold fewer entities thanTake, or none, with a non-null continuation token: a continuation row, declared or left over, takes a place in the page. Keep paging until the token is null; a short page is not the last page.InMemoryStorage<T>pages the same way. - Reads and scans cost more for these types: an entity whose value is split across continuation rows
takes one extra range query for its parts (on every read, queries included), and an eager offloaded
value (
string,byte[]) is downloaded from blob storage on every read, one blob per entity, so a query over N such entities makes N extra requests.TableBlobContentis never downloaded by a read. The entity cache holds the full value, and a hit copies it in process (no codec work), but each entry still counts as size 1 against aSizeLimit: to bound memory, disable caching (CacheFor<T>(CachePolicy.Disabled)) or use a short TTL for types whose values are very large. - A
TableBlobContenton an entity you hold (or one the entity cache returns) names the blob of the version it was read from. Once a replace or delete has deleted that blob (at once when it was older thanCleanupGrace, or later by a sweep),OpenReadAsyncthrowsTableValueIntegrityExceptionsaying the row may have changed, and the store that returned the entity stops answering that row from its cache: read the entity again. It never returns other bytes: blobs are named by their hash. - A projection (
QueryOptions.Select) of a split-aware type is narrowed to the named columns only when every name is known to be an ordinary column: a system column (PartitionKey,RowKey,Timestamp,_TypeName,_SplitOf,_SplitIndex,_SplitParts,odata.etag) or a name that resolves to an ordinary property. Otherwise the whole row is fetched and the entity is returned whole. A name that names a large value in any way returns the whole value: the property's column (Body,Inner_Body), an object that owns one (Inner), one of its cells (Body__c0,Body__chunks,Body__sha256,Body__enc,Body__len, the__Jsonor__GZipcell of an opted-in property), also for a value below a repeated type or a runtime subtype; a projection never returns part of a value. The one exception is the reference cell{Prop}__blobnamed alone, which is fetched alone and leaves the property at its default. A name that resolves to no property (a misspelling,ETag, a stored alias name, a nested object's name) is not known to be ordinary either, so it fetches whole entities: more data than asked for, never less. Name the property, not its cells. A plain type projects exactly as in 0.8.3 (a JSON-stored property by its stored cell's name,Tags__Json). - A projection of a plain type (one that does not opt the property in) that names a property another type stored split or offloaded reads it as null: the plain store neither fetches nor recognises the value's cells. Read such rows through the opted-in type.
- A type that maps a column another type stored split or offloaded to a member that is not a large-value
member (a plain type sharing the table, for one) reads the value into that member where it can read it
at all (a plain type reassembles split cells on the main row and fails to read a value on continuation
rows or in a blob, see Ordering and integrity).
TableStorage<T>then copies the entity it read as that member would be stored: the copy it caches, and every entity thatQueryAsync(partition)andQueryAsync()return (with caching off too). UnderTrimSilentlyorTrimWithMarkera string or JSON-stored value that does not fit one cell even compressed is trimmed in those copies (so a laterOneAsyncthat the cache answers and those two queries return it trimmed) and in the next save of such an entity, which stores it trimmed (a JSON-stored value that is not a list is dropped instead); underThrowthose two queries and the save throwOversizedCellException, and the entity is not cached.OneAsyncwhen it reads the table, and the other queries, return the value whole. Read such rows through the opted-in type. - The store learns a type's large-value columns from its declared property types (along every path that
does not pass through the same type twice) and from the objects a new entity holds. Three shapes fall
outside that: a large value below a repeated type (
Head.Next.Bodywhen the node type refers to itself); a runtime subtype that only the constructor of a nested object creates, below a property that is null on a new entity (Order.Paymentis null by default andPayment's constructor setsMethod = new Card()); and a type graph with more paths to large-value members than can be listed in 10,000 steps, where only the columns nearest the entity are known. In a split-aware type such a value is still written split or offloaded and read back whole, a projection that names it returns it, and a partial update of it is refused, but the store does not track its column: the blob a replaced or deleted row referenced there is left to the orphan sweep (unless the row is read whole, above). A type whose only large values are of the second kind is not split-aware, and its store refuses such a value (see above); mark the entity class[TableValueSplit]. Give the outer property a default instance where you can. - Limits of one split-aware entity, and what a caller gets beyond them:
- a row key of at most 480 characters and an escaped blob name of at most 1,024 (
ArgumentException); - at most 252 properties and 1,000,000 estimated bytes for the cells that cannot leave the main row:
everything but the chunks of split values (
OversizedValueExceptionnaming the largest of them, or, when the properties are what does not fit, the largest split value); 62 split values on a bareEntity(see Tiers); - a main row and at most 99 continuation rows, written in one transaction of at most 100 actions,
3,000,000 estimated bytes and 4 MiB less 64 KiB on the wire, the deletes of stale
continuation rows included. A value that does not fit is offloaded; when nothing more can be offloaded
the write throws
OversizedValueExceptionand stores nothing.
- a row key of at most 480 characters and an escaped blob name of at most 1,024 (
- The storage emulator limits an entity by its request body, the service by its stored size. Split
cells are placed so that each row's request body stays within 1 MB, and a main row whose values
had to be moved is brought within it as far as moving opted-in values can. Cells that are not split
count by their stored size only (binary is base64 on the wire, escaped text up to six bytes per
character), for plain types too. Azurite accepts an insert of a row whose request body exceeds
1 MiB and refuses to replace, merge or upsert it (
EntityTooLarge): fourteen ordinary binary cells of 60,000 bytes can be created there and not updated. - Storage-provider SPI types (namespace
Unified.Data.Tables.Providers) are public only so other providers, such as the in-memory package, can share the implementation. They are hidden from IntelliSense and not for application code.
Sizing your own transactions: TableRequestSize
Code that builds its own TableTransactionAction lists can measure them as the library's planners do:
long stored = TableRequestSize.EstimateEntityBytes(entity); // vs MaxEntityBytes (1,000,000)
long wire = TableRequestSize.EstimateEntityWireBytes(entity); // upper bound of the JSON body the SDK sends
bool fits = TableRequestSize.FitsOneTransaction(actions); // one partition, each row once, <= 100 actions,
// <= 3,000,000 B stored, <= 4 MiB - 64 KiB on the wire
The wire estimate counts JSON escaping (six bytes for every non-ASCII character and for
", \, <, >, &, ', + and `), base64 binary and an @odata.type annotation for every value other than a string,
bool or int (doubles included). A TableEntity is measured as is; another ITableEntity as the SDK
serializes a model (public properties, [IgnoreDataMember] skipped, [DataMember(Name)] honoured).
MaxTransactionActions, MaxTransactionPayloadBytes, MaxTransactionWireBytes, MaxTransactionEntityBytes,
MaxEntityBytes and MaxEntityProperties are public constants.
Polymorphic storage
IStorage<T> is one CLR type per table. When many types share one table — an event store, a command
log, an outbox — use IPolymorphicStorage<TBase> instead. Rows carry a _TypeName discriminator and
read back as TBase with the true derived instance intact.
services.AddUnifiedTableStorage(connectionString);
services.AddUnifiedPolymorphicTable<IEvent>("StateEventStore");
services.AddUnifiedPolymorphicTable<IEvent>("TransactionStore");
public sealed class StateEventStore(
[FromKeyedServices("StateEventStore")] IPolymorphicStorage<IEvent> storage)
{
public Task SaveAsync(string aggregateId, IReadOnlyCollection<IEvent> events) =>
storage.InsertBatchAsync(
[.. events.Select(e => new PolymorphicWrite<IEvent>(
new TableKey(aggregateId, e.Version.ToString("D9")), e))]);
public async Task<IReadOnlyList<IEvent>> GetAsync(string aggregateId)
{
var entries = await storage.QueryAsync(aggregateId);
// Item, not Value: a partition may hold marker rows (see below), and Value throws on one.
return [.. entries.Where(e => e.Item is not null).Select(e => e.Item!)];
}
}
[FromKeyedServices] takes an attribute argument, so the table name has to be a compile-time
constant; a name computed at runtime must be resolved imperatively with
serviceProvider.GetRequiredKeyedService<IPolymorphicStorage<IEvent>>(tableName) instead.
Keys are explicit and verbatim. TableKey(PartitionKey, RowKey) is passed on every operation and
is never normalized — a polymorphic row key is usually a case-sensitive payload or a zero-padded
counter, and lower-casing it would address a different row.
Marker rows. A write whose Item is null stores system columns only, with no discriminator.
That lets a commit flag share one transaction with the rows it guards; it reads back as an entry
whose Item is null and whose Columns are intact.
await storage.InsertBatchAsync([
..events.Select(e => new PolymorphicWrite<IEvent>(new TableKey(txId, RowKey(e)), e)),
PolymorphicWrite<IEvent>.Marker(new TableKey(txId, "FlagEntity"),
new Dictionary<string, object> { ["_IsCommitted"] = false }),
]);
await storage.MergeColumnsAsync(new TableKey(txId, "FlagEntity"),
new Dictionary<string, object> { ["_IsCommitted"] = true });
Type discriminators. The default AssemblyQualifiedTypeDiscriminator stores
Type.AssemblyQualifiedName, byte-identical to what persistType: true has always written — so an
existing table reads with no migration. Prefer a map for anything new: an assembly-qualified name
breaks on rename and costs a few hundred bytes on every row, charged against the transaction budget
that caps batch size.
services.AddUnifiedTableStorage(cs, o => o.TypeDiscriminator =
new TypeDiscriminatorMap()
.MapAssignableTo<IEvent>(typeof(OrderPlaced).Assembly)
.WithAssemblyQualifiedFallback()); // keep reading legacy rows while writes converge
Every read verifies the resolved type is assignable to TBase and throws otherwise. No
configuration disables that check — deserializing a type named by stored bytes is a gadget surface,
and a resolver is not a security boundary.
The store owns its table. There is no server-side type filter, so every enumerating operation sees every row in scope. Point two stores at one table and each sees the other's rows.
Not supported here: caching, LINQ predicates, QueryPageAsync cursors, UpdateBuilder,
ConcurrencyMode, and [ProtectedProperty]. Rows are immutable facts plus mutable _-prefixed
system columns; MergeColumnsAsync is the one mutation.
Id convention
Entity.Id is a composite string split on the first | (helpers: EntityId.Normalize,
EntityId.Split, EntityId.Combine):
Id |
PartitionKey | RowKey |
|---|---|---|
"eu|alice" |
eu |
alice |
"vision|exec|agent" |
vision |
exec|agent |
"single" |
single |
single |
Ids are normalized on write (trim → replace spaces with '-' → ToLowerInvariant). Id is also stored as
a data column, so the full composite id is preserved on read.
Testing with Unified.Data.Tables.InMemory
// In tests (or a dev/offline host):
services.AddUnifiedInMemoryStorage(); // open-generic IStorage<> → InMemoryStorage<>
var store = new InMemoryStorage<Customer>(); // or construct directly
await store.CreateAsync(new Customer { Id = "eu|alice" });
Assert.Equal(1, store.Count); // + Clear(), Snapshot() conveniences
The fake is deliberately faithful: rows round-trip through the REAL serializer (decimal-as-double,
enum-as-string, flattening, __Json/__GZip, 64 KB handling), duplicate CreateAsync throws
DuplicateKeyException, updating a missing row throws 404, stale ETags throw
ConcurrencyConflictException per ConcurrencyMode, deletes are idempotent,
and results arrive in lexical key order — so a green test against the fake means the same code holds
against Azure Tables. [TableValueSplit] and TableBlobContent work too: the fake keeps offloaded blobs in
an in-memory dictionary, with the same integrity checks and SweepOrphanBlobsAsync support (it needs no
BlobServiceClient and ignores BlobOffloadOptions.ServiceClient). It plans the same rows and
transactions, refuses the same keys and shapes, takes back leftover continuation rows, cleans up only a
row's own blobs and releases the same blobs at once, retries a write that lost a race with the same five
attempts and pauses (and a read whose blobs a concurrent replace released, with the same
ConcurrencyConflictException after five), and pages a split-aware type as the table does (a continuation
row takes a place in a page). Like TableStorage<T>, it serializes every entity of a plain type's batch
before it stores the first, so a batch in which one entity cannot be serialized stores nothing (0.8.3's
in-memory store kept the entities before it). Three differences are worth knowing: it has no read cache;
a stream opened on a TableBlobContent holds the bytes it was opened with, so a blob deleted meanwhile
does not fail that read; and a builder partial update checks that the row exists and that its
WithETag matches before it looks at its paths, so for a missing row or a stale ETag it throws the 404
or the conflict where TableStorage<T> throws the refusal of the path (a large property, a member
marked on an interface).
InMemoryStorage<T> and InMemoryPolymorphicStorage<TBase> succeed and fail where TableStorage<T> and
PolymorphicTableStorage<TBase> do over the service. Failures carry the service's Status and
ErrorCode, batches are atomic per transaction only, QueryOptions.Select projects the row, a cancelled
token commits nothing, and a key the service refuses is refused. A differential suite
(InMemoryFidelityTests) runs each scenario on both stores, the service side on Azurite. The CHANGELOG's
0.9.0 "InMemory fidelity" list names each behaviour that changed for tests written against the fake.
The polymorphic store has the same mirror, keyed the same way:
services.AddUnifiedInMemoryPolymorphicTable<IEvent>("StateEventStore",
o => o.TypeDiscriminator = new TypeDiscriminatorMap().MapAssignableTo<IEvent>(asm));
Pass the same discriminator configuration production uses. Without it the fake resolves whatever
UnifiedTableStorageOptions the container happens to hold — and a test host that registers only this
line holds none, so it would quietly fall back to assembly-qualified tokens while production writes
short ones. Nothing fails; the tokens simply differ, in the one place no assertion looks. The table
name is passed through to the store too, not just used as the DI key, so DuplicateKeyException
names the same table the Azure store would.
ASP.NET Core Identity
Unified.Data.Tables.Identity ports ASP.NET Core Identity's IUserStore/IRoleStore contracts onto
IStorage<T> — passwords, external logins, claims, roles, tokens, two-factor and lockout, all as
ordinary rows through the same storage abstraction as your domain entities.
dotnet add package Unified.Data.Tables.Identity
Register it
The package depends on Unified.Data.Tables.Abstractions only, so it deliberately does not
register a storage provider — pick one yourself, then wire the stores on top:
using Unified.Data.Tables.Identity;
builder.Services.AddUnifiedTableStorage(connectionString); // or AddUnifiedInMemoryStorage() in tests
builder.Services
.AddIdentityCore<IdentityUser>()
.AddRoles<IdentityRole>()
.AddUnifiedIdentityStores();
Because it only touches IStorage<T>, the same registration works unmodified against
Unified.Data.Tables.InMemory — an entire Identity stack becomes unit-testable with no Azurite
emulator, no test container, nothing but the fake.
Tables
Seven Entity-derived row models, one Azure table each (named after the type, per the package's
usual typeof(T).Name convention): IdentityUserModel, IdentityRoleModel,
IdentityUserRoleModel, IdentityUserClaimModel, IdentityRoleClaimModel,
IdentityUserLoginModel, IdentityUserTokenModel. Keys are composed deterministically by
IdentityKeys — GUID and constant components are used verbatim, unbounded user- or
provider-supplied text (claim values, login provider keys) is MD5-hashed, because Azure Table
Storage rejects /, \, #, ? and control characters in PartitionKey/RowKey.
Disable caching for user rows
User rows carry SecurityStamp, PasswordHash and LockoutEnd. On Azure, the default sliding
cache can serve a revoked security stamp indefinitely on a multi-instance host — signing a user out
everywhere, or locking them out, wouldn't reliably take effect. Turn caching off for that one type:
builder.Services.AddUnifiedTableStorage(connectionString, o =>
o.CacheFor<IdentityUserModel>(CachePolicy.Disabled));
Login rows use CreateAsync, not upsert
Every other association table (roles, claims, tokens) upserts. Login rows don't, and that's
intentional rather than an inconsistency: a login's key is {provider}|{md5(providerKey)}, and the
owning UserId lives in the row's value, not its key. An upsert would silently reassign
ownership of an external identity to whoever wrote last; CreateAsync fails loud on a duplicate
instead, so a second account colliding on the same external identity surfaces as an error rather
than a silent takeover.
AddToRoleAsync throws when the role does not exist
The other role members stay quiet about a missing role — IsInRoleAsync returns false,
RemoveFromRoleAsync is a no-op and GetUsersInRoleAsync returns empty. AddToRoleAsync is the
exception: it throws InvalidOperationException, matching EF Core's own store. It is a mutation
that cannot be satisfied, and UserManager.AddToRoleAsync does not validate role existence itself,
so a silent return would surface to the caller as IdentityResult.Success having assigned nothing.
Code wrapping UserManager.AddToRoleAsync should expect the throw and translate it (typically to a
400) rather than assume every failure arrives as a failed IdentityResult.
Custom user and role types are not supported yet
AddUnifiedIdentityStores() inspects the IdentityBuilder and throws InvalidOperationException
at startup for anything but exactly IdentityUser and (if roles are enabled) exactly
IdentityRole — a subclass like class AppUser : IdentityUser is rejected, not silently coerced.
Table names are derived from typeof(T).Name, so supporting custom types means the consumer
supplying their own row model; that's an additive change reserved for a later version, not
something this version does partially.
Building & testing
dotnet build Unified.Data.Tables.slnx -c Release
dotnet test Unified.Data.Tables.slnx -c Release
The test suite mixes fast unit tests (Azure SDK mocked with NSubstitute) with integration tests that run
against a local Azurite emulator (tables on port 10002, blobs on 10000). The integration tests skip
only when nothing is listening there (connection refused, host unreachable), so dotnet test is safe to
run anywhere; an emulator that answers but fails (a timeout, an error) fails them after three attempts.
Set UNIFIED_TABLES_REQUIRE_AZURITE=1 to make an absent emulator a failure too. Each integration test
uses partitions of its own (a name plus a GUID), so several test runs can share one emulator.
Three GitHub workflows run the suite with that variable set and a pinned Azurite (3.35.0,
--loose --skipApiVersionCheck), so nothing is gated by skipped integration tests:
- CI (
.github/workflows/ci.yml, jobBuild and test) runs on every pull request and every push tomain: it builds the solution in Release and runs all tests onubuntu-latest. It needs no secret. This is the check that gates a change on the tests; requireCI / Build and testin branch protection. - SonarQube (
sonarqube.yml, jobBuild and analyze) runs the scanner's begin, the build, the tests and the scanner's end as separate steps, so a failed build or test fails the job and its analysis is not uploaded. It needs a validSONAR_TOKENand fails atBegin analysiswithout one, whatever the tests do. - Publish (
publish.yml) runs the tests, packs, and stops before the push unless the packedUnified.Data.Tablesdepends onUnified.Data.Tables.Abstractions[x.y.z]andUnified.Data.Tables.InMemoryonUnified.Data.Tables[x.y.z], the versions packed in the same run.
TableEntitySerializer.OversizedCellPolicy is process-wide state, so a test class that sets it, or
calls AddUnifiedTableStorage or AddUnifiedInMemoryStorage (which set it), must carry
[Collection("OversizedCellPolicy")]; a test of the main test project fails when one does not.
License
Licensed under the MIT License. Copyright © Serhii Seletskyi.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Azure.Data.Tables (>= 12.11.0)
- Azure.Storage.Blobs (>= 12.29.2)
- Microsoft.Extensions.Caching.Memory (>= 10.0.9)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.9)
- Microsoft.Extensions.Hosting.Abstractions (>= 10.0.9)
- Microsoft.Extensions.Logging.Abstractions (>= 10.0.9)
- Unified.Data.Tables.Abstractions (= 0.9.0)
NuGet packages (1)
Showing the top 1 NuGet packages that depend on Unified.Data.Tables:
| Package | Downloads |
|---|---|
|
Unified.Data.Tables.InMemory
Faithful in-memory IStorage<T> for Unified.Data.Tables: rows round-trip through the real TableEntitySerializer (so tests exercise production serialization behavior — decimal-as-double, enum-as-string, nested flattening, oversized cells, lossless [TableValueSplit] large values with an in-memory blob store for offload), with the service's status and error codes (409 on duplicate create, 412 on ETag conflicts), per-transaction batches, QueryOptions.Select projection, and identical id normalization and key splitting. For unit tests, dev mode, and offline runtime. |
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 0.9.0 | 45 | 11/12/2026 |
| 0.8.3 | 846 | 8/22/2026 |
| 0.8.2 | 135 | 8/21/2026 |
| 0.8.1 | 133 | 8/20/2026 |
| 0.8.0 | 140 | 8/18/2026 |
| 0.7.0 | 205 | 8/16/2026 |
| 0.6.0 | 154 | 8/16/2026 |
| 0.5.3 | 160 | 7/13/2026 |
| 0.5.2 | 138 | 7/13/2026 |
| 0.5.1 | 152 | 7/12/2026 |
| 0.5.0 | 309 | 7/12/2026 |
| 0.4.0 | 152 | 7/10/2026 |
| 0.3.1 | 143 | 7/10/2026 |
| 0.3.0 | 152 | 7/10/2026 |
| 0.2.1 | 139 | 7/6/2026 |
| 0.2.0 | 129 | 7/6/2026 |
| 0.1.0 | 129 | 7/6/2026 |