|
| 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. |
0 commit comments