mirror of https://github.com/Squidex/squidex.git
25 changed files with 477 additions and 1633 deletions
@ -0,0 +1,44 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using Squidex.Caching; |
||||
|
using Squidex.Domain.Apps.Core.Apps; |
||||
|
using Squidex.Domain.Apps.Core.Contents; |
||||
|
using Squidex.Domain.Apps.Core.Schemas; |
||||
|
using Squidex.Domain.Apps.Core.Scripting; |
||||
|
|
||||
|
namespace Squidex.Domain.Apps.Entities.Contents; |
||||
|
|
||||
|
public sealed class DynamicContentWorkflows(IScriptEngine scriptEngine, ILocalCache localCache) : IContentWorkflows |
||||
|
{ |
||||
|
public ValueTask<IContentWorkflow> GetWorkflowAsync(App app, Schema schema, |
||||
|
CancellationToken ct = default) |
||||
|
{ |
||||
|
// The definition is only cached for the current request, because the workflow must never be
|
||||
|
// stale. It is resolved several times per request, especially once per command guard.
|
||||
|
var cacheKey = (nameof(DynamicContentWorkflows), app.Id, app.Version, schema.Id, schema.Version); |
||||
|
|
||||
|
if (!localCache.TryGetValue(cacheKey, out var cached) || cached is not WorkflowDefinition definition) |
||||
|
{ |
||||
|
definition = CreateDefinition(app, schema); |
||||
|
|
||||
|
localCache.Add(cacheKey, definition); |
||||
|
} |
||||
|
|
||||
|
return new ValueTask<IContentWorkflow>(new DynamicContentWorkflow(definition, scriptEngine)); |
||||
|
} |
||||
|
|
||||
|
private static WorkflowDefinition CreateDefinition(App app, Schema schema) |
||||
|
{ |
||||
|
var workflow = |
||||
|
app.Workflows.Values.FirstOrDefault(x => x.SchemaIds.Contains(schema.Id)) ?? |
||||
|
app.Workflows.Values.FirstOrDefault(x => x.SchemaIds.Count == 0) ?? |
||||
|
Workflow.Default; |
||||
|
|
||||
|
return new WorkflowDefinition(workflow, schema.Properties.ValidateOnPublish); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,17 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using Squidex.Domain.Apps.Core.Apps; |
||||
|
using Squidex.Domain.Apps.Core.Schemas; |
||||
|
|
||||
|
namespace Squidex.Domain.Apps.Entities.Contents; |
||||
|
|
||||
|
public interface IContentWorkflows |
||||
|
{ |
||||
|
ValueTask<IContentWorkflow> GetWorkflowAsync(App app, Schema schema, |
||||
|
CancellationToken ct = default); |
||||
|
} |
||||
@ -0,0 +1,38 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using Squidex.Domain.Apps.Core.Contents; |
||||
|
|
||||
|
namespace Squidex.Domain.Apps.Entities.Contents; |
||||
|
|
||||
|
public sealed class WorkflowDefinition |
||||
|
{ |
||||
|
public Workflow Workflow { get; } |
||||
|
|
||||
|
public IReadOnlyDictionary<Status, StatusInfo> StatusInfos { get; } |
||||
|
|
||||
|
public StatusInfo[] AllStatuses { get; } |
||||
|
|
||||
|
public bool ValidateOnPublish { get; } |
||||
|
|
||||
|
public WorkflowDefinition(Workflow workflow, bool validateOnPublish) |
||||
|
{ |
||||
|
// The status infos never change for a workflow, therefore they are created once and shared
|
||||
|
// by all contents instead of allocating them for every single status lookup.
|
||||
|
var statusInfos = new Dictionary<Status, StatusInfo>(workflow.Steps.Count); |
||||
|
|
||||
|
foreach (var (status, step) in workflow.Steps) |
||||
|
{ |
||||
|
statusInfos[status] = new StatusInfo(status, step.Color ?? StatusColors.Draft); |
||||
|
} |
||||
|
|
||||
|
Workflow = workflow; |
||||
|
StatusInfos = statusInfos; |
||||
|
AllStatuses = [.. statusInfos.Values]; |
||||
|
ValidateOnPublish = validateOnPublish; |
||||
|
} |
||||
|
} |
||||
File diff suppressed because it is too large
@ -1,100 +0,0 @@ |
|||||
# Backend Performance — Open |
|
||||
|
|
||||
Analysis of `backend/src` (2131 C# files, ~187k LOC). Ordered by severity: expected |
|
||||
production impact × how hot the code path is. |
|
||||
|
|
||||
Severity key: **S1** critical (can dominate request latency or take the process down), |
|
||||
**S2** high (measurable on every request in a common path), **S3** moderate (steady |
|
||||
overhead / allocation churn), **S4** low (worth fixing while nearby). |
|
||||
|
|
||||
Item numbers are stable and never reused. Completed items move to |
|
||||
[resolved.md](resolved.md) keeping their number, so gaps in the sequence here are |
|
||||
expected — items **4**–**35** are closed and live there. |
|
||||
|
|
||||
**Status: 3 open of 35 — items 1, 2 and 3, which are one root cause. The other 32 are in [resolved.md](resolved.md).** |
|
||||
|
|
||||
--- |
|
||||
|
|
||||
## S1 — Critical |
|
||||
|
|
||||
### 1. A fresh Jint `Engine` is constructed for every script evaluation |
|
||||
`backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/JintScriptEngine.cs:144` |
|
||||
|
|
||||
`CreateEngine` calls `new Engine(...)` on every `Execute` / `ExecuteAsync` / |
|
||||
`TransformAsync`. Building a Jint engine allocates a complete JS realm (global object, |
|
||||
`Object`/`Array`/`JSON`/`Math`/`RegExp` prototypes, intrinsics) plus runs every |
|
||||
registered `IJintExtension.Extend`. Script *parsing* is cached via `CacheParser`, but |
|
||||
engine construction — the expensive half — is not. |
|
||||
|
|
||||
This is the root cause of items 2 and 3, which is why it ranks first. |
|
||||
|
|
||||
**Fix:** pool engines (`ObjectPool<Engine>`) keyed by the option set, resetting globals |
|
||||
between uses; or hoist one engine per enrichment batch instead of per item. |
|
||||
|
|
||||
--- |
|
||||
|
|
||||
### 2. Workflow enrichment runs one Jint engine per content *per transition* |
|
||||
`backend/src/Squidex.Domain.Apps.Entities/Contents/DynamicContentWorkflow.cs:90,118` |
|
||||
`backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/EnrichWithWorkflows.cs:22,30,31` |
|
||||
|
|
||||
`EnrichWithWorkflows` loops over every content and awaits `GetNextAsync` and |
|
||||
`CanUpdateAsync` sequentially. `GetNextAsync` loops over every transition and calls |
|
||||
`IsTrue`, which calls `scriptEngine.Evaluate` whenever the transition has an |
|
||||
expression — a new engine each time (item 1). |
|
||||
|
|
||||
A frontend content list of 200 items with a workflow having 3 conditional transitions |
|
||||
executes **200 × (3 + 1) = 800 engine constructions** in one request, serially. |
|
||||
|
|
||||
`GetWorkflowAsync` additionally re-scans `app.Workflows.Values` with |
|
||||
`SchemaIds.Contains(schemaId)` on every one of those calls. |
|
||||
|
|
||||
**Fix:** cache the resolved `Workflow` per (appId, schemaId) for the batch; memoize |
|
||||
condition results per (transition, contentData); reuse one engine. |
|
||||
|
|
||||
--- |
|
||||
|
|
||||
### 3. Query scripts execute one engine per content, serially |
|
||||
`backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/ScriptContent.cs:57` |
|
||||
|
|
||||
`foreach (var content in group) await TransformAsync(...)` — every content in the page |
|
||||
gets its own engine construction plus its own |
|
||||
`CancellationTokenSource.CreateLinkedTokenSource`. Any schema with a query script pays |
|
||||
this on every read. |
|
||||
|
|
||||
**Fix:** same as item 1 — reuse the engine across the group; per-content state is |
|
||||
already isolated in `ContentScriptVars`. |
|
||||
|
|
||||
--- |
|
||||
|
|
||||
## Suggested order of attack |
|
||||
|
|
||||
Only **engine pooling (items 1–3)** is left. It is the largest single cost on the list and |
|
||||
also the most invasive change: it touches the security boundary of user-authored scripts, |
|
||||
because a pooled engine must not carry state from one script into the next. |
|
||||
|
|
||||
**Profile before writing it.** The estimate that engine construction dominates a scripted |
|
||||
content list is read off the loops, not taken from a trace, and this is the one item where |
|
||||
the fix is expensive enough that being wrong about the size of the win would matter. |
|
||||
|
|
||||
--- |
|
||||
|
|
||||
## Method / caveats |
|
||||
|
|
||||
Findings come from static reading of the hot paths (content query + enrichment pipeline, |
|
||||
GraphQL execution, write/validation path, event consumers, HTTP pipeline, MongoDB and EF |
|
||||
repositories, asset serving, response/DTO construction) plus scripted scans for |
|
||||
sync-over-async, awaits inside loops, uncached `Regex`, and repeated LINQ materialisation. |
|
||||
|
|
||||
**No profiling or benchmarking was run.** The ordering is a reasoned estimate of impact, |
|
||||
not measured data. Counts like "200 × 4 engine constructions" or "2000 link generations" |
|
||||
are derived from reading the loops, not observed. Confirm the expensive items with a |
|
||||
profiler against a representative workload before investing in the larger refactors. |
|
||||
|
|
||||
Items 21–30 were added in a second pass over areas the first pass had not covered: the |
|
||||
EF data layer, asset serving and transformation, response DTO and link construction, the |
|
||||
full-text search path, and the remaining enrichment steps. Two candidates were dropped |
|
||||
during that pass after checking them: per-content permission checks (already memoized in |
|
||||
`Resources.Can`) and the lazily built static maps in `Adapt` (a benign race that at worst |
|
||||
builds the same dictionary twice). |
|
||||
|
|
||||
Line numbers were verified against the working tree at the time of writing. |
|
||||
Loading…
Reference in new issue