Skip to content

Commit 4eeb3df

Browse files
KevinJumpclaude
andauthored
Add opt-in import state cache (CacheImportState, default off) (#1016)
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>
1 parent bad27d9 commit 4eeb3df

14 files changed

Lines changed: 2138 additions & 4 deletions

CHANGELOG.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,50 @@ History is backfilled from the v18 release history starting at `v18.0.0`.
1111

1212
## [Unreleased]
1313

14+
### Added
15+
16+
- **Opt-in import state cache — `uSync:Settings:CacheImportState` (default `false`).**
17+
uSync decides whether an item has changed by loading it from Umbraco, serializing
18+
the whole thing, and comparing hashes — for every item, every run. That means an
19+
import where nothing has changed costs roughly as much as a full export, which on
20+
sites with thousands of files is the entire run time.
21+
22+
With this on, uSync remembers the hash of each file it has confirmed matches, and
23+
skips those items on the next run without a database lookup or a re-serialize.
24+
Import cost goes from `O(all items)` of database and serialization work to
25+
`O(changed items)`.
26+
27+
It only ever remembers a file where the full check actually ran and said "no
28+
change", or that uSync has just exported (so the file was written from the
29+
database and the two match by construction). It never assumes that because an
30+
import succeeded the two sides now agree. **The practical effect is that the first
31+
run after turning it on is no faster than before — the benefit arrives on the
32+
second run.** An export warms it too.
33+
34+
The cache lives in the site's temp folder (`{LocalTempPath}/uSync/cache/`), never
35+
in the uSync folder, and is thrown away whenever the database, the uSync version,
36+
or the handler settings change. Items are forgotten when Umbraco says they have
37+
been saved, deleted, moved or published; changing a doc type, data type, template,
38+
language or container clears the whole cache, because those get embedded in other
39+
items' xml. A force import always ignores it.
40+
41+
What it cannot see is a database change made by something that raises no Umbraco
42+
notification — raw SQL, for example — hence the default of off. **Read
43+
[`docs/perf/state-cache.md`](docs/perf/state-cache.md) before enabling it**, in
44+
particular the limitations section, and the note for anyone writing a custom
45+
serializer that embeds data from another item.
46+
47+
Implemented entirely through uSync's existing per-item notifications, so nothing
48+
in the import, report or serialization path changed.
49+
50+
- **Extender API:** the cancelable per-item notifications
51+
(`uSyncImportingItemNotification`, `uSyncReportingItemNotification`, and anything
52+
else deriving from `CancelableuSyncItemNotification<T>`) gain an optional
53+
`Message`, used instead of uSync's generic "change stopped by delegate event" when
54+
you set it. The import and report ones also gain `Force`, so a subscriber that
55+
short-cuts the check because it believes nothing has changed can stand down when
56+
the user has asked for a forced import.
57+
1458
### Changed
1559

1660
- **Handler settings now inherit from `HandlerDefaults`.** When a handler has its

docs/perf/state-cache.md

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
# The import state cache (`CacheImportState`)
2+
3+
**Setting:** `uSync:Settings:CacheImportState`**default `false`**
4+
5+
An opt-in cache that lets a repeat import or report skip items it has already confirmed match
6+
Umbraco, without a database lookup or a re-serialize.
7+
8+
---
9+
10+
## 1. The problem it solves
11+
12+
uSync works out whether an item has changed by loading it from Umbraco, serializing the whole
13+
thing, and comparing hashes:
14+
15+
[`SyncSerializerRoot.DeserializeAsync`](../../uSync.Core/Serialization/SyncSerializerRoot.cs)
16+
calls `IsCurrentAsync` for **every** item, and
17+
[`IsCurrentAsync`](../../uSync.Core/Serialization/SyncSerializerRoot.cs) does:
18+
19+
```
20+
FindItemAsync(node) // database lookup
21+
-> SerializeAsync(item) // full re-serialize of the Umbraco item
22+
-> CleanseNode() x2 // (some serializers deep-clone here)
23+
-> two hashes // one per side
24+
```
25+
26+
The report path pays the same cost through
27+
[`SyncHandlerRoot.IsItemCurrentAsync`](../../uSync.BackOffice/SyncHandlers/SyncHandlerRoot.cs).
28+
29+
The consequence is that an import where **nothing has changed** costs roughly as much as a full
30+
export. Reading the files is not the bottleneck — that is already parallelised in
31+
[`SyncFileService.GetFolderItemsAsync`](../../uSync.BackOffice/Services/SyncFileService.cs) — the
32+
per-item work afterwards is. On a site with thousands of content and dictionary files that per-item
33+
work is the whole run time.
34+
35+
With the cache on, an item we have already confirmed costs one hash of an `XElement` we have
36+
already parsed. Import cost goes from `O(all items)` of database and serialization work to
37+
`O(changed items)`.
38+
39+
## 2. How it hooks in
40+
41+
Through notifications, not by changing the import pipeline. uSync already fires a cancelable
42+
notification per item on both the import and report paths — the report one exists for exactly this
43+
purpose ("this lets us intercept a report and shortcut the checking (sometimes)"). So the whole
44+
feature sits off to one side:
45+
46+
| Notification | What we do |
47+
|---|---|
48+
| `uSyncImportingItemNotification` / `uSyncReportingItemNotification` | cancel the item if we already know the file matches |
49+
| `uSyncImportedItemNotification` / `uSyncReportedItemNotification` | record the file's hash **if the answer was `NoChange`** |
50+
| `uSyncExportedItemNotification` | record the file's hash — we just wrote it from the database |
51+
| `uSync*Starting` / `uSync*Completed` | load / save the cache file |
52+
53+
Nothing in `SyncSerializerRoot`, no serializer, and none of the handler import or report methods
54+
changed. The feature is two classes
55+
([`SyncStateCacheManager`](../../uSync.BackOffice/Cache/SyncStateCacheManager.cs),
56+
[`SyncStateCacheInvalidator`](../../uSync.BackOffice/Cache/SyncStateCacheInvalidator.cs)) plus the
57+
cache itself ([`SyncStateCache`](../../uSync.BackOffice/Cache/SyncStateCache.cs)) and one setting.
58+
59+
### Only confirmed matches are recorded
60+
61+
An entry is written in exactly two situations:
62+
63+
1. the full expensive check ran and returned `NoChange`, or
64+
2. we have just exported the item, so the file was written from the database and the two sides
65+
match by construction.
66+
67+
We deliberately **do not** record after a successful update or create. It is tempting to assume
68+
the two sides now agree, but they do not always: some items do not round-trip exactly, which is
69+
what uSync's *"XML is different - but properties may not have changed"* message is telling you.
70+
Recording those would silence a real difference for good. Instead they are checked again next
71+
time — honest, and self-correcting.
72+
73+
The cost of that choice is that **the first run after turning the cache on is no faster than
74+
before.** The benefit arrives on the second run. An export also warms it, so an
75+
export-then-report cycle is warm already.
76+
77+
## 3. What invalidates it
78+
79+
`SyncStateCacheInvalidator` listens to Umbraco. There are three levels, and picking the right one
80+
is the whole game:
81+
82+
| Level | When | Why |
83+
|---|---|---|
84+
| the item | saved, deleted, published, unpublished | only that item's own xml changed |
85+
| the whole item type | moved, or moved to the recycle bin | a move rewrites the `Path` of every descendant, and paths are part of the serialized xml |
86+
| **everything** | doc type, media type, member type, data type, template, language, or container saved/deleted/moved/renamed | these get embedded in *other* items' xml, so changing one silently changes items whose own rows never moved |
87+
88+
That last row is the important one. A content item's xml carries its doc type alias, template
89+
alias, parent key and path; a dictionary item's carries its languages. So renaming a doc type
90+
changes what every item using it serializes to, without touching those items or raising any
91+
notification for them. There is no cheap way to work out which items are affected, and these
92+
changes are rare, so we throw the whole cache away. The policy lives in one place —
93+
`SyncStateCache`'s shared-item-types set — so it applies however the invalidation arrives.
94+
95+
Invalidation runs whether or not the setting is currently on. Otherwise turning the cache off,
96+
editing things, and turning it back on would leave it confidently wrong.
97+
98+
The whole cache is also discarded when we cannot prove it still applies — see the identity
99+
section below.
100+
101+
### Nothing pauses invalidation during uSync's own import
102+
103+
There is no `IsPaused` check in the invalidator, and that is deliberate. During an import, an item
104+
uSync writes is invalidated and then simply not re-recorded (we only record confirmed `NoChange`
105+
results), so invalidating is harmless — and it also catches items Umbraco saves as a *side effect*
106+
of something we imported, which a paused check would miss.
107+
108+
## 4. Where it lives
109+
110+
`{IHostingEnvironment.LocalTempPath}/uSync/cache/state-{identity}.json`
111+
112+
**Never in the uSync folder.** The cache describes this site's database, not the source of truth,
113+
so it must not travel between environments, get committed, or show up in a diff. (Same precedent
114+
as [uSync.History](../../uSync.History/uSyncHistoryNotificationHandler.cs), which writes to
115+
`LocalTempPath` too.)
116+
117+
### The identity
118+
119+
The file name and its contents both carry an identity hash. On load, a mismatch means the file is
120+
discarded entirely. It is built from:
121+
122+
- **a database stamp** — a GUID we create once and keep in Umbraco's key/value table under
123+
`uSync.StateCache.Id` (the same pattern as
124+
[`SyncTrackerService`](../../uSync.BackOffice/Tracker/SyncTrackerService.cs)). Costs one
125+
key/value read per run, and catches a database restore, a database swap, or pointing the site at
126+
a different database. If we cannot read it, the cache is not used at all.
127+
- **the uSync version** — a new version may serialize differently.
128+
- **a fingerprint of the settings that affect serialization** — folders, root folder, folder mode,
129+
default extension, default set, and the default handler set's settings. Changing a handler
130+
setting changes the shape of the exported xml, so the recorded hashes stop meaning anything.
131+
132+
### The journal
133+
134+
Removing an entry from memory does nothing about the copy of it in the file on disk — and the file
135+
is what the next restart trusts. But writing a whole manifest on every editor save would be far
136+
too much.
137+
138+
So invalidations append a line to `state-{identity}.invalid` (a few bytes), and the journal is
139+
replayed on load and folded back in whenever a fresh manifest is written. Both halves happen under
140+
one lock, so an invalidation can never slip in between "manifest written" and "journal cleared"
141+
and be lost.
142+
143+
## 5. What it cannot see
144+
145+
Read this section before turning it on. **With the cache on, uSync's change detection stops being
146+
self-verifying and starts trusting its own bookkeeping.**
147+
148+
1. **A database change made by something that raises no Umbraco notification.** Raw SQL, a
149+
row-level restore, or an edit made by a differently configured instance. The database stamp
150+
catches a whole-database swap; it cannot catch a targeted edit. This is the accepted residual
151+
risk, and the reason the default is off.
152+
2. **A cross-item dependency we have not thought of.** The known ones are handled (see section 3),
153+
but any serializer that pulls data in from another item is a new hole. **If you write a custom
154+
serializer that embeds data from a different item, that item's type needs adding to
155+
`SyncStateCache`'s shared-item-types set.**
156+
3. **The report is no longer an independent check.** Report and import share one cache so they can
157+
never disagree with each other — but that means a wrong entry hides the item from the report
158+
too. A **force import** is the way to get a guaranteed full check; it bypasses the cache
159+
entirely.
160+
4. **Dictionary items whose file key differs from the database key.** Dictionary items are matched
161+
by alias and uSync deliberately ignores the key when comparing them, but the cache files entries
162+
under the key in the file. If those two differ, an invalidation for the database item will not
163+
find the entry. Narrow, but real.
164+
5. **Multiple handler sets with different serialization settings** run against the same folders.
165+
The identity fingerprint covers the default set only, so entries recorded under one set could be
166+
consulted under another. Leave the cache off if you do this.
167+
6. **`LocalTempPath` is not guaranteed to survive.** Azure App Service recycles it, a container
168+
restart loses it, and on scale-out each instance has its own. All of those degrade to "slow
169+
first run", which is safe — but on some hosting the cache may rarely be warm and the feature
170+
will quietly do very little. Measure before promising anything.
171+
172+
Every one of these fails towards a *stale skip* at worst, and the cache is cleared by the next
173+
relevant save. But a stale skip means an item that should have imported did not.
174+
175+
## 6. Operating it
176+
177+
- **Turn it on:** `"CacheImportState": true` under `uSync:Settings`. No restart needed — the
178+
setting is read at runtime.
179+
- **Turn it off:** set it back to `false`. Behaviour and timings return to exactly what they were.
180+
- **Clear it:** delete `{LocalTempPath}/uSync/cache/`, or run a force import (which ignores the
181+
cache for that run). Changing any handler setting also discards it.
182+
- **See what it is doing:** at `Information` level, uSync logs `loaded {count} known items` on load
183+
and `state cache skipped {skipped} of {total} items` at the end of a run. If `skipped` is 0 on a
184+
second identical run, something is invalidating more than you expect.
185+
186+
## 7. Deliberately not done
187+
188+
**`UpdateDate` verification.** Storing each item's `UpdateDate` alongside the hash and validating
189+
it against a single `IEntityService.GetAll(objectType)` query per handler would give existence plus
190+
a timestamp for every item in one query, at no per-item cost. That would close limitation 1 above
191+
for real Umbraco node types (not dictionary items, languages, domains or webhooks, which are not
192+
in `umbracoNode`).
193+
194+
This is the obvious hardening step if notification-based invalidation proves too leaky in practice.
195+
The manifest format has a version field so the extra data can be added without a migration.

uSync.BackOffice.Targets/appsettings-schema.usync.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,11 @@
159159
"description": "Should folder keys be cached (for speed)",
160160
"default": true
161161
},
162+
"CacheImportState": {
163+
"type": "boolean",
164+
"description": "Cache the results of the \"no change\" checks between runs, so repeat imports can skip\nunchanged items without a database lookup or a re-serialize.",
165+
"default": false
166+
},
162167
"ShowVersionCheckWarning": {
163168
"type": "boolean",
164169
"description": "Show a version check warning to the user if the folder version is less than the version expected by uSync.",
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
using System;
2+
using System.Threading.Tasks;
3+
using System.Xml.Linq;
4+
5+
namespace uSync.BackOffice.Cache;
6+
7+
/// <summary>
8+
/// remembers which uSync files we have already confirmed match what is in Umbraco,
9+
/// so repeat imports and reports can skip those items without going near the database.
10+
/// </summary>
11+
/// <remarks>
12+
/// <para>
13+
/// working out if an item has changed normally means loading it from Umbraco, serializing
14+
/// the whole thing, and comparing hashes. that is the bulk of the cost of an import where
15+
/// nothing has actually changed. this cache lets us answer the same question with a single
16+
/// hash of the file we have already loaded.
17+
/// </para>
18+
/// <para>
19+
/// we only ever record a file we have positively confirmed as matching - either the full
20+
/// check ran and said "no change", or we have just written the file out ourselves during an
21+
/// export. we never assume that because an import succeeded the two sides now agree.
22+
/// </para>
23+
/// <para>
24+
/// the cache is a performance aid, not a source of truth. everything about it is designed to
25+
/// fail towards doing the real work: it is off by default, a force import ignores it, and it
26+
/// is discarded wholesale whenever we cannot prove it still applies.
27+
/// </para>
28+
/// </remarks>
29+
public interface ISyncStateCache
30+
{
31+
/// <summary>
32+
/// is the cache turned on (uSync:Settings:CacheImportState)
33+
/// </summary>
34+
bool IsEnabled { get; }
35+
36+
/// <summary>
37+
/// how many items we currently think we know about.
38+
/// </summary>
39+
int Count { get; }
40+
41+
/// <summary>
42+
/// have we already confirmed that this exact file content matches what is in Umbraco?
43+
/// </summary>
44+
/// <remarks>
45+
/// false whenever we are not sure - including when the cache is off, the node is an
46+
/// action (delete/rename/clean) marker, or anything at all goes wrong.
47+
/// </remarks>
48+
Task<bool> IsKnownCurrentAsync(XElement node);
49+
50+
/// <summary>
51+
/// record that this exact file content is known to match what is in Umbraco.
52+
/// </summary>
53+
Task RecordAsync(XElement node);
54+
55+
/// <summary>
56+
/// forget what we knew about a single item.
57+
/// </summary>
58+
/// <remarks>
59+
/// item types whose contents get embedded in other items (doc types, templates, languages
60+
/// and so on) escalate to <see cref="InvalidateAllAsync"/> - renaming a doc type changes
61+
/// the serialized xml of every item that uses it, without touching their rows.
62+
/// </remarks>
63+
Task InvalidateAsync(string itemType, Guid key);
64+
65+
/// <summary>
66+
/// forget everything we knew about one type of item.
67+
/// </summary>
68+
/// <remarks>
69+
/// used for moves - moving a node rewrites the path of everything beneath it, so
70+
/// invalidating just the moved item is not enough.
71+
/// </remarks>
72+
Task InvalidateTypeAsync(string itemType);
73+
74+
/// <summary>
75+
/// forget everything.
76+
/// </summary>
77+
Task InvalidateAllAsync();
78+
79+
/// <summary>
80+
/// load the cache from disk (called once, on first use).
81+
/// </summary>
82+
Task LoadAsync();
83+
84+
/// <summary>
85+
/// write the cache back to disk, if anything has changed since we loaded it.
86+
/// </summary>
87+
Task PersistAsync();
88+
}

0 commit comments

Comments
 (0)