Browse Source

Event store implementation.

pull/130/head
Sebastian Stehle 9 years ago
parent
commit
c272aa2590
  1. 120
      Squidex.Infrastructure.EventStore/EventStore.cs
  2. 145
      Squidex.Infrastructure.EventStore/EventStoreSubscription.cs
  3. 37
      Squidex.Infrastructure.EventStore/Formatter.cs
  4. 15
      Squidex.Infrastructure.EventStore/Squidex.Infrastructure.EventStore.csproj
  5. 15
      Squidex.sln
  6. 2
      src/Squidex.Domain.Apps.Read.MongoDb/History/MongoHistoryEventRepository.cs
  7. 6
      src/Squidex.Infrastructure.MongoDb/EventStore/MongoEventStore.cs
  8. 5
      src/Squidex.Infrastructure.MongoDb/EventStore/PollingSubscription.cs
  9. 6
      src/Squidex.Infrastructure/CQRS/Events/EventReceiver.cs
  10. 2
      src/Squidex.Infrastructure/CQRS/Events/IEventStore.cs
  11. 2
      src/Squidex.Infrastructure/CQRS/Events/IEventSubscription.cs
  12. 12
      src/Squidex.Infrastructure/CQRS/Events/StoredEvent.cs
  13. 29
      src/Squidex/Config/Domain/EventStoreModule.cs
  14. 2
      src/Squidex/Squidex.csproj
  15. 7
      src/Squidex/appsettings.json
  16. 6
      tests/Squidex.Infrastructure.Tests/CQRS/Events/EventReceiverTests.cs

120
Squidex.Infrastructure.EventStore/EventStore.cs

@ -0,0 +1,120 @@
// ==========================================================================
// EventStore.cs
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex Group
// All rights reserved.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using EventStore.ClientAPI;
using Squidex.Infrastructure.CQRS.Events;
using EventData = Squidex.Infrastructure.CQRS.Events.EventData;
// ReSharper disable ConvertIfStatementToSwitchStatement
// ReSharper disable InvertIf
namespace Squidex.Infrastructure.EventStore
{
public sealed class EventStore : IEventStore, IExternalSystem
{
private const int WritePageSize = 500;
private const int ReadPageSize = 500;
private readonly IEventStoreConnection connection;
private readonly string projectionHost;
private readonly string prefix;
public EventStore(IEventStoreConnection connection, string prefix, string projectionHost)
{
Guard.NotNull(connection, nameof(connection));
this.connection = connection;
this.projectionHost = projectionHost;
this.prefix = prefix?.Trim(' ', '-').WithFallback("squidex");
}
public void Connect()
{
try
{
connection.ConnectAsync().Wait();
}
catch (Exception ex)
{
throw new ConfigurationException("Cannot connect to event store.", ex);
}
}
public IEventSubscription CreateSubscription(string streamFilter = null, string position = null)
{
return new EventStoreSubscription(connection, streamFilter, position, prefix, projectionHost);
}
public async Task<IReadOnlyList<StoredEvent>> GetEventsAsync(string streamName)
{
var result = new List<StoredEvent>();
var sliceStart = 0L;
StreamEventsSlice currentSlice;
do
{
currentSlice = await connection.ReadStreamEventsForwardAsync(GetStreamName(streamName), sliceStart, ReadPageSize, false);
if (currentSlice.Status == SliceReadStatus.Success)
{
sliceStart = currentSlice.NextEventNumber;
foreach (var resolved in currentSlice.Events)
{
var eventData = Formatter.Read(resolved.Event);
result.Add(new StoredEvent(resolved.OriginalPosition.ToString(), resolved.Event.EventNumber, eventData));
}
}
}
while (!currentSlice.IsEndOfStream);
return result;
}
public async Task AppendEventsAsync(Guid commitId, string streamName, int expectedVersion, ICollection<EventData> events)
{
Guard.NotNull(events, nameof(events));
Guard.NotNullOrEmpty(streamName, nameof(streamName));
if (events.Count == 0)
{
return;
}
var eventsToSave = events.Select(Formatter.Write).ToList();
if (eventsToSave.Count < WritePageSize)
{
await connection.AppendToStreamAsync(GetStreamName(streamName), expectedVersion, eventsToSave);
}
else
{
using (var transaction = await connection.StartTransactionAsync(GetStreamName(streamName), expectedVersion))
{
for (var p = 0; p < eventsToSave.Count; p += WritePageSize)
{
await transaction.WriteAsync(eventsToSave.Skip(p).Take(WritePageSize));
}
await transaction.CommitAsync();
}
}
}
private string GetStreamName(string streamName)
{
return $"{prefix}-{streamName}";
}
}
}

145
Squidex.Infrastructure.EventStore/EventStoreSubscription.cs

@ -0,0 +1,145 @@
// ==========================================================================
// EventStoreSubscription.cs
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex Group
// All rights reserved.
// ==========================================================================
using System;
using System.Collections.Concurrent;
using System.Linq;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading.Tasks;
using EventStore.ClientAPI;
using EventStore.ClientAPI.Exceptions;
using EventStore.ClientAPI.Projections;
using Squidex.Infrastructure.CQRS.Events;
namespace Squidex.Infrastructure.EventStore
{
internal sealed class EventStoreSubscription : IEventSubscription
{
private static readonly ConcurrentDictionary<string, bool> subscriptionsCreated = new ConcurrentDictionary<string, bool>();
private readonly IEventStoreConnection connection;
private readonly string position;
private readonly string streamFilter;
private readonly string streamName;
private readonly string prefix;
private readonly string projectionHost;
private EventStoreCatchUpSubscription internalSubscription;
public EventStoreSubscription(IEventStoreConnection connection, string streamFilter, string position, string prefix, string projectionHost)
{
this.prefix = prefix;
this.position = position;
this.connection = connection;
this.streamFilter = streamFilter;
this.projectionHost = projectionHost;
streamName = CreateStreamName(streamFilter, prefix);
}
public void Dispose()
{
internalSubscription?.Stop();
}
public async Task SubscribeAsync(Func<StoredEvent, Task> handler)
{
Guard.NotNull(handler, nameof(handler));
if (internalSubscription != null)
{
throw new InvalidOperationException("An handler has already been registered.");
}
if (subscriptionsCreated.TryAdd(streamName, true))
{
var projectsManager = await ConnectToProjections();
var projectionConfig =
$@"fromAll()
.when({{
$any: function (s, e) {{
if (e.streamId.indexOf('{prefix}') === 0 && /{streamFilter}/.test(e.streamId.substring({prefix.Length + 1}))) {{
linkTo('{streamName}', e);
}}
}}
}});";
try
{
await projectsManager.CreateContinuousAsync($"${streamName}", projectionConfig, connection.Settings.DefaultUserCredentials);
}
catch (ProjectionCommandConflictException)
{
// Projection already exists.
}
}
long? eventStorePosition = null;
if (long.TryParse(position, out var parsedPosition))
{
eventStorePosition = parsedPosition;
}
internalSubscription = connection.SubscribeToStreamFrom(streamName, eventStorePosition, CatchUpSubscriptionSettings.Default, (subscription, resolved) =>
{
var eventData = Formatter.Read(resolved.Event);
handler(new StoredEvent(resolved.OriginalEventNumber.ToString(), resolved.Event.EventNumber, eventData)).Wait();
});
}
private async Task<ProjectionsManager> ConnectToProjections()
{
var addressParts = projectionHost.Split(':');
if (addressParts.Length < 2 || !int.TryParse(addressParts[1], out int port))
{
port = 2113;
}
var endpoints = await Dns.GetHostAddressesAsync(addressParts[0]);
var endpoint = new IPEndPoint(endpoints.First(x => x.AddressFamily == AddressFamily.InterNetwork), port);
var projectsManager =
new ProjectionsManager(
connection.Settings.Log, endpoint,
connection.Settings.OperationTimeout);
return projectsManager;
}
private static string CreateStreamName(string streamFilter, string prefix)
{
var sb = new StringBuilder();
sb.Append(prefix.Trim(' ', '-'));
sb.Append("-");
var prevIsLetterOrDigit = false;
foreach (var c in streamFilter)
{
if (char.IsLetterOrDigit(c))
{
sb.Append(char.ToLowerInvariant(c));
prevIsLetterOrDigit = true;
}
else if (prevIsLetterOrDigit)
{
sb.Append("-");
prevIsLetterOrDigit = false;
}
}
return sb.ToString().Trim(' ', '-');
}
}
}

37
Squidex.Infrastructure.EventStore/Formatter.cs

@ -0,0 +1,37 @@
// ==========================================================================
// Formatter.cs
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex Group
// All rights reserved.
// ==========================================================================
using System.Text;
using EventStore.ClientAPI;
using EventData = Squidex.Infrastructure.CQRS.Events.EventData;
using EventStoreData = EventStore.ClientAPI.EventData;
namespace Squidex.Infrastructure.EventStore
{
public static class Formatter
{
public static EventData Read(RecordedEvent eventData)
{
var body = Encoding.UTF8.GetString(eventData.Data);
var meta = Encoding.UTF8.GetString(eventData.Metadata);
return new EventData { Type = eventData.EventType, EventId = eventData.EventId, Payload = body, Metadata = meta };
}
public static EventStoreData Write(EventData eventData)
{
var body = Encoding.UTF8.GetBytes(eventData.Payload);
var meta = Encoding.UTF8.GetBytes(eventData.Metadata);
return new EventStoreData(
eventData.EventId,
eventData.Type,
true, body, meta);
}
}
}

15
Squidex.Infrastructure.EventStore/Squidex.Infrastructure.EventStore.csproj

@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netcoreapp1.1</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="EventStore.ClientAPI.NetCore" Version="4.0.0-alpha-1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\src\Squidex.Infrastructure\Squidex.Infrastructure.csproj" />
</ItemGroup>
</Project>

15
Squidex.sln

@ -56,6 +56,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Squidex.Domain.Users.MongoD
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Squidex.Domain.Users.Tests", "tests\Squidex.Domain.Users.Tests\Squidex.Domain.Users.Tests.csproj", "{42184546-E3CB-4D4F-9495-43979B9C63B9}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Squidex.Infrastructure.EventStore", "Squidex.Infrastructure.EventStore\Squidex.Infrastructure.EventStore.csproj", "{91D9D992-FA34-49EE-A1D7-DEA5E660424F}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -266,6 +268,18 @@ Global
{42184546-E3CB-4D4F-9495-43979B9C63B9}.Release|x64.Build.0 = Release|Any CPU
{42184546-E3CB-4D4F-9495-43979B9C63B9}.Release|x86.ActiveCfg = Release|Any CPU
{42184546-E3CB-4D4F-9495-43979B9C63B9}.Release|x86.Build.0 = Release|Any CPU
{91D9D992-FA34-49EE-A1D7-DEA5E660424F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{91D9D992-FA34-49EE-A1D7-DEA5E660424F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{91D9D992-FA34-49EE-A1D7-DEA5E660424F}.Debug|x64.ActiveCfg = Debug|Any CPU
{91D9D992-FA34-49EE-A1D7-DEA5E660424F}.Debug|x64.Build.0 = Debug|Any CPU
{91D9D992-FA34-49EE-A1D7-DEA5E660424F}.Debug|x86.ActiveCfg = Debug|Any CPU
{91D9D992-FA34-49EE-A1D7-DEA5E660424F}.Debug|x86.Build.0 = Debug|Any CPU
{91D9D992-FA34-49EE-A1D7-DEA5E660424F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{91D9D992-FA34-49EE-A1D7-DEA5E660424F}.Release|Any CPU.Build.0 = Release|Any CPU
{91D9D992-FA34-49EE-A1D7-DEA5E660424F}.Release|x64.ActiveCfg = Release|Any CPU
{91D9D992-FA34-49EE-A1D7-DEA5E660424F}.Release|x64.Build.0 = Release|Any CPU
{91D9D992-FA34-49EE-A1D7-DEA5E660424F}.Release|x86.ActiveCfg = Release|Any CPU
{91D9D992-FA34-49EE-A1D7-DEA5E660424F}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@ -293,5 +307,6 @@ Global
{F7771E22-47BD-45C4-A133-FD7F1DE27CA0} = {C0D540F0-9158-4528-BFD8-BEAE6EAE45EA}
{27CF800D-890F-4882-BF05-44EC3233537D} = {C0D540F0-9158-4528-BFD8-BEAE6EAE45EA}
{42184546-E3CB-4D4F-9495-43979B9C63B9} = {C0D540F0-9158-4528-BFD8-BEAE6EAE45EA}
{91D9D992-FA34-49EE-A1D7-DEA5E660424F} = {8CF53B92-5EB1-461D-98F8-70DA9B603FBF}
EndGlobalSection
EndGlobal

2
src/Squidex.Domain.Apps.Read.MongoDb/History/MongoHistoryEventRepository.cs

@ -34,7 +34,7 @@ namespace Squidex.Domain.Apps.Read.MongoDb.History
public string EventsFilter
{
get { return "*"; }
get { return ".*"; }
}
public MongoHistoryEventRepository(IMongoDatabase database, IEnumerable<IHistoryEventsCreator> creators)

6
src/Squidex.Infrastructure.MongoDb/EventStore/MongoEventStore.cs

@ -72,7 +72,7 @@ namespace Squidex.Infrastructure.MongoDb.EventStore
return new PollingSubscription(this, notifier, streamFilter, position);
}
public async Task<IReadOnlyList<StoredEvent>> GetEventsAsync(string streamName, string position)
public async Task<IReadOnlyList<StoredEvent>> GetEventsAsync(string streamName)
{
var result = await Observable.Create<StoredEvent>((observer, ct) =>
{
@ -81,7 +81,7 @@ namespace Squidex.Infrastructure.MongoDb.EventStore
observer.OnNext(storedEvent);
return TaskHelper.Done;
}, ct, streamName, position);
}, ct, streamName);
}).ToList();
return result.ToList();
@ -251,7 +251,7 @@ namespace Squidex.Infrastructure.MongoDb.EventStore
filters.Add(Filter.Gte(TimestampField, streamPosition.Timestamp));
}
if (!string.IsNullOrWhiteSpace(streamFilter) && !string.Equals(streamFilter, "*", StringComparison.OrdinalIgnoreCase))
if (!string.IsNullOrWhiteSpace(streamFilter) && !string.Equals(streamFilter, ".*", StringComparison.OrdinalIgnoreCase))
{
if (streamFilter.Contains("^"))
{

5
src/Squidex.Infrastructure.MongoDb/EventStore/PollingSubscription.cs

@ -9,6 +9,7 @@
using System;
using System.Threading.Tasks;
using Squidex.Infrastructure.CQRS.Events;
using Squidex.Infrastructure.Tasks;
using Squidex.Infrastructure.Timers;
namespace Squidex.Infrastructure.MongoDb.EventStore
@ -37,7 +38,7 @@ namespace Squidex.Infrastructure.MongoDb.EventStore
}
}
public IEventSubscription Subscribe(Func<StoredEvent, Task> handler)
public Task SubscribeAsync(Func<StoredEvent, Task> handler)
{
Guard.NotNull(handler, nameof(handler));
@ -53,7 +54,7 @@ namespace Squidex.Infrastructure.MongoDb.EventStore
eventNotifier.Subscribe(timer.Wakeup);
return this;
return TaskHelper.Done;
}
}
}

6
src/Squidex.Infrastructure/CQRS/Events/EventReceiver.cs

@ -124,7 +124,7 @@ namespace Squidex.Infrastructure.CQRS.Events
if (currentSubscription == null)
{
Subscribe(eventConsumer, position);
await SubscribeAsync(eventConsumer, position);
}
}
catch (Exception ex)
@ -135,13 +135,13 @@ namespace Squidex.Infrastructure.CQRS.Events
});
}
private void Subscribe(IEventConsumer eventConsumer, string position)
private async Task SubscribeAsync(IEventConsumer eventConsumer, string position)
{
var consumerName = eventConsumer.Name;
var subscription = eventStore.CreateSubscription(eventConsumer.EventsFilter, position);
subscription.Subscribe(async storedEvent =>
await subscription.SubscribeAsync(async storedEvent =>
{
try
{

2
src/Squidex.Infrastructure/CQRS/Events/IEventStore.cs

@ -14,7 +14,7 @@ namespace Squidex.Infrastructure.CQRS.Events
{
public interface IEventStore
{
Task<IReadOnlyList<StoredEvent>> GetEventsAsync(string streamName, string position = null);
Task<IReadOnlyList<StoredEvent>> GetEventsAsync(string streamName);
Task AppendEventsAsync(Guid commitId, string streamName, int expectedVersion, ICollection<EventData> events);

2
src/Squidex.Infrastructure/CQRS/Events/IEventSubscription.cs

@ -13,6 +13,6 @@ namespace Squidex.Infrastructure.CQRS.Events
{
public interface IEventSubscription : IDisposable
{
IEventSubscription Subscribe(Func<StoredEvent, Task> handler);
Task SubscribeAsync(Func<StoredEvent, Task> handler);
}
}

12
src/Squidex.Infrastructure/CQRS/Events/StoredEvent.cs

@ -11,7 +11,7 @@ namespace Squidex.Infrastructure.CQRS.Events
public sealed class StoredEvent
{
private readonly string eventPosition;
private readonly int eventStreamNumber;
private readonly long eventStreamNumber;
private readonly EventData data;
public string EventPosition
@ -19,17 +19,17 @@ namespace Squidex.Infrastructure.CQRS.Events
get { return eventPosition; }
}
public EventData Data
public long EventStreamNumber
{
get { return data; }
get { return eventStreamNumber; }
}
public int EventStreamNumber
public EventData Data
{
get { return eventStreamNumber; }
get { return data; }
}
public StoredEvent(string eventPosition, int eventStreamNumber, EventData data)
public StoredEvent(string eventPosition, long eventStreamNumber, EventData data)
{
Guard.NotNullOrEmpty(eventPosition, nameof(eventPosition));
Guard.NotNull(data, nameof(data));

29
src/Squidex/Config/Domain/EventStoreModule.cs

@ -9,11 +9,13 @@
using System;
using Autofac;
using Autofac.Core;
using EventStore.ClientAPI;
using Microsoft.Extensions.Configuration;
using MongoDB.Driver;
using Squidex.Infrastructure;
using Squidex.Infrastructure.CQRS.Events;
using Squidex.Infrastructure.MongoDb.EventStore;
using EventStoreStore = Squidex.Infrastructure.EventStore.EventStore;
namespace Squidex.Config.Domain
{
@ -77,9 +79,34 @@ namespace Squidex.Config.Domain
.As<IEventStore>()
.SingleInstance();
}
else if (string.Equals(eventStoreType, "EventStore", StringComparison.OrdinalIgnoreCase))
{
var configuration = Configuration.GetValue<string>("eventStore:eventStore:configuration");
if (string.IsNullOrWhiteSpace(configuration))
{
throw new ConfigurationException("Configure EventStore EventStore configuration with 'eventStore:eventStore:configuration'.");
}
var projectionHost = Configuration.GetValue<string>("eventStore:eventStore:projectionHost");
if (string.IsNullOrWhiteSpace(projectionHost))
{
throw new ConfigurationException("Configure EventStore EventStore projection host with 'eventStore:eventStore:projectionHost'.");
}
var prefix = Configuration.GetValue<string>("eventStore:eventStore:prefix");
var connection = EventStoreConnection.Create(configuration);
builder.Register(c => new EventStoreStore(connection, prefix, projectionHost))
.As<IExternalSystem>()
.As<IEventStore>()
.SingleInstance();
}
else
{
throw new ConfigurationException($"Unsupported value '{eventStoreType}' for 'eventStore:type', supported: MongoDb.");
throw new ConfigurationException($"Unsupported value '{eventStoreType}' for 'eventStore:type', supported: MongoDb, EventStore.");
}
}
}

2
src/Squidex/Squidex.csproj

@ -25,6 +25,7 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Squidex.Infrastructure.EventStore\Squidex.Infrastructure.EventStore.csproj" />
<ProjectReference Include="..\Squidex.Domain.Apps.Core\Squidex.Domain.Apps.Core.csproj" />
<ProjectReference Include="..\Squidex.Domain.Apps.Events\Squidex.Domain.Apps.Events.csproj" />
<ProjectReference Include="..\Squidex.Domain.Users.MongoDb\Squidex.Domain.Users.MongoDb.csproj" />
@ -44,6 +45,7 @@
<PackageReference Include="AspNet.Security.OAuth.GitHub" Version="1.0.0-rc1-final" />
<PackageReference Include="Autofac" Version="4.6.0" />
<PackageReference Include="Autofac.Extensions.DependencyInjection" Version="4.1.0" />
<PackageReference Include="EventStore.ClientAPI.NetCore" Version="4.0.0-alpha-1" />
<PackageReference Include="IdentityServer4" Version="1.5.2" />
<PackageReference Include="IdentityServer4.AccessTokenValidation" Version="1.2.1" />
<PackageReference Include="IdentityServer4.AspNetIdentity" Version="1.0.1" />

7
src/Squidex/appsettings.json

@ -21,11 +21,16 @@
}
},
"eventStore": {
"type": "MongoDb",
"type": "EventStore",
"mongoDb": {
"configuration": "mongodb://localhost",
"database": "Squidex"
},
"eventStore": {
"configuration": "ConnectTo=tcp://admin:changeit@localhost:1113; HeartBeatTimeout=500",
"projectionHost": "localhost",
"prefix": "squidex"
},
"consume": true
},
"eventPublishers": {

6
tests/Squidex.Infrastructure.Tests/CQRS/Events/EventReceiverTests.cs

@ -43,7 +43,7 @@ namespace Squidex.Infrastructure.CQRS.Events
this.storedEvents = storedEvents;
}
public IEventSubscription Subscribe(Func<StoredEvent, Task> handler)
public Task SubscribeAsync(Func<StoredEvent, Task> handler)
{
foreach (var storedEvent in storedEvents)
{
@ -55,7 +55,7 @@ namespace Squidex.Infrastructure.CQRS.Events
handler(storedEvent).Wait();
}
return this;
return TaskHelper.Done;
}
public void Dispose()
@ -78,7 +78,7 @@ namespace Squidex.Infrastructure.CQRS.Events
return new MyEventSubscription(storedEvents);
}
public Task<IReadOnlyList<StoredEvent>> GetEventsAsync(string streamName, string position = null)
public Task<IReadOnlyList<StoredEvent>> GetEventsAsync(string streamName)
{
throw new NotSupportedException();
}

Loading…
Cancel
Save