mirror of https://github.com/Squidex/squidex.git
Browse Source
* Improve rule performance. * Back to async enumerable. * Migration to usage tracker. * Use proper data type. * Fine tuning. * Fix tests * Fix backend tests. * More tests fixed. * Some unrelated testspull/978/head
committed by
GitHub
159 changed files with 2698 additions and 1643 deletions
@ -1,89 +0,0 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using MongoDB.Bson.Serialization; |
|||
using MongoDB.Driver; |
|||
using NodaTime; |
|||
using Squidex.Domain.Apps.Entities.Rules.Repositories; |
|||
using Squidex.Infrastructure; |
|||
using Squidex.Infrastructure.MongoDb; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.MongoDb.Rules; |
|||
|
|||
public sealed class MongoRuleStatisticsCollection : MongoRepositoryBase<RuleStatistics> |
|||
{ |
|||
static MongoRuleStatisticsCollection() |
|||
{ |
|||
BsonClassMap.RegisterClassMap<RuleStatistics>(cm => |
|||
{ |
|||
cm.AutoMap(); |
|||
|
|||
cm.SetIgnoreExtraElements(true); |
|||
}); |
|||
} |
|||
|
|||
public MongoRuleStatisticsCollection(IMongoDatabase database) |
|||
: base(database) |
|||
{ |
|||
} |
|||
|
|||
protected override string CollectionName() |
|||
{ |
|||
return "RuleStatistics"; |
|||
} |
|||
|
|||
protected override Task SetupCollectionAsync(IMongoCollection<RuleStatistics> collection, |
|||
CancellationToken ct) |
|||
{ |
|||
return collection.Indexes.CreateOneAsync( |
|||
new CreateIndexModel<RuleStatistics>( |
|||
Index |
|||
.Ascending(x => x.AppId) |
|||
.Ascending(x => x.RuleId)), |
|||
cancellationToken: ct); |
|||
} |
|||
|
|||
public async Task DeleteAppAsync(DomainId appId, |
|||
CancellationToken ct) |
|||
{ |
|||
await Collection.DeleteManyAsync(Filter.Eq(x => x.AppId, appId), ct); |
|||
} |
|||
|
|||
public async Task<IReadOnlyList<RuleStatistics>> QueryByAppAsync(DomainId appId, |
|||
CancellationToken ct) |
|||
{ |
|||
var statistics = await Collection.Find(x => x.AppId == appId).ToListAsync(ct); |
|||
|
|||
return statistics; |
|||
} |
|||
|
|||
public Task IncrementSuccessAsync(DomainId appId, DomainId ruleId, Instant now, |
|||
CancellationToken ct) |
|||
{ |
|||
return Collection.UpdateOneAsync( |
|||
x => x.AppId == appId && x.RuleId == ruleId, |
|||
Update |
|||
.Inc(x => x.NumSucceeded, 1) |
|||
.Set(x => x.LastExecuted, now) |
|||
.SetOnInsert(x => x.AppId, appId) |
|||
.SetOnInsert(x => x.RuleId, ruleId), |
|||
Upsert, ct); |
|||
} |
|||
|
|||
public Task IncrementFailedAsync(DomainId appId, DomainId ruleId, Instant now, |
|||
CancellationToken ct) |
|||
{ |
|||
return Collection.UpdateOneAsync( |
|||
x => x.AppId == appId && x.RuleId == ruleId, |
|||
Update |
|||
.Inc(x => x.NumFailed, 1) |
|||
.Set(x => x.LastExecuted, now) |
|||
.SetOnInsert(x => x.AppId, appId) |
|||
.SetOnInsert(x => x.RuleId, ruleId), |
|||
Upsert, ct); |
|||
} |
|||
} |
|||
@ -1,12 +0,0 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
#pragma warning disable SA1313 // Parameter names should begin with lower-case letter
|
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Assets; |
|||
|
|||
public sealed record AssetStats(DateTime Date, long TotalCount, long TotalSize); |
|||
@ -0,0 +1,145 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using Squidex.Domain.Apps.Entities.Assets; |
|||
using Squidex.Infrastructure; |
|||
using Squidex.Infrastructure.UsageTracking; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Billing; |
|||
|
|||
public sealed partial class UsageGate : IAssetUsageTracker |
|||
{ |
|||
public static class AssetsKeys |
|||
{ |
|||
public const string TotalAssets = nameof(AssetCounters.TotalAssets); |
|||
public const string TotalSize = nameof(AssetCounters.TotalSize); |
|||
} |
|||
|
|||
Task IAssetUsageTracker.DeleteUsageAsync(DomainId appId, |
|||
CancellationToken ct) |
|||
{ |
|||
// Do not delete the team, as this is only called when an app is deleted.
|
|||
return usageTracker.DeleteAsync(AppAssetsKey(appId), ct); |
|||
} |
|||
|
|||
Task IAssetUsageTracker.DeleteUsageAsync( |
|||
CancellationToken ct) |
|||
{ |
|||
// Use a well defined prefix query for the deletion to improve performance.
|
|||
return usageTracker.DeleteByKeyPatternAsync("^([a-zA-Z0-9]+)_[A-Za-z]+Assets", ct); |
|||
} |
|||
|
|||
Task<AssetCounters> IAssetUsageTracker.GetTotalByAppAsync(DomainId appId, |
|||
CancellationToken ct) |
|||
{ |
|||
return GetTotalForAssetsAsync(AppAssetsKey(appId), ct); |
|||
} |
|||
|
|||
Task<AssetCounters> IAssetUsageTracker.GetTotalByTeamAsync(DomainId teamId, |
|||
CancellationToken ct) |
|||
{ |
|||
return GetTotalForAssetsAsync(TeamAssetsKey(teamId), ct); |
|||
} |
|||
|
|||
Task<IReadOnlyList<AssetStats>> IAssetUsageTracker.QueryByAppAsync(DomainId appId, DateOnly fromDate, DateOnly toDate, |
|||
CancellationToken ct) |
|||
{ |
|||
return QueryForAssetsAsync(AppAssetsKey(appId), fromDate, toDate, ct); |
|||
} |
|||
|
|||
Task<IReadOnlyList<AssetStats>> IAssetUsageTracker.QueryByTeamAsync(DomainId teamId, DateOnly fromDate, DateOnly toDate, |
|||
CancellationToken ct) |
|||
{ |
|||
return QueryForAssetsAsync(TeamAssetsKey(teamId), fromDate, toDate, ct); |
|||
} |
|||
|
|||
private async Task<AssetCounters> GetTotalForAssetsAsync(string key, |
|||
CancellationToken ct) |
|||
{ |
|||
var counters = await usageTracker.GetAsync(key, SummaryDate, SummaryDate, null, ct); |
|||
|
|||
return GetAssetCounters(counters); |
|||
} |
|||
|
|||
private async Task<IReadOnlyList<AssetStats>> QueryForAssetsAsync(string key, DateOnly fromDate, DateOnly toDate, |
|||
CancellationToken ct) |
|||
{ |
|||
var result = new List<AssetStats>(); |
|||
|
|||
var usages = await usageTracker.QueryAsync(key, fromDate, toDate, ct); |
|||
|
|||
for (var date = fromDate; date <= toDate; date = date.AddDays(1)) |
|||
{ |
|||
var aggregated = default(AssetCounters); |
|||
|
|||
foreach (var (_, byCategory) in usages) |
|||
{ |
|||
foreach (var (counterDate, counters) in byCategory) |
|||
{ |
|||
if (counterDate == date) |
|||
{ |
|||
var currentCounters = GetAssetCounters(counters); |
|||
|
|||
aggregated.TotalSize += currentCounters.TotalSize; |
|||
aggregated.TotalAssets += currentCounters.TotalAssets; |
|||
} |
|||
} |
|||
} |
|||
|
|||
result.Add(new AssetStats(date, aggregated)); |
|||
} |
|||
|
|||
return result; |
|||
} |
|||
|
|||
async Task IAssetUsageTracker.TrackAsync(DomainId appId, DateOnly date, long fileSize, long count, |
|||
CancellationToken ct) |
|||
{ |
|||
var counters = new Counters |
|||
{ |
|||
[AssetsKeys.TotalSize] = fileSize, |
|||
[AssetsKeys.TotalAssets] = count |
|||
}; |
|||
|
|||
var appKey = AppAssetsKey(appId); |
|||
|
|||
var tasks = new List<Task> |
|||
{ |
|||
usageTracker.TrackAsync(date, appKey, null, counters, ct), |
|||
usageTracker.TrackAsync(SummaryDate, appKey, null, counters, ct) |
|||
}; |
|||
|
|||
var (_, _, teamId) = await GetPlanForAppAsync(appId, true, ct); |
|||
|
|||
if (teamId != null) |
|||
{ |
|||
var teamKey = TeamAssetsKey(teamId.Value); |
|||
|
|||
tasks.Add(usageTracker.TrackAsync(date, teamKey, appId.ToString(), counters, ct)); |
|||
tasks.Add(usageTracker.TrackAsync(SummaryDate, teamKey, appId.ToString(), counters, ct)); |
|||
} |
|||
|
|||
await Task.WhenAll(tasks); |
|||
} |
|||
|
|||
private static AssetCounters GetAssetCounters(Counters counters) |
|||
{ |
|||
return new AssetCounters( |
|||
counters.GetInt64(AssetsKeys.TotalSize), |
|||
counters.GetInt64(AssetsKeys.TotalAssets)); |
|||
} |
|||
|
|||
private static string AppAssetsKey(DomainId appId) |
|||
{ |
|||
return $"{appId}_Assets"; |
|||
} |
|||
|
|||
private static string TeamAssetsKey(DomainId teamId) |
|||
{ |
|||
return $"{teamId}_TeamAssets"; |
|||
} |
|||
} |
|||
@ -0,0 +1,140 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using Squidex.Domain.Apps.Entities.Rules; |
|||
using Squidex.Infrastructure; |
|||
using Squidex.Infrastructure.UsageTracking; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Billing; |
|||
|
|||
public sealed partial class UsageGate : IRuleUsageTracker |
|||
{ |
|||
public static class RulesKeys |
|||
{ |
|||
public const string TotalCreated = nameof(RuleCounters.TotalCreated); |
|||
public const string TotalSucceeded = nameof(RuleCounters.TotalSucceeded); |
|||
public const string TotalFailed = nameof(RuleCounters.TotalFailed); |
|||
} |
|||
|
|||
Task IRuleUsageTracker.DeleteUsageAsync(DomainId appId, |
|||
CancellationToken ct) |
|||
{ |
|||
// Use a well defined prefix query for the deletion to improve performance.
|
|||
return usageTracker.DeleteAsync(AppRulesKey(appId), ct); |
|||
} |
|||
|
|||
Task<IReadOnlyList<RuleStats>> IRuleUsageTracker.QueryByAppAsync(DomainId appId, DateOnly fromDate, DateOnly toDate, |
|||
CancellationToken ct) |
|||
{ |
|||
return QueryForRulesAsync(AppRulesKey(appId), fromDate, toDate, ct); |
|||
} |
|||
|
|||
Task<IReadOnlyList<RuleStats>> IRuleUsageTracker.QueryByTeamAsync(DomainId appId, DateOnly fromDate, DateOnly toDate, |
|||
CancellationToken ct) |
|||
{ |
|||
return QueryForRulesAsync(TeamRulesKey(appId), fromDate, toDate, ct); |
|||
} |
|||
|
|||
async Task<IReadOnlyDictionary<DomainId, RuleCounters>> IRuleUsageTracker.GetTotalByAppAsync(DomainId appId, |
|||
CancellationToken ct) |
|||
{ |
|||
var result = new Dictionary<DomainId, RuleCounters>(); |
|||
|
|||
var counters = await usageTracker.QueryAsync(AppRulesKey(appId), SummaryDate, SummaryDate, ct); |
|||
|
|||
foreach (var (category, byCategory) in counters) |
|||
{ |
|||
if (byCategory.Count > 0) |
|||
{ |
|||
result[DomainId.Create(category)] = GetRuleCounters(byCategory[0].Item2); |
|||
} |
|||
} |
|||
|
|||
return result; |
|||
} |
|||
|
|||
private async Task<IReadOnlyList<RuleStats>> QueryForRulesAsync(string key, DateOnly fromDate, DateOnly toDate, |
|||
CancellationToken ct) |
|||
{ |
|||
var result = new List<RuleStats>(); |
|||
|
|||
var usages = await usageTracker.QueryAsync(key, fromDate, toDate, ct); |
|||
|
|||
for (var date = fromDate; date <= toDate; date = date.AddDays(1)) |
|||
{ |
|||
var aggregated = default(RuleCounters); |
|||
|
|||
foreach (var (_, byCategory) in usages) |
|||
{ |
|||
foreach (var (counterDate, counters) in byCategory) |
|||
{ |
|||
if (counterDate == date) |
|||
{ |
|||
var currentCounters = GetRuleCounters(counters); |
|||
|
|||
aggregated.TotalCreated += currentCounters.TotalCreated; |
|||
aggregated.TotalSucceeded += currentCounters.TotalSucceeded; |
|||
aggregated.TotalFailed += currentCounters.TotalFailed; |
|||
} |
|||
} |
|||
} |
|||
|
|||
result.Add(new RuleStats(date, aggregated)); |
|||
} |
|||
|
|||
return result; |
|||
} |
|||
|
|||
async Task IRuleUsageTracker.TrackAsync(DomainId appId, DomainId ruleId, DateOnly date, int created, int succeeded, int failed, |
|||
CancellationToken ct) |
|||
{ |
|||
var counters = new Counters |
|||
{ |
|||
[RulesKeys.TotalCreated] = created, |
|||
[RulesKeys.TotalSucceeded] = succeeded, |
|||
[RulesKeys.TotalFailed] = failed |
|||
}; |
|||
|
|||
var appKey = AppRulesKey(appId); |
|||
|
|||
var tasks = new List<Task> |
|||
{ |
|||
usageTracker.TrackAsync(date, appKey, ruleId.ToString(), counters, ct), |
|||
usageTracker.TrackAsync(SummaryDate, appKey, ruleId.ToString(), counters, ct) |
|||
}; |
|||
|
|||
var (_, _, teamId) = await GetPlanForAppAsync(appId, true, ct); |
|||
|
|||
if (teamId != null) |
|||
{ |
|||
var teamKey = TeamRulesKey(teamId.Value); |
|||
|
|||
tasks.Add(usageTracker.TrackAsync(date, teamKey, appId.ToString(), counters, ct)); |
|||
tasks.Add(usageTracker.TrackAsync(SummaryDate, teamKey, appId.ToString(), counters, ct)); |
|||
} |
|||
|
|||
await Task.WhenAll(tasks); |
|||
} |
|||
|
|||
private static RuleCounters GetRuleCounters(Counters counters) |
|||
{ |
|||
return new RuleCounters( |
|||
counters.GetInt64(RulesKeys.TotalCreated), |
|||
counters.GetInt64(RulesKeys.TotalSucceeded), |
|||
counters.GetInt64(RulesKeys.TotalFailed)); |
|||
} |
|||
|
|||
private static string AppRulesKey(DomainId appId) |
|||
{ |
|||
return $"{appId}_Rules"; |
|||
} |
|||
|
|||
private static string TeamRulesKey(DomainId teamId) |
|||
{ |
|||
return $"{teamId}_TeamRules"; |
|||
} |
|||
} |
|||
@ -0,0 +1,35 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using Squidex.Infrastructure; |
|||
|
|||
#pragma warning disable SA1313 // Parameter names should begin with lower-case letter
|
|||
#pragma warning disable MA0048 // File name must match type name
|
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Rules; |
|||
|
|||
public interface IRuleUsageTracker |
|||
{ |
|||
Task<IReadOnlyList<RuleStats>> QueryByAppAsync(DomainId appId, DateOnly fromDate, DateOnly toDate, |
|||
CancellationToken ct = default); |
|||
|
|||
Task<IReadOnlyList<RuleStats>> QueryByTeamAsync(DomainId teamId, DateOnly fromDate, DateOnly toDate, |
|||
CancellationToken ct = default); |
|||
|
|||
Task<IReadOnlyDictionary<DomainId, RuleCounters>> GetTotalByAppAsync(DomainId appId, |
|||
CancellationToken ct = default); |
|||
|
|||
Task TrackAsync(DomainId appId, DomainId ruleId, DateOnly date, int created, int succeeded, int failed, |
|||
CancellationToken ct = default); |
|||
|
|||
Task DeleteUsageAsync(DomainId appId, |
|||
CancellationToken ct = default); |
|||
} |
|||
|
|||
public record struct RuleStats(DateOnly Date, RuleCounters Counters); |
|||
|
|||
public record struct RuleCounters(long TotalCreated, long TotalSucceeded, long TotalFailed); |
|||
@ -1,24 +0,0 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using NodaTime; |
|||
using Squidex.Infrastructure; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Rules.Repositories; |
|||
|
|||
public sealed class RuleStatistics |
|||
{ |
|||
public DomainId AppId { get; set; } |
|||
|
|||
public DomainId RuleId { get; set; } |
|||
|
|||
public int NumSucceeded { get; set; } |
|||
|
|||
public int NumFailed { get; set; } |
|||
|
|||
public Instant? LastExecuted { get; set; } |
|||
} |
|||
@ -0,0 +1,90 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using Microsoft.Extensions.Logging; |
|||
using Squidex.Domain.Apps.Core.HandleRules; |
|||
using Squidex.Domain.Apps.Entities.Rules.Repositories; |
|||
using Squidex.Infrastructure; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Rules; |
|||
|
|||
internal sealed class RuleQueueWriter : IAsyncDisposable |
|||
{ |
|||
private readonly List<RuleEventWrite> writes = new List<RuleEventWrite>(); |
|||
private readonly IRuleEventRepository ruleEventRepository; |
|||
private readonly IRuleUsageTracker ruleUsageTracker; |
|||
private readonly ILogger? log; |
|||
|
|||
public RuleQueueWriter(IRuleEventRepository ruleEventRepository, IRuleUsageTracker ruleUsageTracker, ILogger? log) |
|||
{ |
|||
this.ruleEventRepository = ruleEventRepository; |
|||
this.ruleUsageTracker = ruleUsageTracker; |
|||
this.log = log; |
|||
} |
|||
|
|||
public async Task<bool> WriteAsync(JobResult result) |
|||
{ |
|||
// We do not want to handle events without a job in the normal flow.
|
|||
if (result.Job == null) |
|||
{ |
|||
return false; |
|||
} |
|||
|
|||
if (result.EnrichmentError != null || result.SkipReason is SkipReason.Failed) |
|||
{ |
|||
writes.Add(new RuleEventWrite(result.Job, Error: result.EnrichmentError)); |
|||
} |
|||
else |
|||
{ |
|||
writes.Add(new RuleEventWrite(result.Job, result.Job.Created)); |
|||
} |
|||
|
|||
log?.LogInformation("Adding rule job {jobId} for Rule(action={ruleAction}, trigger={ruleTrigger})", |
|||
result.Job.Id, |
|||
result.Rule.Action.GetType().Name, |
|||
result.Rule.Trigger.GetType().Name); |
|||
|
|||
var totalFailure = result.SkipReason == SkipReason.Failed ? 1 : 0; |
|||
var totalCreated = 1; |
|||
|
|||
// Unfortunately we cannot write in batches here, because the result could be from multiple rules.
|
|||
await ruleUsageTracker.TrackAsync(result.Job.AppId, result.RuleId, result.Job.Created.ToDateOnly(), totalCreated, 0, totalFailure); |
|||
|
|||
if (writes.Count >= 100) |
|||
{ |
|||
await FlushCoreAsync(); |
|||
return true; |
|||
} |
|||
|
|||
return false; |
|||
} |
|||
|
|||
public async Task<bool> FlushAsync() |
|||
{ |
|||
if (writes.Count > 0) |
|||
{ |
|||
await FlushCoreAsync(); |
|||
return true; |
|||
} |
|||
|
|||
return false; |
|||
} |
|||
|
|||
public async ValueTask DisposeAsync() |
|||
{ |
|||
if (writes.Count > 0) |
|||
{ |
|||
await FlushCoreAsync(); |
|||
} |
|||
} |
|||
|
|||
private async Task FlushCoreAsync() |
|||
{ |
|||
await ruleEventRepository.EnqueueAsync(writes, default); |
|||
writes.Clear(); |
|||
} |
|||
} |
|||
@ -1,37 +0,0 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using Squidex.Domain.Apps.Core.Rules.Triggers; |
|||
using Squidex.Infrastructure; |
|||
using Squidex.Infrastructure.Reflection; |
|||
|
|||
namespace Squidex.Areas.Api.Controllers.Rules.Models.Triggers; |
|||
|
|||
public sealed class ContentChangedRuleTriggerSchemaDto |
|||
{ |
|||
/// <summary>
|
|||
/// The ID of the schema.
|
|||
/// </summary>
|
|||
public DomainId SchemaId { get; set; } |
|||
|
|||
/// <summary>
|
|||
/// Javascript condition when to trigger.
|
|||
/// </summary>
|
|||
public string? Condition { get; set; } |
|||
|
|||
public ContentChangedTriggerSchemaV2 ToTrigger() |
|||
{ |
|||
return SimpleMapper.Map(this, new ContentChangedTriggerSchemaV2()); |
|||
} |
|||
|
|||
public static ContentChangedRuleTriggerSchemaDto FromDomain(ContentChangedTriggerSchemaV2 trigger) |
|||
{ |
|||
var result = SimpleMapper.Map(trigger, new ContentChangedRuleTriggerSchemaDto()); |
|||
|
|||
return result; |
|||
} |
|||
} |
|||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue