-
Notifications
You must be signed in to change notification settings - Fork 4
Change tracking and saving
Scrinium tracks what you change on loaded models and, on save, writes back only the changed members atomically. This page explains how tracking works and the exact save semantics — including concurrency and schema handling.
Tracking is snapshot based. When a model is loaded (or created with CreateAsync), its serialized
document is captured as the model document; on save the model is serialized again and diffed
member by member against it — what differs is what gets written. A diff with no difference writes nothing.
Loaded entities are proxies, and keep the save cheap by flagging themselves as
change candidates: a property set or a method call marks the model, and only flagged proxies are
diffed at save (they are what ChangedModelsList exposes). Reading a member flags the model too
when it hands out mutable state — a mutable collection, or a complex value with public setters or
methods — because a change made through the handed-out value can't be observed. See Domain models
for how to expose members so that reads stay free.
Models that are not proxies are tracked as well: an instance you new up and pass to
CreateAsync is tracked with its model document, always diffed at save, so mutations after the create
persist, and registered on the identity map as the instance of its document.
Models read under the no-cache modifier aren't tracked at all — that's the point of using it for
large scans.
await db.SaveChangesAsync(); // flush every tracked change of this context
await db.Cats.SaveChangesAsync(oneCat); // flush a single modelSaveChangesAsync is the synchronization point of the unit of work. Save once per logical
operation rather than after every mutation.
Each changed model is persisted with a single atomic statement that $sets only the changed
members computed by the model document diff (and $unsets members dropped by their serialization
options).
The update filter also matches
the model's active schema id — the guard is inside the filter so it's atomic with the write:
setting members shaped by the active schema into a document written under an older schema would mix
schemas and corrupt it.
A document on a non-active schema can't be patched safely, so it falls back to a
whole-document replace that migrates it to the active schema.
You can force whole-document replacement per repository with
RepositoryOptions.SaveWithDocumentReplace = true, and ReplaceAsync always
replaces.
A changed member can reference an entity that was never persisted (its id is still null): the
save creates those models first, into their source repositories, and then writes the update. See
References and denormalization for the full behavior and its failure modes.
After the write, Scrinium refreshes the saved model in place from the returned document:
- Members you didn't change locally pick up concurrent changes from other scopes — the save is where your in-memory model re-synchronizes.
- A saved summary upgrades to a full model.
- Reference members resolve through the identity map like any load: a referenced instance the scope already holds — loaded or created, full or summary — stays the value of its member, and a document the scope hasn't loaded enters it as a summary.
Inside a transaction — the implicit one, or an ambient ExecuteInTransactionAsync —
the refresh, the model document update and the change candidate clearing happen at the commit:
until then the model stays a change candidate, and an abort leaves it as it was before the
transaction, its changes still pending for the next SaveChangesAsync. A whole-document replace
inside a transaction follows the same rule.
Conflict granularity is the member: concurrent changes to disjoint members all survive; concurrent changes to the same member (arrays included — e.g. two scopes each adding to the same collection) are last-writer-wins. For hot fields, prefer an atomic server-side update.
With EnableTransactionsWithReplicaSet (default true) and a deployment that supports transactions,
db.SaveChangesAsync() flushes its changed models inside an implicit transaction of its own —
atomic with no extra code, and retried on a transient failure (a write conflict with a concurrent
transaction, a primary election) within the TransactionRetryTimeout option, like every
transaction. It's skipped when there are no changes, and when a session is already
ambient (inside ExecuteInTransactionAsync) the saves enlist in it instead of
nesting. CreateAsync uses the same implicit transaction: its insert and the SaveChangesAsync it
triggers run together, so a failing flush rolls the insert back instead of leaving an orphan document.
A direct repository.SaveChangesAsync(model) opens none: its update is a single atomic
statement, but its auto-creations are separate writes.
Child contexts save on their own connections, outside both. Standalone
servers use plain saves. See Transactions.
Changing a member that other documents denormalized enqueues a background propagation of the new summary — off the request path, outside the save transaction, converging to the committed state after an abort. See References and denormalization.
Next: Transactions to group multiple operations atomically, or References and denormalization for how denormalized copies stay in sync.
Scrinium — source · issues (SCR) · GNU LGPL-3.0 · info@etherna.io
Getting started
Core concepts
Working with data
Serialization & mapping
Operations & maintenance
Advanced & reference