Skip to main content

Partial Update (Patch)

A partial update changes individual fields of a document without reading it and without sending the rest of it back. Given a condition, it becomes something the abstraction could not express at all before: an atomic check-and-set.

// the balance only moves if it stays within the cap - no read, no retry, no lost update
var updated = await userRepository.PatchAsync(
user.Id,
user.TenantId,
p => p.Increment(x => x.Balance, 1),
condition: x => x.Balance < x.HardCap);

The condition and the operations are one atomic act on the document. Two callers racing to increment the same balance can never both pass a cap they jointly exceed, and a condition that does not hold is a business answer - "denied by the cap" - not a concurrency conflict to retry.

This feature is implemented for the Azure Cosmos DB and the in-memory provider with the same semantics and the same errors, so a quota rule can be covered by unit tests.

Operations​

OperationBehaviour
Set(x => x.Field, value)Writes a value, creating the field if the document does not carry it
Increment(x => x.Counter, 5)Adds to a long field; a field that is not there starts at zero
Increment(x => x.Score, 0.5)Adds to a double field
Increment(x => x.Balance, 0.5m)Adds to a decimal field marked with [FixedPoint]

Increment takes signed values, so a decrement is Increment(..., -n) - there is no Decrement. Operations chain, and up to ten of them are applied in one act:

await userRepository.PatchAsync(
user.Id,
user.TenantId,
p => p
.Set(x => x.Firstname, "Patched")
.Increment(x => x.LoginCount, 1));

When to prefer it over UpdateAsync​

UpdateAsync reads the document, applies your change and writes the whole document back. That is the right tool when the new state depends on the old one in a way only C# can express. A patch is the right tool when it does not:

  • A counter or a flag. Increment never loses a concurrent update, because it is applied by the database, not computed by a caller.
  • One field of a large document. Only the operations travel, not the document.
  • A rule that has to hold at the moment of the write. That is the conditional form below, and UpdateAsync cannot express it - between its read and its write, another caller fits.

The conditional form​

The condition is an ordinary predicate over the entity:

condition: x => x.Status == UserStatus.Active && x.Credits < x.CreditsLimit

It is evaluated by the database at the moment of the write. If it does not hold, no operation of the patch is applied and a ConflictErrorException with the code PatchConditionNotMet is thrown. Inside a transactional batch, the whole batch is rolled back.

A missing document is always a NotFoundErrorException, with or without a condition - "the document is not there" and "the state does not permit this" are different answers.

A condition compares, it does not compute

Cosmos DB translates the condition into a filter predicate, which is parsed by a stricter parser than a query: it compares fields against constants and against each other, but it does not evaluate arithmetic on document fields. x => x.Balance + 1 <= x.HardCap is refused with PatchConditionNotSupported; write the arithmetic on the constant side instead, e.g. x => x.Balance < x.HardCap or x => x.Balance <= cap - delta with cap and delta being values from your code.

The in-memory provider compiles conditions in process and therefore accepts more than Cosmos DB does. A condition that passes in a unit test can still be refused against a real database, which is why the Cosmos test suite covers this case explicitly.

Why a failed condition is a Conflict and not a PreconditionFailed​

Cosmos DB answers a failed conditional patch with HTTP 412, so PreconditionFailedErrorException looks like the obvious mapping. It is the wrong one.

Every repository is wrapped by a retry policy that retries exactly that exception type three times with an exponential backoff, because a stale eTag means "someone changed this, read again and decide" - which a retry can resolve. A failed patch condition means "the state does not permit this", and it is deterministic: the same call against the same state fails identically. Mapping it to PreconditionFailed would burn three retries and a backoff before returning an answer the first attempt already had.

So the two stay apart, and a caller can tell them apart even when one batch carries both:

CauseExceptionCode
Condition did not holdConflictErrorExceptionPatchConditionNotMet
Stale eTag on a ReplacePreconditionFailedErrorExceptionEtagMismatch

Inside a transactional batch​

A patch is an operation of a transactional batch, so a conditional increment and a document create commit together or not at all:

await userRepository
.CreateTransactionalBatch(tenantId)
.Create(usageEvent)
.Patch(
account.Id,
p => p.Increment(x => x.Credits, 1),
condition: x => x.Credits < x.CreditsLimit)
.ExecuteAsync();

PatchAsync returns the patched document - a single patch has exactly one result, and the new value is usually the reason for the call. ExecuteAsync on a batch returns nothing, because a per-item result would need an index-aligned type its other operations cannot fill. The single patch therefore asks for the write response and pays its request charge; the batch does not.

Errors​

SituationExceptionCode
Condition did not holdConflictErrorExceptionPatchConditionNotMet
Document does not existNotFoundErrorExceptionEntityNotFound
Path is not a chain of member accesses, is not writable, or addresses a member the operation cannot be applied toUnexpectedErrorExceptionPatchPathNotSupported
Path targets the id, the partition key or the eTagUnexpectedErrorExceptionPatchPathNotAllowed
More than ten operationsUnexpectedErrorExceptionPatchOperationLimitExceeded
No operationsUnexpectedErrorExceptionPatchIsEmpty
Condition uses an unsupported constructUnexpectedErrorExceptionPatchConditionNotSupported
The Cosmos client cannot report how it names a memberUnexpectedErrorExceptionPatchMemberNamesNotResolvable
A fixed-point value carries more decimal places than its declared scaleUnexpectedErrorExceptionFixedPointPrecisionExceeded
A fixed-point value is outside the range the database holds exactlyUnexpectedErrorExceptionFixedPointValueOutOfRange
A stored fixed-point counter has grown out of that rangeUnexpectedErrorExceptionFixedPointStoredValueOutOfRange

The path errors are thrown while the operations are collected, before any I/O.

Constraints​

  • One document. A patch addresses one id in one partition.
  • Ten operations. The Cosmos DB limit, enforced for every provider.
  • An empty patch is an error, not a no-op - unlike an empty batch, which a caller can reach by looping over an empty collection, a patch without operations is always a mistake at the call site.
  • The id, the partition key and the eTag cannot be patched. Changing an id or a partition key relocates a document, and the eTag belongs to the provider. Both are rejected before any I/O.
  • Paths are member accesses. x => x.Balance and x => x.Inner.Value are paths; a method call, an indexer or a computed member is not. The field name is resolved through the serializer of the Cosmos client itself, so a [JsonPropertyName] override is honoured - and a client configured with a serializer that cannot report its member names refuses to patch at all rather than guessing at the field a path addresses.
  • A field whose serialized name carries a / or a ~ cannot be patched against Cosmos DB: a patch path separates its segments with a slash, and the escape a JSON pointer would use for one is taken literally. Such a path is refused with PatchPathNotSupported instead of writing to the wrong field. The in-memory provider addresses members directly and is not affected.
  • Filters do not apply. Read filters, property filters and soft delete are not applied to a patch, consistent with the other write paths.
  • A [FixedPoint] decimal is carried as its scaled integer. Both the value of a Set or an Increment and the constants of the condition are scaled by 10^Scale before they leave the process, so p.Increment(x => x.Balance, 0.5m) with condition: x => x.Balance <= 100m becomes incr /balance 500000 under FROM c WHERE c.balance <= 100000000. An increment operand finer than the declared scale, or out of the exact range itself, is refused before any I/O. The result is not pre-checked - the database applies the increment without reading the current value - so keeping the counter inside the exact range is the caller's job - see [FixedPoint].

Not supported​

  • Add, Remove, Move and array-index paths. Set and Increment cover the cases this exists for.
  • decimal increments without [FixedPoint]. Cosmos DB increments a field as a 64-bit integer or as a double, and narrowing a decimal to a double would silently lose precision on values that are usually money. A plain decimal member is therefore refused with PatchPathNotSupported; mark it with [FixedPoint(Scale = n)] to store it as an exact scaled integer, keep it in a long of minor units, or read-modify-write it with UpdateAsync.
  • An increment whose value does not match the kind of the field.Increment(x => x.LoginCount, 0.5) on an int binds to the double overload, and Increment(x => (long)x.Score, 1) reaches the long overload through a cast. Either way the two providers could only disagree about the result, so both are refused with PatchPathNotSupported: increment an integral field by a whole number and a floating point field by a floating point value.
  • Patching across documents or partitions.