Compare commits

...
Author SHA1 Message Date
Andy Butland 9044100f4b Addressed code review comments. 2026-06-17 14:14:21 +02:00
Andy Butland 16f4a82ff4 Added background job to (debug) log memory usage of various caches. 2026-06-17 13:34:48 +02:00
dbecec3451 Cache: Populate the domain cache eagerly during start-up (#23139)
* Populate the domain cache eagerly during start-up

* Added extension method to encapsulate and test logic for skipping startup seeding.

---------

Co-authored-by: Andy Butland <abutland73@gmail.com>
2026-06-16 13:19:09 +00:00
Andy ButlandandGitHub d92e6bbeff Tiptap: Preserve wrapping link when editing an image (closes #23013) (#23019)
* Retain surrounding link when editing image details in rich text editor.

* Add JSDocs.
2026-06-16 11:06:45 +01:00
e56ddcc3f9 Tiptap RTE: Fixes toolbar button active state not updating on collapsed-cursor mark toggle (closes #22907) (#22929)
* update tiptap event listneres

* Tiptap: Add regression test for toolbar button active state on collapsed-cursor toggle (closes #22907)

Tests verify the `transaction` listener wiring that fixes the stored-mark active-state bug.

---------

Co-authored-by: leekelleher <leekelleher@gmail.com>
2026-06-16 09:56:42 +00:00
fb8c3b19ce Backoffice: Fix core circular import (fetchAllPages) breaking the test check (#23133)
Backoffice: Move fetchAllPages into the repository module to break a core circular import

#22765 added the offset pagination helper `fetchAllPages` under
`@umbraco-cms/backoffice/utils`, but its contract is expressed entirely in
repository-owned types (`UmbDataSourceResponse<UmbPagedModel<T>>`). That made
`utils` import `repository` while `repository` already imports `utils`,
introducing a 17th core bidirectional module import and tripping
`check:module-dependencies` (threshold 16) — failing the `test` job on every
open PR.

Relocate the helper (and its test) into the `repository` module, which
legitimately owns those types, and export it from
`@umbraco-cms/backoffice/repository`. The sole consumer
(UmbLanguageCollectionRepository) already imports from that module. Core
bidirectional imports are back to 16.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-16 09:51:36 +01:00
Andy ButlandandGitHub 7a7aadffe5 User Groups: Clear the user group cache when a language is deleted (closes #23121) (#23131)
* Ensure user group cache is cleared on language delete.

* Apply code review feedback.
2026-06-16 08:47:46 +02:00
Andy ButlandandGitHub 0ac6e8500a Background Jobs: Recover cache-sync and server-touch jobs when a database call hangs (closes #23106) (#23119)
* Recover cache-sync job if database Sync() hangs.

* Apply also to TouchServerJob.

* Addressed code review comments.

* Added debug logging to allow monitorring of job runs.

* Added tests verifying that jobs resume after an inflight call completes.
2026-06-16 08:01:14 +02:00
4c35c0c2e9 Management API: Add endpoints to sort the children of a document or media item by a system field (#23077)
* Add endpoints for sorting documents and media by system fields.

* Addressed code review feedback.

* Persist sort-children-by-field with a single set-based update.

* Addressed second round of code review feedback.

* DRYed up similar code, improved comments.

* Split tests into individual class files

* Added test for combined sort of invariant and variant children.

* Addressed further code review feedback.

* Include test scenario from #23128 for SortChildren()

* Renamed children authorizer as it is generic, not specific for sorting - and updated XML docs accordingly

---------

Co-authored-by: kjac <kja@umbraco.dk>
2026-06-16 05:29:47 +00:00
Andy ButlandandGitHub de47e0b1f7 Content/Media: Reload content on Sort to avoid data loss with partially loaded entities (closes #23120) (#23128)
Ensure calling sort for content or media with partially loaded entities doesn't lose the unloaded data on persistence.
2026-06-15 19:31:34 +02:00
Andy Butland 969ae87798 Added TODO to adjust the ScheduledPublishingSettings.AlignToClock default to true. 2026-06-15 16:39:23 +02:00
Andy ButlandandGitHub 828e359666 Languages: Page through to load all configured languages (#22765)
* Ensure all languages are retrieved handling rare (theoretical?) case where the number of languages exceeds the default page size.

* Addressed code review feedback.

* Addressed further code review feedback.
2026-06-15 16:17:30 +02:00
a5b7e0dac1 Scheduled publishing: Add configurable period and optional clock-aligned scheduling (#23127)
* Add configurable period for scheduled publishing task with optional clock alignment.

* Apply suggestions from code review

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

* Addressed code review comments.

* Clarified the maths, improved comments and test coverage.

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-15 13:48:34 +00:00
Andy ButlandandGitHub 86abc3528d Request Logging: Avoid forcing a session store load when resolving the session id for logging (closes #23082) (#23083)
* Guard read of session ID for log enrichment by presence of session cookie.

* Renamed tests.

* Add configurable option for session ID logging, retaining backward compatibility but giving options to skip session Id logging or use a cookie hash.
2026-06-15 06:47:59 +02:00
Andy ButlandandGitHub c45b12ec58 Dependencies: Update MessagePack to 3.1.7 to address security advisories (#23113)
Update MessagePack dependency to 3.1.7.
2026-06-15 06:34:57 +02:00
Andy ButlandandGitHub 22c4bc7835 Package migrations: Surface a failed unattended package migration as a boot failure instead of getting stuck on the upgrade screen (#23114)
* Surface a package migration exception as a boot failure, avoiding being stuck in an upgrading state.

* Addressed code review feedback.

* Fix failing integration tests.
2026-06-15 11:58:58 +09:00
Andy ButlandandGitHub 1e82376420 Skills: Add umb-release-notes skill for improving generated release notes (#23112)
* Add skill to help with improving the generated GitHub release notes.

* Addressed skill review feedback.
2026-06-15 11:41:57 +09: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
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
179 changed files with 8866 additions and 878 deletions
+135
View File
@@ -0,0 +1,135 @@
---
name: umb-release-notes
description: Improve a set of auto-generated GitHub release notes for an Umbraco CMS release. Cross-checks the notes against every PR carrying the release label, adds any that are missing, re-files every PR under the most appropriate category, and strips purely-internal entries. Use whenever the user asks to tidy up, improve, complete, or recategorize release notes for a given version, or mentions a release-notes text file plus a version number.
argument-hint: <version> <path-to-generated-notes-file>
---
# Umbraco CMS - Improve Release Notes
Takes a file of auto-generated GitHub release notes and produces an improved version that:
1. **Is complete** — every merged PR carrying the `release/<version>` label appears.
2. **Is well-categorized** — every PR sits under the most appropriate heading.
3. **Is free of noise** — purely-internal entries of no value to a reader are removed.
The result is written to a **new** file alongside the input, so the user can diff the two.
**Run autonomously.** Do NOT use `AskUserQuestion` once the required arguments (version and input file path) are available — only ask if one of them is missing from `$ARGUMENTS` and cannot be inferred (see Arguments). Beyond that, make the categorization calls yourself using the rules below; if a handful are genuinely borderline, place them anyway and note the borderline ones in your closing summary so the user can override.
## Arguments
`$ARGUMENTS` contains two values:
1. **Version** — e.g. `17.5.0`, `18.1.0`. The GitHub label to search is `release/<version>` (so version `17.5.0` → label `release/17.5.0`).
2. **Input file path** — full path to the text file holding the auto-generated notes (e.g. `C:\Temp\release-17.5.0-rc.md`).
If either is missing, ask the user once for the missing value, then proceed.
## Prerequisites
Run `gh auth status`. If it fails, tell the user to authenticate `gh` (e.g. `gh auth login`) and stop — the skill needs the GitHub CLI to query PRs. The repo is always `umbraco/Umbraco-CMS`.
## Procedure
### 1. Read the input notes
Read the input file. Note its structure — it is GitHub's generated format:
- A leading HTML comment (`<!-- Release notes generated ... -->`).
- A `## What's Changed` heading followed by `### <emoji> <Category>` sub-headings, each with `* <title> by @<author> in <url>` bullets.
- A trailing `## New Contributors` section and a `**Full Changelog**: ...` line.
Extract the set of PR numbers already present (parse the `/pull/<number>` from each bullet). Preserve each existing bullet's **exact text** (title, author, URL) when you re-emit it — only its category placement may change.
### 2. Fetch every labelled PR
```bash
gh pr list --repo umbraco/Umbraco-CMS --label "release/<version>" --state closed --limit 1000 \
--json number,title,author,labels,mergedAt \
--jq '.[] | select(.mergedAt != null) | "\(.number)\t\(.author.login)\t\([.labels[].name] | join(", "))\t\(.title)"' | sort -n
```
This is the authoritative list of what the release *should* contain. Each row gives number, author, labels, title.
**Guard against silent truncation.** `gh pr list` caps at `--limit` without warning, so a large release could drop the overflow and the skill would still look "complete". Count the returned rows and compare against the limit:
```bash
gh pr list --repo umbraco/Umbraco-CMS --label "release/<version>" --state closed --limit 1000 --json number --jq 'length'
```
If this equals 1000, the limit was hit — raise `--limit` and re-fetch before continuing. Do **not** proceed on a truncated list.
### 3. Reconcile
- **Missing labelled PRs** (labelled but not in the input file): these must be **added**. Build a bullet as `* <title> by @<author> in https://github.com/umbraco/Umbraco-CMS/pull/<number>`.
- **Author handle.** `<author>` in the template is the raw `.author.login` value — the bullet supplies the leading `@`, so do not prepend another. `gh`'s `.author.login` already returns bot accounts with the `[bot]` suffix as part of the login — Dependabot comes back as `dependabot[bot]`, not `dependabot` or `app/dependabot` (the `app/` form only appears in git committer metadata and CODEOWNERS, never in `gh`'s JSON). So the login is already in the right shape; use it verbatim (e.g. `.author.login` of `dependabot[bot]` renders as `@dependabot[bot]`, matching what GitHub's generator wrote for the existing bullets). The only thing to guard against is accidentally stripping or altering the `[bot]` suffix.
- **PRs in the file but not labelled**: keep them. The generated notes span a commit range (see the `Full Changelog` compare link), so they legitimately include backports / earlier-version PRs that lack the current label. For any of these you need to categorize, fetch its labels with:
```bash
gh pr view <number> --repo umbraco/Umbraco-CMS --json number,title,labels \
--jq '"\(.number)\t\([.labels[].name] | join(", "))\t\(.title)"'
```
Do **not** invent or alter the `New Contributors` section — carry it over verbatim. You cannot reliably recompute first-time contributors, so leave it as the generator produced it (mention this in the summary).
### 4. Categorize every PR
Use exactly these headings, in this order. Omit any heading that ends up with no entries.
| Heading | What goes here | Primary signal |
|---|---|---|
| `### 🙌 Notable Changes` | **Don't recategorize existing entries.** Label-driven — but still add any missing PR carrying this label here. | label `category/notable` |
| `### 💥 Breaking Changes` | **Don't recategorize existing entries.** Label-driven — but still add any missing PR carrying this label here. | label `category/breaking` |
| `### 📦 Dependencies` | Dependency bumps | label `dependencies`; or dependabot author |
| `### 🚀 New Features` | New user- or developer-facing capability | label `type/feature` / `category/feature`; or title introduces/adds a genuinely new capability |
| `### 🚤 Performance` | Performance improvements | label `category/performance`; or `Performance:` title prefix |
| `### 🌈 Accessibility Improvements` | A11y improvements (labels, contrast, keyboard) | label `category/accessibility` / `accessibility`; or clear a11y intent (e.g. "improve contrast", "missing labels") |
| `### 🐛 Bug Fixes` | Fixes to broken/incorrect behaviour | default for anything describing a fix |
| `### 🧪 Testing` | Test additions/changes only | label `category/test-automation` / `area/test`; or `E2E`/`QA`/"acceptance tests"/"unit test coverage"/"add tests" titles |
| `### 🛡️ Code Quality, Documentation and Refactoring` | Refactors, deprecations, API tidy-ups, XML/MD documentation, knowledge-base (`MD`) updates | label `category/refactor`; or titles about refactoring, deprecating, renaming, documenting, constants extraction, MD/CLAUDE.md content |
| `### 🧑‍💻 Developer Experience` | Things that improve the experience of developers building on or contributing to Umbraco — dev tooling, build/watch ergonomics, test mocks/harnesses, backoffice dev utilities | `Developer Experience` title prefix; dev tooling; mock/harness changes |
**Rules:**
- **Notable and Breaking are off-limits for recategorization** — never move a PR that is *already in the input file* into or out of these sections; they are driven purely by their labels and the generator placed them correctly. This does **not** exempt them from completeness: a PR discovered as missing in step 3 that carries `category/notable` or `category/breaking` must still be **added** under the matching section.
- Label signals beat title wording, except a `Performance:`/`Developer Experience:` title prefix is decisive for its section.
- A PR with both `type/feature` and `category/refactor` whose title clearly describes a refactor (e.g. "swap relative imports", "re-export type") belongs under Code Quality, not New Features.
- "Add ... tests"/"unit test coverage" → Testing, even if it also touches docs. If a PR adds XML documentation *and* tests, lead with where the title's emphasis lies (documentation → Code Quality; test coverage → Testing).
- When a PR is genuinely 50/50, pick the more reader-useful heading and list it in your closing summary as borderline.
### 5. Remove purely-internal noise
Drop entries that have **no value to anyone reading release notes** — pure repository plumbing with no shipped impact. Examples:
- Branch/merge maintenance ("Fix main branch after merge issue").
- CI/pipeline fixes that don't change the product.
- Reverts of changes that never shipped in a release.
**Keep** anything that ships in the product or genuinely helps developers building on Umbraco — that includes documentation/MD updates, dev tooling, and test mocks (those go to Code Quality or Developer Experience, they are *not* noise). When unsure whether something is noise, keep it and flag it in the summary rather than silently dropping it. List every removal in your closing summary.
### 6. Write the output
Write to a new file in the **same folder** as the input, named by appending ` - with updates` before the extension:
- Input `C:\Temp\release-17.5.0-rc.md` → Output `C:\Temp\release-17.5.0-rc - with updates.md`
Preserve the leading HTML comment, the `## What's Changed` heading, the `## New Contributors` section, and the `**Full Changelog**` line exactly. Only the `### <category>` groupings and their bullets change.
### 7. Report
Give a concise summary:
- Count of PRs added (with their numbers), and which categories they landed in.
- Notable recategorizations (PRs moved out of the catch-all Bug Fixes into Features/Performance/Testing/etc.).
- Every entry removed, with the one-line reason.
- Any borderline calls the user may want to override.
- The output file path.
## Verification
Before reporting done, confirm:
- Every PR number from step 2 is present in the output (except any you deliberately removed in step 5 — and those must be in the removal list).
- No PR appears under more than one heading.
- Notable and Breaking sections are byte-for-byte unchanged from the input.
- The header comment, New Contributors, and Full Changelog lines are intact.
+2
View File
@@ -545,6 +545,8 @@ Allowed, but cheap to write and cheaper to leave behind. Keep them short and tra
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.
For integration tests that exercise caching or cache refreshers, see `tests/Umbraco.Tests.Integration/CLAUDE.md` — the harness disables caching by default, which can produce false greens.
---
## Quick Reference
+4 -4
View File
@@ -45,15 +45,15 @@
<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" />
<PackageVersion Include="MailKit" Version="4.16.0" />
<PackageVersion Include="Markdig" Version="0.45.0" />
<PackageVersion Include="Markdown" Version="2.2.1" />
<PackageVersion Include="MessagePack" Version="3.1.4" />
<PackageVersion Include="MessagePack" Version="3.1.7" />
<PackageVersion Include="MiniProfiler.AspNetCore.Mvc" Version="4.5.4" />
<PackageVersion Include="MiniProfiler.Shared" Version="4.5.4" />
<PackageVersion Include="ncrontab" Version="3.4.0" />
@@ -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>
@@ -0,0 +1,100 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Management.Security.Authorization;
using Umbraco.Cms.Api.Management.ViewModels.Sorting;
using Umbraco.Cms.Core.Actions;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Security.Authorization;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.OperationStatus;
using Umbraco.Cms.Web.Common.Authorization;
using Umbraco.Extensions;
namespace Umbraco.Cms.Api.Management.Controllers.Document;
/// <summary>
/// Provides an API endpoint for sorting the root-level documents by a system field.
/// </summary>
[ApiVersion("1.0")]
public class SortChildrenAtRootDocumentController : DocumentControllerBase
{
private readonly IAuthorizationService _authorizationService;
private readonly IContentEditingService _contentEditingService;
private readonly IEntityService _entityService;
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
/// <summary>
/// Initializes a new instance of the <see cref="SortChildrenAtRootDocumentController"/> class.
/// </summary>
/// <param name="authorizationService">Service used to authorize user actions.</param>
/// <param name="contentEditingService">Service for editing and managing content.</param>
/// <param name="entityService">Service used to resolve the children to authorize.</param>
/// <param name="backOfficeSecurityAccessor">Accessor for back office security context.</param>
public SortChildrenAtRootDocumentController(
IAuthorizationService authorizationService,
IContentEditingService contentEditingService,
IEntityService entityService,
IBackOfficeSecurityAccessor backOfficeSecurityAccessor)
{
_authorizationService = authorizationService;
_contentEditingService = contentEditingService;
_entityService = entityService;
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
}
/// <summary>
/// Sorts the root-level documents by a system field.
/// </summary>
/// <param name="cancellationToken">A <see cref="CancellationToken"/> to observe while waiting for the task to complete.</param>
/// <param name="requestModel">The field to sort by and the sort direction.</param>
/// <returns>
/// An <see cref="IActionResult"/> indicating the outcome of the operation:
/// returns <c>200 OK</c> if sorting succeeds or <c>400 Bad Request</c> if the field is not recognised.
/// </returns>
[HttpPut("root/sort-children")]
[MapToApiVersion("1.0")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[EndpointSummary("Sorts the root-level documents by a field.")]
[EndpointDescription("Sorts the root-level documents by a system field in the given direction. When sorting by name, an optional culture selects the variant name; the culture is not validated, so documents that do not vary by it (or an unrecognised culture) fall back to the invariant name.")]
public async Task<IActionResult> SortChildren(CancellationToken cancellationToken, SortDocumentChildrenByFieldRequestModel requestModel)
{
AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync(
User,
ContentPermissionResource.WithKeys(ActionSort.ActionLetter, (Guid?)null),
AuthorizationPolicies.ContentPermissionByResource);
if (!authorizationResult.Succeeded)
{
return Forbidden();
}
var childrenAuthorized = await AllChildrenAuthorizer.IsAuthorizedForChildrenAsync(
_authorizationService,
_entityService,
User,
parentKey: null,
UmbracoObjectTypes.Document,
childKeys => ContentPermissionResource.WithKeys(ActionSort.ActionLetter, childKeys),
AuthorizationPolicies.ContentPermissionByResource);
if (!childrenAuthorized)
{
return Forbidden();
}
ContentEditingOperationStatus result = await _contentEditingService.SortByFieldAsync(
null,
requestModel.Field,
requestModel.Direction,
requestModel.Culture,
CurrentUserKey(_backOfficeSecurityAccessor));
return result == ContentEditingOperationStatus.Success
? Ok()
: ContentEditingOperationStatusResult(result);
}
}
@@ -0,0 +1,102 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Management.Security.Authorization;
using Umbraco.Cms.Api.Management.ViewModels.Sorting;
using Umbraco.Cms.Core.Actions;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Security.Authorization;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.OperationStatus;
using Umbraco.Cms.Web.Common.Authorization;
using Umbraco.Extensions;
namespace Umbraco.Cms.Api.Management.Controllers.Document;
/// <summary>
/// Provides an API endpoint for sorting the children of a document by a system field.
/// </summary>
[ApiVersion("1.0")]
public class SortChildrenDocumentController : DocumentControllerBase
{
private readonly IAuthorizationService _authorizationService;
private readonly IContentEditingService _contentEditingService;
private readonly IEntityService _entityService;
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
/// <summary>
/// Initializes a new instance of the <see cref="SortChildrenDocumentController"/> class.
/// </summary>
/// <param name="authorizationService">Service used to authorize user actions.</param>
/// <param name="contentEditingService">Service for editing and managing content.</param>
/// <param name="entityService">Service used to resolve the children to authorize.</param>
/// <param name="backOfficeSecurityAccessor">Accessor for back office security context.</param>
public SortChildrenDocumentController(
IAuthorizationService authorizationService,
IContentEditingService contentEditingService,
IEntityService entityService,
IBackOfficeSecurityAccessor backOfficeSecurityAccessor)
{
_authorizationService = authorizationService;
_contentEditingService = contentEditingService;
_entityService = entityService;
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
}
/// <summary>
/// Sorts the child documents of the specified parent document by a system field.
/// </summary>
/// <param name="cancellationToken">A <see cref="CancellationToken"/> to observe while waiting for the task to complete.</param>
/// <param name="id">The unique identifier of the parent document whose children should be sorted.</param>
/// <param name="requestModel">The field to sort by and the sort direction.</param>
/// <returns>
/// An <see cref="IActionResult"/> indicating the outcome of the operation:
/// returns <c>200 OK</c> if sorting succeeds, <c>400 Bad Request</c> if the field is not recognised, or <c>404 Not Found</c> if the parent document does not exist.
/// </returns>
[HttpPut("{id:guid}/sort-children")]
[MapToApiVersion("1.0")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[EndpointSummary("Sorts the children of a document by a field.")]
[EndpointDescription("Sorts the children of the specified parent document by a system field in the given direction. When sorting by name, an optional culture selects the variant name; the culture is not validated, so children that do not vary by it (or an unrecognised culture) fall back to the invariant name.")]
public async Task<IActionResult> SortChildren(CancellationToken cancellationToken, Guid id, SortDocumentChildrenByFieldRequestModel requestModel)
{
AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync(
User,
ContentPermissionResource.WithKeys(ActionSort.ActionLetter, id),
AuthorizationPolicies.ContentPermissionByResource);
if (!authorizationResult.Succeeded)
{
return Forbidden();
}
var childrenAuthorized = await AllChildrenAuthorizer.IsAuthorizedForChildrenAsync(
_authorizationService,
_entityService,
User,
id,
UmbracoObjectTypes.Document,
childKeys => ContentPermissionResource.WithKeys(ActionSort.ActionLetter, childKeys),
AuthorizationPolicies.ContentPermissionByResource);
if (!childrenAuthorized)
{
return Forbidden();
}
ContentEditingOperationStatus result = await _contentEditingService.SortByFieldAsync(
id,
requestModel.Field,
requestModel.Direction,
requestModel.Culture,
CurrentUserKey(_backOfficeSecurityAccessor));
return result == ContentEditingOperationStatus.Success
? Ok()
: ContentEditingOperationStatusResult(result);
}
}
@@ -0,0 +1,98 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Management.Security.Authorization;
using Umbraco.Cms.Api.Management.ViewModels.Sorting;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Security.Authorization;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.OperationStatus;
using Umbraco.Cms.Web.Common.Authorization;
using Umbraco.Extensions;
namespace Umbraco.Cms.Api.Management.Controllers.Media;
/// <summary>
/// Provides an API endpoint for sorting the root-level media items by a system field.
/// </summary>
[ApiVersion("1.0")]
public class SortChildrenAtRootMediaController : MediaControllerBase
{
private readonly IAuthorizationService _authorizationService;
private readonly IMediaEditingService _mediaEditingService;
private readonly IEntityService _entityService;
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
/// <summary>
/// Initializes a new instance of the <see cref="SortChildrenAtRootMediaController"/> class.
/// </summary>
/// <param name="authorizationService">Service used to authorize user actions.</param>
/// <param name="mediaEditingService">Service responsible for editing and sorting media items.</param>
/// <param name="entityService">Service used to resolve the children to authorize.</param>
/// <param name="backOfficeSecurityAccessor">Accessor for backoffice security context.</param>
public SortChildrenAtRootMediaController(
IAuthorizationService authorizationService,
IMediaEditingService mediaEditingService,
IEntityService entityService,
IBackOfficeSecurityAccessor backOfficeSecurityAccessor)
{
_authorizationService = authorizationService;
_mediaEditingService = mediaEditingService;
_entityService = entityService;
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
}
/// <summary>
/// Sorts the root-level media items by a system field.
/// </summary>
/// <param name="cancellationToken">A <see cref="CancellationToken"/> to observe while waiting for the task to complete.</param>
/// <param name="requestModel">The field to sort by and the sort direction.</param>
/// <returns>
/// An <see cref="IActionResult"/> indicating the outcome of the operation:
/// returns <c>200 OK</c> if sorting succeeds or <c>400 Bad Request</c> if the field is not recognised.
/// </returns>
[HttpPut("root/sort-children")]
[MapToApiVersion("1.0")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[EndpointSummary("Sorts the root-level media items by a field.")]
[EndpointDescription("Sorts the root-level media items by a system field in the given direction.")]
public async Task<IActionResult> SortChildren(CancellationToken cancellationToken, SortMediaChildrenByFieldRequestModel requestModel)
{
AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync(
User,
MediaPermissionResource.Root(),
AuthorizationPolicies.MediaPermissionByResource);
if (!authorizationResult.Succeeded)
{
return Forbidden();
}
var childrenAuthorized = await AllChildrenAuthorizer.IsAuthorizedForChildrenAsync(
_authorizationService,
_entityService,
User,
parentKey: null,
UmbracoObjectTypes.Media,
childKeys => MediaPermissionResource.WithKeys(childKeys),
AuthorizationPolicies.MediaPermissionByResource);
if (!childrenAuthorized)
{
return Forbidden();
}
ContentEditingOperationStatus result = await _mediaEditingService.SortByFieldAsync(
null,
requestModel.Field,
requestModel.Direction,
CurrentUserKey(_backOfficeSecurityAccessor));
return result == ContentEditingOperationStatus.Success
? Ok()
: ContentEditingOperationStatusResult(result);
}
}
@@ -0,0 +1,100 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Management.Security.Authorization;
using Umbraco.Cms.Api.Management.ViewModels.Sorting;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Security.Authorization;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.OperationStatus;
using Umbraco.Cms.Web.Common.Authorization;
using Umbraco.Extensions;
namespace Umbraco.Cms.Api.Management.Controllers.Media;
/// <summary>
/// Provides an API endpoint for sorting the children of a media item by a system field.
/// </summary>
[ApiVersion("1.0")]
public class SortChildrenMediaController : MediaControllerBase
{
private readonly IAuthorizationService _authorizationService;
private readonly IMediaEditingService _mediaEditingService;
private readonly IEntityService _entityService;
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
/// <summary>
/// Initializes a new instance of the <see cref="SortChildrenMediaController"/> class.
/// </summary>
/// <param name="authorizationService">Service used to authorize user actions.</param>
/// <param name="mediaEditingService">Service responsible for editing and sorting media items.</param>
/// <param name="entityService">Service used to resolve the children to authorize.</param>
/// <param name="backOfficeSecurityAccessor">Accessor for backoffice security context.</param>
public SortChildrenMediaController(
IAuthorizationService authorizationService,
IMediaEditingService mediaEditingService,
IEntityService entityService,
IBackOfficeSecurityAccessor backOfficeSecurityAccessor)
{
_authorizationService = authorizationService;
_mediaEditingService = mediaEditingService;
_entityService = entityService;
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
}
/// <summary>
/// Sorts the child media items of the specified parent media item by a system field.
/// </summary>
/// <param name="cancellationToken">A <see cref="CancellationToken"/> to observe while waiting for the task to complete.</param>
/// <param name="id">The unique identifier of the parent media item whose children should be sorted.</param>
/// <param name="requestModel">The field to sort by and the sort direction.</param>
/// <returns>
/// An <see cref="IActionResult"/> indicating the outcome of the operation:
/// returns <c>200 OK</c> if sorting succeeds, <c>400 Bad Request</c> if the field is not recognised, or <c>404 Not Found</c> if the parent media item does not exist.
/// </returns>
[HttpPut("{id:guid}/sort-children")]
[MapToApiVersion("1.0")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[EndpointSummary("Sorts the children of a media item by a field.")]
[EndpointDescription("Sorts the children of the specified parent media item by a system field in the given direction.")]
public async Task<IActionResult> SortChildren(CancellationToken cancellationToken, Guid id, SortMediaChildrenByFieldRequestModel requestModel)
{
AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync(
User,
MediaPermissionResource.WithKeys(id),
AuthorizationPolicies.MediaPermissionByResource);
if (!authorizationResult.Succeeded)
{
return Forbidden();
}
var childrenAuthorized = await AllChildrenAuthorizer.IsAuthorizedForChildrenAsync(
_authorizationService,
_entityService,
User,
id,
UmbracoObjectTypes.Media,
childKeys => MediaPermissionResource.WithKeys(childKeys),
AuthorizationPolicies.MediaPermissionByResource);
if (!childrenAuthorized)
{
return Forbidden();
}
ContentEditingOperationStatus result = await _mediaEditingService.SortByFieldAsync(
id,
requestModel.Field,
requestModel.Direction,
CurrentUserKey(_backOfficeSecurityAccessor));
return result == ContentEditingOperationStatus.Success
? Ok()
: ContentEditingOperationStatusResult(result);
}
}
@@ -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;
+546
View File
@@ -10888,6 +10888,150 @@
]
}
},
"/umbraco/management/api/v1/document/{id}/sort-children": {
"put": {
"tags": [
"Document"
],
"summary": "Sorts the children of a document by a field.",
"description": "Sorts the children of the specified parent document by a system field in the given direction. When sorting by name, an optional culture selects the variant name; the culture is not validated, so children that do not vary by it (or an unrecognised culture) fall back to the invariant name.",
"operationId": "PutDocumentByIdSortChildren",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"oneOf": [
{
"$ref": "#/components/schemas/SortDocumentChildrenByFieldRequestModel"
}
]
}
},
"text/json": {
"schema": {
"oneOf": [
{
"$ref": "#/components/schemas/SortDocumentChildrenByFieldRequestModel"
}
]
}
},
"application/*+json": {
"schema": {
"oneOf": [
{
"$ref": "#/components/schemas/SortDocumentChildrenByFieldRequestModel"
}
]
}
}
}
},
"responses": {
"200": {
"description": "OK",
"headers": {
"Umb-Notifications": {
"description": "The list of notifications produced during the request.",
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/NotificationHeaderModel"
},
"nullable": true
}
}
}
},
"400": {
"description": "Bad Request",
"headers": {
"Umb-Notifications": {
"description": "The list of notifications produced during the request.",
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/NotificationHeaderModel"
},
"nullable": true
}
}
},
"content": {
"application/json": {
"schema": {
"oneOf": [
{
"$ref": "#/components/schemas/ProblemDetails"
}
]
}
}
}
},
"404": {
"description": "Not Found",
"headers": {
"Umb-Notifications": {
"description": "The list of notifications produced during the request.",
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/NotificationHeaderModel"
},
"nullable": true
}
}
},
"content": {
"application/json": {
"schema": {
"oneOf": [
{
"$ref": "#/components/schemas/ProblemDetails"
}
]
}
}
}
},
"401": {
"description": "The resource is protected and requires an authentication token"
},
"403": {
"description": "The authenticated user does not have access to this resource",
"headers": {
"Umb-Notifications": {
"description": "The list of notifications produced during the request.",
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/NotificationHeaderModel"
},
"nullable": true
}
}
}
}
},
"security": [
{
"Backoffice-User": [ ]
}
]
}
},
"/umbraco/management/api/v1/document/{id}/unpublish": {
"put": {
"tags": [
@@ -11282,6 +11426,113 @@
]
}
},
"/umbraco/management/api/v1/document/root/sort-children": {
"put": {
"tags": [
"Document"
],
"summary": "Sorts the root-level documents by a field.",
"description": "Sorts the root-level documents by a system field in the given direction. When sorting by name, an optional culture selects the variant name; the culture is not validated, so documents that do not vary by it (or an unrecognised culture) fall back to the invariant name.",
"operationId": "PutDocumentRootSortChildren",
"requestBody": {
"content": {
"application/json": {
"schema": {
"oneOf": [
{
"$ref": "#/components/schemas/SortDocumentChildrenByFieldRequestModel"
}
]
}
},
"text/json": {
"schema": {
"oneOf": [
{
"$ref": "#/components/schemas/SortDocumentChildrenByFieldRequestModel"
}
]
}
},
"application/*+json": {
"schema": {
"oneOf": [
{
"$ref": "#/components/schemas/SortDocumentChildrenByFieldRequestModel"
}
]
}
}
}
},
"responses": {
"200": {
"description": "OK",
"headers": {
"Umb-Notifications": {
"description": "The list of notifications produced during the request.",
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/NotificationHeaderModel"
},
"nullable": true
}
}
}
},
"400": {
"description": "Bad Request",
"headers": {
"Umb-Notifications": {
"description": "The list of notifications produced during the request.",
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/NotificationHeaderModel"
},
"nullable": true
}
}
},
"content": {
"application/json": {
"schema": {
"oneOf": [
{
"$ref": "#/components/schemas/ProblemDetails"
}
]
}
}
}
},
"401": {
"description": "The resource is protected and requires an authentication token"
},
"403": {
"description": "The authenticated user does not have access to this resource",
"headers": {
"Umb-Notifications": {
"description": "The list of notifications produced during the request.",
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/NotificationHeaderModel"
},
"nullable": true
}
}
}
}
},
"security": [
{
"Backoffice-User": [ ]
}
]
}
},
"/umbraco/management/api/v1/document/sort": {
"put": {
"tags": [
@@ -19158,6 +19409,150 @@
]
}
},
"/umbraco/management/api/v1/media/{id}/sort-children": {
"put": {
"tags": [
"Media"
],
"summary": "Sorts the children of a media item by a field.",
"description": "Sorts the children of the specified parent media item by a system field in the given direction. Media items do not vary by culture, so any supplied culture is ignored.",
"operationId": "PutMediaByIdSortChildren",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"oneOf": [
{
"$ref": "#/components/schemas/SortMediaChildrenByFieldRequestModel"
}
]
}
},
"text/json": {
"schema": {
"oneOf": [
{
"$ref": "#/components/schemas/SortMediaChildrenByFieldRequestModel"
}
]
}
},
"application/*+json": {
"schema": {
"oneOf": [
{
"$ref": "#/components/schemas/SortMediaChildrenByFieldRequestModel"
}
]
}
}
}
},
"responses": {
"200": {
"description": "OK",
"headers": {
"Umb-Notifications": {
"description": "The list of notifications produced during the request.",
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/NotificationHeaderModel"
},
"nullable": true
}
}
}
},
"400": {
"description": "Bad Request",
"headers": {
"Umb-Notifications": {
"description": "The list of notifications produced during the request.",
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/NotificationHeaderModel"
},
"nullable": true
}
}
},
"content": {
"application/json": {
"schema": {
"oneOf": [
{
"$ref": "#/components/schemas/ProblemDetails"
}
]
}
}
}
},
"404": {
"description": "Not Found",
"headers": {
"Umb-Notifications": {
"description": "The list of notifications produced during the request.",
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/NotificationHeaderModel"
},
"nullable": true
}
}
},
"content": {
"application/json": {
"schema": {
"oneOf": [
{
"$ref": "#/components/schemas/ProblemDetails"
}
]
}
}
}
},
"401": {
"description": "The resource is protected and requires an authentication token"
},
"403": {
"description": "The authenticated user does not have access to this resource",
"headers": {
"Umb-Notifications": {
"description": "The list of notifications produced during the request.",
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/NotificationHeaderModel"
},
"nullable": true
}
}
}
}
},
"security": [
{
"Backoffice-User": [ ]
}
]
}
},
"/umbraco/management/api/v1/media/{id}/validate": {
"put": {
"tags": [
@@ -19408,6 +19803,113 @@
]
}
},
"/umbraco/management/api/v1/media/root/sort-children": {
"put": {
"tags": [
"Media"
],
"summary": "Sorts the root-level media items by a field.",
"description": "Sorts the root-level media items by a system field in the given direction. Media items do not vary by culture, so any supplied culture is ignored.",
"operationId": "PutMediaRootSortChildren",
"requestBody": {
"content": {
"application/json": {
"schema": {
"oneOf": [
{
"$ref": "#/components/schemas/SortMediaChildrenByFieldRequestModel"
}
]
}
},
"text/json": {
"schema": {
"oneOf": [
{
"$ref": "#/components/schemas/SortMediaChildrenByFieldRequestModel"
}
]
}
},
"application/*+json": {
"schema": {
"oneOf": [
{
"$ref": "#/components/schemas/SortMediaChildrenByFieldRequestModel"
}
]
}
}
}
},
"responses": {
"200": {
"description": "OK",
"headers": {
"Umb-Notifications": {
"description": "The list of notifications produced during the request.",
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/NotificationHeaderModel"
},
"nullable": true
}
}
}
},
"400": {
"description": "Bad Request",
"headers": {
"Umb-Notifications": {
"description": "The list of notifications produced during the request.",
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/NotificationHeaderModel"
},
"nullable": true
}
}
},
"content": {
"application/json": {
"schema": {
"oneOf": [
{
"$ref": "#/components/schemas/ProblemDetails"
}
]
}
}
}
},
"401": {
"description": "The resource is protected and requires an authentication token"
},
"403": {
"description": "The authenticated user does not have access to this resource",
"headers": {
"Umb-Notifications": {
"description": "The list of notifications produced during the request.",
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/NotificationHeaderModel"
},
"nullable": true
}
}
}
}
},
"security": [
{
"Backoffice-User": [ ]
}
]
}
},
"/umbraco/management/api/v1/media/sort": {
"put": {
"tags": [
@@ -39856,6 +40358,14 @@
},
"additionalProperties": false
},
"ContentSortFieldModel": {
"enum": [
"Name",
"CreateDate",
"UpdateDate"
],
"type": "string"
},
"CopyDataTypeRequestModel": {
"type": "object",
"properties": {
@@ -50162,6 +50672,42 @@
},
"additionalProperties": false
},
"SortDocumentChildrenByFieldRequestModel": {
"required": [
"direction",
"field"
],
"type": "object",
"properties": {
"field": {
"$ref": "#/components/schemas/ContentSortFieldModel"
},
"direction": {
"$ref": "#/components/schemas/DirectionModel"
},
"culture": {
"type": "string",
"nullable": true
}
},
"additionalProperties": false
},
"SortMediaChildrenByFieldRequestModel": {
"required": [
"direction",
"field"
],
"type": "object",
"properties": {
"field": {
"$ref": "#/components/schemas/ContentSortFieldModel"
},
"direction": {
"$ref": "#/components/schemas/DirectionModel"
}
},
"additionalProperties": false
},
"SortingRequestModel": {
"required": [
"sorting"
@@ -0,0 +1,64 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Security.Authorization;
using Umbraco.Cms.Core.Services;
using Umbraco.Extensions;
namespace Umbraco.Cms.Api.Management.Security.Authorization;
/// <summary>
/// Authorizes permissions on all direct children of a node.
/// </summary>
internal static class AllChildrenAuthorizer
{
/// <summary>
/// Determines whether the user is authorized for every direct child of the given parent (or the root).
/// </summary>
/// <param name="authorizationService">The authorization service.</param>
/// <param name="entityService">The entity service used to resolve the children.</param>
/// <param name="user">The current user.</param>
/// <param name="parentKey">The parent key, or <c>null</c> to authorize the root-level children.</param>
/// <param name="objectType">The object type of the children (and parent).</param>
/// <param name="resourceFactory">Builds the permission resource to authorize a batch of child keys against.</param>
/// <param name="policy">The authorization policy to apply.</param>
/// <returns><c>true</c> if the user is authorized against all children; otherwise <c>false</c>.</returns>
public static async Task<bool> IsAuthorizedForChildrenAsync(
IAuthorizationService authorizationService,
IEntityService entityService,
ClaimsPrincipal user,
Guid? parentKey,
UmbracoObjectTypes objectType,
Func<IEnumerable<Guid>, IPermissionResource> resourceFactory,
string policy)
{
const int pageSize = 500;
var page = 0;
long total;
do
{
Guid[] childKeys = entityService
.GetPagedChildren(parentKey, [objectType], objectType, page * pageSize, pageSize, out total)
.Select(child => child.Key)
.ToArray();
if (childKeys.Length > 0)
{
AuthorizationResult authorizationResult = await authorizationService.AuthorizeResourceAsync(
user,
resourceFactory(childKeys),
policy);
if (authorizationResult.Succeeded is false)
{
return false;
}
}
page++;
}
while (page * pageSize < total);
return true;
}
}
@@ -0,0 +1,21 @@
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Models.ContentEditing;
namespace Umbraco.Cms.Api.Management.ViewModels.Sorting;
/// <summary>
/// Base request model for sorting the children of a node by a system field.
/// </summary>
public abstract class SortChildrenByFieldRequestModelBase
{
/// <summary>
/// Gets or sets the system field to sort the children by.
/// The create and update dates are node-level (not culture-specific).
/// </summary>
public required ContentSortField Field { get; init; }
/// <summary>
/// Gets or sets the direction to sort in.
/// </summary>
public required Direction Direction { get; init; }
}
@@ -0,0 +1,16 @@
using Umbraco.Cms.Core.Models.ContentEditing;
namespace Umbraco.Cms.Api.Management.ViewModels.Sorting;
/// <summary>
/// Request model for sorting the children of a document by a system field.
/// </summary>
public class SortDocumentChildrenByFieldRequestModel : SortChildrenByFieldRequestModelBase
{
/// <summary>
/// Gets or sets the culture whose variant name to sort by, or <c>null</c> to sort by the invariant name.
/// Only applies when sorting by <see cref="ContentSortField.Name"/>. The culture is not validated: a document that
/// does not vary by the given culture - or an unrecognised culture - falls back to the invariant name.
/// </summary>
public string? Culture { get; init; }
}
@@ -0,0 +1,9 @@
namespace Umbraco.Cms.Api.Management.ViewModels.Sorting;
/// <summary>
/// Request model for sorting the children of a media item by a system field.
/// Media items do not vary by culture, so no culture is accepted.
/// </summary>
public class SortMediaChildrenByFieldRequestModel : SortChildrenByFieldRequestModelBase
{
}
@@ -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;
}
@@ -0,0 +1,43 @@
namespace Umbraco.Cms.Core.Cache;
/// <summary>
/// Reports the approximate size of an in-memory cache, for diagnostics and observability.
/// </summary>
/// <remarks>
/// Implemented by in-memory caches whose footprint scales with the size of the content tree, so their
/// retained entry count can be logged (e.g. by a periodic diagnostics job) during full-tree operations
/// such as reindexing or crawling the published site.
/// <para>
/// The reported value is an approximate <em>entry count</em>, not a byte measurement: per-entry size
/// varies widely, so the count is intended as a <em>trend</em> signal (a count that grows during a
/// tree-walk and never falls indicates unbounded retention) and for <em>attribution</em> (which cache is
/// largest when the process heap grows), rather than as an absolute memory figure. Absolute bytes are
/// obtained from process-level totals (managed heap / working set) and a GC dump. The count is read
/// without locking.
/// </para>
/// </remarks>
public interface IMemoryCacheSizeReporter
{
/// <summary>
/// Gets a human-readable name identifying the cache in diagnostic output.
/// </summary>
string CacheName { get; }
/// <summary>
/// Gets the approximate number of entries currently retained in the cache.
/// </summary>
/// <returns>The approximate entry count.</returns>
long GetApproximateCount();
/// <summary>
/// Gets an approximate retained size of the cache in bytes, or <c>null</c> when the cache cannot be
/// cheaply sized.
/// </summary>
/// <remarks>
/// Where provided, this is a coarse estimate (underlying content / structural size, not a precise
/// managed-heap measurement) for the same trend/attribution purpose as the entry count. Absolute bytes
/// come from a GC dump.
/// </remarks>
/// <returns>The approximate size in bytes, or <c>null</c> if not available.</returns>
long? GetApproximateBytes() => null;
}
@@ -23,5 +23,14 @@ public sealed class LanguageDeletedDistributedCacheNotificationHandler : Deleted
/// <inheritdoc />
protected override void Handle(IEnumerable<ILanguage> entities, IDictionary<string, object?> state)
=> _distributedCache.RemoveLanguageCache(entities);
{
_distributedCache.RemoveLanguageCache(entities);
// User groups cache their allowed language ids, so a deleted language must be evicted from
// them too - otherwise a stale, now-missing id lingers on the cached user group and breaks
// reads that resolve those ids. This is a deliberately coarse refresh of the entire user group
// and user caches (RefreshAll also clears IUser): we can't know which groups reference the
// language without a query, and language deletion is rare enough that a full refresh is fine.
_distributedCache.RefreshAllUserGroupCache();
}
}
+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)
@@ -0,0 +1,39 @@
namespace Umbraco.Cms.Core.Cache;
/// <summary>
/// Estimates the total size of a large collection by sizing a bounded sample and extrapolating across the
/// full count, so a per-tick size diagnostic does not pay an O(n) cost on very large caches.
/// </summary>
internal static class SampledSizeEstimator
{
/// <summary>
/// Sizes up to <paramref name="maxSample" /> items from <paramref name="items" /> and scales the sampled
/// average across <paramref name="count" />.
/// </summary>
/// <typeparam name="T">The item type.</typeparam>
/// <param name="count">The total number of items in the collection.</param>
/// <param name="items">The items to sample (enumerated lazily; only the first <paramref name="maxSample" /> are read).</param>
/// <param name="sizeOf">Returns the approximate size, in bytes, of a single item.</param>
/// <param name="maxSample">The maximum number of items to size before extrapolating.</param>
/// <returns>The extrapolated approximate total size in bytes.</returns>
public static long Estimate<T>(int count, IEnumerable<T> items, Func<T, long> sizeOf, int maxSample = 1000)
{
if (count == 0)
{
return 0;
}
long sampled = 0;
long sampledBytes = 0;
foreach (T item in items)
{
sampledBytes += sizeOf(item);
if (++sampled >= maxSample)
{
break;
}
}
return sampled == 0 ? 0 : count * (sampledBytes / sampled);
}
}
@@ -16,6 +16,11 @@ public class ContentSettings
/// </summary>
internal const bool StaticResolveUrlsFromTextString = false;
/// <summary>
/// The default value for whether sorting children by a field fires per-item notifications.
/// </summary>
internal const bool StaticSortChildrenByFieldFiresNotifications = false;
/// <summary>
/// The default preview badge markup template.
/// </summary>
@@ -110,6 +115,18 @@ public class ContentSettings
[DefaultValue(StaticResolveUrlsFromTextString)]
public bool ResolveUrlsFromTextString { get; set; } = StaticResolveUrlsFromTextString;
/// <summary>
/// Gets or sets a value indicating whether sorting the children of a node by a field fires
/// per-item save/sort notifications (and therefore webhooks).
/// </summary>
/// <remarks>
/// Defaults to <c>false</c>: the children are reordered with a single set-based update and a branch
/// cache refresh, without per-item notifications. Set to <c>true</c> to restore per-item notifications
/// (and webhooks), accepting the additional performance cost on nodes with many children.
/// </remarks>
[DefaultValue(StaticSortChildrenByFieldFiresNotifications)]
public bool SortChildrenByFieldFiresNotifications { get; set; } = StaticSortChildrenByFieldFiresNotifications;
/// <summary>
/// Gets or sets a value for the collection of error pages.
/// </summary>
@@ -30,6 +30,17 @@ public class DatabaseServerMessengerSettings
/// </summary>
internal const string StaticTimeBetweenPruneOperations = "00:01:00"; // TimeSpan.FromMinutes(1);
/// <summary>
/// The default timeout for a single synchronization operation.
/// </summary>
internal const string StaticSyncTimeout = "00:01:00"; // TimeSpan.FromMinutes(1);
/// <summary>
/// Gets the default timeout for a single synchronization operation, for use as a fallback when an invalid
/// <see cref="SyncTimeout" /> is configured.
/// </summary>
public static readonly TimeSpan DefaultSyncTimeout = TimeSpan.Parse(StaticSyncTimeout);
/// <summary>
/// Gets or sets a value for the maximum number of instructions that can be processed at startup; otherwise the server
/// cold-boots (rebuilds its caches).
@@ -55,4 +66,13 @@ public class DatabaseServerMessengerSettings
/// </summary>
[DefaultValue(StaticTimeBetweenPruneOperations)]
public TimeSpan TimeBetweenPruneOperations { get; set; } = TimeSpan.Parse(StaticTimeBetweenPruneOperations);
/// <summary>
/// Gets or sets the maximum time to wait for a single synchronization operation to complete before it is
/// considered stalled (for example, blocked on a hung database connection) and abandoned, so the recurring
/// job keeps running rather than stopping permanently. This bounds how long the job waits on a single sync,
/// not how long a stalled connection itself takes to recover (which is governed by the database timeouts).
/// </summary>
[DefaultValue(StaticSyncTimeout)]
public TimeSpan SyncTimeout { get; set; } = DefaultSyncTimeout;
}
@@ -20,6 +20,17 @@ public class DatabaseServerRegistrarSettings
/// </summary>
internal const string StaticStaleServerTimeout = "00:02:00";
/// <summary>
/// The default timeout for a single server touch operation.
/// </summary>
internal const string StaticTouchTimeout = "00:01:00"; // TimeSpan.FromMinutes(1);
/// <summary>
/// Gets the default timeout for a single server touch operation, for use as a fallback when an invalid
/// <see cref="TouchTimeout" /> is configured.
/// </summary>
public static readonly TimeSpan DefaultTouchTimeout = TimeSpan.Parse(StaticTouchTimeout);
/// <summary>
/// Gets or sets a value for the amount of time to wait between calls to the database on the background thread.
/// </summary>
@@ -31,4 +42,13 @@ public class DatabaseServerRegistrarSettings
/// </summary>
[DefaultValue(StaticStaleServerTimeout)]
public TimeSpan StaleServerTimeout { get; set; } = TimeSpan.Parse(StaticStaleServerTimeout);
/// <summary>
/// Gets or sets the maximum time to wait for a single server touch operation to complete before it is
/// considered stalled (for example, blocked on a hung database connection) and abandoned, so the recurring
/// job keeps running rather than stopping permanently. This bounds how long the job waits on a single touch,
/// not how long a stalled connection itself takes to recover (which is governed by the database timeouts).
/// </summary>
[DefaultValue(StaticTouchTimeout)]
public TimeSpan TouchTimeout { get; set; } = DefaultTouchTimeout;
}
@@ -32,6 +32,11 @@ public class LoggingSettings
/// </summary>
internal const string StaticFileNameFormatArguments = "MachineName";
/// <summary>
/// The default mode for enriching log events with a session identifier.
/// </summary>
internal const SessionIdLoggingMode StaticSessionIdLogging = SessionIdLoggingMode.SessionId;
/// <summary>
/// Gets or sets a value for the maximum age of a log file.
/// </summary>
@@ -70,4 +75,16 @@ public class LoggingSettings
/// </remarks>
[DefaultValue(StaticFileNameFormatArguments)]
public string FileNameFormatArguments { get; set; } = StaticFileNameFormatArguments;
/// <summary>
/// Gets or sets a value determining how log events are enriched with a session identifier.
/// </summary>
/// <remarks>
/// Defaults to <see cref="SessionIdLoggingMode.SessionId" /> for backward compatibility. Set to
/// <see cref="SessionIdLoggingMode.CookieHash" /> or <see cref="SessionIdLoggingMode.None" /> to avoid the
/// blocking session-store load that resolving the actual session id incurs per request when the session is
/// backed by an <c>IDistributedCache</c>.
/// </remarks>
[DefaultValue(StaticSessionIdLogging)]
public SessionIdLoggingMode SessionIdLogging { get; set; } = StaticSessionIdLogging;
}
@@ -0,0 +1,33 @@
using System.ComponentModel;
namespace Umbraco.Cms.Core.Configuration.Models;
/// <summary>
/// Settings for scheduled publishing.
/// </summary>
[UmbracoOptions(Constants.Configuration.ConfigScheduledPublishing)]
public class ScheduledPublishingSettings
{
private const string StaticPeriod = "00:01:00";
private const bool StaticAlignToClock = false; // TODO (V19): Switch this to true.
/// <summary>
/// Gets or sets a value for how often scheduled publishing runs.
/// </summary>
[DefaultValue(StaticPeriod)]
public TimeSpan Period { get; set; } = TimeSpan.Parse(StaticPeriod);
/// <summary>
/// Gets or sets a value indicating whether scheduled publishing runs are aligned to clock boundaries
/// derived from <see cref="Period" /> (for example, on the minute, or every N seconds), rather than drifting
/// based on when the previous run completed.
/// </summary>
/// <remarks>
/// When enabled, <see cref="Period" /> must be a whole number of seconds that divides evenly into one hour
/// (for example 10, 12, 15, 20, 30 or 60 seconds) so that boundaries land on consistent clock times.
/// Boundaries are anchored to <strong>UTC</strong>, not the server's local time zone; for sub-minute and
/// whole-minute periods this is indistinguishable from local time at the second level.
/// </remarks>
[DefaultValue(StaticAlignToClock)]
public bool AlignToClock { get; set; } = StaticAlignToClock;
}
@@ -0,0 +1,29 @@
// Copyright (c) Umbraco.
// See LICENSE for more details.
namespace Umbraco.Cms.Core.Configuration.Models;
/// <summary>
/// Determines how request logging enriches log events with a session identifier.
/// </summary>
public enum SessionIdLoggingMode
{
/// <summary>
/// Do not enrich log events with a session identifier.
/// </summary>
None = 0,
/// <summary>
/// Enrich log events with the actual ASP.NET Core session id. This is the default and matches the
/// historical behaviour, but reading the session id forces the session to be loaded from its store, which
/// is a blocking round-trip per request when the session is backed by an <c>IDistributedCache</c>.
/// </summary>
SessionId,
/// <summary>
/// Enrich log events with a one-way hash of the session cookie value. This provides the same per-session
/// correlation as <see cref="SessionId" /> without loading the session from its store, so it never incurs
/// a distributed-cache round-trip.
/// </summary>
CookieHash,
}
@@ -0,0 +1,43 @@
// Copyright (c) Umbraco.
// See LICENSE for more details.
using Microsoft.Extensions.Options;
namespace Umbraco.Cms.Core.Configuration.Models.Validation;
/// <summary>
/// Validator for configuration represented as <see cref="ScheduledPublishingSettings" />.
/// </summary>
public class ScheduledPublishingSettingsValidator : ConfigurationValidatorBase, IValidateOptions<ScheduledPublishingSettings>
{
/// <inheritdoc />
public ValidateOptionsResult Validate(string? name, ScheduledPublishingSettings options)
{
if (options.Period <= TimeSpan.Zero)
{
return ValidateOptionsResult.Fail(
$"Configuration entry {Constants.Configuration.ConfigScheduledPublishing}:Period must be greater than zero.");
}
if (options.AlignToClock && IsCleanDivisorOfAnHour(options.Period) == false)
{
return ValidateOptionsResult.Fail(
$"Configuration entry {Constants.Configuration.ConfigScheduledPublishing}:Period must be a whole number of seconds that divides evenly into one hour (3600 seconds) when {Constants.Configuration.ConfigScheduledPublishing}:AlignToClock is enabled, e.g. 10, 12, 15, 20, 30 or 60 seconds.");
}
return ValidateOptionsResult.Success;
}
private static bool IsCleanDivisorOfAnHour(TimeSpan period)
{
var totalSeconds = period.TotalSeconds;
// Must be a positive, whole number of seconds (no sub-second component).
if (totalSeconds <= 0 || totalSeconds != Math.Floor(totalSeconds))
{
return false;
}
return 3600 % (long)totalSeconds == 0;
}
}
@@ -291,6 +291,11 @@ public static partial class Constants
/// </summary>
public const string ConfigDistributedJobs = ConfigPrefix + "DistributedJobs";
/// <summary>
/// The configuration key for scheduled publishing settings.
/// </summary>
public const string ConfigScheduledPublishing = ConfigPrefix + "ScheduledPublishing";
/// <summary>
/// The configuration key for backoffice token cookie settings.
/// </summary>
@@ -57,6 +57,7 @@ public static partial class UmbracoBuilderExtensions
builder.Services.AddSingleton<IValidateOptions<RequestHandlerSettings>, RequestHandlerSettingsValidator>();
builder.Services.AddSingleton<IValidateOptions<UnattendedSettings>, UnattendedSettingsValidator>();
builder.Services.AddSingleton<IValidateOptions<SecuritySettings>, SecuritySettingsValidator>();
builder.Services.AddSingleton<IValidateOptions<ScheduledPublishingSettings>, ScheduledPublishingSettingsValidator>();
// Register configuration sections.
// TODO (V18): Remove the registrations of UserPasswordConfigurationSettings and MemberPasswordConfigurationSettings.
@@ -102,6 +103,7 @@ public static partial class UmbracoBuilderExtensions
.AddUmbracoOptions<CacheSettings>()
.AddUmbracoOptions<SystemDateMigrationSettings>()
.AddUmbracoOptions<DistributedJobSettings>()
.AddUmbracoOptions<ScheduledPublishingSettings>(options => options.ValidateOnStart())
.AddUmbracoOptions<BackOfficeTokenCookieSettings>()
.AddUmbracoOptions<WebsiteSettings>()
.AddUmbracoOptions<SignalRSettings>();
@@ -377,9 +377,11 @@ namespace Umbraco.Cms.Core.DependencyInjection
Services.AddUnique<DocumentNavigationService, DocumentNavigationService>();
Services.AddUnique<IDocumentNavigationQueryService>(x => x.GetRequiredService<DocumentNavigationService>());
Services.AddUnique<IDocumentNavigationManagementService>(x => x.GetRequiredService<DocumentNavigationService>());
Services.AddSingleton<IMemoryCacheSizeReporter>(x => x.GetRequiredService<DocumentNavigationService>());
Services.AddUnique<MediaNavigationService, MediaNavigationService>();
Services.AddUnique<IMediaNavigationQueryService>(x => x.GetRequiredService<MediaNavigationService>());
Services.AddUnique<IMediaNavigationManagementService>(x => x.GetRequiredService<MediaNavigationService>());
Services.AddSingleton<IMemoryCacheSizeReporter>(x => x.GetRequiredService<MediaNavigationService>());
Services.AddUnique<PublishStatusService, PublishStatusService>();
Services.AddUnique<IPublishStatusManagementService>(x => x.GetRequiredService<PublishStatusService>());
@@ -453,7 +455,9 @@ namespace Umbraco.Cms.Core.DependencyInjection
Services.AddUnique<IElementSwitchValidator, ElementSwitchValidator>();
// Routing
Services.AddUnique<IDocumentUrlService, DocumentUrlService>();
Services.AddUnique<DocumentUrlService, DocumentUrlService>();
Services.AddUnique<IDocumentUrlService>(x => x.GetRequiredService<DocumentUrlService>());
Services.AddSingleton<IMemoryCacheSizeReporter>(x => x.GetRequiredService<DocumentUrlService>());
Services.AddNotificationAsyncHandler<UmbracoApplicationStartingNotification, DocumentUrlServiceInitializerNotificationHandler>();
Services.AddUnique<IDocumentUrlAliasService, DocumentUrlAliasService>();
Services.AddNotificationAsyncHandler<UmbracoApplicationStartingNotification, DocumentUrlAliasServiceInitializerNotificationHandler>();
@@ -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
-->
@@ -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,
};
@@ -0,0 +1,22 @@
namespace Umbraco.Cms.Core.Models.ContentEditing;
/// <summary>
/// Represents a system field that a node's children can be sorted by.
/// </summary>
public enum ContentSortField
{
/// <summary>
/// Sort by the node's name.
/// </summary>
Name,
/// <summary>
/// Sort by the date the node was created.
/// </summary>
CreateDate,
/// <summary>
/// Sort by the date the node was last updated.
/// </summary>
UpdateDate,
}
@@ -16,6 +16,19 @@ public interface IContentRepository<in TId, TEntity> : IReadWriteQueryRepository
/// </summary>
int RecycleBinId { get; }
/// <summary>
/// Updates the sort order of the specified nodes so that each node's sort order matches its
/// position in the supplied (already ordered) collection, in a single set-based update.
/// </summary>
/// <param name="orderedNodeIds">The node identifiers in their desired order.</param>
/// <remarks>
/// This persists the sort order directly and does not load the entities or fire any notifications;
/// callers are responsible for any required cache refresh and auditing.
/// </remarks>
// TODO (V19): Remove the default implementation.
void UpdateSortOrder(IReadOnlyList<int> orderedNodeIds)
=> throw new NotImplementedException();
/// <summary>
/// Gets versions.
/// </summary>
@@ -332,6 +332,15 @@ internal sealed class ContentEditingService
Guid userKey)
=> await HandleSortAsync(parentKey, sortingModels, userKey);
/// <inheritdoc />
public async Task<ContentEditingOperationStatus> SortByFieldAsync(
Guid? parentKey,
ContentSortField field,
Direction direction,
string? culture,
Guid userKey)
=> await HandleSortByFieldAsync(parentKey, field, direction, culture, userKey);
private async Task<Attempt<ContentValidationResult, ContentEditingOperationStatus>> ValidateCulturesAndPropertiesAsync(
ContentEditingModelBase contentEditingModelBase,
Guid contentTypeKey,
@@ -384,8 +393,8 @@ internal sealed class ContentEditingService
protected override OperationResult? Delete(IContent content, int userId) => ContentService.Delete(content, userId);
/// <inheritdoc />
protected override IEnumerable<IContent> GetPagedChildren(int parentId, int pageIndex, int pageSize, out long total)
=> ContentService.GetPagedChildren(parentId, pageIndex, pageSize, out total, propertyAliases: null, filter: null, ordering: null);
protected override IEnumerable<IContent> GetPagedChildren(int parentId, int pageIndex, int pageSize, Ordering? ordering, out long total)
=> ContentService.GetPagedChildren(parentId, pageIndex, pageSize, out total, propertyAliases: null, filter: null, ordering: ordering);
/// <inheritdoc />
protected override ContentEditingOperationStatus Sort(IEnumerable<IContent> items, int userId)
@@ -394,6 +403,13 @@ internal sealed class ContentEditingService
return OperationResultToOperationStatus(result);
}
/// <inheritdoc />
protected override ContentEditingOperationStatus SortChildrenInBulk(int parentId, IReadOnlyList<int> orderedChildIds, int userId)
{
OperationResult result = ContentService.SortChildren(parentId, orderedChildIds, userId);
return OperationResultToOperationStatus(result);
}
private async Task<ContentEditingOperationStatus> Save(IContent content, Guid userKey)
{
try
@@ -458,6 +458,10 @@ internal abstract class ContentEditingServiceBase<TContent, TContentType, TConte
{
// these are the only result states currently expected from the invoked IContentService operations
OperationResultType.Success => ContentEditingOperationStatus.Success,
// a no-op (e.g. sorting children when nothing needs reordering) is a successful outcome, not an error
OperationResultType.NoOperation => ContentEditingOperationStatus.Success,
OperationResultType.FailedCancelledByEvent => ContentEditingOperationStatus.CancelledByNotification,
OperationResultType.FailedCannot => ContentEditingOperationStatus.CannotDeleteWhenReferenced,
@@ -86,9 +86,10 @@ internal abstract class ContentEditingServiceWithSortingBase<TContent, TContentT
/// <param name="parentId">The parent identifier.</param>
/// <param name="pageIndex">The zero-based page index.</param>
/// <param name="pageSize">The page size.</param>
/// <param name="ordering">The ordering to apply, or <c>null</c> to use the default (sort order).</param>
/// <param name="total">The total number of children.</param>
/// <returns>The paged children.</returns>
protected abstract IEnumerable<TContent> GetPagedChildren(int parentId, int pageIndex, int pageSize, out long total);
protected abstract IEnumerable<TContent> GetPagedChildren(int parentId, int pageIndex, int pageSize, Ordering? ordering, out long total);
/// <summary>
/// Handles the sorting operation asynchronously.
@@ -111,16 +112,7 @@ internal abstract class ContentEditingServiceWithSortingBase<TContent, TContentT
return ContentEditingOperationStatus.NotFound;
}
const int pageSize = 500;
var pageNumber = 0;
IEnumerable<TContent> page = GetPagedChildren(contentId.Value, pageNumber++, pageSize, out var total);
var children = new List<TContent>((int)total);
children.AddRange(page);
while (pageNumber * pageSize < total)
{
page = GetPagedChildren(contentId.Value, pageNumber++, pageSize, out _);
children.AddRange(page);
}
List<TContent> children = LoadAllChildren(contentId.Value, ordering: null);
try
{
@@ -138,4 +130,102 @@ internal abstract class ContentEditingServiceWithSortingBase<TContent, TContentT
return ContentEditingOperationStatus.SortingInvalid;
}
}
/// <summary>
/// Handles sorting a parent's children by a system field asynchronously.
/// </summary>
/// <param name="parentKey">The optional parent key.</param>
/// <param name="field">The system field to sort the children by.</param>
/// <param name="direction">The direction to sort in.</param>
/// <param name="culture">The culture whose variant name to sort by, or <c>null</c> to sort by the invariant name. Only applies when sorting by <see cref="ContentSortField.Name"/>. The culture is not validated: a child that does not vary by the given culture - or an unrecognised culture - falls back to the invariant name.</param>
/// <param name="userKey">The user key performing the operation.</param>
/// <returns>The operation status.</returns>
protected async Task<ContentEditingOperationStatus> HandleSortByFieldAsync(
Guid? parentKey,
ContentSortField field,
Direction direction,
string? culture,
Guid userKey)
{
var contentId = parentKey.HasValue
? ContentService.GetById(parentKey.Value)?.Id
: Constants.System.Root;
if (contentId.HasValue is false)
{
return ContentEditingOperationStatus.NotFound;
}
Ordering ordering = BuildOrdering(field, direction, culture);
// The database does the ordering (matching the list view and the order shown in the sort UI).
if (ContentSettings.SortChildrenByFieldFiresNotifications)
{
// Opt-in path: load the children and persist via the standard sort, firing per-item
// save/sort notifications (and therefore webhooks), at the cost of loading every child.
List<TContent> orderedChildren = LoadAllChildren(contentId.Value, ordering);
if (orderedChildren.Count == 0)
{
return ContentEditingOperationStatus.Success;
}
return Sort(orderedChildren, await GetUserIdAsync(userKey));
}
// Default path: persist the resulting order with a single set-based update and a branch cache
// refresh, without loading every child or firing per-item notifications.
List<int> orderedChildIds = LoadOrderedChildIds(contentId.Value, ordering);
if (orderedChildIds.Count == 0)
{
// Nothing to sort - the order is trivially correct.
return ContentEditingOperationStatus.Success;
}
return SortChildrenInBulk(contentId.Value, orderedChildIds, await GetUserIdAsync(userKey));
}
/// <summary>
/// Persists the supplied (already ordered) child identifiers as the new sort order, without loading
/// the children or firing per-item notifications.
/// </summary>
/// <param name="parentId">The parent identifier, or the root identifier for root-level sorting.</param>
/// <param name="orderedChildIds">The child identifiers in their desired order.</param>
/// <param name="userId">The user performing the operation.</param>
/// <returns>The operation status.</returns>
protected abstract ContentEditingOperationStatus SortChildrenInBulk(int parentId, IReadOnlyList<int> orderedChildIds, int userId);
private List<int> LoadOrderedChildIds(int contentId, Ordering ordering)
=> LoadAllChildren(contentId, ordering, child => child.Id);
private List<TContent> LoadAllChildren(int contentId, Ordering? ordering)
=> LoadAllChildren(contentId, ordering, child => child);
// Pages through all children, projecting each page with the selector so callers that only need a
// lightweight value (e.g. the id) don't retain every loaded child.
private List<TResult> LoadAllChildren<TResult>(int contentId, Ordering? ordering, Func<TContent, TResult> selector)
{
const int pageSize = 500;
var pageNumber = 0;
IEnumerable<TContent> page = GetPagedChildren(contentId, pageNumber++, pageSize, ordering, out var total);
var results = new List<TResult>((int)total);
results.AddRange(page.Select(selector));
while (pageNumber * pageSize < total)
{
page = GetPagedChildren(contentId, pageNumber++, pageSize, ordering, out _);
results.AddRange(page.Select(selector));
}
return results;
}
private static Ordering BuildOrdering(ContentSortField field, Direction direction, string? culture)
=> field switch
{
// Name is variant - the culture selects the variant name to order by (invariant content and media
// ignore it). Create and update dates are node-level, so the culture does not apply.
ContentSortField.Name => Ordering.By("name", direction, culture),
ContentSortField.CreateDate => Ordering.By("createDate", direction),
ContentSortField.UpdateDate => Ordering.By("updateDate", direction),
_ => throw new ArgumentOutOfRangeException(nameof(field), field, "Unsupported sort field."),
};
}
+44 -1
View File
@@ -3137,7 +3137,13 @@ public class ContentService : RepositoryService, IContentService
{
scope.WriteLock(Constants.Locks.ContentTree);
OperationResult ret = Sort(scope, itemsA, userId, evtMsgs);
// Reload within the lock so sorting operates on fully-loaded entities. Callers may pass
// partially-loaded content (e.g. loaded with loadTemplates: false or without property data),
// and saving those directly would wipe the template and property data (#23120).
// GetByIds returns items in the requested order, preserving the caller's ordering that drives the sort.
IContent[] reloaded = GetByIds(itemsA.Select(x => x.Id).ToArray()).ToArray();
OperationResult ret = Sort(scope, reloaded, userId, evtMsgs);
scope.Complete();
return ret;
}
@@ -3175,6 +3181,43 @@ public class ContentService : RepositoryService, IContentService
}
}
/// <inheritdoc />
public OperationResult SortChildren(int parentId, IReadOnlyList<int> orderedChildIds, int userId = Constants.Security.SuperUserId)
{
EventMessages evtMsgs = EventMessagesFactory.Get();
if (orderedChildIds.Count == 0)
{
return new OperationResult(OperationResultType.NoOperation, evtMsgs);
}
using ICoreScope scope = ScopeProvider.CreateCoreScope();
scope.WriteLock(Constants.Locks.ContentTree);
_documentRepository.UpdateSortOrder(orderedChildIds);
// Sort order lives in umbracoNode; neither the published cache nor the content repository cache keeps
// a separate serialized copy of it, so refreshing the affected branch (which invalidates both and has
// them reload from umbracoNode) is enough to pick up the new order without re-saving each child.
if (parentId == Constants.System.Root)
{
IContent[] roots = GetByIds(orderedChildIds).ToArray();
scope.Notifications.Publish(new ContentTreeChangeNotification(roots, TreeChangeTypes.RefreshNode, evtMsgs));
}
else
{
IContent? parent = GetById(parentId);
if (parent is not null)
{
scope.Notifications.Publish(new ContentTreeChangeNotification(parent, TreeChangeTypes.RefreshBranch, evtMsgs));
}
}
Audit(AuditType.Sort, userId, parentId);
scope.Complete();
return OperationResult.Succeed(evtMsgs);
}
private OperationResult Sort(ICoreScope scope, IContent[] itemsA, int userId, EventMessages eventMessages)
{
var sortingNotification = new ContentSortingNotification(itemsA, eventMessages);
@@ -5,6 +5,7 @@ using System.Runtime.CompilerServices;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core.Cache;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Models;
@@ -22,7 +23,7 @@ namespace Umbraco.Cms.Core.Services;
/// <summary>
/// Implements <see href="IDocumentUrlService" /> operations for handling document URLs.
/// </summary>
public class DocumentUrlService : IDocumentUrlService
public class DocumentUrlService : IDocumentUrlService, IMemoryCacheSizeReporter
{
/// <summary>
/// Represents the key used to identify the URL generation rebuild operation.
@@ -53,6 +54,33 @@ public class DocumentUrlService : IDocumentUrlService
/// <inheritdoc/>
public bool IsInitialized { get; private set; }
/// <inheritdoc />
public string CacheName => "Document URL segments";
/// <inheritdoc />
public long GetApproximateCount() => _documentUrlCache.Count;
/// <inheritdoc />
// The dictionary is enumerated directly (not via .Values, which snapshot-copies the whole collection).
public long? GetApproximateBytes()
=> SampledSizeEstimator.Estimate(_documentUrlCache.Count, _documentUrlCache, static kvp => EstimateUrlSegmentCacheBytes(kvp.Value));
private static long EstimateUrlSegmentCacheBytes(UrlSegmentCache entry)
{
// UrlCacheKey (struct: Guid + nullable int + bool) + dictionary bucket + the cache object header.
long bytes = 64 + (entry.PrimarySegment.Length * 2L);
if (entry.AlternateSegments is not null)
{
bytes += 24; // array header
foreach (var segment in entry.AlternateSegments)
{
bytes += 16 + ((segment?.Length ?? 0) * 2L);
}
}
return bytes;
}
/// <summary>
/// Struct-based cache key for memory-efficient URL segment caching.
/// Uses LanguageId instead of culture string to reduce memory footprint.
@@ -95,6 +95,18 @@ public interface IContentEditingService
/// <returns>The operation status indicating success or failure.</returns>
Task<ContentEditingOperationStatus> SortAsync(Guid? parentKey, IEnumerable<SortingModel> sortingModels, Guid userKey);
/// <summary>
/// Sorts the children of a parent by a system field.
/// </summary>
/// <param name="parentKey">The unique identifier of the parent, or <c>null</c> for root-level sorting.</param>
/// <param name="field">The system field to sort the children by.</param>
/// <param name="direction">The direction to sort in.</param>
/// <param name="culture">The culture whose variant name to sort by, or <c>null</c> to sort by the invariant name. Only applies when sorting by <see cref="ContentSortField.Name"/>. The culture is not validated: a child that does not vary by the given culture - or an unrecognised culture - falls back to the invariant name.</param>
/// <param name="userKey">The unique identifier of the user performing the action.</param>
/// <returns>The operation status indicating success or failure.</returns>
Task<ContentEditingOperationStatus> SortByFieldAsync(Guid? parentKey, ContentSortField field, Direction direction, string? culture, Guid userKey)
=> throw new NotImplementedException(); // TODO (V19): Remove default implementation.
/// <summary>
/// Deletes a content item whether it is in the recycle bin or not.
/// </summary>
@@ -542,6 +542,22 @@ public interface IContentService : IContentServiceBase<IContent>
/// <returns>The operation result.</returns>
OperationResult Sort(IEnumerable<int>? ids, int userId = Constants.Security.SuperUserId);
/// <summary>
/// Sorts the children of a parent by persisting the supplied (already ordered) child identifiers
/// as the new sort order, in a single set-based update.
/// </summary>
/// <param name="parentId">The identifier of the parent, or <see cref="Constants.System.Root"/> for the root.</param>
/// <param name="orderedChildIds">The child document identifiers, in the desired order.</param>
/// <param name="userId">The identifier of the user performing the action.</param>
/// <returns>The operation result.</returns>
/// <remarks>
/// Unlike <see cref="Sort(IEnumerable{int}?, int)" />, this does not load the children or fire per-item
/// save/sort notifications; it persists the order directly and refreshes the affected cache branch.
/// </remarks>
// TODO (V19): Remove the default implementation.
OperationResult SortChildren(int parentId, IReadOnlyList<int> orderedChildIds, int userId = Constants.Security.SuperUserId)
=> throw new NotImplementedException();
#endregion
#region Publish Document
@@ -118,6 +118,18 @@ public interface IMediaEditingService
/// </returns>
Task<ContentEditingOperationStatus> SortAsync(Guid? parentKey, IEnumerable<SortingModel> sortingModels, Guid userKey);
/// <summary>
/// Sorts the children of a parent by a system field.
/// </summary>
/// <param name="parentKey">The unique identifier of the parent, or <c>null</c> for root-level sorting.</param>
/// <param name="field">The system field to sort the children by.</param>
/// <param name="direction">The direction to sort in.</param>
/// <param name="userKey">The unique identifier of the user performing the operation.</param>
/// <returns>The operation status indicating the operation outcome.</returns>
/// <remarks>Media items never vary by culture, so children are always ordered by the invariant name.</remarks>
Task<ContentEditingOperationStatus> SortByFieldAsync(Guid? parentKey, ContentSortField field, Direction direction, Guid userKey)
=> throw new NotImplementedException(); // TODO (V19): Remove default implementation.
/// <summary>
/// Permanently deletes a media item from the recycle bin.
/// </summary>
@@ -359,6 +359,22 @@ public interface IMediaService : IContentServiceBase<IMedia>
/// <returns>True if sorting succeeded, otherwise False</returns>
bool Sort(IEnumerable<IMedia> items, int userId = Constants.Security.SuperUserId);
/// <summary>
/// Sorts the children of a parent by persisting the supplied (already ordered) child identifiers
/// as the new sort order, in a single set-based update.
/// </summary>
/// <param name="parentId">The identifier of the parent, or <see cref="Constants.System.Root"/> for the root.</param>
/// <param name="orderedChildIds">The child media identifiers, in the desired order.</param>
/// <param name="userId">The identifier of the user performing the action.</param>
/// <returns>The operation result.</returns>
/// <remarks>
/// Unlike <see cref="Sort(IEnumerable{IMedia}, int)" />, this does not load the children or fire per-item
/// save/sort notifications; it persists the order directly and refreshes the affected cache branch.
/// </remarks>
// TODO (V19): Remove the default implementation.
OperationResult SortChildren(int parentId, IReadOnlyList<int> orderedChildIds, int userId = Constants.Security.SuperUserId)
=> throw new NotImplementedException();
/// <summary>
/// Creates an <see cref="IMedia" /> object using the alias of the <see cref="IMediaType" />
/// that this Media should based on.
@@ -165,6 +165,12 @@ internal sealed class MediaEditingService
public async Task<ContentEditingOperationStatus> SortAsync(Guid? parentKey, IEnumerable<SortingModel> sortingModels, Guid userKey)
=> await HandleSortAsync(parentKey, sortingModels, userKey);
/// <inheritdoc />
public async Task<ContentEditingOperationStatus> SortByFieldAsync(Guid? parentKey, ContentSortField field, Direction direction, Guid userKey)
// Media never varies by culture, so children are always ordered by the invariant name.
=> await HandleSortByFieldAsync(parentKey, field, direction, culture: null, userKey);
/// <inheritdoc />
protected override IMedia New(string? name, int parentId, IMediaType mediaType)
=> new Models.Media(name, parentId, mediaType);
@@ -187,8 +193,8 @@ internal sealed class MediaEditingService
=> ContentService.Delete(media, userId).Result;
/// <inheritdoc />
protected override IEnumerable<IMedia> GetPagedChildren(int parentId, int pageIndex, int pageSize, out long total)
=> ContentService.GetPagedChildren(parentId, pageIndex, pageSize, out total);
protected override IEnumerable<IMedia> GetPagedChildren(int parentId, int pageIndex, int pageSize, Ordering? ordering, out long total)
=> ContentService.GetPagedChildren(parentId, pageIndex, pageSize, out total, filter: null, ordering: ordering);
/// <inheritdoc />
protected override ContentEditingOperationStatus Sort(IEnumerable<IMedia> items, int userId)
@@ -199,6 +205,13 @@ internal sealed class MediaEditingService
: ContentEditingOperationStatus.CancelledByNotification;
}
/// <inheritdoc />
protected override ContentEditingOperationStatus SortChildrenInBulk(int parentId, IReadOnlyList<int> orderedChildIds, int userId)
{
OperationResult result = ContentService.SortChildren(parentId, orderedChildIds, userId);
return OperationResultToOperationStatus(result);
}
/// <summary>
/// Saves a media item to the repository.
/// </summary>
+46
View File
@@ -1414,6 +1414,15 @@ namespace Umbraco.Cms.Core.Services
{
scope.WriteLock(Constants.Locks.MediaTree);
// Reload within the lock so sorting operates on fully-loaded entities. Callers may pass
// partially-loaded media (e.g. without property data), and saving those directly would
// wipe the property data (#23120). Preserve the caller's ordering, which drives the sort.
var reloadedById = GetByIds(itemsA.Select(x => x.Id)).ToDictionary(x => x.Id);
itemsA = itemsA
.Select(x => reloadedById.TryGetValue(x.Id, out IMedia? media) ? media : null)
.WhereNotNull()
.ToArray();
var savingNotification = new MediaSavingNotification(itemsA, messages);
if (scope.Notifications.PublishCancelable(savingNotification))
{
@@ -1452,6 +1461,43 @@ namespace Umbraco.Cms.Core.Services
}
/// <inheritdoc />
public OperationResult SortChildren(int parentId, IReadOnlyList<int> orderedChildIds, int userId = Constants.Security.SuperUserId)
{
EventMessages evtMsgs = EventMessagesFactory.Get();
if (orderedChildIds.Count == 0)
{
return new OperationResult(OperationResultType.NoOperation, evtMsgs);
}
using ICoreScope scope = ScopeProvider.CreateCoreScope();
scope.WriteLock(Constants.Locks.MediaTree);
_mediaRepository.UpdateSortOrder(orderedChildIds);
// Sort order lives in umbracoNode; neither the published cache nor the media repository cache keeps
// a separate serialized copy of it, so refreshing the affected branch (which invalidates both and has
// them reload from umbracoNode) is enough to pick up the new order without re-saving each child.
if (parentId == Constants.System.Root)
{
IMedia[] roots = GetByIds(orderedChildIds).ToArray();
scope.Notifications.Publish(new MediaTreeChangeNotification(roots, TreeChangeTypes.RefreshNode, evtMsgs));
}
else
{
IMedia? parent = GetById(parentId);
if (parent is not null)
{
scope.Notifications.Publish(new MediaTreeChangeNotification(parent, TreeChangeTypes.RefreshBranch, evtMsgs));
}
}
Audit(AuditType.Sort, userId, parentId);
scope.Complete();
return OperationResult.Succeed(evtMsgs);
}
/// <summary>
/// Checks the data integrity of the media tree and optionally fixes detected issues.
/// </summary>
@@ -1,5 +1,6 @@
using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
using Umbraco.Cms.Core.Cache;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Navigation;
using Umbraco.Cms.Core.Persistence.Repositories;
@@ -76,6 +77,27 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
private NavigationSnapshot _navigation = new(new(), []);
private NavigationSnapshot _recycleBinNavigation = new(new(), []);
/// <summary>
/// Gets the approximate number of nodes currently held in memory across the active navigation
/// structure and the recycle bin structure, for diagnostics. Each snapshot reference is read once,
/// so the count is consistent per structure even if a rebuild swaps a snapshot concurrently.
/// </summary>
private protected long GetNavigationNodeCount()
=> _navigation.Structure.Count + _recycleBinNavigation.Structure.Count;
/// <summary>
/// Gets an approximate retained size, in bytes, of the navigation structures (active tree plus
/// recycle bin), for diagnostics. Sampled and structural — a coarse estimate, not a heap measurement.
/// </summary>
private protected long GetNavigationApproximateBytes()
=> EstimateStructureBytes(_navigation.Structure) + EstimateStructureBytes(_recycleBinNavigation.Structure);
// The dictionary is enumerated directly (not via .Values, which snapshot-copies the whole collection).
// Per-node estimate: fixed fields (key, content-type key, parent, sort order, lock) + dictionary bucket,
// plus an allowance per child key (held in the child set and the cached ordered array).
private static long EstimateStructureBytes(ConcurrentDictionary<Guid, NavigationNode> structure)
=> SampledSizeEstimator.Estimate(structure.Count, structure, static kvp => 120 + (40L * kvp.Value.Children.Count));
/// <summary>
/// Initializes a new instance of the <see cref="ContentNavigationServiceBase{TContentType, TContentTypeService}"/> class.
/// </summary>
@@ -1,3 +1,4 @@
using Umbraco.Cms.Core.Cache;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Persistence.Repositories;
using Umbraco.Cms.Core.Scoping;
@@ -13,8 +14,17 @@ namespace Umbraco.Cms.Core.Services.Navigation;
/// and implements both <see cref="IDocumentNavigationQueryService"/> and <see cref="IDocumentNavigationManagementService"/>
/// to provide a complete set of navigation operations for document content.
/// </remarks>
internal sealed class DocumentNavigationService : ContentNavigationServiceBase<IContentType, IContentTypeService>, IDocumentNavigationQueryService, IDocumentNavigationManagementService
internal sealed class DocumentNavigationService : ContentNavigationServiceBase<IContentType, IContentTypeService>, IDocumentNavigationQueryService, IDocumentNavigationManagementService, IMemoryCacheSizeReporter
{
/// <inheritdoc />
public string CacheName => "Document navigation";
/// <inheritdoc />
public long GetApproximateCount() => GetNavigationNodeCount();
/// <inheritdoc />
public long? GetApproximateBytes() => GetNavigationApproximateBytes();
/// <summary>
/// Initializes a new instance of the <see cref="DocumentNavigationService"/> class.
/// </summary>
@@ -1,3 +1,4 @@
using Umbraco.Cms.Core.Cache;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Persistence.Repositories;
using Umbraco.Cms.Core.Scoping;
@@ -13,8 +14,17 @@ namespace Umbraco.Cms.Core.Services.Navigation;
/// and implements both <see cref="IMediaNavigationQueryService"/> and <see cref="IMediaNavigationManagementService"/>
/// to provide a complete set of navigation operations for media content.
/// </remarks>
internal sealed class MediaNavigationService : ContentNavigationServiceBase<IMediaType, IMediaTypeService>, IMediaNavigationQueryService, IMediaNavigationManagementService
internal sealed class MediaNavigationService : ContentNavigationServiceBase<IMediaType, IMediaTypeService>, IMediaNavigationQueryService, IMediaNavigationManagementService, IMemoryCacheSizeReporter
{
/// <inheritdoc />
public string CacheName => "Media navigation";
/// <inheritdoc />
public long GetApproximateCount() => GetNavigationNodeCount();
/// <inheritdoc />
public long? GetApproximateBytes() => GetNavigationApproximateBytes();
/// <summary>
/// Initializes a new instance of the <see cref="MediaNavigationService"/> class.
/// </summary>
@@ -1,4 +1,4 @@
namespace Umbraco.Cms.Infrastructure.BackgroundJobs;
namespace Umbraco.Cms.Infrastructure.BackgroundJobs;
/// <summary>
/// A background job that will be executed by an available server. With a single server setup this will always be the same.
@@ -16,6 +16,19 @@ public interface IDistributedBackgroundJob
/// </summary>
TimeSpan Period { get; }
/// <summary>
/// Gets a value indicating whether the job's runs should be aligned to clock boundaries derived from <see cref="Period" />.
/// </summary>
/// <remarks>
/// When <c>true</c>, the job becomes runnable on the next clock boundary that is a multiple of <see cref="Period" />
/// (measured from a fixed <strong>UTC</strong> origin, so boundaries fall on round clock times such as on the minute
/// or every N seconds) rather than at <c>LastRun + Period</c>.
/// For predictable boundaries <see cref="Period" /> should divide evenly into one hour.
/// The scheduler may cache this value when it first evaluates registered jobs; changing it at runtime may require an application restart.
/// Defaults to <c>false</c>, preserving the original drift-from-completion behaviour.
/// </remarks>
bool AlignToClock => false;
/// <summary>
/// Run the job.
/// </summary>
@@ -2,7 +2,9 @@
// See LICENSE for more details.
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Scoping;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Sync;
@@ -22,7 +24,10 @@ internal class ScheduledPublishingJob : IDistributedBackgroundJob
public string Name => "ScheduledPublishingJob";
/// <inheritdoc />
public TimeSpan Period => TimeSpan.FromMinutes(1);
public TimeSpan Period => _scheduledPublishingSettings.CurrentValue.Period;
/// <inheritdoc />
public bool AlignToClock => _scheduledPublishingSettings.CurrentValue.AlignToClock;
private readonly IContentService _contentService;
@@ -31,6 +36,7 @@ internal class ScheduledPublishingJob : IDistributedBackgroundJob
private readonly TimeProvider _timeProvider;
private readonly IServerMessenger _serverMessenger;
private readonly IUmbracoContextFactory _umbracoContextFactory;
private readonly IOptionsMonitor<ScheduledPublishingSettings> _scheduledPublishingSettings;
/// <summary>
/// Initializes a new instance of the <see cref="ScheduledPublishingJob" /> class.
@@ -41,7 +47,8 @@ internal class ScheduledPublishingJob : IDistributedBackgroundJob
ILogger<ScheduledPublishingJob> logger,
IServerMessenger serverMessenger,
ICoreScopeProvider scopeProvider,
TimeProvider timeProvider)
TimeProvider timeProvider,
IOptionsMonitor<ScheduledPublishingSettings> scheduledPublishingSettings)
{
_contentService = contentService;
_umbracoContextFactory = umbracoContextFactory;
@@ -49,6 +56,7 @@ internal class ScheduledPublishingJob : IDistributedBackgroundJob
_serverMessenger = serverMessenger;
_scopeProvider = scopeProvider;
_timeProvider = timeProvider;
_scheduledPublishingSettings = scheduledPublishingSettings;
}
/// <inheritdoc />
@@ -0,0 +1,88 @@
// Copyright (c) Umbraco.
// See LICENSE for more details.
using Microsoft.Extensions.Logging;
using Umbraco.Cms.Core.Cache;
using Umbraco.Cms.Core.Sync;
namespace Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs;
/// <summary>
/// Periodically logs, at debug level, the approximate entry count of each in-memory cache that
/// scales with the size of the content tree, together with process-level memory totals.
/// </summary>
/// <remarks>
/// Intended as observability for memory usage during full-tree operations (reindexing, crawling the
/// published site). The per-cache counts are a trend/attribution signal — a count that climbs and
/// never falls indicates unbounded retention; the managed-heap and working-set totals give the
/// absolute memory picture. Runs on all servers because memory is per-process, and does nothing unless
/// debug logging is enabled for this job.
/// </remarks>
public class MemoryCacheSizeReportingJob : RecurringBackgroundJobBase
{
private readonly IEnumerable<IMemoryCacheSizeReporter> _reporters;
private readonly ILogger<MemoryCacheSizeReportingJob> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="MemoryCacheSizeReportingJob" /> class.
/// </summary>
/// <param name="reporters">The in-memory caches that report their size.</param>
/// <param name="logger">The typed logger.</param>
public MemoryCacheSizeReportingJob(
IEnumerable<IMemoryCacheSizeReporter> reporters,
ILogger<MemoryCacheSizeReportingJob> logger)
: base(TimeSpan.FromMinutes(1))
{
_reporters = reporters;
_logger = logger;
}
/// <summary>
/// Gets the server roles on which this job runs.
/// </summary>
/// <remarks>Runs on all servers, because the reported memory is per-process.</remarks>
public override ServerRole[] ServerRoles => Enum.GetValues<ServerRole>();
/// <inheritdoc />
public override Task RunJobAsync(CancellationToken cancellationToken)
{
// Reporting is debug-only; skip the work entirely when debug logging is not enabled.
if (_logger.IsEnabled(LogLevel.Debug) is false)
{
return Task.CompletedTask;
}
foreach (IMemoryCacheSizeReporter reporter in _reporters)
{
cancellationToken.ThrowIfCancellationRequested();
long? approximateBytes = reporter.GetApproximateBytes();
if (approximateBytes is null)
{
_logger.LogDebug(
"In-memory cache size: {CacheName} = {EntryCount} entries (bytes: n/a — use a GC dump)",
reporter.CacheName,
reporter.GetApproximateCount());
}
else
{
_logger.LogDebug(
"In-memory cache size: {CacheName} = {EntryCount} entries (~{ApproximateBytes} bytes)",
reporter.CacheName,
reporter.GetApproximateCount(),
approximateBytes.Value);
}
}
// The reporters above cover the L0 converted-content caches and the baseline structures. The
// HybridCache L1 (Microsoft's in-process tier of ContentCacheNode entries, behind L0) does not
// expose an entry count; capture it from a GC dump when a finer breakdown is needed. The process
// totals below give the overall picture.
_logger.LogDebug(
"Process memory: managed heap {ManagedHeapBytes} bytes, working set {WorkingSetBytes} bytes",
GC.GetTotalMemory(forceFullCollection: false),
Environment.WorkingSet);
return Task.CompletedTask;
}
}
@@ -4,7 +4,6 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Sync;
namespace Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs.ServerRegistration;
@@ -26,6 +25,8 @@ public class InstructionProcessJob : RecurringBackgroundJobBase
private readonly ILogger<InstructionProcessJob> _logger;
private readonly IServerMessenger _messenger;
private readonly TimeSpan _syncTimeout;
private Task? _inFlightSync;
/// <summary>
/// Initializes a new instance of the <see cref="InstructionProcessJob" /> class.
@@ -41,27 +42,78 @@ public class InstructionProcessJob : RecurringBackgroundJobBase
{
_messenger = messenger;
_logger = logger;
_syncTimeout = ValidateSyncTimeout(globalSettings.Value.DatabaseServerMessenger.SyncTimeout);
}
// A non-positive timeout would make every sync "time out" immediately (or throw from WaitAsync for a
// negative value), so guard against misconfiguration and fall back to the default. Timeout.InfiniteTimeSpan
// is allowed as an explicit opt-out that restores the unbounded wait.
private TimeSpan ValidateSyncTimeout(TimeSpan configuredSyncTimeout)
{
if (configuredSyncTimeout > TimeSpan.Zero || configuredSyncTimeout == Timeout.InfiniteTimeSpan)
{
return configuredSyncTimeout;
}
_logger.LogWarning(
"Configured DatabaseServerMessenger.SyncTimeout of {ConfiguredSyncTimeout} is not valid; it must be positive (or Timeout.InfiniteTimeSpan to disable the timeout). Falling back to {DefaultSyncTimeout}.",
configuredSyncTimeout,
DatabaseServerMessengerSettings.DefaultSyncTimeout);
return DatabaseServerMessengerSettings.DefaultSyncTimeout;
}
/// <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.
/// Logs an error if the synchronization fails or stalls, but always completes the task so polling continues.
/// </summary>
/// <param name="cancellationToken">A cancellation token that is signaled when the host is shutting down.</param>
/// <returns>
/// A completed task representing the asynchronous operation.
/// A task representing the asynchronous operation.
/// </returns>
public override Task RunJobAsync(CancellationToken cancellationToken)
public override async Task RunJobAsync(CancellationToken cancellationToken)
{
// If a previous sync is still running (e.g. blocked on a hung database connection after a timeout),
// skip starting another. This bounds us to a single in-flight call instead of accumulating blocked
// thread-pool threads, and logs the stall once rather than on every interval until it recovers.
if (_inFlightSync is { IsCompleted: false })
{
return;
}
// IServerMessenger.Sync() is synchronous and cannot observe the cancellation token, so a hung database
// connection would otherwise block this job's recurring loop indefinitely and silently stop cache
// polling until the process is recycled. Offload it to the thread pool and bound the wait so the loop
// survives and keeps polling; the in-flight call keeps running until its connection faults (bounded by
// the database command/connection timeout, not by SyncTimeout), after which syncing resumes without a recycle.
//
// The loop is already started under ExecutionContext.SuppressFlow() (see RecurringBackgroundJobHostedService.StartAsync),
// which is what makes offloading the scope-creating Sync() to Task.Run safe for the static ambient scope stack.
var syncTask = Task.Run(_messenger.Sync, cancellationToken);
_inFlightSync = syncTask;
// Observe the task's eventual fault on every exit path (timeout, shutdown cancellation, or a late
// failure once we have stopped awaiting it) so it never surfaces as an UnobservedTaskException.
_ = syncTask.ContinueWith(
static t => _ = t.Exception,
CancellationToken.None,
TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously,
TaskScheduler.Default);
try
{
_messenger.Sync();
await syncTask.WaitAsync(_syncTimeout, cancellationToken);
_logger.LogDebug("Synchronized cache instructions.");
}
catch (Exception e)
catch (TimeoutException)
{
_logger.LogError(
"Cache instruction sync did not complete within {SyncTimeout} and may be stalled on a hung database connection. Cache updates are paused on this server until the stalled connection recovers.",
_syncTimeout);
}
catch (Exception e) when (e is not OperationCanceledException)
{
_logger.LogError(e, "Failed (will repeat).");
}
return Task.CompletedTask;
}
}
@@ -33,6 +33,8 @@ public class TouchServerJob : RecurringBackgroundJobBase
private readonly IServerRoleAccessor _serverRoleAccessor;
private readonly IDisposable? _onChangeRegistration;
private GlobalSettings _globalSettings;
private TimeSpan _touchTimeout;
private Task? _inFlightTouch;
/// <summary>
/// Initializes a new instance of the <see cref="TouchServerJob" /> class.
@@ -55,11 +57,13 @@ public class TouchServerJob : RecurringBackgroundJobBase
_logger = logger;
_globalSettings = globalSettings.CurrentValue;
_serverRoleAccessor = serverRoleAccessor;
_touchTimeout = ValidateTouchTimeout(globalSettings.CurrentValue.DatabaseServerRegistrar.TouchTimeout);
_onChangeRegistration = globalSettings.OnChange(x =>
{
_globalSettings = x;
Period = x.DatabaseServerRegistrar.WaitTimeBetweenCalls;
_touchTimeout = ValidateTouchTimeout(x.DatabaseServerRegistrar.TouchTimeout);
});
}
@@ -71,35 +75,93 @@ public class TouchServerJob : RecurringBackgroundJobBase
/// <returns>
/// A completed task when the job has finished running.
/// </returns>
public override Task RunJobAsync(CancellationToken cancellationToken)
public override async 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.
if (_serverRoleAccessor is not ElectedServerRoleAccessor)
{
return Task.CompletedTask;
return;
}
// If a previous touch is still running (e.g. blocked on a hung database connection after a timeout),
// skip starting another. This bounds us to a single in-flight call instead of accumulating blocked
// thread-pool threads (each contending for the servers lock), and logs the stall once rather than on
// every interval until it recovers.
if (_inFlightTouch is { IsCompleted: false })
{
return;
}
var serverAddress = _hostingEnvironment.ApplicationMainUrl?.ToString();
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);
}
// IServerRegistrationService.TouchServer() runs a synchronous database write and cannot observe the
// cancellation token, so a hung connection would otherwise block this job's recurring loop indefinitely
// and silently stop server-registration heartbeats until the process is recycled. Offload it to the
// thread pool and bound the wait so the loop survives and keeps touching.
// (See InstructionProcessJob for the same pattern and the ExecutionContext.SuppressFlow rationale.)
TimeSpan staleServerTimeout = _globalSettings.DatabaseServerRegistrar.StaleServerTimeout;
var touchTask = Task.Run(() => _serverRegistrationService.TouchServer(serverAddress, staleServerTimeout), cancellationToken);
_inFlightTouch = touchTask;
// Observe the task's eventual fault on every exit path (timeout, shutdown cancellation, or a late
// failure once we have stopped awaiting it) so it never surfaces as an UnobservedTaskException.
_ = touchTask.ContinueWith(
static t => _ = t.Exception,
CancellationToken.None,
TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously,
TaskScheduler.Default);
try
{
_serverRegistrationService.TouchServer(
serverAddress,
_globalSettings.DatabaseServerRegistrar.StaleServerTimeout);
await touchTask.WaitAsync(_touchTimeout, cancellationToken);
_logger.LogDebug("Touched server registration for {ServerAddress}.", serverAddress);
}
catch (Exception ex)
catch (TimeoutException)
{
_logger.LogError(
"Touching the server registration did not complete within {TouchTimeout} and may be stalled on a hung database connection. Server registration is paused on this server until the stalled connection recovers.",
_touchTimeout);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogError(ex, "Failed to update server record in database.");
}
}
return Task.CompletedTask;
// A non-positive timeout would make every touch "time out" immediately (or throw from WaitAsync for a
// negative value), so guard against misconfiguration and fall back to the default. Timeout.InfiniteTimeSpan
// is allowed as an explicit opt-out that restores the unbounded wait.
private TimeSpan ValidateTouchTimeout(TimeSpan configuredTouchTimeout)
{
if (configuredTouchTimeout > TimeSpan.Zero || configuredTouchTimeout == Timeout.InfiniteTimeSpan)
{
return configuredTouchTimeout;
}
_logger.LogWarning(
"Configured DatabaseServerRegistrar.TouchTimeout of {ConfiguredTouchTimeout} is not valid; it must be positive (or Timeout.InfiniteTimeSpan to disable the timeout). Falling back to {DefaultTouchTimeout}.",
configuredTouchTimeout,
DatabaseServerRegistrarSettings.DefaultTouchTimeout);
return DatabaseServerRegistrarSettings.DefaultTouchTimeout;
}
/// <inheritdoc />
@@ -25,6 +25,7 @@ public static partial class UmbracoBuilderExtensions
builder.Services.AddRecurringBackgroundJob<InstructionProcessJob>();
builder.Services.AddRecurringBackgroundJob<TouchServerJob>();
builder.Services.AddRecurringBackgroundJob<ReportSiteJob>();
builder.Services.AddRecurringBackgroundJob<MemoryCacheSizeReportingJob>();
builder.Services.AddSingleton<IDistributedBackgroundJob, WebhookFiring>();
builder.Services.AddSingleton<IDistributedBackgroundJob, ContentVersionCleanupJob>();
@@ -131,12 +131,15 @@ public class PackageMigrationRunner
=> RunPackagePlansAsync(plansToRun).GetAwaiter().GetResult();
/// <summary>
/// Runs the all specified package migration plans and publishes a <see cref="MigrationPlansExecutedNotification" />
/// if all are successful.
/// Runs all the specified package migration plans and publishes a <see cref="MigrationPlansExecutedNotification" />.
/// </summary>
/// <remarks>
/// All plans are run to completion even if one fails, so that one package's failure does not block another's.
/// A failed plan is reported via <see cref="ExecutedMigrationPlan.Successful" /> on the returned result rather
/// than by throwing; callers must inspect the results to detect a failure.
/// </remarks>
/// <param name="plansToRun"></param>
/// <returns></returns>
/// <exception cref="Exception">If any plan fails it will throw an exception.</exception>
public async Task<IEnumerable<ExecutedMigrationPlan>> RunPackagePlansAsync(IEnumerable<string> plansToRun)
{
List<ExecutedMigrationPlan> results = new();
@@ -11,6 +11,7 @@ using Umbraco.Cms.Core.Exceptions;
using Umbraco.Cms.Core.Logging;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Infrastructure.Migrations;
using Umbraco.Cms.Infrastructure.Migrations.Install;
using Umbraco.Cms.Infrastructure.Migrations.Upgrade;
using Umbraco.Cms.Infrastructure.Runtime;
@@ -163,7 +164,23 @@ public class UnattendedUpgrader : INotificationAsyncHandler<RuntimeUnattendedUpg
try
{
await _packageMigrationRunner.RunPackagePlansAsync(pendingMigrations);
IEnumerable<ExecutedMigrationPlan> executedPlans =
await _packageMigrationRunner.RunPackagePlansAsync(pendingMigrations);
// Failed plans are reported via the result, not by throwing (the runner deliberately runs all plans to
// completion so one package's failure doesn't block another's). Surface them as a boot failure here so the
// failure is observable, mirroring the core upgrade path - otherwise the migration stays pending and the
// runtime re-derives Upgrading on every boot, leaving the site stuck on the maintenance page.
// All failures are reported together.
var failedPlans = executedPlans.Where(plan => plan.Successful is false).ToList();
if (failedPlans.Count > 0)
{
SetRuntimeError(CreatePackageMigrationError(failedPlans));
notification.UnattendedUpgradeResult =
RuntimeUnattendedUpgradeNotification.UpgradeResult.HasErrors;
return;
}
notification.UnattendedUpgradeResult = RuntimeUnattendedUpgradeNotification.UpgradeResult.PackageMigrationComplete;
// Migration plans may have changed published content, so refresh the distributed cache to ensure consistency on first request.
@@ -200,6 +217,22 @@ public class UnattendedUpgrader : INotificationAsyncHandler<RuntimeUnattendedUpg
}
}
private static Exception CreatePackageMigrationError(IReadOnlyList<ExecutedMigrationPlan> failedPlans)
{
static Exception ToException(ExecutedMigrationPlan plan)
=> plan.Exception ?? new UnattendedInstallException(
$"An error occurred while running the unattended package migration '{plan.Plan.Name}'.");
if (failedPlans.Count == 1)
{
return ToException(failedPlans[0]);
}
return new AggregateException(
$"{failedPlans.Count} unattended package migrations failed: {string.Join(", ", failedPlans.Select(plan => plan.Plan.Name))}.",
failedPlans.Select(ToException));
}
private void SetRuntimeError(Exception exception)
=> _runtimeState.Configure(
RuntimeLevel.BootFailed,
@@ -1297,6 +1297,36 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement
/// </summary>
public abstract int RecycleBinId { get; }
/// <inheritdoc />
public void UpdateSortOrder(IReadOnlyList<int> orderedNodeIds)
{
if (orderedNodeIds.Count == 0)
{
return;
}
var nodeTable = SqlSyntax.GetQuotedTableName(NodeDto.TableName);
var idColumn = SqlSyntax.GetQuotedColumnName(NodeDto.IdColumnName);
var sortOrderColumn = SqlSyntax.GetQuotedColumnName(NodeDto.SortOrderColumnName);
// Each node's new sort order is its position in the ordered collection.
var ordered = orderedNodeIds
.Select((id, sortOrder) => new KeyValuePair<int, int>(id, sortOrder))
.ToList();
// Two parameters per node (id + sort order), so batch to stay within the SQL Server parameter limit.
foreach (IEnumerable<KeyValuePair<int, int>> group in ordered.InGroupsOf(Constants.Sql.MaxParameterCount / 2))
{
List<KeyValuePair<int, int>> groupList = group.ToList();
var args = groupList.SelectMany(pair => new object[] { pair.Key, pair.Value }).ToArray();
var whenClauses = string.Join(" ", groupList.Select((_, i) => $"WHEN @{i * 2} THEN @{(i * 2) + 1}"));
var inClause = string.Join(", ", groupList.Select((_, i) => $"@{i * 2}"));
var sql = $"UPDATE {nodeTable} SET {sortOrderColumn} = CASE {idColumn} {whenClauses} END WHERE {idColumn} IN ({inClause})";
Database.Execute(sql, args);
}
}
/// <summary>
/// Gets all entities that are currently in the recycle bin.
/// </summary>
@@ -20,6 +20,10 @@ public class DistributedJobService : IDistributedJobService
private readonly ILogger<DistributedJobService> _logger;
private readonly DistributedJobSettings _settings;
// Which jobs align to the clock is a startup configuration concern (changing it requires a restart), so it is
// captured once in the constructor rather than re-evaluated on every poll.
private readonly HashSet<string> _clockAlignedJobNames;
/// <summary>
/// Initializes a new instance of the <see cref="DistributedJobService"/> class.
/// </summary>
@@ -58,6 +62,10 @@ public class DistributedJobService : IDistributedJobService
_distributedBackgroundJobs = distributedBackgroundJobs;
_logger = logger;
_settings = settings.Value;
_clockAlignedJobNames = _distributedBackgroundJobs
.Where(x => x.AlignToClock)
.Select(x => x.Name)
.ToHashSet();
}
/// <inheritdoc />
@@ -67,9 +75,12 @@ public class DistributedJobService : IDistributedJobService
scope.EagerWriteLock(Constants.Locks.DistributedJobs);
DateTime utcNow = DateTime.UtcNow;
IEnumerable<DistributedBackgroundJobModel> jobs = _distributedJobRepository.GetAll();
DistributedBackgroundJobModel? job = jobs.FirstOrDefault(x => x.LastRun < DateTime.UtcNow - x.Period
&& (x.IsRunning is false || x.LastAttemptedRun < DateTime.UtcNow - x.Period - _settings.MaximumExecutionTime));
DistributedBackgroundJobModel? job = jobs.FirstOrDefault(x =>
IsDue(x, utcNow, _clockAlignedJobNames.Contains(x.Name))
&& (x.IsRunning is false || x.LastAttemptedRun < utcNow - x.Period - _settings.MaximumExecutionTime));
if (job is null)
{
@@ -97,6 +108,39 @@ public class DistributedJobService : IDistributedJobService
return distributedJob;
}
/// <summary>
/// Determines whether a job is due to run.
/// </summary>
/// <param name="job">The job state.</param>
/// <param name="utcNow">The current UTC time.</param>
/// <param name="aligned">
/// Whether the job's runs are aligned to clock boundaries (see <see cref="IDistributedBackgroundJob.AlignToClock" />).
/// </param>
/// <remarks>
/// For non-aligned jobs the period counts from the previous run's completion (<c>LastRun + Period</c>, drifting).
/// For aligned jobs the job is due once a clock boundary — a multiple of the period measured from a fixed UTC
/// origin, so boundaries fall on round clock times such as on the minute — has fallen strictly after the previous
/// run's completion. Boundaries are in UTC, not the server's local time zone. This is overrun-safe: if a run takes
/// longer than the period, the boundary it would have targeted has already passed, so the missed boundary is
/// skipped rather than triggering back-to-back runs.
/// </remarks>
internal static bool IsDue(DistributedBackgroundJobModel job, DateTime utcNow, bool aligned)
{
if (aligned == false || job.Period <= TimeSpan.Zero)
{
return job.LastRun < utcNow - job.Period;
}
long periodTicks = job.Period.Ticks;
// Floor the current UTC time to the most recent clock boundary. Ticks count from a fixed origin (0001-01-01), and
// a day divides evenly by any clean sub-hour period, so boundaries fall on round clock times (e.g. each :10s).
long ticksSinceBoundary = utcNow.Ticks % periodTicks;
long currentBoundaryTicks = utcNow.Ticks - ticksSinceBoundary;
return currentBoundaryTicks > job.LastRun.Ticks;
}
/// <inheritdoc />
public async Task FinishAsync(string jobName)
{
@@ -136,11 +180,25 @@ public class DistributedJobService : IDistributedJobService
return;
}
// Clock-aligned jobs only hit their boundaries as tightly as the poll interval allows. If the poll interval
// is longer than the job's period, boundaries between polls are silently missed.
foreach (IDistributedBackgroundJob job in _distributedBackgroundJobs)
{
if (job.AlignToClock && job.Period < _settings.Period)
{
_logger.LogWarning(
"Distributed background job '{JobName}' aligns to the clock with a period of {Period}, but the distributed job poll interval is longer ({PollInterval}). Clock boundaries shorter than the poll interval will be missed; set Umbraco:CMS:DistributedJobs:Period to be no longer than the job period.",
job.Name,
job.Period,
_settings.Period);
}
}
using ICoreScope scope = _coreScopeProvider.CreateCoreScope();
scope.WriteLock(Constants.Locks.DistributedJobs);
DistributedBackgroundJobModel[] existingJobs = _distributedJobRepository.GetAll().ToArray();
var existingJobsByName = existingJobs.ToDictionary(x => x.Name);
Dictionary<string, DistributedBackgroundJobModel> existingJobsByName = existingJobs.ToDictionary(x => x.Name);
// Collect all changes first, then execute - minimizes time spent in the critical section
var jobsToAdd = new List<DistributedBackgroundJobModel>();
@@ -273,9 +273,41 @@ HybridCache API is experimental (suppressed with `#pragma warning disable EXTEXP
Before returning cached content, verifies ancestor path is published via `_publishStatusQueryService.HasPublishedAncestorPath()`. Returns null if parent unpublished.
### In-Memory Content Cache (DocumentCacheService.cs line 39)
### In-Memory Content Cache (the L0 converted-content cache)
Secondary `ConcurrentDictionary<string, IPublishedContent>` caches converted objects, since `ContentCacheNode` to `IPublishedContent` conversion is expensive.
The converted `IPublishedContent` objects are cached in `ConvertedPublishedContentCache<TKey>`
(`Services/ConvertedPublishedContentCache.cs`), used by `DocumentCacheService` (`<string>`) and
`MediaCacheService` (`<Guid>`), since `ContentCacheNode``IPublishedContent` conversion is expensive.
This is the single insert/remove/clear path for the L0 cache (the seam a later bounded/eviction-aware
implementation slots into), and it tracks both the entry count and an approximate retained byte total.
The cache is currently **unbounded** — only evicted on content change / explicit clear, so walking the
whole published tree (Delivery API crawl, sitemap, warm-up) retains the whole tree's converted form.
Bounding it with a scan-resistant policy is tracked separately; the observability below quantifies it.
### Memory observability
The in-memory structures whose footprint scales with the size of the content tree implement
`IMemoryCacheSizeReporter` (`Umbraco.Cms.Core.Cache`), exposing an approximate retained **entry count** and
(where cheaply derivable) an approximate **byte** estimate:
| Reporter (`CacheName`) | Structure | Byte estimate |
|------------------------|-----------|---------------|
| `Published content (converted, L0)` | `DocumentCacheService` L0 cache | running total of per-entry node-size estimates |
| `Published media (converted, L0)` | `MediaCacheService` L0 cache | running total of per-entry node-size estimates |
| `Document URL segments` | `DocumentUrlService._documentUrlCache` (≈ documents × cultures × draft/published) | sampled structural estimate |
| `Document navigation` / `Media navigation` | the in-memory navigation trees (active + recycle bin) | sampled structural estimate |
`MemoryCacheSizeReportingJob` (a recurring job, all server roles, 1-minute period) logs each count and byte
estimate plus `GC.GetTotalMemory` and `Environment.WorkingSet` **at `Debug` level** — enable `Debug` for
`Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs.MemoryCacheSizeReportingJob` to capture, e.g. during a
reindex or crawl. Counts/bytes are a **trend/attribution** signal (a value that climbs and never falls
indicates unbounded retention). The byte figures are coarse approximations, **not** a heap measurement: the
L0 estimate is an *underlying-content lower bound* (`ContentCacheNodeSizeEstimator` sums the source node's
stored content without decompressing or walking the converted graph, so it omits the property-editor-driven
conversion blow-up); true per-object bytes come from a GC dump. Note the tiers: **L0** is the
converted-`IPublishedContent` cache reported above; **L1** is Microsoft HybridCache's in-process tier of
`ContentCacheNode` entries (behind L0); **L2** is the optional distributed tier. The HybridCache **L1** has
no exposed count/size — measure it from the GC dump until a sized backing cache is wired up (PR 3).
### Known Technical Debt
@@ -1,5 +1,6 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core.Cache;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Notifications;
@@ -42,8 +43,12 @@ public static class UmbracoBuilderExtensions
builder.Services.AddSingleton<IDomainCache, DomainCache>();
builder.Services.AddSingleton<IElementsCache, ElementsDictionaryAppCache>();
builder.Services.AddSingleton<IPublishedContentTypeCache, PublishedContentTypeCache>();
builder.Services.AddSingleton<IDocumentCacheService, DocumentCacheService>();
builder.Services.AddSingleton<IMediaCacheService, MediaCacheService>();
builder.Services.AddSingleton<DocumentCacheService>();
builder.Services.AddSingleton<IDocumentCacheService>(s => s.GetRequiredService<DocumentCacheService>());
builder.Services.AddSingleton<MediaCacheService>();
builder.Services.AddSingleton<IMediaCacheService>(s => s.GetRequiredService<MediaCacheService>());
builder.Services.AddSingleton<IMemoryCacheSizeReporter>(s => s.GetRequiredService<DocumentCacheService>());
builder.Services.AddSingleton<IMemoryCacheSizeReporter>(s => s.GetRequiredService<MediaCacheService>());
builder.Services.AddSingleton<IMemberCacheService, MemberCacheService>();
builder.Services.AddSingleton<IDomainCacheService, DomainCacheService>();
builder.Services.AddSingleton<IPublishedContentFactory, PublishedContentFactory>();
@@ -77,6 +82,7 @@ public static class UmbracoBuilderExtensions
builder.AddNotificationHandler<ContentTypeChangedNotification, DeferredCacheRebuildNotificationHandler>();
builder.AddNotificationHandler<MediaTypeChangedNotification, DeferredCacheRebuildNotificationHandler>();
builder.AddNotificationAsyncHandler<UmbracoApplicationStartingNotification, SeedingNotificationHandler>();
builder.AddNotificationHandler<UmbracoApplicationStartingNotification, DomainCacheSeedingNotificationHandler>();
builder.AddCacheSeeding();
return builder;
}
@@ -0,0 +1,21 @@
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Services;
namespace Umbraco.Cms.Infrastructure.HybridCache.Extensions;
/// <summary>
/// Provides extension methods for <see cref="IRuntimeState"/> used by the cache startup notification handlers.
/// </summary>
internal static class RuntimeStateExtensions
{
/// <summary>
/// Returns true when startup cache seeding should be skipped because the site is not yet serving
/// front-end content, i.e. it is installing (or below) or upgrading with the maintenance page shown.
/// </summary>
/// <param name="state">The runtime state.</param>
/// <param name="globalSettings">The global settings.</param>
public static bool ShouldSkipStartupSeeding(this IRuntimeState state, GlobalSettings globalSettings)
=> state.Level <= RuntimeLevel.Install
|| (state.Level == RuntimeLevel.Upgrade && globalSettings.ShowMaintenancePageWhenInUpgradeState);
}
@@ -0,0 +1,34 @@
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Core.PublishedCache;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Infrastructure.HybridCache.Extensions;
namespace Umbraco.Cms.Infrastructure.HybridCache.NotificationHandlers;
internal sealed class DomainCacheSeedingNotificationHandler : INotificationHandler<UmbracoApplicationStartingNotification>
{
private readonly IDomainCacheService _domainCacheService;
private readonly IRuntimeState _runtimeState;
private readonly GlobalSettings _globalSettings;
public DomainCacheSeedingNotificationHandler(IDomainCacheService domainCacheService, IRuntimeState runtimeState, IOptions<GlobalSettings> globalSettings)
{
_domainCacheService = domainCacheService;
_runtimeState = runtimeState;
_globalSettings = globalSettings.Value;
}
public void Handle(UmbracoApplicationStartingNotification notification)
{
if (_runtimeState.ShouldSkipStartupSeeding(_globalSettings))
{
return;
}
// Force eager population of the lazily-loaded domain cache.
_domainCacheService.GetAll(includeWildcards: true);
}
}
@@ -1,11 +1,10 @@
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Core.PublishedCache;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Infrastructure.HybridCache.Services;
using Umbraco.Cms.Infrastructure.HybridCache.Extensions;
namespace Umbraco.Cms.Infrastructure.HybridCache.NotificationHandlers;
@@ -29,7 +28,7 @@ internal sealed class SeedingNotificationHandler : INotificationAsyncHandler<Umb
CancellationToken cancellationToken)
{
if (_runtimeState.Level <= RuntimeLevel.Install || (_runtimeState.Level == RuntimeLevel.Upgrade && _globalSettings.ShowMaintenancePageWhenInUpgradeState))
if (_runtimeState.ShouldSkipStartupSeeding(_globalSettings))
{
return;
}
@@ -59,6 +59,26 @@ internal struct LazyCompressedString
public static implicit operator string(LazyCompressedString l) => l.ToString();
/// <summary>
/// Returns an approximate byte count for this value without decompressing it: the compressed byte
/// length while still compressed, otherwise the approximate UTF-16 byte size (character count × 2) of
/// the already-decompressed string. Never triggers decompression and never throws — intended for cheap
/// size diagnostics. Returning UTF-16 bytes here keeps the decompressed estimate consistent with how
/// plain strings are sized elsewhere.
/// </summary>
public int GetApproximateByteCount()
{
lock (_locker)
{
if (_bytes is not null)
{
return _bytes.Length;
}
return _str is null ? 0 : _str.Length * 2;
}
}
public byte[] GetBytes()
{
if (_bytes == null)
@@ -0,0 +1,59 @@
using Umbraco.Cms.Infrastructure.HybridCache.Serialization;
namespace Umbraco.Cms.Infrastructure.HybridCache.Services;
/// <summary>
/// Produces a cheap, approximate byte size for a <see cref="ContentCacheNode" />, used to track the
/// retained size of the L0 converted-content cache.
/// </summary>
/// <remarks>
/// The estimate sums the node's stored string/property content without decompressing
/// <see cref="LazyCompressedString" /> values or walking the converted object graph, so it is safe to
/// call on the cache-insert path. It is an <em>underlying-content</em> figure and a lower bound on the
/// true managed heap cost — it deliberately ignores the (highly property-editor-dependent) blow-up from
/// converting stored values into their typed model. The true heap figure comes from a GC dump.
/// </remarks>
internal static class ContentCacheNodeSizeEstimator
{
// Rough allowances for object headers and dictionary/array bookkeeping (x64).
private const int BaseOverheadBytes = 64;
private const int PerPropertyOverheadBytes = 24;
public static long EstimateBytes(ContentCacheNode node)
{
long bytes = BaseOverheadBytes;
ContentData? data = node.Data;
if (data is null)
{
return bytes;
}
bytes += EstimateStringBytes(data.Name) + EstimateStringBytes(data.UrlSegment);
foreach (KeyValuePair<string, PropertyData[]> property in data.Properties)
{
bytes += EstimateStringBytes(property.Key);
foreach (PropertyData propertyData in property.Value)
{
bytes += PerPropertyOverheadBytes
+ EstimateStringBytes(propertyData.Culture)
+ EstimateStringBytes(propertyData.Segment)
+ EstimateValueBytes(propertyData.Value);
}
}
return bytes;
}
private static long EstimateStringBytes(string? value) => value is null ? 0 : value.Length * 2L;
private static long EstimateValueBytes(object? value) => value switch
{
null => 0,
LazyCompressedString lazyCompressedString => lazyCompressedString.GetApproximateByteCount(),
string stringValue => stringValue.Length * 2L,
byte[] bytes => bytes.Length,
_ => 8,
};
}
@@ -0,0 +1,108 @@
using System.Collections.Concurrent;
using Umbraco.Cms.Core.Models.PublishedContent;
namespace Umbraco.Cms.Infrastructure.HybridCache.Services;
/// <summary>
/// Encapsulates the in-process (L0) cache of converted <see cref="IPublishedContent" /> behind a single
/// insert/remove/clear path, tracking both the entry count and an approximate retained byte total.
/// </summary>
/// <remarks>
/// Routing every mutation through this one type keeps the byte total consistent by construction —
/// there is exactly one place that adds on insert and subtracts on remove/clear. The byte total is an
/// <em>approximation</em> (the per-entry size is supplied by the caller and the running total is updated
/// without locking the whole structure), suitable for diagnostics, not exact accounting.
/// This is also the seam the later bounded/eviction-aware implementation slots into.
/// </remarks>
/// <typeparam name="TKey">The cache key type (string for documents, Guid for media).</typeparam>
internal sealed class ConvertedPublishedContentCache<TKey>
where TKey : notnull
{
private readonly ConcurrentDictionary<TKey, CacheEntry> _cache = new();
private long _approximateSizeInBytes;
/// <summary>
/// Gets the number of entries currently held.
/// </summary>
public long Count => _cache.Count;
/// <summary>
/// Gets the approximate retained size, in bytes, of the cached entries.
/// </summary>
public long ApproximateSizeInBytes => Interlocked.Read(ref _approximateSizeInBytes);
/// <summary>
/// Attempts to get a cached converted content item.
/// </summary>
public bool TryGet(TKey key, out IPublishedContent? content)
{
if (_cache.TryGetValue(key, out CacheEntry entry))
{
content = entry.Content;
return true;
}
content = null;
return false;
}
/// <summary>
/// Adds or replaces a cached converted content item, adjusting the running byte total by the supplied
/// per-entry size estimate.
/// </summary>
public void Set(TKey key, IPublishedContent content, long approximateSizeInBytes)
{
var entry = new CacheEntry(content, approximateSizeInBytes);
// Compute the delta against any existing entry so overwrites don't inflate the total. A concurrent
// Set/Remove for the same key can make this off by one entry's size; acceptable for a diagnostic
// counter, and Clear() re-establishes the baseline.
long delta = approximateSizeInBytes;
if (_cache.TryGetValue(key, out CacheEntry existing))
{
delta -= existing.Size;
}
_cache[key] = entry;
Interlocked.Add(ref _approximateSizeInBytes, delta);
}
/// <summary>
/// Removes a cached entry, subtracting its size from the running total.
/// </summary>
public bool Remove(TKey key)
{
if (_cache.TryRemove(key, out CacheEntry entry))
{
Interlocked.Add(ref _approximateSizeInBytes, -entry.Size);
return true;
}
return false;
}
/// <summary>
/// Removes every entry whose content matches the predicate, subtracting their sizes from the total.
/// </summary>
public void RemoveWhere(Func<IPublishedContent, bool> predicate)
{
foreach (KeyValuePair<TKey, CacheEntry> kvp in _cache)
{
if (predicate(kvp.Value.Content) && _cache.TryRemove(kvp.Key, out CacheEntry removed))
{
Interlocked.Add(ref _approximateSizeInBytes, -removed.Size);
}
}
}
/// <summary>
/// Removes all entries and resets the running byte total.
/// </summary>
public void Clear()
{
_cache.Clear();
Interlocked.Exchange(ref _approximateSizeInBytes, 0);
}
private readonly record struct CacheEntry(IPublishedContent Content, long Size);
}
@@ -1,11 +1,11 @@
#if DEBUG
using System.Diagnostics;
#endif
using System.Collections.Concurrent;
using Microsoft.Extensions.Caching.Hybrid;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Cache;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Cms.Core.PublishedCache;
@@ -20,7 +20,7 @@ using Umbraco.Extensions;
namespace Umbraco.Cms.Infrastructure.HybridCache.Services;
internal sealed class DocumentCacheService : IDocumentCacheService
internal sealed class DocumentCacheService : IDocumentCacheService, IMemoryCacheSizeReporter
{
private readonly IDatabaseCacheRepository _databaseCacheRepository;
private readonly IIdKeyMap _idKeyMap;
@@ -36,7 +36,7 @@ internal sealed class DocumentCacheService : IDocumentCacheService
private readonly ILogger<DocumentCacheService> _logger;
private HashSet<Guid>? _seedKeys;
private readonly ConcurrentDictionary<string, IPublishedContent> _publishedContentCache = [];
private readonly ConvertedPublishedContentCache<string> _publishedContentCache = new();
private HashSet<Guid> SeedKeys
{
@@ -86,6 +86,15 @@ internal sealed class DocumentCacheService : IDocumentCacheService
_logger = logger;
}
/// <inheritdoc />
public string CacheName => "Published content (converted, L0)";
/// <inheritdoc />
public long GetApproximateCount() => _publishedContentCache.Count;
/// <inheritdoc />
public long? GetApproximateBytes() => _publishedContentCache.ApproximateSizeInBytes;
public async Task<IPublishedContent?> GetByKeyAsync(Guid key, bool? preview = null)
{
bool calculatedPreview = preview ?? GetPreview();
@@ -110,7 +119,7 @@ internal sealed class DocumentCacheService : IDocumentCacheService
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))
if (preview is false && _publishedContentCache.TryGet(GetCacheKey(key, preview), out content))
{
return true;
}
@@ -123,7 +132,7 @@ internal sealed class DocumentCacheService : IDocumentCacheService
{
var cacheKey = GetCacheKey(key, preview);
if (preview is false && _publishedContentCache.TryGetValue(cacheKey, out IPublishedContent? cached))
if (preview is false && _publishedContentCache.TryGet(cacheKey, out IPublishedContent? cached))
{
return cached;
}
@@ -155,7 +164,10 @@ internal sealed class DocumentCacheService : IDocumentCacheService
IPublishedContent? result = _publishedContentFactory.ToIPublishedContent(contentCacheNode, preview).CreateModel(_publishedModelFactory);
if (result is not null)
{
_publishedContentCache[cacheKey] = result;
// The size estimate runs unconditionally (not only when reporting is enabled): it is cheap
// (O(properties), no IO/decompression) and only on the cache-miss path, and keeping the running
// total always-current means it is accurate the moment debug reporting is switched on.
_publishedContentCache.Set(cacheKey, result, ContentCacheNodeSizeEstimator.EstimateBytes(contentCacheNode));
}
return result;
@@ -226,7 +238,7 @@ internal sealed class DocumentCacheService : IDocumentCacheService
{
var cacheKey = GetCacheKey(publishedNode.Key, false);
await _hybridCache.SetAsync(cacheKey, publishedNode, GetEntryOptions(publishedNode.Key, false), GenerateTags(publishedNode));
_publishedContentCache.Remove(cacheKey, out _);
_publishedContentCache.Remove(cacheKey);
}
else
{
@@ -428,14 +440,14 @@ internal sealed class DocumentCacheService : IDocumentCacheService
public void ClearConvertedContentCache(IReadOnlyCollection<int> contentTypeIds)
{
var ids = contentTypeIds as int[] ?? contentTypeIds.ToArray();
_publishedContentCache.RemoveAll(content => ids.Contains(content.Value.ContentType.Id));
_publishedContentCache.RemoveWhere(content => ids.Contains(content.ContentType.Id));
}
private async Task ClearPublishedCacheAsync(Guid key)
{
var cacheKey = GetCacheKey(key, false);
await _hybridCache.RemoveAsync(cacheKey);
_publishedContentCache.Remove(cacheKey, out _);
_publishedContentCache.Remove(cacheKey);
}
private static string ContentTypeIdTag(int contentTypeId)
@@ -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;
}
}
@@ -1,11 +1,11 @@
#if DEBUG
using System.Diagnostics;
#endif
using System.Collections.Concurrent;
using Microsoft.Extensions.Caching.Hybrid;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Cache;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Cms.Core.PublishedCache;
@@ -19,7 +19,7 @@ using Umbraco.Extensions;
namespace Umbraco.Cms.Infrastructure.HybridCache.Services;
internal sealed class MediaCacheService : IMediaCacheService
internal sealed class MediaCacheService : IMediaCacheService, IMemoryCacheSizeReporter
{
private readonly IDatabaseCacheRepository _databaseCacheRepository;
private readonly IIdKeyMap _idKeyMap;
@@ -32,7 +32,7 @@ internal sealed class MediaCacheService : IMediaCacheService
private readonly ILogger<MediaCacheService> _logger;
private readonly CacheSettings _cacheSettings;
private readonly ConcurrentDictionary<Guid, IPublishedContent> _publishedContentCache = [];
private readonly ConvertedPublishedContentCache<Guid> _publishedContentCache = new();
private HashSet<Guid>? _seedKeys;
private HashSet<Guid> SeedKeys
@@ -79,6 +79,15 @@ internal sealed class MediaCacheService : IMediaCacheService
_logger = logger;
}
/// <inheritdoc />
public string CacheName => "Published media (converted, L0)";
/// <inheritdoc />
public long GetApproximateCount() => _publishedContentCache.Count;
/// <inheritdoc />
public long? GetApproximateBytes() => _publishedContentCache.ApproximateSizeInBytes;
public async Task<IPublishedContent?> GetByKeyAsync(Guid key)
{
Attempt<int> idAttempt = _idKeyMap.GetIdForKey(key, UmbracoObjectTypes.Media);
@@ -106,7 +115,7 @@ internal sealed class MediaCacheService : IMediaCacheService
public bool TryGetCached(Guid key, out IPublishedContent? content)
{
// Mirror the L0 (published content cache) fast path in GetNodeAsync.
if (_publishedContentCache.TryGetValue(key, out content))
if (_publishedContentCache.TryGet(key, out content))
{
return true;
}
@@ -117,7 +126,7 @@ internal sealed class MediaCacheService : IMediaCacheService
private async Task<IPublishedContent?> GetNodeAsync(Guid key)
{
if (_publishedContentCache.TryGetValue(key, out IPublishedContent? cached))
if (_publishedContentCache.TryGet(key, out IPublishedContent? cached))
{
return cached;
}
@@ -146,7 +155,10 @@ internal sealed class MediaCacheService : IMediaCacheService
IPublishedContent? result = _publishedContentFactory.ToIPublishedMedia(contentCacheNode).CreateModel(_publishedModelFactory);
if (result is not null)
{
_publishedContentCache[key] = result;
// The size estimate runs unconditionally (not only when reporting is enabled): it is cheap
// (O(properties), no IO/decompression) and only on the cache-miss path, and keeping the running
// total always-current means it is accurate the moment debug reporting is switched on.
_publishedContentCache.Set(key, result, ContentCacheNodeSizeEstimator.EstimateBytes(contentCacheNode));
}
return result;
@@ -185,7 +197,7 @@ internal sealed class MediaCacheService : IMediaCacheService
var cacheNode = _cacheNodeFactory.ToContentCacheNode(media);
await _databaseCacheRepository.RefreshMediaAsync(cacheNode);
_publishedContentCache.Remove(media.Key, out _);
_publishedContentCache.Remove(media.Key);
scope.Complete();
}
@@ -262,7 +274,7 @@ internal sealed class MediaCacheService : IMediaCacheService
if (publishedNode is not null)
{
await _hybridCache.SetAsync(GetCacheKey(publishedNode.Key), publishedNode, GetEntryOptions(publishedNode.Key));
_publishedContentCache.Remove(key, out _);
_publishedContentCache.Remove(key);
}
else
{
@@ -300,7 +312,7 @@ internal sealed class MediaCacheService : IMediaCacheService
public void ClearConvertedContentCache(IReadOnlyCollection<int> mediaTypeIds)
{
var ids = mediaTypeIds as int[] ?? mediaTypeIds.ToArray();
_publishedContentCache.RemoveAll(content => ids.Contains(content.Value.ContentType.Id));
_publishedContentCache.RemoveWhere(content => ids.Contains(content.ContentType.Id));
}
public void Rebuild(IReadOnlyCollection<int> contentTypeIds)
@@ -356,7 +368,7 @@ internal sealed class MediaCacheService : IMediaCacheService
private async Task ClearPublishedCacheAsync(Guid key)
{
await _hybridCache.RemoveAsync(GetCacheKey(key));
_publishedContentCache.Remove(key, out _);
_publishedContentCache.Remove(key);
}
private static string MediaTypeIdTag(int mediaTypeId)
@@ -1,34 +1,120 @@
using System.Security.Cryptography;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Net;
using Umbraco.Cms.Core.Web;
using Umbraco.Extensions;
namespace Umbraco.Cms.Web.Common.AspNetCore;
/// <summary>
/// Resolves the current session identifier and reads, writes and clears session values using the
/// ASP.NET Core <see cref="ISession" /> exposed on the current <see cref="HttpContext" />.
/// </summary>
internal sealed class AspNetCoreSessionManager : ISessionIdResolver, ISessionManager
{
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly IOptions<SessionOptions> _sessionOptions;
private readonly IOptionsMonitor<LoggingSettings> _loggingSettings;
public AspNetCoreSessionManager(IHttpContextAccessor httpContextAccessor) =>
_httpContextAccessor = httpContextAccessor;
public string? SessionId
/// <summary>
/// Initializes a new instance of the <see cref="AspNetCoreSessionManager" /> class.
/// </summary>
/// <param name="httpContextAccessor">Provides access to the current <see cref="HttpContext" />.</param>
/// <param name="sessionOptions">The configured session options, used to determine the session cookie name.</param>
/// <param name="loggingSettings">The logging settings, used to determine how the session id is resolved for log enrichment.</param>
public AspNetCoreSessionManager(
IHttpContextAccessor httpContextAccessor,
IOptions<SessionOptions> sessionOptions,
IOptionsMonitor<LoggingSettings> loggingSettings)
{
get
{
HttpContext? httpContext = _httpContextAccessor.HttpContext;
_httpContextAccessor = httpContextAccessor;
_sessionOptions = sessionOptions;
_loggingSettings = loggingSettings;
}
return IsSessionsAvailable
? httpContext?.Session.Id
: "0";
/// <inheritdoc />
/// <remarks>
/// The resolved value depends on <see cref="LoggingSettings.SessionIdLogging" />: the actual session id
/// (default), a one-way hash of the session cookie, or nothing.
/// </remarks>
public string? SessionId =>
_loggingSettings.CurrentValue.SessionIdLogging switch
{
SessionIdLoggingMode.None => null,
SessionIdLoggingMode.CookieHash => ResolveSessionCookieHash(),
_ => ResolveSessionId(),
};
/// <summary>
/// Resolves the actual ASP.NET Core session id, but only when an established session cookie is present.
/// </summary>
/// <remarks>
/// Reading Session.Id forces a synchronous, blocking load from the session store. When sessions are
/// backed by IDistributedCache (e.g. load-balanced setups), that is a network round-trip incurred on
/// every request that resolves the id for logging - even anonymous requests that never use session.
/// Only an established session sends back the session cookie, so its absence means there is nothing
/// meaningful to load (see #23082).
/// </remarks>
private string? ResolveSessionId()
{
if (IsSessionsAvailable is false)
{
return "0";
}
HttpContext? httpContext = _httpContextAccessor.HttpContext;
if (httpContext is null || TryGetSessionCookieValue(httpContext, out _) is false)
{
return null;
}
return httpContext.Session.Id;
}
/// <summary>
/// If session isn't enabled this will throw an exception so we check
/// Resolves a one-way hash of the session cookie value, which correlates requests to the same session
/// without loading the session from its store.
/// </summary>
private bool IsSessionsAvailable => !(_httpContextAccessor.HttpContext?.Features.Get<ISessionFeature>()?.Session is null);
private string? ResolveSessionCookieHash()
{
HttpContext? httpContext = _httpContextAccessor.HttpContext;
if (httpContext is null || TryGetSessionCookieValue(httpContext, out var cookieValue) is false)
{
return null;
}
// Never log the raw cookie value - it is effectively a bearer token for the session. A one-way hash
// preserves per-session correlation without exposing the cookie and without loading the session.
return cookieValue!.GenerateHash<SHA256>();
}
private bool TryGetSessionCookieValue(HttpContext httpContext, out string? value)
{
var sessionCookieName = _sessionOptions.Value.Cookie.Name;
if (sessionCookieName is null)
{
value = null;
return false;
}
return httpContext.Request.Cookies.TryGetValue(sessionCookieName, out value);
}
/// <summary>
/// Gets a value indicating whether session is available for the current request.
/// </summary>
/// <remarks>
/// Accessing <see cref="HttpContext.Session" /> throws an <see cref="InvalidOperationException" /> when the
/// session middleware has not been configured (i.e. <c>UseSession</c> was not called), so this is checked
/// before reading from or writing to the session.
/// </remarks>
private bool IsSessionsAvailable => _httpContextAccessor.HttpContext?.Features.Get<ISessionFeature>()?.Session is not null;
/// <inheritdoc />
public string? GetSessionValue(string key)
{
if (!IsSessionsAvailable)
@@ -39,6 +125,7 @@ internal sealed class AspNetCoreSessionManager : ISessionIdResolver, ISessionMan
return _httpContextAccessor.HttpContext?.Session.GetString(key);
}
/// <inheritdoc />
public void SetSessionValue(string key, string value)
{
if (!IsSessionsAvailable)
@@ -49,6 +136,7 @@ internal sealed class AspNetCoreSessionManager : ISessionIdResolver, ISessionMan
_httpContextAccessor.HttpContext?.Session.SetString(key, value);
}
/// <inheritdoc />
public void ClearSessionValue(string key)
{
if (!IsSessionsAvailable)
@@ -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'
}
};
File diff suppressed because it is too large Load Diff
@@ -18,6 +18,7 @@ export default {
changeDataType: 'Datatype aanpassen',
copy: 'Kopiëren',
create: 'Nieuw',
createFor: (name: string) => (name ? `Item aanmaken voor ${name}` : 'Aanmaken'),
export: 'Export',
createPackage: 'Nieuwe package',
createGroup: 'Groep maken',
@@ -56,6 +57,7 @@ export default {
setGroup: 'Groep instellen',
sort: 'Sorteren',
translate: 'Vertalen',
trash: 'Verwijderen',
update: 'Bijwerken',
setPermissions: 'Rechten instellen',
unlock: 'Deblokkeer',
+2 -2
View File
@@ -6,7 +6,7 @@
"build": "vite build"
},
"dependencies": {
"@umbraco-ui/uui": "^1.17.3",
"@umbraco-ui/uui-css": "^1.17.3"
"@umbraco-ui/uui": "^1.18.0",
"@umbraco-ui/uui-css": "^1.18.0"
}
}
@@ -705,10 +705,14 @@ export class UmbBlockGridEntriesContext
const allowedBlocks = this.#allowedBlockTypes.getValue();
if (allowedBlocks.length === 0) return false;
// Manager may have been torn down (e.g. when navigating away) while the form-control
// mixin still runs validators from updated(). Treat as valid in that case.
if (!this._manager) return true;
const allowedKeys = allowedBlocks.map((x) => x.contentElementTypeKey);
// get content for each layout entry:
const invalidEntries = layoutEntries.filter((entry) => {
const contentTypeKey = this._manager!.getContentTypeKeyOfContentKey(entry.contentKey);
const contentTypeKey = this._manager?.getContentTypeKeyOfContentKey(entry.contentKey);
if (!contentTypeKey) {
// We could not find the content type key, so we cant determin if this is valid or not when the content is missing.
// This should be captured elsewhere as the Block then becomes invalid. So the unsupported Block should capture this.
@@ -399,7 +399,7 @@ export abstract class UmbContentDetailWorkspaceContextBase<
public async loadLanguages() {
// TODO: If we don't end up having a Global Context for languages, then we should at least change this into using a asObservable which should be returned from the repository. [Nl]
const { data } = await this.#languageRepository.requestCollection({});
const { data } = await this.#languageRepository.requestAllItems();
this.#languages.setValue(data?.items ?? []);
}
@@ -89,10 +89,16 @@ export abstract class UmbMenuTreeStructureWorkspaceContextBase extends UmbContex
let structureItems: Array<UmbStructureItemModel> = [];
const unique = (await this.observe(uniqueObservable, () => {})?.asPromise()) as string;
if (unique === undefined) throw new Error('Unique is not available');
if (unique === undefined) {
if (this._host) console.warn('[UmbMenuTreeStructureWorkspaceContextBase] unique not available');
return;
}
const entityType = (await this.observe(entityTypeObservable, () => {})?.asPromise()) as string;
if (!entityType) throw new Error('Entity type is not available');
if (!entityType) {
if (this._host) console.warn('[UmbMenuTreeStructureWorkspaceContextBase] entityType not available');
return;
}
const treeRepository = await createExtensionApiByAlias<UmbTreeRepository<UmbTreeItemModel, UmbTreeRootModel>>(
this,
@@ -115,6 +121,7 @@ export abstract class UmbMenuTreeStructureWorkspaceContextBase extends UmbContex
const isRoot = entityType === root?.entityType;
// If the entity type is different from the root entity type, then we can request the ancestors.
let ancestorData: Array<UmbTreeItemModel> | undefined;
if (!isRoot) {
const { data } = await treeRepository.requestTreeItemAncestors({ treeItem: { unique, entityType } });
@@ -128,12 +135,19 @@ export abstract class UmbMenuTreeStructureWorkspaceContextBase extends UmbContex
};
});
this.#setAncestorData(data);
ancestorData = data;
structureItems.push(...ancestorItems);
}
}
// Guard: this context may have been destroyed while the async requests were in flight.
if (!this._host) return;
if (ancestorData) {
this.#setAncestorData(ancestorData);
}
this.#structure.setValue(structureItems);
this.#setParentData(structureItems);
@@ -90,7 +90,6 @@ export abstract class UmbMenuVariantTreeStructureWorkspaceContextBase extends Um
(value) => {
// Workspace has changed from new to existing
if (value === false && this.#isNew === true) {
// TODO: We do not need to request here as we already know the structure and unique
this.#requestStructure();
}
this.#isNew = value;
@@ -144,10 +143,16 @@ export abstract class UmbMenuVariantTreeStructureWorkspaceContextBase extends Um
let structureItems: Array<UmbVariantStructureItemModel> = [];
const unique = (await this.observe(uniqueObservable, () => {})?.asPromise()) as string;
if (unique === undefined) throw new Error('Unique is not available');
if (unique === undefined) {
if (this._host) console.warn('[UmbMenuVariantTreeStructureWorkspaceContextBase] unique not available');
return;
}
const entityType = (await this.observe(entityTypeObservable, () => {})?.asPromise()) as string;
if (!entityType) throw new Error('Entity type is not available');
if (!entityType) {
if (this._host) console.warn('[UmbMenuVariantTreeStructureWorkspaceContextBase] entityType not available');
return;
}
// TODO: introduce variant tree item model
const treeRepository = await createExtensionApiByAlias<UmbTreeRepository<any, UmbTreeRootModel>>(
@@ -186,6 +191,10 @@ export abstract class UmbMenuVariantTreeStructureWorkspaceContextBase extends Um
structureItems.push(...treeItemAncestors);
// Guard: this context may have been destroyed while the async requests were in flight
// (e.g. a condition such as IS_NOT_TRASHED flips before the API response arrives).
if (!this._host) return;
this.#structure.setValue(structureItems);
this.#setParentData(structureItems);
this.#setAncestorData(data);
@@ -2,6 +2,7 @@ export * from './constants.js';
export * from './data-mapper/index.js';
export * from './detail/index.js';
export * from './item/index.js';
export * from './pagination/index.js';
export * from './repository-base.js';
export * from './repository-details.manager.js';
export * from './repository-items.manager.js';
@@ -0,0 +1,122 @@
import { expect } from '@open-wc/testing';
import { fetchAllPages } from './fetch-all-pages.function.js';
import type { UmbDataSourceResponse } from '../data-source-response.interface.js';
import type { UmbPagedModel } from '../types.js';
interface TestItem {
id: number;
}
const buildFakeFetcher = (allItems: Array<TestItem>) => {
const calls: Array<{ skip: number; take: number }> = [];
const fetchPage = async (skip: number, take: number) => {
calls.push({ skip, take });
return { data: { items: allItems.slice(skip, skip + take), total: allItems.length } };
};
return { fetchPage, calls };
};
describe('fetchAllPages', () => {
it('returns all items in a single call when total <= take', async () => {
const all: Array<TestItem> = [{ id: 1 }, { id: 2 }];
const { fetchPage, calls } = buildFakeFetcher(all);
const { data } = await fetchAllPages(fetchPage, 10);
expect(data?.items).to.eql(all);
expect(data?.total).to.equal(2);
expect(calls).to.have.lengthOf(1);
expect(calls[0]).to.eql({ skip: 0, take: 10 });
});
it('pages through and returns all items when total > take', async () => {
const all: Array<TestItem> = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }, { id: 5 }];
const { fetchPage, calls } = buildFakeFetcher(all);
const { data } = await fetchAllPages(fetchPage, 2);
expect(data?.items).to.eql(all);
expect(data?.total).to.equal(5);
expect(calls.map((c) => c.skip)).to.eql([0, 2, 4]);
});
it('makes no extra call when the last page exactly fills `take`', async () => {
const all: Array<TestItem> = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }];
const { fetchPage, calls } = buildFakeFetcher(all);
const { data } = await fetchAllPages(fetchPage, 2);
expect(data?.items).to.eql(all);
expect(data?.total).to.equal(4);
expect(calls).to.have.lengthOf(2);
});
it('returns an empty result when there are no items', async () => {
const { fetchPage, calls } = buildFakeFetcher([]);
const { data } = await fetchAllPages(fetchPage, 100);
expect(data?.items).to.eql([]);
expect(data?.total).to.equal(0);
expect(calls).to.have.lengthOf(1);
});
it('returns the error and stops paging when a page fetch fails', async () => {
let callCount = 0;
const fetchPage = async (skip: number, take: number) => {
callCount++;
if (callCount === 1) {
return { data: { items: [{ id: 1 }, { id: 2 }] as Array<TestItem>, total: 100 } };
}
return { error: new Error('boom') };
};
const { data, error } = await fetchAllPages<TestItem>(fetchPage, 2);
expect(data).to.be.undefined;
expect(error).to.exist;
expect(callCount).to.equal(2);
});
it('returns a synthesised error when the fetcher returns neither data nor error', async () => {
const fetchPage = async () => ({}) as UmbDataSourceResponse<UmbPagedModel<TestItem>>;
const { data, error } = await fetchAllPages<TestItem>(fetchPage, 2);
expect(data).to.be.undefined;
expect(error).to.be.an.instanceOf(Error);
});
it('rejects when `take` is not a positive finite number', async () => {
const { fetchPage } = buildFakeFetcher([{ id: 1 }, { id: 2 }]);
for (const invalid of [0, -1, NaN, Number.POSITIVE_INFINITY]) {
let thrown: unknown;
try {
await fetchAllPages(fetchPage, invalid);
} catch (e) {
thrown = e;
}
expect(thrown, `take=${invalid}`).to.be.an.instanceOf(RangeError);
}
});
it('returns an error if the server delivers an empty page before reaching the reported total', async () => {
// Surfaces (rather than masks) a server that reports more items than it returns. Also guards against
// an infinite loop if `total` and the actual items disagree.
let callCount = 0;
const fetchPage = async (skip: number, take: number) => {
callCount++;
if (callCount === 1) {
return { data: { items: [{ id: 1 }, { id: 2 }] as Array<TestItem>, total: 10 } };
}
return { data: { items: [] as Array<TestItem>, total: 10 } };
};
const { data, error } = await fetchAllPages<TestItem>(fetchPage, 2);
expect(data).to.be.undefined;
expect(error).to.be.an.instanceOf(Error);
expect(callCount).to.equal(2);
});
});
@@ -0,0 +1,60 @@
import type { UmbDataSourceResponse } from '../data-source-response.interface.js';
import type { UmbPagedModel } from '../types.js';
/**
* A function that returns a single page of an offset-paginated collection.
* @template T - The type of items in the page.
*/
export type UmbOffsetPageFetcher<T> = (
skip: number,
take: number,
) => Promise<UmbDataSourceResponse<UmbPagedModel<T>>>;
/**
* Pages through an offset-paginated data source, accumulating every item until `total` has been reached.
* Use when a caller genuinely needs the full set rather than a single page — for example, populating a
* dropdown of every configured language. Returns the same `{ data: { items, total } }` shape as a
* single-page fetch, or `{ error }` if any page fails.
*
* If the server reports a higher `total` than it actually delivers — i.e. an empty page is returned
* before `allItems.length` reaches `total` — the function fails with an error rather than silently
* truncating, since a partial "fetch all" result would mislead the caller.
* @param {UmbOffsetPageFetcher} fetchPage - Called once per page with the current `skip` and `take`.
* @param {number} take - Page size used for every request. Must be a positive finite number.
* @returns {Promise} A promise resolving to all items, or the first error encountered.
* @throws {RangeError} If `take` is not a positive finite number.
*/
export async function fetchAllPages<T>(
fetchPage: UmbOffsetPageFetcher<T>,
take: number,
): Promise<UmbDataSourceResponse<UmbPagedModel<T>>> {
if (!Number.isFinite(take) || take <= 0) {
throw new RangeError(`fetchAllPages: \`take\` must be a positive finite number, got ${take}.`);
}
const allItems: Array<T> = [];
let skip = 0;
let total = Number.POSITIVE_INFINITY;
while (allItems.length < total) {
const { data, error } = await fetchPage(skip, take);
if (error) return { error };
if (!data) return { error: new Error('fetchAllPages: page fetcher returned neither data nor error.') };
// If the server reports more items than it delivers, fail rather than silently truncating —
// also guards against an infinite loop on a misbehaving source.
if (data.items.length === 0 && allItems.length < data.total) {
return {
error: new Error(
`fetchAllPages: page fetcher returned an empty page after ${allItems.length} items but reported a total of ${data.total}.`,
),
};
}
allItems.push(...data.items);
total = data.total;
skip += data.items.length;
}
return { data: { items: allItems, total: allItems.length } };
}
@@ -0,0 +1 @@
export * from './fetch-all-pages.function.js';
@@ -1,21 +1,26 @@
import { UMB_DUPLICATE_TO_MODAL } from './modal/duplicate-to-modal.token.js';
import type { MetaEntityActionDuplicateToKind, UmbDuplicateToRepository } from './types.js';
import type { UmbTreeRepository } from '../../data/tree-repository.interface.js';
import { UmbEntityActionBase, UmbRequestReloadStructureForEntityEvent } from '@umbraco-cms/backoffice/entity-action';
import { umbOpenModal } from '@umbraco-cms/backoffice/modal';
import { createExtensionApiByAlias } from '@umbraco-cms/backoffice/extension-registry';
import { UMB_ACTION_EVENT_CONTEXT } from '@umbraco-cms/backoffice/action';
import { linkEntityExpansionEntries } from '@umbraco-cms/backoffice/utils';
export class UmbDuplicateToEntityAction extends UmbEntityActionBase<MetaEntityActionDuplicateToKind> {
override async execute() {
if (!this.args.unique) throw new Error('Unique is not available');
if (!this.args.entityType) throw new Error('Entity Type is not available');
const ancestors = await this.#requestAncestors();
const value = await umbOpenModal(this, UMB_DUPLICATE_TO_MODAL, {
data: {
unique: this.args.unique,
entityType: this.args.entityType,
treeAlias: this.args.meta.treeAlias,
foldersOnly: this.args.meta.foldersOnly,
treeExpansion: ancestors.length ? linkEntityExpansionEntries(ancestors) : undefined,
},
});
@@ -40,6 +45,21 @@ export class UmbDuplicateToEntityAction extends UmbEntityActionBase<MetaEntityAc
this.#reloadMenu();
}
async #requestAncestors() {
try {
const treeRepository = await createExtensionApiByAlias<UmbTreeRepository>(this, this.args.meta.treeRepositoryAlias);
const { data } =
(await treeRepository?.requestTreeItemAncestors({
treeItem: { unique: this.args.unique!, entityType: this.args.entityType! },
})) ?? {};
// Exclude self — the API returns the descendant as part of the ancestors list, but we only want to expand its parents.
return data?.filter((item) => item.unique !== this.args.unique) ?? [];
} catch {
// Tree pre-expansion is a UX convenience — if it fails the modal still opens normally.
return [];
}
}
async #reloadMenu() {
const actionEventContext = await this.getContext(UMB_ACTION_EVENT_CONTEXT);
if (!actionEventContext) throw new Error('Action event context is not available');
@@ -11,6 +11,10 @@ export class UmbDuplicateToModalElement extends UmbModalBaseElement<UmbDuplicate
@state()
private _destinationUnique?: string | null;
private get _treeExpansion() {
return this.data?.treeExpansion ?? [];
}
#onTreeSelectionChange(event: UmbSelectionChangeEvent) {
const target = event.target as UmbTreeElement;
const selection = target.getSelection();
@@ -32,6 +36,7 @@ export class UmbDuplicateToModalElement extends UmbModalBaseElement<UmbDuplicate
.props=${{
foldersOnly: this.data?.foldersOnly,
expandTreeRoot: true,
expansion: this._treeExpansion,
}}
@selection-change=${this.#onTreeSelectionChange}></umb-tree>
</uui-box>
@@ -43,7 +48,10 @@ export class UmbDuplicateToModalElement extends UmbModalBaseElement<UmbDuplicate
#renderActions() {
return html`
<uui-button slot="actions" label=${this.localize.term('general_cancel')} @click="${this._rejectModal}"></uui-button>
<uui-button
slot="actions"
label=${this.localize.term('general_cancel')}
@click="${this._rejectModal}"></uui-button>
<uui-button
slot="actions"
color="positive"
@@ -1,11 +1,13 @@
import type { UmbEntityModel } from '@umbraco-cms/backoffice/entity';
import { UmbModalToken } from '@umbraco-cms/backoffice/modal';
import type { UmbEntityExpansionModel } from '@umbraco-cms/backoffice/utils';
export const UMB_DUPLICATE_TO_MODAL_ALIAS = 'Umb.Modal.DuplicateTo';
export interface UmbDuplicateToModalData extends UmbEntityModel {
treeAlias: string;
foldersOnly?: boolean;
treeExpansion?: UmbEntityExpansionModel;
}
export interface UmbDuplicateToModalValue {
@@ -1,11 +1,13 @@
import { UMB_TREE_PICKER_MODAL } from '../../tree-picker-modal/index.js';
import type { UmbTreeItemModel } from '../../types.js';
import type { UmbTreeRepository } from '../../data/tree-repository.interface.js';
import type { UmbMoveRepository } from './move-repository.interface.js';
import type { MetaEntityActionMoveToKind } from './types.js';
import { UmbEntityActionBase, UmbRequestReloadStructureForEntityEvent } from '@umbraco-cms/backoffice/entity-action';
import { umbOpenModal } from '@umbraco-cms/backoffice/modal';
import { createExtensionApiByAlias } from '@umbraco-cms/backoffice/extension-registry';
import { UMB_ACTION_EVENT_CONTEXT } from '@umbraco-cms/backoffice/action';
import { linkEntityExpansionEntries } from '@umbraco-cms/backoffice/utils';
export class UmbMoveToEntityAction extends UmbEntityActionBase<MetaEntityActionMoveToKind> {
protected async _getPickableFilter(unique: string): Promise<((item: UmbTreeItemModel) => boolean) | undefined> {
@@ -16,12 +18,18 @@ export class UmbMoveToEntityAction extends UmbEntityActionBase<MetaEntityActionM
if (!this.args.unique) throw new Error('Unique is not available');
if (!this.args.entityType) throw new Error('Entity Type is not available');
const [ancestors, pickableFilter] = await Promise.all([
this.#requestAncestors(),
this._getPickableFilter(this.args.unique),
]);
const value = await umbOpenModal(this, UMB_TREE_PICKER_MODAL, {
data: {
treeAlias: this.args.meta.treeAlias,
foldersOnly: this.args.meta.foldersOnly,
expandTreeRoot: true,
pickableFilter: await this._getPickableFilter(this.args.unique),
treeExpansion: ancestors.length ? linkEntityExpansionEntries(ancestors) : undefined,
pickableFilter,
},
});
@@ -43,6 +51,21 @@ export class UmbMoveToEntityAction extends UmbEntityActionBase<MetaEntityActionM
this.#reloadMenu();
}
async #requestAncestors() {
try {
const treeRepository = await createExtensionApiByAlias<UmbTreeRepository>(this, this.args.meta.treeRepositoryAlias);
const { data } =
(await treeRepository?.requestTreeItemAncestors({
treeItem: { unique: this.args.unique!, entityType: this.args.entityType! },
})) ?? {};
// Exclude self — the API returns the descendant as part of the ancestors list, but we only want to expand its parents.
return data?.filter((item) => item.unique !== this.args.unique) ?? [];
} catch {
// Tree pre-expansion is a UX convenience — if it fails the modal still opens normally.
return [];
}
}
async #reloadMenu() {
const actionEventContext = await this.getContext(UMB_ACTION_EVENT_CONTEXT);
if (!actionEventContext) throw new Error('Action Event Context is not available');
@@ -75,6 +75,10 @@ export class UmbTreePickerModalElement<TreeItemType extends UmbTreeItemModelBase
...this._selectionConfiguration,
multiple,
};
if (this.data?.treeExpansion !== undefined) {
this._pickerContext.expansion.setExpansion(this.data.treeExpansion);
}
}
if (_changedProperties.has('value')) {
@@ -2,6 +2,7 @@ import type { UmbTreeItemModel, UmbTreeStartNode } from '../types.js';
import type { UmbPathPattern, UmbPathPatternParamsType } from '@umbraco-cms/backoffice/router';
import type { UmbModalToken, UmbPickerModalData, UmbPickerModalValue } from '@umbraco-cms/backoffice/modal';
import type { UmbWorkspaceModalData } from '@umbraco-cms/backoffice/workspace';
import type { UmbEntityExpansionModel } from '@umbraco-cms/backoffice/utils';
export interface UmbTreePickerModalCreateActionData<PathPatternParamsType extends UmbPathPatternParamsType> {
label: string;
@@ -17,6 +18,7 @@ export interface UmbTreePickerModalData<
> extends UmbPickerModalData<TreeItemType> {
hideTreeRoot?: boolean;
expandTreeRoot?: boolean;
treeExpansion?: UmbEntityExpansionModel;
treeAlias?: string;
// TODO: create action should be replaces by entity actions in the pickers. Then we also open up for creating folders, choosing where to place items etc. [MR]
createAction?: UmbTreePickerModalCreateActionData<PathPatternParamsType>;
@@ -42,7 +42,7 @@ export class UmbDictionaryTableCollectionViewElement extends UmbLitElement {
async #observeCollectionItems() {
if (!this.#collectionContext) return;
const { data: languageData } = await this.#languageCollectionRepository.requestCollection({});
const { data: languageData } = await this.#languageCollectionRepository.requestAllItems();
if (!languageData) return;
this.observe(
@@ -51,7 +51,7 @@ export class UmbWorkspaceViewDictionaryEditorElement extends UmbLitElement {
}
override async firstUpdated() {
const { data } = await this.#languageCollectionRepository.requestCollection({});
const { data } = await this.#languageCollectionRepository.requestAllItems();
if (data) {
this._languages = data.items;
}
@@ -103,7 +103,7 @@ export class UmbCultureAndHostnamesModalElement extends UmbModalBaseElement<
}
async #requestLanguages() {
const { data } = await this.#languageCollectionRepository.requestCollection({ take: 999 });
const { data } = await this.#languageCollectionRepository.requestAllItems();
// Set to empty array if no data, to indicate loading is complete
this._languageModel = data?.items ?? [];
}
@@ -2,6 +2,7 @@ import { UMB_DOCUMENT_ENTITY_TYPE, UMB_DOCUMENT_ROOT_ENTITY_TYPE } from '../../e
import { UmbDocumentItemRepository } from '../../item/index.js';
import { UMB_DUPLICATE_DOCUMENT_MODAL } from './modal/index.js';
import { UmbDuplicateDocumentRepository } from './repository/index.js';
import { UmbDocumentTreeRepository } from '../../tree/index.js';
import { umbOpenModal } from '@umbraco-cms/backoffice/modal';
import { UMB_ACTION_EVENT_CONTEXT } from '@umbraco-cms/backoffice/action';
import { UmbEntityActionBase, UmbRequestReloadChildrenOfEntityEvent } from '@umbraco-cms/backoffice/entity-action';
@@ -9,6 +10,7 @@ import {
UmbDocumentTypeDetailRepository,
UmbDocumentTypeStructureRepository,
} from '@umbraco-cms/backoffice/document-type';
import { linkEntityExpansionEntries } from '@umbraco-cms/backoffice/utils';
export class UmbDuplicateDocumentEntityAction extends UmbEntityActionBase<never> {
override async execute() {
@@ -16,13 +18,17 @@ export class UmbDuplicateDocumentEntityAction extends UmbEntityActionBase<never>
if (!this.args.entityType) throw new Error('Entity Type is not available');
const duplicateRepository = new UmbDuplicateDocumentRepository(this);
const selectableFilter = await this.#getSelectableFilterByDocumentUnique(this.args.unique);
const [selectableFilter, ancestors] = await Promise.all([
this.#getSelectableFilterByDocumentUnique(this.args.unique),
this.#requestAncestors(),
]);
const value = await umbOpenModal(this, UMB_DUPLICATE_DOCUMENT_MODAL, {
data: {
unique: this.args.unique,
entityType: this.args.entityType,
selectableFilter,
treeExpansion: ancestors.length ? linkEntityExpansionEntries(ancestors) : undefined,
},
});
@@ -43,6 +49,20 @@ export class UmbDuplicateDocumentEntityAction extends UmbEntityActionBase<never>
this.#reloadMenu(destinationUnique);
}
async #requestAncestors() {
try {
const treeRepository = new UmbDocumentTreeRepository(this);
const { data } = await treeRepository.requestTreeItemAncestors({
treeItem: { unique: this.args.unique!, entityType: this.args.entityType! },
});
// Exclude self — the API returns the descendant as part of the ancestors list, but we only want to expand its parents.
return data?.filter((item) => item.unique !== this.args.unique) ?? [];
} catch {
// Tree pre-expansion is a UX convenience — if it fails the modal still opens normally.
return [];
}
}
async #getSelectableFilterByDocumentUnique(documentUnique: string) {
// 1. Get the document to find its type
const itemRepository = new UmbDocumentItemRepository(this);
@@ -21,6 +21,10 @@ export class UmbDocumentDuplicateToModalElement extends UmbModalBaseElement<
@state()
private _destinationUnique?: string | null;
private get _treeExpansion() {
return this.data?.treeExpansion ?? [];
}
#onTreeSelectionChange(event: UmbSelectionChangeEvent) {
const target = event.target as UmbTreeElement;
const selection = target.getSelection();
@@ -58,11 +62,14 @@ export class UmbDocumentDuplicateToModalElement extends UmbModalBaseElement<
expandTreeRoot: true,
hideTreeItemActions: true,
selectableFilter: this.#selectableFilter,
expansion: this._treeExpansion,
}}
@selection-change=${this.#onTreeSelectionChange}></umb-tree>
</uui-box>
<uui-box headline=${this.localize.term('general_options')}>
<umb-property-layout label=${this.localize.term('defaultdialogs_relateToOriginalLabel')} orientation="vertical"
<umb-property-layout
label=${this.localize.term('defaultdialogs_relateToOriginalLabel')}
orientation="vertical"
><div slot="editor">
<uui-toggle
@change=${this.#onRelateToOriginalChange}
@@ -85,7 +92,10 @@ export class UmbDocumentDuplicateToModalElement extends UmbModalBaseElement<
#renderActions() {
return html`
<uui-button slot="actions" label=${this.localize.term('general_cancel')} @click="${this._rejectModal}"></uui-button>
<uui-button
slot="actions"
label=${this.localize.term('general_cancel')}
@click="${this._rejectModal}"></uui-button>
<uui-button
slot="actions"
color="positive"

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