Skip to content

Commit b52461d

Browse files
V16: Cache Version Mechanism (#19747)
* Add RepositoryCacheVersion table * Add repository * Add Cache version lock * Add GetAll method to repository * Add RepositoryCacheVersionService * Remember to add lock in data creator * Work my way out of constructor hell This is why we use DI folks. 🤦 * Add checks to specific cache policies * Fix migration * Add to schema creator * Fix database access * Initialize the cache version on in memory miss * Make cache version service internal * Add tests * Apply suggestions from code review Co-authored-by: Andy Butland <[email protected]> * Add missing obsoletions * Prefer full name --------- Co-authored-by: Andy Butland <[email protected]>
1 parent 698d566 commit b52461d

File tree

78 files changed

+1234
-213
lines changed

Some content is hidden

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

78 files changed

+1234
-213
lines changed
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
namespace Umbraco.Cms.Core.Cache;
2+
3+
/// <summary>
4+
/// Provides methods to manage and validate cache versioning for repository entities,
5+
/// ensuring cache consistency with the underlying database.
6+
/// </summary>
7+
public interface IRepositoryCacheVersionService
8+
{
9+
/// <summary>
10+
/// Validates if the cache is synced with the database.
11+
/// </summary>
12+
/// <typeparam name="TEntity">The type of the cached entity.</typeparam>
13+
/// <returns>True if cache is synced, false if cache needs fast-forwarding.</returns>
14+
Task<bool> IsCacheSyncedAsync<TEntity>()
15+
where TEntity : class;
16+
17+
/// <summary>
18+
/// Registers a cache update for the specified entity type.
19+
/// </summary>
20+
/// <typeparam name="TEntity">The type of the cached entity.</typeparam>
21+
Task SetCacheUpdatedAsync<TEntity>()
22+
where TEntity : class;
23+
24+
/// <summary>
25+
/// Registers that the cache has been synced with the database.
26+
/// </summary>
27+
Task SetCachesSyncedAsync();
28+
}
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
using System.Collections.Concurrent;
2+
using Microsoft.Extensions.Logging;
3+
using Umbraco.Cms.Core.Models;
4+
using Umbraco.Cms.Core.Persistence.Repositories;
5+
using Umbraco.Cms.Core.Scoping;
6+
7+
namespace Umbraco.Cms.Core.Cache;
8+
9+
/// <inheritdoc />
10+
internal class RepositoryCacheVersionService : IRepositoryCacheVersionService
11+
{
12+
private readonly ICoreScopeProvider _scopeProvider;
13+
private readonly IRepositoryCacheVersionRepository _repositoryCacheVersionRepository;
14+
private readonly ILogger<RepositoryCacheVersionService> _logger;
15+
private readonly ConcurrentDictionary<string, Guid> _cacheVersions = new();
16+
17+
public RepositoryCacheVersionService(
18+
ICoreScopeProvider scopeProvider,
19+
IRepositoryCacheVersionRepository repositoryCacheVersionRepository,
20+
ILogger<RepositoryCacheVersionService> logger)
21+
{
22+
_scopeProvider = scopeProvider;
23+
_repositoryCacheVersionRepository = repositoryCacheVersionRepository;
24+
_logger = logger;
25+
}
26+
27+
/// <inheritdoc />
28+
public async Task<bool> IsCacheSyncedAsync<TEntity>()
29+
where TEntity : class
30+
{
31+
_logger.LogDebug("Checking if cache for {EntityType} is synced", typeof(TEntity).Name);
32+
33+
// We have to take a read lock to ensure the cache is not being updated while we check the version.
34+
using ICoreScope scope = _scopeProvider.CreateCoreScope(autoComplete: true);
35+
scope.ReadLock(Constants.Locks.CacheVersion);
36+
37+
var cacheKey = GetCacheKey<TEntity>();
38+
39+
RepositoryCacheVersion? databaseVersion = await _repositoryCacheVersionRepository.GetAsync(cacheKey);
40+
41+
if (databaseVersion?.Version is null)
42+
{
43+
_logger.LogDebug("Cache for {EntityType} has no version in the database, considering it synced", typeof(TEntity).Name);
44+
45+
// If the database version is null, it means the cache has never been initialized, so we consider it synced.
46+
return true;
47+
}
48+
49+
if (_cacheVersions.TryGetValue(cacheKey, out Guid localVersion) is false)
50+
{
51+
_logger.LogDebug("Cache for {EntityType} is not initialized, considering it synced", typeof(TEntity).Name);
52+
53+
// We're not initialized yet, so cache is empty, which means cache is synced.
54+
// Since the cache is most likely no longer empty, we should set the cache version to the database version.
55+
_cacheVersions[cacheKey] = Guid.Parse(databaseVersion.Version);
56+
return true;
57+
}
58+
59+
// We could've parsed this in the repository layer; however, the fact that we are using a Guid is an implementation detail.
60+
if (localVersion != Guid.Parse(databaseVersion.Version))
61+
{
62+
_logger.LogDebug(
63+
"Cache for {EntityType} is not synced: local version {LocalVersion} does not match database version {DatabaseVersion}",
64+
typeof(TEntity).Name,
65+
localVersion,
66+
databaseVersion.Version);
67+
return false;
68+
}
69+
70+
_logger.LogDebug("Cache for {EntityType} is synced", typeof(TEntity).Name);
71+
return true;
72+
}
73+
74+
/// <inheritdoc />
75+
public async Task SetCacheUpdatedAsync<TEntity>()
76+
where TEntity : class
77+
{
78+
using ICoreScope scope = _scopeProvider.CreateCoreScope();
79+
80+
// We have to take a write lock to ensure the cache is not being read while we update the version.
81+
scope.WriteLock(Constants.Locks.CacheVersion);
82+
83+
var cacheKey = GetCacheKey<TEntity>();
84+
var newVersion = Guid.NewGuid();
85+
86+
_logger.LogDebug("Setting cache for {EntityType} to version {Version}", typeof(TEntity).Name, newVersion);
87+
await _repositoryCacheVersionRepository.SaveAsync(new RepositoryCacheVersion { Identifier = cacheKey, Version = newVersion.ToString() });
88+
_cacheVersions[cacheKey] = newVersion;
89+
90+
scope.Complete();
91+
}
92+
93+
/// <inheritdoc />
94+
public async Task SetCachesSyncedAsync()
95+
{
96+
using ICoreScope scope = _scopeProvider.CreateCoreScope();
97+
scope.ReadLock(Constants.Locks.CacheVersion);
98+
99+
// We always sync all caches versions, so it's safe to assume all caches are synced at this point.
100+
IEnumerable<RepositoryCacheVersion> cacheVersions = await _repositoryCacheVersionRepository.GetAllAsync();
101+
102+
foreach (RepositoryCacheVersion version in cacheVersions)
103+
{
104+
if (version.Version is null)
105+
{
106+
continue;
107+
}
108+
109+
_cacheVersions[version.Identifier] = Guid.Parse(version.Version);
110+
}
111+
112+
scope.Complete();
113+
}
114+
115+
internal string GetCacheKey<TEntity>()
116+
where TEntity : class =>
117+
typeof(TEntity).FullName ?? typeof(TEntity).Name;
118+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
namespace Umbraco.Cms.Core.Cache;
2+
3+
/// <summary>
4+
/// A simple cache version service that assumes the cache is always in sync.
5+
/// <remarks>
6+
/// This is useful in scenarios where you have a single server setup and do not need to manage cache synchronization across multiple servers.
7+
/// </remarks>
8+
/// </summary>
9+
public class SingleServerCacheVersionService : IRepositoryCacheVersionService
10+
{
11+
/// <inheritdoc />
12+
public Task<bool> IsCacheSyncedAsync<TEntity>()
13+
where TEntity : class
14+
=> Task.FromResult(true);
15+
16+
/// <inheritdoc />
17+
public Task SetCacheUpdatedAsync<TEntity>()
18+
where TEntity : class
19+
=> Task.CompletedTask;
20+
21+
/// <inheritdoc />
22+
public Task SetCachesSyncedAsync() => Task.CompletedTask;
23+
}

src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,7 @@ private void AddCoreServices()
341341
Services.AddUnique<ILocalizedTextService>(factory => new LocalizedTextService(
342342
factory.GetRequiredService<Lazy<LocalizedTextServiceFileSources>>(),
343343
factory.GetRequiredService<ILogger<LocalizedTextService>>()));
344+
Services.AddUnique<IRepositoryCacheVersionService, RepositoryCacheVersionService>();
344345

345346
Services.AddUnique<IEntityXmlSerializer, EntityXmlSerializer>();
346347

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
namespace Umbraco.Cms.Core.Models;
2+
3+
/// <summary>
4+
/// Represents a version of a repository cache.
5+
/// </summary>
6+
public class RepositoryCacheVersion
7+
{
8+
/// <summary>
9+
/// The unique identifier for the cache.
10+
/// </summary>
11+
public required string Identifier { get; init; }
12+
13+
/// <summary>
14+
/// The identifier of the version of the cache.
15+
/// </summary>
16+
public required string? Version { get; init; }
17+
}

src/Umbraco.Core/Persistence/Constants-DatabaseSchema.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,8 @@ public static class Tables
9898
public const string Webhook2Headers = Webhook + "2Headers";
9999
public const string WebhookLog = Webhook + "Log";
100100
public const string WebhookRequest = Webhook + "Request";
101+
102+
public const string RepositoryCacheVersion = TableNamePrefix + "RepositoryCacheVersion";
101103
}
102104
}
103105
}

src/Umbraco.Core/Persistence/Constants-Locks.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,5 +80,10 @@ public static class Locks
8080
/// All webhook logs.
8181
/// </summary>
8282
public const int WebhookLogs = -343;
83+
84+
/// <summary>
85+
/// The cache version.
86+
/// </summary>
87+
public const int CacheVersion = -346;
8388
}
8489
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
using Umbraco.Cms.Core.Models;
2+
3+
namespace Umbraco.Cms.Core.Persistence.Repositories;
4+
5+
/// <summary>
6+
/// Defines methods for accessing and persisting <see cref="RepositoryCacheVersion"/> entities.
7+
/// </summary>
8+
public interface IRepositoryCacheVersionRepository : IRepository
9+
{
10+
/// <summary>
11+
/// Gets a <see cref="RepositoryCacheVersion"/> by its identifier.
12+
/// </summary>
13+
/// <param name="identifier">The unique identifier of the cache version.</param>
14+
/// <returns>
15+
/// A <see cref="RepositoryCacheVersion"/> if found; otherwise, <c>null</c>.
16+
/// </returns>
17+
Task<RepositoryCacheVersion?> GetAsync(string identifier);
18+
19+
/// <summary>
20+
/// Gets all <see cref="RepositoryCacheVersion"/> entities.
21+
/// </summary>
22+
/// <returns>
23+
/// An <see cref="IEnumerable{RepositoryCacheVersion}"/> containing all cache versions.
24+
/// </returns>
25+
Task<IEnumerable<RepositoryCacheVersion>> GetAllAsync();
26+
27+
/// <summary>
28+
/// Saves the specified <see cref="RepositoryCacheVersion"/>.
29+
/// </summary>
30+
/// <param name="repositoryCacheVersion">The cache version entity to save.</param>
31+
Task SaveAsync(RepositoryCacheVersion repositoryCacheVersion);
32+
}

src/Umbraco.Infrastructure/Cache/DefaultRepositoryCachePolicy.cs

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
// Copyright (c) Umbraco.
22
// See LICENSE for more details.
33

4+
using Microsoft.Extensions.DependencyInjection;
5+
using Umbraco.Cms.Core.DependencyInjection;
46
using Umbraco.Cms.Core.Models.Entities;
57
using Umbraco.Cms.Infrastructure.Scoping;
68
using Umbraco.Extensions;
@@ -24,10 +26,27 @@ public class DefaultRepositoryCachePolicy<TEntity, TId> : RepositoryCachePolicyB
2426
private static readonly TEntity[] _emptyEntities = new TEntity[0]; // const
2527
private readonly RepositoryCachePolicyOptions _options;
2628

27-
public DefaultRepositoryCachePolicy(IAppPolicyCache cache, IScopeAccessor scopeAccessor, RepositoryCachePolicyOptions options)
28-
: base(cache, scopeAccessor) =>
29+
public DefaultRepositoryCachePolicy(
30+
IAppPolicyCache cache,
31+
IScopeAccessor scopeAccessor,
32+
RepositoryCachePolicyOptions options,
33+
IRepositoryCacheVersionService repositoryCacheVersionService)
34+
: base(cache, scopeAccessor, repositoryCacheVersionService) =>
2935
_options = options ?? throw new ArgumentNullException(nameof(options));
3036

37+
[Obsolete("Please use the constructor with all parameters. Scheduled for removal in Umbraco 18.")]
38+
public DefaultRepositoryCachePolicy(
39+
IAppPolicyCache cache,
40+
IScopeAccessor scopeAccessor,
41+
RepositoryCachePolicyOptions options)
42+
: this(
43+
cache,
44+
scopeAccessor,
45+
options,
46+
StaticServiceProvider.Instance.GetRequiredService<IRepositoryCacheVersionService>())
47+
{
48+
}
49+
3150
protected string EntityTypeCacheKey { get; } = $"uRepo_{typeof(TEntity).Name}_";
3251

3352
/// <inheritdoc />
@@ -98,6 +117,10 @@ public override void Update(TEntity entity, Action<TEntity> persistUpdated)
98117

99118
throw;
100119
}
120+
121+
// We've changed the entity, register cache change for other servers.
122+
// We assume that if something goes wrong, we'll roll back, so don't need to register the change.
123+
RegisterCacheChange();
101124
}
102125

103126
/// <inheritdoc />
@@ -122,11 +145,16 @@ public override void Delete(TEntity entity, Action<TEntity> persistDeleted)
122145
// if there's a GetAllCacheAllowZeroCount cache, ensure it is cleared
123146
Cache.Clear(EntityTypeCacheKey);
124147
}
148+
149+
// We've removed an entity, register cache change for other servers.
150+
RegisterCacheChange();
125151
}
126152

127153
/// <inheritdoc />
128154
public override TEntity? Get(TId? id, Func<TId?, TEntity?> performGet, Func<TId[]?, IEnumerable<TEntity>?> performGetAll)
129155
{
156+
EnsureCacheIsSynced();
157+
130158
var cacheKey = GetEntityCacheKey(id);
131159

132160
TEntity? fromCache = Cache.GetCacheItem<TEntity>(cacheKey);
@@ -163,13 +191,15 @@ public override void Delete(TEntity entity, Action<TEntity> persistDeleted)
163191
/// <inheritdoc />
164192
public override TEntity? GetCached(TId id)
165193
{
194+
EnsureCacheIsSynced();
166195
var cacheKey = GetEntityCacheKey(id);
167196
return Cache.GetCacheItem<TEntity>(cacheKey);
168197
}
169198

170199
/// <inheritdoc />
171200
public override bool Exists(TId id, Func<TId, bool> performExists, Func<TId[], IEnumerable<TEntity>?> performGetAll)
172201
{
202+
EnsureCacheIsSynced();
173203
// if found in cache the return else check
174204
var cacheKey = GetEntityCacheKey(id);
175205
TEntity? fromCache = Cache.GetCacheItem<TEntity>(cacheKey);
@@ -179,6 +209,7 @@ public override bool Exists(TId id, Func<TId, bool> performExists, Func<TId[], I
179209
/// <inheritdoc />
180210
public override TEntity[] GetAll(TId[]? ids, Func<TId[]?, IEnumerable<TEntity>?> performGetAll)
181211
{
212+
EnsureCacheIsSynced();
182213
if (ids?.Length > 0)
183214
{
184215
// try to get each entity from the cache

src/Umbraco.Infrastructure/Cache/FullDataSetRepositoryCachePolicy.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,10 @@ public override void Update(TEntity entity, Action<TEntity> persistUpdated)
100100
{
101101
ClearAll();
102102
}
103+
104+
// We've changed the entity, register cache change for other servers.
105+
// We assume that if something goes wrong, we'll roll back, so don't need to register the change.
106+
RegisterCacheChange();
103107
}
104108

105109
/// <inheritdoc />
@@ -118,11 +122,17 @@ public override void Delete(TEntity entity, Action<TEntity> persistDeleted)
118122
{
119123
ClearAll();
120124
}
125+
126+
// We've changed the entity, register cache change for other servers.
127+
// We assume that if something goes wrong, we'll roll back, so don't need to register the change.
128+
RegisterCacheChange();
121129
}
122130

123131
/// <inheritdoc />
124132
public override TEntity? Get(TId? id, Func<TId?, TEntity?> performGet, Func<TId[]?, IEnumerable<TEntity>?> performGetAll)
125133
{
134+
EnsureCacheIsSynced();
135+
126136
// get all from the cache, then look for the entity
127137
IEnumerable<TEntity> all = GetAllCached(performGetAll);
128138
TEntity? entity = all.FirstOrDefault(x => _entityGetId(x)?.Equals(id) ?? false);
@@ -135,6 +145,8 @@ public override void Delete(TEntity entity, Action<TEntity> persistDeleted)
135145
/// <inheritdoc />
136146
public override TEntity? GetCached(TId id)
137147
{
148+
EnsureCacheIsSynced();
149+
138150
// get all from the cache -- and only the cache, then look for the entity
139151
DeepCloneableList<TEntity>? all = Cache.GetCacheItem<DeepCloneableList<TEntity>>(GetEntityTypeCacheKey());
140152
TEntity? entity = all?.FirstOrDefault(x => _entityGetId(x)?.Equals(id) ?? false);
@@ -147,6 +159,8 @@ public override void Delete(TEntity entity, Action<TEntity> persistDeleted)
147159
/// <inheritdoc />
148160
public override bool Exists(TId id, Func<TId, bool> performExits, Func<TId[], IEnumerable<TEntity>?> performGetAll)
149161
{
162+
EnsureCacheIsSynced();
163+
150164
// get all as one set, then look for the entity
151165
IEnumerable<TEntity> all = GetAllCached(performGetAll);
152166
return all.Any(x => _entityGetId(x)?.Equals(id) ?? false);
@@ -155,6 +169,8 @@ public override bool Exists(TId id, Func<TId, bool> performExits, Func<TId[], IE
155169
/// <inheritdoc />
156170
public override TEntity[] GetAll(TId[]? ids, Func<TId[], IEnumerable<TEntity>?> performGetAll)
157171
{
172+
EnsureCacheIsSynced();
173+
158174
// get all as one set, from cache if possible, else repo
159175
IEnumerable<TEntity> all = GetAllCached(performGetAll);
160176

0 commit comments

Comments
 (0)