Skip to content

Add opt-in import state cache (CacheImportState, default off) - #1016

Merged
KevinJump merged 1 commit into
v18/mainfrom
v18/feature/import-state-cache
Aug 5, 2026
Merged

Add opt-in import state cache (CacheImportState, default off)#1016
KevinJump merged 1 commit into
v18/mainfrom
v18/feature/import-state-cache

Conversation

@KevinJump

Copy link
Copy Markdown
Owner

Why

Someone passed on an AI analysis of v18/main focused on import performance on large sites (thousands of content and dictionary files). Its main finding checks out:

SyncSerializerRoot.DeserializeAsync calls IsCurrentAsync for every item, and IsCurrentAsync does a database lookup, a full re-serialize of the Umbraco item, CleanseNode on both sides, then two hashes. The report path pays the same through SyncHandlerRoot.IsItemCurrentAsync.

So an import where nothing has changed costs about as much as a full export. Reading the files is not the bottleneck — that is already nicely parallelised in GetFolderItemsAsync — the per-item work afterwards is, and at a few thousand files it is the whole run time.

Two of the analysis's supporting claims are real but smaller than presented, and are not addressed here (they are cheap independent fixes, deliberately not entangled with a feature flag):

  • DictionaryItemSerializer.CleanseNode really does deep-clone via XElement.Parse(node.ToString()), for both sides of every comparison, and GetLevelAsync walks the parent chain with an uncached service lookup per level.
  • The "hash the raw file bytes instead of parsing the XML" idea does not save the parse — GetFolderItemsAsync has to parse every file anyway for key/level/path. The saving is in skipping the database lookup, the re-serialize and the second hash. So this hashes the in-memory XElement we already have.

What this does

Adds uSync:Settings:CacheImportState (default false). With it on, uSync remembers the hash of each file it has confirmed matches Umbraco, and skips those items on the next run with no database lookup and no serialize. Import cost goes from O(all items) of database and serialization work to O(changed items).

Only confirmed matches are recorded

An entry is written in exactly two cases: the full check ran and returned NoChange, or we have just exported the item (so the file was written from the database and the two match by construction).

It deliberately does not record after a successful update or create. Tempting, but some items do not round-trip exactly — that is what uSync's "XML is different - but properties may not have changed" message is telling you — and recording those would silence a real difference for good.

Consequence worth being clear about: the first run after enabling is no faster than before. The benefit arrives on the second run. An export warms it too.

How it hooks in

Through uSync's existing per-item notifications. The report one already carried a comment saying it exists for precisely this — "this lets us intercept a report and shortcut the checking (sometimes)".

Notification What happens
uSyncImportingItemNotification / uSyncReportingItemNotification cancel the item if we already know the file matches
uSyncImportedItemNotification / uSyncReportedItemNotification record the hash if the answer was NoChange
uSyncExportedItemNotification record the hash — we just wrote the file from the database
uSync*Starting / uSync*Completed load / save the cache file

SyncSerializerRoot, every serializer, and the handler import/report methods are untouched. No constructor signatures changed either, which would have been source-breaking for uSync.Complete and community handlers/serializers. The only change to the pipeline is 19 lines in SyncHandlerRoot, all additive.

Storage and invalidation

  • {LocalTempPath}/uSync/cache/state-{identity}.jsonnever in the uSync folder. The cache describes this site's database, not the source of truth, so it must not travel between environments or show up in a diff. (Same precedent as uSync.History.)
  • Identity = a GUID stamp kept in Umbraco's key/value table (same pattern as SyncTrackerService) + the uSync version + a fingerprint of the settings that affect serialization. Any mismatch and the file is discarded. If the stamp can't be read, the cache isn't used at all.
  • SyncStateCacheInvalidator listens to Umbraco. Three levels: the item (saved/deleted/published), the whole type (moved — a move rewrites the path of every descendant), and everything (doc type, data type, template, language, container — these get embedded in other items' xml, so changing one silently changes items whose own rows never moved).
  • A force import ignores the cache entirely.

Two decisions reviewers should look at

No IsPaused guard on invalidation. The original design had one. Working through the ordering, it is both unnecessary and less safe: an item uSync writes gets invalidated and then simply isn't re-recorded (we only record confirmed NoChange), and dropping the guard also catches items Umbraco saves as a side effect of an import, which a paused check would miss. Reasoning is in the class docs.

Invalidations are journalled to disk. This turned out to be load-bearing, not a nicety. Removing an entry from memory does nothing about the copy in the file, and the file is what the next restart trusts — so an item saved between a run and a restart would be wrongly skipped. That is exactly the ImportAtStartup case, i.e. a main reason to want this at all. A tiny append per save, folded back in and cleared on the next persist, both halves under one lock so nothing can be lost between "manifest written" and "journal cleared".

Also: the identity fingerprint is built by hand rather than by serializing the settings object, because dictionaries bound from configuration enumerate in provider order. If that wobbled the identity would change on restart and the cache would silently never survive one — the only symptom being "the feature does nothing". There is a regression test for it.

Limitations (why it's off by default)

Full list in docs/perf/state-cache.md. The headline: with the cache on, uSync's change detection stops being self-verifying and starts trusting its own bookkeeping. It cannot see a database change made by something that raises no Umbraco notification (raw SQL, a row-level restore). The database stamp catches a whole-database swap; it cannot catch a targeted edit.

The pitfall I'd watch hardest is not raw SQL though — it's cross-item dependencies. The known ones are handled, but any serializer that pulls data in from another item is a new hole. That's called out in the docs for third-party serializer authors, and the policy lives in one set in SyncStateCache so it's a one-line extension.

Also documented: report is no longer an independent check (force import is the way to get a guaranteed full one); dictionary items whose file key differs from the database key; multiple handler sets with differing serialization settings; and LocalTempPath not surviving on Azure App Service / containers / scale-out, where the cache may rarely be warm and the feature quietly does little.

Deliberately not in this PR

UpdateDate verification. Storing each item's UpdateDate and validating against one IEntityService.GetAll(objectType) query per handler would give existence plus a timestamp for every item in a single query, at no per-item cost, closing the raw-SQL gap for real Umbraco node types. That's the obvious hardening step if notification-based invalidation proves too leaky; the manifest has a format version so it can be added without a migration.

Testing

  • dotnet build uSync.slnx — clean, 0 warnings, 0 errors.
  • 197 tests pass, including 20 new ones in uSync.Tests/Cache/SyncStateCacheTests.cs. They run against a real temp folder rather than a mocked file service, because the bits most likely to break involve real files. Notably: the hash surviving a save/reload round trip (the whole export warm-up depends on it), identity stability across differing dictionary order, invalidations surviving a reload, and the three invalidation levels.

Not yet verified against a real site. The acceptance test still to do: enable it on a site with a few thousand content and dictionary items, run a report twice, and confirm run 2 is dramatically faster and produces an identical action list to run 1. Any difference between the two is a bug. Also worth walking through: edit one item (only that item re-checked), rename a doc type (everything re-checked), move a node (descendants re-checked), force import (cache ignored), hand-edit a .config (change detected), change a handler setting (cache discarded), and setting the flag back to false (timings return to baseline).

🤖 Generated with Claude Code

uSync decides whether an item has changed by loading it from Umbraco,
serializing the whole thing and comparing hashes - for every item, every
run. SyncSerializerRoot.DeserializeAsync calls IsCurrentAsync for each
item, and that does a database lookup, a full re-serialize, CleanseNode
on both sides and two hashes. The report path pays the same through
SyncHandlerRoot.IsItemCurrentAsync.

The result is that an import where nothing has changed costs about as
much as a full export. Reading the files is not the bottleneck - that is
already parallelised in GetFolderItemsAsync - the per-item work
afterwards is, and on sites with thousands of content and dictionary
files that is the whole run time.

With CacheImportState on, uSync remembers the hash of each file it has
confirmed matches and skips those items next run with no database lookup
and no serialize, taking import cost from O(all items) of database and
serialization work to O(changed items).

Implemented entirely through uSync's existing per-item notifications -
the report one already carried a comment saying it exists for exactly
this ("this lets us intercept a report and shortcut the checking"). So
SyncSerializerRoot, every serializer, and the handler import and report
methods are untouched, and no constructor signatures changed (which
would have been source-breaking for uSync.Complete and community
handlers).

Only confirmed matches are recorded: either the full check ran and
returned NoChange, or we have just exported the item so the file was
written from the database. It never assumes that because an import
succeeded the two sides now agree - some items do not round-trip
exactly, and recording those would silence a real difference for good.
The cost of that choice is that the first run after enabling is no
faster than before; the benefit arrives on the second run.

The cache lives in the site's temp folder, never in the uSync folder,
and carries an identity (a stamp kept in Umbraco's key/value table, the
uSync version, and a fingerprint of the settings that affect
serialization) so it is discarded whenever it cannot be proven to still
apply. Items are forgotten when Umbraco reports a save, delete, move or
publish; changing a doc type, data type, template, language or container
clears the whole cache, because those get embedded in other items' xml.
A force import ignores the cache entirely.

Two details worth calling out:

- Invalidations are journalled to disk. Removing an entry from memory
  does nothing about the copy in the file, and the file is what the next
  restart trusts, so an item saved between a run and a restart would
  otherwise be wrongly skipped - exactly the ImportAtStartup case.
- The identity fingerprint is built by hand rather than by serializing
  the settings, because dictionaries bound from configuration enumerate
  in provider order. If that wobbled, the identity would change on
  restart and the cache would silently never survive one.

Also adds an optional Message to CancelableuSyncItemNotification (used
instead of the generic "change stopped by delegate event" when set) and
Force to the importing/reporting notifications, so a subscriber that
short-cuts the check can stand down on a forced import.

Off by default: what it cannot see is a database change made by
something that raises no Umbraco notification. See
docs/perf/state-cache.md for the full set of limitations, including the
note for anyone writing a custom serializer that embeds data from
another item.

Not yet verified against a real site - the run-1 vs run-2 action list
comparison still needs doing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@KevinJump
KevinJump merged commit 4eeb3df into v18/main Aug 5, 2026
5 checks passed
@KevinJump
KevinJump deleted the v18/feature/import-state-cache branch August 5, 2026 10:03
glitchedmob added a commit to sgfdevs/cms.methodconf.com that referenced this pull request Aug 23, 2026
Updated
[Microsoft.EntityFrameworkCore.Design](https://github.com/dotnet/dotnet)
from 10.0.10 to 10.0.11.

<details>
<summary>Release notes</summary>

_Sourced from [Microsoft.EntityFrameworkCore.Design's
releases](https://github.com/dotnet/dotnet/releases)._

No release notes found for this version range.

Commits viewable in [compare
view](https://github.com/dotnet/dotnet/commits).
</details>

Updated [Microsoft.NET.Test.Sdk](https://github.com/microsoft/vstest)
from 18.8.1 to 18.9.0.

<details>
<summary>Release notes</summary>

_Sourced from [Microsoft.NET.Test.Sdk's
releases](https://github.com/microsoft/vstest/releases)._

## 18.9.0

## What's Changed
* Fix tilde/exclamation characters corrupted in TerminalLogger test
output by @​nohwnd in microsoft/vstest#16046
* Make TranslationLayer Native AOT-compatible by @​drewnoakes in
microsoft/vstest#16045
* Guard GenerateProgramFile target against UseWinUI/UseUwpTools
evaluation order by @​nohwnd in
microsoft/vstest#16072
* Add RequestingAssembly to AssemblyResolveEventArgs for binary compat
by @​nohwnd in microsoft/vstest#16076
* Remove stale Microsoft.Extensions.FileSystemGlobbing binding redirect
from testhost.x86 and datacollector by @​Evangelink in
microsoft/vstest#16082
* Fix TRX attachment paths when LogFileName contains a subdirectory by
@​nohwnd in microsoft/vstest#15791
* Fix missing dumps for .NET Framework child processes in
NetClientHangDumper by @​nohwnd in
microsoft/vstest#16098
* Fix data collection channels to use negotiated protocol version
instead of V1 by @​nohwnd in
microsoft/vstest#16096
* Fix race condition in BlameCollector: skip hang dump when testhost
hasn't launched yet by @​nohwnd in
microsoft/vstest#16065
* Replace TestSDKAutoGeneratedCode with ExcludeFromCodeCoverage in
auto-generated Program files by @​nohwnd in
microsoft/vstest#16101
* Include testhost process path in crash error messages by @​nohwnd in
microsoft/vstest#16108
* Fix DataDriven test results being double-counted in TRX logger totals
by @​nohwnd in microsoft/vstest#15766
* Fix datacollector crash visibility: replace Assert with throwable
exceptions by @​nohwnd in microsoft/vstest#16048
* Add TreatErrorMessagesAsWarnings parameter to TRX logger by @​nohwnd
in microsoft/vstest#16106
* Wait for testhost stderr to drain before reading its crash output by
@​nohwnd in microsoft/vstest#16128
* Handle runtimeconfig.dev.json without additionalProbingPaths by @​tmat
in microsoft/vstest#16166
* Suggest Microsoft.NET.Test.Sdk when a managed test project brings no
testhost by @​nohwnd in microsoft/vstest#16169
* Fix x86 testhost loading mismatched x64 hostfxr (0x800700C1) when run
via vstest.console.exe directly (#​16151) by @​azat-msft in
microsoft/vstest#16156
* Preserve the real exception (type + stack trace) when a test run
aborts in BaseRunTests by @​nohwnd in
microsoft/vstest#16167

## New Contributors
* @​drewnoakes made their first contribution in
microsoft/vstest#16045

**Full Changelog**:
microsoft/vstest@v18.8.0...v18.9.0

Commits viewable in [compare
view](microsoft/vstest@v18.8.1...v18.9.0).
</details>

Updated [Umbraco.Cms](https://github.com/umbraco/Umbraco-CMS) from
18.1.0 to 18.1.1.

<details>
<summary>Release notes</summary>

_Sourced from [Umbraco.Cms's
releases](https://github.com/umbraco/Umbraco-CMS/releases)._

## 18.1.1

## What's Changed

### 🔒 Security
* Resolved incorrect authorization lets Content-only backoffice users
modify Templates, enabling remote code execution from
GHSA-f7m5-5x7g-2p52
* Resolved insufficient authorization on Management API search endpoints
from
GHSA-w5q3-9wf8-43gg
 
### 🐛 Bug Fixes
* Routing: Don't retain the fallback default culture captured during an
upgrade boot (closes #​22581) by @​AndyButland in
umbraco/Umbraco-CMS#23653

**Full Changelog**:
umbraco/Umbraco-CMS@release-18.1.0-rc...release-18.1.1

Commits viewable in [compare
view](umbraco/Umbraco-CMS@release-18.1.0...release-18.1.1).
</details>

Updated [uSync](https://github.com/KevinJump/uSync) from 18.0.3 to
18.1.1.

<details>
<summary>Release notes</summary>

_Sourced from [uSync's
releases](https://github.com/KevinJump/uSync/releases)._

## 18.1.1

## Fixes

- **Import**: fixed a property that's been moved out of all groups (an
empty tab, no matching `<Tabs>` entry) not actually persisting that move
on import — it took a second import to stick.
([#​1043](KevinJump/uSync#1043))
- **Reliability**: save failures rejected by Umbraco (e.g. an invalid
ISO code, a rejected content/media/template save) were previously
discarded silently, so a failed import step could still get reported as
a success. These now surface as proper import failures across Language,
DictionaryItem, Webhook, ContentType/MediaType/MemberType, DataType,
Domain, Template, Media, and Content serializers.
([#​1040](KevinJump/uSync#1040),
[#​1042](KevinJump/uSync#1042))
- **Blueprints**: fixed bulk-imported content blueprints being saved
through the wrong path, which silently corrupted their object type and
caused duplicate-key errors on the next import. Blueprints saved during
the second-pass bulk import now go through `SaveBlueprint` like a normal
single-item save.
([#​1035](KevinJump/uSync#1035))

## Other

- Dependency bumps (chalk, vite-plugin-dts, and a few NuGet/npm
dependency-group updates).
- CI/CD: nightly builds are now automatically published to the [Azure
Artifacts nightly
feed](https://pkgs.dev.azure.com/jumoo/Public/_packaging/nightly/nuget/v3/index.json)
on every push to this branch.


## 18.1.0

This is a minor update to uSync for Umbraco 18 — an opt-in import
performance cache, a double-export fix for save-and-publish, several
allocation/perf improvements ported from v17, and a move to the
`Jumoo.Json` package for JSON handling.

## uSync 18.1.0

**Added**
- **Opt-in import state cache — `uSync:Settings:CacheImportState`
(default `false`).** uSync normally decides whether an item has changed
by loading it, serializing it, and hashing it — every item, every run.
With this on, uSync remembers the hash of items it has already confirmed
match, so unchanged items are skipped without a database lookup or
re-serialize on the next run. The first run after enabling it is no
faster than before; the benefit lands on the second run. Read
[`docs/perf/state-cache.md`](https://github.com/KevinJump/uSync/blob/v18/main/docs/perf/state-cache.md)
before enabling it, especially the limitations section.
([#​1016](KevinJump/uSync#1016))
- **Extender API:** the cancelable per-item notifications gain an
optional `Message` (used instead of uSync's generic cancel message) and,
on the import/report ones, `Force`, so a subscriber can stand down when
the user has asked for a forced import.

**Fixed**
- **Content and Library items are no longer exported twice by one editor
action.** Umbraco 18.1 raises the saved notification for a
save-and-publish as well as the published one
([umbraco/Umbraco-CMS#​23523](umbraco/Umbraco-CMS#23523));
uSync now shares a record of exported items across the notifications for
one operation, so each item is exported once.
([#​1018](KevinJump/uSync#1018))
- `HandlerSettings.Clone()` no longer drops `CreateClean` and
`FullFileOnDifference` — handlers that set either value in their own
block are now honoured.
- Property values containing a quote, backslash or control character are
now converted to valid JSON (previously produced invalid JSON in the
string fallback). Inherited with the move to `Jumoo.Json`.
- Restored backoffice guard and content paging fixes that were dropped
in the v17 → v18 merge.
([#​1012](KevinJump/uSync#1012))

**Performance**
- Ported the v17 allocation work: fewer redundant dictionary lookups on
hot paths, and `internal`/`private` classes are now `sealed` so the JIT
can devirtualize their calls. No behavioural changes.
([#​1019](KevinJump/uSync#1019),
[#​1020](KevinJump/uSync#1020))
> **Extender API:** `SyncHandlerRoot.SyncChangeInfo` is now `sealed`
(still `protected`, so handlers can still construct/return one from
`IsItemCurrentAsync`, just not derive from it).
- **Handler settings now inherit from `HandlerDefaults`.** A handler's
own settings block is layered over the set's defaults instead of
replacing them wholesale, so it only needs to specify what it wants to
change. ([#​1001](KevinJump/uSync#1001))
> **Breaking:** a handler block that previously reset settings back to
built-in defaults will now inherit the set's `HandlerDefaults` instead.
Review any set mixing `HandlerDefaults` with per-handler blocks.
- **JSON helpers now come from the `Jumoo.Json` package**, replacing
uSync's own copy which had drifted behind it.
`uSync.Core.Extensions.JsonTextExtensions` still works but is
`[Obsolete]` (removed in v20) — switch to `using Jumoo.Json;`. Note
`TryGetPropertyAsObject` → `TryGetPropertyAsJsonObject`,
`GetPropertyAsObject` → `GetPropertyAsJsonObject`, and missing/null
values now return `null` instead of `string.Empty`.
([#​1014](KevinJump/uSync#1014),
[#​1015](KevinJump/uSync#1015))
- Removed three O(n²) lookups from import (duplicate-key/"keys to keep"
checks now use set lookups; second-pass content/media imports index the
action list once instead of scanning it per item).
([#​1017](KevinJump/uSync#1017))
> Actions updated by a second pass are now updated in place, keeping
their original position in the results list, rather than being moved to
the end. Only display/reporting order is affected.
> **Extender API:** `List<uSyncAction>.CreateActionIndex()` and a
matching `UpdateActions(index, key, handlerAlias, attempt)` overload are
new.
- Serializing, comparing and expanding large property values allocates
far less, some previously on the large object heap. Inherited with the
move to `Jumoo.Json`.

**Extender API**
- `ISyncManagementService` gains `UnpackStreamAsync(Stream)`; the
synchronous `UnpackStream(Stream)` is now obsolete (removed in v19).
([#​1005](KevinJump/uSync#1005))

**Cleanup**
- Cleared the remaining build warnings left over from the Umbraco 18
upgrade: replaced `ITemplate.MasterTemplateAlias` with
`LayoutTemplateAlias`, and inlined the legacy `{localLink:x}` parsing
Umbraco is removing in v18. No behavioural changes.
([#​1002](KevinJump/uSync#1002),
[#​1003](KevinJump/uSync#1003),
[#​1004](KevinJump/uSync#1004))

**Full Changelog**:
KevinJump/uSync@v18.0.3...v18.1.0


Commits viewable in [compare
view](https://github.com/KevinJump/uSync/commits/v18.1.1).
</details>

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions


</details>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Levi Zitting <me@levizitting.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant