Skip to content

Commit bdff505

Browse files
Performance improvements (#1330)
* Security fixes. * Fix build. * Fix build. * UI fixes. * Fix asset tags. * Info the save the query. * Fix first batches. * More improvements * More fixes * Improve cache keys * More fixes * More fixes * Fix * Fixes * Fixes * More fixes * Fix issues * More progress * Temp * Update packages. * Fix formatting. * Fix db context configuration.
1 parent 0ecfe2f commit bdff505

117 files changed

Lines changed: 3071 additions & 1860 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CLAUDE.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# Squidex
2+
3+
Headless CMS. Angular frontend in `frontend/`, ASP.NET Core backend in `backend/`.
4+
5+
## Frontend
6+
7+
- Angular app in `frontend/`, source under `src/app`:
8+
- `framework/` — generic, reusable UI components and utilities (no domain knowledge).
9+
- `shared/` — Squidex-specific services, state stores and components.
10+
- `features/` — the actual screens (apps, assets, content, rules, schemas, settings, teams, ...).
11+
- `shell/` — app frame, navigation, layout.
12+
- State is handled with the state store pattern from `framework/state.ts` (immutable value objects + `State<T>` subclasses), not with a third-party store library.
13+
- Commands:
14+
15+
```bash
16+
npm start
17+
```
18+
19+
```bash
20+
npm test
21+
```
22+
23+
```bash
24+
npm run lint
25+
```
26+
27+
### Best Practices
28+
29+
- i18n texts live in `backend/i18n`, translations are generated into the frontend — do not edit generated translation files by hand.
30+
- Do not write JsDoc comments.
31+
32+
## Backend
33+
34+
- .NET solution `backend/Squidex.slnx`. Projects under `backend/src`, tests under `backend/tests`, optional integrations under `backend/extensions`.
35+
- Layering: `Squidex.Infrastructure` (generic building blocks) → `Squidex.Domain.Apps.*` (core model, operations, events, entities) → `Squidex.Web` / `Squidex` (API host).
36+
- Event-sourced domain: aggregates emit events from `Squidex.Domain.Apps.Events`, state is projected into MongoDB or EF Core (`Squidex.Data.MongoDb`, `Squidex.Data.EntityFramework`).
37+
- Run tests with the filter below — some tests need external setup (real databases, Docker/Testcontainers) and will fail without it:
38+
39+
### Tests
40+
41+
Some tests need test setup or test containers which are slow. Run the tests like this to skip these tests.
42+
43+
```bash
44+
dotnet test --filter "Category!=Dependencies & Category!=TestContainer"
45+
```
46+
47+
### Best Practices
48+
49+
- Code style is enforced by StyleCop (`backend/stylecop.json`) and `.editorconfig` — follow the surrounding file's conventions.
50+
- Do not write XML comments.
51+
52+
## Shared best practices
53+
54+
- Do write precise short comments and only when needed.
55+
- Do not comment a class or a method, only put comments inside functions or above variables.

backend/src/Squidex.Data.EntityFramework/Infrastructure/Extensions.cs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,9 +54,15 @@ public static string Prefix(this DbContextOptions options)
5454
return options.GetExtension<PrefixExtension>().Prefix;
5555
}
5656

57-
public static DbContextOptionsBuilder SetDefaultWarnings(this DbContextOptionsBuilder builder)
57+
public static DbContextOptionsBuilder SetDefaults(this DbContextOptionsBuilder builder)
5858
{
5959
builder.ConfigureWarnings(w => w.Ignore(CoreEventId.CollectionWithoutComparer));
60+
61+
// Almost everything is read only or written by inserting new entities, so tracking would
62+
// only cost a snapshot of every entity that is read. The few stores that update an entity
63+
// they have queried ask for it with AsTracking. This cannot be done in OnConfiguring,
64+
// because the contexts are pooled and pooling forbids to modify the options there.
65+
builder.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);
6066
return builder;
6167
}
6268

backend/src/Squidex.Data.EntityFramework/ServiceExtensions.cs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ public static void AddSquidexEntityFramework(this IServiceCollection services, I
8282

8383
services.AddPooledDbContextFactory<MySqlAppDbContext>(builder =>
8484
{
85-
builder.SetDefaultWarnings();
85+
builder.SetDefaults();
8686
builder.UseMySql(connectionString, version, options =>
8787
{
8888
options.UseNetTopologySuite();
@@ -93,7 +93,7 @@ public static void AddSquidexEntityFramework(this IServiceCollection services, I
9393

9494
services.AddNamedDbContext<MySqlContentDbContext>((builder, name) =>
9595
{
96-
builder.SetDefaultWarnings();
96+
builder.SetDefaults();
9797
builder.UseBulkInsertMySql();
9898
builder.UseMySql(connectionString, version, options =>
9999
{
@@ -118,7 +118,7 @@ public static void AddSquidexEntityFramework(this IServiceCollection services, I
118118
{
119119
services.AddPooledDbContextFactory<PostgresAppDbContext>(builder =>
120120
{
121-
builder.SetDefaultWarnings();
121+
builder.SetDefaults();
122122
builder.UseBulkInsertPostgreSql();
123123
builder.UseNpgsql(connectionString, options =>
124124
{
@@ -128,7 +128,7 @@ public static void AddSquidexEntityFramework(this IServiceCollection services, I
128128

129129
services.AddNamedDbContext<PostgresContentDbContext>((builder, name) =>
130130
{
131-
builder.SetDefaultWarnings();
131+
builder.SetDefaults();
132132
builder.UseBulkInsertPostgreSql();
133133
builder.UseNpgsql(connectionString, options =>
134134
{
@@ -152,7 +152,7 @@ public static void AddSquidexEntityFramework(this IServiceCollection services, I
152152
{
153153
services.AddPooledDbContextFactory<SqlServerAppDbContext>(builder =>
154154
{
155-
builder.SetDefaultWarnings();
155+
builder.SetDefaults();
156156
builder.UseSqlServer(connectionString, options =>
157157
{
158158
options.UseNetTopologySuite();
@@ -162,7 +162,7 @@ public static void AddSquidexEntityFramework(this IServiceCollection services, I
162162

163163
services.AddNamedDbContext<SqlServerContentDbContext>((builder, name) =>
164164
{
165-
builder.SetDefaultWarnings();
165+
builder.SetDefaults();
166166
builder.UseBulkInsertSqlServer();
167167
builder.UseSqlServer(connectionString, options =>
168168
{

backend/src/Squidex.Data.EntityFramework/Squidex.Data.EntityFramework.csproj

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -43,13 +43,13 @@
4343
<PackageReference Include="Microting.EntityFrameworkCore.MySql.Json.Microsoft" Version="10.0.6" />
4444
<PackageReference Include="Microting.EntityFrameworkCore.MySql.NetTopologySuite" Version="10.0.6" />
4545
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
46-
<PackageReference Include="Squidex.AI.EntityFramework" Version="8.0.3" />
47-
<PackageReference Include="Squidex.Assets.EntityFramework" Version="8.0.3" />
48-
<PackageReference Include="Squidex.Assets.TusAdapter" Version="8.0.3" />
49-
<PackageReference Include="Squidex.Events.EntityFramework" Version="8.0.3" />
50-
<PackageReference Include="Squidex.Flows.EntityFramework" Version="8.0.3" />
51-
<PackageReference Include="Squidex.Hosting" Version="8.0.3" />
52-
<PackageReference Include="Squidex.Messaging.EntityFramework" Version="8.0.3" />
46+
<PackageReference Include="Squidex.AI.EntityFramework" Version="8.0.4" />
47+
<PackageReference Include="Squidex.Assets.EntityFramework" Version="8.0.4" />
48+
<PackageReference Include="Squidex.Assets.TusAdapter" Version="8.0.4" />
49+
<PackageReference Include="Squidex.Events.EntityFramework" Version="8.0.4" />
50+
<PackageReference Include="Squidex.Flows.EntityFramework" Version="8.0.4" />
51+
<PackageReference Include="Squidex.Hosting" Version="8.0.4" />
52+
<PackageReference Include="Squidex.Messaging.EntityFramework" Version="8.0.4" />
5353
<PackageReference Include="Squidex.OpenIdDict.EntityFramework" Version="7.2.1" />
5454
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
5555
<PackageReference Include="System.ValueTuple" Version="4.6.2" />

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ await Collection.Find(filter)
109109
{
110110
assetTotal = -1;
111111
}
112-
else
112+
else if (query.NeedsTotalById(q.Ids.Count))
113113
{
114114
assetTotal = await Collection.Find(filter).CountDocumentsAsync(ct);
115115
}

backend/src/Squidex.Data.MongoDb/Domain/Apps/Entities/Contents/CollectionProvider.cs

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,39 @@ namespace Squidex.Domain.Apps.Entities.Contents;
1313

1414
internal class CollectionProvider(IMongoClient mongoClient, string prefixDatabase, string prefixCollection)
1515
{
16-
private readonly ConcurrentDictionary<(DomainId, DomainId), Task<IMongoCollection<MongoContentEntity>>> collections =
17-
new ConcurrentDictionary<(DomainId, DomainId), Task<IMongoCollection<MongoContentEntity>>>();
16+
private readonly ConcurrentDictionary<(DomainId AppId, DomainId SchemaId), Lazy<Task<IMongoCollection<MongoContentEntity>>>> collections =
17+
new ConcurrentDictionary<(DomainId AppId, DomainId SchemaId), Lazy<Task<IMongoCollection<MongoContentEntity>>>>();
1818

19-
public Task<IMongoCollection<MongoContentEntity>> GetCollectionAsync(DomainId appId, DomainId schemaId)
19+
public async Task<IMongoCollection<MongoContentEntity>> GetCollectionAsync(DomainId appId, DomainId schemaId)
2020
{
21-
return collections.GetOrAdd((appId, schemaId), CreateCollectionAsync);
21+
var key = (appId, schemaId);
22+
23+
// The lazy ensures that the indexes are only created once, even when the same collection is
24+
// requested concurrently. GetOrAdd alone can run the factory several times for one key.
25+
var collection = collections.GetOrAdd(key, CreateLazyCollection);
26+
27+
try
28+
{
29+
return await collection.Value;
30+
}
31+
catch
32+
{
33+
// A failed attempt must not stay in the cache. Creating the indexes can fail for a
34+
// transient reason and the collection would be unusable until the process is restarted.
35+
// Only remove our own entry, so that a newer successful one is not thrown away.
36+
collections.TryRemove(new KeyValuePair<(DomainId AppId, DomainId SchemaId), Lazy<Task<IMongoCollection<MongoContentEntity>>>>(key, collection));
37+
throw;
38+
}
39+
}
40+
41+
private Lazy<Task<IMongoCollection<MongoContentEntity>>> CreateLazyCollection((DomainId AppId, DomainId SchemaId) key)
42+
{
43+
return new Lazy<Task<IMongoCollection<MongoContentEntity>>>(
44+
() => CreateCollectionAsync(key),
45+
LazyThreadSafetyMode.ExecutionAndPublication);
2246
}
2347

24-
private async Task<IMongoCollection<MongoContentEntity>> CreateCollectionAsync((DomainId, DomainId) key)
48+
private async Task<IMongoCollection<MongoContentEntity>> CreateCollectionAsync((DomainId AppId, DomainId SchemaId) key)
2549
{
2650
var (appId, schemaId) = key;
2751

backend/src/Squidex.Data.MongoDb/Domain/Apps/Entities/Contents/MongoContentRepository_SnapshotStore.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ async Task ISnapshotStore<WriteContent>.WriteManyAsync(IEnumerable<SnapshotWrite
135135
collectionUpdates.GetOrAddNew(collection).Add(entity);
136136
});
137137

138-
foreach (var job in jobs)
138+
foreach (var job in validJobs)
139139
{
140140
if (job.Value.ShouldWritePublished())
141141
{

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ public async Task<IResultList<Content>> QueryAsync(App app, List<Schema> schemas
5656
{
5757
contentTotal = -1;
5858
}
59-
else
59+
else if (query.NeedsTotalById(q.Ids.Count))
6060
{
6161
contentTotal = await Collection.Find(filter).CountDocumentsAsync(ct);
6262
}

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,13 @@ public async Task<IResultList<Content>> QueryAsync(App app, List<Schema> schemas
6464
{
6565
contentTotal = -1;
6666
}
67+
else if (isDefault)
68+
{
69+
// Cache total count by app and schemas because no other filters are applied (aka default).
70+
var totalKey = CreateTotalKey(app, schemas);
71+
72+
contentTotal = await countCollection.GetOrAddAsync(totalKey, ct => Collection.Find(filter).CountDocumentsAsync(ct), ct);
73+
}
6774
else if (query.IsSatisfiedByIndex())
6875
{
6976
// It is faster to filter with sorting when there is an index, because it forces the index to be used.
@@ -78,6 +85,16 @@ public async Task<IResultList<Content>> QueryAsync(App app, List<Schema> schemas
7885
return ResultList.Create<Content>(contentTotal, contentEntities);
7986
}
8087

88+
private static string CreateTotalKey(App app, List<Schema> schemas)
89+
{
90+
// The schemas depend on the permissions of the user and are not in a stable order, so the ids
91+
// are sorted. They are also hashed, because the key is the ID of the count document and there
92+
// can be enough schemas to exceed the maximum key size of MongoDB.
93+
var schemaIds = schemas.Select(x => x.Id.ToString()).Order(StringComparer.Ordinal);
94+
95+
return $"{app.Id}_Schemas_{string.Join('_', schemaIds).ToSha256Base64()}";
96+
}
97+
8198
public async Task<IResultList<Content>> QueryAsync(Schema schema, Q q,
8299
CancellationToken ct)
83100
{

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,13 @@ namespace Squidex.Infrastructure.Queries;
1111

1212
public static class LimitExtensions
1313
{
14+
public static bool NeedsTotalById(this ClrQuery query, int idCount)
15+
{
16+
// A query by ID can never match more documents than the number of requested IDs, so the result
17+
// already contains all of them unless skip, take or the random selection could have cut it off.
18+
return query.Skip > 0 || query.Take < idCount || query.Random > 0;
19+
}
20+
1421
public static IAggregateFluent<T> QueryLimit<T>(this IAggregateFluent<T> cursor, ClrQuery query)
1522
{
1623
if (query.Take < long.MaxValue)

0 commit comments

Comments
 (0)