Compare commits
157
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28fa43c371 | ||
|
|
0ded9f1cbf | ||
|
|
a41bb7145a | ||
|
|
925d6bc430 | ||
|
|
acfaf23e43 | ||
|
|
7c1b907410 | ||
|
|
564ca0384b | ||
|
|
9f633416b1 | ||
|
|
91381604dd | ||
|
|
c566dd0a71 | ||
|
|
ea78147657 | ||
|
|
0a5189e54a | ||
|
|
102e4aa80b | ||
|
|
0bec947b8b | ||
|
|
d0e7ef0169 | ||
|
|
e2cf205d34 | ||
|
|
bf0270b244 | ||
|
|
dc77f37129 | ||
|
|
dbecec3451 | ||
|
|
d92e6bbeff | ||
|
|
e56ddcc3f9 | ||
|
|
fb8c3b19ce | ||
|
|
7a7aadffe5 | ||
|
|
0ac6e8500a | ||
|
|
4c35c0c2e9 | ||
|
|
de47e0b1f7 | ||
|
|
969ae87798 | ||
|
|
fe3318ef79 | ||
|
|
828e359666 | ||
|
|
a5b7e0dac1 | ||
|
|
86abc3528d | ||
|
|
7433641348 | ||
|
|
c45b12ec58 | ||
|
|
22c4bc7835 | ||
|
|
1e82376420 | ||
|
|
764d4eb1d7 | ||
|
|
aa854da3f4 | ||
|
|
28cdbe5317 | ||
|
|
3dbd4baefe | ||
|
|
fa5dd209c1 | ||
|
|
4cc4acee62 | ||
|
|
d0fc7dc8a0 | ||
|
|
62663d9573 | ||
|
|
b582f9d2ef | ||
|
|
ad90db8b38 | ||
|
|
8e3b821a55 | ||
|
|
8ac989c4e3 | ||
|
|
89baa9482b | ||
|
|
3913a61b74 | ||
|
|
90bedcd42e | ||
|
|
c54189aa90 | ||
|
|
88ec0a248f | ||
|
|
3e22733081 | ||
|
|
232077e820 | ||
|
|
943d1eeccd | ||
|
|
b3666dad8b | ||
|
|
28a403361e | ||
|
|
38d73b3a41 | ||
|
|
5c0cb154f5 | ||
|
|
4a621a13bc | ||
|
|
bc2048573c | ||
|
|
424209ac06 | ||
|
|
5cf577bf57 | ||
|
|
ed4b207fe7 | ||
|
|
40e027d0ea | ||
|
|
a6f6bdf8bc | ||
|
|
8e6a791de0 | ||
|
|
5ffea3152b | ||
|
|
2043ff1dbd | ||
|
|
f1bc1db6ce | ||
|
|
c4d5b89fc5 | ||
|
|
7597a8ad40 | ||
|
|
d28507e2e5 | ||
|
|
7b75324172 | ||
|
|
172a3be5ac | ||
|
|
ca28195b7c | ||
|
|
4c1fde9e0c | ||
|
|
f0013330e6 | ||
|
|
bd2c985187 | ||
|
|
9711a5d012 | ||
|
|
808cba2747 | ||
|
|
da0117f240 | ||
|
|
61d3e4c53d | ||
|
|
51d70877d1 | ||
|
|
2dcfe68208 | ||
|
|
ce06c4ba4d | ||
|
|
a91de6e677 | ||
|
|
faf3824a0a | ||
|
|
58ed9899be | ||
|
|
bc7bd9a32a | ||
|
|
8160ede4b6 | ||
|
|
e06a583f1a | ||
|
|
61cc37ad1d | ||
|
|
dc7b34eb58 | ||
|
|
dfe98ffa3b | ||
|
|
6b8e8935fd | ||
|
|
53c74efd35 | ||
|
|
1c058a32d9 | ||
|
|
12f838277c | ||
|
|
1ae2a780dd | ||
|
|
04f0e229c7 | ||
|
|
5d76706553 | ||
|
|
1fdcb835bc | ||
|
|
6b3bdb59b7 | ||
|
|
65ab1c0b2b | ||
|
|
a54769758b | ||
|
|
1616997409 | ||
|
|
609b74b475 | ||
|
|
63289e22cb | ||
|
|
82b2991a18 | ||
|
|
721cf53d40 | ||
|
|
f8ba3d8cfc | ||
|
|
7f832d261d | ||
|
|
f79e9586b4 | ||
|
|
c74a58246f | ||
|
|
06b15157cf | ||
|
|
82f7830d26 | ||
|
|
b87d519bf2 | ||
|
|
8aaac65f83 | ||
|
|
8d5826c61f | ||
|
|
5a73f63cfd | ||
|
|
e463cd3a0c | ||
|
|
2901be793a | ||
|
|
f255fd7bff | ||
|
|
7527de7c56 | ||
|
|
df12a3e467 | ||
|
|
0b86312f52 | ||
|
|
f4592111fa | ||
|
|
4c909d8ce8 | ||
|
|
12c699d5bd | ||
|
|
ba29b91301 | ||
|
|
336bffe4c4 | ||
|
|
426e516c61 | ||
|
|
c718a3ce12 | ||
|
|
1637d9b158 | ||
|
|
7737cd3d40 | ||
|
|
139ac6ad72 | ||
|
|
cd4521bd77 | ||
|
|
80e2764eda | ||
|
|
d9bb17de2a | ||
|
|
72fdf281fd | ||
|
|
b24c9ba8ac | ||
|
|
2377e9a555 | ||
|
|
f964a18b5b | ||
|
|
2369f00544 | ||
|
|
0f438c551c | ||
|
|
d822518dd3 | ||
|
|
21ab470d88 | ||
|
|
4921ab9257 | ||
|
|
63bae5958a | ||
|
|
35d726ad31 | ||
|
|
fc9ca861b0 | ||
|
|
68194e1a27 | ||
|
|
1c1787c445 | ||
|
|
3714ebbb29 | ||
|
|
bf32f9e5a6 | ||
|
|
3220739faa |
@@ -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.
|
||||
@@ -435,6 +435,14 @@ When a PR changes Management API controllers or models, the `OpenApi.json` file
|
||||
|
||||
The backoffice is published to npm as `@umbraco-cms/backoffice`. Runtime dependencies are provided via importmap; npm peerDependencies provide types only. For full details on dependency hoisting, version range logic, and plugin development, see `/src/Umbraco.Web.UI.Client/CLAUDE.md` → "npm Package Publishing".
|
||||
|
||||
### SQL Server 2100-parameter limit
|
||||
|
||||
Any `WHERE IN (@0, @1, ...)` built from a runtime-sized collection risks hitting SQL Server's 2100-parameter ceiling and throwing `SqlException` 8003 in production.
|
||||
|
||||
Batch with `IEnumerable<T>.InGroupsOf(Constants.Sql.MaxParameterCount)` or `Database.FetchByGroups(...)` whenever the collection size is driven by user data — not just when it currently fits. Watch for products of two scaling dimensions (documents × languages, properties × versions) and config-tunable batch sizes whose defaults are safe but ceilings aren't.
|
||||
|
||||
Full guidance, safe patterns and decision rule: see `/src/Umbraco.Infrastructure/CLAUDE.md` → "Avoiding the SQL Server 2100-parameter limit".
|
||||
|
||||
### Known Limitations
|
||||
|
||||
1. **Circular Dependencies**: Avoided via `Lazy<T>` or event notifications
|
||||
@@ -531,6 +539,16 @@ Allowed, but cheap to write and cheaper to leave behind. Keep them short and tra
|
||||
|
||||
---
|
||||
|
||||
## 9. Testing Practices
|
||||
|
||||
### Tests for a bug fix must fail before the fix
|
||||
|
||||
Verify any test you add for a bug fix actually catches the bug: either write the failing test first (TDD), or temporarily revert the production change and confirm the test fails before re-applying. A test that passes both ways proves nothing. Watch for coincidental passes — default seed/sort orders can make a buggy path produce the right answer for the test's specific inputs; construct inputs so the broken and fixed behaviours give visibly different results.
|
||||
|
||||
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
|
||||
|
||||
### Essential Commands
|
||||
|
||||
@@ -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>
|
||||
|
||||
+32
-98
@@ -825,74 +825,31 @@ stages:
|
||||
publishFeedCredentials: "MyGet - Umbraco Nightly"
|
||||
${{ else }}:
|
||||
publishFeedCredentials: "MyGet - Pre-releases"
|
||||
# Pre-release/nightly feeds: keep the `latest` dist-tag default (no `next` split).
|
||||
- job:
|
||||
displayName: Push to pre-release feed (npm)
|
||||
steps:
|
||||
- checkout: none
|
||||
- download: current
|
||||
artifact: npm
|
||||
- bash: |
|
||||
# Check if we are on a nightly build
|
||||
if [ $isNightly = "False" ]; then
|
||||
echo "##[debug]Prerelease build detected"
|
||||
registry="https://www.myget.org/F/umbracoprereleases/npm/"
|
||||
else
|
||||
echo "##[debug]Nightly build detected"
|
||||
registry="https://www.myget.org/F/umbraconightly/npm/"
|
||||
fi
|
||||
echo "@umbraco-cms:registry=$registry" >> .npmrc
|
||||
env:
|
||||
isNightly: ${{parameters.isNightly}}
|
||||
workingDirectory: $(Pipeline.Workspace)/npm
|
||||
displayName: Add scoped registry to .npmrc
|
||||
- task: npmAuthenticate@0
|
||||
displayName: Authenticate with npm (MyGet)
|
||||
inputs:
|
||||
workingFile: "$(Pipeline.Workspace)/npm/.npmrc"
|
||||
- template: templates/npm-publish.yml
|
||||
parameters:
|
||||
artifactName: npm
|
||||
customEndpoint: "MyGet (npm) - Umbracoprereleases, MyGet (npm) - Umbraconightly"
|
||||
- bash: |
|
||||
# Setup temp npm project to load in defaults from the local .npmrc
|
||||
npm init -y
|
||||
|
||||
# Find the first .tgz file in the current directory and publish it
|
||||
files=( ./*.tgz )
|
||||
npm publish "${files[0]}"
|
||||
displayName: Push to npm (MyGet)
|
||||
workingDirectory: $(Pipeline.Workspace)/npm
|
||||
displayName: Push to npm (MyGet)
|
||||
${{ if eq(parameters.isNightly, true) }}:
|
||||
registry: https://www.myget.org/F/umbraconightly/npm/
|
||||
${{ else }}:
|
||||
registry: https://www.myget.org/F/umbracoprereleases/npm/
|
||||
- job: PublishTestHelpersNpm
|
||||
displayName: Push TestHelpers to pre-release feed (npm)
|
||||
steps:
|
||||
- checkout: none
|
||||
- download: current
|
||||
artifact: npm-testhelpers
|
||||
- bash: |
|
||||
# Check if we are on a nightly build
|
||||
if [ $isNightly = "False" ]; then
|
||||
echo "##[debug]Prerelease build detected"
|
||||
registry="https://www.myget.org/F/umbracoprereleases/npm/"
|
||||
else
|
||||
echo "##[debug]Nightly build detected"
|
||||
registry="https://www.myget.org/F/umbraconightly/npm/"
|
||||
fi
|
||||
echo "@umbraco-cms:registry=$registry" >> .npmrc
|
||||
env:
|
||||
isNightly: ${{parameters.isNightly}}
|
||||
workingDirectory: $(Pipeline.Workspace)/npm-testhelpers
|
||||
displayName: Add scoped registry to .npmrc
|
||||
- task: npmAuthenticate@0
|
||||
displayName: Authenticate with npm (MyGet)
|
||||
inputs:
|
||||
workingFile: "$(Pipeline.Workspace)/npm-testhelpers/.npmrc"
|
||||
- template: templates/npm-publish.yml
|
||||
parameters:
|
||||
artifactName: npm-testhelpers
|
||||
customEndpoint: "MyGet (npm) - Umbracoprereleases, MyGet (npm) - Umbraconightly"
|
||||
- bash: |
|
||||
# Setup temp npm project to load in defaults from the local .npmrc
|
||||
npm init -y
|
||||
|
||||
# Find the first .tgz file in the current directory and publish it
|
||||
files=( ./*.tgz )
|
||||
npm publish "${files[0]}"
|
||||
displayName: Push test helpers to npm (MyGet)
|
||||
workingDirectory: $(Pipeline.Workspace)/npm-testhelpers
|
||||
displayName: Push test helpers to npm (MyGet)
|
||||
${{ if eq(parameters.isNightly, true) }}:
|
||||
registry: https://www.myget.org/F/umbraconightly/npm/
|
||||
${{ else }}:
|
||||
registry: https://www.myget.org/F/umbracoprereleases/npm/
|
||||
|
||||
- stage: Deploy_NuGet
|
||||
displayName: NuGet release
|
||||
@@ -941,53 +898,30 @@ stages:
|
||||
condition: and(in(dependencies.Deploy_NuGet.result, 'Succeeded', 'SucceededWithIssues'), or(eq(dependencies.Build.outputs['A.build.NBGV_PublicRelease'], 'True'), ${{parameters.npmDeploy}}))
|
||||
dependsOn:
|
||||
- Deploy_NuGet
|
||||
variables:
|
||||
# `latest` for stable releases, `next` for prereleases.
|
||||
npmDistTag: $[ iif(eq(stageDependencies.Build.A.outputs['build.NBGV_PrereleaseVersionNoLeadingHyphen'], ''), 'latest', 'next') ]
|
||||
jobs:
|
||||
- job: Publish
|
||||
displayName: Push to NPM
|
||||
steps:
|
||||
- checkout: none
|
||||
- download: current
|
||||
artifact: npm
|
||||
- bash: echo "@umbraco-cms:registry=https://registry.npmjs.org" >> .npmrc
|
||||
workingDirectory: $(Pipeline.Workspace)/npm
|
||||
displayName: Add scoped registry to .npmrc
|
||||
- task: npmAuthenticate@0
|
||||
displayName: Authenticate with npm
|
||||
inputs:
|
||||
workingFile: $(Pipeline.Workspace)/npm/.npmrc
|
||||
- template: templates/npm-publish.yml
|
||||
parameters:
|
||||
artifactName: npm
|
||||
registry: https://registry.npmjs.org/
|
||||
customEndpoint: "NPM - Umbraco Backoffice"
|
||||
- script: |
|
||||
# Setup temp npm project to load in defaults from the local .npmrc
|
||||
npm init -y
|
||||
|
||||
# Find the first .tgz file in the current directory and publish it
|
||||
files=( ./*.tgz )
|
||||
npm publish "${files[0]}"
|
||||
displayName: Push to npm
|
||||
workingDirectory: $(Pipeline.Workspace)/npm
|
||||
displayName: Push to npm
|
||||
npmTag: $(npmDistTag)
|
||||
- job: PublishTestHelpers
|
||||
displayName: Push Test Helpers to NPM
|
||||
steps:
|
||||
- checkout: none
|
||||
- download: current
|
||||
artifact: npm-testhelpers
|
||||
- bash: echo "@umbraco-cms:registry=https://registry.npmjs.org" >> .npmrc
|
||||
workingDirectory: $(Pipeline.Workspace)/npm-testhelpers
|
||||
displayName: Add scoped registry to .npmrc
|
||||
- task: npmAuthenticate@0
|
||||
displayName: Authenticate with npm
|
||||
inputs:
|
||||
workingFile: $(Pipeline.Workspace)/npm-testhelpers/.npmrc
|
||||
- template: templates/npm-publish.yml
|
||||
parameters:
|
||||
artifactName: npm-testhelpers
|
||||
registry: https://registry.npmjs.org/
|
||||
customEndpoint: "NPM - Umbraco Backoffice"
|
||||
- script: |
|
||||
# Setup temp npm project to load in defaults from the local .npmrc
|
||||
npm init -y
|
||||
|
||||
# Find the first .tgz file in the current directory and publish it
|
||||
files=( ./*.tgz )
|
||||
npm publish "${files[0]}"
|
||||
displayName: Push test helpers to npm
|
||||
workingDirectory: $(Pipeline.Workspace)/npm-testhelpers
|
||||
displayName: Push test helpers to npm
|
||||
npmTag: $(npmDistTag)
|
||||
|
||||
- stage: Upload_API_Docs
|
||||
pool:
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
parameters:
|
||||
- name: artifactName # "npm" or "npm-testhelpers"
|
||||
type: string
|
||||
- name: registry # scoped-registry URL to publish to
|
||||
type: string
|
||||
- name: customEndpoint # npmAuthenticate service connection(s)
|
||||
type: string
|
||||
- name: displayName # label for the publish step
|
||||
type: string
|
||||
- name: npmTag # dist-tag to publish under
|
||||
type: string
|
||||
default: latest
|
||||
|
||||
steps:
|
||||
- checkout: none
|
||||
- download: current
|
||||
artifact: ${{ parameters.artifactName }}
|
||||
- script: npm config set @umbraco-cms:registry ${{ parameters.registry }} --location=project
|
||||
displayName: Add scoped registry to .npmrc
|
||||
workingDirectory: $(Pipeline.Workspace)/${{ parameters.artifactName }}
|
||||
- task: npmAuthenticate@0
|
||||
displayName: Authenticate with npm
|
||||
inputs:
|
||||
workingFile: $(Pipeline.Workspace)/${{ parameters.artifactName }}/.npmrc
|
||||
customEndpoint: ${{ parameters.customEndpoint }}
|
||||
- script: npm publish *.tgz --tag ${{ parameters.npmTag }}
|
||||
displayName: ${{ parameters.displayName }}
|
||||
workingDirectory: $(Pipeline.Workspace)/${{ parameters.artifactName }}
|
||||
@@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
using Umbraco.Cms.Api.Common.DependencyInjection;
|
||||
using Umbraco.Cms.Api.Delivery.Accessors;
|
||||
@@ -162,6 +163,12 @@ public static class UmbracoBuilderExtensions
|
||||
builder.Services.AddUnique<IDeliveryApiOutputCacheRequestFilter, DefaultDeliveryApiOutputCacheRequestFilter>();
|
||||
builder.Services.AddUnique<IDeliveryApiOutputCacheManager, DeliveryApiOutputCacheManager>();
|
||||
|
||||
// Signal that Umbraco has enabled output caching so the application builder registers
|
||||
// the output cache middleware. Gated via a marker rather than IOutputCacheStore so that
|
||||
// applications calling services.AddOutputCache(...) for their own purposes are not
|
||||
// affected by Umbraco's automatic middleware registration.
|
||||
builder.Services.TryAddSingleton<IUmbracoManagedOutputCacheMarker, UmbracoManagedOutputCacheMarker>();
|
||||
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
|
||||
+6
-3
@@ -53,11 +53,14 @@ public class SearchDataTypeItemController : DatatypeItemControllerBase
|
||||
return Ok(new PagedModel<DataTypeItemResponseModel> { Total = searchResult.Total });
|
||||
}
|
||||
|
||||
IEnumerable<IDataType> dataTypes = await _dataTypeService.GetAllAsync(searchResult.Items.Select(item => item.Key).ToArray());
|
||||
Guid[] keys = searchResult.Items.Select(x => x.Key).ToArray();
|
||||
IEnumerable<IDataType> dataTypes = await _dataTypeService.GetAllAsync(keys);
|
||||
IEnumerable<IDataType> orderedDataTypes = OrderByRequestedIds(dataTypes, keys);
|
||||
|
||||
var result = new PagedModel<DataTypeItemResponseModel>
|
||||
{
|
||||
Items = _mapper.MapEnumerable<IDataType, DataTypeItemResponseModel>(dataTypes),
|
||||
Total = searchResult.Total
|
||||
Items = _mapper.MapEnumerable<IDataType, DataTypeItemResponseModel>(orderedDataTypes),
|
||||
Total = searchResult.Total,
|
||||
};
|
||||
|
||||
return Ok(result);
|
||||
|
||||
+100
@@ -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);
|
||||
}
|
||||
}
|
||||
+102
@@ -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);
|
||||
}
|
||||
}
|
||||
+6
-3
@@ -54,11 +54,14 @@ public class SearchMediaTypeItemController : MediaTypeItemControllerBase
|
||||
return Task.FromResult<IActionResult>(Ok(new PagedModel<MediaTypeItemResponseModel> { Total = searchResult.Total }));
|
||||
}
|
||||
|
||||
IEnumerable<IMediaType> mediaTypes = _mediaTypeService.GetMany(searchResult.Items.Select(item => item.Key).ToArray().EmptyNull());
|
||||
Guid[] keys = searchResult.Items.Select(item => item.Key).ToArray();
|
||||
IEnumerable<IMediaType> mediaTypes = _mediaTypeService.GetMany(keys.EmptyNull());
|
||||
IEnumerable<IMediaType> orderedMediaTypes = OrderByRequestedIds(mediaTypes, keys);
|
||||
|
||||
var result = new PagedModel<MediaTypeItemResponseModel>
|
||||
{
|
||||
Items = _mapper.MapEnumerable<IMediaType, MediaTypeItemResponseModel>(mediaTypes),
|
||||
Total = searchResult.Total
|
||||
Items = _mapper.MapEnumerable<IMediaType, MediaTypeItemResponseModel>(orderedMediaTypes),
|
||||
Total = searchResult.Total,
|
||||
};
|
||||
|
||||
return Task.FromResult<IActionResult>(Ok(result));
|
||||
|
||||
+14
-3
@@ -32,6 +32,14 @@ public class SearchMemberTypeItemController : MemberTypeItemControllerBase
|
||||
_mapper = mapper;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Searches for member type items matching the specified query, with support for pagination.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
|
||||
/// <param name="query">The search query used to filter member type items.</param>
|
||||
/// <param name="skip">The number of items to skip before starting to collect the result set (used for pagination).</param>
|
||||
/// <param name="take">The maximum number of items to return in the result set (used for pagination).</param>
|
||||
/// <returns>A task representing the asynchronous operation. The task result contains an <see cref="IActionResult"/> with a <see cref="PagedModel{MemberTypeItemResponseModel}"/> containing the search results.</returns>
|
||||
[HttpGet("search")]
|
||||
[MapToApiVersion("1.0")]
|
||||
[ProducesResponseType(typeof(PagedModel<MemberTypeItemResponseModel>), StatusCodes.Status200OK)]
|
||||
@@ -45,11 +53,14 @@ public class SearchMemberTypeItemController : MemberTypeItemControllerBase
|
||||
return Task.FromResult<IActionResult>(Ok(new PagedModel<MemberTypeItemResponseModel> { Total = searchResult.Total }));
|
||||
}
|
||||
|
||||
IEnumerable<IMemberType> memberTypes = _memberTypeService.GetMany(searchResult.Items.Select(item => item.Key).ToArray());
|
||||
Guid[] keys = searchResult.Items.Select(item => item.Key).ToArray();
|
||||
IEnumerable<IMemberType> memberTypes = _memberTypeService.GetMany(keys);
|
||||
IEnumerable<IMemberType> orderedMemberTypes = OrderByRequestedIds(memberTypes, keys);
|
||||
|
||||
var result = new PagedModel<MemberTypeItemResponseModel>
|
||||
{
|
||||
Items = _mapper.MapEnumerable<IMemberType, MemberTypeItemResponseModel>(memberTypes),
|
||||
Total = searchResult.Total
|
||||
Items = _mapper.MapEnumerable<IMemberType, MemberTypeItemResponseModel>(orderedMemberTypes),
|
||||
Total = searchResult.Total,
|
||||
};
|
||||
|
||||
return Task.FromResult<IActionResult>(Ok(result));
|
||||
|
||||
+16
-45
@@ -8,67 +8,38 @@ using Umbraco.Cms.Core.Security;
|
||||
namespace Umbraco.Cms.Api.Management.Controllers.RedirectUrlManagement;
|
||||
|
||||
/// <summary>
|
||||
/// Controller for setting the redirect URL tracking status.
|
||||
/// Controller for setting the redirect URL tracking status. Retained for backwards compatibility only;
|
||||
/// the endpoint no longer modifies any configuration.
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Obsolete("This controller is deprecated and no longer modifies the configuration. Set the Umbraco:CMS:WebRouting:DisableRedirectUrlTracking configuration key instead. Scheduled for removal in Umbraco 19.")]
|
||||
public class SetStatusRedirectUrlManagementController : RedirectUrlManagementControllerBase
|
||||
{
|
||||
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
|
||||
private readonly IConfigManipulator _configManipulator;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SetStatusRedirectUrlManagementController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="backOfficeSecurityAccessor">The back office security accessor.</param>
|
||||
/// <param name="configManipulator">The configuration manipulator.</param>
|
||||
/// <param name="backOfficeSecurityAccessor">Ignored. Retained for binary compatibility.</param>
|
||||
/// <param name="configManipulator">Ignored. Retained for binary compatibility.</param>
|
||||
public SetStatusRedirectUrlManagementController(
|
||||
#pragma warning disable IDE0060 // Remove unused parameter
|
||||
IBackOfficeSecurityAccessor backOfficeSecurityAccessor,
|
||||
IConfigManipulator configManipulator)
|
||||
#pragma warning restore IDE0060 // Remove unused parameter
|
||||
{
|
||||
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
|
||||
_configManipulator = configManipulator;
|
||||
}
|
||||
|
||||
// TODO: Consider if we should even allow this, or only allow using the appsettings
|
||||
// We generally don't want to edit the appsettings from our code.
|
||||
// But maybe there is a valid use case for doing it on the fly.
|
||||
/// <summary>
|
||||
/// Sets the redirect URL tracking status.
|
||||
/// Deprecated. Returns an OK response without modifying any configuration. To toggle redirect URL tracking,
|
||||
/// set the <c>Umbraco:CMS:WebRouting:DisableRedirectUrlTracking</c> configuration key instead.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The cancellation token for the HTTP request.</param>
|
||||
/// <param name="status">The redirect status to set.</param>
|
||||
/// <returns>An OK result if successful.</returns>
|
||||
/// <param name="status">The redirect status (ignored).</param>
|
||||
/// <returns>An OK result.</returns>
|
||||
[HttpPost("status")]
|
||||
[EndpointSummary("Sets the redirect URL tracking status.")]
|
||||
[EndpointDescription("Updates the redirect URL tracking configuration according to the provided status.")]
|
||||
[EndpointSummary("Deprecated. No longer changes the redirect URL tracking status.")]
|
||||
[EndpointDescription("This endpoint is deprecated and no longer modifies the configuration. To toggle redirect URL tracking, set the Umbraco:CMS:WebRouting:DisableRedirectUrlTracking configuration key instead.")]
|
||||
[MapToApiVersion("1.0")]
|
||||
public async Task<IActionResult> SetStatus(CancellationToken cancellationToken, [FromQuery] RedirectStatus status)
|
||||
{
|
||||
// TODO: uncomment this when auth is implemented.
|
||||
// var userIsAdmin = _backOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.IsAdmin();
|
||||
// if (userIsAdmin is null or false)
|
||||
// {
|
||||
// return Unauthorized();
|
||||
// }
|
||||
|
||||
var enable = status switch
|
||||
{
|
||||
RedirectStatus.Enabled => true,
|
||||
RedirectStatus.Disabled => false,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(status), status, "Unknown redirect status")
|
||||
};
|
||||
|
||||
// For now I'm not gonna change this to limit breaking, but it's weird to have a "disabled" switch,
|
||||
// since you're essentially negating the boolean from the get go,
|
||||
// it's much easier to reason with enabled = false == disabled.
|
||||
await _configManipulator.SaveDisableRedirectUrlTrackingAsync(!enable);
|
||||
|
||||
// Taken from the existing implementation in RedirectUrlManagementController
|
||||
// TODO this is ridiculous, but we need to ensure the configuration is reloaded, before this request is ended.
|
||||
// otherwise we can read the old value in GetEnableState.
|
||||
// The value is equal to JsonConfigurationSource.ReloadDelay
|
||||
Thread.Sleep(250);
|
||||
|
||||
return Ok();
|
||||
}
|
||||
[Obsolete("This endpoint is deprecated and no longer modifies the configuration. Set the Umbraco:CMS:WebRouting:DisableRedirectUrlTracking configuration key instead. Scheduled for removal in Umbraco 19.")]
|
||||
public Task<IActionResult> SetStatus(CancellationToken cancellationToken, [FromQuery] RedirectStatus status)
|
||||
=> Task.FromResult<IActionResult>(Ok());
|
||||
}
|
||||
|
||||
+14
-3
@@ -32,6 +32,14 @@ public class SearchTemplateItemController : TemplateItemControllerBase
|
||||
_mapper = mapper;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Searches for template items matching the specified query, with support for pagination.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
|
||||
/// <param name="query">The search query used to filter template items.</param>
|
||||
/// <param name="skip">The number of items to skip before starting to collect the result set (used for pagination).</param>
|
||||
/// <param name="take">The maximum number of items to return in the result set (used for pagination).</param>
|
||||
/// <returns>A task representing the asynchronous operation. The task result contains an <see cref="IActionResult"/> with a <see cref="PagedModel{TemplateItemResponseModel}"/> containing the search results.</returns>
|
||||
[HttpGet("search")]
|
||||
[MapToApiVersion("1.0")]
|
||||
[ProducesResponseType(typeof(PagedModel<TemplateItemResponseModel>), StatusCodes.Status200OK)]
|
||||
@@ -45,11 +53,14 @@ public class SearchTemplateItemController : TemplateItemControllerBase
|
||||
return Ok(new PagedModel<TemplateItemResponseModel> { Total = searchResult.Total });
|
||||
}
|
||||
|
||||
IEnumerable<ITemplate> templates = await _templateService.GetAllAsync(searchResult.Items.Select(item => item.Key).ToArray());
|
||||
Guid[] keys = searchResult.Items.Select(x => x.Key).ToArray();
|
||||
IEnumerable<ITemplate> templates = await _templateService.GetAllAsync(keys);
|
||||
IEnumerable<ITemplate> orderedTemplates = OrderByRequestedIds(templates, keys);
|
||||
|
||||
var result = new PagedModel<TemplateItemResponseModel>
|
||||
{
|
||||
Items = _mapper.MapEnumerable<ITemplate, TemplateItemResponseModel>(templates),
|
||||
Total = searchResult.Total
|
||||
Items = _mapper.MapEnumerable<ITemplate, TemplateItemResponseModel>(orderedTemplates),
|
||||
Total = searchResult.Total,
|
||||
};
|
||||
|
||||
return Ok(result);
|
||||
|
||||
@@ -5,6 +5,7 @@ using Umbraco.Cms.Core.DependencyInjection;
|
||||
using Umbraco.Cms.Core.Hosting;
|
||||
using Umbraco.Cms.Core.IO;
|
||||
using Umbraco.Cms.Web.Common.Hosting;
|
||||
using Umbraco.Cms.Web.Common.Middleware;
|
||||
|
||||
namespace Umbraco.Extensions;
|
||||
|
||||
@@ -68,6 +69,10 @@ public static partial class UmbracoBuilderExtensions
|
||||
builder.Services.AddSingleton<IBackOfficeEnabledMarker, BackOfficeEnabledMarker>();
|
||||
|
||||
builder.Services.AddUnique<IBackOfficePathGenerator, UmbracoBackOfficePathGenerator>();
|
||||
// Registered here rather than in AddWebComponents because the middleware depends on
|
||||
// IBackOfficePathGenerator (registered just above). DI scope validation would otherwise
|
||||
// fail in Delivery-only/Website-only bootstraps that never call AddBackOffice().
|
||||
builder.Services.AddSingleton<UmbracoBackOfficeCacheHeadersMiddleware>();
|
||||
builder.Services.AddUnique<IPhysicalFileSystem>(factory =>
|
||||
{
|
||||
var path = "~/";
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Umbraco.Cms.Api.Management.ViewModels.DataType;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.DependencyInjection;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.PropertyEditors;
|
||||
using Umbraco.Cms.Core.Serialization;
|
||||
@@ -16,6 +19,7 @@ public class DataTypePresentationFactory : IDataTypePresentationFactory
|
||||
private readonly IDataValueEditorFactory _dataValueEditorFactory;
|
||||
private readonly IConfigurationEditorJsonSerializer _configurationEditorJsonSerializer;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<DataTypePresentationFactory> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DataTypePresentationFactory"/> class, which is responsible for creating data type presentation models.
|
||||
@@ -25,18 +29,46 @@ public class DataTypePresentationFactory : IDataTypePresentationFactory
|
||||
/// <param name="dataValueEditorFactory">Factory for creating data value editors.</param>
|
||||
/// <param name="configurationEditorJsonSerializer">Serializer for configuration editor JSON data.</param>
|
||||
/// <param name="timeProvider">Provides the current time for time-dependent operations.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
public DataTypePresentationFactory(
|
||||
IDataTypeContainerService dataTypeContainerService,
|
||||
PropertyEditorCollection propertyEditorCollection,
|
||||
IDataValueEditorFactory dataValueEditorFactory,
|
||||
IConfigurationEditorJsonSerializer configurationEditorJsonSerializer,
|
||||
TimeProvider timeProvider)
|
||||
TimeProvider timeProvider,
|
||||
ILogger<DataTypePresentationFactory> logger)
|
||||
{
|
||||
_dataTypeContainerService = dataTypeContainerService;
|
||||
_propertyEditorCollection = propertyEditorCollection;
|
||||
_dataValueEditorFactory = dataValueEditorFactory;
|
||||
_configurationEditorJsonSerializer = configurationEditorJsonSerializer;
|
||||
_timeProvider = timeProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DataTypePresentationFactory"/> class, which is responsible for creating data type presentation models.
|
||||
/// </summary>
|
||||
/// <param name="dataTypeContainerService">Service used to manage data type containers.</param>
|
||||
/// <param name="propertyEditorCollection">A collection containing all available property editors.</param>
|
||||
/// <param name="dataValueEditorFactory">Factory for creating data value editors.</param>
|
||||
/// <param name="configurationEditorJsonSerializer">Serializer for configuration editor JSON data.</param>
|
||||
/// <param name="timeProvider">Provides the current time for time-dependent operations.</param>
|
||||
[Obsolete("Please use the constructor that takes all parameters. Scheduled for removal in Umbraco 19.")]
|
||||
public DataTypePresentationFactory(
|
||||
IDataTypeContainerService dataTypeContainerService,
|
||||
PropertyEditorCollection propertyEditorCollection,
|
||||
IDataValueEditorFactory dataValueEditorFactory,
|
||||
IConfigurationEditorJsonSerializer configurationEditorJsonSerializer,
|
||||
TimeProvider timeProvider)
|
||||
: this(
|
||||
dataTypeContainerService,
|
||||
propertyEditorCollection,
|
||||
dataValueEditorFactory,
|
||||
configurationEditorJsonSerializer,
|
||||
timeProvider,
|
||||
StaticServiceProvider.Instance.GetRequiredService<ILogger<DataTypePresentationFactory>>())
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -72,7 +104,6 @@ public class DataTypePresentationFactory : IDataTypePresentationFactory
|
||||
dataType.Key = requestModel.Id.Value;
|
||||
}
|
||||
|
||||
|
||||
return Attempt.SucceedWithStatus<IDataType, DataTypeOperationStatus>(DataTypeOperationStatus.Success, dataType);
|
||||
}
|
||||
|
||||
@@ -82,7 +113,7 @@ public class DataTypePresentationFactory : IDataTypePresentationFactory
|
||||
{
|
||||
try
|
||||
{
|
||||
var parent = await _dataTypeContainerService.GetAsync(requestModel.Parent.Id);
|
||||
EntityContainer? parent = await _dataTypeContainerService.GetAsync(requestModel.Parent.Id);
|
||||
|
||||
return parent is null
|
||||
? Attempt.FailWithStatus(DataTypeOperationStatus.ParentNotFound, 0)
|
||||
@@ -97,6 +128,7 @@ public class DataTypePresentationFactory : IDataTypePresentationFactory
|
||||
return Attempt.SucceedWithStatus(DataTypeOperationStatus.Success, Constants.System.Root);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<Attempt<IDataType, DataTypeOperationStatus>> CreateAsync(UpdateDataTypeRequestModel requestModel, IDataType current)
|
||||
{
|
||||
if (!_propertyEditorCollection.TryGet(requestModel.EditorAlias, out IDataEditor? editor))
|
||||
@@ -104,7 +136,7 @@ public class DataTypePresentationFactory : IDataTypePresentationFactory
|
||||
return Task.FromResult(Attempt.FailWithStatus<IDataType, DataTypeOperationStatus>(DataTypeOperationStatus.PropertyEditorNotFound, new DataType(new VoidEditor(_dataValueEditorFactory), _configurationEditorJsonSerializer) ));
|
||||
}
|
||||
|
||||
IDataType dataType = (IDataType)current.DeepClone();
|
||||
var dataType = (IDataType)current.DeepClone();
|
||||
|
||||
IDictionary<string, object> configurationData = MapConfigurationData(requestModel, editor);
|
||||
dataType.Name = requestModel.Name;
|
||||
@@ -119,12 +151,26 @@ public class DataTypePresentationFactory : IDataTypePresentationFactory
|
||||
|
||||
private ValueStorageType GetEditorValueStorageType(IDataEditor editor, IDictionary<string, object> configurationData)
|
||||
{
|
||||
var configurationObject = editor.GetConfigurationEditor()
|
||||
.ToConfigurationObject(configurationData, _configurationEditorJsonSerializer);
|
||||
|
||||
if (configurationObject is IConfigureValueType configureValueType)
|
||||
// Only editors whose configuration object implements IConfigureValueType derive their storage
|
||||
// type from the configuration. Building the typed configuration object can throw for editors
|
||||
// whose stored configuration doesn't cleanly deserialize into their configuration type; that
|
||||
// must not fail the save, so fall back to the value editor's value type in that case.
|
||||
try
|
||||
{
|
||||
return ValueTypes.ToStorageType(configureValueType.ValueType);
|
||||
if (editor.GetConfigurationEditor().ToConfigurationObject(configurationData, _configurationEditorJsonSerializer)
|
||||
is IConfigureValueType configureValueType)
|
||||
{
|
||||
return ValueTypes.ToStorageType(configureValueType.ValueType);
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Configuration editors are third-party and can throw anything when the stored configuration
|
||||
// doesn't deserialize into their configuration type. Fall back to the value editor's value type
|
||||
// rather than failing the save, but log so the misconfiguration remains observable.
|
||||
_logger.LogError(
|
||||
"Could not build the configuration object for editor {EditorAlias} to determine its value storage type; falling back to the value editor's value type.",
|
||||
editor.Alias);
|
||||
}
|
||||
|
||||
var valueType = editor.GetValueEditor().ValueType;
|
||||
|
||||
+549
-2
@@ -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": [
|
||||
@@ -27860,8 +28362,8 @@
|
||||
"tags": [
|
||||
"Redirect Management"
|
||||
],
|
||||
"summary": "Sets the redirect URL tracking status.",
|
||||
"description": "Updates the redirect URL tracking configuration according to the provided status.",
|
||||
"summary": "Deprecated. No longer changes the redirect URL tracking status.",
|
||||
"description": "This endpoint is deprecated and no longer modifies the configuration. To toggle redirect URL tracking, set the Umbraco:CMS:WebRouting:DisableRedirectUrlTracking configuration key instead.",
|
||||
"operationId": "PostRedirectManagementStatus",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -27907,6 +28409,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"deprecated": true,
|
||||
"security": [
|
||||
{
|
||||
"Backoffice-User": [ ]
|
||||
@@ -39855,6 +40358,14 @@
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ContentSortFieldModel": {
|
||||
"enum": [
|
||||
"Name",
|
||||
"CreateDate",
|
||||
"UpdateDate"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"CopyDataTypeRequestModel": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -50161,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;
|
||||
}
|
||||
}
|
||||
+21
@@ -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; }
|
||||
}
|
||||
+16
@@ -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; }
|
||||
}
|
||||
+9
@@ -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;
|
||||
}
|
||||
}
|
||||
+1
-7
@@ -184,17 +184,11 @@ internal sealed class SqliteEFCoreDistributedLockingMechanism<T> : IDistributedL
|
||||
throw new ArgumentException($"LockObject with id={LockId} does not exist.");
|
||||
}
|
||||
}
|
||||
catch (SqliteException ex) when (IsBusyOrLocked(ex))
|
||||
catch (SqliteException ex) when (ex.IsBusyOrLocked())
|
||||
{
|
||||
throw new DistributedWriteLockTimeoutException(LockId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static bool IsBusyOrLocked(SqliteException ex) =>
|
||||
ex.SqliteErrorCode
|
||||
is raw.SQLITE_BUSY
|
||||
or raw.SQLITE_LOCKED
|
||||
or raw.SQLITE_LOCKED_SHAREDCACHE;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
using SQLitePCL;
|
||||
|
||||
namespace Umbraco.Cms.Persistence.EFCore;
|
||||
|
||||
/// <summary>
|
||||
/// SQLite-specific exception helpers for code running on the EF Core persistence stack.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A parallel helper exists at <c>Umbraco.Cms.Persistence.Sqlite.Services.SqliteExceptionExtensions</c>
|
||||
/// for the NPoco stack. Both stacks are independent (neither references the other) so the small
|
||||
/// duplication is intentional — keeps the layering clean.
|
||||
/// </remarks>
|
||||
public static class SqliteExceptionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Determines if the SQLite exception is a BUSY or LOCKED error.
|
||||
/// </summary>
|
||||
/// <param name="ex">The SQLite exception to check.</param>
|
||||
/// <returns><c>true</c> if the error is BUSY, LOCKED, or LOCKED_SHAREDCACHE; otherwise <c>false</c>.</returns>
|
||||
public static bool IsBusyOrLocked(this SqliteException ex) =>
|
||||
ex.SqliteErrorCode
|
||||
is raw.SQLITE_BUSY
|
||||
or raw.SQLITE_LOCKED
|
||||
or raw.SQLITE_LOCKED_SHAREDCACHE;
|
||||
}
|
||||
@@ -21,7 +21,7 @@
|
||||
var backOfficeAssetsPath = BackOfficePathGenerator.BackOfficeAssetsPath;
|
||||
var loginLogoImageAlternative = Url.RouteUrl(BackOfficeGraphicsController.LoginLogoAlternativeRouteName, new {Version= "1"});
|
||||
}<!doctype html>
|
||||
<html lang="@GlobalSettings.Value.DefaultUILanguage">
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
@@ -61,7 +61,7 @@
|
||||
<p>Here are the <a href="https://www.enable-javascript.com/" target="_blank" rel="noopener" style="text-decoration: underline;">instructions how to enable JavaScript in your web browser</a>.</p>
|
||||
</div>
|
||||
</noscript>
|
||||
<umb-app @(SecuritySettings.Value.KeepUserLoggedIn ? "keep-user-logged-in" : "")></umb-app>
|
||||
<umb-app lang="@GlobalSettings.Value.DefaultUILanguage" @(SecuritySettings.Value.KeepUserLoggedIn ? "keep-user-logged-in" : "")></umb-app>
|
||||
|
||||
@if (isDebug)
|
||||
{
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
}
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="@GlobalSettings.Value.DefaultUILanguage">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<base href="@backOfficePath.EnsureEndsWith('/')" />
|
||||
@@ -83,6 +83,7 @@
|
||||
</noscript>
|
||||
|
||||
<umb-auth
|
||||
lang="@GlobalSettings.Value.DefaultUILanguage"
|
||||
return-url="@backOfficePath"
|
||||
logo-image="@loginLogoImage"
|
||||
logo-image-alternative="@loginLogoImageAlternative"
|
||||
|
||||
@@ -16,21 +16,16 @@
|
||||
</ItemGroup>
|
||||
|
||||
<!--
|
||||
The Razor editor in VS2026 and the C# extension for VS Code uses the Razor source generator
|
||||
The Razor editor in modern Visual Studio and the C# extension for VS Code use the Razor source generator
|
||||
for IDE functionality. We need to add some things to make sure it works correctly, but we
|
||||
only do them for design time builds, so that we don't impact regular builds or CI.
|
||||
We also have an escape hatch in case it does cause issues, users can set the appropriate property
|
||||
We also have an escape hatch in case it does cause issues, users can set EnableCohostEditorCompatibility=false
|
||||
in their project file to disable this.
|
||||
|
||||
CompilerVisibleProperty is surfaced to generators via AnalyzerConfigOptionsProvider, not as a source-generator input file,
|
||||
so it doesn't enter the hintName-collision codepath that AdditionalFiles does. Keeping it at evaluation time is safe.
|
||||
-->
|
||||
<ItemGroup Condition="'$(DesignTimeBuild)' == 'true' and '$(EnableCohostEditorCompatibility)' != 'false'">
|
||||
<!--
|
||||
We have to make sure the source generator can see the .cshtml files, so make them AdditionalFiles.
|
||||
-->
|
||||
<AdditionalFiles Include="**\*.cshtml" />
|
||||
|
||||
<!--
|
||||
Make sure the source generator knows where the project is, so it can compute target paths.
|
||||
-->
|
||||
<CompilerVisibleProperty Include="MSBuildProjectDirectory" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -49,4 +49,39 @@
|
||||
<ContentWithTargetPath Include="@(_UmbracoFolderFiles)" Exclude="@(ContentWithTargetPath)" TargetPath="%(Identity)" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
</Target>
|
||||
|
||||
<!--
|
||||
The Razor source generator needs .cshtml files in @(AdditionalFiles). The Razor SDK adds them
|
||||
via @(RazorGenerate), but only inside a target that runs during the build — so during cohost
|
||||
design-time builds they may not be present yet, which is what PR #21861 worked around.
|
||||
|
||||
Doing the include at evaluation time (as PR #21861 did) causes duplicates with the SDK during
|
||||
dotnet watch / hot reload design-time builds: the SDK adds the same .cshtml under a different
|
||||
item Identity (slash form / relative vs absolute) and the generator then sees two inputs that
|
||||
derive the same hintName, which crashes it with CS8785 (see issue #22773).
|
||||
|
||||
Run as a target before CoreCompile (hot-reload path) and CompileDesignTime (IDE design-time path)
|
||||
so the SDK's contribution is visible in both cases. Then add only the .cshtml files that are not already
|
||||
present. Both sides are normalized to %(FullPath) so items with different Identity forms still compare equal.
|
||||
|
||||
Set EnableCohostEditorCompatibility=false in a project to opt out entirely.
|
||||
-->
|
||||
<Target Name="_UmbracoEnsureRazorAdditionalFilesForCohostEditor"
|
||||
BeforeTargets="CoreCompile;CompileDesignTime"
|
||||
Condition="'$(DesignTimeBuild)' == 'true' and '$(EnableCohostEditorCompatibility)' != 'false'">
|
||||
<ItemGroup>
|
||||
<_UmbracoCshtmlCandidate Include="**\*.cshtml" />
|
||||
<_UmbracoCshtmlCandidateFull Include="@(_UmbracoCshtmlCandidate->'%(FullPath)')" />
|
||||
|
||||
<_UmbracoExistingAdditionalCshtmlFull
|
||||
Include="@(AdditionalFiles->'%(FullPath)')"
|
||||
Condition="'%(Extension)' == '.cshtml'" />
|
||||
|
||||
<_UmbracoCshtmlMissingFromAdditional
|
||||
Include="@(_UmbracoCshtmlCandidateFull)"
|
||||
Exclude="@(_UmbracoExistingAdditionalCshtmlFull)" />
|
||||
|
||||
<AdditionalFiles Include="@(_UmbracoCshtmlMissingFromAdditional)" />
|
||||
</ItemGroup>
|
||||
</Target>
|
||||
</Project>
|
||||
|
||||
@@ -305,6 +305,8 @@ public class MyEntityCacheRefresher : CacheRefresherBase<MyEntityCacheRefresher>
|
||||
- `Attempt.Succeed(value)` / `Attempt.Fail<T>()`
|
||||
- `Attempt<Content, ContentEditingOperationStatus>` - typed result with status
|
||||
|
||||
> Writing or reviewing a query with a `WHERE IN` on a runtime-sized collection? See "Avoiding the SQL Server 2100-parameter limit" in `/src/Umbraco.Infrastructure/CLAUDE.md` — that's where the full helper list (`Constants.Sql.MaxParameterCount`, `InGroupsOf`, NPoco's `FetchByGroups`) and the decision rules live.
|
||||
|
||||
### Configuration
|
||||
|
||||
Configuration models in `/Configuration/Models`:
|
||||
|
||||
@@ -26,9 +26,21 @@ public interface IRepositoryCacheVersionAccessor
|
||||
/// Notifies of a version change on a given cache key.
|
||||
/// </summary>
|
||||
/// <param name="cacheKey">Key of the changed version.</param>
|
||||
[Obsolete("Use version that takes newVersion, scheduled for removal in V19")]
|
||||
void VersionChanged(string cacheKey)
|
||||
{ }
|
||||
|
||||
/// <summary>
|
||||
/// Notifies of a version change on a given cache key, providing the new version so internal caches
|
||||
/// can be updated in-place without a database round-trip.
|
||||
/// </summary>
|
||||
/// <param name="cacheKey">Key of the changed version.</param>
|
||||
/// <param name="newVersion">The new version GUID that was just written to the database.</param>
|
||||
void VersionChanged(string cacheKey, Guid newVersion)
|
||||
{
|
||||
VersionChanged(cacheKey);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notifies the accessor that caches have been synchronized.
|
||||
/// </summary>
|
||||
|
||||
+10
-1
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -368,8 +368,17 @@ public class ObjectCacheAppCache : IAppPolicyCache, IDisposable
|
||||
}
|
||||
|
||||
// Ensure key is removed from set when evicted from cache
|
||||
return options.RegisterPostEvictionCallback((key, _, _, _) =>
|
||||
return options.RegisterPostEvictionCallback((key, _, reason, _) =>
|
||||
{
|
||||
// Removed and Replaced evictions don't need pruning here: the Remove/Clear call sites already
|
||||
// prune the tracking set synchronously under the write lock, and a Replaced key still has a
|
||||
// live entry (the synchronous Set re-added it). Pruning here instead runs on a background
|
||||
// thread and races with that re-add, dropping a key whose entry is still cached. (#23064)
|
||||
if (reason is EvictionReason.Removed or EvictionReason.Replaced)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (_locker.TryEnterWriteLock(_writeLockTimeout) is false)
|
||||
|
||||
@@ -345,15 +345,15 @@ public sealed class ContentCacheRefresher : PayloadCacheRefresherBase<ContentCac
|
||||
if (payload.ChangeTypes.HasType(TreeChangeTypes.RefreshNode))
|
||||
{
|
||||
Guid key = payload.Key ?? _idKeyMap.GetKeyForId(payload.Id, UmbracoObjectTypes.Document).Result;
|
||||
_documentUrlService.CreateOrUpdateUrlSegmentsAsync(key).GetAwaiter().GetResult();
|
||||
_documentUrlAliasService.CreateOrUpdateAliasesAsync(key).GetAwaiter().GetResult();
|
||||
_documentUrlService.UpdateUrlSegmentCacheAsync(key).GetAwaiter().GetResult();
|
||||
_documentUrlAliasService.UpdateAliasCacheAsync(key).GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
if (payload.ChangeTypes.HasType(TreeChangeTypes.RefreshBranch))
|
||||
{
|
||||
Guid key = payload.Key ?? _idKeyMap.GetKeyForId(payload.Id, UmbracoObjectTypes.Document).Result;
|
||||
_documentUrlService.CreateOrUpdateUrlSegmentsWithDescendantsAsync(key).GetAwaiter().GetResult();
|
||||
_documentUrlAliasService.CreateOrUpdateAliasesWithDescendantsAsync(key).GetAwaiter().GetResult();
|
||||
_documentUrlService.UpdateUrlSegmentCacheWithDescendantsAsync(key).GetAwaiter().GetResult();
|
||||
_documentUrlAliasService.UpdateAliasCacheWithDescendantsAsync(key).GetAwaiter().GetResult();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Umbraco.Cms.Core.Collections;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Persistence.Repositories;
|
||||
using Umbraco.Cms.Core.Scoping;
|
||||
@@ -14,6 +15,7 @@ internal class RepositoryCacheVersionService : IRepositoryCacheVersionService
|
||||
private readonly ILogger<RepositoryCacheVersionService> _logger;
|
||||
private readonly IRepositoryCacheVersionAccessor _repositoryCacheVersionAccessor;
|
||||
private readonly ConcurrentDictionary<string, Guid> _cacheVersions = new();
|
||||
private readonly ConcurrentDictionary<Guid, ConcurrentHashSet<string>> _writtenKeysByScope = new();
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RepositoryCacheVersionService" /> class.
|
||||
@@ -44,7 +46,6 @@ internal class RepositoryCacheVersionService : IRepositoryCacheVersionService
|
||||
|
||||
var cacheKey = GetCacheKey<TEntity>();
|
||||
|
||||
// The cache version accessor will take a read lock if the version is not in request cache, so we don't need to take one here.
|
||||
RepositoryCacheVersion? databaseVersion = await _repositoryCacheVersionAccessor.GetAsync(cacheKey);
|
||||
|
||||
if (databaseVersion?.Version is null)
|
||||
@@ -84,18 +85,23 @@ internal class RepositoryCacheVersionService : IRepositoryCacheVersionService
|
||||
public async Task SetCacheUpdatedAsync<TEntity>()
|
||||
where TEntity : class
|
||||
{
|
||||
using ICoreScope scope = _scopeProvider.CreateCoreScope();
|
||||
string cacheKey = GetCacheKey<TEntity>();
|
||||
|
||||
// We have to take a write lock to ensure the cache is not being read while we update the version.
|
||||
ConcurrentHashSet<string>? writtenKeys = GetOrRegisterScopeWrittenKeys();
|
||||
if (writtenKeys?.TryAdd(cacheKey) is false)
|
||||
{
|
||||
_logger.LogDebug("Cache version for {EntityType} already written in this scope, skipping", typeof(TEntity).Name);
|
||||
return;
|
||||
}
|
||||
|
||||
using ICoreScope scope = _scopeProvider.CreateCoreScope();
|
||||
scope.WriteLock(Constants.Locks.CacheVersion);
|
||||
|
||||
var cacheKey = GetCacheKey<TEntity>();
|
||||
var newVersion = Guid.NewGuid();
|
||||
|
||||
_logger.LogDebug("Setting cache for {EntityType} to version {Version}", typeof(TEntity).Name, newVersion);
|
||||
await _repositoryCacheVersionRepository.SaveAsync(new RepositoryCacheVersion { Identifier = cacheKey, Version = newVersion.ToString() });
|
||||
_cacheVersions[cacheKey] = newVersion;
|
||||
_repositoryCacheVersionAccessor.VersionChanged(cacheKey);
|
||||
_repositoryCacheVersionAccessor.VersionChanged(cacheKey, newVersion);
|
||||
|
||||
scope.Complete();
|
||||
}
|
||||
@@ -104,7 +110,6 @@ internal class RepositoryCacheVersionService : IRepositoryCacheVersionService
|
||||
public async Task SetCachesSyncedAsync()
|
||||
{
|
||||
using ICoreScope scope = _scopeProvider.CreateCoreScope();
|
||||
scope.ReadLock(Constants.Locks.CacheVersion);
|
||||
|
||||
// We always sync all caches versions, so it's safe to assume all caches are synced at this point.
|
||||
IEnumerable<RepositoryCacheVersion> cacheVersions = await _repositoryCacheVersionRepository.GetAllAsync();
|
||||
@@ -131,4 +136,22 @@ internal class RepositoryCacheVersionService : IRepositoryCacheVersionService
|
||||
internal string GetCacheKey<TEntity>()
|
||||
where TEntity : class =>
|
||||
typeof(TEntity).FullName ?? typeof(TEntity).Name;
|
||||
|
||||
private ConcurrentHashSet<string>? GetOrRegisterScopeWrittenKeys()
|
||||
{
|
||||
IScopeContext? context = _scopeProvider.Context;
|
||||
if (context is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
Guid contextId = context.InstanceId;
|
||||
ConcurrentHashSet<string> writtenKeys = _writtenKeysByScope.GetOrAdd(contextId, _ => new ConcurrentHashSet<string>());
|
||||
|
||||
context.Enlist(
|
||||
$"RepositoryCacheVersionService_{contextId}",
|
||||
completed => _writtenKeysByScope.TryRemove(contextId, out _));
|
||||
|
||||
return writtenKeys;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ public interface IConfigManipulator
|
||||
/// </summary>
|
||||
/// <param name="disable">The value to save.</param>
|
||||
/// <returns></returns>
|
||||
[Obsolete("This method is no longer used by Umbraco. Set the Umbraco:CMS:WebRouting:DisableRedirectUrlTracking configuration key instead. Scheduled for removal in Umbraco 19.")]
|
||||
Task SaveDisableRedirectUrlTrackingAsync(bool disable);
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -16,6 +16,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;
|
||||
}
|
||||
|
||||
@@ -20,5 +20,12 @@ public class IndexingSettings
|
||||
/// <summary>
|
||||
/// Gets or sets a value for how many items to index at a time.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is the primary lever for the peak memory used while (re)building an index: a full page of
|
||||
/// content and its property data is held in memory at once, so lowering this value reduces rebuild
|
||||
/// memory at the cost of more, smaller batches. Lower it on very large sites that hit memory pressure
|
||||
/// during a rebuild.
|
||||
/// </remarks>
|
||||
[DefaultValue(StaticBatchSize)]
|
||||
public int BatchSize { get; set; } = StaticBatchSize;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -16,6 +16,7 @@ public class UnattendedSettings
|
||||
private const bool StaticInstallUnattended = false;
|
||||
private const bool StaticUpgradeUnattended = false;
|
||||
private const TelemetryLevel StaticTelemetryLevel = TelemetryLevel.Detailed;
|
||||
private const string StaticMigrationClaimTimeout = "02:00:00";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether unattended installs are enabled.
|
||||
@@ -45,6 +46,17 @@ public class UnattendedSettings
|
||||
/// </remarks>
|
||||
public bool PackageMigrationsUnattended { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum time a migration leadership claim is considered valid before
|
||||
/// another server may take over. Protects against a leader crashing mid-migration.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Only relevant in load-balanced deployments with <see cref="UpgradeUnattended"/> enabled.
|
||||
/// Default is 2 hours, which should exceed the longest reasonable migration run time.
|
||||
/// </remarks>
|
||||
[DefaultValue(StaticMigrationClaimTimeout)]
|
||||
public TimeSpan MigrationClaimTimeout { get; set; } = TimeSpan.Parse(StaticMigrationClaimTimeout);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value to use for creating a user with a name for Unattended Installs
|
||||
/// </summary>
|
||||
|
||||
+43
@@ -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>
|
||||
|
||||
@@ -36,6 +36,14 @@ public static partial class Constants
|
||||
/// The key used to store the Umbraco pre-migrations upgrade plan state.
|
||||
/// </summary>
|
||||
public const string UmbracoUpgradePlanPremigrationsKey = KeyValuePrefix + UmbracoUpgradePlanPremigrationsName;
|
||||
|
||||
/// <summary>
|
||||
/// The key used to coordinate migration leadership across servers in a load-balanced
|
||||
/// environment. The value is either empty (no active leader) or
|
||||
/// <c>"{machineIdentifier}|{claimedAtUtc:O}"</c> when a server holds the claim,
|
||||
/// where <c>machineIdentifier</c> is the value returned by <see cref="Umbraco.Cms.Core.Factories.IMachineInfoFactory.GetMachineIdentifier"/>.
|
||||
/// </summary>
|
||||
public const string UpgradeLockKey = "Umbraco.Core.Upgrader.Lock";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace Umbraco.Cms.Core.DependencyInjection;
|
||||
|
||||
/// <summary>
|
||||
/// Marker interface indicating that Umbraco itself has enabled ASP.NET Core output caching
|
||||
/// (via Website template caching or Delivery API caching configuration).
|
||||
/// Used to gate Umbraco's automatic registration of the output cache middleware so that
|
||||
/// applications calling <c>services.AddOutputCache(...)</c> for their own purposes do not
|
||||
/// inadvertently trigger a duplicate <c>UseOutputCache()</c> registration.
|
||||
/// </summary>
|
||||
public interface IUmbracoManagedOutputCacheMarker { }
|
||||
|
||||
/// <summary>
|
||||
/// Marker class implementation for <see cref="IUmbracoManagedOutputCacheMarker"/>.
|
||||
/// </summary>
|
||||
public sealed class UmbracoManagedOutputCacheMarker : IUmbracoManagedOutputCacheMarker { }
|
||||
@@ -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>();
|
||||
|
||||
@@ -458,6 +458,7 @@ namespace Umbraco.Cms.Core.DependencyInjection
|
||||
Services.AddUnique<IDocumentUrlAliasService, DocumentUrlAliasService>();
|
||||
Services.AddNotificationAsyncHandler<UmbracoApplicationStartingNotification, DocumentUrlAliasServiceInitializerNotificationHandler>();
|
||||
Services.AddNotificationAsyncHandler<ContentTypeChangedNotification, DocumentUrlServiceContentTypeChangedNotificationHandler>();
|
||||
Services.AddNotificationAsyncHandler<ContentTreeChangeNotification, DocumentUrlServiceContentTreeChangeNotificationHandler>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -405,7 +405,8 @@
|
||||
0: Comma delimitted list of failed folder paths
|
||||
-->
|
||||
<key alias="umbracoApplicationUrlCheckResultTrue"><![CDATA[AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' je postavljen na <strong>%0%</strong>.]]></key>
|
||||
<key alias="umbracoApplicationUrlCheckResultFalse">AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' nije postavljen.</key>
|
||||
<key alias="umbracoApplicationUrlCheckResultFalse"><![CDATA[AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' nije postavljen, pa će se URL aplikacije automatski otkriti iz dolaznih zahtjeva. Preporučuje se da ga postavite izričito.]]></key>
|
||||
<key alias="umbracoApplicationUrlCheckResultError"><![CDATA[AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' nije postavljen, a automatsko otkrivanje URL-a aplikacije je onemogućeno ('Umbraco:CMS:WebRouting:ApplicationUrlDetection' je 'None'). Značajke koje zahtijevaju apsolutni URL, poput e-pošte za poništavanje lozinke i pozivnica, neće raditi. Postavite URL aplikacije izričito ili omogućite automatsko otkrivanje.]]></key>
|
||||
<!-- The following key get these tokens passed in:
|
||||
0: Comma delimitted list of headers found
|
||||
-->
|
||||
|
||||
@@ -454,7 +454,8 @@
|
||||
<key alias="httpsCheckConfigurationRectifyNotPossible">Mae gosodiad ap 'Umbraco:CMS:Global:UseHttps' wedi'i osod i 'false' yn eich ffeil appSettings.json. Unwaith y byddwch yn cyrchu'r wefan hon gan ddefnyddio'r cynllun HTTPS, dylid gosod hwnnw i 'true'.</key>
|
||||
<key alias="httpsCheckConfigurationCheckResult">Mae'r gosodiad ap 'Umbraco:CMS:Global:UseHttps' wedi'i osod i '%0%' yn eich ffeil appSettings.json, mae eich cwcis %1% wedi'u marcio'n ddiogel.</key>
|
||||
<key alias="umbracoApplicationUrlCheckResultTrue">Mae gosodiad yr ap 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' wedi'i osod i <strong>%0%</strong>.</key>
|
||||
<key alias="umbracoApplicationUrlCheckResultFalse">Nid yw gosodiad ap 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' wedi'i osod.</key>
|
||||
<key alias="umbracoApplicationUrlCheckResultFalse"><![CDATA[Nid yw gosodiad ap 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' wedi'i osod, felly bydd URL y rhaglen yn cael ei ganfod yn awtomatig o geisiadau sy'n dod i mewn. Argymhellir ei osod yn benodol.]]></key>
|
||||
<key alias="umbracoApplicationUrlCheckResultError"><![CDATA[Nid yw gosodiad ap 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' wedi'i osod ac mae canfod URL y rhaglen yn awtomatig wedi'i analluogi (mae 'Umbraco:CMS:WebRouting:ApplicationUrlDetection' yn 'None'). Ni fydd nodweddion sydd angen URL absoliwt, fel e-byst ailosod cyfrinair a gwahoddiadau, yn gweithio. Gosodwch URL y rhaglen yn benodol, neu galluogwch ganfod yn awtomatig.]]></key>
|
||||
<key alias="smtpMailSettingsNotFound">Nid oedd modd dod o hyd i'r ffurfweddiad 'Umbraco:CMS:Global:Smtp'.</key>
|
||||
<key alias="smtpMailSettingsHostNotConfigured">Nid oedd modd dod o hyd i'r ffurfweddiad 'Umbraco:CMS:Global:Smtp:Host'.</key>
|
||||
<key alias="smtpMailSettingsConnectionFail">Methwyd cyrraedd y gweinydd SMTP a ffurfweddwyd gyda gwesteiwr '%0%' a phorth '%1%'. Gwiriwch i sicrhau bod y gosodiadau SMTP yn y ffurfweddiad 'Umbraco:CMS:Global:Smtp' yn gywir.</key>
|
||||
|
||||
@@ -463,7 +463,8 @@
|
||||
0: Comma delimitted list of failed folder paths
|
||||
-->
|
||||
<key alias="umbracoApplicationUrlCheckResultTrue"><![CDATA[The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is set to <strong>%0%</strong>.]]></key>
|
||||
<key alias="umbracoApplicationUrlCheckResultFalse">The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is not set.</key>
|
||||
<key alias="umbracoApplicationUrlCheckResultFalse"><![CDATA[The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is not set, so the application URL will be auto-detected from incoming requests. Setting it explicitly is recommended.]]></key>
|
||||
<key alias="umbracoApplicationUrlCheckResultError"><![CDATA[The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is not set and application URL auto-detection is disabled ('Umbraco:CMS:WebRouting:ApplicationUrlDetection' is 'None'). Features that require an absolute URL, such as password reset and invitation emails, will not work. Set the application URL explicitly, or enable auto-detection.]]></key>
|
||||
<!-- The following key get these tokens passed in:
|
||||
0: Comma delimitted list of headers found
|
||||
-->
|
||||
|
||||
@@ -452,7 +452,8 @@
|
||||
0: Comma delimitted list of failed folder paths
|
||||
-->
|
||||
<key alias="umbracoApplicationUrlCheckResultTrue"><![CDATA[The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is set to <strong>%0%</strong>.]]></key>
|
||||
<key alias="umbracoApplicationUrlCheckResultFalse">The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is not set.</key>
|
||||
<key alias="umbracoApplicationUrlCheckResultFalse"><![CDATA[The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is not set, so the application URL will be auto-detected from incoming requests. Setting it explicitly is recommended.]]></key>
|
||||
<key alias="umbracoApplicationUrlCheckResultError"><![CDATA[The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is not set and application URL auto-detection is disabled ('Umbraco:CMS:WebRouting:ApplicationUrlDetection' is 'None'). Features that require an absolute URL, such as password reset and invitation emails, will not work. Set the application URL explicitly, or enable auto-detection.]]></key>
|
||||
<key alias="clickJackingCheckHeaderFound">
|
||||
<![CDATA[The header or meta-tag <strong>X-Frame-Options</strong> used to control whether a site can be IFRAMEd by another was found.]]></key>
|
||||
<key alias="clickJackingCheckHeaderNotFound">
|
||||
|
||||
@@ -403,7 +403,8 @@
|
||||
0: Comma delimitted list of failed folder paths
|
||||
-->
|
||||
<key alias="umbracoApplicationUrlCheckResultTrue"><![CDATA[AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' je postavljen na <strong>%0%</strong>.]]></key>
|
||||
<key alias="umbracoApplicationUrlCheckResultFalse">AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' nije postavljen.</key>
|
||||
<key alias="umbracoApplicationUrlCheckResultFalse"><![CDATA[AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' nije postavljen, pa će se URL aplikacije automatski otkriti iz dolaznih zahtjeva. Preporučuje se da ga postavite izričito.]]></key>
|
||||
<key alias="umbracoApplicationUrlCheckResultError"><![CDATA[AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' nije postavljen, a automatsko otkrivanje URL-a aplikacije je onemogućeno ('Umbraco:CMS:WebRouting:ApplicationUrlDetection' je 'None'). Značajke koje zahtijevaju apsolutni URL, poput e-pošte za poništavanje lozinke i pozivnica, neće raditi. Postavite URL aplikacije izričito ili omogućite automatsko otkrivanje.]]></key>
|
||||
<!-- The following key get these tokens passed in:
|
||||
0: Comma delimitted list of headers found
|
||||
-->
|
||||
|
||||
@@ -2230,9 +2230,9 @@ public static class PublishedContentExtensions
|
||||
// with a non-existing published node, will get cache misses and call the DB
|
||||
// making it a very slow operation.
|
||||
|
||||
return publishedStatusFilteringService
|
||||
.FilterAvailable(childrenKeys, culture)
|
||||
.OrderBy(x => x.SortOrder);
|
||||
// INavigationQueryService.TryGetChildrenKeys returns keys already ordered by SortOrder
|
||||
// and FilterAvailable preserves enumeration order, so no further OrderBy is needed.
|
||||
return publishedStatusFilteringService.FilterAvailable(childrenKeys, culture);
|
||||
}
|
||||
|
||||
private static IEnumerable<IPublishedContent> EnumerateDescendantsOrSelfInternal(
|
||||
|
||||
@@ -730,6 +730,10 @@ public static partial class StringExtensions
|
||||
/// </summary>
|
||||
/// <param name="fileName">The file name to convert.</param>
|
||||
/// <returns>A friendly name with the extension stripped, underscores and dashes converted to spaces, and title case applied.</returns>
|
||||
/// <remarks>
|
||||
/// Mirrored client-side in <c>src/Umbraco.Web.UI.Client/src/packages/media/media/utils/to-friendly-name.function.ts</c>;
|
||||
/// keep the two implementations in sync.
|
||||
/// </remarks>
|
||||
public static string ToFriendlyName(this string fileName)
|
||||
{
|
||||
// strip the file extension
|
||||
|
||||
@@ -44,28 +44,34 @@ public class UmbracoApplicationUrlCheck : HealthCheck
|
||||
|
||||
private HealthCheckStatus CheckUmbracoApplicationUrl()
|
||||
{
|
||||
var url = _webRoutingSettings.CurrentValue.UmbracoApplicationUrl;
|
||||
WebRoutingSettings settings = _webRoutingSettings.CurrentValue;
|
||||
var url = settings.UmbracoApplicationUrl;
|
||||
|
||||
string resultMessage;
|
||||
StatusResultType resultType;
|
||||
var success = false;
|
||||
|
||||
if (url.IsNullOrWhiteSpace())
|
||||
if (url.IsNullOrWhiteSpace() is false)
|
||||
{
|
||||
resultMessage = _textService.Localize("healthcheck", "umbracoApplicationUrlCheckResultFalse");
|
||||
resultType = StatusResultType.Warning;
|
||||
resultMessage = _textService.Localize("healthcheck", "umbracoApplicationUrlCheckResultTrue", [url]);
|
||||
resultType = StatusResultType.Success;
|
||||
}
|
||||
else if (settings.ApplicationUrlDetection == ApplicationUrlDetection.None)
|
||||
{
|
||||
// No explicit URL and auto-detection is disabled, so the application URL can never be established.
|
||||
// Features that require an absolute URL (e.g. password reset and invitation emails) will not work.
|
||||
resultMessage = _textService.Localize("healthcheck", "umbracoApplicationUrlCheckResultError");
|
||||
resultType = StatusResultType.Error;
|
||||
}
|
||||
else
|
||||
{
|
||||
resultMessage = _textService.Localize("healthcheck", "umbracoApplicationUrlCheckResultTrue", new[] { url });
|
||||
resultType = StatusResultType.Success;
|
||||
success = true;
|
||||
resultMessage = _textService.Localize("healthcheck", "umbracoApplicationUrlCheckResultFalse");
|
||||
resultType = StatusResultType.Warning;
|
||||
}
|
||||
|
||||
return new HealthCheckStatus(resultMessage)
|
||||
{
|
||||
ResultType = resultType,
|
||||
ReadMoreLink = success
|
||||
ReadMoreLink = resultType == StatusResultType.Success
|
||||
? null
|
||||
: Constants.HealthChecks.DocumentationLinks.Security.UmbracoApplicationUrlCheck,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -8,7 +8,25 @@ namespace Umbraco.Cms.Core.Models.Navigation;
|
||||
/// </summary>
|
||||
public sealed class NavigationNode
|
||||
{
|
||||
private ConcurrentHashSet<Guid> _children;
|
||||
private static readonly Comparison<(Guid Key, int SortOrder)> _sortBySortOrder =
|
||||
static (a, b) => a.SortOrder.CompareTo(b.SortOrder);
|
||||
|
||||
private readonly ConcurrentHashSet<Guid> _children;
|
||||
|
||||
/// <summary>
|
||||
/// Cached snapshot of <see cref="Children"/> ordered by each child's <c>SortOrder</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Built lazily by <see cref="GetOrderedChildren"/> on first access and invalidated
|
||||
/// (set to <c>null</c>) by <see cref="AddChild"/> / <see cref="RemoveChild"/> /
|
||||
/// <see cref="InvalidateOrderedChildren"/>. Reads are lock-free on the fast path; the
|
||||
/// build and invalidation paths take <see cref="_orderedChildrenLock"/> so concurrent
|
||||
/// first-access threads agree on a single canonical array and an in-flight build
|
||||
/// cannot finish after a concurrent invalidation has cleared it.
|
||||
/// </remarks>
|
||||
private Guid[]? _orderedChildren;
|
||||
|
||||
private readonly Lock _orderedChildrenLock = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the unique key of this navigation node.
|
||||
@@ -53,6 +71,17 @@ public sealed class NavigationNode
|
||||
/// Updates the sort order of this node.
|
||||
/// </summary>
|
||||
/// <param name="newSortOrder">The new sort order value.</param>
|
||||
/// <remarks>
|
||||
/// The parent node's cached ordered-children list (if any) is now stale because it sorts
|
||||
/// by child <c>SortOrder</c>. Callers that hold a reference to the parent should call
|
||||
/// <see cref="InvalidateOrderedChildren"/> on it; <see cref="NavigationNode"/> does not
|
||||
/// hold a reference to its parent <see cref="NavigationNode"/> so cannot invalidate it
|
||||
/// itself.
|
||||
/// </remarks>
|
||||
// TODO (V19): Make internal. The contract requires the caller to invalidate the parent's
|
||||
// ordered-children cache (InvalidateOrderedChildren is internal, so external callers cannot
|
||||
// satisfy that contract and would silently observe stale ordering on subsequent reads).
|
||||
// Internal callers in ContentNavigationServiceBase already do the invalidation correctly.
|
||||
public void UpdateSortOrder(int newSortOrder) => SortOrder = newSortOrder;
|
||||
|
||||
/// <summary>
|
||||
@@ -74,6 +103,8 @@ public sealed class NavigationNode
|
||||
child.SortOrder = _children.Count;
|
||||
|
||||
_children.Add(childKey);
|
||||
|
||||
InvalidateOrderedChildren();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -91,5 +122,91 @@ public sealed class NavigationNode
|
||||
|
||||
_children.Remove(childKey);
|
||||
child.Parent = null;
|
||||
|
||||
InvalidateOrderedChildren();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns this node's children ordered by <c>SortOrder</c>.
|
||||
/// </summary>
|
||||
/// <param name="navigationStructure">The navigation structure dictionary containing all nodes; needed to look up each child's current <c>SortOrder</c>.</param>
|
||||
/// <returns>An immutable, sort-order-presorted snapshot of the children. The result is cached and reused across calls until the children set or a child's <c>SortOrder</c> is mutated.</returns>
|
||||
/// <remarks>
|
||||
/// Lock-free fast path: a non-null cached array is returned without acquiring the lock.
|
||||
/// If the cache is empty, <see cref="BuildOrderedChildren"/> is called under the lock to
|
||||
/// build (with double-checked re-read) and store the canonical array.
|
||||
/// </remarks>
|
||||
internal IReadOnlyList<Guid> GetOrderedChildren(ConcurrentDictionary<Guid, NavigationNode> navigationStructure)
|
||||
{
|
||||
// Volatile.Read provides the acquire fence that pairs with the release fence on the
|
||||
// lock-protected stores in BuildOrderedChildren / InvalidateOrderedChildren. On weak
|
||||
// memory architectures (e.g. ARM64) a plain read can observe writes out of order with
|
||||
// the lock release, so without this barrier a reader could in principle see a torn or
|
||||
// unpublished reference; on x86/x64 the TSO model already gives acquire semantics so
|
||||
// this compiles to a normal load. Matches the lock-free read idiom in System.Lazy<T>
|
||||
// and LazyInitializer.EnsureInitialized.
|
||||
Guid[]? cached = Volatile.Read(ref _orderedChildren);
|
||||
if (cached is not null)
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
return BuildOrderedChildren(navigationStructure);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invalidates the cached ordered-children snapshot.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Called by <see cref="AddChild"/> and <see cref="RemoveChild"/> automatically. Must be
|
||||
/// called externally when a child's <c>SortOrder</c> changes (the parent's cache sorts by
|
||||
/// child <c>SortOrder</c> and so is stale after such an update).
|
||||
/// </remarks>
|
||||
internal void InvalidateOrderedChildren()
|
||||
{
|
||||
lock (_orderedChildrenLock)
|
||||
{
|
||||
_orderedChildren = null;
|
||||
}
|
||||
}
|
||||
|
||||
private Guid[] BuildOrderedChildren(ConcurrentDictionary<Guid, NavigationNode> navigationStructure)
|
||||
{
|
||||
lock (_orderedChildrenLock)
|
||||
{
|
||||
// Double-check under the lock — another thread may have built the cache while we
|
||||
// were waiting to acquire it.
|
||||
Guid[]? cached = _orderedChildren;
|
||||
if (cached is not null)
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
if (_children.Count == 0)
|
||||
{
|
||||
_orderedChildren = [];
|
||||
return _orderedChildren;
|
||||
}
|
||||
|
||||
var sorted = new List<(Guid Key, int SortOrder)>(_children.Count);
|
||||
foreach (Guid childKey in _children)
|
||||
{
|
||||
if (navigationStructure.TryGetValue(childKey, out NavigationNode? childNode))
|
||||
{
|
||||
sorted.Add((childKey, childNode.SortOrder));
|
||||
}
|
||||
}
|
||||
|
||||
sorted.Sort(_sortBySortOrder);
|
||||
|
||||
var result = new Guid[sorted.Count];
|
||||
for (var i = 0; i < sorted.Count; i++)
|
||||
{
|
||||
result[i] = sorted[i].Key;
|
||||
}
|
||||
|
||||
_orderedChildren = result;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -28,6 +28,27 @@ public interface IDocumentCacheService
|
||||
/// <returns>The published content, or <c>null</c> if not found.</returns>
|
||||
Task<IPublishedContent?> GetByIdAsync(int id, bool? preview = null);
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to retrieve a content item from the in-memory converted-content cache without
|
||||
/// touching the distributed cache or the database.
|
||||
/// </summary>
|
||||
/// <param name="key">The unique key of the content.</param>
|
||||
/// <param name="preview">Whether to consider unpublished content.</param>
|
||||
/// <param name="content">When this method returns, contains the cached published content if a hit was made; otherwise <c>null</c>.</param>
|
||||
/// <returns><c>true</c> if the content was served from the in-memory cache; <c>false</c> if a slower retrieval (HybridCache or database) is required.</returns>
|
||||
/// <remarks>
|
||||
/// Synchronous fast-path used by sync consumers (e.g. <c>IPublishedContentCache.GetById(bool, Guid)</c>)
|
||||
/// to avoid setting up the async state machine on the dominant warm-cache case. On a miss
|
||||
/// the caller falls back to the existing async path. The default implementation always
|
||||
/// returns <c>false</c> so the caller takes the async path.
|
||||
/// </remarks>
|
||||
// TODO (V19): Remove the default implementation.
|
||||
bool TryGetCached(Guid key, bool preview, out IPublishedContent? content)
|
||||
{
|
||||
content = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seeds the cache with initial content data.
|
||||
/// </summary>
|
||||
|
||||
@@ -26,6 +26,26 @@ public interface IMediaCacheService
|
||||
/// <returns>The published media content, or <c>null</c> if not found.</returns>
|
||||
Task<IPublishedContent?> GetByIdAsync(int id);
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to retrieve a media item from the in-memory converted-content cache without
|
||||
/// touching the distributed cache or the database.
|
||||
/// </summary>
|
||||
/// <param name="key">The unique key of the media.</param>
|
||||
/// <param name="content">When this method returns, contains the cached published media if a hit was made; otherwise <c>null</c>.</param>
|
||||
/// <returns><c>true</c> if the media was served from the in-memory cache; <c>false</c> if a slower retrieval (HybridCache or database) is required.</returns>
|
||||
/// <remarks>
|
||||
/// Synchronous fast-path used by sync consumers (e.g. <c>IPublishedMediaCache.GetById(bool, Guid)</c>)
|
||||
/// to avoid setting up the async state machine on the dominant warm-cache case. On a miss
|
||||
/// the caller falls back to the existing async path. The default implementation always
|
||||
/// returns <c>false</c> so the caller takes the async path.
|
||||
/// </remarks>
|
||||
// TODO (V19): Remove the default implementation.
|
||||
bool TryGetCached(Guid key, out IPublishedContent? content)
|
||||
{
|
||||
content = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether media with the specified identifier exists in the cache.
|
||||
/// </summary>
|
||||
|
||||
@@ -105,7 +105,15 @@ internal sealed class ContentEditingService
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Attempt<ContentValidationResult, ContentEditingOperationStatus>> ValidateCreateAsync(ContentCreateModel createModel, Guid userKey)
|
||||
=> await ValidateCulturesAndPropertiesAsync(createModel, createModel.ContentTypeKey, await GetCulturesToValidate(createModel.Variants.Select(variant => variant.Culture), userKey));
|
||||
{
|
||||
ContentEditingOperationStatus creationAllowedStatus = await ValidateCreationAllowedAsync(createModel);
|
||||
if (creationAllowedStatus != ContentEditingOperationStatus.Success)
|
||||
{
|
||||
return Attempt.FailWithStatus(creationAllowedStatus, new ContentValidationResult());
|
||||
}
|
||||
|
||||
return await ValidateCulturesAndPropertiesAsync(createModel, createModel.ContentTypeKey, await GetCulturesToValidate(createModel.Variants.Select(variant => variant.Culture), userKey));
|
||||
}
|
||||
|
||||
private async Task<IEnumerable<string?>?> GetCulturesToValidate(IEnumerable<string?>? cultures, Guid userKey)
|
||||
{
|
||||
@@ -332,6 +340,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 +401,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 +411,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,
|
||||
|
||||
@@ -619,6 +623,25 @@ internal abstract class ContentEditingServiceBase<TContent, TContentType, TConte
|
||||
return filteredContentTypes.Any();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that content of the requested type is allowed to be created under the requested parent, applying the
|
||||
/// same "allowed at root", "allowed as child" and content type filter rules that are enforced when the content is
|
||||
/// actually created. This allows the validation endpoints to be consistent with creation.
|
||||
/// </summary>
|
||||
/// <param name="createModel">The content creation model.</param>
|
||||
/// <returns>The operation status; <see cref="ContentEditingOperationStatus.Success"/> when creation is allowed.</returns>
|
||||
protected async Task<ContentEditingOperationStatus> ValidateCreationAllowedAsync(ContentCreationModelBase createModel)
|
||||
{
|
||||
TContentType? contentType = ContentTypeService.Get(createModel.ContentTypeKey);
|
||||
if (contentType is null)
|
||||
{
|
||||
return ContentEditingOperationStatus.ContentTypeNotFound;
|
||||
}
|
||||
|
||||
(int? _, ContentEditingOperationStatus operationStatus) = await TryGetAndValidateParentIdAsync(createModel.ParentKey, contentType);
|
||||
return operationStatus;
|
||||
}
|
||||
|
||||
private void UpdateNames(ContentEditingModelBase contentEditingModelBase, TContent content, TContentType contentType)
|
||||
{
|
||||
if (contentType.VariesByCulture())
|
||||
|
||||
@@ -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."),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -305,13 +305,40 @@ public class DocumentUrlAliasService : IDocumentUrlAliasService
|
||||
scope.Complete();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task UpdateAliasCacheAsync(Guid documentKey)
|
||||
{
|
||||
using ICoreScope scope = _coreScopeProvider.CreateCoreScope();
|
||||
await CreateOrUpdateAliasesInternalAsync(documentKey, forceSkipDatabaseWrite: true);
|
||||
scope.Complete();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task UpdateAliasCacheWithDescendantsAsync(Guid documentKey)
|
||||
{
|
||||
using ICoreScope scope = _coreScopeProvider.CreateCoreScope();
|
||||
|
||||
var documentKeys = new List<Guid> { documentKey };
|
||||
if (_documentNavigationQueryService.TryGetDescendantsKeys(documentKey, out IEnumerable<Guid> descendantKeys))
|
||||
{
|
||||
documentKeys.AddRange(descendantKeys);
|
||||
}
|
||||
|
||||
foreach (Guid key in documentKeys)
|
||||
{
|
||||
await CreateOrUpdateAliasesInternalAsync(key, forceSkipDatabaseWrite: true);
|
||||
}
|
||||
|
||||
scope.Complete();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Internal implementation that processes a single document without creating its own scope.
|
||||
/// Caller must ensure a scope is active. A write lock on <see cref="Constants.Locks.DocumentUrlAliases"/>
|
||||
/// is required whenever this method may perform database writes — i.e. on all server roles except
|
||||
/// <see cref="ServerRole.Subscriber"/>, where persistence is skipped and the write lock is not taken.
|
||||
/// </summary>
|
||||
private async Task CreateOrUpdateAliasesInternalAsync(Guid documentKey)
|
||||
private async Task CreateOrUpdateAliasesInternalAsync(Guid documentKey, bool forceSkipDatabaseWrite = false)
|
||||
{
|
||||
IContent? document = _contentService.GetById(documentKey);
|
||||
if (document is null || document.Trashed || document.Blueprint)
|
||||
@@ -329,7 +356,7 @@ public class DocumentUrlAliasService : IDocumentUrlAliasService
|
||||
// Save to database (handles insert/update/delete via diff) and add to cache.
|
||||
// On subscribers we skip the persistence — the publisher has already written the aliases — but the
|
||||
// in-memory cache is still refreshed via the deferred enlistments so routing keeps working locally.
|
||||
bool skipDatabaseWrites = SkipDatabaseWrites();
|
||||
bool skipDatabaseWrites = forceSkipDatabaseWrite || SkipDatabaseWrites();
|
||||
if (aliases.Count > 0)
|
||||
{
|
||||
if (skipDatabaseWrites is false)
|
||||
|
||||
@@ -154,7 +154,7 @@ public class DocumentUrlService : IDocumentUrlService
|
||||
IPublishStatusQueryService publishStatusQueryService,
|
||||
IDomainCacheService domainCacheService)
|
||||
#pragma warning disable CS0618 // Type or member is obsolete
|
||||
:this(
|
||||
: this(
|
||||
logger,
|
||||
documentUrlRepository,
|
||||
documentRepository,
|
||||
@@ -604,7 +604,35 @@ public class DocumentUrlService : IDocumentUrlService
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task CreateOrUpdateUrlSegmentsAsync(IEnumerable<IContent> documentsEnumerable)
|
||||
public async Task CreateOrUpdateUrlSegmentsAsync(IEnumerable<IContent> documents)
|
||||
=> await CreateOrUpdateUrlSegmentsInternalAsync(documents, skipDatabaseWrite: false);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task UpdateUrlSegmentCacheAsync(Guid key)
|
||||
{
|
||||
IContent? content = _contentService.GetById(key);
|
||||
if (content is not null)
|
||||
{
|
||||
await CreateOrUpdateUrlSegmentsInternalAsync(content.Yield(), skipDatabaseWrite: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task UpdateUrlSegmentCacheWithDescendantsAsync(Guid key)
|
||||
{
|
||||
var id = _idKeyMap.GetIdForKey(key, UmbracoObjectTypes.Document).Result;
|
||||
IContent? item = _contentService.GetById(id);
|
||||
if (item is null)
|
||||
{
|
||||
_logger.LogDebug("Skipping URL segment cache update for document with key {DocumentKey} — document not found.", key);
|
||||
return;
|
||||
}
|
||||
|
||||
IEnumerable<IContent> descendants = _contentService.GetPagedDescendants(id, 0, int.MaxValue, out _);
|
||||
await CreateOrUpdateUrlSegmentsInternalAsync(new List<IContent>(descendants) { item }, skipDatabaseWrite: true);
|
||||
}
|
||||
|
||||
private async Task CreateOrUpdateUrlSegmentsInternalAsync(IEnumerable<IContent> documentsEnumerable, bool skipDatabaseWrite)
|
||||
{
|
||||
IEnumerable<IContent> documents = documentsEnumerable as IContent[] ?? documentsEnumerable.ToArray();
|
||||
if (documents.Any() is false)
|
||||
@@ -664,7 +692,7 @@ public class DocumentUrlService : IDocumentUrlService
|
||||
}
|
||||
}
|
||||
|
||||
if (toSave.Count > 0 && SkipDatabaseWrites() is false)
|
||||
if (!skipDatabaseWrite && toSave.Count > 0 && SkipDatabaseWrites() is false)
|
||||
{
|
||||
scope.WriteLock(Constants.Locks.DocumentUrls);
|
||||
_documentUrlRepository.Save(toSave);
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
using Umbraco.Cms.Core.Events;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Notifications;
|
||||
using Umbraco.Cms.Core.Services.Changes;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Handles <see cref="ContentTreeChangeNotification"/> to persist URL segments and aliases to the database
|
||||
/// on the originating server. This fires post-commit (during scope disposal) before the cache instruction
|
||||
/// is delivered to other servers, ensuring URL data is in the database before any server processes the instruction.
|
||||
/// </summary>
|
||||
public class DocumentUrlServiceContentTreeChangeNotificationHandler
|
||||
: INotificationAsyncHandler<ContentTreeChangeNotification>
|
||||
{
|
||||
private readonly IDocumentUrlService _documentUrlService;
|
||||
private readonly IDocumentUrlAliasService _documentUrlAliasService;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DocumentUrlServiceContentTreeChangeNotificationHandler"/> class.
|
||||
/// </summary>
|
||||
public DocumentUrlServiceContentTreeChangeNotificationHandler(
|
||||
IDocumentUrlService documentUrlService,
|
||||
IDocumentUrlAliasService documentUrlAliasService)
|
||||
{
|
||||
_documentUrlService = documentUrlService;
|
||||
_documentUrlAliasService = documentUrlAliasService;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task HandleAsync(ContentTreeChangeNotification notification, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_documentUrlService.IsInitialized is false)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var refreshNodeItems = new List<IContent>();
|
||||
|
||||
foreach (TreeChange<IContent> change in notification.Changes)
|
||||
{
|
||||
if (change.ChangeTypes.HasType(TreeChangeTypes.RefreshNode))
|
||||
{
|
||||
refreshNodeItems.Add(change.Item);
|
||||
}
|
||||
|
||||
if (change.ChangeTypes.HasType(TreeChangeTypes.RefreshBranch))
|
||||
{
|
||||
await _documentUrlService.CreateOrUpdateUrlSegmentsWithDescendantsAsync(change.Item.Key);
|
||||
await _documentUrlAliasService.CreateOrUpdateAliasesWithDescendantsAsync(change.Item.Key);
|
||||
}
|
||||
}
|
||||
|
||||
if (refreshNodeItems.Count > 0)
|
||||
{
|
||||
await _documentUrlService.CreateOrUpdateUrlSegmentsAsync(refreshNodeItems);
|
||||
|
||||
foreach (IContent item in refreshNodeItems)
|
||||
{
|
||||
await _documentUrlAliasService.CreateOrUpdateAliasesAsync(item.Key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -60,4 +60,20 @@ public interface IDocumentUrlAliasService
|
||||
/// </summary>
|
||||
/// <returns><c>true</c> if there are any aliases in the cache; otherwise, <c>false</c>.</returns>
|
||||
bool HasAny();
|
||||
|
||||
/// <summary>
|
||||
/// Updates the in-memory alias cache for a single document without writing to the database.
|
||||
/// </summary>
|
||||
/// <param name="documentKey">The document key.</param>
|
||||
// TODO (V19): Remove default implementation when external implementations have had time to adopt.
|
||||
Task UpdateAliasCacheAsync(Guid documentKey)
|
||||
=> CreateOrUpdateAliasesAsync(documentKey);
|
||||
|
||||
/// <summary>
|
||||
/// Updates the in-memory alias cache for a document and its descendants without writing to the database.
|
||||
/// </summary>
|
||||
/// <param name="documentKey">The document key.</param>
|
||||
// TODO (V19): Remove default implementation when external implementations have had time to adopt.
|
||||
Task UpdateAliasCacheWithDescendantsAsync(Guid documentKey)
|
||||
=> CreateOrUpdateAliasesWithDescendantsAsync(documentKey);
|
||||
}
|
||||
|
||||
@@ -100,4 +100,20 @@ public interface IDocumentUrlService
|
||||
/// Gets a value indicating whether any URLs have been cached.
|
||||
/// </summary>
|
||||
bool HasAny();
|
||||
|
||||
/// <summary>
|
||||
/// Updates the in-memory URL segment cache for a single document without writing to the database.
|
||||
/// </summary>
|
||||
/// <param name="key">The document key.</param>
|
||||
// TODO (V19): Remove default implementation when external implementations have had time to adopt.
|
||||
Task UpdateUrlSegmentCacheAsync(Guid key)
|
||||
=> CreateOrUpdateUrlSegmentsAsync(key);
|
||||
|
||||
/// <summary>
|
||||
/// Updates the in-memory URL segment cache for a document and its descendants without writing to the database.
|
||||
/// </summary>
|
||||
/// <param name="key">The document key.</param>
|
||||
// TODO (V19): Remove default implementation when external implementations have had time to adopt.
|
||||
Task UpdateUrlSegmentCacheWithDescendantsAsync(Guid key)
|
||||
=> CreateOrUpdateUrlSegmentsWithDescendantsAsync(key);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -83,7 +83,15 @@ internal sealed class MediaEditingService
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Attempt<ContentValidationResult, ContentEditingOperationStatus>> ValidateCreateAsync(MediaCreateModel createModel)
|
||||
=> await ValidatePropertiesAsync(createModel, createModel.ContentTypeKey);
|
||||
{
|
||||
ContentEditingOperationStatus creationAllowedStatus = await ValidateCreationAllowedAsync(createModel);
|
||||
if (creationAllowedStatus != ContentEditingOperationStatus.Success)
|
||||
{
|
||||
return Attempt.FailWithStatus(creationAllowedStatus, new ContentValidationResult());
|
||||
}
|
||||
|
||||
return await ValidatePropertiesAsync(createModel, createModel.ContentTypeKey);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Attempt<MediaCreateResult, ContentEditingOperationStatus>> CreateAsync(MediaCreateModel createModel, Guid userKey)
|
||||
@@ -165,6 +173,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 +201,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 +213,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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -30,11 +30,48 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
|
||||
/// <summary>
|
||||
/// Bundles a navigation structure dictionary and its root keys into a single reference so that
|
||||
/// <see cref="HandleRebuildAsync"/> can swap both atomically with one <see cref="Interlocked.Exchange{T}"/>
|
||||
/// call and readers always observe a consistent pair.
|
||||
/// call and readers always observe a consistent pair. Also carries the per-snapshot
|
||||
/// descendants cache populated by <see cref="TryGetDescendantsKeysFromStructure"/>.
|
||||
/// </summary>
|
||||
private sealed record NavigationSnapshot(
|
||||
ConcurrentDictionary<Guid, NavigationNode> Structure,
|
||||
HashSet<Guid> Roots);
|
||||
HashSet<Guid> Roots)
|
||||
{
|
||||
private long _generation;
|
||||
|
||||
/// <summary>
|
||||
/// Cache of descendants <c>Guid[]</c> keyed by parent and an optional content-type
|
||||
/// filter. Populated lazily by <see cref="TryGetDescendantsKeysFromStructure"/> and
|
||||
/// cleared by <see cref="Invalidate"/> on any structural mutation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The composite key allows both <c>TryGetDescendantsKeys</c> (content-type =
|
||||
/// <c>null</c>) and <c>TryGetDescendantsKeysOfType</c> (content-type = the resolved
|
||||
/// <c>Guid</c>) to share one cache without their results contaminating each other.
|
||||
/// Realistic per-parent fan-out is bounded by the "allowed types" content model
|
||||
/// (typically 1-5 types per parent), and the cache is populated only for queries
|
||||
/// that actually run, so memory grows with the templates exercised rather than the
|
||||
/// theoretical product of (parents × content types).
|
||||
/// </remarks>
|
||||
public ConcurrentDictionary<(Guid Parent, Guid? ContentType), Guid[]> DescendantsCache { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// A monotonic counter incremented on every mutation. Used by readers to detect a
|
||||
/// concurrent mutation that occurred during their compute, so they can avoid writing
|
||||
/// a now-stale result back to <see cref="DescendantsCache"/>.
|
||||
/// </summary>
|
||||
public long Generation => Interlocked.Read(ref _generation);
|
||||
|
||||
/// <summary>
|
||||
/// Clears the descendants cache and bumps the generation. Call after any mutation to
|
||||
/// this snapshot's <see cref="Structure"/> or <see cref="Roots"/>.
|
||||
/// </summary>
|
||||
public void Invalidate()
|
||||
{
|
||||
Interlocked.Increment(ref _generation);
|
||||
DescendantsCache.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
private NavigationSnapshot _navigation = new(new(), []);
|
||||
private NavigationSnapshot _recycleBinNavigation = new(new(), []);
|
||||
@@ -164,7 +201,12 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
|
||||
/// </param>
|
||||
/// <returns><c>true</c> if the parent node exists in the structure; otherwise, <c>false</c>.</returns>
|
||||
public bool TryGetDescendantsKeys(Guid parentKey, out IEnumerable<Guid> descendantsKeys)
|
||||
=> TryGetDescendantsKeysFromStructure(_navigation.Structure, parentKey, out descendantsKeys);
|
||||
{
|
||||
// Snapshot to a local so cache lookups, the structure walk, and the generation check
|
||||
// all see the same NavigationSnapshot instance even if a rebuild swaps it in mid-call.
|
||||
NavigationSnapshot snapshot = _navigation;
|
||||
return TryGetDescendantsKeysFromStructure(snapshot.Structure, parentKey, out descendantsKeys, contentTypeKey: null, cachingSnapshot: snapshot);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to get all descendant node keys of a specific content type under a parent node.
|
||||
@@ -182,7 +224,11 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
|
||||
{
|
||||
if (TryGetContentTypeKey(contentTypeAlias, out Guid? contentTypeKey))
|
||||
{
|
||||
return TryGetDescendantsKeysFromStructure(_navigation.Structure, parentKey, out descendantsKeys, contentTypeKey);
|
||||
// Snapshot to a local so cache lookups, the structure walk, and the generation
|
||||
// check all see the same NavigationSnapshot instance even if a rebuild swaps it
|
||||
// in mid-call.
|
||||
NavigationSnapshot snapshot = _navigation;
|
||||
return TryGetDescendantsKeysFromStructure(snapshot.Structure, parentKey, out descendantsKeys, contentTypeKey, cachingSnapshot: snapshot);
|
||||
}
|
||||
|
||||
// Content type alias doesn't exist
|
||||
@@ -297,7 +343,10 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
|
||||
/// </param>
|
||||
/// <returns><c>true</c> if the parent node exists in the recycle bin; otherwise, <c>false</c>.</returns>
|
||||
public bool TryGetDescendantsKeysInBin(Guid parentKey, out IEnumerable<Guid> descendantsKeys)
|
||||
=> TryGetDescendantsKeysFromStructure(_recycleBinNavigation.Structure, parentKey, out descendantsKeys);
|
||||
{
|
||||
NavigationSnapshot snapshot = _recycleBinNavigation;
|
||||
return TryGetDescendantsKeysFromStructure(snapshot.Structure, parentKey, out descendantsKeys, contentTypeKey: null, cachingSnapshot: snapshot);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to get all ancestor node keys of a child node in the recycle bin navigation structure.
|
||||
@@ -375,8 +424,14 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
|
||||
|
||||
// Reset the SortOrder based on its new position in the bin
|
||||
nodeToRemove.UpdateSortOrder(_recycleBinNavigation.Structure.Count);
|
||||
return _recycleBinNavigation.Structure.TryAdd(nodeToRemove.Key, nodeToRemove) &&
|
||||
_navigation.Structure.TryRemove(key, out _);
|
||||
var moved = _recycleBinNavigation.Structure.TryAdd(nodeToRemove.Key, nodeToRemove) &&
|
||||
_navigation.Structure.TryRemove(key, out _);
|
||||
|
||||
// Both snapshots' descendant lists are now potentially stale.
|
||||
_navigation.Invalidate();
|
||||
_recycleBinNavigation.Invalidate();
|
||||
|
||||
return moved;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -418,6 +473,7 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
|
||||
|
||||
parentNode?.AddChild(_navigation.Structure, key);
|
||||
|
||||
_navigation.Invalidate();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -468,6 +524,7 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
|
||||
// Set the new parent for the node (if parent node is null - the node is moved to root)
|
||||
targetParentNode?.AddChild(_navigation.Structure, key);
|
||||
|
||||
_navigation.Invalidate();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -488,6 +545,18 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
|
||||
|
||||
node.UpdateSortOrder(newSortOrder);
|
||||
|
||||
// The parent's cached ordered-children snapshot sorts by child SortOrder and is now
|
||||
// stale — invalidate so the next read rebuilds against the new value.
|
||||
if (node.Parent is not null
|
||||
&& _navigation.Structure.TryGetValue(node.Parent.Value, out NavigationNode? parentNode))
|
||||
{
|
||||
parentNode.InvalidateOrderedChildren();
|
||||
}
|
||||
|
||||
// Descendants lists are sort-order-presorted (depth-first using each parent's
|
||||
// ordered children), so re-ordering a child re-orders any cached ancestor descendants.
|
||||
_navigation.Invalidate();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -510,7 +579,9 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
|
||||
|
||||
RemoveDescendantsRecursively(nodeToRemove);
|
||||
|
||||
return _recycleBinNavigation.Structure.TryRemove(key, out _);
|
||||
var removed = _recycleBinNavigation.Structure.TryRemove(key, out _);
|
||||
_recycleBinNavigation.Invalidate();
|
||||
return removed;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -545,8 +616,14 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
|
||||
// Restore the node and its descendants from the recycle bin to the main structure
|
||||
RestoreNodeAndDescendantsRecursively(nodeToRestore);
|
||||
|
||||
return _navigation.Structure.TryAdd(nodeToRestore.Key, nodeToRestore) &&
|
||||
_recycleBinNavigation.Structure.TryRemove(key, out _);
|
||||
var restored = _navigation.Structure.TryAdd(nodeToRestore.Key, nodeToRestore) &&
|
||||
_recycleBinNavigation.Structure.TryRemove(key, out _);
|
||||
|
||||
// Both snapshots' descendant lists are now potentially stale.
|
||||
_navigation.Invalidate();
|
||||
_recycleBinNavigation.Invalidate();
|
||||
|
||||
return restored;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -655,10 +732,9 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
|
||||
ConcurrentDictionary<Guid, NavigationNode> structure,
|
||||
Guid parentKey,
|
||||
out IEnumerable<Guid> descendantsKeys,
|
||||
Guid? contentTypeKey = null)
|
||||
Guid? contentTypeKey = null,
|
||||
NavigationSnapshot? cachingSnapshot = null)
|
||||
{
|
||||
var descendants = new List<Guid>();
|
||||
|
||||
if (structure.TryGetValue(parentKey, out NavigationNode? parentNode) is false)
|
||||
{
|
||||
// Parent doesn't exist
|
||||
@@ -666,9 +742,50 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
|
||||
return false;
|
||||
}
|
||||
|
||||
// Both unfiltered and content-type-filtered queries are cached, distinguished by the
|
||||
// optional contentTypeKey in the composite key. Realistic per-parent fan-out is bounded
|
||||
// by the "allowed types" model (a few types per parent), and entries are populated
|
||||
// lazily for queries that actually run — so memory tracks the templates exercised, not
|
||||
// the theoretical product of (parents × types).
|
||||
var useCache = cachingSnapshot is not null;
|
||||
#pragma warning disable IDE0008 // Use explicit type (in this case using var improves the readability of the tuple key).
|
||||
var cacheKey = (parentKey, contentTypeKey);
|
||||
#pragma warning restore IDE0008 // Use explicit type
|
||||
|
||||
if (useCache && cachingSnapshot!.DescendantsCache.TryGetValue(cacheKey, out Guid[]? cached))
|
||||
{
|
||||
descendantsKeys = cached;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Capture the snapshot's mutation generation BEFORE walking. If a mutation invalidates
|
||||
// between here and the cache write, the result we computed may be stale relative to
|
||||
// the now-current Structure; we still hand it to the caller (it was correct at the
|
||||
// moment we read), but skip the cache write so future readers don't see stale data.
|
||||
var startGeneration = useCache ? cachingSnapshot!.Generation : 0;
|
||||
|
||||
var descendants = new List<Guid>();
|
||||
GetDescendantsRecursively(structure, parentNode, descendants, contentTypeKey);
|
||||
|
||||
descendantsKeys = descendants;
|
||||
if (useCache)
|
||||
{
|
||||
Guid[] result = [.. descendants];
|
||||
|
||||
// Only install if no mutation happened during compute, and skip caching empty
|
||||
// results — they're cheap to recompute and caching them bloats the dictionary with
|
||||
// one entry per (parent, type) pair queried with no measurable benefit.
|
||||
if (result.Length > 0 && cachingSnapshot!.Generation == startGeneration)
|
||||
{
|
||||
cachingSnapshot.DescendantsCache[cacheKey] = result;
|
||||
}
|
||||
|
||||
descendantsKeys = result;
|
||||
}
|
||||
else
|
||||
{
|
||||
descendantsKeys = descendants;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -859,6 +976,15 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
|
||||
return [];
|
||||
}
|
||||
|
||||
// Unfiltered case uses the cached snapshot maintained on the node — returns the same
|
||||
// sorted Guid[] across calls until the children set or a child's SortOrder changes.
|
||||
if (contentTypeKey.HasValue is false)
|
||||
{
|
||||
return node.GetOrderedChildren(structure);
|
||||
}
|
||||
|
||||
// Filtered-by-content-type case stays uncached: it would need a composite (node, type)
|
||||
// key to memoise, and the call site is rare enough not to be worth it.
|
||||
var childrenWithSortOrder = new List<(Guid ChildNodeKey, int SortOrder)>(node.Children.Count);
|
||||
foreach (Guid childNodeKey in node.Children)
|
||||
{
|
||||
@@ -867,8 +993,7 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
|
||||
continue;
|
||||
}
|
||||
|
||||
// Apply contentTypeKey filter
|
||||
if (contentTypeKey.HasValue && childNode.ContentTypeKey != contentTypeKey.Value)
|
||||
if (childNode.ContentTypeKey != contentTypeKey.Value)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -56,14 +56,17 @@ internal sealed class PublishedContentStatusFilteringService : IPublishedContent
|
||||
_publishStatusQueryService.IsDocumentPublished(key, culture)
|
||||
&& _publishStatusQueryService.HasPublishedAncestorPath(key, culture));
|
||||
|
||||
return WhereIsInvariantOrHasCultureOrRequestedAllCultures(candidateKeys, culture, preview).ToArray();
|
||||
// Returned lazily so consumers like .FirstOrDefault() / .Take(n) can short-circuit
|
||||
// without materialising the full result. Callers that need to enumerate the result
|
||||
// more than once should buffer it themselves (.ToList() / .ToArray()).
|
||||
return WhereIsInvariantOrHasCultureOrRequestedAllCultures(candidateKeys, culture, preview);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<IPublishedContent> Unfiltered(IEnumerable<Guid> candidateKeys)
|
||||
{
|
||||
var preview = _previewService.IsInPreview();
|
||||
return candidateKeys.Select(key => _publishedContentCache.GetById(preview, key)).WhereNotNull().ToArray();
|
||||
return candidateKeys.Select(key => _publishedContentCache.GetById(preview, key)).WhereNotNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -24,10 +24,15 @@ internal sealed class PublishedMediaStatusFilteringService : IPublishedMediaStat
|
||||
=> _publishedMediaCache = publishedMediaCache;
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Returned lazily so consumers like .FirstOrDefault() / .Take(n) can short-circuit without
|
||||
/// materialising the full result. Callers that need to enumerate the result more than once
|
||||
/// should buffer it themselves (.ToList() / .ToArray()).
|
||||
/// </remarks>
|
||||
public IEnumerable<IPublishedContent> FilterAvailable(IEnumerable<Guid> candidateKeys, string? culture)
|
||||
=> candidateKeys.Select(_publishedMediaCache.GetById).WhereNotNull().ToArray();
|
||||
=> candidateKeys.Select(_publishedMediaCache.GetById).WhereNotNull();
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<IPublishedContent> Unfiltered(IEnumerable<Guid> candidateKeys)
|
||||
=> candidateKeys.Select(_publishedMediaCache.GetById).WhereNotNull().ToArray();
|
||||
=> candidateKeys.Select(_publishedMediaCache.GetById).WhereNotNull();
|
||||
}
|
||||
|
||||
@@ -43,6 +43,8 @@ public static class UdiEntityTypeHelper
|
||||
return Constants.UdiEntityType.DataTypeContainer;
|
||||
case UmbracoObjectTypes.MemberType:
|
||||
return Constants.UdiEntityType.MemberType;
|
||||
case UmbracoObjectTypes.MemberTypeContainer:
|
||||
return Constants.UdiEntityType.MemberTypeContainer;
|
||||
case UmbracoObjectTypes.MemberGroup:
|
||||
return Constants.UdiEntityType.MemberGroup;
|
||||
case UmbracoObjectTypes.RelationType:
|
||||
@@ -75,6 +77,8 @@ public static class UdiEntityTypeHelper
|
||||
return UmbracoObjectTypes.Document;
|
||||
case Constants.UdiEntityType.DocumentBlueprint:
|
||||
return UmbracoObjectTypes.DocumentBlueprint;
|
||||
case Constants.UdiEntityType.DocumentBlueprintContainer:
|
||||
return UmbracoObjectTypes.DocumentBlueprintContainer;
|
||||
case Constants.UdiEntityType.Media:
|
||||
return UmbracoObjectTypes.Media;
|
||||
case Constants.UdiEntityType.Member:
|
||||
@@ -95,6 +99,8 @@ public static class UdiEntityTypeHelper
|
||||
return UmbracoObjectTypes.DataTypeContainer;
|
||||
case Constants.UdiEntityType.MemberType:
|
||||
return UmbracoObjectTypes.MemberType;
|
||||
case Constants.UdiEntityType.MemberTypeContainer:
|
||||
return UmbracoObjectTypes.MemberTypeContainer;
|
||||
case Constants.UdiEntityType.MemberGroup:
|
||||
return UmbracoObjectTypes.MemberGroup;
|
||||
case Constants.UdiEntityType.RelationType:
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Umbraco.Cms.Core.Configuration;
|
||||
|
||||
@@ -14,37 +9,48 @@ namespace Umbraco.Cms.Infrastructure.BackgroundJobs
|
||||
public class DelayCalculator
|
||||
{
|
||||
/// <summary>
|
||||
/// Determines the delay before the first run of a recurring task implemented as a hosted service when an optonal
|
||||
/// configuration for the first run time is available.
|
||||
/// Determines the delay before the first run of a recurring task, using a <see cref="TimeProvider" /> for the current time.
|
||||
/// </summary>
|
||||
/// <param name="firstRunTime">The configured time to first run the task in crontab format.</param>
|
||||
/// <param name="cronTabParser">An instance of <see cref="ICronTabParser"/></param>
|
||||
/// <param name="cronTabParser">An instance of <see cref="ICronTabParser" />.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
/// <param name="timeProvider">The time provider used to determine the current time.</param>
|
||||
/// <param name="defaultDelay">The default delay to use when a first run time is not configured.</param>
|
||||
/// <returns>The delay before first running the recurring task.</returns>
|
||||
public static TimeSpan GetDelay(
|
||||
string firstRunTime,
|
||||
ICronTabParser cronTabParser,
|
||||
ILogger logger,
|
||||
TimeSpan defaultDelay) => GetDelay(firstRunTime, cronTabParser, logger, DateTime.Now, defaultDelay);
|
||||
/// <returns>
|
||||
/// The delay before first running the recurring task.
|
||||
/// </returns>
|
||||
public static TimeSpan GetDelay(string firstRunTime, ICronTabParser cronTabParser, ILogger logger, TimeProvider timeProvider, TimeSpan defaultDelay)
|
||||
=> GetDelay(firstRunTime, cronTabParser, logger, timeProvider.GetLocalNow().DateTime, defaultDelay);
|
||||
|
||||
/// <summary>
|
||||
/// Determines the delay before the first run of a recurring task implemented as a hosted service when an optonal
|
||||
/// configuration for the first run time is available.
|
||||
/// Determines the delay before the first run of a recurring task implemented as a hosted service when an optional configuration for the first run time is available.
|
||||
/// </summary>
|
||||
/// <param name="firstRunTime">The configured time to first run the task in crontab format.</param>
|
||||
/// <param name="cronTabParser">An instance of <see cref="ICronTabParser"/></param>
|
||||
/// <param name="cronTabParser">An instance of <see cref="ICronTabParser" />.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
/// <param name="defaultDelay">The default delay to use when a first run time is not configured.</param>
|
||||
/// <returns>
|
||||
/// The delay before first running the recurring task.
|
||||
/// </returns>
|
||||
[Obsolete("Use the overload accepting TimeProvider. Scheduled for removal in Umbraco 19.")]
|
||||
public static TimeSpan GetDelay(string firstRunTime, ICronTabParser cronTabParser, ILogger logger, TimeSpan defaultDelay)
|
||||
=> GetDelay(firstRunTime, cronTabParser, logger, DateTime.Now, defaultDelay);
|
||||
|
||||
/// <summary>
|
||||
/// Determines the delay before the first run of a recurring task implemented as a hosted service when an optional configuration for the first run time is available.
|
||||
/// </summary>
|
||||
/// <param name="firstRunTime">The configured time to first run the task in crontab format.</param>
|
||||
/// <param name="cronTabParser">An instance of <see cref="ICronTabParser" />.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
/// <param name="now">The current datetime.</param>
|
||||
/// <param name="defaultDelay">The default delay to use when a first run time is not configured.</param>
|
||||
/// <returns>The delay before first running the recurring task.</returns>
|
||||
/// <remarks>Internal to expose for unit tests.</remarks>
|
||||
internal static TimeSpan GetDelay(
|
||||
string firstRunTime,
|
||||
ICronTabParser cronTabParser,
|
||||
ILogger logger,
|
||||
DateTime now,
|
||||
TimeSpan defaultDelay)
|
||||
/// <returns>
|
||||
/// The delay before first running the recurring task.
|
||||
/// </returns>
|
||||
/// <remarks>
|
||||
/// Internal to expose for unit tests.
|
||||
/// </remarks>
|
||||
internal static TimeSpan GetDelay(string firstRunTime, ICronTabParser cronTabParser, ILogger logger, DateTime now, TimeSpan defaultDelay)
|
||||
{
|
||||
// If first run time not set, start with just small delay after application start.
|
||||
if (string.IsNullOrEmpty(firstRunTime))
|
||||
@@ -56,12 +62,14 @@ namespace Umbraco.Cms.Infrastructure.BackgroundJobs
|
||||
if (!cronTabParser.IsValidCronTab(firstRunTime))
|
||||
{
|
||||
logger.LogWarning("Could not parse {FirstRunTime} as a crontab expression. Defaulting to default delay for hosted service start.", firstRunTime);
|
||||
|
||||
return defaultDelay;
|
||||
}
|
||||
|
||||
// Otherwise start at scheduled time according to cron expression, unless within the default delay period.
|
||||
DateTime firstRunOccurance = cronTabParser.GetNextOccurrence(firstRunTime, now);
|
||||
TimeSpan delay = firstRunOccurance - now;
|
||||
DateTime firstRunOccurrence = cronTabParser.GetNextOccurrence(firstRunTime, now);
|
||||
TimeSpan delay = firstRunOccurrence - now;
|
||||
|
||||
return delay < defaultDelay
|
||||
? defaultDelay
|
||||
: delay;
|
||||
|
||||
@@ -1,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>
|
||||
|
||||
@@ -1,38 +1,98 @@
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Sync;
|
||||
|
||||
namespace Umbraco.Cms.Infrastructure.BackgroundJobs;
|
||||
|
||||
/// <summary>
|
||||
/// A recurring background job
|
||||
/// A recurring background job.
|
||||
/// </summary>
|
||||
public interface IRecurringBackgroundJob
|
||||
{
|
||||
static readonly TimeSpan DefaultDelay = System.TimeSpan.FromMinutes(3);
|
||||
static readonly ServerRole[] DefaultServerRoles = new[] { ServerRole.Single, ServerRole.SchedulingPublisher };
|
||||
/// <summary>
|
||||
/// The default delay to use for recurring tasks for the first run after application start-up if no alternative is configured.
|
||||
/// </summary>
|
||||
[Obsolete("Use RecurringBackgroundJobBase.DefaultDelay instead. Scheduled for removal in Umbraco 19.")]
|
||||
static readonly TimeSpan DefaultDelay = RecurringBackgroundJobBase.DefaultDelay;
|
||||
|
||||
/// <summary>
|
||||
/// The default server roles that recurring background jobs run on.
|
||||
/// </summary>
|
||||
[Obsolete("Use RecurringBackgroundJobBase.DefaultServerRoles instead. Scheduled for removal in Umbraco 19.")]
|
||||
static readonly ServerRole[] DefaultServerRoles = RecurringBackgroundJobBase.DefaultServerRoles;
|
||||
|
||||
/// <summary>
|
||||
/// Timespan representing how often the task should recur.
|
||||
/// </summary>
|
||||
/// <value>
|
||||
/// The period.
|
||||
/// </value>
|
||||
/// <remarks>
|
||||
/// Set to <see cref="Timeout.InfiniteTimeSpan" /> to (temporarily) disable automatic scheduling and turn the job into a manually triggered one (via <see cref="IRecurringBackgroundJobTrigger{TJob}" />). To change the period at runtime, subclasses of <see cref="RecurringBackgroundJobBase" /> assign the protected setter on <see cref="RecurringBackgroundJobBase.Period" /> (which auto-raises <see cref="PeriodChanged" />); direct implementors of this interface must raise <see cref="PeriodChanged" /> themselves after updating the backing value.
|
||||
/// </remarks>
|
||||
TimeSpan Period { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Timespan representing the initial delay after application start-up before the first run of the task
|
||||
/// occurs.
|
||||
/// Timespan representing the initial delay after application start-up before the first run of the task occurs.
|
||||
/// </summary>
|
||||
TimeSpan Delay { get => DefaultDelay; }
|
||||
/// <value>
|
||||
/// The delay.
|
||||
/// </value>
|
||||
/// <remarks>
|
||||
/// Set to <see cref="Timeout.InfiniteTimeSpan" /> to skip the automatic first run entirely; the first execution then only occurs when manually triggered via <see cref="IRecurringBackgroundJobTrigger{TJob}" />.
|
||||
/// </remarks>
|
||||
TimeSpan Delay => RecurringBackgroundJobBase.DefaultDelay; // TODO (V19): Remove the default implementation
|
||||
|
||||
/// <summary>
|
||||
/// Gets the server roles for which this recurring background job is intended.
|
||||
/// Timespan to wait before re-evaluating execution conditions when an execution is ignored (e.g. runtime not ready, wrong server role or not main domain).
|
||||
/// </summary>
|
||||
ServerRole[] ServerRoles { get => DefaultServerRoles; }
|
||||
|
||||
event EventHandler PeriodChanged;
|
||||
/// <value>
|
||||
/// The ignored delay.
|
||||
/// </value>
|
||||
/// <remarks>
|
||||
/// This back-off prevents tight looping when <see cref="Period" /> is short (or <see cref="TimeSpan.Zero" />) and an execution is skipped without invoking <see cref="RunJobAsync(CancellationToken)" />.
|
||||
/// Set to <see cref="Timeout.InfiniteTimeSpan" /> to disable the job for the remaining application lifecycle once an ignored condition is encountered — useful when the condition is known not to change (e.g. a server role that will not be promoted on this instance). To change the ignored delay at runtime, subclasses of <see cref="RecurringBackgroundJobBase" /> assign the protected setter on <see cref="RecurringBackgroundJobBase.IgnoredDelay" /> (which auto-raises <see cref="IgnoredDelayChanged" />); direct implementors of this interface must raise <see cref="IgnoredDelayChanged" /> themselves after updating the backing value.
|
||||
/// </remarks>
|
||||
TimeSpan IgnoredDelay => RecurringBackgroundJobBase.DefaultIgnoredDelay; // TODO (V19): Remove the default implementation
|
||||
|
||||
/// <summary>
|
||||
/// Executes the logic associated with the recurring background job asynchronously.
|
||||
/// Gets the server roles the task executes on.
|
||||
/// </summary>
|
||||
/// <returns>A <see cref="System.Threading.Tasks.Task"/> that represents the asynchronous execution of the background job.</returns>
|
||||
/// <value>
|
||||
/// The server roles.
|
||||
/// </value>
|
||||
ServerRole[] ServerRoles => RecurringBackgroundJobBase.DefaultServerRoles; // TODO (V19): Remove the default implementation
|
||||
|
||||
/// <summary>
|
||||
/// This event should be raised when the <see cref="Period" /> property changes to notify the background job manager to update the schedule for this job.
|
||||
/// </summary>
|
||||
event EventHandler PeriodChanged; // TODO (V19): Change to `event EventHandler? PeriodChanged;` so implementations can use field-like event syntax without manual backing-delegate accessors.
|
||||
|
||||
/// <summary>
|
||||
/// This event should be raised when the <see cref="IgnoredDelay" /> property changes (e.g. from <see cref="Timeout.InfiniteTimeSpan" /> back to a finite value) to interrupt any in-progress ignored back-off and re-read the new value.
|
||||
/// </summary>
|
||||
event EventHandler IgnoredDelayChanged
|
||||
{
|
||||
add { }
|
||||
remove { }
|
||||
} // TODO (V19): Remove the default implementation and change to `event EventHandler? IgnoredDelayChanged;` so implementations can use field-like event syntax without manual backing-delegate accessors.
|
||||
|
||||
/// <summary>
|
||||
/// Runs the background job.
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// A task representing the asynchronous operation.
|
||||
/// </returns>
|
||||
[Obsolete("Use RunJobAsync(CancellationToken) instead. Scheduled for removal in Umbraco 19.")]
|
||||
Task RunJobAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs the background job with cancellation support.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">A cancellation token that is signaled when the host is shutting down.</param>
|
||||
/// <returns>
|
||||
/// A task representing the asynchronous operation.
|
||||
/// </returns>
|
||||
Task RunJobAsync(CancellationToken cancellationToken)
|
||||
#pragma warning disable CS0618 // Type or member is obsolete
|
||||
=> RunJobAsync(); // TODO (V19): Remove the default implementation when RunJobAsync() is removed
|
||||
#pragma warning restore CS0618 // Type or member is obsolete
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// Copyright (c) Umbraco.
|
||||
// See LICENSE for more details.
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Umbraco.Cms.Infrastructure.HostedServices;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Infrastructure.BackgroundJobs;
|
||||
|
||||
/// <summary>
|
||||
/// Provides methods to signal a specific recurring background job to execute immediately.
|
||||
/// </summary>
|
||||
/// <typeparam name="TJob">The type of the recurring background job to trigger, as registered via <see cref="ServiceCollectionExtensions.AddRecurringBackgroundJob{TJob}(IServiceCollection)" />.</typeparam>
|
||||
public interface IRecurringBackgroundJobTrigger<TJob>
|
||||
where TJob : class, ITriggerableRecurringBackgroundJob
|
||||
{
|
||||
/// <summary>
|
||||
/// Signals the background loop to execute immediately.
|
||||
/// After the triggered execution, the original schedule is kept.
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// <c>true</c> if the job was found and triggered; <c>false</c> if no hosted service is running for this job type.
|
||||
/// </returns>
|
||||
/// <seealso cref="NextExecutionStrategy.None" />
|
||||
bool TriggerExecution();
|
||||
|
||||
/// <summary>
|
||||
/// Signals the background loop to execute immediately, with the specified strategy for determining the next execution after the triggered one completes.
|
||||
/// </summary>
|
||||
/// <param name="strategy">Controls the delay after the triggered execution.</param>
|
||||
/// <returns>
|
||||
/// <c>true</c> if the job was found and triggered; <c>false</c> if no hosted service is running for this job type.
|
||||
/// </returns>
|
||||
bool TriggerExecution(NextExecutionStrategy strategy);
|
||||
|
||||
/// <summary>
|
||||
/// Signals the background loop to execute immediately.
|
||||
/// After the triggered execution, the next execution is scheduled after the specified delay (measured from execution start; execution time is subtracted to prevent drift).
|
||||
/// </summary>
|
||||
/// <param name="nextDelay">The target interval from execution start to the next execution. Execution time is subtracted to prevent drift.</param>
|
||||
/// <returns>
|
||||
/// <c>true</c> if the job was found and triggered; <c>false</c> if no hosted service is running for this job type.
|
||||
/// </returns>
|
||||
bool TriggerExecution(TimeSpan nextDelay);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// Copyright (c) Umbraco.
|
||||
// See LICENSE for more details.
|
||||
|
||||
namespace Umbraco.Cms.Infrastructure.BackgroundJobs;
|
||||
|
||||
/// <summary>
|
||||
/// Marker interface for recurring background jobs that support being triggered manually.
|
||||
/// Only jobs implementing this interface can be triggered via <see cref="IRecurringBackgroundJobTrigger{TJob}" />.
|
||||
/// </summary>
|
||||
public interface ITriggerableRecurringBackgroundJob : IRecurringBackgroundJob
|
||||
{ }
|
||||
+10
-2
@@ -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 />
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Umbraco.Cms.Core.DependencyInjection;
|
||||
using Umbraco.Cms.Core.Serialization;
|
||||
using Umbraco.Cms.Core.Sync;
|
||||
using Umbraco.Cms.Core.Telemetry;
|
||||
@@ -12,33 +10,18 @@ namespace Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs;
|
||||
/// <summary>
|
||||
/// Represents a background job that collects and reports information about the current Umbraco site, typically for analytics, diagnostics, or telemetry purposes.
|
||||
/// </summary>
|
||||
public class ReportSiteJob : IRecurringBackgroundJob
|
||||
public class ReportSiteJob : RecurringBackgroundJobBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the period at which the report site job runs.
|
||||
/// </summary>
|
||||
public TimeSpan Period => TimeSpan.FromDays(1);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the time interval to wait between executions of the <see cref="ReportSiteJob"/>.
|
||||
/// The delay is set to 5 minutes.
|
||||
/// </summary>
|
||||
public TimeSpan Delay => TimeSpan.FromMinutes(5);
|
||||
public override TimeSpan Delay => TimeSpan.FromMinutes(5);
|
||||
|
||||
/// <summary>
|
||||
/// Gets an array containing all possible values of the <see cref="ServerRole"/> enumeration.
|
||||
/// </summary>
|
||||
public ServerRole[] ServerRoles => Enum.GetValues<ServerRole>();
|
||||
|
||||
/// <summary>
|
||||
/// Event that is triggered when the reporting period for the site job is changed.
|
||||
/// </summary>
|
||||
/// <remarks>No-op event as the period never changes on this job</remarks>
|
||||
public event EventHandler PeriodChanged
|
||||
{
|
||||
add { }
|
||||
remove { }
|
||||
}
|
||||
public override ServerRole[] ServerRoles => Enum.GetValues<ServerRole>();
|
||||
|
||||
private readonly ILogger<ReportSiteJob> _logger;
|
||||
private readonly ITelemetryService _telemetryService;
|
||||
@@ -57,6 +40,7 @@ public class ReportSiteJob : IRecurringBackgroundJob
|
||||
ITelemetryService telemetryService,
|
||||
IJsonSerializer jsonSerializer,
|
||||
IHttpClientFactory httpClientFactory)
|
||||
: base(TimeSpan.FromDays(1))
|
||||
{
|
||||
_logger = logger;
|
||||
_telemetryService = telemetryService;
|
||||
@@ -67,8 +51,11 @@ public class ReportSiteJob : IRecurringBackgroundJob
|
||||
/// <summary>
|
||||
/// Executes the background job that sends the anonymous site ID to the telemetry service.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async Task RunJobAsync()
|
||||
/// <param name="cancellationToken">A cancellation token that is signaled when the host is shutting down.</param>
|
||||
/// <returns>
|
||||
/// A task that represents the asynchronous operation.
|
||||
/// </returns>
|
||||
public override async Task RunJobAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
TelemetryReportData? telemetryReportData = await _telemetryService.GetTelemetryReportDataAsync().ConfigureAwait(false);
|
||||
if (telemetryReportData is null)
|
||||
@@ -100,7 +87,7 @@ public class ReportSiteJob : IRecurringBackgroundJob
|
||||
// Make a HTTP Post to telemetry service
|
||||
// https://telemetry.umbraco.com/installs/
|
||||
// Fire & Forget, do not need to know if its a 200, 500 etc
|
||||
using (await httpClient.SendAsync(request))
|
||||
using (await httpClient.SendAsync(request, cancellationToken))
|
||||
{ }
|
||||
}
|
||||
catch
|
||||
|
||||
+66
-24
@@ -3,9 +3,7 @@
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Configuration.Models;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Core.Sync;
|
||||
|
||||
namespace Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs.ServerRegistration;
|
||||
@@ -13,31 +11,22 @@ namespace Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs.ServerRegistration;
|
||||
/// <summary>
|
||||
/// Implements periodic database instruction processing as a hosted service.
|
||||
/// </summary>
|
||||
public class InstructionProcessJob : IRecurringBackgroundJob
|
||||
public class InstructionProcessJob : RecurringBackgroundJobBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the interval between executions of the instruction process job.
|
||||
/// </summary>
|
||||
public TimeSpan Period { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the delay time before the job is executed. The delay is fixed at one minute.
|
||||
/// </summary>
|
||||
public TimeSpan Delay { get => TimeSpan.FromMinutes(1); }
|
||||
public override TimeSpan Delay => TimeSpan.FromMinutes(1);
|
||||
|
||||
/// <summary>
|
||||
/// Gets an array containing all possible values of the <see cref="ServerRole"/> enumeration.
|
||||
/// </summary>
|
||||
public ServerRole[] ServerRoles { get => Enum.GetValues<ServerRole>(); }
|
||||
|
||||
/// <summary>
|
||||
/// Event that is raised when the execution period of the <see cref="InstructionProcessJob"/> is changed.
|
||||
/// </summary>
|
||||
/// <remarks>No-op event as the period never changes on this job</remarks>
|
||||
public event EventHandler PeriodChanged { add { } remove { } }
|
||||
public override ServerRole[] ServerRoles => Enum.GetValues<ServerRole>();
|
||||
|
||||
private readonly ILogger<InstructionProcessJob> _logger;
|
||||
private readonly IServerMessenger _messenger;
|
||||
private readonly TimeSpan _syncTimeout;
|
||||
private Task? _inFlightSync;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="InstructionProcessJob" /> class.
|
||||
@@ -49,29 +38,82 @@ public class InstructionProcessJob : IRecurringBackgroundJob
|
||||
IServerMessenger messenger,
|
||||
ILogger<InstructionProcessJob> logger,
|
||||
IOptions<GlobalSettings> globalSettings)
|
||||
: base(globalSettings.Value.DatabaseServerMessenger.TimeBetweenSyncOperations)
|
||||
{
|
||||
_messenger = messenger;
|
||||
_logger = logger;
|
||||
_syncTimeout = ValidateSyncTimeout(globalSettings.Value.DatabaseServerMessenger.SyncTimeout);
|
||||
}
|
||||
|
||||
Period = globalSettings.Value.DatabaseServerMessenger.TimeBetweenSyncOperations;
|
||||
// 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>
|
||||
/// <returns>A completed task representing the asynchronous operation.</returns>
|
||||
public Task RunJobAsync()
|
||||
/// <param name="cancellationToken">A cancellation token that is signaled when the host is shutting down.</param>
|
||||
/// <returns>
|
||||
/// A task representing the asynchronous operation.
|
||||
/// </returns>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
+94
-40
@@ -3,54 +3,38 @@
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Configuration.Models;
|
||||
using Umbraco.Cms.Core.Hosting;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Core.Sync;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs.ServerRegistration;
|
||||
|
||||
/// <summary>
|
||||
/// Implements periodic server "touching" (to mark as active/deactive) as a hosted service.
|
||||
/// </summary>
|
||||
public class TouchServerJob : IRecurringBackgroundJob
|
||||
public class TouchServerJob : RecurringBackgroundJobBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the period that defines how often the server should be touched.
|
||||
/// </summary>
|
||||
public TimeSpan Period { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the fixed delay interval of 15 seconds between executions of the touch server job.
|
||||
/// This interval determines how often the server registration is updated.
|
||||
/// </summary>
|
||||
public TimeSpan Delay { get => TimeSpan.FromSeconds(15); }
|
||||
public override TimeSpan Delay => TimeSpan.FromSeconds(15);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all server roles on which this job runs. This property returns every possible <see cref="ServerRole"/> value, indicating the job runs on all server roles.
|
||||
/// </summary>
|
||||
/// <remarks>Runs on all servers</remarks>
|
||||
public ServerRole[] ServerRoles { get => Enum.GetValues<ServerRole>(); }
|
||||
|
||||
private event EventHandler? _periodChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Occurs when the period of the TouchServerJob changes.
|
||||
/// </summary>
|
||||
public event EventHandler PeriodChanged
|
||||
{
|
||||
add { _periodChanged += value; }
|
||||
remove { _periodChanged -= value; }
|
||||
}
|
||||
|
||||
public override ServerRole[] ServerRoles => Enum.GetValues<ServerRole>();
|
||||
|
||||
private readonly IHostingEnvironment _hostingEnvironment;
|
||||
private readonly ILogger<TouchServerJob> _logger;
|
||||
private readonly IServerRegistrationService _serverRegistrationService;
|
||||
private readonly IServerRoleAccessor _serverRoleAccessor;
|
||||
private readonly IDisposable? _onChangeRegistration;
|
||||
private GlobalSettings _globalSettings;
|
||||
private TimeSpan _touchTimeout;
|
||||
private Task? _inFlightTouch;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TouchServerJob" /> class.
|
||||
@@ -66,21 +50,20 @@ public class TouchServerJob : IRecurringBackgroundJob
|
||||
ILogger<TouchServerJob> logger,
|
||||
IOptionsMonitor<GlobalSettings> globalSettings,
|
||||
IServerRoleAccessor serverRoleAccessor)
|
||||
: base(globalSettings.CurrentValue.DatabaseServerRegistrar.WaitTimeBetweenCalls)
|
||||
{
|
||||
_serverRegistrationService = serverRegistrationService ??
|
||||
throw new ArgumentNullException(nameof(serverRegistrationService));
|
||||
_serverRegistrationService = serverRegistrationService ?? throw new ArgumentNullException(nameof(serverRegistrationService));
|
||||
_hostingEnvironment = hostingEnvironment;
|
||||
_logger = logger;
|
||||
_globalSettings = globalSettings.CurrentValue;
|
||||
_serverRoleAccessor = serverRoleAccessor;
|
||||
_touchTimeout = ValidateTouchTimeout(globalSettings.CurrentValue.DatabaseServerRegistrar.TouchTimeout);
|
||||
|
||||
Period = _globalSettings.DatabaseServerRegistrar.WaitTimeBetweenCalls;
|
||||
globalSettings.OnChange(x =>
|
||||
_onChangeRegistration = globalSettings.OnChange(x =>
|
||||
{
|
||||
_globalSettings = x;
|
||||
Period = x.DatabaseServerRegistrar.WaitTimeBetweenCalls;
|
||||
|
||||
_periodChanged?.Invoke(this, EventArgs.Empty);
|
||||
_touchTimeout = ValidateTouchTimeout(x.DatabaseServerRegistrar.TouchTimeout);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -88,36 +71,107 @@ public class TouchServerJob : IRecurringBackgroundJob
|
||||
/// Executes the job that updates the server registration by touching the server record in the database.
|
||||
/// This keeps the server's registration active and ensures its status remains current.
|
||||
/// </summary>
|
||||
/// <returns>A completed task when the job has finished running.</returns>
|
||||
public Task RunJobAsync()
|
||||
/// <param name="cancellationToken">A cancellation token that is signaled when the host is shutting down.</param>
|
||||
/// <returns>
|
||||
/// A completed task when the job has finished running.
|
||||
/// </returns>
|
||||
public override 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 (serverAddress.IsNullOrWhiteSpace())
|
||||
if (string.IsNullOrWhiteSpace(serverAddress))
|
||||
{
|
||||
_logger.LogWarning("No umbracoApplicationUrl for service (yet), skip.");
|
||||
return Task.CompletedTask;
|
||||
// No application URL is known yet: either detection is off (WebRouting:ApplicationUrlDetection is
|
||||
// None with no UmbracoApplicationUrl set), or detection is on but no request has been served yet.
|
||||
// Register with the machine name as a placeholder so server-role election can still proceed (uniqueness
|
||||
// comes from the server identity, not this address). If a URL is later detected from a request, the next
|
||||
// touch overwrites the placeholder.
|
||||
serverAddress = Environment.MachineName;
|
||||
_logger.LogDebug(
|
||||
"No application URL available; registering server with placeholder address {ServerAddress}.",
|
||||
serverAddress);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("Registering server with application URL {ServerAddress}.", serverAddress);
|
||||
}
|
||||
|
||||
// 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 />
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
_onChangeRegistration?.Dispose();
|
||||
}
|
||||
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Umbraco.Cms.Core.IO;
|
||||
using Umbraco.Cms.Core.Runtime;
|
||||
using Umbraco.Cms.Core.Sync;
|
||||
|
||||
namespace Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs;
|
||||
@@ -15,24 +14,13 @@ namespace Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs;
|
||||
/// Will run on all servers - even though file upload should only be handled on the scheduling publisher, this will
|
||||
/// ensure that in the case it happens on subscribers that they are cleaned up too.
|
||||
/// </remarks>
|
||||
public class TempFileCleanupJob : IRecurringBackgroundJob
|
||||
public class TempFileCleanupJob : RecurringBackgroundJobBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the time interval between each execution of the temporary file cleanup job.
|
||||
/// </summary>
|
||||
public TimeSpan Period { get => TimeSpan.FromMinutes(60); }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the server roles on which this job runs. This job is configured to run on all server roles.
|
||||
/// </summary>
|
||||
/// <remarks>Runs on all servers</remarks>
|
||||
public ServerRole[] ServerRoles { get => Enum.GetValues<ServerRole>(); }
|
||||
|
||||
/// <summary>
|
||||
/// Occurs when the period of the TempFileCleanupJob changes.
|
||||
/// </summary>
|
||||
/// <remarks>No-op event as the period never changes on this job</remarks>
|
||||
public event EventHandler PeriodChanged { add { } remove { } }
|
||||
public override ServerRole[] ServerRoles => Enum.GetValues<ServerRole>();
|
||||
|
||||
private readonly TimeSpan _age = TimeSpan.FromDays(1);
|
||||
private readonly IIOHelper _ioHelper;
|
||||
@@ -45,28 +33,33 @@ public class TempFileCleanupJob : IRecurringBackgroundJob
|
||||
/// <param name="ioHelper">Helper service for IO operations.</param>
|
||||
/// <param name="logger">The typed logger.</param>
|
||||
public TempFileCleanupJob(IIOHelper ioHelper, ILogger<TempFileCleanupJob> logger)
|
||||
: base(TimeSpan.FromMinutes(60))
|
||||
{
|
||||
_ioHelper = ioHelper;
|
||||
_logger = logger;
|
||||
|
||||
_tempFolders = _ioHelper.GetTempFolders();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asynchronously executes the cleanup of temporary files in the configured temporary folders.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous cleanup operation.</returns>
|
||||
public Task RunJobAsync()
|
||||
/// <param name="cancellationToken">A cancellation token that is signaled when the host is shutting down.</param>
|
||||
/// <returns>
|
||||
/// A task that represents the asynchronous cleanup operation.
|
||||
/// </returns>
|
||||
public override Task RunJobAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (DirectoryInfo folder in _tempFolders)
|
||||
{
|
||||
CleanupFolder(folder);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
CleanupFolder(folder, cancellationToken);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void CleanupFolder(DirectoryInfo folder)
|
||||
private void CleanupFolder(DirectoryInfo folder, CancellationToken cancellationToken)
|
||||
{
|
||||
CleanFolderResult result = _ioHelper.CleanFolder(folder, _age);
|
||||
switch (result.Status)
|
||||
@@ -96,6 +89,8 @@ public class TempFileCleanupJob : IRecurringBackgroundJob
|
||||
FileInfo[] files = folder.GetFiles("*.*", SearchOption.AllDirectories);
|
||||
foreach (FileInfo file in files)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (DateTime.UtcNow - file.LastWriteTimeUtc > _age)
|
||||
{
|
||||
try
|
||||
@@ -110,5 +105,4 @@ public class TempFileCleanupJob : IRecurringBackgroundJob
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
using Umbraco.Cms.Core.Sync;
|
||||
|
||||
namespace Umbraco.Cms.Infrastructure.BackgroundJobs;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for recurring background jobs that provides default values for common properties.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Implementors must pass an initial <see cref="Period" /> to the base constructor and implement <see cref="RunJobAsync(CancellationToken)" />.
|
||||
/// </remarks>
|
||||
public abstract class RecurringBackgroundJobBase : IRecurringBackgroundJob, IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// The default delay to use for recurring tasks for the first run after application start-up if no alternative is configured.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The default of 3 minutes is chosen to allow the application to finish starting up and stabilize before the first execution of recurring tasks.
|
||||
/// </remarks>
|
||||
protected internal static readonly TimeSpan DefaultDelay = TimeSpan.FromMinutes(3);
|
||||
|
||||
/// <summary>
|
||||
/// The default back-off to use when an execution is ignored, before re-evaluating execution conditions.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The default of 1 minute prevents tight looping when an execution is skipped (e.g. runtime not ready, wrong server role or not main domain) and the configured <see cref="IRecurringBackgroundJob.Period" /> is short or <see cref="TimeSpan.Zero" />.
|
||||
/// </remarks>
|
||||
protected internal static readonly TimeSpan DefaultIgnoredDelay = TimeSpan.FromMinutes(1);
|
||||
|
||||
/// <summary>
|
||||
/// The default server roles that recurring background jobs run on.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The default of running on both <see cref="ServerRole.Single" /> and <see cref="ServerRole.SchedulingPublisher" /> is chosen to ensure recurring background jobs do not run on every server (in a load-balanced environment).
|
||||
/// </remarks>
|
||||
protected internal static readonly ServerRole[] DefaultServerRoles = [ServerRole.Single, ServerRole.SchedulingPublisher];
|
||||
|
||||
private TimeSpan _period;
|
||||
private TimeSpan _ignoredDelay = DefaultIgnoredDelay;
|
||||
private EventHandler? _periodChanged;
|
||||
private EventHandler? _ignoredDelayChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RecurringBackgroundJobBase" /> class with the specified initial <paramref name="period" />. The initial value is stored directly without raising <see cref="PeriodChanged" />.
|
||||
/// </summary>
|
||||
/// <param name="period">The initial period between executions. Set to <see cref="Timeout.InfiniteTimeSpan" /> for a manual-trigger-only job.</param>
|
||||
/// <exception cref="ArgumentOutOfRangeException">Thrown when <paramref name="period" /> is negative and not <see cref="Timeout.InfiniteTimeSpan" />.</exception>
|
||||
protected RecurringBackgroundJobBase(TimeSpan period)
|
||||
{
|
||||
if (period != Timeout.InfiniteTimeSpan)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(period, TimeSpan.Zero);
|
||||
}
|
||||
|
||||
_period = period;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Setting this property to a different value raises <see cref="PeriodChanged" />. The initial value passed to the constructor is stored without raising the event.
|
||||
/// </remarks>
|
||||
public virtual TimeSpan Period
|
||||
{
|
||||
get => _period;
|
||||
protected set
|
||||
{
|
||||
if (value != Timeout.InfiniteTimeSpan)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(value, TimeSpan.Zero);
|
||||
}
|
||||
|
||||
if (_period == value)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_period = value;
|
||||
OnPeriodChanged(EventArgs.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public virtual TimeSpan Delay => DefaultDelay;
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Setting this property to a different value raises <see cref="IgnoredDelayChanged" />. The initial value (<see cref="DefaultIgnoredDelay" />) is set without raising the event.
|
||||
/// </remarks>
|
||||
public virtual TimeSpan IgnoredDelay
|
||||
{
|
||||
get => _ignoredDelay;
|
||||
protected set
|
||||
{
|
||||
if (value != Timeout.InfiniteTimeSpan)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(value, TimeSpan.Zero);
|
||||
}
|
||||
|
||||
if (_ignoredDelay == value)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_ignoredDelay = value;
|
||||
OnIgnoredDelayChanged(EventArgs.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public virtual ServerRole[] ServerRoles => DefaultServerRoles;
|
||||
|
||||
/// <inheritdoc />
|
||||
public virtual event EventHandler PeriodChanged
|
||||
{
|
||||
add { _periodChanged += value; }
|
||||
remove { _periodChanged -= value; }
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public virtual event EventHandler IgnoredDelayChanged
|
||||
{
|
||||
add { _ignoredDelayChanged += value; }
|
||||
remove { _ignoredDelayChanged -= value; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Raises the <see cref="PeriodChanged" /> event.
|
||||
/// </summary>
|
||||
/// <param name="e">The <see cref="EventArgs" /> instance containing the event data.</param>
|
||||
/// <remarks>
|
||||
/// Override this when overriding <see cref="PeriodChanged" /> to dispatch through the overridden event's backing delegate.
|
||||
/// </remarks>
|
||||
protected virtual void OnPeriodChanged(EventArgs e)
|
||||
=> _periodChanged?.Invoke(this, e);
|
||||
|
||||
/// <summary>
|
||||
/// Raises the <see cref="IgnoredDelayChanged" /> event.
|
||||
/// </summary>
|
||||
/// <param name="e">The <see cref="EventArgs" /> instance containing the event data.</param>
|
||||
/// <remarks>
|
||||
/// Override this when overriding <see cref="IgnoredDelayChanged" /> to dispatch through the overridden event's backing delegate.
|
||||
/// </remarks>
|
||||
protected virtual void OnIgnoredDelayChanged(EventArgs e)
|
||||
=> _ignoredDelayChanged?.Invoke(this, e);
|
||||
|
||||
/// <inheritdoc />
|
||||
[Obsolete("Use RunJobAsync(CancellationToken) instead. Scheduled for removal in Umbraco 19.")]
|
||||
public Task RunJobAsync() => RunJobAsync(CancellationToken.None);
|
||||
|
||||
/// <inheritdoc />
|
||||
public abstract Task RunJobAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
Dispose(true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Releases the resources used by this job. Subclasses adding disposable state should override this method, dispose their own resources, and call <c>base.Dispose(disposing)</c>.
|
||||
/// </summary>
|
||||
/// <param name="disposing"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param>
|
||||
protected virtual void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
// Clear the subscriber delegates so the job does not retain references to (or invoke) listeners after disposal.
|
||||
_periodChanged = null;
|
||||
_ignoredDelayChanged = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,15 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Serilog.Core;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Configuration.Models;
|
||||
using Umbraco.Cms.Core.DependencyInjection;
|
||||
using Umbraco.Cms.Core.Events;
|
||||
using Umbraco.Cms.Core.Notifications;
|
||||
using Umbraco.Cms.Core.Runtime;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Core.Sync;
|
||||
using Umbraco.Cms.Infrastructure.HostedServices;
|
||||
using Umbraco.Cms.Infrastructure.Notifications;
|
||||
|
||||
namespace Umbraco.Cms.Infrastructure.BackgroundJobs;
|
||||
|
||||
@@ -23,32 +22,72 @@ public static class RecurringBackgroundJobHostedService
|
||||
/// Creates a factory function that produces hosted services for recurring background jobs.
|
||||
/// </summary>
|
||||
/// <param name="serviceProvider">The service provider used to create hosted service instances.</param>
|
||||
/// <returns>A function that takes an <see cref="IRecurringBackgroundJob"/> and returns an <see cref="IHostedService"/>.</returns>
|
||||
public static Func<IRecurringBackgroundJob, IHostedService> CreateHostedServiceFactory(IServiceProvider serviceProvider) =>
|
||||
(IRecurringBackgroundJob job) =>
|
||||
/// <returns>
|
||||
/// A function that takes an <see cref="IRecurringBackgroundJob" /> and returns an <see cref="IHostedService" />.
|
||||
/// </returns>
|
||||
public static Func<IRecurringBackgroundJob, IHostedService> CreateHostedServiceFactory(IServiceProvider serviceProvider)
|
||||
=> (IRecurringBackgroundJob job) =>
|
||||
{
|
||||
Type hostedServiceType = typeof(RecurringBackgroundJobHostedService<>).MakeGenericType(job.GetType());
|
||||
|
||||
return (IHostedService)ActivatorUtilities.CreateInstance(serviceProvider, hostedServiceType, job);
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs a recurring background job inside a hosted service.
|
||||
/// Generic version for DependencyInjection
|
||||
/// </summary>
|
||||
/// <typeparam name="TJob">Type of the Job</typeparam>
|
||||
public class RecurringBackgroundJobHostedService<TJob> : RecurringHostedServiceBase where TJob : IRecurringBackgroundJob
|
||||
/// <typeparam name="TJob">The type of the job.</typeparam>
|
||||
public class RecurringBackgroundJobHostedService<TJob> : RecurringHostedServiceBase
|
||||
where TJob : IRecurringBackgroundJob
|
||||
{
|
||||
|
||||
private readonly IRuntimeState _runtimeState;
|
||||
private readonly ILogger<RecurringBackgroundJobHostedService<TJob>> _logger;
|
||||
private readonly IMainDom _mainDom;
|
||||
private readonly IRuntimeState _runtimeState;
|
||||
private readonly IServerRoleAccessor _serverRoleAccessor;
|
||||
private readonly IEventAggregator _eventAggregator;
|
||||
private readonly IEventMessagesFactory _eventMessagesFactory;
|
||||
private readonly IRecurringBackgroundJob _job;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private CancellationTokenSource _ignoredDelayChangeCts = new();
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RecurringBackgroundJobHostedService{TJob}"/> class, which manages the execution of a recurring background job.
|
||||
/// Initializes a new instance of the <see cref="RecurringBackgroundJobHostedService{TJob}" /> class, which manages the execution of a recurring background job.
|
||||
/// </summary>
|
||||
/// <param name="runtimeState">Provides information about the current runtime state of the Umbraco application.</param>
|
||||
/// <param name="logger">The logger used to record diagnostic and operational information for this hosted service.</param>
|
||||
/// <param name="mainDom">The main domain instance responsible for coordinating single-instance operations across multiple application domains.</param>
|
||||
/// <param name="serverRoleAccessor">Determines the current server's role in a multi-server environment.</param>
|
||||
/// <param name="eventAggregator">Handles the publishing and subscribing of application events.</param>
|
||||
/// <param name="eventMessagesFactory">The event messages factory.</param>
|
||||
/// <param name="job">The recurring background job instance to be managed and executed by this service.</param>
|
||||
/// <param name="timeProvider">The time provider used for scheduling and elapsed time measurement.</param>
|
||||
public RecurringBackgroundJobHostedService(
|
||||
IRuntimeState runtimeState,
|
||||
ILogger<RecurringBackgroundJobHostedService<TJob>> logger,
|
||||
IMainDom mainDom,
|
||||
IServerRoleAccessor serverRoleAccessor,
|
||||
IEventAggregator eventAggregator,
|
||||
IEventMessagesFactory eventMessagesFactory,
|
||||
TJob job,
|
||||
TimeProvider timeProvider)
|
||||
: base(logger, job.Period, job.Delay, timeProvider)
|
||||
{
|
||||
_runtimeState = runtimeState;
|
||||
_logger = logger;
|
||||
_mainDom = mainDom;
|
||||
_serverRoleAccessor = serverRoleAccessor;
|
||||
_eventAggregator = eventAggregator;
|
||||
_eventMessagesFactory = eventMessagesFactory;
|
||||
_job = job;
|
||||
_timeProvider = timeProvider;
|
||||
|
||||
_job.PeriodChanged += OnPeriodChanged;
|
||||
_job.IgnoredDelayChanged += OnIgnoredDelayChanged;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RecurringBackgroundJobHostedService{TJob}" /> class, which manages the execution of a recurring background job.
|
||||
/// </summary>
|
||||
/// <param name="runtimeState">Provides information about the current runtime state of the Umbraco application.</param>
|
||||
/// <param name="logger">The logger used to record diagnostic and operational information for this hosted service.</param>
|
||||
@@ -56,6 +95,7 @@ public class RecurringBackgroundJobHostedService<TJob> : RecurringHostedServiceB
|
||||
/// <param name="serverRoleAccessor">Determines the current server's role in a multi-server environment.</param>
|
||||
/// <param name="eventAggregator">Handles the publishing and subscribing of application events.</param>
|
||||
/// <param name="job">The recurring background job instance to be managed and executed by this service.</param>
|
||||
[Obsolete("Use the constructor accepting IEventMessagesFactory and TimeProvider instead. Scheduled for removal in Umbraco 19.")]
|
||||
public RecurringBackgroundJobHostedService(
|
||||
IRuntimeState runtimeState,
|
||||
ILogger<RecurringBackgroundJobHostedService<TJob>> logger,
|
||||
@@ -63,94 +103,176 @@ public class RecurringBackgroundJobHostedService<TJob> : RecurringHostedServiceB
|
||||
IServerRoleAccessor serverRoleAccessor,
|
||||
IEventAggregator eventAggregator,
|
||||
TJob job)
|
||||
: base(logger, job.Period, job.Delay)
|
||||
{
|
||||
_runtimeState = runtimeState;
|
||||
_logger = logger;
|
||||
_mainDom = mainDom;
|
||||
_serverRoleAccessor = serverRoleAccessor;
|
||||
_eventAggregator = eventAggregator;
|
||||
_job = job;
|
||||
|
||||
_job.PeriodChanged += (sender, e) => ChangePeriod(_job.Period);
|
||||
}
|
||||
: this(runtimeState, logger, mainDom, serverRoleAccessor, eventAggregator, StaticServiceProvider.Instance.GetRequiredService<IEventMessagesFactory>(), job, TimeProvider.System)
|
||||
{ }
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task PerformExecuteAsync(object? state)
|
||||
public override async Task PerformExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
var executingNotification = new Notifications.RecurringBackgroundJobExecutingNotification(_job, new EventMessages());
|
||||
await _eventAggregator.PublishAsync(executingNotification);
|
||||
EventMessages eventMessages = _eventMessagesFactory.Get();
|
||||
var executingNotification = new RecurringBackgroundJobExecutingNotification(_job, eventMessages);
|
||||
await _eventAggregator.PublishAsync(executingNotification, stoppingToken);
|
||||
|
||||
try
|
||||
{
|
||||
|
||||
if (_runtimeState.Level != RuntimeLevel.Run)
|
||||
{
|
||||
_logger.LogDebug("Job not running as runlevel not yet ready");
|
||||
await _eventAggregator.PublishAsync(new Notifications.RecurringBackgroundJobIgnoredNotification(_job, new EventMessages()).WithStateFrom(executingNotification));
|
||||
await IgnoreAndWaitAsync("Job not running as runlevel not yet ready", eventMessages, executingNotification, stoppingToken);
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't run on replicas nor unknown role servers
|
||||
if (!_job.ServerRoles.Contains(_serverRoleAccessor.CurrentServerRole))
|
||||
{
|
||||
_logger.LogDebug("Job not running on this server role");
|
||||
await _eventAggregator.PublishAsync(new Notifications.RecurringBackgroundJobIgnoredNotification(_job, new EventMessages()).WithStateFrom(executingNotification));
|
||||
await IgnoreAndWaitAsync("Job not running on this server role", eventMessages, executingNotification, stoppingToken);
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure we do not run if not main domain, but do NOT lock it
|
||||
if (!_mainDom.IsMainDom)
|
||||
{
|
||||
_logger.LogDebug("Job not running as not MainDom");
|
||||
await _eventAggregator.PublishAsync(new Notifications.RecurringBackgroundJobIgnoredNotification(_job, new EventMessages()).WithStateFrom(executingNotification));
|
||||
await IgnoreAndWaitAsync("Job not running as not MainDom", eventMessages, executingNotification, stoppingToken);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
await _job.RunJobAsync();
|
||||
await _eventAggregator.PublishAsync(new Notifications.RecurringBackgroundJobExecutedNotification(_job, new EventMessages()).WithStateFrom(executingNotification));
|
||||
|
||||
|
||||
await _job.RunJobAsync(stoppingToken);
|
||||
await _eventAggregator.PublishAsync(new RecurringBackgroundJobExecutedNotification(_job, eventMessages).WithStateFrom(executingNotification), stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogDebug("Job canceled during shutdown.");
|
||||
await _eventAggregator.PublishAsync(new RecurringBackgroundJobCanceledNotification(_job, eventMessages).WithStateFrom(executingNotification), CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await _eventAggregator.PublishAsync(new Notifications.RecurringBackgroundJobFailedNotification(_job, new EventMessages()).WithStateFrom(executingNotification));
|
||||
_logger.LogError(ex, "Unhandled exception in recurring background job.");
|
||||
await _eventAggregator.PublishAsync(new RecurringBackgroundJobFailedNotification(_job, eventMessages).WithStateFrom(executingNotification), stoppingToken);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asynchronously starts the recurring background job and publishes notifications before and after the job is started.
|
||||
/// This method first publishes a <see cref="Notifications.RecurringBackgroundJobStartingNotification"/> prior to starting the job,
|
||||
/// then calls the base implementation to start the job, and finally publishes a <see cref="Notifications.RecurringBackgroundJobStartedNotification"/>.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
|
||||
/// <returns>A task that represents the asynchronous start operation.</returns>
|
||||
/// <inheritdoc />
|
||||
[Obsolete("Override PerformExecuteAsync(CancellationToken) instead. Scheduled for removal in Umbraco 19.")]
|
||||
public override Task PerformExecuteAsync(object? state) => PerformExecuteAsync(CancellationToken.None);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var startingNotification = new Notifications.RecurringBackgroundJobStartingNotification(_job, new EventMessages());
|
||||
await _eventAggregator.PublishAsync(startingNotification);
|
||||
EventMessages eventMessages = _eventMessagesFactory.Get();
|
||||
var startingNotification = new RecurringBackgroundJobStartingNotification(_job, eventMessages);
|
||||
await _eventAggregator.PublishAsync(startingNotification, cancellationToken);
|
||||
|
||||
await base.StartAsync(cancellationToken);
|
||||
// Suppress execution context flow around base.StartAsync so the fire-and-forget ExecuteAsync loop
|
||||
// does not capture AsyncLocal state from the host — in particular Umbraco's static AmbientScopeStack,
|
||||
// which uses a ConcurrentStack<IScope> reference that, once non-null, would be shared across every
|
||||
// hosted service that inherits this ExecutionContext. Without this, concurrent scope pushes/pops
|
||||
// across recurring loops and other hosted services interleave and trigger "not the ambient scope"
|
||||
// errors at Scope.Dispose (see DistributedJobService.EnsureJobsAsync for the original repro).
|
||||
Task startTask;
|
||||
using (ExecutionContext.IsFlowSuppressed() ? null : (IDisposable?)ExecutionContext.SuppressFlow())
|
||||
{
|
||||
startTask = base.StartAsync(cancellationToken);
|
||||
}
|
||||
|
||||
await _eventAggregator.PublishAsync(new Notifications.RecurringBackgroundJobStartedNotification(_job, new EventMessages()).WithStateFrom(startingNotification));
|
||||
await startTask;
|
||||
|
||||
await _eventAggregator.PublishAsync(new RecurringBackgroundJobStartedNotification(_job, eventMessages).WithStateFrom(startingNotification), cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asynchronously stops the recurring background job service, publishing notifications before and after stopping.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
|
||||
/// <returns>A task that represents the asynchronous stop operation.</returns>
|
||||
/// <inheritdoc />
|
||||
public override async Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var stoppingNotification = new Notifications.RecurringBackgroundJobStoppingNotification(_job, new EventMessages());
|
||||
await _eventAggregator.PublishAsync(stoppingNotification);
|
||||
EventMessages eventMessages = _eventMessagesFactory.Get();
|
||||
var stoppingNotification = new RecurringBackgroundJobStoppingNotification(_job, eventMessages);
|
||||
await _eventAggregator.PublishAsync(stoppingNotification, cancellationToken);
|
||||
|
||||
await base.StopAsync(cancellationToken);
|
||||
|
||||
await _eventAggregator.PublishAsync(new Notifications.RecurringBackgroundJobStoppedNotification(_job, new EventMessages()).WithStateFrom(stoppingNotification));
|
||||
await _eventAggregator.PublishAsync(new RecurringBackgroundJobStoppedNotification(_job, eventMessages).WithStateFrom(stoppingNotification), cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
_job.PeriodChanged -= OnPeriodChanged;
|
||||
_job.IgnoredDelayChanged -= OnIgnoredDelayChanged;
|
||||
|
||||
_ignoredDelayChangeCts.Dispose();
|
||||
}
|
||||
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles the <see cref="IRecurringBackgroundJob.PeriodChanged" /> event by updating the base class period.
|
||||
/// </summary>
|
||||
/// <param name="sender">The sender.</param>
|
||||
/// <param name="e">The <see cref="EventArgs"/> instance containing the event data.</param>
|
||||
private void OnPeriodChanged(object? sender, EventArgs e)
|
||||
=> ChangePeriod(_job.Period);
|
||||
|
||||
/// <summary>
|
||||
/// Handles the <see cref="IRecurringBackgroundJob.IgnoredDelayChanged" /> event by interrupting any in-progress ignored back-off so it re-reads the new <see cref="IRecurringBackgroundJob.IgnoredDelay" /> value.
|
||||
/// </summary>
|
||||
/// <param name="sender">The sender.</param>
|
||||
/// <param name="e">The <see cref="EventArgs"/> instance containing the event data.</param>
|
||||
private void OnIgnoredDelayChanged(object? sender, EventArgs e)
|
||||
=> CancellationTokenSourceRotation.RotateAndCancel(ref _ignoredDelayChangeCts);
|
||||
|
||||
/// <summary>
|
||||
/// Publishes the ignored notification and waits for <see cref="IRecurringBackgroundJob.IgnoredDelay" /> before allowing the next iteration, preventing tight looping when execution is skipped.
|
||||
/// </summary>
|
||||
/// <param name="message">The full debug message describing why the execution is ignored.</param>
|
||||
/// <param name="eventMessages">The event messages for the notification.</param>
|
||||
/// <param name="executingNotification">The originating executing notification to carry state from.</param>
|
||||
/// <param name="stoppingToken">A cancellation token that is signaled when the host is shutting down.</param>
|
||||
private async Task IgnoreAndWaitAsync(
|
||||
string message,
|
||||
EventMessages eventMessages,
|
||||
RecurringBackgroundJobExecutingNotification executingNotification,
|
||||
CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogDebug(message);
|
||||
await _eventAggregator.PublishAsync(new RecurringBackgroundJobIgnoredNotification(_job, eventMessages).WithStateFrom(executingNotification), stoppingToken);
|
||||
|
||||
long waitStart = _timeProvider.GetTimestamp();
|
||||
|
||||
while (true)
|
||||
{
|
||||
TimeSpan ignoredDelay = _job.IgnoredDelay;
|
||||
|
||||
// Skip back-off for zero/negative; Timeout.InfiniteTimeSpan means wait until shutdown or IgnoredDelayChanged.
|
||||
if (ignoredDelay != Timeout.InfiniteTimeSpan && ignoredDelay <= TimeSpan.Zero)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
TimeSpan remaining = ComputeNextDelay(ignoredDelay, _timeProvider.GetElapsedTime(waitStart));
|
||||
if (remaining == TimeSpan.Zero)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
CancellationToken ignoredDelayChangeToken = _ignoredDelayChangeCts.Token;
|
||||
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken, ignoredDelayChangeToken);
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(remaining, _timeProvider, linkedCts.Token);
|
||||
|
||||
// Back-off complete
|
||||
return;
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
// Back-off interrupted by shutdown; the ignored notification has already been published, so do not also publish canceled
|
||||
return;
|
||||
}
|
||||
catch (OperationCanceledException) when (ignoredDelayChangeToken.IsCancellationRequested)
|
||||
{
|
||||
// IgnoredDelay changed — loop to re-read and recompute the remaining wait
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+88
-45
@@ -1,25 +1,26 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Umbraco.Cms.Infrastructure.HostedServices;
|
||||
|
||||
namespace Umbraco.Cms.Infrastructure.BackgroundJobs;
|
||||
|
||||
/// <summary>
|
||||
/// A hosted service that discovers and starts hosted services for any recurring background jobs in the DI container.
|
||||
/// A hosted service that discovers and starts hosted services for any recurring background jobs in the DI container.
|
||||
/// </summary>
|
||||
public class RecurringBackgroundJobHostedServiceRunner : IHostedService
|
||||
{
|
||||
private readonly ILogger<RecurringBackgroundJobHostedServiceRunner> _logger;
|
||||
private readonly List<IRecurringBackgroundJob> _jobs;
|
||||
private readonly Func<IRecurringBackgroundJob, IHostedService> _jobFactory;
|
||||
private readonly List<NamedServiceJob> _hostedServices = new();
|
||||
|
||||
private readonly ConcurrentDictionary<Type, IHostedService> _hostedServices = new();
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RecurringBackgroundJobHostedServiceRunner"/> class.
|
||||
/// Initializes a new instance of the <see cref="RecurringBackgroundJobHostedServiceRunner" /> class.
|
||||
/// </summary>
|
||||
/// <param name="logger">An <see cref="ILogger{RecurringBackgroundJobHostedServiceRunner}"/> used for logging within the runner.</param>
|
||||
/// <param name="jobs">A collection of <see cref="IRecurringBackgroundJob"/> instances to be managed by the runner.</param>
|
||||
/// <param name="jobFactory">A factory function that creates an <see cref="IHostedService"/> for each <see cref="IRecurringBackgroundJob"/>.</param>
|
||||
/// <param name="logger">An <see cref="ILogger{RecurringBackgroundJobHostedServiceRunner}" /> used for logging within the runner.</param>
|
||||
/// <param name="jobs">A collection of <see cref="IRecurringBackgroundJob" /> instances to be managed by the runner.</param>
|
||||
/// <param name="jobFactory">A factory function that creates an <see cref="IHostedService" /> for each <see cref="IRecurringBackgroundJob" />.</param>
|
||||
public RecurringBackgroundJobHostedServiceRunner(
|
||||
ILogger<RecurringBackgroundJobHostedServiceRunner> logger,
|
||||
IEnumerable<IRecurringBackgroundJob> jobs,
|
||||
@@ -30,80 +31,122 @@ public class RecurringBackgroundJobHostedServiceRunner : IHostedService
|
||||
_jobFactory = jobFactory;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("Starting recurring background jobs hosted services");
|
||||
|
||||
foreach (IRecurringBackgroundJob job in _jobs)
|
||||
{
|
||||
var jobName = job.GetType().Name;
|
||||
Type jobType = job.GetType();
|
||||
var added = false;
|
||||
|
||||
try
|
||||
{
|
||||
IHostedService hostedService = _hostedServices.GetOrAdd(jobType, _ =>
|
||||
{
|
||||
_logger.LogDebug("Creating background hosted service for {JobTypeName}", jobType.Name);
|
||||
|
||||
_logger.LogDebug("Creating background hosted service for {job}", jobName);
|
||||
IHostedService hostedService = _jobFactory(job);
|
||||
IHostedService hostedService = _jobFactory(job);
|
||||
added = true;
|
||||
|
||||
_logger.LogInformation("Starting a background hosted service for {job} with a delay of {delay}, running every {period}", jobName, job.Delay, job.Period);
|
||||
return hostedService;
|
||||
});
|
||||
|
||||
if (!added)
|
||||
{
|
||||
_logger.LogWarning("A background hosted service for {JobTypeName} is already registered, skipping duplicate", jobType.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Starting a background hosted service for {JobTypeName} with a delay of {Delay}, running every {Period}", jobType.Name, job.Delay, job.Period);
|
||||
|
||||
await hostedService.StartAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_hostedServices.Add(new NamedServiceJob(jobName, hostedService));
|
||||
}
|
||||
catch (Exception exception)
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(exception, "Failed to start background hosted service for {job}", jobName);
|
||||
if (added)
|
||||
{
|
||||
// Ensure we don't stop hosted services that were not successfully started
|
||||
_hostedServices.TryRemove(jobType, out _);
|
||||
}
|
||||
|
||||
_logger.LogError(ex, "Failed to start background hosted service for {JobTypeName}", jobType.Name);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Completed starting recurring background jobs hosted services");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asynchronously stops all recurring background job hosted services managed by this runner.
|
||||
/// </summary>
|
||||
/// <param name="stoppingToken">A <see cref="CancellationToken"/> that can be used to cancel the stop operation.</param>
|
||||
/// <returns>A <see cref="Task"/> representing the asynchronous stop operation.</returns>
|
||||
/// <inheritdoc />
|
||||
public async Task StopAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("Stopping recurring background jobs hosted services");
|
||||
|
||||
foreach (NamedServiceJob namedServiceJob in _hostedServices)
|
||||
foreach (Type jobType in _hostedServices.Keys)
|
||||
{
|
||||
try
|
||||
if (_hostedServices.TryRemove(jobType, out IHostedService? hostedService))
|
||||
{
|
||||
_logger.LogInformation("Stopping background hosted service for {job}", namedServiceJob.Name);
|
||||
await namedServiceJob.HostedService.StopAsync(stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogError(exception, "Failed to stop background hosted service for {job}", namedServiceJob.Name);
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Stopping background hosted service for {JobTypeName}", jobType.Name);
|
||||
|
||||
await hostedService.StopAsync(stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to stop background hosted service for {JobTypeName}", jobType.Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Completed stopping recurring background jobs hosted services");
|
||||
}
|
||||
|
||||
private sealed class NamedServiceJob
|
||||
/// <summary>
|
||||
/// Signals the background loop for the specified job type to execute immediately, with the specified strategy for determining the next execution after the triggered one completes.
|
||||
/// </summary>
|
||||
/// <typeparam name="TJob">The type of the recurring background job to trigger.</typeparam>
|
||||
/// <param name="strategy">Controls the delay after the triggered execution.</param>
|
||||
/// <returns>
|
||||
/// <c>true</c> if the job was found and triggered; <c>false</c> if no hosted service is running for this job type.
|
||||
/// </returns>
|
||||
internal bool TriggerExecution<TJob>(NextExecutionStrategy strategy)
|
||||
where TJob : ITriggerableRecurringBackgroundJob
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="NamedServiceJob"/> class using the specified job name and hosted service instance.
|
||||
/// </summary>
|
||||
/// <param name="name">The unique name identifying the job.</param>
|
||||
/// <param name="hostedService">The <see cref="IHostedService"/> instance to be executed as the background job.</param>
|
||||
public NamedServiceJob(string name, IHostedService hostedService)
|
||||
if (FindHostedService<TJob>() is not { } hostedService)
|
||||
{
|
||||
Name = name;
|
||||
HostedService = hostedService;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the unique name that identifies this background job.
|
||||
/// </summary>
|
||||
public string Name { get; }
|
||||
hostedService.TriggerExecution(strategy);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the hosted service instance associated with the named service job.
|
||||
/// </summary>
|
||||
public IHostedService HostedService { get; }
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signals the background loop for the specified job type to execute immediately.
|
||||
/// After the triggered execution, the next execution is scheduled after the specified delay (measured from execution start; execution time is subtracted to prevent drift).
|
||||
/// </summary>
|
||||
/// <typeparam name="TJob">The type of the recurring background job to trigger.</typeparam>
|
||||
/// <param name="nextDelay">The target interval from execution start to the next execution. Execution time is subtracted to prevent drift.</param>
|
||||
/// <returns>
|
||||
/// <c>true</c> if the job was found and triggered; <c>false</c> if no hosted service is running for this job type.
|
||||
/// </returns>
|
||||
internal bool TriggerExecution<TJob>(TimeSpan nextDelay)
|
||||
where TJob : ITriggerableRecurringBackgroundJob
|
||||
{
|
||||
if (FindHostedService<TJob>() is not { } hostedService)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
hostedService.TriggerExecution(nextDelay);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private RecurringHostedServiceBase? FindHostedService<TJob>()
|
||||
where TJob : ITriggerableRecurringBackgroundJob
|
||||
=> _hostedServices.TryGetValue(typeof(TJob), out IHostedService? service) ? service as RecurringHostedServiceBase : null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
// Copyright (c) Umbraco.
|
||||
// See LICENSE for more details.
|
||||
|
||||
using Umbraco.Cms.Infrastructure.HostedServices;
|
||||
|
||||
namespace Umbraco.Cms.Infrastructure.BackgroundJobs;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="IRecurringBackgroundJobTrigger{TJob}" /> that delegates to the hosted service runner.
|
||||
/// </summary>
|
||||
/// <typeparam name="TJob">The type of the recurring background job to trigger.</typeparam>
|
||||
internal sealed class RecurringBackgroundJobTrigger<TJob> : IRecurringBackgroundJobTrigger<TJob>
|
||||
where TJob : class, ITriggerableRecurringBackgroundJob
|
||||
{
|
||||
private readonly RecurringBackgroundJobHostedServiceRunner _runner;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RecurringBackgroundJobTrigger{TJob}" /> class.
|
||||
/// </summary>
|
||||
/// <param name="runner">The runner.</param>
|
||||
public RecurringBackgroundJobTrigger(RecurringBackgroundJobHostedServiceRunner runner)
|
||||
=> _runner = runner;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool TriggerExecution()
|
||||
=> TriggerExecution(NextExecutionStrategy.None);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool TriggerExecution(NextExecutionStrategy strategy)
|
||||
=> _runner.TriggerExecution<TJob>(strategy);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool TriggerExecution(TimeSpan nextDelay)
|
||||
=> _runner.TriggerExecution<TJob>(nextDelay);
|
||||
}
|
||||
@@ -384,6 +384,57 @@ using (ICoreScope scope = ScopeProvider.CreateCoreScope())
|
||||
3. **Lazy loading outside scope** - NPoco relationships must load within scope
|
||||
4. **Large migrations** - Split into multiple steps if > 1000 lines
|
||||
5. **Repository logic in services** - Keep repos thin, logic in services
|
||||
6. **Unbatched `WHERE IN` on user-sized collections** - See "Avoiding the SQL Server 2100-parameter limit" below
|
||||
|
||||
### Avoiding the SQL Server 2100-parameter limit
|
||||
|
||||
SQL Server caps a single statement at 2100 parameters. When an `IN` clause is built from a collection sized by user data, that cap can be hit — and the symptom is a runtime `SqlException` (error 8003) on customer installs that nobody hit in dev.
|
||||
|
||||
**The constant and helpers**:
|
||||
- `Constants.Sql.MaxParameterCount = 2000` (in `Umbraco.Core`, `Constants-Sql.cs`) — the ceiling we target (2100 minus headroom for joined predicates already in the SQL).
|
||||
- `IEnumerable<T>.InGroupsOf(groupSize)` (in `Umbraco.Core`, `Extensions/EnumerableExtensions.cs`) — extension method to batch a collection.
|
||||
- `Database.FetchByGroups<TResult, TSource>(source, groupSize, sqlFactory)` (in `Umbraco.Infrastructure`, `Persistence/NPocoDatabaseExtensions.cs`) — NPoco helper that batches a fetch.
|
||||
|
||||
**The safe patterns** (use one of these any time the collection size is user-driven):
|
||||
|
||||
```csharp
|
||||
// Pattern 1: batch a DeleteMany / Execute / Fetch by looping.
|
||||
foreach (IEnumerable<int> group in ids.InGroupsOf(Constants.Sql.MaxParameterCount))
|
||||
{
|
||||
Database.DeleteMany<FooDto>().Where(x => group.Contains(x.Id)).Execute();
|
||||
}
|
||||
|
||||
// Pattern 2: batched fetch with NPoco helper.
|
||||
List<FooDto> dtos = Database.FetchByGroups<FooDto, int>(
|
||||
ids,
|
||||
Constants.Sql.MaxParameterCount,
|
||||
batch => Sql().Select<FooDto>().From<FooDto>().WhereIn<FooDto>(x => x.Id, batch));
|
||||
|
||||
// Pattern 3: reserve headroom for other parameters in the same statement.
|
||||
foreach (IEnumerable<int> group in entityIds.InGroupsOf(Constants.Sql.MaxParameterCount - userGroupIds.Length))
|
||||
{
|
||||
// statement uses entityIds + userGroupIds, so subtract the other predicate's parameter count from the budget
|
||||
}
|
||||
```
|
||||
|
||||
**Decision rule when writing or reviewing a `WHERE IN`-style query**:
|
||||
|
||||
Look at what drives the size of the collection feeding the `IN`. Ask: *could this realistically exceed 2000 on a large install?* Risky drivers — batch any query backed by these:
|
||||
- All content / media / member nodes (or descendants of a deep tree).
|
||||
- A product of two scaling dimensions, e.g. `documents × languages`, `properties × versions`, `relations × endpoints`.
|
||||
- Configuration-tunable batch sizes (`CacheSettings.DocumentSeedBatchSize`, `NuCacheSettings.SqlPageSize`, etc.). The default may be safe but the customer can raise it.
|
||||
- Anything that scans property data, version history, relations, or audit logs across many nodes.
|
||||
|
||||
Safe drivers — don't bother batching:
|
||||
- Languages / content types / member groups / user groups — bounded by install configuration, typically <100.
|
||||
- "Per single content item" collections — properties on one document, versions of one document, tokens for one external login.
|
||||
- IDs supplied directly by a user action through the UI (picker selections, bulk actions on a page of results).
|
||||
|
||||
If you're not sure, batch — the cost is one loop and an `IEnumerable<T>` allocation per batch; the cost of being wrong is a SqlException on a customer's biggest site.
|
||||
|
||||
**For new public APIs** that take an `IEnumerable<int>`/`IEnumerable<Guid>` and feed it into a query, batch internally even if no current caller is large — package authors and future callers will not know about the 2000-limit ceiling.
|
||||
|
||||
**Don't** rely on `if (ids.Length > MaxParameterCount) throw` as a substitute for batching. Throwing only moves the problem; the caller has no obvious way to recover and will most likely just fail in production.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -104,6 +104,7 @@ internal sealed class JsonConfigManipulator : IConfigManipulator
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
[Obsolete("This method is no longer used by Umbraco. Set the Umbraco:CMS:WebRouting:DisableRedirectUrlTracking configuration key instead. Scheduled for removal in Umbraco 19.")]
|
||||
public async Task SaveDisableRedirectUrlTrackingAsync(bool disable)
|
||||
=> await CreateOrUpdateConfigValueAsync(DisableRedirectUrlTrackingPath, disable);
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user