Optimistic Concurrency
Some resources may only be changed by a client that can prove it knows the current state. The service
hands out an ETag when the resource is read, and demands it back in an If-Match header on every
write. If somebody else has written in the meantime the ETag no longer matches, and the write is refused
instead of silently overwriting their change.
odata2ts does this for you: reading a resource remembers its ETag, and writing to it sends the header.
// the read remembers the ETag
await service.Copies(id).query().execute();
// the write sends it back as `If-Match`
await service.Copies(id).patch({ Condition: 7 }).execute();
When it applies
Only where the service says so. A resource is treated as concurrency-controlled when its entity set or
singleton carries the Core.OptimisticConcurrency annotation:
<Annotations Target="Library.Service.EntityContainer/Copies">
<Annotation Term="Core.OptimisticConcurrency">
<Collection />
</Annotation>
</Annotations>
The annotation may name the properties the ETag is computed from, or — as above — nothing at all. Both are enough: the value always arrives in the response, so the client never needs the property name.
In OData V2 there is no such annotation. The concurrency token is part of the schema language
instead, as ConcurrencyMode="Fixed" on the property. odata2ts reads that and treats the entity sets
of that type exactly the same way.
An ETag header on its own is deliberately not enough. The specification is explicit that a service may
hand one out purely for caching (OData V4.01 Part 1,
§11.4.1.1),
so sending If-Match for every resource that ever showed one would turn writes that succeed today into
conflicts.
Reading fills the store
Both a single read and a collection read do it:
// one read of the list is enough to write to any row of it
const copies = await service.Copies().query().execute();
await service.Copies(copies.data.value[0].Id).patch({ Condition: 7 }).execute();
A create stores the ETag of the entity it just created, so a newly created resource is writable straight
away.
Writing again
A write that answers 204 No Content without stating a new ETag makes the old one stale, so it is
forgotten. Writing a second time without reading again therefore fails — deliberately, because the client
can no longer prove anything about the current state:
await service.Copies(id).patch({ Condition: 7 }).execute();
await service.Copies(id).patch({ Condition: 8 }).execute(); // ODataConcurrencyError
Ask for the entity back and both the data and the ETag are fresh in one round trip:
await service.Copies(id).patch<true>({ Condition: 7 }).execute();
Actions carry it too
An action bound to the entity - not a function, which only reads, and not one bound to a collection,
which addresses no single resource - sends If-Match from the same store a patch or query would
fill:
await service.Copies(id).query().execute();
await service.Copies(id).assessCondition({ newCondition: 5 }).execute();
Everything below applies just the same: a fresh read is enough, a stale or missing ETag throws
ODataConcurrencyError before the request is sent, and withETag() / ignoreETag() work exactly as
they do for patch.
When no ETag is known
ODataConcurrencyError is thrown before the request is sent — the service would answer 428 Precondition Required and change nothing, so the round trip has nothing to offer. The message names the
resource and the ways forward.
State the ETag yourself
Useful when your application keeps its own state, or after a page reload:
await service.Copies(id).patch({ Condition: 7 }).withETag(etag).execute();
This works whether or not anything was read, and whatever the metadata says about the resource.
Write regardless
If-Match: * means "apply this whatever the current state is". Services may reject it.
await service.Copies(id).patch({ Condition: 7 }).ignoreETag().execute();
…or for every write
blindConcurrencyWrites on the HTTP client makes an unknown ETag resolve to * instead of failing. This
is last-write-wins on purpose — for data imports and scripts, not for user-facing writes:
const client = new FetchClient(undefined, { blindConcurrencyWrites: true });
Handling a conflict
A 412 Precondition Failed means somebody else wrote first. It arrives as the error your HTTP client
throws — it is deliberately not wrapped, so your existing error handling still recognises it — and
isConcurrencyConflict identifies it:
import { isConcurrencyConflict } from "@odata2ts/odata-service";
try {
await service.Copies(id).patch({ Condition: 7 }).execute();
} catch (error) {
if (isConcurrencyConflict(error)) {
// re-read, merge, and decide what should win
}
throw error;
}
Resolving the conflict is your application's business: only it knows whether the other change may be
overwritten, and odata2ts will not guess.
isConcurrencyRequired is the other half, for 428. Reaching it means the service demands ETags without
announcing them — the one case odata2ts cannot detect from the metadata.
Bringing your own store
By default the client keeps the ETags in memory, bounded at 10 000 resources. Supply a
ConcurrencyHandler to bound it differently, or to survive a page reload:
const client = new FetchClient(undefined, { concurrencyHandler: myHandler });
Switching it off
annotations.disableOptimisticConcurrency in the generator configuration stops the evaluation entirely.
No If-Match is then ever sent, and a service demanding one answers 428.
Limitations
- Navigation-reached entities are covered by the annotation of their entity set. A service that states
concurrency control only through
Capabilities.NavigationRestrictionsis not recognised. - Streams and media content (
…/$value) are written without an ETag. - A service that does not annotate itself gets no help; its writes are answered with
428, whichisConcurrencyRequiredidentifies.