Skip to content

Change tracking and saving

Mirko Da Corte edited this page Sep 9, 2026 · 13 revisions

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.

How change tracking works

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.

Saving

await db.SaveChangesAsync();               // flush every tracked change of this context
await db.Cats.SaveChangesAsync(oneCat);    // flush a single model

SaveChangesAsync is the synchronization point of the unit of work. Save once per logical operation rather than after every mutation.

Member-level updates

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.

New referred models auto-create

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.

Save refreshes the model

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.

Concurrency is per member

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.

Saves and transactions

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.

After the save: denormalized references

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.

Clone this wiki locally