Browse Source

Improve cache keys

pull/1330/head
Sebastian Stehle 2 weeks ago
parent
commit
fbcdd7adb8
  1. 2
      backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/EventEnricher.cs
  2. 4
      backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Internal/CacheParser.cs
  3. 42
      backend/src/Squidex.Domain.Apps.Entities/AppProvider.cs
  4. 13
      backend/src/Squidex.Domain.Apps.Entities/Billing/UsageGate.cs
  5. 9
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/CachingGraphQLResolver.cs
  6. 4
      backend/src/Squidex.Domain.Apps.Entities/Context.cs
  7. 13
      backend/src/Squidex.Domain.Apps.Entities/Rules/RuleEnqueuer.cs
  8. 10
      backend/src/Squidex.Infrastructure/Security/Extensions.cs
  9. 5
      backend/src/Squidex.Infrastructure/UsageTracking/CachingUsageTracker.cs
  10. 72
      resolved.md
  11. 22
      todo.md

2
backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/EventEnricher.cs

@ -43,7 +43,7 @@ public sealed class EventEnricher(IMemoryCache userCache, IUserResolver userReso
private Task<IUser?> FindUserAsync(RefToken actor)
{
var cacheKey = $"{typeof(EventEnricher)}_Users_{actor.Identifier}";
var cacheKey = (typeof(EventEnricher), actor.Identifier);
return userCache.GetOrCreateAsync(cacheKey, async x =>
{

4
backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Internal/CacheParser.cs

@ -17,7 +17,9 @@ internal sealed class CacheParser(IMemoryCache cache)
public Prepared<Script> Parse(string script)
{
var cacheKey = $"{typeof(CacheParser)}_Script_{script}";
// A tuple keeps a reference to the source instead of copying it into a bigger string on
// every execution, which also stops the cache from holding a second copy of every script.
var cacheKey = (typeof(CacheParser), script);
return cache.GetOrCreate(cacheKey, entry =>
{

42
backend/src/Squidex.Domain.Apps.Entities/AppProvider.cs

@ -86,7 +86,7 @@ public sealed class AppProvider(
public async Task<Team?> GetTeamAsync(DomainId teamId,
CancellationToken ct = default)
{
var cacheKey = TeamCacheKey(teamId);
var cacheKey = (nameof(AppProvider), "TEAMS_ID", teamId);
var team = await GetOrCreate(cacheKey, () =>
{
@ -99,7 +99,7 @@ public sealed class AppProvider(
public async Task<Team?> GetTeamByAuthDomainAsync(string authDomain,
CancellationToken ct = default)
{
var cacheKey = TeamCacheKey(authDomain);
var cacheKey = (nameof(AppProvider), "TEAMS_DOMAIN", authDomain);
var team = await GetOrCreate(cacheKey, () =>
{
@ -148,7 +148,7 @@ public sealed class AppProvider(
public async Task<List<App>> GetUserAppsAsync(string userId, PermissionSet permissions,
CancellationToken ct = default)
{
var apps = await GetOrCreate($"GetUserApps({userId})", () =>
var apps = await GetOrCreate((nameof(AppProvider), "GET_USER_APPS", userId), () =>
{
return indexForApps.GetAppsForUserAsync(userId, permissions, ct)!;
});
@ -159,7 +159,7 @@ public sealed class AppProvider(
public async Task<List<App>> GetTeamAppsAsync(DomainId teamId,
CancellationToken ct = default)
{
var apps = await GetOrCreate($"GetTeamApps({teamId})", () =>
var apps = await GetOrCreate((nameof(AppProvider), "GET_TEAM_APPS", teamId), () =>
{
return indexForApps.GetAppsForTeamAsync(teamId, ct)!;
});
@ -169,7 +169,7 @@ public sealed class AppProvider(
public async Task<List<Team>> GetUserTeamsAsync(string userId, CancellationToken ct = default)
{
var teams = await GetOrCreate($"GetUserTeams({userId})", () =>
var teams = await GetOrCreate((nameof(AppProvider), "GET_USER_TEAMS", userId), () =>
{
return indexForTeams.GetTeamsAsync(userId, ct)!;
});
@ -180,7 +180,7 @@ public sealed class AppProvider(
public async Task<List<Schema>> GetSchemasAsync(DomainId appId,
CancellationToken ct = default)
{
var schemas = await GetOrCreate($"GetSchemasAsync({appId})", () =>
var schemas = await GetOrCreate((nameof(AppProvider), "GET_SCHEMAS", appId), () =>
{
return indexForSchemas.GetSchemasAsync(appId, ct)!;
});
@ -200,7 +200,7 @@ public sealed class AppProvider(
public async Task<List<Rule>> GetRulesAsync(DomainId appId,
CancellationToken ct = default)
{
var rules = await GetOrCreate($"GetRulesAsync({appId})", () =>
var rules = await GetOrCreate((nameof(AppProvider), "GET_RULES", appId), () =>
{
return indexForRules.GetRulesAsync(appId, ct)!;
});
@ -242,34 +242,24 @@ public sealed class AppProvider(
return await result;
}
private static string AppCacheKey(DomainId appId)
{
return $"APPS_ID_{appId}";
}
private static string AppCacheKey(string appName)
{
return $"APPS_NAME_{appName}";
}
private static string TeamCacheKey(DomainId teamId)
private static object AppCacheKey(DomainId appId)
{
return $"TEAMS_ID_{teamId}";
return (nameof(AppProvider), "APPS_ID", appId);
}
private static string TeamCacheKey(string authDomain)
private static object AppCacheKey(string appName)
{
return $"TEAMS_DOMAIN_{authDomain}";
return (nameof(AppProvider), "APPS_NAME", appName);
}
private static string SchemaCacheKey(DomainId appId, DomainId id)
private static object SchemaCacheKey(DomainId appId, DomainId id)
{
return $"SCHEMAS_ID_{appId}_{id}";
return (nameof(AppProvider), "SCHEMAS_ID", appId, id);
}
private static string SchemaCacheKey(DomainId appId, string name)
private static object SchemaCacheKey(DomainId appId, string name)
{
return $"SCHEMAS_NAME_{appId}_{name}";
return (nameof(AppProvider), "SCHEMAS_NAME", appId, name);
}
}

13
backend/src/Squidex.Domain.Apps.Entities/Billing/UsageGate.cs

@ -89,12 +89,17 @@ public sealed partial class UsageGate(
private bool HasNotifiedBefore(DomainId appId)
{
return memoryCache.Get<bool>(appId);
return memoryCache.Get<bool>(NotifiedKey(appId));
}
private bool TrackNotified(DomainId appId)
{
return memoryCache.Set(appId, true, TimeSpan.FromHours(1));
return memoryCache.Set(NotifiedKey(appId), true, TimeSpan.FromHours(1));
}
private static object NotifiedKey(DomainId appId)
{
return (typeof(UsageGate), nameof(TrackNotified), appId);
}
private static string[] GetUsers(App app)
@ -192,8 +197,8 @@ public sealed partial class UsageGate(
return Task.FromResult((plan, planId));
}
private static string CacheKey(DomainId appId)
private static object CacheKey(DomainId appId)
{
return $"{appId}_Plan";
return (typeof(UsageGate), nameof(GetPlanForAppAsync), appId);
}
}

9
backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/CachingGraphQLResolver.cs

@ -5,7 +5,6 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Globalization;
using GraphQL;
using GraphQL.DI;
using Microsoft.Extensions.DependencyInjection;
@ -64,7 +63,8 @@ public sealed class CachingGraphQLResolver(
return CreateModelAsync(app);
}
var cacheKey = CreateCacheKey(app.Id, app.Version.ToString(CultureInfo.InvariantCulture));
// A tuple can hold the version as it is, so it does not have to be formatted first.
var cacheKey = (typeof(CachingGraphQLResolver), app.Id, app.Version);
return cache.GetOrCreateAsync(cacheKey, options.CacheDuration, async entry =>
{
@ -85,9 +85,4 @@ public sealed class CachingGraphQLResolver(
return new CacheEntry(new Builder(app, options).BuildSchema(schemasList), schemasKey);
}
private static object CreateCacheKey(DomainId appId, string etag)
{
return $"GraphQLModel_{appId}_{etag}";
}
}

4
backend/src/Squidex.Domain.Apps.Entities/Context.cs

@ -29,7 +29,7 @@ public sealed class Context
public App App { get; set; }
public bool IsFrontendClient => UserPrincipal.IsInClient(DefaultClients.Frontend);
public bool IsFrontendClient { get; };
public Context(ClaimsPrincipal user, App app)
: this(app, user, user.Claims.Permissions(), EmptyHeaders)
@ -48,6 +48,8 @@ public sealed class Context
UserPrincipal = userPrincipal;
UserPermissions = userPermissions;
IsFrontendClient = userPrincipal.IsInClient(DefaultClients.Frontend);
Headers = headers;
}

13
backend/src/Squidex.Domain.Apps.Entities/Rules/RuleEnqueuer.cs

@ -126,17 +126,6 @@ public sealed class RuleEnqueuer(
}
}
// Returns null for events that are not handled, so that they all end up in one group to skip.
private static NamedId<DomainId>? GetAppId(Envelope<IEvent> @event)
{
if (@event.Headers.Restored() || @event.Payload is not AppEvent appEvent)
{
return null;
}
return appEvent.AppId;
}
private Task<List<Rule>> GetRulesAsync(DomainId appId)
{
if (cacheDuration <= TimeSpan.Zero || cacheDuration == TimeSpan.MaxValue)
@ -144,7 +133,7 @@ public sealed class RuleEnqueuer(
return appProvider.GetRulesAsync(appId);
}
var cacheKey = $"{typeof(RuleEnqueuer)}_Rules_{appId}";
var cacheKey = (typeof(RuleEnqueuer), appId);
// Cache the rules for performance reasons for a short period of time (usually 10 sec).
return cache.GetOrCreateAsync(cacheKey, entry =>

10
backend/src/Squidex.Infrastructure/Security/Extensions.cs

@ -69,6 +69,14 @@ public static class Extensions
public static bool IsInClient(this ClaimsPrincipal principal, string client)
{
return principal.Claims.Any(x => x.Type == OpenIdClaims.ClientId && string.Equals(x.Value, client, StringComparison.OrdinalIgnoreCase));
foreach (var claim in principal.Claims)
{
if (claim.Type == OpenIdClaims.ClientId && string.Equals(claim.Value, client, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
}

5
backend/src/Squidex.Infrastructure/UsageTracking/CachingUsageTracker.cs

@ -52,7 +52,8 @@ public sealed class CachingUsageTracker(IUsageTracker inner, IMemoryCache cache)
{
Guard.NotNull(key);
var cacheKey = $"{typeof(CachingUsageTracker)}_UsageForMonth_{key}_{date}_{category}";
// A tuple avoids building a string per request and also avoids formatting the date.
var cacheKey = (typeof(CachingUsageTracker), nameof(GetForMonthAsync), key, date, category);
return cache.GetOrCreateAsync(cacheKey, entry =>
{
@ -67,7 +68,7 @@ public sealed class CachingUsageTracker(IUsageTracker inner, IMemoryCache cache)
{
Guard.NotNull(key);
var cacheKey = $"{typeof(CachingUsageTracker)}_Usage_{key}_{fromDate}_{toDate}_{category}";
var cacheKey = (typeof(CachingUsageTracker), nameof(GetAsync), key, fromDate, toDate, category);
return cache.GetOrCreateAsync(cacheKey, entry =>
{

72
resolved.md

@ -331,3 +331,75 @@ rules, that the grouped order is what reaches the service, and — via `Assert.S
against the expected `app1, app1, app2, app2`) and passes after. Existing coverage did
not include a multi-app batch at all: `Should_handle_events_in_batches` repeats the *same*
event ten times. Full `Squidex.Domain.Apps.Entities.Tests` green (1528).
---
### 20. Script cache key embedded the entire script source — **FIXED**
`backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Internal/CacheParser.cs:20`
**Was:**
```csharp
var cacheKey = $"{typeof(CacheParser)}_Script_{script}";
```
Every parse allocated a new string holding a full copy of the script body, and
`IMemoryCache` then retained that copy as the key — so each cached script was held twice.
**Now:** `var cacheKey = (typeof(CacheParser), script);`
The tuple boxes once (one small allocation) but holds a *reference* to the existing
script string, so nothing is copied and the cache no longer keeps a second copy alive.
**Honest limit:** this removes the allocation and the duplicate retention, not the hash.
`ValueTuple.GetHashCode` still calls `string.GetHashCode()` on the source, which is O(n)
— .NET does not cache string hash codes. Removing that too would mean keying by schema id
+ script version, which needs that context plumbed into `CacheParser` and changes its API.
Not worth it unless profiling says the hash itself shows up.
---
### Tuple cache keys — sweep of the other call sites
Same change applied where the key was an interpolated string and the cache accepts
`object`. Beyond skipping the string build, a tuple also avoids *formatting* non-string
parts (`DateOnly`, `long`), which the interpolation did on every call.
| Site | Key before | Key now |
| --- | --- | --- |
| `CachingUsageTracker.GetForMonthAsync` | `$"{typeof(..)}_UsageForMonth_{key}_{date}_{category}"` | `(typeof(..), nameof(GetForMonthAsync), key, date, category)` |
| `CachingUsageTracker.GetAsync` | `$"{typeof(..)}_Usage_{key}_{fromDate}_{toDate}_{category}"` | `(typeof(..), nameof(GetAsync), key, fromDate, toDate, category)` |
| `EventEnricher.FindUserAsync` | `$"{typeof(..)}_Users_{actor.Identifier}"` | `(typeof(EventEnricher), actor.Identifier)` |
| `RuleEnqueuer.GetRulesAsync` | `$"{typeof(..)}_Rules_{appId}"` | `(typeof(RuleEnqueuer), appId)` |
| `UsageGate.CacheKey` | `$"{appId}_Plan"` | `(typeof(UsageGate), nameof(GetPlanForAppAsync), appId)` |
| `UsageGate` notified flag | bare `DomainId` | `(typeof(UsageGate), nameof(TrackNotified), appId)` |
| `CachingGraphQLResolver` | `$"GraphQLModel_{appId}_{etag}"` | `(typeof(CachingGraphQLResolver), app.Id, app.Version)` |
| `AppProvider` × 11 | `$"APPS_ID_{appId}"`, `$"GetSchemasAsync({appId})"`, … | `(nameof(AppProvider), "APPS_ID", appId)`, … |
Notes:
- `CachingUsageTracker.GetForMonthAsync` runs on **every API request** (via
`UsageGate.IsBlockedAsync`) and its old key formatted a `DateOnly` — a culture lookup
plus an allocation — before building an ~80-character string.
- `CachingGraphQLResolver` no longer needs
`app.Version.ToString(CultureInfo.InvariantCulture)`; the tuple carries the `long`
directly, so `System.Globalization` was dropped from the file.
- `UsageGate`'s notified flag previously used a bare `DomainId` as the key. It was safe
only because that `MemoryCache` is private to the class; it is now explicit.
- `AppProvider` keys carry `nameof(AppProvider)` plus the lookup name, preserving the
namespacing the old string prefixes provided. The two `TeamCacheKey` overloads and
`CachingGraphQLResolver.CreateCacheKey` had a single call site each and were inlined;
`AppCacheKey` and `SchemaCacheKey` have three each and stayed as helpers.
**Three sites were deliberately left as strings:**
- `MongoCountCollection.GetOrAddAsync(string key, …)` — used by `QueryByQuery` and
`MongoAssetRepository`. That key is **persisted as a MongoDB document id**, not an
in-memory cache key. Changing it would change stored data.
- `DataLoaderContext.GetOrAddLoader(string loaderKey, …)` — the GraphQL.DataLoader API
takes a `string`, so `GraphQLExecutionContext.GetContent` cannot use a tuple.
- `Singletons<IMongoClient>.GetOrAdd(string, …)` — typed `string`, and startup-only.
**Verified:** build clean (0 warnings). `Squidex.Domain.Apps.Core.Tests` (1243),
`Squidex.Domain.Apps.Entities.Tests` (1528), `Squidex.Infrastructure.Tests` (1031) and
`Squidex.Web.Tests` (167) all green.

22
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
[resolved.md](resolved.md) keeping their number, so gaps in the sequence here are
expected — items **4**, **5**, **6**, **7**, **8**, **9**, **12** and **13** are closed and live there.
expected — items **4**, **5**, **6**, **7**, **8**, **9**, **12**, **13** and **20** are closed and live there.
**Status: 11 open of 20. Items 4, 5, 6, 7, 8, 9, 12, 13 are in [resolved.md](resolved.md).**
**Status: 10 open of 20. Items 4, 5, 6, 7, 8, 9, 12, 13, 20 are in [resolved.md](resolved.md).**
---
@ -209,24 +209,6 @@ from several steps. The headers never change for the lifetime of a `Context`.
---
## S4 — Low
### 20. Script cache key embeds the entire script source
`backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Internal/CacheParser.cs:20`
```csharp
var cacheKey = $"{typeof(CacheParser)}_Script_{script}";
```
Every parse allocates a new string containing a copy of the whole script body and hashes
it end to end, and `IMemoryCache` retains that string as the key. Entries also have no
size limit, so each edit of a script adds another full-source-sized entry for the
10-minute window.
**Fix:** key by a precomputed hash of the source (or by schema id + script version).
---
## Suggested order of attack
1. **Engine pooling (items 1–3)** — now the largest open cost by a wide margin. One

Loading…
Cancel
Save