diff --git a/src/FluentCMS.Core/Content/ContentLog.cs b/src/FluentCMS.Core/Content/ContentLog.cs index e469a8397..f030bd64b 100644 --- a/src/FluentCMS.Core/Content/ContentLog.cs +++ b/src/FluentCMS.Core/Content/ContentLog.cs @@ -6,4 +6,7 @@ internal static partial class ContentLog { [LoggerMessage(Level = LogLevel.Error, Message = "An entry change sink failed for {Kind} of {Type} {Id} in locale '{Locale}'; the write stands.")] public static partial void SinkFailed(ILogger logger, Exception exception, EntryChangeKind kind, string type, Guid id, string locale); + + [LoggerMessage(Level = LogLevel.Error, Message = "The events raised during a write could not be dispatched; the write stands.")] + public static partial void EventsFailed(ILogger logger, Exception exception); } diff --git a/src/FluentCMS.Core/Content/ContentService.cs b/src/FluentCMS.Core/Content/ContentService.cs index dc4528609..eea1d30e6 100644 --- a/src/FluentCMS.Core/Content/ContentService.cs +++ b/src/FluentCMS.Core/Content/ContentService.cs @@ -5,6 +5,7 @@ using FluentCMS.Core.Content.Hooks; using FluentCMS.Core.Content.Query; using FluentCMS.Core.Content.Validation; +using FluentCMS.Core.Events; using FluentCMS.Core.Localization; using FluentCMS.Core.Storage; using FluentCMS.Core.Storage.Entities; @@ -38,6 +39,8 @@ internal sealed class ContentService( ContentEntryMapper mapper, ResponseRichText richText, IEnumerable sinks, + IEnumerable stagers, + EventBuffer events, ILogger logger) : IContentService { private readonly List _pending = []; @@ -413,20 +416,24 @@ internal async Task InTransactionAsync(Func> work, CancellationTok T result; var transaction = await context.Database.BeginTransactionAsync(cancellationToken); _ownsTransaction = true; + events.Open(); await using (transaction) { try { result = await work(); + await StageAsync([.. _pending], cancellationToken); await transaction.CommitAsync(CancellationToken.None); committed = true; } finally { _ownsTransaction = false; + events.Close(); if (!committed) { _pending.Clear(); + events.Clear(); } } } @@ -438,6 +445,7 @@ internal async Task InTransactionAsync(Func> work, CancellationTok private async Task InSavepointAsync(IDbContextTransaction outer, Func> work, CancellationToken cancellationToken) { var pending = _pending.Count; + var buffered = events.Mark; var savepoint = string.Create(CultureInfo.InvariantCulture, $"cms_content_{++_savepoints}"); if (outer.SupportsSavepoints) { @@ -448,10 +456,18 @@ private async Task InSavepointAsync(IDbContextTransaction outer, Func InSavepointAsync(IDbContextTransaction outer, Func changes, CancellationToken cancellationToken) + { + foreach (var stager in stagers) + { + await stager.StageAsync(context, changes, cancellationToken); + } + + if (_ownsTransaction) + { + await events.StageAsync(context, cancellationToken); + } + } + // The write has committed: neither a failing sink nor a cancelled caller may turn it into a failure (spec 4.11). private async Task PublishCommittedAsync() { List changes = [.. _pending]; _pending.Clear(); + var buffered = events.Take(); foreach (var change in changes) { foreach (var sink in sinks) @@ -500,6 +531,15 @@ private async Task PublishCommittedAsync() } } } + + try + { + await events.DispatchAsync(buffered); + } + catch (Exception exception) + { + ContentLog.EventsFailed(logger, exception); + } } private async Task SaveAsync(string typeName, Guid id, ContentDocument changes, int expectedVersion, bool isPatch, CancellationToken cancellationToken) diff --git a/src/FluentCMS.Core/Content/ContentTransactionProbe.cs b/src/FluentCMS.Core/Content/ContentTransactionProbe.cs new file mode 100644 index 000000000..783d0a4a3 --- /dev/null +++ b/src/FluentCMS.Core/Content/ContentTransactionProbe.cs @@ -0,0 +1,10 @@ +using FluentCMS.Core.Events; +using FluentCMS.Core.Storage; + +namespace FluentCMS.Core.Content; + +// Tells the event bus whether the request is inside a transaction that nobody here controls, on either context. +internal sealed class ContentTransactionProbe(CmsDbContext content, SystemDbContext system) : ITransactionProbe +{ + public bool InTransaction => content.Database.CurrentTransaction is not null || system.Database.CurrentTransaction is not null; +} diff --git a/src/FluentCMS.Core/Content/EntryChange.cs b/src/FluentCMS.Core/Content/EntryChange.cs index 8146948d4..bee471c5b 100644 --- a/src/FluentCMS.Core/Content/EntryChange.cs +++ b/src/FluentCMS.Core/Content/EntryChange.cs @@ -1,4 +1,5 @@ using FluentCMS.Content; +using Microsoft.EntityFrameworkCore; namespace FluentCMS.Core.Content; @@ -9,14 +10,26 @@ internal enum EntryChangeKind Restored, } -internal sealed record EntryChange(EntryChangeKind Kind, string Type, Guid Id, string Locale, int Version, OperationContext Operation); +internal sealed record EntryChange(EntryChangeKind Kind, string Type, Guid Id, string Locale, int Version, OperationContext Operation) +{ + // The id of the event the change becomes, so that the outbox row and the handlers of this process agree on it. + public Guid EventId { get; } = Guid.CreateVersion7(); +} -// The seam for the event bus of #2697: the service tells it what changed once its transaction has committed, one change +// The seam for the event bus: the service tells it what changed once its transaction has committed, one change // per locale, and tells it nothing when that rolls back. The write has happened by then, so a failure here is logged and -// goes no further (spec 4.11). The service cannot see the end of a transaction it did not begin, so it tells nothing of -// a write inside one: whoever needs the changes of several writes in one transaction, such as #2703, runs them in -// ContentService.InTransactionAsync. +// goes no further (spec 4.11). The service cannot see the end of a transaction it did not begin, so it tells this sink +// nothing of a write inside one, and the handlers of this process hear nothing of it; whoever needs the changes of several +// writes in one transaction, such as #2703, runs them in ContentService.InTransactionAsync. The rows for the outbox handlers +// are written inside that transaction through IEntryChangeStager, so they commit or roll back with it and are delivered. internal interface IEntryChangeSink { ValueTask PublishAsync(EntryChange change, CancellationToken cancellationToken); } + +// Hears the changes of a transaction inside it, just before the commit, so that what must survive a crash can be written +// in the same transaction. A failure here fails the write. +internal interface IEntryChangeStager +{ + ValueTask StageAsync(DbContext context, IReadOnlyList changes, CancellationToken cancellationToken); +} diff --git a/src/FluentCMS.Core/Content/EntryStore.cs b/src/FluentCMS.Core/Content/EntryStore.cs index 6b2fb5b21..917d2b818 100644 --- a/src/FluentCMS.Core/Content/EntryStore.cs +++ b/src/FluentCMS.Core/Content/EntryStore.cs @@ -178,9 +178,7 @@ public Task SetTrashedAsync(Guid id, DateTimeOffset? at, Guid? by, CancellationT // this write's to save, or to lose if this write is rolled back, so it stays pending and only this write's rows go. public async Task WriteDraftAsync(SchemaType type, Document row, bool isNewDocument, ContentDocument entry, Origin origin, string? clientId, CancellationToken cancellationToken) { - context.ChangeTracker.DetectChanges(); - var known = context.ChangeTracker.Entries().Select(item => item.Entity).ToHashSet(ReferenceEqualityComparer.Instance); - var held = context.ChangeTracker.Entries().Where(item => item.State != EntityState.Unchanged).Select(Hold).ToList(); + var held = HeldChanges.Take(context); try { if (isNewDocument) @@ -214,43 +212,7 @@ public async Task WriteDraftAsync(SchemaType type, Document row, bool isNewDocum } finally { - foreach (var own in context.ChangeTracker.Entries().Where(item => !known.Contains(item.Entity)).ToList()) - { - own.State = EntityState.Detached; - } - - foreach (var pending in held) - { - pending.Restore(); - } - } - } - - private static HeldEntry Hold(EntityEntry entry) - { - var original = entry.State == EntityState.Modified ? entry.OriginalValues.Clone() : null; - var modified = entry.Properties.Where(property => property.IsModified).Select(property => property.Metadata.Name).ToList(); - var state = entry.State; - entry.State = EntityState.Detached; - return new HeldEntry(entry, state, original, modified); - } - - private sealed record HeldEntry(EntityEntry Entry, EntityState State, PropertyValues? Original, List Modified) - { - public void Restore() - { - if (Original is null) - { - Entry.State = State; - return; - } - - Entry.State = EntityState.Unchanged; - Entry.OriginalValues.SetValues(Original); - foreach (var name in Modified) - { - Entry.Property(name).IsModified = true; - } + held.Release(); } } diff --git a/src/FluentCMS.Core/Content/HeldChanges.cs b/src/FluentCMS.Core/Content/HeldChanges.cs new file mode 100644 index 000000000..64ae3cc71 --- /dev/null +++ b/src/FluentCMS.Core/Content/HeldChanges.cs @@ -0,0 +1,64 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; + +namespace FluentCMS.Core.Content; + +// A context may hold what its caller has not saved. That is not the save of another write to make, or to lose if that +// write fails, so it is set aside while the write saves and put back after, and whatever the write added is detached. +internal sealed class HeldChanges +{ + private readonly DbContext _context; + private readonly HashSet _known; + private readonly List _held; + + private HeldChanges(DbContext context) + { + _context = context; + context.ChangeTracker.DetectChanges(); + _known = context.ChangeTracker.Entries().Select(item => item.Entity).ToHashSet(ReferenceEqualityComparer.Instance); + _held = context.ChangeTracker.Entries().Where(item => item.State != EntityState.Unchanged).Select(Hold).ToList(); + } + + public static HeldChanges Take(DbContext context) => new(context); + + public void Release() + { + foreach (var own in _context.ChangeTracker.Entries().Where(item => !_known.Contains(item.Entity)).ToList()) + { + own.State = EntityState.Detached; + } + + foreach (var pending in _held) + { + pending.Restore(); + } + } + + private static HeldEntry Hold(EntityEntry entry) + { + var original = entry.State == EntityState.Modified ? entry.OriginalValues.Clone() : null; + var modified = entry.Properties.Where(property => property.IsModified).Select(property => property.Metadata.Name).ToList(); + var state = entry.State; + entry.State = EntityState.Detached; + return new HeldEntry(entry, state, original, modified); + } + + private sealed record HeldEntry(EntityEntry Entry, EntityState State, PropertyValues? Original, List Modified) + { + public void Restore() + { + if (Original is null) + { + Entry.State = State; + return; + } + + Entry.State = EntityState.Unchanged; + Entry.OriginalValues.SetValues(Original); + foreach (var name in Modified) + { + Entry.Property(name).IsModified = true; + } + } + } +} diff --git a/src/FluentCMS.Core/Content/OutboxStaging.cs b/src/FluentCMS.Core/Content/OutboxStaging.cs new file mode 100644 index 000000000..195e09985 --- /dev/null +++ b/src/FluentCMS.Core/Content/OutboxStaging.cs @@ -0,0 +1,23 @@ +using FluentCMS.Core.Storage.Entities; +using Microsoft.EntityFrameworkCore; + +namespace FluentCMS.Core.Content; + +// Writes outbox rows in the transaction a context has open, so that they commit or roll back with it. Only these rows are +// saved: nothing the caller tracked is, and a failed save leaves nothing behind to be saved by the next write. +internal static class OutboxStaging +{ + public static async ValueTask SaveAsync(DbContext context, IReadOnlyList rows, CancellationToken cancellationToken) + { + var held = HeldChanges.Take(context); + try + { + context.AddRange(rows); + await context.SaveChangesAsync(cancellationToken); + } + finally + { + held.Release(); + } + } +} diff --git a/src/FluentCMS.Core/DependencyInjection/FluentCmsBuilder.cs b/src/FluentCMS.Core/DependencyInjection/FluentCmsBuilder.cs index aac783f6c..9958f9953 100644 --- a/src/FluentCMS.Core/DependencyInjection/FluentCmsBuilder.cs +++ b/src/FluentCMS.Core/DependencyInjection/FluentCmsBuilder.cs @@ -1,5 +1,6 @@ using System.Reflection; using FluentCMS.Core.Authorization; +using FluentCMS.Core.Events; using FluentCMS.Core.Jobs; using FluentCMS.Core.Storage; using Microsoft.Extensions.DependencyInjection; @@ -12,6 +13,7 @@ internal FluentCmsBuilder(IServiceCollection services) { Services = services; Jobs = new JobsBuilder(services); + Events = new EventsBuilder(services); } public IServiceCollection Services { get; } @@ -20,6 +22,8 @@ internal FluentCmsBuilder(IServiceCollection services) public JobsBuilder Jobs { get; } + public EventsBuilder Events { get; } + internal IDataProvider? DataProvider { get; private set; } internal Assembly? ContentMigrationsAssembly { get; private set; } diff --git a/src/FluentCMS.Core/DependencyInjection/FluentCmsServiceCollectionExtensions.cs b/src/FluentCMS.Core/DependencyInjection/FluentCmsServiceCollectionExtensions.cs index 2a0aa784e..df22b89d6 100644 --- a/src/FluentCMS.Core/DependencyInjection/FluentCmsServiceCollectionExtensions.cs +++ b/src/FluentCMS.Core/DependencyInjection/FluentCmsServiceCollectionExtensions.cs @@ -6,12 +6,14 @@ using FluentCMS.Core.Content.Hooks; using FluentCMS.Core.Content.Query; using FluentCMS.Core.Content.Validation; +using FluentCMS.Core.Events; using FluentCMS.Core.Jobs; using FluentCMS.Core.Localization; using FluentCMS.Core.ProseMirror; using FluentCMS.Core.Schema; using FluentCMS.Core.Sites; using FluentCMS.Core.Storage; +using FluentCMS.Events; using FluentCMS.Jobs; using FluentCMS.Localization; using FluentCMS.Schema; @@ -104,12 +106,31 @@ public static IServiceCollection AddFluentCms(this IServiceCollection services, services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - services.TryAddSingleton(); + services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); services.AddHostedService(); + builder.Events.Outbox.Validate(); + builder.Jobs.Options.Queues.TryAdd(OutboxDrainJob.Queue, 1); + builder.Jobs.AddJob(OutboxDrainJob.Type); + services.AddSingleton(builder.Events.Outbox); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.TryAddSingleton(); + services.AddSingleton(); + services.AddHostedService(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.TryAddScoped(); + services.AddScoped(); + services.AddScoped(serviceProvider => serviceProvider.GetRequiredService()); + services.AddScoped(serviceProvider => serviceProvider.GetRequiredService()); + return services; } } diff --git a/src/FluentCMS.Core/Events/BusJobEventSink.cs b/src/FluentCMS.Core/Events/BusJobEventSink.cs new file mode 100644 index 000000000..c89cf2442 --- /dev/null +++ b/src/FluentCMS.Core/Events/BusJobEventSink.cs @@ -0,0 +1,45 @@ +using FluentCMS.Content; +using FluentCMS.Core.Jobs; +using FluentCMS.Events; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace FluentCMS.Core.Events; + +// Runs the handlers of this process for the events of the job runner, after the commit that settled the job. The outbox rows +// were written in that commit by the job store, so only the handlers of this process are left. There is no request, so each +// event runs in a scope of its own, under a system operation on the site of the job. A job worker waits for them only as long +// as JobEventTimeout: handlers that take longer are left running, on a thread of their own, and the worker goes on. +internal sealed class BusJobEventSink(IServiceScopeFactory scopes, IOperationContextAccessor operation, OutboxOptions options, TimeProvider time, ILogger logger) : IJobEventSink +{ + public Task PublishAsync(JobFailedEvent failed) => DispatchAsync(failed); + + public Task PublishAsync(JobDeadLetteredEvent deadLettered) => DispatchAsync(deadLettered); + + private async Task DispatchAsync(CmsEvent @event) + { + try + { + await Task.Run(() => RunAsync(@event), CancellationToken.None).WaitAsync(options.JobEventTimeout, time); + } + catch (TimeoutException) + { + EventLog.DispatchAbandoned(logger, @event.GetType().Name, @event.EventId, options.JobEventTimeout); + } + } + + private async Task RunAsync(CmsEvent @event) + { + await using var scope = scopes.CreateAsyncScope(); + var previous = operation.Current; + operation.Current = await EventOperation.CreateAsync(scope.ServiceProvider, @event.Source, @event.EventId, rowSite: null, time.GetUtcNow()); + try + { + await scope.ServiceProvider.GetRequiredService().DispatchAsync(@event); + } + finally + { + operation.Current = previous; + } + } +} diff --git a/src/FluentCMS.Core/Events/EntryChangeSink.cs b/src/FluentCMS.Core/Events/EntryChangeSink.cs new file mode 100644 index 000000000..8b0ceeb91 --- /dev/null +++ b/src/FluentCMS.Core/Events/EntryChangeSink.cs @@ -0,0 +1,31 @@ +using FluentCMS.Core.Content; +using FluentCMS.Events; +using Microsoft.EntityFrameworkCore; + +namespace FluentCMS.Core.Events; + +// Turns what the content service tells it into events. Inside the transaction it records the rows of the outbox +// handlers, so that they commit with the write; after the commit it runs the handlers of this process. +internal sealed class EntryChangeSink(EventDispatcher dispatcher, OutboxWriter outbox) : IEntryChangeSink, IEntryChangeStager +{ + public ValueTask StageAsync(DbContext context, IReadOnlyList changes, CancellationToken cancellationToken) => + outbox.StageAsync(context, changes.Select(EntryEvents.From), cancellationToken); + + public ValueTask PublishAsync(EntryChange change, CancellationToken cancellationToken) => + dispatcher.DispatchAsync(EntryEvents.From(change)); +} + +internal static class EntryEvents +{ + public static EntryEvent From(EntryChange change) + { + var source = EventSource.From(change.Operation); + return change.Kind switch + { + EntryChangeKind.DraftSaved => new EntryDraftSavedEvent(change.Type, change.Id, change.Locale, change.Version) { EventId = change.EventId, Source = source }, + EntryChangeKind.Trashed => new EntryTrashedEvent(change.Type, change.Id, change.Locale, change.Version) { EventId = change.EventId, Source = source }, + EntryChangeKind.Restored => new EntryRestoredEvent(change.Type, change.Id, change.Locale, change.Version) { EventId = change.EventId, Source = source }, + _ => throw new ArgumentOutOfRangeException(nameof(change), change.Kind, "No event is defined for this kind of change."), + }; + } +} diff --git a/src/FluentCMS.Core/Events/EventBuffer.cs b/src/FluentCMS.Core/Events/EventBuffer.cs new file mode 100644 index 000000000..c2a981dd1 --- /dev/null +++ b/src/FluentCMS.Core/Events/EventBuffer.cs @@ -0,0 +1,72 @@ +using FluentCMS.Events; +using Microsoft.EntityFrameworkCore; + +namespace FluentCMS.Core.Events; + +// The events a request raises while the content service holds its transaction open, kept until the outermost commit. +// Whatever rolls back, whether the transaction or a savepoint inside it, takes its events with it. +internal sealed class EventBuffer(EventDispatcher dispatcher, OutboxWriter outbox) +{ + private readonly List _events = []; + + public bool IsOpen { get; private set; } + + public int Mark => _events.Count; + + public void Open() => IsOpen = true; + + public void Close() => IsOpen = false; + + // An event published twice is one event. + public void Add(CmsEvent @event) + { + if (!_events.Exists(held => held.EventId == @event.EventId)) + { + _events.Add(@event); + } + } + + public void Truncate(int mark) => _events.RemoveRange(mark, _events.Count - mark); + + public void Clear() => _events.Clear(); + + // The events of the commit that just happened. A handler that writes while it is told gets a buffer of its own, which + // can neither repeat nor lose these. + public List Take() + { + List taken = [.. _events]; + _events.Clear(); + return taken; + } + + // Inside the transaction, so that the rows commit with the write. + public ValueTask StageAsync(DbContext context, CancellationToken cancellationToken) => outbox.StageAsync(context, _events, cancellationToken); + + // After the commit. Nothing that goes wrong here may reach the caller, lose another event or skip the request for a drain. + public async ValueTask DispatchAsync(List events) + { + try + { + foreach (var @event in events) + { + await dispatcher.DispatchAsync(@event); + } + } + finally + { + await outbox.KickIfStagedAsync(); + } + } + + public async ValueTask DispatchNowAsync(CmsEvent @event) + { + try + { + await dispatcher.DispatchAsync(@event); + } + finally + { + await outbox.KickIfStagedAsync(); + } + } +} diff --git a/src/FluentCMS.Core/Events/EventBus.cs b/src/FluentCMS.Core/Events/EventBus.cs new file mode 100644 index 000000000..a9022d6b6 --- /dev/null +++ b/src/FluentCMS.Core/Events/EventBus.cs @@ -0,0 +1,40 @@ +using FluentCMS.Content; +using FluentCMS.Events; + +namespace FluentCMS.Core.Events; + +internal sealed class EventBus(EventBuffer buffer, OutboxWriter outbox, ITransactionProbe transaction, IOperationContextAccessor operation, TimeProvider time) : IEventBus +{ + public async ValueTask PublishAsync(TEvent message, CancellationToken cancellationToken = default) + where TEvent : CmsEvent + { + ArgumentNullException.ThrowIfNull(message); + + if (!message.IsAttributed) + { + message.Attribute(operation.Current is { } current ? EventSource.From(current) : EventSource.FromSystem(time.GetUtcNow())); + } + + if (buffer.IsOpen) + { + buffer.Add(message); + return; + } + + // The end of a transaction someone else began cannot be seen, and an event that may yet roll back is never delivered. + if (transaction.InTransaction) + { + throw new InvalidOperationException( + $"{EventErrorCodes.PublishInForeignTransaction}: an event cannot be published inside a transaction the content service does not own, because its end cannot be seen; publish it after the transaction."); + } + + await outbox.RecordAsync([message], cancellationToken); + await buffer.DispatchNowAsync(message); + } +} + +// The content service owns the transactions events can wait for; this says whether another one is open. +internal interface ITransactionProbe +{ + bool InTransaction { get; } +} diff --git a/src/FluentCMS.Core/Events/EventDispatcher.cs b/src/FluentCMS.Core/Events/EventDispatcher.cs new file mode 100644 index 000000000..59ddcff9e --- /dev/null +++ b/src/FluentCMS.Core/Events/EventDispatcher.cs @@ -0,0 +1,58 @@ +using System.Collections.Concurrent; +using System.Reflection; +using FluentCMS.Core.Content.Hooks; +using FluentCMS.Events; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace FluentCMS.Core.Events; + +// Runs the handlers of an event in this process, in order. The write has committed by the time this runs, so a handler +// that throws changes nothing: the failure is logged and the next handler runs. A handler that cannot even be built +// (a constructor that throws) is the same failure for the handlers of that event, since the container builds them together. +// The token is the host's shutdown, never the request's, which has ended or may end at any time. +internal sealed class EventDispatcher(IServiceProvider services, ILogger logger, IHostApplicationLifetime? lifetime = null) +{ + private static readonly ConcurrentDictionary> Dispatchers = new(); + + private static readonly MethodInfo DispatchMethod = typeof(EventDispatcher).GetMethod(nameof(DispatchTypedAsync), BindingFlags.Instance | BindingFlags.NonPublic)!; + + public ValueTask DispatchAsync(CmsEvent @event) + { + ArgumentNullException.ThrowIfNull(@event); + + return Dispatchers.GetOrAdd( + @event.GetType(), + static type => DispatchMethod.MakeGenericMethod(type).CreateDelegate>())(this, @event); + } + + private async ValueTask DispatchTypedAsync(CmsEvent @event) + where TEvent : CmsEvent + { + var typed = (TEvent)@event; + List> handlers; + try + { + handlers = HandlerOrder.Sort(services.GetServices>(), handler => handler.GetType(), services.GetService()); + } + catch (Exception exception) + { + EventLog.HandlersUnavailable(logger, exception, typeof(TEvent).Name, @event.EventId); + return; + } + + var shutdown = lifetime?.ApplicationStopping ?? CancellationToken.None; + foreach (var handler in handlers) + { + try + { + await handler.HandleAsync(typed, shutdown); + } + catch (Exception exception) + { + EventLog.HandlerFailed(logger, exception, handler.GetType().FullName ?? handler.GetType().Name, typeof(TEvent).Name, @event.EventId); + } + } + } +} diff --git a/src/FluentCMS.Core/Events/EventErrorCodes.cs b/src/FluentCMS.Core/Events/EventErrorCodes.cs new file mode 100644 index 000000000..c603a9b06 --- /dev/null +++ b/src/FluentCMS.Core/Events/EventErrorCodes.cs @@ -0,0 +1,8 @@ +namespace FluentCMS.Core.Events; + +internal static class EventErrorCodes +{ + public const string DuplicateOutboxHandler = "FCMS2601"; + public const string PublishInForeignTransaction = "FCMS2602"; + public const string InvalidOutboxOptions = "FCMS2603"; +} diff --git a/src/FluentCMS.Core/Events/EventLog.cs b/src/FluentCMS.Core/Events/EventLog.cs new file mode 100644 index 000000000..f228f80f3 --- /dev/null +++ b/src/FluentCMS.Core/Events/EventLog.cs @@ -0,0 +1,45 @@ +using Microsoft.Extensions.Logging; + +namespace FluentCMS.Core.Events; + +internal static partial class EventLog +{ + [LoggerMessage(Level = LogLevel.Error, Message = "The handler {Handler} failed for {EventType} {EventId}; the write stands.")] + public static partial void HandlerFailed(ILogger logger, Exception exception, string handler, string eventType, Guid eventId); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Outbox delivery {Id} to {Handler} failed on attempt {Attempt}: {Error}")] + public static partial void DeliveryFailed(ILogger logger, Guid id, string handler, int attempt, string error); + + [LoggerMessage(Level = LogLevel.Error, Message = "Outbox delivery {Id} to {Handler} is out of attempts after {Attempt}: {Error}")] + public static partial void DeliveryGaveUp(ILogger logger, Guid id, string handler, int attempt, string error); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Could not write the outcome of outbox delivery {Id}; it is delivered again when its lease ends.")] + public static partial void SettleFailed(ILogger logger, Exception exception, Guid id); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Could not ask for the outbox to be drained; the next scheduled drain delivers what was recorded.")] + public static partial void KickFailed(ILogger logger, Exception exception); + + [LoggerMessage(Level = LogLevel.Error, Message = "Could not register the recurring drain of the outbox; events are delivered only after a write asks for it.")] + public static partial void RecurringFailed(ILogger logger, Exception exception); + + [LoggerMessage(Level = LogLevel.Error, Message = "The handlers of {EventType} {EventId} could not be built, so none of them ran; the write stands.")] + public static partial void HandlersUnavailable(ILogger logger, Exception exception, string eventType, Guid eventId); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Outbox delivery {Id} waits: no handler is registered as '{Handler}' on this instance.")] + public static partial void HandlerMissing(ILogger logger, Guid id, string handler); + + [LoggerMessage(Level = LogLevel.Error, Message = "{Count} outbox deliveries ran out of attempts without a result, since the workers that held them stopped; they are left as failed.")] + public static partial void Exhausted(ILogger logger, int count); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Outbox delivery {Id} to {Handler} lost its lease, so this worker leaves it alone.")] + public static partial void LeaseLost(ILogger logger, Guid id, string handler); + + [LoggerMessage(Level = LogLevel.Error, Message = "Outbox delivery {Id} to {Handler} did not finish within {Timeout} and was abandoned; the handler may still be running.")] + public static partial void DeliveryAbandoned(ILogger logger, Guid id, string handler, TimeSpan timeout); + + [LoggerMessage(Level = LogLevel.Error, Message = "The in-process handlers of {EventType} {EventId} did not finish within {Timeout} and were abandoned; they may still be running.")] + public static partial void DispatchAbandoned(ILogger logger, string eventType, Guid eventId, TimeSpan timeout); + + [LoggerMessage(Level = LogLevel.Error, Message = "The drain of the outbox hit an error.")] + public static partial void DrainFailed(ILogger logger, Exception exception); +} diff --git a/src/FluentCMS.Core/Events/EventOperation.cs b/src/FluentCMS.Core/Events/EventOperation.cs new file mode 100644 index 000000000..d00e5fa19 --- /dev/null +++ b/src/FluentCMS.Core/Events/EventOperation.cs @@ -0,0 +1,53 @@ +using FluentCMS.Content; +using FluentCMS.Core.Sites; +using FluentCMS.Core.Storage; +using FluentCMS.Core.Storage.Entities; +using FluentCMS.Events; +using FluentCMS.Sites; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace FluentCMS.Core.Events; + +// The operation a handler runs under when no request does: as a job runs, the system principal with the actor of the event +// for audit, on the site of the event, in that site's default locale. +internal static class EventOperation +{ + public static async Task CreateAsync(IServiceProvider services, EventSource source, Guid eventId, Guid? rowSite, DateTimeOffset now) + { + var site = rowSite ?? (source.SiteId == Guid.Empty ? DefaultSite.Id : source.SiteId); + var locale = await services.GetRequiredService().Set() + .Where(row => row.Id == site) + .Select(row => row.DefaultLocale) + .FirstOrDefaultAsync(); + + return new OperationContext( + Principal.System, + Origin.Job, + new SiteId(site), + locale ?? DefaultSite.Locale, + ClientId: null, + string.IsNullOrEmpty(source.CorrelationId) ? eventId.ToString() : source.CorrelationId, + now) + { + Actor = ActorOf(source), + }; + } + + private static Principal? ActorOf(EventSource source) + { + if (source.ActorId == Guid.Empty || source.ActorKind is PrincipalKind.System or PrincipalKind.Anonymous) + { + return null; + } + + try + { + return new Principal(source.ActorKind, source.ActorId, source.ActorName ?? ""); + } + catch (ArgumentException) + { + return null; + } + } +} diff --git a/src/FluentCMS.Core/Events/EventsBuilder.cs b/src/FluentCMS.Core/Events/EventsBuilder.cs new file mode 100644 index 000000000..ce694c94d --- /dev/null +++ b/src/FluentCMS.Core/Events/EventsBuilder.cs @@ -0,0 +1,37 @@ +using FluentCMS.Events; +using Microsoft.Extensions.DependencyInjection; + +namespace FluentCMS.Core.Events; + +public sealed class EventsBuilder +{ + private readonly IServiceCollection _services; + + internal EventsBuilder(IServiceCollection services) + { + _services = services; + } + + public OutboxOptions Outbox { get; } = new(); + + public EventsBuilder AddHandler() + where TEvent : CmsEvent + where THandler : class, IEventHandler + { + _services.AddScoped, THandler>(); + return this; + } + + // The name is what cms_outbox stores, so it must outlive renames of the class. + public EventsBuilder AddOutboxHandler(string name) + where TEvent : CmsEvent + where THandler : class, IOutboxEventHandler + { + ArgumentException.ThrowIfNullOrEmpty(name); + ArgumentOutOfRangeException.ThrowIfGreaterThan(name.Length, OutboxWriter.HandlerMaxLength, nameof(name)); + + _services.AddScoped(); + _services.AddSingleton(OutboxRegistration.Create(name)); + return this; + } +} diff --git a/src/FluentCMS.Core/Events/HandlerOrder.cs b/src/FluentCMS.Core/Events/HandlerOrder.cs new file mode 100644 index 000000000..27179312e --- /dev/null +++ b/src/FluentCMS.Core/Events/HandlerOrder.cs @@ -0,0 +1,20 @@ +using System.Collections.Concurrent; +using System.Reflection; +using FluentCMS.Core.Content.Hooks; +using FluentCMS.Hooks; + +namespace FluentCMS.Core.Events; + +// [HookOrder], then the plugin dependency order the host supplies as a tiebreaker, then registration order. +internal static class HandlerOrder +{ + private static readonly ConcurrentDictionary Orders = new(); + + public static List Sort(IEnumerable handlers, Func typeOf, IHookTiebreaker? tiebreaker) => + [.. handlers + .OrderBy(handler => Of(typeOf(handler))) + .ThenBy(handler => tiebreaker?.Rank(typeOf(handler)) ?? 0)]; + + private static int Of(Type handler) => + Orders.GetOrAdd(handler, static type => type.GetCustomAttribute()?.Order ?? 0); +} diff --git a/src/FluentCMS.Core/Events/OutboxDeadLetters.cs b/src/FluentCMS.Core/Events/OutboxDeadLetters.cs new file mode 100644 index 000000000..384151f3b --- /dev/null +++ b/src/FluentCMS.Core/Events/OutboxDeadLetters.cs @@ -0,0 +1,62 @@ +using FluentCMS.Core.Storage; +using FluentCMS.Core.Storage.Entities; +using FluentCMS.Events; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace FluentCMS.Core.Events; + +internal sealed class OutboxDeadLetters(IServiceScopeFactory scopes, OutboxKick kick, TimeProvider time) : IEventDeadLetters +{ + public async Task> ListAsync(int offset = 0, int limit = 50, CancellationToken cancellationToken = default) + { + ArgumentOutOfRangeException.ThrowIfNegative(offset); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(limit); + + await using var scope = scopes.CreateAsyncScope(); + var context = scope.ServiceProvider.GetRequiredService(); + + return await context.Set().AsNoTracking() + .Where(message => message.Status == OutboxStatus.Failed) + .OrderByDescending(message => message.Sequence) + .ThenBy(message => message.Id) + .Skip(offset) + .Take(limit) + .Select(message => new FailedEventDelivery(message.Id, message.EventId, message.EventType, message.Handler, message.Attempts, message.LastError, message.OccurredAt)) + .ToListAsync(cancellationToken); + } + + // The attempts start over, since a failed delivery has used all of them. + public async Task RetryAsync(Guid id, CancellationToken cancellationToken = default) + { + await using var scope = scopes.CreateAsyncScope(); + var context = scope.ServiceProvider.GetRequiredService(); + + var now = time.GetUtcNow(); + var queued = await context.Set() + .Where(message => message.Id == id && message.Status == OutboxStatus.Failed) + .ExecuteUpdateAsync( + message => message + .SetProperty(row => row.Status, OutboxStatus.Pending) + .SetProperty(row => row.Attempts, 0) + .SetProperty(row => row.AvailableAt, now), + cancellationToken); + + if (queued == 1) + { + await kick.KickAsync(); + } + + return queued == 1; + } + + public async Task RemoveAsync(Guid id, CancellationToken cancellationToken = default) + { + await using var scope = scopes.CreateAsyncScope(); + var context = scope.ServiceProvider.GetRequiredService(); + + return await context.Set() + .Where(message => message.Id == id && message.Status == OutboxStatus.Failed) + .ExecuteDeleteAsync(cancellationToken) == 1; + } +} diff --git a/src/FluentCMS.Core/Events/OutboxDrainJob.cs b/src/FluentCMS.Core/Events/OutboxDrainJob.cs new file mode 100644 index 000000000..c0bbef5c4 --- /dev/null +++ b/src/FluentCMS.Core/Events/OutboxDrainJob.cs @@ -0,0 +1,58 @@ +using FluentCMS.Jobs; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace FluentCMS.Core.Events; + +// Prune is set by the recurring run only, so that the table is not walked for old rows on every drain. +internal sealed record OutboxDrainPayload(bool Prune = false); + +internal sealed class OutboxDrainJob(OutboxDrainer drainer) : IJob +{ + public const string Type = "events.outbox.drain"; + + public const string RecurringName = "events.outbox.drain"; + + // Its own queue, so that a handler that hangs holds up other deliveries and no other job. + public const string Queue = "outbox"; + + public Task ExecuteAsync(OutboxDrainPayload payload, JobContext context) => drainer.DrainAsync(payload.Prune, context.CancellationToken); +} + +// The safety net for a crash between a commit and its kick, which leaves rows nothing has asked to deliver. It does +// nothing when no outbox handler is registered, since then there are never rows, and it keeps trying until the +// schedule is stored, since a database that is down at startup would otherwise leave the host without it for good. +internal sealed class OutboxScheduleService(IJobScheduler scheduler, OutboxRegistry registry, TimeProvider time, ILogger logger) : BackgroundService +{ + private static readonly TimeSpan Retry = TimeSpan.FromSeconds(30); + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + if (!registry.Any) + { + return; + } + + while (!stoppingToken.IsCancellationRequested) + { + try + { + await scheduler.AddRecurringAsync(OutboxDrainJob.RecurringName, "* * * * *", new OutboxDrainPayload(Prune: true), OutboxDrainJob.Queue, stoppingToken); + return; + } + catch (Exception exception) when (!stoppingToken.IsCancellationRequested) + { + EventLog.RecurringFailed(logger, exception); + } + + try + { + await Task.Delay(Retry, time, stoppingToken); + } + catch (OperationCanceledException) + { + return; + } + } + } +} diff --git a/src/FluentCMS.Core/Events/OutboxDrainer.cs b/src/FluentCMS.Core/Events/OutboxDrainer.cs new file mode 100644 index 000000000..b52d704a4 --- /dev/null +++ b/src/FluentCMS.Core/Events/OutboxDrainer.cs @@ -0,0 +1,296 @@ +using FluentCMS.Content; +using FluentCMS.Core.Storage; +using FluentCMS.Core.Storage.Entities; +using FluentCMS.Events; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Query; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace FluentCMS.Core.Events; + +// Delivers the rows of cms_outbox at least once. A batch is claimed with a conditional update that only one of any number +// of instances wins per row, and holds a lease. Claiming costs no attempt, since a row whose lease runs out before its turn +// comes was never tried: the worker starts an attempt, and takes its lease again, with one conditional update before each +// delivery, which matches nothing once another instance has the row. A delivery is cut off before its lease can end, so that +// a worker never delivers on a lease it has lost, and a write of the outcome is conditional on the claim in the same way. +// A handler that has to be abandoned may still be running, on a thread of its own; nothing here can stop it. +internal sealed class OutboxDrainer( + IServiceScopeFactory scopes, + OutboxRegistry registry, + OutboxOptions options, + IOperationContextAccessor operation, + TimeProvider time, + ILogger logger) +{ + private const string ExhaustedError = "The worker of the last attempt stopped without finishing it, and no attempts are left."; + + private readonly string _owner = $"{Environment.MachineName}/{Guid.NewGuid():N}"; + + public Task DrainAsync(CancellationToken cancellationToken = default) => DrainAsync(prune: false, cancellationToken); + + // The number of deliveries that succeeded. + public async Task DrainAsync(bool prune, CancellationToken cancellationToken) + { + var delivered = 0; + while (!cancellationToken.IsCancellationRequested) + { + var batch = await ClaimAsync(cancellationToken); + if (batch.Count == 0) + { + break; + } + + foreach (var message in batch) + { + if (cancellationToken.IsCancellationRequested) + { + await ReleaseAsync(message, delay: null, started: false); + continue; + } + + if (await DeliverAsync(message, cancellationToken)) + { + delivered++; + } + } + } + + if (prune) + { + await PruneAsync(cancellationToken); + } + + return delivered; + } + + private async Task> ClaimAsync(CancellationToken cancellationToken) + { + await using var scope = scopes.CreateAsyncScope(); + var context = scope.ServiceProvider.GetRequiredService(); + + var now = time.GetUtcNow(); + var leaseUntil = now + options.LeaseDuration; + + // A row whose attempts are used up and whose worker is gone has no one left to settle it: a delivery that kills its + // process would otherwise be tried for ever. + var exhausted = await Open(context, now).Where(message => message.Attempts >= options.MaxAttempts).ExecuteUpdateAsync( + message => message + .SetProperty(row => row.Status, OutboxStatus.Failed) + .SetProperty(row => row.LastError, ExhaustedError) + .SetProperty(row => row.LeaseOwner, (string?)null) + .SetProperty(row => row.LeaseUntil, (DateTimeOffset?)null), + cancellationToken); + + if (exhausted > 0) + { + EventLog.Exhausted(logger, exhausted); + } + + var candidates = await Open(context, now) + .OrderBy(message => message.Sequence).ThenBy(message => message.Id) + .Select(message => message.Id) + .Take(options.BatchSize) + .ToListAsync(cancellationToken); + + List claimed = []; + foreach (var id in candidates) + { + var won = await Open(context, now).Where(message => message.Id == id).ExecuteUpdateAsync( + message => message + .SetProperty(row => row.LeaseOwner, _owner) + .SetProperty(row => row.LeaseUntil, leaseUntil), + cancellationToken); + + if (won == 1) + { + claimed.Add(id); + } + } + + if (claimed.Count == 0) + { + return []; + } + + return await context.Set().AsNoTracking() + .Where(message => claimed.Contains(message.Id)) + .OrderBy(message => message.Sequence).ThenBy(message => message.Id) + .ToListAsync(cancellationToken); + } + + private static IQueryable Open(SystemDbContext context, DateTimeOffset now) => + context.Set().Where(message => + message.Status == OutboxStatus.Pending + && message.AvailableAt <= now + && (message.LeaseUntil == null || message.LeaseUntil < now)); + + private static IQueryable Held(SystemDbContext context, OutboxMessage message, string owner) => + context.Set().Where(row => + row.Id == message.Id && row.Status == OutboxStatus.Pending && row.LeaseOwner == owner && row.Attempts == message.Attempts); + + private async Task DeliverAsync(OutboxMessage message, CancellationToken cancellationToken) + { + var registration = registry.Find(message.Handler); + if (registration is null) + { + // Another instance may have the handler: this one is mid-deploy, or the plugin is gone. The row waits, and a + // plugin that stays removed leaves its rows pending. + EventLog.HandlerMissing(logger, message.Id, message.Handler); + await ReleaseAsync(message, options.MissingHandlerDelay, started: false); + return false; + } + + if (!await StartAsync(message)) + { + EventLog.LeaseLost(logger, message.Id, message.Handler); + return false; + } + + try + { + var @event = registration.Read(message.Payload) + ?? throw new InvalidOperationException($"The payload of the event for '{message.Handler}' is null."); + + await using var scope = scopes.CreateAsyncScope(); + using var limit = new CancellationTokenSource(options.DeliveryTimeout, time); + using var attempt = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, limit.Token); + + var previous = operation.Current; + operation.Current = await EventOperation.CreateAsync(scope.ServiceProvider, @event.Source, message.EventId, message.SiteId, time.GetUtcNow()); + try + { + // On a thread of its own, since a handler that blocks before its first await would never hand control back + // to the timeout. + var delivery = new EventDelivery(message.Id, message.Attempts); + await Task.Run(() => registration.Invoke(scope.ServiceProvider, @event, delivery, attempt.Token).AsTask(), CancellationToken.None) + .WaitAsync(options.DeliveryTimeout, time, cancellationToken); + } + finally + { + operation.Current = previous; + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + await ReleaseAsync(message, delay: null, started: true); + return false; + } + catch (TimeoutException) + { + EventLog.DeliveryAbandoned(logger, message.Id, message.Handler, options.DeliveryTimeout); + await FailAsync(message, $"The handler did not finish within {options.DeliveryTimeout}."); + return false; + } + catch (Exception exception) + { + await FailAsync(message, OutboxErrorText.Of(exception, message.Payload)); + return false; + } + + var now = time.GetUtcNow(); + await SettleAsync(message, row => row + .SetProperty(column => column.Status, OutboxStatus.Delivered) + .SetProperty(column => column.DeliveredAt, now) + .SetProperty(column => column.LastError, (string?)null) + .SetProperty(column => column.LeaseOwner, (string?)null) + .SetProperty(column => column.LeaseUntil, (DateTimeOffset?)null)); + return true; + } + + // Starts an attempt and takes the lease again, in one conditional update. False when the row is no longer this worker's. + private async Task StartAsync(OutboxMessage message) + { + try + { + await using var scope = scopes.CreateAsyncScope(); + var context = scope.ServiceProvider.GetRequiredService(); + var until = time.GetUtcNow() + options.LeaseDuration; + var started = await Held(context, message, _owner).ExecuteUpdateAsync(row => row + .SetProperty(column => column.LeaseUntil, until) + .SetProperty(column => column.Attempts, column => column.Attempts + 1)) == 1; + + if (started) + { + message.Attempts++; + } + + return started; + } + catch (Exception exception) + { + EventLog.SettleFailed(logger, exception, message.Id); + return false; + } + } + + private async Task FailAsync(OutboxMessage message, string error) + { + if (message.Attempts >= options.MaxAttempts) + { + EventLog.DeliveryGaveUp(logger, message.Id, message.Handler, message.Attempts, error); + await SettleAsync(message, row => row + .SetProperty(column => column.Status, OutboxStatus.Failed) + .SetProperty(column => column.LastError, error) + .SetProperty(column => column.LeaseOwner, (string?)null) + .SetProperty(column => column.LeaseUntil, (DateTimeOffset?)null)); + return; + } + + EventLog.DeliveryFailed(logger, message.Id, message.Handler, message.Attempts, error); + var retryAt = time.GetUtcNow() + options.RetryAfter(message.Attempts); + await SettleAsync(message, row => row + .SetProperty(column => column.AvailableAt, retryAt) + .SetProperty(column => column.LastError, error) + .SetProperty(column => column.LeaseOwner, (string?)null) + .SetProperty(column => column.LeaseUntil, (DateTimeOffset?)null)); + } + + // An attempt that was started is given back, since the handler did not run to an end. + private Task ReleaseAsync(OutboxMessage message, TimeSpan? delay, bool started) + { + var availableAt = delay is { } wait ? time.GetUtcNow() + wait : message.AvailableAt; + var back = started ? 1 : 0; + return SettleAsync(message, row => row + .SetProperty(column => column.Attempts, column => column.Attempts - back) + .SetProperty(column => column.AvailableAt, availableAt) + .SetProperty(column => column.LeaseOwner, (string?)null) + .SetProperty(column => column.LeaseUntil, (DateTimeOffset?)null)); + } + + // Matches nothing once the lease has gone to another instance. A write that fails leaves the row leased, so it is + // delivered again when the lease ends. + private async Task SettleAsync(OutboxMessage message, Action> update) + { + try + { + await using var scope = scopes.CreateAsyncScope(); + var context = scope.ServiceProvider.GetRequiredService(); + await Held(context, message, _owner).ExecuteUpdateAsync(update); + } + catch (Exception exception) + { + EventLog.SettleFailed(logger, exception, message.Id); + } + } + + private async Task PruneAsync(CancellationToken cancellationToken) + { + var before = time.GetUtcNow() - options.Retention; + try + { + await using var scope = scopes.CreateAsyncScope(); + var context = scope.ServiceProvider.GetRequiredService(); + await context.Set() + .Where(row => row.Status == OutboxStatus.Delivered && row.DeliveredAt < before) + .ExecuteDeleteAsync(cancellationToken); + } + catch (Exception exception) when (!cancellationToken.IsCancellationRequested) + { + EventLog.DrainFailed(logger, exception); + } + catch (OperationCanceledException) + { + } + } +} diff --git a/src/FluentCMS.Core/Events/OutboxErrorText.cs b/src/FluentCMS.Core/Events/OutboxErrorText.cs new file mode 100644 index 000000000..ccfe6c64a --- /dev/null +++ b/src/FluentCMS.Core/Events/OutboxErrorText.cs @@ -0,0 +1,14 @@ +using FluentCMS.Core.Jobs; +using FluentCMS.Core.Storage.Entities; + +namespace FluentCMS.Core.Events; + +// What a failed delivery stores and logs: the type of the exception and a capped message, scrubbed of what looks like a +// secret, and of the values of the event's own payload as a job's error is. +internal static class OutboxErrorText +{ + public static string Of(Exception exception, string payload) => + Cap(SecretScrubber.Of(JobErrorText.ForStorage(exception, payload))); + + public static string Cap(string text) => text.Length <= Job.LastErrorMaxLength ? text : text[..Job.LastErrorMaxLength]; +} diff --git a/src/FluentCMS.Core/Events/OutboxKick.cs b/src/FluentCMS.Core/Events/OutboxKick.cs new file mode 100644 index 000000000..a6582e17c --- /dev/null +++ b/src/FluentCMS.Core/Events/OutboxKick.cs @@ -0,0 +1,34 @@ +using FluentCMS.Core.Storage; +using FluentCMS.Core.Storage.Entities; +using FluentCMS.Jobs; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace FluentCMS.Core.Events; + +// Asks the job runner to drain the outbox now, after the commit that recorded rows. Whether it works or not nothing is +// lost: the rows are committed, and the recurring drain finds them. +internal sealed class OutboxKick(IServiceScopeFactory scopes, IJobScheduler scheduler, ILogger logger) +{ + public async ValueTask KickAsync() + { + try + { + await using (var scope = scopes.CreateAsyncScope()) + { + var context = scope.ServiceProvider.GetRequiredService(); + if (await context.Set().AnyAsync(job => job.Type == OutboxDrainJob.Type && job.Status == JobClaimStatus.Pending)) + { + return; + } + } + + await scheduler.EnqueueAsync(new OutboxDrainPayload(), new JobOptions { Queue = OutboxDrainJob.Queue }); + } + catch (Exception exception) + { + EventLog.KickFailed(logger, exception); + } + } +} diff --git a/src/FluentCMS.Core/Events/OutboxOptions.cs b/src/FluentCMS.Core/Events/OutboxOptions.cs new file mode 100644 index 000000000..87d7581fd --- /dev/null +++ b/src/FluentCMS.Core/Events/OutboxOptions.cs @@ -0,0 +1,63 @@ +using FluentCMS.Core.DependencyInjection; + +namespace FluentCMS.Core.Events; + +public sealed class OutboxOptions +{ + public int BatchSize { get; set; } = 50; + + // Deliveries to one handler before the event is left as failed. + public int MaxAttempts { get; set; } = 8; + + // How long a claim holds a delivery. A handler that outlives it may run a second time. + public TimeSpan LeaseDuration { get; set; } = TimeSpan.FromMinutes(5); + + public TimeSpan RetryBaseDelay { get; set; } = TimeSpan.FromSeconds(30); + + public TimeSpan RetryMaxDelay { get; set; } = TimeSpan.FromHours(1); + + public double RetryJitter { get; set; } = 0.2; + + // How long one delivery may take before it is abandoned and counted as failed. Shorter than the lease, so that a + // delivery never outlives the claim that lets it run. + public TimeSpan DeliveryTimeout { get; set; } = TimeSpan.FromMinutes(1); + + // How long the job runner waits for the in-process handlers of a job event before it goes on. They are not stopped. + public TimeSpan JobEventTimeout { get; set; } = TimeSpan.FromSeconds(5); + + // How long a row waits when no handler is registered under its name, as in a rolling deploy or after a plugin was removed. + public TimeSpan MissingHandlerDelay { get; set; } = TimeSpan.FromMinutes(5); + + // How long a delivered row stays for diagnosis. + public TimeSpan Retention { get; set; } = TimeSpan.FromDays(7); + + internal void Validate() + { + Require(BatchSize >= 1, $"{nameof(BatchSize)} is {BatchSize}; it must be at least 1."); + Require(MaxAttempts >= 1, $"{nameof(MaxAttempts)} is {MaxAttempts}; it must be at least 1."); + Require(LeaseDuration > TimeSpan.Zero, $"{nameof(LeaseDuration)} must be positive."); + Require(DeliveryTimeout > TimeSpan.Zero && DeliveryTimeout < LeaseDuration, $"{nameof(DeliveryTimeout)} must be positive and shorter than {nameof(LeaseDuration)}."); + Require(JobEventTimeout > TimeSpan.Zero, $"{nameof(JobEventTimeout)} must be positive."); + Require(MissingHandlerDelay > TimeSpan.Zero, $"{nameof(MissingHandlerDelay)} must be positive."); + Require(RetryBaseDelay >= TimeSpan.Zero && RetryMaxDelay >= RetryBaseDelay, $"{nameof(RetryMaxDelay)} must be at least {nameof(RetryBaseDelay)}, which must not be negative."); + Require(RetryJitter >= 0, $"{nameof(RetryJitter)} must not be negative."); + Require(Retention >= TimeSpan.Zero, $"{nameof(Retention)} must not be negative."); + } + + internal TimeSpan RetryAfter(int attempt) + { + var seconds = Math.Min( + RetryBaseDelay.TotalSeconds * Math.Pow(2, Math.Max(attempt - 1, 0)), + RetryMaxDelay.TotalSeconds); + + return TimeSpan.FromSeconds(seconds * (1 + (Random.Shared.NextDouble() * RetryJitter))); + } + + private static void Require(bool condition, string message) + { + if (!condition) + { + throw new FluentCmsConfigurationException(EventErrorCodes.InvalidOutboxOptions, message); + } + } +} diff --git a/src/FluentCMS.Core/Events/OutboxRegistration.cs b/src/FluentCMS.Core/Events/OutboxRegistration.cs new file mode 100644 index 000000000..b9794787c --- /dev/null +++ b/src/FluentCMS.Core/Events/OutboxRegistration.cs @@ -0,0 +1,72 @@ +using System.Collections.Frozen; +using System.Text.Json; +using FluentCMS.Core.Content.Hooks; +using FluentCMS.Core.DependencyInjection; +using FluentCMS.Events; +using Microsoft.Extensions.DependencyInjection; + +namespace FluentCMS.Core.Events; + +internal sealed class OutboxRegistration +{ + private OutboxRegistration(string name, Type eventType, Type handler, Func read, Func invoke) + { + Name = name; + EventType = eventType; + Handler = handler; + Read = read; + Invoke = invoke; + } + + public string Name { get; } + + public Type EventType { get; } + + public Type Handler { get; } + + public Func Read { get; } + + public Func Invoke { get; } + + public static OutboxRegistration Create(string name) + where TEvent : CmsEvent + where THandler : class, IOutboxEventHandler => + new( + name, + typeof(TEvent), + typeof(THandler), + json => JsonSerializer.Deserialize(json, EventJson.Options), + (services, @event, delivery, cancellationToken) => services.GetRequiredService().HandleAsync((TEvent)@event, delivery, cancellationToken)); +} + +internal static class EventJson +{ + public static readonly JsonSerializerOptions Options = JsonSerializerOptions.Web; +} + +internal sealed class OutboxRegistry +{ + private readonly FrozenDictionary _byName; + private readonly FrozenDictionary _byEvent; + + public OutboxRegistry(IEnumerable registrations, IHookTiebreaker? tiebreaker = null) + { + var all = registrations.ToList(); + var duplicate = all.GroupBy(registration => registration.Name, StringComparer.Ordinal).FirstOrDefault(group => group.Count() > 1); + if (duplicate is not null) + { + throw new FluentCmsConfigurationException(EventErrorCodes.DuplicateOutboxHandler, $"The outbox handler '{duplicate.Key}' is registered {duplicate.Count()} times."); + } + + _byName = all.ToFrozenDictionary(registration => registration.Name, StringComparer.Ordinal); + _byEvent = all + .GroupBy(registration => registration.EventType) + .ToFrozenDictionary(group => group.Key, group => HandlerOrder.Sort(group, registration => registration.Handler, tiebreaker).ToArray()); + } + + public bool Any => _byName.Count > 0; + + public OutboxRegistration? Find(string name) => _byName.GetValueOrDefault(name); + + public IReadOnlyList ForEvent(Type eventType) => _byEvent.GetValueOrDefault(eventType) ?? []; +} diff --git a/src/FluentCMS.Core/Events/OutboxRows.cs b/src/FluentCMS.Core/Events/OutboxRows.cs new file mode 100644 index 000000000..1418f49bc --- /dev/null +++ b/src/FluentCMS.Core/Events/OutboxRows.cs @@ -0,0 +1,69 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using FluentCMS.Core.Storage.Entities; +using FluentCMS.Events; + +namespace FluentCMS.Core.Events; + +// Turns events into the rows of the outbox handlers registered for them, in a sequence that rises with the order they are +// built in, so that the events of one commit are delivered in the order they were raised. +internal sealed class OutboxRows(OutboxRegistry registry, TimeProvider time) +{ + private long _last; + + public List Build(IEnumerable events) + { + var now = time.GetUtcNow(); + List rows = []; + HashSet<(Guid, string)> seen = []; + foreach (var @event in events) + { + var type = @event.GetType(); + var handlers = registry.ForEvent(type); + if (handlers.Count == 0) + { + continue; + } + + var payload = JsonSerializer.Serialize(@event, type, EventJson.Options); + foreach (var handler in handlers) + { + if (!seen.Add((@event.EventId, handler.Name))) + { + continue; + } + + rows.Add(new OutboxMessage + { + EventId = @event.EventId, + EventType = type.FullName ?? type.Name, + Handler = handler.Name, + Payload = payload, + SiteId = @event.Source.SiteId == Guid.Empty ? null : @event.Source.SiteId, + OccurredAt = @event.Source.OccurredAt.ToUniversalTime(), + Sequence = NextSequence(now), + AvailableAt = now, + }); + } + } + + return rows; + } + + // The same text always gives the same id, so that an event built again after a retry is the same event. + public static Guid StableId(string text) => new(SHA256.HashData(Encoding.UTF8.GetBytes(text)).AsSpan(0, 16)); + + private long NextSequence(DateTimeOffset now) + { + while (true) + { + var last = Interlocked.Read(ref _last); + var next = Math.Max(last + 1, now.UtcTicks); + if (Interlocked.CompareExchange(ref _last, next, last) == last) + { + return next; + } + } + } +} diff --git a/src/FluentCMS.Core/Events/OutboxWriter.cs b/src/FluentCMS.Core/Events/OutboxWriter.cs new file mode 100644 index 000000000..5f8b3e6f7 --- /dev/null +++ b/src/FluentCMS.Core/Events/OutboxWriter.cs @@ -0,0 +1,58 @@ +using FluentCMS.Core.Content; +using FluentCMS.Core.Storage; +using FluentCMS.Events; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace FluentCMS.Core.Events; + +// Records events for the outbox handlers that want them. Staging joins the transaction of the caller's context, so +// that the rows commit or roll back with the write that raised the events; recording writes on a context of its own. +internal sealed class OutboxWriter(IServiceScopeFactory scopes, OutboxRows rows, OutboxKick kick) +{ + public const int HandlerMaxLength = 128; + + private bool _staged; + + public async ValueTask StageAsync(DbContext context, IEnumerable events, CancellationToken cancellationToken) + { + var built = rows.Build(events); + if (built.Count == 0) + { + return; + } + + await OutboxStaging.SaveAsync(context, built, cancellationToken); + _staged = true; + } + + public async ValueTask RecordAsync(IEnumerable events, CancellationToken cancellationToken) + { + var built = rows.Build(events); + if (built.Count == 0) + { + return; + } + + await using (var scope = scopes.CreateAsyncScope()) + { + var own = scope.ServiceProvider.GetRequiredService(); + own.AddRange(built); + await own.SaveChangesAsync(cancellationToken); + } + + _staged = true; + } + + // After the commit: asks for the rows to be delivered at once. A failure waits for the scheduled drain. + public async ValueTask KickIfStagedAsync() + { + if (!_staged) + { + return; + } + + _staged = false; + await kick.KickAsync(); + } +} diff --git a/src/FluentCMS.Core/Events/SecretScrubber.cs b/src/FluentCMS.Core/Events/SecretScrubber.cs new file mode 100644 index 000000000..6352b7279 --- /dev/null +++ b/src/FluentCMS.Core/Events/SecretScrubber.cs @@ -0,0 +1,25 @@ +using System.Text.RegularExpressions; + +namespace FluentCMS.Core.Events; + +// Best effort, by shape, for text that comes out of a handler or a job and is stored in an event or the outbox: a URL is +// reduced to its scheme and host, since its path and query carry webhook tokens, a Bearer or Basic credential is masked, and +// so is the value of a key=value pair whose key names a secret. A secret in any other shape is not recognised. +internal static partial class SecretScrubber +{ + public static string Of(string text) + { + text = Url().Replace(text, "${scheme}${host}"); + text = Credential().Replace(text, "$1 ***"); + return Pair().Replace(text, "${key}${quote}${separator}***"); + } + + [GeneratedRegex(@"(?[A-Za-z][A-Za-z0-9+.\-]*://)(?:[^/?#@\s]*@)?(?[^/?#\s]*)\S*")] + private static partial Regex Url(); + + [GeneratedRegex(@"\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=\-]{8,}", RegexOptions.IgnoreCase)] + private static partial Regex Credential(); + + [GeneratedRegex(@"(?[\w.\-]*(?:token|secret|passw(?:or)?d|pwd|key|authorization|credential|signature)[\w.\-]*)(?[""']?)(?\s*[=:]\s*)(?:""[^""]*""|'[^']*'|[^\s,;&""']+)", RegexOptions.IgnoreCase)] + private static partial Regex Pair(); +} diff --git a/src/FluentCMS.Core/Jobs/JobEvents.cs b/src/FluentCMS.Core/Jobs/JobEvents.cs index 9f26ddd54..23622b96e 100644 --- a/src/FluentCMS.Core/Jobs/JobEvents.cs +++ b/src/FluentCMS.Core/Jobs/JobEvents.cs @@ -1,21 +1,11 @@ -namespace FluentCMS.Core.Jobs; - -// The events of spec 4.11, until the event bus (#2697) publishes them. -internal sealed record JobFailedEvent(Guid JobId, string Type, string Queue, int Attempt, int MaxAttempts, string Error); +using FluentCMS.Events; -internal sealed record JobDeadLetteredEvent(Guid JobId, string Type, string Queue, int Attempts, string Error); +namespace FluentCMS.Core.Jobs; -// The seam for #2697. A sink that throws is logged and changes nothing about the job. +// A sink that throws is logged and changes nothing about the job. internal interface IJobEventSink { Task PublishAsync(JobFailedEvent failed); Task PublishAsync(JobDeadLetteredEvent deadLettered); } - -internal sealed class NullJobEventSink : IJobEventSink -{ - public Task PublishAsync(JobFailedEvent failed) => Task.CompletedTask; - - public Task PublishAsync(JobDeadLetteredEvent deadLettered) => Task.CompletedTask; -} diff --git a/src/FluentCMS.Core/Jobs/JobStore.cs b/src/FluentCMS.Core/Jobs/JobStore.cs index 26cc520cd..f2ac489ab 100644 --- a/src/FluentCMS.Core/Jobs/JobStore.cs +++ b/src/FluentCMS.Core/Jobs/JobStore.cs @@ -1,5 +1,7 @@ +using FluentCMS.Core.Events; using FluentCMS.Core.Storage; using FluentCMS.Core.Storage.Entities; +using FluentCMS.Events; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Query; using Microsoft.Extensions.DependencyInjection; @@ -15,6 +17,8 @@ internal sealed class JobStore( IServiceScopeFactory scopes, IJobClaimDialect dialect, IJobEventSink events, + OutboxRows outbox, + OutboxKick kick, JobRunnerOptions options, TimeProvider time, ILogger logger) @@ -58,13 +62,19 @@ internal sealed class JobStore( cancellationToken); context.Add(new JobAttempt { JobId = id, Attempt = job.Attempts, StartedAt = now }); + + // The event is recorded for the outbox handlers in the same commit as the fact it reports. + var expiredEvent = expired > 0 ? Failed(job.Id, job.SiteId, job.Type, job.Queue, job.Attempts - 1, job.MaxAttempts, LeaseExpiredError) : null; + var rows = expiredEvent is null ? [] : outbox.Build([expiredEvent]); + context.AddRange(rows); await context.SaveChangesAsync(cancellationToken); await transaction.CommitAsync(CancellationToken.None); - if (expired > 0) + if (expiredEvent is not null) { - await PublishAsync(job.Id, new JobFailedEvent(job.Id, job.Type, job.Queue, job.Attempts - 1, job.MaxAttempts, LeaseExpiredError)); + await PublishAsync(job.Id, expiredEvent); + await KickAsync(rows.Count); } return new ClaimedJob(job.Id, job.Type, job.Payload, job.Queue, job.SiteId, job.Attempts, job.MaxAttempts, owner, leaseUntil); @@ -85,7 +95,7 @@ internal sealed class JobStore( public Task SucceedAsync(ClaimedJob job) { var now = time.GetUtcNow(); - return SettleAsync(job, JobAttemptOutcome.Succeeded, error: null, now, row => row + return SettleAsync(job, JobAttemptOutcome.Succeeded, error: null, now, [], row => row .SetProperty(column => column.Status, JobStatus.Succeeded) .SetProperty(column => column.CompletedAt, now) .SetProperty(column => column.LastError, (string?)null) @@ -99,7 +109,11 @@ public async Task FailAsync(ClaimedJob job, string error) var deadLetter = job.Attempt >= job.MaxAttempts; var runAt = now + RetryBackoff.After(job.Attempt, options); - var held = await SettleAsync(job, JobAttemptOutcome.Failed, error, now, row => + var failedEvent = Failed(job.Id, job.SiteId, job.Type, job.Queue, job.Attempt, job.MaxAttempts, error); + var deadEvent = deadLetter ? DeadLettered(job.Id, job.SiteId, job.Type, job.Queue, job.Attempt, error) : null; + var rows = outbox.Build(deadEvent is null ? [failedEvent] : [failedEvent, deadEvent]); + + var held = await SettleAsync(job, JobAttemptOutcome.Failed, error, now, rows, row => { row.SetProperty(column => column.LastError, error) .SetProperty(column => column.LeaseOwner, (string?)null) @@ -119,12 +133,14 @@ public async Task FailAsync(ClaimedJob job, string error) if (held) { - await PublishAsync(job.Id, new JobFailedEvent(job.Id, job.Type, job.Queue, job.Attempt, job.MaxAttempts, error)); - if (deadLetter) + await PublishAsync(job.Id, failedEvent); + if (deadEvent is not null) { JobLog.DeadLettered(logger, job.Id, job.Type, job.Attempt, error); - await PublishAsync(job.Id, new JobDeadLetteredEvent(job.Id, job.Type, job.Queue, job.Attempt, error)); + await PublishAsync(job.Id, deadEvent); } + + await KickAsync(rows.Count); } return held; @@ -134,7 +150,7 @@ public async Task FailAsync(ClaimedJob job, string error) public Task ReleaseAsync(ClaimedJob job) { var now = time.GetUtcNow(); - return SettleAsync(job, JobAttemptOutcome.Released, error: null, now, row => row + return SettleAsync(job, JobAttemptOutcome.Released, error: null, now, [], row => row .SetProperty(column => column.Status, JobStatus.Pending) .SetProperty(column => column.MaxAttempts, column => column.MaxAttempts + 1) .SetProperty(column => column.LeaseOwner, (string?)null) @@ -175,12 +191,19 @@ await context.Set() .SetProperty(row => row.FinishedAt, now) .SetProperty(row => row.Error, OutOfAttemptsError)); + var failedEvent = Failed(job.Id, job.SiteId, job.Type, job.Queue, job.Attempts, job.MaxAttempts, OutOfAttemptsError); + var deadEvent = DeadLettered(job.Id, job.SiteId, job.Type, job.Queue, job.Attempts, OutOfAttemptsError); + var rows = outbox.Build([failedEvent, deadEvent]); + context.AddRange(rows); + await context.SaveChangesAsync(); + await transaction.CommitAsync(); swept++; JobLog.DeadLettered(logger, job.Id, job.Type, job.Attempts, OutOfAttemptsError); - await PublishAsync(job.Id, new JobFailedEvent(job.Id, job.Type, job.Queue, job.Attempts, job.MaxAttempts, OutOfAttemptsError)); - await PublishAsync(job.Id, new JobDeadLetteredEvent(job.Id, job.Type, job.Queue, job.Attempts, OutOfAttemptsError)); + await PublishAsync(job.Id, failedEvent); + await PublishAsync(job.Id, deadEvent); + await KickAsync(rows.Count); } return swept; @@ -205,7 +228,7 @@ private static IQueryable Held(SystemDbContext context, ClaimedJob job) => context.Set().Where(row => row.Id == job.Id && row.Status == JobStatus.Running && row.LeaseOwner == job.Owner && row.Attempts == job.Attempt); - private async Task SettleAsync(ClaimedJob job, string outcome, string? error, DateTimeOffset now, Action> update) + private async Task SettleAsync(ClaimedJob job, string outcome, string? error, DateTimeOffset now, List rows, Action> update) { await using var scope = scopes.CreateAsyncScope(); var context = scope.ServiceProvider.GetRequiredService(); @@ -223,10 +246,43 @@ await context.Set() .SetProperty(row => row.FinishedAt, now) .SetProperty(row => row.Error, error)); + // The events of the outcome are recorded in the commit that makes it true, or not at all. + if (rows.Count > 0) + { + context.AddRange(rows); + await context.SaveChangesAsync(); + } + await transaction.CommitAsync(); return true; } + // The ids follow from the job and the attempt, so that an outcome written again after a failed write is the same event. + private JobFailedEvent Failed(Guid id, Guid? site, string type, string queue, int attempt, int maxAttempts, string error) => + new(id, type, queue, attempt, maxAttempts, SecretScrubber.Of(error)) + { + EventId = OutboxRows.StableId($"job:{id}:{attempt}:failed"), + Source = SourceOf(site), + }; + + private JobDeadLetteredEvent DeadLettered(Guid id, Guid? site, string type, string queue, int attempts, string error) => + new(id, type, queue, attempts, SecretScrubber.Of(error)) + { + EventId = OutboxRows.StableId($"job:{id}:{attempts}:dead"), + Source = SourceOf(site), + }; + + // The system raises it, on the site the job belongs to. + private EventSource SourceOf(Guid? site) => EventSource.FromSystem(time.GetUtcNow()) with { SiteId = site ?? Guid.Empty }; + + private async Task KickAsync(int rows) + { + if (rows > 0) + { + await kick.KickAsync(); + } + } + private Task PublishAsync(Guid jobId, JobFailedEvent failed) => GuardAsync(jobId, () => events.PublishAsync(failed)); private Task PublishAsync(Guid jobId, JobDeadLetteredEvent deadLettered) => GuardAsync(jobId, () => events.PublishAsync(deadLettered)); diff --git a/src/FluentCMS.Core/Storage/Entities/OutboxMessage.cs b/src/FluentCMS.Core/Storage/Entities/OutboxMessage.cs new file mode 100644 index 000000000..3fc7101e2 --- /dev/null +++ b/src/FluentCMS.Core/Storage/Entities/OutboxMessage.cs @@ -0,0 +1,47 @@ +namespace FluentCMS.Core.Storage.Entities; + +// A row of cms_outbox: one event for one outbox handler, written in the transaction that raised the event. +public sealed class OutboxMessage +{ + public Guid Id { get; set; } + + public Guid EventId { get; set; } + + public string EventType { get; set; } = ""; + + // The name the handler was registered under. + public string Handler { get; set; } = ""; + + public string Payload { get; set; } = ""; + + public Guid? SiteId { get; set; } + + public DateTimeOffset OccurredAt { get; set; } + + // Rises with the order rows were recorded in, and within an event with the order its handlers run in. + public long Sequence { get; set; } + + public string Status { get; set; } = OutboxStatus.Pending; + + public int Attempts { get; set; } + + public DateTimeOffset AvailableAt { get; set; } + + public DateTimeOffset? LeaseUntil { get; set; } + + public string? LeaseOwner { get; set; } + + public string? LastError { get; set; } + + public DateTimeOffset? DeliveredAt { get; set; } +} + +public static class OutboxStatus +{ + public const string Pending = "pending"; + + public const string Delivered = "delivered"; + + // Out of attempts: it stays for someone to look at. + public const string Failed = "failed"; +} diff --git a/src/FluentCMS.Core/Storage/StorageNames.cs b/src/FluentCMS.Core/Storage/StorageNames.cs index c773b784e..56e1a3a42 100644 --- a/src/FluentCMS.Core/Storage/StorageNames.cs +++ b/src/FluentCMS.Core/Storage/StorageNames.cs @@ -18,6 +18,7 @@ internal static class StorageNames public const string Job = "cms_job"; public const string JobAttempt = "cms_job_attempt"; public const string RecurringJob = "cms_recurring_job"; + public const string Outbox = "cms_outbox"; public const string Id = "id"; public const string DocumentId = "document_id"; diff --git a/src/FluentCMS.Core/Storage/SystemModelBuilder.cs b/src/FluentCMS.Core/Storage/SystemModelBuilder.cs index 4a8248f7b..0a63bb456 100644 --- a/src/FluentCMS.Core/Storage/SystemModelBuilder.cs +++ b/src/FluentCMS.Core/Storage/SystemModelBuilder.cs @@ -19,12 +19,15 @@ internal sealed class SystemModelBuilder(IStorageConventions conventions) private const int LeaseOwnerLength = 128; private const int RecurringNameLength = 128; private const int CronLength = 128; + private const int EventTypeLength = 256; + private const int HandlerLength = 128; public void Apply(ModelBuilder modelBuilder) { modelBuilder.Entity(MapJob); modelBuilder.Entity(MapJobAttempt); modelBuilder.Entity(MapRecurringJob); + modelBuilder.Entity(MapOutbox); modelBuilder.Entity(MapSite); modelBuilder.Entity(MapSiteLocale); modelBuilder.Entity(MapDocument); @@ -201,6 +204,34 @@ private void MapRecurringJob(EntityTypeBuilder entity) conventions.ConfigureJsonColumn(entity, entity.Property(job => job.Payload)); } + private void MapOutbox(EntityTypeBuilder entity) + { + entity.ToTable(StorageNames.Outbox); + MapColumnNames(entity); + entity.Property(message => message.Id).HasValueGenerator(); + entity.Property(message => message.EventType).HasMaxLength(EventTypeLength); + entity.Property(message => message.Handler).HasMaxLength(HandlerLength); + entity.Property(message => message.Status).HasMaxLength(StatusLength); + entity.Property(message => message.LeaseOwner).HasMaxLength(LeaseOwnerLength); + entity.Property(message => message.LastError).HasMaxLength(Job.LastErrorMaxLength); + conventions.ConfigureJsonColumn(entity, entity.Property(message => message.Payload)); + + // One row per event and handler, so a retry of the write that raised the event cannot add a second delivery. + entity.HasIndex(message => new { message.EventId, message.Handler }) + .IsUnique() + .HasDatabaseName("uq_cms_outbox_event_handler"); + + // Over rows still to deliver only, in the order the drain takes them, so that delivered rows are never walked. + entity.HasIndex(message => new { message.Sequence, message.Id }) + .HasFilter("status = 'pending'") + .HasDatabaseName("ix_cms_outbox_pending"); + + // For the prune of delivered rows. + entity.HasIndex(message => message.DeliveredAt) + .HasFilter("status = 'delivered'") + .HasDatabaseName("ix_cms_outbox_delivered"); + } + private static void MapColumnNames(EntityTypeBuilder entity) where T : class { diff --git a/src/FluentCMS.Data.PostgreSql/Migrations/20261102231104_AddOutbox.Designer.cs b/src/FluentCMS.Data.PostgreSql/Migrations/20261102231104_AddOutbox.Designer.cs new file mode 100644 index 000000000..b64b3f305 --- /dev/null +++ b/src/FluentCMS.Data.PostgreSql/Migrations/20261102231104_AddOutbox.Designer.cs @@ -0,0 +1,842 @@ +// +using System; +using FluentCMS.Core.Storage; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace FluentCMS.Data.PostgreSql.Migrations +{ + [DbContext(typeof(SystemDbContext))] + [Migration("20261102231104_AddOutbox")] + partial class AddOutbox + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.14") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("FluentCMS.Core.Storage.Entities.ContentVersion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AuthorId") + .HasColumnType("uuid") + .HasColumnName("author_id"); + + b.Property("ClientId") + .HasColumnType("text") + .HasColumnName("client_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DocumentId") + .HasColumnType("uuid") + .HasColumnName("document_id"); + + b.Property("Locale") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("locale"); + + b.Property("Message") + .HasColumnType("text") + .HasColumnName("message"); + + b.Property("Number") + .HasColumnType("integer") + .HasColumnName("number"); + + b.Property("Origin") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("origin"); + + b.Property("Snapshot") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("snapshot"); + + b.Property("StatusAfter") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("status_after"); + + b.HasKey("Id"); + + b.HasIndex("DocumentId", "Locale", "Number") + .IsUnique() + .HasDatabaseName("uq_cms_version_document_locale_number"); + + b.ToTable("cms_version", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Core.Storage.Entities.Document", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasColumnName("created_by"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("uuid") + .HasColumnName("deleted_by"); + + b.Property("DocumentId") + .HasColumnType("uuid") + .HasColumnName("document_id"); + + b.Property("DraftVersion") + .HasColumnType("integer") + .HasColumnName("draft_version"); + + b.Property("FirstPublishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("first_published_at"); + + b.Property("Locale") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("locale"); + + b.Property("PublishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("published_at"); + + b.Property("PublishedVersion") + .HasColumnType("integer") + .HasColumnName("published_version"); + + b.Property("SchedulePublishAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("schedule_publish_at"); + + b.Property("ScheduleUnpublishAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("schedule_unpublish_at"); + + b.Property("SiteId") + .HasColumnType("uuid") + .HasColumnName("site_id"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("status"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasColumnType("uuid") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("DocumentId", "Locale") + .IsUnique() + .HasDatabaseName("uq_cms_document_document_locale"); + + b.ToTable("cms_document", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Core.Storage.Entities.Job", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Attempts") + .HasColumnType("integer") + .HasColumnName("attempts"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasColumnName("created_by"); + + b.Property("LastError") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("last_error"); + + b.Property("LeaseOwner") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("lease_owner"); + + b.Property("LeaseUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("lease_until"); + + b.Property("MaxAttempts") + .HasColumnType("integer") + .HasColumnName("max_attempts"); + + b.Property("Origin") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("origin"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("payload"); + + b.Property("Priority") + .HasColumnType("integer") + .HasColumnName("priority"); + + b.Property("Queue") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("queue"); + + b.Property("RunAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("run_at"); + + b.Property("SiteId") + .HasColumnType("uuid") + .HasColumnName("site_id"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("status"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("type"); + + b.HasKey("Id"); + + b.HasIndex("Status", "LeaseUntil") + .HasDatabaseName("ix_cms_job_status_lease"); + + b.HasIndex("Queue", "Priority", "RunAt", "Id") + .IsDescending(false, true, false, false) + .HasDatabaseName("ix_cms_job_claim") + .HasFilter("status IN ('pending', 'running')"); + + b.ToTable("cms_job", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Core.Storage.Entities.JobAttempt", b => + { + b.Property("JobId") + .HasColumnType("uuid") + .HasColumnName("job_id"); + + b.Property("Attempt") + .HasColumnType("integer") + .HasColumnName("attempt"); + + b.Property("Error") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("error"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_at"); + + b.Property("Outcome") + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("outcome"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.HasKey("JobId", "Attempt"); + + b.ToTable("cms_job_attempt", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Core.Storage.Entities.OutboxMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Attempts") + .HasColumnType("integer") + .HasColumnName("attempts"); + + b.Property("AvailableAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("available_at"); + + b.Property("DeliveredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("delivered_at"); + + b.Property("EventId") + .HasColumnType("uuid") + .HasColumnName("event_id"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("event_type"); + + b.Property("Handler") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("handler"); + + b.Property("LastError") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("last_error"); + + b.Property("LeaseOwner") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("lease_owner"); + + b.Property("LeaseUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("lease_until"); + + b.Property("OccurredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_at"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("payload"); + + b.Property("Sequence") + .HasColumnType("bigint") + .HasColumnName("sequence"); + + b.Property("SiteId") + .HasColumnType("uuid") + .HasColumnName("site_id"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("status"); + + b.HasKey("Id"); + + b.HasIndex("DeliveredAt") + .HasDatabaseName("ix_cms_outbox_delivered") + .HasFilter("status = 'delivered'"); + + b.HasIndex("EventId", "Handler") + .IsUnique() + .HasDatabaseName("uq_cms_outbox_event_handler"); + + b.HasIndex("Sequence", "Id") + .HasDatabaseName("ix_cms_outbox_pending") + .HasFilter("status = 'pending'"); + + b.ToTable("cms_outbox", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Core.Storage.Entities.RecurringJob", b => + { + b.Property("Name") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("name"); + + b.Property("Cron") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("cron"); + + b.Property("Enabled") + .HasColumnType("boolean") + .HasColumnName("enabled"); + + b.Property("NextRunAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("next_run_at"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("payload"); + + b.Property("Queue") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("queue"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("type"); + + b.HasKey("Name"); + + b.ToTable("cms_recurring_job", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Core.Storage.Entities.Reference", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Field") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("field"); + + b.Property("Locale") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("locale"); + + b.Property("Position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.Property("SourceDocumentId") + .HasColumnType("uuid") + .HasColumnName("source_document_id"); + + b.Property("SourceType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("source_type"); + + b.Property("State") + .HasColumnType("smallint") + .HasColumnName("state"); + + b.Property("TargetId") + .HasColumnType("uuid") + .HasColumnName("target_id"); + + b.Property("TargetType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("target_type"); + + b.HasKey("Id"); + + b.HasIndex("TargetType", "TargetId") + .HasDatabaseName("ix_cms_reference_target"); + + b.HasIndex("SourceType", "SourceDocumentId", "State", "Locale", "Field", "Position") + .IsUnique() + .HasDatabaseName("uq_cms_reference_source"); + + b.ToTable("cms_reference", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Core.Storage.Entities.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("BuiltIn") + .HasColumnType("boolean") + .HasColumnName("built_in"); + + b.Property("Description") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("description"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("name"); + + b.Property("PermissionsVersion") + .IsConcurrencyToken() + .HasColumnType("bigint") + .HasColumnName("permissions_version"); + + b.Property("SiteId") + .HasColumnType("uuid") + .HasColumnName("site_id"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("uq_cms_role_name") + .HasFilter("site_id IS NULL"); + + b.HasIndex("SiteId", "Name") + .IsUnique() + .HasDatabaseName("uq_cms_role_site_name") + .HasFilter("site_id IS NOT NULL"); + + b.ToTable("cms_role", (string)null); + + b.HasData( + new + { + Id = new Guid("00000000-0000-7000-8000-000000000001"), + BuiltIn = true, + Description = "Holds every permission.", + Name = "Super Admin", + PermissionsVersion = 1L + }, + new + { + Id = new Guid("00000000-0000-7000-8000-000000000002"), + BuiltIn = true, + Description = "Creates, edits, publishes and removes any content.", + Name = "Editor", + PermissionsVersion = 1L + }, + new + { + Id = new Guid("00000000-0000-7000-8000-000000000003"), + BuiltIn = true, + Description = "Creates content, and reads, edits or removes only their own entries.", + Name = "Author", + PermissionsVersion = 1L + }, + new + { + Id = new Guid("00000000-0000-7000-8000-000000000004"), + BuiltIn = true, + Description = "The anonymous visitor. Grants here open public Delivery access.", + Name = "Public", + PermissionsVersion = 1L + }, + new + { + Id = new Guid("00000000-0000-7000-8000-000000000005"), + BuiltIn = true, + Description = "The default role of a registered member.", + Name = "Member", + PermissionsVersion = 1L + }, + new + { + Id = new Guid("00000000-0000-7000-8000-000000000006"), + BuiltIn = true, + Description = "The anonymous visitor. Grants here open public Delivery access.", + Name = "Public", + PermissionsVersion = 1L, + SiteId = new Guid("00000000-0000-7000-8002-000000000001") + }); + }); + + modelBuilder.Entity("FluentCMS.Core.Storage.Entities.RoleGrant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Constraints") + .HasColumnType("jsonb") + .HasColumnName("constraints"); + + b.Property("Permission") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("permission"); + + b.Property("RoleId") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.HasKey("Id"); + + b.HasIndex("RoleId", "Permission") + .HasDatabaseName("ix_cms_role_permission_role_permission"); + + b.ToTable("cms_role_permission", (string)null); + + b.HasData( + new + { + Id = new Guid("00000000-0000-7000-8001-000000000001"), + Permission = "*", + RoleId = new Guid("00000000-0000-7000-8000-000000000001") + }, + new + { + Id = new Guid("00000000-0000-7000-8001-000000000002"), + Permission = "content.*.create", + RoleId = new Guid("00000000-0000-7000-8000-000000000002") + }, + new + { + Id = new Guid("00000000-0000-7000-8001-000000000003"), + Permission = "content.*.read", + RoleId = new Guid("00000000-0000-7000-8000-000000000002") + }, + new + { + Id = new Guid("00000000-0000-7000-8001-000000000004"), + Permission = "content.*.update", + RoleId = new Guid("00000000-0000-7000-8000-000000000002") + }, + new + { + Id = new Guid("00000000-0000-7000-8001-000000000005"), + Permission = "content.*.delete", + RoleId = new Guid("00000000-0000-7000-8000-000000000002") + }, + new + { + Id = new Guid("00000000-0000-7000-8001-000000000006"), + Permission = "content.*.publish", + RoleId = new Guid("00000000-0000-7000-8000-000000000002") + }, + new + { + Id = new Guid("00000000-0000-7000-8001-000000000007"), + Permission = "content.*.unpublish", + RoleId = new Guid("00000000-0000-7000-8000-000000000002") + }, + new + { + Id = new Guid("00000000-0000-7000-8001-000000000008"), + Permission = "content.*.restore", + RoleId = new Guid("00000000-0000-7000-8000-000000000002") + }, + new + { + Id = new Guid("00000000-0000-7000-8001-000000000009"), + Permission = "schema.read", + RoleId = new Guid("00000000-0000-7000-8000-000000000002") + }, + new + { + Id = new Guid("00000000-0000-7000-8001-000000000010"), + Permission = "content.*.create", + RoleId = new Guid("00000000-0000-7000-8000-000000000003") + }, + new + { + Id = new Guid("00000000-0000-7000-8001-000000000011"), + Constraints = "{\"rowRule\":\"own-entries\"}", + Permission = "content.*.read", + RoleId = new Guid("00000000-0000-7000-8000-000000000003") + }, + new + { + Id = new Guid("00000000-0000-7000-8001-000000000012"), + Constraints = "{\"rowRule\":\"own-entries\"}", + Permission = "content.*.update", + RoleId = new Guid("00000000-0000-7000-8000-000000000003") + }, + new + { + Id = new Guid("00000000-0000-7000-8001-000000000013"), + Constraints = "{\"rowRule\":\"own-entries\"}", + Permission = "content.*.delete", + RoleId = new Guid("00000000-0000-7000-8000-000000000003") + }, + new + { + Id = new Guid("00000000-0000-7000-8001-000000000014"), + Permission = "schema.read", + RoleId = new Guid("00000000-0000-7000-8000-000000000003") + }); + }); + + modelBuilder.Entity("FluentCMS.Core.Storage.Entities.Site", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DefaultLocale") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("default_locale"); + + b.Property("IsDefault") + .HasColumnType("boolean") + .HasColumnName("is_default"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("slug"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("uq_cms_site_slug"); + + b.ToTable("cms_site", (string)null); + + b.HasData( + new + { + Id = new Guid("00000000-0000-7000-8002-000000000001"), + CreatedAt = new DateTimeOffset(new DateTime(2026, 11, 2, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DefaultLocale = "en", + IsDefault = true, + Name = "Default", + Slug = "default" + }); + }); + + modelBuilder.Entity("FluentCMS.Core.Storage.Entities.SiteLocale", b => + { + b.Property("SiteId") + .HasColumnType("uuid") + .HasColumnName("site_id"); + + b.Property("Code") + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("code"); + + b.Property("Enabled") + .HasColumnType("boolean") + .HasColumnName("enabled"); + + b.Property("Fallbacks") + .HasColumnType("jsonb") + .HasColumnName("fallbacks"); + + b.Property("Position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.HasKey("SiteId", "Code"); + + b.ToTable("cms_site_locale", (string)null); + + b.HasData( + new + { + SiteId = new Guid("00000000-0000-7000-8002-000000000001"), + Code = "en", + Enabled = true, + Position = 0 + }); + }); + + modelBuilder.Entity("FluentCMS.Core.Storage.Entities.JobAttempt", b => + { + b.HasOne("FluentCMS.Core.Storage.Entities.Job", null) + .WithMany() + .HasForeignKey("JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Core.Storage.Entities.Role", b => + { + b.HasOne("FluentCMS.Core.Storage.Entities.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("FluentCMS.Core.Storage.Entities.RoleGrant", b => + { + b.HasOne("FluentCMS.Core.Storage.Entities.Role", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Core.Storage.Entities.SiteLocale", b => + { + b.HasOne("FluentCMS.Core.Storage.Entities.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/FluentCMS.Data.PostgreSql/Migrations/20261102231104_AddOutbox.cs b/src/FluentCMS.Data.PostgreSql/Migrations/20261102231104_AddOutbox.cs new file mode 100644 index 000000000..3e342bfb9 --- /dev/null +++ b/src/FluentCMS.Data.PostgreSql/Migrations/20261102231104_AddOutbox.cs @@ -0,0 +1,66 @@ +// +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FluentCMS.Data.PostgreSql.Migrations +{ + /// + public partial class AddOutbox : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "cms_outbox", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + event_id = table.Column(type: "uuid", nullable: false), + event_type = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + handler = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + payload = table.Column(type: "jsonb", nullable: false), + site_id = table.Column(type: "uuid", nullable: true), + occurred_at = table.Column(type: "timestamp with time zone", nullable: false), + sequence = table.Column(type: "bigint", nullable: false), + status = table.Column(type: "character varying(16)", maxLength: 16, nullable: false), + attempts = table.Column(type: "integer", nullable: false), + available_at = table.Column(type: "timestamp with time zone", nullable: false), + lease_until = table.Column(type: "timestamp with time zone", nullable: true), + lease_owner = table.Column(type: "character varying(128)", maxLength: 128, nullable: true), + last_error = table.Column(type: "character varying(2000)", maxLength: 2000, nullable: true), + delivered_at = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_cms_outbox", x => x.id); + }); + + migrationBuilder.CreateIndex( + name: "ix_cms_outbox_delivered", + table: "cms_outbox", + column: "delivered_at", + filter: "status = 'delivered'"); + + migrationBuilder.CreateIndex( + name: "ix_cms_outbox_pending", + table: "cms_outbox", + columns: new[] { "sequence", "id" }, + filter: "status = 'pending'"); + + migrationBuilder.CreateIndex( + name: "uq_cms_outbox_event_handler", + table: "cms_outbox", + columns: new[] { "event_id", "handler" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "cms_outbox"); + } + } +} diff --git a/src/FluentCMS.Data.PostgreSql/Migrations/SystemDbContextModelSnapshot.cs b/src/FluentCMS.Data.PostgreSql/Migrations/SystemDbContextModelSnapshot.cs index ea9a86439..574cd3ccb 100644 --- a/src/FluentCMS.Data.PostgreSql/Migrations/SystemDbContextModelSnapshot.cs +++ b/src/FluentCMS.Data.PostgreSql/Migrations/SystemDbContextModelSnapshot.cs @@ -303,6 +303,95 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("cms_job_attempt", (string)null); }); + modelBuilder.Entity("FluentCMS.Core.Storage.Entities.OutboxMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Attempts") + .HasColumnType("integer") + .HasColumnName("attempts"); + + b.Property("AvailableAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("available_at"); + + b.Property("DeliveredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("delivered_at"); + + b.Property("EventId") + .HasColumnType("uuid") + .HasColumnName("event_id"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("event_type"); + + b.Property("Handler") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("handler"); + + b.Property("LastError") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("last_error"); + + b.Property("LeaseOwner") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("lease_owner"); + + b.Property("LeaseUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("lease_until"); + + b.Property("OccurredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_at"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("payload"); + + b.Property("Sequence") + .HasColumnType("bigint") + .HasColumnName("sequence"); + + b.Property("SiteId") + .HasColumnType("uuid") + .HasColumnName("site_id"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("status"); + + b.HasKey("Id"); + + b.HasIndex("DeliveredAt") + .HasDatabaseName("ix_cms_outbox_delivered") + .HasFilter("status = 'delivered'"); + + b.HasIndex("EventId", "Handler") + .IsUnique() + .HasDatabaseName("uq_cms_outbox_event_handler"); + + b.HasIndex("Sequence", "Id") + .HasDatabaseName("ix_cms_outbox_pending") + .HasFilter("status = 'pending'"); + + b.ToTable("cms_outbox", (string)null); + }); + modelBuilder.Entity("FluentCMS.Core.Storage.Entities.RecurringJob", b => { b.Property("Name") diff --git a/src/FluentCMS.Data.SqlServer/Migrations/20261102231110_AddOutbox.Designer.cs b/src/FluentCMS.Data.SqlServer/Migrations/20261102231110_AddOutbox.Designer.cs new file mode 100644 index 000000000..50a4d635a --- /dev/null +++ b/src/FluentCMS.Data.SqlServer/Migrations/20261102231110_AddOutbox.Designer.cs @@ -0,0 +1,860 @@ +// +using System; +using FluentCMS.Core.Storage; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace FluentCMS.Data.SqlServer.Migrations +{ + [DbContext(typeof(SystemDbContext))] + [Migration("20261102231110_AddOutbox")] + partial class AddOutbox + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.14") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("FluentCMS.Core.Storage.Entities.ContentVersion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AuthorId") + .HasColumnType("uniqueidentifier") + .HasColumnName("author_id"); + + b.Property("ClientId") + .HasColumnType("nvarchar(max)") + .HasColumnName("client_id"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset") + .HasColumnName("created_at"); + + b.Property("DocumentId") + .HasColumnType("uniqueidentifier") + .HasColumnName("document_id"); + + b.Property("Locale") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)") + .HasColumnName("locale"); + + b.Property("Message") + .HasColumnType("nvarchar(max)") + .HasColumnName("message"); + + b.Property("Number") + .HasColumnType("int") + .HasColumnName("number"); + + b.Property("Origin") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)") + .HasColumnName("origin"); + + b.Property("Snapshot") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("snapshot"); + + b.Property("StatusAfter") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)") + .HasColumnName("status_after"); + + b.HasKey("Id"); + + b.HasIndex("DocumentId", "Locale", "Number") + .IsUnique() + .HasDatabaseName("uq_cms_version_document_locale_number"); + + b.ToTable("cms_version", null, t => + { + t.HasCheckConstraint("ck_cms_version_snapshot_json", "ISJSON([snapshot]) = 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Core.Storage.Entities.Document", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by"); + + b.Property("DocumentId") + .HasColumnType("uniqueidentifier") + .HasColumnName("document_id"); + + b.Property("DraftVersion") + .HasColumnType("int") + .HasColumnName("draft_version"); + + b.Property("FirstPublishedAt") + .HasColumnType("datetimeoffset") + .HasColumnName("first_published_at"); + + b.Property("Locale") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)") + .HasColumnName("locale"); + + b.Property("PublishedAt") + .HasColumnType("datetimeoffset") + .HasColumnName("published_at"); + + b.Property("PublishedVersion") + .HasColumnType("int") + .HasColumnName("published_version"); + + b.Property("SchedulePublishAt") + .HasColumnType("datetimeoffset") + .HasColumnName("schedule_publish_at"); + + b.Property("ScheduleUnpublishAt") + .HasColumnType("datetimeoffset") + .HasColumnName("schedule_unpublish_at"); + + b.Property("SiteId") + .HasColumnType("uniqueidentifier") + .HasColumnName("site_id"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)") + .HasColumnName("status"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("datetimeoffset") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("DocumentId", "Locale") + .IsUnique() + .HasDatabaseName("uq_cms_document_document_locale"); + + b.ToTable("cms_document", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Core.Storage.Entities.Job", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("Attempts") + .HasColumnType("int") + .HasColumnName("attempts"); + + b.Property("CompletedAt") + .HasColumnType("datetimeoffset") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by"); + + b.Property("LastError") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("last_error"); + + b.Property("LeaseOwner") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("lease_owner"); + + b.Property("LeaseUntil") + .HasColumnType("datetimeoffset") + .HasColumnName("lease_until"); + + b.Property("MaxAttempts") + .HasColumnType("int") + .HasColumnName("max_attempts"); + + b.Property("Origin") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)") + .HasColumnName("origin"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("payload"); + + b.Property("Priority") + .HasColumnType("int") + .HasColumnName("priority"); + + b.Property("Queue") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)") + .HasColumnName("queue"); + + b.Property("RunAt") + .HasColumnType("datetimeoffset") + .HasColumnName("run_at"); + + b.Property("SiteId") + .HasColumnType("uniqueidentifier") + .HasColumnName("site_id"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)") + .HasColumnName("status"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("type"); + + b.HasKey("Id"); + + b.HasIndex("Status", "LeaseUntil") + .HasDatabaseName("ix_cms_job_status_lease"); + + b.HasIndex("Queue", "Priority", "RunAt", "Id") + .IsDescending(false, true, false, false) + .HasDatabaseName("ix_cms_job_claim") + .HasFilter("status IN ('pending', 'running')"); + + b.ToTable("cms_job", null, t => + { + t.HasCheckConstraint("ck_cms_job_payload_json", "ISJSON([payload]) = 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Core.Storage.Entities.JobAttempt", b => + { + b.Property("JobId") + .HasColumnType("uniqueidentifier") + .HasColumnName("job_id"); + + b.Property("Attempt") + .HasColumnType("int") + .HasColumnName("attempt"); + + b.Property("Error") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("error"); + + b.Property("FinishedAt") + .HasColumnType("datetimeoffset") + .HasColumnName("finished_at"); + + b.Property("Outcome") + .HasMaxLength(16) + .HasColumnType("nvarchar(16)") + .HasColumnName("outcome"); + + b.Property("StartedAt") + .HasColumnType("datetimeoffset") + .HasColumnName("started_at"); + + b.HasKey("JobId", "Attempt"); + + b.ToTable("cms_job_attempt", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Core.Storage.Entities.OutboxMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("Attempts") + .HasColumnType("int") + .HasColumnName("attempts"); + + b.Property("AvailableAt") + .HasColumnType("datetimeoffset") + .HasColumnName("available_at"); + + b.Property("DeliveredAt") + .HasColumnType("datetimeoffset") + .HasColumnName("delivered_at"); + + b.Property("EventId") + .HasColumnType("uniqueidentifier") + .HasColumnName("event_id"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("event_type"); + + b.Property("Handler") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("handler"); + + b.Property("LastError") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("last_error"); + + b.Property("LeaseOwner") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("lease_owner"); + + b.Property("LeaseUntil") + .HasColumnType("datetimeoffset") + .HasColumnName("lease_until"); + + b.Property("OccurredAt") + .HasColumnType("datetimeoffset") + .HasColumnName("occurred_at"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("payload"); + + b.Property("Sequence") + .HasColumnType("bigint") + .HasColumnName("sequence"); + + b.Property("SiteId") + .HasColumnType("uniqueidentifier") + .HasColumnName("site_id"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)") + .HasColumnName("status"); + + b.HasKey("Id"); + + b.HasIndex("DeliveredAt") + .HasDatabaseName("ix_cms_outbox_delivered") + .HasFilter("status = 'delivered'"); + + b.HasIndex("EventId", "Handler") + .IsUnique() + .HasDatabaseName("uq_cms_outbox_event_handler"); + + b.HasIndex("Sequence", "Id") + .HasDatabaseName("ix_cms_outbox_pending") + .HasFilter("status = 'pending'"); + + b.ToTable("cms_outbox", null, t => + { + t.HasCheckConstraint("ck_cms_outbox_payload_json", "ISJSON([payload]) = 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Core.Storage.Entities.RecurringJob", b => + { + b.Property("Name") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("name"); + + b.Property("Cron") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("cron"); + + b.Property("Enabled") + .HasColumnType("bit") + .HasColumnName("enabled"); + + b.Property("NextRunAt") + .HasColumnType("datetimeoffset") + .HasColumnName("next_run_at"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("payload"); + + b.Property("Queue") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)") + .HasColumnName("queue"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("type"); + + b.HasKey("Name"); + + b.ToTable("cms_recurring_job", null, t => + { + t.HasCheckConstraint("ck_cms_recurring_job_payload_json", "ISJSON([payload]) = 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Core.Storage.Entities.Reference", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("Field") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)") + .HasColumnName("field"); + + b.Property("Locale") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)") + .HasColumnName("locale"); + + b.Property("Position") + .HasColumnType("int") + .HasColumnName("position"); + + b.Property("SourceDocumentId") + .HasColumnType("uniqueidentifier") + .HasColumnName("source_document_id"); + + b.Property("SourceType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)") + .HasColumnName("source_type"); + + b.Property("State") + .HasColumnType("smallint") + .HasColumnName("state"); + + b.Property("TargetId") + .HasColumnType("uniqueidentifier") + .HasColumnName("target_id"); + + b.Property("TargetType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)") + .HasColumnName("target_type"); + + b.HasKey("Id"); + + b.HasIndex("TargetType", "TargetId") + .HasDatabaseName("ix_cms_reference_target"); + + b.HasIndex("SourceType", "SourceDocumentId", "State", "Locale", "Field", "Position") + .IsUnique() + .HasDatabaseName("uq_cms_reference_source"); + + b.ToTable("cms_reference", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Core.Storage.Entities.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("BuiltIn") + .HasColumnType("bit") + .HasColumnName("built_in"); + + b.Property("Description") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)") + .HasColumnName("description"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("name"); + + b.Property("PermissionsVersion") + .IsConcurrencyToken() + .HasColumnType("bigint") + .HasColumnName("permissions_version"); + + b.Property("SiteId") + .HasColumnType("uniqueidentifier") + .HasColumnName("site_id"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("uq_cms_role_name") + .HasFilter("site_id IS NULL"); + + b.HasIndex("SiteId", "Name") + .IsUnique() + .HasDatabaseName("uq_cms_role_site_name") + .HasFilter("site_id IS NOT NULL"); + + b.ToTable("cms_role", (string)null); + + b.HasData( + new + { + Id = new Guid("00000000-0000-7000-8000-000000000001"), + BuiltIn = true, + Description = "Holds every permission.", + Name = "Super Admin", + PermissionsVersion = 1L + }, + new + { + Id = new Guid("00000000-0000-7000-8000-000000000002"), + BuiltIn = true, + Description = "Creates, edits, publishes and removes any content.", + Name = "Editor", + PermissionsVersion = 1L + }, + new + { + Id = new Guid("00000000-0000-7000-8000-000000000003"), + BuiltIn = true, + Description = "Creates content, and reads, edits or removes only their own entries.", + Name = "Author", + PermissionsVersion = 1L + }, + new + { + Id = new Guid("00000000-0000-7000-8000-000000000004"), + BuiltIn = true, + Description = "The anonymous visitor. Grants here open public Delivery access.", + Name = "Public", + PermissionsVersion = 1L + }, + new + { + Id = new Guid("00000000-0000-7000-8000-000000000005"), + BuiltIn = true, + Description = "The default role of a registered member.", + Name = "Member", + PermissionsVersion = 1L + }, + new + { + Id = new Guid("00000000-0000-7000-8000-000000000006"), + BuiltIn = true, + Description = "The anonymous visitor. Grants here open public Delivery access.", + Name = "Public", + PermissionsVersion = 1L, + SiteId = new Guid("00000000-0000-7000-8002-000000000001") + }); + }); + + modelBuilder.Entity("FluentCMS.Core.Storage.Entities.RoleGrant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("Constraints") + .HasColumnType("nvarchar(max)") + .HasColumnName("constraints"); + + b.Property("Permission") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("permission"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier") + .HasColumnName("role_id"); + + b.HasKey("Id"); + + b.HasIndex("RoleId", "Permission") + .HasDatabaseName("ix_cms_role_permission_role_permission"); + + b.ToTable("cms_role_permission", null, t => + { + t.HasCheckConstraint("ck_cms_role_permission_constraints_json", "ISJSON([constraints]) = 1"); + }); + + b.HasData( + new + { + Id = new Guid("00000000-0000-7000-8001-000000000001"), + Permission = "*", + RoleId = new Guid("00000000-0000-7000-8000-000000000001") + }, + new + { + Id = new Guid("00000000-0000-7000-8001-000000000002"), + Permission = "content.*.create", + RoleId = new Guid("00000000-0000-7000-8000-000000000002") + }, + new + { + Id = new Guid("00000000-0000-7000-8001-000000000003"), + Permission = "content.*.read", + RoleId = new Guid("00000000-0000-7000-8000-000000000002") + }, + new + { + Id = new Guid("00000000-0000-7000-8001-000000000004"), + Permission = "content.*.update", + RoleId = new Guid("00000000-0000-7000-8000-000000000002") + }, + new + { + Id = new Guid("00000000-0000-7000-8001-000000000005"), + Permission = "content.*.delete", + RoleId = new Guid("00000000-0000-7000-8000-000000000002") + }, + new + { + Id = new Guid("00000000-0000-7000-8001-000000000006"), + Permission = "content.*.publish", + RoleId = new Guid("00000000-0000-7000-8000-000000000002") + }, + new + { + Id = new Guid("00000000-0000-7000-8001-000000000007"), + Permission = "content.*.unpublish", + RoleId = new Guid("00000000-0000-7000-8000-000000000002") + }, + new + { + Id = new Guid("00000000-0000-7000-8001-000000000008"), + Permission = "content.*.restore", + RoleId = new Guid("00000000-0000-7000-8000-000000000002") + }, + new + { + Id = new Guid("00000000-0000-7000-8001-000000000009"), + Permission = "schema.read", + RoleId = new Guid("00000000-0000-7000-8000-000000000002") + }, + new + { + Id = new Guid("00000000-0000-7000-8001-000000000010"), + Permission = "content.*.create", + RoleId = new Guid("00000000-0000-7000-8000-000000000003") + }, + new + { + Id = new Guid("00000000-0000-7000-8001-000000000011"), + Constraints = "{\"rowRule\":\"own-entries\"}", + Permission = "content.*.read", + RoleId = new Guid("00000000-0000-7000-8000-000000000003") + }, + new + { + Id = new Guid("00000000-0000-7000-8001-000000000012"), + Constraints = "{\"rowRule\":\"own-entries\"}", + Permission = "content.*.update", + RoleId = new Guid("00000000-0000-7000-8000-000000000003") + }, + new + { + Id = new Guid("00000000-0000-7000-8001-000000000013"), + Constraints = "{\"rowRule\":\"own-entries\"}", + Permission = "content.*.delete", + RoleId = new Guid("00000000-0000-7000-8000-000000000003") + }, + new + { + Id = new Guid("00000000-0000-7000-8001-000000000014"), + Permission = "schema.read", + RoleId = new Guid("00000000-0000-7000-8000-000000000003") + }); + }); + + modelBuilder.Entity("FluentCMS.Core.Storage.Entities.Site", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset") + .HasColumnName("created_at"); + + b.Property("DefaultLocale") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)") + .HasColumnName("default_locale"); + + b.Property("IsDefault") + .HasColumnType("bit") + .HasColumnName("is_default"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)") + .HasColumnName("slug"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("uq_cms_site_slug"); + + b.ToTable("cms_site", (string)null); + + b.HasData( + new + { + Id = new Guid("00000000-0000-7000-8002-000000000001"), + CreatedAt = new DateTimeOffset(new DateTime(2026, 11, 2, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DefaultLocale = "en", + IsDefault = true, + Name = "Default", + Slug = "default" + }); + }); + + modelBuilder.Entity("FluentCMS.Core.Storage.Entities.SiteLocale", b => + { + b.Property("SiteId") + .HasColumnType("uniqueidentifier") + .HasColumnName("site_id"); + + b.Property("Code") + .HasMaxLength(16) + .HasColumnType("nvarchar(16)") + .HasColumnName("code"); + + b.Property("Enabled") + .HasColumnType("bit") + .HasColumnName("enabled"); + + b.Property("Fallbacks") + .HasColumnType("nvarchar(max)") + .HasColumnName("fallbacks"); + + b.Property("Position") + .HasColumnType("int") + .HasColumnName("position"); + + b.HasKey("SiteId", "Code"); + + b.ToTable("cms_site_locale", null, t => + { + t.HasCheckConstraint("ck_cms_site_locale_fallbacks_json", "ISJSON([fallbacks]) = 1"); + }); + + b.HasData( + new + { + SiteId = new Guid("00000000-0000-7000-8002-000000000001"), + Code = "en", + Enabled = true, + Position = 0 + }); + }); + + modelBuilder.Entity("FluentCMS.Core.Storage.Entities.JobAttempt", b => + { + b.HasOne("FluentCMS.Core.Storage.Entities.Job", null) + .WithMany() + .HasForeignKey("JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Core.Storage.Entities.Role", b => + { + b.HasOne("FluentCMS.Core.Storage.Entities.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("FluentCMS.Core.Storage.Entities.RoleGrant", b => + { + b.HasOne("FluentCMS.Core.Storage.Entities.Role", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Core.Storage.Entities.SiteLocale", b => + { + b.HasOne("FluentCMS.Core.Storage.Entities.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/FluentCMS.Data.SqlServer/Migrations/20261102231110_AddOutbox.cs b/src/FluentCMS.Data.SqlServer/Migrations/20261102231110_AddOutbox.cs new file mode 100644 index 000000000..e0d09b2a8 --- /dev/null +++ b/src/FluentCMS.Data.SqlServer/Migrations/20261102231110_AddOutbox.cs @@ -0,0 +1,67 @@ +// +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FluentCMS.Data.SqlServer.Migrations +{ + /// + public partial class AddOutbox : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "cms_outbox", + columns: table => new + { + id = table.Column(type: "uniqueidentifier", nullable: false), + event_id = table.Column(type: "uniqueidentifier", nullable: false), + event_type = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: false), + handler = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), + payload = table.Column(type: "nvarchar(max)", nullable: false), + site_id = table.Column(type: "uniqueidentifier", nullable: true), + occurred_at = table.Column(type: "datetimeoffset", nullable: false), + sequence = table.Column(type: "bigint", nullable: false), + status = table.Column(type: "nvarchar(16)", maxLength: 16, nullable: false), + attempts = table.Column(type: "int", nullable: false), + available_at = table.Column(type: "datetimeoffset", nullable: false), + lease_until = table.Column(type: "datetimeoffset", nullable: true), + lease_owner = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: true), + last_error = table.Column(type: "nvarchar(2000)", maxLength: 2000, nullable: true), + delivered_at = table.Column(type: "datetimeoffset", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_cms_outbox", x => x.id); + table.CheckConstraint("ck_cms_outbox_payload_json", "ISJSON([payload]) = 1"); + }); + + migrationBuilder.CreateIndex( + name: "ix_cms_outbox_delivered", + table: "cms_outbox", + column: "delivered_at", + filter: "status = 'delivered'"); + + migrationBuilder.CreateIndex( + name: "ix_cms_outbox_pending", + table: "cms_outbox", + columns: new[] { "sequence", "id" }, + filter: "status = 'pending'"); + + migrationBuilder.CreateIndex( + name: "uq_cms_outbox_event_handler", + table: "cms_outbox", + columns: new[] { "event_id", "handler" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "cms_outbox"); + } + } +} diff --git a/src/FluentCMS.Data.SqlServer/Migrations/SystemDbContextModelSnapshot.cs b/src/FluentCMS.Data.SqlServer/Migrations/SystemDbContextModelSnapshot.cs index 7f8437fcb..ecc2fdb2b 100644 --- a/src/FluentCMS.Data.SqlServer/Migrations/SystemDbContextModelSnapshot.cs +++ b/src/FluentCMS.Data.SqlServer/Migrations/SystemDbContextModelSnapshot.cs @@ -309,6 +309,98 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("cms_job_attempt", (string)null); }); + modelBuilder.Entity("FluentCMS.Core.Storage.Entities.OutboxMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("Attempts") + .HasColumnType("int") + .HasColumnName("attempts"); + + b.Property("AvailableAt") + .HasColumnType("datetimeoffset") + .HasColumnName("available_at"); + + b.Property("DeliveredAt") + .HasColumnType("datetimeoffset") + .HasColumnName("delivered_at"); + + b.Property("EventId") + .HasColumnType("uniqueidentifier") + .HasColumnName("event_id"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasColumnName("event_type"); + + b.Property("Handler") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("handler"); + + b.Property("LastError") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("last_error"); + + b.Property("LeaseOwner") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("lease_owner"); + + b.Property("LeaseUntil") + .HasColumnType("datetimeoffset") + .HasColumnName("lease_until"); + + b.Property("OccurredAt") + .HasColumnType("datetimeoffset") + .HasColumnName("occurred_at"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("payload"); + + b.Property("Sequence") + .HasColumnType("bigint") + .HasColumnName("sequence"); + + b.Property("SiteId") + .HasColumnType("uniqueidentifier") + .HasColumnName("site_id"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)") + .HasColumnName("status"); + + b.HasKey("Id"); + + b.HasIndex("DeliveredAt") + .HasDatabaseName("ix_cms_outbox_delivered") + .HasFilter("status = 'delivered'"); + + b.HasIndex("EventId", "Handler") + .IsUnique() + .HasDatabaseName("uq_cms_outbox_event_handler"); + + b.HasIndex("Sequence", "Id") + .HasDatabaseName("ix_cms_outbox_pending") + .HasFilter("status = 'pending'"); + + b.ToTable("cms_outbox", null, t => + { + t.HasCheckConstraint("ck_cms_outbox_payload_json", "ISJSON([payload]) = 1"); + }); + }); + modelBuilder.Entity("FluentCMS.Core.Storage.Entities.RecurringJob", b => { b.Property("Name") diff --git a/src/FluentCMS.Data.Sqlite/Migrations/20261102231057_AddOutbox.Designer.cs b/src/FluentCMS.Data.Sqlite/Migrations/20261102231057_AddOutbox.Designer.cs new file mode 100644 index 000000000..c5a66f1ff --- /dev/null +++ b/src/FluentCMS.Data.Sqlite/Migrations/20261102231057_AddOutbox.Designer.cs @@ -0,0 +1,855 @@ +// +using System; +using FluentCMS.Core.Storage; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace FluentCMS.Data.Sqlite.Migrations +{ + [DbContext(typeof(SystemDbContext))] + [Migration("20261102231057_AddOutbox")] + partial class AddOutbox + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.14"); + + modelBuilder.Entity("FluentCMS.Core.Storage.Entities.ContentVersion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("AuthorId") + .HasColumnType("TEXT") + .HasColumnName("author_id"); + + b.Property("ClientId") + .HasColumnType("TEXT") + .HasColumnName("client_id"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER") + .HasColumnName("created_at"); + + b.Property("DocumentId") + .HasColumnType("TEXT") + .HasColumnName("document_id"); + + b.Property("Locale") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT") + .HasColumnName("locale"); + + b.Property("Message") + .HasColumnType("TEXT") + .HasColumnName("message"); + + b.Property("Number") + .HasColumnType("INTEGER") + .HasColumnName("number"); + + b.Property("Origin") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT") + .HasColumnName("origin"); + + b.Property("Snapshot") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("snapshot"); + + b.Property("StatusAfter") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT") + .HasColumnName("status_after"); + + b.HasKey("Id"); + + b.HasIndex("DocumentId", "Locale", "Number") + .IsUnique() + .HasDatabaseName("uq_cms_version_document_locale_number"); + + b.ToTable("cms_version", null, t => + { + t.HasCheckConstraint("ck_cms_version_snapshot_json", "json_valid(\"snapshot\")"); + }); + }); + + modelBuilder.Entity("FluentCMS.Core.Storage.Entities.Document", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasColumnType("TEXT") + .HasColumnName("created_by"); + + b.Property("DeletedAt") + .HasColumnType("INTEGER") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("TEXT") + .HasColumnName("deleted_by"); + + b.Property("DocumentId") + .HasColumnType("TEXT") + .HasColumnName("document_id"); + + b.Property("DraftVersion") + .HasColumnType("INTEGER") + .HasColumnName("draft_version"); + + b.Property("FirstPublishedAt") + .HasColumnType("INTEGER") + .HasColumnName("first_published_at"); + + b.Property("Locale") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT") + .HasColumnName("locale"); + + b.Property("PublishedAt") + .HasColumnType("INTEGER") + .HasColumnName("published_at"); + + b.Property("PublishedVersion") + .HasColumnType("INTEGER") + .HasColumnName("published_version"); + + b.Property("SchedulePublishAt") + .HasColumnType("INTEGER") + .HasColumnName("schedule_publish_at"); + + b.Property("ScheduleUnpublishAt") + .HasColumnType("INTEGER") + .HasColumnName("schedule_unpublish_at"); + + b.Property("SiteId") + .HasColumnType("TEXT") + .HasColumnName("site_id"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT") + .HasColumnName("status"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasColumnType("TEXT") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("DocumentId", "Locale") + .IsUnique() + .HasDatabaseName("uq_cms_document_document_locale"); + + b.ToTable("cms_document", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Core.Storage.Entities.Job", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Attempts") + .HasColumnType("INTEGER") + .HasColumnName("attempts"); + + b.Property("CompletedAt") + .HasColumnType("INTEGER") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasColumnType("TEXT") + .HasColumnName("created_by"); + + b.Property("LastError") + .HasMaxLength(2000) + .HasColumnType("TEXT") + .HasColumnName("last_error"); + + b.Property("LeaseOwner") + .HasMaxLength(128) + .HasColumnType("TEXT") + .HasColumnName("lease_owner"); + + b.Property("LeaseUntil") + .HasColumnType("INTEGER") + .HasColumnName("lease_until"); + + b.Property("MaxAttempts") + .HasColumnType("INTEGER") + .HasColumnName("max_attempts"); + + b.Property("Origin") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT") + .HasColumnName("origin"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("payload"); + + b.Property("Priority") + .HasColumnType("INTEGER") + .HasColumnName("priority"); + + b.Property("Queue") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT") + .HasColumnName("queue"); + + b.Property("RunAt") + .HasColumnType("INTEGER") + .HasColumnName("run_at"); + + b.Property("SiteId") + .HasColumnType("TEXT") + .HasColumnName("site_id"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT") + .HasColumnName("status"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT") + .HasColumnName("type"); + + b.HasKey("Id"); + + b.HasIndex("Status", "LeaseUntil") + .HasDatabaseName("ix_cms_job_status_lease"); + + b.HasIndex("Queue", "Priority", "RunAt", "Id") + .IsDescending(false, true, false, false) + .HasDatabaseName("ix_cms_job_claim") + .HasFilter("status IN ('pending', 'running')"); + + b.ToTable("cms_job", null, t => + { + t.HasCheckConstraint("ck_cms_job_payload_json", "json_valid(\"payload\")"); + }); + }); + + modelBuilder.Entity("FluentCMS.Core.Storage.Entities.JobAttempt", b => + { + b.Property("JobId") + .HasColumnType("TEXT") + .HasColumnName("job_id"); + + b.Property("Attempt") + .HasColumnType("INTEGER") + .HasColumnName("attempt"); + + b.Property("Error") + .HasMaxLength(2000) + .HasColumnType("TEXT") + .HasColumnName("error"); + + b.Property("FinishedAt") + .HasColumnType("INTEGER") + .HasColumnName("finished_at"); + + b.Property("Outcome") + .HasMaxLength(16) + .HasColumnType("TEXT") + .HasColumnName("outcome"); + + b.Property("StartedAt") + .HasColumnType("INTEGER") + .HasColumnName("started_at"); + + b.HasKey("JobId", "Attempt"); + + b.ToTable("cms_job_attempt", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Core.Storage.Entities.OutboxMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Attempts") + .HasColumnType("INTEGER") + .HasColumnName("attempts"); + + b.Property("AvailableAt") + .HasColumnType("INTEGER") + .HasColumnName("available_at"); + + b.Property("DeliveredAt") + .HasColumnType("INTEGER") + .HasColumnName("delivered_at"); + + b.Property("EventId") + .HasColumnType("TEXT") + .HasColumnName("event_id"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT") + .HasColumnName("event_type"); + + b.Property("Handler") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT") + .HasColumnName("handler"); + + b.Property("LastError") + .HasMaxLength(2000) + .HasColumnType("TEXT") + .HasColumnName("last_error"); + + b.Property("LeaseOwner") + .HasMaxLength(128) + .HasColumnType("TEXT") + .HasColumnName("lease_owner"); + + b.Property("LeaseUntil") + .HasColumnType("INTEGER") + .HasColumnName("lease_until"); + + b.Property("OccurredAt") + .HasColumnType("INTEGER") + .HasColumnName("occurred_at"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("payload"); + + b.Property("Sequence") + .HasColumnType("INTEGER") + .HasColumnName("sequence"); + + b.Property("SiteId") + .HasColumnType("TEXT") + .HasColumnName("site_id"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT") + .HasColumnName("status"); + + b.HasKey("Id"); + + b.HasIndex("DeliveredAt") + .HasDatabaseName("ix_cms_outbox_delivered") + .HasFilter("status = 'delivered'"); + + b.HasIndex("EventId", "Handler") + .IsUnique() + .HasDatabaseName("uq_cms_outbox_event_handler"); + + b.HasIndex("Sequence", "Id") + .HasDatabaseName("ix_cms_outbox_pending") + .HasFilter("status = 'pending'"); + + b.ToTable("cms_outbox", null, t => + { + t.HasCheckConstraint("ck_cms_outbox_payload_json", "json_valid(\"payload\")"); + }); + }); + + modelBuilder.Entity("FluentCMS.Core.Storage.Entities.RecurringJob", b => + { + b.Property("Name") + .HasMaxLength(128) + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("Cron") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT") + .HasColumnName("cron"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("NextRunAt") + .HasColumnType("INTEGER") + .HasColumnName("next_run_at"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("payload"); + + b.Property("Queue") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT") + .HasColumnName("queue"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT") + .HasColumnName("type"); + + b.HasKey("Name"); + + b.ToTable("cms_recurring_job", null, t => + { + t.HasCheckConstraint("ck_cms_recurring_job_payload_json", "json_valid(\"payload\")"); + }); + }); + + modelBuilder.Entity("FluentCMS.Core.Storage.Entities.Reference", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Field") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT") + .HasColumnName("field"); + + b.Property("Locale") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT") + .HasColumnName("locale"); + + b.Property("Position") + .HasColumnType("INTEGER") + .HasColumnName("position"); + + b.Property("SourceDocumentId") + .HasColumnType("TEXT") + .HasColumnName("source_document_id"); + + b.Property("SourceType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT") + .HasColumnName("source_type"); + + b.Property("State") + .HasColumnType("INTEGER") + .HasColumnName("state"); + + b.Property("TargetId") + .HasColumnType("TEXT") + .HasColumnName("target_id"); + + b.Property("TargetType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT") + .HasColumnName("target_type"); + + b.HasKey("Id"); + + b.HasIndex("TargetType", "TargetId") + .HasDatabaseName("ix_cms_reference_target"); + + b.HasIndex("SourceType", "SourceDocumentId", "State", "Locale", "Field", "Position") + .IsUnique() + .HasDatabaseName("uq_cms_reference_source"); + + b.ToTable("cms_reference", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Core.Storage.Entities.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("BuiltIn") + .HasColumnType("INTEGER") + .HasColumnName("built_in"); + + b.Property("Description") + .HasMaxLength(512) + .HasColumnType("TEXT") + .HasColumnName("description"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("PermissionsVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER") + .HasColumnName("permissions_version"); + + b.Property("SiteId") + .HasColumnType("TEXT") + .HasColumnName("site_id"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("uq_cms_role_name") + .HasFilter("site_id IS NULL"); + + b.HasIndex("SiteId", "Name") + .IsUnique() + .HasDatabaseName("uq_cms_role_site_name") + .HasFilter("site_id IS NOT NULL"); + + b.ToTable("cms_role", (string)null); + + b.HasData( + new + { + Id = new Guid("00000000-0000-7000-8000-000000000001"), + BuiltIn = true, + Description = "Holds every permission.", + Name = "Super Admin", + PermissionsVersion = 1L + }, + new + { + Id = new Guid("00000000-0000-7000-8000-000000000002"), + BuiltIn = true, + Description = "Creates, edits, publishes and removes any content.", + Name = "Editor", + PermissionsVersion = 1L + }, + new + { + Id = new Guid("00000000-0000-7000-8000-000000000003"), + BuiltIn = true, + Description = "Creates content, and reads, edits or removes only their own entries.", + Name = "Author", + PermissionsVersion = 1L + }, + new + { + Id = new Guid("00000000-0000-7000-8000-000000000004"), + BuiltIn = true, + Description = "The anonymous visitor. Grants here open public Delivery access.", + Name = "Public", + PermissionsVersion = 1L + }, + new + { + Id = new Guid("00000000-0000-7000-8000-000000000005"), + BuiltIn = true, + Description = "The default role of a registered member.", + Name = "Member", + PermissionsVersion = 1L + }, + new + { + Id = new Guid("00000000-0000-7000-8000-000000000006"), + BuiltIn = true, + Description = "The anonymous visitor. Grants here open public Delivery access.", + Name = "Public", + PermissionsVersion = 1L, + SiteId = new Guid("00000000-0000-7000-8002-000000000001") + }); + }); + + modelBuilder.Entity("FluentCMS.Core.Storage.Entities.RoleGrant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Constraints") + .HasColumnType("TEXT") + .HasColumnName("constraints"); + + b.Property("Permission") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT") + .HasColumnName("permission"); + + b.Property("RoleId") + .HasColumnType("TEXT") + .HasColumnName("role_id"); + + b.HasKey("Id"); + + b.HasIndex("RoleId", "Permission") + .HasDatabaseName("ix_cms_role_permission_role_permission"); + + b.ToTable("cms_role_permission", null, t => + { + t.HasCheckConstraint("ck_cms_role_permission_constraints_json", "json_valid(\"constraints\")"); + }); + + b.HasData( + new + { + Id = new Guid("00000000-0000-7000-8001-000000000001"), + Permission = "*", + RoleId = new Guid("00000000-0000-7000-8000-000000000001") + }, + new + { + Id = new Guid("00000000-0000-7000-8001-000000000002"), + Permission = "content.*.create", + RoleId = new Guid("00000000-0000-7000-8000-000000000002") + }, + new + { + Id = new Guid("00000000-0000-7000-8001-000000000003"), + Permission = "content.*.read", + RoleId = new Guid("00000000-0000-7000-8000-000000000002") + }, + new + { + Id = new Guid("00000000-0000-7000-8001-000000000004"), + Permission = "content.*.update", + RoleId = new Guid("00000000-0000-7000-8000-000000000002") + }, + new + { + Id = new Guid("00000000-0000-7000-8001-000000000005"), + Permission = "content.*.delete", + RoleId = new Guid("00000000-0000-7000-8000-000000000002") + }, + new + { + Id = new Guid("00000000-0000-7000-8001-000000000006"), + Permission = "content.*.publish", + RoleId = new Guid("00000000-0000-7000-8000-000000000002") + }, + new + { + Id = new Guid("00000000-0000-7000-8001-000000000007"), + Permission = "content.*.unpublish", + RoleId = new Guid("00000000-0000-7000-8000-000000000002") + }, + new + { + Id = new Guid("00000000-0000-7000-8001-000000000008"), + Permission = "content.*.restore", + RoleId = new Guid("00000000-0000-7000-8000-000000000002") + }, + new + { + Id = new Guid("00000000-0000-7000-8001-000000000009"), + Permission = "schema.read", + RoleId = new Guid("00000000-0000-7000-8000-000000000002") + }, + new + { + Id = new Guid("00000000-0000-7000-8001-000000000010"), + Permission = "content.*.create", + RoleId = new Guid("00000000-0000-7000-8000-000000000003") + }, + new + { + Id = new Guid("00000000-0000-7000-8001-000000000011"), + Constraints = "{\"rowRule\":\"own-entries\"}", + Permission = "content.*.read", + RoleId = new Guid("00000000-0000-7000-8000-000000000003") + }, + new + { + Id = new Guid("00000000-0000-7000-8001-000000000012"), + Constraints = "{\"rowRule\":\"own-entries\"}", + Permission = "content.*.update", + RoleId = new Guid("00000000-0000-7000-8000-000000000003") + }, + new + { + Id = new Guid("00000000-0000-7000-8001-000000000013"), + Constraints = "{\"rowRule\":\"own-entries\"}", + Permission = "content.*.delete", + RoleId = new Guid("00000000-0000-7000-8000-000000000003") + }, + new + { + Id = new Guid("00000000-0000-7000-8001-000000000014"), + Permission = "schema.read", + RoleId = new Guid("00000000-0000-7000-8000-000000000003") + }); + }); + + modelBuilder.Entity("FluentCMS.Core.Storage.Entities.Site", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER") + .HasColumnName("created_at"); + + b.Property("DefaultLocale") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT") + .HasColumnName("default_locale"); + + b.Property("IsDefault") + .HasColumnType("INTEGER") + .HasColumnName("is_default"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT") + .HasColumnName("slug"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("uq_cms_site_slug"); + + b.ToTable("cms_site", (string)null); + + b.HasData( + new + { + Id = new Guid("00000000-0000-7000-8002-000000000001"), + CreatedAt = 639291744000000000L, + DefaultLocale = "en", + IsDefault = true, + Name = "Default", + Slug = "default" + }); + }); + + modelBuilder.Entity("FluentCMS.Core.Storage.Entities.SiteLocale", b => + { + b.Property("SiteId") + .HasColumnType("TEXT") + .HasColumnName("site_id"); + + b.Property("Code") + .HasMaxLength(16) + .HasColumnType("TEXT") + .HasColumnName("code"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("Fallbacks") + .HasColumnType("TEXT") + .HasColumnName("fallbacks"); + + b.Property("Position") + .HasColumnType("INTEGER") + .HasColumnName("position"); + + b.HasKey("SiteId", "Code"); + + b.ToTable("cms_site_locale", null, t => + { + t.HasCheckConstraint("ck_cms_site_locale_fallbacks_json", "json_valid(\"fallbacks\")"); + }); + + b.HasData( + new + { + SiteId = new Guid("00000000-0000-7000-8002-000000000001"), + Code = "en", + Enabled = true, + Position = 0 + }); + }); + + modelBuilder.Entity("FluentCMS.Core.Storage.Entities.JobAttempt", b => + { + b.HasOne("FluentCMS.Core.Storage.Entities.Job", null) + .WithMany() + .HasForeignKey("JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Core.Storage.Entities.Role", b => + { + b.HasOne("FluentCMS.Core.Storage.Entities.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("FluentCMS.Core.Storage.Entities.RoleGrant", b => + { + b.HasOne("FluentCMS.Core.Storage.Entities.Role", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Core.Storage.Entities.SiteLocale", b => + { + b.HasOne("FluentCMS.Core.Storage.Entities.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/FluentCMS.Data.Sqlite/Migrations/20261102231057_AddOutbox.cs b/src/FluentCMS.Data.Sqlite/Migrations/20261102231057_AddOutbox.cs new file mode 100644 index 000000000..21d7d1c89 --- /dev/null +++ b/src/FluentCMS.Data.Sqlite/Migrations/20261102231057_AddOutbox.cs @@ -0,0 +1,67 @@ +// +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FluentCMS.Data.Sqlite.Migrations +{ + /// + public partial class AddOutbox : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "cms_outbox", + columns: table => new + { + id = table.Column(type: "TEXT", nullable: false), + event_id = table.Column(type: "TEXT", nullable: false), + event_type = table.Column(type: "TEXT", maxLength: 256, nullable: false), + handler = table.Column(type: "TEXT", maxLength: 128, nullable: false), + payload = table.Column(type: "TEXT", nullable: false), + site_id = table.Column(type: "TEXT", nullable: true), + occurred_at = table.Column(type: "INTEGER", nullable: false), + sequence = table.Column(type: "INTEGER", nullable: false), + status = table.Column(type: "TEXT", maxLength: 16, nullable: false), + attempts = table.Column(type: "INTEGER", nullable: false), + available_at = table.Column(type: "INTEGER", nullable: false), + lease_until = table.Column(type: "INTEGER", nullable: true), + lease_owner = table.Column(type: "TEXT", maxLength: 128, nullable: true), + last_error = table.Column(type: "TEXT", maxLength: 2000, nullable: true), + delivered_at = table.Column(type: "INTEGER", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_cms_outbox", x => x.id); + table.CheckConstraint("ck_cms_outbox_payload_json", "json_valid(\"payload\")"); + }); + + migrationBuilder.CreateIndex( + name: "ix_cms_outbox_delivered", + table: "cms_outbox", + column: "delivered_at", + filter: "status = 'delivered'"); + + migrationBuilder.CreateIndex( + name: "ix_cms_outbox_pending", + table: "cms_outbox", + columns: new[] { "sequence", "id" }, + filter: "status = 'pending'"); + + migrationBuilder.CreateIndex( + name: "uq_cms_outbox_event_handler", + table: "cms_outbox", + columns: new[] { "event_id", "handler" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "cms_outbox"); + } + } +} diff --git a/src/FluentCMS.Data.Sqlite/Migrations/SystemDbContextModelSnapshot.cs b/src/FluentCMS.Data.Sqlite/Migrations/SystemDbContextModelSnapshot.cs index 496c16940..4dc787e76 100644 --- a/src/FluentCMS.Data.Sqlite/Migrations/SystemDbContextModelSnapshot.cs +++ b/src/FluentCMS.Data.Sqlite/Migrations/SystemDbContextModelSnapshot.cs @@ -304,6 +304,98 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("cms_job_attempt", (string)null); }); + modelBuilder.Entity("FluentCMS.Core.Storage.Entities.OutboxMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Attempts") + .HasColumnType("INTEGER") + .HasColumnName("attempts"); + + b.Property("AvailableAt") + .HasColumnType("INTEGER") + .HasColumnName("available_at"); + + b.Property("DeliveredAt") + .HasColumnType("INTEGER") + .HasColumnName("delivered_at"); + + b.Property("EventId") + .HasColumnType("TEXT") + .HasColumnName("event_id"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT") + .HasColumnName("event_type"); + + b.Property("Handler") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT") + .HasColumnName("handler"); + + b.Property("LastError") + .HasMaxLength(2000) + .HasColumnType("TEXT") + .HasColumnName("last_error"); + + b.Property("LeaseOwner") + .HasMaxLength(128) + .HasColumnType("TEXT") + .HasColumnName("lease_owner"); + + b.Property("LeaseUntil") + .HasColumnType("INTEGER") + .HasColumnName("lease_until"); + + b.Property("OccurredAt") + .HasColumnType("INTEGER") + .HasColumnName("occurred_at"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("payload"); + + b.Property("Sequence") + .HasColumnType("INTEGER") + .HasColumnName("sequence"); + + b.Property("SiteId") + .HasColumnType("TEXT") + .HasColumnName("site_id"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT") + .HasColumnName("status"); + + b.HasKey("Id"); + + b.HasIndex("DeliveredAt") + .HasDatabaseName("ix_cms_outbox_delivered") + .HasFilter("status = 'delivered'"); + + b.HasIndex("EventId", "Handler") + .IsUnique() + .HasDatabaseName("uq_cms_outbox_event_handler"); + + b.HasIndex("Sequence", "Id") + .HasDatabaseName("ix_cms_outbox_pending") + .HasFilter("status = 'pending'"); + + b.ToTable("cms_outbox", null, t => + { + t.HasCheckConstraint("ck_cms_outbox_payload_json", "json_valid(\"payload\")"); + }); + }); + modelBuilder.Entity("FluentCMS.Core.Storage.Entities.RecurringJob", b => { b.Property("Name") diff --git a/src/FluentCMS.Sdk/Events/CmsEvent.cs b/src/FluentCMS.Sdk/Events/CmsEvent.cs new file mode 100644 index 000000000..992871a0a --- /dev/null +++ b/src/FluentCMS.Sdk/Events/CmsEvent.cs @@ -0,0 +1,33 @@ +namespace FluentCMS.Events; + +/// +/// The base of every event. An event is a record that survives a round trip through JSON, because a handler that +/// subscribes through the outbox receives it from the database. +/// +public abstract record CmsEvent +{ + private readonly DateTimeOffset _created = DateTimeOffset.UtcNow; + private EventSource? _source; + + /// Creates an event with no source, which fills from the current operation when it is published. + protected CmsEvent() + { + } + + /// The id of the event, the same for every handler and every delivery of it. + public Guid EventId { get; init; } = Guid.CreateVersion7(); + + /// + /// Where and when the event happened. Set it from an operation with , or leave it + /// for to set from the operation that publishes the event, or from the system when none is running. + /// + public EventSource Source + { + get => _source ?? EventSource.FromSystem(_created); + init => _source = value; + } + + internal bool IsAttributed => _source is not null; + + internal void Attribute(EventSource source) => _source = source; +} diff --git a/src/FluentCMS.Sdk/Events/EntryEvent.cs b/src/FluentCMS.Sdk/Events/EntryEvent.cs new file mode 100644 index 000000000..3e41e0488 --- /dev/null +++ b/src/FluentCMS.Sdk/Events/EntryEvent.cs @@ -0,0 +1,81 @@ +namespace FluentCMS.Events; + +/// An event about one locale of one entry. +/// The name of the content type. +/// The id of the entry. +/// The locale the event is about. +/// The version the event is about. +public abstract record EntryEvent(string Type, Guid EntryId, string Locale, int Version) : CmsEvent; + +/// A draft was created or changed. +/// The name of the content type. +/// The id of the entry. +/// The locale of the draft. +/// The version the draft reached. +public sealed record EntryDraftSavedEvent(string Type, Guid EntryId, string Locale, int Version) : EntryEvent(Type, EntryId, Locale, Version); + +/// A draft was published. +/// The name of the content type. +/// The id of the entry. +/// The locale that was published. +/// The version that went live. +public sealed record EntryPublishedEvent(string Type, Guid EntryId, string Locale, int Version) : EntryEvent(Type, EntryId, Locale, Version); + +/// A published entry was taken offline. +/// The name of the content type. +/// The id of the entry. +/// The locale that was unpublished. +/// The version that was live. +public sealed record EntryUnpublishedEvent(string Type, Guid EntryId, string Locale, int Version) : EntryEvent(Type, EntryId, Locale, Version); + +/// A draft was thrown away in favor of the published version. +/// The name of the content type. +/// The id of the entry. +/// The locale whose draft was discarded. +/// The version the draft went back to. +public sealed record EntryDraftDiscardedEvent(string Type, Guid EntryId, string Locale, int Version) : EntryEvent(Type, EntryId, Locale, Version); + +/// An older version became the draft again. +/// The name of the content type. +/// The id of the entry. +/// The locale that was restored. +/// The new version, which holds the content of an older one. +public sealed record EntryVersionRestoredEvent(string Type, Guid EntryId, string Locale, int Version) : EntryEvent(Type, EntryId, Locale, Version); + +/// An entry was moved to the trash. +/// The name of the content type. +/// The id of the entry. +/// The locale that was trashed. +/// The version of the draft when it was trashed. +public sealed record EntryTrashedEvent(string Type, Guid EntryId, string Locale, int Version) : EntryEvent(Type, EntryId, Locale, Version); + +/// An entry came back from the trash. +/// The name of the content type. +/// The id of the entry. +/// The locale that was restored. +/// The version of the draft. +public sealed record EntryRestoredEvent(string Type, Guid EntryId, string Locale, int Version) : EntryEvent(Type, EntryId, Locale, Version); + +/// An entry was deleted for good. +/// The name of the content type. +/// The id of the entry. +/// The locale that was purged. +/// The last version it had. +public sealed record EntryPurgedEvent(string Type, Guid EntryId, string Locale, int Version) : EntryEvent(Type, EntryId, Locale, Version); + +/// A publish or an unpublish was scheduled. +/// The name of the content type. +/// The id of the entry. +/// The locale that was scheduled. +/// The version the schedule is for. +/// When it is due. +/// publish or unpublish. +public sealed record EntryScheduledEvent(string Type, Guid EntryId, string Locale, int Version, DateTimeOffset At, string Action) : EntryEvent(Type, EntryId, Locale, Version); + +/// A schedule was cancelled before it came due. +/// The name of the content type. +/// The id of the entry. +/// The locale whose schedule was cancelled. +/// The version the schedule was for. +/// publish or unpublish. +public sealed record EntryScheduleCancelledEvent(string Type, Guid EntryId, string Locale, int Version, string Action) : EntryEvent(Type, EntryId, Locale, Version); diff --git a/src/FluentCMS.Sdk/Events/EventDelivery.cs b/src/FluentCMS.Sdk/Events/EventDelivery.cs new file mode 100644 index 000000000..6ade9d3c7 --- /dev/null +++ b/src/FluentCMS.Sdk/Events/EventDelivery.cs @@ -0,0 +1,6 @@ +namespace FluentCMS.Events; + +/// One delivery of an event to an outbox handler. +/// The idempotency key: the same for every delivery of this event to this handler, and different for another event or handler. +/// The number of this delivery, starting at one. +public sealed record EventDelivery(Guid Key, int Attempt); diff --git a/src/FluentCMS.Sdk/Events/EventSource.cs b/src/FluentCMS.Sdk/Events/EventSource.cs new file mode 100644 index 000000000..83628c442 --- /dev/null +++ b/src/FluentCMS.Sdk/Events/EventSource.cs @@ -0,0 +1,31 @@ +using FluentCMS.Content; + +namespace FluentCMS.Events; + +/// Where and when an event happened: the operation that raised it. +/// When the operation began. +/// The site of the operation. +/// The id of the principal to audit, which is the actor a job impersonates or else the principal; empty for the system. +/// The name of that principal. +/// The kind of that principal. +/// Where the operation came from. +/// The correlation id of the operation. +public sealed record EventSource(DateTimeOffset OccurredAt, Guid SiteId, Guid ActorId, string ActorName, PrincipalKind ActorKind, Origin Origin, string CorrelationId) +{ + /// Describes an event raised by an operation. + /// The operation. + /// The source. + public static EventSource From(OperationContext operation) + { + ArgumentNullException.ThrowIfNull(operation); + + var actor = operation.AuditPrincipal; + return new EventSource(operation.Timestamp, operation.Site.Value, actor.Id, actor.Name, actor.Kind, operation.Origin, operation.CorrelationId); + } + + /// Describes an event the system raised outside any request, such as a job's failure. + /// When it happened. + /// The source. + public static EventSource FromSystem(DateTimeOffset occurredAt) => + new(occurredAt, Guid.Empty, Principal.System.Id, Principal.System.Name, PrincipalKind.System, Origin.Job, ""); +} diff --git a/src/FluentCMS.Sdk/Events/IEventBus.cs b/src/FluentCMS.Sdk/Events/IEventBus.cs new file mode 100644 index 000000000..feb85975e --- /dev/null +++ b/src/FluentCMS.Sdk/Events/IEventBus.cs @@ -0,0 +1,18 @@ +namespace FluentCMS.Events; + +/// +/// Raises events. Inside a transaction of the content service an event is held back until the outermost commit and +/// never delivered if that rolls back; elsewhere it is delivered at once. A handler that throws is logged and does not +/// affect the caller. +/// +public interface IEventBus +{ + /// Raises an event for the handlers of its exact type. + /// The type of the event. + /// The event. + /// Cancels recording the event for the outbox handlers; handlers that run in this process are not cancelled by it. + /// A task that completes when the event is delivered, or held back. + /// The caller is inside a transaction that the content service does not own, whose end cannot be seen. + ValueTask PublishAsync(TEvent message, CancellationToken cancellationToken = default) + where TEvent : CmsEvent; +} diff --git a/src/FluentCMS.Sdk/Events/IEventDeadLetters.cs b/src/FluentCMS.Sdk/Events/IEventDeadLetters.cs new file mode 100644 index 000000000..881190da7 --- /dev/null +++ b/src/FluentCMS.Sdk/Events/IEventDeadLetters.cs @@ -0,0 +1,34 @@ +namespace FluentCMS.Events; + +/// A delivery to an outbox handler that ran out of attempts. The event is left out, because it can hold secrets. +/// The id of the delivery, which is also its idempotency key. +/// The id of the event. +/// The full name of the event type. +/// The name the handler is registered under. +/// The deliveries it has had. +/// The error of the last one, with URL query strings and credentials removed. +/// When the event happened. +public sealed record FailedEventDelivery(Guid Id, Guid EventId, string EventType, string Handler, int Attempts, string? LastError, DateTimeOffset OccurredAt); + +/// Lists the outbox deliveries that ran out of attempts, gives them another chance, or removes them. +public interface IEventDeadLetters +{ + /// Lists failed deliveries, the most recent first. + /// The number of deliveries to skip. + /// The most deliveries to return. + /// Cancels the read. + /// The deliveries. + Task> ListAsync(int offset = 0, int limit = 50, CancellationToken cancellationToken = default); + + /// Queues a failed delivery to run again now, with a fresh set of attempts. + /// The id of the delivery. + /// Cancels the write. + /// Whether a failed delivery with this id was queued. + Task RetryAsync(Guid id, CancellationToken cancellationToken = default); + + /// Deletes a failed delivery for good. + /// The id of the delivery. + /// Cancels the write. + /// Whether a failed delivery with this id was removed. + Task RemoveAsync(Guid id, CancellationToken cancellationToken = default); +} diff --git a/src/FluentCMS.Sdk/Events/IEventHandler.cs b/src/FluentCMS.Sdk/Events/IEventHandler.cs new file mode 100644 index 000000000..5c6f1e2bc --- /dev/null +++ b/src/FluentCMS.Sdk/Events/IEventHandler.cs @@ -0,0 +1,20 @@ +using System.Diagnostics.CodeAnalysis; + +namespace FluentCMS.Events; + +/// +/// Hears events of one type in this process, after the commit of the write that raised them. A handler can be lost +/// to a crash; one that must not be lost implements instead. Register it in the service collection, +/// or with cms.Events.AddHandler. Handlers run in order. +/// +/// The type of the event, which is matched exactly. +[SuppressMessage("Naming", "CA1711:Identifiers should not have incorrect suffix", Justification = "The specification names the contract IEventHandler.")] +public interface IEventHandler + where TEvent : CmsEvent +{ + /// Handles the event. A handler that throws is logged and changes nothing about the write or the other handlers. + /// The event. + /// Cancelled when the host is stopping, never when the request that raised the event ends. + /// A task that completes when the handler is done. + ValueTask HandleAsync(TEvent message, CancellationToken cancellationToken); +} diff --git a/src/FluentCMS.Sdk/Events/IOutboxEventHandler.cs b/src/FluentCMS.Sdk/Events/IOutboxEventHandler.cs new file mode 100644 index 000000000..6f4d6476d --- /dev/null +++ b/src/FluentCMS.Sdk/Events/IOutboxEventHandler.cs @@ -0,0 +1,22 @@ +using System.Diagnostics.CodeAnalysis; + +namespace FluentCMS.Events; + +/// +/// Hears events of one type and never misses one. The event is recorded in the transaction that raised it and a job +/// delivers it, so a handler runs after a crash too, at least once and possibly more than once: use +/// to ignore a repeat. Register it with cms.Events.AddOutboxHandler. +/// Deliveries start in order and in the order the events were recorded, but a failed delivery is retried later and instances deliver at the same time, so do not depend on order. +/// +/// The type of the event, which is matched exactly. +[SuppressMessage("Naming", "CA1711:Identifiers should not have incorrect suffix", Justification = "It is the outbox counterpart of IEventHandler.")] +public interface IOutboxEventHandler + where TEvent : CmsEvent +{ + /// Handles the event. A handler that throws is called again later with backoff, until it is out of attempts. + /// The event. + /// Which delivery this is. + /// Cancelled when the host is stopping and when the delivery has run out of time; a delivery that outlasts the time limit is abandoned and counted as failed. + /// A task that completes when the handler is done. + ValueTask HandleAsync(TEvent message, EventDelivery delivery, CancellationToken cancellationToken); +} diff --git a/src/FluentCMS.Sdk/Events/SystemEvents.cs b/src/FluentCMS.Sdk/Events/SystemEvents.cs new file mode 100644 index 000000000..85b7a972a --- /dev/null +++ b/src/FluentCMS.Sdk/Events/SystemEvents.cs @@ -0,0 +1,36 @@ +namespace FluentCMS.Events; + +/// A settings area was changed. +/// The name of the settings area. +public sealed record SettingsChangedEvent(string Area) : CmsEvent; + +/// A site was created. +/// The id of the site. +/// Its slug. +public sealed record SiteCreatedEvent(Guid SiteId, string Slug) : CmsEvent; + +/// A site, or its locales, was changed. +/// The id of the site. +/// Its slug. +public sealed record SiteUpdatedEvent(Guid SiteId, string Slug) : CmsEvent; + +/// Cached output was evicted, for a CDN to purge too. +/// The cache tags that were evicted. +public sealed record CacheInvalidatedEvent(IReadOnlyList Tags) : CmsEvent; + +/// An attempt of a job failed. It may be retried. +/// The id of the job. +/// The name the job is registered under. +/// The queue. +/// The attempt that failed. +/// The attempts the job may have. +/// What went wrong, without the payload. +public sealed record JobFailedEvent(Guid JobId, string Type, string Queue, int Attempt, int MaxAttempts, string Error) : CmsEvent; + +/// A job is out of attempts and will not run again unless someone retries it. +/// The id of the job. +/// The name the job is registered under. +/// The queue. +/// The attempts it had. +/// What went wrong in the last one, without the payload. +public sealed record JobDeadLetteredEvent(Guid JobId, string Type, string Queue, int Attempts, string Error) : CmsEvent; diff --git a/src/FluentCMS.Sdk/PublicAPI.Unshipped.txt b/src/FluentCMS.Sdk/PublicAPI.Unshipped.txt index 16ec49387..46e016f75 100644 --- a/src/FluentCMS.Sdk/PublicAPI.Unshipped.txt +++ b/src/FluentCMS.Sdk/PublicAPI.Unshipped.txt @@ -266,6 +266,186 @@ FluentCMS.Content.VersionConflictException.Current.get -> FluentCMS.Content.Cont FluentCMS.Content.VersionConflictException.CurrentVersion.get -> int FluentCMS.Content.VersionConflictException.ExpectedVersion.get -> int FluentCMS.Content.VersionConflictException.VersionConflictException(int expectedVersion, int currentVersion, FluentCMS.Content.ContentDocument? current) -> void +FluentCMS.Events.CacheInvalidatedEvent +FluentCMS.Events.CacheInvalidatedEvent.CacheInvalidatedEvent(System.Collections.Generic.IReadOnlyList! Tags) -> void +FluentCMS.Events.CacheInvalidatedEvent.Deconstruct(out System.Collections.Generic.IReadOnlyList! Tags) -> void +FluentCMS.Events.CacheInvalidatedEvent.Equals(FluentCMS.Events.CacheInvalidatedEvent? other) -> bool +FluentCMS.Events.CacheInvalidatedEvent.Tags.get -> System.Collections.Generic.IReadOnlyList! +FluentCMS.Events.CacheInvalidatedEvent.Tags.init -> void +FluentCMS.Events.CmsEvent +FluentCMS.Events.CmsEvent.CmsEvent() -> void +FluentCMS.Events.CmsEvent.CmsEvent(FluentCMS.Events.CmsEvent! original) -> void +FluentCMS.Events.CmsEvent.EventId.get -> System.Guid +FluentCMS.Events.CmsEvent.EventId.init -> void +FluentCMS.Events.CmsEvent.Source.get -> FluentCMS.Events.EventSource! +FluentCMS.Events.CmsEvent.Source.init -> void +FluentCMS.Events.EntryDraftDiscardedEvent +FluentCMS.Events.EntryDraftDiscardedEvent.Deconstruct(out string! Type, out System.Guid EntryId, out string! Locale, out int Version) -> void +FluentCMS.Events.EntryDraftDiscardedEvent.EntryDraftDiscardedEvent(string! Type, System.Guid EntryId, string! Locale, int Version) -> void +FluentCMS.Events.EntryDraftDiscardedEvent.Equals(FluentCMS.Events.EntryDraftDiscardedEvent? other) -> bool +FluentCMS.Events.EntryDraftSavedEvent +FluentCMS.Events.EntryDraftSavedEvent.Deconstruct(out string! Type, out System.Guid EntryId, out string! Locale, out int Version) -> void +FluentCMS.Events.EntryDraftSavedEvent.EntryDraftSavedEvent(string! Type, System.Guid EntryId, string! Locale, int Version) -> void +FluentCMS.Events.EntryDraftSavedEvent.Equals(FluentCMS.Events.EntryDraftSavedEvent? other) -> bool +FluentCMS.Events.EntryEvent +FluentCMS.Events.EntryEvent.Deconstruct(out string! Type, out System.Guid EntryId, out string! Locale, out int Version) -> void +FluentCMS.Events.EntryEvent.EntryEvent(FluentCMS.Events.EntryEvent! original) -> void +FluentCMS.Events.EntryEvent.EntryEvent(string! Type, System.Guid EntryId, string! Locale, int Version) -> void +FluentCMS.Events.EntryEvent.EntryId.get -> System.Guid +FluentCMS.Events.EntryEvent.EntryId.init -> void +FluentCMS.Events.EntryEvent.Locale.get -> string! +FluentCMS.Events.EntryEvent.Locale.init -> void +FluentCMS.Events.EntryEvent.Type.get -> string! +FluentCMS.Events.EntryEvent.Type.init -> void +FluentCMS.Events.EntryEvent.Version.get -> int +FluentCMS.Events.EntryEvent.Version.init -> void +FluentCMS.Events.EntryPublishedEvent +FluentCMS.Events.EntryPublishedEvent.Deconstruct(out string! Type, out System.Guid EntryId, out string! Locale, out int Version) -> void +FluentCMS.Events.EntryPublishedEvent.EntryPublishedEvent(string! Type, System.Guid EntryId, string! Locale, int Version) -> void +FluentCMS.Events.EntryPublishedEvent.Equals(FluentCMS.Events.EntryPublishedEvent? other) -> bool +FluentCMS.Events.EntryPurgedEvent +FluentCMS.Events.EntryPurgedEvent.Deconstruct(out string! Type, out System.Guid EntryId, out string! Locale, out int Version) -> void +FluentCMS.Events.EntryPurgedEvent.EntryPurgedEvent(string! Type, System.Guid EntryId, string! Locale, int Version) -> void +FluentCMS.Events.EntryPurgedEvent.Equals(FluentCMS.Events.EntryPurgedEvent? other) -> bool +FluentCMS.Events.EntryRestoredEvent +FluentCMS.Events.EntryRestoredEvent.Deconstruct(out string! Type, out System.Guid EntryId, out string! Locale, out int Version) -> void +FluentCMS.Events.EntryRestoredEvent.EntryRestoredEvent(string! Type, System.Guid EntryId, string! Locale, int Version) -> void +FluentCMS.Events.EntryRestoredEvent.Equals(FluentCMS.Events.EntryRestoredEvent? other) -> bool +FluentCMS.Events.EntryScheduleCancelledEvent +FluentCMS.Events.EntryScheduleCancelledEvent.Action.get -> string! +FluentCMS.Events.EntryScheduleCancelledEvent.Action.init -> void +FluentCMS.Events.EntryScheduleCancelledEvent.Deconstruct(out string! Type, out System.Guid EntryId, out string! Locale, out int Version, out string! Action) -> void +FluentCMS.Events.EntryScheduleCancelledEvent.EntryScheduleCancelledEvent(string! Type, System.Guid EntryId, string! Locale, int Version, string! Action) -> void +FluentCMS.Events.EntryScheduleCancelledEvent.Equals(FluentCMS.Events.EntryScheduleCancelledEvent? other) -> bool +FluentCMS.Events.EntryScheduledEvent +FluentCMS.Events.EntryScheduledEvent.Action.get -> string! +FluentCMS.Events.EntryScheduledEvent.Action.init -> void +FluentCMS.Events.EntryScheduledEvent.At.get -> System.DateTimeOffset +FluentCMS.Events.EntryScheduledEvent.At.init -> void +FluentCMS.Events.EntryScheduledEvent.Deconstruct(out string! Type, out System.Guid EntryId, out string! Locale, out int Version, out System.DateTimeOffset At, out string! Action) -> void +FluentCMS.Events.EntryScheduledEvent.EntryScheduledEvent(string! Type, System.Guid EntryId, string! Locale, int Version, System.DateTimeOffset At, string! Action) -> void +FluentCMS.Events.EntryScheduledEvent.Equals(FluentCMS.Events.EntryScheduledEvent? other) -> bool +FluentCMS.Events.EntryTrashedEvent +FluentCMS.Events.EntryTrashedEvent.Deconstruct(out string! Type, out System.Guid EntryId, out string! Locale, out int Version) -> void +FluentCMS.Events.EntryTrashedEvent.EntryTrashedEvent(string! Type, System.Guid EntryId, string! Locale, int Version) -> void +FluentCMS.Events.EntryTrashedEvent.Equals(FluentCMS.Events.EntryTrashedEvent? other) -> bool +FluentCMS.Events.EntryUnpublishedEvent +FluentCMS.Events.EntryUnpublishedEvent.Deconstruct(out string! Type, out System.Guid EntryId, out string! Locale, out int Version) -> void +FluentCMS.Events.EntryUnpublishedEvent.EntryUnpublishedEvent(string! Type, System.Guid EntryId, string! Locale, int Version) -> void +FluentCMS.Events.EntryUnpublishedEvent.Equals(FluentCMS.Events.EntryUnpublishedEvent? other) -> bool +FluentCMS.Events.EntryVersionRestoredEvent +FluentCMS.Events.EntryVersionRestoredEvent.Deconstruct(out string! Type, out System.Guid EntryId, out string! Locale, out int Version) -> void +FluentCMS.Events.EntryVersionRestoredEvent.EntryVersionRestoredEvent(string! Type, System.Guid EntryId, string! Locale, int Version) -> void +FluentCMS.Events.EntryVersionRestoredEvent.Equals(FluentCMS.Events.EntryVersionRestoredEvent? other) -> bool +FluentCMS.Events.EventDelivery +FluentCMS.Events.EventDelivery.$() -> FluentCMS.Events.EventDelivery! +FluentCMS.Events.EventDelivery.Attempt.get -> int +FluentCMS.Events.EventDelivery.Attempt.init -> void +FluentCMS.Events.EventDelivery.Deconstruct(out System.Guid Key, out int Attempt) -> void +FluentCMS.Events.EventDelivery.Equals(FluentCMS.Events.EventDelivery? other) -> bool +FluentCMS.Events.EventDelivery.EventDelivery(System.Guid Key, int Attempt) -> void +FluentCMS.Events.EventDelivery.Key.get -> System.Guid +FluentCMS.Events.EventDelivery.Key.init -> void +FluentCMS.Events.EventSource +FluentCMS.Events.EventSource.$() -> FluentCMS.Events.EventSource! +FluentCMS.Events.EventSource.ActorId.get -> System.Guid +FluentCMS.Events.EventSource.ActorId.init -> void +FluentCMS.Events.EventSource.ActorKind.get -> FluentCMS.Content.PrincipalKind +FluentCMS.Events.EventSource.ActorKind.init -> void +FluentCMS.Events.EventSource.ActorName.get -> string! +FluentCMS.Events.EventSource.ActorName.init -> void +FluentCMS.Events.EventSource.CorrelationId.get -> string! +FluentCMS.Events.EventSource.CorrelationId.init -> void +FluentCMS.Events.EventSource.Deconstruct(out System.DateTimeOffset OccurredAt, out System.Guid SiteId, out System.Guid ActorId, out string! ActorName, out FluentCMS.Content.PrincipalKind ActorKind, out FluentCMS.Content.Origin Origin, out string! CorrelationId) -> void +FluentCMS.Events.EventSource.Equals(FluentCMS.Events.EventSource? other) -> bool +FluentCMS.Events.EventSource.EventSource(System.DateTimeOffset OccurredAt, System.Guid SiteId, System.Guid ActorId, string! ActorName, FluentCMS.Content.PrincipalKind ActorKind, FluentCMS.Content.Origin Origin, string! CorrelationId) -> void +FluentCMS.Events.EventSource.OccurredAt.get -> System.DateTimeOffset +FluentCMS.Events.EventSource.OccurredAt.init -> void +FluentCMS.Events.EventSource.Origin.get -> FluentCMS.Content.Origin +FluentCMS.Events.EventSource.Origin.init -> void +FluentCMS.Events.EventSource.SiteId.get -> System.Guid +FluentCMS.Events.EventSource.SiteId.init -> void +FluentCMS.Events.FailedEventDelivery +FluentCMS.Events.FailedEventDelivery.$() -> FluentCMS.Events.FailedEventDelivery! +FluentCMS.Events.FailedEventDelivery.Attempts.get -> int +FluentCMS.Events.FailedEventDelivery.Attempts.init -> void +FluentCMS.Events.FailedEventDelivery.Deconstruct(out System.Guid Id, out System.Guid EventId, out string! EventType, out string! Handler, out int Attempts, out string? LastError, out System.DateTimeOffset OccurredAt) -> void +FluentCMS.Events.FailedEventDelivery.Equals(FluentCMS.Events.FailedEventDelivery? other) -> bool +FluentCMS.Events.FailedEventDelivery.EventId.get -> System.Guid +FluentCMS.Events.FailedEventDelivery.EventId.init -> void +FluentCMS.Events.FailedEventDelivery.EventType.get -> string! +FluentCMS.Events.FailedEventDelivery.EventType.init -> void +FluentCMS.Events.FailedEventDelivery.FailedEventDelivery(System.Guid Id, System.Guid EventId, string! EventType, string! Handler, int Attempts, string? LastError, System.DateTimeOffset OccurredAt) -> void +FluentCMS.Events.FailedEventDelivery.Handler.get -> string! +FluentCMS.Events.FailedEventDelivery.Handler.init -> void +FluentCMS.Events.FailedEventDelivery.Id.get -> System.Guid +FluentCMS.Events.FailedEventDelivery.Id.init -> void +FluentCMS.Events.FailedEventDelivery.LastError.get -> string? +FluentCMS.Events.FailedEventDelivery.LastError.init -> void +FluentCMS.Events.FailedEventDelivery.OccurredAt.get -> System.DateTimeOffset +FluentCMS.Events.FailedEventDelivery.OccurredAt.init -> void +FluentCMS.Events.IEventBus +FluentCMS.Events.IEventBus.PublishAsync(TEvent! message, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +FluentCMS.Events.IEventDeadLetters +FluentCMS.Events.IEventDeadLetters.ListAsync(int offset = 0, int limit = 50, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +FluentCMS.Events.IEventDeadLetters.RemoveAsync(System.Guid id, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +FluentCMS.Events.IEventDeadLetters.RetryAsync(System.Guid id, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +FluentCMS.Events.IEventHandler +FluentCMS.Events.IEventHandler.HandleAsync(TEvent! message, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +FluentCMS.Events.IOutboxEventHandler +FluentCMS.Events.IOutboxEventHandler.HandleAsync(TEvent! message, FluentCMS.Events.EventDelivery! delivery, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +FluentCMS.Events.JobDeadLetteredEvent +FluentCMS.Events.JobDeadLetteredEvent.Attempts.get -> int +FluentCMS.Events.JobDeadLetteredEvent.Attempts.init -> void +FluentCMS.Events.JobDeadLetteredEvent.Deconstruct(out System.Guid JobId, out string! Type, out string! Queue, out int Attempts, out string! Error) -> void +FluentCMS.Events.JobDeadLetteredEvent.Equals(FluentCMS.Events.JobDeadLetteredEvent? other) -> bool +FluentCMS.Events.JobDeadLetteredEvent.Error.get -> string! +FluentCMS.Events.JobDeadLetteredEvent.Error.init -> void +FluentCMS.Events.JobDeadLetteredEvent.JobDeadLetteredEvent(System.Guid JobId, string! Type, string! Queue, int Attempts, string! Error) -> void +FluentCMS.Events.JobDeadLetteredEvent.JobId.get -> System.Guid +FluentCMS.Events.JobDeadLetteredEvent.JobId.init -> void +FluentCMS.Events.JobDeadLetteredEvent.Queue.get -> string! +FluentCMS.Events.JobDeadLetteredEvent.Queue.init -> void +FluentCMS.Events.JobDeadLetteredEvent.Type.get -> string! +FluentCMS.Events.JobDeadLetteredEvent.Type.init -> void +FluentCMS.Events.JobFailedEvent +FluentCMS.Events.JobFailedEvent.Attempt.get -> int +FluentCMS.Events.JobFailedEvent.Attempt.init -> void +FluentCMS.Events.JobFailedEvent.Deconstruct(out System.Guid JobId, out string! Type, out string! Queue, out int Attempt, out int MaxAttempts, out string! Error) -> void +FluentCMS.Events.JobFailedEvent.Equals(FluentCMS.Events.JobFailedEvent? other) -> bool +FluentCMS.Events.JobFailedEvent.Error.get -> string! +FluentCMS.Events.JobFailedEvent.Error.init -> void +FluentCMS.Events.JobFailedEvent.JobFailedEvent(System.Guid JobId, string! Type, string! Queue, int Attempt, int MaxAttempts, string! Error) -> void +FluentCMS.Events.JobFailedEvent.JobId.get -> System.Guid +FluentCMS.Events.JobFailedEvent.JobId.init -> void +FluentCMS.Events.JobFailedEvent.MaxAttempts.get -> int +FluentCMS.Events.JobFailedEvent.MaxAttempts.init -> void +FluentCMS.Events.JobFailedEvent.Queue.get -> string! +FluentCMS.Events.JobFailedEvent.Queue.init -> void +FluentCMS.Events.JobFailedEvent.Type.get -> string! +FluentCMS.Events.JobFailedEvent.Type.init -> void +FluentCMS.Events.SettingsChangedEvent +FluentCMS.Events.SettingsChangedEvent.Area.get -> string! +FluentCMS.Events.SettingsChangedEvent.Area.init -> void +FluentCMS.Events.SettingsChangedEvent.Deconstruct(out string! Area) -> void +FluentCMS.Events.SettingsChangedEvent.Equals(FluentCMS.Events.SettingsChangedEvent? other) -> bool +FluentCMS.Events.SettingsChangedEvent.SettingsChangedEvent(string! Area) -> void +FluentCMS.Events.SiteCreatedEvent +FluentCMS.Events.SiteCreatedEvent.Deconstruct(out System.Guid SiteId, out string! Slug) -> void +FluentCMS.Events.SiteCreatedEvent.Equals(FluentCMS.Events.SiteCreatedEvent? other) -> bool +FluentCMS.Events.SiteCreatedEvent.SiteCreatedEvent(System.Guid SiteId, string! Slug) -> void +FluentCMS.Events.SiteCreatedEvent.SiteId.get -> System.Guid +FluentCMS.Events.SiteCreatedEvent.SiteId.init -> void +FluentCMS.Events.SiteCreatedEvent.Slug.get -> string! +FluentCMS.Events.SiteCreatedEvent.Slug.init -> void +FluentCMS.Events.SiteUpdatedEvent +FluentCMS.Events.SiteUpdatedEvent.Deconstruct(out System.Guid SiteId, out string! Slug) -> void +FluentCMS.Events.SiteUpdatedEvent.Equals(FluentCMS.Events.SiteUpdatedEvent? other) -> bool +FluentCMS.Events.SiteUpdatedEvent.SiteId.get -> System.Guid +FluentCMS.Events.SiteUpdatedEvent.SiteId.init -> void +FluentCMS.Events.SiteUpdatedEvent.SiteUpdatedEvent(System.Guid SiteId, string! Slug) -> void +FluentCMS.Events.SiteUpdatedEvent.Slug.get -> string! +FluentCMS.Events.SiteUpdatedEvent.Slug.init -> void FluentCMS.Hooks.HookContext FluentCMS.Hooks.HookContext.Cancel(string! code, string! message) -> void FluentCMS.Hooks.HookContext.CancelCode.get -> string? @@ -776,6 +956,7 @@ FluentCMS.Sites.SiteRequest.Parameter.get -> string? FluentCMS.Sites.SiteRequest.Parameter.init -> void FluentCMS.Sites.SiteRequest.SiteRequest() -> void abstract FluentCMS.Content.ContentFilter.$() -> FluentCMS.Content.ContentFilter! +abstract FluentCMS.Events.CmsEvent.$() -> FluentCMS.Events.CmsEvent! const FluentCMS.Authorization.Permission.ContentPrefix = "content" -> string! const FluentCMS.Content.ValidationErrorCodes.AboveMaximum = "FCMS2105" -> string! const FluentCMS.Content.ValidationErrorCodes.BelowMinimum = "FCMS2104" -> string! @@ -838,6 +1019,87 @@ override FluentCMS.Content.SortField.ToString() -> string! override FluentCMS.Content.ValidationError.Equals(object? obj) -> bool override FluentCMS.Content.ValidationError.GetHashCode() -> int override FluentCMS.Content.ValidationError.ToString() -> string! +override FluentCMS.Events.CacheInvalidatedEvent.$() -> FluentCMS.Events.CacheInvalidatedEvent! +override FluentCMS.Events.CacheInvalidatedEvent.Equals(object? obj) -> bool +override FluentCMS.Events.CacheInvalidatedEvent.GetHashCode() -> int +override FluentCMS.Events.CacheInvalidatedEvent.ToString() -> string! +override FluentCMS.Events.CmsEvent.Equals(object? obj) -> bool +override FluentCMS.Events.CmsEvent.GetHashCode() -> int +override FluentCMS.Events.CmsEvent.ToString() -> string! +override FluentCMS.Events.EntryDraftDiscardedEvent.$() -> FluentCMS.Events.EntryDraftDiscardedEvent! +override FluentCMS.Events.EntryDraftDiscardedEvent.Equals(object? obj) -> bool +override FluentCMS.Events.EntryDraftDiscardedEvent.GetHashCode() -> int +override FluentCMS.Events.EntryDraftDiscardedEvent.ToString() -> string! +override FluentCMS.Events.EntryDraftSavedEvent.$() -> FluentCMS.Events.EntryDraftSavedEvent! +override FluentCMS.Events.EntryDraftSavedEvent.Equals(object? obj) -> bool +override FluentCMS.Events.EntryDraftSavedEvent.GetHashCode() -> int +override FluentCMS.Events.EntryDraftSavedEvent.ToString() -> string! +override FluentCMS.Events.EntryEvent.EqualityContract.get -> System.Type! +override FluentCMS.Events.EntryEvent.Equals(object? obj) -> bool +override FluentCMS.Events.EntryEvent.GetHashCode() -> int +override FluentCMS.Events.EntryEvent.PrintMembers(System.Text.StringBuilder! builder) -> bool +override FluentCMS.Events.EntryEvent.ToString() -> string! +override FluentCMS.Events.EntryPublishedEvent.$() -> FluentCMS.Events.EntryPublishedEvent! +override FluentCMS.Events.EntryPublishedEvent.Equals(object? obj) -> bool +override FluentCMS.Events.EntryPublishedEvent.GetHashCode() -> int +override FluentCMS.Events.EntryPublishedEvent.ToString() -> string! +override FluentCMS.Events.EntryPurgedEvent.$() -> FluentCMS.Events.EntryPurgedEvent! +override FluentCMS.Events.EntryPurgedEvent.Equals(object? obj) -> bool +override FluentCMS.Events.EntryPurgedEvent.GetHashCode() -> int +override FluentCMS.Events.EntryPurgedEvent.ToString() -> string! +override FluentCMS.Events.EntryRestoredEvent.$() -> FluentCMS.Events.EntryRestoredEvent! +override FluentCMS.Events.EntryRestoredEvent.Equals(object? obj) -> bool +override FluentCMS.Events.EntryRestoredEvent.GetHashCode() -> int +override FluentCMS.Events.EntryRestoredEvent.ToString() -> string! +override FluentCMS.Events.EntryScheduleCancelledEvent.$() -> FluentCMS.Events.EntryScheduleCancelledEvent! +override FluentCMS.Events.EntryScheduleCancelledEvent.Equals(object? obj) -> bool +override FluentCMS.Events.EntryScheduleCancelledEvent.GetHashCode() -> int +override FluentCMS.Events.EntryScheduleCancelledEvent.ToString() -> string! +override FluentCMS.Events.EntryScheduledEvent.$() -> FluentCMS.Events.EntryScheduledEvent! +override FluentCMS.Events.EntryScheduledEvent.Equals(object? obj) -> bool +override FluentCMS.Events.EntryScheduledEvent.GetHashCode() -> int +override FluentCMS.Events.EntryScheduledEvent.ToString() -> string! +override FluentCMS.Events.EntryTrashedEvent.$() -> FluentCMS.Events.EntryTrashedEvent! +override FluentCMS.Events.EntryTrashedEvent.Equals(object? obj) -> bool +override FluentCMS.Events.EntryTrashedEvent.GetHashCode() -> int +override FluentCMS.Events.EntryTrashedEvent.ToString() -> string! +override FluentCMS.Events.EntryUnpublishedEvent.$() -> FluentCMS.Events.EntryUnpublishedEvent! +override FluentCMS.Events.EntryUnpublishedEvent.Equals(object? obj) -> bool +override FluentCMS.Events.EntryUnpublishedEvent.GetHashCode() -> int +override FluentCMS.Events.EntryUnpublishedEvent.ToString() -> string! +override FluentCMS.Events.EntryVersionRestoredEvent.$() -> FluentCMS.Events.EntryVersionRestoredEvent! +override FluentCMS.Events.EntryVersionRestoredEvent.Equals(object? obj) -> bool +override FluentCMS.Events.EntryVersionRestoredEvent.GetHashCode() -> int +override FluentCMS.Events.EntryVersionRestoredEvent.ToString() -> string! +override FluentCMS.Events.EventDelivery.Equals(object? obj) -> bool +override FluentCMS.Events.EventDelivery.GetHashCode() -> int +override FluentCMS.Events.EventDelivery.ToString() -> string! +override FluentCMS.Events.EventSource.Equals(object? obj) -> bool +override FluentCMS.Events.EventSource.GetHashCode() -> int +override FluentCMS.Events.EventSource.ToString() -> string! +override FluentCMS.Events.FailedEventDelivery.Equals(object? obj) -> bool +override FluentCMS.Events.FailedEventDelivery.GetHashCode() -> int +override FluentCMS.Events.FailedEventDelivery.ToString() -> string! +override FluentCMS.Events.JobDeadLetteredEvent.$() -> FluentCMS.Events.JobDeadLetteredEvent! +override FluentCMS.Events.JobDeadLetteredEvent.Equals(object? obj) -> bool +override FluentCMS.Events.JobDeadLetteredEvent.GetHashCode() -> int +override FluentCMS.Events.JobDeadLetteredEvent.ToString() -> string! +override FluentCMS.Events.JobFailedEvent.$() -> FluentCMS.Events.JobFailedEvent! +override FluentCMS.Events.JobFailedEvent.Equals(object? obj) -> bool +override FluentCMS.Events.JobFailedEvent.GetHashCode() -> int +override FluentCMS.Events.JobFailedEvent.ToString() -> string! +override FluentCMS.Events.SettingsChangedEvent.$() -> FluentCMS.Events.SettingsChangedEvent! +override FluentCMS.Events.SettingsChangedEvent.Equals(object? obj) -> bool +override FluentCMS.Events.SettingsChangedEvent.GetHashCode() -> int +override FluentCMS.Events.SettingsChangedEvent.ToString() -> string! +override FluentCMS.Events.SiteCreatedEvent.$() -> FluentCMS.Events.SiteCreatedEvent! +override FluentCMS.Events.SiteCreatedEvent.Equals(object? obj) -> bool +override FluentCMS.Events.SiteCreatedEvent.GetHashCode() -> int +override FluentCMS.Events.SiteCreatedEvent.ToString() -> string! +override FluentCMS.Events.SiteUpdatedEvent.$() -> FluentCMS.Events.SiteUpdatedEvent! +override FluentCMS.Events.SiteUpdatedEvent.Equals(object? obj) -> bool +override FluentCMS.Events.SiteUpdatedEvent.GetHashCode() -> int +override FluentCMS.Events.SiteUpdatedEvent.ToString() -> string! override FluentCMS.Jobs.DeadLetteredJob.Equals(object? obj) -> bool override FluentCMS.Jobs.DeadLetteredJob.GetHashCode() -> int override FluentCMS.Jobs.DeadLetteredJob.ToString() -> string! @@ -875,10 +1137,28 @@ override FluentCMS.Sites.SiteId.ToString() -> string! override FluentCMS.Sites.SiteRequest.Equals(object? obj) -> bool override FluentCMS.Sites.SiteRequest.GetHashCode() -> int override FluentCMS.Sites.SiteRequest.ToString() -> string! +override abstract FluentCMS.Events.EntryEvent.$() -> FluentCMS.Events.EntryEvent! override sealed FluentCMS.Content.And.Equals(FluentCMS.Content.ContentFilter? other) -> bool override sealed FluentCMS.Content.FieldFilter.Equals(FluentCMS.Content.ContentFilter? other) -> bool override sealed FluentCMS.Content.Not.Equals(FluentCMS.Content.ContentFilter? other) -> bool override sealed FluentCMS.Content.Or.Equals(FluentCMS.Content.ContentFilter? other) -> bool +override sealed FluentCMS.Events.CacheInvalidatedEvent.Equals(FluentCMS.Events.CmsEvent? other) -> bool +override sealed FluentCMS.Events.EntryDraftDiscardedEvent.Equals(FluentCMS.Events.EntryEvent? other) -> bool +override sealed FluentCMS.Events.EntryDraftSavedEvent.Equals(FluentCMS.Events.EntryEvent? other) -> bool +override sealed FluentCMS.Events.EntryEvent.Equals(FluentCMS.Events.CmsEvent? other) -> bool +override sealed FluentCMS.Events.EntryPublishedEvent.Equals(FluentCMS.Events.EntryEvent? other) -> bool +override sealed FluentCMS.Events.EntryPurgedEvent.Equals(FluentCMS.Events.EntryEvent? other) -> bool +override sealed FluentCMS.Events.EntryRestoredEvent.Equals(FluentCMS.Events.EntryEvent? other) -> bool +override sealed FluentCMS.Events.EntryScheduleCancelledEvent.Equals(FluentCMS.Events.EntryEvent? other) -> bool +override sealed FluentCMS.Events.EntryScheduledEvent.Equals(FluentCMS.Events.EntryEvent? other) -> bool +override sealed FluentCMS.Events.EntryTrashedEvent.Equals(FluentCMS.Events.EntryEvent? other) -> bool +override sealed FluentCMS.Events.EntryUnpublishedEvent.Equals(FluentCMS.Events.EntryEvent? other) -> bool +override sealed FluentCMS.Events.EntryVersionRestoredEvent.Equals(FluentCMS.Events.EntryEvent? other) -> bool +override sealed FluentCMS.Events.JobDeadLetteredEvent.Equals(FluentCMS.Events.CmsEvent? other) -> bool +override sealed FluentCMS.Events.JobFailedEvent.Equals(FluentCMS.Events.CmsEvent? other) -> bool +override sealed FluentCMS.Events.SettingsChangedEvent.Equals(FluentCMS.Events.CmsEvent? other) -> bool +override sealed FluentCMS.Events.SiteCreatedEvent.Equals(FluentCMS.Events.CmsEvent? other) -> bool +override sealed FluentCMS.Events.SiteUpdatedEvent.Equals(FluentCMS.Events.CmsEvent? other) -> bool static FluentCMS.Authorization.Permission.IsValidName(string? name) -> bool static FluentCMS.Content.And.operator !=(FluentCMS.Content.And? left, FluentCMS.Content.And? right) -> bool static FluentCMS.Content.And.operator ==(FluentCMS.Content.And? left, FluentCMS.Content.And? right) -> bool @@ -907,6 +1187,50 @@ static FluentCMS.Content.SystemOperation.Begin(FluentCMS.Content.IOperationConte static FluentCMS.Content.ValidationError.operator !=(FluentCMS.Content.ValidationError? left, FluentCMS.Content.ValidationError? right) -> bool static FluentCMS.Content.ValidationError.operator ==(FluentCMS.Content.ValidationError? left, FluentCMS.Content.ValidationError? right) -> bool static FluentCMS.Content.ValidationResult.Success.get -> FluentCMS.Content.ValidationResult! +static FluentCMS.Events.CacheInvalidatedEvent.operator !=(FluentCMS.Events.CacheInvalidatedEvent? left, FluentCMS.Events.CacheInvalidatedEvent? right) -> bool +static FluentCMS.Events.CacheInvalidatedEvent.operator ==(FluentCMS.Events.CacheInvalidatedEvent? left, FluentCMS.Events.CacheInvalidatedEvent? right) -> bool +static FluentCMS.Events.CmsEvent.operator !=(FluentCMS.Events.CmsEvent? left, FluentCMS.Events.CmsEvent? right) -> bool +static FluentCMS.Events.CmsEvent.operator ==(FluentCMS.Events.CmsEvent? left, FluentCMS.Events.CmsEvent? right) -> bool +static FluentCMS.Events.EntryDraftDiscardedEvent.operator !=(FluentCMS.Events.EntryDraftDiscardedEvent? left, FluentCMS.Events.EntryDraftDiscardedEvent? right) -> bool +static FluentCMS.Events.EntryDraftDiscardedEvent.operator ==(FluentCMS.Events.EntryDraftDiscardedEvent? left, FluentCMS.Events.EntryDraftDiscardedEvent? right) -> bool +static FluentCMS.Events.EntryDraftSavedEvent.operator !=(FluentCMS.Events.EntryDraftSavedEvent? left, FluentCMS.Events.EntryDraftSavedEvent? right) -> bool +static FluentCMS.Events.EntryDraftSavedEvent.operator ==(FluentCMS.Events.EntryDraftSavedEvent? left, FluentCMS.Events.EntryDraftSavedEvent? right) -> bool +static FluentCMS.Events.EntryEvent.operator !=(FluentCMS.Events.EntryEvent? left, FluentCMS.Events.EntryEvent? right) -> bool +static FluentCMS.Events.EntryEvent.operator ==(FluentCMS.Events.EntryEvent? left, FluentCMS.Events.EntryEvent? right) -> bool +static FluentCMS.Events.EntryPublishedEvent.operator !=(FluentCMS.Events.EntryPublishedEvent? left, FluentCMS.Events.EntryPublishedEvent? right) -> bool +static FluentCMS.Events.EntryPublishedEvent.operator ==(FluentCMS.Events.EntryPublishedEvent? left, FluentCMS.Events.EntryPublishedEvent? right) -> bool +static FluentCMS.Events.EntryPurgedEvent.operator !=(FluentCMS.Events.EntryPurgedEvent? left, FluentCMS.Events.EntryPurgedEvent? right) -> bool +static FluentCMS.Events.EntryPurgedEvent.operator ==(FluentCMS.Events.EntryPurgedEvent? left, FluentCMS.Events.EntryPurgedEvent? right) -> bool +static FluentCMS.Events.EntryRestoredEvent.operator !=(FluentCMS.Events.EntryRestoredEvent? left, FluentCMS.Events.EntryRestoredEvent? right) -> bool +static FluentCMS.Events.EntryRestoredEvent.operator ==(FluentCMS.Events.EntryRestoredEvent? left, FluentCMS.Events.EntryRestoredEvent? right) -> bool +static FluentCMS.Events.EntryScheduleCancelledEvent.operator !=(FluentCMS.Events.EntryScheduleCancelledEvent? left, FluentCMS.Events.EntryScheduleCancelledEvent? right) -> bool +static FluentCMS.Events.EntryScheduleCancelledEvent.operator ==(FluentCMS.Events.EntryScheduleCancelledEvent? left, FluentCMS.Events.EntryScheduleCancelledEvent? right) -> bool +static FluentCMS.Events.EntryScheduledEvent.operator !=(FluentCMS.Events.EntryScheduledEvent? left, FluentCMS.Events.EntryScheduledEvent? right) -> bool +static FluentCMS.Events.EntryScheduledEvent.operator ==(FluentCMS.Events.EntryScheduledEvent? left, FluentCMS.Events.EntryScheduledEvent? right) -> bool +static FluentCMS.Events.EntryTrashedEvent.operator !=(FluentCMS.Events.EntryTrashedEvent? left, FluentCMS.Events.EntryTrashedEvent? right) -> bool +static FluentCMS.Events.EntryTrashedEvent.operator ==(FluentCMS.Events.EntryTrashedEvent? left, FluentCMS.Events.EntryTrashedEvent? right) -> bool +static FluentCMS.Events.EntryUnpublishedEvent.operator !=(FluentCMS.Events.EntryUnpublishedEvent? left, FluentCMS.Events.EntryUnpublishedEvent? right) -> bool +static FluentCMS.Events.EntryUnpublishedEvent.operator ==(FluentCMS.Events.EntryUnpublishedEvent? left, FluentCMS.Events.EntryUnpublishedEvent? right) -> bool +static FluentCMS.Events.EntryVersionRestoredEvent.operator !=(FluentCMS.Events.EntryVersionRestoredEvent? left, FluentCMS.Events.EntryVersionRestoredEvent? right) -> bool +static FluentCMS.Events.EntryVersionRestoredEvent.operator ==(FluentCMS.Events.EntryVersionRestoredEvent? left, FluentCMS.Events.EntryVersionRestoredEvent? right) -> bool +static FluentCMS.Events.EventDelivery.operator !=(FluentCMS.Events.EventDelivery? left, FluentCMS.Events.EventDelivery? right) -> bool +static FluentCMS.Events.EventDelivery.operator ==(FluentCMS.Events.EventDelivery? left, FluentCMS.Events.EventDelivery? right) -> bool +static FluentCMS.Events.EventSource.From(FluentCMS.Content.OperationContext! operation) -> FluentCMS.Events.EventSource! +static FluentCMS.Events.EventSource.FromSystem(System.DateTimeOffset occurredAt) -> FluentCMS.Events.EventSource! +static FluentCMS.Events.EventSource.operator !=(FluentCMS.Events.EventSource? left, FluentCMS.Events.EventSource? right) -> bool +static FluentCMS.Events.EventSource.operator ==(FluentCMS.Events.EventSource? left, FluentCMS.Events.EventSource? right) -> bool +static FluentCMS.Events.FailedEventDelivery.operator !=(FluentCMS.Events.FailedEventDelivery? left, FluentCMS.Events.FailedEventDelivery? right) -> bool +static FluentCMS.Events.FailedEventDelivery.operator ==(FluentCMS.Events.FailedEventDelivery? left, FluentCMS.Events.FailedEventDelivery? right) -> bool +static FluentCMS.Events.JobDeadLetteredEvent.operator !=(FluentCMS.Events.JobDeadLetteredEvent? left, FluentCMS.Events.JobDeadLetteredEvent? right) -> bool +static FluentCMS.Events.JobDeadLetteredEvent.operator ==(FluentCMS.Events.JobDeadLetteredEvent? left, FluentCMS.Events.JobDeadLetteredEvent? right) -> bool +static FluentCMS.Events.JobFailedEvent.operator !=(FluentCMS.Events.JobFailedEvent? left, FluentCMS.Events.JobFailedEvent? right) -> bool +static FluentCMS.Events.JobFailedEvent.operator ==(FluentCMS.Events.JobFailedEvent? left, FluentCMS.Events.JobFailedEvent? right) -> bool +static FluentCMS.Events.SettingsChangedEvent.operator !=(FluentCMS.Events.SettingsChangedEvent? left, FluentCMS.Events.SettingsChangedEvent? right) -> bool +static FluentCMS.Events.SettingsChangedEvent.operator ==(FluentCMS.Events.SettingsChangedEvent? left, FluentCMS.Events.SettingsChangedEvent? right) -> bool +static FluentCMS.Events.SiteCreatedEvent.operator !=(FluentCMS.Events.SiteCreatedEvent? left, FluentCMS.Events.SiteCreatedEvent? right) -> bool +static FluentCMS.Events.SiteCreatedEvent.operator ==(FluentCMS.Events.SiteCreatedEvent? left, FluentCMS.Events.SiteCreatedEvent? right) -> bool +static FluentCMS.Events.SiteUpdatedEvent.operator !=(FluentCMS.Events.SiteUpdatedEvent? left, FluentCMS.Events.SiteUpdatedEvent? right) -> bool +static FluentCMS.Events.SiteUpdatedEvent.operator ==(FluentCMS.Events.SiteUpdatedEvent? left, FluentCMS.Events.SiteUpdatedEvent? right) -> bool static FluentCMS.Jobs.DeadLetteredJob.operator !=(FluentCMS.Jobs.DeadLetteredJob? left, FluentCMS.Jobs.DeadLetteredJob? right) -> bool static FluentCMS.Jobs.DeadLetteredJob.operator ==(FluentCMS.Jobs.DeadLetteredJob? left, FluentCMS.Jobs.DeadLetteredJob? right) -> bool static FluentCMS.Jobs.JobActor.operator !=(FluentCMS.Jobs.JobActor? left, FluentCMS.Jobs.JobActor? right) -> bool @@ -940,6 +1264,10 @@ static FluentCMS.Sites.SiteRequest.operator ==(FluentCMS.Sites.SiteRequest? left virtual FluentCMS.Content.ContentFilter.EqualityContract.get -> System.Type! virtual FluentCMS.Content.ContentFilter.Equals(FluentCMS.Content.ContentFilter? other) -> bool virtual FluentCMS.Content.ContentFilter.PrintMembers(System.Text.StringBuilder! builder) -> bool +virtual FluentCMS.Events.CmsEvent.EqualityContract.get -> System.Type! +virtual FluentCMS.Events.CmsEvent.Equals(FluentCMS.Events.CmsEvent? other) -> bool +virtual FluentCMS.Events.CmsEvent.PrintMembers(System.Text.StringBuilder! builder) -> bool +virtual FluentCMS.Events.EntryEvent.Equals(FluentCMS.Events.EntryEvent? other) -> bool ~FluentCMS.Schema.Ref.Equals(FluentCMS.Schema.Ref other) -> bool ~FluentCMS.Schema.RefList.$() -> FluentCMS.Schema.RefList! ~override FluentCMS.Media.MediaRef.Equals(object obj) -> bool diff --git a/tests/FluentCMS.Data.Tests/Events/EventContextTests.cs b/tests/FluentCMS.Data.Tests/Events/EventContextTests.cs new file mode 100644 index 000000000..af7efc1e7 --- /dev/null +++ b/tests/FluentCMS.Data.Tests/Events/EventContextTests.cs @@ -0,0 +1,420 @@ +using FluentCMS.Content; +using FluentCMS.Core.Events; +using FluentCMS.Core.Storage; +using FluentCMS.Core.Storage.Entities; +using FluentCMS.Data.Tests.Content; +using FluentCMS.Data.Tests.Jobs; +using FluentCMS.Events; +using FluentCMS.Hooks; +using FluentCMS.Jobs; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Shouldly; +using Xunit; +using static FluentCMS.Data.Tests.Content.ServiceDatabase; + +namespace FluentCMS.Data.Tests.Events; + +// What a handler is told it runs as, how events are attributed and ordered, and what a broken handler may and may not break. +public sealed class EventContextTests +{ + private static ContentDocument Titled(string title) => new() { ["title"] = title }; + + private static Task DrainAsync(IServiceProvider instance) => + instance.GetRequiredService().DrainAsync(CancellationToken.None); + + private static Action Registrations(Seen seen, Action? more = null) => + EventRegistrations.Outbox(seen, cms => + { + cms.Events.AddHandler(); + cms.Events.AddOutboxHandler("ping-outbox"); + more?.Invoke(cms); + }); + + private sealed class PublishesMany(IEventBus bus) : IAfterCreateHook + { + public async ValueTask AfterCreateAsync(HookContext context, CancellationToken cancellationToken) + { + for (var index = 0; index < 20; index++) + { + await bus.PublishAsync(new Ping($"{index:00}"), cancellationToken); + } + } + } + + private sealed class PublishesTwice(IEventBus bus) : IAfterCreateHook + { + public async ValueTask AfterCreateAsync(HookContext context, CancellationToken cancellationToken) + { + var ping = new Ping("same"); + await bus.PublishAsync(ping, cancellationToken); + await bus.PublishAsync(ping, cancellationToken); + } + } + + private sealed class PublishesBoth(IEventBus bus) : IAfterCreateHook + { + public async ValueTask AfterCreateAsync(HookContext context, CancellationToken cancellationToken) + { + await bus.PublishAsync(new Ping("first"), cancellationToken); + await bus.PublishAsync(new Ping2("second"), cancellationToken); + } + } + + private sealed class FakeLifetime : IHostApplicationLifetime + { + public CancellationTokenSource Stopping { get; } = new(); + + public CancellationToken ApplicationStarted => CancellationToken.None; + + public CancellationToken ApplicationStopping => Stopping.Token; + + public CancellationToken ApplicationStopped => CancellationToken.None; + + public void StopApplication() => Stopping.Cancel(); + } + + [Fact] + public async Task APublishWithoutASourceIsAttributedToTheOperationThatPublishesIt() + { + var seen = new Seen(); + using var database = new ServiceDatabase(Registrations(seen)); + + await database.AsAsync(Editor, async services => + { + await services.GetRequiredService().PublishAsync(new Ping("x"), Token); + return true; + }, site: SiteB); + + var source = seen.Of().Single().Source; + source.ActorId.ShouldBe(Editor.Id); + source.ActorKind.ShouldBe(Editor.Kind); + source.SiteId.ShouldBe(SiteB.Value); + source.CorrelationId.ShouldBe("correlation"); + source.Origin.ShouldBe(Origin.Api); + + var row = database.Rows().Single(); + row.SiteId.ShouldBe(SiteB.Value); + row.Payload.ShouldContain(Editor.Id.ToString()); + } + + [Fact] + public async Task APublishWithNoOperationRunningIsAttributedToTheSystem() + { + var seen = new Seen(); + using var database = new ServiceDatabase(Registrations(seen)); + + using var scope = database.Database.NewScope(); + await scope.ServiceProvider.GetRequiredService().PublishAsync(new Ping("x"), Token); + + var source = seen.Of().Single().Source; + source.ActorKind.ShouldBe(PrincipalKind.System); + source.ActorId.ShouldBe(Guid.Empty); + source.Origin.ShouldBe(Origin.Job); + database.Rows().Single().SiteId.ShouldBeNull(); + } + + [Fact] + public async Task ASourceThePublisherSetIsKeptAndRecordedInUtc() + { + var seen = new Seen(); + using var database = new ServiceDatabase(Registrations(seen)); + var occurredAt = new DateTimeOffset(2026, 11, 2, 9, 30, 0, TimeSpan.FromHours(2)); + var own = new EventSource(occurredAt, SiteB.Value, Guid.Parse("00000000-0000-0000-0000-0000000000f1"), "Importer", PrincipalKind.AdminUser, Origin.Cli, "import-1"); + + await database.AsAsync(Editor, async services => + { + await services.GetRequiredService().PublishAsync(new Ping("x") { Source = own }, Token); + return true; + }); + + seen.Of().Single().Source.ShouldBe(own); + var row = database.Rows().Single(); + row.OccurredAt.Offset.ShouldBe(TimeSpan.Zero); + row.OccurredAt.ShouldBe(occurredAt); + row.SiteId.ShouldBe(SiteB.Value); + } + + [Fact] + public async Task AnOutboxHandlerRunsAsTheSystemOnTheSiteOfTheEventInItsDefaultLocaleWithTheActorAndCorrelationOfTheEvent() + { + var seen = new Seen(); + using var database = new ServiceDatabase(EventRegistrations.Outbox(seen)); + database.Database.Context.Set().Where(site => site.Id == SiteA.Value).ExecuteUpdate(site => site.SetProperty(column => column.DefaultLocale, "fr")); + OperationContext? seenOperation = null; + seen.Behavior = (_, _) => + { + seenOperation = database.Database.Services.GetRequiredService().Current; + return Task.CompletedTask; + }; + + await database.CreateAsync(Editor, Titled("T"), locale: "fr"); + await DrainAsync(database.Database.Services); + + var operation = seenOperation.ShouldNotBeNull(); + operation.Principal.IsSystem.ShouldBeTrue(); + operation.Origin.ShouldBe(Origin.Job); + operation.Site.ShouldBe(SiteA); + operation.Locale.ShouldBe("fr"); + operation.Actor!.Id.ShouldBe(Editor.Id); + operation.Actor.Kind.ShouldBe(Editor.Kind); + operation.CorrelationId.ShouldBe("correlation"); + database.Database.Services.GetRequiredService().Current.ShouldBeNull(); + } + + [Fact] + public async Task AnInProcessHandlerGetsTheShutdownOfTheHostAndNeverTheTokenOfTheRequest() + { + var seen = new Seen(); + var lifetime = new FakeLifetime(); + using var request = new CancellationTokenSource(); + using var database = new ServiceDatabase(EventRegistrations.With(seen, cms => + { + cms.Services.AddSingleton(lifetime); + cms.Events.AddHandler(); + })); + + await database.AsAsync(Editor, services => services.GetRequiredService().CreateAsync("article", Titled("T"), request.Token)); + await request.CancelAsync(); + + var token = seen.Tokens.Single(); + token.ShouldBe(lifetime.Stopping.Token); + token.IsCancellationRequested.ShouldBeFalse(); + await lifetime.Stopping.CancelAsync(); + token.IsCancellationRequested.ShouldBeTrue(); + } + + [Fact] + public async Task TheEventsOfOneCommitAreDeliveredInTheOrderTheyWereRaised() + { + var seen = new Seen(); + using var database = new ServiceDatabase(Registrations(seen, cms => cms.Services.AddScoped, PublishesMany>())); + + await database.CreateAsync(Editor, Titled("T")); + await DrainAsync(database.Database.Services); + + seen.Calls.Zip(seen.Events).Where(heard => heard.First == "ping-outbox").Select(heard => ((Ping)heard.Second).Note) + .ShouldBe(Enumerable.Range(0, 20).Select(index => $"{index:00}")); + } + + [Fact] + public async Task TheEntryEventsOfSeparateCommitsAreDeliveredInTheOrderTheyCommitted() + { + var seen = new Seen(); + using var database = new ServiceDatabase(EventRegistrations.Outbox(seen, cms => cms.Events.Outbox.BatchSize = 3)); + var entry = await database.CreateAsync(Editor, Titled("T")); + var version = VersionOf(entry); + for (var index = 0; index < 11; index++) + { + version = VersionOf(await database.UpdateAsync(Editor, IdOf(entry), Titled($"T{index}"), version)); + } + + await DrainAsync(database.Database.Services); + + seen.Of().Select(saved => saved.Version).ShouldBe(Enumerable.Range(1, 12)); + } + + [Fact] + public async Task AnEventPublishedTwiceByAHookIsOneEventAndTheWriteSucceeds() + { + var seen = new Seen(); + using var database = new ServiceDatabase(Registrations(seen, cms => cms.Services.AddScoped, PublishesTwice>())); + + await database.CreateAsync(Editor, Titled("T")); + await DrainAsync(database.Database.Services); + + seen.Calls.Count(call => call == "ping").ShouldBe(1); + seen.Calls.Count(call => call == "ping-outbox").ShouldBe(1); + database.Rows().Count(row => row.Handler == "ping-outbox").ShouldBe(1); + } + + [Fact] + public async Task AHandlerThatCannotBeBuiltNeverReachesTheCallerOfAPublish() + { + var seen = new Seen(); + var logs = new RecordingLogs(); + using var database = new ServiceDatabase(Registrations(seen, cms => + { + cms.Services.AddSingleton(logs); + cms.Events.AddHandler(); + })); + + await database.AsAsync(Editor, async services => + { + await services.GetRequiredService().PublishAsync(new Ping("x"), Token); + return true; + }); + + logs.Count("HandlersUnavailable").ShouldBe(1); + database.Rows().ShouldHaveSingleItem().Handler.ShouldBe("ping-outbox"); + DrainJobs(database).ShouldBe(1); + } + + [Fact] + public async Task AHandlerThatCannotBeBuiltLosesNeitherALaterEventNorTheRequestForADrain() + { + var seen = new Seen(); + using var database = new ServiceDatabase(Registrations(seen, cms => + { + cms.Events.AddHandler(); + cms.Events.AddHandler(); + cms.Services.AddScoped, PublishesBoth>(); + })); + + var entry = await database.CreateAsync(Editor, Titled("T")); + + entry.ShouldContainKey("id"); + seen.Calls.ShouldBe(["ping2"]); + DrainJobs(database).ShouldBe(1); + database.Rows().Select(row => row.Handler).Order().ShouldBe(["ping-outbox", "saved-outbox"]); + } + + [Fact] + public async Task AnEntryEventWhoseHandlersCannotBeBuiltLeavesTheWriteAndTheOutboxAlone() + { + var seen = new Seen(); + using var database = new ServiceDatabase(EventRegistrations.Outbox(seen, cms => + { + cms.Events.AddHandler(); + cms.Events.AddHandler(); + })); + + var entry = await database.CreateAsync(Editor, Titled("T")); + + database.Versions(IdOf(entry)).Count.ShouldBe(1); + database.Rows().ShouldHaveSingleItem(); + DrainJobs(database).ShouldBe(1); + } + + [Fact] + public async Task APublishInsideATransactionOnTheSystemContextIsRefusedAndNothingIsDelivered() + { + var seen = new Seen(); + using var database = new ServiceDatabase(Registrations(seen)); + + var message = await database.AsAsync(Editor, async services => + { + await using var transaction = await services.GetRequiredService().Database.BeginTransactionAsync(Token); + var exception = await Should.ThrowAsync(async () => await services.GetRequiredService().PublishAsync(new Ping("x"), Token)); + await transaction.RollbackAsync(Token); + return exception.Message; + }); + + message.ShouldStartWith("FCMS2602"); + seen.Events.ShouldBeEmpty(); + database.Rows().ShouldBeEmpty(); + } + + [Fact] + public async Task AJobEventIsRecordedInTheCommitThatSettlesTheJobAndTheJobStaysOpenWhileItCannotBe() + { + using var fixture = new JobFixture(); + var seen = new Seen(); + var faults = new JobFaults(); + var attempts = 0; + using var instance = fixture.Database.CreateServices(configure: cms => + { + cms.Services.AddSingleton(seen); + cms.Events.AddOutboxHandler("dead"); + cms.Services.ConfigureDbContext(options => options.AddInterceptors(faults)); + }); + var id = fixture.AddRow("no-such-job", "{}", maxAttempts: 1); + + faults.Fail(command => + { + if (command.CommandText.StartsWith("INSERT", StringComparison.Ordinal) && command.CommandText.Contains("cms_outbox", StringComparison.Ordinal)) + { + Interlocked.Increment(ref attempts); + return true; + } + + return false; + }); + var run = instance.GetRequiredService().RunNextAsync("default", "worker", TestContext.Current.CancellationToken); + await Eventually.HoldsAsync(() => Volatile.Read(ref attempts) >= 1); + + // The outcome and its event stand or fall together: the job is not dead-lettered while its event cannot be recorded. + fixture.Job(id).Status.ShouldBe(Core.Jobs.JobStatus.Running); + OutboxRows(fixture.Services).ShouldBeEmpty(); + + faults.Recover(); + await Eventually.CompletesAsync(run); + + fixture.Job(id).Status.ShouldBe(Core.Jobs.JobStatus.DeadLettered); + var row = OutboxRows(fixture.Services).ShouldHaveSingleItem(); + row.Handler.ShouldBe("dead"); + row.EventId.ShouldBe(Core.Events.OutboxRows.StableId($"job:{id}:1:dead")); + + await instance.GetRequiredService().DrainAsync(CancellationToken.None); + seen.Of().Single().JobId.ShouldBe(id); + } + + [Fact] + public async Task TheInProcessHandlersOfAJobEventRunUnderASystemOperation() + { + using var fixture = new JobFixture(); + var seen = new Seen(); + OperationContext? seenOperation = null; + using var instance = fixture.Database.CreateServices(configure: cms => + { + cms.Services.AddSingleton(seen); + cms.Events.AddHandler(); + }); + seen.Behavior = (_, _) => + { + seenOperation = instance.GetRequiredService().Current; + return Task.CompletedTask; + }; + fixture.AddRow("no-such-job", "{}", maxAttempts: 1); + + await instance.GetRequiredService().RunNextAsync("default", "worker", TestContext.Current.CancellationToken); + + var operation = seenOperation.ShouldNotBeNull(); + operation.Principal.IsSystem.ShouldBeTrue(); + operation.Origin.ShouldBe(Origin.Job); + instance.GetRequiredService().Current.ShouldBeNull(); + } + + [Fact] + public void TheOutboxHasItsOwnQueueSoThatAHandlerThatHangsHoldsUpNoOtherJob() + { + using var database = new ServiceDatabase(); + + var queues = database.Database.Services.GetRequiredService().Queues; + + queues["outbox"].ShouldBe(1); + queues["default"].ShouldBe(2); + } + + [Fact] + public async Task TheDrainIsAskedForOnTheOutboxQueue() + { + var seen = new Seen(); + using var database = new ServiceDatabase(Registrations(seen)); + + await database.CreateAsync(Editor, Titled("T")); + + database.Read(context => context.Set().AsNoTracking().Single(job => job.Type == OutboxDrainJob.Type).Queue).ShouldBe("outbox"); + } + + private static int DrainJobs(ServiceDatabase database) => + database.Read(context => context.Set().AsNoTracking().Count(job => job.Type == OutboxDrainJob.Type)); + + private static List OutboxRows(IServiceProvider instance) + { + using var scope = instance.CreateScope(); + return [.. scope.ServiceProvider.GetRequiredService().Set().AsNoTracking()]; + } + + private sealed class DeadOutbox(Seen seen) : IOutboxEventHandler + { + public ValueTask HandleAsync(JobDeadLetteredEvent message, EventDelivery delivery, CancellationToken cancellationToken) => seen.HeardAsync("dead", message, delivery, cancellationToken); + } + + private sealed class DeadHandler(Seen seen) : IEventHandler + { + public ValueTask HandleAsync(JobDeadLetteredEvent message, CancellationToken cancellationToken) => seen.HeardAsync("dead-in-process", message, null, cancellationToken); + } +} diff --git a/tests/FluentCMS.Data.Tests/Events/EventDispatchTests.cs b/tests/FluentCMS.Data.Tests/Events/EventDispatchTests.cs new file mode 100644 index 000000000..c5fed8db9 --- /dev/null +++ b/tests/FluentCMS.Data.Tests/Events/EventDispatchTests.cs @@ -0,0 +1,342 @@ +using FluentCMS.Content; +using FluentCMS.Core.Content.Hooks; +using FluentCMS.Core.Events; +using FluentCMS.Core.Storage; +using FluentCMS.Core.Storage.Entities; +using FluentCMS.Data.Tests.Content; +using FluentCMS.Events; +using FluentCMS.Hooks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Shouldly; +using Xunit; +using static FluentCMS.Data.Tests.Content.ServiceDatabase; + +namespace FluentCMS.Data.Tests.Events; + +public sealed class EventDispatchTests +{ + private static ContentDocument Titled(string title) => new() { ["title"] = title }; + + private sealed class PublishesThenFails(IEventBus bus) : IAfterCreateHook + { + public async ValueTask AfterCreateAsync(HookContext context, CancellationToken cancellationToken) + { + await bus.PublishAsync(new Ping("from a hook"), cancellationToken); + throw new InvalidOperationException("A plugin table is gone."); + } + } + + private sealed class PublishesThenSucceeds(IEventBus bus) : IAfterCreateHook + { + public ValueTask AfterCreateAsync(HookContext context, CancellationToken cancellationToken) => + bus.PublishAsync(new Ping("from a hook"), cancellationToken); + } + + // The first create makes a second entry from its after hook; the second create publishes and fails, which rolls the + // second entry back to its savepoint, and the first hook carries on. + private sealed class NestedWrite(IContentService content, IEventBus bus, NestedState state) : IAfterCreateHook + { + public async ValueTask AfterCreateAsync(HookContext context, CancellationToken cancellationToken) + { + if (state.Depth++ == 0) + { + try + { + await content.CreateAsync("article", Titled("Nested"), cancellationToken); + } + catch (InvalidOperationException) + { + } + + return; + } + + await bus.PublishAsync(new Ping("from the nested write"), cancellationToken); + throw new InvalidOperationException("The nested write fails."); + } + } + + private sealed class NestedState + { + public int Depth { get; set; } + } + + [Fact] + public async Task AHandlerHearsTheChangeOnlyAfterTheCommit() + { + var seen = new Seen(); + using var database = new ServiceDatabase(EventRegistrations.With(seen, cms => cms.Events.AddHandler())); + var committed = new List(); + seen.Behavior = (@event, _) => + { + committed.Add(database.Versions(((EntryEvent)@event).EntryId).Count); + return Task.CompletedTask; + }; + + var entry = await database.CreateAsync(Editor, Titled("T")); + + var heard = seen.Of().ShouldHaveSingleItem(); + heard.EntryId.ShouldBe(IdOf(entry)); + heard.Type.ShouldBe("article"); + heard.Locale.ShouldBe("en"); + heard.Version.ShouldBe(1); + heard.Source.ActorId.ShouldBe(Editor.Id); + heard.Source.Origin.ShouldBe(Origin.Api); + heard.Source.SiteId.ShouldBe(SiteA.Value); + committed.ShouldBe([1]); + } + + [Fact] + public async Task EveryKindOfChangeBecomesItsEvent() + { + var seen = new Seen(); + using var database = new ServiceDatabase(EventRegistrations.With(seen, cms => + { + cms.Events.AddHandler(); + cms.Events.AddHandler(); + cms.Events.AddHandler(); + })); + + var entry = await database.CreateAsync(Editor, Titled("T")); + await database.DeleteAsync(Editor, IdOf(entry)); + await database.RestoreAsync(Editor, IdOf(entry)); + + seen.Calls.ShouldBe(["saved", "trashed", "restored"]); + seen.Of().ShouldHaveSingleItem().EntryId.ShouldBe(IdOf(entry)); + seen.Of().ShouldHaveSingleItem().EntryId.ShouldBe(IdOf(entry)); + } + + [Fact] + public async Task AWriteThatFailsIsNeverPublished() + { + var seen = new Seen(); + using var database = new ServiceDatabase(EventRegistrations.Outbox(seen, cms => cms.Events.AddHandler())); + + await Should.ThrowAsync(() => database.CreateAsync(Editor, Titled(new string('x', 41)))); + + seen.Events.ShouldBeEmpty(); + database.Rows().ShouldBeEmpty(); + } + + [Fact] + public async Task AWriteRolledBackByAnAfterHookIsNeverPublishedNorRecorded() + { + var seen = new Seen(); + using var database = new ServiceDatabase(EventRegistrations.Outbox(seen, cms => + { + cms.Events.AddHandler(); + cms.Events.AddHandler(); + cms.Events.AddOutboxHandler("ping-outbox"); + cms.Services.AddScoped, PublishesThenFails>(); + })); + + await Should.ThrowAsync(() => database.CreateAsync(Editor, Titled("T"))); + + seen.Events.ShouldBeEmpty(); + database.Rows().ShouldBeEmpty(); + database.Footprint().ShouldBe("0,0,0,0,0"); + } + + [Fact] + public async Task AnEventPublishedByAHookIsDeliveredAfterTheCommitWithTheChange() + { + var seen = new Seen(); + using var database = new ServiceDatabase(EventRegistrations.Outbox(seen, cms => + { + cms.Events.AddHandler(); + cms.Events.AddHandler(); + cms.Events.AddOutboxHandler("ping-outbox"); + cms.Services.AddScoped, PublishesThenSucceeds>(); + })); + var versionsWhenPingHeard = -1; + seen.Behavior = (@event, _) => + { + if (@event is Ping) + { + versionsWhenPingHeard = database.Read(context => context.Versions.Count()); + } + + return Task.CompletedTask; + }; + + await database.CreateAsync(Editor, Titled("T")); + + seen.Calls.ShouldBe(["saved", "ping"]); + versionsWhenPingHeard.ShouldBe(1); + database.Rows().Select(row => row.Handler).Order().ShouldBe(["ping-outbox", "saved-outbox"]); + } + + [Fact] + public async Task EventsOfAWriteRolledBackToASavepointAreDroppedWhileTheOuterWriteIsKept() + { + var seen = new Seen(); + var state = new NestedState(); + using var database = new ServiceDatabase(EventRegistrations.Outbox(seen, cms => + { + cms.Events.AddHandler(); + cms.Events.AddHandler(); + cms.Events.AddOutboxHandler("ping-outbox"); + cms.Services.AddSingleton(state); + cms.Services.AddScoped, NestedWrite>(); + })); + + var entry = await database.CreateAsync(Editor, Titled("Outer")); + + state.Depth.ShouldBe(2); + seen.Calls.ShouldBe(["saved"]); + seen.Of().ShouldHaveSingleItem().EntryId.ShouldBe(IdOf(entry)); + database.Rows().ShouldHaveSingleItem().Handler.ShouldBe("saved-outbox"); + database.Footprint().ShouldStartWith("1,"); + } + + [Fact] + public async Task AHandlerThatThrowsDoesNotAffectTheWriteOrTheOtherHandlers() + { + var seen = new Seen(); + var logs = new EventLogs(); + using var database = new ServiceDatabase(EventRegistrations.With(seen, cms => + { + cms.Services.AddSingleton(logs); + cms.Events.AddHandler(); + cms.Events.AddHandler(); + cms.Events.AddHandler(); + })); + seen.Behavior = (_, _) => seen.Calls.Count == 1 ? throw new InvalidOperationException("The handler is broken.") : Task.CompletedTask; + + var entry = await database.CreateAsync(Editor, Titled("T")); + + seen.Calls.ShouldBe(["early", "saved", "late"]); + logs.Count("HandlerFailed").ShouldBe(1); + (await database.GetAsync(Editor, IdOf(entry)))!["title"].ShouldBe("T"); + database.Versions(IdOf(entry)).Count.ShouldBe(1); + } + + [Fact] + public async Task AHandlerThatCancelsDoesNotAffectTheWrite() + { + var seen = new Seen(); + using var database = new ServiceDatabase(EventRegistrations.With(seen, cms => cms.Events.AddHandler())); + seen.Behavior = (_, _) => throw new OperationCanceledException(); + + var entry = await database.CreateAsync(Editor, Titled("T")); + + database.Versions(IdOf(entry)).Count.ShouldBe(1); + } + + [Fact] + public async Task HandlersRunInHookOrderThenByThePluginDependencyRankThenByRegistration() + { + var seen = new Seen(); + using var database = new ServiceDatabase(EventRegistrations.With(seen, cms => + { + cms.Services.AddSingleton(); + cms.Events.AddHandler(); + cms.Events.AddHandler(); + cms.Events.AddHandler(); + cms.Events.AddHandler(); + })); + + await database.CreateAsync(Editor, Titled("T")); + + seen.Calls.ShouldBe(["early", "tied-last-registered", "tied-first-registered", "late"]); + } + + [Fact] + public async Task APublishOutsideATransactionIsDeliveredAtOnce() + { + var seen = new Seen(); + using var database = new ServiceDatabase(EventRegistrations.Outbox(seen, cms => + { + cms.Events.AddHandler(); + cms.Events.AddOutboxHandler("ping-outbox"); + })); + + await database.AsAsync(Editor, async services => + { + await services.GetRequiredService().PublishAsync(new Ping("now"), Token); + return true; + }); + + seen.Calls.ShouldBe(["ping"]); + database.Rows().ShouldHaveSingleItem().Handler.ShouldBe("ping-outbox"); + } + + [Fact] + public async Task APublishInATransactionTheServiceDoesNotOwnIsRefused() + { + var seen = new Seen(); + using var database = new ServiceDatabase(EventRegistrations.With(seen, cms => cms.Events.AddHandler())); + + var exception = await database.AsAsync(Editor, async services => + { + await using var transaction = await services.GetRequiredService().Database.BeginTransactionAsync(Token); + return await Should.ThrowAsync(async () => await services.GetRequiredService().PublishAsync(new Ping("early"), Token)); + }); + + exception.Message.ShouldStartWith("FCMS2602"); + seen.Events.ShouldBeEmpty(); + } + + [Fact] + public async Task AWriteInATransactionTheServiceDoesNotOwnRecordsItsRowWithThatTransactionAndTellsNoHandlerOfThisProcess() + { + var seen = new Seen(); + using var database = new ServiceDatabase(EventRegistrations.Outbox(seen, cms => cms.Events.AddHandler())); + + await database.AsAsync(Editor, async services => + { + await using var transaction = await services.GetRequiredService().Database.BeginTransactionAsync(Token); + await services.GetRequiredService().CreateAsync("article", Titled("Rolled back"), Token); + await transaction.RollbackAsync(Token); + return true; + }); + + seen.Events.ShouldBeEmpty(); + database.Rows().ShouldBeEmpty(); + + var committed = await database.AsAsync(Editor, async services => + { + await using var transaction = await services.GetRequiredService().Database.BeginTransactionAsync(Token); + var entry = await services.GetRequiredService().CreateAsync("article", Titled("Committed"), Token); + await transaction.CommitAsync(Token); + return IdOf(entry); + }); + + // In-process handlers cannot see the commit, as in #2747; the outbox handler is told, because its row committed too. + seen.Events.ShouldBeEmpty(); + var row = database.Rows().ShouldHaveSingleItem(); + row.Payload.ShouldContain(committed.ToString()); + (await DrainAsync(database.Database.Services)).ShouldBe(1); + seen.Of().ShouldHaveSingleItem().EntryId.ShouldBe(committed); + } + + private static Task DrainAsync(IServiceProvider instance) => instance.GetRequiredService().DrainAsync(Token); + + [Fact] + public async Task ARecordedRowNamesTheEventAndItsHandler() + { + var seen = new Seen(); + using var database = new ServiceDatabase(EventRegistrations.Outbox(seen)); + + await database.CreateAsync(Editor, Titled("T")); + + var row = database.Rows().ShouldHaveSingleItem(); + row.Status.ShouldBe("pending"); + row.EventType.ShouldBe(typeof(EntryDraftSavedEvent).FullName); + database.Read(context => context.Set().AsNoTracking().Count()).ShouldBe(1); + } + + [Fact] + public async Task AnEventOfOneTypeIsNotHeardByTheHandlersOfAnother() + { + var seen = new Seen(); + using var database = new ServiceDatabase(EventRegistrations.With(seen, cms => cms.Events.AddHandler())); + var dispatcher = database.Database.Services.GetRequiredService(); + + await dispatcher.DispatchAsync(new EntryDraftSavedEvent("article", Guid.NewGuid(), "en", 1)); + + seen.Events.ShouldBeEmpty(); + } +} diff --git a/tests/FluentCMS.Data.Tests/Events/EventFixture.cs b/tests/FluentCMS.Data.Tests/Events/EventFixture.cs new file mode 100644 index 000000000..f4b168cfa --- /dev/null +++ b/tests/FluentCMS.Data.Tests/Events/EventFixture.cs @@ -0,0 +1,210 @@ +using System.Collections.Concurrent; +using FluentCMS.Core.Content.Hooks; +using Microsoft.EntityFrameworkCore; +using FluentCMS.Core.DependencyInjection; +using FluentCMS.Core.Storage.Entities; +using FluentCMS.Data.Tests.Content; +using FluentCMS.Events; +using FluentCMS.Hooks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace FluentCMS.Data.Tests.Events; + +// What the handlers of a test saw, shared by every instance of the test, and what they should do next. +internal sealed class Seen +{ + public ConcurrentQueue Calls { get; } = new(); + + public ConcurrentQueue Events { get; } = new(); + + public ConcurrentQueue Deliveries { get; } = new(); + + public ConcurrentQueue Tokens { get; } = new(); + + // Runs inside the handler, so a test can look at the database at the moment the event arrives or make the handler fail. + public Func Behavior { get; set; } = (_, _) => Task.CompletedTask; + + public IEnumerable Of() + where T : CmsEvent => Events.OfType(); + + public async ValueTask HeardAsync(string name, CmsEvent @event, EventDelivery? delivery, CancellationToken token) + { + Tokens.Enqueue(token); + Calls.Enqueue(name); + Events.Enqueue(@event); + if (delivery is not null) + { + Deliveries.Enqueue(delivery); + } + + await Behavior(@event, delivery); + } +} + +internal sealed class SavedHandler(Seen seen) : IEventHandler +{ + public ValueTask HandleAsync(EntryDraftSavedEvent message, CancellationToken cancellationToken) => seen.HeardAsync("saved", message, null, cancellationToken); +} + +internal sealed class RestoredHandler(Seen seen) : IEventHandler +{ + public ValueTask HandleAsync(EntryRestoredEvent message, CancellationToken cancellationToken) => seen.HeardAsync("restored", message, null, cancellationToken); +} + +// Writes another entry while it is being told of the first, once. +internal sealed class WritingHandler(Seen seen, FluentCMS.Content.IContentService content) : IEventHandler +{ + public async ValueTask HandleAsync(EntryDraftSavedEvent message, CancellationToken cancellationToken) + { + await seen.HeardAsync("writing", message, null, cancellationToken); + if (seen.Calls.Count(call => call == "writing") == 1) + { + await content.CreateAsync("article", new FluentCMS.Content.ContentDocument { ["title"] = "From a handler" }, cancellationToken); + } + } +} + +internal sealed class TrashedHandler(Seen seen) : IEventHandler +{ + public ValueTask HandleAsync(EntryTrashedEvent message, CancellationToken cancellationToken) => seen.HeardAsync("trashed", message, null, cancellationToken); +} + +[HookOrder(2)] +internal sealed class LateSaved(Seen seen) : IEventHandler +{ + public ValueTask HandleAsync(EntryDraftSavedEvent message, CancellationToken cancellationToken) => seen.HeardAsync("late", message, null, cancellationToken); +} + +[HookOrder(-1)] +internal sealed class EarlySaved(Seen seen) : IEventHandler +{ + public ValueTask HandleAsync(EntryDraftSavedEvent message, CancellationToken cancellationToken) => seen.HeardAsync("early", message, null, cancellationToken); +} + +internal sealed class TiedFirstRegistered(Seen seen) : IEventHandler +{ + public ValueTask HandleAsync(EntryDraftSavedEvent message, CancellationToken cancellationToken) => seen.HeardAsync("tied-first-registered", message, null, cancellationToken); +} + +internal sealed class TiedLastRegistered(Seen seen) : IEventHandler +{ + public ValueTask HandleAsync(EntryDraftSavedEvent message, CancellationToken cancellationToken) => seen.HeardAsync("tied-last-registered", message, null, cancellationToken); +} + +internal sealed class RankTiebreaker : IHookTiebreaker +{ + public int Rank(Type hookType) => hookType == typeof(TiedLastRegistered) ? -1 : 0; +} + +internal sealed class SavedOutbox(Seen seen) : IOutboxEventHandler +{ + public ValueTask HandleAsync(EntryDraftSavedEvent message, EventDelivery delivery, CancellationToken cancellationToken) => seen.HeardAsync("outbox", message, delivery, cancellationToken); +} + +[HookOrder(3)] +internal sealed class OutboxThird(Seen seen) : IOutboxEventHandler +{ + public ValueTask HandleAsync(EntryDraftSavedEvent message, EventDelivery delivery, CancellationToken cancellationToken) => seen.HeardAsync("outbox-3", message, delivery, cancellationToken); +} + +[HookOrder(1)] +internal sealed class OutboxFirst(Seen seen) : IOutboxEventHandler +{ + public ValueTask HandleAsync(EntryDraftSavedEvent message, EventDelivery delivery, CancellationToken cancellationToken) => seen.HeardAsync("outbox-1", message, delivery, cancellationToken); +} + +[HookOrder(2)] +internal sealed class OutboxSecond(Seen seen) : IOutboxEventHandler +{ + public ValueTask HandleAsync(EntryDraftSavedEvent message, EventDelivery delivery, CancellationToken cancellationToken) => seen.HeardAsync("outbox-2", message, delivery, cancellationToken); +} + +internal sealed class TrashedOutbox(Seen seen) : IOutboxEventHandler +{ + public ValueTask HandleAsync(EntryTrashedEvent message, EventDelivery delivery, CancellationToken cancellationToken) => seen.HeardAsync("trashed-outbox", message, delivery, cancellationToken); +} + +internal sealed record Ping(string Note) : CmsEvent; + +internal sealed record Ping2(string Note) : CmsEvent; + +internal sealed class Ping2Handler(Seen seen) : IEventHandler +{ + public ValueTask HandleAsync(Ping2 message, CancellationToken cancellationToken) => seen.HeardAsync("ping2", message, null, cancellationToken); +} + +// A plugin whose handler cannot be built. +internal sealed class BrokenPingHandler : IEventHandler +{ + public BrokenPingHandler() => throw new InvalidOperationException("ctor of a plugin handler failed"); + + public ValueTask HandleAsync(Ping message, CancellationToken cancellationToken) => ValueTask.CompletedTask; +} + +internal sealed class BrokenSavedHandler : IEventHandler +{ + public BrokenSavedHandler() => throw new InvalidOperationException("ctor of a plugin handler failed"); + + public ValueTask HandleAsync(EntryDraftSavedEvent message, CancellationToken cancellationToken) => ValueTask.CompletedTask; +} + +internal sealed class PingHandler(Seen seen) : IEventHandler +{ + public ValueTask HandleAsync(Ping message, CancellationToken cancellationToken) => seen.HeardAsync("ping", message, null, cancellationToken); +} + +internal sealed class PingOutbox(Seen seen) : IOutboxEventHandler +{ + public ValueTask HandleAsync(Ping message, EventDelivery delivery, CancellationToken cancellationToken) => seen.HeardAsync("ping-outbox", message, delivery, cancellationToken); +} + +internal static class EventRegistrations +{ + public static Action With(Seen seen, Action? more = null) => cms => + { + cms.Services.AddSingleton(seen); + more?.Invoke(cms); + }; + + public static Action Outbox(Seen seen, Action? more = null) => With(seen, cms => + { + cms.Events.AddOutboxHandler("saved-outbox"); + cms.Events.Outbox.RetryBaseDelay = TimeSpan.Zero; + cms.Events.Outbox.RetryMaxDelay = TimeSpan.Zero; + cms.Events.Outbox.RetryJitter = 0; + more?.Invoke(cms); + }); + + public static List Rows(this Content.ServiceDatabase database) => + database.Read(context => context.Set().AsNoTracking().OrderBy(row => row.Sequence).ToList()); +} + +internal sealed class EventLogs : ILoggerProvider +{ + private readonly ConcurrentDictionary _events = new(StringComparer.Ordinal); + + public int Count(string eventName) => _events.GetValueOrDefault(eventName); + + public ILogger CreateLogger(string categoryName) => new Logger(_events); + + public void Dispose() + { + } + + private sealed class Logger(ConcurrentDictionary events) : ILogger + { + public IDisposable? BeginScope(TState state) + where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + if (eventId.Name is { } name) + { + events.AddOrUpdate(name, 1, (_, count) => count + 1); + } + } + } +} diff --git a/tests/FluentCMS.Data.Tests/Events/EventLimitsTests.cs b/tests/FluentCMS.Data.Tests/Events/EventLimitsTests.cs new file mode 100644 index 000000000..d2428ec99 --- /dev/null +++ b/tests/FluentCMS.Data.Tests/Events/EventLimitsTests.cs @@ -0,0 +1,370 @@ +using FluentCMS.Content; +using FluentCMS.Core.DependencyInjection; +using FluentCMS.Core.Events; +using FluentCMS.Core.Jobs; +using FluentCMS.Core.Storage; +using FluentCMS.Core.Storage.Entities; +using FluentCMS.Data.Tests.Content; +using FluentCMS.Data.Tests.Jobs; +using FluentCMS.Events; +using FluentCMS.Hooks; +using FluentCMS.Sites; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Shouldly; +using Xunit; +using static FluentCMS.Data.Tests.Content.ServiceDatabase; + +namespace FluentCMS.Data.Tests.Events; + +// Attempts that are only counted when a handler really starts, handlers that block or write while they are told, the +// scrubbing of what is stored, and the site of a job's events. +public sealed class EventLimitsTests +{ + private static ContentDocument Titled(string title) => new() { ["title"] = title }; + + private static Task DrainAsync(IServiceProvider instance) => + instance.GetRequiredService().DrainAsync(Token); + + private static Action Registrations(Seen seen, Action? more = null) => cms => + { + cms.Services.AddSingleton(seen); + cms.Events.AddOutboxHandler("saved-outbox"); + cms.Events.AddHandler(); + cms.Events.AddOutboxHandler("ping-outbox"); + cms.Events.Outbox.RetryBaseDelay = TimeSpan.Zero; + cms.Events.Outbox.RetryMaxDelay = TimeSpan.Zero; + cms.Events.Outbox.RetryJitter = 0; + more?.Invoke(cms); + }; + + private sealed class PublishesPing(IEventBus bus) : IAfterCreateHook + { + public async ValueTask AfterCreateAsync(HookContext context, CancellationToken cancellationToken) + { + if (context.Entry["title"] as string == "Outer") + { + await bus.PublishAsync(new Ping("outer"), cancellationToken); + } + } + } + + // Writes another entry, valid or not, while it is told of the first. + private sealed class NestedWriter(IContentService content, NestedPlan plan) : IEventHandler + { + public async ValueTask HandleAsync(EntryDraftSavedEvent message, CancellationToken cancellationToken) + { + if (plan.Done++ > 0) + { + return; + } + + try + { + await content.CreateAsync("article", Titled(plan.Valid ? "Nested" : new string('x', 41)), cancellationToken); + } + catch (ValidationException) + { + } + } + } + + private sealed class NestedPlan + { + public bool Valid { get; set; } + + public int Done { get; set; } + } + + private sealed class DeadOutbox(Seen seen) : IOutboxEventHandler + { + public ValueTask HandleAsync(JobDeadLetteredEvent message, EventDelivery delivery, CancellationToken cancellationToken) => seen.HeardAsync("dead", message, delivery, cancellationToken); + } + + private sealed class FailedOutbox(Seen seen) : IOutboxEventHandler + { + public ValueTask HandleAsync(JobFailedEvent message, EventDelivery delivery, CancellationToken cancellationToken) => seen.HeardAsync("failed", message, delivery, cancellationToken); + } + + private sealed class DeadHandler(Seen seen) : IEventHandler + { + public ValueTask HandleAsync(JobDeadLetteredEvent message, CancellationToken cancellationToken) => seen.HeardAsync("dead-in-process", message, null, cancellationToken); + } + + [Fact] + public async Task HealthyEventsAreNeverFailedBecauseWorkersClaimedThemAndRanOutOfLeaseBeforeTheirTurn() + { + const int entries = 8; + var seen = new Seen(); + seen.Behavior = async (_, _) => await Task.Delay(120); + void Tight(FluentCmsBuilder cms) + { + cms.Events.Outbox.BatchSize = entries; + cms.Events.Outbox.MaxAttempts = 3; + cms.Events.Outbox.LeaseDuration = TimeSpan.FromMilliseconds(400); + cms.Events.Outbox.DeliveryTimeout = TimeSpan.FromMilliseconds(300); + } + + using var database = new ServiceDatabase(Registrations(seen, Tight)); + for (var index = 0; index < entries; index++) + { + await database.CreateAsync(Editor, Titled($"T{index}")); + } + + using var second = database.Database.NewInstance(Registrations(seen, Tight)); + using var third = database.Database.NewInstance(Registrations(seen, Tight)); + + await Task.WhenAll( + Task.Run(() => DrainAsync(database.Database.Services), Token), + Task.Run(async () => + { + await Task.Delay(250, Token); + return await DrainAsync(second); + }, Token), + Task.Run(async () => + { + await Task.Delay(500, Token); + return await DrainAsync(third); + }, Token)); + + for (var round = 0; round < 20 && database.Rows().Any(row => row.Status == OutboxStatus.Pending); round++) + { + await Task.Delay(100, Token); + await DrainAsync(database.Database.Services); + } + + var rows = database.Rows(); + rows.ShouldAllBe(row => row.Status == OutboxStatus.Delivered); + seen.Of().Select(heard => heard.EventId).Distinct().Count().ShouldBe(entries); + } + + [Fact] + public async Task AnAttemptIsOnlyCountedWhenAHandlerStarts() + { + var seen = new Seen(); + using var database = new ServiceDatabase(Registrations(seen)); + await database.CreateAsync(Editor, Titled("T")); + var drained = database.Database.Services.GetRequiredService(); + + // Claimed by a worker that never got to it: the lease runs out with no attempt used. + database.Read(context => context.Set().ExecuteUpdate(row => row + .SetProperty(column => column.LeaseOwner, "slow/worker") + .SetProperty(column => column.LeaseUntil, DateTimeOffset.UtcNow.AddMinutes(-1)))); + database.Rows().Single().Attempts.ShouldBe(0); + + (await drained.DrainAsync(Token)).ShouldBe(1); + + seen.Deliveries.ShouldHaveSingleItem().Attempt.ShouldBe(1); + database.Rows().Single().Attempts.ShouldBe(1); + } + + [Fact] + public async Task AHandlerThatWritesContentWhileItIsToldNeitherRepeatsNorLosesTheEventsOfTheWriteBeingDelivered() + { + var seen = new Seen(); + var plan = new NestedPlan { Valid = true }; + using var database = new ServiceDatabase(Registrations(seen, cms => + { + cms.Services.AddSingleton(plan); + cms.Services.AddScoped, PublishesPing>(); + cms.Events.AddHandler(); + })); + + await database.CreateAsync(Editor, Titled("Outer")); + + seen.Calls.Count(call => call == "ping").ShouldBe(1); + database.Footprint().ShouldStartWith("2,"); + database.Rows().Select(row => row.Handler).Order().ShouldBe(["ping-outbox", "saved-outbox", "saved-outbox"]); + (await DrainAsync(database.Database.Services)).ShouldBe(3); + } + + [Fact] + public async Task AHandlerWhoseOwnWriteFailsDoesNotTakeTheEventsOfTheWriteBeingDeliveredWithIt() + { + var seen = new Seen(); + var plan = new NestedPlan { Valid = false }; + using var database = new ServiceDatabase(Registrations(seen, cms => + { + cms.Services.AddSingleton(plan); + cms.Services.AddScoped, PublishesPing>(); + cms.Events.AddHandler(); + })); + + await database.CreateAsync(Editor, Titled("Outer")); + + seen.Calls.Count(call => call == "ping").ShouldBe(1); + database.Footprint().ShouldStartWith("1,"); + database.Rows().Select(row => row.Handler).Order().ShouldBe(["ping-outbox", "saved-outbox"]); + database.Read(context => context.Set().AsNoTracking().Count(job => job.Type == OutboxDrainJob.Type)).ShouldBe(1); + } + + [Fact] + public async Task AHandlerThatBlocksBeforeItsFirstAwaitIsStillCutOffAndCountedAsAbandoned() + { + var seen = new Seen(); + var logs = new RecordingLogs(); + using var gate = new ManualResetEventSlim(); + seen.Behavior = (_, _) => + { + gate.Wait(Token); + return Task.CompletedTask; + }; + using var database = new ServiceDatabase(Registrations(seen, cms => + { + cms.Services.AddSingleton(logs); + cms.Events.Outbox.DeliveryTimeout = TimeSpan.FromMilliseconds(100); + cms.Events.Outbox.LeaseDuration = TimeSpan.FromSeconds(30); + cms.Events.Outbox.MaxAttempts = 1; + })); + await database.CreateAsync(Editor, Titled("T")); + + try + { + await DrainAsync(database.Database.Services).WaitAsync(TimeSpan.FromSeconds(10), Token); + + var row = database.Rows().Single(); + row.Status.ShouldBe(OutboxStatus.Failed); + row.LastError!.ShouldContain("did not finish"); + logs.Count("DeliveryAbandoned").ShouldBe(1); + } + finally + { + gate.Set(); + } + } + + [Fact] + public async Task AnInProcessHandlerOfAJobEventThatNeverReturnsDoesNotHoldTheJobWorkerForLong() + { + using var fixture = new JobFixture(); + var seen = new Seen(); + var logs = new RecordingLogs(); + using var gate = new ManualResetEventSlim(); + seen.Behavior = (_, _) => + { + gate.Wait(Token); + return Task.CompletedTask; + }; + using var instance = fixture.Database.CreateServices(configure: cms => + { + cms.Services.AddSingleton(seen); + cms.Services.AddSingleton(logs); + cms.Events.AddHandler(); + cms.Events.Outbox.JobEventTimeout = TimeSpan.FromMilliseconds(200); + }); + var id = fixture.AddRow("no-such-job", "{}", maxAttempts: 1); + + try + { + await instance.GetRequiredService().RunNextAsync("default", "worker", Token).WaitAsync(TimeSpan.FromSeconds(10), Token); + + fixture.Job(id).Status.ShouldBe(JobStatus.DeadLettered); + logs.Count("DispatchAbandoned").ShouldBe(1); + } + finally + { + gate.Set(); + } + } + + [Fact] + public async Task TheEventsOfAJobCarryTheSiteOfTheJobAndRunThereInTheDefaultLocaleOfThatSite() + { + using var fixture = new JobFixture(); + var seen = new Seen(); + var site = Guid.CreateVersion7(); + OperationContext? seenOperation = null; + using var instance = fixture.Database.CreateServices(configure: cms => + { + cms.Services.AddSingleton(seen); + cms.Events.AddHandler(); + cms.Events.AddOutboxHandler("dead"); + }); + seen.Behavior = (@event, _) => + { + if (@event is JobDeadLetteredEvent && seenOperation is null) + { + seenOperation = instance.GetRequiredService().Current; + } + + return Task.CompletedTask; + }; + using (var scope = fixture.Services.CreateScope()) + { + var context = scope.ServiceProvider.GetRequiredService(); + context.Add(new Site { Id = site, Name = "Other", Slug = "other", DefaultLocale = "fr", CreatedAt = DateTimeOffset.UtcNow }); + context.Add(new SiteLocale { SiteId = site, Code = "fr", Position = 0, Enabled = true }); + context.SaveChanges(); + } + + var id = fixture.AddRow("no-such-job", "{}", maxAttempts: 1); + using (var scope = fixture.Services.CreateScope()) + { + scope.ServiceProvider.GetRequiredService().Set().Where(job => job.Id == id).ExecuteUpdate(job => job.SetProperty(column => column.SiteId, site)); + } + + await instance.GetRequiredService().RunNextAsync("default", "worker", Token); + await DrainAsync(instance); + + var operation = seenOperation.ShouldNotBeNull(); + operation.Site.ShouldBe(new SiteId(site)); + operation.Locale.ShouldBe("fr"); + var dead = seen.Of().ToList(); + dead.Count.ShouldBe(2); + dead.ShouldAllBe(heard => heard.Source.SiteId == site); + using var check = fixture.Services.CreateScope(); + check.ServiceProvider.GetRequiredService().Set().AsNoTracking().Single().SiteId.ShouldBe(site); + } + + [Fact] + public async Task AJobErrorThatQuotesASecretIsScrubbedBeforeItReachesAnEventOrTheOutbox() + { + using var fixture = new JobFixture(); + var seen = new Seen(); + fixture.Probe.Behavior = (_, _) => throw new InvalidOperationException("POST https://hooks.slack.com/services/T00/B00/XXSECRETXX failed, token=s3cr3t"); + using var instance = fixture.NewInstance(cms: cms => + { + cms.Services.AddSingleton(seen); + cms.Events.AddOutboxHandler("failed"); + }); + await fixture.EnqueueAsync(options: new FluentCMS.Jobs.JobOptions { MaxAttempts = 1 }); + + await instance.GetRequiredService().RunNextAsync("default", "worker", Token); + + using var check = fixture.Services.CreateScope(); + var payload = check.ServiceProvider.GetRequiredService().Set().AsNoTracking().Single().Payload; + payload.ShouldContain("https://hooks.slack.com"); + payload.ShouldNotContain("XXSECRETXX"); + payload.ShouldNotContain("s3cr3t"); + fixture.Events.Failed.Single().Error.ShouldNotContain("XXSECRETXX"); + } + + [Fact] + public async Task AHandlerErrorThatQuotesASecretIsScrubbedBeforeItIsStored() + { + var seen = new Seen(); + seen.Behavior = (_, _) => throw new InvalidOperationException("POST https://hooks.slack.com/services/T00/B00/XXSECRETXX failed, token=s3cr3t, password: \"hunter2\", Authorization: Bearer abcdef123456"); + using var database = new ServiceDatabase(Registrations(seen, cms => cms.Events.Outbox.MaxAttempts = 1)); + await database.CreateAsync(Editor, Titled("T")); + + await DrainAsync(database.Database.Services); + + var error = database.Rows().Single().LastError!; + error.ShouldContain("https://hooks.slack.com failed"); + foreach (var secret in new[] { "XXSECRETXX", "s3cr3t", "hunter2", "abcdef123456" }) + { + error.ShouldNotContain(secret); + } + } + + [Theory] + [InlineData("see https://a.example/p/q?x=1#f now", "see https://a.example now")] + [InlineData("https://user:pw@a.example:8443/p", "https://a.example:8443")] + [InlineData("api_key=abc&other=1", "api_key=***&other=1")] + [InlineData("{\"clientSecret\":\"abc\",\"name\":\"n\"}", "{\"clientSecret\":***,\"name\":\"n\"}")] + [InlineData("Authorization: Bearer abcdefghijkl", "Authorization: *** ***")] + [InlineData("nothing to hide here", "nothing to hide here")] + public void AUrlIsReducedToItsHostAndTheValueOfASecretKeyIsMasked(string text, string expected) => + SecretScrubber.Of(text).ShouldBe(expected); +} diff --git a/tests/FluentCMS.Data.Tests/Events/EventRecordTests.cs b/tests/FluentCMS.Data.Tests/Events/EventRecordTests.cs new file mode 100644 index 000000000..b9b2052fd --- /dev/null +++ b/tests/FluentCMS.Data.Tests/Events/EventRecordTests.cs @@ -0,0 +1,163 @@ +using System.Text.Json; +using FluentCMS.Content; +using FluentCMS.Data.Tests.Jobs; +using FluentCMS.Events; +using FluentCMS.Sites; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Xunit; + +namespace FluentCMS.Data.Tests.Events; + +public sealed class EventRecordTests +{ + private static readonly OperationContext Operation = new( + new Principal(PrincipalKind.AdminUser, Guid.Parse("00000000-0000-0000-0000-000000000001"), "Amir"), + Origin.Admin, + new SiteId(Guid.Parse("00000000-0000-0000-0000-0000000000a1")), + "en", + null, + "corr-1", + new DateTimeOffset(2026, 11, 2, 9, 0, 0, TimeSpan.Zero)) + { + Actor = new Principal(PrincipalKind.AdminUser, Guid.Parse("00000000-0000-0000-0000-000000000002"), "Sam"), + }; + + private static readonly Guid Entry = Guid.Parse("00000000-0000-0000-0000-0000000000e1"); + + public static TheoryData Catalogue() => + [ + new EntryDraftSavedEvent("article", Entry, "en", 3), + new EntryPublishedEvent("article", Entry, "en", 3), + new EntryUnpublishedEvent("article", Entry, "en", 3), + new EntryDraftDiscardedEvent("article", Entry, "en", 2), + new EntryVersionRestoredEvent("article", Entry, "en", 4), + new EntryTrashedEvent("article", Entry, "en", 3), + new EntryRestoredEvent("article", Entry, "en", 3), + new EntryPurgedEvent("article", Entry, "en", 3), + new EntryScheduledEvent("article", Entry, "en", 3, new DateTimeOffset(2026, 12, 1, 8, 0, 0, TimeSpan.Zero), "publish"), + new EntryScheduleCancelledEvent("article", Entry, "en", 3, "publish"), + new SettingsChangedEvent("general"), + new SiteCreatedEvent(Guid.Parse("00000000-0000-0000-0000-0000000000a1"), "main"), + new SiteUpdatedEvent(Guid.Parse("00000000-0000-0000-0000-0000000000a1"), "main"), + new CacheInvalidatedEvent(["site:1", "entry:2"]), + new JobFailedEvent(Guid.Parse("00000000-0000-0000-0000-0000000000b1"), "probe", "default", 1, 3, "It failed."), + new JobDeadLetteredEvent(Guid.Parse("00000000-0000-0000-0000-0000000000b1"), "probe", "default", 3, "It failed."), + ]; + + [Theory] + [MemberData(nameof(Catalogue))] + public void EveryEventOfTheCatalogueSurvivesAJsonRoundTripThatTheOutboxMakes(CmsEvent original) + { + var stamped = original switch + { + EntryDraftSavedEvent saved => saved with { Source = EventSource.From(Operation) }, + _ => original, + }; + + var json = JsonSerializer.Serialize(stamped, stamped.GetType(), JsonSerializerOptions.Web); + var read = (CmsEvent)JsonSerializer.Deserialize(json, stamped.GetType(), JsonSerializerOptions.Web)!; + + JsonSerializer.Serialize(read, read.GetType(), JsonSerializerOptions.Web).ShouldBe(json); + read.GetType().ShouldBe(stamped.GetType()); + read.EventId.ShouldBe(stamped.EventId); + read.Source.ShouldBe(stamped.Source); + } + + [Fact] + public void TheCatalogueHoldsEveryEventOfSpecSection411ThatThisIssueDefines() + { + var names = typeof(CmsEvent).Assembly.GetTypes() + .Where(type => type is { IsAbstract: false, IsPublic: true } && type.IsAssignableTo(typeof(CmsEvent))) + .Select(type => type.Name) + .Order() + .ToList(); + + names.ShouldBe( + [ + "CacheInvalidatedEvent", + "EntryDraftDiscardedEvent", + "EntryDraftSavedEvent", + "EntryPublishedEvent", + "EntryPurgedEvent", + "EntryRestoredEvent", + "EntryScheduleCancelledEvent", + "EntryScheduledEvent", + "EntryTrashedEvent", + "EntryUnpublishedEvent", + "EntryVersionRestoredEvent", + "JobDeadLetteredEvent", + "JobFailedEvent", + "SettingsChangedEvent", + "SiteCreatedEvent", + "SiteUpdatedEvent", + ]); + } + + [Fact] + public void ASourceNamesTheActorThatAJobImpersonatesNotTheSystemPrincipal() + { + var source = EventSource.From(Operation); + + source.ActorId.ShouldBe(Guid.Parse("00000000-0000-0000-0000-000000000002")); + source.ActorName.ShouldBe("Sam"); + source.SiteId.ShouldBe(Guid.Parse("00000000-0000-0000-0000-0000000000a1")); + source.OccurredAt.ShouldBe(Operation.Timestamp); + source.Origin.ShouldBe(Origin.Admin); + source.CorrelationId.ShouldBe("corr-1"); + } + + [Fact] + public async Task AJobThatFailsRaisesTheEventsOnTheBusAndNeverFailsTheJob() + { + var seen = new Seen(); + using var fixture = new JobFixture(); + using var instance = fixture.Database.CreateServices(configure: cms => + { + cms.Services.AddSingleton(fixture.Clock); + cms.Services.AddSingleton(seen); + cms.Events.AddHandler(); + cms.Events.AddHandler(); + }); + var id = fixture.AddRow("no-such-job", "{}", maxAttempts: 1); + + (await instance.GetRequiredService().RunNextAsync("default", "worker", TestContext.Current.CancellationToken)).ShouldBeTrue(); + + seen.Calls.ShouldBe(["job-failed", "job-dead-lettered"]); + var failed = seen.Of().Single(); + failed.JobId.ShouldBe(id); + failed.Attempt.ShouldBe(1); + failed.Source.OccurredAt.ShouldBe(JobFixture.Start); + seen.Of().Single().JobId.ShouldBe(id); + } + + [Fact] + public async Task AHandlerOfJobEventsThatThrowsLeavesTheJobSettled() + { + var seen = new Seen(); + seen.Behavior = (_, _) => throw new InvalidOperationException("The handler is broken."); + using var fixture = new JobFixture(); + using var instance = fixture.Database.CreateServices(configure: cms => + { + cms.Services.AddSingleton(fixture.Clock); + cms.Services.AddSingleton(seen); + cms.Events.AddHandler(); + cms.Events.AddHandler(); + }); + var id = fixture.AddRow("no-such-job", "{}", maxAttempts: 1); + + await instance.GetRequiredService().RunNextAsync("default", "worker", TestContext.Current.CancellationToken); + + fixture.Job(id).Status.ShouldBe(Core.Jobs.JobStatus.DeadLettered); + } + + private sealed class FailedHandler(Seen seen) : IEventHandler + { + public ValueTask HandleAsync(JobFailedEvent message, CancellationToken cancellationToken) => seen.HeardAsync("job-failed", message, null, cancellationToken); + } + + private sealed class DeadLetteredHandler(Seen seen) : IEventHandler + { + public ValueTask HandleAsync(JobDeadLetteredEvent message, CancellationToken cancellationToken) => seen.HeardAsync("job-dead-lettered", message, null, cancellationToken); + } +} diff --git a/tests/FluentCMS.Data.Tests/Events/OutboxFailureTests.cs b/tests/FluentCMS.Data.Tests/Events/OutboxFailureTests.cs new file mode 100644 index 000000000..d3d68cae3 --- /dev/null +++ b/tests/FluentCMS.Data.Tests/Events/OutboxFailureTests.cs @@ -0,0 +1,416 @@ +using System.Data.Common; +using FluentCMS.Content; +using FluentCMS.Core.Events; +using FluentCMS.Core.Storage; +using FluentCMS.Core.Storage.Entities; +using FluentCMS.Data.Tests.Content; +using FluentCMS.Data.Tests.Jobs; +using FluentCMS.Events; +using FluentCMS.Hooks; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Shouldly; +using Xunit; +using static FluentCMS.Data.Tests.Content.ServiceDatabase; + +namespace FluentCMS.Data.Tests.Events; + +// Failures injected at the stage of the outbox row, at the commit, at the settle of a delivery, in the lease of a worker +// that is still running, and in the process that delivers. +public sealed class OutboxFailureTests +{ + private static ContentDocument Titled(string title) => new() { ["title"] = title }; + + private static Task DrainAsync(IServiceProvider instance) => + instance.GetRequiredService().DrainAsync(Token); + + private static Action Fast(Action? more = null) => cms => + { + cms.Events.AddOutboxHandler("saved-outbox"); + cms.Events.Outbox.RetryBaseDelay = TimeSpan.Zero; + cms.Events.Outbox.RetryMaxDelay = TimeSpan.Zero; + cms.Events.Outbox.RetryJitter = 0; + more?.Invoke(cms); + }; + + private sealed class CommitFault : DbTransactionInterceptor + { + public volatile bool Armed; + + public override ValueTask TransactionCommittingAsync(DbTransaction transaction, TransactionEventData eventData, InterceptionResult result, CancellationToken cancellationToken = default) => + Armed ? throw new InvalidOperationException("The database went away at the commit.") : ValueTask.FromResult(result); + } + + private sealed class PoisoningHandler(CmsDbContext context) : IEventHandler + { + public ValueTask HandleAsync(EntryDraftSavedEvent message, CancellationToken cancellationToken) + { + context.Add(new Site { Name = "Duplicate", Slug = "a", DefaultLocale = "en", CreatedAt = DateTimeOffset.UtcNow }); + throw new InvalidOperationException("The handler left a mess in the context."); + } + } + + [Fact] + public async Task AWriteThatFailsToRecordItsRowLeavesNothingForTheNextWriteOfTheScopeToCommit() + { + var seen = new Seen(); + var faults = new JobFaults(); + using var database = new ServiceDatabase(EventRegistrations.With(seen, Fast(cms => cms.Services.ConfigureDbContext(options => options.AddInterceptors(faults))))); + + var second = await database.AsAsync(Editor, async services => + { + var content = services.GetRequiredService(); + faults.Fail(command => command.CommandText.Contains("cms_outbox", StringComparison.Ordinal)); + await Should.ThrowAsync(() => content.CreateAsync("article", Titled("First"), Token)); + faults.Recover(); + return IdOf(await content.CreateAsync("article", Titled("Second"), Token)); + }); + + database.Rows().ShouldHaveSingleItem().Payload.ShouldContain(second.ToString()); + database.Footprint().ShouldStartWith("1,"); + database.Versions(second).Count.ShouldBe(1); + } + + [Fact] + public async Task AWriteWhoseCommitFailsIsNeverDeliveredNorRecorded() + { + var seen = new Seen(); + var fault = new CommitFault(); + using var database = new ServiceDatabase(EventRegistrations.With(seen, Fast(cms => + { + cms.Events.AddHandler(); + cms.Services.ConfigureDbContext(options => options.AddInterceptors(fault)); + }))); + + fault.Armed = true; + await Should.ThrowAsync(() => database.CreateAsync(Editor, Titled("T"))); + fault.Armed = false; + + seen.Events.ShouldBeEmpty(); + database.Rows().ShouldBeEmpty(); + database.Footprint().ShouldBe("0,0,0,0,0"); + (await DrainAsync(database.Database.Services)).ShouldBe(0); + } + + [Fact] + public async Task AWriteDoesNotSaveWhatTheCallerTrackedAndNeverSaved() + { + var seen = new Seen(); + using var database = new ServiceDatabase(EventRegistrations.With(seen, Fast())); + + var (state, kept) = await database.AsAsync(Editor, async services => + { + var context = services.GetRequiredService(); + var site = new Site { Name = "Pending", Slug = "pending", DefaultLocale = "en", CreatedAt = DateTimeOffset.UtcNow }; + context.Add(site); + + await services.GetRequiredService().CreateAsync("article", Titled("T"), Token); + + return (context.Entry(site).State, site.Slug); + }); + + state.ShouldBe(EntityState.Added); + kept.ShouldBe("pending"); + database.Read(context => context.Set().AsNoTracking().Count(site => site.Slug == "pending")).ShouldBe(0); + database.Rows().ShouldHaveSingleItem(); + } + + [Fact] + public async Task AnInProcessHandlerThatLeavesAMessInTheContextDoesNotSpoilTheNextWrite() + { + var seen = new Seen(); + using var database = new ServiceDatabase(EventRegistrations.With(seen, Fast(cms => cms.Events.AddHandler()))); + + await database.AsAsync(Editor, async services => + { + var content = services.GetRequiredService(); + await content.CreateAsync("article", Titled("First"), Token); + await content.CreateAsync("article", Titled("Second"), Token); + return true; + }); + + database.Footprint().ShouldStartWith("2,"); + database.Rows().Count.ShouldBe(2); + } + + [Fact] + public async Task AnInProcessHandlerMayWriteContentWhileItIsBeingTold() + { + var seen = new Seen(); + using var database = new ServiceDatabase(EventRegistrations.With(seen, Fast(cms => + { + cms.Events.AddHandler(); + cms.Events.AddHandler(); + }))); + + await database.CreateAsync(Editor, Titled("Outer")); + + database.Footprint().ShouldStartWith("2,"); + seen.Calls.Count(call => call == "saved").ShouldBe(2); + database.Rows().Count.ShouldBe(2); + } + + [Fact] + public async Task ADeliveryWhoseSettleFailsIsDeliveredAgainWithTheSameKeyWhenItsLeaseEnds() + { + var seen = new Seen(); + var faults = new JobFaults(); + var logs = new RecordingLogs(); + using var database = new ServiceDatabase(EventRegistrations.With(seen, Fast(cms => + { + cms.Services.AddSingleton(logs); + cms.Services.ConfigureDbContext(options => options.AddInterceptors(faults)); + }))); + await database.CreateAsync(Editor, Titled("T")); + + faults.Fail(command => command.CommandText.StartsWith("UPDATE", StringComparison.Ordinal) && command.CommandText.Contains("delivered_at", StringComparison.Ordinal)); + (await DrainAsync(database.Database.Services)).ShouldBe(1); + faults.Recover(); + + logs.Count("SettleFailed").ShouldBe(1); + var held = database.Rows().Single(); + held.Status.ShouldBe(OutboxStatus.Pending); + held.LeaseOwner.ShouldNotBeNull(); + + (await DrainAsync(database.Database.Services)).ShouldBe(0); + ExpireLeases(database); + (await DrainAsync(database.Database.Services)).ShouldBe(1); + + seen.Deliveries.Select(delivery => delivery.Key).Distinct().ShouldHaveSingleItem(); + seen.Deliveries.Select(delivery => delivery.Attempt).ShouldBe([1, 2]); + database.Rows().Single().Status.ShouldBe(OutboxStatus.Delivered); + } + + [Fact] + public async Task AWorkerWhoseLeaseWasTakenOverMidBatchDeliversNothingMoreAndLeavesTheNewOwnersResultAlone() + { + var seen = new Seen(); + var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var heard = 0; + seen.Behavior = async (_, _) => + { + if (Interlocked.Increment(ref heard) != 1) + { + return; + } + + // The first worker is in its first delivery when its lease on the whole batch runs out. + entered.SetResult(); + await gate.Task; + throw new InvalidOperationException("The slow worker fails when it finally ends."); + }; + + using var database = new ServiceDatabase(EventRegistrations.With(seen, Fast())); + for (var index = 0; index < 3; index++) + { + await database.CreateAsync(Editor, Titled($"T{index}")); + } + + using var other = database.Database.NewInstance(EventRegistrations.With(seen, Fast())); + var slow = Task.Run(() => DrainAsync(database.Database.Services), Token); + await entered.Task.WaitAsync(TimeSpan.FromSeconds(30), Token); + + ExpireLeases(database); + (await DrainAsync(other)).ShouldBe(3); + + gate.SetResult(); + (await slow).ShouldBe(0); + + var perEvent = seen.Of().GroupBy(heard => heard.EventId).Select(group => group.Count()).Order().ToList(); + perEvent.ShouldBe([1, 1, 2]); + var rows = database.Rows(); + rows.ShouldAllBe(row => row.Status == OutboxStatus.Delivered); + rows.ShouldAllBe(row => row.LastError == null); + } + + [Fact] + public async Task ADeliveryThatKillsItsProcessEveryTimeIsLeftAsFailedAtTheLimitAndNeverDeliveredAgain() + { + var seen = new Seen(); + var logs = new RecordingLogs(); + using var database = new ServiceDatabase(EventRegistrations.With(seen, Fast(cms => + { + cms.Events.Outbox.MaxAttempts = 3; + cms.Services.AddSingleton(logs); + }))); + await database.CreateAsync(Editor, Titled("T")); + + // Three workers claimed the row and died before settling it. + database.Read(context => context.Set().ExecuteUpdate(row => row + .SetProperty(column => column.LeaseOwner, "dead/worker") + .SetProperty(column => column.LeaseUntil, DateTimeOffset.UtcNow.AddMinutes(-1)) + .SetProperty(column => column.Attempts, 3))); + + (await DrainAsync(database.Database.Services)).ShouldBe(0); + + seen.Events.ShouldBeEmpty(); + var row = database.Rows().Single(); + row.Status.ShouldBe(OutboxStatus.Failed); + row.Attempts.ShouldBe(3); + row.LastError.ShouldNotBeNull(); + logs.Count("Exhausted").ShouldBe(1); + } + + [Fact] + public async Task AHandlerThatNeverReturnsIsCutOffAndCountedAsFailed() + { + var seen = new Seen(); + seen.Behavior = (_, _) => new TaskCompletionSource().Task; + using var database = new ServiceDatabase(EventRegistrations.With(seen, Fast(cms => + { + cms.Events.Outbox.DeliveryTimeout = TimeSpan.FromMilliseconds(150); + cms.Events.Outbox.LeaseDuration = TimeSpan.FromSeconds(30); + cms.Events.Outbox.MaxAttempts = 2; + }))); + await database.CreateAsync(Editor, Titled("T")); + + (await DrainAsync(database.Database.Services)).ShouldBe(0); + + seen.Deliveries.Count.ShouldBe(2); + var row = database.Rows().Single(); + row.Status.ShouldBe(OutboxStatus.Failed); + row.LastError!.ShouldContain("did not finish"); + } + + [Fact] + public async Task ADrainTheHostIsStoppingDoesNotCallTheRestOfItsBatchAndGivesTheAttemptBack() + { + var seen = new Seen(); + using var stopping = new CancellationTokenSource(); + seen.Behavior = (_, _) => + { + stopping.Cancel(); + throw new OperationCanceledException(stopping.Token); + }; + using var database = new ServiceDatabase(EventRegistrations.With(seen, Fast())); + for (var index = 0; index < 4; index++) + { + await database.CreateAsync(Editor, Titled($"T{index}")); + } + + await DrainWithAsync(database.Database.Services, stopping.Token); + + seen.Calls.Count.ShouldBe(1); + var rows = database.Rows(); + rows.ShouldAllBe(row => row.Status == OutboxStatus.Pending && row.Attempts == 0 && row.LeaseOwner == null && row.LastError == null); + } + + [Fact] + public async Task ARowWhoseHandlerThisInstanceDoesNotHaveWaitsWithoutUsingAnAttempt() + { + var seen = new Seen(); + var logs = new RecordingLogs(); + using var database = new ServiceDatabase(EventRegistrations.With(seen, Fast(cms => + { + cms.Events.Outbox.MaxAttempts = 2; + cms.Services.AddSingleton(logs); + }))); + database.Read(context => + { + context.Add(new OutboxMessage + { + EventId = Guid.CreateVersion7(), + EventType = "Acme.Thing", + Handler = "acme-plugin", + Payload = "{}", + OccurredAt = DateTimeOffset.UtcNow, + Sequence = 1, + AvailableAt = DateTimeOffset.UtcNow.AddMinutes(-1), + }); + return context.SaveChanges(); + }); + + for (var drain = 0; drain < 3; drain++) + { + await DrainAsync(database.Database.Services); + } + + var row = database.Rows().Single(); + row.Status.ShouldBe(OutboxStatus.Pending); + row.Attempts.ShouldBe(0); + row.LeaseOwner.ShouldBeNull(); + row.AvailableAt.ShouldBeGreaterThan(DateTimeOffset.UtcNow); + logs.Count("HandlerMissing").ShouldBe(1); + } + + [Fact] + public async Task AFailedDeliveryCanBeListedRetriedAndRemoved() + { + var seen = new Seen(); + var behave = 0; + seen.Behavior = (_, _) => Volatile.Read(ref behave) == 0 ? throw new InvalidOperationException("Down.") : Task.CompletedTask; + using var database = new ServiceDatabase(EventRegistrations.With(seen, Fast(cms => cms.Events.Outbox.MaxAttempts = 2))); + await database.CreateAsync(Editor, Titled("First")); + await database.CreateAsync(Editor, Titled("Second")); + await DrainAsync(database.Database.Services); + var dead = database.Database.Services.GetRequiredService(); + + var failed = await dead.ListAsync(cancellationToken: Token); + + failed.Count.ShouldBe(2); + failed.ShouldAllBe(item => item.Handler == "saved-outbox" && item.Attempts == 2 && item.LastError != null); + + Volatile.Write(ref behave, 1); + (await dead.RetryAsync(failed[0].Id, Token)).ShouldBeTrue(); + (await dead.RetryAsync(failed[0].Id, Token)).ShouldBeFalse(); + (await dead.RemoveAsync(failed[1].Id, Token)).ShouldBeTrue(); + (await dead.RemoveAsync(failed[1].Id, Token)).ShouldBeFalse(); + + (await DrainAsync(database.Database.Services)).ShouldBe(1); + (await dead.ListAsync(cancellationToken: Token)).ShouldBeEmpty(); + database.Rows().ShouldHaveSingleItem().Status.ShouldBe(OutboxStatus.Delivered); + } + + [Fact] + public async Task AFailureStoresTheTypeOfTheExceptionAndNoCredentialsOrQueryStrings() + { + var seen = new Seen(); + seen.Behavior = (_, _) => throw new HttpRequestException("POST https://user:pw@hooks.example/x?token=s3cr3t#frag failed with 500"); + using var database = new ServiceDatabase(EventRegistrations.With(seen, Fast(cms => cms.Events.Outbox.MaxAttempts = 1))); + await database.CreateAsync(Editor, Titled("T")); + + await DrainAsync(database.Database.Services); + + var error = database.Rows().Single().LastError!; + error.ShouldStartWith("System.Net.Http.HttpRequestException: "); + error.ShouldContain("https://hooks.example failed with 500"); + error.ShouldNotContain("s3cr3t"); + error.ShouldNotContain("pw@"); + error.ShouldNotContain("frag"); + } + + [Fact] + public void ErrorTextKeepsTheHostAndPathOfEveryUrlAndNothingElseOfIt() + { + var text = OutboxErrorText.Of(new InvalidOperationException("a http://h/p?q=1 and ftp://u:p@h2:21/f.txt?x=y then wss://h3/ws#z"), "{}"); + + text.ShouldBe("System.InvalidOperationException: a http://h and ftp://h2:21 then wss://h3"); + OutboxErrorText.Of(new InvalidOperationException(new string('x', 5000)), "{}").Length.ShouldBe(Job.LastErrorMaxLength); + } + + [Fact] + public async Task OnlyARunWithPruneRemovesOldDeliveredRows() + { + var seen = new Seen(); + using var database = new ServiceDatabase(EventRegistrations.With(seen, Fast(cms => cms.Events.Outbox.Retention = TimeSpan.Zero))); + await database.CreateAsync(Editor, Titled("T")); + await DrainAsync(database.Database.Services); + await Task.Delay(5, Token); + + await DrainAsync(database.Database.Services); + database.Rows().Count.ShouldBe(1); + + await database.Database.Services.GetRequiredService().DrainAsync(prune: true, Token); + database.Rows().ShouldBeEmpty(); + } + + private static Task DrainWithAsync(IServiceProvider instance, CancellationToken token) => + instance.GetRequiredService().DrainAsync(prune: false, token); + + // As if the workers that hold these rows had stopped renewing. + private static void ExpireLeases(ServiceDatabase database) => + database.Read(context => context.Set().Where(row => row.LeaseOwner != null).ExecuteUpdate(row => row.SetProperty(column => column.LeaseUntil, DateTimeOffset.UtcNow.AddMinutes(-1)))); +} diff --git a/tests/FluentCMS.Data.Tests/Events/OutboxTests.cs b/tests/FluentCMS.Data.Tests/Events/OutboxTests.cs new file mode 100644 index 000000000..a394c5e2c --- /dev/null +++ b/tests/FluentCMS.Data.Tests/Events/OutboxTests.cs @@ -0,0 +1,418 @@ +using FluentCMS.Content; +using FluentCMS.Core.Content.Hooks; +using FluentCMS.Core.DependencyInjection; +using FluentCMS.Core.Events; +using FluentCMS.Core.Storage; +using FluentCMS.Core.Storage.Entities; +using FluentCMS.Data.Tests.Content; +using FluentCMS.Data.Tests.Jobs; +using FluentCMS.Events; +using FluentCMS.Jobs; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Shouldly; +using Xunit; +using static FluentCMS.Data.Tests.Content.ServiceDatabase; + +namespace FluentCMS.Data.Tests.Events; + +public sealed class OutboxTests +{ + private static ContentDocument Titled(string title) => new() { ["title"] = title }; + + private static Task DrainAsync(IServiceProvider instance) => + instance.GetRequiredService().DrainAsync(Token); + + private static Task PruneAsync(IServiceProvider instance) => + instance.GetRequiredService().DrainAsync(prune: true, Token); + + [Fact] + public async Task TheRowIsWrittenWithTheWriteAndNamesTheSameEventAsTheHandlersOfThisProcess() + { + var seen = new Seen(); + using var database = new ServiceDatabase(EventRegistrations.Outbox(seen, cms => cms.Events.AddHandler())); + + var entry = await database.CreateAsync(Editor, Titled("T")); + + var row = database.Rows().ShouldHaveSingleItem(); + row.Handler.ShouldBe("saved-outbox"); + row.Status.ShouldBe(OutboxStatus.Pending); + row.Attempts.ShouldBe(0); + row.SiteId.ShouldBe(SiteA.Value); + row.Payload.ShouldContain(IdOf(entry).ToString()); + row.EventId.ShouldBe(seen.Of().Single().EventId); + row.EventType.ShouldBe(typeof(EntryDraftSavedEvent).FullName); + } + + [Fact] + public async Task TheDrainDeliversTheEventOnceAndMarksTheRow() + { + var seen = new Seen(); + using var database = new ServiceDatabase(EventRegistrations.Outbox(seen)); + var entry = await database.CreateAsync(Editor, Titled("T")); + + (await DrainAsync(database.Database.Services)).ShouldBe(1); + (await DrainAsync(database.Database.Services)).ShouldBe(0); + + var heard = seen.Of().ShouldHaveSingleItem(); + heard.EntryId.ShouldBe(IdOf(entry)); + heard.Source.ActorId.ShouldBe(Editor.Id); + var delivery = seen.Deliveries.ShouldHaveSingleItem(); + delivery.Attempt.ShouldBe(1); + + var row = database.Rows().Single(); + row.Status.ShouldBe(OutboxStatus.Delivered); + row.DeliveredAt.ShouldNotBeNull(); + row.LeaseOwner.ShouldBeNull(); + delivery.Key.ShouldBe(row.Id); + } + + [Fact] + public async Task AnOutboxHandlerGetsTheEventAfterACrashBetweenTheCommitAndTheDispatch() + { + var seen = new Seen(); + var faults = new JobFaults(); + var logs = new RecordingLogs(); + using var crashing = new ServiceDatabase(EventRegistrations.Outbox(seen, cms => + { + cms.Services.AddSingleton(logs); + cms.Services.ConfigureDbContext(options => options.AddInterceptors(faults)); + })); + + // The process loses the database as soon as the write has committed: nothing asks for the delivery, and none runs. + faults.Fail(command => command.CommandText.Contains("cms_job", StringComparison.Ordinal)); + var entry = await crashing.CreateAsync(Editor, Titled("T")); + + logs.Count("KickFailed").ShouldBe(1); + seen.Events.ShouldBeEmpty(); + crashing.Rows().ShouldHaveSingleItem().Status.ShouldBe(OutboxStatus.Pending); + + // A process that starts later finds the row and delivers it. + using var restarted = crashing.Database.NewInstance(EventRegistrations.Outbox(seen)); + (await DrainAsync(restarted)).ShouldBe(1); + + seen.Of().ShouldHaveSingleItem().EntryId.ShouldBe(IdOf(entry)); + crashing.Rows().Single().Status.ShouldBe(OutboxStatus.Delivered); + } + + [Fact] + public async Task AWriteThatRollsBackLeavesNoRowForTheOutbox() + { + var seen = new Seen(); + using var database = new ServiceDatabase(EventRegistrations.Outbox(seen)); + + await Should.ThrowAsync(() => database.CreateAsync(Editor, Titled(new string('x', 41)))); + + database.Rows().ShouldBeEmpty(); + (await DrainAsync(database.Database.Services)).ShouldBe(0); + seen.Events.ShouldBeEmpty(); + } + + [Fact] + public async Task InstancesDrainingTogetherDeliverEachEventOnce() + { + const int entries = 12; + var seen = new Seen(); + seen.Behavior = async (_, _) => await Task.Delay(3); + using var database = new ServiceDatabase(EventRegistrations.Outbox(seen, cms => cms.Events.Outbox.BatchSize = 4)); + for (var index = 0; index < entries; index++) + { + await database.CreateAsync(Editor, Titled($"T{index}")); + } + + using var second = database.Database.NewInstance(EventRegistrations.Outbox(seen, cms => cms.Events.Outbox.BatchSize = 4)); + using var third = database.Database.NewInstance(EventRegistrations.Outbox(seen, cms => cms.Events.Outbox.BatchSize = 4)); + + var delivered = await Task.WhenAll( + Task.Run(() => DrainAsync(database.Database.Services), Token), + Task.Run(() => DrainAsync(second), Token), + Task.Run(() => DrainAsync(third), Token)); + + delivered.Sum().ShouldBe(entries); + seen.Deliveries.Select(delivery => delivery.Key).Distinct().Count().ShouldBe(entries); + seen.Of().Select(heard => heard.EventId).Distinct().Count().ShouldBe(entries); + seen.Deliveries.ShouldAllBe(delivery => delivery.Attempt == 1); + database.Rows().ShouldAllBe(row => row.Status == OutboxStatus.Delivered); + } + + [Fact] + public async Task AFailingHandlerIsCalledAgainWithTheSameKeyUntilItSucceeds() + { + var seen = new Seen(); + seen.Behavior = (_, delivery) => delivery!.Attempt < 3 ? throw new InvalidOperationException("The endpoint is down.") : Task.CompletedTask; + using var database = new ServiceDatabase(EventRegistrations.Outbox(seen)); + await database.CreateAsync(Editor, Titled("T")); + + (await DrainAsync(database.Database.Services)).ShouldBe(1); + + seen.Deliveries.Select(delivery => delivery.Attempt).ShouldBe([1, 2, 3]); + seen.Deliveries.Select(delivery => delivery.Key).Distinct().ShouldHaveSingleItem(); + var row = database.Rows().Single(); + row.Status.ShouldBe(OutboxStatus.Delivered); + row.Attempts.ShouldBe(3); + row.LastError.ShouldBeNull(); + } + + [Fact] + public async Task AHandlerThatKeepsFailingIsLeftAsFailedAfterItsAttempts() + { + var seen = new Seen(); + seen.Behavior = (_, _) => throw new InvalidOperationException("The endpoint is down."); + using var database = new ServiceDatabase(EventRegistrations.Outbox(seen, cms => cms.Events.Outbox.MaxAttempts = 3)); + await database.CreateAsync(Editor, Titled("T")); + + (await DrainAsync(database.Database.Services)).ShouldBe(0); + (await DrainAsync(database.Database.Services)).ShouldBe(0); + + seen.Deliveries.Count.ShouldBe(3); + var row = database.Rows().Single(); + row.Status.ShouldBe(OutboxStatus.Failed); + row.Attempts.ShouldBe(3); + row.LastError!.ShouldContain("The endpoint is down."); + } + + [Fact] + public async Task AFailingHandlerDoesNotHoldBackTheOthers() + { + var seen = new Seen(); + seen.Behavior = (_, _) => seen.Calls.Last() == "outbox-1" ? throw new InvalidOperationException("The endpoint is down.") : Task.CompletedTask; + using var database = new ServiceDatabase(EventRegistrations.With(seen, cms => + { + cms.Events.AddOutboxHandler("first"); + cms.Events.AddOutboxHandler("second"); + cms.Events.Outbox.MaxAttempts = 2; + cms.Events.Outbox.RetryBaseDelay = TimeSpan.Zero; + cms.Events.Outbox.RetryMaxDelay = TimeSpan.Zero; + cms.Events.Outbox.RetryJitter = 0; + })); + await database.CreateAsync(Editor, Titled("T")); + + (await DrainAsync(database.Database.Services)).ShouldBe(1); + + database.Rows().ToDictionary(row => row.Handler, row => row.Status).ShouldBe(new Dictionary + { + ["first"] = OutboxStatus.Failed, + ["second"] = OutboxStatus.Delivered, + }); + } + + [Fact] + public async Task OutboxHandlersAreDeliveredInHookOrder() + { + var seen = new Seen(); + using var database = new ServiceDatabase(EventRegistrations.With(seen, cms => + { + cms.Events.AddOutboxHandler("third"); + cms.Events.AddOutboxHandler("first"); + cms.Events.AddOutboxHandler("second"); + })); + await database.CreateAsync(Editor, Titled("T")); + + database.Rows().Select(row => row.Handler).ShouldBe(["first", "second", "third"]); + await DrainAsync(database.Database.Services); + + seen.Calls.ShouldBe(["outbox-1", "outbox-2", "outbox-3"]); + } + + [Fact] + public async Task OutboxHandlersFollowThePluginDependencyRankWhenTheirOrderIsEqual() + { + var seen = new Seen(); + using var database = new ServiceDatabase(EventRegistrations.With(seen, cms => + { + cms.Services.AddSingleton(); + cms.Events.AddOutboxHandler("registered-first"); + cms.Events.AddOutboxHandler("registered-last"); + })); + await database.CreateAsync(Editor, Titled("T")); + + await DrainAsync(database.Database.Services); + + seen.Calls.ShouldBe(["plugin-outbox", "outbox"]); + } + + [Fact] + public async Task ADeliveryHeldByADeadWorkerIsTakenOverWhenItsLeaseEnds() + { + var seen = new Seen(); + var clock = new ManualTimeProvider(JobFixture.Start); + using var database = new ServiceDatabase(EventRegistrations.Outbox(seen, cms => cms.Services.AddSingleton(clock))); + await database.CreateAsync(Editor, Titled("T")); + + // The first worker claimed the row and died before settling it. + database.Read(context => context.Set().ExecuteUpdate(row => row + .SetProperty(column => column.LeaseOwner, "dead/worker") + .SetProperty(column => column.LeaseUntil, clock.GetUtcNow() + TimeSpan.FromMinutes(5)) + .SetProperty(column => column.Attempts, 1))); + + (await DrainAsync(database.Database.Services)).ShouldBe(0); + seen.Events.ShouldBeEmpty(); + + clock.Advance(TimeSpan.FromMinutes(6)); + (await DrainAsync(database.Database.Services)).ShouldBe(1); + + seen.Deliveries.ShouldHaveSingleItem().Attempt.ShouldBe(2); + } + + [Fact] + public async Task DeliveredRowsAreKeptForTheRetentionAndThenRemoved() + { + var seen = new Seen(); + var clock = new ManualTimeProvider(JobFixture.Start); + using var database = new ServiceDatabase(EventRegistrations.Outbox(seen, cms => + { + cms.Services.AddSingleton(clock); + cms.Events.Outbox.Retention = TimeSpan.FromDays(1); + })); + await database.CreateAsync(Editor, Titled("T")); + await DrainAsync(database.Database.Services); + + clock.Advance(TimeSpan.FromHours(23)); + await PruneAsync(database.Database.Services); + database.Rows().Count.ShouldBe(1); + + clock.Advance(TimeSpan.FromHours(2)); + await DrainAsync(database.Database.Services); + database.Rows().Count.ShouldBe(1); + + await PruneAsync(database.Database.Services); + database.Rows().ShouldBeEmpty(); + } + + [Fact] + public async Task ARunningJobRunnerDeliversAfterTheCommit() + { + var seen = new Seen(); + using var database = new ServiceDatabase(EventRegistrations.Outbox(seen, cms => cms.Jobs.Options.PollInterval = TimeSpan.FromMilliseconds(10))); + var runner = database.Database.Services.GetRequiredService(); + await runner.StartAsync(Token); + try + { + var entry = await database.CreateAsync(Editor, Titled("T")); + + await Eventually.HoldsAsync(() => !seen.Deliveries.IsEmpty); + seen.Of().ShouldHaveSingleItem().EntryId.ShouldBe(IdOf(entry)); + } + finally + { + await runner.StopAsync(Token); + } + } + + [Fact] + public async Task WritesAskForOneDrainWhileOneIsWaiting() + { + var seen = new Seen(); + using var database = new ServiceDatabase(EventRegistrations.Outbox(seen)); + + for (var index = 0; index < 3; index++) + { + await database.CreateAsync(Editor, Titled($"T{index}")); + } + + database.Read(context => context.Set().AsNoTracking().Count(job => job.Type == OutboxDrainJob.Type)).ShouldBe(1); + } + + [Fact] + public async Task AWriteWithNoOutboxHandlerRecordsNothingAndAsksForNoDrain() + { + using var database = new ServiceDatabase(); + + await database.CreateAsync(Editor, Titled("T")); + + database.Rows().ShouldBeEmpty(); + database.Read(context => context.Set().AsNoTracking().Count()).ShouldBe(0); + } + + [Fact] + public async Task StartupSchedulesTheRecurringDrainThatCatchesWhatNothingAskedFor() + { + using var database = new ServiceDatabase(EventRegistrations.Outbox(new Seen())); + + var schedule = database.Database.Services.GetServices().OfType().Single(); + await schedule.StartAsync(Token); + await schedule.ExecuteTask!; + + var recurring = database.Read(context => context.Set().AsNoTracking().Single(job => job.Name == OutboxDrainJob.RecurringName)); + recurring.Type.ShouldBe(OutboxDrainJob.Type); + recurring.Cron.ShouldBe("* * * * *"); + recurring.Queue.ShouldBe(OutboxDrainJob.Queue); + recurring.Payload.ShouldContain("true"); + recurring.Enabled.ShouldBeTrue(); + } + + [Fact] + public async Task AHostWithNoOutboxHandlerSchedulesNoDrain() + { + using var database = new ServiceDatabase(); + + var schedule = database.Database.Services.GetServices().OfType().Single(); + await schedule.StartAsync(Token); + await schedule.ExecuteTask!; + + database.Read(context => context.Set().AsNoTracking().Count()).ShouldBe(0); + } + + [Fact] + public async Task TheScheduleIsRetriedUntilItIsStoredWhenTheDatabaseIsDownAtStartup() + { + var faults = new JobFaults(); + var logs = new RecordingLogs(); + var clock = new ManualTimeProvider(JobFixture.Start); + using var database = new ServiceDatabase(EventRegistrations.Outbox(new Seen(), cms => + { + cms.Services.AddSingleton(clock); + cms.Services.AddSingleton(logs); + cms.Services.ConfigureDbContext(options => options.AddInterceptors(faults)); + })); + faults.Fail(command => command.CommandText.Contains("cms_recurring_job", StringComparison.Ordinal)); + + var schedule = database.Database.Services.GetServices().OfType().Single(); + await schedule.StartAsync(Token); + await Eventually.HoldsAsync(() => logs.Count("RecurringFailed") >= 1); + database.Read(context => context.Set().AsNoTracking().Count()).ShouldBe(0); + + faults.Recover(); + clock.Advance(TimeSpan.FromSeconds(30)); + await Eventually.CompletesAsync(schedule.ExecuteTask!, () => clock.Advance(TimeSpan.FromSeconds(30))); + + database.Read(context => context.Set().AsNoTracking().Count()).ShouldBe(1); + } + + [Fact] + public void TwoHandlersUnderOneNameAreRefused() + { + var seen = new Seen(); + using var database = new ServiceDatabase(EventRegistrations.With(seen, cms => + { + cms.Events.AddOutboxHandler("same"); + cms.Events.AddOutboxHandler("same"); + })); + + Should.Throw(() => database.Database.Services.GetRequiredService()) + .Message.ShouldContain("FCMS2601"); + } + + [Fact] + public void AnInvalidOutboxOptionIsRefusedAtStartup() + { + var exception = Should.Throw(() => new ServiceDatabase(cms => cms.Events.Outbox.BatchSize = 0)); + + exception.Message.ShouldContain("FCMS2603"); + } + + [Fact] + public void AHandlerNameTooLongForTheColumnIsRefused() => + Should.Throw(() => new ServiceDatabase(cms => cms.Events.AddOutboxHandler(new string('n', 129)))); + + private sealed class PluginOutbox(Seen seen) : IOutboxEventHandler + { + public ValueTask HandleAsync(EntryDraftSavedEvent message, EventDelivery delivery, CancellationToken cancellationToken) => seen.HeardAsync("plugin-outbox", message, delivery, cancellationToken); + } + + private sealed class OutboxRank : IHookTiebreaker + { + public int Rank(Type hookType) => hookType == typeof(PluginOutbox) ? -1 : 0; + } +} diff --git a/tests/FluentCMS.Data.Tests/Jobs/JobFixture.cs b/tests/FluentCMS.Data.Tests/Jobs/JobFixture.cs index f56c24c75..f85c7dc75 100644 --- a/tests/FluentCMS.Data.Tests/Jobs/JobFixture.cs +++ b/tests/FluentCMS.Data.Tests/Jobs/JobFixture.cs @@ -6,6 +6,7 @@ using FluentCMS.Core.Storage; using FluentCMS.Core.Storage.Entities; using FluentCMS.Data.Tests.Sqlite; +using FluentCMS.Events; using FluentCMS.Jobs; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; diff --git a/tests/FluentCMS.Data.Tests/Migrations/MigrationRunnerTests.cs b/tests/FluentCMS.Data.Tests/Migrations/MigrationRunnerTests.cs index c9778770f..dc116b22f 100644 --- a/tests/FluentCMS.Data.Tests/Migrations/MigrationRunnerTests.cs +++ b/tests/FluentCMS.Data.Tests/Migrations/MigrationRunnerTests.cs @@ -34,7 +34,7 @@ public async Task AFreshDatabaseIsFullyMigratedAtStartup() await host.StartAsync(Token); _database.Tables().ShouldBe( - ["__EFMigrationsLock", "cms__migrations", "cms_article", "cms_content__migrations", "cms_document", "cms_job", "cms_job_attempt", "cms_recurring_job", "cms_reference", "cms_role", "cms_role_permission", "cms_site", "cms_site_locale", "cms_version"], + ["__EFMigrationsLock", "cms__migrations", "cms_article", "cms_content__migrations", "cms_document", "cms_job", "cms_job_attempt", "cms_outbox", "cms_recurring_job", "cms_reference", "cms_role", "cms_role_permission", "cms_site", "cms_site_locale", "cms_version"], ignoreOrder: true); _database.ReadStrings("select MigrationId from cms__migrations").ShouldContain(id => id.EndsWith("_InitialSystem", StringComparison.Ordinal)); _database.ReadStrings("select MigrationId from cms_content__migrations").ShouldHaveSingleItem().ShouldEndWith("_Initial"); @@ -250,7 +250,7 @@ public void TheContentContextHasItsHistoryTableAndExcludesEverythingElse() var tables = context.GetService().Model.GetRelationalModel().Tables; tables.Where(table => !table.IsExcludedFromMigrations).Select(table => table.Name).ShouldBe(["cms_article"]); - tables.Where(table => table.IsExcludedFromMigrations).Select(table => table.Name).ShouldBe(["cms_document", "cms_job", "cms_job_attempt", "cms_recurring_job", "cms_reference", "cms_role", "cms_role_permission", "cms_site", "cms_site_locale", "cms_version"], ignoreOrder: true); + tables.Where(table => table.IsExcludedFromMigrations).Select(table => table.Name).ShouldBe(["cms_document", "cms_job", "cms_job_attempt", "cms_outbox", "cms_recurring_job", "cms_reference", "cms_role", "cms_role_permission", "cms_site", "cms_site_locale", "cms_version"], ignoreOrder: true); } [Fact] diff --git a/tests/FluentCMS.Data.Tests/Providers/OfflineProviderTests.cs b/tests/FluentCMS.Data.Tests/Providers/OfflineProviderTests.cs index efb1eb214..7c6723ac8 100644 --- a/tests/FluentCMS.Data.Tests/Providers/OfflineProviderTests.cs +++ b/tests/FluentCMS.Data.Tests/Providers/OfflineProviderTests.cs @@ -124,7 +124,7 @@ public void ThePostgreSqlSystemMigrationsMatchTheModel() using var scope = services.CreateScope(); var database = scope.ServiceProvider.GetRequiredService().Database; - database.GetMigrations().Count().ShouldBe(5); + database.GetMigrations().Count().ShouldBe(6); database.HasPendingModelChanges().ShouldBeFalse(); } @@ -135,7 +135,7 @@ public void TheSqlServerSystemMigrationsMatchTheModel() using var scope = services.CreateScope(); var database = scope.ServiceProvider.GetRequiredService().Database; - database.GetMigrations().Count().ShouldBe(5); + database.GetMigrations().Count().ShouldBe(6); database.HasPendingModelChanges().ShouldBeFalse(); } diff --git a/tests/FluentCMS.Data.Tests/Query/QueryDatabase.cs b/tests/FluentCMS.Data.Tests/Query/QueryDatabase.cs index 96518d8bd..5367c858b 100644 --- a/tests/FluentCMS.Data.Tests/Query/QueryDatabase.cs +++ b/tests/FluentCMS.Data.Tests/Query/QueryDatabase.cs @@ -103,6 +103,9 @@ public QueryDatabase(Action? configure = null, SchemaModel? sc public IServiceScope NewScope() => _services.CreateScope(); + // Another process on the same database file, with its own container. + public ServiceProvider NewInstance(Action? configure = null) => _database.CreateServices(_schema, configure); + public ContentQueryEngine Engine(IContentQueryAccess? access = null, ContentQueryOptions? options = null) => new( Context, _schema, diff --git a/tests/FluentCMS.Data.Tests/Sqlite/SystemMigrationTests.cs b/tests/FluentCMS.Data.Tests/Sqlite/SystemMigrationTests.cs index f1f57e209..fad443b5e 100644 --- a/tests/FluentCMS.Data.Tests/Sqlite/SystemMigrationTests.cs +++ b/tests/FluentCMS.Data.Tests/Sqlite/SystemMigrationTests.cs @@ -46,11 +46,11 @@ public void AppliesToAFreshFileDatabaseWithItsOwnHistoryTable() using var scope = services.CreateScope(); var context = scope.ServiceProvider.GetRequiredService(); - context.Database.GetPendingMigrations().Count().ShouldBe(5); + context.Database.GetPendingMigrations().Count().ShouldBe(6); context.Database.Migrate(); - Tables(context).ShouldBe(["__EFMigrationsLock", "cms__migrations", "cms_document", "cms_job", "cms_job_attempt", "cms_recurring_job", "cms_reference", "cms_role", "cms_role_permission", "cms_site", "cms_site_locale", "cms_version"], ignoreOrder: true); - Count(context, SystemDbContext.HistoryTable).ShouldBe(5); + Tables(context).ShouldBe(["__EFMigrationsLock", "cms__migrations", "cms_document", "cms_job", "cms_job_attempt", "cms_outbox", "cms_recurring_job", "cms_reference", "cms_role", "cms_role_permission", "cms_site", "cms_site_locale", "cms_version"], ignoreOrder: true); + Count(context, SystemDbContext.HistoryTable).ShouldBe(6); context.Database.GetPendingMigrations().ShouldBeEmpty(); } @@ -70,7 +70,7 @@ public void TheModelHoldsOnlySystemTablesWhateverTheSchema() using var scope = services.CreateScope(); var context = scope.ServiceProvider.GetRequiredService(); - context.Model.GetEntityTypes().Select(type => type.GetTableName()).ShouldBe(["cms_document", "cms_job", "cms_job_attempt", "cms_recurring_job", "cms_reference", "cms_role", "cms_role_permission", "cms_site", "cms_site_locale", "cms_version"], ignoreOrder: true); + context.Model.GetEntityTypes().Select(type => type.GetTableName()).ShouldBe(["cms_document", "cms_job", "cms_job_attempt", "cms_outbox", "cms_recurring_job", "cms_reference", "cms_role", "cms_role_permission", "cms_site", "cms_site_locale", "cms_version"], ignoreOrder: true); } [Fact] diff --git a/tests/FluentCMS.Data.Tests/Storage/ModelBuildTimeTests.cs b/tests/FluentCMS.Data.Tests/Storage/ModelBuildTimeTests.cs index 1aff359fb..868d460f9 100644 --- a/tests/FluentCMS.Data.Tests/Storage/ModelBuildTimeTests.cs +++ b/tests/FluentCMS.Data.Tests/Storage/ModelBuildTimeTests.cs @@ -50,7 +50,7 @@ public void FiftyTypesBuildWithinTheBound() watch.Stop(); TestContext.Current.TestOutputHelper?.WriteLine($"Model of {TypeCount} types built in {watch.ElapsedMilliseconds} ms"); - model.GetEntityTypes().Count().ShouldBe(TypeCount + (TypeCount / 2) + 10); + model.GetEntityTypes().Count().ShouldBe(TypeCount + (TypeCount / 2) + 11); watch.Elapsed.ShouldBeLessThan(Bound); } }