Compare commits

...
Author SHA1 Message Date
Andy Butland 91586e69ec Add ancestor details to media picker search results in table view. 2026-06-14 13:10:24 +02:00
Mads RasmussenandGitHub aa854da3f4 Backoffice: Pre-expand tree to target entity when opening Duplicate and Move To modals (closes #22015) (#23063)
* Support tree expansion in generic Duplicate To modal

* Add expansion prop to tree picker modal types

* Apply expansion from data to picker context

* move to action: populate tree picker expansion with ancestors

* Pass tree expansion to duplicate document modal

* Extract ancestor fetching into private method

* Use UmbDocumentTreeRepository directly

* Exclude self from ancestor results

* make name more explicit

* Guard ancestor fetch and simplify expansion

* Only set treeExpansion when ancestors exist

* fix type issues

* Parallelize ancestor and pickable filter fetch

* Use getter for treeExpansion; remove unused imports
2026-06-09 16:53:37 +02:00
Niels LyngsøandGitHub 28cdbe5317 TipTap: Let the stylesheet load parallel to tiptap-extensions (#23024)
do not await stylesheets to be loaded before extensions
2026-06-09 11:14:49 +00:00
3dbd4baefe Backoffice Search: Batch the ancestors lookup for search results to avoid exceeding the maximum URL length (closes #23032) (#23048)
* Ensure requests to fetch ancestors after retrieving search results are batched to avoid a single query exceeding the maximum URL length.

* Guard against undefined ancestor entries from a failed batch

batchTryExecute resolves each chunk via tryExecute, which never rejects, so
a per-chunk failure comes back as a fulfilled result carrying an error and
leaves an undefined hole in the amalgamated data without surfacing an error.
Detect that before mapping and return an explicit error instead of throwing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Assert ancestor id uniqueness and silence direct-api lint rule

Strengthen the batching tests to assert every search-result id is requested
exactly once (Set size), not just that the total count matches. Add the
no-direct-api-import disable on the controller's api callback, matching the
existing url data sources, since the call is wrapped by the controller.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Addessed Codescene warnings.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 10:31:37 +02:00
Andy ButlandandGitHub fa5dd209c1 Tags: Fix null reference error in tags input on render (closes #23044) (#23049)
Fix intermittent null reference exception in tags element.
2026-06-09 10:29:27 +02:00
Andy ButlandandGitHub 4cc4acee62 Published Cache: Fix multi-site domains falling back to the first root node after restart (#23084)
* Prevent empty domain cache during concurrent initialization.

* Addressed code review comments and added further comment to the code.

* Use Lock object.
2026-06-07 15:35:38 +02:00
62663d9573 Runtime Cache: Fix IAppPolicyCache.ClearByKey intermittently failing to clear cached items (closes #23064) (#23068)
* Prevent ClearByKey leaving stale runtime cache items.

* Lowered loop timer.

* Clarified code comment.

* Improve assertions and comments in tests.

---------

Co-authored-by: Kenn Jacobsen <kja@umbraco.dk>
2026-06-05 09:23:26 +02:00
b582f9d2ef Dependencies: Upgrade Examine to 3.8.0 (#23075)
Upgrade Examine to 3.8.0

Co-authored-by: Simon Gibbs <sgibbs@qmu.ac.uk>
2026-06-05 06:59:11 +02:00
ad90db8b38 Performance: Coalesce concurrent tree data requests (Management API client) (#23021)
* perf(tree): coalesce concurrent identical tree data requests

The tree data request manager hit the network on every call, so multiple
concurrent consumers (sidebar tree, breadcrumb structure, pickers) each
fetched the same data independently — e.g. three identical tree/document/root
requests per document-workspace load.

Apply the existing UmbManagementApiInFlightRequestCache (already used by the
item and detail request managers) to the tree request manager via a shared
static cache, coalescing concurrent identical root/children/ancestors/siblings
calls into a single in-flight request, cleared on settle (in-flight only, so
no stale-cache risk). The document tree opts in; other trees are unchanged
until they pass a cache.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(tree): cover request coalescing; address review feedback

- Add focused tests: concurrent identical root requests share one call,
  the in-flight entry is cleared on settle, and no cache means no coalescing.
- Build the cache key lazily (only when a cache is wired) so non-opted-in
  trees keep the original lightweight path.
- Constrain the #coalesce generic to drop the cast on cache.set.
- Document the new inflightRequestCache arg; trim the comment to one line.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 10:40:58 +02:00
Erik-Jan WestendorpandAndy Butland 8e3b821a55 Localization: Add Dutch translations for create and delete actions (#23039)
Update nl.ts
2026-06-04 08:37:53 +02:00
Andy ButlandandGitHub 8ac989c4e3 Data Types: Tolerate invalid configuration when determining the editor value storage type (closes #23057) (#23058)
* Tolerate invalid data type configuration when getting the editor value storage type.

* Add logging in case of error.

* Resolve warning.

* Removed exception from warning (it's not useful).

* Log an error instead of a warning.
2026-06-04 14:38:21 +09:00
Andy ButlandandGitHub 3913a61b74 Background Jobs: Resolve server role so recurring jobs run when no application URL is configured (#23033)
Resolve server role when no application URL is configured.
2026-06-04 06:37:08 +02:00
Lee KelleherandGitHub 90bedcd42e Menu Structure: Guard against use-after-destroy in async structure request (#23055)
* Menu Structure: Guard against use-after-destroy in async structure request

When navigating to a trashed item, the IS_NOT_TRASHED condition initially
permits the standard menu structure context, which is then destroyed once the
workspace confirms the item is trashed. The in-flight async #requestStructure()
could resume after destruction and call setValue() on a completed subject,
throwing "_subject is undefined".

Guard the state mutations with the framework's existing _host-cleared-on-destroy
signal, and handle the previously fire-and-forget #requestStructure() promises so
a teardown mid-request is silently abandoned rather than surfacing as an uncaught
rejection. Applied to both the variant and non-variant menu structure base
contexts.

* Menu Structure: Make #requestStructure non-throwing instead of catching at call sites

Per PR review feedback: replace the blanket .catch(() => {}) wrappers with
early returns inside #requestStructure(). The _host guard already prevents
post-destroy state mutation; the throws only fire for can't-happen missing
observable states and were producing unhandled rejections with no caller
able to act on them.

* Added console warning, if the host is still available
2026-06-03 18:32:29 +02:00
Lee KelleherandGitHub c54189aa90 Block Grid: Guard validator against torn-down manager on navigation (#22852)
* Block Grid: Guard validator against torn-down manager on navigation

The form-control mixin's updated() hook runs validators when the element
re-renders during teardown. If navigation has already disposed _manager,
checkBlockTypeConfigurationValidity would throw "Cannot read properties
of undefined (reading 'getContentTypeKeyOfContentKey')".

Early-return as valid when the manager is gone and use optional chaining
on the per-entry lookup as a safety net.

* Removed optional chaining of `_manager`

As `_manager` has already been checked.

* Reverting the `_manager` optional chaining

As TypeScript compiler doesn't like it, (inside the `filter` callback).
2026-06-03 15:29:41 +02:00
88ec0a248f Media: Restore friendly naming of uploaded media items (closes #22989) (#22998)
* Update uploaded media file name to a friendly name.

* Correct test description for acronym handling.

The case JUST-A-FILE.jpg verifies all-uppercase words are preserved
as acronyms, not that lowercase words get lowercased.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Match server-side StripFileExtension semantics in toFriendlyName.

The TypeScript helper previously delegated to getFileExtension, which
diverges from the C# StripFileExtension on two edge cases:
- a trailing dot ("file.") is stripped by the server but not the client
- an "extension" containing whitespace is preserved by the server but
  stripped by the client

Inlined a stripFileExtension helper that mirrors the C# rules exactly,
making the "keep in sync" cross-reference accurate. Added tests for both
divergent cases and replaced the contrived leading/trailing whitespace
test with a realistic interior-whitespace case.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Add parity test for trailing-whitespace extension span.

Restores the '  spaced-name.jpg  ' case as a parity test against
StripFileExtension's "extension containing whitespace is preserved"
rule. Output is 'Spaced Name.Jpg' (Jpg title-cased, matching the
server's TextInfo.ToTitleCase behaviour on the now-unstripped extension).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Handle getContext rejection in ensureMediaNameFromFile.

getContext rejects on timeout when the dataset context never resolves;
callers used void ensureMediaNameFromFile(...) so an unhandled rejection
would bubble. Catch the rejection and treat it as an absent context.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 12:37:49 +01:00
Lee KelleherandGitHub 3e22733081 Document Recycle Bin: Checks user permission for Document "Read" (#23041)
* Adds conditions to Document Recycle Bin

that the user must have "Read" permission.

* Directly imports Media Recycle Bin condition

this will remove an extra fetch request.
2026-06-02 16:31:51 +02:00
Andy ButlandandGitHub 232077e820 EF Core: Retry transient SQLite lock errors during long-running operations (closes #22939) (#22969)
* Retry transient SQLite lock errors during long-running operations.

* Addressed code review comments.
2026-06-02 13:59:37 +02:00
Jacob Overgaard 943d1eeccd Merge branch 'release/17.5.0' into v17/dev 2026-06-02 12:44:29 +02:00
Jacob Overgaard b3666dad8b build(deps): bumps @umbraco-ui/uui to 1.18.0 2026-06-02 12:43:21 +02:00
Sven GeusensandAndy Butland 28a403361e Developer Experience: Improve cohost editor polyfill to work with dotnet watch (closes #22773) (#22999)
* Improve cohost polyfill

* Apply suggestions from code review

Co-authored-by: Andy Butland <abutland73@gmail.com>

* Move <target/> part of the polyfill to targets file.

---------

Co-authored-by: Andy Butland <abutland73@gmail.com>
2026-06-01 14:38:35 +02:00
38d73b3a41 Developer Experience: Improve cohost editor polyfill to work with dotnet watch (closes #22773) (#22999)
* Improve cohost polyfill

* Apply suggestions from code review

Co-authored-by: Andy Butland <abutland73@gmail.com>

* Move <target/> part of the polyfill to targets file.

---------

Co-authored-by: Andy Butland <abutland73@gmail.com>
2026-06-01 12:34:24 +00:00
Niels Lyngsø 5c0cb154f5 cherry picked #23027 2026-06-01 10:04:01 +02:00
4a621a13bc Performance: Parallelize independent boot API requests (server status/config + public extensions) (#23020)
* perf(core): parallelize independent boot API requests

UmbServerConnection.connect() awaited server status and configuration
sequentially even though they are independent reads; run them with
Promise.allSettled so both errors surface (the app cannot function
without either) while saving a round-trip.

During app startup, public (login) extension registration was awaited
before the auth flow; kick it off in parallel and await it only before
routing, where the login screen actually needs it.

Each serialized call costs a full management-API round-trip, which is
negligible locally but ~150 ms each on high-latency (e.g. Cloud) hosts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(core): only mark connection connected once both calls succeed

Move isConnected.setValue(true) out of #setStatus() into connect() after
the allSettled check, so the observable never reflects a partially
established connection when configuration fails but status succeeded.

Addresses review feedback on the parallelized connect().

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 08:20:34 +02:00
bc2048573c Link Picker: Render picked content/media as non-interactive when their workspace URL can't be resolved (closes #22955) (#22964)
* Entity refs render readonly when their workspace URL can't be resolved.
Also fixes name on remove dialog.

* Apply read-only on the picked content ref only in the non-routable link picker

* Address PR feedback: simplify document item resolver guard in the link picker, document the implicit uui-card-media disabled dependency in input-media, and cover the disabled card state with a test.

* Drop out of date comments.

* Simplify updates.

---------

Co-authored-by: leekelleher <leekelleher@gmail.com>
2026-05-28 13:23:48 +00:00
Andy ButlandandGitHub 424209ac06 References: Fix missing node name when content referenced by media or member (closes #22990) (#22965)
Fix missing node name when content referenced by media or member.
2026-05-28 13:34:25 +01:00
Andy Butland 5cf577bf57 Fix broken tiptap reference for storybook build. 2026-05-28 13:32:09 +02:00
ed4b207fe7 Backoffice: Render $index in block detail overlay label (closes #21154) (#22959)
* Render $index in block detail overlay label.

* Cache $index, resolve append sentinel, and cover with unit tests.

* refactor(block): use pipeline for index deduplication and clean up stale observer

* Rename function to remove the unnecessary umb prefix.

---------

Co-authored-by: leekelleher <leekelleher@gmail.com>
2026-05-28 11:15:21 +00:00
Andy ButlandandGitHub 40e027d0ea Content Type Editor: Fix empty Design tab when opened in a modal workspace (closes #22855) (#22954)
* Fix empty content type Design tab when opened in a modal workspace.

* Addressed code review comments.

* Fixed failing E2E tests.
2026-05-28 09:39:11 +00:00
Andy ButlandandGitHub a6f6bdf8bc Backoffice: Hide "Edit permissions" button in create modals from users without Settings section access (closes #22981) (#22984)
* Show the edit permissions for document type button only for users with settings access.

* Fix translation for message (the "Permissions" tab is not called "Structure").

* Addressed code review feedback.
2026-05-28 09:23:26 +01:00
Jacob OvergaardandClaude Opus 4.7 8e6a791de0 Backoffice: Drop redundant search manifest import from Storybook preview
`core/manifests.ts` already imports and spreads `core/search/manifests.ts`
into its aggregate (line 23 + 58), so importing `searchManifests`
separately in `.storybook/preview.js` and spreading it next to
`coreManifests` registered the same manifests twice. Remove the redundant
import and spread.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 08:47:38 +02:00
Jacob OvergaardandClaude Opus 4.7 5ffea3152b Backoffice: Repoint Storybook preview imports at umbraco-package.ts
PR #22957 deleted every package's `manifests.ts` and consolidated the
exports into `umbraco-package.ts`, but `.storybook/preview.js` still
imported from the old paths. The result was a Vite resolve error during
`npm run build-storybook` (first failure: "Could not resolve
../src/packages/block/manifests from .storybook/preview.js").

37 import paths swapped from `…/<pkg>/manifests` to
`…/<pkg>/umbraco-package`. The two packages that still expose their
manifests via a standalone `manifests.ts` — `core` and `core/search` —
are left untouched.

Verified by `npm run build-storybook` — succeeds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 08:43:38 +02:00
Jacob OvergaardandClaude Opus 4.7 2043ff1dbd Tiptap: Fix dead manifests.js import in the input-tiptap story
PR #22995 added `input-tiptap.stories.ts` with an import from
`'../../manifests.js'`, but PR #22957 (already on release/17.5.0) had
deleted that file and moved the `manifests` array into
`umbraco-package.ts`. The merge into release/17.5.0 didn't catch the dead
import, so Storybook 404s on the story load.

Point the import at the new home — `manifests` is still exported by name,
so this is a one-line path fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 08:40:17 +02:00
Jacob Overgaard f1bc1db6ce Merge branch 'v17/dev' of https://github.com/umbraco/Umbraco-CMS into v17/dev 2026-05-28 08:27:15 +02:00
Jacob Overgaard c4d5b89fc5 Merge remote-tracking branch 'origin/release/17.5.0' into v17/dev 2026-05-28 08:27:04 +02:00
7597a8ad40 Sort Dialog: Show current language node names (closes #22872) (#22948)
* Display variant node name on sort children dialog.

* Preserve user sort order when patching variant names on culture change

Patch names in-place on the existing _tableItems rather than rebuilding
from _children, so a user's drag-sorted or column-ordered arrangement is
not silently reverted if the app culture changes while the modal is open.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Refactor to reduce cyclomatic complexity of #resolveName method.

* Resolve sort dialog variant names and icons via item data resolvers

Replace the inlined variant-name logic in the content sort dialog with the
shared UmbItemDataResolver abstraction, and add UmbMediaItemDataResolver so
media items resolve their active-culture name and icon the same way documents
do. Each content sort entity action now supplies its resolver through manifest
meta, flowing into the modal via a new content-specific modal data type and a
base-action _getModalData() hook. This also removes the previously hard-coded
document icon in the dialog.

* Disable load more when page of items is being retrieved.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: leekelleher <leekelleher@gmail.com>
2026-05-28 05:50:16 +00:00
d28507e2e5 Migrations: Convert all sibling RTE blocks (closes #22979) (#22980)
* Fix migration of embedded block data when blocks are direct siblings in the 13 RTE source code.

* Apply suggestions from code review to update comments.

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Address review: keep RteBlockHelper in original namespace; tidy docs and comment

- Move RteBlockHelper back to Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_15_0_0.LocalLinks
  to avoid a binary breaking change within the obsolete window (scheduled removal in v18).
  Kept as its own file rather than reverting it into LocalLinkRteProcessor.cs.
- Add a <remarks> note on ConvertBlockUdisToKeys explaining that blocks with malformed UDIs
  are dropped rather than preserved.
- Replace the opaque "fix recursive hiccup" comment in LocalLinkRteProcessor with one that
  describes what the line actually does.
- Move RteBlockHelperTests back to mirror the production namespace.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 07:45:01 +02:00
7b75324172 Migrations: Convert all sibling RTE blocks (closes #22979) (#22980)
* Fix migration of embedded block data when blocks are direct siblings in the 13 RTE source code.

* Apply suggestions from code review to update comments.

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Address review: keep RteBlockHelper in original namespace; tidy docs and comment

- Move RteBlockHelper back to Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_15_0_0.LocalLinks
  to avoid a binary breaking change within the obsolete window (scheduled removal in v18).
  Kept as its own file rather than reverting it into LocalLinkRteProcessor.cs.
- Add a <remarks> note on ConvertBlockUdisToKeys explaining that blocks with malformed UDIs
  are dropped rather than preserved.
- Replace the opaque "fix recursive hiccup" comment in LocalLinkRteProcessor with one that
  describes what the line actually does.
- Move RteBlockHelperTests back to mirror the production namespace.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 07:38:56 +02:00
172a3be5ac Repositories: Batch WHERE IN queries to avoid SQL Server 2100-parameter limit (#22987)
* Batch WHERE IN queries to avoid SQL Server 2100-parameter limit and add memory files.

* Drop past-incident references from SQL parameter-limit docs

The memory files should describe the current rule and safe patterns;
specific historical bugs belong in commit history, not CLAUDE.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Update comments from code review

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Addressed memory file feedback.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-28 10:44:15 +09:00
ca28195b7c Tiptap: Load enabled extensions in parallel and inline manifest APIs (#22995)
* Tiptap: Load enabled extensions in parallel and inline manifest APIs

Replace the for…of/await loop in umb-input-tiptap's #loadExtensions with
Promise.all over .map, so all enabled Tiptap extension APIs are fetched
in parallel. Configured-extension order in _extensions is preserved.

Inline the first-party Tiptap manifest API references: every
`api: () => import('./X.tiptap-api.js')` and the equivalent toolbar /
statusbar / kind references now use a static top-of-file import and
`api: ClassName`. The dynamic `await import('rich-text-essentials.tiptap-api.js')`
fallback in input-tiptap.element.ts is inlined for the same reason.

External (plugin-supplied) Tiptap extensions and the lazy modal/toolbar
UI element imports are unchanged.

Why: on Umbraco Cloud, opening a document workspace with a rich text
editor takes ~16 s uncached, of which ~14.6 s is a single serial
waterfall — 31 extension APIs fetched one after the other from a
for…of await loop, ~170 ms RTT stacked. Replacing the loop with
Promise.all collapses that to roughly one round-trip; eagerly bundling
the first-party manifests removes the dynamic chunk explosion that made
the waterfall so long in the first place. The toolbar APIs (~20 of them)
already load in a sub-100 ms parallel burst against the same server,
confirming HTTP/2 multiplexing handles bulk parallel requests fine.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Tiptap: Inline element references for toolbar/statusbar/modal/clipboard manifests

Extends the manifest-inlining pass to the remaining `element: () => import(...)`
and runtime API loader sites in the Tiptap package — toolbar/menu/action-button
kinds, the table & character-map & anchor modals, the colour-picker button, the
property-editor configuration UIs, both clipboard translators, the style-menu
kind, and the default toolbar API fallback in tiptap-toolbar.element.ts.

Result on the same Cloud test site (uncached, 17.5-rc):
  Tiptap chunk count: 71 → 4
  Total tiptap bytes: ~3.2 MB → ~3.1 MB (essentially unchanged)
  Phase 5 of the load — the serial extension chain — collapses to a single
  consolidated chunk fetch.

`input-tiptap.element.ts` and `property-editor-ui-tiptap.element.ts` are
intentionally not inlined into anything else: `<umb-input-tiptap>` is a public
element usable standalone (custom dashboards, workspace views), and the
property-editor shell loads via the property-editor UI loader. They remain
exported as their own modules.

CLAUDE.md updated to document the new convention for first-party Tiptap
extensions (direct class refs) and the carve-out for external plugin
extensions that may keep `() => import(...)` to ship their API code in a
separate chunk.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Tiptap: Move extension APIs and elements into a shared lazy boundary chunk

The previous PR collapsed ~70 Tiptap chunks into 3 by inlining first-party API
and element references directly into manifest files. That win came with a real
downside flagged in code review (lke / mra): the API/element implementation
bytes ended up in the manifest registration bundle, so every workspace —
including ones without an RTE — paid ~700 KB of Tiptap code on boot.

This commit keeps the chunk-coalescing win but restores the lazy boundary by
routing every first-party manifest's `api` / `element` reference through a
single shared bundle file `extensions/extension-apis.bundle.ts`. Each manifest
holds a dynamic-import thunk pointing at that one bundle, so:

- Rollup still emits a single chunk for all Tiptap extension code (no chunk
  explosion).
- The manifest registration bundle stays slim — it carries only metadata
  (alias / label / icon / group / kind / forExtensions) plus the thunks.
- The bundle is only fetched the first time `<umb-input-tiptap>` actually
  mounts.

Data-type configuration UIs (`extensions-configuration`,
`toolbar-configuration`, `statusbar-configuration`) read manifest metadata
via `umbExtensionsRegistry.byType(...)` only — they never call
`loadManifestApi` / `loadManifestElement`, so the data-type editor continues
to work without loading any Tiptap implementation code.

Property-editor UI elements (`tiptap-rte`, the three configuration UIs) also
revert to `() => import('./X.element.js')` so each loads on demand from its
own chunk rather than being inlined into the manifest bundle.

`umb-input-tiptap` no longer statically imports the Rich Text Essentials API;
it prepends the alias to the observed list instead, so essentials resolves
through the same lazy bundle as every other extension.

Added a test and stories file that mount `<umb-input-tiptap>` standalone (no
property-editor wrapper) to make the public usage pattern explicit.

Built and verified via `npm run build:for:cms`:
- `dist-cms/packages/tiptap/manifests.js`           48 KB  (eager at boot)
- `dist-cms/packages/tiptap/extension-apis.bundle-*.js` 84 KB  (lazy)
- `dist-cms/packages/tiptap/tiptap-toolbar-element-api-base-*.js` 654 KB
  (lazy dependency of the bundle)
- per-element property-editor UI chunks load on demand when settings open

`npm run check:circular`, `npm run compile`, `npx wtr src/packages/tiptap`
all pass.

Related to #21152, builds on #22995.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Tiptap: Don't mount <umb-input-tiptap> in the standalone test

Mounting the element via fixture() spins up an UmbTiptapRteContext that
consumes UMB_SERVER_CONTEXT. In the unit-test runtime no server context
provider exists, so the context request stays pending. When @open-wc's
fixture tears down at end-of-file the request rejects with
"host disconnected" — surfaced as an unhandled promise rejection that
web-test-runner counts as a fatal runner error, exiting 1 even though every
individual test passed. The rejection happened to be in flight while a
block-grid clipboard test was active in CI, which is why the failure surfaced
there rather than in the tiptap test file itself.

Drop the manifest-registration assertion too — pulling the package-level
`manifests.ts` aggregator triggers a transitive 404 on the
`@umbraco-cms/backoffice/tiptap` importmap entry in the wtr environment.

The class-export + custom-element-registration checks are enough to prove
standalone exportability. The Storybook stories still cover the visual
end-to-end load path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 14:32:20 +00:00
Jacob Overgaard 4c1fde9e0c Merge branch 'release/17.5.0' into v17/dev 2026-05-27 14:28:03 +02:00
Mads RasmussenandJacob Overgaard f0013330e6 Backoffice: Embed package root manifests into umbraco-package.ts to reduce startup requests (#22957)
* Consolidate block package into index export

* keep umbraco-package.ts and embed manifests instead

* Inline package manifests into umbraco-package

* update docs
2026-05-27 14:26:56 +02:00
Mads RasmussenandJacob Overgaard bd2c985187 Backoffice: Embed implementations directly in core manifests to reduce startup network requests (#22944)
* Use direct imports in core manifests

* Extract theme aliases into constants file

---------

Co-authored-by: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com>
2026-05-27 14:26:34 +02:00
Mads RasmussenandJacob Overgaard 9711a5d012 Backoffice: Swap relative imports to @umbraco-cms/backoffice module imports in core packages (#22942)
Use @umbraco-cms/backoffice imports

Replace numerous relative/internal import paths with centralized '@umbraco-cms/backoffice' package entry points across core modules.This consolidates exports, simplifies import paths.
2026-05-27 14:24:57 +02:00
Andy ButlandandGitHub 808cba2747 Members: Default Approved to true when creating a member (closes #22991) (#22993)
Default new members created via the backoffice to approved.
2026-05-27 06:59:24 +00:00
Mads Rasmussen da0117f240 Merge branch 'v17/dev' of https://github.com/umbraco/Umbraco-CMS into v17/dev 2026-05-26 09:50:48 +02:00
Jacob OvergaardandGitHub 61d3e4c53d Backoffice: Add Cache-Control headers to cache-busted backoffice assets (AB#68478) (#22951)
* Backoffice: Add Cache-Control headers to cache-busted backoffice assets (AB#68478)

Adds a UseUmbracoBackOfficeCacheHeaders middleware that sets
Cache-Control: public, max-age=31536000, immutable on responses served
from the cache-busted backoffice path (/umbraco/backoffice/<hash>/*).
The hash in the URL is derived from the Umbraco version, so the URL
itself invalidates on every release - making 'immutable' safe regardless
of whether individual filenames contain a content hash.

In debug mode the cache-bust hash changes per request, so the header is
set to 'no-cache' to avoid filling the browser disk cache with single-use
entries.

Design is non-destructive to consumer customisation, addressing the
review feedback on the v14 attempt (#14475):

- Does not touch StaticFileOptions; consumer
  services.Configure<StaticFileOptions>(...) and OnPrepareResponse
  callbacks continue to work unchanged.
- Sets the header via Response.OnStarting with a ContainsKey guard, so
  any synchronous Cache-Control set upstream wins; consumer OnStarting
  callbacks registered later fire first (LIFO) and also win.
- Skips non-2xx responses to avoid long-lived caching of error responses.

Related: GH #21152, PR #22896.

* Backoffice: Correct rationale for no-cache in debug mode

Reword the XML doc on UseUmbracoBackOfficeCacheHeaders to reflect that
IBackOfficePathGenerator is a singleton, so the cache-bust hash is
computed once at startup even in debug mode (per Copilot review on
#22951). The reason for no-cache is not "hash changes per request" but
that built assets may change in place during dev iteration; no-cache
allows fast 304 revalidation while no-store would force full
re-downloads.

No functional change.

* Backoffice: Add unit tests for UseUmbracoBackOfficeCacheHeaders

Covers six scenarios via a minimal in-process pipeline composed with
Microsoft.AspNetCore.TestHost:

- Production: 200 under hash prefix gets immutable header
- Debug: 200 under hash prefix gets no-cache
- Non-2xx under prefix: header not set (status gate)
- Path outside prefix: header not set (path gate)
- Consumer synchronous override: ContainsKey guard skips, consumer wins
- Consumer OnStarting override: LIFO ordering lets consumer win

Adds Microsoft.AspNetCore.TestHost to Umbraco.Tests.UnitTests (standard
Microsoft package, version pinned in tests/Directory.Packages.props).

* Backoffice: Extract cache-headers logic into IMiddleware class

Matches the existing Umbraco middleware convention (BootFailedMiddleware,
PreviewAuthenticationMiddleware, UmbracoRequestMiddleware, etc.) per
Kenn's note: prefer UseMiddleware<T>() with a DI-resolved class over
inline builder.Use lambdas.

The new UmbracoBackOfficeCacheHeadersMiddleware:
- Implements IMiddleware; registered as a singleton in AddWebComponents
- Computes prefix and header value once in the constructor (both
  dependencies are singletons themselves, so this is stable)
- Behaviour is unchanged from the inline version

The UseUmbracoBackOfficeCacheHeaders extension method becomes a thin
UseMiddleware<T>() wrapper. Tests updated to register the middleware in
the TestServer DI container so it can be resolved through UseMiddleware.

* Backoffice: Document IMiddleware convention in Web.Common CLAUDE.md

Adds an explicit "Convention" note before the middleware list so future
contributors (and AI assistants) default to the IMiddleware class +
AddSingleton + UseMiddleware<T>() pattern rather than inline
builder.Use(async ...) lambdas. Also lists the new
UmbracoBackOfficeCacheHeadersMiddleware in the folder structure and
middleware reference.

* Backoffice: Tighten middleware convention note with full corroboration

Lists every IMiddleware implementer in the codebase (10/10) and calls
out the two known inline-lambda exceptions (CspNonceExtensions,
WebApplicationExtensions) so the rule reads as the established
convention rather than an absolute, while still steering new work
toward IMiddleware + AddSingleton + UseMiddleware<T>().

* Backoffice: Register cache-headers middleware in AddBackOfficeCore

DI scope validation runs in Development/CI and pre-checks every
singleton's dependency graph can be constructed. The middleware was
registered in AddWebComponents (which runs for every Umbraco bootstrap),
but its IBackOfficePathGenerator dependency is only registered by
AddBackOffice(). The previous CI run on this branch surfaced the
problem in four Delivery-only/Website-only bootstrap tests
(CoreWithDeliveryApi_BootsSuccessfully, DeliveryOnlyScenario_BootsSuccessfully,
etc.) with "Unable to resolve service for type 'IBackOfficePathGenerator'
while attempting to activate 'UmbracoBackOfficeCacheHeadersMiddleware'".

Move the registration alongside IBackOfficePathGenerator in
AddBackOfficeCore (Api.Management), which is the same scope as the
backoffice itself. This also matches the wire-up gate in
UmbracoApplicationBuilder.cs that only calls UseUmbracoBackOfficeCacheHeaders
when IBackOfficeEnabledMarker is registered.

CLAUDE.md updated with the rule ("register the middleware next to its
dependencies' registration") and a pitfall note about DI scope validation.

* Backoffice: Address review feedback from AndyButland (PR #22951)

- Move UseUmbracoBackOfficeCacheHeadersTests from Umbraco.Tests.UnitTests
  to Umbraco.Tests.Integration. It uses HostBuilder + TestServer to
  exercise the real HTTP pipeline, which is integration-shaped rather
  than unit-shaped. Drop Microsoft.AspNetCore.TestHost from UnitTests
  (Mvc.Testing in Integration provides it transitively) and from
  tests/Directory.Packages.props.
- Soften the misleading "no trailing slash" comment in
  UmbracoBackOfficeCacheHeadersMiddleware — we trim anyway, so the
  comment is now framed as defensive normalisation.
- Trim the dense middleware convention note in Web.Common/CLAUDE.md to
  one paragraph (rule + the two known inline-lambda exceptions). Move
  the DI-scope-validation pitfall narrative out of CLAUDE.md and into a
  three-line code comment next to the AddSingleton call in
  AddBackOfficeCore where it actually applies.

* Backoffice: HTTP verb gate, 304 inclusion, namespace + unused using (PR #22951 review)

Three more from AndyButland's review:

1. Verb gate + 304 inclusion in UmbracoBackOfficeCacheHeadersMiddleware.
   Restrict the path-prefix match to GET and HEAD so POST/PUT/DELETE
   responses and OPTIONS (CORS preflight) responses don't get tagged as
   immutable. Include 304 alongside 2xx in the status gate so
   intermediate caches (CDN/proxy) receive the Cache-Control directive on
   revalidation responses too. Extended the test suite with four new
   cases: NotModifiedResponseUnderPrefix_SetsImmutable,
   HeadRequestUnderPrefix_SetsImmutable,
   OptionsRequestUnderPrefix_DoesNotSetHeader,
   PostRequestUnderPrefix_DoesNotSetHeader. All 10 tests pass.
2. Test namespace updated to Umbraco.Cms.Tests.Integration.* to match
   the convention used by ~629 other files in Umbraco.Tests.Integration
   (vs the 2 outliers I copied from).
3. Drop unused 'using Umbraco.Extensions;' from the test file.

* Backoffice: Extract conditional checks to satisfy CodeScene complexity gate

CodeScene flagged InvokeAsync with "Complex Conditional" (advisory rule,
code health impact 9.69) after the verb + 304 additions in the prior
commit. Extract the two checks into IsCacheableAssetRequest and
ShouldSetCacheControl helper methods. No behaviour change; tests still
green (10/10, 149 ms).
2026-05-26 08:31:10 +02:00
51d70877d1 QA: Stabilise rollback content versioning E2E test (#22975)
* Stabilise rollback E2E test by waiting for document reload before asserting.

* Condense rollback wait comment per code-review feedback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Addressed code review feedback.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 12:21:03 +09:00
Andy ButlandandGitHub 2dcfe68208 Backoffice search: Preserve Lucene score order through content-picker lookups (closes #22862) (#22977)
* Preserve Lucene score order through content-picker lookups.

* Removed unnecessary tests.  Addressed code review comments.
2026-05-26 12:15:21 +09:00
ce06c4ba4d Management API: ensure the order from the search endpoints taking a collection of keys is preserved (#22973)
* ensure the order from the search endpoints taking a collection of keys is preserved

* Align cosmetic changes to ensure later merge up doesn't run into conflicts.

---------

Co-authored-by: Lan Nguyen Thuy <lnt@umbraco.dk>
Co-authored-by: Andy Butland <abutland73@gmail.com>
2026-05-25 17:32:17 +00:00
Andy Butland a91de6e677 Fix StoryBook build failure following updates in #22957. 2026-05-25 10:20:45 +02:00
faf3824a0a Backoffice: Embed implementations directly in core manifests to reduce startup network requests (#22944)
* Use direct imports in core manifests

* Extract theme aliases into constants file

---------

Co-authored-by: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com>
2026-05-25 10:07:24 +02:00
Andy Butland 58ed9899be Merge branch 'release/17.5.0' into v17/dev 2026-05-25 08:01:04 +02:00
Engiber LozadaandGitHub bc7bd9a32a Body Layout: Replace overflow: auto with uui-scroll-container (#22950)
* replace overflow: auto with uui-scroll-container in layout components

* Remove stale comment
2026-05-25 07:57:22 +02:00
8160ede4b6 Background Jobs: Refine RecurringBackgroundJobBase API (#22966)
* Add IgnoredDelayChanged event to allow updates during back-off

* Make Period and IgnoredDelay settable on RecurringBackgroundJobBase with auto-raising events

* Address PR review: handle CTS race, restore negative-IgnoredDelay guard, clarify setter remarks

- Swallow ObjectDisposedException in OnIgnoredDelayChanged for the shutdown race where an in-flight handler reads the to-be-disposed CTS via Interlocked.Exchange before Dispose disposes it.
- Restore "skip back-off when IgnoredDelay <= TimeSpan.Zero (and not Timeout.InfiniteTimeSpan)" guard in IgnoreAndWaitAsync to defend against direct IRecurringBackgroundJob implementations / property overrides returning a negative value that would otherwise tight-loop via ComputeNextDelay clamping to zero.
- Add regression test for the negative-IgnoredDelay skip path.
- Mirror the constructor "stored without raising" remark on the Period and IgnoredDelay setter doc comments.

* Dispose newly-installed CTS when shutdown race wins the rotate-and-cancel

* Clarify XML docs.

* Introduce helper for cancellation source rotate and cancel.

---------

Co-authored-by: Andy Butland <abutland73@gmail.com>
2026-05-25 05:42:41 +00:00
Mads RasmussenandGitHub e06a583f1a Backoffice: Embed package root manifests into umbraco-package.ts to reduce startup requests (#22957)
* Consolidate block package into index export

* keep umbraco-package.ts and embed manifests instead

* Inline package manifests into umbraco-package

* update docs
2026-05-23 10:09:03 +02:00
Andy Butland 61cc37ad1d Revert "Entity refs render readonly when their workspace URL can't be resolved."
This reverts commit dc7b34eb58.
2026-05-23 09:42:42 +02:00
Andy Butland dc7b34eb58 Entity refs render readonly when their workspace URL can't be resolved.
Also fixes name on remove dialog.
2026-05-23 09:35:42 +02:00
Andy Butland dfe98ffa3b Disable failing link to selected content from link picker launched from RTE.
Also fixes name on remove dialog.
2026-05-23 09:16:47 +02:00
Andreas Lykke BorgandAndy Butland 6b8e8935fd Accessibility: Added missing labels to webhook details and headers (#22918)
* Added missing labels to webhoot details and headers

* Changed toggle label to aria-label to remove visible text
2026-05-22 19:04:41 +02:00
Andy ButlandandSven Geusens 53c74efd35 Migrations: Append data-anchor value to href when missing in local link migration (closes #22860) (#22936)
* Support anchor fragments that are included in the data attribute but missing in the href when migrating local links.

* Addressed code review feedback.
2026-05-22 12:04:12 +02:00
Jacob Overgaard 1c058a32d9 Merge branch 'release/17.5.0' into v17/dev 2026-05-22 11:22:13 +02:00
Andy ButlandandGitHub 12f838277c Migrations: Append data-anchor value to href when missing in local link migration (closes #22860) (#22936)
* Support anchor fragments that are included in the data attribute but missing in the href when migrating local links.

* Addressed code review feedback.
2026-05-22 11:20:05 +02:00
Jacob OvergaardandClaude Opus 4.7 1ae2a780dd Workspace Actions: Restore waiting state for buttons with additional options (closes #18670, #20593) (#22554)
* Workspace Actions: Restore waiting state for buttons with additional options

The waiting state was suppressed whenever a workspace action reported
hasAdditionalOptions() (e.g. Save and publish on multi-variant sites),
so users saw the button jump straight from idle to the success tick
with no in-flight feedback.

Always set 'waiting' on click (unless the action is a link). The
variant-picker modal still opens on top of the button, so the spinner
is effectively invisible during selection — but it becomes visible
as soon as the modal closes and the publish request is in flight.

Fixes #22551

* Workspace Actions: Spin button only while real work is in flight

Replace the eager always-set-waiting behaviour from the previous commit
with an opt-in `isPending` signal so the spinner appears only while
actual work (validation + HTTP) is happening - not while the variant
picker modal is open, and never as a spurious success tick when the
user cancels the modal.

Changes:

- Add optional `isPending: Observable<boolean>` to UmbWorkspaceAction
  and a default UmbBooleanState + protected setPending() on the base
  class. Optional + backwards compatible for external implementers.

- Add optional `onActionStarting` callback (via a shared
  UmbWorkspaceActionExecutionOptions type) to
  UmbPublishableWorkspaceContext.saveAndPublish and
  UmbSaveableWorkspaceContext.requestSave. The document publishing
  context and content detail workspace base invoke the callback at the
  join point right after the variant picker resolves (or is skipped
  for the single-variant case), so it never fires when the modal is
  cancelled.

- Wire the document save and save-and-publish actions to clear pending
  at the start of execute() and pass an onActionStarting callback that
  flips it true when work begins.

- Update the workspace action element to observe api.isPending: when
  the observable is present the waiting state is driven by the
  observable (and the success tick is suppressed if the action
  resolves without ever signalling pending - i.e. a cancellation).
  When the observable is absent the element falls back to the legacy
  eager-waiting behaviour. Failures always surface the failed tick.

Fixes #22551

* Reduce cyclomatic complexity of #onClick and _handleSave

CodeScene Code Health Review flagged two complexity issues:

- UmbWorkspaceActionElement.#onClick reached cyclomatic complexity 9
  (threshold is < 9). Extracted the api-execution branch into a new
  private #runApiAction helper so #onClick collapses to a simple
  link-vs-action dispatch.
- _handleSave was already over the threshold (14); my optional-chain
  callback invocation pushed it to 16. Moved the
  `executionOptions?.onActionStarting?.()` call into a #notifyActionStarting
  helper so the call site is a plain method call and contributes zero
  cyclomatic complexity to _handleSave.

No behavioural change.

* Reduce cyclomatic complexity of #handleSaveAndPublish

Same fix as the previous commit's #notifyActionStarting extraction in
content-detail-workspace-base: move the optional-chain callback
invocation into a private helper so #handleSaveAndPublish stays at its
pre-PR cyclomatic complexity (15) instead of degrading to 17.

No behavioural change.

* DRY: extract notifyWorkspaceActionStarting into a shared utility

Both UmbDocumentPublishingWorkspaceContext.#handleSaveAndPublish and
UmbContentDetailWorkspaceContextBase._handleSave had identical private
optional-chain callback off the host method's cyclomatic complexity.

Replace both with a single exported notifyWorkspaceActionStarting()
utility co-located with UmbWorkspaceActionExecutionOptions. This:

- Removes a duplication point between the two contexts.
- Gives future workspace context implementations a ready-made way to
  honour the optional callback without re-inventing the helper or
  paying the cyclomatic-complexity cost at the call site.

No behavioural change.

* Rename isPending -> isExecuting to mirror the execute() method

Niels suggested correlating the observable's name with the action's
`execute()` method, so the symbol set is now:

- isExecuting (observable on UmbWorkspaceAction interface)
- _isExecuting / setExecuting (UmbWorkspaceActionBase)
- #observeIsExecuting / #executionStarted (workspace-action element)
- isExecutingObserver (observer alias)

Pure rename; no behavioural change.

* Address Copilot review: lazy isExecuting, observer scope, finally reset

Five Copilot findings on PR #22554. Three real regressions + two
contract violations, all addressed:

1. UmbWorkspaceActionBase always exposing `isExecuting` made every
   existing subclass appear to opt in to the new modal-aware flow,
   suppressing waiting/success states for actions that never call
   setExecuting(true). Made `_isExecuting`/`isExecuting` lazy: only
   created on the first setExecuting() call. Opt-in subclasses call
   `setExecuting(false)` in their constructor so the observable is
   exposed before the workspace-action element reads it. Subclasses
   that don't opt in keep `isExecuting` undefined and the element
   falls back to legacy eager waiting feedback.

2. Element observation of `isExecuting` now lives inside #runApiAction
   so it tracks whichever api is actually invoked (`_actionApi ?? #api`),
   correctly handling subclasses like UmbSaveAndPreviewWorkspaceActionElement
   that swap in a different api at runtime. The shared observer alias
   replaces any previous observation on re-clicks.

3. UmbSaveWorkspaceAction and UmbDocumentSaveAndPublishWorkspaceAction
   now wrap their execute() body in try/finally and reset
   setExecuting(false) on completion so the observable honours the
   "true while execute() is performing real work, false otherwise"
   contract instead of getting stuck at true between executions.

No behavioural change for actions that already worked correctly before
this PR; the regression-prone "always exposed" behaviour is gone.

* Address Claude review: Elements gap, type placement, tests + cleanup

Three follow-ups on top of c15eb2d0bc:

1. Elements gap — UmbElementSaveAndPublishWorkspaceAction +
   UmbElementPublishingWorkspaceContext now wire through the same
   onActionStarting/notifyWorkspaceActionStarting handshake as the
   Document equivalents, so multi-variant Elements (Forms, Commerce, etc.)
   get the spinner-after-modal behaviour rather than no spinner at all.

2. Type placement — moved UmbWorkspaceActionExecutionOptions out of
   publishable-workspace-context.interface.ts into its own file so the
   saveable interface no longer has a directional dependency on the
   publishable one. Both peer contexts now import from the same neutral
   location.

3. Unit tests — added blackbox coverage for notifyWorkspaceActionStarting
   (no-op on undefined options/callback, invokes when present) and the
   UmbWorkspaceActionBase.setExecuting lazy-opt-in contract (undefined
   until first call, observable then exposed, value flips, sequential
   emissions, stable reference across calls).

Code-review cleanup applied on the same pass:

- Dropped the redundant `setExecuting(false)` at the start of execute()
  in the save and save-and-publish actions; the finally block plus
  UmbBooleanState's value-dedup already cover idempotency on retries.
- Removed an overlong block comment on `_isExecuting`; the JSDoc on
  setExecuting already documents the lazy/opt-in contract for subclasses.
- Trimmed an internal motivation comment from notify-workspace-action-
  starting.function.ts that referenced cyclomatic complexity.
- Extracted a tiny makeAction() helper in the controller test to remove
  the `{ meta: {} as never }` repetition.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Workspace Actions: Align Save button state with Save & Publish (Andy review feedback)

Two related fixes addressing the variant-Save inconsistency Andy reported:

- Element catch block now only sets `failed` once `#executionStarted` is
  true. Pre-flight rejections (user cancelling a variant-picker modal,
  context-missing throws, etc.) leave the button idle, matching the
  silent-cancel path used by `#handleSaveAndPublish`. Legacy actions
  that don't opt in to `isExecuting` are unaffected because they set
  `#executionStarted = true` eagerly on click.

- `UmbDocumentWorkspaceContext._handleSave` and
  `UmbElementWorkspaceContext._handleSave` now accept and forward the
  `UmbWorkspaceActionExecutionOptions` argument to `super._handleSave`.
  The previous overrides dropped the parameter, so the
  `onActionStarting` callback supplied by `UmbSaveWorkspaceAction` never
  fired - which is why Save showed no waiting/success indicator even
  on a successful submit.

Result: Save and Save-and-publish now behave identically -
cancel = no indicator, submit = waiting then success - for both
invariant and multi-variant documents and elements.

* Docs: Document the modal-aware execution feedback contract for workspace actions

New 'Button state when the action opens a modal' subsection in
docs/workspaces.md explaining the three-piece contract:
UmbWorkspaceActionExecutionOptions + notifyWorkspaceActionStarting +
UmbWorkspaceActionBase.setExecuting. Covers third-party authoring of
modal-aware buttons, the cancel/pre-flight idle behaviour, and the
silent-parameter-drop pitfall on _handleSave overrides.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 11:00:49 +02:00
Jacob OvergaardandClaude Opus 4.7 04f0e229c7 Backoffice: Coalesce small Rollup chunks across all workspaces (AB#67983) (#22896)
* Backoffice: Coalesce small Rollup chunks across all workspaces (AB#67983)

Set experimentalMinChunkSize=10_000 as the default in the shared Vite
helper. Every workspace inherits the coalescing automatically; the
threshold can still be overridden per workspace (pass 0 to disable).

Impact on dist-cms output:
- packages/core .js files: 981 -> 272 (-72%)
- All workspaces combined .js files: 2194 -> 1401 (-36%)
- Welcome dashboard .js requests: 510 -> 497 (-2.5%)
- packages/ufm requests in particular: 23 -> 12 (-48%)
- Gzipped bundle total: -1.2%
- Raw bytes: +1.4% (small overhead from merged chunks; gzip wins it back)

All entry chunks are preserved, so every public
@umbraco-cms/backoffice/<sub> import keeps resolving without changes
to package.json exports or tsconfig paths.

Further consolidation (collapsing core's per-subpath entries into a
single bundle with stubs) was prototyped but hits a TDZ cycle between
the eager entry and its dynamic-import descendants. Tracked for v18,
not part of this change.

* Backoffice: Normalise umbraco-package + manifests shapes (AB#67983)

Aligns the two outliers with the conventions used by the other 38
first-party packages:

- documents/umbraco-package.ts now uses the lazy bundle pattern
  (type: 'bundle', js: () => import('./manifests.js')) instead of
  eagerly importing manifests at module evaluation. The bundle
  initializer auto-loads the manifests at boot, so behaviour is
  unchanged.
- umbraco-news/manifests.ts now exports `manifests: Array<...>`
  instead of a bare `dashboard` object. The bundle initializer
  enumerates exports regardless of name, so behaviour is unchanged.

Preparatory cleanup so future build-time manifest aggregation can
treat every workspace uniformly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 09:35:44 +02:00
5d76706553 Backoffice: Coalesce small Rollup chunks across all workspaces (AB#67983) (#22896)
* Backoffice: Coalesce small Rollup chunks across all workspaces (AB#67983)

Set experimentalMinChunkSize=10_000 as the default in the shared Vite
helper. Every workspace inherits the coalescing automatically; the
threshold can still be overridden per workspace (pass 0 to disable).

Impact on dist-cms output:
- packages/core .js files: 981 -> 272 (-72%)
- All workspaces combined .js files: 2194 -> 1401 (-36%)
- Welcome dashboard .js requests: 510 -> 497 (-2.5%)
- packages/ufm requests in particular: 23 -> 12 (-48%)
- Gzipped bundle total: -1.2%
- Raw bytes: +1.4% (small overhead from merged chunks; gzip wins it back)

All entry chunks are preserved, so every public
@umbraco-cms/backoffice/<sub> import keeps resolving without changes
to package.json exports or tsconfig paths.

Further consolidation (collapsing core's per-subpath entries into a
single bundle with stubs) was prototyped but hits a TDZ cycle between
the eager entry and its dynamic-import descendants. Tracked for v18,
not part of this change.

* Backoffice: Normalise umbraco-package + manifests shapes (AB#67983)

Aligns the two outliers with the conventions used by the other 38
first-party packages:

- documents/umbraco-package.ts now uses the lazy bundle pattern
  (type: 'bundle', js: () => import('./manifests.js')) instead of
  eagerly importing manifests at module evaluation. The bundle
  initializer auto-loads the manifests at boot, so behaviour is
  unchanged.
- umbraco-news/manifests.ts now exports `manifests: Array<...>`
  instead of a bare `dashboard` object. The bundle initializer
  enumerates exports regardless of name, so behaviour is unchanged.

Preparatory cleanup so future build-time manifest aggregation can
treat every workspace uniformly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 09:28:31 +02:00
Mads RasmussenandGitHub 1fdcb835bc Devops: Report bidirectional import detection for both Core and Packages modules (#22938)
report bidirectional imports for core modules
2026-05-22 07:29:37 +02:00
Mads RasmussenandGitHub 6b3bdb59b7 Backoffice: Swap relative imports to @umbraco-cms/backoffice module imports in core packages (#22942)
Use @umbraco-cms/backoffice imports

Replace numerous relative/internal import paths with centralized '@umbraco-cms/backoffice' package entry points across core modules.This consolidates exports, simplifies import paths.
2026-05-22 07:24:39 +02:00
65ab1c0b2b Background Jobs: Rewrite RecurringHostedServiceBase with SemaphoreSlim and add signalling support (#22331)
* Compute next delay to compensate for time drift

* Use SemaphoreSlim to properly handle exceptions, cancellation tokens and triggering immediate executions

* Add RecurringBackgroundJobBase to contain default values and hide obsoleted method

* Add NextExecutionStrategy parameter to adjust the schedule after triggered executions

* Add TriggerExecution methods to RecurringBackgroundJobHostedServiceRunner

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Handle cancellation (application shutdown) and publish RecurringBackgroundJobCanceledNotification

* Match hosted services by Type instead of type name string

* Extract shared helper for TriggerExecution tests

* Clear trigger state when initial delay is interrupted

* Clear _nextExecutionSkipOnOvershoot unconditionally

* Combine ComputeNextDelay tests

* Consolidate trigger state into an immutable record for thread safety

* Use ConcurrentDictionary for thread-safe hosted service lookup

* Remove hosted services from dictionary on stop

* Fix API compatibility errors

* Removed unneeded using.

* Register RecurringBackgroundJobHostedServiceRunner as resolvable singleton

* Remove failed hosted service from dictionary when StartAsync throws

* Use semaphore signaling instead of Task.Delay in trigger tests

Use semaphore signaling instead of Task.Delay in trigger tests 2

* Inject TimeProvider into RecurringHostedServiceBase for deterministic testing

Fix timeprovider

* Use DelayCalculator.GetDelay instead of RecurringHostedServiceBase.GetDelay

* Fix Exception_In_PerformExecuteAsync_Does_Not_Kill_Loop test

* Avoid disposing period-change CTS while wait loop may still reference it

* Configure IEventMessagesFactory mock to return real EventMessages

* Clarify TriggerExecution(TimeSpan) docs and add ChangePeriod test

* Validate period is positive and use GetOrAdd to avoid creating unused hosted services

* Set up Period and Delay on mock job to satisfy constructor validation

* Ensure PeriodChanged event is unsubscribed again

* Fix trigger state race, simplify ReleaseSignal, and add canceled notification test

Fix trigger state

* Use Interlocked for _period reads/writes and implement thread-safe dispose pattern

* Remove hosted service from dictionary before stopping to prevent triggering during shutdown

* Replace Task.Yield with semaphore timeouts in negative assertions

* Tidy RecurringBackgroundJobBase docs and runner error handling

* Wait IgnoredDelay after ignored execution to prevent tight looping when Period is short or zero

* Add IRecurringBackgroundJobTrigger<TJob> for opt-in job triggering

* Register IRecurringBackgroundJobTrigger as open generic and drop AddTriggerableRecurringBackgroundJob

* Fix and add parameter validation

* Allow Timeout.InfiniteTimeSpan as Period for manual-trigger-only recurring jobs

* Migrate built-in jobs to RecurringBackgroundJobBase and require ITriggerableRecurringBackgroundJob in runner trigger overloads

* Support infinite Delay and honor TriggerExecution(TimeSpan) issued during the initial delay

* Handle edge case of backoff via InfiniteTimeSpan.

* Refactored large method.

* Added clarifying documentation.

* Suppress ExecutionContext flow when starting the recurring background loop, restoring previous timer behaviour.

* Relocate Suppress ExecutionContext flow to avoid package validation error.

* Align IRecurringBackgroundJobTrigger generic type constraint with AddRecurringBackgroundJob

* Rename ApplyTriggerState to ComputeNextDelayFromTriggerState

* Allow Timeout.InfiniteTimeSpan as IgnoredDelay to fully disable a job for the remaining application lifecycle

* Fix generic type constraint

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Andy Butland <abutland73@gmail.com>
2026-05-21 19:39:21 +02:00
a54769758b Background Jobs: Rewrite RecurringHostedServiceBase with SemaphoreSlim and add signalling support (#22331)
* Compute next delay to compensate for time drift

* Use SemaphoreSlim to properly handle exceptions, cancellation tokens and triggering immediate executions

* Add RecurringBackgroundJobBase to contain default values and hide obsoleted method

* Add NextExecutionStrategy parameter to adjust the schedule after triggered executions

* Add TriggerExecution methods to RecurringBackgroundJobHostedServiceRunner

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Handle cancellation (application shutdown) and publish RecurringBackgroundJobCanceledNotification

* Match hosted services by Type instead of type name string

* Extract shared helper for TriggerExecution tests

* Clear trigger state when initial delay is interrupted

* Clear _nextExecutionSkipOnOvershoot unconditionally

* Combine ComputeNextDelay tests

* Consolidate trigger state into an immutable record for thread safety

* Use ConcurrentDictionary for thread-safe hosted service lookup

* Remove hosted services from dictionary on stop

* Fix API compatibility errors

* Removed unneeded using.

* Register RecurringBackgroundJobHostedServiceRunner as resolvable singleton

* Remove failed hosted service from dictionary when StartAsync throws

* Use semaphore signaling instead of Task.Delay in trigger tests

Use semaphore signaling instead of Task.Delay in trigger tests 2

* Inject TimeProvider into RecurringHostedServiceBase for deterministic testing

Fix timeprovider

* Use DelayCalculator.GetDelay instead of RecurringHostedServiceBase.GetDelay

* Fix Exception_In_PerformExecuteAsync_Does_Not_Kill_Loop test

* Avoid disposing period-change CTS while wait loop may still reference it

* Configure IEventMessagesFactory mock to return real EventMessages

* Clarify TriggerExecution(TimeSpan) docs and add ChangePeriod test

* Validate period is positive and use GetOrAdd to avoid creating unused hosted services

* Set up Period and Delay on mock job to satisfy constructor validation

* Ensure PeriodChanged event is unsubscribed again

* Fix trigger state race, simplify ReleaseSignal, and add canceled notification test

Fix trigger state

* Use Interlocked for _period reads/writes and implement thread-safe dispose pattern

* Remove hosted service from dictionary before stopping to prevent triggering during shutdown

* Replace Task.Yield with semaphore timeouts in negative assertions

* Tidy RecurringBackgroundJobBase docs and runner error handling

* Wait IgnoredDelay after ignored execution to prevent tight looping when Period is short or zero

* Add IRecurringBackgroundJobTrigger<TJob> for opt-in job triggering

* Register IRecurringBackgroundJobTrigger as open generic and drop AddTriggerableRecurringBackgroundJob

* Fix and add parameter validation

* Allow Timeout.InfiniteTimeSpan as Period for manual-trigger-only recurring jobs

* Migrate built-in jobs to RecurringBackgroundJobBase and require ITriggerableRecurringBackgroundJob in runner trigger overloads

* Support infinite Delay and honor TriggerExecution(TimeSpan) issued during the initial delay

* Handle edge case of backoff via InfiniteTimeSpan.

* Refactored large method.

* Added clarifying documentation.

* Suppress ExecutionContext flow when starting the recurring background loop, restoring previous timer behaviour.

* Relocate Suppress ExecutionContext flow to avoid package validation error.

* Align IRecurringBackgroundJobTrigger generic type constraint with AddRecurringBackgroundJob

* Rename ApplyTriggerState to ComputeNextDelayFromTriggerState

* Allow Timeout.InfiniteTimeSpan as IgnoredDelay to fully disable a job for the remaining application lifecycle

* Fix generic type constraint

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Andy Butland <abutland73@gmail.com>
2026-05-21 19:36:04 +02:00
Andreas Lykke BorgandAndy Butland 1616997409 Image Cropper: Improve contrast of append label in crop options editor (closes #22878) (#22917)
Improve contrast of input append label in image crops editor
2026-05-21 19:06:01 +02:00
Andreas Lykke BorgandGitHub 609b74b475 Image Cropper: Improve contrast of append label in crop options editor (closes #22878) (#22917)
Improve contrast of input append label in image crops editor
2026-05-21 19:05:33 +02:00
Engiber LozadaandAndy Butland 63289e22cb Block Grid: Fix inline create button width not updating on workspace resize (closes #22527) (#22928)
* Add ResizeObserver for inline create buttons

* Disconnect layout resize observer on destroy

* Initialize ResizeObserver and simplify cleanup

* Remove optional chaining on layout observer disconnect
2026-05-21 18:12:23 +02:00
Engiber LozadaandGitHub 82b2991a18 Block Grid: Fix inline create button width not updating on workspace resize (closes #22527) (#22928)
* Add ResizeObserver for inline create buttons

* Disconnect layout resize observer on destroy

* Initialize ResizeObserver and simplify cleanup

* Remove optional chaining on layout observer disconnect
2026-05-21 18:11:24 +02:00
Andy Butland 721cf53d40 Fix styling of redirect tracker enabled/disabled icon. 2026-05-21 17:59:27 +02:00
Andy Butland f8ba3d8cfc Merge branch 'release/17.5.0' into v17/dev 2026-05-21 17:41:20 +02:00
Andy ButlandandLan Nguyen Thuy 7f832d261d Reset password: Add inline validation messaging for password pattern requirements (#22880)
* add custom validation for password input in reset password

* update remove invalid listeners in disconnectedCallback

---------

Co-authored-by: Lan Nguyen Thuy <lnt@umbraco.dk>
2026-05-21 17:34:05 +02:00
Mads RasmussenandGitHub f79e9586b4 Collections: Replace direct filter pass-through in collection server data sources (#22921)
Pass explicit skip/take to collection services
2026-05-21 09:18:36 +01:00
Mads RasmussenandGitHub c74a58246f Entity Data Picker: Fix "Not Found" in remove dialog for entities without a top-level name (#22915)
* Add item data resolver support to picker data sources

* add js docs

* remove duplicated fallback logic

* wip unit tests of requestItemName method

* Use DocumentVariantStateModel in mock documents to fix compiler

* Update input-entity-data.context.ts

* Update input-entity-data.context.test.ts
2026-05-21 09:13:45 +01:00
nikolajlauridsen 06b15157cf Merge branch 'release/17.4.2' into release/17.5.0
# Conflicts:
#	src/Umbraco.Web.UI.Client/package-lock.json
#	src/Umbraco.Web.UI.Client/package.json
#	tests/Umbraco.Tests.AcceptanceTest/package-lock.json
#	tests/Umbraco.Tests.AcceptanceTest/package.json
#	version.json
2026-05-21 09:18:11 +02:00
nikolajlauridsen 82f7830d26 Merge branch 'release/17.4.2' into v17/dev
# Conflicts:
#	src/Umbraco.Web.UI.Client/package-lock.json
#	src/Umbraco.Web.UI.Client/package.json
#	tests/Umbraco.Tests.AcceptanceTest/package-lock.json
#	tests/Umbraco.Tests.AcceptanceTest/package.json
#	version.json
2026-05-21 09:16:39 +02:00
MoleandGitHub b87d519bf2 Cache: Only write to url table on a single server in load balanced environments to remove lock contention (#22890)
* Move database writes out of cache refreshers

* add tests

* Fix up tests
2026-05-20 17:08:33 +02:00
8aaac65f83 Content Editing: Fix save composition values on invariant content and save of default segment (closes #22800, #22865) (#22846)
* Fix edit of a variant property composed to an invariant document.

* Collapse multi-line guard comment to a single line per project policy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Add unit test coverage for invariant content with a culture-variant composition property.

Adds a third mock document/document-type pair representing an invariant
content type whose flattened property list contains a culture-variant
property (the runtime shape produced when a variant composition is applied
to an invariant content type) and a setPropertyValue test asserting the
value is stored as a culture/segment-invariant entry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Remove null guard for segment variant documents, as null segment is the default segment.

* update mock data and tests to include real compositions

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Mads Rasmussen <madsr@hey.com>
2026-05-20 10:52:09 +02:00
8d5826c61f Content Editing: Fix save composition values on invariant content and save of default segment (closes #22800, #22865) (#22846)
* Fix edit of a variant property composed to an invariant document.

* Collapse multi-line guard comment to a single line per project policy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Add unit test coverage for invariant content with a culture-variant composition property.

Adds a third mock document/document-type pair representing an invariant
content type whose flattened property list contains a culture-variant
property (the runtime shape produced when a variant composition is applied
to an invariant content type) and a setPropertyValue test asserting the
value is stored as a culture/segment-invariant entry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Remove null guard for segment variant documents, as null segment is the default segment.

* update mock data and tests to include real compositions

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Mads Rasmussen <madsr@hey.com>
2026-05-20 10:43:02 +02:00
Lee KelleherandGitHub 5a73f63cfd feat(components): adds umb-entity-frame component + Storybook stories (#22844)
* feat(components): adds `umb-entity-frame` component + Storybook stories

* fix(components): address review feedback for `umb-entity-frame`

- Remove `pointer-events: auto` from `.tab` so the overlay is truly passive
  (was intercepting events above the parent and causing hover flicker when
  toggled via opacity).
- Replace `--uui-color-surface` tab text with `--uui-color-selected-contrast`
  (the proper paired contrast token) and expose
  `--umb-entity-frame-contrast-color` so consumers can override when supplying
  a non-default `--umb-entity-frame-color`. Fixes contrast in dark and
  high-contrast themes.
- Add `aria-hidden="true"` to `.tab`; the frame is purely decorative and the
  parent owns the real semantics.
- Add a unit test verifying slot content takes precedence over the `label`
  property.

* Removed `aria-hidden` from the label tab

As will need to be used with assistive technologies.
2026-05-20 08:39:49 +00:00
e463cd3a0c Log Viewer: Defensively handle corrupt log files (closes #22820) (#22826)
* Defensively handle log file corruptions by amalgamating errors per file and reporting as warning.

* Addressed code review comments.

* Use local reference to Newtonsoft.Json so it's clear we are only using it for exception handling.

---------

Co-authored-by: Kenn Jacobsen <kja@umbraco.dk>
2026-05-20 07:22:54 +00:00
2901be793a Redirect URL Tracker: Remove ability to toggle from the UI (#22830)
* Remove the ability to enable or disable the redirect tracker from the UI.

* Addressed code review feedback.

* Update OpenApi.json.

* Regenerate backend SDK from updated OpenApi.json

* Update UI to use lozenge status indicator rather than an imperative action.

* Further UX tweak.

---------

Co-authored-by: Laura Neto <12862535+lauraneto@users.noreply.github.com>
2026-05-20 09:20:37 +02:00
Andy Butlandandmole f255fd7bff Bump version to 17.4.2. 2026-05-20 09:18:56 +02:00
7527de7c56 Migrations: Add auto upgrade coordination for load-balanced setups (#22815)
* Add auto upgrade coordination for load balanced setups

* Add tests

* Apply suggestions from code review

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* fix(infrastructure): move TryBecomeLeaderAsync inside try/catch in UnattendedUpgradeBackgroundService

Ensures DB exceptions thrown during migration coordination set BootFailed
rather than faulting the background service silently.

* Fix feedback

* Update src/Umbraco.Infrastructure/Install/MigrationCoordinator.cs

Co-authored-by: Andy Butland <abutland73@gmail.com>

* Recheck state

* fix(tests): update concurrent race test for post-claim DetermineRuntimeLevel check

The winner now calls DetermineRuntimeLevel() once from the post-claim check
and must see Upgrading; the loser polls twice before seeing Run. Transition
the mock on the second call instead of the first.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Andy Butland <abutland73@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 09:18:56 +02:00
df12a3e467 Cache: Add scope-level cache version tier to reduce DB hits in bulk operations (#22563)
* Cache cacheversion on scope

* Add tests

* Cache: Use ConcurrentDictionary for the inner per-scope version map

The inner Dictionary<string, Guid> was not thread-safe. Replacing it
with ConcurrentDictionary<string, Guid> removes the hidden assumption
that the root scope is only accessed from a single thread at a time.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Update src/Umbraco.Core/Cache/IRepositoryCacheVersionAccessor.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Cache/RepositoryCacheVersionAccessorTests.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/Umbraco.Web.Common/Cache/RepositoryCacheVersionAccessor.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Skips failing test so we can run nightly. THIS NEEDS TO BE REVERTED

* Revert "Skips failing test so we can run nightly. THIS NEEDS TO BE REVERTED"

This reverts commit c34d1736c336b3fcf7803b44e88f6018fa45c275.

* Only write version once pr. scope

* Add tests

* Remove unnececary locks

* Fix thread-safety: replace HashSet with ConcurrentHashSet and use GetOrAdd to eliminate TOCTOU races

* Add unit tests for RepositoryCacheVersionService

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Andreas Zerbst <andr317c@live.dk>
2026-05-20 09:18:56 +02:00
Andy Butland 0b86312f52 Children/Descendants: improve traversal performance (closes #22646) (#22742)
* Add benchmark test for measuring improvements to children and descendant retrieval.

* Remove unnecessary sort from retrieval of children.

* Return the result of the filtered collection of children/decendants without materialising.

* Lazily build property wrappers when materializing IPublishedContent.

* Cache the ordered children list on NavigationNode.

* Cache descendants per parent on the navigation snapshot.

* Add synchronous fast path for retrieved of cached content.

* Additional unit tests.

* Add TODO to make UpdateSortOrder internal.

* Addressed code review feedback.

* Further unit tests.

* Future-proofed code comments.
2026-05-19 18:01:12 +02:00
Andy ButlandandGitHub f4592111fa Children/Descendants: improve traversal performance (closes #22646) (#22742)
* Add benchmark test for measuring improvements to children and descendant retrieval.

* Remove unnecessary sort from retrieval of children.

* Return the result of the filtered collection of children/decendants without materialising.

* Lazily build property wrappers when materializing IPublishedContent.

* Cache the ordered children list on NavigationNode.

* Cache descendants per parent on the navigation snapshot.

* Add synchronous fast path for retrieved of cached content.

* Additional unit tests.

* Add TODO to make UpdateSortOrder internal.

* Addressed code review feedback.

* Further unit tests.

* Future-proofed code comments.
2026-05-19 18:00:34 +02:00
Andy Butland 4c909d8ce8 Merge branch 'release/17.4.1' into release/17.5.0 2026-05-19 17:54:19 +02:00
Andy Butland 12c699d5bd Merge branch 'release/17.4.1' into v17/dev 2026-05-19 17:47:47 +02:00
Niels LyngsøandGitHub ba29b91301 Slider: fix duplicated property editor settings properties (#22898)
* fix duplicate slider pe-settings properties

* remove comment

* avoid throws
2026-05-19 14:19:34 +00:00
Andy ButlandandGitHub 336bffe4c4 Output Caching: Correctly gate auto-registration of UseOutputCache() middleware (#22897)
Correct the gating of the call to UseOutputCache() to only proceed Umbraco managed caching via configuration is enabled, and not consider existing implementation specific registrations.
2026-05-19 16:18:21 +02:00
426e516c61 Content Workspace: Load Data-Types based on Loaded Content Types (#22886)
* Load Data-Types based on Loaded Content Types

* Update Comment

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* mergeObservables approach

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-19 15:02:13 +02:00
Andy Butland c718a3ce12 Bump version to 17.4.1. 2026-05-19 15:01:44 +02:00
Jacob Overgaard 1637d9b158 Merge branch 'release/17.5.0' into v17/dev 2026-05-19 10:55:58 +02:00
Jacob Overgaard 7737cd3d40 Localization: Honor DefaultUILanguage on initial load (closes #22808) (#22822)
* Localization: Honor DefaultUILanguage on initial load (closes #22808)

Closes #22808.

Previously, the configured DefaultUILanguage was silently overridden to
'en' at startup because the UmbLocalizationRegistry constructor called
loadLanguage(UMB_DEFAULT_LOCALIZATION_CULTURE) unconditionally. The
configured locale rendered into <html lang="..."> by Razor never had a
chance to flow through to the active language.

Changes:

- localization.registry.ts: stop forcing the active language to 'en'
  in the constructor. Initial state is canonicalised from
  document.documentElement.lang, falling back to 'en' for empty or
  malformed input. The extension filter now always includes the
  default culture alongside the active locale so 'en' translations
  remain available as a key-level fallback regardless of which
  language is active. A synchronous tap mirrors the active locale to
  document.lang and the manager when the state changes, so a fresh
  element rendered between loadLanguage() and the async translation
  load picks up the right language immediately.

- localization.manager.ts: drop the MutationObserver on
  document.documentElement and rely on the registry as the single
  channel for language changes. setActiveLanguage accepts a `silent`
  option so the synchronous tap can update fields without firing a
  consumer notification (translations may still be loading). A new
  notifyLanguageChanged() method is fired by the registry once
  translations are in place.

- app.element.ts: subscribe to umbLocalizationRegistry.currentLanguage
  in connectedCallback and mirror it onto the host element's lang
  attribute, so myApp.lang reflects the source of truth rather than a
  stale snapshot of <html lang>.

- auth.element.ts (login app): same lang subscription, plus after the
  slim backoffice controller registers extensions, prefer the
  visitor's navigator.language if a matching localization extension
  exists (falls through baseName -> language -> en automatically).

Tests: new initialization tests for the registry, manager
setActiveLanguage tests, and the controller tests refactored to use
the new explicit setActiveLanguage API instead of writing directly to
document.documentElement.lang.

* Login: Only override DefaultUILanguage with navigator.language when default has no translation

If the admin sets DefaultUILanguage to a language we have a translation for,
respect that choice over the visitor's browser language. Falling back to
navigator.language only when the configured default isn't available avoids
silently ignoring the admin's explicit setting (e.g., DefaultUILanguage='da-DK'
on a site whose visitor's browser is 'en-GB' should still show Danish).

* Simplify: split setActiveLanguage from notifyLanguageChanged

Drop the silent option in favor of two intent-revealing methods:
setActiveLanguage updates the active language and direction without
side-effects; notifyLanguageChanged tells all connected controllers
to re-render against the current state. Callers compose them based
on what they need (the registry's pipeline updates language sync
then flushes notifications after async translation load).

Also extracts baseLocaleOf() helper, simplifies the navigator.language
match logic in the login app's #applyPreferredLanguage, and removes
narration-style comments in the new code.

* Restore deprecated UmbLocalizationManager.updateAll for backward compat

The old MutationObserver-driven updateAll() field was technically part
of the manager's public surface. Restore it as a deprecated alias that
reads document.lang/dir and forwards to setActiveLanguage + notifyLanguageChanged,
with a runtime UmbDeprecation warning pointing consumers at the new API.

* Fix deprecation removal version to v20 + correct baseLocaleOf JSDoc

Per the deprecation policy in CLAUDE.md (current major + 2): a method
deprecated in v18 must remain through v19 before removal, so the
earliest removal is v20, not v19.

Also corrects the baseLocaleOf JSDoc — Intl.Locale.baseName can include
script subtags (e.g. 'zh-Hant-TW'), not just language and region.

* Scope the active language to the host element, drop navigator.language

- Razor now sets `lang` on `<umb-app>` and `<umb-auth>` from
  DefaultUILanguage. The element passes its lang through on connect, so
  the host owns its own scope — future multi-backoffice scenarios (e.g.
  signing into two Umbraco Cloud sites in the same document) get their
  own language without fighting over a global `<html lang>`.

- The registry no longer reads or writes `document.documentElement.lang`.
  Host elements drive it via `loadLanguage()`; `<html lang>` stays as
  whatever Razor rendered.

- Removed the navigator.language preference detection in the login app.
  Not in scope for the bug fix and adds behavior the admin can't opt out
  of. The existing current-user-locale flow already handles per-user
  preference after login.

- Tests updated to assert on `umbLocalizationManager.documentLanguage`
  instead of `document.documentElement.lang`.

* Set <html lang="en"> to match the static (noscript) text in the templates

The page's `<html lang>` should describe the language of the document's
own innate content. Both Index.cshtml files only contain English static
text (the noscript fallback), so the page-level lang is now "en".

The dynamic UI inside <umb-app> / <umb-auth> carries its own `lang`
attribute (from DefaultUILanguage), which overrides for that subtree —
correct per the HTML spec for language inheritance.

* Drop deprecated UmbLocalizationManager.updateAll

It was public as an artifact of being an arrow function so it could be
passed to a MutationObserver without binding — not because it was
intended as part of the public API. External usage is effectively
zero, and the new explicit setActiveLanguage + notifyLanguageChanged
covers anyone who did reach for it.

* Docs: document active-language-on-host pattern in package-development.md

After PR #22822, the active UI language is driven by the shell elements
(<umb-app>, <umb-auth>) via their own lang attribute, not by <html lang>.
Document that so future contributors don't reach for the global.

* Collapse setActiveLanguage + notifyLanguageChanged into one method

The silent-write path is just `manager.documentLanguage = ...` — no new
method needed; the field is already public and was always writable. The
notify path keeps setActiveLanguage, which now both sets and notifies.

Net: one new public method on the manager instead of two.

* Inline the active-language write in the registry, drop setActiveLanguage

The previous version added setActiveLanguage on the manager as
a 'cleaner API' than direct field writes. But the manager's fields
(documentLanguage, documentDirection, connectedControllers) have
always been public, the registry is the only caller, and the wrapper
was just more public surface to maintain through the eventual
manager/registry collapse.

Net: -70 lines across the test file, no new public methods on the
manager, the registry pipeline writes the fields and iterates the
controllers directly where it would have called setActiveLanguage.

* Document that documentLanguage/Direction are read-only for consumers

Note in JSDoc that the only supported way to change the active language
is umbLocalizationRegistry.loadLanguage(). The fields stay writable for
the registry pipeline (cross-module internal); the comment is here so
the next contributor doesn't reach for them as a shortcut and end up
with the manager state out of sync with what's actually loaded.
2026-05-19 10:52:55 +02:00
139ac6ad72 Blueprints: Fix UdiEntityTypeHelper.ToUmbracoObjectType() for document blueprint containers (#22875)
* Blueprints: Fix UdiEntityTypeHelper.ToUmbracoObjectType() for document blueprint containers

Adds the missing DocumentBlueprintContainer case so the conversion is
symmetric with FromUmbracoObjectType().

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Add missing case for MemberTypeContainer.

* Use reflection to ensure other future missed cases are surfaced without having to explicitly extend the tests.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Andy Butland <abutland73@gmail.com>
2026-05-19 09:13:52 +02:00
cd4521bd77 Blueprints: Fix UdiEntityTypeHelper.ToUmbracoObjectType() for document blueprint containers (#22875)
* Blueprints: Fix UdiEntityTypeHelper.ToUmbracoObjectType() for document blueprint containers

Adds the missing DocumentBlueprintContainer case so the conversion is
symmetric with FromUmbracoObjectType().

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Add missing case for MemberTypeContainer.

* Use reflection to ensure other future missed cases are surfaced without having to explicitly extend the tests.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Andy Butland <abutland73@gmail.com>
2026-05-19 09:11:09 +02:00
80e2764eda Migrations: Add auto upgrade coordination for load-balanced setups (#22815)
* Add auto upgrade coordination for load balanced setups

* Add tests

* Apply suggestions from code review

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* fix(infrastructure): move TryBecomeLeaderAsync inside try/catch in UnattendedUpgradeBackgroundService

Ensures DB exceptions thrown during migration coordination set BootFailed
rather than faulting the background service silently.

* Fix feedback

* Update src/Umbraco.Infrastructure/Install/MigrationCoordinator.cs

Co-authored-by: Andy Butland <abutland73@gmail.com>

* Recheck state

* fix(tests): update concurrent race test for post-claim DetermineRuntimeLevel check

The winner now calls DetermineRuntimeLevel() once from the post-claim check
and must see Upgrading; the loser polls twice before seeing Run. Transition
the mock on the second call instead of the first.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Andy Butland <abutland73@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 12:06:34 +02:00
Andy Butland f964a18b5b Merge branch 'release/17.5.0' of https://github.com/umbraco/Umbraco-CMS into release/17.5.0 2026-05-14 19:10:30 +02:00
Lee KelleherandAndy Butland 2369f00544 Mocks: Add missing signalR property to mock server configuration response (#22849)
Mocks: Add missing signalR property to mock server configuration response

The GetServerConfigurationResponse type was updated in #22700 to require
a signalR.skipNegotiation property, but the MSW mock handler was not
updated to match, causing a tsc compilation error.
2026-05-14 19:08:11 +02:00
Sebastiaan Janssen d822518dd3 Core: Preserve path case in ShadowFileSystem (#22838)
* Core: Preserve path case in ShadowFileSystem

ShadowFileSystem stored staged files at their original case via _sfs.AddFile
but tracked them under a lowercased key (NormPath calling ToLowerInvariant).
On Complete(), Inner.AddFile(kvp.Key, _sfs.GetFullPath(kvp.Key)) reconstructed
the staged file's path from the lowercased key, so on case-sensitive file
systems (Linux) File.Move failed with FileNotFoundException whenever a path
contained any uppercase character.

Drop the ToLowerInvariant from NormPath and switch the tracking dictionary
to StringComparer.OrdinalIgnoreCase. Lookups remain case-insensitive
(matching Windows semantics) while the stored key now matches what was
written to disk. IsChild/IsDescendant updated to OrdinalIgnoreCase
StartsWith for consistency.

Added regression test reproducing the original FileNotFoundException with
Views/PageNotFound.cshtml.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Rename regression test to follow Can_ naming convention

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Track canonical staged path per shadow node

The case-insensitive node dictionary preserved only the first inserted
key, so re-staging a logical path with a different case (e.g. AddFile
"Views/Foo.cshtml" then "views/foo.cshtml") wrote a phantom second file
to _sfs on Linux while Complete still resolved the original key — leaving
orphaned shadow files and committing stale content.

Track the original-case staged path on each ShadowNode and route all
_sfs operations (AddFile, OpenFile, GetFullPath, GetLastModified,
GetCreated, GetSize, MoveFile, Complete) through that canonical path.
Inner.AddFile on commit still uses the stored dictionary key, so the
destination case in the inner file system is unchanged.

Expanded the regression test to also exercise OpenFile, GetSize and
AddFile against a different-cased path, and to assert that the staged
file is written exactly once.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Make ShadowNode.CanonicalPath non-nullable

Every node now carries the original-case path it tracks, set at construction.
This removes the defensive 'sf.CanonicalPath ?? path' fallbacks at the read
sites (OpenFile, GetFullPath, GetLastModified, GetCreated, GetSize, Complete)
which were unreachable but noise.

The GetCanonicalPath helper is gone; AddFile and MoveFile now use the existing
node variable inline ('sf?.CanonicalPath ?? path' — node can legitimately be
null when staging a path for the first time).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Address Copilot review: normalize delete key, cross-platform test

DeleteDirectory(recursive=false) stored the deletion marker under the
caller-supplied path (which can contain backslashes) instead of the
normalized key, so a follow-up NormPath-based lookup could miss the
deletion and IsChild scans could become inconsistent. Use normPath.

The shadow-second-file assertion in the regression test used
File.Exists on a different-cased path; that returns true on
case-insensitive file systems (Windows / default macOS) regardless of
the actual stored case, so the assertion was platform-dependent.
Replaced it with a directory-count check that's cross-platform.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Add cross-platform regression test so any reversion would be caught on a non-case sensitive file system.

* Cleaned up warnings, obsoletions and comments in the existing tests.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Andy Butland <abutland73@gmail.com>
(cherry picked from commit abfa8cb144)
2026-05-14 10:42:20 +02:00
Andy Butland 21ab470d88 Merge branch 'release/17.4.0' into release/17.5.0 2026-05-14 08:31:39 +02:00
Ronald BarendseandAndy Butland 4921ab9257 SignalR: Mark ServerEventSender as a distributed cache notification handler (#22818)
* Mark ServerEventSender as distributed cache notification handler

* Batch and deduplicate notifications in ServerEventSender

* Introduce IDistributedCacheAsyncNotificationHandler<T> and use it in ServerEventSender

* Add ServerEventSender unit tests and address PR review feedback
2026-05-13 08:29:22 +02:00
Ronald Barendseandleekelleher 63bae5958a User Permission: Re-export fallback condition config type and global augmentation (#22794)
(cherry picked from commit 55fec1dc2a)
2026-05-12 17:15:28 +01:00
Andy Butlandandleekelleher 35d726ad31 Sort Children: Show loading state on Sort button (closes #22651) (#22813)
* Add submit button state to sort dialog.

* Guard against re-entrant submit in sort-children-of modal.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Set failed button state when sort-children-of submit throws.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
(cherry picked from commit dfe93c5639)
2026-05-12 17:06:44 +01:00
Kenn JacobsenandAndy Butland fc9ca861b0 Content: Ensure correct variant change tracking when unpublishing variant content (#22799)
* Ensure correct change tracking when unpublishing

* Update src/Umbraco.Core/Services/PublishStatus/PublishStatusService.cs

Co-authored-by: Andy Butland <abutland73@gmail.com>

* Add comment

---------

Co-authored-by: Andy Butland <abutland73@gmail.com>
2026-05-12 10:01:20 +02:00
Sven Geusensandmole 68194e1a27 Distributed background jobs: Improve gracefull shutdown behaviour (#22796)
* Dont fail silently on missing ambientscope

This makes it in line with other methods in the repo

* Pass on Cancellationtoken to the job to support gracefull job shutdown

(cherry picked from commit 5ae17ace6a)
2026-05-12 09:51:25 +02:00
1c1787c445 Auth: Un-deprecate getLatestToken and route per-request fetches through it (#22736)
* Auth: un-deprecates getLatestToken and routes per-request fetches through it

getLatestToken is the only public API for "wait for any in-flight refresh,
trigger one if the access token has expired, then return". External and
internal consumers were warned off it without an equivalent replacement:
configureClient only helps @hey-api/openapi-ts clients, and consumers using
axios/ky/native fetch had no other gate.

- Removes the @deprecated JSDoc + UmbDeprecation.warn() call so the public
  surface no longer prints a console warning per call.
- Uses getLatestToken.bind(this) for the auth callback inside configureClient
  and the token callback inside getOpenApiConfiguration so both paths share
  the same #ensureTokenReady gate.
- Replaces the hard-coded `Authorization: Bearer [redacted]` in unlinkLogin
  and #makeLinkTokenRequest with `Bearer ${await getLatestToken()}` so those
  fetches participate in the refresh coordination rather than firing with a
  potentially-revoked cookie.

Also wires the UmbracoExtension template's entrypoint to call
authContext.configureClient(client), matching the v18 template change. The
framework awaits onInit, so this guarantees the API client is fully
configured before any element in the extension can use it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Auth: tightens UmbAuthContext correctness and accepts any hey-api client

Pulls in a batch of non-breaking improvements to UmbAuthContext that came
out of an audit on the back of the un-deprecation work in this PR:

Public surface:
- configureClient(client) now accepts a new structural UmbApiClient type
  (exported from @umbraco-cms/backoffice/http-client). Each @hey-api/openapi-ts
  generation produces a fully-bound Client<…>; the backoffice's umbHttpClient
  and an extension's regenerated client are structurally identical but TS
  treats them as distinct generic instantiations. The widened parameter lets
  extensions wire their own client without `as never` casts at call sites.
  bindDefaultInterceptors keeps its strict typeof umbHttpClient parameter
  (preserving autocomplete inside interceptor callbacks); the cast happens
  once, internally.

Correctness:
- The auth context now holds a single UmbApiInterceptorController, lazy-
  initialised on first configureClient() call. Previously each call
  instantiated a new controller, which re-provided the UmbAuthSignalerContext
  on the host and stacked listeners — visible the moment an extension also
  called configureClient. One controller for the lifetime of the host, all
  configured clients share it.
- completeAuthorizationRequest checks sessionStorage before asking
  window.opener for the PKCE verifier. The previous order hung for the full
  postMessage timeout whenever oauth_complete loaded with a non-OAuth
  window.opener (which is set for ANY window.open target). The opener
  postMessage timeout is also dropped from 5s to 1.5s — a real popup parent
  responds within milliseconds; longer is just wait time for the unrelated-
  opener case.
- The cross-tab 'authorized' BroadcastChannel handler now routes through
  #setSessionLocally so the timestamp math stays in one place. The
  'sessionUpdate' handler still applies pre-computed timestamps directly
  (peer broadcast already did the math) but does so inside the
  #inSessionUpdateCallback guard, so a synchronous session$ observer can no
  longer trigger a spurious /token refresh on top of a peer's update.
- #ensureTokenReady drops its query-then-request pattern. Now always queues
  behind the umb:token-refresh lock with a no-op callback — if the lock is
  free it acquires immediately, if held it waits. Eliminates the race window
  between query() and request().
- destroy() invokes #popupCleanup before tearing down so an in-flight popup
  flow's window-level message listener and closed-poll interval don't leak
  past the context's lifetime. The cleanup helper itself now resolves the
  popup-flow Promise — every termination path (authorized, popup closed,
  superseded by a new flow, context destroyed) is observable to the awaiter
  instead of hanging forever.

Cleanup:
- makeAuthorizationRequest is annotated Promise<void> so the redirect and
  popup branches share an explicit return type.
- unlinkLogin wraps the parsed problem-details payload in a real Error (with
  the original payload exposed on `.cause`) so callers using `instanceof
  Error` or expecting a stack trace get sane behaviour.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 10:58:19 +02:00
Andy Butland 3714ebbb29 Published Content: Fix Fallback.ToAncestors with no match throwing exception at property level (closes #22759) (#22763)
* Fix Fallback.ToAncestors regression at property level.

* Further unit tests.
2026-05-08 10:18:09 +02:00
Niels Lyngsø bf32f9e5a6 update package-lock 2026-05-08 09:01:20 +02:00
Niels Lyngsø 3220739faa upgrade to UI LIbrary 1.17.3 2026-05-08 08:59:49 +02:00
694 changed files with 17536 additions and 2962 deletions
+16
View File
@@ -435,6 +435,14 @@ When a PR changes Management API controllers or models, the `OpenApi.json` file
The backoffice is published to npm as `@umbraco-cms/backoffice`. Runtime dependencies are provided via importmap; npm peerDependencies provide types only. For full details on dependency hoisting, version range logic, and plugin development, see `/src/Umbraco.Web.UI.Client/CLAUDE.md` → "npm Package Publishing".
### SQL Server 2100-parameter limit
Any `WHERE IN (@0, @1, ...)` built from a runtime-sized collection risks hitting SQL Server's 2100-parameter ceiling and throwing `SqlException` 8003 in production.
Batch with `IEnumerable<T>.InGroupsOf(Constants.Sql.MaxParameterCount)` or `Database.FetchByGroups(...)` whenever the collection size is driven by user data — not just when it currently fits. Watch for products of two scaling dimensions (documents × languages, properties × versions) and config-tunable batch sizes whose defaults are safe but ceilings aren't.
Full guidance, safe patterns and decision rule: see `/src/Umbraco.Infrastructure/CLAUDE.md` → "Avoiding the SQL Server 2100-parameter limit".
### Known Limitations
1. **Circular Dependencies**: Avoided via `Lazy<T>` or event notifications
@@ -531,6 +539,14 @@ Allowed, but cheap to write and cheaper to leave behind. Keep them short and tra
---
## 9. Testing Practices
### Tests for a bug fix must fail before the fix
Verify any test you add for a bug fix actually catches the bug: either write the failing test first (TDD), or temporarily revert the production change and confirm the test fails before re-applying. A test that passes both ways proves nothing. Watch for coincidental passes — default seed/sort orders can make a buggy path produce the right answer for the test's specific inputs; construct inputs so the broken and fixed behaviours give visibly different results.
---
## Quick Reference
### Essential Commands
+3 -3
View File
@@ -45,8 +45,8 @@
<PackageVersion Include="Asp.Versioning.Mvc" Version="8.1.1" />
<PackageVersion Include="Asp.Versioning.Mvc.ApiExplorer" Version="8.1.1" />
<PackageVersion Include="Dazinator.Extensions.FileProviders" Version="2.0.0" />
<PackageVersion Include="Examine" Version="3.7.1" />
<PackageVersion Include="Examine.Core" Version="3.7.1" />
<PackageVersion Include="Examine" Version="3.8.0" />
<PackageVersion Include="Examine.Core" Version="3.8.0" />
<PackageVersion Include="HtmlAgilityPack" Version="1.12.4" />
<PackageVersion Include="JsonPatch.Net" Version="3.3.0" />
<PackageVersion Include="K4os.Compression.LZ4" Version="1.3.8" />
@@ -92,4 +92,4 @@
<!-- TODO: Remove this pinned dependency when Examine updates its Microsoft.AspNetCore.DataProtection reference. -->
<PackageVersion Include="System.Security.Cryptography.Xml" Version="10.0.6" />
</ItemGroup>
</Project>
</Project>
@@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Primitives;
using Umbraco.Cms.Api.Common.DependencyInjection;
using Umbraco.Cms.Api.Delivery.Accessors;
@@ -162,6 +163,12 @@ public static class UmbracoBuilderExtensions
builder.Services.AddUnique<IDeliveryApiOutputCacheRequestFilter, DefaultDeliveryApiOutputCacheRequestFilter>();
builder.Services.AddUnique<IDeliveryApiOutputCacheManager, DeliveryApiOutputCacheManager>();
// Signal that Umbraco has enabled output caching so the application builder registers
// the output cache middleware. Gated via a marker rather than IOutputCacheStore so that
// applications calling services.AddOutputCache(...) for their own purposes are not
// affected by Umbraco's automatic middleware registration.
builder.Services.TryAddSingleton<IUmbracoManagedOutputCacheMarker, UmbracoManagedOutputCacheMarker>();
return builder;
}
}
@@ -53,11 +53,14 @@ public class SearchDataTypeItemController : DatatypeItemControllerBase
return Ok(new PagedModel<DataTypeItemResponseModel> { Total = searchResult.Total });
}
IEnumerable<IDataType> dataTypes = await _dataTypeService.GetAllAsync(searchResult.Items.Select(item => item.Key).ToArray());
Guid[] keys = searchResult.Items.Select(x => x.Key).ToArray();
IEnumerable<IDataType> dataTypes = await _dataTypeService.GetAllAsync(keys);
IEnumerable<IDataType> orderedDataTypes = OrderByRequestedIds(dataTypes, keys);
var result = new PagedModel<DataTypeItemResponseModel>
{
Items = _mapper.MapEnumerable<IDataType, DataTypeItemResponseModel>(dataTypes),
Total = searchResult.Total
Items = _mapper.MapEnumerable<IDataType, DataTypeItemResponseModel>(orderedDataTypes),
Total = searchResult.Total,
};
return Ok(result);
@@ -54,11 +54,14 @@ public class SearchMediaTypeItemController : MediaTypeItemControllerBase
return Task.FromResult<IActionResult>(Ok(new PagedModel<MediaTypeItemResponseModel> { Total = searchResult.Total }));
}
IEnumerable<IMediaType> mediaTypes = _mediaTypeService.GetMany(searchResult.Items.Select(item => item.Key).ToArray().EmptyNull());
Guid[] keys = searchResult.Items.Select(item => item.Key).ToArray();
IEnumerable<IMediaType> mediaTypes = _mediaTypeService.GetMany(keys.EmptyNull());
IEnumerable<IMediaType> orderedMediaTypes = OrderByRequestedIds(mediaTypes, keys);
var result = new PagedModel<MediaTypeItemResponseModel>
{
Items = _mapper.MapEnumerable<IMediaType, MediaTypeItemResponseModel>(mediaTypes),
Total = searchResult.Total
Items = _mapper.MapEnumerable<IMediaType, MediaTypeItemResponseModel>(orderedMediaTypes),
Total = searchResult.Total,
};
return Task.FromResult<IActionResult>(Ok(result));
@@ -32,6 +32,14 @@ public class SearchMemberTypeItemController : MemberTypeItemControllerBase
_mapper = mapper;
}
/// <summary>
/// Searches for member type items matching the specified query, with support for pagination.
/// </summary>
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
/// <param name="query">The search query used to filter member type items.</param>
/// <param name="skip">The number of items to skip before starting to collect the result set (used for pagination).</param>
/// <param name="take">The maximum number of items to return in the result set (used for pagination).</param>
/// <returns>A task representing the asynchronous operation. The task result contains an <see cref="IActionResult"/> with a <see cref="PagedModel{MemberTypeItemResponseModel}"/> containing the search results.</returns>
[HttpGet("search")]
[MapToApiVersion("1.0")]
[ProducesResponseType(typeof(PagedModel<MemberTypeItemResponseModel>), StatusCodes.Status200OK)]
@@ -45,11 +53,14 @@ public class SearchMemberTypeItemController : MemberTypeItemControllerBase
return Task.FromResult<IActionResult>(Ok(new PagedModel<MemberTypeItemResponseModel> { Total = searchResult.Total }));
}
IEnumerable<IMemberType> memberTypes = _memberTypeService.GetMany(searchResult.Items.Select(item => item.Key).ToArray());
Guid[] keys = searchResult.Items.Select(item => item.Key).ToArray();
IEnumerable<IMemberType> memberTypes = _memberTypeService.GetMany(keys);
IEnumerable<IMemberType> orderedMemberTypes = OrderByRequestedIds(memberTypes, keys);
var result = new PagedModel<MemberTypeItemResponseModel>
{
Items = _mapper.MapEnumerable<IMemberType, MemberTypeItemResponseModel>(memberTypes),
Total = searchResult.Total
Items = _mapper.MapEnumerable<IMemberType, MemberTypeItemResponseModel>(orderedMemberTypes),
Total = searchResult.Total,
};
return Task.FromResult<IActionResult>(Ok(result));
@@ -8,67 +8,38 @@ using Umbraco.Cms.Core.Security;
namespace Umbraco.Cms.Api.Management.Controllers.RedirectUrlManagement;
/// <summary>
/// Controller for setting the redirect URL tracking status.
/// Controller for setting the redirect URL tracking status. Retained for backwards compatibility only;
/// the endpoint no longer modifies any configuration.
/// </summary>
[ApiVersion("1.0")]
[Obsolete("This controller is deprecated and no longer modifies the configuration. Set the Umbraco:CMS:WebRouting:DisableRedirectUrlTracking configuration key instead. Scheduled for removal in Umbraco 19.")]
public class SetStatusRedirectUrlManagementController : RedirectUrlManagementControllerBase
{
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
private readonly IConfigManipulator _configManipulator;
/// <summary>
/// Initializes a new instance of the <see cref="SetStatusRedirectUrlManagementController"/> class.
/// </summary>
/// <param name="backOfficeSecurityAccessor">The back office security accessor.</param>
/// <param name="configManipulator">The configuration manipulator.</param>
/// <param name="backOfficeSecurityAccessor">Ignored. Retained for binary compatibility.</param>
/// <param name="configManipulator">Ignored. Retained for binary compatibility.</param>
public SetStatusRedirectUrlManagementController(
#pragma warning disable IDE0060 // Remove unused parameter
IBackOfficeSecurityAccessor backOfficeSecurityAccessor,
IConfigManipulator configManipulator)
#pragma warning restore IDE0060 // Remove unused parameter
{
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
_configManipulator = configManipulator;
}
// TODO: Consider if we should even allow this, or only allow using the appsettings
// We generally don't want to edit the appsettings from our code.
// But maybe there is a valid use case for doing it on the fly.
/// <summary>
/// Sets the redirect URL tracking status.
/// Deprecated. Returns an OK response without modifying any configuration. To toggle redirect URL tracking,
/// set the <c>Umbraco:CMS:WebRouting:DisableRedirectUrlTracking</c> configuration key instead.
/// </summary>
/// <param name="cancellationToken">The cancellation token for the HTTP request.</param>
/// <param name="status">The redirect status to set.</param>
/// <returns>An OK result if successful.</returns>
/// <param name="status">The redirect status (ignored).</param>
/// <returns>An OK result.</returns>
[HttpPost("status")]
[EndpointSummary("Sets the redirect URL tracking status.")]
[EndpointDescription("Updates the redirect URL tracking configuration according to the provided status.")]
[EndpointSummary("Deprecated. No longer changes the redirect URL tracking status.")]
[EndpointDescription("This endpoint is deprecated and no longer modifies the configuration. To toggle redirect URL tracking, set the Umbraco:CMS:WebRouting:DisableRedirectUrlTracking configuration key instead.")]
[MapToApiVersion("1.0")]
public async Task<IActionResult> SetStatus(CancellationToken cancellationToken, [FromQuery] RedirectStatus status)
{
// TODO: uncomment this when auth is implemented.
// var userIsAdmin = _backOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.IsAdmin();
// if (userIsAdmin is null or false)
// {
// return Unauthorized();
// }
var enable = status switch
{
RedirectStatus.Enabled => true,
RedirectStatus.Disabled => false,
_ => throw new ArgumentOutOfRangeException(nameof(status), status, "Unknown redirect status")
};
// For now I'm not gonna change this to limit breaking, but it's weird to have a "disabled" switch,
// since you're essentially negating the boolean from the get go,
// it's much easier to reason with enabled = false == disabled.
await _configManipulator.SaveDisableRedirectUrlTrackingAsync(!enable);
// Taken from the existing implementation in RedirectUrlManagementController
// TODO this is ridiculous, but we need to ensure the configuration is reloaded, before this request is ended.
// otherwise we can read the old value in GetEnableState.
// The value is equal to JsonConfigurationSource.ReloadDelay
Thread.Sleep(250);
return Ok();
}
[Obsolete("This endpoint is deprecated and no longer modifies the configuration. Set the Umbraco:CMS:WebRouting:DisableRedirectUrlTracking configuration key instead. Scheduled for removal in Umbraco 19.")]
public Task<IActionResult> SetStatus(CancellationToken cancellationToken, [FromQuery] RedirectStatus status)
=> Task.FromResult<IActionResult>(Ok());
}
@@ -32,6 +32,14 @@ public class SearchTemplateItemController : TemplateItemControllerBase
_mapper = mapper;
}
/// <summary>
/// Searches for template items matching the specified query, with support for pagination.
/// </summary>
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
/// <param name="query">The search query used to filter template items.</param>
/// <param name="skip">The number of items to skip before starting to collect the result set (used for pagination).</param>
/// <param name="take">The maximum number of items to return in the result set (used for pagination).</param>
/// <returns>A task representing the asynchronous operation. The task result contains an <see cref="IActionResult"/> with a <see cref="PagedModel{TemplateItemResponseModel}"/> containing the search results.</returns>
[HttpGet("search")]
[MapToApiVersion("1.0")]
[ProducesResponseType(typeof(PagedModel<TemplateItemResponseModel>), StatusCodes.Status200OK)]
@@ -45,11 +53,14 @@ public class SearchTemplateItemController : TemplateItemControllerBase
return Ok(new PagedModel<TemplateItemResponseModel> { Total = searchResult.Total });
}
IEnumerable<ITemplate> templates = await _templateService.GetAllAsync(searchResult.Items.Select(item => item.Key).ToArray());
Guid[] keys = searchResult.Items.Select(x => x.Key).ToArray();
IEnumerable<ITemplate> templates = await _templateService.GetAllAsync(keys);
IEnumerable<ITemplate> orderedTemplates = OrderByRequestedIds(templates, keys);
var result = new PagedModel<TemplateItemResponseModel>
{
Items = _mapper.MapEnumerable<ITemplate, TemplateItemResponseModel>(templates),
Total = searchResult.Total
Items = _mapper.MapEnumerable<ITemplate, TemplateItemResponseModel>(orderedTemplates),
Total = searchResult.Total,
};
return Ok(result);
@@ -5,6 +5,7 @@ using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Hosting;
using Umbraco.Cms.Core.IO;
using Umbraco.Cms.Web.Common.Hosting;
using Umbraco.Cms.Web.Common.Middleware;
namespace Umbraco.Extensions;
@@ -68,6 +69,10 @@ public static partial class UmbracoBuilderExtensions
builder.Services.AddSingleton<IBackOfficeEnabledMarker, BackOfficeEnabledMarker>();
builder.Services.AddUnique<IBackOfficePathGenerator, UmbracoBackOfficePathGenerator>();
// Registered here rather than in AddWebComponents because the middleware depends on
// IBackOfficePathGenerator (registered just above). DI scope validation would otherwise
// fail in Delivery-only/Website-only bootstraps that never call AddBackOffice().
builder.Services.AddSingleton<UmbracoBackOfficeCacheHeadersMiddleware>();
builder.Services.AddUnique<IPhysicalFileSystem>(factory =>
{
var path = "~/";
@@ -1,5 +1,8 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Umbraco.Cms.Api.Management.ViewModels.DataType;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.PropertyEditors;
using Umbraco.Cms.Core.Serialization;
@@ -16,6 +19,7 @@ public class DataTypePresentationFactory : IDataTypePresentationFactory
private readonly IDataValueEditorFactory _dataValueEditorFactory;
private readonly IConfigurationEditorJsonSerializer _configurationEditorJsonSerializer;
private readonly TimeProvider _timeProvider;
private readonly ILogger<DataTypePresentationFactory> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="DataTypePresentationFactory"/> class, which is responsible for creating data type presentation models.
@@ -25,18 +29,46 @@ public class DataTypePresentationFactory : IDataTypePresentationFactory
/// <param name="dataValueEditorFactory">Factory for creating data value editors.</param>
/// <param name="configurationEditorJsonSerializer">Serializer for configuration editor JSON data.</param>
/// <param name="timeProvider">Provides the current time for time-dependent operations.</param>
/// <param name="logger">The logger.</param>
public DataTypePresentationFactory(
IDataTypeContainerService dataTypeContainerService,
PropertyEditorCollection propertyEditorCollection,
IDataValueEditorFactory dataValueEditorFactory,
IConfigurationEditorJsonSerializer configurationEditorJsonSerializer,
TimeProvider timeProvider)
TimeProvider timeProvider,
ILogger<DataTypePresentationFactory> logger)
{
_dataTypeContainerService = dataTypeContainerService;
_propertyEditorCollection = propertyEditorCollection;
_dataValueEditorFactory = dataValueEditorFactory;
_configurationEditorJsonSerializer = configurationEditorJsonSerializer;
_timeProvider = timeProvider;
_logger = logger;
}
/// <summary>
/// Initializes a new instance of the <see cref="DataTypePresentationFactory"/> class, which is responsible for creating data type presentation models.
/// </summary>
/// <param name="dataTypeContainerService">Service used to manage data type containers.</param>
/// <param name="propertyEditorCollection">A collection containing all available property editors.</param>
/// <param name="dataValueEditorFactory">Factory for creating data value editors.</param>
/// <param name="configurationEditorJsonSerializer">Serializer for configuration editor JSON data.</param>
/// <param name="timeProvider">Provides the current time for time-dependent operations.</param>
[Obsolete("Please use the constructor that takes all parameters. Scheduled for removal in Umbraco 19.")]
public DataTypePresentationFactory(
IDataTypeContainerService dataTypeContainerService,
PropertyEditorCollection propertyEditorCollection,
IDataValueEditorFactory dataValueEditorFactory,
IConfigurationEditorJsonSerializer configurationEditorJsonSerializer,
TimeProvider timeProvider)
: this(
dataTypeContainerService,
propertyEditorCollection,
dataValueEditorFactory,
configurationEditorJsonSerializer,
timeProvider,
StaticServiceProvider.Instance.GetRequiredService<ILogger<DataTypePresentationFactory>>())
{
}
/// <inheritdoc />
@@ -72,7 +104,6 @@ public class DataTypePresentationFactory : IDataTypePresentationFactory
dataType.Key = requestModel.Id.Value;
}
return Attempt.SucceedWithStatus<IDataType, DataTypeOperationStatus>(DataTypeOperationStatus.Success, dataType);
}
@@ -82,7 +113,7 @@ public class DataTypePresentationFactory : IDataTypePresentationFactory
{
try
{
var parent = await _dataTypeContainerService.GetAsync(requestModel.Parent.Id);
EntityContainer? parent = await _dataTypeContainerService.GetAsync(requestModel.Parent.Id);
return parent is null
? Attempt.FailWithStatus(DataTypeOperationStatus.ParentNotFound, 0)
@@ -97,6 +128,7 @@ public class DataTypePresentationFactory : IDataTypePresentationFactory
return Attempt.SucceedWithStatus(DataTypeOperationStatus.Success, Constants.System.Root);
}
/// <inheritdoc/>
public Task<Attempt<IDataType, DataTypeOperationStatus>> CreateAsync(UpdateDataTypeRequestModel requestModel, IDataType current)
{
if (!_propertyEditorCollection.TryGet(requestModel.EditorAlias, out IDataEditor? editor))
@@ -104,7 +136,7 @@ public class DataTypePresentationFactory : IDataTypePresentationFactory
return Task.FromResult(Attempt.FailWithStatus<IDataType, DataTypeOperationStatus>(DataTypeOperationStatus.PropertyEditorNotFound, new DataType(new VoidEditor(_dataValueEditorFactory), _configurationEditorJsonSerializer) ));
}
IDataType dataType = (IDataType)current.DeepClone();
var dataType = (IDataType)current.DeepClone();
IDictionary<string, object> configurationData = MapConfigurationData(requestModel, editor);
dataType.Name = requestModel.Name;
@@ -119,12 +151,26 @@ public class DataTypePresentationFactory : IDataTypePresentationFactory
private ValueStorageType GetEditorValueStorageType(IDataEditor editor, IDictionary<string, object> configurationData)
{
var configurationObject = editor.GetConfigurationEditor()
.ToConfigurationObject(configurationData, _configurationEditorJsonSerializer);
if (configurationObject is IConfigureValueType configureValueType)
// Only editors whose configuration object implements IConfigureValueType derive their storage
// type from the configuration. Building the typed configuration object can throw for editors
// whose stored configuration doesn't cleanly deserialize into their configuration type; that
// must not fail the save, so fall back to the value editor's value type in that case.
try
{
return ValueTypes.ToStorageType(configureValueType.ValueType);
if (editor.GetConfigurationEditor().ToConfigurationObject(configurationData, _configurationEditorJsonSerializer)
is IConfigureValueType configureValueType)
{
return ValueTypes.ToStorageType(configureValueType.ValueType);
}
}
catch (Exception)
{
// Configuration editors are third-party and can throw anything when the stored configuration
// doesn't deserialize into their configuration type. Fall back to the value editor's value type
// rather than failing the save, but log so the misconfiguration remains observable.
_logger.LogError(
"Could not build the configuration object for editor {EditorAlias} to determine its value storage type; falling back to the value editor's value type.",
editor.Alias);
}
var valueType = editor.GetValueEditor().ValueType;
+3 -2
View File
@@ -27860,8 +27860,8 @@
"tags": [
"Redirect Management"
],
"summary": "Sets the redirect URL tracking status.",
"description": "Updates the redirect URL tracking configuration according to the provided status.",
"summary": "Deprecated. No longer changes the redirect URL tracking status.",
"description": "This endpoint is deprecated and no longer modifies the configuration. To toggle redirect URL tracking, set the Umbraco:CMS:WebRouting:DisableRedirectUrlTracking configuration key instead.",
"operationId": "PostRedirectManagementStatus",
"parameters": [
{
@@ -27907,6 +27907,7 @@
}
}
},
"deprecated": true,
"security": [
{
"Backoffice-User": [ ]
@@ -15,8 +15,9 @@ SQLite-specific EF Core provider for Umbraco CMS. Contains SQLite migrations and
This is a thin provider project that implements SQLite-specific functionality for the EF Core persistence layer:
1. **Migration Provider** - Executes SQLite-specific migrations
2. **Migration Provider Setup** - Configures DbContext to use SQLite
2. **Migration Provider Setup** - Configures DbContext to use SQLite (incl. transient-error retry)
3. **Migrations** - SQLite-specific migration files for OpenIddict tables
4. **Retrying Execution Strategy** - Retries transient SQLite lock errors on EF Core operations
### Folder Structure
@@ -30,7 +31,8 @@ Umbraco.Cms.Persistence.EFCore.Sqlite/
│ └── UmbracoDbContextModelSnapshot.cs # Current model state
├── EFCoreSqliteComposer.cs # DI registration
├── SqliteMigrationProvider.cs # IMigrationProvider impl
── SqliteMigrationProviderSetup.cs # IMigrationProviderSetup impl
── SqliteMigrationProviderSetup.cs # IMigrationProviderSetup impl
└── SqliteRetryingExecutionStrategy.cs # IExecutionStrategy for transient lock errors
```
### Relationship with Parent Project
@@ -65,7 +67,19 @@ Registers `IMigrationProvider` and `IMigrationProviderSetup` for SQLite.
### SqliteMigrationProviderSetup (line 11-14)
Configures `DbContextOptionsBuilder` with `UseSqlite` and migrations assembly.
Configures `DbContextOptionsBuilder` with `UseSqlite`, the migrations assembly, and the
`SqliteRetryingExecutionStrategy` (see below). Invoked from
`UmbracoDbContext.ConfigureOptions` for every `UmbracoDbContext` instance, so all EF Core
access to the Umbraco database (including OpenIddict's token store) inherits the retry.
### SqliteRetryingExecutionStrategy
Custom `Microsoft.EntityFrameworkCore.Storage.ExecutionStrategy` that retries on transient
SQLite errors (`SQLITE_BUSY`, `SQLITE_LOCKED`) using `SqliteExceptionExtensions.IsBusyOrLocked`
from the parent project. Defaults inherit `ExecutionStrategy.DefaultMaxRetryCount` (6) and
`ExecutionStrategy.DefaultMaxDelay` (30s), giving a ~56-second retry budget — see the class's
XML doc for the rationale and the unattended-upgrade escape hatch for very long migrations.
Added to resolve issue #22939 (OpenIddict token reads failing during long migrations).
---
@@ -122,7 +136,8 @@ All tables prefixed with `umbraco`:
| File | Purpose |
|------|---------|
| `SqliteMigrationProvider.cs` | Migration execution |
| `SqliteMigrationProviderSetup.cs` | DbContext configuration |
| `SqliteMigrationProviderSetup.cs` | DbContext configuration (UseSqlite + retry strategy) |
| `SqliteRetryingExecutionStrategy.cs` | Retry on transient SQLite BUSY/LOCKED errors |
| `EFCoreSqliteComposer.cs` | DI registration |
| `Migrations/*.cs` | Migration files |
@@ -1,5 +1,4 @@
using Microsoft.EntityFrameworkCore;
using Umbraco.Cms.Core;
using Umbraco.Cms.Persistence.EFCore.Migrations;
namespace Umbraco.Cms.Persistence.EFCore.Sqlite;
@@ -15,6 +14,15 @@ public class SqliteMigrationProviderSetup : IMigrationProviderSetup
/// <inheritdoc />
public void Setup(DbContextOptionsBuilder builder, string? connectionString)
{
builder.UseSqlite(connectionString, x => x.MigrationsAssembly(GetType().Assembly.FullName));
builder.UseSqlite(connectionString, x =>
{
x.MigrationsAssembly(GetType().Assembly.FullName);
// Retry transient SQLite errors (BUSY / LOCKED). See SqliteRetryingExecutionStrategy
// for the rationale — long-running migrations or schema-modifying operations can
// briefly lock the database in a way that surfaces as a hard error to concurrent
// EF Core readers (notably OpenIddict token validation). See issue #22939.
x.ExecutionStrategy(deps => new SqliteRetryingExecutionStrategy(deps));
});
}
}
@@ -0,0 +1,71 @@
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore.Storage;
namespace Umbraco.Cms.Persistence.EFCore.Sqlite;
/// <summary>
/// EF Core execution strategy that retries on transient SQLite errors (BUSY / LOCKED).
/// </summary>
/// <remarks>
/// <para>
/// SQLite serialises writers at the database level, and schema-modifying statements briefly
/// block readers — even in WAL mode. Without retries, concurrent EF Core reads (for example
/// OpenIddict's token validation against <c>umbracoOpenIddictTokens</c>) surface those
/// transient locks as <see cref="SqliteException"/> and fail the caller's request.
/// </para>
/// <para>
/// Microsoft does not ship a built-in execution strategy for SQLite (only the SQL Server
/// equivalent), so we provide this one. It piggy-backs on <see cref="ExecutionStrategy"/>'s
/// default exponential backoff and re-uses its inherited
/// <see cref="ExecutionStrategy.DefaultMaxRetryCount"/> (6) and
/// <see cref="ExecutionStrategy.DefaultMaxDelay"/> (30 seconds), which produce a delay
/// schedule of roughly 0s, 1s, 3s, 7s, 15s, 30s — a ~56-second retry window.
/// </para>
/// <para>
/// On top of those EF Core delays, <c>SQLITE_BUSY</c> (error 5) is also retried internally
/// by Microsoft.Data.Sqlite for up to the connection's <c>Default Timeout</c> (30 seconds
/// by default) per attempt. <c>SQLITE_LOCKED</c> (error 6) is not — it returns immediately,
/// so EF Core's retry budget is the only buffer.
/// </para>
/// </remarks>
public class SqliteRetryingExecutionStrategy : ExecutionStrategy
{
/// <summary>
/// Initializes a new instance of the <see cref="SqliteRetryingExecutionStrategy"/> class
/// with default retry settings inherited from <see cref="ExecutionStrategy"/>.
/// </summary>
/// <param name="dependencies">Parameter object containing service dependencies.</param>
public SqliteRetryingExecutionStrategy(ExecutionStrategyDependencies dependencies)
: this(dependencies, DefaultMaxRetryCount, DefaultMaxDelay)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="SqliteRetryingExecutionStrategy"/> class.
/// </summary>
/// <param name="dependencies">Parameter object containing service dependencies.</param>
/// <param name="maxRetryCount">The maximum number of retry attempts.</param>
/// <param name="maxRetryDelay">The maximum delay between retries.</param>
public SqliteRetryingExecutionStrategy(
ExecutionStrategyDependencies dependencies,
int maxRetryCount,
TimeSpan maxRetryDelay)
: base(dependencies, maxRetryCount, maxRetryDelay)
{
}
/// <inheritdoc />
protected override bool ShouldRetryOn(Exception exception)
{
// EF Core wraps provider exceptions, so walk the inner-exception chain.
for (Exception? current = exception; current is not null; current = current.InnerException)
{
if (current is SqliteException sqlite && sqlite.IsBusyOrLocked())
{
return true;
}
}
return false;
}
}
@@ -184,17 +184,11 @@ internal sealed class SqliteEFCoreDistributedLockingMechanism<T> : IDistributedL
throw new ArgumentException($"LockObject with id={LockId} does not exist.");
}
}
catch (SqliteException ex) when (IsBusyOrLocked(ex))
catch (SqliteException ex) when (ex.IsBusyOrLocked())
{
throw new DistributedWriteLockTimeoutException(LockId);
}
});
}
private static bool IsBusyOrLocked(SqliteException ex) =>
ex.SqliteErrorCode
is raw.SQLITE_BUSY
or raw.SQLITE_LOCKED
or raw.SQLITE_LOCKED_SHAREDCACHE;
}
}
@@ -0,0 +1,26 @@
using Microsoft.Data.Sqlite;
using SQLitePCL;
namespace Umbraco.Cms.Persistence.EFCore;
/// <summary>
/// SQLite-specific exception helpers for code running on the EF Core persistence stack.
/// </summary>
/// <remarks>
/// A parallel helper exists at <c>Umbraco.Cms.Persistence.Sqlite.Services.SqliteExceptionExtensions</c>
/// for the NPoco stack. Both stacks are independent (neither references the other) so the small
/// duplication is intentional — keeps the layering clean.
/// </remarks>
public static class SqliteExceptionExtensions
{
/// <summary>
/// Determines if the SQLite exception is a BUSY or LOCKED error.
/// </summary>
/// <param name="ex">The SQLite exception to check.</param>
/// <returns><c>true</c> if the error is BUSY, LOCKED, or LOCKED_SHAREDCACHE; otherwise <c>false</c>.</returns>
public static bool IsBusyOrLocked(this SqliteException ex) =>
ex.SqliteErrorCode
is raw.SQLITE_BUSY
or raw.SQLITE_LOCKED
or raw.SQLITE_LOCKED_SHAREDCACHE;
}
@@ -21,7 +21,7 @@
var backOfficeAssetsPath = BackOfficePathGenerator.BackOfficeAssetsPath;
var loginLogoImageAlternative = Url.RouteUrl(BackOfficeGraphicsController.LoginLogoAlternativeRouteName, new {Version= "1"});
}<!doctype html>
<html lang="@GlobalSettings.Value.DefaultUILanguage">
<html lang="en">
<head>
<meta charset="UTF-8" />
@@ -61,7 +61,7 @@
<p>Here are the <a href="https://www.enable-javascript.com/" target="_blank" rel="noopener" style="text-decoration: underline;">instructions how to enable JavaScript in your web browser</a>.</p>
</div>
</noscript>
<umb-app @(SecuritySettings.Value.KeepUserLoggedIn ? "keep-user-logged-in" : "")></umb-app>
<umb-app lang="@GlobalSettings.Value.DefaultUILanguage" @(SecuritySettings.Value.KeepUserLoggedIn ? "keep-user-logged-in" : "")></umb-app>
@if (isDebug)
{
@@ -35,7 +35,7 @@
}
<!DOCTYPE html>
<html lang="@GlobalSettings.Value.DefaultUILanguage">
<html lang="en">
<head>
<meta charset="UTF-8"/>
<base href="@backOfficePath.EnsureEndsWith('/')" />
@@ -83,6 +83,7 @@
</noscript>
<umb-auth
lang="@GlobalSettings.Value.DefaultUILanguage"
return-url="@backOfficePath"
logo-image="@loginLogoImage"
logo-image-alternative="@loginLogoImageAlternative"
@@ -16,21 +16,16 @@
</ItemGroup>
<!--
The Razor editor in VS2026 and the C# extension for VS Code uses the Razor source generator
The Razor editor in modern Visual Studio and the C# extension for VS Code use the Razor source generator
for IDE functionality. We need to add some things to make sure it works correctly, but we
only do them for design time builds, so that we don't impact regular builds or CI.
We also have an escape hatch in case it does cause issues, users can set the appropriate property
We also have an escape hatch in case it does cause issues, users can set EnableCohostEditorCompatibility=false
in their project file to disable this.
CompilerVisibleProperty is surfaced to generators via AnalyzerConfigOptionsProvider, not as a source-generator input file,
so it doesn't enter the hintName-collision codepath that AdditionalFiles does. Keeping it at evaluation time is safe.
-->
<ItemGroup Condition="'$(DesignTimeBuild)' == 'true' and '$(EnableCohostEditorCompatibility)' != 'false'">
<!--
We have to make sure the source generator can see the .cshtml files, so make them AdditionalFiles.
-->
<AdditionalFiles Include="**\*.cshtml" />
<!--
Make sure the source generator knows where the project is, so it can compute target paths.
-->
<CompilerVisibleProperty Include="MSBuildProjectDirectory" />
</ItemGroup>
</Project>
@@ -49,4 +49,39 @@
<ContentWithTargetPath Include="@(_UmbracoFolderFiles)" Exclude="@(ContentWithTargetPath)" TargetPath="%(Identity)" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
</ItemGroup>
</Target>
<!--
The Razor source generator needs .cshtml files in @(AdditionalFiles). The Razor SDK adds them
via @(RazorGenerate), but only inside a target that runs during the build — so during cohost
design-time builds they may not be present yet, which is what PR #21861 worked around.
Doing the include at evaluation time (as PR #21861 did) causes duplicates with the SDK during
dotnet watch / hot reload design-time builds: the SDK adds the same .cshtml under a different
item Identity (slash form / relative vs absolute) and the generator then sees two inputs that
derive the same hintName, which crashes it with CS8785 (see issue #22773).
Run as a target before CoreCompile (hot-reload path) and CompileDesignTime (IDE design-time path)
so the SDK's contribution is visible in both cases. Then add only the .cshtml files that are not already
present. Both sides are normalized to %(FullPath) so items with different Identity forms still compare equal.
Set EnableCohostEditorCompatibility=false in a project to opt out entirely.
-->
<Target Name="_UmbracoEnsureRazorAdditionalFilesForCohostEditor"
BeforeTargets="CoreCompile;CompileDesignTime"
Condition="'$(DesignTimeBuild)' == 'true' and '$(EnableCohostEditorCompatibility)' != 'false'">
<ItemGroup>
<_UmbracoCshtmlCandidate Include="**\*.cshtml" />
<_UmbracoCshtmlCandidateFull Include="@(_UmbracoCshtmlCandidate->'%(FullPath)')" />
<_UmbracoExistingAdditionalCshtmlFull
Include="@(AdditionalFiles->'%(FullPath)')"
Condition="'%(Extension)' == '.cshtml'" />
<_UmbracoCshtmlMissingFromAdditional
Include="@(_UmbracoCshtmlCandidateFull)"
Exclude="@(_UmbracoExistingAdditionalCshtmlFull)" />
<AdditionalFiles Include="@(_UmbracoCshtmlMissingFromAdditional)" />
</ItemGroup>
</Target>
</Project>
+2
View File
@@ -305,6 +305,8 @@ public class MyEntityCacheRefresher : CacheRefresherBase<MyEntityCacheRefresher>
- `Attempt.Succeed(value)` / `Attempt.Fail<T>()`
- `Attempt<Content, ContentEditingOperationStatus>` - typed result with status
> Writing or reviewing a query with a `WHERE IN` on a runtime-sized collection? See "Avoiding the SQL Server 2100-parameter limit" in `/src/Umbraco.Infrastructure/CLAUDE.md` — that's where the full helper list (`Constants.Sql.MaxParameterCount`, `InGroupsOf`, NPoco's `FetchByGroups`) and the decision rules live.
### Configuration
Configuration models in `/Configuration/Models`:
+10 -1
View File
@@ -368,8 +368,17 @@ public class ObjectCacheAppCache : IAppPolicyCache, IDisposable
}
// Ensure key is removed from set when evicted from cache
return options.RegisterPostEvictionCallback((key, _, _, _) =>
return options.RegisterPostEvictionCallback((key, _, reason, _) =>
{
// Removed and Replaced evictions don't need pruning here: the Remove/Clear call sites already
// prune the tracking set synchronously under the write lock, and a Replaced key still has a
// live entry (the synchronous Set re-added it). Pruning here instead runs on a background
// thread and races with that re-add, dropping a key whose entry is still cached. (#23064)
if (reason is EvictionReason.Removed or EvictionReason.Replaced)
{
return;
}
try
{
if (_locker.TryEnterWriteLock(_writeLockTimeout) is false)
@@ -345,15 +345,15 @@ public sealed class ContentCacheRefresher : PayloadCacheRefresherBase<ContentCac
if (payload.ChangeTypes.HasType(TreeChangeTypes.RefreshNode))
{
Guid key = payload.Key ?? _idKeyMap.GetKeyForId(payload.Id, UmbracoObjectTypes.Document).Result;
_documentUrlService.CreateOrUpdateUrlSegmentsAsync(key).GetAwaiter().GetResult();
_documentUrlAliasService.CreateOrUpdateAliasesAsync(key).GetAwaiter().GetResult();
_documentUrlService.UpdateUrlSegmentCacheAsync(key).GetAwaiter().GetResult();
_documentUrlAliasService.UpdateAliasCacheAsync(key).GetAwaiter().GetResult();
}
if (payload.ChangeTypes.HasType(TreeChangeTypes.RefreshBranch))
{
Guid key = payload.Key ?? _idKeyMap.GetKeyForId(payload.Id, UmbracoObjectTypes.Document).Result;
_documentUrlService.CreateOrUpdateUrlSegmentsWithDescendantsAsync(key).GetAwaiter().GetResult();
_documentUrlAliasService.CreateOrUpdateAliasesWithDescendantsAsync(key).GetAwaiter().GetResult();
_documentUrlService.UpdateUrlSegmentCacheWithDescendantsAsync(key).GetAwaiter().GetResult();
_documentUrlAliasService.UpdateAliasCacheWithDescendantsAsync(key).GetAwaiter().GetResult();
}
}
@@ -36,6 +36,7 @@ public interface IConfigManipulator
/// </summary>
/// <param name="disable">The value to save.</param>
/// <returns></returns>
[Obsolete("This method is no longer used by Umbraco. Set the Umbraco:CMS:WebRouting:DisableRedirectUrlTracking configuration key instead. Scheduled for removal in Umbraco 19.")]
Task SaveDisableRedirectUrlTrackingAsync(bool disable);
/// <summary>
@@ -16,6 +16,7 @@ public class UnattendedSettings
private const bool StaticInstallUnattended = false;
private const bool StaticUpgradeUnattended = false;
private const TelemetryLevel StaticTelemetryLevel = TelemetryLevel.Detailed;
private const string StaticMigrationClaimTimeout = "02:00:00";
/// <summary>
/// Gets or sets a value indicating whether unattended installs are enabled.
@@ -45,6 +46,17 @@ public class UnattendedSettings
/// </remarks>
public bool PackageMigrationsUnattended { get; set; } = true;
/// <summary>
/// Gets or sets the maximum time a migration leadership claim is considered valid before
/// another server may take over. Protects against a leader crashing mid-migration.
/// </summary>
/// <remarks>
/// Only relevant in load-balanced deployments with <see cref="UpgradeUnattended"/> enabled.
/// Default is 2 hours, which should exceed the longest reasonable migration run time.
/// </remarks>
[DefaultValue(StaticMigrationClaimTimeout)]
public TimeSpan MigrationClaimTimeout { get; set; } = TimeSpan.Parse(StaticMigrationClaimTimeout);
/// <summary>
/// Gets or sets a value to use for creating a user with a name for Unattended Installs
/// </summary>
@@ -36,6 +36,14 @@ public static partial class Constants
/// The key used to store the Umbraco pre-migrations upgrade plan state.
/// </summary>
public const string UmbracoUpgradePlanPremigrationsKey = KeyValuePrefix + UmbracoUpgradePlanPremigrationsName;
/// <summary>
/// The key used to coordinate migration leadership across servers in a load-balanced
/// environment. The value is either empty (no active leader) or
/// <c>"{machineIdentifier}|{claimedAtUtc:O}"</c> when a server holds the claim,
/// where <c>machineIdentifier</c> is the value returned by <see cref="Umbraco.Cms.Core.Factories.IMachineInfoFactory.GetMachineIdentifier"/>.
/// </summary>
public const string UpgradeLockKey = "Umbraco.Core.Upgrader.Lock";
}
/// <summary>
@@ -0,0 +1,15 @@
namespace Umbraco.Cms.Core.DependencyInjection;
/// <summary>
/// Marker interface indicating that Umbraco itself has enabled ASP.NET Core output caching
/// (via Website template caching or Delivery API caching configuration).
/// Used to gate Umbraco's automatic registration of the output cache middleware so that
/// applications calling <c>services.AddOutputCache(...)</c> for their own purposes do not
/// inadvertently trigger a duplicate <c>UseOutputCache()</c> registration.
/// </summary>
public interface IUmbracoManagedOutputCacheMarker { }
/// <summary>
/// Marker class implementation for <see cref="IUmbracoManagedOutputCacheMarker"/>.
/// </summary>
public sealed class UmbracoManagedOutputCacheMarker : IUmbracoManagedOutputCacheMarker { }
@@ -458,6 +458,7 @@ namespace Umbraco.Cms.Core.DependencyInjection
Services.AddUnique<IDocumentUrlAliasService, DocumentUrlAliasService>();
Services.AddNotificationAsyncHandler<UmbracoApplicationStartingNotification, DocumentUrlAliasServiceInitializerNotificationHandler>();
Services.AddNotificationAsyncHandler<ContentTypeChangedNotification, DocumentUrlServiceContentTypeChangedNotificationHandler>();
Services.AddNotificationAsyncHandler<ContentTreeChangeNotification, DocumentUrlServiceContentTreeChangeNotificationHandler>();
}
}
}
@@ -405,7 +405,8 @@
0: Comma delimitted list of failed folder paths
-->
<key alias="umbracoApplicationUrlCheckResultTrue"><![CDATA[AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' je postavljen na <strong>%0%</strong>.]]></key>
<key alias="umbracoApplicationUrlCheckResultFalse">AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' nije postavljen.</key>
<key alias="umbracoApplicationUrlCheckResultFalse"><![CDATA[AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' nije postavljen, pa će se URL aplikacije automatski otkriti iz dolaznih zahtjeva. Preporučuje se da ga postavite izričito.]]></key>
<key alias="umbracoApplicationUrlCheckResultError"><![CDATA[AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' nije postavljen, a automatsko otkrivanje URL-a aplikacije je onemogućeno ('Umbraco:CMS:WebRouting:ApplicationUrlDetection' je 'None'). Značajke koje zahtijevaju apsolutni URL, poput e-pošte za poništavanje lozinke i pozivnica, neće raditi. Postavite URL aplikacije izričito ili omogućite automatsko otkrivanje.]]></key>
<!-- The following key get these tokens passed in:
0: Comma delimitted list of headers found
-->
@@ -454,7 +454,8 @@
<key alias="httpsCheckConfigurationRectifyNotPossible">Mae gosodiad ap 'Umbraco:CMS:Global:UseHttps' wedi'i osod i 'false' yn eich ffeil appSettings.json. Unwaith y byddwch yn cyrchu'r wefan hon gan ddefnyddio'r cynllun HTTPS, dylid gosod hwnnw i 'true'.</key>
<key alias="httpsCheckConfigurationCheckResult">Mae'r gosodiad ap 'Umbraco:CMS:Global:UseHttps' wedi'i osod i '%0%' yn eich ffeil appSettings.json, mae eich cwcis %1% wedi'u marcio'n ddiogel.</key>
<key alias="umbracoApplicationUrlCheckResultTrue">Mae gosodiad yr ap 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' wedi'i osod i <strong>%0%</strong>.</key>
<key alias="umbracoApplicationUrlCheckResultFalse">Nid yw gosodiad ap 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' wedi'i osod.</key>
<key alias="umbracoApplicationUrlCheckResultFalse"><![CDATA[Nid yw gosodiad ap 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' wedi'i osod, felly bydd URL y rhaglen yn cael ei ganfod yn awtomatig o geisiadau sy'n dod i mewn. Argymhellir ei osod yn benodol.]]></key>
<key alias="umbracoApplicationUrlCheckResultError"><![CDATA[Nid yw gosodiad ap 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' wedi'i osod ac mae canfod URL y rhaglen yn awtomatig wedi'i analluogi (mae 'Umbraco:CMS:WebRouting:ApplicationUrlDetection' yn 'None'). Ni fydd nodweddion sydd angen URL absoliwt, fel e-byst ailosod cyfrinair a gwahoddiadau, yn gweithio. Gosodwch URL y rhaglen yn benodol, neu galluogwch ganfod yn awtomatig.]]></key>
<key alias="smtpMailSettingsNotFound">Nid oedd modd dod o hyd i'r ffurfweddiad 'Umbraco:CMS:Global:Smtp'.</key>
<key alias="smtpMailSettingsHostNotConfigured">Nid oedd modd dod o hyd i'r ffurfweddiad 'Umbraco:CMS:Global:Smtp:Host'.</key>
<key alias="smtpMailSettingsConnectionFail">Methwyd cyrraedd y gweinydd SMTP a ffurfweddwyd gyda gwesteiwr '%0%' a phorth '%1%'. Gwiriwch i sicrhau bod y gosodiadau SMTP yn y ffurfweddiad 'Umbraco:CMS:Global:Smtp' yn gywir.</key>
@@ -463,7 +463,8 @@
0: Comma delimitted list of failed folder paths
-->
<key alias="umbracoApplicationUrlCheckResultTrue"><![CDATA[The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is set to <strong>%0%</strong>.]]></key>
<key alias="umbracoApplicationUrlCheckResultFalse">The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is not set.</key>
<key alias="umbracoApplicationUrlCheckResultFalse"><![CDATA[The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is not set, so the application URL will be auto-detected from incoming requests. Setting it explicitly is recommended.]]></key>
<key alias="umbracoApplicationUrlCheckResultError"><![CDATA[The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is not set and application URL auto-detection is disabled ('Umbraco:CMS:WebRouting:ApplicationUrlDetection' is 'None'). Features that require an absolute URL, such as password reset and invitation emails, will not work. Set the application URL explicitly, or enable auto-detection.]]></key>
<!-- The following key get these tokens passed in:
0: Comma delimitted list of headers found
-->
@@ -452,7 +452,8 @@
0: Comma delimitted list of failed folder paths
-->
<key alias="umbracoApplicationUrlCheckResultTrue"><![CDATA[The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is set to <strong>%0%</strong>.]]></key>
<key alias="umbracoApplicationUrlCheckResultFalse">The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is not set.</key>
<key alias="umbracoApplicationUrlCheckResultFalse"><![CDATA[The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is not set, so the application URL will be auto-detected from incoming requests. Setting it explicitly is recommended.]]></key>
<key alias="umbracoApplicationUrlCheckResultError"><![CDATA[The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is not set and application URL auto-detection is disabled ('Umbraco:CMS:WebRouting:ApplicationUrlDetection' is 'None'). Features that require an absolute URL, such as password reset and invitation emails, will not work. Set the application URL explicitly, or enable auto-detection.]]></key>
<key alias="clickJackingCheckHeaderFound">
<![CDATA[The header or meta-tag <strong>X-Frame-Options</strong> used to control whether a site can be IFRAMEd by another was found.]]></key>
<key alias="clickJackingCheckHeaderNotFound">
@@ -403,7 +403,8 @@
0: Comma delimitted list of failed folder paths
-->
<key alias="umbracoApplicationUrlCheckResultTrue"><![CDATA[AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' je postavljen na <strong>%0%</strong>.]]></key>
<key alias="umbracoApplicationUrlCheckResultFalse">AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' nije postavljen.</key>
<key alias="umbracoApplicationUrlCheckResultFalse"><![CDATA[AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' nije postavljen, pa će se URL aplikacije automatski otkriti iz dolaznih zahtjeva. Preporučuje se da ga postavite izričito.]]></key>
<key alias="umbracoApplicationUrlCheckResultError"><![CDATA[AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' nije postavljen, a automatsko otkrivanje URL-a aplikacije je onemogućeno ('Umbraco:CMS:WebRouting:ApplicationUrlDetection' je 'None'). Značajke koje zahtijevaju apsolutni URL, poput e-pošte za poništavanje lozinke i pozivnica, neće raditi. Postavite URL aplikacije izričito ili omogućite automatsko otkrivanje.]]></key>
<!-- The following key get these tokens passed in:
0: Comma delimitted list of headers found
-->
@@ -2230,9 +2230,9 @@ public static class PublishedContentExtensions
// with a non-existing published node, will get cache misses and call the DB
// making it a very slow operation.
return publishedStatusFilteringService
.FilterAvailable(childrenKeys, culture)
.OrderBy(x => x.SortOrder);
// INavigationQueryService.TryGetChildrenKeys returns keys already ordered by SortOrder
// and FilterAvailable preserves enumeration order, so no further OrderBy is needed.
return publishedStatusFilteringService.FilterAvailable(childrenKeys, culture);
}
private static IEnumerable<IPublishedContent> EnumerateDescendantsOrSelfInternal(
@@ -730,6 +730,10 @@ public static partial class StringExtensions
/// </summary>
/// <param name="fileName">The file name to convert.</param>
/// <returns>A friendly name with the extension stripped, underscores and dashes converted to spaces, and title case applied.</returns>
/// <remarks>
/// Mirrored client-side in <c>src/Umbraco.Web.UI.Client/src/packages/media/media/utils/to-friendly-name.function.ts</c>;
/// keep the two implementations in sync.
/// </remarks>
public static string ToFriendlyName(this string fileName)
{
// strip the file extension
@@ -44,28 +44,34 @@ public class UmbracoApplicationUrlCheck : HealthCheck
private HealthCheckStatus CheckUmbracoApplicationUrl()
{
var url = _webRoutingSettings.CurrentValue.UmbracoApplicationUrl;
WebRoutingSettings settings = _webRoutingSettings.CurrentValue;
var url = settings.UmbracoApplicationUrl;
string resultMessage;
StatusResultType resultType;
var success = false;
if (url.IsNullOrWhiteSpace())
if (url.IsNullOrWhiteSpace() is false)
{
resultMessage = _textService.Localize("healthcheck", "umbracoApplicationUrlCheckResultFalse");
resultType = StatusResultType.Warning;
resultMessage = _textService.Localize("healthcheck", "umbracoApplicationUrlCheckResultTrue", [url]);
resultType = StatusResultType.Success;
}
else if (settings.ApplicationUrlDetection == ApplicationUrlDetection.None)
{
// No explicit URL and auto-detection is disabled, so the application URL can never be established.
// Features that require an absolute URL (e.g. password reset and invitation emails) will not work.
resultMessage = _textService.Localize("healthcheck", "umbracoApplicationUrlCheckResultError");
resultType = StatusResultType.Error;
}
else
{
resultMessage = _textService.Localize("healthcheck", "umbracoApplicationUrlCheckResultTrue", new[] { url });
resultType = StatusResultType.Success;
success = true;
resultMessage = _textService.Localize("healthcheck", "umbracoApplicationUrlCheckResultFalse");
resultType = StatusResultType.Warning;
}
return new HealthCheckStatus(resultMessage)
{
ResultType = resultType,
ReadMoreLink = success
ReadMoreLink = resultType == StatusResultType.Success
? null
: Constants.HealthChecks.DocumentationLinks.Security.UmbracoApplicationUrlCheck,
};
@@ -8,7 +8,25 @@ namespace Umbraco.Cms.Core.Models.Navigation;
/// </summary>
public sealed class NavigationNode
{
private ConcurrentHashSet<Guid> _children;
private static readonly Comparison<(Guid Key, int SortOrder)> _sortBySortOrder =
static (a, b) => a.SortOrder.CompareTo(b.SortOrder);
private readonly ConcurrentHashSet<Guid> _children;
/// <summary>
/// Cached snapshot of <see cref="Children"/> ordered by each child's <c>SortOrder</c>.
/// </summary>
/// <remarks>
/// Built lazily by <see cref="GetOrderedChildren"/> on first access and invalidated
/// (set to <c>null</c>) by <see cref="AddChild"/> / <see cref="RemoveChild"/> /
/// <see cref="InvalidateOrderedChildren"/>. Reads are lock-free on the fast path; the
/// build and invalidation paths take <see cref="_orderedChildrenLock"/> so concurrent
/// first-access threads agree on a single canonical array and an in-flight build
/// cannot finish after a concurrent invalidation has cleared it.
/// </remarks>
private Guid[]? _orderedChildren;
private readonly Lock _orderedChildrenLock = new();
/// <summary>
/// Gets the unique key of this navigation node.
@@ -53,6 +71,17 @@ public sealed class NavigationNode
/// Updates the sort order of this node.
/// </summary>
/// <param name="newSortOrder">The new sort order value.</param>
/// <remarks>
/// The parent node's cached ordered-children list (if any) is now stale because it sorts
/// by child <c>SortOrder</c>. Callers that hold a reference to the parent should call
/// <see cref="InvalidateOrderedChildren"/> on it; <see cref="NavigationNode"/> does not
/// hold a reference to its parent <see cref="NavigationNode"/> so cannot invalidate it
/// itself.
/// </remarks>
// TODO (V19): Make internal. The contract requires the caller to invalidate the parent's
// ordered-children cache (InvalidateOrderedChildren is internal, so external callers cannot
// satisfy that contract and would silently observe stale ordering on subsequent reads).
// Internal callers in ContentNavigationServiceBase already do the invalidation correctly.
public void UpdateSortOrder(int newSortOrder) => SortOrder = newSortOrder;
/// <summary>
@@ -74,6 +103,8 @@ public sealed class NavigationNode
child.SortOrder = _children.Count;
_children.Add(childKey);
InvalidateOrderedChildren();
}
/// <summary>
@@ -91,5 +122,91 @@ public sealed class NavigationNode
_children.Remove(childKey);
child.Parent = null;
InvalidateOrderedChildren();
}
/// <summary>
/// Returns this node's children ordered by <c>SortOrder</c>.
/// </summary>
/// <param name="navigationStructure">The navigation structure dictionary containing all nodes; needed to look up each child's current <c>SortOrder</c>.</param>
/// <returns>An immutable, sort-order-presorted snapshot of the children. The result is cached and reused across calls until the children set or a child's <c>SortOrder</c> is mutated.</returns>
/// <remarks>
/// Lock-free fast path: a non-null cached array is returned without acquiring the lock.
/// If the cache is empty, <see cref="BuildOrderedChildren"/> is called under the lock to
/// build (with double-checked re-read) and store the canonical array.
/// </remarks>
internal IReadOnlyList<Guid> GetOrderedChildren(ConcurrentDictionary<Guid, NavigationNode> navigationStructure)
{
// Volatile.Read provides the acquire fence that pairs with the release fence on the
// lock-protected stores in BuildOrderedChildren / InvalidateOrderedChildren. On weak
// memory architectures (e.g. ARM64) a plain read can observe writes out of order with
// the lock release, so without this barrier a reader could in principle see a torn or
// unpublished reference; on x86/x64 the TSO model already gives acquire semantics so
// this compiles to a normal load. Matches the lock-free read idiom in System.Lazy<T>
// and LazyInitializer.EnsureInitialized.
Guid[]? cached = Volatile.Read(ref _orderedChildren);
if (cached is not null)
{
return cached;
}
return BuildOrderedChildren(navigationStructure);
}
/// <summary>
/// Invalidates the cached ordered-children snapshot.
/// </summary>
/// <remarks>
/// Called by <see cref="AddChild"/> and <see cref="RemoveChild"/> automatically. Must be
/// called externally when a child's <c>SortOrder</c> changes (the parent's cache sorts by
/// child <c>SortOrder</c> and so is stale after such an update).
/// </remarks>
internal void InvalidateOrderedChildren()
{
lock (_orderedChildrenLock)
{
_orderedChildren = null;
}
}
private Guid[] BuildOrderedChildren(ConcurrentDictionary<Guid, NavigationNode> navigationStructure)
{
lock (_orderedChildrenLock)
{
// Double-check under the lock — another thread may have built the cache while we
// were waiting to acquire it.
Guid[]? cached = _orderedChildren;
if (cached is not null)
{
return cached;
}
if (_children.Count == 0)
{
_orderedChildren = [];
return _orderedChildren;
}
var sorted = new List<(Guid Key, int SortOrder)>(_children.Count);
foreach (Guid childKey in _children)
{
if (navigationStructure.TryGetValue(childKey, out NavigationNode? childNode))
{
sorted.Add((childKey, childNode.SortOrder));
}
}
sorted.Sort(_sortBySortOrder);
var result = new Guid[sorted.Count];
for (var i = 0; i < sorted.Count; i++)
{
result[i] = sorted[i].Key;
}
_orderedChildren = result;
return result;
}
}
}
@@ -28,6 +28,27 @@ public interface IDocumentCacheService
/// <returns>The published content, or <c>null</c> if not found.</returns>
Task<IPublishedContent?> GetByIdAsync(int id, bool? preview = null);
/// <summary>
/// Attempts to retrieve a content item from the in-memory converted-content cache without
/// touching the distributed cache or the database.
/// </summary>
/// <param name="key">The unique key of the content.</param>
/// <param name="preview">Whether to consider unpublished content.</param>
/// <param name="content">When this method returns, contains the cached published content if a hit was made; otherwise <c>null</c>.</param>
/// <returns><c>true</c> if the content was served from the in-memory cache; <c>false</c> if a slower retrieval (HybridCache or database) is required.</returns>
/// <remarks>
/// Synchronous fast-path used by sync consumers (e.g. <c>IPublishedContentCache.GetById(bool, Guid)</c>)
/// to avoid setting up the async state machine on the dominant warm-cache case. On a miss
/// the caller falls back to the existing async path. The default implementation always
/// returns <c>false</c> so the caller takes the async path.
/// </remarks>
// TODO (V19): Remove the default implementation.
bool TryGetCached(Guid key, bool preview, out IPublishedContent? content)
{
content = null;
return false;
}
/// <summary>
/// Seeds the cache with initial content data.
/// </summary>
@@ -26,6 +26,26 @@ public interface IMediaCacheService
/// <returns>The published media content, or <c>null</c> if not found.</returns>
Task<IPublishedContent?> GetByIdAsync(int id);
/// <summary>
/// Attempts to retrieve a media item from the in-memory converted-content cache without
/// touching the distributed cache or the database.
/// </summary>
/// <param name="key">The unique key of the media.</param>
/// <param name="content">When this method returns, contains the cached published media if a hit was made; otherwise <c>null</c>.</param>
/// <returns><c>true</c> if the media was served from the in-memory cache; <c>false</c> if a slower retrieval (HybridCache or database) is required.</returns>
/// <remarks>
/// Synchronous fast-path used by sync consumers (e.g. <c>IPublishedMediaCache.GetById(bool, Guid)</c>)
/// to avoid setting up the async state machine on the dominant warm-cache case. On a miss
/// the caller falls back to the existing async path. The default implementation always
/// returns <c>false</c> so the caller takes the async path.
/// </remarks>
// TODO (V19): Remove the default implementation.
bool TryGetCached(Guid key, out IPublishedContent? content)
{
content = null;
return false;
}
/// <summary>
/// Determines whether media with the specified identifier exists in the cache.
/// </summary>
@@ -305,13 +305,40 @@ public class DocumentUrlAliasService : IDocumentUrlAliasService
scope.Complete();
}
/// <inheritdoc/>
public async Task UpdateAliasCacheAsync(Guid documentKey)
{
using ICoreScope scope = _coreScopeProvider.CreateCoreScope();
await CreateOrUpdateAliasesInternalAsync(documentKey, forceSkipDatabaseWrite: true);
scope.Complete();
}
/// <inheritdoc/>
public async Task UpdateAliasCacheWithDescendantsAsync(Guid documentKey)
{
using ICoreScope scope = _coreScopeProvider.CreateCoreScope();
var documentKeys = new List<Guid> { documentKey };
if (_documentNavigationQueryService.TryGetDescendantsKeys(documentKey, out IEnumerable<Guid> descendantKeys))
{
documentKeys.AddRange(descendantKeys);
}
foreach (Guid key in documentKeys)
{
await CreateOrUpdateAliasesInternalAsync(key, forceSkipDatabaseWrite: true);
}
scope.Complete();
}
/// <summary>
/// Internal implementation that processes a single document without creating its own scope.
/// Caller must ensure a scope is active. A write lock on <see cref="Constants.Locks.DocumentUrlAliases"/>
/// is required whenever this method may perform database writes — i.e. on all server roles except
/// <see cref="ServerRole.Subscriber"/>, where persistence is skipped and the write lock is not taken.
/// </summary>
private async Task CreateOrUpdateAliasesInternalAsync(Guid documentKey)
private async Task CreateOrUpdateAliasesInternalAsync(Guid documentKey, bool forceSkipDatabaseWrite = false)
{
IContent? document = _contentService.GetById(documentKey);
if (document is null || document.Trashed || document.Blueprint)
@@ -329,7 +356,7 @@ public class DocumentUrlAliasService : IDocumentUrlAliasService
// Save to database (handles insert/update/delete via diff) and add to cache.
// On subscribers we skip the persistence — the publisher has already written the aliases — but the
// in-memory cache is still refreshed via the deferred enlistments so routing keeps working locally.
bool skipDatabaseWrites = SkipDatabaseWrites();
bool skipDatabaseWrites = forceSkipDatabaseWrite || SkipDatabaseWrites();
if (aliases.Count > 0)
{
if (skipDatabaseWrites is false)
@@ -154,7 +154,7 @@ public class DocumentUrlService : IDocumentUrlService
IPublishStatusQueryService publishStatusQueryService,
IDomainCacheService domainCacheService)
#pragma warning disable CS0618 // Type or member is obsolete
:this(
: this(
logger,
documentUrlRepository,
documentRepository,
@@ -604,7 +604,35 @@ public class DocumentUrlService : IDocumentUrlService
}
/// <inheritdoc/>
public async Task CreateOrUpdateUrlSegmentsAsync(IEnumerable<IContent> documentsEnumerable)
public async Task CreateOrUpdateUrlSegmentsAsync(IEnumerable<IContent> documents)
=> await CreateOrUpdateUrlSegmentsInternalAsync(documents, skipDatabaseWrite: false);
/// <inheritdoc/>
public async Task UpdateUrlSegmentCacheAsync(Guid key)
{
IContent? content = _contentService.GetById(key);
if (content is not null)
{
await CreateOrUpdateUrlSegmentsInternalAsync(content.Yield(), skipDatabaseWrite: true);
}
}
/// <inheritdoc/>
public async Task UpdateUrlSegmentCacheWithDescendantsAsync(Guid key)
{
var id = _idKeyMap.GetIdForKey(key, UmbracoObjectTypes.Document).Result;
IContent? item = _contentService.GetById(id);
if (item is null)
{
_logger.LogDebug("Skipping URL segment cache update for document with key {DocumentKey} — document not found.", key);
return;
}
IEnumerable<IContent> descendants = _contentService.GetPagedDescendants(id, 0, int.MaxValue, out _);
await CreateOrUpdateUrlSegmentsInternalAsync(new List<IContent>(descendants) { item }, skipDatabaseWrite: true);
}
private async Task CreateOrUpdateUrlSegmentsInternalAsync(IEnumerable<IContent> documentsEnumerable, bool skipDatabaseWrite)
{
IEnumerable<IContent> documents = documentsEnumerable as IContent[] ?? documentsEnumerable.ToArray();
if (documents.Any() is false)
@@ -664,7 +692,7 @@ public class DocumentUrlService : IDocumentUrlService
}
}
if (toSave.Count > 0 && SkipDatabaseWrites() is false)
if (!skipDatabaseWrite && toSave.Count > 0 && SkipDatabaseWrites() is false)
{
scope.WriteLock(Constants.Locks.DocumentUrls);
_documentUrlRepository.Save(toSave);
@@ -0,0 +1,65 @@
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Core.Services.Changes;
using Umbraco.Extensions;
namespace Umbraco.Cms.Core.Services;
/// <summary>
/// Handles <see cref="ContentTreeChangeNotification"/> to persist URL segments and aliases to the database
/// on the originating server. This fires post-commit (during scope disposal) before the cache instruction
/// is delivered to other servers, ensuring URL data is in the database before any server processes the instruction.
/// </summary>
public class DocumentUrlServiceContentTreeChangeNotificationHandler
: INotificationAsyncHandler<ContentTreeChangeNotification>
{
private readonly IDocumentUrlService _documentUrlService;
private readonly IDocumentUrlAliasService _documentUrlAliasService;
/// <summary>
/// Initializes a new instance of the <see cref="DocumentUrlServiceContentTreeChangeNotificationHandler"/> class.
/// </summary>
public DocumentUrlServiceContentTreeChangeNotificationHandler(
IDocumentUrlService documentUrlService,
IDocumentUrlAliasService documentUrlAliasService)
{
_documentUrlService = documentUrlService;
_documentUrlAliasService = documentUrlAliasService;
}
/// <inheritdoc/>
public async Task HandleAsync(ContentTreeChangeNotification notification, CancellationToken cancellationToken)
{
if (_documentUrlService.IsInitialized is false)
{
return;
}
var refreshNodeItems = new List<IContent>();
foreach (TreeChange<IContent> change in notification.Changes)
{
if (change.ChangeTypes.HasType(TreeChangeTypes.RefreshNode))
{
refreshNodeItems.Add(change.Item);
}
if (change.ChangeTypes.HasType(TreeChangeTypes.RefreshBranch))
{
await _documentUrlService.CreateOrUpdateUrlSegmentsWithDescendantsAsync(change.Item.Key);
await _documentUrlAliasService.CreateOrUpdateAliasesWithDescendantsAsync(change.Item.Key);
}
}
if (refreshNodeItems.Count > 0)
{
await _documentUrlService.CreateOrUpdateUrlSegmentsAsync(refreshNodeItems);
foreach (IContent item in refreshNodeItems)
{
await _documentUrlAliasService.CreateOrUpdateAliasesAsync(item.Key);
}
}
}
}
@@ -60,4 +60,20 @@ public interface IDocumentUrlAliasService
/// </summary>
/// <returns><c>true</c> if there are any aliases in the cache; otherwise, <c>false</c>.</returns>
bool HasAny();
/// <summary>
/// Updates the in-memory alias cache for a single document without writing to the database.
/// </summary>
/// <param name="documentKey">The document key.</param>
// TODO (V19): Remove default implementation when external implementations have had time to adopt.
Task UpdateAliasCacheAsync(Guid documentKey)
=> CreateOrUpdateAliasesAsync(documentKey);
/// <summary>
/// Updates the in-memory alias cache for a document and its descendants without writing to the database.
/// </summary>
/// <param name="documentKey">The document key.</param>
// TODO (V19): Remove default implementation when external implementations have had time to adopt.
Task UpdateAliasCacheWithDescendantsAsync(Guid documentKey)
=> CreateOrUpdateAliasesWithDescendantsAsync(documentKey);
}
@@ -100,4 +100,20 @@ public interface IDocumentUrlService
/// Gets a value indicating whether any URLs have been cached.
/// </summary>
bool HasAny();
/// <summary>
/// Updates the in-memory URL segment cache for a single document without writing to the database.
/// </summary>
/// <param name="key">The document key.</param>
// TODO (V19): Remove default implementation when external implementations have had time to adopt.
Task UpdateUrlSegmentCacheAsync(Guid key)
=> CreateOrUpdateUrlSegmentsAsync(key);
/// <summary>
/// Updates the in-memory URL segment cache for a document and its descendants without writing to the database.
/// </summary>
/// <param name="key">The document key.</param>
// TODO (V19): Remove default implementation when external implementations have had time to adopt.
Task UpdateUrlSegmentCacheWithDescendantsAsync(Guid key)
=> CreateOrUpdateUrlSegmentsWithDescendantsAsync(key);
}
@@ -30,11 +30,48 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
/// <summary>
/// Bundles a navigation structure dictionary and its root keys into a single reference so that
/// <see cref="HandleRebuildAsync"/> can swap both atomically with one <see cref="Interlocked.Exchange{T}"/>
/// call and readers always observe a consistent pair.
/// call and readers always observe a consistent pair. Also carries the per-snapshot
/// descendants cache populated by <see cref="TryGetDescendantsKeysFromStructure"/>.
/// </summary>
private sealed record NavigationSnapshot(
ConcurrentDictionary<Guid, NavigationNode> Structure,
HashSet<Guid> Roots);
HashSet<Guid> Roots)
{
private long _generation;
/// <summary>
/// Cache of descendants <c>Guid[]</c> keyed by parent and an optional content-type
/// filter. Populated lazily by <see cref="TryGetDescendantsKeysFromStructure"/> and
/// cleared by <see cref="Invalidate"/> on any structural mutation.
/// </summary>
/// <remarks>
/// The composite key allows both <c>TryGetDescendantsKeys</c> (content-type =
/// <c>null</c>) and <c>TryGetDescendantsKeysOfType</c> (content-type = the resolved
/// <c>Guid</c>) to share one cache without their results contaminating each other.
/// Realistic per-parent fan-out is bounded by the "allowed types" content model
/// (typically 1-5 types per parent), and the cache is populated only for queries
/// that actually run, so memory grows with the templates exercised rather than the
/// theoretical product of (parents × content types).
/// </remarks>
public ConcurrentDictionary<(Guid Parent, Guid? ContentType), Guid[]> DescendantsCache { get; } = new();
/// <summary>
/// A monotonic counter incremented on every mutation. Used by readers to detect a
/// concurrent mutation that occurred during their compute, so they can avoid writing
/// a now-stale result back to <see cref="DescendantsCache"/>.
/// </summary>
public long Generation => Interlocked.Read(ref _generation);
/// <summary>
/// Clears the descendants cache and bumps the generation. Call after any mutation to
/// this snapshot's <see cref="Structure"/> or <see cref="Roots"/>.
/// </summary>
public void Invalidate()
{
Interlocked.Increment(ref _generation);
DescendantsCache.Clear();
}
}
private NavigationSnapshot _navigation = new(new(), []);
private NavigationSnapshot _recycleBinNavigation = new(new(), []);
@@ -164,7 +201,12 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
/// </param>
/// <returns><c>true</c> if the parent node exists in the structure; otherwise, <c>false</c>.</returns>
public bool TryGetDescendantsKeys(Guid parentKey, out IEnumerable<Guid> descendantsKeys)
=> TryGetDescendantsKeysFromStructure(_navigation.Structure, parentKey, out descendantsKeys);
{
// Snapshot to a local so cache lookups, the structure walk, and the generation check
// all see the same NavigationSnapshot instance even if a rebuild swaps it in mid-call.
NavigationSnapshot snapshot = _navigation;
return TryGetDescendantsKeysFromStructure(snapshot.Structure, parentKey, out descendantsKeys, contentTypeKey: null, cachingSnapshot: snapshot);
}
/// <summary>
/// Attempts to get all descendant node keys of a specific content type under a parent node.
@@ -182,7 +224,11 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
{
if (TryGetContentTypeKey(contentTypeAlias, out Guid? contentTypeKey))
{
return TryGetDescendantsKeysFromStructure(_navigation.Structure, parentKey, out descendantsKeys, contentTypeKey);
// Snapshot to a local so cache lookups, the structure walk, and the generation
// check all see the same NavigationSnapshot instance even if a rebuild swaps it
// in mid-call.
NavigationSnapshot snapshot = _navigation;
return TryGetDescendantsKeysFromStructure(snapshot.Structure, parentKey, out descendantsKeys, contentTypeKey, cachingSnapshot: snapshot);
}
// Content type alias doesn't exist
@@ -297,7 +343,10 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
/// </param>
/// <returns><c>true</c> if the parent node exists in the recycle bin; otherwise, <c>false</c>.</returns>
public bool TryGetDescendantsKeysInBin(Guid parentKey, out IEnumerable<Guid> descendantsKeys)
=> TryGetDescendantsKeysFromStructure(_recycleBinNavigation.Structure, parentKey, out descendantsKeys);
{
NavigationSnapshot snapshot = _recycleBinNavigation;
return TryGetDescendantsKeysFromStructure(snapshot.Structure, parentKey, out descendantsKeys, contentTypeKey: null, cachingSnapshot: snapshot);
}
/// <summary>
/// Attempts to get all ancestor node keys of a child node in the recycle bin navigation structure.
@@ -375,8 +424,14 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
// Reset the SortOrder based on its new position in the bin
nodeToRemove.UpdateSortOrder(_recycleBinNavigation.Structure.Count);
return _recycleBinNavigation.Structure.TryAdd(nodeToRemove.Key, nodeToRemove) &&
_navigation.Structure.TryRemove(key, out _);
var moved = _recycleBinNavigation.Structure.TryAdd(nodeToRemove.Key, nodeToRemove) &&
_navigation.Structure.TryRemove(key, out _);
// Both snapshots' descendant lists are now potentially stale.
_navigation.Invalidate();
_recycleBinNavigation.Invalidate();
return moved;
}
/// <summary>
@@ -418,6 +473,7 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
parentNode?.AddChild(_navigation.Structure, key);
_navigation.Invalidate();
return true;
}
@@ -468,6 +524,7 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
// Set the new parent for the node (if parent node is null - the node is moved to root)
targetParentNode?.AddChild(_navigation.Structure, key);
_navigation.Invalidate();
return true;
}
@@ -488,6 +545,18 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
node.UpdateSortOrder(newSortOrder);
// The parent's cached ordered-children snapshot sorts by child SortOrder and is now
// stale — invalidate so the next read rebuilds against the new value.
if (node.Parent is not null
&& _navigation.Structure.TryGetValue(node.Parent.Value, out NavigationNode? parentNode))
{
parentNode.InvalidateOrderedChildren();
}
// Descendants lists are sort-order-presorted (depth-first using each parent's
// ordered children), so re-ordering a child re-orders any cached ancestor descendants.
_navigation.Invalidate();
return true;
}
@@ -510,7 +579,9 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
RemoveDescendantsRecursively(nodeToRemove);
return _recycleBinNavigation.Structure.TryRemove(key, out _);
var removed = _recycleBinNavigation.Structure.TryRemove(key, out _);
_recycleBinNavigation.Invalidate();
return removed;
}
/// <summary>
@@ -545,8 +616,14 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
// Restore the node and its descendants from the recycle bin to the main structure
RestoreNodeAndDescendantsRecursively(nodeToRestore);
return _navigation.Structure.TryAdd(nodeToRestore.Key, nodeToRestore) &&
_recycleBinNavigation.Structure.TryRemove(key, out _);
var restored = _navigation.Structure.TryAdd(nodeToRestore.Key, nodeToRestore) &&
_recycleBinNavigation.Structure.TryRemove(key, out _);
// Both snapshots' descendant lists are now potentially stale.
_navigation.Invalidate();
_recycleBinNavigation.Invalidate();
return restored;
}
/// <summary>
@@ -655,10 +732,9 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
ConcurrentDictionary<Guid, NavigationNode> structure,
Guid parentKey,
out IEnumerable<Guid> descendantsKeys,
Guid? contentTypeKey = null)
Guid? contentTypeKey = null,
NavigationSnapshot? cachingSnapshot = null)
{
var descendants = new List<Guid>();
if (structure.TryGetValue(parentKey, out NavigationNode? parentNode) is false)
{
// Parent doesn't exist
@@ -666,9 +742,50 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
return false;
}
// Both unfiltered and content-type-filtered queries are cached, distinguished by the
// optional contentTypeKey in the composite key. Realistic per-parent fan-out is bounded
// by the "allowed types" model (a few types per parent), and entries are populated
// lazily for queries that actually run — so memory tracks the templates exercised, not
// the theoretical product of (parents × types).
var useCache = cachingSnapshot is not null;
#pragma warning disable IDE0008 // Use explicit type (in this case using var improves the readability of the tuple key).
var cacheKey = (parentKey, contentTypeKey);
#pragma warning restore IDE0008 // Use explicit type
if (useCache && cachingSnapshot!.DescendantsCache.TryGetValue(cacheKey, out Guid[]? cached))
{
descendantsKeys = cached;
return true;
}
// Capture the snapshot's mutation generation BEFORE walking. If a mutation invalidates
// between here and the cache write, the result we computed may be stale relative to
// the now-current Structure; we still hand it to the caller (it was correct at the
// moment we read), but skip the cache write so future readers don't see stale data.
var startGeneration = useCache ? cachingSnapshot!.Generation : 0;
var descendants = new List<Guid>();
GetDescendantsRecursively(structure, parentNode, descendants, contentTypeKey);
descendantsKeys = descendants;
if (useCache)
{
Guid[] result = [.. descendants];
// Only install if no mutation happened during compute, and skip caching empty
// results — they're cheap to recompute and caching them bloats the dictionary with
// one entry per (parent, type) pair queried with no measurable benefit.
if (result.Length > 0 && cachingSnapshot!.Generation == startGeneration)
{
cachingSnapshot.DescendantsCache[cacheKey] = result;
}
descendantsKeys = result;
}
else
{
descendantsKeys = descendants;
}
return true;
}
@@ -859,6 +976,15 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
return [];
}
// Unfiltered case uses the cached snapshot maintained on the node — returns the same
// sorted Guid[] across calls until the children set or a child's SortOrder changes.
if (contentTypeKey.HasValue is false)
{
return node.GetOrderedChildren(structure);
}
// Filtered-by-content-type case stays uncached: it would need a composite (node, type)
// key to memoise, and the call site is rare enough not to be worth it.
var childrenWithSortOrder = new List<(Guid ChildNodeKey, int SortOrder)>(node.Children.Count);
foreach (Guid childNodeKey in node.Children)
{
@@ -867,8 +993,7 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
continue;
}
// Apply contentTypeKey filter
if (contentTypeKey.HasValue && childNode.ContentTypeKey != contentTypeKey.Value)
if (childNode.ContentTypeKey != contentTypeKey.Value)
{
continue;
}
@@ -56,14 +56,17 @@ internal sealed class PublishedContentStatusFilteringService : IPublishedContent
_publishStatusQueryService.IsDocumentPublished(key, culture)
&& _publishStatusQueryService.HasPublishedAncestorPath(key, culture));
return WhereIsInvariantOrHasCultureOrRequestedAllCultures(candidateKeys, culture, preview).ToArray();
// Returned lazily so consumers like .FirstOrDefault() / .Take(n) can short-circuit
// without materialising the full result. Callers that need to enumerate the result
// more than once should buffer it themselves (.ToList() / .ToArray()).
return WhereIsInvariantOrHasCultureOrRequestedAllCultures(candidateKeys, culture, preview);
}
/// <inheritdoc />
public IEnumerable<IPublishedContent> Unfiltered(IEnumerable<Guid> candidateKeys)
{
var preview = _previewService.IsInPreview();
return candidateKeys.Select(key => _publishedContentCache.GetById(preview, key)).WhereNotNull().ToArray();
return candidateKeys.Select(key => _publishedContentCache.GetById(preview, key)).WhereNotNull();
}
/// <summary>
@@ -24,10 +24,15 @@ internal sealed class PublishedMediaStatusFilteringService : IPublishedMediaStat
=> _publishedMediaCache = publishedMediaCache;
/// <inheritdoc />
/// <remarks>
/// Returned lazily so consumers like .FirstOrDefault() / .Take(n) can short-circuit without
/// materialising the full result. Callers that need to enumerate the result more than once
/// should buffer it themselves (.ToList() / .ToArray()).
/// </remarks>
public IEnumerable<IPublishedContent> FilterAvailable(IEnumerable<Guid> candidateKeys, string? culture)
=> candidateKeys.Select(_publishedMediaCache.GetById).WhereNotNull().ToArray();
=> candidateKeys.Select(_publishedMediaCache.GetById).WhereNotNull();
/// <inheritdoc />
public IEnumerable<IPublishedContent> Unfiltered(IEnumerable<Guid> candidateKeys)
=> candidateKeys.Select(_publishedMediaCache.GetById).WhereNotNull().ToArray();
=> candidateKeys.Select(_publishedMediaCache.GetById).WhereNotNull();
}
+6
View File
@@ -43,6 +43,8 @@ public static class UdiEntityTypeHelper
return Constants.UdiEntityType.DataTypeContainer;
case UmbracoObjectTypes.MemberType:
return Constants.UdiEntityType.MemberType;
case UmbracoObjectTypes.MemberTypeContainer:
return Constants.UdiEntityType.MemberTypeContainer;
case UmbracoObjectTypes.MemberGroup:
return Constants.UdiEntityType.MemberGroup;
case UmbracoObjectTypes.RelationType:
@@ -75,6 +77,8 @@ public static class UdiEntityTypeHelper
return UmbracoObjectTypes.Document;
case Constants.UdiEntityType.DocumentBlueprint:
return UmbracoObjectTypes.DocumentBlueprint;
case Constants.UdiEntityType.DocumentBlueprintContainer:
return UmbracoObjectTypes.DocumentBlueprintContainer;
case Constants.UdiEntityType.Media:
return UmbracoObjectTypes.Media;
case Constants.UdiEntityType.Member:
@@ -95,6 +99,8 @@ public static class UdiEntityTypeHelper
return UmbracoObjectTypes.DataTypeContainer;
case Constants.UdiEntityType.MemberType:
return UmbracoObjectTypes.MemberType;
case Constants.UdiEntityType.MemberTypeContainer:
return UmbracoObjectTypes.MemberTypeContainer;
case Constants.UdiEntityType.MemberGroup:
return UmbracoObjectTypes.MemberGroup;
case Constants.UdiEntityType.RelationType:
@@ -1,8 +1,3 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Umbraco.Cms.Core.Configuration;
@@ -14,37 +9,48 @@ namespace Umbraco.Cms.Infrastructure.BackgroundJobs
public class DelayCalculator
{
/// <summary>
/// Determines the delay before the first run of a recurring task implemented as a hosted service when an optonal
/// configuration for the first run time is available.
/// Determines the delay before the first run of a recurring task, using a <see cref="TimeProvider" /> for the current time.
/// </summary>
/// <param name="firstRunTime">The configured time to first run the task in crontab format.</param>
/// <param name="cronTabParser">An instance of <see cref="ICronTabParser"/></param>
/// <param name="cronTabParser">An instance of <see cref="ICronTabParser" />.</param>
/// <param name="logger">The logger.</param>
/// <param name="timeProvider">The time provider used to determine the current time.</param>
/// <param name="defaultDelay">The default delay to use when a first run time is not configured.</param>
/// <returns>The delay before first running the recurring task.</returns>
public static TimeSpan GetDelay(
string firstRunTime,
ICronTabParser cronTabParser,
ILogger logger,
TimeSpan defaultDelay) => GetDelay(firstRunTime, cronTabParser, logger, DateTime.Now, defaultDelay);
/// <returns>
/// The delay before first running the recurring task.
/// </returns>
public static TimeSpan GetDelay(string firstRunTime, ICronTabParser cronTabParser, ILogger logger, TimeProvider timeProvider, TimeSpan defaultDelay)
=> GetDelay(firstRunTime, cronTabParser, logger, timeProvider.GetLocalNow().DateTime, defaultDelay);
/// <summary>
/// Determines the delay before the first run of a recurring task implemented as a hosted service when an optonal
/// configuration for the first run time is available.
/// Determines the delay before the first run of a recurring task implemented as a hosted service when an optional configuration for the first run time is available.
/// </summary>
/// <param name="firstRunTime">The configured time to first run the task in crontab format.</param>
/// <param name="cronTabParser">An instance of <see cref="ICronTabParser"/></param>
/// <param name="cronTabParser">An instance of <see cref="ICronTabParser" />.</param>
/// <param name="logger">The logger.</param>
/// <param name="defaultDelay">The default delay to use when a first run time is not configured.</param>
/// <returns>
/// The delay before first running the recurring task.
/// </returns>
[Obsolete("Use the overload accepting TimeProvider. Scheduled for removal in Umbraco 19.")]
public static TimeSpan GetDelay(string firstRunTime, ICronTabParser cronTabParser, ILogger logger, TimeSpan defaultDelay)
=> GetDelay(firstRunTime, cronTabParser, logger, DateTime.Now, defaultDelay);
/// <summary>
/// Determines the delay before the first run of a recurring task implemented as a hosted service when an optional configuration for the first run time is available.
/// </summary>
/// <param name="firstRunTime">The configured time to first run the task in crontab format.</param>
/// <param name="cronTabParser">An instance of <see cref="ICronTabParser" />.</param>
/// <param name="logger">The logger.</param>
/// <param name="now">The current datetime.</param>
/// <param name="defaultDelay">The default delay to use when a first run time is not configured.</param>
/// <returns>The delay before first running the recurring task.</returns>
/// <remarks>Internal to expose for unit tests.</remarks>
internal static TimeSpan GetDelay(
string firstRunTime,
ICronTabParser cronTabParser,
ILogger logger,
DateTime now,
TimeSpan defaultDelay)
/// <returns>
/// The delay before first running the recurring task.
/// </returns>
/// <remarks>
/// Internal to expose for unit tests.
/// </remarks>
internal static TimeSpan GetDelay(string firstRunTime, ICronTabParser cronTabParser, ILogger logger, DateTime now, TimeSpan defaultDelay)
{
// If first run time not set, start with just small delay after application start.
if (string.IsNullOrEmpty(firstRunTime))
@@ -56,12 +62,14 @@ namespace Umbraco.Cms.Infrastructure.BackgroundJobs
if (!cronTabParser.IsValidCronTab(firstRunTime))
{
logger.LogWarning("Could not parse {FirstRunTime} as a crontab expression. Defaulting to default delay for hosted service start.", firstRunTime);
return defaultDelay;
}
// Otherwise start at scheduled time according to cron expression, unless within the default delay period.
DateTime firstRunOccurance = cronTabParser.GetNextOccurrence(firstRunTime, now);
TimeSpan delay = firstRunOccurance - now;
DateTime firstRunOccurrence = cronTabParser.GetNextOccurrence(firstRunTime, now);
TimeSpan delay = firstRunOccurrence - now;
return delay < defaultDelay
? defaultDelay
: delay;
@@ -1,38 +1,98 @@
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Sync;
namespace Umbraco.Cms.Infrastructure.BackgroundJobs;
/// <summary>
/// A recurring background job
/// A recurring background job.
/// </summary>
public interface IRecurringBackgroundJob
{
static readonly TimeSpan DefaultDelay = System.TimeSpan.FromMinutes(3);
static readonly ServerRole[] DefaultServerRoles = new[] { ServerRole.Single, ServerRole.SchedulingPublisher };
/// <summary>
/// The default delay to use for recurring tasks for the first run after application start-up if no alternative is configured.
/// </summary>
[Obsolete("Use RecurringBackgroundJobBase.DefaultDelay instead. Scheduled for removal in Umbraco 19.")]
static readonly TimeSpan DefaultDelay = RecurringBackgroundJobBase.DefaultDelay;
/// <summary>
/// The default server roles that recurring background jobs run on.
/// </summary>
[Obsolete("Use RecurringBackgroundJobBase.DefaultServerRoles instead. Scheduled for removal in Umbraco 19.")]
static readonly ServerRole[] DefaultServerRoles = RecurringBackgroundJobBase.DefaultServerRoles;
/// <summary>
/// Timespan representing how often the task should recur.
/// </summary>
/// <value>
/// The period.
/// </value>
/// <remarks>
/// Set to <see cref="Timeout.InfiniteTimeSpan" /> to (temporarily) disable automatic scheduling and turn the job into a manually triggered one (via <see cref="IRecurringBackgroundJobTrigger{TJob}" />). To change the period at runtime, subclasses of <see cref="RecurringBackgroundJobBase" /> assign the protected setter on <see cref="RecurringBackgroundJobBase.Period" /> (which auto-raises <see cref="PeriodChanged" />); direct implementors of this interface must raise <see cref="PeriodChanged" /> themselves after updating the backing value.
/// </remarks>
TimeSpan Period { get; }
/// <summary>
/// Timespan representing the initial delay after application start-up before the first run of the task
/// occurs.
/// Timespan representing the initial delay after application start-up before the first run of the task occurs.
/// </summary>
TimeSpan Delay { get => DefaultDelay; }
/// <value>
/// The delay.
/// </value>
/// <remarks>
/// Set to <see cref="Timeout.InfiniteTimeSpan" /> to skip the automatic first run entirely; the first execution then only occurs when manually triggered via <see cref="IRecurringBackgroundJobTrigger{TJob}" />.
/// </remarks>
TimeSpan Delay => RecurringBackgroundJobBase.DefaultDelay; // TODO (V19): Remove the default implementation
/// <summary>
/// Gets the server roles for which this recurring background job is intended.
/// Timespan to wait before re-evaluating execution conditions when an execution is ignored (e.g. runtime not ready, wrong server role or not main domain).
/// </summary>
ServerRole[] ServerRoles { get => DefaultServerRoles; }
event EventHandler PeriodChanged;
/// <value>
/// The ignored delay.
/// </value>
/// <remarks>
/// This back-off prevents tight looping when <see cref="Period" /> is short (or <see cref="TimeSpan.Zero" />) and an execution is skipped without invoking <see cref="RunJobAsync(CancellationToken)" />.
/// Set to <see cref="Timeout.InfiniteTimeSpan" /> to disable the job for the remaining application lifecycle once an ignored condition is encountered — useful when the condition is known not to change (e.g. a server role that will not be promoted on this instance). To change the ignored delay at runtime, subclasses of <see cref="RecurringBackgroundJobBase" /> assign the protected setter on <see cref="RecurringBackgroundJobBase.IgnoredDelay" /> (which auto-raises <see cref="IgnoredDelayChanged" />); direct implementors of this interface must raise <see cref="IgnoredDelayChanged" /> themselves after updating the backing value.
/// </remarks>
TimeSpan IgnoredDelay => RecurringBackgroundJobBase.DefaultIgnoredDelay; // TODO (V19): Remove the default implementation
/// <summary>
/// Executes the logic associated with the recurring background job asynchronously.
/// Gets the server roles the task executes on.
/// </summary>
/// <returns>A <see cref="System.Threading.Tasks.Task"/> that represents the asynchronous execution of the background job.</returns>
/// <value>
/// The server roles.
/// </value>
ServerRole[] ServerRoles => RecurringBackgroundJobBase.DefaultServerRoles; // TODO (V19): Remove the default implementation
/// <summary>
/// This event should be raised when the <see cref="Period" /> property changes to notify the background job manager to update the schedule for this job.
/// </summary>
event EventHandler PeriodChanged; // TODO (V19): Change to `event EventHandler? PeriodChanged;` so implementations can use field-like event syntax without manual backing-delegate accessors.
/// <summary>
/// This event should be raised when the <see cref="IgnoredDelay" /> property changes (e.g. from <see cref="Timeout.InfiniteTimeSpan" /> back to a finite value) to interrupt any in-progress ignored back-off and re-read the new value.
/// </summary>
event EventHandler IgnoredDelayChanged
{
add { }
remove { }
} // TODO (V19): Remove the default implementation and change to `event EventHandler? IgnoredDelayChanged;` so implementations can use field-like event syntax without manual backing-delegate accessors.
/// <summary>
/// Runs the background job.
/// </summary>
/// <returns>
/// A task representing the asynchronous operation.
/// </returns>
[Obsolete("Use RunJobAsync(CancellationToken) instead. Scheduled for removal in Umbraco 19.")]
Task RunJobAsync();
}
/// <summary>
/// Runs the background job with cancellation support.
/// </summary>
/// <param name="cancellationToken">A cancellation token that is signaled when the host is shutting down.</param>
/// <returns>
/// A task representing the asynchronous operation.
/// </returns>
Task RunJobAsync(CancellationToken cancellationToken)
#pragma warning disable CS0618 // Type or member is obsolete
=> RunJobAsync(); // TODO (V19): Remove the default implementation when RunJobAsync() is removed
#pragma warning restore CS0618 // Type or member is obsolete
}
@@ -0,0 +1,45 @@
// Copyright (c) Umbraco.
// See LICENSE for more details.
using Microsoft.Extensions.DependencyInjection;
using Umbraco.Cms.Infrastructure.HostedServices;
using Umbraco.Extensions;
namespace Umbraco.Cms.Infrastructure.BackgroundJobs;
/// <summary>
/// Provides methods to signal a specific recurring background job to execute immediately.
/// </summary>
/// <typeparam name="TJob">The type of the recurring background job to trigger, as registered via <see cref="ServiceCollectionExtensions.AddRecurringBackgroundJob{TJob}(IServiceCollection)" />.</typeparam>
public interface IRecurringBackgroundJobTrigger<TJob>
where TJob : class, ITriggerableRecurringBackgroundJob
{
/// <summary>
/// Signals the background loop to execute immediately.
/// After the triggered execution, the original schedule is kept.
/// </summary>
/// <returns>
/// <c>true</c> if the job was found and triggered; <c>false</c> if no hosted service is running for this job type.
/// </returns>
/// <seealso cref="NextExecutionStrategy.None" />
bool TriggerExecution();
/// <summary>
/// Signals the background loop to execute immediately, with the specified strategy for determining the next execution after the triggered one completes.
/// </summary>
/// <param name="strategy">Controls the delay after the triggered execution.</param>
/// <returns>
/// <c>true</c> if the job was found and triggered; <c>false</c> if no hosted service is running for this job type.
/// </returns>
bool TriggerExecution(NextExecutionStrategy strategy);
/// <summary>
/// Signals the background loop to execute immediately.
/// After the triggered execution, the next execution is scheduled after the specified delay (measured from execution start; execution time is subtracted to prevent drift).
/// </summary>
/// <param name="nextDelay">The target interval from execution start to the next execution. Execution time is subtracted to prevent drift.</param>
/// <returns>
/// <c>true</c> if the job was found and triggered; <c>false</c> if no hosted service is running for this job type.
/// </returns>
bool TriggerExecution(TimeSpan nextDelay);
}
@@ -0,0 +1,11 @@
// Copyright (c) Umbraco.
// See LICENSE for more details.
namespace Umbraco.Cms.Infrastructure.BackgroundJobs;
/// <summary>
/// Marker interface for recurring background jobs that support being triggered manually.
/// Only jobs implementing this interface can be triggered via <see cref="IRecurringBackgroundJobTrigger{TJob}" />.
/// </summary>
public interface ITriggerableRecurringBackgroundJob : IRecurringBackgroundJob
{ }
@@ -1,7 +1,5 @@
using System.Text;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Serialization;
using Umbraco.Cms.Core.Sync;
using Umbraco.Cms.Core.Telemetry;
@@ -12,33 +10,18 @@ namespace Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs;
/// <summary>
/// Represents a background job that collects and reports information about the current Umbraco site, typically for analytics, diagnostics, or telemetry purposes.
/// </summary>
public class ReportSiteJob : IRecurringBackgroundJob
public class ReportSiteJob : RecurringBackgroundJobBase
{
/// <summary>
/// Gets the period at which the report site job runs.
/// </summary>
public TimeSpan Period => TimeSpan.FromDays(1);
/// <summary>
/// Gets the time interval to wait between executions of the <see cref="ReportSiteJob"/>.
/// The delay is set to 5 minutes.
/// </summary>
public TimeSpan Delay => TimeSpan.FromMinutes(5);
public override TimeSpan Delay => TimeSpan.FromMinutes(5);
/// <summary>
/// Gets an array containing all possible values of the <see cref="ServerRole"/> enumeration.
/// </summary>
public ServerRole[] ServerRoles => Enum.GetValues<ServerRole>();
/// <summary>
/// Event that is triggered when the reporting period for the site job is changed.
/// </summary>
/// <remarks>No-op event as the period never changes on this job</remarks>
public event EventHandler PeriodChanged
{
add { }
remove { }
}
public override ServerRole[] ServerRoles => Enum.GetValues<ServerRole>();
private readonly ILogger<ReportSiteJob> _logger;
private readonly ITelemetryService _telemetryService;
@@ -57,6 +40,7 @@ public class ReportSiteJob : IRecurringBackgroundJob
ITelemetryService telemetryService,
IJsonSerializer jsonSerializer,
IHttpClientFactory httpClientFactory)
: base(TimeSpan.FromDays(1))
{
_logger = logger;
_telemetryService = telemetryService;
@@ -67,8 +51,11 @@ public class ReportSiteJob : IRecurringBackgroundJob
/// <summary>
/// Executes the background job that sends the anonymous site ID to the telemetry service.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task RunJobAsync()
/// <param name="cancellationToken">A cancellation token that is signaled when the host is shutting down.</param>
/// <returns>
/// A task that represents the asynchronous operation.
/// </returns>
public override async Task RunJobAsync(CancellationToken cancellationToken)
{
TelemetryReportData? telemetryReportData = await _telemetryService.GetTelemetryReportDataAsync().ConfigureAwait(false);
if (telemetryReportData is null)
@@ -100,7 +87,7 @@ public class ReportSiteJob : IRecurringBackgroundJob
// Make a HTTP Post to telemetry service
// https://telemetry.umbraco.com/installs/
// Fire & Forget, do not need to know if its a 200, 500 etc
using (await httpClient.SendAsync(request))
using (await httpClient.SendAsync(request, cancellationToken))
{ }
}
catch
@@ -3,7 +3,6 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Sync;
@@ -13,28 +12,17 @@ namespace Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs.ServerRegistration;
/// <summary>
/// Implements periodic database instruction processing as a hosted service.
/// </summary>
public class InstructionProcessJob : IRecurringBackgroundJob
public class InstructionProcessJob : RecurringBackgroundJobBase
{
/// <summary>
/// Gets the interval between executions of the instruction process job.
/// </summary>
public TimeSpan Period { get; }
/// <summary>
/// Gets the delay time before the job is executed. The delay is fixed at one minute.
/// </summary>
public TimeSpan Delay { get => TimeSpan.FromMinutes(1); }
public override TimeSpan Delay => TimeSpan.FromMinutes(1);
/// <summary>
/// Gets an array containing all possible values of the <see cref="ServerRole"/> enumeration.
/// </summary>
public ServerRole[] ServerRoles { get => Enum.GetValues<ServerRole>(); }
/// <summary>
/// Event that is raised when the execution period of the <see cref="InstructionProcessJob"/> is changed.
/// </summary>
/// <remarks>No-op event as the period never changes on this job</remarks>
public event EventHandler PeriodChanged { add { } remove { } }
public override ServerRole[] ServerRoles => Enum.GetValues<ServerRole>();
private readonly ILogger<InstructionProcessJob> _logger;
private readonly IServerMessenger _messenger;
@@ -49,19 +37,21 @@ public class InstructionProcessJob : IRecurringBackgroundJob
IServerMessenger messenger,
ILogger<InstructionProcessJob> logger,
IOptions<GlobalSettings> globalSettings)
: base(globalSettings.Value.DatabaseServerMessenger.TimeBetweenSyncOperations)
{
_messenger = messenger;
_logger = logger;
Period = globalSettings.Value.DatabaseServerMessenger.TimeBetweenSyncOperations;
}
/// <summary>
/// Executes the instruction processing job asynchronously by synchronizing messages using the messenger service.
/// Logs an error if the synchronization fails, but always completes the task.
/// </summary>
/// <returns>A completed task representing the asynchronous operation.</returns>
public Task RunJobAsync()
/// <param name="cancellationToken">A cancellation token that is signaled when the host is shutting down.</param>
/// <returns>
/// A completed task representing the asynchronous operation.
/// </returns>
public override Task RunJobAsync(CancellationToken cancellationToken)
{
try
{
@@ -3,53 +3,35 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Hosting;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Sync;
using Umbraco.Extensions;
namespace Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs.ServerRegistration;
/// <summary>
/// Implements periodic server "touching" (to mark as active/deactive) as a hosted service.
/// </summary>
public class TouchServerJob : IRecurringBackgroundJob
public class TouchServerJob : RecurringBackgroundJobBase
{
/// <summary>
/// Gets the period that defines how often the server should be touched.
/// </summary>
public TimeSpan Period { get; private set; }
/// <summary>
/// Gets the fixed delay interval of 15 seconds between executions of the touch server job.
/// This interval determines how often the server registration is updated.
/// </summary>
public TimeSpan Delay { get => TimeSpan.FromSeconds(15); }
public override TimeSpan Delay => TimeSpan.FromSeconds(15);
/// <summary>
/// Gets all server roles on which this job runs. This property returns every possible <see cref="ServerRole"/> value, indicating the job runs on all server roles.
/// </summary>
/// <remarks>Runs on all servers</remarks>
public ServerRole[] ServerRoles { get => Enum.GetValues<ServerRole>(); }
private event EventHandler? _periodChanged;
/// <summary>
/// Occurs when the period of the TouchServerJob changes.
/// </summary>
public event EventHandler PeriodChanged
{
add { _periodChanged += value; }
remove { _periodChanged -= value; }
}
public override ServerRole[] ServerRoles => Enum.GetValues<ServerRole>();
private readonly IHostingEnvironment _hostingEnvironment;
private readonly ILogger<TouchServerJob> _logger;
private readonly IServerRegistrationService _serverRegistrationService;
private readonly IServerRoleAccessor _serverRoleAccessor;
private readonly IDisposable? _onChangeRegistration;
private GlobalSettings _globalSettings;
/// <summary>
@@ -66,21 +48,18 @@ public class TouchServerJob : IRecurringBackgroundJob
ILogger<TouchServerJob> logger,
IOptionsMonitor<GlobalSettings> globalSettings,
IServerRoleAccessor serverRoleAccessor)
: base(globalSettings.CurrentValue.DatabaseServerRegistrar.WaitTimeBetweenCalls)
{
_serverRegistrationService = serverRegistrationService ??
throw new ArgumentNullException(nameof(serverRegistrationService));
_serverRegistrationService = serverRegistrationService ?? throw new ArgumentNullException(nameof(serverRegistrationService));
_hostingEnvironment = hostingEnvironment;
_logger = logger;
_globalSettings = globalSettings.CurrentValue;
_serverRoleAccessor = serverRoleAccessor;
Period = _globalSettings.DatabaseServerRegistrar.WaitTimeBetweenCalls;
globalSettings.OnChange(x =>
_onChangeRegistration = globalSettings.OnChange(x =>
{
_globalSettings = x;
Period = x.DatabaseServerRegistrar.WaitTimeBetweenCalls;
_periodChanged?.Invoke(this, EventArgs.Empty);
});
}
@@ -88,10 +67,12 @@ public class TouchServerJob : IRecurringBackgroundJob
/// Executes the job that updates the server registration by touching the server record in the database.
/// This keeps the server's registration active and ensures its status remains current.
/// </summary>
/// <returns>A completed task when the job has finished running.</returns>
public Task RunJobAsync()
/// <param name="cancellationToken">A cancellation token that is signaled when the host is shutting down.</param>
/// <returns>
/// A completed task when the job has finished running.
/// </returns>
public override Task RunJobAsync(CancellationToken cancellationToken)
{
// If the IServerRoleAccessor has been changed away from ElectedServerRoleAccessor this task no longer makes sense,
// since all it's used for is to allow the ElectedServerRoleAccessor
// to figure out what role a given server has, so we just stop this task.
@@ -101,16 +82,27 @@ public class TouchServerJob : IRecurringBackgroundJob
}
var serverAddress = _hostingEnvironment.ApplicationMainUrl?.ToString();
if (serverAddress.IsNullOrWhiteSpace())
if (string.IsNullOrWhiteSpace(serverAddress))
{
_logger.LogWarning("No umbracoApplicationUrl for service (yet), skip.");
return Task.CompletedTask;
// No application URL is known yet: either detection is off (WebRouting:ApplicationUrlDetection is
// None with no UmbracoApplicationUrl set), or detection is on but no request has been served yet.
// Register with the machine name as a placeholder so server-role election can still proceed (uniqueness
// comes from the server identity, not this address). If a URL is later detected from a request, the next
// touch overwrites the placeholder.
serverAddress = Environment.MachineName;
_logger.LogDebug(
"No application URL available; registering server with placeholder address {ServerAddress}.",
serverAddress);
}
else
{
_logger.LogDebug("Registering server with application URL {ServerAddress}.", serverAddress);
}
try
{
_serverRegistrationService.TouchServer(
serverAddress!,
serverAddress,
_globalSettings.DatabaseServerRegistrar.StaleServerTimeout);
}
catch (Exception ex)
@@ -120,4 +112,15 @@ public class TouchServerJob : IRecurringBackgroundJob
return Task.CompletedTask;
}
/// <inheritdoc />
protected override void Dispose(bool disposing)
{
if (disposing)
{
_onChangeRegistration?.Dispose();
}
base.Dispose(disposing);
}
}
@@ -3,7 +3,6 @@
using Microsoft.Extensions.Logging;
using Umbraco.Cms.Core.IO;
using Umbraco.Cms.Core.Runtime;
using Umbraco.Cms.Core.Sync;
namespace Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs;
@@ -15,24 +14,13 @@ namespace Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs;
/// Will run on all servers - even though file upload should only be handled on the scheduling publisher, this will
/// ensure that in the case it happens on subscribers that they are cleaned up too.
/// </remarks>
public class TempFileCleanupJob : IRecurringBackgroundJob
public class TempFileCleanupJob : RecurringBackgroundJobBase
{
/// <summary>
/// Gets the time interval between each execution of the temporary file cleanup job.
/// </summary>
public TimeSpan Period { get => TimeSpan.FromMinutes(60); }
/// <summary>
/// Gets the server roles on which this job runs. This job is configured to run on all server roles.
/// </summary>
/// <remarks>Runs on all servers</remarks>
public ServerRole[] ServerRoles { get => Enum.GetValues<ServerRole>(); }
/// <summary>
/// Occurs when the period of the TempFileCleanupJob changes.
/// </summary>
/// <remarks>No-op event as the period never changes on this job</remarks>
public event EventHandler PeriodChanged { add { } remove { } }
public override ServerRole[] ServerRoles => Enum.GetValues<ServerRole>();
private readonly TimeSpan _age = TimeSpan.FromDays(1);
private readonly IIOHelper _ioHelper;
@@ -45,28 +33,33 @@ public class TempFileCleanupJob : IRecurringBackgroundJob
/// <param name="ioHelper">Helper service for IO operations.</param>
/// <param name="logger">The typed logger.</param>
public TempFileCleanupJob(IIOHelper ioHelper, ILogger<TempFileCleanupJob> logger)
: base(TimeSpan.FromMinutes(60))
{
_ioHelper = ioHelper;
_logger = logger;
_tempFolders = _ioHelper.GetTempFolders();
}
/// <summary>
/// Asynchronously executes the cleanup of temporary files in the configured temporary folders.
/// </summary>
/// <returns>A task that represents the asynchronous cleanup operation.</returns>
public Task RunJobAsync()
/// <param name="cancellationToken">A cancellation token that is signaled when the host is shutting down.</param>
/// <returns>
/// A task that represents the asynchronous cleanup operation.
/// </returns>
public override Task RunJobAsync(CancellationToken cancellationToken)
{
foreach (DirectoryInfo folder in _tempFolders)
{
CleanupFolder(folder);
cancellationToken.ThrowIfCancellationRequested();
CleanupFolder(folder, cancellationToken);
}
return Task.CompletedTask;
}
private void CleanupFolder(DirectoryInfo folder)
private void CleanupFolder(DirectoryInfo folder, CancellationToken cancellationToken)
{
CleanFolderResult result = _ioHelper.CleanFolder(folder, _age);
switch (result.Status)
@@ -96,6 +89,8 @@ public class TempFileCleanupJob : IRecurringBackgroundJob
FileInfo[] files = folder.GetFiles("*.*", SearchOption.AllDirectories);
foreach (FileInfo file in files)
{
cancellationToken.ThrowIfCancellationRequested();
if (DateTime.UtcNow - file.LastWriteTimeUtc > _age)
{
try
@@ -110,5 +105,4 @@ public class TempFileCleanupJob : IRecurringBackgroundJob
}
}
}
}
@@ -0,0 +1,172 @@
using Umbraco.Cms.Core.Sync;
namespace Umbraco.Cms.Infrastructure.BackgroundJobs;
/// <summary>
/// Base class for recurring background jobs that provides default values for common properties.
/// </summary>
/// <remarks>
/// Implementors must pass an initial <see cref="Period" /> to the base constructor and implement <see cref="RunJobAsync(CancellationToken)" />.
/// </remarks>
public abstract class RecurringBackgroundJobBase : IRecurringBackgroundJob, IDisposable
{
/// <summary>
/// The default delay to use for recurring tasks for the first run after application start-up if no alternative is configured.
/// </summary>
/// <remarks>
/// The default of 3 minutes is chosen to allow the application to finish starting up and stabilize before the first execution of recurring tasks.
/// </remarks>
protected internal static readonly TimeSpan DefaultDelay = TimeSpan.FromMinutes(3);
/// <summary>
/// The default back-off to use when an execution is ignored, before re-evaluating execution conditions.
/// </summary>
/// <remarks>
/// The default of 1 minute prevents tight looping when an execution is skipped (e.g. runtime not ready, wrong server role or not main domain) and the configured <see cref="IRecurringBackgroundJob.Period" /> is short or <see cref="TimeSpan.Zero" />.
/// </remarks>
protected internal static readonly TimeSpan DefaultIgnoredDelay = TimeSpan.FromMinutes(1);
/// <summary>
/// The default server roles that recurring background jobs run on.
/// </summary>
/// <remarks>
/// The default of running on both <see cref="ServerRole.Single" /> and <see cref="ServerRole.SchedulingPublisher" /> is chosen to ensure recurring background jobs do not run on every server (in a load-balanced environment).
/// </remarks>
protected internal static readonly ServerRole[] DefaultServerRoles = [ServerRole.Single, ServerRole.SchedulingPublisher];
private TimeSpan _period;
private TimeSpan _ignoredDelay = DefaultIgnoredDelay;
private EventHandler? _periodChanged;
private EventHandler? _ignoredDelayChanged;
/// <summary>
/// Initializes a new instance of the <see cref="RecurringBackgroundJobBase" /> class with the specified initial <paramref name="period" />. The initial value is stored directly without raising <see cref="PeriodChanged" />.
/// </summary>
/// <param name="period">The initial period between executions. Set to <see cref="Timeout.InfiniteTimeSpan" /> for a manual-trigger-only job.</param>
/// <exception cref="ArgumentOutOfRangeException">Thrown when <paramref name="period" /> is negative and not <see cref="Timeout.InfiniteTimeSpan" />.</exception>
protected RecurringBackgroundJobBase(TimeSpan period)
{
if (period != Timeout.InfiniteTimeSpan)
{
ArgumentOutOfRangeException.ThrowIfLessThan(period, TimeSpan.Zero);
}
_period = period;
}
/// <inheritdoc />
/// <remarks>
/// Setting this property to a different value raises <see cref="PeriodChanged" />. The initial value passed to the constructor is stored without raising the event.
/// </remarks>
public virtual TimeSpan Period
{
get => _period;
protected set
{
if (value != Timeout.InfiniteTimeSpan)
{
ArgumentOutOfRangeException.ThrowIfLessThan(value, TimeSpan.Zero);
}
if (_period == value)
{
return;
}
_period = value;
OnPeriodChanged(EventArgs.Empty);
}
}
/// <inheritdoc />
public virtual TimeSpan Delay => DefaultDelay;
/// <inheritdoc />
/// <remarks>
/// Setting this property to a different value raises <see cref="IgnoredDelayChanged" />. The initial value (<see cref="DefaultIgnoredDelay" />) is set without raising the event.
/// </remarks>
public virtual TimeSpan IgnoredDelay
{
get => _ignoredDelay;
protected set
{
if (value != Timeout.InfiniteTimeSpan)
{
ArgumentOutOfRangeException.ThrowIfLessThan(value, TimeSpan.Zero);
}
if (_ignoredDelay == value)
{
return;
}
_ignoredDelay = value;
OnIgnoredDelayChanged(EventArgs.Empty);
}
}
/// <inheritdoc />
public virtual ServerRole[] ServerRoles => DefaultServerRoles;
/// <inheritdoc />
public virtual event EventHandler PeriodChanged
{
add { _periodChanged += value; }
remove { _periodChanged -= value; }
}
/// <inheritdoc />
public virtual event EventHandler IgnoredDelayChanged
{
add { _ignoredDelayChanged += value; }
remove { _ignoredDelayChanged -= value; }
}
/// <summary>
/// Raises the <see cref="PeriodChanged" /> event.
/// </summary>
/// <param name="e">The <see cref="EventArgs" /> instance containing the event data.</param>
/// <remarks>
/// Override this when overriding <see cref="PeriodChanged" /> to dispatch through the overridden event's backing delegate.
/// </remarks>
protected virtual void OnPeriodChanged(EventArgs e)
=> _periodChanged?.Invoke(this, e);
/// <summary>
/// Raises the <see cref="IgnoredDelayChanged" /> event.
/// </summary>
/// <param name="e">The <see cref="EventArgs" /> instance containing the event data.</param>
/// <remarks>
/// Override this when overriding <see cref="IgnoredDelayChanged" /> to dispatch through the overridden event's backing delegate.
/// </remarks>
protected virtual void OnIgnoredDelayChanged(EventArgs e)
=> _ignoredDelayChanged?.Invoke(this, e);
/// <inheritdoc />
[Obsolete("Use RunJobAsync(CancellationToken) instead. Scheduled for removal in Umbraco 19.")]
public Task RunJobAsync() => RunJobAsync(CancellationToken.None);
/// <inheritdoc />
public abstract Task RunJobAsync(CancellationToken cancellationToken);
/// <inheritdoc />
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
/// <summary>
/// Releases the resources used by this job. Subclasses adding disposable state should override this method, dispose their own resources, and call <c>base.Dispose(disposing)</c>.
/// </summary>
/// <param name="disposing"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param>
protected virtual void Dispose(bool disposing)
{
if (disposing)
{
// Clear the subscriber delegates so the job does not retain references to (or invoke) listeners after disposal.
_periodChanged = null;
_ignoredDelayChanged = null;
}
}
}
@@ -1,16 +1,15 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Serilog.Core;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Core.Runtime;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Sync;
using Umbraco.Cms.Infrastructure.HostedServices;
using Umbraco.Cms.Infrastructure.Notifications;
namespace Umbraco.Cms.Infrastructure.BackgroundJobs;
@@ -23,32 +22,72 @@ public static class RecurringBackgroundJobHostedService
/// Creates a factory function that produces hosted services for recurring background jobs.
/// </summary>
/// <param name="serviceProvider">The service provider used to create hosted service instances.</param>
/// <returns>A function that takes an <see cref="IRecurringBackgroundJob"/> and returns an <see cref="IHostedService"/>.</returns>
public static Func<IRecurringBackgroundJob, IHostedService> CreateHostedServiceFactory(IServiceProvider serviceProvider) =>
(IRecurringBackgroundJob job) =>
/// <returns>
/// A function that takes an <see cref="IRecurringBackgroundJob" /> and returns an <see cref="IHostedService" />.
/// </returns>
public static Func<IRecurringBackgroundJob, IHostedService> CreateHostedServiceFactory(IServiceProvider serviceProvider)
=> (IRecurringBackgroundJob job) =>
{
Type hostedServiceType = typeof(RecurringBackgroundJobHostedService<>).MakeGenericType(job.GetType());
return (IHostedService)ActivatorUtilities.CreateInstance(serviceProvider, hostedServiceType, job);
};
}
/// <summary>
/// Runs a recurring background job inside a hosted service.
/// Generic version for DependencyInjection
/// </summary>
/// <typeparam name="TJob">Type of the Job</typeparam>
public class RecurringBackgroundJobHostedService<TJob> : RecurringHostedServiceBase where TJob : IRecurringBackgroundJob
/// <typeparam name="TJob">The type of the job.</typeparam>
public class RecurringBackgroundJobHostedService<TJob> : RecurringHostedServiceBase
where TJob : IRecurringBackgroundJob
{
private readonly IRuntimeState _runtimeState;
private readonly ILogger<RecurringBackgroundJobHostedService<TJob>> _logger;
private readonly IMainDom _mainDom;
private readonly IRuntimeState _runtimeState;
private readonly IServerRoleAccessor _serverRoleAccessor;
private readonly IEventAggregator _eventAggregator;
private readonly IEventMessagesFactory _eventMessagesFactory;
private readonly IRecurringBackgroundJob _job;
private readonly TimeProvider _timeProvider;
private CancellationTokenSource _ignoredDelayChangeCts = new();
/// <summary>
/// Initializes a new instance of the <see cref="RecurringBackgroundJobHostedService{TJob}"/> class, which manages the execution of a recurring background job.
/// Initializes a new instance of the <see cref="RecurringBackgroundJobHostedService{TJob}" /> class, which manages the execution of a recurring background job.
/// </summary>
/// <param name="runtimeState">Provides information about the current runtime state of the Umbraco application.</param>
/// <param name="logger">The logger used to record diagnostic and operational information for this hosted service.</param>
/// <param name="mainDom">The main domain instance responsible for coordinating single-instance operations across multiple application domains.</param>
/// <param name="serverRoleAccessor">Determines the current server's role in a multi-server environment.</param>
/// <param name="eventAggregator">Handles the publishing and subscribing of application events.</param>
/// <param name="eventMessagesFactory">The event messages factory.</param>
/// <param name="job">The recurring background job instance to be managed and executed by this service.</param>
/// <param name="timeProvider">The time provider used for scheduling and elapsed time measurement.</param>
public RecurringBackgroundJobHostedService(
IRuntimeState runtimeState,
ILogger<RecurringBackgroundJobHostedService<TJob>> logger,
IMainDom mainDom,
IServerRoleAccessor serverRoleAccessor,
IEventAggregator eventAggregator,
IEventMessagesFactory eventMessagesFactory,
TJob job,
TimeProvider timeProvider)
: base(logger, job.Period, job.Delay, timeProvider)
{
_runtimeState = runtimeState;
_logger = logger;
_mainDom = mainDom;
_serverRoleAccessor = serverRoleAccessor;
_eventAggregator = eventAggregator;
_eventMessagesFactory = eventMessagesFactory;
_job = job;
_timeProvider = timeProvider;
_job.PeriodChanged += OnPeriodChanged;
_job.IgnoredDelayChanged += OnIgnoredDelayChanged;
}
/// <summary>
/// Initializes a new instance of the <see cref="RecurringBackgroundJobHostedService{TJob}" /> class, which manages the execution of a recurring background job.
/// </summary>
/// <param name="runtimeState">Provides information about the current runtime state of the Umbraco application.</param>
/// <param name="logger">The logger used to record diagnostic and operational information for this hosted service.</param>
@@ -56,6 +95,7 @@ public class RecurringBackgroundJobHostedService<TJob> : RecurringHostedServiceB
/// <param name="serverRoleAccessor">Determines the current server's role in a multi-server environment.</param>
/// <param name="eventAggregator">Handles the publishing and subscribing of application events.</param>
/// <param name="job">The recurring background job instance to be managed and executed by this service.</param>
[Obsolete("Use the constructor accepting IEventMessagesFactory and TimeProvider instead. Scheduled for removal in Umbraco 19.")]
public RecurringBackgroundJobHostedService(
IRuntimeState runtimeState,
ILogger<RecurringBackgroundJobHostedService<TJob>> logger,
@@ -63,94 +103,176 @@ public class RecurringBackgroundJobHostedService<TJob> : RecurringHostedServiceB
IServerRoleAccessor serverRoleAccessor,
IEventAggregator eventAggregator,
TJob job)
: base(logger, job.Period, job.Delay)
{
_runtimeState = runtimeState;
_logger = logger;
_mainDom = mainDom;
_serverRoleAccessor = serverRoleAccessor;
_eventAggregator = eventAggregator;
_job = job;
_job.PeriodChanged += (sender, e) => ChangePeriod(_job.Period);
}
: this(runtimeState, logger, mainDom, serverRoleAccessor, eventAggregator, StaticServiceProvider.Instance.GetRequiredService<IEventMessagesFactory>(), job, TimeProvider.System)
{ }
/// <inheritdoc />
public override async Task PerformExecuteAsync(object? state)
public override async Task PerformExecuteAsync(CancellationToken stoppingToken)
{
var executingNotification = new Notifications.RecurringBackgroundJobExecutingNotification(_job, new EventMessages());
await _eventAggregator.PublishAsync(executingNotification);
EventMessages eventMessages = _eventMessagesFactory.Get();
var executingNotification = new RecurringBackgroundJobExecutingNotification(_job, eventMessages);
await _eventAggregator.PublishAsync(executingNotification, stoppingToken);
try
{
if (_runtimeState.Level != RuntimeLevel.Run)
{
_logger.LogDebug("Job not running as runlevel not yet ready");
await _eventAggregator.PublishAsync(new Notifications.RecurringBackgroundJobIgnoredNotification(_job, new EventMessages()).WithStateFrom(executingNotification));
await IgnoreAndWaitAsync("Job not running as runlevel not yet ready", eventMessages, executingNotification, stoppingToken);
return;
}
// Don't run on replicas nor unknown role servers
if (!_job.ServerRoles.Contains(_serverRoleAccessor.CurrentServerRole))
{
_logger.LogDebug("Job not running on this server role");
await _eventAggregator.PublishAsync(new Notifications.RecurringBackgroundJobIgnoredNotification(_job, new EventMessages()).WithStateFrom(executingNotification));
await IgnoreAndWaitAsync("Job not running on this server role", eventMessages, executingNotification, stoppingToken);
return;
}
// Ensure we do not run if not main domain, but do NOT lock it
if (!_mainDom.IsMainDom)
{
_logger.LogDebug("Job not running as not MainDom");
await _eventAggregator.PublishAsync(new Notifications.RecurringBackgroundJobIgnoredNotification(_job, new EventMessages()).WithStateFrom(executingNotification));
await IgnoreAndWaitAsync("Job not running as not MainDom", eventMessages, executingNotification, stoppingToken);
return;
}
await _job.RunJobAsync();
await _eventAggregator.PublishAsync(new Notifications.RecurringBackgroundJobExecutedNotification(_job, new EventMessages()).WithStateFrom(executingNotification));
await _job.RunJobAsync(stoppingToken);
await _eventAggregator.PublishAsync(new RecurringBackgroundJobExecutedNotification(_job, eventMessages).WithStateFrom(executingNotification), stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
_logger.LogDebug("Job canceled during shutdown.");
await _eventAggregator.PublishAsync(new RecurringBackgroundJobCanceledNotification(_job, eventMessages).WithStateFrom(executingNotification), CancellationToken.None);
}
catch (Exception ex)
{
await _eventAggregator.PublishAsync(new Notifications.RecurringBackgroundJobFailedNotification(_job, new EventMessages()).WithStateFrom(executingNotification));
_logger.LogError(ex, "Unhandled exception in recurring background job.");
await _eventAggregator.PublishAsync(new RecurringBackgroundJobFailedNotification(_job, eventMessages).WithStateFrom(executingNotification), stoppingToken);
}
}
/// <summary>
/// Asynchronously starts the recurring background job and publishes notifications before and after the job is started.
/// This method first publishes a <see cref="Notifications.RecurringBackgroundJobStartingNotification"/> prior to starting the job,
/// then calls the base implementation to start the job, and finally publishes a <see cref="Notifications.RecurringBackgroundJobStartedNotification"/>.
/// </summary>
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
/// <returns>A task that represents the asynchronous start operation.</returns>
/// <inheritdoc />
[Obsolete("Override PerformExecuteAsync(CancellationToken) instead. Scheduled for removal in Umbraco 19.")]
public override Task PerformExecuteAsync(object? state) => PerformExecuteAsync(CancellationToken.None);
/// <inheritdoc />
public override async Task StartAsync(CancellationToken cancellationToken)
{
var startingNotification = new Notifications.RecurringBackgroundJobStartingNotification(_job, new EventMessages());
await _eventAggregator.PublishAsync(startingNotification);
EventMessages eventMessages = _eventMessagesFactory.Get();
var startingNotification = new RecurringBackgroundJobStartingNotification(_job, eventMessages);
await _eventAggregator.PublishAsync(startingNotification, cancellationToken);
await base.StartAsync(cancellationToken);
// Suppress execution context flow around base.StartAsync so the fire-and-forget ExecuteAsync loop
// does not capture AsyncLocal state from the host — in particular Umbraco's static AmbientScopeStack,
// which uses a ConcurrentStack<IScope> reference that, once non-null, would be shared across every
// hosted service that inherits this ExecutionContext. Without this, concurrent scope pushes/pops
// across recurring loops and other hosted services interleave and trigger "not the ambient scope"
// errors at Scope.Dispose (see DistributedJobService.EnsureJobsAsync for the original repro).
Task startTask;
using (ExecutionContext.IsFlowSuppressed() ? null : (IDisposable?)ExecutionContext.SuppressFlow())
{
startTask = base.StartAsync(cancellationToken);
}
await _eventAggregator.PublishAsync(new Notifications.RecurringBackgroundJobStartedNotification(_job, new EventMessages()).WithStateFrom(startingNotification));
await startTask;
await _eventAggregator.PublishAsync(new RecurringBackgroundJobStartedNotification(_job, eventMessages).WithStateFrom(startingNotification), cancellationToken);
}
/// <summary>
/// Asynchronously stops the recurring background job service, publishing notifications before and after stopping.
/// </summary>
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
/// <returns>A task that represents the asynchronous stop operation.</returns>
/// <inheritdoc />
public override async Task StopAsync(CancellationToken cancellationToken)
{
var stoppingNotification = new Notifications.RecurringBackgroundJobStoppingNotification(_job, new EventMessages());
await _eventAggregator.PublishAsync(stoppingNotification);
EventMessages eventMessages = _eventMessagesFactory.Get();
var stoppingNotification = new RecurringBackgroundJobStoppingNotification(_job, eventMessages);
await _eventAggregator.PublishAsync(stoppingNotification, cancellationToken);
await base.StopAsync(cancellationToken);
await _eventAggregator.PublishAsync(new Notifications.RecurringBackgroundJobStoppedNotification(_job, new EventMessages()).WithStateFrom(stoppingNotification));
await _eventAggregator.PublishAsync(new RecurringBackgroundJobStoppedNotification(_job, eventMessages).WithStateFrom(stoppingNotification), cancellationToken);
}
/// <inheritdoc />
protected override void Dispose(bool disposing)
{
if (disposing)
{
_job.PeriodChanged -= OnPeriodChanged;
_job.IgnoredDelayChanged -= OnIgnoredDelayChanged;
_ignoredDelayChangeCts.Dispose();
}
base.Dispose(disposing);
}
/// <summary>
/// Handles the <see cref="IRecurringBackgroundJob.PeriodChanged" /> event by updating the base class period.
/// </summary>
/// <param name="sender">The sender.</param>
/// <param name="e">The <see cref="EventArgs"/> instance containing the event data.</param>
private void OnPeriodChanged(object? sender, EventArgs e)
=> ChangePeriod(_job.Period);
/// <summary>
/// Handles the <see cref="IRecurringBackgroundJob.IgnoredDelayChanged" /> event by interrupting any in-progress ignored back-off so it re-reads the new <see cref="IRecurringBackgroundJob.IgnoredDelay" /> value.
/// </summary>
/// <param name="sender">The sender.</param>
/// <param name="e">The <see cref="EventArgs"/> instance containing the event data.</param>
private void OnIgnoredDelayChanged(object? sender, EventArgs e)
=> CancellationTokenSourceRotation.RotateAndCancel(ref _ignoredDelayChangeCts);
/// <summary>
/// Publishes the ignored notification and waits for <see cref="IRecurringBackgroundJob.IgnoredDelay" /> before allowing the next iteration, preventing tight looping when execution is skipped.
/// </summary>
/// <param name="message">The full debug message describing why the execution is ignored.</param>
/// <param name="eventMessages">The event messages for the notification.</param>
/// <param name="executingNotification">The originating executing notification to carry state from.</param>
/// <param name="stoppingToken">A cancellation token that is signaled when the host is shutting down.</param>
private async Task IgnoreAndWaitAsync(
string message,
EventMessages eventMessages,
RecurringBackgroundJobExecutingNotification executingNotification,
CancellationToken stoppingToken)
{
_logger.LogDebug(message);
await _eventAggregator.PublishAsync(new RecurringBackgroundJobIgnoredNotification(_job, eventMessages).WithStateFrom(executingNotification), stoppingToken);
long waitStart = _timeProvider.GetTimestamp();
while (true)
{
TimeSpan ignoredDelay = _job.IgnoredDelay;
// Skip back-off for zero/negative; Timeout.InfiniteTimeSpan means wait until shutdown or IgnoredDelayChanged.
if (ignoredDelay != Timeout.InfiniteTimeSpan && ignoredDelay <= TimeSpan.Zero)
{
return;
}
TimeSpan remaining = ComputeNextDelay(ignoredDelay, _timeProvider.GetElapsedTime(waitStart));
if (remaining == TimeSpan.Zero)
{
return;
}
CancellationToken ignoredDelayChangeToken = _ignoredDelayChangeCts.Token;
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken, ignoredDelayChangeToken);
try
{
await Task.Delay(remaining, _timeProvider, linkedCts.Token);
// Back-off complete
return;
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
// Back-off interrupted by shutdown; the ignored notification has already been published, so do not also publish canceled
return;
}
catch (OperationCanceledException) when (ignoredDelayChangeToken.IsCancellationRequested)
{
// IgnoredDelay changed — loop to re-read and recompute the remaining wait
}
}
}
}
@@ -1,25 +1,26 @@
using System.Collections.Concurrent;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Umbraco.Cms.Infrastructure.HostedServices;
namespace Umbraco.Cms.Infrastructure.BackgroundJobs;
/// <summary>
/// A hosted service that discovers and starts hosted services for any recurring background jobs in the DI container.
/// A hosted service that discovers and starts hosted services for any recurring background jobs in the DI container.
/// </summary>
public class RecurringBackgroundJobHostedServiceRunner : IHostedService
{
private readonly ILogger<RecurringBackgroundJobHostedServiceRunner> _logger;
private readonly List<IRecurringBackgroundJob> _jobs;
private readonly Func<IRecurringBackgroundJob, IHostedService> _jobFactory;
private readonly List<NamedServiceJob> _hostedServices = new();
private readonly ConcurrentDictionary<Type, IHostedService> _hostedServices = new();
/// <summary>
/// Initializes a new instance of the <see cref="RecurringBackgroundJobHostedServiceRunner"/> class.
/// Initializes a new instance of the <see cref="RecurringBackgroundJobHostedServiceRunner" /> class.
/// </summary>
/// <param name="logger">An <see cref="ILogger{RecurringBackgroundJobHostedServiceRunner}"/> used for logging within the runner.</param>
/// <param name="jobs">A collection of <see cref="IRecurringBackgroundJob"/> instances to be managed by the runner.</param>
/// <param name="jobFactory">A factory function that creates an <see cref="IHostedService"/> for each <see cref="IRecurringBackgroundJob"/>.</param>
/// <param name="logger">An <see cref="ILogger{RecurringBackgroundJobHostedServiceRunner}" /> used for logging within the runner.</param>
/// <param name="jobs">A collection of <see cref="IRecurringBackgroundJob" /> instances to be managed by the runner.</param>
/// <param name="jobFactory">A factory function that creates an <see cref="IHostedService" /> for each <see cref="IRecurringBackgroundJob" />.</param>
public RecurringBackgroundJobHostedServiceRunner(
ILogger<RecurringBackgroundJobHostedServiceRunner> logger,
IEnumerable<IRecurringBackgroundJob> jobs,
@@ -30,80 +31,122 @@ public class RecurringBackgroundJobHostedServiceRunner : IHostedService
_jobFactory = jobFactory;
}
/// <inheritdoc />
public async Task StartAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Starting recurring background jobs hosted services");
foreach (IRecurringBackgroundJob job in _jobs)
{
var jobName = job.GetType().Name;
Type jobType = job.GetType();
var added = false;
try
{
IHostedService hostedService = _hostedServices.GetOrAdd(jobType, _ =>
{
_logger.LogDebug("Creating background hosted service for {JobTypeName}", jobType.Name);
_logger.LogDebug("Creating background hosted service for {job}", jobName);
IHostedService hostedService = _jobFactory(job);
IHostedService hostedService = _jobFactory(job);
added = true;
_logger.LogInformation("Starting a background hosted service for {job} with a delay of {delay}, running every {period}", jobName, job.Delay, job.Period);
return hostedService;
});
if (!added)
{
_logger.LogWarning("A background hosted service for {JobTypeName} is already registered, skipping duplicate", jobType.Name);
continue;
}
_logger.LogInformation("Starting a background hosted service for {JobTypeName} with a delay of {Delay}, running every {Period}", jobType.Name, job.Delay, job.Period);
await hostedService.StartAsync(cancellationToken).ConfigureAwait(false);
_hostedServices.Add(new NamedServiceJob(jobName, hostedService));
}
catch (Exception exception)
catch (Exception ex)
{
_logger.LogError(exception, "Failed to start background hosted service for {job}", jobName);
if (added)
{
// Ensure we don't stop hosted services that were not successfully started
_hostedServices.TryRemove(jobType, out _);
}
_logger.LogError(ex, "Failed to start background hosted service for {JobTypeName}", jobType.Name);
}
}
_logger.LogInformation("Completed starting recurring background jobs hosted services");
}
/// <summary>
/// Asynchronously stops all recurring background job hosted services managed by this runner.
/// </summary>
/// <param name="stoppingToken">A <see cref="CancellationToken"/> that can be used to cancel the stop operation.</param>
/// <returns>A <see cref="Task"/> representing the asynchronous stop operation.</returns>
/// <inheritdoc />
public async Task StopAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Stopping recurring background jobs hosted services");
foreach (NamedServiceJob namedServiceJob in _hostedServices)
foreach (Type jobType in _hostedServices.Keys)
{
try
if (_hostedServices.TryRemove(jobType, out IHostedService? hostedService))
{
_logger.LogInformation("Stopping background hosted service for {job}", namedServiceJob.Name);
await namedServiceJob.HostedService.StopAsync(stoppingToken).ConfigureAwait(false);
}
catch (Exception exception)
{
_logger.LogError(exception, "Failed to stop background hosted service for {job}", namedServiceJob.Name);
try
{
_logger.LogInformation("Stopping background hosted service for {JobTypeName}", jobType.Name);
await hostedService.StopAsync(stoppingToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to stop background hosted service for {JobTypeName}", jobType.Name);
}
}
}
_logger.LogInformation("Completed stopping recurring background jobs hosted services");
}
private sealed class NamedServiceJob
/// <summary>
/// Signals the background loop for the specified job type to execute immediately, with the specified strategy for determining the next execution after the triggered one completes.
/// </summary>
/// <typeparam name="TJob">The type of the recurring background job to trigger.</typeparam>
/// <param name="strategy">Controls the delay after the triggered execution.</param>
/// <returns>
/// <c>true</c> if the job was found and triggered; <c>false</c> if no hosted service is running for this job type.
/// </returns>
internal bool TriggerExecution<TJob>(NextExecutionStrategy strategy)
where TJob : ITriggerableRecurringBackgroundJob
{
/// <summary>
/// Initializes a new instance of the <see cref="NamedServiceJob"/> class using the specified job name and hosted service instance.
/// </summary>
/// <param name="name">The unique name identifying the job.</param>
/// <param name="hostedService">The <see cref="IHostedService"/> instance to be executed as the background job.</param>
public NamedServiceJob(string name, IHostedService hostedService)
if (FindHostedService<TJob>() is not { } hostedService)
{
Name = name;
HostedService = hostedService;
return false;
}
/// <summary>
/// Gets the unique name that identifies this background job.
/// </summary>
public string Name { get; }
hostedService.TriggerExecution(strategy);
/// <summary>
/// Gets the hosted service instance associated with the named service job.
/// </summary>
public IHostedService HostedService { get; }
return true;
}
/// <summary>
/// Signals the background loop for the specified job type to execute immediately.
/// After the triggered execution, the next execution is scheduled after the specified delay (measured from execution start; execution time is subtracted to prevent drift).
/// </summary>
/// <typeparam name="TJob">The type of the recurring background job to trigger.</typeparam>
/// <param name="nextDelay">The target interval from execution start to the next execution. Execution time is subtracted to prevent drift.</param>
/// <returns>
/// <c>true</c> if the job was found and triggered; <c>false</c> if no hosted service is running for this job type.
/// </returns>
internal bool TriggerExecution<TJob>(TimeSpan nextDelay)
where TJob : ITriggerableRecurringBackgroundJob
{
if (FindHostedService<TJob>() is not { } hostedService)
{
return false;
}
hostedService.TriggerExecution(nextDelay);
return true;
}
private RecurringHostedServiceBase? FindHostedService<TJob>()
where TJob : ITriggerableRecurringBackgroundJob
=> _hostedServices.TryGetValue(typeof(TJob), out IHostedService? service) ? service as RecurringHostedServiceBase : null;
}
@@ -0,0 +1,35 @@
// Copyright (c) Umbraco.
// See LICENSE for more details.
using Umbraco.Cms.Infrastructure.HostedServices;
namespace Umbraco.Cms.Infrastructure.BackgroundJobs;
/// <summary>
/// Default implementation of <see cref="IRecurringBackgroundJobTrigger{TJob}" /> that delegates to the hosted service runner.
/// </summary>
/// <typeparam name="TJob">The type of the recurring background job to trigger.</typeparam>
internal sealed class RecurringBackgroundJobTrigger<TJob> : IRecurringBackgroundJobTrigger<TJob>
where TJob : class, ITriggerableRecurringBackgroundJob
{
private readonly RecurringBackgroundJobHostedServiceRunner _runner;
/// <summary>
/// Initializes a new instance of the <see cref="RecurringBackgroundJobTrigger{TJob}" /> class.
/// </summary>
/// <param name="runner">The runner.</param>
public RecurringBackgroundJobTrigger(RecurringBackgroundJobHostedServiceRunner runner)
=> _runner = runner;
/// <inheritdoc />
public bool TriggerExecution()
=> TriggerExecution(NextExecutionStrategy.None);
/// <inheritdoc />
public bool TriggerExecution(NextExecutionStrategy strategy)
=> _runner.TriggerExecution<TJob>(strategy);
/// <inheritdoc />
public bool TriggerExecution(TimeSpan nextDelay)
=> _runner.TriggerExecution<TJob>(nextDelay);
}
+51
View File
@@ -384,6 +384,57 @@ using (ICoreScope scope = ScopeProvider.CreateCoreScope())
3. **Lazy loading outside scope** - NPoco relationships must load within scope
4. **Large migrations** - Split into multiple steps if > 1000 lines
5. **Repository logic in services** - Keep repos thin, logic in services
6. **Unbatched `WHERE IN` on user-sized collections** - See "Avoiding the SQL Server 2100-parameter limit" below
### Avoiding the SQL Server 2100-parameter limit
SQL Server caps a single statement at 2100 parameters. When an `IN` clause is built from a collection sized by user data, that cap can be hit — and the symptom is a runtime `SqlException` (error 8003) on customer installs that nobody hit in dev.
**The constant and helpers**:
- `Constants.Sql.MaxParameterCount = 2000` (in `Umbraco.Core`, `Constants-Sql.cs`) — the ceiling we target (2100 minus headroom for joined predicates already in the SQL).
- `IEnumerable<T>.InGroupsOf(groupSize)` (in `Umbraco.Core`, `Extensions/EnumerableExtensions.cs`) — extension method to batch a collection.
- `Database.FetchByGroups<TResult, TSource>(source, groupSize, sqlFactory)` (in `Umbraco.Infrastructure`, `Persistence/NPocoDatabaseExtensions.cs`) — NPoco helper that batches a fetch.
**The safe patterns** (use one of these any time the collection size is user-driven):
```csharp
// Pattern 1: batch a DeleteMany / Execute / Fetch by looping.
foreach (IEnumerable<int> group in ids.InGroupsOf(Constants.Sql.MaxParameterCount))
{
Database.DeleteMany<FooDto>().Where(x => group.Contains(x.Id)).Execute();
}
// Pattern 2: batched fetch with NPoco helper.
List<FooDto> dtos = Database.FetchByGroups<FooDto, int>(
ids,
Constants.Sql.MaxParameterCount,
batch => Sql().Select<FooDto>().From<FooDto>().WhereIn<FooDto>(x => x.Id, batch));
// Pattern 3: reserve headroom for other parameters in the same statement.
foreach (IEnumerable<int> group in entityIds.InGroupsOf(Constants.Sql.MaxParameterCount - userGroupIds.Length))
{
// statement uses entityIds + userGroupIds, so subtract the other predicate's parameter count from the budget
}
```
**Decision rule when writing or reviewing a `WHERE IN`-style query**:
Look at what drives the size of the collection feeding the `IN`. Ask: *could this realistically exceed 2000 on a large install?* Risky drivers — batch any query backed by these:
- All content / media / member nodes (or descendants of a deep tree).
- A product of two scaling dimensions, e.g. `documents × languages`, `properties × versions`, `relations × endpoints`.
- Configuration-tunable batch sizes (`CacheSettings.DocumentSeedBatchSize`, `NuCacheSettings.SqlPageSize`, etc.). The default may be safe but the customer can raise it.
- Anything that scans property data, version history, relations, or audit logs across many nodes.
Safe drivers — don't bother batching:
- Languages / content types / member groups / user groups — bounded by install configuration, typically <100.
- "Per single content item" collections — properties on one document, versions of one document, tokens for one external login.
- IDs supplied directly by a user action through the UI (picker selections, bulk actions on a page of results).
If you're not sure, batch — the cost is one loop and an `IEnumerable<T>` allocation per batch; the cost of being wrong is a SqlException on a customer's biggest site.
**For new public APIs** that take an `IEnumerable<int>`/`IEnumerable<Guid>` and feed it into a query, batch internally even if no current caller is large — package authors and future callers will not know about the 2000-limit ceiling.
**Don't** rely on `if (ids.Length > MaxParameterCount) throw` as a substitute for batching. Throwing only moves the problem; the caller has no obvious way to recover and will most likely just fail in production.
---
@@ -104,6 +104,7 @@ internal sealed class JsonConfigManipulator : IConfigManipulator
}
/// <inheritdoc />
[Obsolete("This method is no longer used by Umbraco. Set the Umbraco:CMS:WebRouting:DisableRedirectUrlTracking configuration key instead. Scheduled for removal in Umbraco 19.")]
public async Task SaveDisableRedirectUrlTrackingAsync(bool disable)
=> await CreateOrUpdateConfigValueAsync(DisableRedirectUrlTrackingPath, disable);
@@ -38,7 +38,9 @@ public static partial class UmbracoBuilderExtensions
builder.Services.AddHostedService<DistributedBackgroundJobHostedService>();
builder.Services.AddSingleton(RecurringBackgroundJobHostedService.CreateHostedServiceFactory);
builder.Services.AddHostedService<RecurringBackgroundJobHostedServiceRunner>();
builder.Services.AddSingleton<RecurringBackgroundJobHostedServiceRunner>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<RecurringBackgroundJobHostedServiceRunner>());
builder.Services.AddSingleton(typeof(IRecurringBackgroundJobTrigger<>), typeof(RecurringBackgroundJobTrigger<>));
builder.Services.AddHostedService<QueuedHostedService>();
builder.AddNotificationAsyncHandler<PostRuntimePremigrationsUpgradeNotification, NavigationInitializationNotificationHandler>();
builder.AddNotificationAsyncHandler<PostRuntimePremigrationsUpgradeNotification, PublishStatusInitializationNotificationHandler>();
@@ -94,6 +94,7 @@ public static partial class UmbracoBuilderExtensions
builder.AddNotificationAsyncHandler<RuntimeUnattendedInstallNotification, UnattendedInstaller>();
builder.AddNotificationAsyncHandler<RuntimeUnattendedUpgradeNotification, UnattendedUpgrader>();
builder.AddNotificationAsyncHandler<RuntimePremigrationsUpgradeNotification, PremigrationUpgrader>();
builder.Services.AddSingleton<IMigrationCoordinator, MigrationCoordinator>();
builder.Services.AddHostedService<UnattendedUpgradeBackgroundService>();
// Database availability check.
@@ -1,6 +1,4 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Umbraco.Cms.Core.Composing;
using Umbraco.Cms.Infrastructure.BackgroundJobs;
namespace Umbraco.Extensions;
@@ -11,27 +9,22 @@ namespace Umbraco.Extensions;
public static class ServiceCollectionExtensions
{
/// <summary>
/// Adds a recurring background job with an implementation type of
/// <typeparamref name="TJob" /> to the specified <see cref="IServiceCollection" />.
/// Adds a recurring background job with an implementation type of <typeparamref name="TJob" />.
/// </summary>
/// <param name="services">The <see cref="IServiceCollection" /> to add the recurring background job to.</param>
public static void AddRecurringBackgroundJob<TJob>(
this IServiceCollection services)
where TJob : class, IRecurringBackgroundJob =>
services.AddSingleton<IRecurringBackgroundJob, TJob>();
where TJob : class, IRecurringBackgroundJob
=> services.AddSingleton<IRecurringBackgroundJob, TJob>();
/// <summary>
/// Adds a recurring background job with an implementation type of
/// <typeparamref name="TJob" /> using the factory <paramref name="implementationFactory"/>
/// to the specified <see cref="IServiceCollection" />.
/// Adds a recurring background job with an implementation type of <typeparamref name="TJob" /> using the factory <paramref name="implementationFactory" />.
/// </summary>
/// <param name="services">The <see cref="IServiceCollection" /> to add the recurring background job to.</param>
/// <param name="implementationFactory">A factory function to create an instance of <typeparamref name="TJob" /> using the provided <see cref="IServiceProvider" />.</param>
public static void AddRecurringBackgroundJob<TJob>(
this IServiceCollection services,
Func<IServiceProvider, TJob> implementationFactory)
where TJob : class, IRecurringBackgroundJob =>
services.AddSingleton<IRecurringBackgroundJob, TJob>(implementationFactory);
where TJob : class, IRecurringBackgroundJob
=> services.AddSingleton<IRecurringBackgroundJob, TJob>(implementationFactory);
}
@@ -0,0 +1,33 @@
// Copyright (c) Umbraco.
// See LICENSE for more details.
namespace Umbraco.Cms.Infrastructure.HostedServices;
/// <summary>
/// Helpers for rotating <see cref="CancellationTokenSource" /> instances used to interrupt cooperative waits.
/// </summary>
internal static class CancellationTokenSourceRotation
{
/// <summary>
/// Atomically installs a fresh <see cref="CancellationTokenSource" /> at <paramref name="field" /> and cancels the previous one without disposing it.
/// If the previous CTS has already been disposed (lost the shutdown race), the newly installed CTS is also disposed since no waiter will observe it.
/// </summary>
/// <param name="field">A reference to the field holding the active CTS.</param>
/// <remarks>
/// The previous CTS is not disposed because the wait loop may still be registering against its token. Once cancelled it is small and will be collected by the GC.
/// </remarks>
public static void RotateAndCancel(ref CancellationTokenSource field)
{
var newCts = new CancellationTokenSource();
CancellationTokenSource oldCts = Interlocked.Exchange(ref field, newCts);
try
{
oldCts.Cancel();
}
catch (ObjectDisposedException)
{
newCts.Dispose();
}
}
}
@@ -0,0 +1,30 @@
// Copyright (c) Umbraco.
// See LICENSE for more details.
namespace Umbraco.Cms.Infrastructure.HostedServices;
/// <summary>
/// Determines the next execution strategy after a manually triggered execution completes.
/// </summary>
public enum NextExecutionStrategy
{
/// <summary>
/// Keep the current scheduled run unchanged.
/// The next execution occurs at the originally-scheduled time.
/// If that time has already passed (e.g. the triggered execution took longer than the remaining wait), it is skipped and the next period tick is awaited instead.
/// </summary>
None,
/// <summary>
/// Reset the period: wait a full period after the triggered execution completes.
/// The triggered execution effectively shifts the schedule forward.
/// </summary>
Reset,
/// <summary>
/// The triggered execution replaces the next scheduled run.
/// The following execution occurs one full period after the originally-scheduled time.
/// Use this when the manual trigger is an early execution of the next scheduled run.
/// </summary>
Replace,
}
@@ -1,7 +1,6 @@
// Copyright (c) Umbraco.
// See LICENSE for more details.
using System.Diagnostics;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Umbraco.Cms.Core;
@@ -10,168 +9,268 @@ using Umbraco.Cms.Core.Configuration;
namespace Umbraco.Cms.Infrastructure.HostedServices;
/// <summary>
/// Provides a base class for recurring background tasks implemented as hosted services.
/// Provides a base class for recurring background tasks implemented as hosted services.
/// </summary>
/// <remarks>
/// See: <see href="https://docs.microsoft.com/en-us/aspnet/core/fundamentals/host/hosted-services?view=aspnetcore-3.1&amp;tabs=visual-studio#timed-background-tasks"/>.
/// </remarks>
public abstract class RecurringHostedServiceBase : IHostedService, IDisposable
public abstract class RecurringHostedServiceBase : BackgroundService
{
/// <summary>
/// The default delay to use for recurring tasks for the first run after application start-up if no alternative is
/// configured.
/// The default delay to use for recurring tasks for the first run after application start-up if no alternative is configured.
/// </summary>
protected static readonly TimeSpan DefaultDelay = TimeSpan.FromMinutes(3);
private readonly TimeSpan _delay;
private readonly ILogger? _logger;
private bool _disposedValue;
private TimeSpan _period;
private Timer? _timer;
private readonly TimeProvider _timeProvider;
private readonly SemaphoreSlim _signal = new(0, 1);
private CancellationTokenSource _periodChangeCts = new();
private long _periodTicks;
private TriggerState _triggerState = TriggerState.Default;
private volatile bool _nextExecutionSkipOnOvershoot;
private int _isDisposed;
/// <summary>
/// Initializes a new instance of the <see cref="RecurringHostedServiceBase" /> class.
/// Initializes a new instance of the <see cref="RecurringHostedServiceBase" /> class.
/// </summary>
/// <param name="logger">Logger.</param>
/// <param name="period">Timespan representing how often the task should recur. Set to <see cref="Timeout.InfiniteTimeSpan" /> to disable automatic scheduling and only run when manually triggered via <see cref="TriggerExecution()" />.</param>
/// <param name="delay">Timespan representing the initial delay after application start-up before the first run of the task occurs. Set to <see cref="Timeout.InfiniteTimeSpan" /> to skip the automatic first run; the first execution then only occurs when manually triggered via <see cref="TriggerExecution()" />.</param>
/// <param name="timeProvider">The time provider used for scheduling and elapsed time measurement.</param>
protected RecurringHostedServiceBase(ILogger? logger, TimeSpan period, TimeSpan delay, TimeProvider timeProvider)
{
if (period != Timeout.InfiniteTimeSpan)
{
ArgumentOutOfRangeException.ThrowIfLessThan(period, TimeSpan.Zero);
}
if (delay != Timeout.InfiniteTimeSpan)
{
ArgumentOutOfRangeException.ThrowIfLessThan(delay, TimeSpan.Zero);
}
_logger = logger;
Interlocked.Exchange(ref _periodTicks, period.Ticks);
_delay = delay;
_timeProvider = timeProvider;
}
/// <summary>
/// Initializes a new instance of the <see cref="RecurringHostedServiceBase" /> class.
/// </summary>
/// <param name="logger">Logger.</param>
/// <param name="period">Timespan representing how often the task should recur.</param>
/// <param name="delay">
/// Timespan representing the initial delay after application start-up before the first run of the task
/// occurs.
/// </param>
/// <param name="delay">Timespan representing the initial delay after application start-up before the first run of the task occurs.</param>
[Obsolete("Use the constructor accepting TimeProvider. Scheduled for removal in Umbraco 19.")]
protected RecurringHostedServiceBase(ILogger? logger, TimeSpan period, TimeSpan delay)
{
_logger = logger;
_period = period;
_delay = delay;
}
/// <inheritdoc />
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
: this(logger, period, delay, TimeProvider.System)
{ }
/// <summary>
/// Determines the delay before the first run of a recurring task implemented as a hosted service when an optonal
/// configuration for the first run time is available.
/// Determines the delay before the first run of a recurring task implemented as a hosted service when an optional configuration for the first run time is available.
/// </summary>
/// <param name="firstRunTime">The configured time to first run the task in crontab format.</param>
/// <param name="cronTabParser">An instance of <see cref="ICronTabParser"/></param>
/// <param name="cronTabParser">An instance of <see cref="ICronTabParser" />.</param>
/// <param name="logger">The logger.</param>
/// <param name="defaultDelay">The default delay to use when a first run time is not configured.</param>
/// <returns>The delay before first running the recurring task.</returns>
protected static TimeSpan GetDelay(
string firstRunTime,
ICronTabParser cronTabParser,
ILogger logger,
TimeSpan defaultDelay) => GetDelay(firstRunTime, cronTabParser, logger, DateTime.Now, defaultDelay);
/// <returns>
/// The delay before first running the recurring task.
/// </returns>
[Obsolete("Use DelayCalculator.GetDelay instead. Scheduled for removal in Umbraco 19.")]
protected static TimeSpan GetDelay(string firstRunTime, ICronTabParser cronTabParser, ILogger logger, TimeSpan defaultDelay)
=> BackgroundJobs.DelayCalculator.GetDelay(firstRunTime, cronTabParser, logger, defaultDelay);
/// <inheritdoc />
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// Initial delay (also interruptible via signal)
bool signaled = false;
if (_delay != TimeSpan.Zero)
{
try
{
// Do not cancel/signal the wait when the period changes during the initial delay
signaled = await WaitForSignalAsync(_delay, CancellationToken.None, stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
return;
}
}
// Honor a TriggerExecution(TimeSpan) issued during the initial delay on the first wait cycle.
// Strategy-only triggers (None/Reset/Replace) have no custom delay and collapse to the normal Period —
// there is no "next scheduled tick" yet for Replace to skip, and None/Reset reduce to "use Period" in this phase.
TimeSpan nextDelayBasis = ReadPeriod();
if (signaled)
{
TriggerState initialTrigger = Interlocked.Exchange(ref _triggerState, TriggerState.Default);
if (initialTrigger.Delay.HasValue)
{
nextDelayBasis = initialTrigger.Delay.Value;
}
}
while (!stoppingToken.IsCancellationRequested)
{
long startTimestamp = _timeProvider.GetTimestamp();
try
{
await PerformExecuteAsync(stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
ILogger logger = _logger ?? StaticApplicationLogging.CreateLogger(GetType());
logger.LogError(ex, "Unhandled exception in recurring hosted service.");
}
TimeSpan executionElapsed = _timeProvider.GetElapsedTime(startTimestamp);
nextDelayBasis = await WaitForNextExecutionAsync(nextDelayBasis, executionElapsed, stoppingToken);
}
}
/// <summary>
/// Determines the delay before the first run of a recurring task implemented as a hosted service when an optonal
/// configuration for the first run time is available.
/// Waits for the remaining period (minus execution time) before the next execution.
/// If <see cref="TriggerExecution()" /> is called, the wait exits immediately and returns the delay basis for the execution after the triggered one.
/// </summary>
/// <param name="firstRunTime">The configured time to first run the task in crontab format.</param>
/// <param name="cronTabParser">An instance of <see cref="ICronTabParser"/></param>
/// <param name="logger">The logger.</param>
/// <param name="now">The current datetime.</param>
/// <param name="defaultDelay">The default delay to use when a first run time is not configured.</param>
/// <returns>The delay before first running the recurring task.</returns>
/// <remarks>Internal to expose for unit tests.</remarks>
internal static TimeSpan GetDelay(
string firstRunTime,
ICronTabParser cronTabParser,
ILogger logger,
DateTime now,
TimeSpan defaultDelay)
/// <param name="delayBasis">The delay basis.</param>
/// <param name="executionElapsed">The execution elapsed.</param>
/// <param name="stoppingToken">The stopping token.</param>
/// <returns>
/// The delay basis to use for the next wait cycle.
/// </returns>
private async Task<TimeSpan> WaitForNextExecutionAsync(TimeSpan delayBasis, TimeSpan executionElapsed, CancellationToken stoppingToken)
{
// If first run time not set, start with just small delay after application start.
if (string.IsNullOrEmpty(firstRunTime))
TimeSpan period = ReadPeriod();
TimeSpan delay = ComputeNextDelay(delayBasis, executionElapsed);
// If the delay basis was from a NextExecutionStrategy.None trigger and the execution overshot the scheduled time,
// advance to the next period tick instead of executing immediately.
// The flag is consumed unconditionally so it never leaks into later cycles.
bool skipOnOvershoot = _nextExecutionSkipOnOvershoot;
_nextExecutionSkipOnOvershoot = false;
if (delay == TimeSpan.Zero && skipOnOvershoot)
{
return defaultDelay;
delay = ComputeNextDelay(delayBasis + period, executionElapsed);
}
// If first run time not a valid cron tab, log, and revert to small delay after application start.
if (!cronTabParser.IsValidCronTab(firstRunTime))
if (delay == TimeSpan.Zero)
{
logger.LogWarning("Could not parse {FirstRunTime} as a crontab expression. Defaulting to default delay for hosted service start.", firstRunTime);
return defaultDelay;
return period;
}
// Otherwise start at scheduled time according to cron expression, unless within the default delay period.
DateTime firstRunOccurance = cronTabParser.GetNextOccurrence(firstRunTime, now);
TimeSpan delay = firstRunOccurance - now;
return delay < defaultDelay
? defaultDelay
: delay;
}
long waitStart = _timeProvider.GetTimestamp();
/// <inheritdoc />
public virtual Task StartAsync(CancellationToken cancellationToken)
{
using (!ExecutionContext.IsFlowSuppressed() ? (IDisposable)ExecutionContext.SuppressFlow() : null)
while (true)
{
_timer = new Timer(ExecuteAsync, null, _delay, _period);
CancellationToken periodChangeToken = _periodChangeCts.Token;
bool signaled;
try
{
signaled = await WaitForSignalAsync(delay, periodChangeToken, stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
return ReadPeriod();
}
if (signaled is false && periodChangeToken.IsCancellationRequested)
{
// Period changed — re-read and recalculate remaining delay with the new period.
period = ReadPeriod();
TimeSpan totalElapsed = executionElapsed + _timeProvider.GetElapsedTime(waitStart);
delay = ComputeNextDelay(period, totalElapsed);
if (delay == TimeSpan.Zero)
{
return period;
}
continue;
}
if (signaled is false)
{
return period; // Normal timeout — next wait uses normal period.
}
return ComputeNextDelayFromTriggerState(delay, waitStart, period);
}
return Task.CompletedTask;
}
/// <inheritdoc />
public virtual Task StopAsync(CancellationToken cancellationToken)
{
_period = Timeout.InfiniteTimeSpan;
_timer?.Change(Timeout.Infinite, 0);
return Task.CompletedTask;
}
/// <summary>
/// Executes the task.
/// Computes the next wait cycle's delay basis from the pending <see cref="TriggerState" />, consuming it in the process.
/// </summary>
/// <param name="delay">The delay that was being waited on when the trigger arrived.</param>
/// <param name="waitStart">The timestamp at which the wait started, used to measure how much of <paramref name="delay" /> remains.</param>
/// <param name="period">The current period, used by the <see cref="NextExecutionStrategy.Reset" /> and <see cref="NextExecutionStrategy.Replace" /> strategies.</param>
/// <returns>
/// The delay basis for the next wait cycle.
/// </returns>
private TimeSpan ComputeNextDelayFromTriggerState(TimeSpan delay, long waitStart, TimeSpan period)
{
TriggerState triggerState = Interlocked.Exchange(ref _triggerState, TriggerState.Default);
if (triggerState.Delay.HasValue)
{
return triggerState.Delay.Value;
}
TimeSpan waitElapsed = _timeProvider.GetElapsedTime(waitStart);
TimeSpan remaining = ComputeNextDelay(delay, waitElapsed);
switch (triggerState.Strategy)
{
case NextExecutionStrategy.None:
_nextExecutionSkipOnOvershoot = true;
return remaining;
case NextExecutionStrategy.Replace:
return remaining == Timeout.InfiniteTimeSpan || period == Timeout.InfiniteTimeSpan
? Timeout.InfiniteTimeSpan
: remaining + period;
case NextExecutionStrategy.Reset:
default:
return period;
}
}
/// <summary>
/// Implements the work of the recurring task.
/// </summary>
/// <param name="stoppingToken">A cancellation token that is signaled when the host is shutting down.</param>
/// <returns>
/// A task representing the asynchronous operation.
/// </returns>
public virtual Task PerformExecuteAsync(CancellationToken stoppingToken)
#pragma warning disable CS0618 // Type or member is obsolete
=> PerformExecuteAsync(null);
#pragma warning restore CS0618 // Type or member is obsolete
/// <summary>
/// Implements the work of the recurring task.
/// </summary>
/// <param name="state">The task state.</param>
public virtual async void ExecuteAsync(object? state)
{
var sw = Stopwatch.StartNew();
try
{
// First, stop the timer, we do not want tasks to execute in parallel
_timer?.Change(Timeout.Infinite, 0);
// Delegate work to method returning a task, that can be called and asserted in a unit test.
// Without this there can be behaviour where tests pass, but an error within them causes the test
// running process to crash.
// Hat-tip: https://stackoverflow.com/a/14207615/489433
await PerformExecuteAsync(state);
}
catch (Exception ex)
{
ILogger logger = _logger ?? StaticApplicationLogging.CreateLogger(GetType());
logger.LogError(ex, "Unhandled exception in recurring hosted service.");
}
finally
{
sw.Stop();
// If the service has been stopped, _period is set to InfiniteTimeSpan in StopAsync.
// Preserve it to keep the timer disabled.
TimeSpan remaining = _period == Timeout.InfiniteTimeSpan
? Timeout.InfiniteTimeSpan
: ComputeNextDelay(_period, sw.Elapsed);
_timer?.Change(remaining, _period);
}
}
/// <returns>
/// A task representing the asynchronous operation.
/// </returns>
/// <remarks>
/// This overload does not receive a <see cref="CancellationToken" />, so shutdown cancellation is not propagated to the implementation.
/// </remarks>
[Obsolete("Override PerformExecuteAsync(CancellationToken) instead. Scheduled for removal in Umbraco 19.")]
public virtual Task PerformExecuteAsync(object? state)
=> Task.CompletedTask;
/// <summary>
/// Executes the core logic of the recurring hosted service asynchronously.
/// Executes the task.
/// </summary>
/// <param name="state">An optional object containing state information for the execution.</param>
/// <returns>A <see cref="Task"/> that represents the asynchronous execution of the recurring task.</returns>
public abstract Task PerformExecuteAsync(object? state);
/// <param name="state">The task state.</param>
[Obsolete("No longer used. The base class now uses BackgroundService.ExecuteAsync(CancellationToken). Scheduled for removal in Umbraco 19.")]
public virtual void ExecuteAsync(object? state)
{ }
/// <summary>
/// Computes the delay before the next execution, subtracting the elapsed execution time from the period to prevent drift.
/// Clamps to <see cref="TimeSpan.Zero" /> if execution exceeded the period.
/// </summary>
/// <param name="period">The configured period between executions.</param>
/// <param name="elapsed">The elapsed time of the current execution.</param>
@@ -183,30 +282,155 @@ public abstract class RecurringHostedServiceBase : IHostedService, IDisposable
/// </remarks>
internal static TimeSpan ComputeNextDelay(TimeSpan period, TimeSpan elapsed)
{
if (period == Timeout.InfiniteTimeSpan)
{
return Timeout.InfiniteTimeSpan;
}
TimeSpan remaining = period - elapsed;
// A negative period (e.g. Timeout.InfiniteTimeSpan = -1ms, set by StopAsync) will always produce a
// negative remaining value. The caller in ExecuteAsync guards against this by checking for InfiniteTimeSpan
// before calling this method, to avoid scheduling an extra execution after stop.
return remaining < TimeSpan.Zero ? TimeSpan.Zero : remaining;
}
/// <summary>
/// Change the period between operations.
/// Change the period between operations. The new period takes effect immediately, interrupting the current wait if necessary.
/// </summary>
/// <param name="newPeriod">The new period between tasks</param>
protected void ChangePeriod(TimeSpan newPeriod) => _period = newPeriod;
protected virtual void Dispose(bool disposing)
/// <param name="newPeriod">The new period between tasks. Set to <see cref="Timeout.InfiniteTimeSpan" /> to (temporarily) disable automatic scheduling and turn the loop into a manually triggered one; change back to a finite period to resume scheduling.</param>
protected void ChangePeriod(TimeSpan newPeriod)
{
if (!_disposedValue)
if (newPeriod != Timeout.InfiniteTimeSpan)
{
if (disposing)
{
_timer?.Dispose();
}
ArgumentOutOfRangeException.ThrowIfLessThan(newPeriod, TimeSpan.Zero);
}
_disposedValue = true;
Interlocked.Exchange(ref _periodTicks, newPeriod.Ticks);
CancellationTokenSourceRotation.RotateAndCancel(ref _periodChangeCts);
}
/// <summary>
/// Signals the background loop to execute immediately.
/// After the triggered execution, the original schedule is kept.
/// If the scheduled time has already passed during the triggered execution, it is skipped and the next period tick is awaited.
/// </summary>
/// <seealso cref="NextExecutionStrategy.None" />
protected internal void TriggerExecution()
=> TriggerExecution(NextExecutionStrategy.None);
/// <summary>
/// Signals the background loop to execute immediately, with the specified strategy for determining the next execution after the triggered one completes.
/// </summary>
/// <param name="strategy">Controls the delay after the triggered execution.</param>
protected internal void TriggerExecution(NextExecutionStrategy strategy)
{
Interlocked.Exchange(ref _triggerState, new TriggerState(Strategy: strategy));
ReleaseSignal();
}
/// <summary>
/// Signals the background loop to execute immediately.
/// After the triggered execution, the next execution is scheduled after the specified delay (measured from execution start; execution time is subtracted to prevent drift).
/// </summary>
/// <param name="nextDelay">The target interval from execution start to the next execution. Execution time is subtracted to prevent drift. Set to <see cref="Timeout.InfiniteTimeSpan" /> to leave the loop in manually triggered mode after this execution.</param>
protected internal void TriggerExecution(TimeSpan nextDelay)
{
if (nextDelay != Timeout.InfiniteTimeSpan)
{
ArgumentOutOfRangeException.ThrowIfLessThan(nextDelay, TimeSpan.Zero);
}
Interlocked.Exchange(ref _triggerState, new TriggerState(Delay: nextDelay));
ReleaseSignal();
}
/// <summary>
/// Reads the current period in a thread-safe manner.
/// </summary>
/// <returns>
/// The current period between executions.
/// </returns>
private TimeSpan ReadPeriod()
=> TimeSpan.FromTicks(Interlocked.Read(ref _periodTicks));
/// <summary>
/// Waits for the semaphore to be signaled or for the timeout to expire, using the injected <see cref="TimeProvider" />.
/// </summary>
/// <param name="timeout">The maximum time to wait.</param>
/// <param name="periodChangeToken">A cancellation token that is signaled when the period changes.</param>
/// <param name="stoppingToken">A cancellation token for shutdown.</param>
/// <returns>
/// <c>true</c> if the semaphore was signaled; <c>false</c> if the timeout expired or the period changed.
/// </returns>
/// <exception cref="OperationCanceledException">Thrown when <paramref name="stoppingToken" /> is cancelled.</exception>
private async Task<bool> WaitForSignalAsync(TimeSpan timeout, CancellationToken periodChangeToken, CancellationToken stoppingToken)
{
using var timeoutCts = new CancellationTokenSource(timeout, _timeProvider);
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(timeoutCts.Token, periodChangeToken, stoppingToken);
try
{
await _signal.WaitAsync(linkedCts.Token);
return true;
}
catch (OperationCanceledException) when (!stoppingToken.IsCancellationRequested)
{
return false; // Timeout expired or period changed
}
}
/// <summary>
/// Releases the semaphore to wake the background loop. If the semaphore is already signaled, the call is a no-op.
/// </summary>
private void ReleaseSignal()
{
try
{
_signal.Release();
}
catch (SemaphoreFullException)
{
// Already signaled
}
}
/// <inheritdoc />
public sealed override void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
/// <summary>
/// Releases unmanaged and optionally managed resources.
/// </summary>
/// <param name="disposing"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param>
protected virtual void Dispose(bool disposing)
{
if (Interlocked.CompareExchange(ref _isDisposed, 1, 0) != 0)
{
return;
}
if (disposing)
{
_signal.Dispose();
_periodChangeCts.Dispose();
}
base.Dispose();
}
/// <summary>
/// Immutable snapshot of the trigger state.
/// </summary>
private sealed record TriggerState(NextExecutionStrategy Strategy = default, TimeSpan? Delay = null)
{
/// <summary>
/// Gets the default trigger state with no strategy and no custom delay.
/// </summary>
/// <value>
/// The default trigger state.
/// </value>
public static TriggerState Default { get; } = new();
}
}
@@ -0,0 +1,24 @@
namespace Umbraco.Cms.Infrastructure.Install;
/// <summary>
/// Coordinates migration leadership across servers in a load-balanced environment.
/// </summary>
internal interface IMigrationCoordinator
{
/// <summary>
/// Attempts to become the migration leader, blocking until either this server wins the claim
/// or another server completes all migrations.
/// </summary>
/// <param name="cancellationToken">A token that cancels the leadership wait loop.</param>
/// <returns>
/// <c>true</c> if this server is the migration leader and must call <see cref="ReleaseLeadership"/> after
/// running migrations; <c>false</c> if another server completed migrations and this server should skip them.
/// </returns>
Task<bool> TryBecomeLeaderAsync(CancellationToken cancellationToken);
/// <summary>
/// Releases the migration leadership claim if it is still held by this instance.
/// Must be called in a <c>finally</c> block to ensure release even on failure.
/// </summary>
void ReleaseLeadership();
}
@@ -0,0 +1,177 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Factories;
using Umbraco.Cms.Core.Scoping;
using Umbraco.Cms.Core.Services;
namespace Umbraco.Cms.Infrastructure.Install;
/// <summary>
/// Coordinates migration leadership across servers in a load-balanced environment.
/// Exactly one server claims leadership, runs all migrations, then releases the claim.
/// All other servers wait until the leader finishes, then proceed with per-server initialization.
/// </summary>
internal sealed class MigrationCoordinator : IMigrationCoordinator
{
private readonly ICoreScopeProvider _scopeProvider;
private readonly IKeyValueService _keyValueService;
private readonly IRuntimeState _runtimeState;
private readonly IMachineInfoFactory _machineInfoFactory;
private readonly IOptions<UnattendedSettings> _unattendedSettings;
private readonly ILogger<MigrationCoordinator> _logger;
private string? _leaderClaim;
public MigrationCoordinator(
ICoreScopeProvider scopeProvider,
IKeyValueService keyValueService,
IRuntimeState runtimeState,
IMachineInfoFactory machineInfoFactory,
IOptions<UnattendedSettings> unattendedSettings,
ILogger<MigrationCoordinator> logger)
{
_scopeProvider = scopeProvider;
_keyValueService = keyValueService;
_runtimeState = runtimeState;
_machineInfoFactory = machineInfoFactory;
_unattendedSettings = unattendedSettings;
_logger = logger;
}
/// <inheritdoc/>
public async Task<bool> TryBecomeLeaderAsync(CancellationToken cancellationToken)
{
var machineIdentifier = _machineInfoFactory.GetMachineIdentifier();
while (cancellationToken.IsCancellationRequested is false)
{
if (TryClaimLeadership(machineIdentifier))
{
// Re-check after claiming: the previous leader may have finished between our last
// DetermineRuntimeLevel call and our successful claim of the now-empty lock.
try
{
_runtimeState.DetermineRuntimeLevel();
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
return false;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not re-determine runtime level after claiming leadership; proceeding as leader.");
}
if (_runtimeState.Level == RuntimeLevel.Run)
{
ReleaseLeadership();
_logger.LogInformation("Migrations completed by another server; proceeding as follower.");
return false;
}
_logger.LogInformation("This server claimed migration leadership.");
return true;
}
try
{
_runtimeState.DetermineRuntimeLevel();
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
return false;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not determine runtime level during migration wait; will retry.");
}
switch (_runtimeState.Level)
{
case RuntimeLevel.Run:
_logger.LogInformation("Migrations completed by another server; proceeding as follower.");
return false;
case RuntimeLevel.BootFailed:
_logger.LogError("Runtime entered BootFailed state while waiting for migrations.");
return false;
default:
_logger.LogDebug("Waiting for migration leader to finish...");
try
{
await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
return false;
}
break;
}
}
return false;
}
/// <inheritdoc/>
public void ReleaseLeadership()
{
if (_leaderClaim is null)
{
return;
}
try
{
using ICoreScope scope = _scopeProvider.CreateCoreScope();
scope.WriteLock(Constants.Locks.KeyValues);
string? current = _keyValueService.GetValue(Constants.Conventions.Migrations.UpgradeLockKey);
if (current == _leaderClaim)
{
_keyValueService.SetValue(Constants.Conventions.Migrations.UpgradeLockKey, string.Empty);
}
scope.Complete();
_leaderClaim = null;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to release migration leadership; continuing shutdown because leadership release is best-effort.");
}
}
private static bool IsStale(string claim, TimeSpan timeout)
{
var separatorIndex = claim.IndexOf('|');
return separatorIndex < 0
|| !DateTimeOffset.TryParse(claim.AsSpan(separatorIndex + 1), out DateTimeOffset timestamp)
|| DateTimeOffset.UtcNow - timestamp > timeout;
}
// Acquires WriteLock(KeyValues) so the read-then-write is serialized across all servers.
// Inner GetValue and SetValue calls create nested scopes that join the outer transaction;
// their internal WriteLock requests are no-ops because the lock is already held.
private bool TryClaimLeadership(string machineIdentifier)
{
TimeSpan timeout = _unattendedSettings.Value.MigrationClaimTimeout;
using ICoreScope scope = _scopeProvider.CreateCoreScope();
scope.WriteLock(Constants.Locks.KeyValues);
string? current = _keyValueService.GetValue(Constants.Conventions.Migrations.UpgradeLockKey);
bool canClaim = string.IsNullOrEmpty(current)
|| IsStale(current, timeout)
|| current.StartsWith(machineIdentifier + "|", StringComparison.Ordinal);
if (canClaim)
{
_leaderClaim = $"{machineIdentifier}|{DateTimeOffset.UtcNow:O}";
_keyValueService.SetValue(Constants.Conventions.Migrations.UpgradeLockKey, _leaderClaim);
}
scope.Complete();
return canClaim;
}
}
@@ -24,6 +24,7 @@ internal sealed class UnattendedUpgradeBackgroundService : BackgroundService
private readonly IEventAggregator _eventAggregator;
private readonly ComponentCollection _components;
private readonly IHostApplicationLifetime _hostApplicationLifetime;
private readonly IMigrationCoordinator _coordinator;
private readonly ILogger<UnattendedUpgradeBackgroundService> _logger;
/// <summary>
@@ -33,18 +34,21 @@ internal sealed class UnattendedUpgradeBackgroundService : BackgroundService
/// <param name="eventAggregator">The event aggregator used to publish upgrade notifications.</param>
/// <param name="components">The component collection to initialize after migration completes.</param>
/// <param name="hostApplicationLifetime">The host application lifetime for registering started/stopped callbacks.</param>
/// <param name="coordinator">Coordinates migration leadership across servers in a load-balanced environment.</param>
/// <param name="logger">The logger.</param>
public UnattendedUpgradeBackgroundService(
IRuntimeState runtimeState,
IEventAggregator eventAggregator,
ComponentCollection components,
IHostApplicationLifetime hostApplicationLifetime,
IMigrationCoordinator coordinator,
ILogger<UnattendedUpgradeBackgroundService> logger)
{
_runtimeState = runtimeState;
_eventAggregator = eventAggregator;
_components = components;
_hostApplicationLifetime = hostApplicationLifetime;
_coordinator = coordinator;
_logger = logger;
}
@@ -59,9 +63,30 @@ internal sealed class UnattendedUpgradeBackgroundService : BackgroundService
_logger.LogInformation("Unattended upgrade background service started.");
bool isLeader = false;
try
{
await RunMigrationsAsync(stoppingToken);
isLeader = await _coordinator.TryBecomeLeaderAsync(stoppingToken);
if (_runtimeState.Level == RuntimeLevel.BootFailed)
{
return;
}
if (isLeader)
{
// Belt-and-suspenders for graceful shutdowns (e.g. Azure SIGTERM): release the claim
// as soon as the host begins stopping, even if a migration step is still blocking.
_hostApplicationLifetime.ApplicationStopping.Register(() => _coordinator.ReleaseLeadership());
await RunMigrationsAsync(stoppingToken);
}
else
{
// Follower: rebuild per-server in-memory navigation and publish status
// from the fully-migrated database.
await _eventAggregator.PublishAsync(new PostRuntimePremigrationsUpgradeNotification(), stoppingToken);
}
}
catch (Exception ex)
{
@@ -69,14 +94,20 @@ internal sealed class UnattendedUpgradeBackgroundService : BackgroundService
_runtimeState.Configure(RuntimeLevel.BootFailed, RuntimeLevelReason.BootFailedOnException, ex);
return;
}
finally
{
// Always release the claim — even on leader failure — so other servers
// can detect completion or take over.
if (isLeader)
{
_coordinator.ReleaseLeadership();
}
}
// Re-evaluate runtime level after migrations complete. This handles all result cases:
// - CoreUpgradeComplete / PackageMigrationComplete: confirms the new Run level.
// - NotRequired: another instance may have already run migrations; re-check to get Run level.
// - HasErrors: BootFailedException is set, so DetermineRuntimeLevel() returns early (no-op).
// For the leader: confirms migrations succeeded and level transitions to Run.
// For followers: level is already Run (set during TryBecomeLeaderAsync polling).
DetermineRuntimeLevel();
// RunMigrationsAsync may have set BootFailed via a non-throwing error path (HasErrors result).
if (_runtimeState.Level == RuntimeLevel.BootFailed)
{
return;
@@ -1,4 +1,3 @@
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core;
@@ -8,6 +7,7 @@ using Umbraco.Cms.Core.Scoping;
using Umbraco.Cms.Core.Serialization;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Web;
using Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_15_0_0.LocalLinks;
namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_15_0_0;
@@ -15,7 +15,7 @@ namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_15_0_0;
/// Migration responsible for converting rich text editor properties to the new format as part of the upgrade process to Umbraco version 15.0.0.
/// </summary>
[Obsolete("Scheduled for removal in Umbraco 18.")]
public partial class ConvertRichTextEditorProperties : ConvertBlockEditorPropertiesBase
public class ConvertRichTextEditorProperties : ConvertBlockEditorPropertiesBase
{
/// <summary>
/// Initializes a new instance of the <see cref="ConvertRichTextEditorProperties"/> class.
@@ -62,13 +62,7 @@ public partial class ConvertRichTextEditorProperties : ConvertBlockEditorPropert
return base.UpdateEditorValue(editorValue);
}
richTextEditorValue.Markup = BlockRegex().Replace(
richTextEditorValue.Markup,
match => UdiParser.TryParse(match.Groups["udi"].Value, out GuidUdi? guidUdi)
? match.Value
.Replace(match.Groups["attribute"].Value, "data-content-key")
.Replace(match.Groups["udi"].Value, guidUdi.Guid.ToString("D"))
: string.Empty);
richTextEditorValue.Markup = RteBlockHelper.ConvertBlockUdisToKeys(richTextEditorValue.Markup);
return richTextEditorValue;
}
@@ -100,7 +94,4 @@ public partial class ConvertRichTextEditorProperties : ConvertBlockEditorPropert
protected override bool IsCandidateForMigration(IPropertyType propertyType, IDataType dataType)
=> dataType.ConfigurationObject is RichTextConfiguration richTextConfiguration
&& richTextConfiguration.Blocks?.Any() is true;
[GeneratedRegex("<umb-rte-block.*(?<attribute>data-content-udi)=\"(?<udi>.[^\"]*)\".*<\\/umb-rte-block")]
private static partial Regex BlockRegex();
}
@@ -1,3 +1,4 @@
using System.Text.RegularExpressions;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Services;
@@ -11,6 +12,10 @@ namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_15_0_0.LocalLinks;
[Obsolete("Scheduled for removal in Umbraco 18.")]
public class LocalLinkProcessor
{
private static readonly Regex _dataAnchorPattern = new(
@"data-anchor=['""](?<anchor>[^'""]*)['""]",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
private readonly HtmlLocalLinkParser _localLinkParser;
private readonly IIdKeyMap _idKeyMap;
private readonly IEnumerable<ITypedLocalLinkProcessor> _localLinkProcessors;
@@ -121,18 +126,70 @@ public class LocalLinkProcessor
}
// Extract any trailing href content (fragment, query string) between the localLink and closing quote.
var trailingHrefContent = input.Substring(afterTagHref, closingQuoteIndex - afterTagHref);
var existingTrailingHrefContent = input.Substring(afterTagHref, closingQuoteIndex - afterTagHref);
var closingQuote = input[closingQuoteIndex];
// If the anchor tag carries a data-anchor attribute and that value is not already part
// of the href (e.g. when migrating older content that stored the anchor only in data-anchor),
// append it to the href so the link resolves correctly in the v15+ RTE (#22860).
// When the href already contains a different fragment, we trust the href and skip the append
// rather than producing an invalid URL with two '#' separators.
var newTrailingHrefContent = existingTrailingHrefContent;
var anchorFromAttribute = ExtractDataAnchorValue(input, tagHrefIndex);
if (anchorFromAttribute is not null
&& existingTrailingHrefContent.Contains(anchorFromAttribute, StringComparison.Ordinal) is false
&& existingTrailingHrefContent.Contains('#') is false)
{
newTrailingHrefContent += anchorFromAttribute;
}
// Build the replacement: converted localLink + trailing content + close quote + type attribute
var oldSegment = tag.TagHref + trailingHrefContent + closingQuote;
var newSegment = convertedLocalLink + trailingHrefContent + closingQuote + $" type=\"{entityType}\"";
var oldSegment = tag.TagHref + existingTrailingHrefContent + closingQuote;
var newSegment = convertedLocalLink + newTrailingHrefContent + closingQuote + $" type=\"{entityType}\"";
input = input.Remove(tagHrefIndex, oldSegment.Length).Insert(tagHrefIndex, newSegment);
}
return input;
}
// Searches for a non-empty data-anchor attribute within the opening anchor tag that contains the
// local link href at tagHrefIndex. Returns the attribute value (e.g. "#" or "#section-1"),
// or null when no usable data-anchor is present.
// The legacy local link pattern matches any href attribute (not just anchors), so this check
// is scoped to elements whose tag name is "a" — data-anchor on other elements is not relevant.
private static string? ExtractDataAnchorValue(string input, int tagHrefIndex)
{
var tagStartIndex = input.LastIndexOf('<', tagHrefIndex);
if (tagStartIndex < 0 || tagStartIndex + 2 >= input.Length)
{
return null;
}
// Verify the surrounding element is an anchor tag: "<a" followed by whitespace.
// (An href attribute always implies at least one whitespace separator after the tag name.)
var nameChar = input[tagStartIndex + 1];
if ((nameChar != 'a' && nameChar != 'A') || char.IsWhiteSpace(input[tagStartIndex + 2]) is false)
{
return null;
}
var tagEndIndex = input.IndexOf('>', tagHrefIndex);
if (tagEndIndex < tagStartIndex)
{
return null;
}
var openingTag = input.Substring(tagStartIndex, tagEndIndex - tagStartIndex);
Match anchorMatch = _dataAnchorPattern.Match(openingTag);
if (anchorMatch.Success is false)
{
return null;
}
var anchorValue = anchorMatch.Groups["anchor"].Value;
return string.IsNullOrEmpty(anchorValue) ? null : anchorValue;
}
private (Guid Key, string EntityType)? CreateIntBasedKeyType(int id)
{
// very old data, best effort replacement.
@@ -1,4 +1,3 @@
using System.Text.RegularExpressions;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Models.Blocks;
@@ -51,14 +50,9 @@ public class LocalLinkRteProcessor : ITypedLocalLinkProcessor
var newMarkup = processStringValue.Invoke(richTextValue.Markup);
// fix recursive hickup in ConvertRichTextEditorProperties
newMarkup = RteBlockHelper.BlockRegex().Replace(
newMarkup,
match => UdiParser.TryParse(match.Groups["udi"].Value, out GuidUdi? guidUdi)
? match.Value
.Replace(match.Groups["attribute"].Value, "data-content-key")
.Replace(match.Groups["udi"].Value, guidUdi.Guid.ToString("D"))
: string.Empty);
// Re-apply block UDI→key conversion in case ConvertRichTextEditorProperties missed any
// (e.g. under recursive / nested block structures the primary migration could leave behind).
newMarkup = RteBlockHelper.ConvertBlockUdisToKeys(newMarkup);
if (newMarkup.Equals(richTextValue.Markup) == false)
{
@@ -85,17 +79,3 @@ public class LocalLinkRteProcessor : ITypedLocalLinkProcessor
return hasChanged;
}
}
/// <summary>
/// Provides helper methods for processing rich text editor (RTE) blocks containing local links during the upgrade to Umbraco version 15.0.0.
/// </summary>
[Obsolete("Scheduled for removal in Umbraco 18.")]
public static partial class RteBlockHelper
{
/// <summary>
/// Returns a <see cref="Regex"/> that matches <c>umb-rte-block</c> elements containing a <c>data-content-udi</c> attribute in the input HTML.
/// </summary>
/// <returns>A <see cref="Regex"/> instance for identifying <c>umb-rte-block</c> elements with a <c>data-content-udi</c> attribute.</returns>
[GeneratedRegex("<umb-rte-block.*(?<attribute>data-content-udi)=\"(?<udi>.[^\"]*)\".*<\\/umb-rte-block")]
public static partial Regex BlockRegex();
}
@@ -0,0 +1,41 @@
using System.Text.RegularExpressions;
using Umbraco.Cms.Core;
namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_15_0_0.LocalLinks;
/// <summary>
/// Provides helper methods for processing rich text editor (RTE) block markup by rewriting block UDIs to keys.
/// </summary>
[Obsolete("Scheduled for removal in Umbraco 18.")]
public static partial class RteBlockHelper
{
/// <summary>
/// Returns a <see cref="Regex"/> that matches <c>umb-rte-block</c> elements containing a <c>data-content-udi</c> attribute in the input HTML.
/// </summary>
/// <returns>A <see cref="Regex"/> instance for identifying <c>umb-rte-block</c> elements with a <c>data-content-udi</c> attribute.</returns>
// Non-greedy on both [^>]*? and .*? so consecutive sibling <umb-rte-block> elements are matched
// individually rather than collapsed into one span (which left all-but-last sibling UDIs un-converted).
[GeneratedRegex("<umb-rte-block\\b[^>]*?(?<attribute>data-content-udi)=\"(?<udi>[^\"]+)\"[^>]*>.*?<\\/umb-rte-block>")]
public static partial Regex BlockRegex();
/// <summary>
/// Rewrites every <c>&lt;umb-rte-block&gt;</c> element in <paramref name="markup"/> from the legacy
/// <c>data-content-udi="umb://element/..."</c> form to the v15+ <c>data-content-key="&lt;guid&gt;"</c>
/// form.
/// </summary>
/// <remarks>
/// Blocks whose UDI fails to parse are <b>dropped</b> from the output rather than preserved.
/// This mirrors the original behaviour of <c>ConvertRichTextEditorProperties</c> and should not be
/// changed without considering migrated content that may contain malformed UDIs.
/// </remarks>
/// <param name="markup">The RTE markup to convert.</param>
/// <returns>The converted markup, or the input unchanged if no convertible blocks are present.</returns>
public static string ConvertBlockUdisToKeys(string markup) =>
BlockRegex().Replace(
markup,
match => UdiParser.TryParse(match.Groups["udi"].Value, out GuidUdi? guidUdi)
? match.Value
.Replace(match.Groups["attribute"].Value, "data-content-key")
.Replace(match.Groups["udi"].Value, guidUdi.Guid.ToString("D"))
: string.Empty);
}
@@ -0,0 +1,19 @@
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Infrastructure.BackgroundJobs;
namespace Umbraco.Cms.Infrastructure.Notifications;
/// <summary>
/// Notification that is raised when a recurring background job is cancelled during host shutdown.
/// </summary>
public sealed class RecurringBackgroundJobCanceledNotification : RecurringBackgroundJobNotification
{
/// <summary>
/// Initializes a new instance of the <see cref="RecurringBackgroundJobCanceledNotification" /> class.
/// </summary>
/// <param name="target">The instance of the recurring background job that was cancelled.</param>
/// <param name="messages">The <see cref="EventMessages" /> associated with the cancellation.</param>
public RecurringBackgroundJobCanceledNotification(IRecurringBackgroundJob target, EventMessages messages)
: base(target, messages)
{ }
}
@@ -249,14 +249,24 @@ internal sealed class RedirectUrlRepository : EntityRepositoryBase<Guid, IRedire
protected override IEnumerable<IRedirectUrl> PerformGetAll(params Guid[]? ids)
{
if (ids?.Length > Constants.Sql.MaxParameterCount)
if (ids is null || ids.Length == 0)
{
throw new NotSupportedException(
$"This repository does not support more than {Constants.Sql.MaxParameterCount} ids.");
return Database.Fetch<RedirectUrlDto>(GetBaseQuery(false))
.WhereNotNull()
.Select(Map)
.WhereNotNull();
}
// Batch the WhereIn fetch so we never exceed SQL Server's 2100 parameter limit.
// EntityRepositoryBase.GetMany already groups IDs, but we keep the batching here as
// a defensive measure for safety and consistency at the repository boundary.
var dtos = new List<RedirectUrlDto>(ids.Length);
foreach (IEnumerable<Guid> group in ids.InGroupsOf(Constants.Sql.MaxParameterCount))
{
Sql<ISqlContext> sql = GetBaseQuery(false).WhereIn<RedirectUrlDto>(x => x.Id, group);
dtos.AddRange(Database.Fetch<RedirectUrlDto>(sql));
}
Sql<ISqlContext> sql = GetBaseQuery(false).WhereIn<RedirectUrlDto>(x => x.Id, ids);
List<RedirectUrlDto> dtos = Database.Fetch<RedirectUrlDto>(sql);
return dtos.WhereNotNull().Select(Map).WhereNotNull();
}
@@ -74,6 +74,7 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement
string[] columns = [
sx.ColumnWithAlias("x", "otherId", "nodeId"),
sx.ColumnWithAlias("n", "uniqueId", "nodeKey"),
sx.ColumnWithAlias("n", "text", "nodeName"),
sx.ColumnWithAlias("n", "nodeObjectType", "nodeObjectType"),
sx.ColumnWithAlias("d", "published", "nodePublished"),
sx.ColumnWithAlias("ctn", "uniqueId", "contentTypeKey"),
@@ -122,11 +122,30 @@ internal sealed class IndexedEntitySearchService : IIndexedEntitySearchService
.Where(key => key != Guid.Empty)
.ToArray();
// EntityService.GetAll returns entities in database (not Lucene score) order, which
// would discard the relevance ranking. Re-order to match the search result sequence.
IEnumerable<IEntitySlim> orderedItems;
if (keys.Length > 0)
{
var keyOrder = new Dictionary<Guid, int>(keys.Length);
for (var i = 0; i < keys.Length; i++)
{
keyOrder.TryAdd(keys[i], i);
}
orderedItems = _entityService
.GetAll(objectType, keys)
.OrderBy(entity => keyOrder.TryGetValue(entity.Key, out var index) ? index : int.MaxValue)
.ToArray();
}
else
{
orderedItems = [];
}
return Task.FromResult(new PagedModel<IEntitySlim>
{
Items = keys.Any()
? _entityService.GetAll(objectType, keys)
: Enumerable.Empty<IEntitySlim>(),
Items = orderedItems,
Total = totalFound
});
}
@@ -47,30 +47,21 @@ public class LogViewerRepository : LogViewerRepositoryBase
var filesForCurrentDay = Directory.GetFiles(_loggingConfiguration.LogDirectory, filesToFind);
// Foreach file we find - open it
// Foreach file we find - open it. Any failure reading a single file (open error,
// unrecoverable parse error, etc.) should not prevent the remaining files for the
// day or date range from being read.
foreach (var filePath in filesForCurrentDay)
{
// Open log file & add contents to the log collection
// Which we then use LINQ to page over
using (var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
try
{
using (var stream = new StreamReader(fs))
{
var reader = new LogEventReader(stream);
while (TryRead(reader, out LogEvent? evt))
{
// We may get a null if log line is malformed
if (evt == null)
{
continue;
}
if (logFilter.TakeLogEvent(evt))
{
logs.Add(evt);
}
}
}
ReadLogFile(filePath, logFilter, logs);
}
catch (Exception ex)
{
_logger.LogWarning(
ex,
"Skipped log file {FilePath} after a file-level error; the file may be inaccessible or unreadable.",
filePath);
}
}
}
@@ -88,6 +79,63 @@ public class LogViewerRepository : LogViewerRepositoryBase
}).ToArray();
}
private void ReadLogFile(string filePath, ILogFilter logFilter, List<LogEvent> logs)
{
using var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
using var stream = new StreamReader(fs);
var reader = new LogEventReader(stream);
var errorCount = 0;
Exception? firstError = null;
while (true)
{
LogEvent? evt;
try
{
if (!reader.TryRead(out evt))
{
break;
}
}
catch (Exception ex) when (ex is Newtonsoft.Json.JsonException or InvalidDataException)
{
// Serilog.Formatting.Compact.Reader uses Newtonsoft.Json internally and surfaces
// its exceptions (Umbraco's own serialization is on System.Text.Json, but that
// doesn't apply here — we have to catch what the reader actually throws).
// JsonException covers parse failures (e.g. an unterminated string in a truncated
// entry); InvalidDataException covers structurally-valid JSON that isn't a valid
// Serilog Compact event. Either way the offending line has been consumed from the
// underlying StreamReader and the next TryRead call advances. Anything else
// (IOException, decoder failures, etc.) is propagated to the file-level catch in
// GetLogs so we don't risk a tight loop or silently swallow a more serious failure.
errorCount++;
firstError ??= ex;
continue;
}
// LogEventReader may return true with a null event for a benign skip.
if (evt is null)
{
continue;
}
if (logFilter.TakeLogEvent(evt))
{
logs.Add(evt);
}
}
if (errorCount > 0)
{
_logger.LogWarning(
firstError,
"Encountered {ErrorCount} unreadable line(s) while reading log file {FilePath}. The file may contain partially-written or corrupt entries; affected lines were skipped.",
errorCount,
filePath);
}
}
private IReadOnlyDictionary<string, string?> MapLogMessageProperties(IReadOnlyDictionary<string, LogEventPropertyValue>? properties)
{
var result = new Dictionary<string, string?>();
@@ -121,21 +169,4 @@ public class LogViewerRepository : LogViewerRepositoryBase
}
private static string GetSearchPattern(DateTime day) => $"*{day:yyyyMMdd}*.json";
private bool TryRead(LogEventReader reader, out LogEvent? evt)
{
try
{
return reader.TryRead(out evt);
}
catch (Exception ex)
{
// As we are reading/streaming one line at a time in the JSON file
// Thus we can not report the line number, as it will always be 1
_logger.LogError(ex, "Unable to parse a line in the JSON log file");
evt = null;
return true;
}
}
}
@@ -35,7 +35,19 @@ public sealed class DocumentCache : IPublishedContentCache
public IPublishedContent? GetById(bool preview, int contentId) => GetByIdAsync(contentId, preview).GetAwaiter().GetResult();
public IPublishedContent? GetById(bool preview, Guid contentId) => GetByIdAsync(contentId, preview).GetAwaiter().GetResult();
public IPublishedContent? GetById(bool preview, Guid contentId)
{
// Sync fast path: when the converted-content L0 cache already holds the item we can
// return it without spinning up an async state machine. This is the dominant case on
// a warm site and is hit per-key by the FilterAvailable lazy chain. On a miss we fall
// through to the async path which handles HybridCache (L1/L2) and database lookups.
if (_documentCacheService.TryGetCached(contentId, preview, out IPublishedContent? cached))
{
return cached;
}
return GetByIdAsync(contentId, preview).GetAwaiter().GetResult();
}
public IPublishedContent? GetById(int contentId) => GetByIdAsync(contentId).GetAwaiter().GetResult();
@@ -24,8 +24,19 @@ public sealed class MediaCache : IPublishedMediaCache
public IPublishedContent? GetById(bool preview, int contentId) => GetByIdAsync(contentId).GetAwaiter().GetResult();
public IPublishedContent? GetById(bool preview, Guid contentId) =>
GetByIdAsync(contentId).GetAwaiter().GetResult();
public IPublishedContent? GetById(bool preview, Guid contentId)
{
// Sync fast path: when the converted-content L0 cache already holds the item we can
// return it without spinning up an async state machine. This is the dominant case on
// a warm site and is hit per-key by the FilterAvailable lazy chain. On a miss we fall
// through to the async path which handles HybridCache (L1/L2) and database lookups.
if (_mediaCacheService.TryGetCached(contentId, out IPublishedContent? cached))
{
return cached;
}
return GetByIdAsync(contentId).GetAwaiter().GetResult();
}
public IPublishedContent? GetById(int contentId) => GetByIdAsync(contentId).GetAwaiter().GetResult();
@@ -207,21 +207,28 @@ internal sealed class DatabaseCacheRepository : RepositoryBase, IDatabaseCacheRe
/// <inheritdoc/>
public async Task<IEnumerable<ContentCacheNode>> GetContentSourcesAsync(IEnumerable<Guid> keys, bool preview = false)
{
Sql<ISqlContext>? sql = SqlContentSourcesSelect()
.Append(SqlObjectTypeNotTrashed(SqlContext, Constants.ObjectTypes.Document))
.WhereIn<NodeDto>(x => x.UniqueId, keys)
.Append(SqlOrderByLevelIdSortOrder(SqlContext));
// Batch the WHERE IN to stay within SQL Server's parameter limit.
// The configurable document seed batch size is applied upstream; this method only enforces MaxParameterCount.
Guid[] keysArray = keys as Guid[] ?? keys.ToArray();
var dtos = new List<ContentSourceDto>(keysArray.Length);
foreach (IEnumerable<Guid> group in keysArray.InGroupsOf(Constants.Sql.MaxParameterCount))
{
Sql<ISqlContext>? sql = SqlContentSourcesSelect()
.Append(SqlObjectTypeNotTrashed(SqlContext, Constants.ObjectTypes.Document))
.WhereIn<NodeDto>(x => x.UniqueId, group)
.Append(SqlOrderByLevelIdSortOrder(SqlContext));
List<ContentSourceDto> dtos = await Database.FetchAsync<ContentSourceDto>(sql);
dtos.AddRange(await Database.FetchAsync<ContentSourceDto>(sql));
}
dtos = dtos
var filtered = dtos
.Where(x => x is not null)
.Where(x => preview || ((x.PubDataRaw is not null || x.PubData is not null) && (!x.Published || x.PubName is not null)))
.ToList();
IContentCacheDataSerializer serializer =
_contentCacheDataSerializerFactory.Create(ContentCacheDataSerializerEntityType.Document);
return dtos
return filtered
.Select(x => CreateContentNodeKit(x, serializer, preview))
.OfType<ContentCacheNode>();
}
@@ -379,20 +386,27 @@ internal sealed class DatabaseCacheRepository : RepositoryBase, IDatabaseCacheRe
/// <inheritdoc/>
public async Task<IEnumerable<ContentCacheNode>> GetMediaSourcesAsync(IEnumerable<Guid> keys)
{
Sql<ISqlContext>? sql = SqlMediaSourcesSelect()
.Append(SqlObjectTypeNotTrashed(SqlContext, Constants.ObjectTypes.Media))
.WhereIn<NodeDto>(x => x.UniqueId, keys)
.Append(SqlOrderByLevelIdSortOrder(SqlContext));
// Batch the WHERE IN by Constants.Sql.MaxParameterCount so callers configuring
// CacheSettings.MediaSeedBatchSize above that limit do not hit SQL Server's 2100 parameter limit.
Guid[] keysArray = keys as Guid[] ?? keys.ToArray();
var dtos = new List<ContentSourceDto>(keysArray.Length);
foreach (IEnumerable<Guid> group in keysArray.InGroupsOf(Constants.Sql.MaxParameterCount))
{
Sql<ISqlContext>? sql = SqlMediaSourcesSelect()
.Append(SqlObjectTypeNotTrashed(SqlContext, Constants.ObjectTypes.Media))
.WhereIn<NodeDto>(x => x.UniqueId, group)
.Append(SqlOrderByLevelIdSortOrder(SqlContext));
List<ContentSourceDto> dtos = await Database.FetchAsync<ContentSourceDto>(sql);
dtos.AddRange(await Database.FetchAsync<ContentSourceDto>(sql));
}
dtos = dtos
var filtered = dtos
.Where(x => x is not null)
.ToList();
IContentCacheDataSerializer serializer =
_contentCacheDataSerializerFactory.Create(ContentCacheDataSerializerEntityType.Media);
return dtos
return filtered
.Select(x => CreateMediaNodeKit(x, serializer));
}
@@ -578,107 +592,135 @@ internal sealed class DatabaseCacheRepository : RepositoryBase, IDatabaseCacheRe
/// </summary>
private List<CacheRebuildDocumentDto> GetDocumentMetadataForNodes(List<int> nodeIds)
{
// Query content metadata with both edit and published version info
// Query content metadata with both edit and published version info.
// Uses nested join pattern to ensure we only get the published ContentVersion
// (where a DocumentVersionDto with Published=true exists)
Sql<ISqlContext> sql = Sql()
.Select<NodeDto>(
x => x.NodeId,
x => x.UniqueId,
x => x.Text,
x => x.Path,
x => x.Level,
x => x.ParentId,
x => x.SortOrder,
x => x.CreateDate,
x => Alias(x.UserId, "CreatorId"))
.AndSelect<ContentDto>(x => x.ContentTypeId)
.AndSelect<DocumentDto>(x => x.Published)
.AndSelect<ContentVersionDto>(
x => Alias(x.Id, "EditVersionId"),
x => Alias(x.Text, "EditName"),
x => Alias(x.VersionDate, "EditVersionDate"),
x => Alias(x.UserId, "EditWriterId"))
.AndSelect<ContentVersionDto>(
"pcv",
x => Alias(x.Id, "PublishedVersionId"),
x => Alias(x.Text, "PublishedName"),
x => Alias(x.VersionDate, "PublishedVersionDate"),
x => Alias(x.UserId, "PublishedWriterId"))
.From<NodeDto>()
.InnerJoin<ContentDto>().On<NodeDto, ContentDto>((n, c) => n.NodeId == c.NodeId)
.InnerJoin<DocumentDto>().On<NodeDto, DocumentDto>((n, d) => n.NodeId == d.NodeId)
.InnerJoin<ContentVersionDto>().On<NodeDto, ContentVersionDto>((n, cv) => n.NodeId == cv.NodeId && cv.Current)
// (where a DocumentVersionDto with Published=true exists).
// Batched on nodeIds so a NuCacheSettings.SqlPageSize larger than MaxParameterCount still works.
var results = new List<CacheRebuildDocumentDto>(nodeIds.Count);
foreach (IEnumerable<int> group in nodeIds.InGroupsOf(Constants.Sql.MaxParameterCount))
{
Sql<ISqlContext> sql = Sql()
.Select<NodeDto>(
x => x.NodeId,
x => x.UniqueId,
x => x.Text,
x => x.Path,
x => x.Level,
x => x.ParentId,
x => x.SortOrder,
x => x.CreateDate,
x => Alias(x.UserId, "CreatorId"))
.AndSelect<ContentDto>(x => x.ContentTypeId)
.AndSelect<DocumentDto>(x => x.Published)
.AndSelect<ContentVersionDto>(
x => Alias(x.Id, "EditVersionId"),
x => Alias(x.Text, "EditName"),
x => Alias(x.VersionDate, "EditVersionDate"),
x => Alias(x.UserId, "EditWriterId"))
.AndSelect<ContentVersionDto>(
"pcv",
x => Alias(x.Id, "PublishedVersionId"),
x => Alias(x.Text, "PublishedName"),
x => Alias(x.VersionDate, "PublishedVersionDate"),
x => Alias(x.UserId, "PublishedWriterId"))
.From<NodeDto>()
.InnerJoin<ContentDto>().On<NodeDto, ContentDto>((n, c) => n.NodeId == c.NodeId)
.InnerJoin<DocumentDto>().On<NodeDto, DocumentDto>((n, d) => n.NodeId == d.NodeId)
.InnerJoin<ContentVersionDto>().On<NodeDto, ContentVersionDto>((n, cv) => n.NodeId == cv.NodeId && cv.Current)
// Nested join: ContentVersionDto "pcv" INNER JOIN DocumentVersionDto "pdv" ON published=true
// This ensures pcv only includes rows where there's a published DocumentVersion
.LeftJoin<ContentVersionDto>(
j => j.InnerJoin<DocumentVersionDto>("pdv")
.On<ContentVersionDto, DocumentVersionDto>(
(left, right) => left.Id == right.Id && right.Published == true, "pcv", "pdv"),
"pcv")
// Nested join: ContentVersionDto "pcv" INNER JOIN DocumentVersionDto "pdv" ON published=true.
// This ensures pcv only includes rows where there's a published DocumentVersion.
.LeftJoin<ContentVersionDto>(
j => j.InnerJoin<DocumentVersionDto>("pdv")
.On<ContentVersionDto, DocumentVersionDto>(
(left, right) => left.Id == right.Id && right.Published == true, "pcv", "pdv"),
"pcv")
.On<NodeDto, ContentVersionDto>((n, cv) => n.NodeId == cv.NodeId, aliasRight: "pcv")
.WhereIn<NodeDto>(x => x.NodeId, nodeIds);
.On<NodeDto, ContentVersionDto>((n, cv) => n.NodeId == cv.NodeId, aliasRight: "pcv")
.WhereIn<NodeDto>(x => x.NodeId, group);
return Database.Fetch<CacheRebuildDocumentDto>(sql);
results.AddRange(Database.Fetch<CacheRebuildDocumentDto>(sql));
}
return results;
}
/// <summary>
/// Gets property data for the specified node IDs using efficient JOIN on nodeId.
/// This avoids the expensive WHERE IN on versionId that causes index scans.
/// Batched on nodeIds so a NuCacheSettings.SqlPageSize larger than MaxParameterCount still works.
/// </summary>
private List<CacheRebuildPropertyDto> GetPropertyDataForNodes(List<int> nodeIds)
{
// JOIN through nodeId → versionId path for efficient query plan
Sql<ISqlContext> sql = Sql()
.Select<PropertyDataDto>(
x => x.VersionId,
x => x.LanguageId,
x => x.Segment,
x => x.IntegerValue,
x => x.DecimalValue,
x => x.DateValue,
x => x.VarcharValue,
x => x.TextValue)
.AndSelect<PropertyTypeDto>(x => Alias(x.Alias, "PropertyAlias"))
.From<PropertyDataDto>()
.InnerJoin<PropertyTypeDto>().On<PropertyDataDto, PropertyTypeDto>((pd, pt) => pd.PropertyTypeId == pt.Id)
.InnerJoin<ContentVersionDto>().On<PropertyDataDto, ContentVersionDto>((pd, cv) => pd.VersionId == cv.Id)
.WhereIn<ContentVersionDto>(x => x.NodeId, nodeIds);
var results = new List<CacheRebuildPropertyDto>();
foreach (IEnumerable<int> group in nodeIds.InGroupsOf(Constants.Sql.MaxParameterCount))
{
// JOIN through nodeId → versionId path for efficient query plan.
Sql<ISqlContext> sql = Sql()
.Select<PropertyDataDto>(
x => x.VersionId,
x => x.LanguageId,
x => x.Segment,
x => x.IntegerValue,
x => x.DecimalValue,
x => x.DateValue,
x => x.VarcharValue,
x => x.TextValue)
.AndSelect<PropertyTypeDto>(x => Alias(x.Alias, "PropertyAlias"))
.From<PropertyDataDto>()
.InnerJoin<PropertyTypeDto>().On<PropertyDataDto, PropertyTypeDto>((pd, pt) => pd.PropertyTypeId == pt.Id)
.InnerJoin<ContentVersionDto>().On<PropertyDataDto, ContentVersionDto>((pd, cv) => pd.VersionId == cv.Id)
.WhereIn<ContentVersionDto>(x => x.NodeId, group);
return Database.Fetch<CacheRebuildPropertyDto>(sql);
results.AddRange(Database.Fetch<CacheRebuildPropertyDto>(sql));
}
return results;
}
/// <summary>
/// Gets culture variation data for the specified node IDs.
/// Batched on nodeIds so a NuCacheSettings.SqlPageSize larger than MaxParameterCount still works.
/// </summary>
private List<CacheRebuildCultureDto> GetCultureDataForNodes(List<int> nodeIds)
{
Sql<ISqlContext> sql = Sql()
.Select<ContentVersionCultureVariationDto>(x => x.VersionId, x => x.Name, x => x.UpdateDate)
.AndSelect<LanguageDto>(x => Alias(x.IsoCode, "IsoCode"))
.From<ContentVersionCultureVariationDto>()
.InnerJoin<LanguageDto>().On<ContentVersionCultureVariationDto, LanguageDto>((cv, l) => cv.LanguageId == l.Id)
.InnerJoin<ContentVersionDto>().On<ContentVersionCultureVariationDto, ContentVersionDto>((ccv, cv) => ccv.VersionId == cv.Id)
.WhereIn<ContentVersionDto>(x => x.NodeId, nodeIds);
var results = new List<CacheRebuildCultureDto>();
foreach (IEnumerable<int> group in nodeIds.InGroupsOf(Constants.Sql.MaxParameterCount))
{
Sql<ISqlContext> sql = Sql()
.Select<ContentVersionCultureVariationDto>(x => x.VersionId, x => x.Name, x => x.UpdateDate)
.AndSelect<LanguageDto>(x => Alias(x.IsoCode, "IsoCode"))
.From<ContentVersionCultureVariationDto>()
.InnerJoin<LanguageDto>().On<ContentVersionCultureVariationDto, LanguageDto>((cv, l) => cv.LanguageId == l.Id)
.InnerJoin<ContentVersionDto>().On<ContentVersionCultureVariationDto, ContentVersionDto>((ccv, cv) => ccv.VersionId == cv.Id)
.WhereIn<ContentVersionDto>(x => x.NodeId, group);
return Database.Fetch<CacheRebuildCultureDto>(sql);
results.AddRange(Database.Fetch<CacheRebuildCultureDto>(sql));
}
return results;
}
/// <summary>
/// Gets document culture variation data (edited status per culture) for the specified node IDs.
/// Batched on nodeIds so a NuCacheSettings.SqlPageSize larger than MaxParameterCount still works.
/// </summary>
private List<CacheRebuildDocumentCultureDto> GetDocumentCultureDataForNodes(List<int> nodeIds)
{
Sql<ISqlContext> sql = Sql()
.Select<DocumentCultureVariationDto>(x => x.NodeId, x => x.Edited)
.AndSelect<LanguageDto>(x => Alias(x.IsoCode, "IsoCode"))
.From<DocumentCultureVariationDto>()
.InnerJoin<LanguageDto>().On<DocumentCultureVariationDto, LanguageDto>((dcv, l) => dcv.LanguageId == l.Id)
.WhereIn<DocumentCultureVariationDto>(x => x.NodeId, nodeIds);
var results = new List<CacheRebuildDocumentCultureDto>();
foreach (IEnumerable<int> group in nodeIds.InGroupsOf(Constants.Sql.MaxParameterCount))
{
Sql<ISqlContext> sql = Sql()
.Select<DocumentCultureVariationDto>(x => x.NodeId, x => x.Edited)
.AndSelect<LanguageDto>(x => Alias(x.IsoCode, "IsoCode"))
.From<DocumentCultureVariationDto>()
.InnerJoin<LanguageDto>().On<DocumentCultureVariationDto, LanguageDto>((dcv, l) => dcv.LanguageId == l.Id)
.WhereIn<DocumentCultureVariationDto>(x => x.NodeId, group);
return Database.Fetch<CacheRebuildDocumentCultureDto>(sql);
results.AddRange(Database.Fetch<CacheRebuildDocumentCultureDto>(sql));
}
return results;
}
/// <summary>
@@ -1207,31 +1249,38 @@ internal sealed class DatabaseCacheRepository : RepositoryBase, IDatabaseCacheRe
/// <summary>
/// Gets content metadata for the specified node IDs using efficient JOIN. Used for media and members.
/// Batched on nodeIds so a NuCacheSettings.SqlPageSize larger than MaxParameterCount still works.
/// </summary>
private List<CacheRebuildContentDto> GetContentMetadataForNodes(List<int> nodeIds)
{
Sql<ISqlContext> sql = Sql()
.Select<NodeDto>(
x => x.NodeId,
x => x.UniqueId,
x => x.Text,
x => x.Path,
x => x.Level,
x => x.ParentId,
x => x.SortOrder,
x => x.CreateDate,
x => Alias(x.UserId, "CreatorId"))
.AndSelect<ContentDto>(x => x.ContentTypeId)
.AndSelect<ContentVersionDto>(
x => Alias(x.Id, "VersionId"),
x => Alias(x.VersionDate, "VersionDate"),
x => Alias(x.UserId, "WriterId"))
.From<NodeDto>()
.InnerJoin<ContentDto>().On<NodeDto, ContentDto>((n, c) => n.NodeId == c.NodeId)
.InnerJoin<ContentVersionDto>().On<NodeDto, ContentVersionDto>((n, cv) => n.NodeId == cv.NodeId && cv.Current)
.WhereIn<NodeDto>(x => x.NodeId, nodeIds);
var results = new List<CacheRebuildContentDto>(nodeIds.Count);
foreach (IEnumerable<int> group in nodeIds.InGroupsOf(Constants.Sql.MaxParameterCount))
{
Sql<ISqlContext> sql = Sql()
.Select<NodeDto>(
x => x.NodeId,
x => x.UniqueId,
x => x.Text,
x => x.Path,
x => x.Level,
x => x.ParentId,
x => x.SortOrder,
x => x.CreateDate,
x => Alias(x.UserId, "CreatorId"))
.AndSelect<ContentDto>(x => x.ContentTypeId)
.AndSelect<ContentVersionDto>(
x => Alias(x.Id, "VersionId"),
x => Alias(x.VersionDate, "VersionDate"),
x => Alias(x.UserId, "WriterId"))
.From<NodeDto>()
.InnerJoin<ContentDto>().On<NodeDto, ContentDto>((n, c) => n.NodeId == c.NodeId)
.InnerJoin<ContentVersionDto>().On<NodeDto, ContentVersionDto>((n, cv) => n.NodeId == cv.NodeId && cv.Current)
.WhereIn<NodeDto>(x => x.NodeId, group);
return Database.Fetch<CacheRebuildContentDto>(sql);
results.AddRange(Database.Fetch<CacheRebuildContentDto>(sql));
}
return results;
}
/// <summary>
@@ -3,19 +3,32 @@ using Microsoft.Extensions.DependencyInjection;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Exceptions;
using Umbraco.Cms.Core.Extensions;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.Navigation;
using Umbraco.Cms.Core.PublishedCache;
using Umbraco.Extensions;
using Umbraco.Cms.Core.Extensions;
namespace Umbraco.Cms.Infrastructure.HybridCache;
internal class PublishedContent : PublishedContentBase
{
private IPublishedProperty[] _properties;
/// <summary>
/// Backing array of materialized properties for this content item. Built lazily on first
/// access via <see cref="EnsureProperties"/>; <c>null</c> until then.
/// </summary>
/// <remarks>
/// Lazy construction avoids allocating a <see cref="PublishedProperty"/> wrapper per
/// property type for traversal-only operations (e.g. <c>Children().Count()</c>,
/// <c>Descendants()</c> without property reads), which is a significant slice of
/// allocation for tree traversals.
/// </remarks>
private IPublishedProperty[]? _properties;
private readonly Dictionary<string, PropertyData[]> _propertyData;
private readonly IElementsCache _elementsCache;
private readonly ContentNode _contentNode;
private IReadOnlyDictionary<string, PublishedCultureInfo>? _cultures;
private readonly string? _urlSegment;
@@ -44,21 +57,11 @@ internal class PublishedContent : PublishedContentBase
_contentName = contentData.Name;
_urlSegment = contentData.UrlSegment;
_published = contentData.Published;
_propertyData = contentData.Properties;
_elementsCache = elementsCache;
IsPreviewing = preview;
var properties = new IPublishedProperty[_contentNode.ContentType.PropertyTypes.Count()];
var i = 0;
foreach (IPublishedPropertyType propertyType in _contentNode.ContentType.PropertyTypes)
{
// add one property per property type - this is required, for the indexing to work
// if contentData supplies pdatas, use them, else use null
contentData.Properties.TryGetValue(propertyType.Alias, out PropertyData[]? propertyDatas); // else will be null
properties[i++] = new PublishedProperty(propertyType, this, propertyDatas, elementsCache, propertyType.CacheLevel);
}
_properties = properties;
Id = contentNode.Id;
Key = contentNode.Key;
CreatorId = contentNode.CreatorId;
@@ -73,7 +76,7 @@ internal class PublishedContent : PublishedContentBase
public override Guid Key { get; }
public override IEnumerable<IPublishedProperty> Properties => _properties;
public override IEnumerable<IPublishedProperty> Properties => EnsureProperties();
public override int Id { get; }
@@ -213,15 +216,45 @@ internal class PublishedContent : PublishedContentBase
return null; // happens when 'alias' does not match a content type property alias
}
IPublishedProperty[] properties = EnsureProperties();
// should never happen - properties array must be in sync with property type
if (index >= _properties.Length)
if (index >= properties.Length)
{
throw new IndexOutOfRangeException(
"Index points outside the properties array, which means the properties array is corrupt.");
}
IPublishedProperty property = _properties[index];
return property;
return properties[index];
}
private IPublishedProperty[] EnsureProperties()
{
IPublishedProperty[]? properties = _properties;
if (properties is not null)
{
return properties;
}
return BuildProperties();
}
private IPublishedProperty[] BuildProperties()
{
IEnumerable<IPublishedPropertyType> propertyTypes = _contentNode.ContentType.PropertyTypes;
var newProperties = new IPublishedProperty[propertyTypes.Count()];
var i = 0;
foreach (IPublishedPropertyType propertyType in propertyTypes)
{
// add one property per property type - this is required for the indexing to work
// if propertyData supplies pdatas, use them, else use null
_propertyData.TryGetValue(propertyType.Alias, out PropertyData[]? propertyDatas);
newProperties[i++] = new PublishedProperty(propertyType, this, propertyDatas, _elementsCache, propertyType.CacheLevel);
}
// Use CompareExchange so concurrent first-access threads agree on a single canonical
// array — losers discard their newly built array and use the winner's.
return Interlocked.CompareExchange(ref _properties, newProperties, null) ?? newProperties;
}
public override bool IsDraft(string? culture = null)
@@ -107,6 +107,18 @@ internal sealed class DocumentCacheService : IDocumentCacheService
return await GetNodeAsync(key, calculatedPreview);
}
public bool TryGetCached(Guid key, bool preview, out IPublishedContent? content)
{
// Mirror the L0 (published content cache) fast path in GetNodeAsync.
if (preview is false && _publishedContentCache.TryGetValue(GetCacheKey(key, preview), out content))
{
return true;
}
content = null;
return false;
}
private async Task<IPublishedContent?> GetNodeAsync(Guid key, bool preview)
{
var cacheKey = GetCacheKey(key, preview);
@@ -1,4 +1,4 @@
using System.Collections.Concurrent;
using System.Collections.Concurrent;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Cache;
using Umbraco.Cms.Core.Models;
@@ -9,23 +9,41 @@ using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.Changes;
using Umbraco.Extensions;
namespace Umbraco.Cms.Infrastructure.HybridCache.Services;
/// <summary>
/// Implements <see cref="IDomainCacheService" />, providing an in-memory cache of the configured <see cref="Domain" />s.
/// </summary>
/// <remarks>
/// The cache is lazily populated from the database on first access and kept up to date in response to domain
/// cache refresher notifications. It is registered as a singleton, so a single instance serves all requests.
/// </remarks>
public class DomainCacheService : IDomainCacheService
{
private readonly IDomainService _domainService;
private readonly ICoreScopeProvider _coreScopeProvider;
private readonly ConcurrentDictionary<int, Domain> _domains;
private bool _initialized = false;
private readonly Lock _initializationLock = new();
// Both fields are written under _initializationLock but read on the hot path (request routing) without
// it. Marking them volatile makes those lock-free reads acquire-reads, so a reader is guaranteed to see
// the fully populated dictionary and the completed-initialization flag together, never a stale or
// half-published value. This is required for correctness on weak memory models such as ARM; on x86/x64
// ordinary reads already have acquire semantics, but we cannot rely on that.
private volatile ConcurrentDictionary<int, Domain> _domains = new();
private volatile bool _initialized;
/// <summary>
/// Initializes a new instance of the <see cref="DomainCacheService" /> class.
/// </summary>
/// <param name="domainService">The service used to load domains from the database.</param>
/// <param name="coreScopeProvider">The provider used to create scopes for database access.</param>
public DomainCacheService(IDomainService domainService, ICoreScopeProvider coreScopeProvider)
{
_domainService = domainService;
_coreScopeProvider = coreScopeProvider;
_domains = new ConcurrentDictionary<int, Domain>();
}
/// <inheritdoc />
public IEnumerable<Domain> GetAll(bool includeWildcards)
{
InitializeIfMissing();
@@ -34,22 +52,38 @@ public class DomainCacheService : IDomainCacheService
: _domains.Select(x => x.Value).OrderBy(x => x.SortOrder);
}
/// <summary>
/// Loads the domains on first access, ensuring the cache is populated before any caller reads from it.
/// </summary>
private void InitializeIfMissing()
{
// Lazy, on-demand initialization triggered by the first request to reach the cache.
// The flag must only be set to true *after* the domains have been loaded and published.
// Setting it beforehand creates a window where a concurrent caller observes _initialized == true,
// skips loading, and reads an empty domain cache. On a multi-site setup that empties domain
// resolution, causing every site to fall back to the first root node (see ContentFinderByUrlNew).
// The double-checked lock ensures a single load while concurrent readers block until it completes.
if (_initialized)
{
return;
}
_initialized = true;
LoadDomains();
lock (_initializationLock)
{
if (_initialized)
{
return;
}
LoadDomains();
_initialized = true;
}
}
/// <inheritdoc />
public IEnumerable<Domain> GetAssigned(int documentId, bool includeWildcards = false)
{
InitializeIfMissing();
// probably this could be optimized with an index
// but then we'd need a custom DomainStore of some sort
IEnumerable<Domain> list = _domains.Values.Where(x => x.ContentId == documentId);
if (includeWildcards == false)
{
@@ -66,6 +100,7 @@ public class DomainCacheService : IDomainCacheService
return documentId > 0 && GetAssigned(documentId, includeWildcards).Any();
}
/// <inheritdoc />
public void Refresh(DomainCacheRefresher.JsonPayload[] payloads)
{
foreach (DomainCacheRefresher.JsonPayload payload in payloads)
@@ -102,20 +137,23 @@ public class DomainCacheService : IDomainCacheService
continue; // anomaly
}
var newDomain = new Domain(domain.Id, domain.DomainName, domain.RootContentId.Value, culture, domain.IsWildcard, domain.SortOrder);
// Feels wierd to use key and oldvalue, but we're using neither when updating.
_domains.AddOrUpdate(
domain.Id,
new Domain(domain.Id, domain.DomainName, domain.RootContentId.Value, culture, domain.IsWildcard, domain.SortOrder),
(key, oldValue) => newDomain);
_domains[domain.Id] = new Domain(domain.Id, domain.DomainName, domain.RootContentId.Value, culture, domain.IsWildcard, domain.SortOrder);
break;
}
}
}
/// <summary>
/// Reads the configured domains from the database into a fresh dictionary and atomically swaps it in
/// as the current cache.
/// </summary>
private void LoadDomains()
{
// Build the replacement set in a local dictionary and publish it with a single write to the
// (volatile) _domains field. A reader never observes a partially populated cache during a RefreshAll
// rebuild, and the published set contains exactly the current domains (any removed since the last
// load are absent).
var newDomains = new ConcurrentDictionary<int, Domain>();
using (ICoreScope scope = _coreScopeProvider.CreateCoreScope())
{
scope.ReadLock(Constants.Locks.Domains);
@@ -124,11 +162,11 @@ public class DomainCacheService : IDomainCacheService
.Where(x => x.RootContentId.HasValue && x.LanguageIsoCode.IsNullOrWhiteSpace() == false)
.Select(x => new Domain(x.Id, x.DomainName, x.RootContentId!.Value, x.LanguageIsoCode!, x.IsWildcard, x.SortOrder)))
{
_domains.AddOrUpdate(domain.Id, domain, (key, oldValue) => domain);
newDomains[domain.Id] = domain;
}
scope.Complete();
}
_domains = newDomains;
}
}
@@ -103,6 +103,18 @@ internal sealed class MediaCacheService : IMediaCacheService
return await GetNodeAsync(key);
}
public bool TryGetCached(Guid key, out IPublishedContent? content)
{
// Mirror the L0 (published content cache) fast path in GetNodeAsync.
if (_publishedContentCache.TryGetValue(key, out content))
{
return true;
}
content = null;
return false;
}
private async Task<IPublishedContent?> GetNodeAsync(Guid key)
{
if (_publishedContentCache.TryGetValue(key, out IPublishedContent? cached))
@@ -30,6 +30,9 @@
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
<_Parameter1>Umbraco.Tests.Integration</_Parameter1>
</AssemblyAttribute>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
<_Parameter1>Umbraco.Tests.Benchmarks</_Parameter1>
</AssemblyAttribute>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
<_Parameter1>DynamicProxyGenAssembly2</_Parameter1>
</AssemblyAttribute>
@@ -1,5 +1,4 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.OutputCaching;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core.DependencyInjection;
@@ -78,6 +77,8 @@ public class UmbracoApplicationBuilder : IUmbracoApplicationBuilder, IUmbracoEnd
// Only use backoffice rewrites if backoffice is enabled
if (ApplicationServices.GetService<IBackOfficeEnabledMarker>() is not null)
{
// Must run before the rewriter so the cache-bust hash is still present on the request path.
AppBuilder.UseUmbracoBackOfficeCacheHeaders();
AppBuilder.UseUmbracoBackOfficeRewrites();
}
@@ -97,15 +98,14 @@ public class UmbracoApplicationBuilder : IUmbracoApplicationBuilder, IUmbracoEnd
AppBuilder.UseAuthentication();
AppBuilder.UseAuthorization();
// Register output cache middleware if any feature (website, delivery API) has configured output caching.
// This must be called at most once per application — individual features register policies via
// AddOutputCache() (which is additive) but the middleware itself must only be added here.
// Register output cache middleware only when Umbraco itself has enabled output caching
// (via Website template caching or Delivery API caching configuration). Gating on
// IUmbracoManagedOutputCacheMarker rather than IOutputCacheStore ensures we don't
// duplicate UseOutputCache() when the application has called services.AddOutputCache(...)
// for its own purposes (which also registers IOutputCacheStore).
// Placed after auth (policies may check preview/access state) but before antiforgery, localization,
// and session so that cache hits bypass those middlewares for better throughput.
// NOTE: If the application has already registered UseOutputCache() elsewhere (e.g. in Program.cs),
// this will cause an InvalidOperationException at request time. Remove the external UseOutputCache()
// call when using Umbraco's managed output caching.
if (ApplicationServices.GetService<IOutputCacheStore>() is not null)
if (ApplicationServices.GetService<IUmbracoManagedOutputCacheMarker>() is not null)
{
AppBuilder.UseOutputCache();
}
+9 -1
View File
@@ -63,7 +63,8 @@ Umbraco.Web.Common/
│ └── UmbracoPublishedContentCultureProvider.cs
├── Middleware/
│ ├── BootFailedMiddleware.cs # Startup failure handling (81 lines)
── PreviewAuthenticationMiddleware.cs # Preview mode auth (84 lines)
── PreviewAuthenticationMiddleware.cs # Preview mode auth (84 lines)
│ └── UmbracoBackOfficeCacheHeadersMiddleware.cs # Cache-Control on cache-busted backoffice asset path
├── Routing/
│ ├── IAreaRoutes.cs # Area routing interface
│ ├── IRoutableDocumentFilter.cs # Content routing filter
@@ -256,6 +257,8 @@ ASP.NET Core Identity sign-in manager for members.
### Middleware
**Convention**: middleware lives in `Middleware/` as a class implementing `IMiddleware`, registered as a singleton next to its dependencies' registration (generic middleware in `AddWebComponents`; feature-specific middleware where the feature's services are added, e.g. backoffice middleware in `AddBackOfficeCore`), and wired into the pipeline via `app.UseMiddleware<TMiddleware>()`. Companion `IApplicationBuilder` extension methods are thin one-line `UseMiddleware<T>()` wrappers — inline `builder.Use(async …)` lambdas bypass DI and are harder to test; `CspNonceExtensions` and `Web.UI/WebApplicationExtensions` are tiny pre-existing exceptions, not a precedent for new work.
**BootFailedMiddleware** (lines 17-81):
- Intercepts requests when `RuntimeLevel == BootFailed`
- Debug mode: Rethrows exception for stack trace
@@ -266,6 +269,11 @@ ASP.NET Core Identity sign-in manager for members.
- Skips client-side requests and backoffice paths
- Uses `IPreviewService.TryGetPreviewClaimsIdentityAsync()`
**UmbracoBackOfficeCacheHeadersMiddleware**:
- Sets `Cache-Control: public, max-age=31536000, immutable` on responses under the cache-busted backoffice asset prefix (`/umbraco/backoffice/<hash>/…`); `no-cache` in debug mode
- Runs before `UseUmbracoBackOfficeRewrites` so the original (hash-bearing) path can be matched
- Non-destructive: uses `Response.OnStarting` + `ContainsKey` guard so any consumer override wins
---
## 4. Routing
@@ -229,6 +229,19 @@ public static class ApplicationBuilderExtensions
return app;
}
/// <summary>
/// Registers <see cref="UmbracoBackOfficeCacheHeadersMiddleware"/> to set the default
/// <c>Cache-Control</c> header on responses served from the cache-busted BackOffice assets path.
/// </summary>
/// <remarks>
/// See <see cref="UmbracoBackOfficeCacheHeadersMiddleware"/> for behaviour, debug-mode semantics,
/// and the precedence rules for consumer overrides. Must be registered before
/// <see cref="UseUmbracoBackOfficeRewrites"/> so that the original request path (still containing
/// the cache-bust hash) can be matched.
/// </remarks>
public static IApplicationBuilder UseUmbracoBackOfficeCacheHeaders(this IApplicationBuilder builder)
=> builder.UseMiddleware<UmbracoBackOfficeCacheHeadersMiddleware>();
/// <summary>
/// Configure a virtual path with IApplicationBuilder.UseRewriter for BackOffice assets to allow cache-busting using the url
/// /umbraco/backoffice/!cache-busting-id!/assets/index.js => /umbraco/backoffice/assets/index.js.
@@ -0,0 +1,100 @@
using Microsoft.AspNetCore.Http;
using Microsoft.Net.Http.Headers;
using Umbraco.Cms.Web.Common.Hosting;
using IHostingEnvironment = Umbraco.Cms.Core.Hosting.IHostingEnvironment;
namespace Umbraco.Cms.Web.Common.Middleware;
/// <summary>
/// Sets the default <c>Cache-Control</c> response header on requests served from the cache-busted
/// BackOffice assets path (<c>/umbraco/backoffice/&lt;hash&gt;/...</c>).
/// </summary>
/// <remarks>
/// <para>
/// The path prefix contains a deployment-wide hash derived from the Umbraco version
/// (see <see cref="IBackOfficePathGenerator.BackOfficeCacheBustHash"/>). Because the URL itself
/// changes whenever the version changes, all responses served under that prefix are safe to mark
/// as <c>immutable</c> with a long <c>max-age</c>, regardless of whether the on-disk filename
/// contains a content hash.
/// </para>
/// <para>
/// In debug mode the underlying built assets may change while the app is running (typically
/// from a developer rebuilding the backoffice without restarting the host). The header is
/// therefore set to <c>no-cache</c>, which still allows the browser to store the response
/// but forces an <c>If-None-Match</c> revalidation on the next request — yielding fast 304s
/// when nothing has changed and full 200s when the file on disk has been rebuilt.
/// <c>no-store</c> would force a full re-download on every request, which is unnecessary.
/// </para>
/// <para>
/// This middleware is non-destructive to consumer customisation:
/// <list type="bullet">
/// <item>
/// The header is only set when no <c>Cache-Control</c> value is already present on the
/// response, so synchronous overrides written upstream (including
/// <c>StaticFileOptions.OnPrepareResponse</c>) take precedence.
/// </item>
/// <item>
/// The header is set via <c>HttpResponse.OnStarting</c>; consumer callbacks registered
/// later in the pipeline fire first (LIFO) and can therefore override the default.
/// </item>
/// <item>
/// Non-2xx responses (e.g. 404) are not marked as immutable to avoid long-lived caching
/// of error responses.
/// </item>
/// </list>
/// </para>
/// <para>
/// Must run before <see cref="Umbraco.Extensions.ApplicationBuilderExtensions.UseUmbracoBackOfficeRewrites"/>
/// so the original request path (still containing the cache-bust hash) can be matched.
/// </para>
/// </remarks>
/// <seealso cref="Microsoft.AspNetCore.Http.IMiddleware" />
public class UmbracoBackOfficeCacheHeadersMiddleware : IMiddleware
{
private readonly string _prefix;
private readonly string _headerValue;
public UmbracoBackOfficeCacheHeadersMiddleware(
IBackOfficePathGenerator backOfficePathGenerator,
IHostingEnvironment hostingEnvironment)
{
// Normalise to a single leading slash, no trailing slash — defensive against any
// future change in IBackOfficePathGenerator's output shape.
_prefix = "/" + backOfficePathGenerator.BackOfficeAssetsPath.TrimStart('/').TrimEnd('/');
_headerValue = hostingEnvironment.IsDebugMode
? "no-cache"
: "public, max-age=31536000, immutable";
}
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
if (IsCacheableAssetRequest(context.Request))
{
context.Response.OnStarting(static state =>
{
(HttpResponse response, string value) = ((HttpResponse, string))state;
if (ShouldSetCacheControl(response))
{
response.Headers[HeaderNames.CacheControl] = value;
}
return Task.CompletedTask;
}, (context.Response, _headerValue));
}
await next(context);
}
// Only GET/HEAD: POST/PUT/DELETE responses aren't cacheable in the immutable sense and
// OPTIONS is used for CORS preflight, where a long cache lifetime would prevent the
// browser from re-issuing preflights when needed.
private bool IsCacheableAssetRequest(HttpRequest request)
=> (HttpMethods.IsGet(request.Method) || HttpMethods.IsHead(request.Method))
&& request.Path.StartsWithSegments(_prefix, StringComparison.OrdinalIgnoreCase);
// Include 304 alongside 2xx: intermediate caches (CDNs, proxies) use the Cache-Control on
// the 304 response to update freshness for the cached body.
private static bool ShouldSetCacheControl(HttpResponse response)
=> response.StatusCode is (>= 200 and < 300) or 304
&& !response.Headers.ContainsKey(HeaderNames.CacheControl);
}
+45 -46
View File
@@ -22,45 +22,45 @@ import icons from '../src/packages/core/icon-registry/icons';
import '../src/libs/context-api/provide/context-provider.element';
import '../src/packages/core/components';
import { manifests as blockManifests } from '../src/packages/block/manifests';
import { manifests as clipboardManifests } from '../src/packages/clipboard/manifests';
import { manifests as codeEditorManifests } from '../src/packages/code-editor/manifests';
import { manifests as contentManifests } from '../src/packages/content/manifests';
import { manifests as blockManifests } from '../src/packages/block/umbraco-package';
import { manifests as clipboardManifests } from '../src/packages/clipboard/umbraco-package';
import { manifests as codeEditorManifests } from '../src/packages/code-editor/umbraco-package';
import { manifests as contentManifests } from '../src/packages/content/umbraco-package';
import { manifests as coreManifests } from '../src/packages/core/manifests';
import { manifests as dataTypeManifests } from '../src/packages/data-type/manifests';
import { manifests as dictionaryManifests } from '../src/packages/dictionary/manifests';
import { manifests as documentManifests } from '../src/packages/documents/manifests';
import { manifests as embeddedMediaManifests } from '../src/packages/embedded-media/manifests';
import { manifests as extensionInsightsManifests } from '../src/packages/extension-insights/manifests';
import { manifests as healthCheckManifests } from '../src/packages/health-check/manifests';
import { manifests as helpManifests } from '../src/packages/help/manifests';
import { manifests as languageManifests } from '../src/packages/language/manifests';
import { manifests as logViewerManifests } from '../src/packages/log-viewer/manifests';
import { manifests as markdownEditorManifests } from '../src/packages/markdown-editor/manifests';
import { manifests as mediaManifests } from '../src/packages/media/manifests';
import { manifests as memberManifests } from '../src/packages/members/manifests';
import { manifests as modelsBuilderManifests } from '../src/packages/models-builder/manifests';
import { manifests as multiUrlPickerManifests } from '../src/packages/multi-url-picker/manifests';
import { manifests as packageManifests } from '../src/packages/packages/manifests';
import { manifests as performanceProfilingManifests } from '../src/packages/performance-profiling/manifests';
import { manifests as propertyEditorManifests } from '../src/packages/property-editors/manifests';
import { manifests as publishCacheManifests } from '../src/packages/publish-cache/manifests';
import { manifests as relationsManifests } from '../src/packages/relations/manifests';
import { manifests as rteManifests } from '../src/packages/rte/manifests';
import { manifests as dataTypeManifests } from '../src/packages/data-type/umbraco-package';
import { manifests as dictionaryManifests } from '../src/packages/dictionary/umbraco-package';
import { manifests as documentManifests } from '../src/packages/documents/umbraco-package';
import { manifests as embeddedMediaManifests } from '../src/packages/embedded-media/umbraco-package';
import { manifests as extensionInsightsManifests } from '../src/packages/extension-insights/umbraco-package';
import { manifests as healthCheckManifests } from '../src/packages/health-check/umbraco-package';
import { manifests as helpManifests } from '../src/packages/help/umbraco-package';
import { manifests as languageManifests } from '../src/packages/language/umbraco-package';
import { manifests as logViewerManifests } from '../src/packages/log-viewer/umbraco-package';
import { manifests as markdownEditorManifests } from '../src/packages/markdown-editor/umbraco-package';
import { manifests as mediaManifests } from '../src/packages/media/umbraco-package';
import { manifests as memberManifests } from '../src/packages/members/umbraco-package';
import { manifests as modelsBuilderManifests } from '../src/packages/models-builder/umbraco-package';
import { manifests as multiUrlPickerManifests } from '../src/packages/multi-url-picker/umbraco-package';
import { manifests as packageManifests } from '../src/packages/packages/umbraco-package';
import { manifests as performanceProfilingManifests } from '../src/packages/performance-profiling/umbraco-package';
import { manifests as propertyEditorManifests } from '../src/packages/property-editors/umbraco-package';
import { manifests as publishCacheManifests } from '../src/packages/publish-cache/umbraco-package';
import { manifests as relationsManifests } from '../src/packages/relations/umbraco-package';
import { manifests as rteManifests } from '../src/packages/rte/umbraco-package';
import { manifests as searchManifests } from '../src/packages/core/search/manifests';
import { manifests as segmentManifests } from '../src/packages/segment/manifests';
import { manifests as settingsManifests } from '../src/packages/settings/manifests';
import { manifests as staticFileManifests } from '../src/packages/static-file/manifests';
import { manifests as sysInfoManifests } from '../src/packages/sysinfo/manifests';
import { manifests as tagManifests } from '../src/packages/tags/manifests';
import { manifests as telemetryManifests } from '../src/packages/telemetry/manifests';
import { manifests as templatingManifests } from '../src/packages/templating/manifests';
import { manifests as tipTapManifests } from '../src/packages/tiptap/manifests';
import { manifests as translationManifests } from '../src/packages/translation/manifests';
import { manifests as ufmManifests } from '../src/packages/ufm/manifests';
//import { manifests as umbracoNewsManifests } from '../src/packages/umbraco-news/manifests';
import { manifests as userManifests } from '../src/packages/user/manifests';
import { manifests as webhookManifests } from '../src/packages/webhook/manifests';
import { manifests as segmentManifests } from '../src/packages/segment/umbraco-package';
import { manifests as settingsManifests } from '../src/packages/settings/umbraco-package';
import { manifests as staticFileManifests } from '../src/packages/static-file/umbraco-package';
import { manifests as sysInfoManifests } from '../src/packages/sysinfo/umbraco-package';
import { manifests as tagManifests } from '../src/packages/tags/umbraco-package';
import { manifests as telemetryManifests } from '../src/packages/telemetry/umbraco-package';
import { manifests as templatingManifests } from '../src/packages/templating/umbraco-package';
import { manifests as tipTapManifests } from '../src/packages/tiptap/umbraco-package';
import { manifests as translationManifests } from '../src/packages/translation/umbraco-package';
import { manifests as ufmManifests } from '../src/packages/ufm/umbraco-package';
//import { manifests as umbracoNewsManifests } from '../src/packages/umbraco-news/umbraco-package';
import { manifests as userManifests } from '../src/packages/user/umbraco-package';
import { manifests as webhookManifests } from '../src/packages/webhook/umbraco-package';
import { UmbNotificationContext } from '../src/packages/core/notification';
import { UmbContextBase } from '../src/libs/class-api/index';
@@ -108,7 +108,6 @@ class UmbStoryBookElement extends UmbLitElement {
...publishCacheManifests,
...relationsManifests,
...rteManifests,
...searchManifests,
...segmentManifests,
...settingsManifests,
...staticFileManifests,
@@ -190,25 +189,25 @@ export const parameters = {
},
},
backgrounds: {
options: {
greyish: {
options: {
greyish: {
name: 'Greyish',
value: '#F3F3F5',
},
white: {
white: {
name: 'White',
value: '#ffffff',
}
}
},
}
},
};
setCustomElements(customElementManifests);
export const tags = ['autodocs'];
export const initialGlobals = {
backgrounds: {
value: 'greyish'
}
backgrounds: {
value: 'greyish'
}
};
@@ -5,7 +5,7 @@ import { createImportMap } from '../importmap/index.js';
const excludeTheseMaps = [
'@umbraco-cms/backoffice/models',
'@umbraco-cms/backoffice/markdown-editor',
'@umbraco-cms/backoffice/markdown-editor', // Excluded because it loads Monaco Editor which fails to load workers in the test environment
'@umbraco-cms/backoffice/external/',
]
@@ -4,7 +4,8 @@ import { createImportMap } from '../importmap/index.js';
const ILLEGAL_CORE_IMPORTS_THRESHOLD = 5;
const SELF_IMPORTS_THRESHOLD = 0;
const BIDIRECTIONAL_IMPORTS_THRESHOLD = 15;
const CORE_MODULES_BIDIRECTIONAL_IMPORTS_THRESHOLD = 16;
const PACKAGES_MODULES_BIDIRECTIONAL_IMPORTS_THRESHOLD = 14;
const clientProjectRoot = path.resolve(import.meta.dirname, '../../');
const modulePrefix = '@umbraco-cms/backoffice/';
@@ -187,13 +188,13 @@ function reportSelfImportsFromModules() {
console.log(`\n\n`);
}
function reportBidirectionalModuleImports() {
console.error(`🔍 Scanning all modules for bidirectional imports...`);
function reportBidirectionalModuleImports(modules, label, threshold) {
console.error(`🔍 Scanning all ${label} modules for bidirectional imports...`);
console.log(`\n`);
let entries = [];
packageModules.forEach(([alias, path]) => {
modules.forEach(([alias, path]) => {
const importsInModule = getUmbracoModuleImportsInModule(alias);
// Check imports for all the modules
@@ -216,16 +217,12 @@ function reportBidirectionalModuleImports() {
console.error(`🚨 ${moduleA} and ${moduleB} are importing each other`);
});
if (total > BIDIRECTIONAL_IMPORTS_THRESHOLD) {
throw new Error(
`Bidirectional imports found in ${total} modules. ${total - BIDIRECTIONAL_IMPORTS_THRESHOLD} more than the threshold.`,
);
if (total > threshold) {
throw new Error(`Bidirectional imports found in ${total} modules. ${total - threshold} more than the threshold.`);
} else if (total === 0) {
console.log(`✅ Success! No bidirectional imports found.`);
} else {
console.log(
`✅ Success! Still (${total}) under the threshold of ${BIDIRECTIONAL_IMPORTS_THRESHOLD} bidirectional imports.`,
);
console.log(`✅ Success! Still (${total}) under the threshold of ${threshold} bidirectional imports.`);
}
console.log(`\n\n`);
@@ -234,7 +231,8 @@ function reportBidirectionalModuleImports() {
function report() {
reportIllegalImportsFromCore();
reportSelfImportsFromModules();
reportBidirectionalModuleImports();
reportBidirectionalModuleImports(coreModules, 'Core', CORE_MODULES_BIDIRECTIONAL_IMPORTS_THRESHOLD);
reportBidirectionalModuleImports(packageModules, 'Packages', PACKAGES_MODULES_BIDIRECTIONAL_IMPORTS_THRESHOLD);
}
report();
@@ -651,3 +651,60 @@ if (state.held?.some((l) => l.name === 'umb:token-refresh')) {
Note: there is a TOCTOU gap between `query()` and `request()`. If the lock releases between the two calls, `request()` acquires and releases immediately — this is harmless.
### Routing (`umb-router-slot` + dynamic routes)
When a view owns an `umb-router-slot` and computes its routes from observable data (e.g. workspace/design editors), three behaviours of the slot must be respected. Getting any one of them wrong leaves the view stuck on a path it cannot recover from.
**Guard the slot until routes are populated**
If `umb-router-slot` mounts with `routes = undefined` (or an early/empty array), it fires `init`/`change` against whatever the URL currently is, settles on that local path, and does **not** re-match the URL when the routes array is replaced later. Always wrap the slot:
```typescript
// ❌ Slot mounts with undefined routes, locks in the wrong active path
return html`<umb-router-slot .routes=${this._routes}></umb-router-slot>`;
// ✅ Slot only mounts once real routes are in hand
return html`
${this._routes
? html`<umb-router-slot .routes=${this._routes}></umb-router-slot>`
: nothing}
`;
```
`umb-workspace-editor` (`packages/core/workspace/components/workspace-editor/workspace-editor.element.ts`) uses this guard; views that build their own router-slot must do the same.
**Don't compute routes against not-yet-loaded data**
Helpers like `UmbContentTypeContainerStructureHelper.childContainers` ship with `[]` as their initial value, so the first observer callback fires synchronously with empty data. If `#createRoutes()` runs at that point, the slot sees a wrong route set first. Await the structure load before wiring the helper:
```typescript
this.consumeContext(UMB_CONTENT_TYPE_WORKSPACE_CONTEXT, async (workspaceContext) => {
this.#workspaceContext = workspaceContext;
if (!workspaceContext) return;
// Block route generation until real containers are loaded
await workspaceContext.structure.whenLoaded();
this.#tabsStructureHelper.setStructureManager(workspaceContext.structure);
this.#observeRootGroups();
});
```
**`redirectTo` doesn't fire on the initial route attachment**
The router-slot library only applies `redirectTo` on navigation events, not when routes are first attached. A `path: ''` route with `redirectTo: 'foo'` will leave the slot sitting on the empty local path forever. Use **route duplication** instead — copy the target route onto the empty path:
```typescript
// ❌ Redirect never fires when the slot mounts late (e.g. inside a modal workspace)
routes.push({ path: '', pathMatch: 'full', redirectTo: 'tab/settings' });
// ✅ Duplicate the landing route directly under the empty path
const defaultRoute = routes[0]; // or whichever is the landing route
routes.push({ ...defaultRoute, path: '' });
```
`umb-workspace-editor` uses this pattern — see the `// Duplicate first workspace and use it for the empty path scenario.` block in `workspace-editor.element.ts`.
Do **not** add `pathMatch: 'full'` to the duplicated empty-path route. The modal sub-router appends modal paths (e.g. `/add-property/-1/container-root`) to the current active local path. With `path: ''` matching prefix-wise (regex `/^/`), the main route stays matched and the modal-router can resolve the appended segment. With `pathMatch: 'full'`, the empty-path route only matches an exactly-empty URL — modal URLs fall through to the catch-all, the route component unmounts, the modal registration is torn down, and the modal never opens.
+16 -8
View File
@@ -209,29 +209,37 @@ Three mechanisms exist for registering manifests, appropriate for different scen
### Package Bundles (Internal Packages)
The standard pattern for packages within the monorepo. A `umbraco-package.ts` exports a bundle manifest that lazy-loads the package's manifests:
The standard pattern for packages within the monorepo. A `umbraco-package.ts` imports manifests from each sub-feature, assembles them into a static array, and passes that array directly to the bundle extension:
```typescript
// umbraco-package.ts
import { manifests as sectionManifests } from './section/manifests.js';
import { manifests as dashboardManifests } from './dashboard/manifests.js';
import type { UmbExtensionManifestKind } from '@umbraco-cms/backoffice/extension-registry';
export const manifests: Array<UmbExtensionManifest | UmbExtensionManifestKind> = [
...sectionManifests,
...dashboardManifests,
];
export const name = 'Umbraco.Documents';
export const extensions = [
{
type: 'bundle',
alias: 'Umb.Bundle.Documents',
name: 'Documents Bundle',
js: () => import('./manifests.js'),
js: {
manifests,
},
},
];
```
#### Manifest Bundling
Each sub-feature exports its own `manifests` array. The package-level `manifests.ts` aggregates them:
Each sub-feature exports its own `manifests` array from its local `manifests.ts`. These are imported and spread directly into the package-level `manifests` array in `umbraco-package.ts` — there is no separate root-level `manifests.ts` aggregator file.
```typescript
import { manifests as sectionManifests } from './section/manifests.js';
import { manifests as dashboardManifests } from './dashboard/manifests.js';
export const manifests = [...sectionManifests, ...dashboardManifests];
```
The static `js: { manifests }` reference (instead of a dynamic `js: () => import('./manifests.js')`) allows Vite to bundle the manifest registrations together with the package file, eliminating a network round-trip per bundle at startup.
### Static Package Manifest (External Packages)
@@ -20,8 +20,7 @@ src/packages/media/ <- package root
│ ├── index.ts
│ ├── manifests.ts
│ └── ...
── manifests.ts <- aggregates all module manifests
└── umbraco-package.ts <- bundle entry point
── umbraco-package.ts <- aggregates module manifests + bundle entry point
```
### Public vs. Private Modules
@@ -65,7 +64,7 @@ declare global {
### Manifest Bundling
Each sub-feature exports its own `manifests` array, aggregated up to the package root. See [Manifests & Aliases — Manifest Bundling](./manifests.md#manifest-bundling) for the pattern.
Each sub-feature exports its own `manifests` array from its local `manifests.ts`. These bubble up to `umbraco-package.ts`, which assembles them and registers the bundle — there is no separate root-level `manifests.ts`. See [Manifests & Aliases — Package Bundles](./manifests.md#package-bundles-internal-packages) for the full pattern.
---
@@ -99,6 +98,16 @@ No hardcoded UI-facing strings. All user-visible text must go through the locali
For step-by-step instructions on adding localization keys and using them in elements or controllers, use the `general-add-localization` skill.
### Active language
The active language is driven by the shell elements `<umb-app>` and `<umb-auth>`, not by `<html lang>`:
- Razor sets `lang` on the shell element from `GlobalSettings.DefaultUILanguage`. The shell reads its own `lang` on connect and calls `umbLocalizationRegistry.loadLanguage(this.lang)`.
- After login, `current-user.context` calls `loadLanguage(user.languageIsoCode)` and the shell mirrors the new value back onto its own `lang` attribute via `umbLocalizationRegistry.currentLanguage`.
- `<html lang>` is the static `"en"` for the noscript fallback text. Don't conflate it with the dynamic UI language.
If you're adding a new shell-like element (rare — most code lives inside `<umb-app>`), give it a `lang` attribute and the same subscribe-and-mirror pattern. For everything else, just use `this.localize` and the inherited context resolves the rest.
---
## Conventions & Rules
@@ -275,6 +275,79 @@ Workspace contexts can use kinds for shared patterns:
Most simple entities use `UmbSubmitWorkspaceAction` directly (no custom action class needed). Complex entities like documents define custom action classes for variant dialogs, permission checks, etc.
### Button state when the action opens a modal
If your action opens a confirmation/picker modal before doing real work, the legacy "set `waiting` on click" path produces a spinner during the modal and a success tick the moment the user cancels — both wrong. Workspace actions can opt in to a deferred-feedback contract instead.
The contract has three pieces:
| Piece | Where | Role |
|---|---|---|
| `UmbWorkspaceActionExecutionOptions` | `@umbraco-cms/backoffice/workspace` | Public type with `onActionStarting?: () => void` |
| `notifyWorkspaceActionStarting(options)` | `@umbraco-cms/backoffice/workspace` | Fires `options?.onActionStarting?.()` once, at the point real work begins |
| `UmbWorkspaceActionBase.setExecuting(value)` | `workspace-action-base.controller.ts` | Materialises an `isExecuting` observable on first call; the button element observes it |
**Action side.** Subclass `UmbWorkspaceActionBase` (or one of its specialisations like `UmbSaveWorkspaceAction`), opt in in the constructor, then forward the callback through the workspace-context method:
```ts
export class MyWorkspaceAction extends UmbWorkspaceActionBase {
constructor(host: UmbControllerHost, args: UmbWorkspaceActionArgs) {
super(host, args);
// Opt in — exposes `isExecuting` so the button waits for real work.
this.setExecuting(false);
}
override async execute() {
try {
await this._workspaceContext?.doTheirThing({
onActionStarting: () => this.setExecuting(true),
});
} finally {
this.setExecuting(false);
}
}
}
```
**Context side.** A workspace-context method that gates work behind a modal accepts the options and calls `notifyWorkspaceActionStarting` after the gate:
```ts
public async doTheirThing(options?: UmbWorkspaceActionExecutionOptions): Promise<void> {
const result = await umbOpenModal(this, MY_CONFIRM_MODAL, { /* ... */ })
.catch(() => undefined);
if (!result) return; // user cancelled — silent return, no spinner, no tick
notifyWorkspaceActionStarting(options);
// real work below
await this.#repository.doTheirThing(result);
}
```
What the button element does with this:
- `isExecuting` is undefined (action didn't opt in) → legacy behaviour: `waiting` on click, `success` or `failed` on resolve/reject.
- `isExecuting` exists but never flips to `true` → button stays idle. This is the cancel path.
- `isExecuting` flips to `true``waiting` shown. Action resolves → `success`. Action rejects → `failed`.
Pre-flight rejections (modal cancel, missing context, `throw new Error('...')` before `setExecuting(true)`) leave the button idle — the warning still reaches the console via `#runApiAction`'s catch, but the user isn't shown a red cross for something they can't act on.
**Overriding `_handleSave` / context methods in subclasses.** If you extend a base workspace context and override a method that accepts `UmbWorkspaceActionExecutionOptions`, forward the argument:
```ts
protected override async _handleSave(executionOptions?: UmbWorkspaceActionExecutionOptions) {
// ... your override logic ...
await super._handleSave(executionOptions); // forward — not super._handleSave()
}
```
TypeScript won't warn if you drop the parameter; the spinner just silently stops working.
Reference implementations:
- `UmbSaveWorkspaceAction``src/packages/core/workspace/components/workspace-action/common/save/save.action.ts`
- `UmbDocumentSaveAndPublishWorkspaceAction``src/packages/documents/documents/publishing/publish/workspace-action/save-and-publish.action.ts`
- `UmbContentDetailWorkspaceContextBase._handleSave` and `UmbDocumentPublishingWorkspaceContext.saveAndPublish` for the context-side handshake.
---
## Route Patterns
@@ -1,11 +1,13 @@
import { getConfigValue } from '@umbraco-cms/backoffice/utils';
import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api';
import {
UmbDocumentItemDataResolver,
UmbDocumentItemRepository,
UmbDocumentSearchRepository,
UmbDocumentTreeRepository,
UMB_DOCUMENT_ENTITY_TYPE,
} from '@umbraco-cms/backoffice/document';
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
import { UMB_DOCUMENT_TYPE_ENTITY_TYPE } from '@umbraco-cms/backoffice/document-type';
import { UMB_PROPERTY_TYPE_BASED_PROPERTY_CONTEXT } from '@umbraco-cms/backoffice/content';
import type {
@@ -20,6 +22,7 @@ import type {
UmbPickerSearchableDataSource,
UmbPickerTreeDataSource,
} from '@umbraco-cms/backoffice/picker-data-source';
import type { UmbItemDataResolver } from '@umbraco-cms/backoffice/entity-item';
import type { UmbReferenceByUnique } from '@umbraco-cms/backoffice/models';
import type { UmbSearchRequestArgs } from '@umbraco-cms/backoffice/search';
import type { UmbTreeAncestorsOfRequestArgs } from '@umbraco-cms/backoffice/tree';
@@ -84,6 +87,17 @@ export class ExampleDocumentPickerPropertyEditorDataSource
return this.#item.requestItems(uniques);
}
/**
* Creates a document item data resolver bound to the given host.
* The resolver reads variant-based names and icons using UMB_VARIANT_CONTEXT,
* so the host must be the element or context that owns the picker in the DOM.
* @param {UmbControllerHost} host The controller host of the picker consumer.
* @returns {UmbItemDataResolver} A resolver that provides language-context-aware metadata for document items.
*/
createItemDataResolver(host: UmbControllerHost): UmbItemDataResolver {
return new UmbDocumentItemDataResolver(host);
}
search(args: UmbSearchRequestArgs) {
const allowedContentTypes = this.#getAllowedDocumentTypesConfig();
const combinedArgs: UmbDocumentSearchRequestArgs = { ...args, allowedContentTypes };

Some files were not shown because too many files have changed in this diff Show More