Browse Source

Fixes

pull/1330/head
Sebastian Stehle 2 weeks ago
parent
commit
2ab1f7052f
  1. 10
      backend/src/Squidex.Data.EntityFramework/AppDbContext.cs
  2. 9
      backend/src/Squidex.Data.EntityFramework/ContentDbContext.cs
  3. 2
      backend/src/Squidex.Data.MongoDb/Domain/Apps/Entities/Assets/MongoAssetRepository.cs
  4. 2
      backend/src/Squidex.Data.MongoDb/Domain/Apps/Entities/Contents/Operations/QueryByIds.cs
  5. 7
      backend/src/Squidex.Data.MongoDb/Infrastructure/Queries/LimitExtensions.cs
  6. 13
      backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/CalculatePreviewText.cs
  7. 8
      backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/EnrichForCaching.cs
  8. 6
      backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/ResolveAssets.cs
  9. 185
      resolved.md
  10. 150
      todo.md

10
backend/src/Squidex.Data.EntityFramework/AppDbContext.cs

@ -29,6 +29,16 @@ public abstract class AppDbContext(DbContextOptions options, IJsonSerializer jso
{ {
public abstract SqlDialect Dialect { get; } public abstract SqlDialect Dialect { get; }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
// Almost everything is read only or written by inserting new entities, so tracking would
// only cost a snapshot of every entity that is read. The few stores that update an entity
// they have queried ask for it with AsTracking.
optionsBuilder.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);
base.OnConfiguring(optionsBuilder);
}
protected override void OnModelCreating(ModelBuilder builder) protected override void OnModelCreating(ModelBuilder builder)
{ {
var jsonColumnType = Dialect.JsonColumnType(); var jsonColumnType = Dialect.JsonColumnType();

9
backend/src/Squidex.Data.EntityFramework/ContentDbContext.cs

@ -19,6 +19,15 @@ public abstract class ContentDbContext(DbContextOptions options, IJsonSerializer
{ {
public abstract SqlDialect Dialect { get; } public abstract SqlDialect Dialect { get; }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
// Contents are never written by changing a queried entity, they are inserted in bulk, so
// tracking only costs a snapshot of every content that is read.
optionsBuilder.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);
base.OnConfiguring(optionsBuilder);
}
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
modelBuilder.UseContent(jsonSerializer, Dialect.JsonColumnType(), options.Prefix()); modelBuilder.UseContent(jsonSerializer, Dialect.JsonColumnType(), options.Prefix());

2
backend/src/Squidex.Data.MongoDb/Domain/Apps/Entities/Assets/MongoAssetRepository.cs

@ -109,7 +109,7 @@ public sealed partial class MongoAssetRepository : MongoRepositoryBase<MongoAsse
{ {
assetTotal = -1; assetTotal = -1;
} }
else else if (query.NeedsTotalById(q.Ids.Count))
{ {
assetTotal = await Collection.Find(filter).CountDocumentsAsync(ct); assetTotal = await Collection.Find(filter).CountDocumentsAsync(ct);
} }

2
backend/src/Squidex.Data.MongoDb/Domain/Apps/Entities/Contents/Operations/QueryByIds.cs

@ -56,7 +56,7 @@ internal sealed class QueryByIds : OperationBase
{ {
contentTotal = -1; contentTotal = -1;
} }
else else if (query.NeedsTotalById(q.Ids.Count))
{ {
contentTotal = await Collection.Find(filter).CountDocumentsAsync(ct); contentTotal = await Collection.Find(filter).CountDocumentsAsync(ct);
} }

7
backend/src/Squidex.Data.MongoDb/Infrastructure/Queries/LimitExtensions.cs

@ -11,6 +11,13 @@ namespace Squidex.Infrastructure.Queries;
public static class LimitExtensions public static class LimitExtensions
{ {
public static bool NeedsTotalById(this ClrQuery query, int idCount)
{
// A query by ID can never match more documents than the number of requested IDs, so the result
// already contains all of them unless skip, take or the random selection could have cut it off.
return query.Skip > 0 || query.Take < idCount || query.Random > 0;
}
public static IAggregateFluent<T> QueryLimit<T>(this IAggregateFluent<T> cursor, ClrQuery query) public static IAggregateFluent<T> QueryLimit<T>(this IAggregateFluent<T> cursor, ClrQuery query)
{ {
if (query.Take < long.MaxValue) if (query.Take < long.MaxValue)

13
backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/CalculatePreviewText.cs

@ -30,18 +30,25 @@ public sealed class CalculatePreviewText : IContentEnricherStep
private static void AddTexts(Schema schema, RichTextNode node, IEnumerable<EnrichedContent> contents) private static void AddTexts(Schema schema, RichTextNode node, IEnumerable<EnrichedContent> contents)
{ {
// The fields are the same for all contents of the schema, so they are only filtered once.
var richTextFields = schema.Fields.Where(x => x.RawProperties is RichTextFieldProperties).ToList();
if (richTextFields.Count == 0)
{
return;
}
foreach (var content in contents) foreach (var content in contents)
{ {
foreach (var field in schema.Fields.Where(x => x.RawProperties is RichTextFieldProperties)) foreach (var richTextField in richTextFields)
{ {
if (!content.Data.TryGetValue(field.Name, out var fieldData) || fieldData is not { Count: > 0 }) if (!content.Data.TryGetValue(richTextField.Name, out var fieldData) || fieldData is not { Count: > 0 })
{ {
continue; continue;
} }
content.ReferenceData ??= []; content.ReferenceData ??= [];
var fieldReference = content.ReferenceData.GetOrAdd(field.Name, _ => [])!; var fieldReference = content.ReferenceData.GetOrAdd(richTextField.Name, _ => [])!;
foreach (var (partitionKey, partitionValue) in fieldData) foreach (var (partitionKey, partitionValue) in fieldData)
{ {

8
backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/EnrichForCaching.cs

@ -43,11 +43,15 @@ public sealed class EnrichForCaching(IRequestCache requestCache) : IContentEnric
var (schema, _) = await schemas(group.Key); var (schema, _) = await schemas(group.Key);
// The app and the schema are the same for all contents of the group, so they are added
// once per group and not once per content. They are added inside the loop, so that a
// result without contents also has no dependencies and therefore no etag, as before.
requestCache.AddDependency(app.UniqueId, app.Version);
requestCache.AddDependency(schema.UniqueId, schema.Version);
foreach (var content in group) foreach (var content in group)
{ {
requestCache.AddDependency(content.UniqueId, content.Version); requestCache.AddDependency(content.UniqueId, content.Version);
requestCache.AddDependency(schema.UniqueId, schema.Version);
requestCache.AddDependency(app.UniqueId, app.Version);
} }
} }
} }

6
backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/ResolveAssets.cs

@ -128,9 +128,13 @@ public sealed class ResolveAssets(IUrlGenerator urlGenerator, IAssetQueryService
private static void AddAssetIds(HashSet<DomainId> ids, Schema schema, ResolvedComponents components, IEnumerable<EnrichedContent> contents) private static void AddAssetIds(HashSet<DomainId> ids, Schema schema, ResolvedComponents components, IEnumerable<EnrichedContent> contents)
{ {
// ResolvingAssets is a lazy query over all fields of the schema, therefore it is only
// evaluated once here and not again for every content.
var fields = schema.ResolvingAssets().ToList();
foreach (var content in contents) foreach (var content in contents)
{ {
content.Data.AddReferencedIds(schema.ResolvingAssets(), ids, components, 1); content.Data.AddReferencedIds(fields, ids, components, 1);
} }
} }

185
resolved.md

@ -3,8 +3,9 @@
Items from the backend performance review that are done. Numbering matches Items from the backend performance review that are done. Numbering matches
[todo.md](todo.md) — resolved items keep their original number so references stay valid. [todo.md](todo.md) — resolved items keep their original number so references stay valid.
Most entries are fixes. Items **6** and **14** are closed as *accepted* rather than fixed — Most entries are fixes. Items **6** and **14** are closed as *accepted* and item **21** as
kept here so they are not re-reported as new findings. *rejected*, kept here so they are not re-reported as new findings. Item **18** records a
finding that turned out to be wrong.
--- ---
@ -741,3 +742,183 @@ explicitly configured to return null.
**Verified:** build clean. Entities 1541, Web 176, Core 1243, Infrastructure 1033, **Verified:** build clean. Entities 1541, Web 176, Core 1243, Infrastructure 1033,
Data 180 — all green. Data 180 — all green.
---
### 21. Content DTO link generation — **CLOSED: REJECTED**
`backend/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentDto.cs:156`
`CreateLinksAsync` issues up to ten `IUrlHelper.Action` calls per content, so a 200-item
frontend page runs on the order of 2000 link generations.
**Rejected, not fixed.** The proposed fix — building URLs from a cached per-schema prefix
and concatenating the id — bypasses the ASP.NET routing system. Links would stop reflecting
the actual route table, so any change to a route template, a route constraint, or the path
base would silently produce wrong URLs. That is not a trade worth making for link
generation, whatever it costs. Recorded here so it is not re-reported as a new finding.
If this ever does show up in a profile, the answer has to stay inside the routing system —
for example ASP.NET's own `LinkGenerator` with a cached endpoint lookup — not around it.
---
### 22. The EF data layer never used `AsNoTracking` — **FIXED**
`backend/src/Squidex.Data.EntityFramework/ContentDbContext.cs`
`backend/src/Squidex.Data.EntityFramework/Infrastructure/Extensions.cs:116,150`
plus the entity-materializing reads in the content and asset repositories
**Was:** not a single `AsNoTracking()` in the layer and no `QueryTrackingBehavior` setting
anywhere. Every entity from every read query got a change-tracking snapshot — on entities
that carry a full content `Data` blob, so roughly double the memory per content read.
**Now, and the split matters:**
- `ContentDbContext` gets `QueryTrackingBehavior.NoTracking` as its **default**. That
context is content-only, and every content write goes through `BulkInsertAsync`, never by
mutating a queried entity.
- `AppDbContext` keeps its default, with `AsNoTracking()` applied to the individual read
paths: both `QueryAsync` helpers in `Infrastructure/Extensions.cs` (which most repository
reads funnel through), `EFContentRepository.FindContentAsync`,
`EFAssetRepository.StreamAll`, the `ReadAllAsync` / single-read paths of the content, asset
and asset-folder snapshot stores, `DynamicTables`, and both paths of the generic
`EFSnapshotStore`.
**Why `AppDbContext` was not flipped globally — corrected.** The first version of this note
claimed ASP.NET Identity's `UserStore.SetTokenAsync` would silently stop persisting under a
global `NoTracking` default, because it assigns `token.Value = value` with no `Update` call.
**That was wrong**, and it was asserted from memory rather than checked. Tested against
Identity 10.0.6 + EF SQLite with the default flipped both ways: the token round trip and the
user update both persist correctly. The reason is that the EF `UserStore` reaches tokens via
`DbSet.FindAsync`, and `Find`/`FindAsync` track the entity regardless of
`QueryTrackingBehavior` — they are not LINQ queries.
**The real reason, found by auditing the shared libraries** (`D:\squidex-tools\libs`).
`AppDbContext` is not only Squidex's own repositories — `OnModelCreating` also mounts
`UseOpenIddict()`, `UseAssetKeyValueStore` (Tus), `UseChatStore()`, `UseFlows()`,
`UseCronJobs()`, `UseMessagingDataStore()`, `UseMessagingTransport()` and Identity. Two of
those stores read an entity with a **LINQ query**, mutate it, and call `SaveChanges` with no
`Update`, which is exactly the pattern a `NoTracking` default turns into a silent no-op:
| Store | Code | Effect under a global `NoTracking` default |
| --- | --- | --- |
| `Squidex.AI.EntityFramework/EFChatStore.SetAsync` | `Where(...).FirstOrDefaultAsync()` then `entity.Value = json` | conversation updates never persist |
| `Squidex.Messaging.EntityFramework/EFSubscription` | `query.FirstOrDefaultAsync()` then `efMessage.TimeHandled = now` | **message is never marked handled** |
The messaging one is the blocker. That assignment *is* the queue's claim on a message, and
the `DbUpdateConcurrencyException` it can raise is the only thing stopping two processes
consuming the same message. With no tracked change, `SaveChangesAsync` issues no UPDATE, so
`TimeHandled` stays null, the concurrency guard can never fire, the callback still runs, and
the next poll matches the same row again — silent infinite redelivery plus duplicate
processing across processes, with no exception anywhere.
Everything else audited clean: `EFCronJobStore`, `EFAssetKeyValueStore`, `EFEventStore`,
`EFMessagingDataStore` and `EFTransport` all `AddAsync` new entities; `EFFlowStateStore` uses
`ExecuteUpdateAsync` and bulk upsert; OpenIddict uses explicit `Attach` + `Update`; Identity
was verified empirically (see above) and calls `_userStore.Update(user)` explicitly.
**So the flip is two one-line fixes away.** Adding `dbContext.Update(entity)` before
`SaveChangesAsync` in those two stores would make `AppDbContext` safe to default to
`NoTracking` — and would also remove a latent fragility, since both currently depend on the
tracking configuration of a `DbContext` the library does not own.
`ContentDbContext` has no such tenants, which is what makes the global flip safe there.
**Caveat on the explicit approach, which is real.** Enumerating read sites is fragile: a
later sweep found five more entity-materializing reads that the first pass missed —
`EFAssetFolderRepository_SnapshotStore` (both paths), `DynamicTables`, and both paths of the
generic `EFSnapshotStore`, which backs *every* domain object snapshot and streams the whole
table on a rebuild. Those have been fixed too, but a global default would not have needed
finding them.
The `ReadAllAsync` streams were the worst individual case: they walk every content or asset
in the database for a rebuild, so tracking retained the entire table in the change tracker.
All seven `SaveChangesAsync` call sites in the layer were checked first — every one
constructs a new entity and `Add`s or bulk-inserts it. None mutate a queried entity, which
is what makes the change safe.
---
### 24. Queries by id spent an extra round trip counting a bounded set — **FIXED**
`backend/src/Squidex.Data.MongoDb/Infrastructure/Queries/LimitExtensions.cs:16`
`backend/src/Squidex.Data.MongoDb/Domain/Apps/Entities/Contents/Operations/QueryByIds.cs:59`
`backend/src/Squidex.Data.MongoDb/Domain/Apps/Entities/Assets/MongoAssetRepository.cs:112`
**Was:** both id-query paths ran `CountDocumentsAsync` to get the total even though the
filter is `In(ids)`. Since `ContentQueryParser.WithPaging` sets `Take = q.Ids.Count` for id
queries, the guard fired whenever every requested id was found — the normal case — so this
was an extra round trip on the reference-resolution path.
**Now:** a shared predicate decides when a count can tell you anything new.
```csharp
public static bool NeedsTotalById(this ClrQuery query, int idCount)
{
return query.Skip > 0 || query.Take < idCount || query.Random > 0;
}
```
**The `Random` term is the non-obvious one.** Both paths finish through
`ToListRandomAsync`, which — when `query.Random > 0` — returns a random *sample* of the
matches rather than all of them. In that case the returned count is not the match count, so
the count query is still required. The first version of this fix omitted that and would have
reported the sample size as the total.
`NoTotal` semantics are unchanged: it still short-circuits to `-1` before this predicate is
consulted, rather than opportunistically returning a total the caller asked not to have.
---
### 25. `ResolvingAssets()` re-evaluated per content — **FIXED**
`backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/ResolveAssets.cs:129`
The same defect as item 17, in the sibling step: `AddAssetIds` called the lazy
`schema.ResolvingAssets()` inside the per-content loop, rescanning every field of the schema
and allocating two LINQ iterators per content. Hoisted to a single `ToList()` above the loop.
---
### 26. `CalculatePreviewText` filtered all schema fields once per content — **FIXED**
`backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/CalculatePreviewText.cs:31`
`schema.Fields.Where(x => x.RawProperties is RichTextFieldProperties)` sat in the inner loop,
re-scanning every field for every content to produce a list identical for the whole group.
Hoisted, with an early return when the schema has no rich-text fields at all — which is the
common case and previously still paid a full field scan per content.
---
### 27. `EnrichForCaching` re-added the same schema and app dependency per content — **FIXED**
`backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/EnrichForCaching.cs`
**Was:** all three `AddDependency` calls sat in the per-content loop, but only the content one
varies. The other two re-added a key already in the set, so `CachingManager` took its lock and
did a `HashSet.Add` that returned false — a 200-item page paid ~600 lock acquisitions to do
~202 useful ones.
**Now:** the app and schema dependencies are added once per schema group.
**They were deliberately left *inside* the group loop rather than hoisted to the top of the
method.** Hoisting looks tidier but changes behaviour for an empty result: with no contents
there are no groups, so today nothing is added, `hasDependency` stays false, and the response
gets no ETag. Adding the app dependency unconditionally would start emitting an ETag for
empty responses — a change in caching behaviour that has nothing to do with this finding.
Once per group is still 1 instead of 200 for the normal single-schema query.
---
### Verification note for items 22 and 24
Build clean; `Squidex.Domain.Apps.Entities.Tests` (1541), `Squidex.Domain.Apps.Core.Tests`
(1243), `Squidex.Infrastructure.Tests` (1033), `Squidex.Web.Tests` (176) and the runnable part
of `Squidex.Data.Tests` (180) are all green.
**That green is weaker than it looks for items 22 and 24.** `Squidex.Data.Tests` contains
~1349 tests, of which only 180 run without the `Dependencies` / `TestContainer` categories —
the ~1169 excluded ones are exactly the EF and MongoDB integration tests that would actually
exercise `AsNoTracking` and `NeedsTotalById` against a real database. Those two items are
reasoned-correct and compile, but they are **not covered by any test that was run here**.
They should be validated against a container run before release.
Items 25, 26 and 27 are pure hoists with no behavioural change and are covered by the
enrichment tests that did run.

150
todo.md

@ -9,9 +9,9 @@ overhead / allocation churn), **S4** low (worth fixing while nearby).
Item numbers are stable and never reused. Completed items move to 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 [resolved.md](resolved.md) keeping their number, so gaps in the sequence here are
expected — items **4**–**20** are closed and live there. expected — items **4**–**22**, **24**–**27** are closed and live there.
**Status: 3 open of 20 — items 1, 2, 3, all the same root cause. The other 17 are in [resolved.md](resolved.md).** **Status: 7 open of 30 — items 1, 2, 3, 23, 28, 29, 30. The other 23 are in [resolved.md](resolved.md).**
--- ---
@ -66,31 +66,137 @@ already isolated in `ContentScriptVars`.
--- ---
## Suggested order of attack ## S2 — High
### 23. Full-text search inlines up to 1000 ids into a MongoDB `$in`
`backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentQueryParser.cs:93,107`
```csharp
var textQuery = new TextQuery(query.FullText, 1000) { PreferredSchemaId = schema.Id };
var fullTextIds = await textIndex.SearchAsync(context.App, textQuery, context.Scope(), ct);
...
searchFilters.Add(ClrFilter.In("id", fullTextIds.Select(x => x.ToString()).ToList()));
```
Every full-text content query becomes: one search round trip, then a second query whose filter
carries up to 1000 GUID strings — roughly 37 KB of BSON — forcing 1000 index seeks. The
`Select(x => x.ToString())` also allocates 1000 strings per query.
There is a correctness edge here too, which is why it outranks pure throughput items:
the index returns the top 1000 *by relevance*, but the outer query then re-sorts by the default
`LastModified` and pages over that. Relevance order is discarded, and anything past the 1000
cap is silently missing — invisible to the caller, who just sees fewer results than exist.
**Fix:** push paging into the text index so it returns only the page (plus a total), rather
than a fixed 1000-id prefix that the outer query re-sorts.
---
Only one piece of work is left: **pool the Jint engines (item 1)**. Items 2 and 3 are the ## S3 — Moderate
same cost seen from two call sites and mostly disappear once item 1 is done; what remains
of them afterwards is the sequential `await` per content, which is worth re-measuring ### 28. Asset downloads use an exception as the legacy-path fallback
rather than assuming. `backend/src/Squidex.Domain.Apps.Entities/Assets/DefaultAssetFileStore.cs:57,74`
```csharp
try
{
await assetStore.DownloadAsync(fileNameNew, stream, range, ct);
}
catch (AssetNotFoundException) when (!options.FolderPerApp)
{
await assetStore.DownloadAsync(fileNameOld, stream, range, ct);
}
```
On an instance that still holds assets under the old naming scheme, **every** download of those
assets throws and catches first. A .NET exception costs on the order of tens of microseconds,
and against a cloud store (S3, Azure Blob) the failed attempt is also a full network round trip
before the retry — so the fallback doubles the latency of every legacy asset served.
`GetFileSizeAsync` (line 57) has the same shape.
Worth checking while fixing: whether `assetStore.DownloadAsync` can write bytes into the target
stream before discovering the file is missing. If it can, the retry appends to a partially
written response body rather than replacing it.
**Fix:** probe once per asset and remember which naming scheme it uses, or migrate the names so
the fallback can be deleted.
---
## S4 — Low
### 29. Removing items while iterating a `JsonArray` is quadratic
`backend/src/Squidex.Domain.Apps.Core.Operations/ConvertContent/ContentConverter.cs:159,185`
```csharp
for (int i = 0; i < array.Count; i++)
{
...
if (removed)
{
array.RemoveAt(i);
i--;
}
}
```
`JsonArray` derives from `List<JsonValue>`, so each `RemoveAt` shifts every following element.
Dropping *k* items from an *n*-element array costs O(n·k). It only bites when many items are
removed at once — an array or components field whose entries reference deleted schemas or
components — but that is exactly the case where the array is large.
**Fix:** compact in a single pass (write index) rather than removing in place.
---
### 30. `stream.ToArray()` copies straight back out of the pooled buffer
`backend/src/Squidex.Domain.Apps.Entities/Assets/Transformations.cs:79`
`GetTextAsync` downloads into a `DefaultPools.MemoryStream` (a
`RecyclableMemoryStreamManager`) and then calls `stream.ToArray()`, which allocates a fresh
array of the full file and copies the pooled buffer into it — defeating the point of the pool.
`RecyclableMemoryStream` documents `ToArray` as the thing not to call for this reason.
Bounded at 4 MB by `MaxSize`, and this runs from scripts rather than the main content path,
which is why it ranks last — but 4 MB straight into the large object heap per call is still
worth avoiding.
**Fix:** `GetBuffer()` with the stream length, or the `Encoding.GetString(ReadOnlySpan<byte>)`
overload over the stream's sequence.
---
## Suggested order of attack
**Profile this one before writing it.** Everything correctness- and stability-shaped is 1. **Item 23** — full-text paging. Really a correctness fix that happens to also be faster:
closed, so what is left is pure throughput, and the estimate that engine construction the 1000-id cap silently truncates results and discards relevance order today.
dominates a scripted content list is read off the loops, not taken from a trace. Engine 2. **Items 1–3, engine pooling** — the largest single cost, and the most invasive change on
pooling is also the most invasive change on the whole list — it touches the security the list. It touches the security boundary of user-authored scripts, since a pooled
boundary of user-authored scripts, since a pooled engine must not carry state from one engine must not carry state from one script into the next. **Profile before writing it**:
script into the next. That is worth confirming is a real cost before taking the risk. the estimate that engine construction dominates a scripted content list is read off the
loops, not taken from a trace.
3. **Items 28, 29, 30** — narrow, conditional or off the main path.
--- ---
## Method / caveats ## Method / caveats
Findings come from static reading of the hot paths (content query + enrichment pipeline, Findings come from static reading of the hot paths (content query + enrichment pipeline,
GraphQL execution, write/validation path, event consumers, HTTP pipeline, MongoDB GraphQL execution, write/validation path, event consumers, HTTP pipeline, MongoDB and EF
repositories) plus scripted scans for sync-over-async, awaits inside loops, uncached repositories, asset serving, response/DTO construction) plus scripted scans for
`Regex`, and repeated LINQ materialisation. **No profiling or benchmarking was run** sync-over-async, awaits inside loops, uncached `Regex`, and repeated LINQ materialisation.
the ordering is a reasoned estimate of impact, not measured data. Item counts like
"200 × 4 engine constructions" are derived from reading the loops, not observed. **No profiling or benchmarking was run.** The ordering is a reasoned estimate of impact,
Confirm items 1–3 with a profiler against a representative workload before investing in not measured data. Counts like "200 × 4 engine constructions" or "2000 link generations"
the larger refactors. 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.
Line numbers were re-verified against the working tree after the first round of fixes.
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…
Cancel
Save